Files

652 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});