import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; 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; 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 { 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; 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 { 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, `'\\''`)}'`; } function dockerCoreFetchCommand(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }): string[] { const maxResponseBytes = Math.max(1024, Math.floor(init?.maxResponseBytes ?? 5_000_000)); const method = init?.method ?? "GET"; const body = init?.body === undefined ? "" : JSON.stringify(init.body); const url = `http://127.0.0.1:8080${path}`; const script = [ "set -euo pipefail", "if command -v backend-core >/dev/null 2>&1; then", ` exec backend-core --fetch-json ${shellQuote(url)} --method ${shellQuote(method)} --max-response-bytes ${shellQuote(String(maxResponseBytes))}${body.length > 0 ? ` --body-json ${shellQuote(body)}` : ""}`, "fi", `url=${shellQuote(url)}`, `method=${shellQuote(method)}`, `max_bytes=${shellQuote(String(maxResponseBytes))}`, `body=${shellQuote(body)}`, "export url method body max_bytes", "bun -e 'const url=process.env.url; const method=process.env.method; const body=process.env.body; const maxBytes=Number(process.env.max_bytes||\"5000000\"); fetch(url,{method,body:body?body:undefined,headers:body?{\"content-type\":\"application/json\"}:undefined}).then(async r=>{const text=await r.text(); const out={ok:r.ok,status:r.status,body:text?JSON.parse(text):null}; const json=JSON.stringify(out); if (Buffer.byteLength(json) > maxBytes) { console.error(\"response too large\"); process.exit(1); } console.log(json); process.exit(r.ok?0:1);}).catch(e=>{console.error(e); process.exit(1)})'", ].join("\n"); return ["docker", "exec", "unidesk-backend-core", "bash", "-lc", script]; } interface RelatedContainer { name: string; image: string; status: string; } function unquoteEnvValue(value: string): string { return value.replace(/^['"]|['"]$/g, ""); } function baiduSecretPresence(envPath: string): Record { const keys = [ "UNIDESK_BAIDU_NETDISK_CLIENT_ID", "UNIDESK_BAIDU_NETDISK_CLIENT_SECRET", "UNIDESK_BAIDU_NETDISK_TOKEN_KEY", ]; if (!existsSync(envPath)) { return { envPath, exists: false, keys: Object.fromEntries(keys.map((key) => [key, { present: false, nonEmpty: false }])) }; } const env = new Map(); for (const line of readFileSync(envPath, "utf8").split(/\r?\n/u)) { if (line.length === 0 || line.trimStart().startsWith("#")) continue; const index = line.indexOf("="); if (index === -1) continue; env.set(line.slice(0, index), unquoteEnvValue(line.slice(index + 1))); } return { envPath, exists: true, keys: Object.fromEntries(keys.map((key) => [key, { present: env.has(key), nonEmpty: Boolean((env.get(key) || "").trim()) }])), }; } function listRelatedStackContainers(): RelatedContainer[] { const result = runCommand(["docker", "ps", "-a", "--format", "{{json .}}"], repoRoot); if (result.exitCode !== 0 || result.stdout.trim().length === 0) return []; const names = ["unidesk-backend-core", "unidesk-database", "baidu-netdisk-backend"]; return result.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => { try { return JSON.parse(line) as Record; } catch { return null; } }) .filter((row): row is Record => row !== null) .filter((row) => names.some((name) => row.Names === name || String(row.Names || "").startsWith(`${name}.`))) .map((row) => ({ name: row.Names ?? "", image: row.Image ?? "", status: row.Status ?? "" })); } export function backendCoreUnavailableDiagnostic(detail: { exitCode: number | null; stdoutTail: string; stderrTail: string; relatedContainers: RelatedContainer[]; envPath: string; baiduSecretPresence: Record; }): Record { const expectedContainers = ["unidesk-backend-core", "unidesk-database", "baidu-netdisk-backend"]; const presentExactNames = new Set(detail.relatedContainers.map((container) => container.name)); const missingContainers = expectedContainers.filter((name) => !presentExactNames.has(name)); const verifyOnlyObserved = detail.relatedContainers.some((container) => /\.verify-/u.test(container.name)); return { ok: false, failureKind: "target-stack-not-running", degradedReason: "backend-core-container-missing", runnerDisposition: "infra-blocked", readOnlyReview: true, message: verifyOnlyObserved ? "backend-core/database target containers are not running; only verify-only containers were observed." : "backend-core target container is not running, so private backend-core API checks cannot observe schedules or microservices.", targetStack: { expectedContainers, missingContainers, relatedContainers: detail.relatedContainers, verifyOnlyObserved, }, observed: { commandExitCode: detail.exitCode, stdoutTail: detail.stdoutTail, stderrTail: detail.stderrTail, }, baiduNetdiskConfigPresence: detail.baiduSecretPresence, readOnlyCommands: [ "bun scripts/cli.ts server status", "bun scripts/cli.ts schedule list", "bun scripts/cli.ts schedule runs --limit 20", "bun scripts/cli.ts microservice health baidu-netdisk", "bun scripts/cli.ts microservice proxy baidu-netdisk /api/auth/status --raw", "bun scripts/cli.ts microservice proxy baidu-netdisk '/api/transfers?limit=20' --raw", ], authorizationRequiredForRecovery: [ "restore non-empty Baidu Netdisk runtime secrets in the controlled Compose env source without printing secret values", "start or deploy the production target stack containing backend-core, database, and baidu-netdisk", "after health is green, explicitly authorize schedule retry-run or schedule run before triggering a real PGDATA backup", ], blockedWithoutAuthorization: [ "server start", "server rebuild backend-core", "server rebuild baidu-netdisk", "deploy apply --env prod --service baidu-netdisk", "schedule run ", "schedule retry-run ", ], }; } function backendCoreContainerMissing(stderr: string): boolean { return stderr.includes("No such container: unidesk-backend-core"); } export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number; timeoutMs?: number }): unknown { if (!path.startsWith("/")) throw new Error("core internal path must start with /"); const command = dockerCoreFetchCommand(path, init); const result = runCommand(command, repoRoot, { timeoutMs: init?.timeoutMs }); if (result.exitCode !== 0) { if (backendCoreContainerMissing(result.stderr)) { const envPath = join(repoRoot, ".state", "docker-compose.env"); return backendCoreUnavailableDiagnostic({ exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200), relatedContainers: listRelatedStackContainers(), envPath, baiduSecretPresence: baiduSecretPresence(envPath), }); } const parsedStdout = parseJsonRecord(result.stdout.trim()); if (parsedStdout !== null) { return { ...parsedStdout, commandExitCode: result.exitCode, commandStderrTail: result.stderr.slice(-1200), }; } const codeQueueStableProxy = path.startsWith("/api/microservices/code-queue/"); return { ok: false, exitCode: result.exitCode, timedOut: result.timedOut, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200), ...(codeQueueStableProxy ? { codeQueueObservationNote: "The stable /api/microservices/code-queue path could not be reached from this CLI container. This does not by itself mean Code Queue tasks are unevaluable or stopped. From the supervisor or main-server CLI environment, use the existing codex queues/tasks/task observation commands instead of treating this container-local proxy failure as liveness evidence.", recommendedCommands: [ "bun scripts/cli.ts codex queues", "bun scripts/cli.ts codex tasks --limit 20", "bun scripts/cli.ts codex task ", ], } : {}), }; } try { return JSON.parse(result.stdout.trim()) as unknown; } catch { return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) }; } } function parseJsonRecord(text: string): Record | null { if (text.length === 0) return null; try { const parsed = JSON.parse(text) as unknown; return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null; } catch { return null; } } function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function asNumber(value: unknown, fallback = 0): number { const number = Number(value); return Number.isFinite(number) ? number : fallback; } function stringList(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((item) => String(item)).filter((item) => item.length > 0); } function boundedUniqueStringList(value: unknown, limit = 5): Record { const all = Array.from(new Set(stringList(value))).sort(); const items = all.slice(0, limit); return { items, count: all.length, omitted: Math.max(0, all.length - items.length), truncated: all.length > items.length, }; } function limitedStrings(value: unknown, limit: number, maxLength: number): string[] { return stringList(value).slice(0, limit).map((item) => item.length > maxLength ? item.slice(0, maxLength) : item); } function recordValue(record: Record | null, key: string): Record | null { return record === null ? null : asRecord(record[key]); } function isCodeQueueHealthBody(record: Record | null): boolean { if (record === null) return false; return record.service === "code-queue" || record.serviceId === "code-queue" || asRecord(record.queue) !== null || asRecord(record.executionDiagnostics) !== null; } function hasCodeQueueHealthDetails(record: Record | null): boolean { if (record === null) return false; return record.service === "code-queue" || asRecord(record.queue) !== null || asRecord(record.executionDiagnostics) !== null; } function splitBrainLiveFromDiagnostics(record: Record): boolean { if (typeof record.splitBrainLive === "boolean") return record.splitBrainLive; const state = String(record.state ?? record.health ?? "").toLowerCase(); const riskTaskIds = Array.from(new Set([ ...stringList(record.heartbeatRiskTaskIds), ...stringList(record.heartbeatExpiredTaskIds), ...stringList(record.heartbeatMissingTaskIds), ...stringList(record.staleRecoveryCandidateTaskIds), ])); return state === "split-brain" && stringList(record.heartbeatFreshTaskIds).length > 0 && riskTaskIds.length === 0; } function effectiveLivenessFromDiagnostics(record: Record): string { if (typeof record.effectiveLiveness === "string" && record.effectiveLiveness.length > 0) return record.effectiveLiveness; if (stringList(record.heartbeatRiskTaskIds).length > 0 || stringList(record.heartbeatExpiredTaskIds).length > 0 || stringList(record.heartbeatMissingTaskIds).length > 0 || stringList(record.staleRecoveryCandidateTaskIds).length > 0) { return "at-risk"; } if (splitBrainLiveFromDiagnostics(record)) return "live"; return String(record.state ?? record.health ?? "unknown") === "healthy" ? "healthy" : "degraded"; } function recommendedActionFromDiagnostics(record: Record, effectiveLiveness: string): string { if (typeof record.recommendedAction === "string" && record.recommendedAction.length > 0) return record.recommendedAction; if (effectiveLiveness === "at-risk") return "investigate-heartbeat-risk"; if (effectiveLiveness === "live") return "continue-supervision"; if (effectiveLiveness === "degraded") return "observe-degraded"; return "none"; } function livenessInterpretation(effectiveLiveness: string, splitBrainLive: boolean): string { if (splitBrainLive) return "scheduler heartbeat is fresh; treat active task count from heartbeat as live and continue supervision"; if (effectiveLiveness === "at-risk") return "heartbeat risk is present; investigate heartbeat freshness before recovery"; if (effectiveLiveness === "degraded") return "diagnostics are degraded; cross-check heartbeat, trace and control-plane sources"; return "diagnostics indicate healthy liveness"; } function compactCodeQueueDiagnostics(value: unknown): Record | null { const diagnostics = asRecord(value); if (diagnostics === null) return null; const heartbeatRiskIds = Array.from(new Set([ ...stringList(diagnostics.heartbeatRiskTaskIds), ...stringList(diagnostics.heartbeatExpiredTaskIds), ...stringList(diagnostics.heartbeatMissingTaskIds), ...stringList(diagnostics.staleRecoveryCandidateTaskIds), ])).sort(); const splitBrainLive = splitBrainLiveFromDiagnostics(diagnostics); const effectiveLiveness = effectiveLivenessFromDiagnostics(diagnostics); const recommendedAction = recommendedActionFromDiagnostics(diagnostics, effectiveLiveness); const heartbeatFreshTaskIds = boundedUniqueStringList(diagnostics.heartbeatFreshTaskIds); const activeHeartbeatTaskIds = boundedUniqueStringList(diagnostics.activeHeartbeatTaskIds); return { state: diagnostics.state ?? diagnostics.health ?? null, degraded: diagnostics.degraded ?? null, splitBrain: diagnostics.splitBrain ?? null, splitBrainLive, effectiveLiveness, recommendedAction, interpretation: livenessInterpretation(effectiveLiveness, splitBrainLive), executionStateSource: diagnostics.executionStateSource ?? null, controlPlane: diagnostics.controlPlane ?? null, databaseActiveTaskCount: diagnostics.databaseActiveTaskCount ?? stringList(diagnostics.databaseActiveTaskIds).length, schedulerActiveRunSlotCount: diagnostics.schedulerActiveRunSlotCount ?? null, activeHeartbeatCount: diagnostics.activeHeartbeatCount ?? Math.max(asNumber(activeHeartbeatTaskIds.count), asNumber(heartbeatFreshTaskIds.count)), activeHeartbeatTaskIds, heartbeatFreshTaskCount: heartbeatFreshTaskIds.count, heartbeatFreshTaskIds, heartbeatRiskTaskCount: heartbeatRiskIds.length, heartbeatRiskTaskIds: boundedUniqueStringList(heartbeatRiskIds), heartbeatExpiredTaskCount: stringList(diagnostics.heartbeatExpiredTaskIds).length, heartbeatMissingTaskCount: stringList(diagnostics.heartbeatMissingTaskIds).length, staleRecoveryCandidateTaskCount: stringList(diagnostics.staleRecoveryCandidateTaskIds).length, traceGapTaskCount: stringList(diagnostics.traceGapTaskIds).length, lastSchedulerHeartbeatAt: diagnostics.lastSchedulerHeartbeatAt ?? null, lastObservedAgentEventAt: diagnostics.lastObservedAgentEventAt ?? null, lastPersistedTraceAt: diagnostics.lastPersistedTraceAt ?? null, reasons: limitedStrings(diagnostics.reasons, 3, 240), }; } function compactCodeQueueQueueSummary(value: unknown, diagnostics: Record | null): Record { const queue = asRecord(value); const counts = asRecord(queue?.counts) ?? {}; const running = asNumber(counts.running); const judging = asNumber(counts.judging); const activeTaskIds = boundedUniqueStringList(queue?.activeTaskIds); const activeQueueIds = boundedUniqueStringList(queue?.activeQueueIds); const processingQueueIds = boundedUniqueStringList(queue?.processingQueueIds); return { total: queue?.total ?? null, counts, runningCount: running + judging, running, judging, queued: asNumber(counts.queued), retryWait: asNumber(counts.retry_wait), queueCount: queue?.queueCount ?? null, unreadTerminal: queue?.unreadTerminal ?? null, activeRunSlotCount: queue?.activeRunSlotCount ?? diagnostics?.schedulerActiveRunSlotCount ?? null, activeTaskCount: activeTaskIds.count, activeTaskIds, activeTaskId: queue?.activeTaskId ?? null, activeQueueIds, processingQueueIds, processing: queue?.processing ?? null, orphanedActiveTaskCount: queue?.orphanedActiveTaskCount ?? null, defaultQueueId: queue?.defaultQueueId ?? null, defaultModel: queue?.defaultModel ?? null, defaultReasoningEffort: queue?.defaultReasoningEffort ?? null, maxActiveQueues: queue?.maxActiveQueues ?? null, }; } function compactCodeQueueComponents(body: Record, queue: Record | null): Record { const storage = asRecord(queue?.storage); const egressProxy = asRecord(body.egressProxy); const oaPublisher = asRecord(body.oaEventPublisher); return { databaseReady: body.databaseReady ?? storage?.postgresReady ?? null, serviceReady: body.serviceReady ?? null, schedulerEnabled: body.schedulerEnabled ?? null, egressProxyConnected: egressProxy?.connected ?? null, oaEventPublisher: oaPublisher === null ? null : { enabled: oaPublisher.enabled ?? null, pendingCount: oaPublisher.pendingCount ?? null, lastError: oaPublisher.lastError ?? null, }, }; } export function summarizeMicroserviceHealthResponse(response: unknown, args: string[], serviceId: string): unknown { if (serviceId !== "code-queue") return summarizeMicroserviceObservation("health", serviceId, response, args); if (hasFlag(args, "--raw") || hasFlag(args, "--full")) return response; const responseRecord = asRecord(response); if (responseRecord === null) return response; const responseBodySource = "body" in responseRecord ? responseRecord.body : responseRecord; const responseBody = asRecord(responseBodySource); if (responseBody === null) return summarizeMicroserviceObservation("health", serviceId, response, args); const upstream = recordValue(responseBody, "upstream"); const upstreamBody = recordValue(upstream, "body"); const healthBody = hasCodeQueueHealthDetails(responseBody) ? responseBody : isCodeQueueHealthBody(upstreamBody) ? upstreamBody : isCodeQueueHealthBody(responseBody) ? responseBody : null; if (healthBody === null) return summarizeMicroserviceObservation("health", serviceId, response, args); const queue = asRecord(healthBody.queue); const diagnostics = compactCodeQueueDiagnostics(healthBody.executionDiagnostics ?? queue?.executionDiagnostics); const bodyBytes = jsonByteLength(responseBodySource); return { ok: responseRecord.ok ?? responseBody.ok ?? healthBody.ok ?? null, status: responseRecord.status ?? responseBody.status ?? healthBody.status ?? null, serviceId: "code-queue", service: healthBody.service ?? "code-queue", name: responseBody.name ?? null, providerId: responseBody.providerId ?? null, healthPath: responseBody.healthPath ?? "/health", reason: responseBody.reason ?? null, checkedAt: responseBody.checkedAt ?? null, instanceId: healthBody.instanceId ?? null, role: healthBody.role ?? null, deploy: previewJson(healthBody.deploy ?? null, { maxDepth: 2, maxArrayItems: 2, maxObjectKeys: 8, maxStringLength: 160 }), components: compactCodeQueueComponents(healthBody, queue), queue: compactCodeQueueQueueSummary(queue, diagnostics), heartbeat: diagnostics === null ? null : { lastSchedulerHeartbeatAt: diagnostics.lastSchedulerHeartbeatAt, lastObservedAgentEventAt: diagnostics.lastObservedAgentEventAt, lastPersistedTraceAt: diagnostics.lastPersistedTraceAt, activeHeartbeatCount: diagnostics.activeHeartbeatCount, activeHeartbeatTaskIds: diagnostics.activeHeartbeatTaskIds, heartbeatFreshTaskCount: diagnostics.heartbeatFreshTaskCount, heartbeatFreshTaskIds: diagnostics.heartbeatFreshTaskIds, heartbeatRiskTaskCount: diagnostics.heartbeatRiskTaskCount, heartbeatRiskTaskIds: diagnostics.heartbeatRiskTaskIds, heartbeatExpiredTaskCount: diagnostics.heartbeatExpiredTaskCount, heartbeatMissingTaskCount: diagnostics.heartbeatMissingTaskCount, staleRecoveryCandidateTaskCount: diagnostics.staleRecoveryCandidateTaskCount, traceGapTaskCount: diagnostics.traceGapTaskCount, schedulerHeartbeatStaleMs: queue?.schedulerHeartbeatStaleMs ?? null, }, liveness: diagnostics === null ? null : { state: diagnostics.state, degraded: diagnostics.degraded, splitBrain: diagnostics.splitBrain, splitBrainLive: diagnostics.splitBrainLive, effectiveLiveness: diagnostics.effectiveLiveness, recommendedAction: diagnostics.recommendedAction, interpretation: diagnostics.interpretation, reasons: diagnostics.reasons, }, outputPolicy: { mode: "compact", defaultCompact: true, omittedFullHealthBody: true, originalBodyBytes: bodyBytes, idPreviewLimit: 5, rawCommand: "bun scripts/cli.ts microservice health code-queue --raw", fullCommand: "bun scripts/cli.ts microservice health code-queue --raw --full", proxyFullCommand: "bun scripts/cli.ts microservice proxy code-queue /health --raw --full", }, }; } function requireId(value: string | undefined, command: string): string { if (value === undefined || value.length === 0) throw new Error(`${command} requires microservice id`); return value; } function requireProxyPath(value: string | undefined): string { if (value === undefined || value.length === 0) throw new Error("microservice proxy requires upstream path, for example /api/summary"); if (!value.startsWith("/")) throw new Error("microservice proxy upstream path must start with /"); return value; } function encodeId(value: string): string { return encodeURIComponent(value); } function numberOption(args: string[], name: string, defaultValue: number): number { const index = args.indexOf(name); if (index === -1) return defaultValue; const raw = args[index + 1]; const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return value; } function cappedNumberOption(args: string[], name: string, defaultValue: number, maxValue: number): number { const value = numberOption(args, name, defaultValue); return Math.min(value, maxValue); } function stringOption(args: string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1) return undefined; const raw = args[index + 1]; if (raw === undefined || raw.length === 0) throw new Error(`${name} requires a non-empty value`); return raw; } function hasFlag(args: string[], name: string): boolean { return args.includes(name); } function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function parseJsonOption(raw: string, name: string): unknown { try { return JSON.parse(raw) as unknown; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`${name} must be valid JSON: ${message}`); } } function requestBodyOption(args: string[]): unknown | undefined { const bodyJson = stringOption(args, "--body-json"); const bodyFile = stringOption(args, "--body-file"); const bodyStdin = hasFlag(args, "--body-stdin"); const sources = [bodyJson !== undefined, bodyFile !== undefined, bodyStdin].filter(Boolean).length; if (sources > 1) throw new Error("microservice proxy accepts only one request body source: --body-json, --body-file, or --body-stdin"); if (bodyJson !== undefined) return parseJsonOption(bodyJson, "--body-json"); if (bodyFile !== undefined) { const text = bodyFile === "-" ? readFileSync(0, "utf8") : readFileSync(bodyFile, "utf8"); return parseJsonOption(text, "--body-file"); } if (bodyStdin) return parseJsonOption(readFileSync(0, "utf8"), "--body-stdin"); return undefined; } function assertKnownObservationOptions(args: string[], command: string, extraFlags: string[] = []): void { const flags = new Set(["--full", "--raw", ...extraFlags]); for (const arg of args) { if (!arg.startsWith("--")) continue; if (!flags.has(arg)) throw new Error(`unsupported ${command} option: ${arg}`); } } function methodOption(args: string[], hasBody = false): string { const method = (stringOption(args, "--method") ?? (hasBody ? "POST" : "GET")).toUpperCase(); if (!["GET", "HEAD", "POST", "DELETE", "PUT", "PATCH"].includes(method)) throw new Error(`unsupported --method ${method}`); if (hasBody && (method === "GET" || method === "HEAD")) throw new Error(`microservice proxy cannot send a request body with ${method}`); return method; } export function summarizeMicroserviceProxyResponse(response: unknown, args: string[]): unknown { const full = args.includes("--full"); const raw = args.includes("--raw"); const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000); if (typeof response !== "object" || response === null || Array.isArray(response)) return response; const record = response as Record; if (!("body" in record)) return response; if (record.responseTruncated === true) { return { ...record, bodyOmitted: true, bodyMaxBytes: maxBodyBytes, rawHint: "The upstream response exceeded the CLI collection cap before JSON parsing; re-run with --raw --full and a specific --max-body-bytes only when the full body is required.", }; } const bodyBytes = jsonByteLength(record.body); if (bodyBytes <= maxBodyBytes) { if (!raw || full) return response; return { ...record, outputPolicy: { rawRequested: true, bounded: true, maxBodyBytes, bodyBytes, fullCommand: "Re-run with --raw --full to allow the complete body.", }, }; } const rest = { ...record }; delete rest.body; return { ...rest, bodyOmitted: true, bodyBytes, bodyMaxBytes: maxBodyBytes, bodyPreview: previewJson(record.body, { maxDepth: 3, maxArrayItems: 3, maxObjectKeys: 16, maxStringLength: 320 }), rawHint: raw && !full ? "The --raw response exceeded the default hard limit; re-run with --raw --full for the complete body, or add/tighten __unideskArrayLimit=: in the proxied path." : "Re-run with --raw --full for the complete body, or add/tighten __unideskArrayLimit=: in the proxied path.", }; } function compactStringList(value: unknown, limit = 8): Record { const all = Array.from(new Set(asArray(value).map((item) => String(item ?? "")).filter(Boolean))); return { items: all.slice(0, limit), count: all.length, truncated: all.length > limit, omitted: Math.max(0, all.length - limit), }; } function compactRecordFields(record: Record | null, keys: string[]): Record | null { if (record === null) return null; const selected: Record = {}; for (const key of keys) { if (record[key] !== undefined) selected[key] = record[key]; } return Object.keys(selected).length > 0 ? selected : null; } function compactQueueHealth(value: unknown): Record | null { const queue = asRecord(value); if (queue === null) return null; return { controlPlane: queue.controlPlane ?? null, defaultProviderId: queue.defaultProviderId ?? null, defaultModel: queue.defaultModel ?? null, counts: queue.counts ?? {}, total: queue.total ?? null, queueCount: queue.queueCount ?? null, activeQueueIds: compactStringList(queue.activeQueueIds, 8), activeTaskIds: compactStringList(queue.activeTaskIds ?? queue.databaseActiveTaskIds, 8), queuedTaskIds: compactStringList(queue.queuedTaskIds, 8), databaseActiveTaskCount: queue.databaseActiveTaskCount ?? null, schedulerActiveRunSlotCount: queue.schedulerActiveRunSlotCount ?? queue.activeRunSlotCount ?? null, }; } function compactSkillAvailability(value: unknown): Record | null { const skills = asRecord(value); if (skills === null) return null; return { ok: skills.ok ?? false, runnerUsable: skills.runnerUsable ?? skills.ok ?? false, contractOk: skills.contractOk ?? skills.ok ?? false, path: skills.path ?? null, resolvedPath: skills.resolvedPath ?? skills.path ?? null, resolvedPathSource: skills.resolvedPathSource ?? null, resolution: skills.resolution ?? null, source: skills.source ?? null, target: skills.target ?? null, mountPoint: skills.mountPoint ?? null, exists: skills.exists ?? false, available: skills.available ?? false, degraded: skills.degraded ?? true, blocker: skills.blocker ?? null, degradedReason: skills.degradedReason ?? skills.blocker ?? null, readonly: skills.readonly ?? false, skillCount: skills.skillCount ?? 0, version: skills.version ?? null, sourceSkillCount: skills.sourceSkillCount ?? null, targetSkillCount: skills.targetSkillCount ?? null, sourceMissingSkills: Array.isArray(skills.sourceMissingSkills) ? skills.sourceMissingSkills.map(String) : [], targetMissingSkills: Array.isArray(skills.targetMissingSkills) ? skills.targetMissingSkills.map(String) : [], requiredSkills: Array.isArray(skills.requiredSkills) ? skills.requiredSkills.map(String) : [], missingSkills: Array.isArray(skills.missingSkills) ? skills.missingSkills.map(String) : [], valuesPrinted: skills.valuesPrinted ?? false, pathSpelling: compactRecordFields(asRecord(skills.pathSpelling), [ "expectedTarget", "forbiddenPathChecked", "forbiddenPathExists", "forbiddenPathConfigured", "forbiddenPathRoles", "forbiddenPathMustNotBeUsed", ]), repairHint: skills.repairHint ?? null, }; } function compactSkillSync(value: unknown): Record | null { const sync = asRecord(value); if (sync === null) return null; const source = compactRecordFields(asRecord(sync.source), [ "path", "approved", "exists", "directory", "readable", "writable", "readonly", "mountPoint", "symlink", "realPath", "skillCount", "version", "requiredSkills", "missingSkills", "error", ]); const target = compactRecordFields(asRecord(sync.target), [ "path", "approved", "exists", "directory", "readable", "writable", "readonly", "mountPoint", "symlink", "realPath", "skillCount", "version", "requiredSkills", "missingSkills", "error", ]); const permissionFailures = Array.isArray(sync.permissionFailures) ? sync.permissionFailures : []; return { ok: sync.ok ?? false, degraded: sync.degraded ?? true, blocker: sync.blocker ?? null, mode: sync.mode ?? "dry-run", dryRun: sync.dryRun ?? true, mutation: sync.mutation ?? false, syncMode: sync.syncMode ?? null, source, target, counts: sync.counts ?? null, version: sync.version ?? null, missing: sync.missing ?? null, permissionFailureCount: permissionFailures.length, permissionFailures: permissionFailures.slice(0, 4), plannedActions: sync.plannedActions ?? null, commands: sync.commands ?? null, valuesPrinted: sync.valuesPrinted ?? false, }; } function compactMicroserviceBody(body: unknown, serviceId: string): Record | null { const record = asRecord(body); if (record === null) return null; const diagnostics = asRecord(record.executionDiagnostics) ?? asRecord(record.diagnostics); const devReady = asRecord(record.devReady); return { ok: record.ok ?? null, serviceId: record.serviceId ?? record.service ?? serviceId, service: record.service ?? null, role: record.role ?? null, status: record.status ?? null, healthy: record.healthy ?? null, mode: record.mode ?? null, environment: record.environment ?? null, version: record.version ?? record.gatewayVersion ?? null, target: record.target ?? record.k3sServiceId ?? null, updatedAt: record.updatedAt ?? record.observedAt ?? record.generatedAt ?? null, startedAt: record.startedAt ?? null, taskCount: record.taskCount ?? null, schemaReady: record.schemaReady ?? null, queue: compactQueueHealth(record.queue), skills: compactSkillAvailability(record.skills), skillsSync: compactSkillSync(record.skillsSync), resourceBudget: compactRecordFields(asRecord(record.resourceBudget), [ "targetMemoryMb", "mgrPoolMax", "tracePoolMax", "noRunnerDependencies", "noDockerSocket", "noPlaywright", "rustRewrite", ]), topLevelKeys: compactStringList(Object.keys(record), 16), devReady: devReady === null ? null : { ok: devReady.ok ?? null, missingTools: compactStringList(devReady.missingTools, 8), skills: compactSkillAvailability(devReady.skills), skillsSync: compactSkillSync(devReady.skillsSync), }, executionDiagnostics: diagnostics === null ? null : { state: diagnostics.state ?? null, effectiveLiveness: diagnostics.effectiveLiveness ?? null, recommendedAction: diagnostics.recommendedAction ?? null, splitBrainLive: diagnostics.splitBrainLive ?? null, schedulerActiveRunSlotCount: diagnostics.schedulerActiveRunSlotCount ?? null, databaseActiveTaskCount: diagnostics.databaseActiveTaskCount ?? null, heartbeatFreshTaskIds: compactStringList(diagnostics.heartbeatFreshTaskIds, 8), heartbeatRiskTaskIds: compactStringList(diagnostics.heartbeatRiskTaskIds, 8), }, }; } export function summarizeMicroserviceObservation(action: string, serviceId: string, response: unknown, args: string[]): unknown { if (hasFlag(args, "--full") || hasFlag(args, "--raw")) return response; const record = asRecord(response); if (record === null) return response; const body = "body" in record ? record.body : response; const bodyBytes = jsonByteLength(body); const summary = compactMicroserviceBody(body, serviceId); const includePreview = summary === null || bodyBytes <= 20_000; return { upstream: { ok: record.ok ?? null, status: record.status ?? null, exitCode: record.exitCode ?? null, }, microservice: { action, id: serviceId, summary, bodyBytes, bodyOmitted: true, outputPolicy: { default: "compact-health-summary", full: `bun scripts/cli.ts microservice ${action} ${serviceId} --full`, raw: `bun scripts/cli.ts microservice ${action} ${serviceId} --raw`, proxyRaw: serviceId === "code-queue" && action === "health" ? "bun scripts/cli.ts microservice proxy code-queue /health --raw --full" : null, }, ...(includePreview ? { bodyPreview: previewJson(body, { maxDepth: 2, maxArrayItems: 2, maxObjectKeys: 8, maxStringLength: 160 }) } : { bodyPreviewOmitted: true, bodyPreviewHint: "Large body preview omitted because compact summary is available; use --full or --raw for complete evidence.", }), }, }; } export async function runMicroserviceCommand(_config: UniDeskConfig, args: string[]): Promise { const [action = "list", idArg, pathArg] = args; if (action === "list") return coreInternalFetch("/api/microservices"); if (action === "status") { const id = requireId(idArg, "microservice status"); const optionArgs = args.slice(2); assertKnownObservationOptions(optionArgs, "microservice status"); return summarizeMicroserviceObservation(action, id, coreInternalFetch(`/api/microservices/${encodeId(id)}/status`), optionArgs); } if (action === "health") { const id = requireId(idArg, "microservice health"); const optionArgs = args.slice(2); assertKnownObservationOptions(optionArgs, "microservice health", ["--compact"]); return summarizeMicroserviceHealthResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/health`), optionArgs, id); } if (action === "diagnostics") { const id = requireId(idArg, "microservice diagnostics"); const optionArgs = args.slice(2); assertKnownObservationOptions(optionArgs, "microservice diagnostics"); return summarizeMicroserviceObservation(action, id, coreInternalFetch(`/api/microservices/${encodeId(id)}/diagnostics`), optionArgs); } if (action === "tunnel-self-test") { const id = requireId(idArg, "microservice tunnel-self-test"); return coreInternalFetch(`/api/microservices/${encodeId(id)}/tunnel-self-test`); } if (action === "proxy") { const id = requireId(idArg, "microservice proxy"); const path = requireProxyPath(pathArg); 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); 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"); }