diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d8492f18..d7954ef6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -26,7 +26,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 - 每个 CLI 命名空间必须支持 `help`、`--help` 或 `-h` 并返回 JSON,不得为了打印帮助而访问 runtime 服务、拉起交互会话或执行长时任务。 - `--main-server-ip ` 默认通过公网 frontend 登录态调用主 server 的同源 API 代理,不要求计算节点持有主 server SSH key;显式提供 `--main-server-key` 或 `--main-server-transport ssh` 时才使用旧 SSH 传输。远程 frontend 传输下的 `ssh ...` 必须复用同一套结构化 route parser,支持 `D601`、`G14`、host workspace、`D601:win`、`D601:win/c/test`、`D601:k3s` 和 `D601:k3s::` 这类定位路径;它不向调用容器下发 provider token,也不要求调用容器能解析 backend-core 内网 DNS。 - `config show` 读取并校验根目录 `config.json`,不从环境变量、默认值或隐藏文件静默补配置。 -- `check` 默认只执行轻量配置校验、Bun 版本检查和 Bun Transpiler 语法解析(覆盖 CLI 入口、主要 `scripts/` 模块和核心组件入口,不做类型推导);关键文件存在性、`scripts/` TypeScript 类型检查、GitHub CLI contract、`src/components/` TypeScript 类型检查、Docker Compose config、日志轮转策略扫描和 D601 recovery guardrails 默认不启用,分别通过 `--files`、`--scripts-typecheck`、`--gh-contracts`、`--components`、`--compose`、`--logs`、`--recovery-guardrails` 开启,或用 `--full` 一次性开启。`--scripts-typecheck` 只表示 scripts TypeScript 与本地脚本合同,不应触发 GitHub issue/PR live contract 这类可能受网络/API 时延影响的检查;需要验证 GitHub CLI 时显式加 `--gh-contracts`,并以该 profile 的结果判定 GitHub CLI 变更。长命令项必须在 stderr 输出 `unidesk.check.progress` JSON lines,stdout 保持最终 JSON 结果,避免 post-task 或人工运行时长时间无可见进度。`check recovery-guardrails` 是同一诊断的低噪声直接入口,报告 malformed `/proc/mounts`、kubelet validation risk、stale CRI sandbox count、Code Queue worktree/symlink、Code Queue/MDTODO hostPath 和 `ContainerCreating` 分类;它不得重启 k3s、删除 CRI sandbox、修改 hostPath、deploy/rollout 或 prune/reset。`--rust` 只允许在 D601 CI/dev execution 中配合 `UNIDESK_D601_RUST_CHECK=1` 使用,长期规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 +- `check` 默认只执行轻量配置校验、Bun 版本检查和 Bun Transpiler 语法解析(覆盖 CLI 入口、主要 `scripts/` 模块和核心组件入口,不做类型推导);关键文件存在性、`scripts/` TypeScript 类型检查、GitHub CLI contract、`src/components/` TypeScript 类型检查、Docker Compose config、日志轮转策略扫描和 D601 recovery guardrails 默认不启用,分别通过 `--files`、`--scripts-typecheck`、`--gh-contracts`、`--components`、`--compose`、`--logs`、`--recovery-guardrails` 开启,或用 `--full` 一次性开启。`--scripts-typecheck` 只表示 scripts TypeScript 与本地脚本合同,不应触发 GitHub issue/PR live contract 这类可能受网络/API 时延影响的检查;需要验证 GitHub CLI 时显式加 `--gh-contracts`,并以该 profile 的结果判定 GitHub CLI 变更。长命令项必须在 stderr 输出 `unidesk.check.progress` JSON lines,stdout 保持最终 JSON 结果,避免 post-task 或人工运行时长时间无可见进度。`typescript:scripts` 固定通过 `bun --bun tsc -p scripts/tsconfig.json --noEmit --pretty false` 执行,默认 `--scripts-typecheck-timeout-ms 120000`,可按目标运行面显式调小或调大但 CLI 会封顶;`--check-heartbeat-ms` 控制运行中心跳间隔,默认 `15000`。所有命令项的最终 item detail 必须包含 `durationMs`、`timeoutMs`、`heartbeatMs`、`exitCode`、`signal`、`timedOut`、stdout/stderr byte count、truncation flag 和有界 tail;超时必须返回 `timedOut=true`,不得只留下被外层命令杀死的空输出。`check recovery-guardrails` 是同一诊断的低噪声直接入口,报告 malformed `/proc/mounts`、kubelet validation risk、stale CRI sandbox count、Code Queue worktree/symlink、Code Queue/MDTODO hostPath 和 `ContainerCreating` 分类;它不得重启 k3s、删除 CRI sandbox、修改 hostPath、deploy/rollout 或 prune/reset。`--rust` 只允许在 D601 CI/dev execution 中配合 `UNIDESK_D601_RUST_CHECK=1` 使用,长期规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 - `server start` 创建异步 job,在后台执行 Docker 构建和启动;命令本身只负责返回 job id、日志路径和启动命令。 - `server stop` 创建异步 job,在后台停止固定 Compose project 中的全部 UniDesk 服务。 - `server status` 查询公开端口、受限宿主端口、内部端口、主机 swap 摘要、Compose 容器、core/frontend/dev-frontend/provider/database 健康检查和访问 URL;D601 Code Queue 使用的 PostgreSQL/OA Event Flow host mapping 必须出现在受限宿主端口而不是无条件公开入口中。低内存主 server 上 `swap.warning` 非空时,先执行 `server swap status` 或 `server swap ensure`。 diff --git a/scripts/check-command-progress-contract-test.ts b/scripts/check-command-progress-contract-test.ts new file mode 100644 index 00000000..a34df0f7 --- /dev/null +++ b/scripts/check-command-progress-contract-test.ts @@ -0,0 +1,49 @@ +import { strict as assert } from "node:assert"; +import { runCommandObserved, type CommandProgress } from "./src/command"; +import { repoRoot } from "./src/config"; + +const progressEvents: CommandProgress[] = []; +const startedAt = Date.now(); +const result = await runCommandObserved( + [ + "bun", + "--eval", + "setTimeout(() => {}, 10_000)", + ], + repoRoot, + { + timeoutMs: 250, + heartbeatMs: 50, + killAfterMs: 100, + onProgress: (progress) => progressEvents.push(progress), + }, +); + +assert.equal(result.timedOut, true); +assert.notEqual(result.signal, null); +assert.ok(result.durationMs !== undefined && result.durationMs < 2_000, `expected bounded duration, got ${result.durationMs}`); +assert.ok(Date.now() - startedAt < 2_000, "silent child should not block beyond timeout"); +assert.ok(progressEvents.length > 0, "silent child must still emit heartbeat progress"); +assert.ok(progressEvents.some((event) => event.elapsedMs > 0 && event.timeoutMs === 250), "heartbeat must expose elapsed and timeout"); +assert.equal(result.stdoutBytes, 0); +assert.equal(result.stderrBytes, 0); +assert.equal(result.stdoutTruncated, false); +assert.equal(result.stderrTruncated, false); + +const noisy = await runCommandObserved( + [ + "bun", + "--eval", + "console.log('x'.repeat(2048)); console.error('y'.repeat(2048));", + ], + repoRoot, + { timeoutMs: 5_000, maxCaptureChars: 128 }, +); + +assert.equal(noisy.exitCode, 0); +assert.ok(noisy.stdout.length <= 128); +assert.ok(noisy.stderr.length <= 128); +assert.equal(noisy.stdoutTruncated, true); +assert.equal(noisy.stderrTruncated, true); + +console.log("check command progress contract ok"); diff --git a/scripts/check-gh-contract-scope-contract-test.ts b/scripts/check-gh-contract-scope-contract-test.ts index 2ec00b9a..3e221076 100644 --- a/scripts/check-gh-contract-scope-contract-test.ts +++ b/scripts/check-gh-contract-scope-contract-test.ts @@ -4,6 +4,8 @@ import { parseCheckOptions, runChecks } from "./src/check"; const scriptsOnly = parseCheckOptions(["--scripts-typecheck"]); assert.equal(scriptsOnly.scriptsTypecheck, true); assert.equal(scriptsOnly.ghContracts, false); +assert.equal(scriptsOnly.scriptsTypecheckTimeoutMs, 120_000); +assert.equal(scriptsOnly.checkHeartbeatMs, 15_000); const full = parseCheckOptions(["--full"]); assert.equal(full.scriptsTypecheck, true); @@ -13,11 +15,16 @@ const explicit = parseCheckOptions(["--gh-contracts"]); assert.equal(explicit.scriptsTypecheck, false); assert.equal(explicit.ghContracts, true); +const tunedVisibility = parseCheckOptions(["--scripts-typecheck", "--scripts-typecheck-timeout-ms", "1000", "--check-heartbeat-ms", "200"]); +assert.equal(tunedVisibility.scriptsTypecheck, true); +assert.equal(tunedVisibility.scriptsTypecheckTimeoutMs, 1000); +assert.equal(tunedVisibility.checkHeartbeatMs, 200); + const minimalConfig = { project: { name: "contract" }, runtime: "contract", } as never; -const result = runChecks(minimalConfig, { ...scriptsOnly, scriptsTypecheck: false }); +const result = await runChecks(minimalConfig, { ...scriptsOnly, scriptsTypecheck: false }); const issueGuard = result.items.find((item) => item.name === "gh:issue-guard-contract"); const prFiles = result.items.find((item) => item.name === "gh:pr-files-contract"); const pr = result.items.find((item) => item.name === "gh:pr-contract"); diff --git a/scripts/cli.ts b/scripts/cli.ts index d947c2e9..81f75682 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -363,7 +363,7 @@ async function main(): Promise { if (!result.ok) process.exitCode = 1; return; } - const result = runChecks(config, parseCheckOptions(args.slice(1))); + const result = await runChecks(config, parseCheckOptions(args.slice(1))); emitJson(commandName, result, result.ok); if (!result.ok) process.exitCode = 1; return; diff --git a/scripts/src/check.ts b/scripts/src/check.ts index 74f530e4..72adf862 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { extname } from "node:path"; -import { runCommand } from "./command"; +import { runCommandObserved, type CommandProgress } from "./command"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { composeConfig } from "./docker"; import { compactD601RecoveryGuardrails, runD601RecoveryGuardrails } from "./recovery-guardrails"; @@ -19,6 +19,7 @@ const syntaxFiles = [ "scripts/platform-infra-sub2api-codex-routing-contract-test.ts", "scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts", "scripts/platform-infra-sub2api-http-upstream-contract-test.ts", + "scripts/check-command-progress-contract-test.ts", "scripts/check-gh-contract-scope-contract-test.ts", "scripts/src/playwright-cli.ts", "scripts/src/check.ts", @@ -89,8 +90,13 @@ export interface CheckOptions { recoveryGuardrails: boolean; ghContracts: boolean; rust: boolean; + scriptsTypecheckTimeoutMs: number; + checkHeartbeatMs: number; } +const defaultScriptsTypecheckTimeoutMs = 120_000; +const defaultCheckHeartbeatMs = 15_000; + const defaultCheckOptions: CheckOptions = { full: false, files: false, @@ -101,14 +107,24 @@ const defaultCheckOptions: CheckOptions = { recoveryGuardrails: false, ghContracts: 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|--gh-contracts|--components|--compose|--logs|--recovery-guardrails|--rust]", + "bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--gh-contracts|--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", @@ -116,7 +132,9 @@ export function checkHelp(): Record { { name: "--syntax-only|--basic", description: "Run only config validation, Bun version and TypeScript syntax transpile." }, { name: "--full", description: "Enable all non-Rust checks, including explicit GitHub CLI contracts." }, { name: "--files", description: "Verify required entrypoint files, including backend-core Cargo files." }, - { name: "--scripts-typecheck", description: "Run scripts TypeScript typecheck." }, + { 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: "--gh-contracts", description: "Run slower GitHub CLI contract tests; intentionally separate from generic scripts typecheck." }, { name: "--components", description: "Run component TypeScript typecheck." }, { name: "--compose", description: "Render Docker Compose config." }, @@ -138,7 +156,8 @@ export function checkHelp(): Record { export function parseCheckOptions(args: string[]): CheckOptions { const options = { ...defaultCheckOptions }; - for (const arg of args) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; if (arg === "--full") { options.full = true; options.files = true; @@ -148,6 +167,12 @@ export function parseCheckOptions(args: string[]): CheckOptions { options.logs = true; options.recoveryGuardrails = true; options.ghContracts = 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") { @@ -173,6 +198,12 @@ export function parseCheckOptions(args: string[]): CheckOptions { 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 }; @@ -182,13 +213,18 @@ function emitCheckProgress(detail: Record): void { console.error(JSON.stringify({ event: "unidesk.check.progress", ...detail })); } -function commandItem(name: string, command: string[], timeoutMs = 30_000, env: NodeJS.ProcessEnv = process.env): CheckItem { +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 }); - const result = runCommand(command, repoRoot, { timeoutMs, env }); - const durationMs = Date.now() - startedAt; + 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.exitCode === 0 ? "succeeded" : "failed", + phase: result.timedOut ? "timed-out" : result.exitCode === 0 ? "succeeded" : "failed", name, durationMs, exitCode: result.exitCode, @@ -204,12 +240,32 @@ function commandItem(name: string, command: string[], timeoutMs = 30_000, env: N 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[] = []; @@ -307,24 +363,26 @@ function codeQueueMgrHealthcheckItem(): CheckItem { }; } -function rustCheckItem(): CheckItem { +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 { - 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", - }, - }; - } + 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); + 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 { @@ -335,13 +393,13 @@ export function runRecoveryGuardrailsCheck(config: UniDeskConfig): ReturnType { const items: CheckItem[] = [ { name: "config:validated", ok: true, detail: { project: config.project.name, runtime: config.runtime } }, - commandItem("bun:version", ["bun", "--version"]), - syntaxItem(), - codeQueueMgrHealthcheckItem(), ]; + 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"), @@ -405,6 +463,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/deploy-artifact-matrix-contract-test.ts"), fileItem("scripts/decision-center-desired-state-contract-test.ts"), fileItem("scripts/code-queue-prompt-observation-test.ts"), + fileItem("scripts/check-command-progress-contract-test.ts"), fileItem("scripts/check-gh-contract-scope-contract-test.ts"), fileItem("scripts/gh-cli-issue-guard-contract-test.ts"), fileItem("scripts/gh-cli-pr-files-contract-test.ts"), @@ -436,54 +495,56 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("files:required-entrypoints", "required file presence scan is opt-in", "--files or --full")); } if (options.scriptsTypecheck) { - items.push(commandItem("typescript:scripts", ["bunx", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"], 120_000)); - items.push(commandItem("code-queue:prompt-observation-contract", ["bun", "scripts/code-queue-prompt-observation-test.ts"], 30_000)); - items.push(commandItem("code-queue:issue3-diagnostics-and-image-preflight", ["bun", "scripts/code-queue-issue3-regression-test.ts"], 30_000)); - items.push(commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:runner-skills-contract", ["bun", "scripts/code-queue-runner-skills-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:cli-disclosure-contract", ["bun", "scripts/code-queue-cli-disclosure-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:prompt-lint-contract", ["bun", "scripts/code-queue-prompt-lint-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:cli-steer-contract", ["bun", "scripts/code-queue-cli-steer-test.ts"], 30_000)); - items.push(commandItem("code-queue:steer-confirmation-contract", ["bun", "scripts/code-queue-steer-confirmation-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:resume-contract", ["bun", "scripts/code-queue-resume-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:read-terminal-contract", ["bun", "scripts/code-queue-cli-read-terminal-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:submit-prompt-contract", ["bun", "scripts/code-queue-cli-submit-prompt-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:submit-execution-mode-contract", ["bun", "scripts/code-queue-submit-execution-mode-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:submit-summary-contract", ["bun", "scripts/code-queue-submit-summary-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:gh-auth-redaction-contract", ["bun", "scripts/code-queue-gh-auth-redaction-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:queues-shape-contract", ["bun", "scripts/code-queue-queues-shape-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:supervisor-disclosure-contract", ["bun", "scripts/code-queue-supervisor-disclosure-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:commander-view-contract", ["bun", "scripts/code-queue-commander-view-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:postgres-rotation-contract", ["bun", "scripts/code-queue-postgres-rotation-contract-test.ts"], 30_000)); - items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000)); - items.push(commandItem("host-codex-commander:no-daemon-smoke-contract", ["bun", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"], 30_000)); - items.push(commandItem("host-codex-commander:prompt-lint-contract", ["bun", "scripts/host-codex-commander-prompt-lint-contract-test.ts"], 30_000)); - items.push(commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000)); - items.push(commandItem("ssh:argv-guidance-contract", ["bun", "scripts/ssh-argv-guidance-contract-test.ts"], 30_000)); - items.push(commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 90_000)); - items.push(commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000)); - items.push(commandItem("code-queue:active-run-heartbeat-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:active-run-heartbeat-visible"], 30_000)); - items.push(commandItem("code-queue:trace-gap-not-stale", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:trace-gap-not-stale"], 30_000)); - items.push(commandItem("code-queue:stale-active-owner-expired", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:stale-active-owner-expired"], 30_000)); - items.push(commandItem("code-queue:control-plane-split-brain-diagnostics", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:control-plane-split-brain-diagnostics"], 30_000)); - items.push(commandItem("code-queue:oa-publisher-degraded-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:oa-publisher-degraded-visible"], 30_000)); - items.push(commandItem("baidu-netdisk:artifact-guard-contract", ["bun", "scripts/baidu-netdisk-artifact-guard-contract-test.ts"], 30_000)); - items.push(commandItem("artifact-registry:direct-compose-dry-run-matrix", ["bun", "scripts/artifact-consumer-dry-run-matrix-test.ts"], 30_000)); - items.push(commandItem("schedule:cli-contract", ["bun", "scripts/schedule-cli-contract-test.ts"], 30_000)); - items.push(commandItem("server:cleanup-plan-contract", ["bun", "scripts/server-cleanup-plan-contract-test.ts"], 30_000)); - items.push(commandItem("check:gh-contract-scope-contract", ["bun", "scripts/check-gh-contract-scope-contract-test.ts"], 30_000)); - items.push(commandItem("playwright:cli-wrapper-contract", ["bun", "scripts/playwright-cli-contract-test.ts"], 30_000)); - items.push(commandItem("platform-infra:sub2api-codex-local-config-contract", ["bun", "scripts/platform-infra-sub2api-codex-local-config-contract-test.ts"], 30_000)); - items.push(commandItem("platform-infra:sub2api-codex-routing-contract", ["bun", "scripts/platform-infra-sub2api-codex-routing-contract-test.ts"], 30_000)); - items.push(commandItem("platform-infra:sub2api-codex-temp-unsched-contract", ["bun", "scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts"], 30_000)); - items.push(commandItem("platform-infra:sub2api-http-upstream-contract", ["bun", "scripts/platform-infra-sub2api-http-upstream-contract-test.ts"], 30_000)); - items.push(commandItem("auth-broker:p0-contract", ["bun", "scripts/auth-broker-contract-test.ts"], 30_000)); - items.push(commandItem("d601:recovery-guardrails-contract", ["bun", "scripts/d601-recovery-guardrails-contract-test.ts"], 30_000)); - items.push(commandItem("hwlab:cd-wrapper-contract", ["bun", "scripts/hwlab-cd-wrapper-contract-test.ts"], 30_000)); + items.push(await commandItem("typescript:scripts", ["bun", "--bun", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"], options.scriptsTypecheckTimeoutMs, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("check:command-progress-contract", ["bun", "scripts/check-command-progress-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:prompt-observation-contract", ["bun", "scripts/code-queue-prompt-observation-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:issue3-diagnostics-and-image-preflight", ["bun", "scripts/code-queue-issue3-regression-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:runner-skills-contract", ["bun", "scripts/code-queue-runner-skills-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:cli-disclosure-contract", ["bun", "scripts/code-queue-cli-disclosure-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:prompt-lint-contract", ["bun", "scripts/code-queue-prompt-lint-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:cli-steer-contract", ["bun", "scripts/code-queue-cli-steer-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:steer-confirmation-contract", ["bun", "scripts/code-queue-steer-confirmation-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:resume-contract", ["bun", "scripts/code-queue-resume-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:read-terminal-contract", ["bun", "scripts/code-queue-cli-read-terminal-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:submit-prompt-contract", ["bun", "scripts/code-queue-cli-submit-prompt-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:submit-execution-mode-contract", ["bun", "scripts/code-queue-submit-execution-mode-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:submit-summary-contract", ["bun", "scripts/code-queue-submit-summary-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:gh-auth-redaction-contract", ["bun", "scripts/code-queue-gh-auth-redaction-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:queues-shape-contract", ["bun", "scripts/code-queue-queues-shape-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:supervisor-disclosure-contract", ["bun", "scripts/code-queue-supervisor-disclosure-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:commander-view-contract", ["bun", "scripts/code-queue-commander-view-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:postgres-rotation-contract", ["bun", "scripts/code-queue-postgres-rotation-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("host-codex-commander:no-daemon-smoke-contract", ["bun", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("host-codex-commander:prompt-lint-contract", ["bun", "scripts/host-codex-commander-prompt-lint-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("ssh:argv-guidance-contract", ["bun", "scripts/ssh-argv-guidance-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 90_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:active-run-heartbeat-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:active-run-heartbeat-visible"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:trace-gap-not-stale", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:trace-gap-not-stale"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:stale-active-owner-expired", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:stale-active-owner-expired"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:control-plane-split-brain-diagnostics", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:control-plane-split-brain-diagnostics"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("code-queue:oa-publisher-degraded-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:oa-publisher-degraded-visible"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("baidu-netdisk:artifact-guard-contract", ["bun", "scripts/baidu-netdisk-artifact-guard-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("artifact-registry:direct-compose-dry-run-matrix", ["bun", "scripts/artifact-consumer-dry-run-matrix-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("schedule:cli-contract", ["bun", "scripts/schedule-cli-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("server:cleanup-plan-contract", ["bun", "scripts/server-cleanup-plan-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("check:gh-contract-scope-contract", ["bun", "scripts/check-gh-contract-scope-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("playwright:cli-wrapper-contract", ["bun", "scripts/playwright-cli-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("platform-infra:sub2api-codex-local-config-contract", ["bun", "scripts/platform-infra-sub2api-codex-local-config-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("platform-infra:sub2api-codex-routing-contract", ["bun", "scripts/platform-infra-sub2api-codex-routing-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("platform-infra:sub2api-codex-temp-unsched-contract", ["bun", "scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("platform-infra:sub2api-http-upstream-contract", ["bun", "scripts/platform-infra-sub2api-http-upstream-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("auth-broker:p0-contract", ["bun", "scripts/auth-broker-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("d601:recovery-guardrails-contract", ["bun", "scripts/d601-recovery-guardrails-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("hwlab:cd-wrapper-contract", ["bun", "scripts/hwlab-cd-wrapper-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); } else { items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full")); + items.push(skippedItem("check:command-progress-contract", "observed command progress contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:prompt-observation-contract", "prompt observation contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:issue3-diagnostics-and-image-preflight", "Code Queue issue #3 regression fixtures are opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:trace-summary-contract", "Code Queue trace summary contract is opt-in with script checks", "--scripts-typecheck or --full")); @@ -528,9 +589,9 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("logs:unified-hourly-rotation", "policy scan is opt-in", "--logs or --full")); } if (options.ghContracts) { - items.push(commandItem("gh:issue-guard-contract", ["bun", "scripts/gh-cli-issue-guard-contract-test.ts"], 30_000)); - items.push(commandItem("gh:pr-files-contract", ["bun", "scripts/gh-cli-pr-files-contract-test.ts"], 30_000)); - items.push(commandItem("gh:pr-contract", ["bun", "scripts/gh-cli-pr-contract-test.ts"], 30_000)); + items.push(await commandItem("gh:issue-guard-contract", ["bun", "scripts/gh-cli-issue-guard-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("gh:pr-files-contract", ["bun", "scripts/gh-cli-pr-files-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("gh:pr-contract", ["bun", "scripts/gh-cli-pr-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); } else { items.push(skippedItem("gh:issue-guard-contract", "GitHub issue CLI contract is opt-in because it can be slower than generic scripts typecheck", "--gh-contracts or --full")); items.push(skippedItem("gh:pr-files-contract", "GitHub PR files/stat contract is opt-in because it can be slower than generic scripts typecheck", "--gh-contracts or --full")); @@ -547,7 +608,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default 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(commandItem("typescript:components", ["bunx", "tsc", "-p", "src/tsconfig.check.json", "--pretty", "false"], 180_000)); + 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")); } @@ -570,7 +631,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("docker-compose:config", "Docker Compose config rendering is opt-in", "--compose or --full")); } if (options.rust) { - items.push(rustCheckItem()); + 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")); } diff --git a/scripts/src/command.ts b/scripts/src/command.ts index dd052d78..2a3cd72f 100644 --- a/scripts/src/command.ts +++ b/scripts/src/command.ts @@ -1,4 +1,4 @@ -import { spawn, spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import { closeSync, createWriteStream, existsSync, openSync, readSync, statSync } from "node:fs"; export interface CommandResult { @@ -9,6 +9,30 @@ export interface CommandResult { stderr: string; signal: NodeJS.Signals | null; timedOut: boolean; + durationMs?: number; + stdoutBytes?: number; + stderrBytes?: number; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; +} + +export interface CommandProgress { + elapsedMs: number; + timeoutMs: number | null; + pid: number | null; + stdoutBytes: number; + stderrBytes: number; + lastOutputAgeMs: number | null; +} + +interface ObservedCommandOptions { + timeoutMs?: number; + heartbeatMs?: number; + killAfterMs?: number; + env?: NodeJS.ProcessEnv; + input?: string; + maxCaptureChars?: number; + onProgress?: (progress: CommandProgress) => void; } export function runCommand(command: string[], cwd: string, options: { timeoutMs?: number; env?: NodeJS.ProcessEnv; input?: string } = {}): CommandResult { @@ -33,6 +57,129 @@ export function runCommand(command: string[], cwd: string, options: { timeoutMs? }; } +export async function runCommandObserved(command: string[], cwd: string, options: ObservedCommandOptions = {}): Promise { + const startedAt = Date.now(); + const timeoutMs = options.timeoutMs; + const heartbeatMs = Math.max(0, options.heartbeatMs ?? 0); + const maxCaptureChars = Math.max(1, options.maxCaptureChars ?? 1024 * 1024); + let stdout = ""; + let stderr = ""; + let stdoutBytes = 0; + let stderrBytes = 0; + let stdoutTruncated = false; + let stderrTruncated = false; + let lastOutputAt: number | null = null; + let timedOut = false; + let timeoutTimer: NodeJS.Timeout | undefined; + let killTimer: NodeJS.Timeout | undefined; + let heartbeatTimer: NodeJS.Timeout | undefined; + + const child = spawn(command[0], command.slice(1), { + cwd, + env: options.env, + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk: Buffer | string) => { + const text = chunk.toString(); + stdoutBytes += Buffer.byteLength(text); + lastOutputAt = Date.now(); + const captured = appendBounded(stdout, text, maxCaptureChars); + stdout = captured.text; + stdoutTruncated = stdoutTruncated || captured.truncated; + }); + child.stderr.on("data", (chunk: Buffer | string) => { + const text = chunk.toString(); + stderrBytes += Buffer.byteLength(text); + lastOutputAt = Date.now(); + const captured = appendBounded(stderr, text, maxCaptureChars); + stderr = captured.text; + stderrTruncated = stderrTruncated || captured.truncated; + }); + + child.stdin.end(options.input ?? ""); + + if (heartbeatMs > 0) { + heartbeatTimer = setInterval(() => { + options.onProgress?.({ + elapsedMs: Date.now() - startedAt, + timeoutMs: timeoutMs ?? null, + pid: child.pid ?? null, + stdoutBytes, + stderrBytes, + lastOutputAgeMs: lastOutputAt === null ? null : Date.now() - lastOutputAt, + }); + }, heartbeatMs); + } + + if (timeoutMs !== undefined) { + timeoutTimer = setTimeout(() => { + timedOut = true; + killProcessTree(child, "SIGTERM"); + killTimer = setTimeout(() => { + killProcessTree(child, "SIGKILL"); + }, Math.max(100, options.killAfterMs ?? 5_000)); + }, timeoutMs); + } + + const completion = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null; error?: Error }>((resolve) => { + let resolved = false; + const finish = (result: { exitCode: number | null; signal: NodeJS.Signals | null; error?: Error }) => { + if (resolved) return; + resolved = true; + resolve(result); + }; + child.on("error", (error) => finish({ exitCode: 127, signal: null, error })); + child.on("close", (code, signal) => finish({ exitCode: code, signal })); + }); + + if (timeoutTimer !== undefined) clearTimeout(timeoutTimer); + if (killTimer !== undefined) clearTimeout(killTimer); + if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer); + + if (completion.error !== undefined && stderr.length === 0) { + stderr = completion.error.message; + stderrBytes = Buffer.byteLength(stderr); + } + + return { + command, + cwd, + exitCode: completion.exitCode, + stdout, + stderr, + signal: completion.signal, + timedOut, + durationMs: Date.now() - startedAt, + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated, + }; +} + +function appendBounded(current: string, next: string, maxChars: number): { text: string; truncated: boolean } { + const combined = `${current}${next}`; + if (combined.length <= maxChars) return { text: combined, truncated: false }; + return { text: combined.slice(-maxChars), truncated: true }; +} + +function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean { + if (child.pid === undefined) return false; + try { + if (process.platform === "win32") return child.kill(signal); + process.kill(-child.pid, signal); + return true; + } catch { + try { + return child.kill(signal); + } catch { + return false; + } + } +} + export function commandOk(command: string[], cwd: string): boolean { return runCommand(command, cwd).exitCode === 0; } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 12a5b995..f8ff3efc 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -14,7 +14,7 @@ export function rootHelp(): unknown { { command: "help", description: "List supported commands." }, { command: "--main-server-ip ", description: "Run selected commands through the public frontend API; use --main-server-key only for legacy SSH transport." }, { command: "config show", description: "Validate and print config.json as the single source of truth." }, - { command: "check [--full|--files|--scripts-typecheck|--components|--compose|--logs|--recovery-guardrails|--rust] | check recovery-guardrails", description: "Run the lightweight default syntax/config gate or the low-noise read-only D601 recovery guardrails; check --rust stays D601-guarded, while backend-core main-server online uses the separate constrained rebuild path." }, + { command: "check [--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--components|--compose|--logs|--recovery-guardrails|--rust] | check recovery-guardrails", description: "Run the lightweight default syntax/config gate or the low-noise read-only D601 recovery guardrails; long command checks emit progress heartbeats and bounded timeout details." }, { command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, provider gateway, and managed main-server user services." }, { command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." }, { command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },