import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { execFileSync, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { batchContract, commandBudget, completedBatch } from "./wbv2-batch-contract.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const abs = (file) => path.join(root, file); const read = (file) => fs.readFileSync(abs(file), "utf8"); const json = (file) => JSON.parse(read(file)); const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(abs(file))).digest("hex"); const dynamicInventoryPath = "tests/golden/WBV2/dynamic-inventory.json"; const out = `tests/golden/${process.argv[process.argv.indexOf("--task") + 1]}`; const writeJson = (file, value) => { fs.mkdirSync(path.dirname(abs(file)), { recursive: true }); fs.writeFileSync(abs(file), `${JSON.stringify(value, null, 2)}\n`); }; const writeText = (file, value) => { fs.mkdirSync(path.dirname(abs(file)), { recursive: true }); fs.writeFileSync(abs(file), value); }; const taskArg = process.argv.indexOf("--task"); const task = taskArg >= 0 ? process.argv[taskArg + 1] : null; if (!task || !process.argv.includes("--write")) throw new Error("usage: node tools/web/run-wbv2-module-task.mjs --task --write"); const queue = read("docs/EXECUTION_QUEUE.md"); const currentTask = queue.match(/\| 当前任务 \| `([^`]+)` \|/u)?.[1]; const index = json("tests/golden/WBV2/task-index.json"); const plan = json(index.source.path); const dynamicInventory = json(dynamicInventoryPath); const record = plan.tasks.find((candidate) => candidate.id === task); if (currentTask !== task) throw new Error(`module task is not the authoritative queue task: ${currentTask}!=${task}`); if (!record?.module) throw new Error(`task ${task} is not a module task`); const parentId = record.dependsOn?.at(-1); const parentPath = `tests/golden/${parentId}/manifest.json`; const parent = json(parentPath); if (parent.status !== "done" || parent.nextTask !== task) throw new Error(`module parent is not a completed handoff: ${parentId}`); if (!record.module.sourceBacked) throw new Error(`module ${record.module.moduleId} has no source-backed implementation`); const contract = batchContract(task, record.module); if (!record.batchId) record.batchId = contract.batchId; if (!record.selectedRecordIds) record.selectedRecordIds = contract.selectedRecordIds; if (!record.module.batchId) record.module.batchId = contract.batchId; if (!record.module.selectedRecordIds) record.module.selectedRecordIds = contract.selectedRecordIds; const commandContract = commandBudget(record.module.commands ?? []); if (!commandContract.withinBudget) throw new Error(`task ${task} exceeds command budget: ${JSON.stringify(commandContract)}`); const existingManifestPath = `${out}/manifest.json`; if (fs.existsSync(abs(existingManifestPath))) { try { const existingManifest = json(existingManifestPath); const legacyCompleted = existingManifest.status === "done" && existingManifest.nextTask === null && JSON.stringify(existingManifest.selectedRecordIds ?? record.module.selectedRecordIds ?? []) === JSON.stringify(contract.selectedRecordIds); if (completedBatch(existingManifest, task, record.module) || legacyCompleted) { process.stdout.write(`module-batch-idempotent task=${task} batchId=${contract.batchId} plannerRequired=true\n`); process.exit(0); } } catch { /* A partial manifest is not evidence and follows the normal path. */ } } if (record.status !== "in_progress" || record.ready !== true) throw new Error(`module task ${task} is not active`); for (const source of record.module.sourcePaths ?? []) if (!fs.existsSync(abs(source))) throw new Error(`module source is missing: ${source}`); const sourceAuditPaths = (record.module.sourcePaths ?? []).filter((source) => /^(blender-5\.2\.0\/source\/blender\/|web\/protocol\/|web\/app\/src\/(?!vendor\/))/u.test(source) && !/\.(?:blend|wasm|json|log)$/u.test(source)); const sourceBefore = Object.fromEntries(sourceAuditPaths.map((source) => [source, sha(source)])); const reusedReceipts = (record.module.reuseReceipts ?? []).map((receipt) => ({ ...receipt, manifestSha256: sha(receipt.manifest), reportSha256: sha(receipt.report), })); const reportPath = `${out}/module-receipt.json`; const statusPath = `docs/status/${task}.md`; const logPath = `${out}/module-run.log`; const results = []; let failed = false; const reuseRequested = !process.argv.includes("--fresh") && process.env.WBV2_REUSE_EXISTING_EVIDENCE !== "0"; const sameIds = (left, right) => Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => value === right[index]); const reusableProductionEvidence = () => { if (!reuseRequested) return null; const configuredReusePath = record.module.reuseExistingEvidence; const evidencePath = (configuredReusePath && fs.existsSync(abs(configuredReusePath))) ? configuredReusePath : record.module.evidencePaths?.find((file) => fs.existsSync(abs(file))); if (!evidencePath) return null; let evidence; try { evidence = json(evidencePath); } catch { return null; } if ((evidence.task !== task && !configuredReusePath) || evidence.browser !== "chromium" || evidence.queueMutation !== false || evidence.error !== undefined) return null; if (evidence.reusedEvidence?.length && evidence.dependencyProofs?.length) { for (const reused of evidence.reusedEvidence) { if (!reused.path || !fs.existsSync(abs(reused.path))) return null; let prior; try { prior = json(reused.path); } catch { return null; } const paths = Array.isArray(prior.fixtures) ? prior.fixtures : prior.fixture ? [prior.fixture] : []; const entries = Array.isArray(prior.fixtures) ? paths.map((fixture) => prior[fixture]) : [prior]; if (!paths.length || entries.some((entry) => !entry || entry.error !== undefined || (entry.saveReopenExact !== undefined && entry.saveReopenExact !== true) || (entry.deterministicExact !== undefined && entry.deterministicExact !== true) || (entry.exact !== undefined && entry.exact !== true))) return null; if (Array.isArray(prior.fixtures)) { if (paths.some((fixture) => !fs.existsSync(abs(fixture)) || prior.fixtureSha256?.[fixture] !== sha(fixture))) return null; } else if (!fs.existsSync(abs(prior.fixture)) || prior.fixtureSha256 !== sha(prior.fixture)) return null; } if (!configuredReusePath) { if (!fs.existsSync(abs(reportPath))) return null; let previousReceipt; try { previousReceipt = json(reportPath); } catch { return null; } if (!['PASS', 'BLOCKED'].includes(previousReceipt.status)) return null; if (!sourceAuditPaths.every((source) => previousReceipt.source?.after?.[source] === sourceBefore[source])) return null; } return { evidencePath, evidence }; } const fixturePaths = Array.isArray(evidence.fixtures) ? evidence.fixtures : evidence.fixture ? [evidence.fixture] : []; if (!fixturePaths.length) return null; const fixtureEvidence = Array.isArray(evidence.fixtures) ? fixturePaths.map((fixture) => evidence[fixture]) : [evidence]; if (fixtureEvidence.some((entry) => !entry || entry.error !== undefined || entry.saveReopenExact !== true || entry.deterministicExact !== true)) return null; if (Array.isArray(evidence.fixtures)) { if (!evidence.fixtureSha256 || typeof evidence.fixtureSha256 !== "object") return null; if (fixturePaths.some((fixture) => !fs.existsSync(abs(fixture)) || evidence.fixtureSha256[fixture] !== sha(fixture))) return null; } else if (!fs.existsSync(abs(evidence.fixture)) || evidence.fixtureSha256 !== sha(evidence.fixture)) return null; const expectedUnits = record.module.coverageBindings ?? []; if (expectedUnits.length && !sameIds(evidence.sourceUnits, expectedUnits)) return null; if (!configuredReusePath) { if (!fs.existsSync(abs(reportPath))) return null; let previousReceipt; try { previousReceipt = json(reportPath); } catch { return null; } if (!['PASS', 'BLOCKED'].includes(previousReceipt.status)) return null; if (!sourceAuditPaths.every((source) => previousReceipt.source?.after?.[source] === sourceBefore[source])) return null; } return { evidencePath, evidence }; }; const reusedEvidence = reusableProductionEvidence(); if (reusedEvidence && record.module.evidencePaths?.[0] && reusedEvidence.evidencePath !== record.module.evidencePaths[0]) { writeJson(record.module.evidencePaths[0], { ...reusedEvidence.evidence, task, reusedFrom: reusedEvidence.evidencePath }); } if (reusedEvidence) { for (const command of record.module.commands ?? []) { results.push({ command, exitCode: 0, reused: true, durationMs: 0, output: `reused existing Chromium production evidence: ${reusedEvidence.evidencePath}\n` }); } } else { for (const command of record.module.commands ?? []) { const started = Date.now(); const result = spawnSync("bash", ["-lc", command], { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; results.push({ command, exitCode: result.status ?? 1, durationMs: Date.now() - started, output }); if ((result.status ?? 1) !== 0) { failed = true; break; } } } fs.mkdirSync(abs(out), { recursive: true }); writeText(logPath, results.map((result) => `> ${result.command}\n${result.output}`).join("\n")); const sourceAfter = Object.fromEntries(sourceAuditPaths.map((source) => [source, sha(source)])); const sourceUnchanged = JSON.stringify(sourceBefore) === JSON.stringify(sourceAfter); const report = { schemaVersion: 1, task, operation: "MODULE_FUNCTION_EXECUTION", phase: failed || !sourceUnchanged ? "BLOCKED" : "CONTINUE", moduleId: record.module.moduleId, batchId: contract.batchId, selectedRecordIds: contract.selectedRecordIds, commandBudget: commandContract, status: failed || !sourceUnchanged ? "BLOCKED" : "PASS", source: { paths: record.module.sourcePaths, auditedPaths: sourceAuditPaths, before: sourceBefore, after: sourceAfter, unchanged: sourceUnchanged }, reusedReceipts, cases: { positive: record.module.positive, negative: record.module.negative, recovery: record.module.recovery }, commands: results.map(({ command, exitCode, durationMs, reused }) => ({ command, exitCode, durationMs, ...(reused ? { reused: true } : {}) })), verification: { required: plan.verificationPolicy.required, chromiumOnly: plan.verificationPolicy.browser === "chromium_only", fullFamilyParity: false }, efficiency: { existingEvidenceReused: Boolean(reusedEvidence), skippedCommands: reusedEvidence ? results.length : 0, totalDurationMs: results.reduce((total, result) => total + (result.durationMs ?? 0), 0), reusePolicy: reuseRequested ? "hash_bound_receipt" : "disabled" }, inventory: { historical: plan.inventory, dynamic: { path: dynamicInventoryPath, sha256: sha(dynamicInventoryPath), sourceTreeSha256: dynamicInventory.authority.sourceTreeSha256, unitCount: dynamicInventory.summary.unitCount, planningEstimate: dynamicInventory.policy.planningEstimate, hardTarget: dynamicInventory.policy.hardTarget, verifiedUnits: dynamicInventory.coverage.verifiedUnits, unverifiedUnits: dynamicInventory.coverage.unverifiedUnits } }, nextTask: failed || !sourceUnchanged ? task : plan.tasks[plan.tasks.findIndex((candidate) => candidate.id === task) + 1]?.id ?? null, }; writeJson(reportPath, report); if (failed || !sourceUnchanged) { writeText(statusPath, `# ${task} status\n\n- task: \`${task}\`; module: \`${record.module.moduleId}\`; status: \`in_progress\`\n- module execution blocked; no queue advance or parity claim\n- source unchanged: \`${sourceUnchanged}\`; failed command: \`${results.find((result) => result.exitCode !== 0)?.command ?? "source hash drift"}\`\n- dynamic source inventory: \`${dynamicInventory.summary.unitCount}\` units; verified \`${dynamicInventory.coverage.verifiedUnits}\`; unverified \`${dynamicInventory.coverage.unverifiedUnits}\`; planning estimate \`${dynamicInventory.policy.planningEstimate}\` (non-binding)\n- historical 6900-entry inventory remains read-only\n- nextTask: \`${task}\`\n`); process.exitCode = 2; } else { const nextTask = report.nextTask; const planIndex = plan.tasks.findIndex((candidate) => candidate.id === task); plan.tasks = plan.tasks.map((candidate, index) => index === planIndex ? { ...candidate, status: "done", ready: false } : candidate.id === nextTask ? { ...candidate, status: "in_progress", ready: true } : { ...candidate, ready: false }); if (!nextTask) plan.status = "COMPLETED_MODULE_EXECUTION"; writeJson(index.source.path, plan); execFileSync(process.execPath, [abs("tools/web/generate-v2-task-index.mjs"), "--plan-path", abs(index.source.path)], { cwd: root, stdio: "inherit" }); let updatedQueue = read("docs/EXECUTION_QUEUE.md"); if (nextTask) updatedQueue = updatedQueue.replace(/\| 当前任务 \| `[^`]+` \|/u, `| 当前任务 | \`${nextTask}\` |`).replace(/\| parent manifest \| `[^`]+` \|/u, `| parent manifest | \`tests/golden/${task}/manifest.json\` |`).replace(/\| 任务卡 \| \[[^\]]+\]\([^\)]+\) \|/u, `| 任务卡 | [\`tasks/${nextTask}.md\`](tasks/${nextTask}.md) |`); writeText("docs/EXECUTION_QUEUE.md", updatedQueue); writeText(statusPath, `# ${task} status\n\n- task: \`${task}\`; module: \`${record.module.moduleId}\`; status: \`done\`\n- source hashes stable: \`${sourceUnchanged}\`; commands passed: \`${results.length}\`\n- existing production evidence reused: \`${Boolean(reusedEvidence)}\`; skipped commands: \`${reusedEvidence ? results.length : 0}\`\n- positive Main mutation, negative no-mutation, recovery and Chromium gates are recorded in the module receipt\n- dynamic source inventory: \`${dynamicInventory.summary.unitCount}\` units; verified \`${dynamicInventory.coverage.verifiedUnits}\`; unverified \`${dynamicInventory.coverage.unverifiedUnits}\`; planning estimate \`${dynamicInventory.policy.planningEstimate}\` (non-binding)\n- historical 6900-entry inventory remains read-only; full-family parity: \`false\`\n- nextTask: \`${nextTask ?? "NONE"}\`\n`); const manifestPath = `${out}/manifest.json`; const evidenceArtifacts = Object.fromEntries((record.module.evidencePaths ?? []).filter((file) => fs.existsSync(abs(file))).map((file, item) => [`evidence${item + 1}`, { path: file, sha256: sha(file) }])); const artifacts = { parentManifest: { path: parentPath, sha256: sha(parentPath) }, ...Object.fromEntries(record.module.sourcePaths.map((source, item) => [`source${item + 1}`, { path: source, sha256: sha(source) }])), ...evidenceArtifacts, moduleReceipt: { path: reportPath, sha256: sha(reportPath) }, status: { path: statusPath, sha256: sha(statusPath) }, log: { path: logPath, sha256: sha(logPath) }, plan: { path: index.source.path, sha256: sha(index.source.path) }, taskContext: { path: `${out}/task-context.json`, sha256: sha(`${out}/task-context.json`) } }; writeJson(manifestPath, { schemaVersion: 1, task, parentTask: parentId, operation: "MODULE_FUNCTION_EXECUTION", phase: "CONTINUE", taskClass: "MODULE", status: "done", enablingTask: true, parityStateChange: false, runtime: "BLENDER_WEB_FUNCTION_MODULE", batchId: contract.batchId, selectedRecordIds: contract.selectedRecordIds, artifacts, assertions: { moduleId: record.module.moduleId, batchId: contract.batchId, selectedRecordIds: contract.selectedRecordIds, sourceBacked: true, positiveCases: record.module.positive.length, negativeCases: record.module.negative.length, recoveryCases: record.module.recovery.length, commandCount: results.length, commandBudget, reusedReceiptCount: reusedReceipts.length, sourceUnchanged, fullFamilyParity: false, dynamicInventoryPath, dynamicSourceTreeSha256: dynamicInventory.authority.sourceTreeSha256, dynamicUnitCount: dynamicInventory.summary.unitCount, dynamicVerifiedUnits: dynamicInventory.coverage.verifiedUnits, dynamicUnverifiedUnits: dynamicInventory.coverage.unverifiedUnits, planningEstimate: dynamicInventory.policy.planningEstimate, historicalInventoryTotal: 6900, historicalEffectiveCompleted: 290, historicalEffectivePending: 6609, historicalReproduction: false }, nextTask }); if (record.module.coverageBindings?.length) { execFileSync(process.execPath, [abs("tools/web/bind-wbv2-module-coverage.mjs"), "--task", task], { cwd: root, stdio: "inherit" }); execFileSync(process.execPath, [abs("tools/web/finalize-wbv2-module-batch.mjs"), "--task", task], { cwd: root, stdio: "inherit" }); } console.log(`module-task-complete task=${task} module=${record.module.moduleId} next=${nextTask} commands=${results.length}`); }