2026-09-10 18:27:41 -07:00
import fs from "node:fs/promises" ;
import path from "node:path" ;
import os from "node:os" ;
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" ;
2026-09-14 10:26:11 -07:00
import { snapshotRepository } from "./candidate-check.js" ;
2026-09-10 18:27:41 -07:00
import { compileWorkspaceRepository , compileCapabilityResourceRepository , type ResolvedCapabilityResource } from "./assembly.js" ;
import { createGitCapabilityResolver } from "./git-resolver.js" ;
2026-09-14 10:26:11 -07:00
import { snapshotCommit , buildImmutableCandidate } from "./checked-build.js" ;
import { planEvolution , type WorkspaceRevision , type EvolutionReview } from "../capability-model/index.js" ;
import { withFileLock } from "./file-lock.js" ;
2026-09-10 18:27:41 -07:00
const execFile = promisify ( callback );
type Source = { repository : string ; commit : string };
export type UpgradeNode = { kind : "workspace" | "package" | "interface" ; directory : string ; source : Source };
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 ();
const validSource = ( value : Source ) => {
const url = new URL ( value . repository );
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" );
};
const location = async ( root : string , directory : string ) => {
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" );
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-" ));
try { return ( await snapshotRepository ( root , temporary )). treeDigest ;} finally { await fs . rm ( temporary , { recursive : true , force : true });}
};
const writeJournal = async ( filename : string , journal : unknown ) => {
const temp = ` ${ filename } . ${ randomUUID () } .tmp` ;
const handle = await fs . open ( temp , "wx" , 0 o600 );
try { await handle . writeFile ( JSON . stringify ( journal , null , 2 )); await handle . sync ();} finally { await handle . close ();}
await fs . rename ( temp , filename );
const directory = await fs . open ( path . dirname ( filename ), "r" );
try { await directory . sync ();} finally { await directory . close ();}
};
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" );
const nodes : UpgradeNode [] = [{ kind : "workspace" , directory : "root" , source : {
2026-09-13 22:07:18 -07:00
repository : await command ( root , "git" , [ "config" , "--get" , "remote.origin.url" ]),
2026-09-10 18:27:41 -07:00
commit : await command ( root , "jj" , [ "--ignore-working-copy" , "log" , "--no-graph" , "-r" , "@" , "-T" , "commit_id" ]),
}}, ... graph . resources . map (( entry : { kind : "interface" | "package" ; directory : string ; source : Source }) => ({ kind : entry.kind , source : entry.source ,
directory : path.relative ( workbench , path . resolve ( workbench , entry . directory ))}))];
let baseline : string | undefined ;
try {
const host = JSON . parse ( await fs . readFile ( "/etc/quixos/workspace-source.json" , "utf8" ));
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 };
};
/** 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 );
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" );
if ( spec . quixos ) validSource ( spec . quixos );
const keys = new Map < string , string >();
for ( const node of spec . nodes ) {
validSource ( node . source );
if ( ! [ "workspace" , "package" , "interface" ]. includes ( node . kind ) || keys . has ( sourceKey ( node ))) throw new Error ( "Duplicate/invalid upgrade resource identity" );
keys . set ( sourceKey ( node ), node . directory );
}
if ( new Set ( spec . nodes . map (( node ) => node . directory )). size !== spec . nodes . length ) throw new Error ( "Upgrade directories must be distinct" );
const nodes : NodePlan [] = [];
for ( const node of spec . nodes ) {
const root = await location ( workbench , node . directory );
2026-09-13 22:07:18 -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" ));
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" ]});
}
const ordered : NodePlan [] = [], remaining = [... nodes ];
while ( remaining . length ) {
const index = remaining . findIndex (( node ) => node . dependencies . every (( dependency ) => ordered . some (( entry ) => entry . directory === dependency )));
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.
ordered . splice ( ordered . indexOf ( workspace ), 1 ); ordered . push ( workspace );
const plan = { schemaVersion : 1 as const , workbench , spec , nodes : ordered };
return {... plan , digest : contentDigest ( plan )};
};
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 >;
};
const effects : UpgradeEffects = {
async check ( node , root , output , spec ) {
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 );
const artifact = await buildImmutableCandidate ({... node . source , commit }, node . kind , path . join ( output , "nix.log" ));
const candidate = await fs . readFile ( path . join ( artifact , "candidate.json" ), "utf8" );
await fs . writeFile ( path . join ( output , "candidate.json" ), candidate );
if ( node . kind === "workspace" ) {
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 [] : [];
const evolution = planEvolution ( baseline , JSON . parse ( candidate ), { reviews });
await fs . writeFile ( path . join ( output , "evolution.json" ), JSON . stringify ( evolution , null , 2 ));
if ( evolution . blockers . length ) throw new Error ( `Refactor required in ${ node . directory } : ${ evolution . blockers . join ( "; " ) } ` );
}
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" ));
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 } ` );
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 } ` ]);
if ( await command ( root , "git" , [ "ls-remote" , "--refs" , "origin" , ref ]) !== ` ${ commit } \ t ${ ref } ` ) throw new Error ( "Publication response uncertain; retry the same journal" );
},
};
/** Explicit --publish only. Append-only remote retention; never moves the
* workspace branch, activates code, or rolls back previously published nodes. */
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 ;
if ( contentDigest ( body ) !== digest ) throw new Error ( "Upgrade plan digest mismatch" );
const directory = path . join ( plan . workbench , ".quixos" , "upgrades" );
await fs . mkdir ( directory , { recursive : true , mode : 0o700 });
if ( await fs . realpath ( directory ) !== directory ) throw new Error ( "Upgrade journals must not cross symlinks" );
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-10 18:27:41 -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 );
2026-09-13 22:07:18 -07:00
if ( await command ( root , "git" , [ "config" , "--get" , "remote.origin.url" ]) !== node . source . repository ) throw new Error ( "Upgrade remote changed after planning" );
2026-09-10 18:27:41 -07:00
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 });
}
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 );
}
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" ; await writeJournal ( filename , journal );
}
// 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 ;}
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 , kind : node.kind as "package" | "interface" , source , resolveResource });
for ( const dependency of standalone . resources ) resources . set ( dependency . key , dependency );
resources . set ( key , { key , kind : node.kind as "package" | "interface" , 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" ); return { kind , binding , resourceKey : node.key , directory : node.directory };}),
resources : [... resources . values ()]. map (( node ) => ({ key : node.key , kind : node.kind , source : node.source , directory : node.directory ,
resourceId : node.resource.kind === "interface" ? node.resource.revision.interfaceId : node.resource.revision.packageId ,
revisionId : node.resource.revision.revisionId , dependencies : [... node . dependencies . entries ()]. map (([ binding , dependency ]) => ({ binding , resourceKey : dependency.key }))})),
});
}
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-14 10:26:11 -07:00
});
2026-09-10 18:27:41 -07:00
};