fix: add visible scripts typecheck progress
This commit is contained in:
+140
-79
@@ -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
@@ -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
@@ -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." },
|
||||
|
||||
Reference in New Issue
Block a user