Govern task context and advance execution pointer
This commit is contained in:
379
tools/web/task-context-lib.mjs
Normal file
379
tools/web/task-context-lib.mjs
Normal file
@@ -0,0 +1,379 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user