@6reduk/workspace-pipeline 0.1.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.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/docs/collaboration.md +59 -0
- package/docs/config-fields.md +65 -0
- package/docs/contracts.md +149 -0
- package/docs/doctor.md +165 -0
- package/docs/launch.md +50 -0
- package/docs/lifecycle-cli.md +141 -0
- package/docs/lifecycle.md +42 -0
- package/docs/migrations/unity.md +288 -0
- package/docs/native-provider-format.md +149 -0
- package/docs/provider-bundles.md +81 -0
- package/docs/provider-resources.md +37 -0
- package/docs/release.md +25 -0
- package/docs/remove.md +70 -0
- package/docs/repair.md +80 -0
- package/docs/repositories.md +209 -0
- package/docs/repository-manual-recovery.md +102 -0
- package/docs/repository-observations.md +29 -0
- package/docs/repository-recovery.md +204 -0
- package/docs/repository-retention.md +46 -0
- package/docs/repository-transport-budgets.md +26 -0
- package/docs/retention.md +237 -0
- package/docs/source.md +50 -0
- package/docs/switch.md +412 -0
- package/package.json +39 -0
- package/schemas/common.schema.json +251 -0
- package/schemas/inventory.schema.json +15 -0
- package/schemas/operation.schema.json +286 -0
- package/schemas/pipeline.schema.json +317 -0
- package/schemas/state.schema.json +302 -0
- package/schemas/workspace.schema.json +67 -0
- package/src/cli.js +7 -0
- package/src/commands/adopt.js +2 -0
- package/src/commands/bootstrap-recovery.js +90 -0
- package/src/commands/dispatch.js +394 -0
- package/src/commands/init.js +84 -0
- package/src/commands/launch.js +69 -0
- package/src/commands/migration-apply.js +53 -0
- package/src/commands/migration.js +50 -0
- package/src/commands/repositories.js +61 -0
- package/src/commands/repository-abandon.js +16 -0
- package/src/commands/repository-ancestors.js +21 -0
- package/src/commands/repository-locks.js +77 -0
- package/src/contracts/parse.js +57 -0
- package/src/contracts/semantic.js +240 -0
- package/src/contracts/validate.js +21 -0
- package/src/launch/grok.js +20 -0
- package/src/migrations/legacy-unity-begin.js +35 -0
- package/src/migrations/legacy-unity-compensate.js +82 -0
- package/src/migrations/legacy-unity-deactivate.js +60 -0
- package/src/migrations/legacy-unity-deactivation-resume-apply.js +62 -0
- package/src/migrations/legacy-unity-deactivation-resume.js +81 -0
- package/src/migrations/legacy-unity-finalize.js +88 -0
- package/src/migrations/legacy-unity-install-recovery.js +76 -0
- package/src/migrations/legacy-unity-install-resume.js +60 -0
- package/src/migrations/legacy-unity-install.js +88 -0
- package/src/migrations/legacy-unity-lease.js +136 -0
- package/src/migrations/legacy-unity-preflight.js +67 -0
- package/src/migrations/legacy-unity-preview.js +91 -0
- package/src/migrations/legacy-unity-resume-apply.js +39 -0
- package/src/migrations/legacy-unity-resume.js +41 -0
- package/src/migrations/legacy-unity-resumed-evidence.js +88 -0
- package/src/migrations/legacy-unity.js +99 -0
- package/src/operations/apply.js +576 -0
- package/src/operations/backup.js +94 -0
- package/src/operations/bootstrap-lock.js +87 -0
- package/src/operations/bootstrap-owner-retirement.js +121 -0
- package/src/operations/bundle-update.js +54 -0
- package/src/operations/config-fields.js +24 -0
- package/src/operations/continuation-lifecycle.js +77 -0
- package/src/operations/doctor.js +214 -0
- package/src/operations/history.js +170 -0
- package/src/operations/installer-identity.js +49 -0
- package/src/operations/journal.js +142 -0
- package/src/operations/lifecycle.js +133 -0
- package/src/operations/lineage-guard.js +20 -0
- package/src/operations/lock.js +109 -0
- package/src/operations/maintenance.js +62 -0
- package/src/operations/migration-pending.js +22 -0
- package/src/operations/ownership.js +122 -0
- package/src/operations/plan.js +278 -0
- package/src/operations/reconciliation.js +112 -0
- package/src/operations/recovery-lease.js +66 -0
- package/src/operations/remove.js +140 -0
- package/src/operations/repair.js +188 -0
- package/src/operations/repository-abandon.js +192 -0
- package/src/operations/repository-ancestors.js +158 -0
- package/src/operations/repository-apply.js +122 -0
- package/src/operations/repository-authorization.js +48 -0
- package/src/operations/repository-bootstrap-continuation.js +190 -0
- package/src/operations/repository-bootstrap-reconcile.js +106 -0
- package/src/operations/repository-bootstrap-recover.js +114 -0
- package/src/operations/repository-bootstrap.js +82 -0
- package/src/operations/repository-clone.js +63 -0
- package/src/operations/repository-history.js +108 -0
- package/src/operations/repository-inputs.js +41 -0
- package/src/operations/repository-journal.js +127 -0
- package/src/operations/repository-lock-reconcile.js +401 -0
- package/src/operations/repository-pending.js +29 -0
- package/src/operations/repository-reconcile.js +182 -0
- package/src/operations/repository-resumption-approvals.js +77 -0
- package/src/operations/repository-retention-apply.js +75 -0
- package/src/operations/repository-retention.js +137 -0
- package/src/operations/repository-workspace.js +78 -0
- package/src/operations/retention-apply.js +133 -0
- package/src/operations/retention-combined-scan.js +30 -0
- package/src/operations/retention-combined.js +41 -0
- package/src/operations/retention-policy.js +65 -0
- package/src/operations/retention-receipts.js +126 -0
- package/src/operations/retention-scan.js +86 -0
- package/src/operations/retention.js +56 -0
- package/src/operations/state.js +210 -0
- package/src/operations/switch-activate.js +64 -0
- package/src/operations/switch-backups.js +29 -0
- package/src/operations/switch-continuation-journal.js +94 -0
- package/src/operations/switch-continuation-pending.js +60 -0
- package/src/operations/switch-continuation-records.js +109 -0
- package/src/operations/switch-continuation-recovery.js +91 -0
- package/src/operations/switch-continuation-runtime.js +135 -0
- package/src/operations/switch-continuation-store.js +120 -0
- package/src/operations/switch-continuation.js +76 -0
- package/src/operations/switch-execute.js +68 -0
- package/src/operations/switch-inspect.js +45 -0
- package/src/operations/switch-journal-store.js +109 -0
- package/src/operations/switch-journal.js +71 -0
- package/src/operations/switch-lifecycle.js +72 -0
- package/src/operations/switch-pending.js +43 -0
- package/src/operations/switch-preflight.js +73 -0
- package/src/operations/switch-prepare.js +75 -0
- package/src/operations/switch-records.js +60 -0
- package/src/operations/switch-recovery-store.js +74 -0
- package/src/operations/switch.js +52 -0
- package/src/operations/toml-fields.js +133 -0
- package/src/providers/bundles.js +42 -0
- package/src/providers/common-entry.js +16 -0
- package/src/providers/grok.js +26 -0
- package/src/providers/interface.js +25 -0
- package/src/providers/kimi.js +26 -0
- package/src/providers/native.js +155 -0
- package/src/providers/registry.js +10 -0
- package/src/providers/shared.js +51 -0
- package/src/providers/source.js +30 -0
- package/src/source/git.js +303 -0
- package/src/source/inventory.js +87 -0
- package/src/source/repository-budget.js +18 -0
- package/src/source/snapshot.js +37 -0
- package/src/workspace/paths.js +54 -0
- package/src/workspace/profiles.js +8 -0
- package/src/workspace/repositories.js +57 -0
- package/src/workspace/repository-inventory.js +77 -0
- package/src/workspace/repository-observation.js +38 -0
- package/src/workspace/repository-preflight.js +129 -0
- package/src/workspace/repository-preview.js +196 -0
- package/src/workspace/repository-tree.js +57 -0
- package/src/workspace/reserved.js +11 -0
- package/src/workspace/resolve.js +53 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdir,open,lstat,rename,readdir } from 'node:fs/promises';
|
|
3
|
+
import { ContractError,fail } from '../contracts/parse.js';
|
|
4
|
+
import { sha256 } from '../source/inventory.js';
|
|
5
|
+
import { contractDigest } from '../contracts/semantic.js';
|
|
6
|
+
import { inspectDirectory } from '../workspace/paths.js';
|
|
7
|
+
import { inspectRepositoryDestination } from '../workspace/repository-preflight.js';
|
|
8
|
+
import { revalidateRepositoryPreview } from '../workspace/repository-preview.js';
|
|
9
|
+
import { acquireBootstrapLock,assertBootstrapLockHeld } from './bootstrap-lock.js';
|
|
10
|
+
import { readRecord } from './state.js';
|
|
11
|
+
|
|
12
|
+
const identity=s=>({dev:String(s.dev),ino:String(s.ino)});
|
|
13
|
+
async function persist(file,value) {
|
|
14
|
+
const bytes=Buffer.from(JSON.stringify(value)+'\n'),handle=await open(file,'wx',0o600);
|
|
15
|
+
try {await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
|
|
16
|
+
if((await readRecord(file)).digest!==sha256(bytes))fail('repository-bootstrap.readback');
|
|
17
|
+
return sha256(bytes);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Internal bootstrap phase only. Repository effects and pipeline activation are
|
|
21
|
+
// separate: its original preview cannot be reused after the approved mkdir.
|
|
22
|
+
// A durable external intent precedes mkdir. Any interrupted handoff retains the
|
|
23
|
+
// sibling lock and records for explicit recovery, never recursive rollback.
|
|
24
|
+
export async function createRepositoryWrapper({pipeline,workspace,wrapper,choices,options={},previewText,approval,
|
|
25
|
+
onLocation=async()=>{},ioBoundary=async()=>{}}) {
|
|
26
|
+
if(!approval || Object.keys(approval).sort().join(',')!=='decision,previewDigest' || approval.decision!=='approve')
|
|
27
|
+
fail('repository-bootstrap.approval');
|
|
28
|
+
const validate=()=>revalidateRepositoryPreview(previewText,approval.previewDigest,pipeline,workspace,wrapper,choices,options);
|
|
29
|
+
const preview=await validate();
|
|
30
|
+
if(!preview.manifest)fail('repository-bootstrap.manifest-required');
|
|
31
|
+
if(preview.wrapperObservation.action!=='create')fail('repository-bootstrap.wrapper-exists');
|
|
32
|
+
if(!(await inspectDirectory(path.dirname(preview.wrapper))).exists)fail('repository-bootstrap.parent-required');
|
|
33
|
+
let lock,intentStarted=false,completed=false;
|
|
34
|
+
try {
|
|
35
|
+
lock=await acquireBootstrapLock(preview.wrapper,{onLocation});
|
|
36
|
+
await validate();await assertBootstrapLockHeld(lock);
|
|
37
|
+
const intentFile=path.join(lock.directory,'intent.json');
|
|
38
|
+
intentStarted=true;
|
|
39
|
+
const intentDigest=await persist(intentFile,{schemaVersion:1,kind:'wrapper-create-intent',previewDigest:preview.digest,
|
|
40
|
+
wrapper:preview.wrapper,before:preview.wrapperObservation,compensation:'no-automatic-rollback'});
|
|
41
|
+
await ioBoundary('wrapper-intent-persisted',{directory:lock.directory});
|
|
42
|
+
await assertBootstrapLockHeld(lock);
|
|
43
|
+
if((await readRecord(intentFile)).digest!==intentDigest)fail('repository-bootstrap.intent-drift');
|
|
44
|
+
const before={action:'create',...(await inspectRepositoryDestination(preview.wrapper))};
|
|
45
|
+
if(contractDigest(before)!==contractDigest(preview.wrapperObservation))fail('repository-bootstrap.destination-drift');
|
|
46
|
+
await mkdir(preview.wrapper);
|
|
47
|
+
const created=identity(await lstat(preview.wrapper,{bigint:true}));
|
|
48
|
+
await ioBoundary('wrapper-created',{wrapper:preview.wrapper});
|
|
49
|
+
await assertBootstrapLockHeld(lock);await inspectDirectory(preview.wrapper);
|
|
50
|
+
if(contractDigest(identity(await lstat(preview.wrapper,{bigint:true})))!==contractDigest(created) ||
|
|
51
|
+
(await readdir(preview.wrapper)).length)fail('repository-bootstrap.wrapper-drift');
|
|
52
|
+
const metadata=path.join(preview.wrapper,'.pipeline');await mkdir(metadata);
|
|
53
|
+
const metadataIdentity=identity(await lstat(metadata,{bigint:true}));
|
|
54
|
+
const receipt=path.join(metadata,'repository-bootstrap.json');
|
|
55
|
+
const receiptDigest=await persist(receipt,{schemaVersion:1,kind:'wrapper-created',previewDigest:preview.digest,
|
|
56
|
+
wrapper:preview.wrapper,identity:created,metadataIdentity,intentDigest,
|
|
57
|
+
pipelineActivated:false,repositoryEffectsPerformed:false});
|
|
58
|
+
await ioBoundary('wrapper-receipt-persisted',{receipt});
|
|
59
|
+
await assertBootstrapLockHeld(lock);await inspectDirectory(metadata);
|
|
60
|
+
if(contractDigest(identity(await lstat(preview.wrapper,{bigint:true})))!==contractDigest(created) ||
|
|
61
|
+
contractDigest(identity(await lstat(metadata,{bigint:true})))!==contractDigest(metadataIdentity))
|
|
62
|
+
fail('repository-bootstrap.wrapper-drift');
|
|
63
|
+
const history=path.join(metadata,'repository-bootstrap-intent.json');
|
|
64
|
+
// Never replace even an untrusted receipt. Rename remains a cooperative,
|
|
65
|
+
// not hostile-concurrent-writer, guarantee like the surrounding lifecycle.
|
|
66
|
+
if((await readdir(metadata)).includes(path.basename(history)))fail('repository-bootstrap.history-exists');
|
|
67
|
+
if((await readRecord(intentFile)).digest!==intentDigest || (await readRecord(receipt)).digest!==receiptDigest)
|
|
68
|
+
fail('repository-bootstrap.readback');
|
|
69
|
+
await rename(intentFile,history);
|
|
70
|
+
const record=await readRecord(receipt);
|
|
71
|
+
if(record.digest!==receiptDigest || (await readRecord(history)).digest!==intentDigest)fail('repository-bootstrap.readback');
|
|
72
|
+
completed=true;
|
|
73
|
+
return {status:'wrapper-created',wrapper:preview.wrapper,receipt,requiresRepositoryPreview:true,
|
|
74
|
+
pipelineActivated:false,repositoryEffectsPerformed:false};
|
|
75
|
+
}catch(cause){
|
|
76
|
+
const error=cause instanceof ContractError?cause:new ContractError('repository-bootstrap.io');
|
|
77
|
+
if(lock)error.bootstrapDirectory=lock.directory;
|
|
78
|
+
throw error;
|
|
79
|
+
}finally{
|
|
80
|
+
if(lock && (!intentStarted || completed))await lock.release();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fail } from '../contracts/parse.js';
|
|
4
|
+
import { contractDigest } from '../contracts/semantic.js';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
6
|
+
import { runGit, runRepositoryGit } from '../source/git.js';
|
|
7
|
+
import { repositoryBudget } from '../source/repository-budget.js';
|
|
8
|
+
import { summarizeTree } from '../workspace/repository-observation.js';
|
|
9
|
+
import { inventoryRepository } from '../workspace/repository-inventory.js';
|
|
10
|
+
import { inspectRepositoryGit } from '../workspace/repository-preflight.js';
|
|
11
|
+
import { inspectRepositoryTree } from '../workspace/repository-tree.js';
|
|
12
|
+
import { assertLockHeld } from './lock.js';
|
|
13
|
+
import { assertBootstrapLockHeld } from './bootstrap-lock.js';
|
|
14
|
+
|
|
15
|
+
const policy={head:'detached',history:'depth-1',origin:'not-configured',templates:'disabled'};
|
|
16
|
+
export async function verifyClonedRepository(op,observed) {
|
|
17
|
+
if(contractDigest(op.clonePolicy)!==contractDigest(policy))fail('repository-clone.policy');
|
|
18
|
+
// Conservative config screening precedes native status; arbitrary local filter
|
|
19
|
+
// configuration cannot be executed just because a saved receipt says completed.
|
|
20
|
+
const git=await inspectRepositoryGit(op.target,op.binding);
|
|
21
|
+
if(git.blockers.length || git.head!==op.binding.commit || git.dirty || git.inventory.digest!==observed.digest)return false;
|
|
22
|
+
const tree=await inspectRepositoryTree(op.target,op.binding.commit,op.target,op.binding);
|
|
23
|
+
if(contractDigest(op.checkoutTree?.kind==='repository-tree-summary'?summarizeTree(tree):tree)!==contractDigest(op.checkoutTree))return false;
|
|
24
|
+
const expected=new Set(tree.entries.map(e=>e.path));
|
|
25
|
+
const actual=observed.entries.filter(e=>e.type==='file' && !e.path.startsWith('.git/'));
|
|
26
|
+
if(actual.length!==expected.size || actual.some(e=>!expected.has(e.path)))return false;
|
|
27
|
+
// No origin is silently assigned to a temporary staging directory.
|
|
28
|
+
const remotes=await runGit(op.target,['remote']);
|
|
29
|
+
return remotes.bytes.length===0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Journal intent and destination precheck belong to the caller. Local transport
|
|
33
|
+
// only; no clone hardlinks/shared object database and no account/network access.
|
|
34
|
+
export async function materializeRepositoryClone(op,lock,bootstrap,ioBoundary) {
|
|
35
|
+
if(contractDigest(op.clonePolicy)!==contractDigest(policy))fail('repository-clone.policy');
|
|
36
|
+
const budget=repositoryBudget(op.binding), deadline=performance.now()+budget.acquisitionMs;
|
|
37
|
+
const execute=(args,options={})=>runRepositoryGit(op.target,args,{...budget,deadline,...options});
|
|
38
|
+
const source=op.binding.repo ?? op.binding.resolvedSource;
|
|
39
|
+
const before=op.binding.preparedInventory ?? op.binding.sourceObservation.inventory;
|
|
40
|
+
const guard=async()=>{
|
|
41
|
+
await assertLockHeld(lock);await assertBootstrapLockHeld(bootstrap);
|
|
42
|
+
if((await inventoryRepository(source)).digest!==before.digest)fail('repository-clone.source-drift');
|
|
43
|
+
};
|
|
44
|
+
await guard();await mkdir(op.target);
|
|
45
|
+
await ioBoundary('clone-directory-created',{repository:op.repository});
|
|
46
|
+
await guard();
|
|
47
|
+
if((await inventoryRepository(op.target)).entries.length!==1)fail('repository-clone.target-drift');
|
|
48
|
+
await execute(['init','--template=','--object-format='+(op.binding.commit.length===64?'sha256':'sha1')]);
|
|
49
|
+
await guard();
|
|
50
|
+
await execute(['-c','protocol.allow=never','-c','protocol.file.allow=always',
|
|
51
|
+
'-c','fetch.unpackLimit=0','-c','transfer.unpackLimit=0','fetch','--depth=1','--no-tags',
|
|
52
|
+
'--no-recurse-submodules','--no-auto-maintenance','--no-write-fetch-head','--keep','--',source,op.binding.commit],
|
|
53
|
+
{objects:path.join(op.target,'.git','objects')});
|
|
54
|
+
await ioBoundary('clone-fetched',{repository:op.repository});
|
|
55
|
+
await guard();
|
|
56
|
+
const staged=await inventoryRepository(op.target);
|
|
57
|
+
if(staged.entries.some(e=>e.path!=='.' && e.path!=='.git' && !e.path.startsWith('.git/')))fail('repository-clone.target-drift');
|
|
58
|
+
// Metadata whitelist before checkout, not just after it.
|
|
59
|
+
const {inspectRepositoryMetadata}=await import('../workspace/repository-preflight.js');
|
|
60
|
+
if((await inspectRepositoryMetadata(op.target,{...budget,deadline})).blockers.length)fail('repository-clone.metadata');
|
|
61
|
+
await execute(['-c','core.autocrlf=false','-c','core.attributesFile=/dev/null','checkout','--detach',op.binding.commit]);
|
|
62
|
+
await guard();
|
|
63
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat,opendir } from 'node:fs/promises';
|
|
3
|
+
import { absoluteRoot,inspectDirectory } from '../workspace/paths.js';
|
|
4
|
+
import { fail } from '../contracts/parse.js';
|
|
5
|
+
import { bootstrapLockDirectory } from './bootstrap-lock.js';
|
|
6
|
+
import { readRecord } from './state.js';
|
|
7
|
+
|
|
8
|
+
// Location inventory only, not journal/evidence validation or cleanup authority.
|
|
9
|
+
// No recursive enumeration and no secret-bearing record contents in the output.
|
|
10
|
+
export async function listRepositoryHistory(workspace,{maxEntries=1000}={}) {
|
|
11
|
+
workspace=absoluteRoot(workspace);
|
|
12
|
+
if(!Number.isSafeInteger(maxEntries) || maxEntries<1 || maxEntries>1000)fail('repository-history.limit-option');
|
|
13
|
+
const entries=[],diagnostics=[];let count=0;
|
|
14
|
+
const issue=(error,subject)=>diagnostics.push({code:error.code??'repository-history.io',subject});
|
|
15
|
+
const consume=()=>{if(++count>maxEntries)fail('repository-history.limit');};
|
|
16
|
+
const add=async(filename,kind)=>{
|
|
17
|
+
let info;try{info=await lstat(filename);}catch(e){if(e.code==='ENOENT')return;throw e;}
|
|
18
|
+
entries.push({path:filename,kind,type:info.isSymbolicLink()?'link':info.isDirectory()?'directory':info.isFile()?'file':'other',
|
|
19
|
+
deletionEligible:false,protectionReasons:['requires-validated-retention-preview']});
|
|
20
|
+
if(info.isSymbolicLink() || (!info.isFile() && !info.isDirectory()) || (info.isFile() && info.nlink!==1))
|
|
21
|
+
fail('repository-history.unsafe-type');
|
|
22
|
+
};
|
|
23
|
+
const uuid='[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}';
|
|
24
|
+
for(const [name,pattern] of [
|
|
25
|
+
['repository-journals',new RegExp('^'+uuid+'$')],['repository-evidence',new RegExp('^'+uuid+'$')],
|
|
26
|
+
['repository-authorizations',new RegExp('^'+uuid+'\\.json$')],
|
|
27
|
+
['repository-inputs',new RegExp('^'+uuid+'\\.json$')],
|
|
28
|
+
['repository-lock-recoveries',/^[a-f0-9]{64}$/],
|
|
29
|
+
['repository-cleanup',new RegExp('^'+uuid+'\\.json$')],
|
|
30
|
+
['repository-completions',new RegExp('^'+uuid+'(?:\\.pending)?\\.json$')]]) {
|
|
31
|
+
const directory=path.join(workspace,'.pipeline',name);
|
|
32
|
+
try {
|
|
33
|
+
if(!(await inspectDirectory(directory)).exists)continue;
|
|
34
|
+
for await(const entry of await opendir(directory)) {
|
|
35
|
+
consume();const filename=path.join(directory,entry.name);
|
|
36
|
+
try{await add(filename,name);}catch(e){issue(e,filename);}
|
|
37
|
+
if(!pattern.test(entry.name))issue({code:'repository-history.foreign-name'},filename);
|
|
38
|
+
}
|
|
39
|
+
}catch(e){issue(e,directory);}
|
|
40
|
+
}
|
|
41
|
+
for(const name of ['repository-operation.json','repository-bootstrap.json','repository-bootstrap-intent.json',
|
|
42
|
+
'repository-bootstrap-recovery.json','repository-bootstrap-recovered-lock']) {
|
|
43
|
+
const filename=path.join(workspace,'.pipeline',name);
|
|
44
|
+
try{consume();await inspectDirectory(path.dirname(filename));await add(filename,'repository-bootstrap-record');}
|
|
45
|
+
catch(e){issue(e,filename);}
|
|
46
|
+
}
|
|
47
|
+
const bootstrap=bootstrapLockDirectory(workspace);
|
|
48
|
+
for(const filename of [bootstrap,bootstrap+'.recovery',bootstrap+'.recovery-resume']) {
|
|
49
|
+
try{consume();await inspectDirectory(path.dirname(filename));await add(filename,'repository-bootstrap-lock');}
|
|
50
|
+
catch(e){issue(e,filename);}
|
|
51
|
+
}
|
|
52
|
+
const parent=path.dirname(workspace);
|
|
53
|
+
try {
|
|
54
|
+
let scanned=0;
|
|
55
|
+
if((await inspectDirectory(parent)).exists)for await(const item of await opendir(parent)) {
|
|
56
|
+
if(++scanned>100000)fail('repository-history.parent-limit');
|
|
57
|
+
const abandoned=/^\.wpc-bootstrap-abandoned-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$(?![\s\S])/.test(item.name);
|
|
58
|
+
const resolution=new RegExp('^\\.wpc-repository-resolution-'+uuid+'$(?![\\s\\S])').test(item.name);
|
|
59
|
+
if(!abandoned && !resolution && !/^\.wpc-bootstrap-(?:history|recovery)-[a-f0-9]{64}$(?![\s\S])/.test(item.name))continue;
|
|
60
|
+
consume();
|
|
61
|
+
const directory=path.join(parent,item.name);
|
|
62
|
+
// Historical siblings may belong to another wrapper. Only an exact owner
|
|
63
|
+
// binding selects one; ambiguous/unreadable history is reported, not deleted.
|
|
64
|
+
try {
|
|
65
|
+
await inspectDirectory(directory);
|
|
66
|
+
if(resolution) {
|
|
67
|
+
const request=await readRecord(path.join(directory,'request.json'));
|
|
68
|
+
if(typeof request.value?.wrapper!=='string')fail('repository-history.owner');
|
|
69
|
+
if(request.value.wrapper===workspace)await add(directory,'repository-resolution-history');
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if(abandoned) {
|
|
73
|
+
const request=await readRecord(path.join(directory,'request.json'));
|
|
74
|
+
if(typeof request.value?.preview?.wrapper!=='string')fail('repository-history.owner');
|
|
75
|
+
if(request.value.preview.wrapper===workspace)await add(directory,'repository-bootstrap-abandoned-history');
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const owner=await readRecord(path.join(directory,'owner.json'));
|
|
79
|
+
const isRecovery=item.name.startsWith('.wpc-bootstrap-recovery-');
|
|
80
|
+
const binding=isRecovery?owner.value?.wrapper:owner.value?.workspace;
|
|
81
|
+
if(typeof binding!=='string')fail('repository-history.owner');
|
|
82
|
+
if(binding===workspace)await add(directory,isRecovery?'repository-bootstrap-recovery-history':'repository-bootstrap-history');
|
|
83
|
+
}catch(e){issue(e,directory);}
|
|
84
|
+
}
|
|
85
|
+
}catch(e){issue(e,parent);}
|
|
86
|
+
// Ancestor creation may have retained ownership evidence above the direct
|
|
87
|
+
// wrapper parent. Scan only names, under a shared depth/entry bound; never
|
|
88
|
+
// descend through unrelated directories or infer deletion authority.
|
|
89
|
+
try {
|
|
90
|
+
let ancestor=parent,scanned=0,depth=0;
|
|
91
|
+
for(;depth<32;depth++) {
|
|
92
|
+
if((await inspectDirectory(ancestor)).exists)for await(const item of await opendir(ancestor)) {
|
|
93
|
+
if(++scanned>100000)fail('repository-history.ancestor-limit');
|
|
94
|
+
if(!new RegExp('^\\.wpc-ancestors-'+uuid+'$(?![\\s\\S])').test(item.name))continue;
|
|
95
|
+
consume();const directory=path.join(ancestor,item.name);
|
|
96
|
+
try {
|
|
97
|
+
await inspectDirectory(directory);const request=await readRecord(path.join(directory,'request.json'));
|
|
98
|
+
if(typeof request.value?.wrapper!=='string')fail('repository-history.owner');
|
|
99
|
+
if(request.value.wrapper===workspace)await add(directory,'repository-ancestor-history');
|
|
100
|
+
}catch(e){issue(e,directory);}
|
|
101
|
+
}
|
|
102
|
+
const next=path.dirname(ancestor);if(next===ancestor)break;ancestor=next;
|
|
103
|
+
}
|
|
104
|
+
if(depth===32)fail('repository-history.ancestor-depth');
|
|
105
|
+
}catch(e){issue(e,parent);}
|
|
106
|
+
entries.sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);
|
|
107
|
+
return {workspace,entries,diagnostics,complete:diagnostics.length===0,validation:'locations-only',deletionEligible:false};
|
|
108
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {mkdir,open} from 'node:fs/promises';
|
|
3
|
+
import {fail,MAX_INPUT_BYTES} from '../contracts/parse.js';
|
|
4
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
5
|
+
import {resolveChild,inspectDirectory} from '../workspace/paths.js';
|
|
6
|
+
import {readRecord} from './state.js';
|
|
7
|
+
import {sha256} from '../source/inventory.js';
|
|
8
|
+
|
|
9
|
+
function location(wrapper,journal) {
|
|
10
|
+
if(!/^\.pipeline\/repository-journals\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(journal))
|
|
11
|
+
fail('repository-inputs.journal');
|
|
12
|
+
return resolveChild(wrapper,'.pipeline/repository-inputs/'+path.basename(journal)+'.json');
|
|
13
|
+
}
|
|
14
|
+
function validate(value,wrapper,journal,digest) {
|
|
15
|
+
if(!value || Object.keys(value).sort().join(',')!=='journal,preview,schemaVersion,wrapper' ||
|
|
16
|
+
value.schemaVersion!==1 || value.wrapper!==wrapper || value.journal!==journal)
|
|
17
|
+
fail('repository-inputs.binding');
|
|
18
|
+
const {digest:actual,...body}=value.preview??{};
|
|
19
|
+
if(actual!==digest || contractDigest(body)!==digest || body.wrapper!==wrapper ||
|
|
20
|
+
body.kind!=='repository-preview-candidate')fail('repository-inputs.preview');
|
|
21
|
+
}
|
|
22
|
+
// Recorded before any repository effect. This is recoverable input, not approval
|
|
23
|
+
// to execute/replay: callers still inspect current state and obtain exact authority.
|
|
24
|
+
export async function persistRepositoryInputs(wrapper,journal,preview) {
|
|
25
|
+
const filename=location(wrapper,journal),value={schemaVersion:1,wrapper,journal,preview};
|
|
26
|
+
validate(value,wrapper,journal,preview.digest);
|
|
27
|
+
const bytes=Buffer.from(JSON.stringify(value)+'\n');
|
|
28
|
+
if(bytes.length>MAX_INPUT_BYTES)fail('repository-inputs.size');
|
|
29
|
+
try{await mkdir(path.dirname(filename));}catch(e){if(e.code!=='EEXIST')throw e;}
|
|
30
|
+
await inspectDirectory(path.dirname(filename));
|
|
31
|
+
const handle=await open(filename,'wx',0o600);
|
|
32
|
+
try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
|
|
33
|
+
if((await readRecord(filename)).digest!==sha256(bytes))fail('repository-inputs.readback');
|
|
34
|
+
return filename;
|
|
35
|
+
}
|
|
36
|
+
export async function readRepositoryInputs(wrapper,journal,previewDigest) {
|
|
37
|
+
if(!/^sha256:[a-f0-9]{64}$/.test(previewDigest??''))fail('repository-inputs.digest');
|
|
38
|
+
const record=await readRecord(location(wrapper,journal));
|
|
39
|
+
validate(record.value,wrapper,journal,previewDigest);
|
|
40
|
+
return {previewText:JSON.stringify(record.value.preview),previewDigest,path:record.path,recordDigest:record.digest};
|
|
41
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdir,open,readdir } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { fail,ContractError } from '../contracts/parse.js';
|
|
4
|
+
import { contractDigest } from '../contracts/semantic.js';
|
|
5
|
+
import { resolveChild,inspectDirectory } from '../workspace/paths.js';
|
|
6
|
+
import { sha256 } from '../source/inventory.js';
|
|
7
|
+
import { readRecord } from './state.js';
|
|
8
|
+
import { assertLockHeld } from './lock.js';
|
|
9
|
+
|
|
10
|
+
const exact=(o,keys)=>o!==null && typeof o==='object' && !Array.isArray(o) && Object.keys(o).sort().join(',')===[...keys].sort().join(',');
|
|
11
|
+
const hash=s=>typeof s==='string' && /^sha256:[a-f0-9]{64}$/.test(s);
|
|
12
|
+
const file=seq=>String(seq).padStart(6,'0')+'.json';
|
|
13
|
+
function bound(preview) {
|
|
14
|
+
if (!preview || preview.kind!=='repository-preview-candidate' || !hash(preview.digest) ||
|
|
15
|
+
!Array.isArray(preview.operations) || !preview.operations.length || preview.operations.length>128)fail('repository-journal.preview');
|
|
16
|
+
const {digest,...body}=preview;
|
|
17
|
+
if(contractDigest(body)!==digest)fail('repository-journal.preview');
|
|
18
|
+
const ids=new Set();
|
|
19
|
+
for(const op of preview.operations) {
|
|
20
|
+
if(!op || typeof op.repository!=='string' || ids.has(op.repository))fail('repository-journal.preview');
|
|
21
|
+
ids.add(op.repository);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function advance(state,kind,payload,preview) {
|
|
25
|
+
const op=preview.operations[state.nextIndex];
|
|
26
|
+
if(kind==='authorization') {
|
|
27
|
+
if(state.sequence!==1 || state.authorization!==null || state.pending!==null ||
|
|
28
|
+
!exact(payload,['path','digest']) || !hash(payload.digest) ||
|
|
29
|
+
!/^\.pipeline\/repository-authorizations\/[a-f0-9-]{36}\.json$/.test(payload.path))fail('repository-journal.authorization');
|
|
30
|
+
return {...state,authorization:payload};
|
|
31
|
+
}
|
|
32
|
+
if(kind==='intent') {
|
|
33
|
+
if(state.pending!==null || state.stopped || !op ||
|
|
34
|
+
!exact(payload,['repository','operationDigest']) || payload.repository!==op.repository || payload.operationDigest!==contractDigest(op))
|
|
35
|
+
fail('repository-journal.sequence');
|
|
36
|
+
return {...state,pending:state.nextIndex};
|
|
37
|
+
}
|
|
38
|
+
if(kind!=='outcome' || state.pending===null || !exact(payload,['status','evidenceDigest']) ||
|
|
39
|
+
!['completed','failed','uncertain'].includes(payload.status) ||
|
|
40
|
+
!(hash(payload.evidenceDigest) || (payload.status==='uncertain' && payload.evidenceDigest===null)))fail('repository-journal.outcome');
|
|
41
|
+
return {...state,pending:null,nextIndex:state.nextIndex+1,stopped:payload.status!=='completed',outcomes:[...state.outcomes,payload]};
|
|
42
|
+
}
|
|
43
|
+
const initial=()=>({sequence:0,lastHash:null,nextIndex:0,pending:null,stopped:false,outcomes:[],authorization:null});
|
|
44
|
+
function result(state,preview) {
|
|
45
|
+
const terminal=state.stopped || state.nextIndex===preview.operations.length;
|
|
46
|
+
return {...state,phase:state.pending!==null?'interrupted':terminal?'terminal':'open',
|
|
47
|
+
operations:preview.operations.map((op,i)=>({repository:op.repository,
|
|
48
|
+
...(state.outcomes[i] ?? {status:state.pending===i?'uncertain':'skipped',evidenceDigest:null})})),
|
|
49
|
+
// Outcomes are executor claims with evidence references, not self-proving PASS.
|
|
50
|
+
evidenceVerified:false};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readRepositoryJournal(workspace,relative,preview) {
|
|
54
|
+
bound(preview);
|
|
55
|
+
if(workspace!==preview.wrapper || !/^\.pipeline\/repository-journals\/[a-f0-9-]{36}$/.test(relative))fail('repository-journal.path');
|
|
56
|
+
const directory=resolveChild(workspace,relative);
|
|
57
|
+
try {
|
|
58
|
+
await inspectDirectory(directory);const names=(await readdir(directory)).sort();
|
|
59
|
+
if(!names.length || names.length>2+2*preview.operations.length)fail('repository-journal.sequence');
|
|
60
|
+
let state=initial();
|
|
61
|
+
for(let seq=0;seq<names.length;seq++) {
|
|
62
|
+
if(names[seq]!==file(seq))fail('repository-journal.sequence');
|
|
63
|
+
const record=await readRecord(resolveChild(workspace,relative+'/'+file(seq))),event=record.value;
|
|
64
|
+
if(!exact(event,['schemaVersion','seq','previous','previewDigest','kind','payload']) || event.schemaVersion!==1 ||
|
|
65
|
+
event.seq!==seq || event.previous!==state.lastHash || event.previewDigest!==preview.digest)fail('repository-journal.binding');
|
|
66
|
+
if(seq===0) {if(event.kind!=='start' || event.payload!==null)fail('repository-journal.sequence');}
|
|
67
|
+
else {
|
|
68
|
+
if(event.kind==='authorization' && event.payload?.path!==relative.replace('/repository-journals/','/repository-authorizations/')+'.json')
|
|
69
|
+
fail('repository-journal.authorization');
|
|
70
|
+
state=advance(state,event.kind,event.payload,preview);
|
|
71
|
+
}
|
|
72
|
+
state={...state,sequence:seq+1,lastHash:record.digest};
|
|
73
|
+
}
|
|
74
|
+
return result(state,preview);
|
|
75
|
+
}catch(error){throw error instanceof ContractError?error:new ContractError('repository-journal.io');}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Same intent/outcome hash-chain protocol as S5, separate namespace because
|
|
79
|
+
// repository operations are not file replacement plans. No repository IO here.
|
|
80
|
+
export async function createRepositoryJournal(lock,preview,{onLocation=async()=>{},ioBoundary=async()=>{}}={}) {
|
|
81
|
+
bound(preview);await assertLockHeld(lock);
|
|
82
|
+
if(preview.wrapper!==lock.workspace || preview.status!=='review-only' || preview.blockers.length)fail('repository-journal.preview');
|
|
83
|
+
preview=structuredClone(preview);
|
|
84
|
+
const relative='.pipeline/repository-journals/'+randomUUID(),directory=resolveChild(lock.workspace,relative);
|
|
85
|
+
let state=initial(),busy=false,broken=false;
|
|
86
|
+
const persist=async(kind,payload,next)=>{
|
|
87
|
+
await assertLockHeld(lock);await inspectDirectory(directory);
|
|
88
|
+
const filename=resolveChild(lock.workspace,relative+'/'+file(state.sequence));
|
|
89
|
+
const bytes=Buffer.from(JSON.stringify({schemaVersion:1,seq:state.sequence,previous:state.lastHash,previewDigest:preview.digest,kind,payload})+'\n');
|
|
90
|
+
const handle=await open(filename,'wx',0o600);
|
|
91
|
+
try {await ioBoundary('opened',{filename});await handle.writeFile(bytes);await handle.sync();await ioBoundary('synced',{filename});}
|
|
92
|
+
finally {await handle.close();}
|
|
93
|
+
const record=await readRecord(filename);if(record.digest!==sha256(bytes))fail('repository-journal.readback');
|
|
94
|
+
state={...next,sequence:state.sequence+1,lastHash:record.digest};
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
await onLocation({directory,relative,status:'planned'});
|
|
98
|
+
const parent=resolveChild(lock.workspace,'.pipeline/repository-journals');
|
|
99
|
+
try{await mkdir(parent);}catch(e){if(e.code!=='EEXIST')throw e;}
|
|
100
|
+
await inspectDirectory(parent);await mkdir(directory);
|
|
101
|
+
await persist('start',null,state);
|
|
102
|
+
await onLocation({directory,relative,status:'initialized'});
|
|
103
|
+
async function append(kind,payload) {
|
|
104
|
+
if(busy || broken)fail('repository-journal.unavailable');busy=true;
|
|
105
|
+
try {
|
|
106
|
+
await assertLockHeld(lock);
|
|
107
|
+
const head=await readRecord(resolveChild(lock.workspace,relative+'/'+file(state.sequence-1)));
|
|
108
|
+
if(head.digest!==state.lastHash)fail('repository-journal.drift');
|
|
109
|
+
const next=advance(state,kind,payload,preview);await persist(kind,payload,next);
|
|
110
|
+
if(state.stopped || state.nextIndex===preview.operations.length) {
|
|
111
|
+
const audited=await readRepositoryJournal(lock.workspace,relative,preview);
|
|
112
|
+
if(audited.lastHash!==state.lastHash || audited.sequence!==state.sequence)fail('repository-journal.drift');
|
|
113
|
+
return audited;
|
|
114
|
+
}
|
|
115
|
+
return result(state,preview);
|
|
116
|
+
}catch(e){broken=true;throw e instanceof ContractError?e:new ContractError('repository-journal.io');}
|
|
117
|
+
finally{busy=false;}
|
|
118
|
+
}
|
|
119
|
+
return Object.freeze({relative,directory,
|
|
120
|
+
authorize:binding=>{
|
|
121
|
+
if(binding?.path!==relative.replace('/repository-journals/','/repository-authorizations/')+'.json')fail('repository-journal.authorization');
|
|
122
|
+
return append('authorization',binding);
|
|
123
|
+
},
|
|
124
|
+
intent:repository=>append('intent',{repository,operationDigest:contractDigest(preview.operations.find(op=>op.repository===repository) ?? null)}),
|
|
125
|
+
outcome:(status,evidenceDigest)=>append('outcome',{status,evidenceDigest})});
|
|
126
|
+
}catch(e){const error=e instanceof ContractError?e:new ContractError('repository-journal.io');error.repositoryJournal=relative;throw error;}
|
|
127
|
+
}
|