@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,188 @@
1
+ import {readState,verifyInstalledSnapshot,observeTargets} from './state.js';
2
+ import {absoluteRoot,resolveChild} from '../workspace/paths.js';
3
+ import {planLayout} from '../workspace/resolve.js';
4
+ import {assertRequestScope,bindPreview} from './plan.js';
5
+ import {assertLockHeld} from './lock.js';
6
+ import {requestShape,reconcileFields} from './ownership.js';
7
+ import {readConfigField,reconcileConfigFields} from './config-fields.js';
8
+ import {contractDigest} from '../contracts/semantic.js';
9
+ import {fail,parse} from '../contracts/parse.js';
10
+ import {sha256,cap,LIMITS} from '../source/inventory.js';
11
+ import {installedSelection} from '../providers/bundles.js';
12
+
13
+ const hash=bytes=>bytes===null?null:sha256(bytes);
14
+ const key=(name,pointer)=>JSON.stringify([name,pointer]);
15
+ function at(root,pointer) {
16
+ let node=root;
17
+ for(const part of pointer.slice(1).split('/').map(p=>p.replace(/~1/g,'/').replace(/~0/g,'~'))) {
18
+ if(node===null || typeof node!=='object' || Array.isArray(node))fail('repair.field-ancestor');
19
+ if(!Object.hasOwn(node,part))return {present:false};
20
+ node=node[part];
21
+ }
22
+ return {present:true,value:node};
23
+ }
24
+
25
+ // Read-only inspection, NOT a prepared repair/apply envelope. All requests are
26
+ // replayed from installed bytes by trusted adapters; owned value hashes must
27
+ // match state. User changes become conflicts, never rewritten ownership records.
28
+ async function observeRepair(workspace,registry) {
29
+ requestShape(registry,['adapters','sharedAdapter'],[],'provider.interface');
30
+ if(!registry.adapters || typeof registry.adapters!=='object' || Array.isArray(registry.adapters) ||
31
+ typeof registry.sharedAdapter?.plan!=='function')fail('provider.interface');
32
+ workspace=absoluteRoot(workspace);
33
+ const filename=resolveChild(workspace,'.pipeline/state.json'),record=await readState(filename),state=record.value;
34
+ if(state.workspace!==workspace || !state.active)fail('repair.state');
35
+ if(state.pending!==null)fail('repair.pending');
36
+ const active=state.active,installed=await verifyInstalledSnapshot(state);
37
+ const manifest={schemaVersion:1,pipeline:structuredClone(active.snapshot.source),
38
+ ...installedSelection(active),layout:structuredClone(active.layout),
39
+ ...(active.agentsDocument?{agentsDocument:structuredClone(active.agentsDocument)}:{})};
40
+ const layout=planLayout(installed.manifest,manifest,workspace,{adapters:registry.adapters});
41
+ const context=()=>({pipeline:structuredClone(installed.manifest),workspace:structuredClone(manifest),
42
+ installedAdapterVersions:structuredClone(active.adapterVersions),
43
+ layout:structuredClone(layout),snapshot:structuredClone(active.snapshot),
44
+ files:new Map([...installed.files].map(([name,bytes])=>[name,Buffer.from(bytes)]))});
45
+ const desired=new Map(),requests=[];let totalBytes=0;
46
+ for(const [owner,adapter] of [['shared',registry.sharedAdapter],...active.providers.map(id=>[id,registry.adapters[id]])]) {
47
+ if(owner!=='shared' && (await adapter.validate(context()))?.valid===false)fail('provider.validation');
48
+ const batch=await adapter.plan(context());
49
+ if(!Array.isArray(batch))fail('plan.adapter-output');
50
+ cap(requests.length+batch.length,LIMITS.files,'plan.count');
51
+ for(const request of batch) {
52
+ assertRequestScope(request,active.providers);
53
+ if(request.owner!==owner)fail('plan.adapter-owner');
54
+ if(requests.some(r=>r.path===request.path))fail('plan.overlap');
55
+ requests.push(request);
56
+ const add=(pointer,desiredHash,value)=>{
57
+ const id=key(request.path,pointer);if(desired.has(id))fail('repair.duplicate');
58
+ desired.set(id,{owner,hash:desiredHash,value});
59
+ };
60
+ if(request.kind==='file') {
61
+ if(!Buffer.isBuffer(request.bytes))fail('plan.bytes');
62
+ cap(request.bytes.length,LIMITS.blob,'preview.size');totalBytes+=request.bytes.length;
63
+ add(null,sha256(request.bytes),Buffer.from(request.bytes));
64
+ } else {
65
+ // Historical takeover decisions are not fresh authority. Inspection only
66
+ // reconstructs desired values, without reusing those decisions.
67
+ const fields=request.fields.map(f=>f.present?{pointer:f.pointer,present:true,value:f.value}:{pointer:f.pointer,present:false});
68
+ const reconciled=reconcileFields({},fields);
69
+ totalBytes+=Buffer.byteLength(JSON.stringify(reconciled.value));
70
+ for(const decision of reconciled.decisions) add(decision.pointer,decision.desiredHash,at(reconciled.value,decision.pointer).value);
71
+ }
72
+ cap(totalBytes,LIMITS.total,'preview.total');
73
+ }
74
+ }
75
+ planLayout(installed.manifest,manifest,workspace,{adapters:registry.adapters,managedPaths:requests.map(r=>r.path)});
76
+ const names=[...new Set(active.owned.map(o=>o.path))],observations=await observeTargets(workspace,names);
77
+ const current=new Map(observations.map(o=>[o.path,o.bytes])),entries=[];
78
+ for(const owned of active.owned) {
79
+ const expected=desired.get(key(owned.path,owned.pointer));
80
+ if(!expected || expected.owner!==owned.owner || expected.hash!==owned.managedHash)fail('repair.replay-mismatch');
81
+ const bytes=current.get(owned.path);let currentHash=null,malformed=false;
82
+ if(bytes!==null) {
83
+ if(owned.kind==='file')currentHash=sha256(bytes);
84
+ else {
85
+ try{const field=readConfigField(owned.path,bytes,owned.pointer);if(field.present)currentHash=contractDigest(field.value);}
86
+ catch{malformed=true;}
87
+ }
88
+ }
89
+ entries.push({path:owned.path,owner:owned.owner,kind:owned.kind,pointer:owned.pointer,
90
+ managedHash:owned.managedHash,currentHash,fileHash:hash(bytes),
91
+ disposition:malformed?'conflict':currentHash===owned.managedHash?'intact':currentHash===null?'missing':'conflict'});
92
+ }
93
+ if((await readState(filename)).digest!==record.digest)fail('repair.observation-drift');
94
+ await verifyInstalledSnapshot(state);
95
+ for(const observation of await observeTargets(workspace,names))
96
+ if(hash(observation.bytes)!==hash(current.get(observation.path)))fail('repair.observation-drift');
97
+ const body={kind:'repair-inspection',workspace,stateHash:record.digest,snapshot:active.snapshot.digest,entries,
98
+ runtime:'not-run',automaticActions:false,applySupported:false,requiresFreshApproval:true};
99
+ return {inspection:{...body,digest:contractDigest(body)},current,desired};
100
+ }
101
+
102
+ export async function inspectRepair(workspace,registry) {
103
+ return (await observeRepair(workspace,registry)).inspection;
104
+ }
105
+
106
+ // Private preview payloads can include foreign configuration siblings. Do not
107
+ // print/publish this envelope as a diagnostic. No write/approval happens here.
108
+ export async function prepareRepairPreview(workspace,registry) {
109
+ const {inspection,current,desired}=await observeRepair(workspace,registry);
110
+ const blockers=inspection.entries.filter(e=>e.disposition==='conflict'),targets=[],outputs=[];
111
+ // Whole-preview fail closed: no partial execution when another target conflicts.
112
+ if(!blockers.length) {
113
+ for(const name of [...new Set(inspection.entries.filter(e=>e.disposition==='missing').map(e=>e.path))].sort()) {
114
+ const entries=inspection.entries.filter(e=>e.path===name && e.disposition==='missing');
115
+ const before=current.get(name);let bytes;
116
+ if(entries[0].kind==='file')bytes=desired.get(key(name,null)).value;
117
+ else {
118
+ const fields=entries.map(e=>({pointer:e.pointer,present:true,value:desired.get(key(name,e.pointer)).value}));
119
+ bytes=reconcileConfigFields(name,before,fields).bytes;
120
+ }
121
+ targets.push({path:name,action:before===null?'create':'edit-fields',beforeHash:hash(before),desiredHash:sha256(bytes),
122
+ fields:entries.filter(e=>e.pointer!==null).map(e=>({pointer:e.pointer,desiredHash:e.managedHash}))});
123
+ outputs.push({path:name,bytes:bytes.toString('base64')});
124
+ }
125
+ }
126
+ const body={kind:'repair-preview',inspection,targets,outputs,blockers,
127
+ requiresFreshApproval:true,applySupported:false,automaticActions:false,runtime:'not-run'};
128
+ parse(JSON.stringify(body),'json');
129
+ return {...body,digest:contractDigest(body)};
130
+ }
131
+
132
+ // Native operation contract, deliberately distinct from a source-based prepared
133
+ // setup/update. Preserve deployment identity and
134
+ // original backup lineage; repair never converts observed drift into ownership.
135
+ export async function prepareRepairPlan(workspace,registry) {
136
+ const candidate=await prepareRepairPreview(workspace,registry);
137
+ if(candidate.blockers.length)fail('repair.conflict');
138
+ workspace=candidate.inspection.workspace;
139
+ const record=await readState(resolveChild(workspace,'.pipeline/state.json'));
140
+ if(record.digest!==candidate.inspection.stateHash)fail('repair.observation-drift');
141
+ const previous=record.value;
142
+ const names=[...new Set(candidate.inspection.entries.map(e=>e.path))];
143
+ const observations=await observeTargets(workspace,names);
144
+ for(const observation of observations) {
145
+ const expected=candidate.inspection.entries.find(e=>e.path===observation.path);
146
+ if(hash(observation.bytes)!==expected.fileHash)fail('repair.observation-drift');
147
+ }
148
+ const targets=candidate.targets.map((t,i)=>({id:'target-'+String(i+1),path:t.path,
149
+ owner:candidate.inspection.entries.find(e=>e.path===t.path).owner,
150
+ action:t.action,beforeHash:t.beforeHash,desiredHash:t.desiredHash,
151
+ fields:t.action==='edit-fields'?t.fields.map(f=>({...f,beforeHash:null})):[]}));
152
+ const plan={schemaVersion:1,kind:'plan',workspace,command:'repair',
153
+ beforeStateHash:contractDigest(previous),source:structuredClone(previous.active.snapshot),
154
+ desired:structuredClone(previous.active),targets};
155
+ const preview=bindPreview(plan,previous,{observations,
156
+ outputs:candidate.outputs.map(o=>({path:o.path,bytes:Buffer.from(o.bytes,'base64')}))});
157
+ await verifyInstalledSnapshot(previous);
158
+ if((await readState(resolveChild(workspace,'.pipeline/state.json'))).digest!==record.digest)fail('repair.observation-drift');
159
+ const body={kind:'prepared-repair',preview,stateFileHash:record.digest,
160
+ runtime:'not-run',applySupported:true,requiresFreshApproval:true};
161
+ parse(JSON.stringify(body),'json');
162
+ return {...body,digest:contractDigest(body)};
163
+ }
164
+
165
+ // Read-only approval/preflight, not authority for a caller-provided payload.
166
+ // Replay the entire plan under the caller's live lock, including unchanged
167
+ // dependencies. No original source/manifest lookup and no old approval reuse.
168
+ export function validateRepairRecord(prepared,approval) {
169
+ requestShape(prepared,['kind','preview','stateFileHash','runtime','applySupported','requiresFreshApproval','digest'],[],'repair.prepared');
170
+ requestShape(approval,['decision','preparedDigest'],[],'repair.approval');
171
+ const copy=parse(JSON.stringify(prepared),'json'),decision=structuredClone(approval);
172
+ const {digest,...body}=copy;
173
+ if(copy.kind!=='prepared-repair' || copy.runtime!=='not-run' || copy.applySupported!==true ||
174
+ copy.requiresFreshApproval!==true || contractDigest(body)!==digest)fail('repair.prepared');
175
+ if(decision.decision!=='approve' || decision.preparedDigest!==digest)fail('repair.approval');
176
+ if(copy.preview?.plan?.command!=='repair')fail('repair.prepared');
177
+ return structuredClone(copy);
178
+ }
179
+
180
+ export async function verifyRepairApproval(lock,prepared,approval,registry) {
181
+ const copy=validateRepairRecord(prepared,approval);
182
+ await assertLockHeld(lock);
183
+ if(copy.preview?.plan?.workspace!==lock.workspace)fail('repair.workspace');
184
+ const fresh=await prepareRepairPlan(lock.workspace,registry);
185
+ if(contractDigest(fresh)!==contractDigest(copy))fail('repair.plan-drift');
186
+ await assertLockHeld(lock);
187
+ return fresh;
188
+ }
@@ -0,0 +1,192 @@
1
+ import path from 'node:path';
2
+ import {hostname} from 'node:os';
3
+ import {randomUUID} from 'node:crypto';
4
+ import {mkdir,open,rename,lstat} from 'node:fs/promises';
5
+ import {absoluteRoot,inspectDirectory} from '../workspace/paths.js';
6
+ import {inventoryRepository} from '../workspace/repository-inventory.js';
7
+ import {contractDigest} from '../contracts/semantic.js';
8
+ import {fail,MAX_INPUT_BYTES} from '../contracts/parse.js';
9
+ import {readRecord} from './state.js';
10
+ import {readRepositoryInputs} from './repository-inputs.js';
11
+ import {bootstrapLockDirectory} from './bootstrap-lock.js';
12
+ import {withRecoveryLease,assertRecoveryLeaseHeld} from './recovery-lease.js';
13
+ import {sha256} from '../source/inventory.js';
14
+ import {inspectRepositoryReconciliation} from './repository-reconcile.js';
15
+
16
+ const eq=(a,b)=>contractDigest(a)===contractDigest(b);
17
+ const uuid=s=>typeof s==='string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(s);
18
+ const id=s=>({dev:String(s.dev),ino:String(s.ino)});
19
+ async function exists(p){try{await lstat(p);return true;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
20
+ // Renaming a root changes its timestamps. Content, nested identities and file
21
+ // hashes remain bound; directory timestamps alone do not prove foreign writes.
22
+ async function observe(p,omit=[]){
23
+ if(!await exists(p))return null;
24
+ const v=await inventoryRepository(p);
25
+ return v.entries.filter(e=>!omit.some(x=>e.path===x || e.path.startsWith(x+'/'))).map(e=>
26
+ ({path:e.path,type:e.type,identity:e.type==='directory'?{dev:e.identity.dev,ino:e.identity.ino}:e.identity,
27
+ ...(e.sha256?{sha256:e.sha256}:{})}));
28
+ }
29
+ function stopped(o){
30
+ if(o.host!==hostname() || !Number.isSafeInteger(o.pid) || o.pid<=0)fail('repository-abandon.owner-unknown');
31
+ try{process.kill(o.pid,0);}catch(e){if(e.code==='ESRCH')return;fail('repository-abandon.owner-unknown');}
32
+ fail('repository-abandon.owner-live');
33
+ }
34
+ function routes(w,attempt){return {gate:bootstrapLockDirectory(w)+'.recovery',
35
+ archive:path.join(path.dirname(w),'.wpc-repository-resolution-'+attempt),
36
+ sources:[path.join(w,'.pipeline/repository-operation.json'),path.join(w,'.pipeline/lock'),bootstrapLockDirectory(w)]};}
37
+ async function lock(p,w){
38
+ const v=await observe(p);if(!v)return null;
39
+ if(!eq(v.map(e=>e.path).sort(),['.','owner.json']))fail('repository-abandon.lock-incomplete');
40
+ const o=(await readRecord(path.join(p,'owner.json'))).value;
41
+ if(Object.keys(o).sort().join(',')!=='createdAt,host,pid,schemaVersion,token,workspace' ||
42
+ o.workspace!==w || o.schemaVersion!==1 || !uuid(o.token) || typeof o.createdAt!=='string' ||
43
+ !Number.isFinite(Date.parse(o.createdAt)))fail('repository-abandon.owner-binding');stopped(o);return v;
44
+ }
45
+ async function data(w,input){
46
+ const preview=JSON.parse(input.previewText),trees=[];
47
+ if(!Array.isArray(preview.operations) || !preview.operations.length || preview.operations.length>128)fail('repository-abandon.inputs');
48
+ for(const op of preview.operations){
49
+ for(const p of [op.target,...(op.action==='move'?[op.from]:[])]){
50
+ absoluteRoot(p);if(trees.some(x=>x.path===p))continue;
51
+ const entries=await observe(p);
52
+ trees.push({path:p,inventory:entries===null?null:{entryCount:entries.length,digest:contractDigest(entries)}});
53
+ }
54
+ }
55
+ return {trees,metadata:await observe(path.join(w,'.pipeline'),['lock','repository-operation.json'])};
56
+ }
57
+ async function state(w){
58
+ const r=routes(w,'unused');
59
+ for(const p of [r.gate,bootstrapLockDirectory(w)+'.recovery-resume'])if(await exists(p))fail('repository-abandon.recovery-pending');
60
+ const m=await readRecord(r.sources[0]);
61
+ if(m.value.schemaVersion!==1 || m.value.status!=='requires-reconciliation')fail('repository-abandon.marker');
62
+ const input=await readRepositoryInputs(w,m.value.journal,m.value.previewDigest);
63
+ if((await inspectRepositoryReconciliation(w,input.previewText,m.value.previewDigest)).canFinalize)
64
+ fail('repository-abandon.finalizable');
65
+ return {parent:id(await lstat(path.dirname(w),{bigint:true})),wrapperIdentity:id(await lstat(w,{bigint:true})),
66
+ markerDigest:m.digest,inputDigest:input.recordDigest,journal:m.value.journal,previewDigest:m.value.previewDigest,
67
+ locks:[await lock(r.sources[1],w),await lock(r.sources[2],w)],...await data(w,input)};
68
+ }
69
+ export async function prepareRepositoryAbandon(wrapper){
70
+ wrapper=absoluteRoot(wrapper);await inspectDirectory(wrapper);
71
+ const before=await state(wrapper);if(!eq(before,await state(wrapper)))fail('repository-abandon.drift');
72
+ const value={schemaVersion:1,kind:'repository-abandon-preview',wrapper,attempt:randomUUID(),
73
+ decision:'preserve-current-state-abandon-operation',before,executionAuthorized:false,
74
+ completed:false,pipelineActivated:false,requiresNewRepositoryPreview:true};
75
+ return {...value,digest:contractDigest(value)};
76
+ }
77
+ function validate(p,w){
78
+ const {digest,...body}=p??{};
79
+ if(p?.kind!=='repository-abandon-preview' || p.wrapper!==w || p.schemaVersion!==1 ||
80
+ p.decision!=='preserve-current-state-abandon-operation' || !uuid(p.attempt) ||
81
+ contractDigest(body)!==digest)fail('repository-abandon.preview');
82
+ }
83
+ async function persist(p,value){
84
+ const b=Buffer.from(JSON.stringify(value)+'\n');if(b.length>MAX_INPUT_BYTES)fail('repository-abandon.size');
85
+ const h=await open(p,'wx',0o600);try{await h.writeFile(b);await h.sync();}finally{await h.close();}
86
+ if((await readRecord(p)).digest!==sha256(b))fail('repository-abandon.readback');
87
+ }
88
+ // Read-only observation for a fresh continuation approval. No successful-effect
89
+ // claim: this route explicitly preserves partial/uncertain trees as user data.
90
+ export async function inspectRepositoryAbandon(wrapper,attempt){
91
+ wrapper=absoluteRoot(wrapper);if(!uuid(attempt))fail('repository-abandon.attempt');
92
+ const r=routes(wrapper,attempt),request=await readRecord(path.join(r.archive,'request.json')),p=request.value;
93
+ validate(p,wrapper);if(p.attempt!==attempt)fail('repository-abandon.binding');
94
+ const gateActive=await exists(r.gate),guard=gateActive?r.gate:path.join(r.archive,'guard');
95
+ const owner=await readRecord(path.join(guard,'owner.json'));
96
+ if(owner.value.wrapper!==wrapper || owner.value.attempt!==attempt || owner.value.previewDigest!==p.digest)fail('repository-abandon.guard');
97
+ const contents=await observe(guard);if(!eq(contents.map(e=>e.path).sort(),['.','owner.json']))fail('repository-abandon.guard');
98
+ const locations=[];
99
+ for(let i=0;i<r.sources.length;i++){
100
+ const source=await exists(r.sources[i]),dest=path.join(r.archive,String(i));
101
+ const archived=await exists(dest),expected=i===0?true:p.before.locks[i-1]!==null;
102
+ if(expected?source===archived:source||archived)fail('repository-abandon.location');
103
+ const at=source?r.sources[i]:dest;
104
+ if(i===0){if((await readRecord(at)).digest!==p.before.markerDigest)fail('repository-abandon.marker-drift');}
105
+ else if(expected && !eq(await lock(at,wrapper),p.before.locks[i-1]))fail('repository-abandon.lock-drift');
106
+ locations.push(expected?(source?'source':'archived'):'absent');
107
+ }
108
+ const marker=await readRecord(locations[0]==='source'?r.sources[0]:path.join(r.archive,'0'));
109
+ const input=await readRepositoryInputs(wrapper,marker.value.journal,marker.value.previewDigest);
110
+ if(input.recordDigest!==p.before.inputDigest || !eq(await data(wrapper,input),{trees:p.before.trees,metadata:p.before.metadata}) ||
111
+ !eq(id(await lstat(wrapper,{bigint:true})),p.before.wrapperIdentity) ||
112
+ !eq(id(await lstat(path.dirname(wrapper),{bigint:true})),p.before.parent))fail('repository-abandon.subject-drift');
113
+ const receiptPath=path.join(r.archive,'receipt.json'),receipt=await exists(receiptPath)?await readRecord(receiptPath):null;
114
+ const expected={schemaVersion:1,status:'abandoned-preserving-current-state',previewDigest:p.digest,
115
+ completed:false,pipelineActivated:false,requiresNewRepositoryPreview:true};
116
+ if(receipt && (!eq(receipt.value,expected) || locations.includes('source')))fail('repository-abandon.receipt');
117
+ if(!gateActive && !receipt)fail('repository-abandon.order');
118
+ const archive=await observe(r.archive),approvals=archive.filter(e=>/^authorization-\d{4}\.json$/.test(e.path)).sort((a,b)=>a.path.localeCompare(b.path));
119
+ if(approvals.length>64)fail('repository-abandon.approval-limit');
120
+ const previousApprovals=[];
121
+ for(const [i,a] of approvals.entries()){
122
+ if(a.path!=='authorization-'+String(i+1).padStart(4,'0')+'.json')fail('repository-abandon.approval-sequence');
123
+ const record=await readRecord(path.join(r.archive,a.path)),v=record.value,o=v.observation;
124
+ const {digest,...body}=o??{};
125
+ if(Object.keys(v).sort().join(',')!=='decision,observation,previousApprovals,schemaVersion,sequence' ||
126
+ v.schemaVersion!==1 || v.sequence!==i+1 || !eq(v.previousApprovals,previousApprovals) ||
127
+ v.decision!=='preserve-current-state-abandon-operation' || o?.wrapper!==wrapper || o.attempt!==attempt ||
128
+ o.requestDigest!==request.digest || o.ownerDigest!==owner.digest || contractDigest(body)!==digest)
129
+ fail('repository-abandon.approval');
130
+ previousApprovals.push({name:a.path,digest:record.digest});
131
+ }
132
+ const allowed=['.','request.json',...approvals.map(a=>a.path),...locations.flatMap((s,i)=>s==='archived'?
133
+ (i===0?[String(i)]:[String(i),i+'/owner.json']):[]),...(receipt?['receipt.json']:[]),...(!gateActive?['guard','guard/owner.json']:[])].sort();
134
+ if(!eq(archive.map(e=>e.path).sort(),allowed))fail('repository-abandon.foreign-history');
135
+ const body={kind:'repository-abandon-observation',wrapper,attempt,requestDigest:request.digest,
136
+ ownerDigest:owner.digest,guard:contents,locations,archive,gateActive,receiptDigest:receipt?.digest??null,executionAuthorized:false};
137
+ return {...body,digest:contractDigest(body)};
138
+ }
139
+ export async function applyRepositoryAbandon(wrapper,preview,{ioBoundary=async()=>{},continuation=false}={}){
140
+ wrapper=absoluteRoot(wrapper);
141
+ return withRecoveryLease(wrapper,async lease=>{
142
+ const checkLease=()=>assertRecoveryLeaseHeld(lease);checkLease();
143
+ let p=preview,r,original;
144
+ if(!continuation){
145
+ validate(p,wrapper);if(!eq(await state(wrapper),p.before))fail('repository-abandon.stale');r=routes(wrapper,p.attempt);
146
+ if(await exists(r.archive))fail('repository-abandon.history-exists');
147
+ await mkdir(r.archive);await persist(path.join(r.archive,'request.json'),p);
148
+ await ioBoundary('abandon-request-retained');checkLease();
149
+ if(!eq(await state(wrapper),p.before))fail('repository-abandon.stale');
150
+ await mkdir(r.gate);await persist(path.join(r.gate,'owner.json'),{wrapper,attempt:p.attempt,previewDigest:p.digest,pid:process.pid,host:hostname()});
151
+ original=await inspectRepositoryAbandon(wrapper,p.attempt);
152
+ await ioBoundary('abandon-guard-created');
153
+ }else{
154
+ const fresh=await inspectRepositoryAbandon(wrapper,preview?.attempt);
155
+ if(!eq(fresh,preview))fail('repository-abandon.stale');original=fresh;r=routes(wrapper,preview.attempt);
156
+ if(!fresh.gateActive)return {status:'abandoned-preserving-current-state',archive:r.archive,completed:false,pipelineActivated:false};
157
+ stopped((await readRecord(path.join(r.gate,'owner.json'))).value);
158
+ p=(await readRecord(path.join(r.archive,'request.json'))).value;
159
+ const prior=fresh.archive.filter(e=>/^authorization-\d{4}\.json$/.test(e.path)).sort((a,b)=>a.path.localeCompare(b.path));
160
+ if(prior.length>=64)fail('repository-abandon.approval-limit');
161
+ const previousApprovals=[];
162
+ for(const a of prior)previousApprovals.push({name:a.path,digest:(await readRecord(path.join(r.archive,a.path))).digest});
163
+ await persist(path.join(r.archive,'authorization-'+String(prior.length+1).padStart(4,'0')+'.json'),{
164
+ schemaVersion:1,sequence:prior.length+1,previousApprovals,
165
+ decision:'preserve-current-state-abandon-operation',observation:fresh});
166
+ original=await inspectRepositoryAbandon(wrapper,p.attempt);await ioBoundary('abandon-continuation-approved');
167
+ }
168
+ let current=original;
169
+ const check=async()=>{checkLease();const next=await inspectRepositoryAbandon(wrapper,p.attempt);
170
+ if(continuation)stopped((await readRecord(path.join(r.gate,'owner.json'))).value);
171
+ if(!eq(next,current))fail('repository-abandon.drift');};
172
+ for(let i=0;i<3;i++){
173
+ await check();if(current.locations[i]!=='source')continue;
174
+ await rename(r.sources[i],path.join(r.archive,String(i)));
175
+ current=await inspectRepositoryAbandon(wrapper,p.attempt);await ioBoundary('abandon-moved-'+i);
176
+ }
177
+ await check();
178
+ if(!current.receiptDigest){
179
+ await persist(path.join(r.archive,'receipt.json'),{schemaVersion:1,status:'abandoned-preserving-current-state',previewDigest:p.digest,
180
+ completed:false,pipelineActivated:false,requiresNewRepositoryPreview:true});
181
+ current=await inspectRepositoryAbandon(wrapper,p.attempt);await ioBoundary('abandon-receipt');
182
+ }
183
+ await check();await rename(r.gate,path.join(r.archive,'guard'));await ioBoundary('abandon-guard-archived');
184
+ checkLease();
185
+ if(!eq(await observe(path.join(r.archive,'guard')),original.guard) ||
186
+ (await readRecord(path.join(r.archive,'request.json'))).digest!==original.requestDigest ||
187
+ (await readRecord(path.join(r.archive,'receipt.json'))).digest!==current.receiptDigest)
188
+ fail('repository-abandon.readback');
189
+ return {status:'abandoned-preserving-current-state',archive:r.archive,receipt:path.join(r.archive,'receipt.json'),
190
+ completed:false,pipelineActivated:false,requiresNewRepositoryPreview:true};
191
+ });
192
+ }
@@ -0,0 +1,158 @@
1
+ import path from 'node:path';
2
+ import {randomUUID} from 'node:crypto';
3
+ import {lstat,mkdir,open,opendir,rename} from 'node:fs/promises';
4
+ import {absoluteRoot,inspectDirectory,pathBudget} from '../workspace/paths.js';
5
+ import {fail,parse} from '../contracts/parse.js';
6
+ import {contractDigest} from '../contracts/semantic.js';
7
+ import {readRecord} from './state.js';
8
+ import {withRecoveryLease,assertRecoveryLeaseHeld} from './recovery-lease.js';
9
+
10
+ const MAX_DEPTH=32,MAX_APPROVALS=64, id=s=>({dev:String(s.dev),ino:String(s.ino)});
11
+ const same=(a,b)=>contractDigest(a)===contractDigest(b);
12
+ async function directoryId(p){if(!(await inspectDirectory(p)).exists)fail('ancestors.missing');return id(await lstat(p,{bigint:true}));}
13
+ async function names(p,limit=4){const values=[];for await(const entry of await opendir(p)){if(values.length>=limit)fail('ancestors.entries');values.push(entry.name);}return values.sort();}
14
+ async function absent(p){try{await lstat(p);}catch(e){if(e.code==='ENOENT')return;throw e;}fail('ancestors.destination-exists');}
15
+ async function persist(file,value){const h=await open(file,'wx',0o600);try{await h.writeFile(JSON.stringify(value)+'\n');await h.sync();}finally{await h.close();}if(!same((await readRecord(file)).value,value))fail('ancestors.readback');}
16
+ function validate(p){
17
+ if(!p || p.schemaVersion!==1 || p.kind!=='repository-parent-preview' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(p.attemptId) ||
18
+ !Array.isArray(p.targets) || !p.targets.length || p.targets.length>MAX_DEPTH)fail('ancestors.preview');
19
+ const wrapper=absoluteRoot(p.wrapper),anchor=absoluteRoot(p.anchor),history=path.join(anchor,'.wpc-ancestors-'+p.attemptId);
20
+ const keys=['anchor','anchorIdentity','attemptId','history','kind','schemaVersion','targets','wrapper'];
21
+ if(!same(Object.keys(p).sort(),keys) || p.wrapper!==wrapper || p.anchor!==anchor || p.history!==history || !same(Object.keys(p.anchorIdentity??{}).sort(),['dev','ino']) ||
22
+ !Object.values(p.anchorIdentity).every(v=>typeof v==='string' && /^\d+$/.test(v)))fail('ancestors.preview');
23
+ let previous=anchor;
24
+ for(const target of p.targets){
25
+ if(absoluteRoot(target)!==target || path.dirname(target)!==previous || target===previous)fail('ancestors.preview');
26
+ pathBudget(target);previous=target;
27
+ }
28
+ if(previous!==path.dirname(wrapper) || path.dirname(wrapper)===wrapper || p.targets[0].toLowerCase()===history.toLowerCase())fail('ancestors.preview');
29
+ pathBudget(wrapper);pathBudget(history);pathBudget(path.join(history,'projection.json'));
30
+ pathBudget(path.join(history,'tree',...p.targets.slice(1).map(t=>path.basename(t))));
31
+ return p;
32
+ }
33
+ function input(value){return validate(typeof value==='string'?parse(value,'json'):structuredClone(value));}
34
+ async function anchor(p){if(!same(await directoryId(p.anchor),p.anchorIdentity))fail('ancestors.anchor-drift');}
35
+ function staged(p,index){return path.join(p.history,'tree',...p.targets.slice(1,index+1).map(t=>path.basename(t)));}
36
+
37
+ export async function prepareRepositoryAncestors(wrapper){
38
+ wrapper=absoluteRoot(wrapper);pathBudget(wrapper);await inspectDirectory(wrapper);
39
+ let current=path.dirname(wrapper),targets=[];
40
+ while(!(await inspectDirectory(current)).exists){
41
+ targets.unshift(current);if(targets.length>MAX_DEPTH)fail('ancestors.depth');current=path.dirname(current);
42
+ }
43
+ if(!targets.length)fail('ancestors.parent-present');
44
+ const attemptId=randomUUID();
45
+ return validate({schemaVersion:1,kind:'repository-parent-preview',wrapper,anchor:current,
46
+ anchorIdentity:await directoryId(current),attemptId,history:path.join(current,'.wpc-ancestors-'+attemptId),targets});
47
+ }
48
+
49
+ async function projection(p){
50
+ const r=await readRecord(path.join(p.history,'projection.json')),v=r.value;
51
+ if(!same(Object.keys(v).sort(),['directories','previewDigest','schemaVersion']) || v.schemaVersion!==1 ||
52
+ v.previewDigest!==contractDigest(p) || !Array.isArray(v.directories) || v.directories.length!==p.targets.length)fail('ancestors.projection');
53
+ for(let i=0;i<v.directories.length;i++){
54
+ const d=v.directories[i];if(!same(Object.keys(d).sort(),['identity','target']) || d.target!==p.targets[i] ||
55
+ !same(Object.keys(d.identity??{}).sort(),['dev','ino']) || !Object.values(d.identity).every(x=>typeof x==='string' && /^\d+$/.test(x)))fail('ancestors.projection');
56
+ }
57
+ return r;
58
+ }
59
+ async function checkTree(p,v,applied){
60
+ for(let i=0;i<p.targets.length;i++){
61
+ const dir=applied?p.targets[i]:staged(p,i);
62
+ if(!same(await directoryId(dir),v.directories[i].identity))fail('ancestors.directory-drift');
63
+ const expected=i+1<p.targets.length?[path.basename(p.targets[i+1])]:[];
64
+ if(!same(await names(dir),expected))fail('ancestors.foreign-entry');
65
+ }
66
+ }
67
+ function receipt(p,v){return {schemaVersion:1,previewDigest:contractDigest(p),projectionDigest:contractDigest(v),
68
+ status:'parent-directories-created',wrapper:p.wrapper,targets:p.targets,repositoryEffectsPerformed:false,pipelineActivated:false};}
69
+ async function observe(p){
70
+ await anchor(p);const historyIdentity=await directoryId(p.history);
71
+ const request=await readRecord(path.join(p.history,'request.json'));
72
+ if(!same(request.value,p))fail('ancestors.request-drift');
73
+ const record=await projection(p);
74
+ const hasStage=(await inspectDirectory(staged(p,0))).exists,hasTarget=(await inspectDirectory(p.targets[0])).exists;
75
+ if(hasStage===hasTarget)fail('ancestors.location');
76
+ await checkTree(p,record.value,hasTarget);
77
+ const entries=await names(p.history,MAX_APPROVALS+4),base=['projection.json','request.json',...(hasStage?['tree']:[])].sort();
78
+ const resultFile=path.join(p.history,'receipt.json');let result=null;
79
+ if(entries.includes('receipt.json')){result=await readRecord(resultFile);if(hasStage || !same(result.value,receipt(p,record.value)))fail('ancestors.receipt');base.push('receipt.json');base.sort();}
80
+ const stable={schemaVersion:1,previewDigest:contractDigest(p),historyIdentity,requestDigest:request.digest,projectionDigest:record.digest};
81
+ const continuationApprovals=[];
82
+ const approvals=entries.filter(n=>/^authorization-\d{4}\.json$/.test(n));
83
+ if(approvals.length>MAX_APPROVALS)fail('ancestors.approval-limit');
84
+ for(let i=0;i<approvals.length;i++){
85
+ const name='authorization-'+String(i+1).padStart(4,'0')+'.json';if(approvals[i]!==name)fail('ancestors.approval-chain');
86
+ const saved=await readRecord(path.join(p.history,name)),a=saved.value,{digest,...body}=a.observation??{};
87
+ if(!same(Object.keys(a).sort(),['decision','observation','schemaVersion','sequence']) || a.schemaVersion!==1 ||
88
+ a.decision!=='approve-remaining-parent-actions' || a.sequence!==i+1 || contractDigest(body)!==digest ||
89
+ !same(Object.keys(body).sort(),[...Object.keys(stable),'status','receiptDigest','continuationApprovals'].sort()) ||
90
+ !same(Object.fromEntries(Object.keys(stable).map(k=>[k,body[k]])),stable) ||
91
+ !['staged','applied-receipt-missing'].includes(body.status) || body.receiptDigest!==null ||
92
+ !same(body.continuationApprovals,continuationApprovals))fail('ancestors.approval-chain');
93
+ continuationApprovals.push({name,digest:saved.digest});base.push(name);
94
+ }
95
+ if(!same(entries,base.sort()))fail('ancestors.history-entry');
96
+ const value={...stable,status:result?'completed':hasTarget?'applied-receipt-missing':'staged',receiptDigest:result?.digest??null,continuationApprovals};
97
+ return {...value,digest:contractDigest(value)};
98
+ }
99
+ export async function inspectRepositoryAncestors(preview){const p=input(preview),a=await observe(p),b=await observe(p);if(!same(a,b))fail('ancestors.drift');return b;}
100
+
101
+ async function finish(p,initial,lease,ioBoundary){
102
+ async function check(){assertRecoveryLeaseHeld(lease);const next=await inspectRepositoryAncestors(p);if(!same(next,initial))fail('ancestors.drift');}
103
+ if(initial.status==='completed')return {status:'parent-directories-created',history:p.history,requiresRepositoryPreview:true,repositoryEffectsPerformed:false,pipelineActivated:false};
104
+ await check();
105
+ if(initial.status==='staged'){
106
+ await absent(p.targets[0]);await rename(staged(p,0),p.targets[0]);
107
+ await ioBoundary('ancestors-published',{history:p.history});
108
+ const next=await inspectRepositoryAncestors(p);
109
+ if(next.status!=='applied-receipt-missing' || !same(next.historyIdentity,initial.historyIdentity) ||
110
+ next.requestDigest!==initial.requestDigest || next.projectionDigest!==initial.projectionDigest)fail('ancestors.drift');
111
+ initial=next;
112
+ }
113
+ await check();const v=(await projection(p)).value;await persist(path.join(p.history,'receipt.json'),receipt(p,v));
114
+ await ioBoundary('ancestors-receipt-persisted',{history:p.history});
115
+ const final=await inspectRepositoryAncestors(p);if(final.status!=='completed' || !same(final.historyIdentity,initial.historyIdentity) ||
116
+ final.requestDigest!==initial.requestDigest || final.projectionDigest!==initial.projectionDigest)fail('ancestors.readback');
117
+ return {status:'parent-directories-created',history:p.history,requiresRepositoryPreview:true,repositoryEffectsPerformed:false,pipelineActivated:false};
118
+ }
119
+ export async function applyRepositoryAncestors(preview,{ioBoundary=async()=>{}}={}){
120
+ const p=input(preview);
121
+ return withRecoveryLease(p.targets[0],async lease=>{
122
+ await anchor(p);await absent(p.targets[0]);await absent(p.history);
123
+ await mkdir(p.history,{mode:0o700});const historyId=await directoryId(p.history);
124
+ async function checkHistory(){assertRecoveryLeaseHeld(lease);await anchor(p);await absent(p.targets[0]);if(!same(await directoryId(p.history),historyId))fail('ancestors.history-drift');}
125
+ await ioBoundary('ancestors-history-created',{history:p.history});await checkHistory();
126
+ await persist(path.join(p.history,'request.json'),p);
127
+ await ioBoundary('ancestors-request-retained',{history:p.history});
128
+ const directories=[];
129
+ for(let i=0;i<p.targets.length;i++){
130
+ await checkHistory();if(!same((await readRecord(path.join(p.history,'request.json'))).value,p))fail('ancestors.request-drift');
131
+ await mkdir(staged(p,i),{mode:0o700});directories.push({target:p.targets[i],identity:await directoryId(staged(p,i))});
132
+ await ioBoundary('ancestors-staged-directory',{history:p.history,index:i});
133
+ }
134
+ const planned={schemaVersion:1,previewDigest:contractDigest(p),directories};
135
+ await checkHistory();await persist(path.join(p.history,'projection.json'),planned);
136
+ await ioBoundary('ancestors-projection-retained',{history:p.history});
137
+ await checkHistory();if(!same((await projection(p)).value,planned))fail('ancestors.projection-drift');
138
+ return finish(p,await inspectRepositoryAncestors(p),lease,ioBoundary);
139
+ });
140
+ }
141
+ export async function finishRepositoryAncestors(preview,observationDigest,{ioBoundary=async()=>{}}={}){
142
+ const p=input(preview);return withRecoveryLease(p.targets[0],async lease=>{
143
+ const current=await inspectRepositoryAncestors(p);if(current.digest!==observationDigest)fail('ancestors.approval-drift');
144
+ if(current.status==='completed')return finish(p,current,lease,ioBoundary);
145
+ const sequence=current.continuationApprovals.length+1;if(sequence>MAX_APPROVALS)fail('ancestors.approval-limit');
146
+ assertRecoveryLeaseHeld(lease);
147
+ if(!same(await inspectRepositoryAncestors(p),current))fail('ancestors.drift');
148
+ const name='authorization-'+String(sequence).padStart(4,'0')+'.json';
149
+ const authorization={schemaVersion:1,sequence,decision:'approve-remaining-parent-actions',observation:current};
150
+ await persist(path.join(p.history,name),authorization);
151
+ await ioBoundary('ancestors-continuation-authorized',{history:p.history,sequence});
152
+ if(!same((await readRecord(path.join(p.history,name))).value,authorization))fail('ancestors.approval-drift');
153
+ const next=await inspectRepositoryAncestors(p),{digest:unused,...withoutDigest}=next;
154
+ if(!same({...withoutDigest,continuationApprovals:current.continuationApprovals},(({digest,...v})=>v)(current)) ||
155
+ next.continuationApprovals.length!==sequence)fail('ancestors.drift');
156
+ return finish(p,next,lease,ioBoundary);
157
+ });
158
+ }