import { existsSync, readFileSync } from "node:fs"; import { extname } from "node:path"; import { runCommandObserved, type CommandProgress } from "./command"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { composeConfig } from "./docker"; import { compactD601RecoveryGuardrails, runD601RecoveryGuardrails } from "./recovery-guardrails"; interface CheckItem { name: string; ok: boolean; detail: unknown; } const syntaxFiles = [ "scripts/cli.ts", "scripts/playwright-cli.ts", "scripts/src/playwright-cli.ts", "scripts/src/check.ts", "scripts/src/artifact-registry.ts", "scripts/src/auth-broker.ts", "scripts/src/code-queue.ts", "scripts/src/code-queue-execution-plane.ts", "scripts/src/command.ts", "scripts/src/d601-k3s-guard.ts", "scripts/src/hwlab-cd.ts", "scripts/src/decision-center.ts", "scripts/src/dev-env.ts", "scripts/src/deploy.ts", "scripts/src/docker.ts", "scripts/src/e2e.ts", "scripts/src/help.ts", "scripts/src/health.ts", "scripts/src/commander.ts", "scripts/src/platform-db.ts", "scripts/src/recovery-guardrails.ts", "scripts/src/server-cleanup.ts", "scripts/src/remote.ts", "scripts/code-queue-cli-steer-test.ts", "src/components/frontend/src/index.ts", "src/components/frontend/src/app.tsx", "src/components/frontend/src/decision-center.tsx", "src/components/provider-gateway/src/index.ts", "src/components/microservices/oa-event-flow/src/index.ts", "src/components/microservices/baidu-netdisk/src/index.ts", "src/components/microservices/k3sctl-adapter/src/index.ts", "src/components/microservices/mdtodo/src/index.ts", "src/components/microservices/decision-center/src/index.ts", "src/components/microservices/code-queue-mgr/src/index.ts", "src/components/microservices/code-agent-sandbox/src/index.ts", "src/components/microservices/host-codex-commander/src/index.ts", "src/components/microservices/host-codex-commander/src/contract.ts", "src/components/microservices/host-codex-commander/src/redaction.ts", "src/components/microservices/host-codex-commander/src/state.ts", ]; export interface CheckOptions { full: boolean; files: boolean; scriptsTypecheck: boolean; components: boolean; compose: boolean; logs: boolean; recoveryGuardrails: boolean; rust: boolean; scriptsTypecheckTimeoutMs: number; checkHeartbeatMs: number; } const defaultScriptsTypecheckTimeoutMs = 120_000; const defaultCheckHeartbeatMs = 15_000; const defaultCheckOptions: CheckOptions = { full: false, files: false, scriptsTypecheck: false, components: false, compose: false, logs: false, recoveryGuardrails: false, rust: false, scriptsTypecheckTimeoutMs: defaultScriptsTypecheckTimeoutMs, checkHeartbeatMs: defaultCheckHeartbeatMs, }; interface CheckRunResult { ok: boolean; mode: string; options: CheckOptions; summary: { total: number; passed: number; failed: number; failedItems: string[] }; items: CheckItem[]; } export function checkHelp(): Record { return { ok: true, command: "check", usage: [ "bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--components|--compose|--logs|--recovery-guardrails|--rust]", "bun scripts/cli.ts check recovery-guardrails", ], defaultMode: "syntax/config only; Rust is never compiled on the master server by default", options: [ { name: "--syntax-only|--basic", description: "Run only config validation, Bun version and TypeScript syntax transpile." }, { name: "--full", description: "Enable all non-Rust checks." }, { name: "--files", description: "Verify required entrypoint files, including backend-core Cargo files." }, { name: "--scripts-typecheck", description: "Run scripts TypeScript typecheck through the observed checker." }, { name: "--scripts-typecheck-timeout-ms N", description: `Bound scripts TypeScript typecheck duration; default ${defaultScriptsTypecheckTimeoutMs}.` }, { name: "--check-heartbeat-ms N", description: `Emit unidesk.check.progress JSON lines for running command checks; default ${defaultCheckHeartbeatMs}.` }, { name: "--components", description: "Run component TypeScript typecheck." }, { name: "--compose", description: "Render Docker Compose config." }, { name: "--logs", description: "Check unified log rotation policy." }, { name: "--recovery-guardrails", description: "Run D601 k3s/Code Queue reboot recovery diagnostics in read-only mode." }, { name: "--rust", description: "Run cargo check only when UNIDESK_D601_RUST_CHECK=1 or UNIDESK_NATIVE_K3S_RUST_CHECK=1 is set inside an approved native k3s CI/dev execution." }, ], rustBoundary: { masterServer: "do not run cargo check/build here", nativeK3sCi: "use deploy apply --env dev --service backend-core and CI with UNIDESK_NATIVE_K3S_RUST_CHECK=1", }, recoveryGuardrailsBoundary: { command: "bun scripts/cli.ts check recovery-guardrails", mutation: false, forbidden: ["restart k3s", "delete CRI sandboxes or pods", "modify hostPath directories", "deploy/rollout", "destructive prune/reset"], }, }; } export function parseCheckOptions(args: string[]): CheckOptions { const options = { ...defaultCheckOptions }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--full") { options.full = true; options.files = true; options.scriptsTypecheck = true; options.components = true; options.compose = true; options.logs = true; options.recoveryGuardrails = true; } else if (arg === "--scripts-typecheck-timeout-ms") { options.scriptsTypecheckTimeoutMs = positiveIntegerOption(arg, args[index + 1], 600_000); index += 1; } else if (arg === "--check-heartbeat-ms") { options.checkHeartbeatMs = positiveIntegerOption(arg, args[index + 1], 60_000); index += 1; } else if (arg === "--files") { options.files = true; } else if (arg === "--scripts-typecheck") { options.scriptsTypecheck = true; } else if (arg === "--components") { options.components = true; } else if (arg === "--compose") { options.compose = true; } else if (arg === "--logs") { options.logs = true; } else if (arg === "--recovery-guardrails") { options.recoveryGuardrails = true; } else if (arg === "--rust") { options.rust = true; } else if (arg === "--basic" || arg === "--syntax-only") { Object.assign(options, defaultCheckOptions); } else { throw new Error(`unknown check option: ${arg}`); } } return options; } function positiveIntegerOption(name: string, raw: string | undefined, max: number): number { const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return Math.min(value, max); } function fileItem(path: string): CheckItem { const absolute = rootPath(path); return { name: `file:${path}`, ok: existsSync(absolute), detail: absolute }; } function emitCheckProgress(detail: Record): void { console.error(JSON.stringify({ event: "unidesk.check.progress", ...detail })); } async function commandItem(name: string, command: string[], timeoutMs = 30_000, env: NodeJS.ProcessEnv = process.env, heartbeatMs = defaultCheckHeartbeatMs): Promise { const startedAt = Date.now(); emitCheckProgress({ phase: "started", name, command, timeoutMs, heartbeatMs }); const result = await runCommandObserved(command, repoRoot, { timeoutMs, env, heartbeatMs, onProgress: (progress) => emitCommandHeartbeat(name, command, progress), }); const durationMs = result.durationMs ?? Date.now() - startedAt; emitCheckProgress({ phase: result.timedOut ? "timed-out" : result.exitCode === 0 ? "succeeded" : "failed", name, durationMs, exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, }); return { name, ok: result.exitCode === 0, detail: { command, durationMs, exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, timeoutMs, heartbeatMs, stdoutBytes: result.stdoutBytes, stderrBytes: result.stderrBytes, stdoutTruncated: result.stdoutTruncated, stderrTruncated: result.stderrTruncated, stdoutTail: result.stdout.slice(-4000), stderrTail: result.stderr.slice(-4000), }, }; } function emitCommandHeartbeat(name: string, command: string[], progress: CommandProgress): void { emitCheckProgress({ phase: "running", name, command, elapsedMs: progress.elapsedMs, timeoutMs: progress.timeoutMs, pid: progress.pid, stdoutBytes: progress.stdoutBytes, stderrBytes: progress.stderrBytes, lastOutputAgeMs: progress.lastOutputAgeMs, }); } function syntaxItem(): CheckItem { const failures: Array<{ path: string; error: string }> = []; const checked: string[] = []; const ts = new Bun.Transpiler({ loader: "ts" }); const tsx = new Bun.Transpiler({ loader: "tsx" }); for (const path of syntaxFiles) { const absolute = rootPath(path); try { const source = readFileSync(absolute, "utf8"); const loader = extname(path) === ".tsx" ? tsx : ts; loader.transformSync(source); checked.push(path); } catch (error) { failures.push({ path, error: error instanceof Error ? error.message : String(error) }); } } return { name: "syntax:transpile", ok: failures.length === 0, detail: { checked, failures }, }; } function unifiedLogRotationItem(): CheckItem { const serviceFiles = [ "src/components/frontend/src/index.ts", "src/components/provider-gateway/src/index.ts", "src/components/microservices/code-queue-mgr/src/index.ts", "src/components/microservices/code-queue/src/index.ts", "src/components/microservices/k3sctl-adapter/src/index.ts", "src/components/microservices/mdtodo/src/index.ts", "src/components/microservices/project-manager/src/index.ts", "src/components/microservices/baidu-netdisk/src/index.ts", "src/components/microservices/oa-event-flow/src/index.ts", "src/components/microservices/decision-center/src/index.ts", "src/components/microservices/code-agent-sandbox/src/index.ts", "src/components/microservices/host-codex-commander/src/index.ts", ]; const offenders = serviceFiles.flatMap((path) => { const text = readFileSync(rootPath(path), "utf8"); const directLogAppend = /appendFileSync\(\s*(?:config\.)?logFile\b/u.test(text) || /appendFileSync\(\s*process\.env\.LOG_FILE\b/u.test(text); const missingWriter = !text.includes("createHourlyJsonlWriter"); return directLogAppend || missingWriter ? [{ path, directLogAppend, missingWriter }] : []; }); const backendLogger = readFileSync(rootPath("src/components/backend-core/src/logger.rs"), "utf8"); const backendMissingRotation = !backendLogger.includes("current_path") || !backendLogger.includes("prune"); const backendDirectUnboundedAppend = backendLogger.includes("appendFileSync"); if (backendMissingRotation || backendDirectUnboundedAppend) { offenders.push({ path: "src/components/backend-core/src/logger.rs", directLogAppend: backendDirectUnboundedAppend, missingWriter: backendMissingRotation }); } return { name: "logs:unified-hourly-rotation", ok: offenders.length === 0, detail: { sharedWriter: "src/components/shared/src/rotating-jsonl.ts", checkedFiles: ["src/components/backend-core/src/logger.rs", ...serviceFiles], offenders, }, }; } function extractComposeServiceBlock(composeText: string, serviceName: string): string { const lines = composeText.split("\n"); const startLine = lines.findIndex((line) => line === ` ${serviceName}:`); if (startLine < 0) return ""; let endLine = lines.length; for (let index = startLine + 1; index < lines.length; index += 1) { if (/^ [A-Za-z0-9][A-Za-z0-9_-]*:$/u.test(lines[index])) { endLine = index; break; } } return lines.slice(startLine, endLine).join("\n"); } function codeQueueMgrHealthcheckItem(): CheckItem { const composeText = readFileSync(rootPath("docker-compose.yml"), "utf8"); const serviceBlock = extractComposeServiceBlock(composeText, "code-queue-mgr"); const dockerfileText = readFileSync(rootPath("src/components/microservices/code-queue-mgr/Dockerfile"), "utf8"); const sourceText = readFileSync(rootPath("src/components/microservices/code-queue-mgr/src-rs/main.rs"), "utf8"); const healthcheckUsesRustProbe = serviceBlock.includes("code-queue-mgr") && serviceBlock.includes("--healthcheck"); const healthcheckReferencesBun = /\bhealthcheck:[\s\S]*?\bbun\b/u.test(serviceBlock); const binaryCopiedIntoRuntime = dockerfileText.includes("/usr/local/bin/code-queue-mgr"); const binaryImplementsProbe = sourceText.includes("--healthcheck") && sourceText.includes("run_healthcheck"); return { name: "docker-compose:code-queue-mgr-rust-healthcheck", ok: healthcheckUsesRustProbe && !healthcheckReferencesBun && binaryCopiedIntoRuntime && binaryImplementsProbe, detail: { healthcheckUsesRustProbe, healthcheckReferencesBun, binaryCopiedIntoRuntime, binaryImplementsProbe, expected: "code-queue-mgr Rust runtime healthcheck must use code-queue-mgr --healthcheck and must not depend on bun/curl/wget being present.", }, }; } function skippedRustCheckItem(): CheckItem { return { name: "rust:backend-core", ok: false, detail: { skipped: true, reason: "Rust compilation is intentionally not allowed on the master server; run it from an approved native k3s CI/dev execution plane.", enableOnNativeK3s: "UNIDESK_NATIVE_K3S_RUST_CHECK=1 bun scripts/cli.ts check --rust", legacyEnableOnD601: "UNIDESK_D601_RUST_CHECK=1 bun scripts/cli.ts check --rust", deployPath: "bun scripts/cli.ts deploy apply --env dev --service backend-core", }, }; } async function runRustCheckItem(heartbeatMs: number): Promise { const rustCheckAllowed = process.env.UNIDESK_D601_RUST_CHECK === "1" || process.env.UNIDESK_NATIVE_K3S_RUST_CHECK === "1"; if (!rustCheckAllowed) return skippedRustCheckItem(); const envPath = process.env.HOME ? `${process.env.HOME}/.cargo/bin:${process.env.PATH ?? ""}` : process.env.PATH; const env = envPath ? { ...process.env, PATH: envPath } : process.env; return commandItem("rust:backend-core", ["cargo", "check", "--manifest-path", "src/components/backend-core/Cargo.toml"], 180_000, env, heartbeatMs); } function skippedItem(name: string, reason: string, enableWith: string): CheckItem { return { name, ok: true, detail: { skipped: true, reason, enableWith } }; } export function runRecoveryGuardrailsCheck(config: UniDeskConfig): ReturnType { return compactD601RecoveryGuardrails(runD601RecoveryGuardrails(config)); } export async function runChecks(config: UniDeskConfig, options: CheckOptions = defaultCheckOptions): Promise { const items: CheckItem[] = [ { name: "config:validated", ok: true, detail: { project: config.project.name, runtime: config.runtime } }, ]; items.push(await commandItem("bun:version", ["bun", "--version"], 30_000, process.env, options.checkHeartbeatMs)); items.push(syntaxItem()); items.push(codeQueueMgrHealthcheckItem()); if (options.files) { items.push( fileItem("scripts/cli.ts"), fileItem("scripts/playwright-cli.ts"), fileItem("scripts/src/playwright-cli.ts"), fileItem("AGENTS.md"), fileItem("TEST.md"), fileItem("docs/reference/artifact-registry.md"), fileItem("docs/reference/auth-broker.md"), fileItem("docker-compose.yml"), fileItem("src/components/backend-core/Cargo.toml"), fileItem("src/components/backend-core/Cargo.lock"), fileItem("src/components/backend-core/src/main.rs"), fileItem("src/components/backend-core/src/http.rs"), fileItem("src/components/frontend/src/index.ts"), fileItem("src/components/provider-gateway/src/index.ts"), fileItem("src/components/microservices/oa-event-flow/src/index.ts"), fileItem("src/components/microservices/k3sctl-adapter/src/index.ts"), fileItem("src/components/microservices/mdtodo/src/index.ts"), fileItem("src/components/microservices/decision-center/src/index.ts"), fileItem("src/components/microservices/code-queue-mgr/src/index.ts"), fileItem("src/components/microservices/code-agent-sandbox/src/index.ts"), fileItem("src/components/microservices/host-codex-commander/package.json"), fileItem("src/components/microservices/host-codex-commander/tsconfig.json"), fileItem("src/components/microservices/host-codex-commander/Dockerfile"), fileItem("src/components/microservices/host-codex-commander/src/index.ts"), fileItem("src/components/microservices/host-codex-commander/src/contract.ts"), fileItem("src/components/microservices/host-codex-commander/src/redaction.ts"), fileItem("src/components/microservices/host-codex-commander/src/state.ts"), fileItem("src/components/microservices/code-queue-mgr/src/prompt-observation.ts"), fileItem("scripts/src/deploy.ts"), fileItem("scripts/code-queue-issue3-regression-test.ts"), fileItem("scripts/code-queue-liveness-diagnostics-test.ts"), fileItem("scripts/src/code-queue-liveness-fixtures.ts"), fileItem("scripts/code-queue-cli-steer-test.ts"), fileItem("scripts/src/provider-triage.ts"), fileItem("src/components/microservices/code-queue/src/runner-error-classifier.ts"), fileItem("scripts/src/ci.ts"), fileItem("scripts/src/e2e.ts"), fileItem("scripts/code-queue-prompt-observation-test.ts"), fileItem("scripts/code-queue-pr-preflight-example.ts"), fileItem("scripts/src/artifact-registry.ts"), fileItem("scripts/src/server-cleanup.ts"), fileItem("scripts/src/recovery-guardrails.ts"), fileItem("scripts/src/auth-broker.ts"), fileItem("src/components/microservices/auth-broker/Cargo.toml"), fileItem("src/components/microservices/auth-broker/Dockerfile"), fileItem("src/components/microservices/auth-broker/src/main.rs"), fileItem("scripts/artifact-consumer-dry-run-matrix-test.ts"), fileItem("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml"), fileItem("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.g14.yaml"), fileItem("src/components/microservices/k3sctl-adapter/k3s/code-queue.g14.k3s.json"), fileItem("src/components/microservices/k3sctl-adapter/k3s/code-queue.g14.k8s.yaml"), ); } else { items.push(skippedItem("files:required-entrypoints", "required file presence scan is opt-in", "--files or --full")); } if (options.scriptsTypecheck) { items.push(await commandItem("typescript:scripts", ["bun", "--bun", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"], options.scriptsTypecheckTimeoutMs, process.env, options.checkHeartbeatMs)); } else { items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full")); } if (options.logs) { items.push(unifiedLogRotationItem()); } else { items.push(skippedItem("logs:unified-hourly-rotation", "policy scan is opt-in", "--logs or --full")); } if (options.recoveryGuardrails) { const recovery = runRecoveryGuardrailsCheck(config); items.push({ name: "d601:recovery-guardrails", ok: recovery.ok, detail: recovery, }); } else { items.push(skippedItem("d601:recovery-guardrails", "D601 reboot recovery diagnostics are opt-in and read-only", "--recovery-guardrails or --full")); } if (options.components) { items.push(await commandItem("typescript:components", ["bunx", "tsc", "-p", "src/tsconfig.check.json", "--pretty", "false"], 180_000, process.env, options.checkHeartbeatMs)); } else { items.push(skippedItem("typescript:components", "component TypeScript check is opt-in", "--components or --full")); } if (options.compose) { const compose = composeConfig(config); items.push({ name: "docker-compose:config", ok: compose.result.exitCode === 0, detail: { command: compose.command, exitCode: compose.result.exitCode, signal: compose.result.signal, timedOut: compose.result.timedOut, stdoutTail: compose.result.stdout.slice(-4000), stderrTail: compose.result.stderr.slice(-4000), runtimeEnv: compose.runtimeEnv, }, }); } else { items.push(skippedItem("docker-compose:config", "Docker Compose config rendering is opt-in", "--compose or --full")); } if (options.rust) { items.push(await runRustCheckItem(options.checkHeartbeatMs)); } else { items.push(skippedItem("rust:backend-core", "Rust check/build must run through an approved native k3s CI artifact publication, not on the master server or CD runtime target", "--rust inside native k3s CI with UNIDESK_NATIVE_K3S_RUST_CHECK=1")); } const failedItems = items.filter((item) => !item.ok).map((item) => item.name); return { ok: failedItems.length === 0, mode: options.full ? "full" : "basic", options, summary: { total: items.length, passed: items.length - failedItems.length, failed: failedItems.length, failedItems, }, items, }; }