@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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -0
  3. package/docs/collaboration.md +59 -0
  4. package/docs/config-fields.md +65 -0
  5. package/docs/contracts.md +149 -0
  6. package/docs/doctor.md +165 -0
  7. package/docs/launch.md +50 -0
  8. package/docs/lifecycle-cli.md +141 -0
  9. package/docs/lifecycle.md +42 -0
  10. package/docs/migrations/unity.md +288 -0
  11. package/docs/native-provider-format.md +149 -0
  12. package/docs/provider-bundles.md +81 -0
  13. package/docs/provider-resources.md +37 -0
  14. package/docs/release.md +25 -0
  15. package/docs/remove.md +70 -0
  16. package/docs/repair.md +80 -0
  17. package/docs/repositories.md +209 -0
  18. package/docs/repository-manual-recovery.md +102 -0
  19. package/docs/repository-observations.md +29 -0
  20. package/docs/repository-recovery.md +204 -0
  21. package/docs/repository-retention.md +46 -0
  22. package/docs/repository-transport-budgets.md +26 -0
  23. package/docs/retention.md +237 -0
  24. package/docs/source.md +50 -0
  25. package/docs/switch.md +412 -0
  26. package/package.json +39 -0
  27. package/schemas/common.schema.json +251 -0
  28. package/schemas/inventory.schema.json +15 -0
  29. package/schemas/operation.schema.json +286 -0
  30. package/schemas/pipeline.schema.json +317 -0
  31. package/schemas/state.schema.json +302 -0
  32. package/schemas/workspace.schema.json +67 -0
  33. package/src/cli.js +7 -0
  34. package/src/commands/adopt.js +2 -0
  35. package/src/commands/bootstrap-recovery.js +90 -0
  36. package/src/commands/dispatch.js +394 -0
  37. package/src/commands/init.js +84 -0
  38. package/src/commands/launch.js +69 -0
  39. package/src/commands/migration-apply.js +53 -0
  40. package/src/commands/migration.js +50 -0
  41. package/src/commands/repositories.js +61 -0
  42. package/src/commands/repository-abandon.js +16 -0
  43. package/src/commands/repository-ancestors.js +21 -0
  44. package/src/commands/repository-locks.js +77 -0
  45. package/src/contracts/parse.js +57 -0
  46. package/src/contracts/semantic.js +240 -0
  47. package/src/contracts/validate.js +21 -0
  48. package/src/launch/grok.js +20 -0
  49. package/src/migrations/legacy-unity-begin.js +35 -0
  50. package/src/migrations/legacy-unity-compensate.js +82 -0
  51. package/src/migrations/legacy-unity-deactivate.js +60 -0
  52. package/src/migrations/legacy-unity-deactivation-resume-apply.js +62 -0
  53. package/src/migrations/legacy-unity-deactivation-resume.js +81 -0
  54. package/src/migrations/legacy-unity-finalize.js +88 -0
  55. package/src/migrations/legacy-unity-install-recovery.js +76 -0
  56. package/src/migrations/legacy-unity-install-resume.js +60 -0
  57. package/src/migrations/legacy-unity-install.js +88 -0
  58. package/src/migrations/legacy-unity-lease.js +136 -0
  59. package/src/migrations/legacy-unity-preflight.js +67 -0
  60. package/src/migrations/legacy-unity-preview.js +91 -0
  61. package/src/migrations/legacy-unity-resume-apply.js +39 -0
  62. package/src/migrations/legacy-unity-resume.js +41 -0
  63. package/src/migrations/legacy-unity-resumed-evidence.js +88 -0
  64. package/src/migrations/legacy-unity.js +99 -0
  65. package/src/operations/apply.js +576 -0
  66. package/src/operations/backup.js +94 -0
  67. package/src/operations/bootstrap-lock.js +87 -0
  68. package/src/operations/bootstrap-owner-retirement.js +121 -0
  69. package/src/operations/bundle-update.js +54 -0
  70. package/src/operations/config-fields.js +24 -0
  71. package/src/operations/continuation-lifecycle.js +77 -0
  72. package/src/operations/doctor.js +214 -0
  73. package/src/operations/history.js +170 -0
  74. package/src/operations/installer-identity.js +49 -0
  75. package/src/operations/journal.js +142 -0
  76. package/src/operations/lifecycle.js +133 -0
  77. package/src/operations/lineage-guard.js +20 -0
  78. package/src/operations/lock.js +109 -0
  79. package/src/operations/maintenance.js +62 -0
  80. package/src/operations/migration-pending.js +22 -0
  81. package/src/operations/ownership.js +122 -0
  82. package/src/operations/plan.js +278 -0
  83. package/src/operations/reconciliation.js +112 -0
  84. package/src/operations/recovery-lease.js +66 -0
  85. package/src/operations/remove.js +140 -0
  86. package/src/operations/repair.js +188 -0
  87. package/src/operations/repository-abandon.js +192 -0
  88. package/src/operations/repository-ancestors.js +158 -0
  89. package/src/operations/repository-apply.js +122 -0
  90. package/src/operations/repository-authorization.js +48 -0
  91. package/src/operations/repository-bootstrap-continuation.js +190 -0
  92. package/src/operations/repository-bootstrap-reconcile.js +106 -0
  93. package/src/operations/repository-bootstrap-recover.js +114 -0
  94. package/src/operations/repository-bootstrap.js +82 -0
  95. package/src/operations/repository-clone.js +63 -0
  96. package/src/operations/repository-history.js +108 -0
  97. package/src/operations/repository-inputs.js +41 -0
  98. package/src/operations/repository-journal.js +127 -0
  99. package/src/operations/repository-lock-reconcile.js +401 -0
  100. package/src/operations/repository-pending.js +29 -0
  101. package/src/operations/repository-reconcile.js +182 -0
  102. package/src/operations/repository-resumption-approvals.js +77 -0
  103. package/src/operations/repository-retention-apply.js +75 -0
  104. package/src/operations/repository-retention.js +137 -0
  105. package/src/operations/repository-workspace.js +78 -0
  106. package/src/operations/retention-apply.js +133 -0
  107. package/src/operations/retention-combined-scan.js +30 -0
  108. package/src/operations/retention-combined.js +41 -0
  109. package/src/operations/retention-policy.js +65 -0
  110. package/src/operations/retention-receipts.js +126 -0
  111. package/src/operations/retention-scan.js +86 -0
  112. package/src/operations/retention.js +56 -0
  113. package/src/operations/state.js +210 -0
  114. package/src/operations/switch-activate.js +64 -0
  115. package/src/operations/switch-backups.js +29 -0
  116. package/src/operations/switch-continuation-journal.js +94 -0
  117. package/src/operations/switch-continuation-pending.js +60 -0
  118. package/src/operations/switch-continuation-records.js +109 -0
  119. package/src/operations/switch-continuation-recovery.js +91 -0
  120. package/src/operations/switch-continuation-runtime.js +135 -0
  121. package/src/operations/switch-continuation-store.js +120 -0
  122. package/src/operations/switch-continuation.js +76 -0
  123. package/src/operations/switch-execute.js +68 -0
  124. package/src/operations/switch-inspect.js +45 -0
  125. package/src/operations/switch-journal-store.js +109 -0
  126. package/src/operations/switch-journal.js +71 -0
  127. package/src/operations/switch-lifecycle.js +72 -0
  128. package/src/operations/switch-pending.js +43 -0
  129. package/src/operations/switch-preflight.js +73 -0
  130. package/src/operations/switch-prepare.js +75 -0
  131. package/src/operations/switch-records.js +60 -0
  132. package/src/operations/switch-recovery-store.js +74 -0
  133. package/src/operations/switch.js +52 -0
  134. package/src/operations/toml-fields.js +133 -0
  135. package/src/providers/bundles.js +42 -0
  136. package/src/providers/common-entry.js +16 -0
  137. package/src/providers/grok.js +26 -0
  138. package/src/providers/interface.js +25 -0
  139. package/src/providers/kimi.js +26 -0
  140. package/src/providers/native.js +155 -0
  141. package/src/providers/registry.js +10 -0
  142. package/src/providers/shared.js +51 -0
  143. package/src/providers/source.js +30 -0
  144. package/src/source/git.js +303 -0
  145. package/src/source/inventory.js +87 -0
  146. package/src/source/repository-budget.js +18 -0
  147. package/src/source/snapshot.js +37 -0
  148. package/src/workspace/paths.js +54 -0
  149. package/src/workspace/profiles.js +8 -0
  150. package/src/workspace/repositories.js +57 -0
  151. package/src/workspace/repository-inventory.js +77 -0
  152. package/src/workspace/repository-observation.js +38 -0
  153. package/src/workspace/repository-preflight.js +129 -0
  154. package/src/workspace/repository-preview.js +196 -0
  155. package/src/workspace/repository-tree.js +57 -0
  156. package/src/workspace/reserved.js +11 -0
  157. package/src/workspace/resolve.js +53 -0
@@ -0,0 +1,122 @@
1
+ import path from 'node:path';
2
+ import { mkdir,open,rename,lstat } from 'node:fs/promises';
3
+ import { fail,ContractError,MAX_INPUT_BYTES } from '../contracts/parse.js';
4
+ import { contractDigest } from '../contracts/semantic.js';
5
+ import { sha256 } from '../source/inventory.js';
6
+ import { inspectDirectory,resolveChild } from '../workspace/paths.js';
7
+ import { inventoryRepository } from '../workspace/repository-inventory.js';
8
+ import { inspectRepositoryDestination } from '../workspace/repository-preflight.js';
9
+ import { revalidateRepositoryPreview } from '../workspace/repository-preview.js';
10
+ import { acquireBootstrapLock,assertBootstrapLockHeld } from './bootstrap-lock.js';
11
+ import { acquireWorkspaceLock,assertLockHeld } from './lock.js';
12
+ import { createRepositoryJournal,readRepositoryJournal } from './repository-journal.js';
13
+ import { readRecord } from './state.js';
14
+ import { runGit } from '../source/git.js';
15
+ import { inspectRepositoryGit } from '../workspace/repository-preflight.js';
16
+ import { materializeRepositoryClone,verifyClonedRepository } from './repository-clone.js';
17
+ import { persistRepositoryInputs } from './repository-inputs.js';
18
+ import {summarizeInventory,repositoryContentDigest} from '../workspace/repository-observation.js';
19
+ export {repositoryContentDigest} from '../workspace/repository-observation.js';
20
+
21
+ const absent=async filename=>{try{await lstat(filename);return false;}catch(e){if(e.code==='ENOENT')return true;throw e;}};
22
+ // Rename changes root timestamps. Content/mode projection is deliberately separate
23
+ // from exact prewrite identity inventory; every file and empty directory remains.
24
+ const content=repositoryContentDigest;
25
+ export function matchesInitializedRepository(operation,inventory) {
26
+ return operation.initialization?.initialBranch==='main' && operation.initialization?.templates==='disabled' &&
27
+ inventory.entries.some(e=>e.path==='.git' && e.type==='directory') &&
28
+ inventory.entries.some(e=>e.path==='.git/HEAD' && e.sha256===sha256(Buffer.from('ref: refs/heads/main\n')).slice(7)) &&
29
+ inventory.entries.every(e=>e.path==='.' || e.path==='.git' || e.path.startsWith('.git/')) &&
30
+ !inventory.entries.some(e=>e.type==='file' && (e.path.startsWith('.git/refs/') || e.path==='.git/index' || e.path==='.git/packed-refs'));
31
+ }
32
+ async function persist(filename,value) {
33
+ const bytes=Buffer.from(JSON.stringify(value)+'\n');if(bytes.length>MAX_INPUT_BYTES)fail('repository-apply.evidence-size');
34
+ const handle=await open(filename,'wx',0o600);
35
+ try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
36
+ const record=await readRecord(filename);if(record.digest!==sha256(bytes))fail('repository-apply.readback');return record.digest;
37
+ }
38
+
39
+ // Internal first execution slice: existing wrapper, existing direct target parents,
40
+ // keep/directory/init/pinned clone/same-device move. Unsupported actions refuse BEFORE lock writes.
41
+ // An unresolved operation marker always blocks a new run; recovery owns its removal.
42
+ export async function applyRepositoryOperations({pipeline,workspace,wrapper,choices,options={},previewText,approval,
43
+ onLocation=async()=>{},ioBoundary=async()=>{}}) {
44
+ if(!approval || Object.keys(approval).sort().join(',')!=='decision,previewDigest' || approval.decision!=='approve')fail('repository-apply.approval');
45
+ const validate=()=>revalidateRepositoryPreview(previewText,approval.previewDigest,pipeline,workspace,wrapper,choices,options);
46
+ const preview=await validate();
47
+ if(!preview.manifest)fail('repository-apply.manifest-required');
48
+ if(!(await inspectDirectory(preview.wrapper)).exists)fail('repository-apply.bootstrap-required');
49
+ for(const op of preview.operations) {
50
+ if(!['keep','directory','init','clone','move'].includes(op.action))fail('repository-apply.action-not-implemented');
51
+ if(!(await inspectDirectory(path.dirname(op.target))).exists)fail('repository-apply.parent-required');
52
+ }
53
+ const marker=resolveChild(preview.wrapper,'.pipeline/repository-operation.json');
54
+ if(!await absent(marker))fail('repository-apply.pending');
55
+ let bootstrap,lock,journal;
56
+ try {
57
+ bootstrap=await acquireBootstrapLock(preview.wrapper,{onLocation});
58
+ lock=await acquireWorkspaceLock(preview.wrapper,{repositoryOperation:bootstrap});
59
+ await assertBootstrapLockHeld(bootstrap);await validate();
60
+ if(!await absent(marker))fail('repository-apply.pending');
61
+ journal=await createRepositoryJournal(lock,preview,{onLocation});
62
+ await persistRepositoryInputs(preview.wrapper,journal.relative,preview);
63
+ await ioBoundary('repository-inputs-persisted',{journal:journal.relative,previewDigest:preview.digest});
64
+ await persist(marker,{schemaVersion:1,previewDigest:preview.digest,journal:journal.relative,status:'requires-reconciliation'});
65
+ const authorization=await ioBoundary('repository-run-started',{journal:journal.relative,previewDigest:preview.digest});
66
+ if(authorization!==undefined)await journal.authorize(authorization);
67
+ await assertBootstrapLockHeld(bootstrap);await assertLockHeld(lock);
68
+ const evidenceRoot=resolveChild(preview.wrapper,'.pipeline/repository-evidence');
69
+ try{await mkdir(evidenceRoot);}catch(e){if(e.code!=='EEXIST')throw e;}
70
+ await inspectDirectory(evidenceRoot);
71
+ const evidenceDirectory=path.join(evidenceRoot,path.basename(journal.directory));await mkdir(evidenceDirectory);
72
+ await onLocation({directory:evidenceDirectory,status:'evidence-created'});
73
+ for(let index=0;index<preview.operations.length;index++) {
74
+ const op=preview.operations[index];
75
+ await assertBootstrapLockHeld(bootstrap);await assertLockHeld(lock);
76
+ await journal.intent(op.repository);
77
+ // Error here leaves an intent without outcome: uncertain, never auto-retry.
78
+ await ioBoundary('before-effect',{repository:op.repository});
79
+ await assertBootstrapLockHeld(bootstrap);await assertLockHeld(lock);
80
+ if(op.existing && (await inventoryRepository(op.existing.inventory.root)).digest!==op.existing.inventory.digest)fail('repository-apply.source-drift');
81
+ if(op.destination && contractDigest(await inspectRepositoryDestination(op.target))!==contractDigest(op.destination))fail('repository-apply.destination-drift');
82
+ if(op.action==='directory')await mkdir(op.target);
83
+ else if(op.action==='clone')await materializeRepositoryClone(op,lock,bootstrap,ioBoundary);
84
+ else if(op.action==='init') {
85
+ await mkdir(op.target);
86
+ await ioBoundary('init-directory-created',{repository:op.repository});
87
+ await assertBootstrapLockHeld(bootstrap);await assertLockHeld(lock);
88
+ if((await inventoryRepository(op.target)).entries.length!==1)fail('repository-apply.init-target-drift');
89
+ await runGit(op.target,['init','--template=','--initial-branch='+op.initialization.initialBranch]);
90
+ }
91
+ else if(op.action==='move')await rename(op.from,op.target);
92
+ await ioBoundary('after-effect',{repository:op.repository});
93
+ await assertBootstrapLockHeld(bootstrap);await assertLockHeld(lock);
94
+ const observed=await inventoryRepository(op.target);
95
+ if(op.action==='directory' && observed.entries.length!==1)fail('repository-apply.postcondition');
96
+ if(op.action==='clone' && !await verifyClonedRepository(op,observed))fail('repository-apply.postcondition');
97
+ if(op.action==='init') {
98
+ const git=await inspectRepositoryGit(op.target);
99
+ if(!matchesInitializedRepository(op,observed) || git.head!==null || git.dirty || git.blockers.length || git.inventory.digest!==observed.digest)
100
+ fail('repository-apply.postcondition');
101
+ }
102
+ if(op.action==='move' && (!await absent(op.from) || content(observed)!==content(op.existing.inventory)))fail('repository-apply.postcondition');
103
+ if(op.action==='keep' && observed.digest!==op.existing.inventory.digest)fail('repository-apply.postcondition');
104
+ const evidence={schemaVersion:1,previewDigest:preview.digest,repository:op.repository,action:op.action,
105
+ target:op.target,sourceAbsent:op.action==='move'?true:null,contentDigest:content(observed),inventory:summarizeInventory(observed)};
106
+ const evidenceDigest=await persist(path.join(evidenceDirectory,String(index).padStart(6,'0')+'.json'),evidence);
107
+ await ioBoundary('evidence-persisted',{repository:op.repository});
108
+ await journal.outcome('completed',evidenceDigest);
109
+ }
110
+ const result=await readRepositoryJournal(preview.wrapper,journal.relative,preview);
111
+ return {status:'effects-completed',journal:journal.relative,evidenceDirectory,result,
112
+ requiresReconciliation:true,pipelineActivated:false};
113
+ }catch(cause){
114
+ const error=cause instanceof ContractError?cause:new ContractError(cause.code==='EBUSY'?
115
+ 'repository-apply.busy':['EACCES','EPERM'].includes(cause.code)?'repository-apply.access-denied':'repository-apply.io');
116
+ if(journal)error.repositoryJournal=journal.relative;
117
+ throw error;
118
+ }finally{
119
+ // Exact lock releases only, never rollback filesystem effects or clear marker.
120
+ try{if(lock)await lock.release();}finally{if(bootstrap)await bootstrap.release();}
121
+ }
122
+ }
@@ -0,0 +1,48 @@
1
+ import path from 'node:path';
2
+ import { lstat } from 'node:fs/promises';
3
+ import { fail } 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
+
8
+ const same=(a,b)=>contractDigest(a)===contractDigest(b);
9
+ const exact=(value,keys)=>value && typeof value==='object' && !Array.isArray(value) &&
10
+ Object.keys(value).sort().join(',')===[...keys].sort().join(',');
11
+ export async function verifyRepositoryAuthorization(wrapper,journal,preview,binding) {
12
+ if(!binding)return [];
13
+ const expectedPath=journal.replace('/repository-journals/','/repository-authorizations/')+'.json';
14
+ if(!exact(binding,['path','digest']) || binding.path!==expectedPath)fail('repository-authorization.binding');
15
+ const record=await readRecord(resolveChild(wrapper,binding.path)),chain=record.value;
16
+ if(record.digest!==binding.digest || !exact(chain,['schemaVersion','kind','originalPreview','originalApproval','derivedPreview',
17
+ 'bootstrapReceiptDigest','bootstrapIntentDigest','journal','newHumanApproval']) || chain.schemaVersion!==1 ||
18
+ chain.kind!=='wrapper-creation-projection' || chain.journal!==journal || chain.newHumanApproval!==false ||
19
+ !same(chain.derivedPreview,preview))fail('repository-authorization.chain');
20
+ const original=chain.originalPreview;
21
+ if(!original || typeof original!=='object' || Array.isArray(original))fail('repository-authorization.original');
22
+ const {digest,...body}=original;
23
+ if(contractDigest(body)!==digest || original.wrapper!==wrapper || original.wrapperObservation?.action!=='create' ||
24
+ original.wrapperObservation.target!==wrapper || original.kind!=='repository-preview-candidate' ||
25
+ original.executionAuthorized!==false || original.status!=='review-only' || !Array.isArray(original.blockers) ||
26
+ original.blockers.length || !Array.isArray(original.operations) ||
27
+ !same(chain.originalApproval,{decision:'approve',previewDigest:digest}))fail('repository-authorization.original');
28
+ const receiptPath='.pipeline/repository-bootstrap.json',intentPath='.pipeline/repository-bootstrap-intent.json';
29
+ const receipt=await readRecord(resolveChild(wrapper,receiptPath)),intent=await readRecord(resolveChild(wrapper,intentPath));
30
+ if(receipt.digest!==chain.bootstrapReceiptDigest || intent.digest!==chain.bootstrapIntentDigest ||
31
+ !same(intent.value,{schemaVersion:1,kind:'wrapper-create-intent',previewDigest:digest,wrapper,
32
+ before:original.wrapperObservation,compensation:'no-automatic-rollback'}))fail('repository-authorization.bootstrap');
33
+ await inspectDirectory(wrapper);await inspectDirectory(path.join(wrapper,'.pipeline'));
34
+ const root=await lstat(wrapper,{bigint:true}),metadata=await lstat(path.join(wrapper,'.pipeline'),{bigint:true});
35
+ const identity={dev:String(root.dev),ino:String(root.ino)};
36
+ if(!same(receipt.value,{schemaVersion:1,kind:'wrapper-created',previewDigest:digest,wrapper,identity,
37
+ metadataIdentity:{dev:String(metadata.dev),ino:String(metadata.ino)},intentDigest:intent.digest,
38
+ pipelineActivated:false,repositoryEffectsPerformed:false}))fail('repository-authorization.bootstrap');
39
+ const derived=structuredClone(body);
40
+ derived.wrapperObservation={action:'keep',path:wrapper,identity};
41
+ for(const op of derived.operations) {
42
+ if(path.dirname(op.target)!==wrapper || !op.destination || op.destination.ancestor!==path.dirname(wrapper) ||
43
+ !same(op.destination.ancestorIdentity,original.wrapperObservation.ancestorIdentity))fail('repository-authorization.projection');
44
+ op.destination={...op.destination,ancestor:wrapper,ancestorIdentity:identity};
45
+ }
46
+ if(!same({...derived,digest:contractDigest(derived)},preview))fail('repository-authorization.projection');
47
+ return [binding,{path:receiptPath,digest:receipt.digest},{path:intentPath,digest:intent.digest}];
48
+ }
@@ -0,0 +1,190 @@
1
+ import path from 'node:path';
2
+ import {hostname} from 'node:os';
3
+ import {lstat,open,rename,readdir} from 'node:fs/promises';
4
+ import {fail,MAX_INPUT_BYTES} from '../contracts/parse.js';
5
+ import {contractDigest} from '../contracts/semantic.js';
6
+ import {absoluteRoot,inspectDirectory,pathBudget} from '../workspace/paths.js';
7
+ import {inventoryRepository} from '../workspace/repository-inventory.js';
8
+ import {bootstrapLockDirectory} from './bootstrap-lock.js';
9
+ import {readRecord} from './state.js';
10
+ import {withRecoveryLease,assertRecoveryLeaseHeld} from './recovery-lease.js';
11
+ import {sha256} from '../source/inventory.js';
12
+
13
+ const equal=(a,b)=>contractDigest(a)===contractDigest(b);
14
+ const rootId=inv=>{const s=inv?.entries.find(e=>e.path==='.')?.identity;return s?{dev:s.dev,ino:s.ino}:null;};
15
+ const stopped=owner=>{
16
+ if(owner?.host!==hostname() || !Number.isSafeInteger(owner.pid) || owner.pid<=0)return false;
17
+ try{process.kill(owner.pid,0);return false;}catch(e){return e.code==='ESRCH';}
18
+ };
19
+ async function inventory(directory){return (await inspectDirectory(directory)).exists?inventoryRepository(directory,{entries:512,bytes:16*1024*1024,depth:8}):null;}
20
+ async function record(filename){try{await lstat(filename);}catch(e){if(e.code==='ENOENT')return null;throw e;}return readRecord(filename);}
21
+ function routes(wrapper,initial) {
22
+ const retry=initial.status==='before-state-observed',base=bootstrapLockDirectory(wrapper),metadata=path.join(wrapper,'.pipeline');
23
+ const archive=retry?path.join(path.dirname(wrapper),'.wpc-bootstrap-history-'+initial.digest.slice(7)):
24
+ path.join(metadata,'repository-bootstrap-recovered-lock');
25
+ const result={base,gate:base+'.recovery',metadata,archive,retry,
26
+ history:path.join(path.dirname(wrapper),'.wpc-bootstrap-recovery-'+initial.digest.slice(7)),
27
+ intent:path.join(metadata,'repository-bootstrap-intent.json'),
28
+ receipt:retry?path.join(archive,'recovery.json'):path.join(metadata,'repository-bootstrap-recovery.json')};
29
+ for(const value of Object.values(result))if(typeof value==='string')pathBudget(value);
30
+ return result;
31
+ }
32
+ function expectedReceipt(wrapper,initial,r) {
33
+ return r.retry?{schemaVersion:1,kind:'wrapper-bootstrap-attempt-archived',wrapper,previewDigest:initial.previewDigest,
34
+ reconciliationDigest:initial.digest,archive:r.archive,ownerDigest:initial.recordDigests.owner,
35
+ intentDigest:initial.recordDigests.externalIntent,ownerLiveness:'local-pid-absent',wrapperAbsentAtCheck:true,
36
+ attemptCompleted:false,pipelineActivated:false}:{schemaVersion:1,kind:'wrapper-bootstrap-recovered',wrapper,
37
+ previewDigest:initial.previewDigest,reconciliationDigest:initial.digest,ownerDigest:initial.recordDigests.owner,
38
+ originalReceiptDigest:initial.recordDigests.receipt,archive:r.archive,ownerLiveness:'local-pid-absent',
39
+ pipelineActivated:false,repositoryEffectsPerformed:false};
40
+ }
41
+
42
+ // Read-only reconstruction from the original request, not inference from an empty
43
+ // wrapper. Completed history requires its exact initial reconciliation digest.
44
+ export async function inspectBootstrapRecovery({wrapper,initialDigest}) {
45
+ wrapper=absoluteRoot(wrapper);
46
+ if(initialDigest!==undefined && !/^sha256:[a-f0-9]{64}$/.test(initialDigest))fail('bootstrap-continue.initial-digest');
47
+ const observe=async()=>{
48
+ const gate=bootstrapLockDirectory(wrapper)+'.recovery',active=await inventory(gate);
49
+ if(!active && !initialDigest)fail('bootstrap-continue.initial-digest-required');
50
+ const directory=active?gate:path.join(path.dirname(wrapper),'.wpc-bootstrap-recovery-'+initialDigest.slice(7));
51
+ const guard=active??await inventory(directory);
52
+ if(!guard)fail('bootstrap-continue.missing');
53
+ const request=await record(path.join(directory,'request.json')),owner=await record(path.join(directory,'owner.json'));
54
+ if(!request || !owner)fail('bootstrap-continue.request-incomplete');
55
+ const value=request.value,initial=value.initial,preview=value.preview;
56
+ if(!initial || !preview)fail('bootstrap-continue.request');
57
+ const {digest,...body}=initial,{digest:pd,...pb}=preview;
58
+ if(value.schemaVersion!==1 || Object.keys(value).sort().join(',')!=='approval,initial,preview,schemaVersion' ||
59
+ !equal(value.approval,{decision:'approve',reconciliationDigest:digest}) || contractDigest(body)!==digest ||
60
+ contractDigest(pb)!==pd || initial.previewDigest!==pd || preview.wrapper!==wrapper || initial.wrapper!==wrapper ||
61
+ (initialDigest!==undefined && initialDigest!==digest) || initial.directory!==bootstrapLockDirectory(wrapper) ||
62
+ initial.kind!=='repository-bootstrap-reconciliation' || initial.executionAuthorized!==false ||
63
+ preview.kind!=='repository-preview-candidate' || preview.executionAuthorized!==false ||
64
+ preview.wrapperObservation?.target!==wrapper || !equal(initial.conflicts,[]) ||
65
+ !['before-state-observed','receipt-consistent-lock-retained'].includes(initial.status) ||
66
+ preview.wrapperObservation?.ancestor!==path.dirname(wrapper) || preview.wrapperObservation.action!=='create' ||
67
+ !equal(owner.value,{pid:owner.value.pid,host:owner.value.host,wrapper,reconciliationDigest:digest}))
68
+ fail('bootstrap-continue.request');
69
+ const r=routes(wrapper,initial);
70
+ if(active && await inventory(r.history))fail('bootstrap-continue.duplicate-gate');
71
+ const parent=await lstat(path.dirname(wrapper),{bigint:true});
72
+ if(!equal({dev:String(parent.dev),ino:String(parent.ino)},preview.wrapperObservation.ancestorIdentity))fail('bootstrap-continue.parent-drift');
73
+ const source=await inventory(r.base),archive=await inventory(r.archive),currentWrapper=await inventory(wrapper);
74
+ if(Boolean(source)===Boolean(archive))fail('bootstrap-continue.lock-location');
75
+ const lock=source??archive,lockPath=source?r.base:r.archive;
76
+ if(!equal(rootId(lock),rootId(initial.observations.lock)))fail('bootstrap-continue.lock-identity');
77
+ const originalOwner=await record(path.join(lockPath,'owner.json'));
78
+ if(originalOwner?.digest!==initial.recordDigests.owner)fail('bootstrap-continue.owner-drift');
79
+ const ext=await record(path.join(lockPath,'intent.json')),hist=r.retry?null:await record(r.intent);
80
+ const intentDigest=initial.recordDigests.externalIntent??initial.recordDigests.history;
81
+ if(r.retry ? ext?.digest!==intentDigest : Boolean(ext)===Boolean(hist) || (ext??hist)?.digest!==intentDigest)
82
+ fail('bootstrap-continue.intent-drift');
83
+ if(!r.retry && archive && ext)fail('bootstrap-continue.order');
84
+ const receipt=await record(r.receipt),expected=expectedReceipt(wrapper,initial,r);
85
+ if(receipt && (!archive || !equal(receipt.value,expected)))fail('bootstrap-continue.receipt');
86
+ const lockNames=['.','owner.json',...(ext?['intent.json']:[]),...(r.retry && receipt?['recovery.json']:[])].sort();
87
+ if(!equal(lock.entries.map(e=>e.path).sort(),lockNames))fail('bootstrap-continue.foreign-lock-entry');
88
+ if(r.retry){if(currentWrapper)fail('bootstrap-continue.wrapper-appeared');}
89
+ else {
90
+ const old=initial.observations.wrapper;
91
+ if(!currentWrapper || !equal(rootId(currentWrapper),rootId(old)))fail('bootstrap-continue.wrapper-drift');
92
+ const md=inv=>{const e=inv.entries.find(e=>e.path==='.pipeline')?.identity;return e?{dev:e.dev,ino:e.ino}:null;};
93
+ if(!equal(md(currentWrapper),md(old)))fail('bootstrap-continue.wrapper-drift');
94
+ const originalReceipt=await record(path.join(r.metadata,'repository-bootstrap.json'));
95
+ if(originalReceipt?.digest!==initial.recordDigests.receipt)fail('bootstrap-continue.wrapper-drift');
96
+ const allowed=['.','.pipeline','.pipeline/repository-bootstrap.json',...(hist?['.pipeline/repository-bootstrap-intent.json']:[]),
97
+ ...(archive?['.pipeline/repository-bootstrap-recovered-lock','.pipeline/repository-bootstrap-recovered-lock/owner.json']:[]),
98
+ ...(receipt?['.pipeline/repository-bootstrap-recovery.json']:[])];
99
+ if(!equal(currentWrapper.entries.map(e=>e.path).sort(),allowed.sort()))fail('bootstrap-continue.foreign-wrapper-entry');
100
+ }
101
+ if(!active && !receipt)fail('bootstrap-continue.order');
102
+ const authorizations=[];
103
+ const names=guard.entries.map(e=>e.path).filter(n=>!['.','owner.json','request.json'].includes(n)).sort();
104
+ if(names.length>64 || names.some((n,i)=>n!=='authorization-'+String(i+1).padStart(4,'0')+'.json'))fail('bootstrap-continue.authorization-entries');
105
+ for(const name of names) {
106
+ const saved=await record(path.join(directory,name)),v=saved?.value,o=v?.observation;
107
+ if(!o)fail('bootstrap-continue.authorization');
108
+ const {digest:od,...ob}=o;
109
+ if(!equal(v,{schemaVersion:1,approval:{decision:'approve',recoveryDigest:od},observation:o}) ||
110
+ contractDigest(ob)!==od || o.kind!=='bootstrap-recovery-observation' || o.wrapper!==wrapper ||
111
+ o.initialDigest!==digest || o.requestDigest!==request.digest || o.ownerDigest!==owner.digest ||
112
+ !equal(o.authorizations,authorizations) || o.ownerStopped!==true || o.originalOwnerStopped!==true ||
113
+ o.status==='complete' || o.executionAuthorized!==false)fail('bootstrap-continue.authorization');
114
+ authorizations.push({name,digest:saved.digest});
115
+ }
116
+ const result={kind:'bootstrap-recovery-observation',wrapper,initialDigest:digest,requestDigest:request.digest,
117
+ ownerDigest:owner.digest,ownerStopped:stopped(owner.value),originalOwnerStopped:stopped(originalOwner.value),
118
+ status:!active?'complete':receipt?'receipt-recorded':archive?'lock-archived':!r.retry && hist?'intent-archived':'request-recorded',
119
+ routes:r,directory,guard,source,archive,currentWrapper,receiptDigest:receipt?.digest??null,
120
+ intentExternal:Boolean(ext),authorizations,executionAuthorized:false};
121
+ return {...result,digest:contractDigest(result)};
122
+ };
123
+ const first=await observe();if(!equal(first,await observe()))fail('bootstrap-continue.drift');return first;
124
+ }
125
+
126
+ async function persist(filename,value){
127
+ const bytes=Buffer.from(JSON.stringify(value)+'\n');if(bytes.length>MAX_INPUT_BYTES)fail('bootstrap-continue.input-size');
128
+ const h=await open(filename,'wx',0o600);try{await h.writeFile(bytes);await h.sync();}finally{await h.close();}
129
+ if((await readRecord(filename)).digest!==sha256(bytes))fail('bootstrap-continue.readback');
130
+ }
131
+
132
+ // One remaining-action engine. Initial recovery must prove that this process
133
+ // owns the existing gate; resumed recovery must prove the recorded owner stopped.
134
+ // Both modes still require the kernel lease and revalidate all evidence.
135
+ export async function executeBootstrapRecovery({args,lease,current,initialOwner=false}) {
136
+ assertRecoveryLeaseHeld(lease);
137
+ const original=current,r=current.routes,request=await readRecord(path.join(current.directory,'request.json'));
138
+ const boundary=args.ioBoundary??(async()=>{});
139
+ const check=async()=>{
140
+ assertRecoveryLeaseHeld(lease);const next=await inspectBootstrapRecovery({...args,initialDigest:original.initialDigest});
141
+ const owner=await readRecord(path.join(next.directory,'owner.json'));
142
+ const ownerConfirmed=initialOwner?owner.value.pid===process.pid && owner.value.host===hostname():next.ownerStopped;
143
+ if(next.requestDigest!==original.requestDigest || next.ownerDigest!==original.ownerDigest ||
144
+ !equal(rootId(next.guard),rootId(original.guard)) || !ownerConfirmed || !next.originalOwnerStopped ||
145
+ !equal(next,current))fail('bootstrap-continue.drift');
146
+ };
147
+ await check();
148
+ if(!r.retry && current.intentExternal){
149
+ await check();await rename(path.join(r.base,'intent.json'),r.intent);
150
+ current=await inspectBootstrapRecovery({...args,initialDigest:original.initialDigest});await boundary('bootstrap-continuation-intent-archived',current);
151
+ }
152
+ if(current.source){
153
+ await check();await rename(r.base,r.archive);
154
+ current=await inspectBootstrapRecovery({...args,initialDigest:original.initialDigest});await boundary('bootstrap-continuation-lock-archived',current);
155
+ }
156
+ if(!current.receiptDigest){
157
+ await check();await persist(r.receipt,expectedReceipt(original.wrapper,request.value.initial,r));
158
+ current=await inspectBootstrapRecovery({...args,initialDigest:original.initialDigest});await boundary('bootstrap-continuation-receipt-persisted',current);
159
+ }
160
+ await check();await rename(r.gate,r.history);
161
+ current=await inspectBootstrapRecovery({...args,initialDigest:original.initialDigest});await boundary('bootstrap-continuation-gate-archived',current);
162
+ await check();return {status:'bootstrap-recovery-complete',history:r.history,receipt:r.receipt,
163
+ archive:r.archive,requiresRepositoryPreview:true,repositoryEffectsPerformed:false,pipelineActivated:false};
164
+ }
165
+
166
+ export async function finishBootstrapRecovery(args) {
167
+ return withRecoveryLease(args.wrapper,async lease=>{
168
+ let current=await inspectBootstrapRecovery(args);
169
+ if(!equal(args.approval,{decision:'approve',recoveryDigest:current.digest}))fail('bootstrap-continue.stale-approval');
170
+ if(current.status==='complete')return {status:'bootstrap-recovery-complete',history:current.directory,repositoryEffectsPerformed:false,pipelineActivated:false};
171
+ if(!current.ownerStopped || !current.originalOwnerStopped)fail('bootstrap-continue.owner-unconfirmed');
172
+ const original=current,r=current.routes;
173
+ const boundary=args.ioBoundary??(async()=>{});
174
+ const check=async()=>{
175
+ assertRecoveryLeaseHeld(lease);const next=await inspectBootstrapRecovery({...args,initialDigest:original.initialDigest});
176
+ if(next.requestDigest!==original.requestDigest || next.ownerDigest!==original.ownerDigest ||
177
+ !equal(rootId(next.guard),rootId(original.guard)) || !next.ownerStopped || !next.originalOwnerStopped)
178
+ fail('bootstrap-continue.drift');
179
+ if(!equal(next,current))fail('bootstrap-continue.drift');return next;
180
+ };
181
+ await check();if(current.authorizations.length>=64)fail('bootstrap-continue.authorization-limit');
182
+ const authorization={schemaVersion:1,approval:args.approval,observation:current};
183
+ if(current.guard.bytes+Buffer.byteLength(JSON.stringify(authorization))+1>16*1024*1024)
184
+ fail('bootstrap-continue.authorization-limit');
185
+ await persist(path.join(r.gate,'authorization-'+String(current.authorizations.length+1).padStart(4,'0')+'.json'),authorization);
186
+ current=await inspectBootstrapRecovery({...args,initialDigest:original.initialDigest});
187
+ await boundary('bootstrap-continuation-authorized',current);
188
+ await check();return executeBootstrapRecovery({args,lease,current});
189
+ });
190
+ }
@@ -0,0 +1,106 @@
1
+ import path from 'node:path';
2
+ import { lstat } from 'node:fs/promises';
3
+ import { parse,fail } from '../contracts/parse.js';
4
+ import { contractDigest } from '../contracts/semantic.js';
5
+ import { absoluteRoot,inspectDirectory } from '../workspace/paths.js';
6
+ import { inventoryRepository } from '../workspace/repository-inventory.js';
7
+ import { bootstrapLockDirectory } from './bootstrap-lock.js';
8
+ import { readRecord } from './state.js';
9
+
10
+ const id=s=>({dev:String(s.dev),ino:String(s.ino)});
11
+ const equal=(a,b)=>contractDigest(a)===contractDigest(b);
12
+ async function inventory(root) {
13
+ return (await inspectDirectory(root)).exists?await inventoryRepository(root):null;
14
+ }
15
+ async function optional(file) {
16
+ try{await lstat(file);}catch(e){if(e.code==='ENOENT')return null;throw e;}
17
+ return readRecord(file);
18
+ }
19
+
20
+ // Read-only recovery evidence. A consistent receipt is not a capability, proof
21
+ // of process death, or permission to remove/reuse a lock. No PID probing or writes.
22
+ export async function inspectRepositoryBootstrap(wrapper,previewText,expectedDigest) {
23
+ wrapper=absoluteRoot(wrapper);
24
+ if(!/^sha256:[a-f0-9]{64}$/.test(expectedDigest??''))fail('repository-bootstrap.expected-digest');
25
+ const preview=parse(previewText,'json');
26
+ if(!preview || typeof preview!=='object' || Array.isArray(preview))fail('repository-bootstrap.preview');
27
+ const {digest,...body}=preview;
28
+ if(digest!==expectedDigest || contractDigest(body)!==expectedDigest || preview.wrapper!==wrapper ||
29
+ preview.kind!=='repository-preview-candidate' || preview.executionAuthorized!==false ||
30
+ preview.wrapperObservation?.action!=='create' || preview.wrapperObservation.target!==wrapper)
31
+ fail('repository-bootstrap.preview');
32
+ const directory=bootstrapLockDirectory(wrapper),metadata=path.join(wrapper,'.pipeline');
33
+ const before={wrapper:await inventory(wrapper),lock:await inventory(directory),recoveryGate:await inventory(directory+'.recovery')};
34
+ const files={owner:path.join(directory,'owner.json'),externalIntent:path.join(directory,'intent.json'),
35
+ history:path.join(metadata,'repository-bootstrap-intent.json'),receipt:path.join(metadata,'repository-bootstrap.json'),
36
+ recovery:path.join(metadata,'repository-bootstrap-recovery.json'),
37
+ archivedOwner:path.join(metadata,'repository-bootstrap-recovered-lock','owner.json')};
38
+ const records={};for(const [key,file] of Object.entries(files))records[key]=await optional(file);
39
+ const conflicts=[];
40
+ const owner=records.owner?.value;
41
+ if(before.lock && (!owner || owner.schemaVersion!==1 || owner.workspace!==wrapper ||
42
+ typeof owner.token!=='string' || !Number.isSafeInteger(owner.pid) || owner.pid<=0 ||
43
+ typeof owner.host!=='string' || typeof owner.createdAt!=='string'))conflicts.push('owner-invalid');
44
+ const expectedIntent={schemaVersion:1,kind:'wrapper-create-intent',previewDigest:expectedDigest,
45
+ wrapper,before:preview.wrapperObservation,compensation:'no-automatic-rollback'};
46
+ for(const key of ['externalIntent','history'])
47
+ if(records[key] && !equal(records[key].value,expectedIntent))conflicts.push(key+'-binding');
48
+ if(records.externalIntent && records.history)conflicts.push('duplicate-intent');
49
+ const intent=records.externalIntent??records.history;
50
+ if(before.lock?.entries.some(e=>!['.','owner.json','intent.json'].includes(e.path)))conflicts.push('lock-foreign-entry');
51
+ const allowed=['.','.pipeline','.pipeline/repository-bootstrap.json','.pipeline/repository-bootstrap-intent.json',
52
+ '.pipeline/repository-bootstrap-recovery.json','.pipeline/repository-bootstrap-recovered-lock',
53
+ '.pipeline/repository-bootstrap-recovered-lock/owner.json'];
54
+ if(before.wrapper?.entries.some(e=>!allowed.includes(e.path)))conflicts.push('wrapper-foreign-entry');
55
+ let receiptConsistent=false;
56
+ if(records.receipt) {
57
+ const wrapperId=before.wrapper?.entries.find(e=>e.path==='.')?.identity;
58
+ const metadataId=before.wrapper?.entries.find(e=>e.path==='.pipeline' && e.type==='directory')?.identity;
59
+ const value=records.receipt.value;
60
+ receiptConsistent=Boolean(intent && wrapperId && metadataId && equal(value,{
61
+ schemaVersion:1,kind:'wrapper-created',previewDigest:expectedDigest,wrapper,
62
+ identity:{dev:wrapperId.dev,ino:wrapperId.ino},metadataIdentity:{dev:metadataId.dev,ino:metadataId.ino},
63
+ intentDigest:intent.digest,pipelineActivated:false,repositoryEffectsPerformed:false}));
64
+ if(!receiptConsistent)conflicts.push('receipt-binding');
65
+ }
66
+ const parent=path.dirname(wrapper);
67
+ if(!(await inspectDirectory(parent)).exists ||
68
+ !equal(id(await lstat(parent,{bigint:true})),preview.wrapperObservation.ancestorIdentity) ||
69
+ preview.wrapperObservation.ancestor!==parent)conflicts.push('parent-binding');
70
+ if(records.externalIntent && !before.lock)conflicts.push('intent-without-lock');
71
+ if(records.history && !records.receipt)conflicts.push('history-without-receipt');
72
+ let recovered=false;
73
+ if(records.recovery || records.archivedOwner) {
74
+ const value=records.recovery?.value,oldOwner=records.archivedOwner?.value;
75
+ recovered=Boolean(receiptConsistent && records.history && records.archivedOwner && oldOwner?.workspace===wrapper &&
76
+ oldOwner.schemaVersion===1 && typeof oldOwner.token==='string' && Number.isSafeInteger(oldOwner.pid) && oldOwner.pid>0 &&
77
+ /^sha256:[a-f0-9]{64}$/.test(value?.reconciliationDigest??'') && equal(value,{
78
+ schemaVersion:1,kind:'wrapper-bootstrap-recovered',wrapper,previewDigest:expectedDigest,
79
+ reconciliationDigest:value.reconciliationDigest,ownerDigest:records.archivedOwner.digest,
80
+ originalReceiptDigest:records.receipt.digest,archive:path.join(metadata,'repository-bootstrap-recovered-lock'),
81
+ ownerLiveness:'local-pid-absent',pipelineActivated:false,repositoryEffectsPerformed:false}));
82
+ if(!recovered || before.lock)conflicts.push('recovery-binding');
83
+ }
84
+ let status='missing-records';
85
+ if(conflicts.length)status='conflict';
86
+ else if(before.recoveryGate)status='recovery-incomplete';
87
+ else if(recovered)status='recovery-handoff-recorded';
88
+ else if(receiptConsistent)status=before.lock?'receipt-consistent-lock-retained':'handoff-recorded';
89
+ else if(intent && !before.wrapper)status='before-state-observed';
90
+ else if(intent && before.wrapper)status='effect-compatible-unconfirmed';
91
+ else if(before.lock)status='owner-only-unconfirmed';
92
+ else if(before.wrapper)status='unattributed-wrapper';
93
+ // Recheck all byte/identity observations, including missing records. No atomic
94
+ // snapshot or protection from a malicious concurrent writer is claimed.
95
+ for(const [key,file] of Object.entries(files)) {
96
+ const current=await optional(file);
97
+ if((current?.digest??null)!==(records[key]?.digest??null))fail('repository-bootstrap.observation-drift');
98
+ }
99
+ if(!equal(before,{wrapper:await inventory(wrapper),lock:await inventory(directory),recoveryGate:await inventory(directory+'.recovery')}))
100
+ fail('repository-bootstrap.observation-drift');
101
+ const result={kind:'repository-bootstrap-reconciliation',wrapper,previewDigest:expectedDigest,directory,status,
102
+ conflicts,receiptConsistent,observations:before,
103
+ recordDigests:Object.fromEntries(Object.entries(records).map(([key,value])=>[key,value?.digest??null])),
104
+ ownerLiveness:'not-verified',executionAuthorized:false,canReleaseLock:false,canResume:false};
105
+ return {...result,digest:contractDigest(result)};
106
+ }
@@ -0,0 +1,114 @@
1
+ import path from 'node:path';
2
+ import { hostname } from 'node:os';
3
+ import { mkdir,open,lstat } from 'node:fs/promises';
4
+ import { fail,ContractError,parse,MAX_INPUT_BYTES } from '../contracts/parse.js';
5
+ import { sha256 } from '../source/inventory.js';
6
+ import { contractDigest } from '../contracts/semantic.js';
7
+ import { inspectDirectory,pathBudget } from '../workspace/paths.js';
8
+ import { readRecord } from './state.js';
9
+ import { inspectRepositoryBootstrap } from './repository-bootstrap-reconcile.js';
10
+ import { inspectBootstrapRecovery,executeBootstrapRecovery } from './repository-bootstrap-continuation.js';
11
+ import { withRecoveryLease,assertRecoveryLeaseHeld } from './recovery-lease.js';
12
+
13
+ async function persist(file,bytes) {
14
+ if(bytes.length>MAX_INPUT_BYTES)fail('bootstrap-recover.input-size');
15
+ const handle=await open(file,'wx',0o600);
16
+ try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
17
+ if((await readRecord(file)).digest!==sha256(bytes))fail('bootstrap-recover.readback');
18
+ return sha256(bytes);
19
+ }
20
+ async function absent(file) {
21
+ try{await lstat(file);}catch(e){if(e.code==='ENOENT')return;throw e;}
22
+ fail('bootstrap-recover.destination-exists');
23
+ }
24
+ function requireStopped(owner) {
25
+ if(owner.host!==hostname() || !Number.isSafeInteger(owner.pid) || owner.pid<=0)fail('bootstrap-recover.owner-unknown');
26
+ try {process.kill(owner.pid,0);}catch(e){if(e.code==='ESRCH')return;fail('bootstrap-recover.owner-unknown');}
27
+ fail('bootstrap-recover.owner-live');
28
+ }
29
+
30
+ // Completed receipt or still-absent wrapper. Preserve the original lock as history, do not
31
+ // steal it or infer completion from an empty directory. The gate excludes new
32
+ // cooperative bootstrap creators while the old lock is relocated.
33
+ export async function recoverRepositoryBootstrap(args) {
34
+ return withRecoveryLease(args.wrapper,lease=>recoverBootstrap(args,lease));
35
+ }
36
+ async function recoverBootstrap({wrapper,previewText,previewDigest,approval,
37
+ ioBoundary=async()=>{},onLocation=async()=>{}},lease) {
38
+ assertRecoveryLeaseHeld(lease);
39
+ if(!approval || Object.keys(approval).sort().join(',')!=='decision,reconciliationDigest' || approval.decision!=='approve')
40
+ fail('bootstrap-recover.approval');
41
+ const inspect=()=>inspectRepositoryBootstrap(wrapper,previewText,previewDigest);
42
+ const initial=await inspect();
43
+ if(initial.digest!==approval.reconciliationDigest)fail('bootstrap-recover.stale-approval');
44
+ const retry=initial.status==='before-state-observed';
45
+ if(!retry && initial.status!=='receipt-consistent-lock-retained')fail('bootstrap-recover.unconfirmed');
46
+ const ownerFile=path.join(initial.directory,'owner.json');
47
+ requireStopped((await readRecord(ownerFile)).value);
48
+ const gate=initial.directory+'.recovery',metadata=path.join(initial.wrapper,'.pipeline');
49
+ const archive=retry?path.join(path.dirname(initial.wrapper),'.wpc-bootstrap-history-'+initial.digest.slice(7)):
50
+ path.join(metadata,'repository-bootstrap-recovered-lock');
51
+ const receipt=retry?path.join(archive,'recovery.json'):path.join(metadata,'repository-bootstrap-recovery.json');
52
+ const recoveryHistory=path.join(path.dirname(initial.wrapper),'.wpc-bootstrap-recovery-'+initial.digest.slice(7));
53
+ pathBudget(gate);pathBudget(archive);pathBudget(receipt);pathBudget(recoveryHistory);
54
+ await absent(archive);await absent(receipt);await absent(recoveryHistory);
55
+ let acquired=false,gateDigest,gateIdentity,requestDigest;
56
+ const identity=s=>String(s.dev)+':'+String(s.ino);
57
+ const checkGate=async(directory=gate)=>{
58
+ assertRecoveryLeaseHeld(lease);
59
+ await inspectDirectory(directory);
60
+ if(identity(await lstat(directory,{bigint:true}))!==gateIdentity ||
61
+ (await readRecord(path.join(directory,'owner.json'))).digest!==gateDigest ||
62
+ (await readRecord(path.join(directory,'request.json'))).digest!==requestDigest)fail('bootstrap-recover.gate-drift');
63
+ };
64
+ try {
65
+ await onLocation({directory:gate,status:'planned'});
66
+ try{await mkdir(gate);acquired=true;}catch(e){if(e.code==='EEXIST')fail('bootstrap-recover.busy');throw e;}
67
+ gateIdentity=identity(await lstat(gate,{bigint:true}));
68
+ gateDigest=await persist(path.join(gate,'owner.json'),Buffer.from(JSON.stringify({pid:process.pid,host:hostname(),
69
+ wrapper:initial.wrapper,reconciliationDigest:initial.digest})+'\n'));
70
+ requestDigest=await persist(path.join(gate,'request.json'),Buffer.from(JSON.stringify({schemaVersion:1,
71
+ approval,initial,preview:parse(previewText,'json')})+'\n'));
72
+ await ioBoundary('recovery-gate-created',{directory:gate});
73
+ await checkGate();
74
+ const current=await inspect();
75
+ const {digest:ignored,...projected}=current;
76
+ projected.observations={...current.observations,recoveryGate:null};
77
+ projected.status=initial.status;
78
+ if(current.status!=='recovery-incomplete' || contractDigest(projected)!==initial.digest)fail('bootstrap-recover.drift');
79
+ const owner=await readRecord(ownerFile);
80
+ if(owner.digest!==initial.recordDigests.owner)fail('bootstrap-recover.drift');
81
+ requireStopped(owner.value);
82
+ if(retry) {
83
+ // Current absence is not a claim that mkdir never happened historically.
84
+ // Archive the attempt without creating/deleting the wrapper or replaying it.
85
+ const intent=await readRecord(path.join(initial.directory,'intent.json'));
86
+ if(intent.digest!==initial.recordDigests.externalIntent)fail('bootstrap-recover.drift');
87
+ await absent(initial.wrapper);await absent(archive);
88
+ await onLocation({directory:archive,status:'history-planned'});
89
+ await checkGate();
90
+ const finalBefore=await inspect();
91
+ const {digest:unused,...projectedBefore}=finalBefore;
92
+ projectedBefore.observations={...finalBefore.observations,recoveryGate:null};
93
+ projectedBefore.status=initial.status;
94
+ if(finalBefore.status!=='recovery-incomplete' || contractDigest(projectedBefore)!==initial.digest)
95
+ fail('bootstrap-recover.drift');
96
+ }
97
+ const recovery=await inspectBootstrapRecovery({wrapper:initial.wrapper,initialDigest:initial.digest});
98
+ const phases={
99
+ 'bootstrap-continuation-intent-archived':'recovery-intent-archived',
100
+ 'bootstrap-continuation-lock-archived':'recovery-lock-archived',
101
+ 'bootstrap-continuation-receipt-persisted':'recovery-receipt-persisted',
102
+ 'bootstrap-continuation-gate-archived':'recovery-gate-archived'
103
+ };
104
+ await executeBootstrapRecovery({lease,current:recovery,initialOwner:true,args:{
105
+ wrapper:initial.wrapper,initialDigest:initial.digest,
106
+ ioBoundary:async phase=>ioBoundary(phases[phase],{archive,recoveryHistory})
107
+ }});
108
+ return {status:retry?'bootstrap-attempt-archived':'bootstrap-recovered',
109
+ receipt,archive,recoveryHistory,requiresRepositoryPreview:true,pipelineActivated:false};
110
+ }catch(cause){
111
+ const error=cause instanceof ContractError?cause:new ContractError('bootstrap-recover.io');
112
+ if(acquired)error.recoveryDirectory=gate;throw error;
113
+ }
114
+ }