Make workspace authoring converge through immutable Nix candidates

Coordinate registered resource edits bottom-up into retained exact remote sources.
Use one Nix-owned source graph for provisional checking, template publication,
explicit baseline upgrades and host activation; retain independent runtime pins.

Add scoped contract inspection, historical recovery, derived worklists, crash-safe
locks, named dependency adoption and plain-QX structural editing. Repair TODO
ownership and template instantiation, and document the supported agent workflow.

Validated with protocol and command suites, real jj/Nix convergence and cache
checks, TS/React installed-command acceptance, and fresh TODO first-edit acceptance.
No live deployment or public publication performed. Props projection generation
and a one-command rich feature generator remain explicitly outside this delivery.
This commit is contained in:
Timothy J. Aveni
2026-09-14 10:26:11 -07:00
parent fae4e48f72
commit 01ca965c7f
29 changed files with 1103 additions and 75 deletions
+25 -11
View File
@@ -8,11 +8,15 @@ import { snapshotRepository, localResourceSnapshots } from "./candidate-check.js
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
import { createGitCapabilityResolver } from "./git-resolver.js";
import {bindingSchema, generateTypeScriptBindings} from "../bindings/index.js";
import { parseQx } from "./source.js";
import { parseQuixosLockDocument } from "../resource-lock/index.js";
import { withFileLock } from "./file-lock.js";
export type StructuralRequest = {
kind: "workspace" | "interface" | "package";
source?: {repository: string; commit: string};
resourceRoot?: string;
validation?: "syntax" | "resource-graph";
files: ({file: string; edits: StructuralEdit[]} | {file: string; create: string} | {file: string; generated: string})[];
};
type Change = {file: string; before: string | null; after: string; mode: number};
@@ -50,6 +54,7 @@ const durableJson = async (file: string, value: unknown) => {
/** Validate the entire edited resource graph in a private snapshot before writes. */
export const planStructure = async (rootPath: string, request: StructuralRequest, snapshotMap?: string) => {
if (request.validation && !["syntax", "resource-graph"].includes(request.validation)) throw new Error("Unknown structural validation mode");
const root = await fs.realpath(rootPath);
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structure-"));
try {
@@ -74,11 +79,28 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
after = input.edits.reduce(editStructure, before);
}
if (Buffer.byteLength(after) > 1024 * 1024) throw new Error("Scaffold file exceeds 1 MiB");
if (input.file.endsWith("package.qx")) {
let registry;
try { registry = JSON.parse(await fs.readFile(path.join(root, path.dirname(input.file), "quixos.scaffold.json"), "utf8")); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
if (registry?.generatedBy === "qx-scaffold-v1") {
const declaration = parseQx(after).root.children.find(node => node.kind === "packageResourceDecl");
const literal = declaration?.children.find(node => node.kind === "stringLiteral");
if (literal && JSON.parse(after.slice(literal.start, literal.end)) !== registry.id)
throw new Error("Cannot change a scaffold-owned package identity independently of its registry; create a new managed package instead");
}
}
const mode = before === null ? 0o644 : (await fs.stat(path.join(root, input.file))).mode & 0o777;
changes.push({file: input.file, before, after, mode});
await containedParent(snapshot.directory, input.file);
await fs.writeFile(path.join(snapshot.directory, input.file), after);
}
if (request.validation === "syntax") {
for (const change of changes) {
if (change.file.endsWith(".qx") && parseQx(change.after, change.file).diagnostics.length) throw new Error(`Invalid QX syntax in ${change.file}`);
if (change.file.endsWith(".lock") && !parseQuixosLockDocument(change.after, change.file).ok) throw new Error(`Invalid lock syntax in ${change.file}`);
}
} else {
const localMap = path.join(temporary, "local-resources.json");
await fs.writeFile(localMap, JSON.stringify(await localResourceSnapshots(root, snapshotMap)));
const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resources"), snapshotMap: localMap});
@@ -93,7 +115,6 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8"));
const artifacts = [
{file: configuration.bindingOutput as string, after: generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options)},
{file: "quixos.resources.json", after: JSON.stringify({generatedBy: "qx-scaffold-v1", resources: compiled.resources.filter((entry) => entry.directory !== resourceRoot).map((entry) => ({kind: entry.kind, repository: entry.source.repository, commit: entry.source.commit}))}, null, 2) + "\n"},
];
for (const artifact of artifacts) {
const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file;
@@ -108,11 +129,12 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
}
}
else throw new Error("Resource plans require kind and exact authored source identity");
}
if (changes.length > 100) throw new Error("Structural plan including generated artifacts exceeds 100 files");
// Validation may fetch dependencies; reject edits made while it was running.
for (const entry of changes) if (await read(root, entry.file) !== entry.before) throw new Error(`Source changed while planning: ${entry.file}`);
for (const entry of observed) if (contentDigest(await fs.readFile(path.join(root, entry.file), "utf8")) !== entry.digest) throw new Error(`Validation input changed while planning: ${entry.file}`);
return {root, changes, observed, digest: contentDigest(changes), validation: "resource-graph" as const};
return {root, changes, observed, digest: contentDigest(changes), validation: request.validation ?? "resource-graph" as const};
} finally { await fs.rm(temporary, {recursive: true, force: true}); }
};
@@ -154,15 +176,7 @@ const replayStructure = async (rootPath: string, id: string) => {
const withStructureLock = async <T>(root: string, work: () => Promise<T>) => {
await containedParent(root, ".quixos/scaffolds/placeholder.json");
const lock = path.join(root, ".quixos", "scaffolds", "writer.lock");
// Never steal a possibly live writer's lock. A process crash requires the
// operator to verify that writer is gone, remove this lock, then resume its
// journal. This is deliberately fail-closed instead of guessing from a PID.
const handle = await fs.open(lock, "wx", 0o600).catch((error) => {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Another scaffold writer or interrupted writer owns ${lock}; verify it has exited before removing its lock and resuming`);
throw error;
});
try { await handle.writeFile(JSON.stringify({pid: process.pid})); await handle.sync(); return await work(); }
finally { await handle.close(); await fs.unlink(lock); }
return withFileLock(lock, work);
};
export const resumeStructure = async (rootPath: string, id: string) => {
const root = await fs.realpath(rootPath);