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 { execFile as execFileCallback } from "node:child_process" ;
import { promisify } from "node:util" ;
import { createHash } from "node:crypto" ;
import { compileWorkspaceRepository , compileCapabilityResourceRepository } from "./assembly.js" ;
import { createGitCapabilityResolver } from "./git-resolver.js" ;
2026-09-15 15:23:24 -07:00
import {
contentDigest ,
planEvolution ,
type EvolutionReview ,
type WorkspaceRevision ,
} from "../capability-model/index.js" ;
2026-09-13 22:07:18 -07:00
import { bindingSchema } from "../bindings/index.js" ;
2026-09-15 15:23:24 -07:00
import { snapshotCommit , checkoutCommit , buildCheckedPackage } from "./checked-build.js" ;
2026-09-10 18:27:41 -07:00
const execFile = promisify ( execFileCallback );
const bytesDigest = ( value : Uint8Array ) => `sha256: ${ createHash ( "sha256" ). update ( value ). digest ( "hex" ) } ` ;
2026-09-15 15:23:24 -07:00
export const localResourceSnapshots = async (
root : string ,
filename? : string ,
) : Promise < { resources : { kind : string ; repository : string ; commit : string ; directory : string }[] } > => {
2026-09-10 18:27:41 -07:00
if ( filename ) {
const document = JSON . parse ( await fs . readFile ( filename , "utf8" ));
2026-09-15 15:23:24 -07:00
return {
resources : document.resources.map (( entry : { directory : string }) => ({
... entry ,
directory : path.resolve ( path . dirname ( filename ), entry . directory ),
})),
};
2026-09-10 18:27:41 -07:00
}
let directory = await fs . realpath ( root );
for (;;) {
let graphText : string | undefined ;
2026-09-15 15:23:24 -07:00
try {
graphText = await fs . readFile ( path . join ( directory , ".quixos/resource-graph.json" ), "utf8" );
} catch ( error ) {
if (( error as NodeJS . ErrnoException ). code !== "ENOENT" ) throw error ;
}
2026-09-10 18:27:41 -07:00
if ( graphText !== undefined ) {
const graph = JSON . parse ( graphText );
const resources = [];
for ( const entry of graph . resources ) {
const location = await fs . realpath ( path . resolve ( directory , entry . directory ));
2026-09-15 15:23:24 -07:00
if ( ! location . startsWith ( ` ${ directory } /resources/` ))
throw new Error ( "Workbench resource escapes managed directory" );
resources . push ({ kind : entry.kind , ... entry . source , directory : location });
2026-09-10 18:27:41 -07:00
}
2026-09-15 15:23:24 -07:00
return { resources };
2026-09-10 18:27:41 -07:00
}
const parent = path . dirname ( directory );
2026-09-15 15:23:24 -07:00
if ( parent === directory ) return { resources : [] };
2026-09-10 18:27:41 -07:00
directory = parent ;
}
};
/** Copy actual authoring files without snapshotting jj or creating a Git commit. */
export const snapshotRepository = async ( source : string , destination : string ) => {
const root = await fs . realpath ( source );
2026-09-15 15:23:24 -07:00
const files = async () =>
(
await execFile ( "git" , [ "-C" , root , "ls-files" , "--cached" , "--others" , "--exclude-standard" , "-z" ], {
maxBuffer : 16 * 1024 * 1024 ,
})
). stdout
. split ( "\0" )
. filter ( Boolean )
. sort ();
2026-09-10 18:27:41 -07:00
const names = [... new Set ( await files ())];
if ( names . length > 50 _000 ) throw new Error ( "Candidate source exceeds 50000 files" );
2026-09-15 15:23:24 -07:00
const contents : { name : string ; digest : string ; mode : number }[] = [];
2026-09-10 18:27:41 -07:00
let bytes = 0 ;
await fs . mkdir ( destination , { recursive : true , mode : 0o700 });
for ( const name of names ) {
2026-09-15 15:23:24 -07:00
if ( path . isAbsolute ( name ) || name . split ( /[\\/]/ ). some (( part ) => part === ".." || part === ".git" || part === ".jj" ))
throw new Error ( "Invalid candidate source path" );
2026-09-10 18:27:41 -07:00
const file = path . join ( root , name );
let metadata ;
2026-09-15 15:23:24 -07:00
try {
metadata = await fs . lstat ( file );
} catch ( error ) {
if (( error as NodeJS . ErrnoException ). code === "ENOENT" ) continue ;
throw error ;
}
if ( ! metadata . isFile () || metadata . isSymbolicLink () || ! ( await fs . realpath ( file )). startsWith ( ` ${ root }${ path . sep } ` ))
throw new Error ( `Candidate source must be a regular file: ${ name } ` );
2026-09-10 18:27:41 -07:00
const data = await fs . readFile ( file );
bytes += data . length ;
if ( bytes > 128 * 1024 * 1024 ) throw new Error ( "Candidate source exceeds 128 MiB" );
contents . push ({ name , digest : bytesDigest ( data ), mode : metadata.mode & 0 o777 });
await fs . mkdir ( path . dirname ( path . join ( destination , name )), { recursive : true });
await fs . writeFile ( path . join ( destination , name ), data , { flag : "wx" , mode : metadata.mode & 0 o777 });
}
2026-09-15 15:23:24 -07:00
if ( JSON . stringify ([... new Set ( await files ())]) !== JSON . stringify ( names ))
throw new Error ( "Source files changed during candidate snapshot" );
for ( const entry of contents )
if ( bytesDigest ( await fs . readFile ( path . join ( root , entry . name ))) !== entry . digest )
throw new Error ( `Source changed during candidate snapshot: ${ entry . name } ` );
2026-09-10 18:27:41 -07:00
return { source : root , directory : destination , treeDigest : contentDigest ( contents ), files : contents };
};
2026-09-13 22:07:18 -07:00
// Local mirrors accelerate resolution, but only their committed locked trees
// may stand in for published dependencies. Never relabel dirty files as a pin.
async function committedResolver ( root : string , temporary : string , filename? : string , publishedOnly = false ) {
2026-09-15 15:23:24 -07:00
const map = publishedOnly ? { resources : [] } : await localResourceSnapshots ( root , filename );
2026-09-13 22:07:18 -07:00
const resources = [];
for ( const [ index , entry ] of map . resources . entries ()) {
const directory = path . join ( temporary , `dependency- ${ index } ` );
await checkoutCommit ( entry . directory , entry . commit , directory );
2026-09-15 15:23:24 -07:00
resources . push ({ ... entry , directory });
2026-09-13 22:07:18 -07:00
}
const snapshotMap = path . join ( temporary , "snapshots.json" );
2026-09-15 15:23:24 -07:00
await fs . writeFile ( snapshotMap , JSON . stringify ({ resources }));
return createGitCapabilityResolver ({ checkoutRoot : path.join ( temporary , "resolved" ), snapshotMap });
2026-09-13 22:07:18 -07:00
}
2026-09-15 15:23:24 -07:00
export const checkResourceCandidate = async ( options : {
root : string ;
output : string ;
kind : "package" | "interface" ;
source : { repository : string ; commit : string };
snapshotMap? : string ;
publishedOnly? : boolean ;
}) => {
await fs . mkdir ( options . output , { mode : 0o700 });
2026-09-10 18:27:41 -07:00
const temporary = await fs . mkdtemp ( path . join ( os . tmpdir (), "qx-resource-check-" ));
const blockers : string [] = [];
2026-09-13 22:07:18 -07:00
let treeDigest : string | undefined , commit : string | undefined , artifactPath : string | undefined ;
2026-09-10 18:27:41 -07:00
try {
2026-09-13 22:07:18 -07:00
commit = await snapshotCommit ( options . root );
treeDigest = ( await snapshotRepository ( options . root , path . join ( temporary , "observed" ))). treeDigest ;
const root = path . join ( temporary , "source" );
await checkoutCommit ( options . root , commit , root );
2026-09-15 15:23:24 -07:00
const resolveResource = await committedResolver (
options . root ,
temporary ,
options . snapshotMap ,
options . publishedOnly ,
);
const compiled = await compileCapabilityResourceRepository ({
rootDirectory : root ,
kind : options.kind ,
source : { resolver : "git" , repository : options.source.repository , commit },
resolveResource ,
});
2026-09-10 18:27:41 -07:00
if ( compiled . resource . kind === "package" ) {
2026-09-13 22:07:18 -07:00
const schema = path . join ( temporary , "bindings.json" );
await fs . writeFile ( schema , JSON . stringify ( bindingSchema ( compiled )));
artifactPath = await buildCheckedPackage ( root , schema , compiled . resource . revision . revisionId );
2026-09-10 18:27:41 -07:00
}
2026-09-15 15:23:24 -07:00
if (( await snapshotCommit ( options . root )) !== commit )
throw new Error ( "Source changed during verification; run the check again" );
2026-09-10 18:27:41 -07:00
await fs . writeFile ( path . join ( options . output , "candidate.json" ), JSON . stringify ( compiled . resource , null , 2 ));
} catch ( error ) {
blockers . push ( error instanceof Error ? error.message : String ( error ));
2026-09-15 15:23:24 -07:00
} finally {
await fs . rm ( temporary , { recursive : true , force : true });
}
const result = {
candidateOnly : true ,
activationEvidence : false ,
commit ,
treeDigest ,
artifactPath ,
blockers ,
note : "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed." ,
};
2026-09-10 18:27:41 -07:00
await fs . writeFile ( path . join ( options . output , "report.json" ), JSON . stringify ( result , null , 2 ));
return result ;
};
2026-09-15 15:23:24 -07:00
export const checkWorkspaceCandidate = async ( options : {
root : string ;
output : string ;
snapshotMap? : string ;
baseline? : string ;
reviews? : string ;
}) => {
await fs . mkdir ( options . output , { mode : 0o700 });
2026-09-13 22:07:18 -07:00
const temporary = await fs . mkdtemp ( path . join ( os . tmpdir (), "qx-workspace-check-" ));
2026-09-15 15:23:24 -07:00
const blockers : string [] = [],
checks : { packageRevisionId : string ; artifactPath : string }[] = [];
2026-09-13 22:07:18 -07:00
let commit : string | undefined ;
2026-09-10 18:27:41 -07:00
try {
2026-09-13 22:07:18 -07:00
commit = await snapshotCommit ( options . root );
for ( const entry of ( await localResourceSnapshots ( options . root , options . snapshotMap )). resources ) {
const current = await snapshotCommit ( entry . directory );
2026-09-15 15:23:24 -07:00
const tree = async ( revision : string ) =>
( await execFile ( "git" , [ "rev-parse" , ` ${ revision } ^{tree}` ], { cwd : entry.directory })). stdout . trim ();
if (( await tree ( current )) !== ( await tree ( entry . commit )))
throw new Error (
`Edited resource is not in the root's locked candidate: ${ entry . directory } . Check that resource, then run qx-workspace resource upgrade --publish to propagate its revision.` ,
);
2026-09-10 18:27:41 -07:00
}
2026-09-13 22:07:18 -07:00
const root = path . join ( temporary , "source" );
await checkoutCommit ( options . root , commit , root );
const resolveResource = await committedResolver ( options . root , temporary , options . snapshotMap );
2026-09-15 15:23:24 -07:00
const compiled = await compileWorkspaceRepository ({
rootDirectory : root ,
sourceRootCommit : commit ,
resolveResource ,
});
const baseline = options . baseline
? ( JSON . parse ( await fs . readFile ( options . baseline , "utf8" )) as WorkspaceRevision )
: null ;
const reviews = options . reviews
? ( JSON . parse ( await fs . readFile ( options . reviews , "utf8" )) as EvolutionReview [])
: [];
const evolution = planEvolution ( baseline , compiled . workspace , { reviews });
2026-09-10 18:27:41 -07:00
blockers . push (... evolution . blockers );
2026-09-15 15:23:24 -07:00
for ( const resource of compiled . resources . filter (( entry ) => entry . kind === "package" )) {
2026-09-13 22:07:18 -07:00
// Per-package recursive schema, identical to host activation, not unrelated
// workspace declarations that would unnecessarily invalidate build caches.
2026-09-15 15:23:24 -07:00
const candidate = await compileCapabilityResourceRepository ({
rootDirectory : resource.directory ,
kind : "package" ,
source : resource.source ,
resolveResource ,
});
2026-09-13 22:07:18 -07:00
const schema = path . join ( temporary , "bindings.json" );
await fs . writeFile ( schema , JSON . stringify ( bindingSchema ( candidate )));
2026-09-15 15:23:24 -07:00
const artifactPath = await buildCheckedPackage (
resource . directory ,
schema ,
candidate . resource . revision . revisionId ,
);
checks . push ({ packageRevisionId : candidate.resource.revision.revisionId , artifactPath });
2026-09-10 18:27:41 -07:00
}
2026-09-15 15:23:24 -07:00
if (( await snapshotCommit ( options . root )) !== commit )
throw new Error ( "Source changed during verification; run the check again" );
const result = {
schemaVersion : 1 ,
candidateOnly : true ,
activationEvidence : false ,
commit ,
evolution ,
checks ,
blockers ,
note : "Checks the committed root and its exact locked dependencies. Resource edits must be verified and repinned before they enter this candidate. No publication or activation performed." ,
};
2026-09-10 18:27:41 -07:00
await fs . writeFile ( path . join ( options . output , "candidate.json" ), JSON . stringify ( compiled . workspace , null , 2 ));
await fs . writeFile ( path . join ( options . output , "report.json" ), JSON . stringify ( result , null , 2 ));
return result ;
} catch ( error ) {
2026-09-15 15:23:24 -07:00
const result = {
schemaVersion : 1 ,
candidateOnly : true ,
activationEvidence : false ,
commit ,
checks ,
blockers : [... blockers , error instanceof Error ? error.message : String ( error )],
};
2026-09-10 18:27:41 -07:00
await fs . writeFile ( path . join ( options . output , "report.json" ), JSON . stringify ( result , null , 2 ));
return result ;
2026-09-15 15:23:24 -07:00
} finally {
await fs . rm ( temporary , { recursive : true , force : true });
}
2026-09-10 18:27:41 -07:00
};