Files
workinf_Blender_Wasm/tools/web/task-context-lib.mjs
mes123456 10640aeb3c
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
Govern task context and advance execution pointer
2026-08-20 06:02:43 -04:00

380 lines
18 KiB
JavaScript
Raw 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 fs from "node:fs";
import crypto from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const CONTEXT_LIMITS = Object.freeze({
queueBytes: 4096,
taskBytes: 8192,
parentManifestBytes: 24576,
parentStatusBytes: 6144,
evidenceFiles: 12,
evidenceBytes: 8192,
contextTokens: 3500,
});
const read = (file) => fs.readFileSync(file, "utf8");
const json = (file) => JSON.parse(read(file));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
const tokenEstimate = (value) => Math.ceil(Buffer.byteLength(value, "utf8") / 4);
const queuePath = path.join(root, "docs/EXECUTION_QUEUE.md");
const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
const taskIndexPath = path.join(root, "tests/golden/M15-03A/task-index.json");
const sha256File = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
let cachedTaskIndex;
let taskIndexLoaded = false;
const safeName = (value) => value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "gap";
const expandTask = (task) => ({
...task,
fixture: { path: `tests/files/web/generated/${task.id}-${safeName(task.gapId)}.blend`, state: "REQUIRED" },
desktopCommand: `build_blender_5.2.0/bin/blender -b --factory-startup --python tools/web/generated/${task.id}.py -- tests/files/web/generated/${task.id}-${safeName(task.gapId)}.blend`,
webCommand: `npm --prefix web run test:generated-gap -- --task ${task.id}`,
comparator: `node tools/web/check-generated-gap.mjs --task ${task.id}`,
exitCriteria: ["desktop fixture evidence exists", "WASM uses the same fixture", "comparator passes", "save/reopen preserves Main", "manifest hashes all artifacts"],
});
export function parseQueue() {
const source = read(queuePath);
const current = source.match(/当前任务\s*[:]\s*`([^`]+)`/u)?.[1]
?? source.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1];
const parentManifest = source.match(/parent manifest\s*[:]\s*`?([^`\n]+)`?/iu)?.[1]?.trim()
?? source.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/u)?.[1];
if (!current || !parentManifest) throw new Error("EXECUTION_QUEUE.md must declare current task and parent manifest");
return { path: queuePath, source, currentTask: current, parentManifest };
}
function loadTaskIndex() {
if (taskIndexLoaded) return cachedTaskIndex;
taskIndexLoaded = true;
if (!fs.existsSync(taskIndexPath)) {
if (fs.existsSync(planPath)) throw new Error(`missing task index; run node tools/web/generate-task-index.mjs (${relative(taskIndexPath)})`);
return null;
}
const index = json(taskIndexPath);
if (index.schemaVersion !== 1 || !index.source?.path || !index.catalog?.path || !index.entries) {
throw new Error(`invalid task index: ${relative(taskIndexPath)}`);
}
cachedTaskIndex = index;
return cachedTaskIndex;
}
export function verifyTaskIndex() {
const index = loadTaskIndex();
if (!index) return null;
const sourcePath = path.join(root, index.source.path);
const catalogPath = path.join(root, index.catalog.path);
if (!fs.existsSync(sourcePath) || sha256File(sourcePath) !== index.source.sha256) {
throw new Error(`task index is stale; run node tools/web/generate-task-index.mjs (${relative(taskIndexPath)})`);
}
if (!fs.existsSync(catalogPath) || sha256File(catalogPath) !== index.catalog.sha256) {
throw new Error(`task catalog hash mismatch; run node tools/web/generate-task-index.mjs (${relative(taskIndexPath)})`);
}
return index;
}
function indexedEntry(task, index = loadTaskIndex()) {
const metadata = index?.entries?.[task];
if (!metadata) return null;
const catalogPath = path.join(root, index.catalog.path);
if (!fs.existsSync(catalogPath)) throw new Error(`missing task catalog: ${relative(catalogPath)}`);
const handle = fs.openSync(catalogPath, "r");
try {
const buffer = Buffer.alloc(metadata.length);
fs.readSync(handle, buffer, 0, metadata.length, metadata.offset);
const entry = { ...expandTask(JSON.parse(buffer.toString("utf8"))), previous: metadata.previous, next: metadata.next };
if (entry.id !== task) throw new Error(`task catalog id mismatch for ${task}`);
return entry;
} finally {
fs.closeSync(handle);
}
}
export function readIndexedTask(task) {
return indexedEntry(task);
}
function planEntry(task) {
const indexed = indexedEntry(task);
if (indexed) return indexed;
return null;
}
function planParent(task) {
const index = loadTaskIndex();
if (index?.entries?.[task]) return index.entries[task].previous;
return null;
}
function cardFields(source) {
const get = (name) => source.match(new RegExp(`^[-*]?\\s*\\x60?${name}\\x60?\\s*[:]\\s*\\x60?([^\\n\\x60]+)`, "imu"))?.[1]?.trim();
const task = get("task");
const parent = get("parent");
const status = get("status");
const gap = get("gap");
const ownerFamily = get("ownerFamily");
const targetImplementationClass = get("targetImplementationClass");
return { task, parent, status, gap, ownerFamily, targetImplementationClass };
}
function commandLines(source) {
const candidates = [];
for (const line of source.split("\n")) {
const clean = line.replaceAll("`", "").replace(/^\s*[-*]\s*/, "").trim();
if (clean === "bash") continue;
if (/^(npm|node|cmake|ctest|bash|build_[A-Za-z0-9_./-]+\/bin\/blender)\b/u.test(clean)) candidates.push(clean);
}
return [...new Set(candidates)].slice(0, 8);
}
function inputPaths(source) {
const paths = [];
for (const match of source.matchAll(/`([^`]+)`/gu)) {
const value = match[1].trim();
if (!value || /\s/u.test(value)) continue;
if (/^(docs|tests|tools|web|blender-5\.2\.0|build_blender_5\.2\.0)\//u.test(value)) paths.push(value);
}
return [...new Set(paths)];
}
function pathBytes(file, limit) {
let stat;
try {
stat = fs.lstatSync(file);
} catch (error) {
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return { bytes: null, reason: "MISSING_PATH" };
return { bytes: null, reason: "PATH_STAT_FAILED" };
}
if (stat.isSymbolicLink()) return { bytes: null, reason: "SYMLINK_PATH" };
if (stat.isFile()) return { bytes: stat.size };
if (!stat.isDirectory()) return { bytes: null, reason: "UNREADABLE_PATH_TYPE" };
// Directory evidence is measured recursively, but stop as soon as it cannot fit.
let bytes = 0;
const stack = [file];
while (stack.length > 0) {
const current = stack.pop();
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return { bytes: null, reason: "PATH_STAT_FAILED" };
}
for (const entry of entries) {
const child = path.join(current, entry.name);
if (entry.isSymbolicLink()) return { bytes: null, reason: "SYMLINK_PATH" };
if (entry.isDirectory()) {
stack.push(child);
continue;
}
if (entry.isFile()) {
try {
bytes += fs.statSync(child).size;
} catch {
return { bytes: null, reason: "PATH_STAT_FAILED" };
}
if (bytes > limit) return { bytes };
}
}
}
return { bytes };
}
const pathWithinRoot = (candidate) => {
if (typeof candidate !== "string" || candidate.length === 0 || path.isAbsolute(candidate)) return false;
const absolute = path.resolve(root, candidate);
return absolute === root || absolute.startsWith(`${root}${path.sep}`);
};
const normalizedPathKey = (candidate) => path.normalize(candidate).replace(/[\\/]+$/u, "");
export function selectInputPaths(candidates, {
sourceBytes = 0,
sourceTokens = 0,
reservedPaths = [],
generatedPaths = [],
limits = {},
} = {}) {
const effectiveLimits = { ...CONTEXT_LIMITS, ...limits };
const unique = [...new Set((candidates ?? []).filter((candidate) => typeof candidate === "string" && candidate.length > 0))];
const reserved = new Set(reservedPaths.map(normalizedPathKey));
const generated = new Set(generatedPaths.map(normalizedPathKey));
const contextRemainingTokens = Math.max(0, effectiveLimits.contextTokens - sourceTokens);
const contextRemainingBytes = Math.max(0, effectiveLimits.contextTokens * 4 - sourceBytes);
const excluded = [];
const selected = [];
let selectedBytes = 0;
let selectedTokens = 0;
for (const candidate of unique) {
const normalized = normalizedPathKey(candidate);
if (generated.has(normalized)) {
excluded.push({ path: candidate, bytes: null, reason: "GENERATED_EVIDENCE_OUTPUT" });
continue;
}
if (reserved.has(normalized)) {
excluded.push({ path: candidate, bytes: 0, reason: "ALREADY_IN_CONTEXT" });
continue;
}
if (!pathWithinRoot(candidate)) {
excluded.push({ path: candidate, bytes: null, reason: "PATH_OUTSIDE_REPOSITORY" });
continue;
}
const absolute = path.join(root, candidate);
const measured = pathBytes(absolute, effectiveLimits.evidenceBytes);
if (measured.reason) {
excluded.push({ path: candidate, bytes: measured.bytes, reason: measured.reason });
continue;
}
const bytes = measured.bytes;
const tokens = Math.ceil(bytes / 4);
if (selected.length >= effectiveLimits.evidenceFiles) {
excluded.push({ path: candidate, bytes, reason: "EVIDENCE_FILE_COUNT" });
continue;
}
if (bytes > effectiveLimits.evidenceBytes || selectedBytes + bytes > effectiveLimits.evidenceBytes) {
excluded.push({ path: candidate, bytes, reason: "EVIDENCE_BYTE_BUDGET" });
continue;
}
if (bytes > contextRemainingBytes || selectedBytes + bytes > contextRemainingBytes
|| tokens > contextRemainingTokens || selectedTokens + tokens > contextRemainingTokens) {
excluded.push({ path: candidate, bytes, reason: "CONTEXT_REMAINING_SPACE" });
continue;
}
selected.push({ path: candidate, bytes, tokens });
selectedBytes += bytes;
selectedTokens += tokens;
}
return {
inputPaths: selected.map(({ path: candidate }) => candidate),
inputSelection: {
schemaVersion: 1,
limits: {
maxFiles: effectiveLimits.evidenceFiles,
evidenceBytes: effectiveLimits.evidenceBytes,
contextRemainingBytes,
contextRemainingTokens,
},
source: { bytes: sourceBytes, tokens: sourceTokens },
selected,
excluded,
totals: { files: selected.length, bytes: selectedBytes, tokens: selectedTokens },
},
};
}
function nextFromPlan(task) {
const index = loadTaskIndex();
if (index?.entries?.[task]) return index.entries[task].next;
return null;
}
export function buildTaskContext(requestedTask) {
verifyTaskIndex();
const queue = parseQueue();
const task = requestedTask ?? queue.currentTask;
const taskPath = path.join(root, "docs/tasks", `${task}.md`);
const entry = planEntry(task);
const taskExists = fs.existsSync(taskPath);
const taskSource = taskExists ? read(taskPath) : "";
const fields = taskExists ? cardFields(taskSource) : {};
if (taskExists && fields.task !== task) throw new Error(`task card id mismatch: ${relative(taskPath)}`);
const parent = fields.parent ?? (task === queue.currentTask ? path.basename(path.dirname(queue.parentManifest)) : planParent(task));
if (!parent) throw new Error(`task ${task} has no parent; add a compact task card or plan parent`);
const parentManifestPath = path.join(root, "tests/golden", parent, "manifest.json");
if (!fs.existsSync(parentManifestPath)) throw new Error(`missing parent manifest: ${relative(parentManifestPath)}`);
const parentManifestSource = read(parentManifestPath);
const parentManifest = JSON.parse(parentManifestSource);
if (parentManifest.nextTask !== task) throw new Error(`parent manifest nextTask=${parentManifest.nextTask} does not point to ${task}`);
if (parentManifest.status && parentManifest.status !== "done" && !parentManifest.enablingTask) {
throw new Error(`parent manifest status=${parentManifest.status} is not complete`);
}
const parentStatusPath = path.join(root, "docs/status", `${parent}.md`);
const parentStatusSource = fs.existsSync(parentStatusPath) ? read(parentStatusPath) : "";
const ownManifestPath = path.join(root, "tests/golden", task, "manifest.json");
const ownManifest = fs.existsSync(ownManifestPath) ? json(ownManifestPath) : null;
if (!requestedTask && task === queue.currentTask && ownManifest?.status === "done") {
throw new Error(`queue current task ${task} is already done; advance EXECUTION_QUEUE.md to ${ownManifest.nextTask}`);
}
if (!requestedTask && task === queue.currentTask && path.basename(path.dirname(queue.parentManifest)) !== parent) {
throw new Error(`queue parent manifest does not match task card parent ${parent}`);
}
const goal = taskExists
? (taskSource.match(/^##\s+目标\s*\n([\s\S]*?)(?=^##\s|$)/mu)?.[1].trim()
?? taskSource.match(/^#\s+[^\n:]+[:]\s*(.+)$/mu)?.[1].trim()
?? taskSource.match(/^#\s+[^\n]+\n\n([^\n]+)/u)?.[1].trim()
?? `完成 ${entry?.gapId ?? task} 的最小可观察切片`)
: `完成 ${entry?.gapId ?? task} 的最小可观察切片`;
const commands = [...commandLines(taskSource), ...(entry ? [entry.desktopCommand, entry.webCommand, entry.comparator] : [])].filter(Boolean).filter((value, index, values) => values.indexOf(value) === index).slice(0, 8);
const taskBytes = Buffer.byteLength(taskSource, "utf8");
const taskInputPaths = taskExists ? inputPaths(taskSource) : [];
const candidatePaths = taskExists && taskInputPaths.length > 0
? taskInputPaths
: [entry?.fixture?.path, entry?.comparator && "tools/web/check-generated-gap.mjs"].filter(Boolean);
const contextSources = [queue.source, taskSource, parentManifestSource, parentStatusSource];
const sourceBytes = contextSources.reduce((sum, source) => sum + Buffer.byteLength(source, "utf8"), 0);
const sourceTokens = contextSources.reduce((sum, source) => sum + tokenEstimate(source), 0);
const reservedPaths = [relative(queuePath), relative(taskPath), relative(parentManifestPath), relative(parentStatusPath)];
const ownStatusPath = path.join(root, "docs/status", `${task}.md`);
const ownOutputDir = path.join(root, "tests/golden", task);
const generatedPaths = [relative(ownOutputDir), relative(ownManifestPath), relative(ownStatusPath)];
const selectedInputs = selectInputPaths(candidatePaths, { sourceBytes, sourceTokens, reservedPaths, generatedPaths });
const nextTask = ownManifest?.nextTask ?? nextFromPlan(task) ?? null;
const context = {
schemaVersion: 1,
task,
parentTask: parent,
status: fields.status ?? entry?.state ?? "pending",
goal,
scope: {
gap: fields.gap ?? entry?.gapId ?? "NOT_APPLICABLE",
ownerFamily: fields.ownerFamily ?? entry?.ownerFamily ?? "NOT_APPLICABLE",
implementationClass: fields.targetImplementationClass ?? entry?.targetImplementationClass ?? "NOT_APPLICABLE",
},
commands,
inputPaths: selectedInputs.inputPaths,
inputSelection: selectedInputs.inputSelection,
exitCriteria: entry?.exitCriteria ?? ["focused command exits 0", "report and manifest are hash-bound", "failure path does not publish a late result"],
nextTask,
sourceDocuments: {
queue: relative(queuePath),
taskCard: taskExists ? relative(taskPath) : "TASK_INDEX_ENTRY",
taskIndex: relative(taskIndexPath),
parentManifest: relative(parentManifestPath),
parentStatus: fs.existsSync(parentStatusPath) ? relative(parentStatusPath) : "NOT_APPLICABLE",
},
readPolicy: {
required: [relative(queuePath), taskExists ? relative(taskPath) : "TASK_INDEX_ENTRY", relative(parentManifestPath), fs.existsSync(parentStatusPath) ? relative(parentStatusPath) : "NOT_APPLICABLE"],
machineOnly: [relative(taskIndexPath), "tests/golden/M15-03A/task-catalog.jsonl"],
optionalByNeed: ["docs/WEB_BLENDER_MODELER_V1_SCOPE.md", "任务卡列出的精确协议/生产路径", "任务卡明确命名的长期计划小节"],
forbiddenByDefault: ["完整 CURRENT_EXECUTION_PLAN.md", "完整 PROJECT_STATUS_AND_NEXT_WORK.md", "全部 docs/status/*.md", "完整 parity/WBS 计划", "README 历史和 test-results/"],
},
parent: {
manifest: {
schemaVersion: parentManifest.schemaVersion,
task: parentManifest.task,
parentTask: parentManifest.parentTask,
status: parentManifest.status ?? "done",
nextTask: parentManifest.nextTask,
artifactCount: Object.keys(parentManifest.artifacts ?? {}).length,
},
statusSummary: parentStatusSource.split("\n").filter(Boolean).slice(0, 8),
},
budgets: { ...CONTEXT_LIMITS, taskBytes, taskLines: taskSource ? taskSource.split("\n").length : 0, taskTokens: tokenEstimate(taskSource) },
};
return { context, files: { queuePath, taskPath, parentManifestPath, parentStatusPath }, sources: { queue, taskSource, parentManifest, parentManifestSource, parentStatusSource } };
}
export function contextSizeReport(bundle) {
const { context, files, sources } = bundle;
const measure = (file, source) => ({ path: relative(file), bytes: Buffer.byteLength(source, "utf8"), lines: source ? source.split("\n").length : 0, tokens: tokenEstimate(source) });
const docs = [measure(files.queuePath, sources.queue.source), measure(files.taskPath, sources.taskSource), measure(files.parentManifestPath, sources.parentManifestSource), measure(files.parentStatusPath, sources.parentStatusSource)];
const sourceTokens = docs.reduce((sum, item) => sum + item.tokens, 0);
const evidenceBytes = context?.inputSelection?.totals?.bytes ?? 0;
const evidenceFiles = context?.inputSelection?.totals?.files ?? 0;
const evidenceTokens = context?.inputSelection?.totals?.tokens ?? 0;
const totalTokens = sourceTokens + evidenceTokens;
return { documents: docs, sourceTokens, evidenceFiles, evidenceBytes, evidenceTokens, totalTokens, withinBudget: docs[0].bytes <= CONTEXT_LIMITS.queueBytes && docs[1].bytes <= CONTEXT_LIMITS.taskBytes && docs[2].bytes <= CONTEXT_LIMITS.parentManifestBytes && docs[3].bytes <= CONTEXT_LIMITS.parentStatusBytes && evidenceFiles <= CONTEXT_LIMITS.evidenceFiles && evidenceBytes <= CONTEXT_LIMITS.evidenceBytes && totalTokens <= CONTEXT_LIMITS.contextTokens };
}