@6reduk/workspace-pipeline 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ import path from 'node:path';
2
+ import {tmpdir} from 'node:os';
3
+ import {parse,fail} from '../contracts/parse.js';
4
+ import {validateStructure} from '../contracts/validate.js';
5
+ import {validateLayoutReferences} from '../contracts/semantic.js';
6
+ import {absoluteRoot} from '../workspace/paths.js';
7
+ import {assertUnreserved,overlaps} from '../workspace/reserved.js';
8
+ import {acquire} from '../source/git.js';
9
+ import {compileDesiredManifest} from '../contracts/desired-state.js';
10
+
11
+ export function parseDesiredWorkspace(text) {
12
+ const value=parse(text,'json');
13
+ if(value.schemaVersion!==2 || Object.keys(value).some(k=>!['schemaVersion','pipeline','adapters','layout'].includes(k)) ||
14
+ !Array.isArray(value.adapters)||!value.adapters.length || new Set(value.adapters).size!==value.adapters.length ||
15
+ value.adapters.some(id=>typeof id!=='string'||!/^[a-z][a-z0-9-]{0,62}$/.test(id)))fail('desired.workspace-schema');
16
+ // Reuse the established Git/layout data types, not old provider installation.
17
+ validateStructure('workspace',{schemaVersion:1,pipeline:value.pipeline,providers:['codex'],layout:value.layout});
18
+ validateLayoutReferences(value.layout);
19
+ const repositories=Object.values(value.layout.repositories).map(repo=>repo.path);
20
+ for(const name of repositories){assertUnreserved(name);if(overlaps(name,'workspace.json'))fail('layout.reserved');}
21
+ for(let i=0;i<repositories.length;i++)for(let j=0;j<i;j++)
22
+ if(overlaps(repositories[i],repositories[j]))fail('desired.repository-overlap');
23
+ return value;
24
+ }
25
+
26
+ // No writes to workspace, no harness execution. Acquired Git objects/snapshot
27
+ // are temporary source material, never backups of installed user contents.
28
+ export async function prepareDesiredWorkspace(workspace,text,{manifestBase,tempRoot=tmpdir(),network=false}={}) {
29
+ workspace=absoluteRoot(workspace);tempRoot=absoluteRoot(tempRoot);
30
+ const rel=path.relative(workspace,tempRoot);
31
+ if(!rel || (!rel.startsWith('..'+path.sep)&&rel!=='..'&&!path.isAbsolute(rel)))fail('desired.preparation-location');
32
+ const descriptor=parseDesiredWorkspace(text);
33
+ const acquired=await acquire(descriptor.pipeline,{manifestBase:manifestBase??workspace,tempRoot,network,packageFormat:'desired'});
34
+ const protectedPaths=Object.values(descriptor.layout.repositories).map(repo=>repo.path);
35
+ const manifest=JSON.stringify(acquired.manifest);
36
+ compileDesiredManifest(manifest,{selected:descriptor.adapters,protectedPaths});
37
+ const binding={source:acquired.source,commit:acquired.commit,digest:acquired.digest,layout:descriptor.layout};
38
+ return {input:{workspace,manifest,selected:descriptor.adapters,protectedPaths,source:acquired.files,binding},
39
+ provenance:{source:acquired.source,commit:acquired.commit,digest:acquired.digest},
40
+ preparation:{objects:acquired.preparation,snapshot:acquired.snapshotPath},descriptor};
41
+ }
@@ -28,8 +28,9 @@ export async function assertLockHeld(lock) {
28
28
  // Cooperative workspace-local exclusion. Existing/stale locks are never stolen.
29
29
  // This does not prevent an editor or hostile process from replacing filesystem
30
30
  // entries; S5 apply must independently recheck each subject before every write.
31
- export async function acquireWorkspaceLock(workspace,{repositoryOperation,migrationOperation}={}) {
31
+ export async function acquireWorkspaceLock(workspace,{repositoryOperation,migrationOperation,purpose}={}) {
32
32
  workspace = absoluteRoot(workspace);
33
+ if(purpose!==undefined && (purpose!=='desired-state'||repositoryOperation!==undefined||migrationOperation!==undefined))fail('lock.purpose');
33
34
  if(repositoryOperation!==undefined&&migrationOperation!==undefined)fail('lock.capability-conflict');
34
35
  const guard=async()=>{
35
36
  if(migrationOperation!==undefined){
@@ -57,7 +58,7 @@ export async function acquireWorkspaceLock(workspace,{repositoryOperation,migrat
57
58
  // Failure after mkdir deliberately leaves a non-acquirable lock for review.
58
59
  await inspectDirectory(directory);
59
60
  const owner = { schemaVersion: 1, workspace, token: randomUUID(), pid: process.pid,
60
- host: hostname(), createdAt: new Date().toISOString() };
61
+ host: hostname(), createdAt: new Date().toISOString(),...(purpose?{purpose}:{}) };
61
62
  const bytes = Buffer.from(JSON.stringify(owner) + '\n');
62
63
  const handle = await open(ownerFile, 'wx', 0o600);
63
64
  try { await handle.writeFile(bytes); await handle.sync(); }
@@ -0,0 +1,33 @@
1
+ import {checkEntries,LIMITS,cap,utf8,sha256} from './inventory.js';
2
+ import {parse,fail} from '../contracts/parse.js';
3
+ import {contractDigest} from '../contracts/semantic.js';
4
+ import {compileDesiredManifest} from '../contracts/desired-state.js';
5
+ import {materializeDesiredFiles} from '../desired-state/inventory.js';
6
+
7
+ // Git already identifies the immutable tree and blobs. V2 computes its source
8
+ // inventory instead of requiring a second hand-maintained installation plan.
9
+ export async function verifyDesiredPackage(entries,readBlob) {
10
+ checkEntries(entries);
11
+ const manifests=entries.filter(e=>['pipeline.json','pipeline.yaml','pipeline.yml'].includes(e.path));
12
+ if(manifests.length!==1)fail('source.manifest');
13
+ cap(manifests[0].size,LIMITS.manifest,'source.metadata-size');
14
+ const files=new Map();
15
+ for(const entry of entries) {
16
+ const bytes=await readBlob(entry);
17
+ if(!Buffer.isBuffer(bytes)||bytes.length!==entry.size)fail('source.blob-size');
18
+ if(bytes.subarray(0,200).toString('ascii').startsWith('version https://git-lfs.github.com/spec/v1'))fail('source.lfs');
19
+ files.set(entry.path,bytes);
20
+ }
21
+ const manifestPath=manifests[0].path;
22
+ const manifest=parse(utf8(files.get(manifestPath)),manifestPath.endsWith('.json')?'json':'yaml');
23
+ // Compile each delivery separately: different alternatives may intentionally
24
+ // serve the same provider; selecting both later still rejects collisions.
25
+ if(!manifest.adapters || typeof manifest.adapters!=='object')fail('desired.schema');
26
+ const serialized=JSON.stringify(manifest);
27
+ for(const id of Object.keys(manifest.adapters))
28
+ materializeDesiredFiles(compileDesiredManifest(serialized,{selected:[id]}),files);
29
+ if(!Object.keys(manifest.adapters).length)fail('desired.schema');
30
+ const fileHashes=Object.fromEntries([...files].map(([name,bytes])=>[name,sha256(bytes)]));
31
+ const digest=contractDigest(fileHashes);
32
+ return {manifest,manifestPath,files,fileHashes,digest,inventoryDigest:digest};
33
+ }
package/src/source/git.js CHANGED
@@ -8,6 +8,7 @@ import { validateStructure } from '../contracts/validate.js';
8
8
  import { portablePath } from '../contracts/semantic.js';
9
9
  import { LIMITS, cap, utf8, verifyPackage } from './inventory.js';
10
10
  import { materialize } from './snapshot.js';
11
+ import {verifyDesiredPackage} from './desired-package.js';
11
12
  import { repositoryBudget } from './repository-budget.js';
12
13
 
13
14
  const oid = value => /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
@@ -259,7 +260,8 @@ export async function acquireRemoteRepository(source, { tempRoot, network = fals
259
260
  }
260
261
  }
261
262
 
262
- export async function acquire(source, { manifestBase, tempRoot = tmpdir(), network = false, packLimit = LIMITS.pack } = {}) {
263
+ export async function acquire(source, { manifestBase, tempRoot = tmpdir(), network = false, packLimit = LIMITS.pack, packageFormat='legacy' } = {}) {
264
+ if(!['legacy','desired'].includes(packageFormat))fail('source.package-format');
263
265
  validateSource(source);
264
266
  cap(packLimit, LIMITS.pack, 'source.pack-limit');
265
267
  if (!path.isAbsolute(manifestBase ?? '')) fail('source.manifest-base');
@@ -284,7 +286,8 @@ export async function acquire(source, { manifestBase, tempRoot = tmpdir(), netwo
284
286
  const rootType = await runGit(repo, ['cat-file', '-t', treeish], options);
285
287
  if (utf8(rootType.bytes).trim() !== 'tree') fail('source.package-root');
286
288
  const listing = await runGit(repo, ['ls-tree', '-r', '-l', '-z', '--full-tree', treeish], options);
287
- const verified = await verifyPackage(parseListing(listing.bytes), async entry => {
289
+ const verify=packageFormat==='desired'?verifyDesiredPackage:verifyPackage;
290
+ const verified = await verify(parseListing(listing.bytes), async entry => {
288
291
  const r = await runGit(repo, ['cat-file', 'blob', entry.oid], { ...options, outputLimit: LIMITS.blob });
289
292
  return r.bytes;
290
293
  });
@@ -293,7 +296,8 @@ export async function acquire(source, { manifestBase, tempRoot = tmpdir(), netwo
293
296
  if (performance.now() > deadline) fail('source.timeout');
294
297
  return { source: structuredClone(source), resolvedSource, commit, preparation, snapshotPath,
295
298
  manifest: verified.manifest, inventoryDigest: verified.inventoryDigest,
296
- digest: verified.digest, fileHashes: verified.fileHashes, runtime: 'not-run' };
299
+ digest: verified.digest, fileHashes: verified.fileHashes, runtime: 'not-run',
300
+ ...(packageFormat==='desired'?{files:verified.files}: {}) };
297
301
  } catch (cause) {
298
302
  const error = cause instanceof ContractError ? cause : new ContractError('source.io');
299
303
  if (preparation) error.preparation = preparation;