fix: add visible scripts typecheck progress

This commit is contained in:
Codex
2026-06-10 16:43:02 +00:00
parent 20dda46e03
commit e2c41a893e
7 changed files with 348 additions and 84 deletions
+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." },