Initial public-ready standalone repository
This commit is contained in:
commit
d002386bc2
66 files changed
+28683
No files matched your search
@@ -0,0 +1,651 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
interface DoDCheckResult {
|
||||
id: string;
|
||||
passed: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
type DoDStatus = "DONE" | "NOT_DONE";
|
||||
|
||||
interface DoDStatusArtifact {
|
||||
generatedAt: string;
|
||||
runtimeDir: string;
|
||||
status: DoDStatus;
|
||||
passed: boolean;
|
||||
checks: DoDCheckResult[];
|
||||
}
|
||||
|
||||
interface WorkerProgress {
|
||||
cycle?: number;
|
||||
lastStep?: {
|
||||
id?: string;
|
||||
exitCode?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProductDoDDefinition {
|
||||
fullChinese: boolean;
|
||||
nonTechnicalCopy: boolean;
|
||||
middleSchoolReadable: boolean;
|
||||
globalVisibility: boolean;
|
||||
appleNativeUI?: boolean;
|
||||
realSubscriptionConnected?: boolean;
|
||||
tiApproval?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface ProductSourceAudit {
|
||||
fullChinese: boolean;
|
||||
nonTechnicalCopy: boolean;
|
||||
middleSchoolReadable: boolean;
|
||||
globalVisibility: boolean;
|
||||
appleNativeUI: boolean;
|
||||
details: {
|
||||
fullChinese: string;
|
||||
nonTechnicalCopy: string;
|
||||
middleSchoolReadable: string;
|
||||
globalVisibility: string;
|
||||
appleNativeUI: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RealSubscriptionAudit {
|
||||
connected: boolean;
|
||||
detail: string;
|
||||
sourcePath?: string;
|
||||
}
|
||||
|
||||
interface PhraseGroup {
|
||||
id: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
const REQUIRED_ZH_GROUPS: PhraseGroup[] = [
|
||||
{ id: "title", options: ["全局可视", "全局总览"] },
|
||||
{
|
||||
id: "summary",
|
||||
options: [
|
||||
"一眼看全局:定时任务、任务心跳、当前任务和工具调用。",
|
||||
"一眼看全局:定时任务、任务心跳、当前任务、工具调用。",
|
||||
"一眼看四件事:定时任务、任务心跳、当前任务、工具调用。",
|
||||
],
|
||||
},
|
||||
{ id: "schedule", options: ["定时任务:"] },
|
||||
{ id: "heartbeat", options: ["任务心跳:", "心跳检查:"] },
|
||||
{ id: "currentTasks", options: ["当前任务:", "进行中任务:"] },
|
||||
{ id: "toolCalls", options: ["工具调用:"] },
|
||||
];
|
||||
|
||||
const REQUIRED_EN_GROUPS: PhraseGroup[] = [
|
||||
{ id: "title", options: ["Global Visibility"] },
|
||||
{
|
||||
id: "summary",
|
||||
options: [
|
||||
"One place to see cron, heartbeat, current tasks, and tool calls.",
|
||||
"One place to see timed jobs, heartbeat, current tasks, and tool calls.",
|
||||
"At a glance: timed jobs, heartbeat, current tasks, and tool calls.",
|
||||
"Simple view: schedule checks (cron), heartbeat checks, current tasks, and tool calls.",
|
||||
"Simple view: schedule (cron), heartbeat, tasks in progress, and tool calls.",
|
||||
],
|
||||
},
|
||||
{ id: "schedule", options: ["Cron:", "Timed jobs:", "Schedule checks (cron):", "Schedule (cron):"] },
|
||||
{ id: "heartbeat", options: ["Heartbeat:", "Heartbeat checks:"] },
|
||||
{ id: "currentTasks", options: ["Current tasks:", "Tasks in progress:"] },
|
||||
{ id: "toolCalls", options: ["Tool calls:"] },
|
||||
];
|
||||
|
||||
const GLOBAL_VISIBILITY_RENDER_TOKENS = [
|
||||
"renderGlobalVisibilityStrip(",
|
||||
"renderGlobalVisibilityCard(",
|
||||
"renderGlobalVisibilityStripCard(",
|
||||
'<div class="content-stack">${globalVisibilityBlock}${sectionBody}</div>',
|
||||
"copy.scheduleLabel",
|
||||
"copy.heartbeatLabel",
|
||||
"copy.currentTasksLabel",
|
||||
"copy.toolCallsLabel",
|
||||
'buildGlobalVisibilityDetailHref("cron", language)',
|
||||
'buildGlobalVisibilityDetailHref("heartbeat", language)',
|
||||
'buildGlobalVisibilityDetailHref("current_task", language)',
|
||||
'buildGlobalVisibilityDetailHref("tool_call", language)',
|
||||
'taskType: "cron"',
|
||||
'taskType: "heartbeat"',
|
||||
'taskType: "current_task"',
|
||||
'taskType: "tool_call"',
|
||||
"signalCounts: {",
|
||||
"schedule: enabledCronCount",
|
||||
"heartbeat: enabledHeartbeatCount",
|
||||
"currentTasks: currentTasksCount",
|
||||
"toolCalls: toolCallsCount",
|
||||
];
|
||||
const GLOBAL_VISIBILITY_RENDER_GROUPS: PhraseGroup[] = [
|
||||
{
|
||||
id: "overview-block",
|
||||
options: [
|
||||
'const globalVisibilityBlock = options.section === "overview" ? globalVisibilityCard : globalVisibilityStripCard;',
|
||||
'const globalVisibilityBlock = options.section === "overview" ? globalVisibilityCard : "";',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const NON_TECH_JARGON = ["telemetry", "instrumentation", "payload", "schema", "protocol", "SDK"];
|
||||
const MIXED_ZH_PHRASES = [
|
||||
"Cron 正在运行。",
|
||||
"还没有设置 Cron。",
|
||||
"保持 Cron 开启。",
|
||||
"定时任务(cron)",
|
||||
"定时检查(cron)",
|
||||
"定时安排(cron)",
|
||||
];
|
||||
const APPLE_NATIVE_UI_RENDER_TOKENS = [
|
||||
"data-apple-window-controls=\"true\"",
|
||||
"--apple-glass-blur",
|
||||
"-webkit-backdrop-filter",
|
||||
"@media (prefers-reduced-motion: reduce)",
|
||||
];
|
||||
const APPLE_NATIVE_UI_RENDER_GROUPS: PhraseGroup[] = [
|
||||
{
|
||||
id: "polish-marker",
|
||||
options: [
|
||||
'data-ui-polish="apple-native-v1"',
|
||||
'data-ui-polish="apple-native-v2"',
|
||||
'data-ui-polish="apple-native-v3"',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "hover-elevation",
|
||||
options: [
|
||||
".card:hover { transform: translateY(-1px);",
|
||||
".card:hover {",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
async function runNodeScript(
|
||||
scriptPath: string,
|
||||
args: string[],
|
||||
): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return await new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", rejectPromise);
|
||||
child.on("close", (code) => resolvePromise({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function readProgress(path: string): Promise<WorkerProgress | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as WorkerProgress;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readProductDoD(path: string): Promise<ProductDoDDefinition | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as ProductDoDDefinition;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findMissingPhrases(input: string, phrases: string[]): string[] {
|
||||
return phrases.filter((phrase) => !input.includes(phrase));
|
||||
}
|
||||
|
||||
function findMissingGroups(input: string, groups: PhraseGroup[]): string[] {
|
||||
return groups
|
||||
.filter((group) => group.options.every((phrase) => !input.includes(phrase)))
|
||||
.map((group) => `${group.id}(${group.options.join(" | ")})`);
|
||||
}
|
||||
|
||||
async function auditProductSource(uiServerPath: string): Promise<ProductSourceAudit> {
|
||||
let source = "";
|
||||
try {
|
||||
source = await readFile(uiServerPath, "utf8");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
fullChinese: false,
|
||||
nonTechnicalCopy: false,
|
||||
middleSchoolReadable: false,
|
||||
globalVisibility: false,
|
||||
appleNativeUI: false,
|
||||
details: {
|
||||
fullChinese: `unable to read ${uiServerPath}: ${detail}`,
|
||||
nonTechnicalCopy: `unable to read ${uiServerPath}: ${detail}`,
|
||||
middleSchoolReadable: `unable to read ${uiServerPath}: ${detail}`,
|
||||
globalVisibility: `unable to read ${uiServerPath}: ${detail}`,
|
||||
appleNativeUI: `unable to read ${uiServerPath}: ${detail}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const copyBlock = source.match(
|
||||
/function globalVisibilityCopy[\s\S]*?function formatExecutorAgentLabel/,
|
||||
)?.[0];
|
||||
const modelBlock = source.match(
|
||||
/async function buildGlobalVisibilityViewModel[\s\S]*?function dashboardSectionLinks/,
|
||||
)?.[0];
|
||||
const globalVisibilityBlock =
|
||||
[copyBlock, modelBlock]
|
||||
.filter((segment): segment is string => typeof segment === "string" && segment.length > 0)
|
||||
.join("\n\n")
|
||||
.trim() || source;
|
||||
const missingZh = findMissingGroups(globalVisibilityBlock, REQUIRED_ZH_GROUPS);
|
||||
const missingEn = findMissingGroups(globalVisibilityBlock, REQUIRED_EN_GROUPS);
|
||||
const missingRender = findMissingPhrases(source, GLOBAL_VISIBILITY_RENDER_TOKENS);
|
||||
const missingRenderGroups = findMissingGroups(source, GLOBAL_VISIBILITY_RENDER_GROUPS);
|
||||
const missingAppleNativeTokens = findMissingPhrases(source, APPLE_NATIVE_UI_RENDER_TOKENS);
|
||||
const missingAppleNativeGroups = findMissingGroups(source, APPLE_NATIVE_UI_RENDER_GROUPS);
|
||||
const matchedMixedZh = MIXED_ZH_PHRASES.filter((phrase) => globalVisibilityBlock.includes(phrase));
|
||||
|
||||
const matchedJargon = NON_TECH_JARGON.filter((token) =>
|
||||
globalVisibilityBlock.toLowerCase().includes(token.toLowerCase()),
|
||||
);
|
||||
|
||||
const readabilitySamples = [
|
||||
...REQUIRED_EN_GROUPS.flatMap((group) => group.options),
|
||||
"Tasks checked:",
|
||||
"Tasks selected:",
|
||||
"Tasks started:",
|
||||
"Recent tool calls:",
|
||||
"Tool calls in recent activity:",
|
||||
];
|
||||
const longPhrases = readabilitySamples.filter((phrase) => phrase.length > 90);
|
||||
const hardWords = readabilitySamples
|
||||
.flatMap((phrase) => phrase.split(/[^A-Za-z]+/))
|
||||
.filter((word) => word.length >= 14);
|
||||
|
||||
return {
|
||||
fullChinese: missingZh.length === 0 && matchedMixedZh.length === 0,
|
||||
nonTechnicalCopy: missingEn.length === 0 && matchedJargon.length === 0,
|
||||
middleSchoolReadable: longPhrases.length === 0 && hardWords.length === 0,
|
||||
globalVisibility:
|
||||
missingRender.length === 0 &&
|
||||
missingRenderGroups.length === 0 &&
|
||||
missingEn.length === 0 &&
|
||||
missingZh.length === 0,
|
||||
appleNativeUI: missingAppleNativeTokens.length === 0 && missingAppleNativeGroups.length === 0,
|
||||
details: {
|
||||
fullChinese:
|
||||
missingZh.length === 0 && matchedMixedZh.length === 0
|
||||
? "ui source includes required zh copy for global visibility"
|
||||
: `missing zh copy: ${missingZh.join(", ") || "none"} mixed zh/en copy: ${matchedMixedZh.join(", ") || "none"}`,
|
||||
nonTechnicalCopy:
|
||||
missingEn.length === 0 && matchedJargon.length === 0
|
||||
? "global visibility copy remains plain-language and operator-facing"
|
||||
: `missing en plain copy=${missingEn.join(", ") || "none"} jargon=${matchedJargon.join(", ") || "none"}`,
|
||||
middleSchoolReadable:
|
||||
longPhrases.length === 0 && hardWords.length === 0
|
||||
? "global visibility copy length/wording stays middle-school readable"
|
||||
: `long phrases=${longPhrases.join(" | ") || "none"} hard words=${hardWords.join(", ") || "none"}`,
|
||||
globalVisibility:
|
||||
missingRender.length === 0 && missingRenderGroups.length === 0
|
||||
? "strip + card render all four signals (cron/heartbeat/current tasks/tool calls) across sections"
|
||||
: `missing render tokens: ${[...missingRender, ...missingRenderGroups].join(", ")}`,
|
||||
appleNativeUI:
|
||||
missingAppleNativeTokens.length === 0 && missingAppleNativeGroups.length === 0
|
||||
? "apple-native polish markers present (glass surfaces, window controls, reduced-motion safety)"
|
||||
: `missing apple-native tokens: ${[...missingAppleNativeTokens, ...missingAppleNativeGroups].join(", ")}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function subscriptionSnapshotPaths(runtimeDir: string): string[] {
|
||||
const openclawHome = process.env.OPENCLAW_HOME?.trim() || join(homedir(), ".openclaw");
|
||||
const paths = [
|
||||
process.env.OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH?.trim(),
|
||||
resolve(runtimeDir, "subscription-snapshot.json"),
|
||||
join(openclawHome, "subscription.json"),
|
||||
join(openclawHome, "subscription-snapshot.json"),
|
||||
join(openclawHome, "billing", "subscription.json"),
|
||||
join(openclawHome, "billing", "subscription-snapshot.json"),
|
||||
join(openclawHome, "billing", "usage.json"),
|
||||
join(openclawHome, "usage", "subscription.json"),
|
||||
join(openclawHome, "usage", "subscription-snapshot.json"),
|
||||
].filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
function asObjectValue(input: unknown): Record<string, unknown> | undefined {
|
||||
return input !== null && typeof input === "object" && !Array.isArray(input)
|
||||
? (input as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asStringValue(input: unknown): string | undefined {
|
||||
return typeof input === "string" ? input : undefined;
|
||||
}
|
||||
|
||||
function pickFirstNonNegative(values: unknown[]): number | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseSubscriptionSnapshotSignal(raw: unknown): {
|
||||
consumed?: number;
|
||||
remaining?: number;
|
||||
limit?: number;
|
||||
unit: string;
|
||||
planLabel: string;
|
||||
} {
|
||||
const root = asObjectValue(raw) ?? {};
|
||||
const subscription = asObjectValue(root.subscription) ?? {};
|
||||
const usage = asObjectValue(root.usage) ?? asObjectValue(subscription.usage) ?? {};
|
||||
const rootCost = asObjectValue(root.cost) ?? {};
|
||||
const subscriptionCost = asObjectValue(subscription.cost) ?? {};
|
||||
const plan = asObjectValue(root.plan) ?? asObjectValue(subscription.plan) ?? {};
|
||||
|
||||
const consumed = pickFirstNonNegative([
|
||||
root.consumed,
|
||||
root.used,
|
||||
root.spent,
|
||||
subscription.consumed,
|
||||
subscription.used,
|
||||
subscription.spent,
|
||||
usage.consumed,
|
||||
usage.used,
|
||||
usage.spent,
|
||||
rootCost.used,
|
||||
rootCost.spent,
|
||||
subscriptionCost.used,
|
||||
subscriptionCost.spent,
|
||||
]);
|
||||
const limit = pickFirstNonNegative([
|
||||
root.limit,
|
||||
root.total,
|
||||
root.quota,
|
||||
root.cap,
|
||||
subscription.limit,
|
||||
subscription.total,
|
||||
subscription.quota,
|
||||
subscription.cap,
|
||||
usage.limit,
|
||||
usage.total,
|
||||
usage.quota,
|
||||
rootCost.limit,
|
||||
subscriptionCost.limit,
|
||||
]);
|
||||
const remaining = pickFirstNonNegative([
|
||||
root.remaining,
|
||||
root.left,
|
||||
subscription.remaining,
|
||||
subscription.left,
|
||||
usage.remaining,
|
||||
usage.left,
|
||||
typeof limit === "number" && typeof consumed === "number" ? Math.max(0, limit - consumed) : undefined,
|
||||
]);
|
||||
|
||||
const unit =
|
||||
asStringValue(root.unit) ??
|
||||
asStringValue(subscription.unit) ??
|
||||
asStringValue(usage.unit) ??
|
||||
asStringValue(root.currency) ??
|
||||
asStringValue(subscription.currency) ??
|
||||
asStringValue(usage.currency) ??
|
||||
"USD";
|
||||
const planLabel =
|
||||
asStringValue(plan.name) ??
|
||||
asStringValue(root.planLabel) ??
|
||||
asStringValue(root.planName) ??
|
||||
asStringValue(root.plan) ??
|
||||
asStringValue(root.tier) ??
|
||||
asStringValue(subscription.planLabel) ??
|
||||
asStringValue(subscription.planName) ??
|
||||
asStringValue(subscription.plan) ??
|
||||
asStringValue(subscription.tier) ??
|
||||
"Subscription";
|
||||
|
||||
return {
|
||||
consumed,
|
||||
remaining,
|
||||
limit,
|
||||
unit,
|
||||
planLabel,
|
||||
};
|
||||
}
|
||||
|
||||
async function auditRealSubscriptionSource(runtimeDir: string): Promise<RealSubscriptionAudit> {
|
||||
const paths = subscriptionSnapshotPaths(runtimeDir);
|
||||
const partial: string[] = [];
|
||||
const unreadable: string[] = [];
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(path, "utf8")) as unknown;
|
||||
const parsed = parseSubscriptionSnapshotSignal(raw);
|
||||
const hasAllFields =
|
||||
typeof parsed.consumed === "number" && typeof parsed.remaining === "number" && typeof parsed.limit === "number";
|
||||
if (hasAllFields) {
|
||||
return {
|
||||
connected: true,
|
||||
sourcePath: path,
|
||||
detail: `provider snapshot connected at ${path} (${parsed.planLabel}; ${parsed.unit})`,
|
||||
};
|
||||
}
|
||||
const missingFields = [
|
||||
typeof parsed.consumed === "number" ? "" : "consumed",
|
||||
typeof parsed.remaining === "number" ? "" : "remaining",
|
||||
typeof parsed.limit === "number" ? "" : "limit",
|
||||
].filter((item) => item !== "");
|
||||
partial.push(`${path} missing ${missingFields.join("/") || "required fields"}`);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error && typeof (error as { code?: unknown }).code === "string"
|
||||
? (error as { code: string }).code
|
||||
: undefined;
|
||||
if (code === "ENOENT") {
|
||||
missing.push(path);
|
||||
} else {
|
||||
unreadable.push(`${path}${code ? ` (${code})` : ""}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (partial.length > 0) {
|
||||
return {
|
||||
connected: false,
|
||||
detail: `provider snapshot found but incomplete: ${partial.join("; ")}`,
|
||||
};
|
||||
}
|
||||
if (unreadable.length > 0) {
|
||||
return {
|
||||
connected: false,
|
||||
detail: `provider snapshot unreadable: ${unreadable.join("; ")}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
connected: false,
|
||||
detail: missing.length > 0 ? `provider snapshot missing at: ${missing.join(", ")}` : "provider snapshot paths not configured",
|
||||
};
|
||||
}
|
||||
|
||||
function toProductChecks(
|
||||
product: ProductDoDDefinition | null,
|
||||
sourceAudit: ProductSourceAudit,
|
||||
subscriptionAudit: RealSubscriptionAudit,
|
||||
requireTiApproval: boolean,
|
||||
): DoDCheckResult[] {
|
||||
const declared = product ?? null;
|
||||
const checks: DoDCheckResult[] = [
|
||||
{
|
||||
id: "product-dod-definition",
|
||||
passed: declared !== null,
|
||||
detail: declared ? "runtime/evidence/product-dod.json present" : "missing runtime/evidence/product-dod.json",
|
||||
},
|
||||
];
|
||||
|
||||
const declaredFullChinese = declared?.fullChinese === true;
|
||||
checks.push({
|
||||
id: "product-full-chinese",
|
||||
passed: declaredFullChinese && sourceAudit.fullChinese,
|
||||
detail: `声明=${declaredFullChinese ? "通过" : "未通过"};源码=${sourceAudit.fullChinese ? "通过" : "未通过"}(${sourceAudit.details.fullChinese})`,
|
||||
});
|
||||
|
||||
const declaredNonTechnical = declared?.nonTechnicalCopy === true;
|
||||
checks.push({
|
||||
id: "product-non-technical-copy",
|
||||
passed: declaredNonTechnical && sourceAudit.nonTechnicalCopy,
|
||||
detail: `声明=${declaredNonTechnical ? "通过" : "未通过"};源码=${sourceAudit.nonTechnicalCopy ? "通过" : "未通过"}(${sourceAudit.details.nonTechnicalCopy})`,
|
||||
});
|
||||
|
||||
const declaredReadable = declared?.middleSchoolReadable === true;
|
||||
checks.push({
|
||||
id: "product-middle-school-readable",
|
||||
passed: declaredReadable && sourceAudit.middleSchoolReadable,
|
||||
detail: `声明=${declaredReadable ? "通过" : "未通过"};源码=${sourceAudit.middleSchoolReadable ? "通过" : "未通过"}(${sourceAudit.details.middleSchoolReadable})`,
|
||||
});
|
||||
|
||||
const declaredGlobalVisibility = declared?.globalVisibility === true;
|
||||
checks.push({
|
||||
id: "product-global-visibility",
|
||||
passed: declaredGlobalVisibility && sourceAudit.globalVisibility,
|
||||
detail: `声明=${declaredGlobalVisibility ? "通过" : "未通过"};源码=${sourceAudit.globalVisibility ? "通过" : "未通过"}(${sourceAudit.details.globalVisibility})`,
|
||||
});
|
||||
|
||||
const declaredAppleNativeUI = declared?.appleNativeUI === true;
|
||||
checks.push({
|
||||
id: "product-apple-native-ui",
|
||||
passed: declaredAppleNativeUI && sourceAudit.appleNativeUI,
|
||||
detail:
|
||||
`声明=${declaredAppleNativeUI ? "通过" : "未通过"};` +
|
||||
`客观检查=${sourceAudit.appleNativeUI ? "通过" : "未通过"}(${sourceAudit.details.appleNativeUI})`,
|
||||
});
|
||||
|
||||
const declaredRealSubscriptionConnected = declared?.realSubscriptionConnected === true;
|
||||
checks.push({
|
||||
id: "product-real-subscription-connected",
|
||||
passed: declaredRealSubscriptionConnected && subscriptionAudit.connected,
|
||||
detail:
|
||||
`声明=${declaredRealSubscriptionConnected ? "通过" : "未通过"};` +
|
||||
`客观检查=${subscriptionAudit.connected ? "通过" : "未通过"}(${subscriptionAudit.detail})`,
|
||||
});
|
||||
|
||||
const tiApproved = declared?.tiApproval === true;
|
||||
checks.push({
|
||||
id: "product-ti-approval",
|
||||
passed: requireTiApproval ? tiApproved : true,
|
||||
detail: requireTiApproval
|
||||
? tiApproved
|
||||
? "Ti 人工验收:通过(严格模式)"
|
||||
: "Ti 人工验收:未通过(严格模式)"
|
||||
: tiApproved
|
||||
? "Ti 人工验收:已记录(非阻塞)"
|
||||
: "Ti 人工验收:待补充(非阻塞)",
|
||||
});
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
async function runChecks(runtimeDir: string): Promise<DoDCheckResult[]> {
|
||||
const checks: DoDCheckResult[] = [];
|
||||
const goalPath = resolve(runtimeDir, "goal-state.json");
|
||||
const evidencePath = resolve(runtimeDir, "evidence", "latest.json");
|
||||
const progressPath = resolve(runtimeDir, "evidence", "worker-progress.json");
|
||||
const productDoDPath = resolve(runtimeDir, "evidence", "product-dod.json");
|
||||
const uiServerPath = resolve(process.cwd(), "src", "ui", "server.ts");
|
||||
const requireTiApproval = (process.env.DOD_REQUIRE_TI_APPROVAL ?? "false").toLowerCase() === "true";
|
||||
|
||||
const goalGate = await runNodeScript(resolve(process.cwd(), "scripts", "goal-gate.ts"), [goalPath]);
|
||||
checks.push({
|
||||
id: "goal-gate",
|
||||
passed: goalGate.code === 0,
|
||||
detail: goalGate.code === 0 ? "goal gate passed" : (goalGate.stderr.trim() || goalGate.stdout.trim() || "goal gate failed"),
|
||||
});
|
||||
|
||||
const evidenceGate = await runNodeScript(resolve(process.cwd(), "scripts", "evidence-gate.ts"), ["validate", evidencePath]);
|
||||
checks.push({
|
||||
id: "evidence-gate",
|
||||
passed: evidenceGate.code === 0,
|
||||
detail:
|
||||
evidenceGate.code === 0
|
||||
? "evidence bundle valid"
|
||||
: (evidenceGate.stderr.trim() || evidenceGate.stdout.trim() || "evidence validation failed"),
|
||||
});
|
||||
|
||||
const progress = await readProgress(progressPath);
|
||||
const stepPassed = progress?.lastStep?.exitCode === 0;
|
||||
checks.push({
|
||||
id: "worker-last-step",
|
||||
passed: stepPassed,
|
||||
detail: stepPassed
|
||||
? `cycle=${progress?.cycle ?? "unknown"} step=${progress?.lastStep?.id ?? "unknown"} exit=0`
|
||||
: "missing or failing worker-progress lastStep.exitCode",
|
||||
});
|
||||
|
||||
const product = await readProductDoD(productDoDPath);
|
||||
const sourceAudit = await auditProductSource(uiServerPath);
|
||||
const subscriptionAudit = await auditRealSubscriptionSource(runtimeDir);
|
||||
checks.push(...toProductChecks(product, sourceAudit, subscriptionAudit, requireTiApproval));
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const runtimeDir = resolve(process.argv[2] ?? join(process.cwd(), "runtime"));
|
||||
const statusPath = resolve(process.argv[3] ?? join(runtimeDir, "evidence", "dod-status.json"));
|
||||
|
||||
let checks: DoDCheckResult[] = [];
|
||||
try {
|
||||
checks = await runChecks(runtimeDir);
|
||||
} catch (error) {
|
||||
checks = [
|
||||
{
|
||||
id: "dod-check-runtime",
|
||||
passed: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const passed = checks.every((check) => check.passed);
|
||||
const artifact: DoDStatusArtifact = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtimeDir,
|
||||
status: passed ? "DONE" : "NOT_DONE",
|
||||
passed,
|
||||
checks,
|
||||
};
|
||||
|
||||
await writeJsonAtomic(statusPath, artifact);
|
||||
|
||||
if (!artifact.passed) {
|
||||
console.error(`NOT_DONE status=${statusPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`DONE status=${statusPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
export interface EvidenceBundle {
|
||||
generatedAt: string;
|
||||
itemId: string;
|
||||
completed: string;
|
||||
filesChanged: string[];
|
||||
verification: string;
|
||||
visibleMarker: string;
|
||||
}
|
||||
|
||||
const cmd = (process.argv[2] ?? "validate").toLowerCase();
|
||||
const targetPath = resolve(process.argv[3] ?? join(process.cwd(), "runtime", "evidence", "latest.json"));
|
||||
|
||||
export function validateBundle(bundle: Partial<EvidenceBundle>): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!bundle.itemId || bundle.itemId.trim().length === 0) errors.push("missing itemId");
|
||||
if (!bundle.completed || bundle.completed.trim().length === 0) errors.push("missing completed");
|
||||
if (!Array.isArray(bundle.filesChanged) || bundle.filesChanged.length === 0) errors.push("missing filesChanged[]");
|
||||
if (!bundle.verification || bundle.verification.trim().length === 0) errors.push("missing verification");
|
||||
if (!bundle.visibleMarker || bundle.visibleMarker.trim().length === 0) errors.push("missing visibleMarker");
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function emit(path: string): Promise<number> {
|
||||
const files = (process.env.EVIDENCE_FILES ?? "")
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const bundle: EvidenceBundle = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
itemId: process.env.EVIDENCE_ITEM_ID ?? "",
|
||||
completed: process.env.EVIDENCE_COMPLETED ?? "",
|
||||
filesChanged: files,
|
||||
verification: process.env.EVIDENCE_VERIFICATION ?? "",
|
||||
visibleMarker: process.env.EVIDENCE_VISIBLE_MARKER ?? "",
|
||||
};
|
||||
|
||||
const errors = validateBundle(bundle);
|
||||
if (errors.length > 0) {
|
||||
console.error(`EVIDENCE_INVALID ${errors.join("; ")}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
|
||||
console.log(`EVIDENCE_EMITTED ${path}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function validate(path: string): Promise<number> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
const bundle = JSON.parse(raw) as Partial<EvidenceBundle>;
|
||||
const errors = validateBundle(bundle);
|
||||
if (errors.length > 0) {
|
||||
console.error(`EVIDENCE_INVALID ${errors.join("; ")}`);
|
||||
return 1;
|
||||
}
|
||||
console.log(`EVIDENCE_VALID item=${bundle.itemId} file=${path}`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error(`EVIDENCE_READ_ERROR ${error instanceof Error ? error.message : String(error)}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const code = cmd === "emit" ? await emit(targetPath) : await validate(targetPath);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
interface EvidenceBundle {
|
||||
generatedAt: string;
|
||||
itemId: string;
|
||||
completed: string;
|
||||
filesChanged: string[];
|
||||
verification: string;
|
||||
visibleMarker: string;
|
||||
}
|
||||
|
||||
interface OutboundProgressPayload {
|
||||
reportedAt: string;
|
||||
evidencePath: string;
|
||||
progress: {
|
||||
itemId: string;
|
||||
completed: string;
|
||||
filesChanged: string[];
|
||||
verification: string;
|
||||
visibleMarker: string;
|
||||
};
|
||||
}
|
||||
|
||||
function buildSnapshotId(now = new Date()): string {
|
||||
const yyyy = now.getUTCFullYear();
|
||||
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(now.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(now.getUTCHours()).padStart(2, "0");
|
||||
const min = String(now.getUTCMinutes()).padStart(2, "0");
|
||||
const sec = String(now.getUTCSeconds()).padStart(2, "0");
|
||||
return `${yyyy}${mm}${dd}-${hh}${min}${sec}Z`;
|
||||
}
|
||||
|
||||
function isCompletionLike(text: string): boolean {
|
||||
return /(\bdone\b|\bcompleted\b|完成|已完成|全部完成)/i.test(text);
|
||||
}
|
||||
|
||||
function isFullPassLike(text: string): boolean {
|
||||
return /(\bfull\s+pass\b|\ball\s+checks?\s+pass(?:ed)?\b|全部通过|全通过|全量通过|全面通过)/i.test(text);
|
||||
}
|
||||
|
||||
async function runNodeScript(scriptPath: string, args: string[]): Promise<number | null> {
|
||||
return await new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on("error", rejectPromise);
|
||||
child.on("close", (code) => resolvePromise(code));
|
||||
});
|
||||
}
|
||||
|
||||
function validateEvidenceBundle(bundle: Partial<EvidenceBundle>): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!bundle.itemId || bundle.itemId.trim() === "") errors.push("missing itemId");
|
||||
if (!bundle.completed || bundle.completed.trim() === "") errors.push("missing completed");
|
||||
if (!Array.isArray(bundle.filesChanged) || bundle.filesChanged.length === 0) errors.push("missing filesChanged[]");
|
||||
if (!bundle.verification || bundle.verification.trim() === "") errors.push("missing verification");
|
||||
if (!bundle.visibleMarker || bundle.visibleMarker.trim() === "") errors.push("missing visibleMarker");
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const evidencePath = resolve(process.argv[2] ?? join(process.cwd(), "runtime", "evidence", "latest.json"));
|
||||
const outboundDir = resolve(process.argv[3] ?? join(process.cwd(), "runtime", "evidence", "outbound"));
|
||||
|
||||
const raw = await readFile(evidencePath, "utf8");
|
||||
const evidence = JSON.parse(raw) as Partial<EvidenceBundle>;
|
||||
const errors = validateEvidenceBundle(evidence);
|
||||
if (errors.length > 0) {
|
||||
console.error(`REPORT_BLOCKED_EVIDENCE ${errors.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const completionText = String(evidence.completed ?? "");
|
||||
const requireDodGate = (process.env.REQUIRE_DOD_FOR_COMPLETION ?? "true").toLowerCase() !== "false";
|
||||
const claimsCompletion = isCompletionLike(completionText) || isFullPassLike(completionText);
|
||||
if ((requireDodGate && claimsCompletion) || isFullPassLike(completionText)) {
|
||||
const runtimeDir = resolve(process.cwd(), "runtime");
|
||||
const dodCode = await runNodeScript(resolve(process.cwd(), "scripts", "dod-check.ts"), [runtimeDir]);
|
||||
if (dodCode !== 0) {
|
||||
console.error("REPORT_BLOCKED_DOD NOT_DONE");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const snapshotId = buildSnapshotId(now);
|
||||
const snapshotPath = resolve(outboundDir, `${snapshotId}.json`);
|
||||
const latestPath = resolve(outboundDir, "latest.json");
|
||||
const pointerPath = resolve(outboundDir, "latest.pointer.json");
|
||||
const payload: OutboundProgressPayload = {
|
||||
reportedAt: now.toISOString(),
|
||||
evidencePath,
|
||||
progress: {
|
||||
itemId: evidence.itemId ?? "",
|
||||
completed: evidence.completed ?? "",
|
||||
filesChanged: evidence.filesChanged ?? [],
|
||||
verification: evidence.verification ?? "",
|
||||
visibleMarker: evidence.visibleMarker ?? "",
|
||||
},
|
||||
};
|
||||
|
||||
await writeJsonAtomic(snapshotPath, payload);
|
||||
await writeJsonAtomic(latestPath, payload);
|
||||
await writeJsonAtomic(pointerPath, {
|
||||
updatedAt: now.toISOString(),
|
||||
snapshotId,
|
||||
latest: snapshotPath,
|
||||
});
|
||||
|
||||
console.log(`REPORT_EMITTED latest=${latestPath} snapshot=${snapshotPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { deflateSync } from "node:zlib";
|
||||
|
||||
type Sprite = string[][];
|
||||
|
||||
type SpriteRuntime = {
|
||||
spriteSize: number;
|
||||
spriteFactory: Record<string, () => Sprite>;
|
||||
spriteVisualSpanCompensation: Record<string, number>;
|
||||
};
|
||||
|
||||
type StaffAvatarExport = {
|
||||
agentId: string;
|
||||
animal: string;
|
||||
label: string;
|
||||
labelZh: string;
|
||||
};
|
||||
|
||||
type SpriteBounds = {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
span: number;
|
||||
};
|
||||
|
||||
export const STAFF_AVATARS: StaffAvatarExport[] = [
|
||||
{ agentId: "codex", animal: "robot", label: "Codex", labelZh: "机器人" },
|
||||
{ agentId: "coq", animal: "rooster", label: "Coq", labelZh: "公鸡" },
|
||||
{ agentId: "dolphin", animal: "dolphin", label: "Dolphin", labelZh: "海豚" },
|
||||
{ agentId: "main", animal: "lion", label: "Main", labelZh: "狮子" },
|
||||
{ agentId: "monkey", animal: "monkey", label: "Monkey", labelZh: "猴子" },
|
||||
{ agentId: "otter", animal: "otter", label: "Otter", labelZh: "水獭" },
|
||||
{ agentId: "pandas", animal: "panda", label: "Pandas", labelZh: "熊猫" },
|
||||
{ agentId: "tiger", animal: "tiger", label: "Tiger", labelZh: "老虎" },
|
||||
];
|
||||
|
||||
export function extractSpriteRuntime(source: string): SpriteRuntime {
|
||||
const start = source.indexOf("const spriteSize = 44;");
|
||||
const end = source.indexOf("const motionActors = [];", start);
|
||||
if (start < 0 || end < 0 || end <= start) {
|
||||
throw new Error("Unable to locate embedded staff avatar sprite runtime in src/ui/server.ts");
|
||||
}
|
||||
|
||||
const block = source.slice(start, end);
|
||||
const runtime = vm.runInNewContext(
|
||||
`
|
||||
(() => {
|
||||
${block}
|
||||
return { spriteSize, spriteFactory, spriteVisualSpanCompensation };
|
||||
})()
|
||||
`,
|
||||
{ Math },
|
||||
) as SpriteRuntime;
|
||||
|
||||
if (!runtime || typeof runtime.spriteSize !== "number" || !runtime.spriteFactory || !runtime.spriteVisualSpanCompensation) {
|
||||
throw new Error("Failed to evaluate embedded avatar sprite runtime");
|
||||
}
|
||||
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export async function loadSpriteRuntime(serverPath: string): Promise<SpriteRuntime> {
|
||||
const source = await fs.readFile(serverPath, "utf8");
|
||||
return extractSpriteRuntime(source);
|
||||
}
|
||||
|
||||
export function getSpriteBounds(sprite: Sprite): SpriteBounds {
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxX = Number.NEGATIVE_INFINITY;
|
||||
let maxY = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (let y = 0; y < sprite.length; y += 1) {
|
||||
for (let x = 0; x < sprite[y].length; x += 1) {
|
||||
if (!sprite[y][x]) continue;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
||||
return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 1, height: 1, span: 1 };
|
||||
}
|
||||
|
||||
const width = maxX - minX + 1;
|
||||
const height = maxY - minY + 1;
|
||||
return { minX, minY, maxX, maxY, width, height, span: Math.max(width, height) };
|
||||
}
|
||||
|
||||
export function centerSprite(sprite: Sprite, padding = 2): Sprite {
|
||||
const bounds = getSpriteBounds(sprite);
|
||||
const size = bounds.span + padding * 2;
|
||||
const centered = Array.from({ length: size }, () => Array(size).fill(""));
|
||||
const offsetX = Math.floor((size - bounds.width) / 2);
|
||||
const offsetY = Math.floor((size - bounds.height) / 2);
|
||||
|
||||
for (let y = bounds.minY; y <= bounds.maxY; y += 1) {
|
||||
for (let x = bounds.minX; x <= bounds.maxX; x += 1) {
|
||||
const color = sprite[y]?.[x];
|
||||
if (!color) continue;
|
||||
centered[offsetY + (y - bounds.minY)][offsetX + (x - bounds.minX)] = color;
|
||||
}
|
||||
}
|
||||
|
||||
return centered;
|
||||
}
|
||||
|
||||
export function renderSpriteSvg(sprite: Sprite, pixelScale = 8): string {
|
||||
const centered = centerSprite(sprite);
|
||||
const size = centered.length;
|
||||
const width = size * pixelScale;
|
||||
const rects: string[] = [];
|
||||
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const color = centered[y]?.[x];
|
||||
if (!color) continue;
|
||||
rects.push(
|
||||
`<rect x="${x * pixelScale}" y="${y * pixelScale}" width="${pixelScale}" height="${pixelScale}" fill="${color}" />`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${width}" viewBox="0 0 ${width} ${width}" shape-rendering="crispEdges">`,
|
||||
`<rect width="${width}" height="${width}" fill="transparent" />`,
|
||||
...rects,
|
||||
`</svg>`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderSpriteRgba(sprite: Sprite, pixelScale = 8): { width: number; height: number; data: Buffer } {
|
||||
const centered = centerSprite(sprite);
|
||||
const size = centered.length;
|
||||
const width = size * pixelScale;
|
||||
const height = size * pixelScale;
|
||||
const data = Buffer.alloc(width * height * 4);
|
||||
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const color = centered[y]?.[x];
|
||||
for (let py = 0; py < pixelScale; py += 1) {
|
||||
for (let px = 0; px < pixelScale; px += 1) {
|
||||
const idx = ((y * pixelScale + py) * width + (x * pixelScale + px)) * 4;
|
||||
if (!color) {
|
||||
data[idx + 0] = 0;
|
||||
data[idx + 1] = 0;
|
||||
data[idx + 2] = 0;
|
||||
data[idx + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
const rgb = hexToRgb(color);
|
||||
data[idx + 0] = rgb.r;
|
||||
data[idx + 1] = rgb.g;
|
||||
data[idx + 2] = rgb.b;
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const safe = hex.trim().replace(/^#/, "");
|
||||
if (safe.length !== 6) {
|
||||
throw new Error(`Unsupported color "${hex}"`);
|
||||
}
|
||||
return {
|
||||
r: Number.parseInt(safe.slice(0, 2), 16),
|
||||
g: Number.parseInt(safe.slice(2, 4), 16),
|
||||
b: Number.parseInt(safe.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
const crcTable = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k += 1) {
|
||||
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
table[i] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buffer: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
crc = crcTable[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBuffer = Buffer.from(type, "ascii");
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length, 0);
|
||||
const crcBuffer = Buffer.concat([typeBuffer, data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(crcBuffer), 0);
|
||||
return Buffer.concat([length, typeBuffer, data, crc]);
|
||||
}
|
||||
|
||||
export function renderSpritePng(sprite: Sprite, pixelScale = 8): Buffer {
|
||||
const rgba = renderSpriteRgba(sprite, pixelScale);
|
||||
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(rgba.width, 0);
|
||||
ihdr.writeUInt32BE(rgba.height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
const stride = rgba.width * 4;
|
||||
const raw = Buffer.alloc((stride + 1) * rgba.height);
|
||||
for (let y = 0; y < rgba.height; y += 1) {
|
||||
const rowStart = y * (stride + 1);
|
||||
raw[rowStart] = 0;
|
||||
rgba.data.copy(raw, rowStart + 1, y * stride, (y + 1) * stride);
|
||||
}
|
||||
|
||||
const idat = deflateSync(raw, { level: 9 });
|
||||
return Buffer.concat([
|
||||
signature,
|
||||
pngChunk("IHDR", ihdr),
|
||||
pngChunk("IDAT", idat),
|
||||
pngChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderPreviewHtml(items: StaffAvatarExport[]): string {
|
||||
const cards = items
|
||||
.map(
|
||||
(item) => `<article class="card">
|
||||
<img src="./${item.agentId}.png" alt="${item.label}" width="176" height="176" />
|
||||
<div class="meta">
|
||||
<h2>${item.label}</h2>
|
||||
<p>${item.labelZh}</p>
|
||||
<code>${item.agentId}</code>
|
||||
</div>
|
||||
</article>`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Staff Avatar Exports</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f5f7;
|
||||
--panel: #ffffff;
|
||||
--border: rgba(17, 24, 39, 0.08);
|
||||
--text: #1d1d1f;
|
||||
--muted: #6e6e73;
|
||||
--shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
|
||||
font-family: "SF Pro Display", "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, #fbfbfd 0%, var(--bg) 100%);
|
||||
color: var(--text);
|
||||
padding: 40px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 34px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
p.lead {
|
||||
margin: 0 0 28px;
|
||||
color: var(--muted);
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255,255,255,0.88);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
width: 176px;
|
||||
height: 176px;
|
||||
margin: 0 auto 12px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
.meta p,
|
||||
.meta code {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Staff Avatar Exports</h1>
|
||||
<p class="lead">Exported from the current pixel avatar definitions embedded in <code>src/ui/server.ts</code>.</p>
|
||||
<section class="grid">
|
||||
${cards}
|
||||
</section>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function exportStaffAvatars(outputDir: string, serverPath: string): Promise<string[]> {
|
||||
const runtime = await loadSpriteRuntime(serverPath);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const written: string[] = [];
|
||||
|
||||
for (const item of STAFF_AVATARS) {
|
||||
const renderer = runtime.spriteFactory[item.animal];
|
||||
if (!renderer) {
|
||||
throw new Error(`Missing renderer for animal "${item.animal}"`);
|
||||
}
|
||||
const sprite = renderer();
|
||||
|
||||
const svg = renderSpriteSvg(sprite);
|
||||
const svgPath = path.join(outputDir, `${item.agentId}.svg`);
|
||||
await fs.writeFile(svgPath, svg, "utf8");
|
||||
written.push(svgPath);
|
||||
|
||||
const png = renderSpritePng(sprite);
|
||||
const pngPath = path.join(outputDir, `${item.agentId}.png`);
|
||||
await fs.writeFile(pngPath, png);
|
||||
written.push(pngPath);
|
||||
}
|
||||
|
||||
const manifestPath = path.join(outputDir, "manifest.json");
|
||||
await fs.writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: "src/ui/server.ts",
|
||||
count: STAFF_AVATARS.length,
|
||||
avatars: STAFF_AVATARS.map((item) => ({
|
||||
agentId: item.agentId,
|
||||
animal: item.animal,
|
||||
label: item.label,
|
||||
labelZh: item.labelZh,
|
||||
svg: `${item.agentId}.svg`,
|
||||
png: `${item.agentId}.png`,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
written.push(manifestPath);
|
||||
|
||||
const previewPath = path.join(outputDir, "index.html");
|
||||
await fs.writeFile(previewPath, renderPreviewHtml(STAFF_AVATARS), "utf8");
|
||||
written.push(previewPath);
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const repoRoot = process.cwd();
|
||||
const serverPath = path.join(repoRoot, "src/ui/server.ts");
|
||||
const outputDir = path.join(repoRoot, "runtime/exports/staff-avatars");
|
||||
const written = await exportStaffAvatars(outputDir, serverPath);
|
||||
process.stdout.write(`Exported ${written.length} staff avatar files to ${outputDir}\n`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
interface GoalState {
|
||||
goalId: string;
|
||||
definitionOfDone: string[];
|
||||
phases: Array<{ id: string; status: string }>;
|
||||
}
|
||||
|
||||
const path = resolve(process.argv[2] ?? "runtime/goal-state.json");
|
||||
|
||||
async function main() {
|
||||
const raw = await readFile(path, "utf8");
|
||||
const state = JSON.parse(raw) as GoalState;
|
||||
|
||||
const inProgress = state.phases.filter((p) => p.status === "in_progress");
|
||||
const blocked = state.phases.filter((p) => p.status === "blocked");
|
||||
|
||||
if (blocked.length > 0) {
|
||||
console.error(`GATE_BLOCKED phases=${blocked.map((p) => p.id).join(",")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (inProgress.length > 1) {
|
||||
console.error(`GATE_INVALID multiple_in_progress=${inProgress.map((p) => p.id).join(",")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const doneCount = state.phases.filter((p) => p.status === "done").length;
|
||||
const total = state.phases.length;
|
||||
const current = inProgress[0]?.id ?? "none";
|
||||
console.log(`GATE_OK goal=${state.goalId} done=${doneCount}/${total} current=${current}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mkdir, stat, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export interface HealthSnapshot {
|
||||
generatedAt: string;
|
||||
files: {
|
||||
lock: { path: string; ageMs: number | null };
|
||||
evidence: { path: string; ageMs: number | null };
|
||||
recovery: { path: string; ageMs: number | null };
|
||||
};
|
||||
}
|
||||
|
||||
async function ageMs(path: string): Promise<number | null> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return Math.max(0, Date.now() - s.mtimeMs);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildHealthSnapshot(runtimeDir = resolve(process.cwd(), "runtime")): Promise<HealthSnapshot> {
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
files: {
|
||||
lock: {
|
||||
path: join(runtimeDir, "run.lock"),
|
||||
ageMs: await ageMs(join(runtimeDir, "run.lock")),
|
||||
},
|
||||
evidence: {
|
||||
path: join(runtimeDir, "evidence", "latest.json"),
|
||||
ageMs: await ageMs(join(runtimeDir, "evidence", "latest.json")),
|
||||
},
|
||||
recovery: {
|
||||
path: join(runtimeDir, "recovery", "latest.json"),
|
||||
ageMs: await ageMs(join(runtimeDir, "recovery", "latest.json")),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runtime = resolve(process.cwd(), "runtime");
|
||||
const out = resolve(runtime, "health", "latest.json");
|
||||
const snapshot = await buildHealthSnapshot(runtime);
|
||||
await mkdir(resolve(runtime, "health"), { recursive: true });
|
||||
await writeFile(out, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
||||
console.log(`HEALTH_SNAPSHOT_WRITTEN ${out}`);
|
||||
}
|
||||
|
||||
if ((process.argv[1] ?? "").endsWith("health-snapshot.ts")) {
|
||||
main().catch((e) => {
|
||||
console.error(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE = ROOT / "runtime"
|
||||
STATE_PATH = BASE / "state.json"
|
||||
DOD_PATH = BASE / "evidence" / "dod-status.json"
|
||||
OUTPUT_PATH = BASE / "evidence" / "dod-eval.json"
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
res = {"ts": now.isoformat(), "status": "fail", "reason": "init", "checks": {}}
|
||||
|
||||
def load(p):
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
try:
|
||||
state = load(STATE_PATH)
|
||||
dod = load(DOD_PATH)
|
||||
res["checks"]["state_exists"] = True
|
||||
res["checks"]["dod_exists"] = True
|
||||
missing = []
|
||||
for k in ["updatedAt", "status"]:
|
||||
if k not in state:
|
||||
missing.append(k)
|
||||
res["checks"]["state_fields_ok"] = len(missing) == 0
|
||||
res["checks"]["state_missing_fields"] = missing
|
||||
|
||||
fresh = False
|
||||
try:
|
||||
t = datetime.datetime.fromisoformat(str(state.get("updatedAt")).replace("Z", "+00:00"))
|
||||
fresh = (now - t).total_seconds() <= 1200
|
||||
except Exception:
|
||||
fresh = False
|
||||
res["checks"]["fresh_20m"] = fresh
|
||||
|
||||
text = json.dumps(dod, ensure_ascii=False)
|
||||
done_all = "A1" in text and "E8" in text and (
|
||||
"all_pass" in text or "allPassed" in text or "全达标" in text
|
||||
)
|
||||
res["checks"]["dod_all_pass_hint"] = done_all
|
||||
|
||||
st = str(state.get("status", "")).upper()
|
||||
if st in ["ALERT", "FAIL", "STALE"]:
|
||||
res["status"] = "alert"
|
||||
res["reason"] = f"state_status={st}"
|
||||
elif not fresh:
|
||||
res["status"] = "alert"
|
||||
res["reason"] = "state_stale"
|
||||
else:
|
||||
res["status"] = "ok"
|
||||
res["reason"] = "healthy"
|
||||
except FileNotFoundError as e:
|
||||
res["status"] = "fail"
|
||||
res["reason"] = f"missing_file:{e.filename}"
|
||||
except Exception as e:
|
||||
res["status"] = "fail"
|
||||
res["reason"] = f"exception:{e}"
|
||||
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(res, f, ensure_ascii=False, indent=2)
|
||||
print(json.dumps(res, ensure_ascii=False))
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE = ROOT / "runtime" / "evidence"
|
||||
REGRESSION_PATH = BASE / "regression-status.json"
|
||||
SNAPSHOT_PATH = BASE / "mission-control-jobs-v2-snapshot.json"
|
||||
OUTPUT_PATH = BASE / "rollback-plan.json"
|
||||
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
plan = {"ts": now, "canRollback": False, "reason": "", "jobs": [], "commands": []}
|
||||
try:
|
||||
with open(REGRESSION_PATH, "r", encoding="utf-8") as regression_file:
|
||||
reg = json.load(regression_file)
|
||||
with open(SNAPSHOT_PATH, "r", encoding="utf-8") as snapshot_file:
|
||||
snap = json.load(snapshot_file)
|
||||
status = str(reg.get("status", "")).lower()
|
||||
if status in ["fail", "alert"]:
|
||||
jobs = snap.get("jobs", [])
|
||||
plan["canRollback"] = True
|
||||
plan["reason"] = f"regression_{status}"
|
||||
for j in jobs:
|
||||
jid = j.get("id")
|
||||
plan["jobs"].append(jid)
|
||||
plan["commands"].append(f"openclaw cron update {jid} --json '<snapshot-payload>'")
|
||||
else:
|
||||
plan["reason"] = "regression_not_triggered"
|
||||
except Exception as e:
|
||||
plan["reason"] = f"error:{e}"
|
||||
|
||||
BASE.mkdir(parents=True, exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as output_file:
|
||||
json.dump(plan, output_file, ensure_ascii=False, indent=2)
|
||||
print(json.dumps(plan, ensure_ascii=False))
|
||||
@@ -0,0 +1,76 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { buildHealthSnapshot } from "./health-snapshot";
|
||||
|
||||
interface SnapshotPointer {
|
||||
updatedAt: string;
|
||||
snapshotId: string;
|
||||
latest: string;
|
||||
}
|
||||
|
||||
function buildSnapshotId(now = new Date()): string {
|
||||
const yyyy = now.getUTCFullYear();
|
||||
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(now.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(now.getUTCHours()).padStart(2, "0");
|
||||
const min = String(now.getUTCMinutes()).padStart(2, "0");
|
||||
const sec = String(now.getUTCSeconds()).padStart(2, "0");
|
||||
return `${yyyy}${mm}${dd}-${hh}${min}${sec}Z`;
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
async function readJson<T>(path: string): Promise<T> {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const runtimeDir = resolve(process.argv[2] ?? join(process.cwd(), "runtime"));
|
||||
const healthDir = resolve(runtimeDir, "health");
|
||||
const evidenceDir = resolve(runtimeDir, "evidence");
|
||||
const evidenceLatestPath = resolve(evidenceDir, "latest.json");
|
||||
|
||||
const now = new Date();
|
||||
const snapshotId = buildSnapshotId(now);
|
||||
const healthSnapshotPath = resolve(healthDir, "snapshots", `${snapshotId}.json`);
|
||||
const evidenceSnapshotPath = resolve(evidenceDir, "snapshots", `${snapshotId}.json`);
|
||||
const healthLatestPath = resolve(healthDir, "latest.json");
|
||||
const healthPointerPath = resolve(healthDir, "latest.pointer.json");
|
||||
const evidencePointerPath = resolve(evidenceDir, "latest.pointer.json");
|
||||
|
||||
const healthSnapshot = await buildHealthSnapshot(runtimeDir);
|
||||
const evidenceSnapshot = await readJson<Record<string, unknown>>(evidenceLatestPath);
|
||||
|
||||
const healthPointer: SnapshotPointer = {
|
||||
updatedAt: now.toISOString(),
|
||||
snapshotId,
|
||||
latest: healthSnapshotPath,
|
||||
};
|
||||
const evidencePointer: SnapshotPointer & { sourceLatest: string } = {
|
||||
updatedAt: now.toISOString(),
|
||||
snapshotId,
|
||||
latest: evidenceSnapshotPath,
|
||||
sourceLatest: evidenceLatestPath,
|
||||
};
|
||||
|
||||
await writeJsonAtomic(healthSnapshotPath, healthSnapshot);
|
||||
await writeJsonAtomic(evidenceSnapshotPath, evidenceSnapshot);
|
||||
await writeJsonAtomic(healthLatestPath, healthSnapshot);
|
||||
await writeJsonAtomic(healthPointerPath, healthPointer);
|
||||
await writeJsonAtomic(evidencePointerPath, evidencePointer);
|
||||
|
||||
console.log(
|
||||
`PERIODIC_SNAPSHOT_OK id=${snapshotId} health=${healthSnapshotPath} evidence=${evidenceSnapshotPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
FILES=()
|
||||
while IFS= read -r file; do
|
||||
FILES+=("$file")
|
||||
done < <(
|
||||
rg --files --hidden \
|
||||
-g '!.git/**' \
|
||||
-g '!node_modules/**' \
|
||||
-g '!dist/**' \
|
||||
-g '!runtime/**' \
|
||||
-g '!coverage/**' \
|
||||
-g '!scripts/release-audit.sh'
|
||||
)
|
||||
|
||||
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||
echo "release-audit: no source files found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check_no_match() {
|
||||
local description="$1"
|
||||
local pattern="$2"
|
||||
if rg -n --no-heading -P -e "$pattern" "${FILES[@]}" >/tmp/release-audit-match.txt 2>/dev/null; then
|
||||
echo "release-audit: failed: ${description}" >&2
|
||||
cat /tmp/release-audit-match.txt >&2
|
||||
rm -f /tmp/release-audit-match.txt
|
||||
exit 1
|
||||
fi
|
||||
rm -f /tmp/release-audit-match.txt
|
||||
}
|
||||
|
||||
check_exists() {
|
||||
local file="$1"
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "release-audit: missing required file: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_exists "README.md"
|
||||
check_exists "LICENSE"
|
||||
check_exists ".gitignore"
|
||||
check_exists ".env.example"
|
||||
check_exists "package.json"
|
||||
|
||||
check_no_match "absolute macOS home paths" '/Users/[^/]+/'
|
||||
check_no_match "absolute Linux home paths" '/home/[^/]+/'
|
||||
check_no_match "hard-coded internal channel ids" '1477617216529760378'
|
||||
check_no_match "obvious OpenAI-style secret keys" 'sk-[A-Za-z0-9]{20,}'
|
||||
check_no_match "hard-coded bearer tokens" 'Authorization: Bearer (?!<)[^[:space:]]+'
|
||||
check_no_match "hard-coded local API tokens" 'LOCAL_API_TOKEN=(?!<)[^[:space:]]+'
|
||||
check_no_match "hard-coded x-local-token header values" 'x-local-token:[[:space:]]*(?!<)[^[:space:]]+'
|
||||
|
||||
if [ -d ".git" ]; then
|
||||
if git ls-files | rg '^(runtime|dist|node_modules|coverage|plans|workflows)/' >/tmp/release-audit-match.txt 2>/dev/null; then
|
||||
echo "release-audit: failed: ignored build/runtime/internal-only paths are tracked" >&2
|
||||
cat /tmp/release-audit-match.txt >&2
|
||||
rm -f /tmp/release-audit-match.txt
|
||||
exit 1
|
||||
fi
|
||||
rm -f /tmp/release-audit-match.txt
|
||||
fi
|
||||
|
||||
echo "release-audit: passed"
|
||||
@@ -0,0 +1,306 @@
|
||||
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
interface RunLock {
|
||||
owner: string;
|
||||
leaseUntil: string;
|
||||
}
|
||||
|
||||
interface WorkerHeartbeat {
|
||||
generatedAt: string;
|
||||
pid?: number;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
interface SupervisorReport {
|
||||
generatedAt: string;
|
||||
status: "healthy" | "restarted" | "failed" | "done";
|
||||
reason: string;
|
||||
retries: number;
|
||||
maxRetries: number;
|
||||
observed: {
|
||||
heartbeatAgeMs?: number;
|
||||
heartbeatState?: string;
|
||||
workerPid?: number;
|
||||
workerDead?: boolean;
|
||||
};
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
function isExpired(lock: RunLock): boolean {
|
||||
const expiry = Date.parse(lock.leaseUntil);
|
||||
if (Number.isNaN(expiry)) return true;
|
||||
return expiry <= Date.now();
|
||||
}
|
||||
|
||||
async function readLock(path: string): Promise<RunLock | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as RunLock;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readHeartbeat(path: string): Promise<WorkerHeartbeat | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as WorkerHeartbeat;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ageMs(path: string): Promise<number | undefined> {
|
||||
try {
|
||||
const fileStat = await stat(path);
|
||||
return Math.max(0, Date.now() - fileStat.mtimeMs);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (code === "EPERM") return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
async function appendAudit(path: string, line: string): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
let existing = "";
|
||||
try {
|
||||
existing = await readFile(path, "utf8");
|
||||
} catch {
|
||||
existing = "";
|
||||
}
|
||||
await writeFile(path, `${existing}${line}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function runNodeScript(
|
||||
scriptPath: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return await new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", rejectPromise);
|
||||
child.on("close", (code) => resolvePromise({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function runLockCommand(action: "acquire" | "renew" | "release", lockPath: string, owner: string): Promise<void> {
|
||||
const result = await runNodeScript(resolve(process.cwd(), "scripts", "run-lock.ts"), [action, lockPath], {
|
||||
...process.env,
|
||||
RUN_OWNER: owner,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || result.stdout.trim() || `run-lock ${action} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDoDCheck(runtimeDir: string, statusPath: string): Promise<boolean> {
|
||||
const result = await runNodeScript(resolve(process.cwd(), "scripts", "dod-check.ts"), [runtimeDir, statusPath]);
|
||||
return result.code === 0;
|
||||
}
|
||||
|
||||
async function startWorker(
|
||||
workerEntry: string,
|
||||
runtimeDir: string,
|
||||
mode: "detached" | "sync",
|
||||
): Promise<{ ok: boolean; detail: string }> {
|
||||
if (mode === "sync") {
|
||||
const run = await runNodeScript(resolve(workerEntry), [runtimeDir], process.env);
|
||||
if (run.code !== 0) {
|
||||
const combined = `${run.stdout}\n${run.stderr}`;
|
||||
if (combined.includes("WORKER_MAX_CYCLES_REACHED")) {
|
||||
return { ok: true, detail: "worker restarted (sync bounded cycles)" };
|
||||
}
|
||||
return { ok: false, detail: run.stderr.trim() || run.stdout.trim() || "worker restart failed" };
|
||||
}
|
||||
return { ok: true, detail: "worker restarted (sync)" };
|
||||
}
|
||||
|
||||
return await new Promise((resolvePromise) => {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", resolve(workerEntry), runtimeDir], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
resolvePromise({ ok: false, detail: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
child.unref();
|
||||
resolvePromise({ ok: true, detail: "worker restarted (detached)" });
|
||||
});
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolvePromise) => {
|
||||
setTimeout(resolvePromise, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const runtimeDir = resolve(process.argv[2] ?? join(process.cwd(), "runtime"));
|
||||
const owner = process.env.RUN_OWNER ?? "pandas-main";
|
||||
const once = (process.env.SUPERVISOR_ONCE ?? "false").toLowerCase() === "true";
|
||||
const pollMs = Number(process.env.SUPERVISOR_POLL_MS ?? 15_000);
|
||||
const maxRetries = Number(process.env.SUPERVISOR_MAX_RESTARTS ?? 3);
|
||||
const heartbeatMaxAgeMs = Number(process.env.SUPERVISOR_HEARTBEAT_MAX_AGE_MS ?? 120_000);
|
||||
const restartMode = (process.env.SUPERVISOR_RESTART_MODE ?? "detached") === "sync" ? "sync" : "detached";
|
||||
const workerEntry = resolve(process.env.SUPERVISOR_WORKER_ENTRY ?? join(process.cwd(), "scripts", "resident-worker.ts"));
|
||||
|
||||
const lockPath = resolve(runtimeDir, "run.lock");
|
||||
const heartbeatPath = resolve(runtimeDir, "health", "worker-heartbeat.json");
|
||||
const dodStatusPath = resolve(runtimeDir, "evidence", "dod-status.json");
|
||||
const supervisorLockPath = resolve(runtimeDir, "health", "supervisor-lock.json");
|
||||
|
||||
const existingSupervisor = await readHeartbeat(supervisorLockPath);
|
||||
const existingPid = existingSupervisor?.pid;
|
||||
if (existingPid && existingPid !== process.pid && isPidAlive(existingPid)) {
|
||||
console.log(`SUPERVISOR_ALREADY_RUNNING pid=${existingPid}`);
|
||||
process.exit(0);
|
||||
}
|
||||
await writeJsonAtomic(supervisorLockPath, {
|
||||
generatedAt: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
state: "running",
|
||||
});
|
||||
const reportPath = resolve(runtimeDir, "recovery", "supervisor-latest.json");
|
||||
const auditPath = resolve(runtimeDir, "recovery", "supervisor-audit.log");
|
||||
|
||||
let retries = 0;
|
||||
|
||||
while (true) {
|
||||
const actions: string[] = [];
|
||||
const heartbeat = await readHeartbeat(heartbeatPath);
|
||||
const heartbeatAgeMs = await ageMs(heartbeatPath);
|
||||
const workerPid = heartbeat?.pid;
|
||||
const workerDead = workerPid !== undefined ? !isPidAlive(workerPid) : false;
|
||||
const stale = heartbeatAgeMs === undefined || heartbeatAgeMs > heartbeatMaxAgeMs;
|
||||
const dead = workerDead;
|
||||
|
||||
let status: SupervisorReport["status"] = "healthy";
|
||||
let reason = stale ? "worker-heartbeat-stale" : "worker-healthy";
|
||||
|
||||
if (heartbeat?.state === "done") {
|
||||
const dodPassed = await runDoDCheck(runtimeDir, dodStatusPath);
|
||||
actions.push(`dod-check-${dodPassed ? "pass" : "fail"}`);
|
||||
if (dodPassed) {
|
||||
status = "done";
|
||||
reason = "dod-pass";
|
||||
} else {
|
||||
reason = "worker-done-but-dod-failed";
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== "done" && (stale || dead)) {
|
||||
if (retries >= maxRetries) {
|
||||
status = "failed";
|
||||
reason = "max-retries-exhausted";
|
||||
actions.push("restart-skipped-max-retries");
|
||||
} else {
|
||||
const lock = await readLock(lockPath);
|
||||
if (lock && !isExpired(lock) && lock.owner !== owner) {
|
||||
status = "failed";
|
||||
reason = `lock-conflict owner=${lock.owner}`;
|
||||
actions.push("restart-blocked-lock-conflict");
|
||||
} else {
|
||||
if (lock && !isExpired(lock) && lock.owner === owner) {
|
||||
await runLockCommand("release", lockPath, owner).catch(() => {
|
||||
// best-effort release before restart
|
||||
});
|
||||
actions.push("released-existing-owner-lock");
|
||||
}
|
||||
await runLockCommand("acquire", lockPath, owner).catch(() => {
|
||||
// worker can also acquire after restart; keep attempting restart.
|
||||
});
|
||||
actions.push("acquired-supervisor-lock");
|
||||
|
||||
const started = await startWorker(workerEntry, runtimeDir, restartMode);
|
||||
if (!started.ok) {
|
||||
retries += 1;
|
||||
status = retries >= maxRetries ? "failed" : "healthy";
|
||||
reason = `restart-failed: ${started.detail}`;
|
||||
actions.push("restart-worker-failed");
|
||||
} else {
|
||||
retries += 1;
|
||||
status = "restarted";
|
||||
reason = stale && dead ? "worker-stale-and-dead" : stale ? "worker-stale" : "worker-dead";
|
||||
actions.push("restart-worker");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const report: SupervisorReport = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
status,
|
||||
reason,
|
||||
retries,
|
||||
maxRetries,
|
||||
observed: {
|
||||
heartbeatAgeMs,
|
||||
heartbeatState: heartbeat?.state,
|
||||
workerPid,
|
||||
workerDead,
|
||||
},
|
||||
actions,
|
||||
};
|
||||
|
||||
await writeJsonAtomic(reportPath, report);
|
||||
await appendAudit(
|
||||
auditPath,
|
||||
`${report.generatedAt} | ${status} | ${reason} | retries=${retries}/${maxRetries} | actions=${actions.join(",") || "none"}`,
|
||||
);
|
||||
|
||||
if (status === "failed") {
|
||||
console.error(`SUPERVISOR_FAILED report=${reportPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (status === "done") {
|
||||
console.log(`SUPERVISOR_DONE report=${reportPath}`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (once) {
|
||||
console.log(`SUPERVISOR_ONCE status=${status} report=${reportPath}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
interface RunLock {
|
||||
runId: string;
|
||||
owner: string;
|
||||
leaseUntil: string;
|
||||
}
|
||||
|
||||
interface WorkerProgressArtifact {
|
||||
generatedAt: string;
|
||||
runId: string;
|
||||
owner: string;
|
||||
cycle: number;
|
||||
state: "running" | "waiting" | "done";
|
||||
lastStep: {
|
||||
id: string;
|
||||
command: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
exitCode: number | null;
|
||||
stdoutTail: string;
|
||||
stderrTail: string;
|
||||
};
|
||||
dod: {
|
||||
passed: boolean;
|
||||
statusPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkerHeartbeatArtifact {
|
||||
generatedAt: string;
|
||||
runId: string;
|
||||
owner: string;
|
||||
pid: number;
|
||||
cycle: number;
|
||||
state: "running" | "waiting" | "done";
|
||||
stepId: string;
|
||||
stepExitCode: number | null;
|
||||
dodPassed: boolean;
|
||||
}
|
||||
|
||||
function getNpmCommand(): string {
|
||||
return process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
}
|
||||
|
||||
function parseSteps(raw: string): string[] {
|
||||
const steps = raw
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return steps.length > 0 ? steps : ["goal-gate"];
|
||||
}
|
||||
|
||||
function getStepCommand(
|
||||
stepId: string,
|
||||
runtimeDir: string,
|
||||
): { command: string; args: string[]; label: string } {
|
||||
const npm = getNpmCommand();
|
||||
if (stepId === "build") return { command: npm, args: ["run", "build"], label: "npm run build" };
|
||||
if (stepId === "test") return { command: npm, args: ["test"], label: "npm test" };
|
||||
if (stepId === "evidence-report") {
|
||||
return { command: npm, args: ["run", "evidence:report"], label: "npm run evidence:report" };
|
||||
}
|
||||
if (stepId === "goal-gate") {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ["--import", "tsx", resolve(process.cwd(), "scripts", "goal-gate.ts"), resolve(runtimeDir, "goal-state.json")],
|
||||
label: "goal-gate",
|
||||
};
|
||||
}
|
||||
if (stepId === "evidence-validate") {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"--import",
|
||||
"tsx",
|
||||
resolve(process.cwd(), "scripts", "evidence-gate.ts"),
|
||||
"validate",
|
||||
resolve(runtimeDir, "evidence", "latest.json"),
|
||||
],
|
||||
label: "evidence-validate",
|
||||
};
|
||||
}
|
||||
if (stepId === "health-snapshot") {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ["--import", "tsx", resolve(process.cwd(), "scripts", "health-snapshot.ts")],
|
||||
label: "health-snapshot",
|
||||
};
|
||||
}
|
||||
if (stepId === "codex-patch") {
|
||||
const prompt = process.env.WORKER_CODE_PROMPT ??
|
||||
"Improve Mission Control product DoD gaps: full Chinese coverage, non-technical copy, middle-school readability, and global visibility for cron/heartbeat/current tasks/tool calls. Make minimal safe code changes.";
|
||||
return {
|
||||
command: "codex",
|
||||
args: ["exec", "--full-auto", prompt],
|
||||
label: "codex exec --full-auto",
|
||||
};
|
||||
}
|
||||
throw new Error(`unknown worker step: ${stepId}`);
|
||||
}
|
||||
|
||||
function tail(value: string, maxChars = 2000): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return value.slice(value.length - maxChars);
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return await new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", rejectPromise);
|
||||
child.on("close", (code) => resolvePromise({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function readLock(path: string): Promise<RunLock | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as RunLock;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isExpired(lock: RunLock): boolean {
|
||||
const expiry = Date.parse(lock.leaseUntil);
|
||||
if (Number.isNaN(expiry)) return true;
|
||||
return expiry <= Date.now();
|
||||
}
|
||||
|
||||
async function runLockCommand(action: "acquire" | "renew" | "release", lockPath: string, owner: string): Promise<void> {
|
||||
const result = await runCommand(
|
||||
process.execPath,
|
||||
["--import", "tsx", resolve(process.cwd(), "scripts", "run-lock.ts"), action, lockPath],
|
||||
{ ...process.env, RUN_OWNER: owner },
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || result.stdout.trim() || `run-lock ${action} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOwnedLock(lockPath: string, owner: string): Promise<void> {
|
||||
const existing = await readLock(lockPath);
|
||||
if (existing && !isExpired(existing) && existing.owner !== owner) {
|
||||
throw new Error(`WORKER_LOCK_CONFLICT expected=${owner} current=${existing.owner} leaseUntil=${existing.leaseUntil}`);
|
||||
}
|
||||
if (!existing || isExpired(existing)) {
|
||||
await runLockCommand("acquire", lockPath, owner);
|
||||
return;
|
||||
}
|
||||
await runLockCommand("renew", lockPath, owner);
|
||||
}
|
||||
|
||||
async function runDoDCheck(runtimeDir: string, statusPath: string): Promise<boolean> {
|
||||
const result = await runCommand(process.execPath, [
|
||||
"--import",
|
||||
"tsx",
|
||||
resolve(process.cwd(), "scripts", "dod-check.ts"),
|
||||
runtimeDir,
|
||||
statusPath,
|
||||
]);
|
||||
return result.code === 0;
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolvePromise) => {
|
||||
setTimeout(resolvePromise, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const runtimeDir = resolve(process.argv[2] ?? join(process.cwd(), "runtime"));
|
||||
const owner = process.env.RUN_OWNER ?? "pandas-main";
|
||||
const steps = parseSteps(process.env.WORKER_STEPS ?? "codex-patch,test,evidence-report,health-snapshot");
|
||||
const intervalMs = Number(process.env.WORKER_INTERVAL_MS ?? 15_000);
|
||||
const maxCycles = Number(process.env.WORKER_MAX_CYCLES ?? 0);
|
||||
|
||||
const lockPath = resolve(runtimeDir, "run.lock");
|
||||
const heartbeatPath = resolve(runtimeDir, "health", "worker-heartbeat.json");
|
||||
const progressPath = resolve(runtimeDir, "evidence", "worker-progress.json");
|
||||
const dodStatusPath = resolve(runtimeDir, "evidence", "dod-status.json");
|
||||
const runId = randomUUID();
|
||||
|
||||
let cycle = 0;
|
||||
try {
|
||||
await ensureOwnedLock(lockPath, owner);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
cycle += 1;
|
||||
const stepId = steps[(cycle - 1) % steps.length];
|
||||
const step = getStepCommand(stepId, runtimeDir);
|
||||
const stepStartedAt = new Date().toISOString();
|
||||
|
||||
const runningHeartbeat: WorkerHeartbeatArtifact = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
runId,
|
||||
owner,
|
||||
pid: process.pid,
|
||||
cycle,
|
||||
state: "running",
|
||||
stepId,
|
||||
stepExitCode: null,
|
||||
dodPassed: false,
|
||||
};
|
||||
await writeJsonAtomic(heartbeatPath, runningHeartbeat);
|
||||
|
||||
let stepResult: { code: number | null; stdout: string; stderr: string };
|
||||
try {
|
||||
stepResult = await runCommand(step.command, step.args);
|
||||
} catch (error) {
|
||||
stepResult = { code: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
const stepFinishedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await ensureOwnedLock(lockPath, owner);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const interimProgress: WorkerProgressArtifact = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
runId,
|
||||
owner,
|
||||
cycle,
|
||||
state: "waiting",
|
||||
lastStep: {
|
||||
id: stepId,
|
||||
command: step.label,
|
||||
startedAt: stepStartedAt,
|
||||
finishedAt: stepFinishedAt,
|
||||
exitCode: stepResult.code,
|
||||
stdoutTail: tail(stepResult.stdout),
|
||||
stderrTail: tail(stepResult.stderr),
|
||||
},
|
||||
dod: {
|
||||
passed: false,
|
||||
statusPath: dodStatusPath,
|
||||
},
|
||||
};
|
||||
|
||||
await writeJsonAtomic(progressPath, interimProgress);
|
||||
|
||||
const dodPassed = await runDoDCheck(runtimeDir, dodStatusPath);
|
||||
const state: WorkerProgressArtifact["state"] = dodPassed ? "done" : "waiting";
|
||||
const progress: WorkerProgressArtifact = {
|
||||
...interimProgress,
|
||||
generatedAt: new Date().toISOString(),
|
||||
state,
|
||||
dod: {
|
||||
passed: dodPassed,
|
||||
statusPath: dodStatusPath,
|
||||
},
|
||||
};
|
||||
|
||||
const heartbeat: WorkerHeartbeatArtifact = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
runId,
|
||||
owner,
|
||||
pid: process.pid,
|
||||
cycle,
|
||||
state,
|
||||
stepId,
|
||||
stepExitCode: stepResult.code,
|
||||
dodPassed,
|
||||
};
|
||||
|
||||
await writeJsonAtomic(progressPath, progress);
|
||||
await writeJsonAtomic(heartbeatPath, heartbeat);
|
||||
|
||||
if (dodPassed) {
|
||||
await runLockCommand("release", lockPath, owner).catch(() => {
|
||||
// Keep done outcome even if release fails; lock lease expiry is still a safe fallback.
|
||||
});
|
||||
console.log(`WORKER_DONE cycles=${cycle} dodStatus=${dodStatusPath}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (maxCycles > 0 && cycle >= maxCycles) {
|
||||
console.error(`WORKER_MAX_CYCLES_REACHED cycles=${cycle}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
interface RunLock {
|
||||
runId: string;
|
||||
owner: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
leaseUntil: string;
|
||||
fencingToken: number;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
const runtimeDir = resolve(process.cwd(), "runtime");
|
||||
const lockPath = resolve(process.argv[3] ?? join(runtimeDir, "run.lock"));
|
||||
const cmd = (process.argv[2] ?? "status").toLowerCase();
|
||||
const owner = process.env.RUN_OWNER || "pandas-main";
|
||||
const leaseMs = Number(process.env.RUN_LEASE_MS ?? 10 * 60 * 1000);
|
||||
|
||||
async function readLock(path: string): Promise<RunLock | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as RunLock;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isExpired(lock: RunLock, now = Date.now()): boolean {
|
||||
const expiry = Date.parse(lock.leaseUntil);
|
||||
if (Number.isNaN(expiry)) return true;
|
||||
return expiry <= now;
|
||||
}
|
||||
|
||||
async function writeLock(path: string, lock: RunLock): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
async function acquire(path: string): Promise<number> {
|
||||
const now = Date.now();
|
||||
const current = await readLock(path);
|
||||
|
||||
if (current && !isExpired(current, now)) {
|
||||
console.error(`LOCKED runId=${current.runId} owner=${current.owner} leaseUntil=${current.leaseUntil}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const nextToken = (current?.fencingToken ?? 0) + 1;
|
||||
const runId = randomUUID();
|
||||
const lock: RunLock = {
|
||||
runId,
|
||||
owner,
|
||||
startedAt: new Date(now).toISOString(),
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
leaseUntil: new Date(now + leaseMs).toISOString(),
|
||||
fencingToken: nextToken,
|
||||
pid: process.pid,
|
||||
};
|
||||
|
||||
await writeLock(path, lock);
|
||||
console.log(`ACQUIRED runId=${lock.runId} fencingToken=${lock.fencingToken} leaseUntil=${lock.leaseUntil}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function renew(path: string): Promise<number> {
|
||||
const now = Date.now();
|
||||
const lock = await readLock(path);
|
||||
if (!lock) {
|
||||
console.error("MISSING_LOCK");
|
||||
return 1;
|
||||
}
|
||||
if (isExpired(lock, now)) {
|
||||
console.error(`EXPIRED runId=${lock.runId}`);
|
||||
return 1;
|
||||
}
|
||||
if (lock.owner !== owner) {
|
||||
console.error(`NOT_OWNER expected=${owner} current=${lock.owner}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const next: RunLock = {
|
||||
...lock,
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
leaseUntil: new Date(now + leaseMs).toISOString(),
|
||||
pid: process.pid,
|
||||
};
|
||||
await writeLock(path, next);
|
||||
console.log(`RENEWED runId=${next.runId} leaseUntil=${next.leaseUntil}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function release(path: string): Promise<number> {
|
||||
const lock = await readLock(path);
|
||||
if (!lock) {
|
||||
console.log("RELEASED noop=no-lock");
|
||||
return 0;
|
||||
}
|
||||
if (lock.owner !== owner && !isExpired(lock)) {
|
||||
console.error(`NOT_OWNER expected=${owner} current=${lock.owner}`);
|
||||
return 1;
|
||||
}
|
||||
await rm(path, { force: true });
|
||||
console.log(`RELEASED runId=${lock.runId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function status(path: string): Promise<number> {
|
||||
const lock = await readLock(path);
|
||||
if (!lock) {
|
||||
console.log("STATUS unlocked");
|
||||
return 0;
|
||||
}
|
||||
const expired = isExpired(lock);
|
||||
console.log(`STATUS ${expired ? "expired" : "locked"} runId=${lock.runId} owner=${lock.owner} leaseUntil=${lock.leaseUntil} fencingToken=${lock.fencingToken}`);
|
||||
return expired ? 2 : 0;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// quick sanity: parent runtime dir should be writable once
|
||||
await mkdir(dirname(lockPath), { recursive: true });
|
||||
try {
|
||||
await stat(dirname(lockPath));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let code = 0;
|
||||
if (cmd === "acquire") code = await acquire(lockPath);
|
||||
else if (cmd === "renew") code = await renew(lockPath);
|
||||
else if (cmd === "release") code = await release(lockPath);
|
||||
else code = await status(lockPath);
|
||||
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="$ROOT/runtime/evidence/verifier-status.json"
|
||||
TMP="$OUT.tmp"
|
||||
cd "$ROOT"
|
||||
./scripts/mc_dod_evaluator.py > "$TMP"
|
||||
python3 - "$TMP" "$OUT" <<'PY2'
|
||||
import datetime
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
tmp_path = pathlib.Path(sys.argv[1])
|
||||
out_path = pathlib.Path(sys.argv[2])
|
||||
raw = tmp_path.read_text(encoding="utf-8").strip()
|
||||
data = json.loads(raw) if raw else {}
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit("Verifier must output a JSON object")
|
||||
data.setdefault("ok", bool(data.get("status") == "DONE"))
|
||||
data.setdefault("status", data.get("status", "UNKNOWN"))
|
||||
data.setdefault("checkedItems", [])
|
||||
data.setdefault("failedChecks", [])
|
||||
data.setdefault("evidence", [])
|
||||
data.setdefault("nextRecommendedItem", "")
|
||||
data["updatedAt"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
out_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(data, ensure_ascii=False))
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
PY2
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
type HealStatus = "healthy" | "stalled" | "healed" | "failed";
|
||||
|
||||
interface HealReport {
|
||||
generatedAt: string;
|
||||
status: HealStatus;
|
||||
reason: string;
|
||||
thresholds: {
|
||||
evidenceMaxAgeMs: number;
|
||||
lockMaxAgeMs: number;
|
||||
};
|
||||
observed: {
|
||||
evidenceAgeMs?: number;
|
||||
lockAgeMs?: number;
|
||||
};
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
const runtimeDir = resolve(process.cwd(), "runtime");
|
||||
const evidencePath = resolve(process.argv[2] ?? join(runtimeDir, "evidence", "latest.json"));
|
||||
const lockPath = resolve(process.argv[3] ?? join(runtimeDir, "run.lock"));
|
||||
const reportPath = resolve(process.argv[4] ?? join(runtimeDir, "recovery", "latest.json"));
|
||||
const auditPath = resolve(process.argv[5] ?? join(dirname(reportPath), "audit.log"));
|
||||
|
||||
const evidenceMaxAgeMs = Number(process.env.STALL_EVIDENCE_MAX_AGE_MS ?? 30 * 60 * 1000);
|
||||
const lockMaxAgeMs = Number(process.env.STALL_LOCK_MAX_AGE_MS ?? 30 * 60 * 1000);
|
||||
const owner = process.env.RUN_OWNER ?? "pandas-main";
|
||||
|
||||
async function mtimeAgeMs(path: string): Promise<number | undefined> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return Date.now() - s.mtimeMs;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function runReleaseLock(): Promise<boolean> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const child = spawn("npm", ["run", "lock:release"], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, RUN_OWNER: owner },
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on("exit", (code) => resolvePromise(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function appendRecoveryAudit(line: string): Promise<void> {
|
||||
await mkdir(dirname(auditPath), { recursive: true });
|
||||
let old = "";
|
||||
try {
|
||||
old = await readFile(auditPath, "utf8");
|
||||
} catch {
|
||||
old = "";
|
||||
}
|
||||
await writeFile(auditPath, `${old}${line}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const evidenceAgeMs = await mtimeAgeMs(evidencePath);
|
||||
const lockAgeMs = await mtimeAgeMs(lockPath);
|
||||
|
||||
const stalledByEvidence = evidenceAgeMs === undefined || evidenceAgeMs > evidenceMaxAgeMs;
|
||||
const stalledByLock = lockAgeMs !== undefined && lockAgeMs > lockMaxAgeMs;
|
||||
|
||||
const actions: string[] = [];
|
||||
let status: HealStatus = "healthy";
|
||||
let reason = "within-threshold";
|
||||
|
||||
if (stalledByEvidence || stalledByLock) {
|
||||
status = "stalled";
|
||||
reason = `${stalledByEvidence ? "evidence-stale" : ""}${stalledByEvidence && stalledByLock ? "+" : ""}${stalledByLock ? "lock-stale" : ""}`;
|
||||
|
||||
const released = await runReleaseLock();
|
||||
actions.push(released ? "release-lock-ok" : "release-lock-failed");
|
||||
|
||||
if (released) {
|
||||
status = "healed";
|
||||
actions.push("restart-chain-required");
|
||||
} else {
|
||||
status = "failed";
|
||||
}
|
||||
}
|
||||
|
||||
const report: HealReport = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
status,
|
||||
reason,
|
||||
thresholds: { evidenceMaxAgeMs, lockMaxAgeMs },
|
||||
observed: { evidenceAgeMs, lockAgeMs },
|
||||
actions,
|
||||
};
|
||||
|
||||
await mkdir(dirname(reportPath), { recursive: true });
|
||||
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
await appendRecoveryAudit(`${report.generatedAt} | ${report.status} | ${report.reason} | actions=${actions.join(",") || "none"}`);
|
||||
|
||||
console.log(`AUTO_HEAL_${report.status.toUpperCase()} reason=${report.reason} actions=${actions.join(",") || "none"}`);
|
||||
process.exit(report.status === "failed" ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PORT="${UI_SMOKE_PORT:-4516}"
|
||||
WAIT_SECONDS="${UI_SMOKE_WAIT_SECONDS:-10}"
|
||||
LOG_DIR="$ROOT/runtime"
|
||||
LOG_FILE="$LOG_DIR/ui-smoke-${PORT}.log"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
cd "$ROOT"
|
||||
|
||||
UI_MODE=true UI_PORT="$PORT" npm run dev >"$LOG_FILE" 2>&1 &
|
||||
PID=$!
|
||||
|
||||
cleanup() {
|
||||
kill "$PID" >/dev/null 2>&1 || true
|
||||
wait "$PID" 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_for_ui() {
|
||||
local deadline=$((SECONDS + WAIT_SECONDS))
|
||||
while true; do
|
||||
if curl -fsS "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "UI smoke failed: server did not become ready within ${WAIT_SECONDS}s." >&2
|
||||
cat "$LOG_FILE" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_ui
|
||||
curl -fsS "http://127.0.0.1:${PORT}/" | grep -E "Mission Control|Office|Usage|Language:" >/dev/null
|
||||
curl -fsS "http://127.0.0.1:${PORT}/docs?lang=en" | grep -E "Open document workbench|Mission Control|Docs" >/dev/null
|
||||
|
||||
printf 'UI smoke passed on http://127.0.0.1:%s\n' "$PORT"
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { computeBudgetSummary } from "../src/runtime/budget-governance";
|
||||
import type {
|
||||
ProjectStoreSnapshot,
|
||||
SessionStatusSnapshot,
|
||||
SessionSummary,
|
||||
TaskStoreSnapshot,
|
||||
} from "../src/types";
|
||||
|
||||
function main(): void {
|
||||
const sessions: SessionSummary[] = [
|
||||
{ sessionKey: "s1", agentId: "main", state: "running" },
|
||||
{ sessionKey: "s2", agentId: "main", state: "running" },
|
||||
{ sessionKey: "s3", agentId: "worker", state: "idle" },
|
||||
];
|
||||
|
||||
const statuses: SessionStatusSnapshot[] = [
|
||||
{
|
||||
sessionKey: "s1",
|
||||
tokensIn: 110,
|
||||
tokensOut: 30,
|
||||
cost: 5,
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
sessionKey: "s2",
|
||||
tokensIn: 15,
|
||||
tokensOut: 10,
|
||||
cost: 1,
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
sessionKey: "s3",
|
||||
tokensIn: 20,
|
||||
tokensOut: 5,
|
||||
cost: 0.5,
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const tasks: TaskStoreSnapshot = {
|
||||
tasks: [
|
||||
{
|
||||
projectId: "proj-1",
|
||||
taskId: "task-over",
|
||||
title: "Task Over",
|
||||
status: "in_progress",
|
||||
owner: "main",
|
||||
definitionOfDone: [],
|
||||
artifacts: [],
|
||||
rollback: { strategy: "manual", steps: [] },
|
||||
sessionKeys: ["s1"],
|
||||
budget: { tokensIn: 100, warnRatio: 0.8 },
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
projectId: "proj-1",
|
||||
taskId: "task-ok",
|
||||
title: "Task Ok",
|
||||
status: "todo",
|
||||
owner: "main",
|
||||
definitionOfDone: [],
|
||||
artifacts: [],
|
||||
rollback: { strategy: "manual", steps: [] },
|
||||
sessionKeys: ["s2"],
|
||||
budget: { totalTokens: 100, warnRatio: 0.8 },
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
agentBudgets: [
|
||||
{
|
||||
agentId: "main",
|
||||
thresholds: { totalTokens: 200, warnRatio: 0.8 },
|
||||
},
|
||||
],
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const projects: ProjectStoreSnapshot = {
|
||||
projects: [
|
||||
{
|
||||
projectId: "proj-1",
|
||||
title: "Project 1",
|
||||
status: "active",
|
||||
owner: "main",
|
||||
budget: { totalTokens: 120, warnRatio: 0.8 },
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const summary = computeBudgetSummary(sessions, statuses, tasks, projects);
|
||||
assert.equal(summary.total, 4);
|
||||
assert.equal(summary.ok, 1);
|
||||
assert.equal(summary.warn, 1);
|
||||
assert.equal(summary.over, 2);
|
||||
|
||||
const byId = new Map(summary.evaluations.map((item) => [`${item.scope}:${item.scopeId}`, item.status]));
|
||||
assert.equal(byId.get("task:task-over"), "over");
|
||||
assert.equal(byId.get("task:task-ok"), "ok");
|
||||
assert.equal(byId.get("project:proj-1"), "over");
|
||||
assert.equal(byId.get("agent:main"), "warn");
|
||||
|
||||
console.log("validate-budget-compute: ok");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const previousCwd = process.cwd();
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cc-task-store-"));
|
||||
|
||||
try {
|
||||
process.chdir(tempRoot);
|
||||
await mkdir(join(tempRoot, "runtime"), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempRoot, "runtime", "tasks.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
tasks: [],
|
||||
agentBudgets: [],
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(tempRoot, "runtime", "projects.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
projects: [
|
||||
{
|
||||
projectId: "p1",
|
||||
title: "Project One",
|
||||
status: "active",
|
||||
owner: "ops",
|
||||
budget: {},
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const taskStore = await import("../src/runtime/task-store");
|
||||
|
||||
const created = await taskStore.createTask({
|
||||
projectId: "p1",
|
||||
taskId: "task-1",
|
||||
title: "Ship endpoint",
|
||||
owner: "main",
|
||||
dueAt: "2026-03-03T10:00:00.000Z",
|
||||
sessionKeys: ["agent:main:main"],
|
||||
status: "todo",
|
||||
});
|
||||
assert.equal(created.projectId, "p1");
|
||||
assert.equal(created.task.taskId, "task-1");
|
||||
assert.equal(created.task.status, "todo");
|
||||
assert.equal(created.task.dueAt, "2026-03-03T10:00:00.000Z");
|
||||
|
||||
const updated = await taskStore.updateTaskStatus({
|
||||
taskId: "task-1",
|
||||
projectId: "p1",
|
||||
status: "blocked",
|
||||
});
|
||||
assert.equal(updated.task.status, "blocked");
|
||||
|
||||
const store = await taskStore.loadTaskStore();
|
||||
const listed = taskStore.listTasks(store);
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].taskId, "task-1");
|
||||
assert.equal(listed[0].status, "blocked");
|
||||
|
||||
await assert.rejects(
|
||||
taskStore.createTask({
|
||||
projectId: "missing",
|
||||
taskId: "task-2",
|
||||
title: "Invalid",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof taskStore.TaskStoreValidationError &&
|
||||
error.statusCode === 404,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
taskStore.updateTaskStatus({
|
||||
taskId: "task-1",
|
||||
projectId: "p1",
|
||||
status: "bad-status",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof taskStore.TaskStoreValidationError &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
|
||||
console.log("validate-task-store: ok");
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,219 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
interface RunLock {
|
||||
runId: string;
|
||||
owner: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
leaseUntil: string;
|
||||
fencingToken: number;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
interface RecoveryReport {
|
||||
generatedAt: string;
|
||||
status: "healthy" | "stalled" | "healed" | "failed";
|
||||
reason: string;
|
||||
thresholds: {
|
||||
evidenceMaxAgeMs: number;
|
||||
lockMaxAgeMs: number;
|
||||
};
|
||||
observed: {
|
||||
evidenceAgeMs?: number;
|
||||
lockAgeMs?: number;
|
||||
};
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
interface DoDStatus {
|
||||
status?: "DONE" | "NOT_DONE";
|
||||
passed?: boolean;
|
||||
}
|
||||
|
||||
interface WorkerHeartbeat {
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
function isExpired(lock: RunLock, nowMs = Date.now()): boolean {
|
||||
const expiry = Date.parse(lock.leaseUntil);
|
||||
if (Number.isNaN(expiry)) return true;
|
||||
return expiry <= nowMs;
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (code === "EPERM") return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLock(path: string): Promise<RunLock | null> {
|
||||
return await readJson<RunLock>(path);
|
||||
}
|
||||
|
||||
async function runNodeScript(
|
||||
scriptPath: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return await new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", rejectPromise);
|
||||
child.on("close", (code) => resolvePromise({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function readRecovery(path: string): Promise<RecoveryReport | null> {
|
||||
return await readJson<RecoveryReport>(path);
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
async function inspectWorker(runtimeDir: string): Promise<{ active: boolean; detail: string }> {
|
||||
const heartbeatPath = resolve(runtimeDir, "health", "worker-heartbeat.json");
|
||||
const hb = await readJson<WorkerHeartbeat>(heartbeatPath);
|
||||
const active = hb?.pid !== undefined && isPidAlive(hb.pid);
|
||||
if (active) {
|
||||
return { active: true, detail: `worker-alive pid=${hb?.pid}` };
|
||||
}
|
||||
return { active: false, detail: "worker-not-alive" };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const runtimeDir = resolve(process.cwd(), "runtime");
|
||||
const owner = process.env.RUN_OWNER ?? "pandas-main";
|
||||
const lockPath = resolve(process.argv[2] ?? join(runtimeDir, "run.lock"));
|
||||
const evidencePath = resolve(process.argv[3] ?? join(runtimeDir, "evidence", "latest.json"));
|
||||
const recoveryPath = resolve(process.argv[4] ?? join(runtimeDir, "recovery", "latest.json"));
|
||||
const reportPath = resolve(process.argv[5] ?? join(dirname(recoveryPath), "watchdog-latest.json"));
|
||||
const dodStatusPath = resolve(runtimeDir, "evidence", "dod-status.json");
|
||||
|
||||
const lockBefore = await readLock(lockPath);
|
||||
const lockExpired = lockBefore ? isExpired(lockBefore) : true;
|
||||
|
||||
const actions: string[] = [];
|
||||
let lockAction = "none";
|
||||
|
||||
if (lockBefore && !lockExpired && lockBefore.owner !== owner) {
|
||||
console.error(
|
||||
`WATCHDOG_LOCK_CONFLICT expected=${owner} current=${lockBefore.owner} leaseUntil=${lockBefore.leaseUntil}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const runLockScript = resolve(process.cwd(), "scripts", "run-lock.ts");
|
||||
if (!lockBefore || lockExpired) {
|
||||
lockAction = "acquire";
|
||||
const acquire = await runNodeScript(runLockScript, ["acquire", lockPath], {
|
||||
...process.env,
|
||||
RUN_OWNER: owner,
|
||||
});
|
||||
if (acquire.code !== 0) {
|
||||
console.error(`WATCHDOG_LOCK_ACQUIRE_FAILED ${acquire.stderr.trim() || acquire.stdout.trim()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
actions.push("lock-acquire");
|
||||
} else {
|
||||
lockAction = "renew";
|
||||
const renew = await runNodeScript(runLockScript, ["renew", lockPath], {
|
||||
...process.env,
|
||||
RUN_OWNER: owner,
|
||||
});
|
||||
if (renew.code !== 0) {
|
||||
console.error(`WATCHDOG_LOCK_RENEW_FAILED ${renew.stderr.trim() || renew.stdout.trim()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
actions.push("lock-renew");
|
||||
}
|
||||
|
||||
const healScript = resolve(process.cwd(), "scripts", "stall-auto-heal.ts");
|
||||
const heal = await runNodeScript(healScript, [evidencePath, lockPath, recoveryPath], {
|
||||
...process.env,
|
||||
RUN_OWNER: owner,
|
||||
});
|
||||
if (heal.code !== 0) {
|
||||
console.error(`WATCHDOG_HEAL_FAILED ${heal.stderr.trim() || heal.stdout.trim()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
actions.push("heal-check");
|
||||
|
||||
const dod = await readJson<DoDStatus>(dodStatusPath);
|
||||
const isNotDone = dod?.status === "NOT_DONE" || dod?.passed === false || dod == null;
|
||||
if (isNotDone) {
|
||||
const worker = await inspectWorker(runtimeDir);
|
||||
actions.push(worker.detail);
|
||||
// Single starter rule: watchdog never starts worker directly.
|
||||
// Supervisor is the only component allowed to (re)start resident worker.
|
||||
if (!worker.active) {
|
||||
actions.push("restart-delegated-to-supervisor");
|
||||
}
|
||||
} else {
|
||||
actions.push("dod-done");
|
||||
}
|
||||
|
||||
const lockAfter = await readLock(lockPath);
|
||||
const recovery = await readRecovery(recoveryPath);
|
||||
const outcome =
|
||||
recovery?.status === "healed" ? "healed" : recovery?.status === "failed" ? "failed" : "healthy";
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
owner,
|
||||
lockPath,
|
||||
lockAction,
|
||||
lock: lockAfter,
|
||||
evidencePath,
|
||||
recoveryPath,
|
||||
recovery,
|
||||
dodStatus: dod?.status ?? "UNKNOWN",
|
||||
outcome,
|
||||
actions,
|
||||
};
|
||||
await writeJsonAtomic(reportPath, report);
|
||||
|
||||
if (outcome === "failed") {
|
||||
console.error(`WATCHDOG_FAILED report=${reportPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`WATCHDOG_OK outcome=${outcome} action=${lockAction} report=${reportPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user