2026-09-10 18:27:41 -07:00
|
|
|
import fs from "node:fs/promises";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
import os from "node:os";
|
2026-09-15 15:23:24 -07:00
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
|
import { execFile as callback } from "node:child_process";
|
|
|
|
|
import { promisify } from "node:util";
|
|
|
|
|
import { loadQuixosLock, parseQuixosLockDocument } from "../resource-lock/index.js";
|
|
|
|
|
import { contentDigest } from "../capability-model/evolution.js";
|
|
|
|
|
import { planStructure, applyStructure, type StructuralRequest } from "./structural-plan.js";
|
|
|
|
|
import { snapshotRepository } from "./candidate-check.js";
|
|
|
|
|
import {
|
|
|
|
|
compileWorkspaceRepository,
|
|
|
|
|
compileCapabilityResourceRepository,
|
|
|
|
|
type ResolvedCapabilityResource,
|
|
|
|
|
} from "./assembly.js";
|
|
|
|
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
|
|
|
|
import { snapshotCommit, buildImmutableCandidate } from "./checked-build.js";
|
2026-10-08 07:17:40 -07:00
|
|
|
import {
|
|
|
|
|
planEvolution,
|
|
|
|
|
reviewSemanticMajors,
|
|
|
|
|
type WorkspaceRevision,
|
|
|
|
|
type EvolutionReview,
|
|
|
|
|
} from "../capability-model/index.js";
|
2026-09-15 15:23:24 -07:00
|
|
|
import { withFileLock } from "./file-lock.js";
|
2026-09-10 18:27:41 -07:00
|
|
|
const execFile = promisify(callback);
|
2026-09-15 15:23:24 -07:00
|
|
|
type Source = { repository: string; commit: string };
|
2026-09-22 13:51:32 -07:00
|
|
|
export type UpgradeNode = { kind: "workspace" | "package" | "interface" | "build"; directory: string; source: Source };
|
2026-09-15 15:23:24 -07:00
|
|
|
export type UpgradeSpec = {
|
|
|
|
|
nodes: UpgradeNode[];
|
|
|
|
|
quixos?: Source;
|
|
|
|
|
baseline?: string;
|
|
|
|
|
reviews?: string;
|
|
|
|
|
bootstrap?: boolean;
|
|
|
|
|
};
|
|
|
|
|
type NodePlan = UpgradeNode & { treeDigest: string; dependencies: string[]; lockFiles: string[] };
|
|
|
|
|
export type UpgradePlan = { schemaVersion: 1; workbench: string; spec: UpgradeSpec; nodes: NodePlan[]; digest: string };
|
|
|
|
|
type Step = {
|
|
|
|
|
directory: string;
|
|
|
|
|
phase: "editing" | "prepared" | "refactor" | "checked" | "publishing" | "published";
|
|
|
|
|
commit?: string;
|
|
|
|
|
treeDigest?: string;
|
|
|
|
|
structuralJournal?: string;
|
|
|
|
|
structuralPlan?: Awaited<ReturnType<typeof planStructure>>;
|
|
|
|
|
};
|
|
|
|
|
type Journal = { schemaVersion: 1; plan: UpgradePlan; steps: Step[] };
|
|
|
|
|
const sourceKey = (node: { kind: string; source: Source }) =>
|
|
|
|
|
JSON.stringify([node.kind, node.source.repository, node.source.commit]);
|
|
|
|
|
const command = async (cwd: string, tool: string, args: string[]) =>
|
|
|
|
|
(
|
|
|
|
|
await execFile(tool, args, {
|
|
|
|
|
cwd,
|
|
|
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
|
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0" },
|
|
|
|
|
})
|
|
|
|
|
).stdout.trim();
|
2026-09-10 18:27:41 -07:00
|
|
|
const validSource = (value: Source) => {
|
|
|
|
|
const url = new URL(value.repository);
|
2026-09-15 15:23:24 -07:00
|
|
|
if (
|
|
|
|
|
url.protocol !== "https:" ||
|
|
|
|
|
url.username ||
|
|
|
|
|
url.password ||
|
|
|
|
|
url.search ||
|
|
|
|
|
url.hash ||
|
|
|
|
|
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value.commit)
|
|
|
|
|
)
|
|
|
|
|
throw new Error("Upgrade sources must be exact credential-free HTTPS revisions");
|
2026-09-10 18:27:41 -07:00
|
|
|
};
|
|
|
|
|
const location = async (root: string, directory: string) => {
|
2026-09-15 15:23:24 -07:00
|
|
|
if (directory !== "root" && !/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(directory))
|
|
|
|
|
throw new Error("Upgrade target must be a managed root/resource repository");
|
2026-09-10 18:27:41 -07:00
|
|
|
const resolved = await fs.realpath(path.join(root, directory));
|
|
|
|
|
if (resolved !== path.join(root, directory)) throw new Error("Upgrade target crosses a symlink");
|
|
|
|
|
return resolved;
|
|
|
|
|
};
|
|
|
|
|
const treeDigest = async (root: string) => {
|
|
|
|
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-tree-"));
|
2026-09-15 15:23:24 -07:00
|
|
|
try {
|
|
|
|
|
return (await snapshotRepository(root, temporary)).treeDigest;
|
|
|
|
|
} finally {
|
|
|
|
|
await fs.rm(temporary, { recursive: true, force: true });
|
|
|
|
|
}
|
2026-09-10 18:27:41 -07:00
|
|
|
};
|
|
|
|
|
const writeJournal = async (filename: string, journal: unknown) => {
|
|
|
|
|
const temp = `${filename}.${randomUUID()}.tmp`;
|
|
|
|
|
const handle = await fs.open(temp, "wx", 0o600);
|
2026-09-15 15:23:24 -07:00
|
|
|
try {
|
|
|
|
|
await handle.writeFile(JSON.stringify(journal, null, 2));
|
|
|
|
|
await handle.sync();
|
|
|
|
|
} finally {
|
|
|
|
|
await handle.close();
|
|
|
|
|
}
|
2026-09-10 18:27:41 -07:00
|
|
|
await fs.rename(temp, filename);
|
|
|
|
|
const directory = await fs.open(path.dirname(filename), "r");
|
2026-09-15 15:23:24 -07:00
|
|
|
try {
|
|
|
|
|
await directory.sync();
|
|
|
|
|
} finally {
|
|
|
|
|
await directory.close();
|
|
|
|
|
}
|
2026-09-10 18:27:41 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const discoverUpgradeSpec = async (workbench: string): Promise<UpgradeSpec> => {
|
|
|
|
|
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
|
|
|
|
const root = await location(workbench, "root");
|
2026-09-15 15:23:24 -07:00
|
|
|
const nodes: UpgradeNode[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "workspace",
|
|
|
|
|
directory: "root",
|
|
|
|
|
source: {
|
|
|
|
|
repository: await command(root, "git", ["config", "--get", "remote.origin.url"]),
|
|
|
|
|
commit: await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]),
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-09-22 13:51:32 -07:00
|
|
|
...graph.resources.map((entry: { kind: "interface" | "package" | "build"; directory: string; source: Source }) => ({
|
2026-09-15 15:23:24 -07:00
|
|
|
kind: entry.kind,
|
|
|
|
|
source: entry.source,
|
|
|
|
|
directory: path.relative(workbench, path.resolve(workbench, entry.directory)),
|
|
|
|
|
})),
|
|
|
|
|
];
|
2026-09-10 18:27:41 -07:00
|
|
|
let baseline: string | undefined;
|
|
|
|
|
try {
|
|
|
|
|
const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8"));
|
2026-09-15 15:23:24 -07:00
|
|
|
if ((await fs.realpath(host.workbenchRoot)) === (await fs.realpath(workbench)))
|
|
|
|
|
baseline = JSON.parse(
|
|
|
|
|
await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8"),
|
|
|
|
|
).workspacePlanPath;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!["ENOENT", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
|
|
|
|
|
}
|
|
|
|
|
return { nodes, baseline };
|
2026-09-10 18:27:41 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Read-only source plan. Repositories are selected explicitly, including any
|
|
|
|
|
* parallel versions of the same resource; no guesses at a floating 'latest'. */
|
|
|
|
|
export const planPinUpgrades = async (workbenchPath: string, spec: UpgradeSpec): Promise<UpgradePlan> => {
|
|
|
|
|
const workbench = await fs.realpath(workbenchPath);
|
2026-09-15 15:23:24 -07:00
|
|
|
if (
|
|
|
|
|
!Array.isArray(spec.nodes) ||
|
|
|
|
|
!spec.nodes.length ||
|
|
|
|
|
spec.nodes.length > 100 ||
|
|
|
|
|
spec.nodes.filter((node) => node.kind === "workspace").length !== 1
|
|
|
|
|
)
|
|
|
|
|
throw new Error("Upgrade graph requires one workspace and at most 100 repositories");
|
2026-09-10 18:27:41 -07:00
|
|
|
if (spec.quixos) validSource(spec.quixos);
|
|
|
|
|
const keys = new Map<string, string>();
|
|
|
|
|
for (const node of spec.nodes) {
|
|
|
|
|
validSource(node.source);
|
2026-09-22 13:51:32 -07:00
|
|
|
if (!["workspace", "package", "interface", "build"].includes(node.kind) || keys.has(sourceKey(node)))
|
2026-09-15 15:23:24 -07:00
|
|
|
throw new Error("Duplicate/invalid upgrade resource identity");
|
2026-09-10 18:27:41 -07:00
|
|
|
keys.set(sourceKey(node), node.directory);
|
|
|
|
|
}
|
2026-09-15 15:23:24 -07:00
|
|
|
if (new Set(spec.nodes.map((node) => node.directory)).size !== spec.nodes.length)
|
|
|
|
|
throw new Error("Upgrade directories must be distinct");
|
2026-09-10 18:27:41 -07:00
|
|
|
const nodes: NodePlan[] = [];
|
|
|
|
|
for (const node of spec.nodes) {
|
|
|
|
|
const root = await location(workbench, node.directory);
|
2026-09-15 15:23:24 -07:00
|
|
|
if ((await command(root, "git", ["config", "--get", "remote.origin.url"])) !== node.source.repository)
|
|
|
|
|
throw new Error(`Upgrade origin differs from selected source: ${node.directory}`);
|
2026-09-10 18:27:41 -07:00
|
|
|
const loaded = await loadQuixosLock(path.join(root, "quixos.lock"));
|
2026-09-15 15:23:24 -07:00
|
|
|
if (!loaded.ok)
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Invalid lock in ${node.directory}: ${loaded.diagnostics.map((entry) => entry.message).join("; ")}`,
|
|
|
|
|
);
|
|
|
|
|
const dependencies = loaded.lock.resources
|
|
|
|
|
.map((resource) => keys.get(sourceKey(resource)))
|
|
|
|
|
.filter((value): value is string => Boolean(value));
|
|
|
|
|
nodes.push({
|
|
|
|
|
...node,
|
|
|
|
|
treeDigest: await treeDigest(root),
|
|
|
|
|
dependencies: [...new Set(dependencies)],
|
|
|
|
|
lockFiles: loaded.lock.sourceFiles ?? ["quixos.lock"],
|
|
|
|
|
});
|
2026-09-10 18:27:41 -07:00
|
|
|
}
|
2026-09-15 15:23:24 -07:00
|
|
|
const ordered: NodePlan[] = [],
|
|
|
|
|
remaining = [...nodes];
|
2026-09-10 18:27:41 -07:00
|
|
|
while (remaining.length) {
|
2026-09-15 15:23:24 -07:00
|
|
|
const index = remaining.findIndex((node) =>
|
|
|
|
|
node.dependencies.every((dependency) => ordered.some((entry) => entry.directory === dependency)),
|
|
|
|
|
);
|
2026-09-10 18:27:41 -07:00
|
|
|
if (index < 0) throw new Error("Cyclic source publication graph");
|
|
|
|
|
ordered.push(remaining.splice(index, 1)[0]);
|
|
|
|
|
}
|
|
|
|
|
const workspace = ordered.find((node) => node.kind === "workspace")!;
|
|
|
|
|
// Even unreferenced new resources are published before the root.
|
2026-09-15 15:23:24 -07:00
|
|
|
ordered.splice(ordered.indexOf(workspace), 1);
|
|
|
|
|
ordered.push(workspace);
|
|
|
|
|
const plan = { schemaVersion: 1 as const, workbench, spec, nodes: ordered };
|
|
|
|
|
return { ...plan, digest: contentDigest(plan) };
|
2026-09-10 18:27:41 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type UpgradeEffects = {
|
|
|
|
|
check(node: NodePlan, root: string, output: string, spec: UpgradeSpec): Promise<void>;
|
|
|
|
|
snapshot(root: string): Promise<string>;
|
|
|
|
|
publish(root: string, commit: string): Promise<void>;
|
|
|
|
|
};
|
2026-10-08 07:17:40 -07:00
|
|
|
|
|
|
|
|
/** Review a successfully built immutable candidate using the same boundary as
|
|
|
|
|
* ordinary authoring checks. Execution storage compatibility is checked against
|
|
|
|
|
* the current graph at activation; the legacy evolution model is not its schema.
|
|
|
|
|
*/
|
|
|
|
|
export async function reviewUpgradeCandidate({
|
|
|
|
|
artifact,
|
|
|
|
|
baseline,
|
|
|
|
|
candidate,
|
|
|
|
|
reviews = [],
|
|
|
|
|
}: {
|
|
|
|
|
artifact: string;
|
|
|
|
|
baseline: WorkspaceRevision | null;
|
|
|
|
|
candidate: WorkspaceRevision;
|
|
|
|
|
reviews?: EvolutionReview[];
|
|
|
|
|
}) {
|
|
|
|
|
let execution = false;
|
|
|
|
|
try {
|
|
|
|
|
await fs.access(path.join(artifact, "execution-plan.json"));
|
|
|
|
|
execution = true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
|
|
|
}
|
|
|
|
|
return execution
|
|
|
|
|
? { filename: "semantic-review.json", review: reviewSemanticMajors(baseline, candidate, reviews) }
|
|
|
|
|
: { filename: "evolution.json", review: planEvolution(baseline, candidate, { reviews }) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 18:27:41 -07:00
|
|
|
const effects: UpgradeEffects = {
|
|
|
|
|
async check(node, root, output, spec) {
|
2026-09-15 15:23:24 -07:00
|
|
|
if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap)
|
|
|
|
|
throw new Error(
|
|
|
|
|
"Upgrading a workspace requires its checked active baseline for major-review checks (or explicit bootstrap:true for a new workspace)",
|
|
|
|
|
);
|
2026-09-14 10:26:11 -07:00
|
|
|
// Explicit baseline upgrades use the same immutable Nix checker. Retaining
|
|
|
|
|
// an unverified source is safe and must precede a remote flake fetch.
|
|
|
|
|
await fs.mkdir(output);
|
|
|
|
|
const commit = await snapshotCommit(root);
|
|
|
|
|
await effects.publish(root, commit);
|
2026-09-15 15:23:24 -07:00
|
|
|
const artifact = await buildImmutableCandidate({ ...node.source, commit }, node.kind, path.join(output, "nix.log"));
|
2026-09-14 10:26:11 -07:00
|
|
|
const candidate = await fs.readFile(path.join(artifact, "candidate.json"), "utf8");
|
|
|
|
|
await fs.writeFile(path.join(output, "candidate.json"), candidate);
|
|
|
|
|
if (node.kind === "workspace") {
|
2026-09-15 15:23:24 -07:00
|
|
|
const baseline = spec.baseline
|
|
|
|
|
? (JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision)
|
|
|
|
|
: null;
|
|
|
|
|
const reviews = spec.reviews ? (JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[]) : [];
|
2026-10-08 07:17:40 -07:00
|
|
|
const { filename, review } = await reviewUpgradeCandidate({
|
|
|
|
|
artifact,
|
|
|
|
|
baseline,
|
|
|
|
|
candidate: JSON.parse(candidate),
|
|
|
|
|
reviews,
|
|
|
|
|
});
|
|
|
|
|
await fs.writeFile(path.join(output, filename), JSON.stringify(review, null, 2));
|
|
|
|
|
if (review.blockers.length)
|
|
|
|
|
throw new Error(`Refactor required in ${node.directory}: ${review.blockers.join("; ")}`);
|
2026-09-14 10:26:11 -07:00
|
|
|
}
|
2026-09-10 18:27:41 -07:00
|
|
|
},
|
|
|
|
|
async snapshot(root) {
|
2026-09-13 22:07:18 -07:00
|
|
|
const commit = await snapshotCommit(root);
|
2026-09-10 18:27:41 -07:00
|
|
|
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Publication did not resolve an exact commit");
|
|
|
|
|
await command(root, "git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"]);
|
|
|
|
|
const tracked = new Set((await command(root, "git", ["ls-tree", "-r", "--name-only", "-z", commit])).split("\0"));
|
2026-09-15 15:23:24 -07:00
|
|
|
for (const file of (await command(root, "git", ["ls-files", "--others", "--exclude-standard", "-z"]))
|
|
|
|
|
.split("\0")
|
|
|
|
|
.filter(Boolean))
|
|
|
|
|
if (!tracked.has(file)) throw new Error(`Uncaptured source file ${file}`);
|
2026-09-10 18:27:41 -07:00
|
|
|
return commit;
|
|
|
|
|
},
|
|
|
|
|
async publish(root, commit) {
|
|
|
|
|
const ref = `refs/tags/quixos-reachability/${commit}`;
|
|
|
|
|
const remote = await command(root, "git", ["ls-remote", "--refs", "origin", ref]);
|
|
|
|
|
if (remote && remote !== `${commit}\t${ref}`) throw new Error("Immutable publication ref conflict");
|
|
|
|
|
if (!remote) await command(root, "git", ["push", "origin", `${commit}:${ref}`]);
|
2026-09-15 15:23:24 -07:00
|
|
|
if ((await command(root, "git", ["ls-remote", "--refs", "origin", ref])) !== `${commit}\t${ref}`)
|
|
|
|
|
throw new Error("Publication response uncertain; retry the same journal");
|
2026-09-10 18:27:41 -07:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Explicit --publish only. Append-only remote retention; never moves the
|
|
|
|
|
* workspace branch, activates code, or rolls back previously published nodes. */
|
2026-09-15 15:23:24 -07:00
|
|
|
export const applyPinUpgrades = async (
|
|
|
|
|
plan: UpgradePlan,
|
|
|
|
|
journalId?: string,
|
|
|
|
|
implementation: UpgradeEffects = effects,
|
|
|
|
|
options: { acceptEdits?: boolean } = {},
|
|
|
|
|
) => {
|
|
|
|
|
if (implementation === effects && !plan.spec.baseline && !plan.spec.bootstrap)
|
|
|
|
|
throw new Error("Publication requires an active checked baseline or explicit bootstrap:true");
|
|
|
|
|
const { digest, ...body } = plan;
|
2026-09-10 18:27:41 -07:00
|
|
|
if (contentDigest(body) !== digest) throw new Error("Upgrade plan digest mismatch");
|
|
|
|
|
const directory = path.join(plan.workbench, ".quixos", "upgrades");
|
2026-09-15 15:23:24 -07:00
|
|
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
|
|
|
if ((await fs.realpath(directory)) !== directory) throw new Error("Upgrade journals must not cross symlinks");
|
2026-09-10 18:27:41 -07:00
|
|
|
const id = journalId ?? randomUUID();
|
2026-09-14 10:26:11 -07:00
|
|
|
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid upgrade journal ID");
|
2026-09-10 18:27:41 -07:00
|
|
|
const filename = path.join(directory, `${id}.json`);
|
2026-09-14 10:26:11 -07:00
|
|
|
return withFileLock(path.join(directory, "writer.lock"), async () => {
|
2026-09-15 15:23:24 -07:00
|
|
|
try {
|
|
|
|
|
const journal: Journal = journalId
|
|
|
|
|
? JSON.parse(await fs.readFile(filename, "utf8"))
|
|
|
|
|
: { schemaVersion: 1, plan, steps: [] };
|
|
|
|
|
if (journal.plan.digest !== plan.digest) throw new Error("Upgrade journal belongs to another plan");
|
|
|
|
|
if (!journalId) await writeJournal(filename, journal);
|
|
|
|
|
for (const node of plan.nodes) {
|
|
|
|
|
const root = await location(plan.workbench, node.directory);
|
|
|
|
|
if ((await command(root, "git", ["config", "--get", "remote.origin.url"])) !== node.source.repository)
|
|
|
|
|
throw new Error("Upgrade remote changed after planning");
|
|
|
|
|
let step = journal.steps.find((entry) => entry.directory === node.directory);
|
|
|
|
|
if (step?.phase === "published") continue;
|
|
|
|
|
if (!step) {
|
|
|
|
|
if ((await treeDigest(root)) !== node.treeDigest) throw new Error(`Stale upgrade plan: ${node.directory}`);
|
|
|
|
|
const files: StructuralRequest["files"] = [];
|
|
|
|
|
for (const file of node.lockFiles) {
|
|
|
|
|
const parsed = parseQuixosLockDocument(await fs.readFile(path.join(root, file), "utf8"));
|
|
|
|
|
if (!parsed.ok) throw new Error("Invalid lock during upgrade");
|
|
|
|
|
const edits = [];
|
|
|
|
|
for (const resource of parsed.document.resources) {
|
|
|
|
|
const dependency = plan.nodes.find((entry) => sourceKey(entry) === sourceKey(resource));
|
|
|
|
|
const published =
|
|
|
|
|
dependency &&
|
|
|
|
|
journal.steps.find((entry) => entry.directory === dependency.directory && entry.phase === "published");
|
|
|
|
|
if (published?.commit && published.commit !== resource.source.commit)
|
|
|
|
|
edits.push({
|
|
|
|
|
operation: "dependency" as const,
|
|
|
|
|
kind: resource.kind,
|
|
|
|
|
name: resource.binding,
|
|
|
|
|
source: { ...resource.source, commit: published.commit },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (parsed.document.kind === "root" && plan.spec.quixos)
|
|
|
|
|
edits.push({ operation: "quixos-pin" as const, source: plan.spec.quixos });
|
|
|
|
|
if (edits.length) files.push({ file, edits });
|
2026-09-10 18:27:41 -07:00
|
|
|
}
|
2026-09-15 15:23:24 -07:00
|
|
|
const structuralPlan = files.length
|
|
|
|
|
? await planStructure(
|
|
|
|
|
root,
|
|
|
|
|
{ kind: node.kind, source: node.source, files },
|
|
|
|
|
process.env.QUIXOS_SNAPSHOT_MAP,
|
|
|
|
|
)
|
|
|
|
|
: undefined;
|
|
|
|
|
step = {
|
|
|
|
|
directory: node.directory,
|
|
|
|
|
phase: "editing",
|
|
|
|
|
treeDigest: node.treeDigest,
|
|
|
|
|
structuralPlan,
|
|
|
|
|
structuralJournal: structuralPlan ? randomUUID() : undefined,
|
|
|
|
|
};
|
|
|
|
|
journal.steps.push(step);
|
|
|
|
|
await writeJournal(filename, journal);
|
2026-09-10 18:27:41 -07:00
|
|
|
}
|
2026-09-15 15:23:24 -07:00
|
|
|
if (step.phase === "editing") {
|
|
|
|
|
if (step.structuralPlan) await applyStructure(step.structuralPlan, step.structuralJournal);
|
|
|
|
|
else if ((await treeDigest(root)) !== step.treeDigest)
|
|
|
|
|
throw new Error("Source changed before upgrade editing");
|
|
|
|
|
step.treeDigest = await treeDigest(root);
|
|
|
|
|
step.phase = "prepared";
|
|
|
|
|
await writeJournal(filename, journal);
|
|
|
|
|
}
|
|
|
|
|
if (step.phase === "refactor") {
|
|
|
|
|
const current = await treeDigest(root);
|
|
|
|
|
if (current !== step.treeDigest && !options.acceptEdits)
|
|
|
|
|
throw new Error("Refactored source requires --accept-edits when resuming");
|
|
|
|
|
step.treeDigest = current;
|
|
|
|
|
step.phase = "prepared";
|
|
|
|
|
await writeJournal(filename, journal);
|
|
|
|
|
}
|
|
|
|
|
if ((await treeDigest(root)) !== step.treeDigest)
|
|
|
|
|
throw new Error(`Source changed during upgrade: ${node.directory}; inspect ${filename}`);
|
|
|
|
|
if (step.phase === "prepared") {
|
|
|
|
|
try {
|
|
|
|
|
await implementation.check(
|
|
|
|
|
node,
|
|
|
|
|
root,
|
|
|
|
|
path.join(directory, `${id}-${node.directory.replaceAll("/", "-")}-${randomUUID()}`),
|
|
|
|
|
plan.spec,
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
step.phase = "refactor";
|
|
|
|
|
await writeJournal(filename, journal);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
if ((await treeDigest(root)) !== step.treeDigest) throw new Error("Source changed while checking");
|
|
|
|
|
step.phase = "checked";
|
|
|
|
|
await writeJournal(filename, journal);
|
|
|
|
|
}
|
|
|
|
|
if (step.phase === "checked") {
|
|
|
|
|
step.commit = await implementation.snapshot(root);
|
|
|
|
|
if ((await treeDigest(root)) !== step.treeDigest)
|
|
|
|
|
throw new Error("Publication snapshot changed checked files");
|
|
|
|
|
step.phase = "publishing";
|
|
|
|
|
await writeJournal(filename, journal);
|
|
|
|
|
}
|
|
|
|
|
await implementation.publish(root, step.commit!);
|
|
|
|
|
step.phase = "published";
|
2026-09-10 18:27:41 -07:00
|
|
|
await writeJournal(filename, journal);
|
|
|
|
|
}
|
2026-09-15 15:23:24 -07:00
|
|
|
// Keep subsequent automatic upgrades associated with the newly published
|
|
|
|
|
// identities, without renaming repositories or changing any selected branch.
|
|
|
|
|
// Explicit-spec callers without a managed graph retain the journal as their
|
|
|
|
|
// source of revisions instead.
|
|
|
|
|
const graphFile = path.join(plan.workbench, ".quixos/resource-graph.json");
|
|
|
|
|
let graphText: string | undefined;
|
|
|
|
|
try {
|
|
|
|
|
graphText = await fs.readFile(graphFile, "utf8");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
2026-09-10 18:27:41 -07:00
|
|
|
}
|
2026-09-15 15:23:24 -07:00
|
|
|
if (graphText !== undefined) {
|
|
|
|
|
const previous = JSON.parse(graphText);
|
|
|
|
|
const snapshots = await Promise.all(
|
|
|
|
|
previous.resources.map(async (entry: { kind: string; source: Source; directory: string }) => ({
|
|
|
|
|
kind: entry.kind,
|
|
|
|
|
...entry.source,
|
|
|
|
|
directory: await location(
|
|
|
|
|
plan.workbench,
|
|
|
|
|
path.relative(plan.workbench, path.resolve(plan.workbench, entry.directory)),
|
|
|
|
|
),
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) {
|
|
|
|
|
const step = journal.steps.find((entry) => entry.directory === node.directory)!;
|
|
|
|
|
if ((await treeDigest(await location(plan.workbench, node.directory))) !== step.treeDigest)
|
|
|
|
|
throw new Error("Published source changed before workbench graph refresh");
|
|
|
|
|
snapshots.push({
|
|
|
|
|
kind: node.kind,
|
|
|
|
|
repository: node.source.repository,
|
|
|
|
|
commit: step.commit,
|
|
|
|
|
directory: path.join(plan.workbench, node.directory),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const unique = [
|
|
|
|
|
...new Map(
|
|
|
|
|
snapshots.map((entry: { kind: string; repository: string; commit: string }) => [
|
|
|
|
|
JSON.stringify([entry.kind, entry.repository, entry.commit]),
|
|
|
|
|
entry,
|
|
|
|
|
]),
|
|
|
|
|
).values(),
|
|
|
|
|
];
|
|
|
|
|
const snapshotMap = path.join(directory, `${id}-published-snapshots.json`);
|
|
|
|
|
await fs.writeFile(snapshotMap, JSON.stringify({ resources: unique }));
|
|
|
|
|
const resolveResource = await createGitCapabilityResolver({
|
|
|
|
|
checkoutRoot: path.join(directory, `${id}-graph-resources`),
|
|
|
|
|
snapshotMap,
|
|
|
|
|
snapshotOnly: true,
|
|
|
|
|
});
|
|
|
|
|
const rootNode = plan.nodes.find((node) => node.kind === "workspace")!;
|
|
|
|
|
if (
|
|
|
|
|
(await treeDigest(await location(plan.workbench, rootNode.directory))) !==
|
|
|
|
|
journal.steps.find((step) => step.directory === rootNode.directory)!.treeDigest
|
|
|
|
|
)
|
|
|
|
|
throw new Error("Root source changed before workbench graph refresh");
|
|
|
|
|
const compiled = await compileWorkspaceRepository({
|
|
|
|
|
rootDirectory: await location(plan.workbench, rootNode.directory),
|
|
|
|
|
resolveResource,
|
|
|
|
|
});
|
|
|
|
|
// Managed repositories need not currently be reachable from the workspace.
|
|
|
|
|
// Keep them discoverable/checkpointed until explicitly removed by the user.
|
|
|
|
|
const resources = new Map<string, ResolvedCapabilityResource>(
|
|
|
|
|
compiled.resources.map((node) => [node.key, node]),
|
|
|
|
|
);
|
|
|
|
|
for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) {
|
|
|
|
|
const step = journal.steps.find((entry) => entry.directory === node.directory)!;
|
|
|
|
|
const source = { resolver: "git" as const, repository: node.source.repository, commit: step.commit! };
|
|
|
|
|
const key = `${node.kind}\0${source.repository}\0${source.commit}`;
|
|
|
|
|
if (resources.has(key)) continue;
|
|
|
|
|
const directory = await location(plan.workbench, node.directory);
|
|
|
|
|
const standalone = await compileCapabilityResourceRepository({
|
|
|
|
|
rootDirectory: directory,
|
2026-09-22 13:51:32 -07:00
|
|
|
kind: node.kind as "package" | "interface" | "build",
|
2026-09-15 15:23:24 -07:00
|
|
|
source,
|
|
|
|
|
resolveResource,
|
|
|
|
|
});
|
|
|
|
|
for (const dependency of standalone.resources) resources.set(dependency.key, dependency);
|
|
|
|
|
resources.set(key, {
|
|
|
|
|
key,
|
2026-09-22 13:51:32 -07:00
|
|
|
kind: node.kind as "package" | "interface" | "build",
|
2026-09-15 15:23:24 -07:00
|
|
|
source,
|
|
|
|
|
directory,
|
|
|
|
|
lock: standalone.lock,
|
|
|
|
|
resource: standalone.resource,
|
|
|
|
|
dependencies: standalone.directResources,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
await writeJournal(graphFile, {
|
|
|
|
|
formatVersion: 1,
|
|
|
|
|
quixos: compiled.lock.quixos,
|
|
|
|
|
directResources: [...compiled.directResources.entries()].map(([bindingKey, node]) => {
|
|
|
|
|
const [kind, binding] = bindingKey.split("\0");
|
2026-10-08 07:45:52 -07:00
|
|
|
return { kind, binding, resourceKey: node.key, directory: path.relative(plan.workbench, node.directory) };
|
2026-09-15 15:23:24 -07:00
|
|
|
}),
|
|
|
|
|
resources: [...resources.values()].map((node) => ({
|
|
|
|
|
key: node.key,
|
|
|
|
|
kind: node.kind,
|
|
|
|
|
source: node.source,
|
2026-10-08 07:45:52 -07:00
|
|
|
directory: path.relative(plan.workbench, node.directory),
|
2026-09-22 13:51:32 -07:00
|
|
|
...(node.resource.kind === "build"
|
|
|
|
|
? {}
|
|
|
|
|
: {
|
|
|
|
|
resourceId:
|
|
|
|
|
node.resource.kind === "interface"
|
|
|
|
|
? node.resource.revision.interfaceId
|
|
|
|
|
: node.resource.revision.packageId,
|
|
|
|
|
revisionId: node.resource.revision.revisionId,
|
|
|
|
|
}),
|
2026-09-15 15:23:24 -07:00
|
|
|
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
|
|
|
|
|
binding,
|
|
|
|
|
resourceKey: dependency.key,
|
|
|
|
|
})),
|
|
|
|
|
})),
|
|
|
|
|
});
|
2026-09-10 18:27:41 -07:00
|
|
|
}
|
2026-09-15 15:23:24 -07:00
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
journal: filename,
|
|
|
|
|
revisions: journal.steps.map((step) => ({ directory: step.directory, commit: step.commit })),
|
|
|
|
|
activated: false,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw new Error(`${error instanceof Error ? error.message : String(error)}; upgrade journal ${filename}`, {
|
|
|
|
|
cause: error,
|
2026-09-10 18:27:41 -07:00
|
|
|
});
|
|
|
|
|
}
|
2026-09-14 10:26:11 -07:00
|
|
|
});
|
2026-09-10 18:27:41 -07:00
|
|
|
};
|