diff --git a/docs/EXECUTION_HEALTH.md b/docs/EXECUTION_HEALTH.md new file mode 100644 index 00000000..c65333aa --- /dev/null +++ b/docs/EXECUTION_HEALTH.md @@ -0,0 +1,39 @@ +# 执行健康与恢复顺序 + +这份报告只记录当前可复验的执行阻塞,不替代 `docs/EXECUTION_QUEUE.md`、parent +manifest 或任务卡。先运行廉价预检,再运行 Blender/WASM 或浏览器命令: + +```bash +node tools/web/check-execution-health.mjs +``` + +预检会一次性检查当前 queue、parent `nextTask`、task index、catalog 唯一 active +记录、紧凑任务卡,以及当前 gap 的 fixture/generator。它在发现阻塞时退出码为 `2`, +不会启动 Blender、加载 WASM 或写入任何证据。 + +## 当前基线(2026-08-24) + +- `npm --prefix web run test:task-context`:通过,约 `708 ms`。 +- `npm --prefix web run test:context-governance`:失败,约 `732 ms`;index/catalog + active 仍是 `M16-GAP-00281`,queue 已指向 `M16-GAP-00282`。 +- `node tools/web/check-generated-gap.mjs --task M16-GAP-00282`:预检失败,约 + `131 ms`;任务仍是 `pending`,fixture 和 generator 均不存在。 +- `npm --prefix web test`:约 `13.5 s`,`398/417` 通过;19 个失败来自历史 + manifest 的 artifact SHA-256 漂移,不能通过放宽校验解决。 +- `npm --prefix web run typecheck`:约 `15.7 s`,通过。 +- `npm --prefix web run lint`:约 `12.8 s`,通过。 + +## 恢复顺序 + +1. 先修复 queue、plan、catalog、index 的单一指针来源,并重新生成 index;不得手工 + 修改 active 状态或 offset。 +2. 为当前任务生成非空紧凑卡: + `node tools/web/generate-task-card.mjs --task M16-GAP-00282`。 +3. 只有任务变为 `active` 且 fixture/generator 真实存在后,才运行专项 gap 命令。 +4. 历史 manifest hash 漂移需要不可变的源快照或经审计的重签流程;禁止在 checker 中 + 忽略漂移,也不要把 `CANCELLED`、metadata-only 或 negative-only 证据计为功能完成。 +5. 专项任务通过后才运行浏览器验收;浏览器门永久限制为 Chromium。 + +`tools/web/check-generated-gap.mjs` 现在先执行同一套轻量预检,并把状态、缺失输入 +和修复方向打印成单行诊断;只有预检通过才动态加载 WASM。这避免无效任务浪费启动时间, +也让失败停在真正的输入问题上。 diff --git a/docs/tasks/M16-GAP-00282.md b/docs/tasks/M16-GAP-00282.md new file mode 100644 index 00000000..d65a1092 --- /dev/null +++ b/docs/tasks/M16-GAP-00282.md @@ -0,0 +1,37 @@ +# M16-GAP-00282: operator:asset.screenshot_preview LOCAL_EXACT slice + +- task: M16-GAP-00282 +- parent: M16-GAP-00281 +- status: in_progress +- gap: operator:asset.screenshot_preview +- ownerFamily: OPERATOR +- targetImplementationClass: LOCAL_EXACT + +## 目标 + +Make the same minimal fixture produce observable operator:asset.screenshot_preview data in Blender desktop and WASM/Main, with save/reopen stability. Change only this gap; do not expand to other data-blocks, editors, or browsers. + +## 输入与范围 + +- Fixture: `tests/files/web/generated/M16-GAP-00282-operator-asset.screenshot_preview.blend` +- Generator: `tools/web/generated/M16-GAP-00282.py` +- Production: `blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp` +- Checker: `tools/web/check-generated-gap.mjs` +- Do: field read, desktop/WASM comparison, save/reopen, structured report. +- Do not: other gaps, Firefox/WebKit, or full editor behavior. + +## 验收 + +build_blender_5.2.0/bin/blender -b --factory-startup --python tools/web/generated/M16-GAP-00282.py -- tests/files/web/generated/M16-GAP-00282-operator-asset.screenshot_preview.blend +npm --prefix web run test:generated-gap -- --task M16-GAP-00282 +node tools/web/check-generated-gap.mjs --task M16-GAP-00282 + +Malformed/unsupported, cancellation, duplicate, over-budget, or hash-drift cases must keep in_progress; they must not change Main revision or advance nextTask. + +## 交付与回滚 + +- Reports: `tests/golden/M16-GAP-00282/` +- Status: `docs/status/M16-GAP-00282.md` +- Manifest: `tests/golden/M16-GAP-00282/manifest.json` +- Handoff: `node tools/web/check-task-context.mjs --task M16-GAP-00282 --write` +- Rollback: remove this task production entry, fixture, tests, reports, manifest, status, and context; restore the parent as queue tail without rewriting parent evidence. diff --git a/tools/web/check-execution-health.mjs b/tools/web/check-execution-health.mjs new file mode 100644 index 00000000..b36bca57 --- /dev/null +++ b/tools/web/check-execution-health.mjs @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + buildTaskContext, + parseQueue, + readIndexedTask, + verifyTaskIndex, +} from "./task-context-lib.mjs"; +import { collectGeneratedGapPreflight } from "./generated-gap-preflight.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const requested = process.argv.indexOf("--task"); +const requestedTask = requested >= 0 ? process.argv[requested + 1] : undefined; +const issues = []; +const add = (code, detail) => issues.push({ code, detail }); +const relative = (file) => path.relative(repoRoot, file).replaceAll(path.sep, "/"); + +let queue; +let index; +let entry; +try { + queue = parseQueue(); + index = verifyTaskIndex(); + const task = requestedTask ?? queue.currentTask; + const checkingCurrent = requestedTask === undefined; + entry = readIndexedTask(task); + if (!entry) add("TASK_NOT_INDEXED", task); + + const parentPath = path.resolve(repoRoot, queue.parentManifest); + if (checkingCurrent && !fs.existsSync(parentPath)) add("PARENT_MANIFEST_MISSING", queue.parentManifest); + else if (checkingCurrent) { + const parent = JSON.parse(fs.readFileSync(parentPath, "utf8")); + if (parent.nextTask !== queue.currentTask) add("PARENT_NEXT_TASK_MISMATCH", `${parent.nextTask ?? "NONE"}!=${queue.currentTask}`); + } + + if (index) { + if (checkingCurrent && index.activeTask !== queue.currentTask) add("INDEX_ACTIVE_TASK_MISMATCH", `${index.activeTask ?? "NONE"}!=${queue.currentTask}`); + const catalogPath = path.resolve(repoRoot, index.catalog.path); + const active = fs.readFileSync(catalogPath, "utf8").trimEnd().split("\n").filter(Boolean) + .map((line) => JSON.parse(line)).filter((record) => record.state === "active"); + if (checkingCurrent && active.length !== 1) add("CATALOG_ACTIVE_TASK_COUNT", `count=${active.length}`); + else if (checkingCurrent && active[0].id !== queue.currentTask) add("CATALOG_ACTIVE_TASK_MISMATCH", `${active[0].id}!=${queue.currentTask}`); + } + + const cardPath = path.join(repoRoot, "docs/tasks", `${task}.md`); + if (!fs.existsSync(cardPath) || fs.statSync(cardPath).size === 0) add("TASK_CARD_MISSING", relative(cardPath)); + else if (checkingCurrent) { + const context = buildTaskContext(task); + if (context.context.task !== task) add("TASK_CONTEXT_MISMATCH", `${context.context.task}!=${task}`); + } + + for (const issue of collectGeneratedGapPreflight({ root: repoRoot, task: requestedTask ?? queue.currentTask, entry })) { + add(issue.code, issue.detail); + } +} +catch (error) { + add("HEALTH_CHECK_ERROR", error instanceof Error ? error.message : String(error)); +} + +const report = { + schemaVersion: 1, + operation: "WEB_EXECUTION_HEALTH", + status: issues.length === 0 ? "PASS" : "BLOCKED", + queueTask: queue?.currentTask ?? null, + checkedTask: requestedTask ?? queue?.currentTask ?? null, + indexActiveTask: index?.activeTask ?? null, + issueCount: issues.length, + issues, + remediation: [ + ...(issues.some(({ code }) => ["INDEX_ACTIVE_TASK_MISMATCH", "CATALOG_ACTIVE_TASK_MISMATCH", "CATALOG_ACTIVE_TASK_COUNT", "PARENT_NEXT_TASK_MISMATCH"].includes(code)) + ? ["repair the queue/index/catalog pointer through the repository generator"] : []), + ...(issues.some(({ code }) => code === "TASK_CARD_MISSING") + ? ["generate the compact task card before running a gap checker"] : []), + ...(issues.some(({ code }) => ["TASK_NOT_RUNNABLE", "INPUT_MISSING", "INPUT_NOT_FILE"].includes(code)) + ? ["do not start Blender or load WASM until the task state and inputs are ready"] : []), + ], +}; +process.stdout.write(`${JSON.stringify(report)}\n`); +if (issues.length !== 0) process.exitCode = 2; diff --git a/tools/web/check-generated-gap.mjs b/tools/web/check-generated-gap.mjs index bcdcce4b..5d1c38d5 100644 --- a/tools/web/check-generated-gap.mjs +++ b/tools/web/check-generated-gap.mjs @@ -4,13 +4,21 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import crypto from "node:crypto"; import { spawnSync } from "node:child_process"; -import factory from "../../web/app/src/vendor/blender/web_engine.js"; import { readIndexedTask, verifyTaskIndex } from "./task-context-lib.mjs"; +import { collectGeneratedGapPreflight, formatGeneratedGapPreflight } from "./generated-gap-preflight.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const task = process.argv[process.argv.indexOf("--task") + 1]; +const taskArgument = process.argv.indexOf("--task"); +const task = taskArgument >= 0 ? process.argv[taskArgument + 1] : undefined; verifyTaskIndex(); -const entry = readIndexedTask(task); assert.ok(entry); assert.ok(["active", "completed"].includes(entry.state)); +const entry = readIndexedTask(task); +const preflightIssues = collectGeneratedGapPreflight({ root, task, entry }); +if (preflightIssues.length !== 0) { + process.stderr.write(`${formatGeneratedGapPreflight(task, preflightIssues)}\n`); + process.exitCode = 2; + process.exit(); +} const wasmBinary = fs.readFileSync(path.join(root, "web/app/src/vendor/blender/web_engine.wasm")); +const factory = async (options) => (await import("../../web/app/src/vendor/blender/web_engine.js")).default(options); const open = (engine, handle, input) => { const pointer = engine._malloc(input.byteLength); engine.HEAPU8.set(input, pointer); try { assert.equal(engine._web_engine_open_blend(handle, pointer, input.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); } finally { engine._free(pointer); } }; const output = (engine, handle, fn, owned = false) => { const data = engine._malloc(4); const length = engine._malloc(4); try { assert.equal(fn(handle, data, length), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); const pointer = engine.HEAPU32[data >>> 2]; const size = engine.HEAPU32[length >>> 2]; const result = engine.HEAPU8.slice(pointer, pointer + size); if (owned) engine._web_engine_free_buffer(pointer); return result; } finally { engine._free(data); engine._free(length); } }; const snapshot = (engine, handle) => JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_get_scene_snapshot))); diff --git a/tools/web/generated-gap-preflight.mjs b/tools/web/generated-gap-preflight.mjs new file mode 100644 index 00000000..56f2d82e --- /dev/null +++ b/tools/web/generated-gap-preflight.mjs @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import path from "node:path"; + +const TASK_ID = /^M\d+-GAP-\d{5}$/u; + +function add(issues, code, detail) { + issues.push({ code, detail }); +} + +/** + * Validate the cheap, deterministic inputs before a generated-gap checker + * loads the WASM module or starts Blender. This is intentionally independent + * of the task-specific assertions in check-generated-gap.mjs. + */ +export function collectGeneratedGapPreflight({ root, task, entry }) { + const issues = []; + if (typeof task !== "string" || !TASK_ID.test(task)) { + add(issues, "TASK_ARGUMENT_INVALID", "use --task Mxx-GAP-nnnnn"); + return issues; + } + if (!entry) { + add(issues, "TASK_NOT_INDEXED", task); + return issues; + } + if (!["active", "completed"].includes(entry.state)) { + add(issues, "TASK_NOT_RUNNABLE", `${task} state=${entry.state}; expected active or completed`); + } + + const required = []; + if (entry.fixture?.path) required.push(["fixture", entry.fixture.path]); + const generator = typeof entry.desktopCommand === "string" + ? entry.desktopCommand.match(/\s--python\s+(\S+)/u)?.[1] + : undefined; + if (generator) required.push(["generator", generator]); + for (const [role, relativePath] of required) { + const resolved = path.resolve(root, relativePath); + if (!fs.existsSync(resolved)) add(issues, "INPUT_MISSING", `${role}=${relativePath}`); + else if (!fs.statSync(resolved).isFile()) add(issues, "INPUT_NOT_FILE", `${role}=${relativePath}`); + } + return issues; +} + +export function formatGeneratedGapPreflight(task, issues) { + return `generated-gap-preflight-failed task=${task ?? "MISSING"} issues=${issues.map((issue) => `${issue.code}:${issue.detail}`).join(",")}`; +} diff --git a/web/tests/unit/generated-gap-preflight.test.mjs b/web/tests/unit/generated-gap-preflight.test.mjs new file mode 100644 index 00000000..681017d9 --- /dev/null +++ b/web/tests/unit/generated-gap-preflight.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { collectGeneratedGapPreflight } from "../../../tools/web/generated-gap-preflight.mjs"; +import { readIndexedTask, root, verifyTaskIndex } from "../../../tools/web/task-context-lib.mjs"; + +test("generated gap preflight accepts a runnable task with existing inputs", () => { + verifyTaskIndex(); + const entry = readIndexedTask("M16-GAP-00281"); + assert.deepEqual(collectGeneratedGapPreflight({ root, task: "M16-GAP-00281", entry }), []); +}); + +test("generated gap preflight reports state and input failures before WASM startup", () => { + const issues = collectGeneratedGapPreflight({ + root, + task: "M16-GAP-00282", + entry: { + id: "M16-GAP-00282", + state: "pending", + fixture: { path: "tests/files/web/generated/does-not-exist.blend" }, + desktopCommand: "blender --python tools/web/generated/does-not-exist.py -- fixture.blend", + }, + }); + assert.deepEqual(issues.map(({ code }) => code), ["TASK_NOT_RUNNABLE", "INPUT_MISSING", "INPUT_MISSING"]); +}); + +test("generated gap preflight rejects a missing task argument", () => { + assert.deepEqual(collectGeneratedGapPreflight({ root, task: undefined, entry: null }).map(({ code }) => code), ["TASK_ARGUMENT_INVALID"]); +});