Merge pull request #256 from pikasTech/fix/check-scripts-typecheck-visibility

fix: 让 scripts typecheck 长命令持续可见
This commit is contained in:
Lyon
2026-06-11 00:45:36 +08:00
committed by GitHub
7 changed files with 348 additions and 84 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动
- 每个 CLI 命名空间必须支持 `help``--help``-h` 并返回 JSON,不得为了打印帮助而访问 runtime 服务、拉起交互会话或执行长时任务。
- `--main-server-ip <ip> <command>` 默认通过公网 frontend 登录态调用主 server 的同源 API 代理,不要求计算节点持有主 server SSH key;显式提供 `--main-server-key``--main-server-transport ssh` 时才使用旧 SSH 传输。远程 frontend 传输下的 `ssh <route> ...` 必须复用同一套结构化 route parser,支持 `D601``G14`、host workspace、`D601:win``D601:win/c/test``D601:k3s``D601:k3s:<namespace>:<workload>` 这类定位路径;它不向调用容器下发 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 linesstdout 保持最终 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 linesstdout 保持最终 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 健康检查和访问 URLD601 Code Queue 使用的 PostgreSQL/OA Event Flow host mapping 必须出现在受限宿主端口而不是无条件公开入口中。低内存主 server 上 `swap.warning` 非空时,先执行 `server swap status``server swap ensure`
@@ -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");
@@ -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");
+1 -1
View File
@@ -363,7 +363,7 @@ async function main(): Promise<void> {
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;
+140 -79
View File
@@ -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<string, unknown> {
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<string, unknown> {
{ 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<string, unknown> {
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<string, unknown>): 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<CheckItem> {
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<CheckItem> {
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<ty
return compactD601RecoveryGuardrails(runD601RecoveryGuardrails(config));
}
export function runChecks(config: UniDeskConfig, options: CheckOptions = defaultCheckOptions): { ok: boolean; mode: string; options: CheckOptions; summary: { total: number; passed: number; failed: number; failedItems: string[] }; items: CheckItem[] } {
export async function runChecks(config: UniDeskConfig, options: CheckOptions = defaultCheckOptions): Promise<CheckRunResult> {
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"));
}
+148 -1
View File
@@ -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<CommandResult> {
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;
}
+1 -1
View File
@@ -14,7 +14,7 @@ export function rootHelp(): unknown {
{ command: "help", description: "List supported commands." },
{ command: "--main-server-ip <ip> <command>", 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." },