docs: preserve parallel UniDesk updates
This commit is contained in:
@@ -96,6 +96,7 @@ const SERVICE_CHECK_NAMES = [
|
||||
"microservice:todo-note-health",
|
||||
"microservice:todo-note-migrated-data",
|
||||
"microservice:todo-note-write-path",
|
||||
"microservice:todo-note-route-diagnostic",
|
||||
"microservice:code-queue-status",
|
||||
"microservice:code-queue-health",
|
||||
"microservice:code-queue-workdirs",
|
||||
@@ -1761,6 +1762,68 @@ function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: str
|
||||
return { ok: result.exitCode === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode };
|
||||
}
|
||||
|
||||
function runTodoNoteCliProbe(args: string[]): { ok: boolean; exitCode: number | null; payload: unknown; stderr: string } {
|
||||
const result = runCommand(["bun", "scripts/cli.ts", "microservice", "proxy", "todo-note", ...args], repoRoot, { timeoutMs: 30_000 });
|
||||
let payload: unknown = null;
|
||||
if (result.stdout.trim().length > 0) {
|
||||
try {
|
||||
payload = JSON.parse(result.stdout.trim()) as unknown;
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
return { ok: result.exitCode === 0, exitCode: result.exitCode, payload, stderr: result.stderr.trim() };
|
||||
}
|
||||
|
||||
function runTodoNoteRouteDiagnosticChecks(): { ok: boolean; detail: unknown } {
|
||||
const issues: string[] = [];
|
||||
// 1) Bad path: CLI must rewrite the misleading 404 to the structured diagnostic.
|
||||
const badPathRewrite = runTodoNoteCliProbe(["/api/instances/instance_probe_bad/todos", "--method", "POST", "--body-json", JSON.stringify({ title: "e2e-route-diagnostic" })]);
|
||||
const badPathBody = (badPathRewrite.payload as { data?: { body?: Record<string, unknown> } } | null)?.data?.body ?? null;
|
||||
const rewriteOk = (
|
||||
badPathRewrite.exitCode === 1
|
||||
&& badPathBody !== null
|
||||
&& badPathBody.error === "Todo Note route not found"
|
||||
&& badPathBody.backendOnly === true
|
||||
&& badPathBody.method === "POST"
|
||||
&& badPathBody.path === "/api/instances/instance_probe_bad/todos"
|
||||
&& Array.isArray(badPathBody.writableApiEndpoints)
|
||||
&& (badPathBody.writableApiEndpoints as Array<{ path?: string }>).some((endpoint) => endpoint.path === "/api/instances/:instanceId/actions")
|
||||
&& Array.isArray(badPathBody.actionTypes)
|
||||
&& (badPathBody.actionTypes as string[]).includes("addTodo")
|
||||
&& (badPathRewrite.payload as { data?: { bodyRewritten?: boolean } } | null)?.data?.bodyRewritten === true
|
||||
&& (badPathRewrite.payload as { data?: { upstreamBody?: { error?: string } } } | null)?.data?.upstreamBody?.error === "Todo Note is running in backend-only mode"
|
||||
);
|
||||
if (!rewriteOk) issues.push("bad-path-rewrite");
|
||||
// 2) --check-path matched: POST /api/instances must succeed and return matched=true.
|
||||
const checkPathMatched = runTodoNoteCliProbe(["/api/instances", "--method", "POST", "--check-path"]);
|
||||
const checkPathMatchedData = (checkPathMatched.payload as { data?: { matched?: boolean; endpoint?: { path?: string } } } | null)?.data ?? null;
|
||||
const matchOk = checkPathMatched.exitCode === 0 && checkPathMatchedData?.matched === true && checkPathMatchedData?.endpoint?.path === "/api/instances";
|
||||
if (!matchOk) issues.push("check-path-match");
|
||||
// 3) --check-path mismatched: must return the structured diagnostic and skip the upstream call.
|
||||
const checkPathMissed = runTodoNoteCliProbe(["/api/todos", "--method", "POST", "--check-path"]);
|
||||
const checkPathMissedData = (checkPathMissed.payload as { data?: { checkPath?: boolean; error?: string; path?: string } } | null)?.data ?? null;
|
||||
const missOk = checkPathMissed.exitCode === 1 && checkPathMissedData?.checkPath === true && checkPathMissedData?.error === "Todo Note route not found" && checkPathMissedData?.path === "/api/todos";
|
||||
if (!missOk) issues.push("check-path-miss");
|
||||
// 4) --check-path on a non-todo-note service must return a structured unsupported error.
|
||||
const unsupportedProbe = runCommand(["bun", "scripts/cli.ts", "microservice", "proxy", "code-queue", "/api/foo", "--method", "GET", "--check-path"], repoRoot, { timeoutMs: 30_000 });
|
||||
let unsupportedPayload: unknown = null;
|
||||
try { unsupportedPayload = JSON.parse(unsupportedProbe.stdout.trim()) as unknown; } catch { unsupportedPayload = null; }
|
||||
const unsupportedData = (unsupportedPayload as { data?: { error?: string; supportedServices?: string[] } } | null)?.data ?? null;
|
||||
const unsupportedOk = unsupportedProbe.exitCode === 1 && typeof unsupportedData?.error === "string" && unsupportedData.error.includes("--check-path currently only supports service=todo-note") && Array.isArray(unsupportedData.supportedServices) && unsupportedData.supportedServices.includes("todo-note");
|
||||
if (!unsupportedOk) issues.push("check-path-unsupported");
|
||||
return {
|
||||
ok: issues.length === 0,
|
||||
detail: {
|
||||
issues,
|
||||
badPathRewrite: { exitCode: badPathRewrite.exitCode, body: badPathBody },
|
||||
checkPathMatched,
|
||||
checkPathMissed: { exitCode: checkPathMissed.exitCode, data: checkPathMissedData },
|
||||
unsupportedProbe: { exitCode: unsupportedProbe.exitCode, data: unsupportedData },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dockerCoreJson(path: string, init?: { method?: string; body?: unknown }): unknown {
|
||||
const method = init?.method ?? "GET";
|
||||
const body = init?.body === undefined ? "" : JSON.stringify(init.body);
|
||||
@@ -2474,6 +2537,10 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
addSelectedCheck(checks, options, "microservice:todo-note-health", (todoNoteHealth as { ok?: boolean; body?: { ok?: boolean; storage?: string } }).ok === true && (todoNoteHealth as { body?: { ok?: boolean; storage?: string } }).body?.ok === true && (todoNoteHealth as { body?: { storage?: string } }).body?.storage === "postgres", todoNoteHealth);
|
||||
addSelectedCheck(checks, options, "microservice:todo-note-migrated-data", (todoNoteInstances as { ok?: boolean }).ok === true && todoNoteRows.length >= 5 && ["CONSTAR", "大论文", "找工作", "小论文", "事务"].every((name) => todoNoteNames.includes(name)) && todoNoteRows.reduce((sum, row) => sum + Number(row.todoCount ?? 0), 0) >= 100, { todoNoteInstances });
|
||||
addSelectedCheck(checks, options, "microservice:todo-note-write-path", (todoNoteCreate as { ok?: boolean }).ok === true && (todoNoteAdd as { ok?: boolean }).ok === true && (todoNoteToggle as { ok?: boolean }).ok === true && (todoNoteUndo as { ok?: boolean }).ok === true && (todoNoteDelete as { ok?: boolean }).ok === true, { todoNoteCreate, todoNoteAdd, todoNoteToggle, todoNoteUndo, todoNoteDelete });
|
||||
// Issue #198: assert the CLI-side rewrite of the Todo Note catch-all misleading 404.
|
||||
// When the upstream fix lands, this gate will pass through the upstream body instead of the rewritten one — keep the assertion focused on the CLI-side shape (the upstream body is still preserved under upstreamBody so a future flip is auditable).
|
||||
const todoNoteRouteDiagnostic = runTodoNoteRouteDiagnosticChecks();
|
||||
addSelectedCheck(checks, options, "microservice:todo-note-route-diagnostic", todoNoteRouteDiagnostic.ok, todoNoteRouteDiagnostic.detail);
|
||||
addSelectedCheck(checks, options, "microservice:oa-event-flow-status", (oaEventFlowStatus as { ok?: boolean }).ok === true && (oaEventFlowStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, oaEventFlowStatus);
|
||||
addSelectedCheck(checks, options, "microservice:oa-event-flow-health", (oaEventFlowHealth as { ok?: boolean }).ok === true && oaEventFlowHealthBody?.ok === true && oaEventFlowHealthBody.service === "oa-event-flow" && oaEventFlowHealthBody.databaseReady === true, oaEventFlowHealth);
|
||||
addSelectedCheck(checks, options, "microservice:oa-event-flow-diagnostics", (oaEventFlowDiagnostics as { ok?: boolean }).ok === true && oaEventFlowDiagnosticsBody?.ok === true && oaEventFlowDiagnosticsBody.service === "oa-event-flow" && oaEventFlowDiagnosticsBody.databaseReady === true && Number.isFinite(oaEventFlowDiagnosticsBody.eventCount) && Array.isArray(oaEventFlowDiagnosticsBody.eventTypes), oaEventFlowDiagnostics);
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." },
|
||||
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
|
||||
{ command: "microservice health <id> [--compact|--raw|--full]", description: "Probe one user service through backend-core -> provider-gateway HTTP proxy; default output is compact, and code-queue uses a commander-safe liveness summary unless raw/full is requested." },
|
||||
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; JSON request bodies are supported for controlled write/debug endpoints." },
|
||||
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--check-path] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; JSON request bodies are supported for controlled write/debug endpoints; --check-path (currently todo-note only) validates the path/method against the registered CLI-side endpoint catalog without contacting upstream." },
|
||||
{ command: "microservice diagnostics <id> [--full|--raw]", description: "Split k3sctl-managed proxy health into a compact summary by default; use --full/--raw for complete evidence." },
|
||||
{ command: "microservice tunnel-self-test <id>", description: "Trigger an expected provider HTTP tunnel failure and verify requestId/stage diagnostics are returned." },
|
||||
{ command: "decision upload <markdown-file> [--title text] [--type meeting|decision|goal|external_goal|internal_goal|blocker|debt|experiment] [--level|--priority G0|G1|G2|G3|P0|P1|P2|P3|none] [--doc-no DC-...] [--doc-type DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS] [--doc-priority P0|P1|P2|P3] [--signer text] [--issued-at ISO]", description: "Upload a meeting note or decision/requirement record through backend-core -> decision-center user-service proxy." },
|
||||
@@ -233,7 +233,7 @@ function microserviceHelp(): unknown {
|
||||
"bun scripts/cli.ts microservice health <id> [--compact|--full|--raw]",
|
||||
"bun scripts/cli.ts microservice diagnostics <id> [--full|--raw]",
|
||||
"bun scripts/cli.ts microservice tunnel-self-test <id>",
|
||||
"bun scripts/cli.ts microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--raw] [--full] [--max-body-bytes N]",
|
||||
"bun scripts/cli.ts microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--check-path] [--raw] [--full] [--max-body-bytes N]",
|
||||
],
|
||||
description: "Access UniDesk-managed user services through the same backend-core/provider proxy path used by the WebUI.",
|
||||
};
|
||||
|
||||
@@ -4,6 +4,138 @@ import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import { jsonByteLength, previewJson } from "./preview";
|
||||
|
||||
// Todo Note misleading-404 rewrite (issue #198) — CLI-side interim workaround
|
||||
// for the upstream todo_note catch-all handler. The upstream repo
|
||||
// (https://gitee.com/Lyon1998/todo_note) wraps every unregistered path in
|
||||
// `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,
|
||||
// which makes agents and humans misdiagnose a path typo as a write lock.
|
||||
// When the upstream fix lands, this rewrite becomes a no-op: the upstream
|
||||
// body will not match `isTodoNoteMisleadingRouteNotFoundBody` anymore and
|
||||
// the CLI will pass the upstream response through unchanged.
|
||||
//
|
||||
// Source of truth for the writable endpoint list below is
|
||||
// docs/reference/microservices.md "Todo Note On Main Server" and
|
||||
// apps/server/src/server.ts in the upstream todo_note repo. If the
|
||||
// upstream registered routes change, update TODO_NOTE_WRITABLE_ENDPOINTS
|
||||
// and TODO_NOTE_ACTION_TYPES in lock-step.
|
||||
|
||||
interface TodoNoteEndpoint {
|
||||
method: string;
|
||||
path: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export const TODO_NOTE_ACTION_TYPES = [
|
||||
"addTodo",
|
||||
"updateTodoTitle",
|
||||
"toggleTodoCompleted",
|
||||
"toggleTodoExpanded",
|
||||
"setAllTodosExpanded",
|
||||
"moveTodo",
|
||||
"deleteTodo",
|
||||
"renameInstance",
|
||||
"setTodoReminder",
|
||||
] as const;
|
||||
|
||||
export const TODO_NOTE_WRITABLE_ENDPOINTS: readonly TodoNoteEndpoint[] = [
|
||||
{ method: "POST", path: "/api/instances", hint: "Create a new todo list; body {name}." },
|
||||
{ method: "DELETE", path: "/api/instances/:instanceId", hint: "Delete a todo list." },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/instances/:instanceId/actions",
|
||||
hint: "Apply a typed action; body {action: {type, ...}}. Use the action queue pattern, not REST collection paths like /api/instances/:id/todos.",
|
||||
},
|
||||
{ method: "POST", path: "/api/instances/:instanceId/undo", hint: "Undo the last applied action." },
|
||||
{ method: "POST", path: "/api/instances/:instanceId/redo", hint: "Redo the last undone action." },
|
||||
];
|
||||
|
||||
function isTodoNoteMisleadingRouteNotFoundBody(body: unknown): boolean {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) return false;
|
||||
const record = body as Record<string, unknown>;
|
||||
return record.error === "Todo Note is running in backend-only mode" && record.ok === false;
|
||||
}
|
||||
|
||||
function splitPathQuery(path: string): string {
|
||||
const questionIndex = path.indexOf("?");
|
||||
return questionIndex === -1 ? path : path.slice(0, questionIndex);
|
||||
}
|
||||
|
||||
export function matchTodoNoteEndpoint(method: string, path: string): TodoNoteEndpoint | null {
|
||||
const upperMethod = method.toUpperCase();
|
||||
const targetSegments = splitPathQuery(path).split("/").filter(Boolean);
|
||||
for (const endpoint of TODO_NOTE_WRITABLE_ENDPOINTS) {
|
||||
if (endpoint.method !== upperMethod) continue;
|
||||
const patternSegments = endpoint.path.split("/").filter(Boolean);
|
||||
if (patternSegments.length !== targetSegments.length) continue;
|
||||
let matched = true;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
const pattern = patternSegments[i];
|
||||
const actual = targetSegments[i];
|
||||
if (pattern.startsWith(":")) continue;
|
||||
if (pattern !== actual) { matched = false; break; }
|
||||
}
|
||||
if (matched) return endpoint;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildTodoNoteRouteNotFoundDiagnostic(method: string, path: string): Record<string, unknown> {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Todo Note route not found",
|
||||
backendOnly: true,
|
||||
method,
|
||||
path,
|
||||
writableApiEndpoints: TODO_NOTE_WRITABLE_ENDPOINTS.map((endpoint) => ({ method: endpoint.method, path: endpoint.path, hint: endpoint.hint })),
|
||||
actionTypes: [...TODO_NOTE_ACTION_TYPES],
|
||||
hint: "Write operations use the action queue pattern: POST /api/instances/:instanceId/actions with body {action: {type, ...}}. See docs/reference/microservices.md (Todo Note On Main Server) and the upstream apps/server/src/server.ts:97-126 for the registered action types. The CLI rewrote this 404 because the upstream catch-all body was misleading (issue #198).",
|
||||
issueReference: "pikasTech/unidesk#198",
|
||||
};
|
||||
}
|
||||
|
||||
export function rewriteTodoNoteMisleadingRouteNotFound(serviceId: string, method: string, path: string, response: unknown): unknown {
|
||||
if (serviceId !== "todo-note") return response;
|
||||
if (typeof response !== "object" || response === null || Array.isArray(response)) return response;
|
||||
const record = response as Record<string, unknown>;
|
||||
if (record.status !== 404) return response;
|
||||
if (!isTodoNoteMisleadingRouteNotFoundBody(record.body)) return response;
|
||||
const diagnostic = buildTodoNoteRouteNotFoundDiagnostic(method, path);
|
||||
return {
|
||||
...record,
|
||||
body: diagnostic,
|
||||
bodyRewritten: true,
|
||||
rewriteReason: "Todo Note catch-all 404 body was misleading; CLI wrapped it with a structured route diagnostic. See docs/reference/cli.md and pikasTech/unidesk issue #198 for context.",
|
||||
upstreamBody: record.body,
|
||||
};
|
||||
}
|
||||
|
||||
export function runTodoNoteCheckPath(method: string, path: string): Record<string, unknown> {
|
||||
const matched = matchTodoNoteEndpoint(method, path);
|
||||
if (matched) {
|
||||
return {
|
||||
ok: true,
|
||||
service: "todo-note",
|
||||
method,
|
||||
path,
|
||||
matched: true,
|
||||
endpoint: { method: matched.method, path: matched.path, hint: matched.hint },
|
||||
hint: "Path and method match a registered Todo Note write endpoint; safe to send the request.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
command: `microservice proxy todo-note ${path} --method ${method} --check-path`,
|
||||
data: {
|
||||
ok: false,
|
||||
status: 404,
|
||||
method,
|
||||
path,
|
||||
checkPath: true,
|
||||
...buildTodoNoteRouteNotFoundDiagnostic(method, path),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@@ -840,9 +972,28 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin
|
||||
const body = requestBodyOption(args);
|
||||
const full = hasFlag(args, "--full");
|
||||
const raw = hasFlag(args, "--raw");
|
||||
const checkPath = hasFlag(args, "--check-path");
|
||||
const method = methodOption(args, body !== undefined);
|
||||
if (checkPath) {
|
||||
if (id !== "todo-note") {
|
||||
return {
|
||||
ok: false,
|
||||
command: `microservice proxy ${id} ${path} --method ${method} --check-path`,
|
||||
data: {
|
||||
ok: false,
|
||||
error: `--check-path currently only supports service=todo-note; got ${id}`,
|
||||
supportedServices: ["todo-note"],
|
||||
hint: "Open docs/reference/cli.md (microservice proxy --check-path) for the supported endpoint catalog. Other services do not yet ship a CLI-side writable endpoint list; file a follow-up if you need one.",
|
||||
},
|
||||
};
|
||||
}
|
||||
return runTodoNoteCheckPath(method, path);
|
||||
}
|
||||
const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000);
|
||||
const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000);
|
||||
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: methodOption(args, body !== undefined), body, maxResponseBytes }), args);
|
||||
const fetched = coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method, body, maxResponseBytes });
|
||||
const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, method, path, fetched);
|
||||
return summarizeMicroserviceProxyResponse(rewritten, args);
|
||||
}
|
||||
throw new Error("microservice command must be one of: list, status, health, diagnostics, tunnel-self-test, proxy");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user