import { readFile, writeFile, rename, rm, realpath } from "node:fs/promises"; import path from "node:path"; import { execFile as callback } from "node:child_process"; import { promisify } from "node:util"; import { randomUUID } from "node:crypto"; import { authoringContext } from "./authoring-context.js"; import { snapshotCommit } from "./checked-build.js"; import { loadQuixosLock, parseQuixosLockDocument, formatQuixosLockDocument, retentionTagForCommit, type GitSource, } from "../resource-lock/index.js"; const execFile = promisify(callback); const command = async (cwd: string, executable: string, args: string[]) => { const start = performance.now(); // Do not log arguments: transports may contain credentials. Source identities // remain in the normal checked result, not in this timing channel. const label = `${path.basename(cwd)} ${executable} ${args[0]}`; try { return ( await execFile(executable, args, { cwd, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0", GIT_TERMINAL_PROMPT: "0" }, }) ).stdout.trim(); } finally { if (process.env.QUIXOS_TRACE_CAPTURE === "1") console.error(`[${new Date().toISOString()}] Capture: ${label}: ${Math.round(performance.now() - start)}ms`); } }; const identity = (kind: string, repository: string) => `${kind}\0${repository}`; export type AuthoringBlocker = { directory: string; phase: "resolution" | "dependency" | "source" | "publication" | "concurrent-edit"; message: string; }; /** Source retention only. Neither successful convergence nor an empty source * worklist grants typechecking, semantic review or activation approval. * Caller serializes coordinators; package authors may still be editing. */ export async function convergeAuthoring(start: string, target = "root") { const context = await authoringContext(start); const graphFile = path.join(context.workbench, ".quixos/resource-graph.json"); const graphBefore = await readFile(graphFile, "utf8"); const blockers: AuthoringBlocker[] = []; const nodes = new Map(); const selected = new Map(); for (const entry of context.resources) { const root = path.join(context.workbench, entry.directory); let repository = entry.source?.repository; try { if ((await realpath(root)) !== root) throw new Error(`Managed checkout crosses a symlink: ${entry.directory}`); // Transport rewrites must not become committed source identities. const origin = await command(root, "git", ["config", "--get", "remote.origin.url"]); if (repository && origin !== repository) throw new Error(`Origin differs from registered source for ${entry.directory}`); repository ??= origin; } catch (error) { blockers.push({ directory: entry.directory, phase: "source", message: String(error).slice(0, 2000) }); if (!repository) throw error; // The root has no separate registered source. } const key = identity(entry.kind, repository); if (selected.has(key)) throw new Error(`More than one editable checkout for ${repository}`); selected.set(key, entry.directory); nodes.set(entry.directory, { ...entry, source: { resolver: "git", repository, commit: entry.source?.commit ?? "" }, dependencies: [], }); } for (const node of nodes.values()) { try { const lock = await loadQuixosLock(path.join(context.workbench, node.directory, "quixos.lock")); if (!lock.ok) throw new Error(lock.diagnostics.map((d) => `${d.fileName}: ${d.message}`).join("\n")); node.dependencies = [ ...new Set( lock.lock.resources.flatMap((entry) => { const directory = selected.get(identity(entry.kind, entry.source.repository)); return directory ? [directory] : []; }), ), ]; } catch (error) { blockers.push({ directory: node.directory, phase: "resolution", message: String(error) }); } } const complete = new Map(), active = new Set(); const visited = new Set(); if (!nodes.has(target)) throw new Error(`Not a registered repository: ${target}`); const visit = async (directory: string): Promise => { visited.add(directory); if (complete.has(directory)) return true; if (blockers.some((entry) => entry.directory === directory)) return false; if (active.has(directory)) { blockers.push({ directory, phase: "dependency", message: `Source dependency cycle: ${[...active, directory].join(" -> ")}`, }); return false; } active.add(directory); const node = nodes.get(directory)!; for (const dependency of node.dependencies) if (!(await visit(dependency))) { blockers.push({ directory, phase: "dependency", message: `Waiting for ${dependency}` }); active.delete(directory); return false; } const root = path.join(context.workbench, directory); let phase: AuthoringBlocker["phase"] = "source"; try { const lock = await loadQuixosLock(path.join(root, "quixos.lock")); if (!lock.ok) throw new Error("Lock changed during convergence; retry after joining writers"); for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) { const filename = path.join(root, file), before = await readFile(filename, "utf8"); const parsed = parseQuixosLockDocument(before, file); if (!parsed.ok) throw new Error(`Invalid lock ${file}`); let changed = false; for (const dependency of parsed.document.resources) { const target = selected.get(identity(dependency.kind, dependency.source.repository)); const source = target ? complete.get(target) : undefined; if (target && !source) throw new Error(`Dependencies changed during convergence (${dependency.binding}); join writers and retry`); if (source && source.commit !== dependency.source.commit) { dependency.source = source; changed = true; } } if (changed) { const temporary = `${filename}.${randomUUID()}.tmp`; try { await writeFile(temporary, formatQuixosLockDocument(parsed.document), { flag: "wx" }); if ((await readFile(filename, "utf8")) !== before) throw new Error(`Concurrent edit to ${file}; retry after joining writers`); await rename(temporary, filename); } finally { await rm(temporary, { force: true }); } } } const snapshotStarted = performance.now(); const commit = await snapshotCommit(root); if (process.env.QUIXOS_TRACE_CAPTURE === "1") console.error( `[${new Date().toISOString()}] Capture: ${directory} snapshot: ${Math.round(performance.now() - snapshotStarted)}ms`, ); phase = "publication"; const ref = retentionTagForCommit(commit); const remote = await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref]); if (remote && remote.split(/\s+/)[0] !== commit) throw new Error(`Conflicting immutable retention ref ${ref}`); if (!remote) await command(root, "git", ["push", node.source.repository, `${commit}:${ref}`]); if ((await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref])).split(/\s+/)[0] !== commit) throw new Error("Published source retention was not observed"); complete.set(directory, { ...node.source, commit }); } catch (error) { blockers.push({ directory, phase, message: String(error).slice(0, 4000) }); } active.delete(directory); return complete.has(directory); }; // Include newly created, not-yet-imported resources, then the root. if (target === "root") for (const directory of [...nodes.keys()].filter((d) => d !== "root")) await visit(directory); await visit(target); for (let index = blockers.length - 1; index >= 0; index--) if (!visited.has(blockers[index].directory)) blockers.splice(index, 1); for (const [directory, source] of complete) { try { if ((await snapshotCommit(path.join(context.workbench, directory))) !== source.commit) throw new Error("Source advanced while converging; join writers and retry"); } catch (error) { blockers.push({ directory, phase: "concurrent-edit", message: String(error) }); } } // Persist successful selections even if another repository is still broken. // Recovery must not depend on all parents succeeding in the same invocation. const graph = JSON.parse(graphBefore); for (const resource of graph.resources) resource.directory = path.relative(context.workbench, path.resolve(context.workbench, resource.directory)); const replacements = new Map(); for (const resource of graph.resources) { const source = complete.get(resource.directory); if (!source) continue; const key = `${resource.kind}\0${source.repository}\0${source.commit}`; replacements.set(resource.key, key); if (resource.source.commit !== source.commit) delete resource.revisionId; resource.source = source; resource.key = key; } for (const resource of graph.resources) for (const dependency of resource.dependencies ?? []) { dependency.resourceKey = replacements.get(dependency.resourceKey) ?? dependency.resourceKey; } for (const direct of graph.directResources ?? []) direct.resourceKey = replacements.get(direct.resourceKey) ?? direct.resourceKey; // Inventory is a projection of actual locks, including newly added/removed // imports. Never require a successful parent compilation to repair it. for (const [directory] of complete) { const lock = await loadQuixosLock(path.join(context.workbench, directory, "quixos.lock")); if (!lock.ok) continue; const dependencies = lock.lock.resources.map((dependency) => ({ binding: `${dependency.kind}\0${dependency.binding}`, resourceKey: `${dependency.kind}\0${dependency.source.repository}\0${dependency.source.commit}`, })); if (directory === "root") { graph.quixos = lock.lock.quixos; graph.directResources = lock.lock.resources.map((dependency, index) => ({ kind: dependency.kind, binding: dependency.binding, resourceKey: dependencies[index].resourceKey, ...(selected.has(identity(dependency.kind, dependency.source.repository)) ? { directory: selected.get(identity(dependency.kind, dependency.source.repository)) } : {}), })); } else { const resource = graph.resources.find((entry: { directory: string }) => entry.directory === directory); if (resource) resource.dependencies = dependencies; } } const graphAfter = JSON.stringify(graph, null, 2) + "\n"; if (graphBefore !== graphAfter) { const temporary = `${graphFile}.${randomUUID()}.tmp`; try { await writeFile(temporary, graphAfter, { flag: "wx", mode: 0o600 }); if ((await readFile(graphFile, "utf8")) !== graphBefore) throw new Error( "Managed inventory changed during convergence; source is retained, retry after joining writers", ); await rename(temporary, graphFile); } finally { await rm(temporary, { force: true }); } } return { workbench: context.workbench, converged: blockers.length === 0, candidate: blockers.length ? null : (complete.get(target) ?? null), retained: [...complete].map(([directory, source]) => ({ directory, source })), worklist: blockers, verificationEvidence: false, activated: false, }; }