@6reduk/workspace-pipeline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/docs/collaboration.md +59 -0
- package/docs/config-fields.md +65 -0
- package/docs/contracts.md +149 -0
- package/docs/doctor.md +165 -0
- package/docs/launch.md +50 -0
- package/docs/lifecycle-cli.md +141 -0
- package/docs/lifecycle.md +42 -0
- package/docs/migrations/unity.md +288 -0
- package/docs/native-provider-format.md +149 -0
- package/docs/provider-bundles.md +81 -0
- package/docs/provider-resources.md +37 -0
- package/docs/release.md +25 -0
- package/docs/remove.md +70 -0
- package/docs/repair.md +80 -0
- package/docs/repositories.md +209 -0
- package/docs/repository-manual-recovery.md +102 -0
- package/docs/repository-observations.md +29 -0
- package/docs/repository-recovery.md +204 -0
- package/docs/repository-retention.md +46 -0
- package/docs/repository-transport-budgets.md +26 -0
- package/docs/retention.md +237 -0
- package/docs/source.md +50 -0
- package/docs/switch.md +412 -0
- package/package.json +39 -0
- package/schemas/common.schema.json +251 -0
- package/schemas/inventory.schema.json +15 -0
- package/schemas/operation.schema.json +286 -0
- package/schemas/pipeline.schema.json +317 -0
- package/schemas/state.schema.json +302 -0
- package/schemas/workspace.schema.json +67 -0
- package/src/cli.js +7 -0
- package/src/commands/adopt.js +2 -0
- package/src/commands/bootstrap-recovery.js +90 -0
- package/src/commands/dispatch.js +394 -0
- package/src/commands/init.js +84 -0
- package/src/commands/launch.js +69 -0
- package/src/commands/migration-apply.js +53 -0
- package/src/commands/migration.js +50 -0
- package/src/commands/repositories.js +61 -0
- package/src/commands/repository-abandon.js +16 -0
- package/src/commands/repository-ancestors.js +21 -0
- package/src/commands/repository-locks.js +77 -0
- package/src/contracts/parse.js +57 -0
- package/src/contracts/semantic.js +240 -0
- package/src/contracts/validate.js +21 -0
- package/src/launch/grok.js +20 -0
- package/src/migrations/legacy-unity-begin.js +35 -0
- package/src/migrations/legacy-unity-compensate.js +82 -0
- package/src/migrations/legacy-unity-deactivate.js +60 -0
- package/src/migrations/legacy-unity-deactivation-resume-apply.js +62 -0
- package/src/migrations/legacy-unity-deactivation-resume.js +81 -0
- package/src/migrations/legacy-unity-finalize.js +88 -0
- package/src/migrations/legacy-unity-install-recovery.js +76 -0
- package/src/migrations/legacy-unity-install-resume.js +60 -0
- package/src/migrations/legacy-unity-install.js +88 -0
- package/src/migrations/legacy-unity-lease.js +136 -0
- package/src/migrations/legacy-unity-preflight.js +67 -0
- package/src/migrations/legacy-unity-preview.js +91 -0
- package/src/migrations/legacy-unity-resume-apply.js +39 -0
- package/src/migrations/legacy-unity-resume.js +41 -0
- package/src/migrations/legacy-unity-resumed-evidence.js +88 -0
- package/src/migrations/legacy-unity.js +99 -0
- package/src/operations/apply.js +576 -0
- package/src/operations/backup.js +94 -0
- package/src/operations/bootstrap-lock.js +87 -0
- package/src/operations/bootstrap-owner-retirement.js +121 -0
- package/src/operations/bundle-update.js +54 -0
- package/src/operations/config-fields.js +24 -0
- package/src/operations/continuation-lifecycle.js +77 -0
- package/src/operations/doctor.js +214 -0
- package/src/operations/history.js +170 -0
- package/src/operations/installer-identity.js +49 -0
- package/src/operations/journal.js +142 -0
- package/src/operations/lifecycle.js +133 -0
- package/src/operations/lineage-guard.js +20 -0
- package/src/operations/lock.js +109 -0
- package/src/operations/maintenance.js +62 -0
- package/src/operations/migration-pending.js +22 -0
- package/src/operations/ownership.js +122 -0
- package/src/operations/plan.js +278 -0
- package/src/operations/reconciliation.js +112 -0
- package/src/operations/recovery-lease.js +66 -0
- package/src/operations/remove.js +140 -0
- package/src/operations/repair.js +188 -0
- package/src/operations/repository-abandon.js +192 -0
- package/src/operations/repository-ancestors.js +158 -0
- package/src/operations/repository-apply.js +122 -0
- package/src/operations/repository-authorization.js +48 -0
- package/src/operations/repository-bootstrap-continuation.js +190 -0
- package/src/operations/repository-bootstrap-reconcile.js +106 -0
- package/src/operations/repository-bootstrap-recover.js +114 -0
- package/src/operations/repository-bootstrap.js +82 -0
- package/src/operations/repository-clone.js +63 -0
- package/src/operations/repository-history.js +108 -0
- package/src/operations/repository-inputs.js +41 -0
- package/src/operations/repository-journal.js +127 -0
- package/src/operations/repository-lock-reconcile.js +401 -0
- package/src/operations/repository-pending.js +29 -0
- package/src/operations/repository-reconcile.js +182 -0
- package/src/operations/repository-resumption-approvals.js +77 -0
- package/src/operations/repository-retention-apply.js +75 -0
- package/src/operations/repository-retention.js +137 -0
- package/src/operations/repository-workspace.js +78 -0
- package/src/operations/retention-apply.js +133 -0
- package/src/operations/retention-combined-scan.js +30 -0
- package/src/operations/retention-combined.js +41 -0
- package/src/operations/retention-policy.js +65 -0
- package/src/operations/retention-receipts.js +126 -0
- package/src/operations/retention-scan.js +86 -0
- package/src/operations/retention.js +56 -0
- package/src/operations/state.js +210 -0
- package/src/operations/switch-activate.js +64 -0
- package/src/operations/switch-backups.js +29 -0
- package/src/operations/switch-continuation-journal.js +94 -0
- package/src/operations/switch-continuation-pending.js +60 -0
- package/src/operations/switch-continuation-records.js +109 -0
- package/src/operations/switch-continuation-recovery.js +91 -0
- package/src/operations/switch-continuation-runtime.js +135 -0
- package/src/operations/switch-continuation-store.js +120 -0
- package/src/operations/switch-continuation.js +76 -0
- package/src/operations/switch-execute.js +68 -0
- package/src/operations/switch-inspect.js +45 -0
- package/src/operations/switch-journal-store.js +109 -0
- package/src/operations/switch-journal.js +71 -0
- package/src/operations/switch-lifecycle.js +72 -0
- package/src/operations/switch-pending.js +43 -0
- package/src/operations/switch-preflight.js +73 -0
- package/src/operations/switch-prepare.js +75 -0
- package/src/operations/switch-records.js +60 -0
- package/src/operations/switch-recovery-store.js +74 -0
- package/src/operations/switch.js +52 -0
- package/src/operations/toml-fields.js +133 -0
- package/src/providers/bundles.js +42 -0
- package/src/providers/common-entry.js +16 -0
- package/src/providers/grok.js +26 -0
- package/src/providers/interface.js +25 -0
- package/src/providers/kimi.js +26 -0
- package/src/providers/native.js +155 -0
- package/src/providers/registry.js +10 -0
- package/src/providers/shared.js +51 -0
- package/src/providers/source.js +30 -0
- package/src/source/git.js +303 -0
- package/src/source/inventory.js +87 -0
- package/src/source/repository-budget.js +18 -0
- package/src/source/snapshot.js +37 -0
- package/src/workspace/paths.js +54 -0
- package/src/workspace/profiles.js +8 -0
- package/src/workspace/repositories.js +57 -0
- package/src/workspace/repository-inventory.js +77 -0
- package/src/workspace/repository-observation.js +38 -0
- package/src/workspace/repository-preflight.js +129 -0
- package/src/workspace/repository-preview.js +196 -0
- package/src/workspace/repository-tree.js +57 -0
- package/src/workspace/reserved.js +11 -0
- package/src/workspace/resolve.js +53 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { lstat,mkdir,open,rename,readdir } from 'node:fs/promises';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { fail } from '../contracts/parse.js';
|
|
6
|
+
import { contractDigest } from '../contracts/semantic.js';
|
|
7
|
+
import { absoluteRoot,inspectDirectory,resolveChild } from '../workspace/paths.js';
|
|
8
|
+
import { sha256 } from '../source/inventory.js';
|
|
9
|
+
import { inventoryRepository } from '../workspace/repository-inventory.js';
|
|
10
|
+
import { readRecord } from './state.js';
|
|
11
|
+
import { bootstrapLockDirectory } from './bootstrap-lock.js';
|
|
12
|
+
import { inspectRepositoryReconciliation,verifyRepositoryCompletion } from './repository-reconcile.js';
|
|
13
|
+
import { withRecoveryLease,assertRecoveryLeaseHeld } from './recovery-lease.js';
|
|
14
|
+
import {readResumptionApprovals,persistResumptionApproval} from './repository-resumption-approvals.js';
|
|
15
|
+
|
|
16
|
+
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
17
|
+
async function absent(filename) {
|
|
18
|
+
try{await lstat(filename);return false;}catch(e){if(e.code==='ENOENT')return true;throw e;}
|
|
19
|
+
}
|
|
20
|
+
function liveness(owner) {
|
|
21
|
+
if(owner.host!==hostname())return 'unknown-host';
|
|
22
|
+
try{process.kill(owner.pid,0);return 'live-or-reused-pid';}
|
|
23
|
+
catch(e){return e.code==='ESRCH'?'local-pid-absent':'unknown';}
|
|
24
|
+
}
|
|
25
|
+
async function observeLock(directory,wrapper) {
|
|
26
|
+
const inventory=await inventoryRepository(directory);
|
|
27
|
+
if(inventory.entries.length!==2 || !inventory.entries.some(e=>e.path==='owner.json' && e.type==='file'))
|
|
28
|
+
fail('repository-lock.foreign-entry');
|
|
29
|
+
const record=await readRecord(path.join(directory,'owner.json')),owner=record.value;
|
|
30
|
+
if(!owner || Object.keys(owner).sort().join(',')!=='createdAt,host,pid,schemaVersion,token,workspace' ||
|
|
31
|
+
owner.schemaVersion!==1 || owner.workspace!==wrapper || !uuid.test(owner.token??'') ||
|
|
32
|
+
!Number.isSafeInteger(owner.pid) || owner.pid<=0 || typeof owner.host!=='string' ||
|
|
33
|
+
typeof owner.createdAt!=='string' || !Number.isFinite(Date.parse(owner.createdAt)))fail('repository-lock.owner');
|
|
34
|
+
return {directory,inventory,ownerDigest:record.digest,owner};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A read-only subject for subsequent explicit recovery approval, never an unlock
|
|
38
|
+
// capability. PID absence is observed, not proof that a PID can never be reused.
|
|
39
|
+
export async function inspectRepositoryLocks(args) {return inspectLocks(args);}
|
|
40
|
+
async function inspectLocks({wrapper,previewText,previewDigest,journal,reconciliationDigest},checkGate) {
|
|
41
|
+
wrapper=absoluteRoot(wrapper);
|
|
42
|
+
const bootstrap=bootstrapLockDirectory(wrapper),gate=bootstrap+'.recovery';
|
|
43
|
+
const guard=checkGate??(async()=>{if(!await absent(gate))fail('repository-lock.recovery-pending');});
|
|
44
|
+
await guard();
|
|
45
|
+
const marker=path.join(wrapper,'.pipeline/repository-operation.json');
|
|
46
|
+
const completed=await absent(marker);
|
|
47
|
+
const verify=async()=>{
|
|
48
|
+
if(completed)return verifyRepositoryCompletion({wrapper,previewText,previewDigest,journal,reconciliationDigest});
|
|
49
|
+
const result=await inspectRepositoryReconciliation(wrapper,previewText,previewDigest);
|
|
50
|
+
if(!result.canFinalize || result.journal!==journal || result.digest!==reconciliationDigest)
|
|
51
|
+
fail('repository-lock.subject');
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
const subject=await verify();
|
|
55
|
+
const directories=[bootstrap,path.join(wrapper,'.pipeline/lock')];
|
|
56
|
+
const locks=[];for(const directory of directories)locks.push(await observeLock(directory,wrapper));
|
|
57
|
+
if(locks[0].owner.pid!==locks[1].owner.pid || locks[0].owner.host!==locks[1].owner.host)
|
|
58
|
+
fail('repository-lock.owner-mismatch');
|
|
59
|
+
const ownerLiveness=liveness(locks[0].owner);
|
|
60
|
+
if(contractDigest(await verify())!==contractDigest(subject))fail('repository-lock.drift');
|
|
61
|
+
for(let i=0;i<directories.length;i++)
|
|
62
|
+
if(contractDigest(await observeLock(directories[i],wrapper))!==contractDigest(locks[i]))fail('repository-lock.drift');
|
|
63
|
+
await guard();
|
|
64
|
+
if(await absent(marker)!==completed || liveness(locks[0].owner)!==ownerLiveness)
|
|
65
|
+
fail('repository-lock.drift');
|
|
66
|
+
const result={kind:'repository-lock-reconciliation',wrapper,previewDigest,journal,reconciliationDigest,
|
|
67
|
+
phase:completed?'completion-recorded':'finalization-pending',subjectDigest:contractDigest(subject),locks,
|
|
68
|
+
ownerLiveness,status:ownerLiveness==='local-pid-absent'?'stopped-owner-observed':'owner-unconfirmed',
|
|
69
|
+
executionAuthorized:false,canReleaseLock:false};
|
|
70
|
+
return {...result,digest:contractDigest(result)};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function persist(filename,value) {
|
|
74
|
+
const bytes=Buffer.from(JSON.stringify(value)+'\n'),handle=await open(filename,'wx',0o600);
|
|
75
|
+
try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
|
|
76
|
+
const digest=sha256(bytes);
|
|
77
|
+
if((await readRecord(filename)).digest!==digest)fail('repository-lock.readback');
|
|
78
|
+
return digest;
|
|
79
|
+
}
|
|
80
|
+
const identity=s=>String(s.dev)+':'+String(s.ino);
|
|
81
|
+
|
|
82
|
+
// Read-only checkpoint reconstruction for interrupted retirement. Historical
|
|
83
|
+
// approval is an input binding, not fresh permission to clear the recovery gate.
|
|
84
|
+
export async function inspectRepositoryLockRecovery(args) {
|
|
85
|
+
return inspectRecovery(args,false);
|
|
86
|
+
}
|
|
87
|
+
async function inspectRecovery(args,archivedGate,resumptionHistory=false) {
|
|
88
|
+
const wrapper=absoluteRoot(args.wrapper),{lockDigest}=args;
|
|
89
|
+
if(!/^sha256:[a-f0-9]{64}$/.test(lockDigest??''))fail('repository-lock.recovery-subject');
|
|
90
|
+
const archive=resolveChild(wrapper,'.pipeline/repository-lock-recoveries/'+lockDigest.slice(7));
|
|
91
|
+
const gate=archivedGate?path.join(archive,'recovery-gate'):bootstrapLockDirectory(wrapper)+'.recovery';
|
|
92
|
+
const observe=async()=>{
|
|
93
|
+
const gateInventory=await inventoryRepository(gate),archiveInventory=await inventoryRepository(archive);
|
|
94
|
+
if(gateInventory.entries.length!==2 || !gateInventory.entries.some(e=>e.path==='owner.json' && e.type==='file'))
|
|
95
|
+
fail('repository-lock.gate-drift');
|
|
96
|
+
const gateRecord=await readRecord(path.join(gate,'owner.json')),owner=gateRecord.value;
|
|
97
|
+
if(!owner || Object.keys(owner).sort().join(',')!=='host,lockDigest,pid,schemaVersion,token,wrapper' ||
|
|
98
|
+
owner.schemaVersion!==1 || owner.wrapper!==wrapper || owner.lockDigest!==lockDigest ||
|
|
99
|
+
!uuid.test(owner.token??'') || !Number.isSafeInteger(owner.pid) || owner.pid<=0 || typeof owner.host!=='string')
|
|
100
|
+
fail('repository-lock.recovery-owner');
|
|
101
|
+
const request=await readRecord(path.join(archive,'request.json')),initial=request.value?.observation;
|
|
102
|
+
if(!initial || typeof initial!=='object')fail('repository-lock.recovery-request');
|
|
103
|
+
const {digest,...body}=initial;
|
|
104
|
+
if(digest!==lockDigest || contractDigest(body)!==lockDigest || initial.wrapper!==wrapper ||
|
|
105
|
+
initial.previewDigest!==args.previewDigest || initial.journal!==args.journal ||
|
|
106
|
+
initial.reconciliationDigest!==args.reconciliationDigest || initial.status!=='stopped-owner-observed' ||
|
|
107
|
+
initial.ownerLiveness!=='local-pid-absent' || initial.executionAuthorized!==false || initial.canReleaseLock!==false ||
|
|
108
|
+
!['completion-recorded','finalization-pending'].includes(initial.phase) || !Array.isArray(initial.locks) || initial.locks.length!==2 ||
|
|
109
|
+
contractDigest(request.value)!==contractDigest({schemaVersion:1,approval:{decision:'approve',lockDigest},observation:initial}))
|
|
110
|
+
fail('repository-lock.recovery-request');
|
|
111
|
+
const sources=[bootstrapLockDirectory(wrapper),path.join(wrapper,'.pipeline/lock')],locations=[];
|
|
112
|
+
for(let i=0;i<2;i++) {
|
|
113
|
+
const destination=path.join(archive,i===0?'bootstrap-lock':'workspace-lock'),original=initial.locks[i];
|
|
114
|
+
if(original.directory!==sources[i])fail('repository-lock.recovery-request');
|
|
115
|
+
const sourceAbsent=await absent(sources[i]),destinationAbsent=await absent(destination);
|
|
116
|
+
if(sourceAbsent===destinationAbsent)fail('repository-lock.recovery-location');
|
|
117
|
+
const current=await observeLock(sourceAbsent?destination:sources[i],wrapper);
|
|
118
|
+
const oldRoot=original.inventory?.entries?.find(e=>e.path==='.')?.identity;
|
|
119
|
+
const newRoot=current.inventory.entries.find(e=>e.path==='.').identity;
|
|
120
|
+
if(!oldRoot || oldRoot.dev!==newRoot.dev || oldRoot.ino!==newRoot.ino || current.ownerDigest!==original.ownerDigest ||
|
|
121
|
+
(!sourceAbsent && contractDigest(current)!==contractDigest(original)))fail('repository-lock.history-drift');
|
|
122
|
+
locations.push({state:sourceAbsent?'archived':'original',observation:current,ownerLiveness:liveness(current.owner)});
|
|
123
|
+
}
|
|
124
|
+
// Executor always archives the workspace lock first.
|
|
125
|
+
if(locations[0].state==='archived' && locations[1].state!=='archived')fail('repository-lock.recovery-order');
|
|
126
|
+
const subject=initial.phase==='completion-recorded'?await verifyRepositoryCompletion(args):
|
|
127
|
+
await inspectRepositoryReconciliation(wrapper,args.previewText,args.previewDigest);
|
|
128
|
+
if(contractDigest(subject)!==initial.subjectDigest)fail('repository-lock.subject-drift');
|
|
129
|
+
const receiptFile=path.join(archive,'receipt.json');let receiptDigest=null;
|
|
130
|
+
if(!await absent(receiptFile)) {
|
|
131
|
+
const receipt=await readRecord(receiptFile);receiptDigest=receipt.digest;
|
|
132
|
+
if(locations.some(l=>l.state!=='archived') || contractDigest(receipt.value)!==contractDigest({schemaVersion:1,
|
|
133
|
+
status:'repository-locks-retired',wrapper,lockDigest,requestDigest:request.digest,phase:initial.phase,
|
|
134
|
+
repositoryEffectsPerformed:false,pipelineActivated:false}))fail('repository-lock.recovery-receipt');
|
|
135
|
+
}
|
|
136
|
+
const allowed=['.','request.json',...(receiptDigest?['receipt.json']:[]),
|
|
137
|
+
...(archivedGate?['recovery-gate','recovery-gate/owner.json']:[]),
|
|
138
|
+
...(resumptionHistory?['resumption','resumption/owner.json','resumption/approval.json']:[])];
|
|
139
|
+
if(resumptionHistory) {
|
|
140
|
+
const directory=path.join(archive,'resumption');
|
|
141
|
+
const owner=await readRecord(path.join(directory,'owner.json')),approval=await readRecord(path.join(directory,'approval.json'));
|
|
142
|
+
const continuations=await readResumptionApprovals(directory,{wrapper,lockDigest,
|
|
143
|
+
recoveryDigest:owner.value.recoveryDigest,ownerDigest:owner.digest,approvalDigest:approval.digest});
|
|
144
|
+
allowed.push(...continuations.map(r=>'resumption/'+r.name));
|
|
145
|
+
}
|
|
146
|
+
for(let i=0;i<2;i++)if(locations[i].state==='archived') {
|
|
147
|
+
const name=i===0?'bootstrap-lock':'workspace-lock';allowed.push(name,name+'/owner.json');
|
|
148
|
+
}
|
|
149
|
+
if(archiveInventory.entries.some(e=>!allowed.includes(e.path)))fail('repository-lock.history-drift');
|
|
150
|
+
const count=locations.filter(l=>l.state==='archived').length;
|
|
151
|
+
return {kind:'repository-lock-recovery-observation',wrapper,gate,archive,lockDigest,
|
|
152
|
+
status:receiptDigest?'receipt-recorded-gate-retained':count===2?'locks-archived-receipt-missing':
|
|
153
|
+
count===1?'workspace-lock-archived':'intent-recorded',gateInventory,archiveInventory,
|
|
154
|
+
gateDigest:gateRecord.digest,requestDigest:request.digest,receiptDigest,locations,
|
|
155
|
+
recoveryOwnerLiveness:liveness(owner),executionAuthorized:false,canResume:false,canReleaseLock:false};
|
|
156
|
+
};
|
|
157
|
+
const first=await observe();
|
|
158
|
+
if(contractDigest(await observe())!==contractDigest(first))fail('repository-lock.drift');
|
|
159
|
+
return {...first,digest:contractDigest(first)};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Verify the exact approved resumption, whether still blocked or already retained
|
|
163
|
+
// in history. No replay, lock release or new receipt; later operations may make
|
|
164
|
+
// the original repository subject stale and are not silently projected here.
|
|
165
|
+
export async function inspectRepositoryLockResumption(args) {
|
|
166
|
+
const wrapper=absoluteRoot(args.wrapper),{lockDigest,recoveryDigest}=args;
|
|
167
|
+
if(!/^sha256:[a-f0-9]{64}$/.test(lockDigest??'') || !/^sha256:[a-f0-9]{64}$/.test(recoveryDigest??''))
|
|
168
|
+
fail('repository-lock.resumption-subject');
|
|
169
|
+
const base=bootstrapLockDirectory(wrapper),archive=resolveChild(wrapper,'.pipeline/repository-lock-recoveries/'+lockDigest.slice(7));
|
|
170
|
+
const active=base+'.recovery-resume',history=path.join(archive,'resumption');
|
|
171
|
+
const rootId=inventory=>{const e=inventory?.entries?.find(e=>e.path==='.');return e?.identity?.dev+':'+e?.identity?.ino;};
|
|
172
|
+
const observe=async()=>{
|
|
173
|
+
const activeAbsent=await absent(active),historyAbsent=await absent(history);
|
|
174
|
+
if(activeAbsent===historyAbsent)fail('repository-lock.resumption-location');
|
|
175
|
+
const directory=activeAbsent?history:active,inventory=await inventoryRepository(directory);
|
|
176
|
+
const owner=await readRecord(path.join(directory,'owner.json')),approval=await readRecord(path.join(directory,'approval.json'));
|
|
177
|
+
const continuationApprovals=await readResumptionApprovals(directory,{wrapper,lockDigest,recoveryDigest,
|
|
178
|
+
ownerDigest:owner.digest,approvalDigest:approval.digest});
|
|
179
|
+
if(inventory.entries.map(e=>e.path).sort().join(',')!==
|
|
180
|
+
['.','approval.json','owner.json',...continuationApprovals.map(r=>r.name)].sort().join(','))
|
|
181
|
+
fail('repository-lock.resumption-entries');
|
|
182
|
+
const original=approval.value?.observation,record=owner.value;
|
|
183
|
+
if(!original || typeof original!=='object')fail('repository-lock.resumption-approval');
|
|
184
|
+
const {digest,...body}=original;
|
|
185
|
+
if(digest!==recoveryDigest || contractDigest(body)!==recoveryDigest ||
|
|
186
|
+
original.kind!=='repository-lock-recovery-observation' || original.wrapper!==wrapper || original.lockDigest!==lockDigest ||
|
|
187
|
+
original.archive!==archive || original.gate!==base+'.recovery' ||
|
|
188
|
+
original.executionAuthorized!==false || original.canResume!==false || original.canReleaseLock!==false ||
|
|
189
|
+
original.recoveryOwnerLiveness!=='local-pid-absent' || !Array.isArray(original.locations) || original.locations.length!==2 ||
|
|
190
|
+
original.locations.some(l=>l.ownerLiveness!=='local-pid-absent') ||
|
|
191
|
+
contractDigest(approval.value)!==contractDigest({schemaVersion:1,approval:{decision:'approve',recoveryDigest},observation:original}))
|
|
192
|
+
fail('repository-lock.resumption-approval');
|
|
193
|
+
if(!record || Object.keys(record).sort().join(',')!=='host,pid,recoveryDigest,schemaVersion,token,wrapper' ||
|
|
194
|
+
record.schemaVersion!==1 || record.wrapper!==wrapper || record.recoveryDigest!==recoveryDigest ||
|
|
195
|
+
!Number.isSafeInteger(record.pid) || record.pid<=0 || typeof record.host!=='string' || !uuid.test(record.token??''))
|
|
196
|
+
fail('repository-lock.resumption-owner');
|
|
197
|
+
const gateAbsent=await absent(base+'.recovery'),archivedGateAbsent=await absent(path.join(archive,'recovery-gate'));
|
|
198
|
+
if(gateAbsent===archivedGateAbsent || (activeAbsent && !gateAbsent))fail('repository-lock.resumption-gate');
|
|
199
|
+
const current=await inspectRecovery(args,gateAbsent,activeAbsent);
|
|
200
|
+
if(current.gateDigest!==original.gateDigest || current.requestDigest!==original.requestDigest ||
|
|
201
|
+
(original.receiptDigest!==null && current.receiptDigest!==original.receiptDigest) ||
|
|
202
|
+
rootId(current.gateInventory)!==rootId(original.gateInventory) ||
|
|
203
|
+
rootId(current.archiveInventory)!==rootId(original.archiveInventory) || current.locations.some((l,i)=>
|
|
204
|
+
l.observation.ownerDigest!==original.locations[i].observation.ownerDigest ||
|
|
205
|
+
rootId(l.observation.inventory)!==rootId(original.locations[i].observation.inventory) ||
|
|
206
|
+
(original.locations[i].state==='archived' && l.state!=='archived')) ||
|
|
207
|
+
(gateAbsent && current.status!=='receipt-recorded-gate-retained'))fail('repository-lock.resumption-drift');
|
|
208
|
+
return {kind:'repository-lock-resumption-observation',wrapper,lockDigest,recoveryDigest,
|
|
209
|
+
status:activeAbsent?'resumption-complete':gateAbsent?'gate-archived-resumption-pending':'resumption-in-progress',
|
|
210
|
+
directory,inventory,ownerDigest:owner.digest,approvalDigest:approval.digest,current,continuationApprovals,
|
|
211
|
+
resumptionOwnerLiveness:liveness(record),executionAuthorized:false,canResume:false,canReleaseLock:false};
|
|
212
|
+
};
|
|
213
|
+
const first=await observe();
|
|
214
|
+
if(contractDigest(await observe())!==contractDigest(first))fail('repository-lock.resumption-drift');
|
|
215
|
+
return {...first,digest:contractDigest(first)};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Resume only the remaining actions of a recorded retirement. A second exclusive guard
|
|
219
|
+
// prevents concurrent recovery attempts and stays visible to ordinary writers.
|
|
220
|
+
// Both guards are preserved by rename; no owner file or history is deleted.
|
|
221
|
+
export async function finishRepositoryLockRecovery(args) {
|
|
222
|
+
return withRecoveryLease(args.wrapper,lease=>finishRecovery(args,lease));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Shared effect engine for first retirement and continuation. Approval/guard
|
|
226
|
+
// creation and final guard handoff remain separate, but no path has its own
|
|
227
|
+
// lock-move or receipt-writing loop. Native observation verifies repository
|
|
228
|
+
// subjects and retained request/evidence before every remaining effect.
|
|
229
|
+
async function retireRemaining({initial,inspect,checkGuard,ownerLiveness,ioBoundary,movePhase,receiptPhase,details}) {
|
|
230
|
+
const rootId=inventory=>{const e=inventory.entries.find(e=>e.path==='.');return e.identity.dev+':'+e.identity.ino;};
|
|
231
|
+
const expectedLocations=initial.locations.map(l=>l.state);
|
|
232
|
+
let expectedReceipt=initial.receiptDigest;
|
|
233
|
+
const checkCurrent=async()=>{
|
|
234
|
+
await checkGuard();const current=await inspect();
|
|
235
|
+
if(current.requestDigest!==initial.requestDigest || current.gateDigest!==initial.gateDigest ||
|
|
236
|
+
contractDigest(current.gateInventory)!==contractDigest(initial.gateInventory) ||
|
|
237
|
+
rootId(current.archiveInventory)!==rootId(initial.archiveInventory) || current.receiptDigest!==expectedReceipt ||
|
|
238
|
+
current.recoveryOwnerLiveness!==ownerLiveness || current.locations.some((l,i)=>
|
|
239
|
+
l.state!==expectedLocations[i] || l.ownerLiveness!=='local-pid-absent' ||
|
|
240
|
+
l.observation.ownerDigest!==initial.locations[i].observation.ownerDigest ||
|
|
241
|
+
rootId(l.observation.inventory)!==rootId(initial.locations[i].observation.inventory)))
|
|
242
|
+
fail('repository-lock.resume-drift');
|
|
243
|
+
return current;
|
|
244
|
+
};
|
|
245
|
+
for(const index of [1,0]) {
|
|
246
|
+
const current=await checkCurrent();if(expectedLocations[index]==='archived')continue;
|
|
247
|
+
const target=path.join(initial.archive,index===0?'bootstrap-lock':'workspace-lock');
|
|
248
|
+
if(!await absent(target))fail('repository-lock.history-exists');
|
|
249
|
+
await checkGuard();await rename(current.locations[index].observation.directory,target);
|
|
250
|
+
expectedLocations[index]='archived';
|
|
251
|
+
await ioBoundary(movePhase,{...details,archive:initial.archive,index});
|
|
252
|
+
}
|
|
253
|
+
await checkCurrent();const receiptFile=path.join(initial.archive,'receipt.json');
|
|
254
|
+
if(expectedReceipt===null) {
|
|
255
|
+
const request=await readRecord(path.join(initial.archive,'request.json'));
|
|
256
|
+
if(request.digest!==initial.requestDigest)fail('repository-lock.resume-drift');
|
|
257
|
+
await checkGuard();
|
|
258
|
+
expectedReceipt=await persist(receiptFile,{schemaVersion:1,status:'repository-locks-retired',
|
|
259
|
+
wrapper:initial.wrapper,lockDigest:initial.lockDigest,requestDigest:initial.requestDigest,
|
|
260
|
+
phase:request.value.observation.phase,repositoryEffectsPerformed:false,pipelineActivated:false});
|
|
261
|
+
await ioBoundary(receiptPhase,{...details,archive:initial.archive,receiptFile});
|
|
262
|
+
}
|
|
263
|
+
return {observation:await checkCurrent(),receiptFile,receiptDigest:expectedReceipt};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function finishRecovery(args,lease) {
|
|
267
|
+
assertRecoveryLeaseHeld(lease);
|
|
268
|
+
const {approval,ioBoundary=async()=>{}}=args;
|
|
269
|
+
const repeated=approval && Object.keys(approval).sort().join(',')==='decision,resumptionDigest';
|
|
270
|
+
if(!approval || (!repeated && Object.keys(approval).sort().join(',')!=='decision,recoveryDigest') || approval.decision!=='approve')
|
|
271
|
+
fail('repository-lock.resume-approval');
|
|
272
|
+
const prior=repeated?await inspectRepositoryLockResumption(args):null;
|
|
273
|
+
if(prior && prior.digest!==approval.resumptionDigest)fail('repository-lock.resume-stale');
|
|
274
|
+
if(prior?.status==='resumption-complete')return {status:'repository-lock-recovery-finished',
|
|
275
|
+
archive:prior.current.archive,resumption:prior.directory,approvalDigest:prior.approvalDigest,
|
|
276
|
+
pipelineActivated:false,repositoryEffectsPerformed:false};
|
|
277
|
+
if(prior && prior.resumptionOwnerLiveness!=='local-pid-absent')fail('repository-lock.resume-owner');
|
|
278
|
+
const initial=prior?.current??await inspectRepositoryLockRecovery(args);
|
|
279
|
+
if(!prior && initial.digest!==approval.recoveryDigest)fail('repository-lock.resume-stale');
|
|
280
|
+
if(!['intent-recorded','workspace-lock-archived','locks-archived-receipt-missing','receipt-recorded-gate-retained'].includes(initial.status))
|
|
281
|
+
fail('repository-lock.resume-incomplete');
|
|
282
|
+
if(initial.recoveryOwnerLiveness!=='local-pid-absent' || initial.locations.some(l=>l.ownerLiveness!=='local-pid-absent'))
|
|
283
|
+
fail('repository-lock.resume-owner');
|
|
284
|
+
const guard=bootstrapLockDirectory(initial.wrapper)+'.recovery-resume';
|
|
285
|
+
const destination=path.join(initial.archive,'recovery-gate'),history=path.join(initial.archive,'resumption');
|
|
286
|
+
let gateArchived=prior?.status==='gate-archived-resumption-pending';
|
|
287
|
+
if((!gateArchived && !await absent(destination)) || !await absent(history))fail('repository-lock.history-exists');
|
|
288
|
+
if(!prior)try{await mkdir(guard,{mode:0o700});}catch(e){if(e.code==='EEXIST')fail('repository-lock.resume-busy');throw e;}
|
|
289
|
+
const guardId=identity(await lstat(guard,{bigint:true}));
|
|
290
|
+
const ownerDigest=prior?.ownerDigest??await persist(path.join(guard,'owner.json'),{schemaVersion:1,wrapper:initial.wrapper,
|
|
291
|
+
pid:process.pid,host:hostname(),token:randomUUID(),recoveryDigest:initial.digest});
|
|
292
|
+
const approvalDigest=prior?.approvalDigest??await persist(path.join(guard,'approval.json'),{schemaVersion:1,approval,observation:initial});
|
|
293
|
+
const binding={wrapper:initial.wrapper,lockDigest:initial.lockDigest,
|
|
294
|
+
recoveryDigest:prior?.recoveryDigest??initial.digest,ownerDigest,approvalDigest};
|
|
295
|
+
let expectedApprovals=prior?.continuationApprovals??[];
|
|
296
|
+
const checkGuard=async(directory=guard)=>{
|
|
297
|
+
assertRecoveryLeaseHeld(lease);
|
|
298
|
+
await inspectDirectory(directory);
|
|
299
|
+
if(identity(await lstat(directory,{bigint:true}))!==guardId ||
|
|
300
|
+
(await readdir(directory)).sort().join(',')!==['approval.json','owner.json',...expectedApprovals.map(r=>r.name)].sort().join(',') ||
|
|
301
|
+
(await readRecord(path.join(directory,'owner.json'))).digest!==ownerDigest ||
|
|
302
|
+
(await readRecord(path.join(directory,'approval.json'))).digest!==approvalDigest)fail('repository-lock.resume-guard-drift');
|
|
303
|
+
if(contractDigest(await readResumptionApprovals(directory,binding))!==contractDigest(expectedApprovals))
|
|
304
|
+
fail('repository-lock.continuation-drift');
|
|
305
|
+
};
|
|
306
|
+
await ioBoundary('lock-recovery-resume-guard-created',{guard,archive:initial.archive});
|
|
307
|
+
await checkGuard();
|
|
308
|
+
if((await inspectRecovery(args,gateArchived)).digest!==initial.digest)fail('repository-lock.resume-drift');
|
|
309
|
+
if(prior) {
|
|
310
|
+
expectedApprovals=await persistResumptionApproval(guard,prior,approval,lease);
|
|
311
|
+
await ioBoundary('lock-recovery-continuation-authorized',{guard,archive:initial.archive});
|
|
312
|
+
await checkGuard();
|
|
313
|
+
}
|
|
314
|
+
const rootId=inventory=>{const e=inventory.entries.find(e=>e.path==='.');return e.identity.dev+':'+e.identity.ino;};
|
|
315
|
+
const {observation:completed}=await retireRemaining({initial,inspect:()=>inspectRecovery(args,gateArchived),
|
|
316
|
+
checkGuard,ownerLiveness:'local-pid-absent',ioBoundary,details:{guard},
|
|
317
|
+
movePhase:'lock-recovery-resume-lock-archived',receiptPhase:'lock-recovery-resume-receipt-persisted'});
|
|
318
|
+
if(!gateArchived) {
|
|
319
|
+
if(!await absent(destination))fail('repository-lock.history-exists');
|
|
320
|
+
await rename(initial.gate,destination);gateArchived=true;
|
|
321
|
+
await ioBoundary('lock-recovery-gate-archived',{guard,archive:initial.archive});
|
|
322
|
+
}
|
|
323
|
+
await checkGuard();
|
|
324
|
+
const moved=await inspectRecovery(args,true);
|
|
325
|
+
if(!await absent(bootstrapLockDirectory(initial.wrapper)+'.recovery') || rootId(moved.gateInventory)!==rootId(initial.gateInventory) ||
|
|
326
|
+
rootId(moved.archiveInventory)!==rootId(initial.archiveInventory) || moved.gateDigest!==initial.gateDigest ||
|
|
327
|
+
moved.requestDigest!==initial.requestDigest || moved.receiptDigest!==completed.receiptDigest ||
|
|
328
|
+
contractDigest(moved.locations)!==contractDigest(completed.locations) || moved.recoveryOwnerLiveness!=='local-pid-absent')
|
|
329
|
+
fail('repository-lock.resume-drift');
|
|
330
|
+
await checkGuard();
|
|
331
|
+
if(!await absent(history))fail('repository-lock.history-exists');
|
|
332
|
+
// Last state transition removes the exclusion guard by retaining it in history.
|
|
333
|
+
await rename(guard,history);
|
|
334
|
+
await ioBoundary('lock-recovery-resumption-archived',{archive:initial.archive,history});
|
|
335
|
+
await checkGuard(history);
|
|
336
|
+
return {status:'repository-lock-recovery-finished',archive:initial.archive,resumption:history,
|
|
337
|
+
approvalDigest,pipelineActivated:false,repositoryEffectsPerformed:false};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Exact stopped-owner recovery only. Original owner records are retained, not
|
|
341
|
+
// deleted. Any interrupted recovery leaves its gate blocking cooperative writers.
|
|
342
|
+
export async function recoverRepositoryLocks(args) {
|
|
343
|
+
return withRecoveryLease(args.wrapper,lease=>recoverLocks(args,lease));
|
|
344
|
+
}
|
|
345
|
+
async function recoverLocks(args,lease) {
|
|
346
|
+
assertRecoveryLeaseHeld(lease);
|
|
347
|
+
const {approval,ioBoundary=async()=>{}}=args;
|
|
348
|
+
if(!approval || Object.keys(approval).sort().join(',')!=='decision,lockDigest' || approval.decision!=='approve')
|
|
349
|
+
fail('repository-lock.approval');
|
|
350
|
+
const initial=await inspectRepositoryLocks(args);
|
|
351
|
+
if(initial.digest!==approval.lockDigest)fail('repository-lock.stale-approval');
|
|
352
|
+
if(initial.ownerLiveness!=='local-pid-absent')fail('repository-lock.owner-unconfirmed');
|
|
353
|
+
const gate=bootstrapLockDirectory(initial.wrapper)+'.recovery';
|
|
354
|
+
const parent=resolveChild(initial.wrapper,'.pipeline/repository-lock-recoveries');
|
|
355
|
+
const archive=path.join(parent,initial.digest.slice(7));
|
|
356
|
+
if(!await absent(archive))fail('repository-lock.history-exists');
|
|
357
|
+
// mkdir is exclusive; failures must not remove somebody else's gate.
|
|
358
|
+
try{await mkdir(gate,{mode:0o700});}catch(e){if(e.code==='EEXIST')fail('repository-lock.recovery-pending');throw e;}
|
|
359
|
+
const gateId=identity(await lstat(gate,{bigint:true}));
|
|
360
|
+
const gateOwner=path.join(gate,'owner.json');
|
|
361
|
+
const gateDigest=await persist(gateOwner,{schemaVersion:1,wrapper:initial.wrapper,lockDigest:initial.digest,
|
|
362
|
+
pid:process.pid,host:hostname(),token:randomUUID()});
|
|
363
|
+
const checkGate=async(directory=gate)=>{
|
|
364
|
+
assertRecoveryLeaseHeld(lease);
|
|
365
|
+
await inspectDirectory(directory);
|
|
366
|
+
if(identity(await lstat(directory,{bigint:true}))!==gateId ||
|
|
367
|
+
(await readdir(directory)).join(',')!=='owner.json' || (await readRecord(path.join(directory,'owner.json'))).digest!==gateDigest)
|
|
368
|
+
fail('repository-lock.gate-drift');
|
|
369
|
+
};
|
|
370
|
+
{
|
|
371
|
+
await ioBoundary('lock-recovery-gate-created',{gate,archive});
|
|
372
|
+
if((await inspectLocks(args,checkGate)).digest!==initial.digest)fail('repository-lock.drift');
|
|
373
|
+
try{await mkdir(parent);}catch(e){if(e.code!=='EEXIST')throw e;}
|
|
374
|
+
await inspectDirectory(parent);await mkdir(archive);
|
|
375
|
+
const archiveId=identity(await lstat(archive,{bigint:true}));
|
|
376
|
+
const requestFile=path.join(archive,'request.json');
|
|
377
|
+
const requestDigest=await persist(requestFile,{schemaVersion:1,approval,observation:initial});
|
|
378
|
+
const checkArchive=async()=>{
|
|
379
|
+
await inspectDirectory(archive);
|
|
380
|
+
if(identity(await lstat(archive,{bigint:true}))!==archiveId ||
|
|
381
|
+
(await readRecord(requestFile)).digest!==requestDigest)fail('repository-lock.history-drift');
|
|
382
|
+
};
|
|
383
|
+
await ioBoundary('lock-recovery-intent-persisted',{gate,archive});
|
|
384
|
+
if((await inspectLocks(args,checkGate)).digest!==initial.digest)fail('repository-lock.drift');
|
|
385
|
+
await checkArchive();
|
|
386
|
+
const inspect=()=>inspectRecovery({...args,lockDigest:initial.digest},false);
|
|
387
|
+
const baseline=await inspect();
|
|
388
|
+
if(baseline.status!=='intent-recorded')fail('repository-lock.drift');
|
|
389
|
+
const {receiptFile,receiptDigest}=await retireRemaining({initial:baseline,inspect,
|
|
390
|
+
checkGuard:async()=>{await checkGate();await checkArchive();},ownerLiveness:'live-or-reused-pid',
|
|
391
|
+
ioBoundary,details:{gate},movePhase:'lock-recovery-lock-archived',receiptPhase:'lock-recovery-receipt-persisted'});
|
|
392
|
+
await checkGate();await checkArchive();
|
|
393
|
+
const archivedGate=path.join(archive,'recovery-gate');
|
|
394
|
+
if(!await absent(archivedGate))fail('repository-lock.history-exists');
|
|
395
|
+
await rename(gate,archivedGate);
|
|
396
|
+
await ioBoundary('lock-recovery-first-gate-archived',{archive,archivedGate});
|
|
397
|
+
await checkGate(archivedGate);
|
|
398
|
+
return {status:'repository-locks-retired',archive,receiptFile,receiptDigest,
|
|
399
|
+
requiresFinalization:initial.phase==='finalization-pending',pipelineActivated:false};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat } from 'node:fs/promises';
|
|
3
|
+
import { fail } from '../contracts/parse.js';
|
|
4
|
+
import { absoluteRoot,inspectDirectory } from '../workspace/paths.js';
|
|
5
|
+
import { bootstrapLockDirectory } from './bootstrap-lock.js';
|
|
6
|
+
import {inspectMigrationPending} from './migration-pending.js';
|
|
7
|
+
|
|
8
|
+
// Presence is enough to block, including malformed files and links. Never parse
|
|
9
|
+
// unknown records to infer that an unfinished operation is safe to ignore.
|
|
10
|
+
export async function inspectRepositoryPending(workspace) {
|
|
11
|
+
workspace=absoluteRoot(workspace);
|
|
12
|
+
const bootstrap=bootstrapLockDirectory(workspace),blockers=[];
|
|
13
|
+
for(const [code,filename] of [
|
|
14
|
+
['repository.pending',path.join(workspace,'.pipeline/repository-operation.json')],
|
|
15
|
+
['repository.bootstrap-pending',bootstrap],
|
|
16
|
+
['repository.recovery-pending',bootstrap+'.recovery'],
|
|
17
|
+
['repository.recovery-resume-pending',bootstrap+'.recovery-resume']]) {
|
|
18
|
+
await inspectDirectory(path.dirname(filename));
|
|
19
|
+
try{await lstat(filename);blockers.push({code,subject:filename});}
|
|
20
|
+
catch(e){if(e.code!=='ENOENT')throw e;}
|
|
21
|
+
}
|
|
22
|
+
blockers.push(...(await inspectMigrationPending(workspace)).blockers);
|
|
23
|
+
return {workspace,blockers,blocked:blockers.length!==0};
|
|
24
|
+
}
|
|
25
|
+
export async function assertNoRepositoryPending(workspace) {
|
|
26
|
+
const result=await inspectRepositoryPending(workspace);
|
|
27
|
+
if(result.blockers.some(b=>b.code==='migration.pending'))fail('migration.pending');
|
|
28
|
+
if(result.blocked)fail('lock.repository-pending');
|
|
29
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat,mkdir,open,rename } from 'node:fs/promises';
|
|
3
|
+
import { parse,fail,MAX_INPUT_BYTES } from '../contracts/parse.js';
|
|
4
|
+
import { contractDigest } from '../contracts/semantic.js';
|
|
5
|
+
import { absoluteRoot,inspectDirectory,resolveChild } from '../workspace/paths.js';
|
|
6
|
+
import { inventoryRepository } from '../workspace/repository-inventory.js';
|
|
7
|
+
import { readRecord } from './state.js';
|
|
8
|
+
import { readRepositoryJournal } from './repository-journal.js';
|
|
9
|
+
import { repositoryContentDigest,matchesInitializedRepository } from './repository-apply.js';
|
|
10
|
+
import { inspectRepositoryGit } from '../workspace/repository-preflight.js';
|
|
11
|
+
import { acquireBootstrapLock,assertBootstrapLockHeld } from './bootstrap-lock.js';
|
|
12
|
+
import { acquireWorkspaceLock,assertLockHeld } from './lock.js';
|
|
13
|
+
import { sha256 } from '../source/inventory.js';
|
|
14
|
+
import { verifyClonedRepository } from './repository-clone.js';
|
|
15
|
+
import { verifyRepositoryAuthorization } from './repository-authorization.js';
|
|
16
|
+
import { assertRecoveryLeaseHeld } from './recovery-lease.js';
|
|
17
|
+
import {summarizeInventory} from '../workspace/repository-observation.js';
|
|
18
|
+
|
|
19
|
+
const exact=(value,keys)=>value!==null && typeof value==='object' && !Array.isArray(value) && Object.keys(value).sort().join(',')===[...keys].sort().join(',');
|
|
20
|
+
async function observe(directory) {
|
|
21
|
+
await inspectDirectory(path.dirname(directory));
|
|
22
|
+
try {await lstat(directory);}catch(e){if(e.code==='ENOENT')return null;throw e;}
|
|
23
|
+
return inventoryRepository(directory);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Read-only recovery observation; never replay, clear pending or promote outcomes.
|
|
27
|
+
// Independent expected preview digest is required, not extracted from the file.
|
|
28
|
+
export async function inspectRepositoryReconciliation(wrapper,previewText,expectedDigest) {
|
|
29
|
+
return inspectReconciliation(wrapper,previewText,expectedDigest,'.pipeline/repository-operation.json');
|
|
30
|
+
}
|
|
31
|
+
async function inspectReconciliation(wrapper,previewText,expectedDigest,markerPath) {
|
|
32
|
+
wrapper=absoluteRoot(wrapper);
|
|
33
|
+
const preview=parse(previewText,'json');
|
|
34
|
+
if(!preview || typeof preview!=='object' || Array.isArray(preview))fail('repository-reconcile.preview');
|
|
35
|
+
const {digest,...body}=preview;
|
|
36
|
+
if(!/^sha256:[a-f0-9]{64}$/.test(expectedDigest ?? '') || digest!==expectedDigest || contractDigest(body)!==expectedDigest || preview.wrapper!==wrapper)
|
|
37
|
+
fail('repository-reconcile.preview');
|
|
38
|
+
const markerFile=resolveChild(wrapper,markerPath),marker=await readRecord(markerFile);
|
|
39
|
+
if(!exact(marker.value,['schemaVersion','previewDigest','journal','status']) || marker.value.schemaVersion!==1 ||
|
|
40
|
+
marker.value.previewDigest!==expectedDigest || marker.value.status!=='requires-reconciliation')fail('repository-reconcile.marker');
|
|
41
|
+
const journal=await readRepositoryJournal(wrapper,marker.value.journal,preview);
|
|
42
|
+
const observations=[],evidenceBindings=await verifyRepositoryAuthorization(wrapper,marker.value.journal,preview,journal.authorization);
|
|
43
|
+
for(let i=0;i<preview.operations.length;i++) {
|
|
44
|
+
const op=preview.operations[i],claimed=journal.operations[i];
|
|
45
|
+
if(!['keep','directory','init','clone','move'].includes(op.action))fail('repository-reconcile.unsupported-action');
|
|
46
|
+
if(!preview.layout?.repositories?.[op.repository] || op.target!==preview.layout.repositories[op.repository].path)
|
|
47
|
+
fail('repository-reconcile.target');
|
|
48
|
+
const target=await observe(op.target),source=op.action==='move'?await observe(op.from):null;
|
|
49
|
+
let initialized=false;
|
|
50
|
+
let cloned=false;
|
|
51
|
+
if(op.action==='clone' && target && target.entries.some(e=>e.path==='.git/HEAD'))cloned=await verifyClonedRepository(op,target);
|
|
52
|
+
if(op.action==='init' && target && matchesInitializedRepository(op,target)) {
|
|
53
|
+
const git=await inspectRepositoryGit(op.target);
|
|
54
|
+
initialized=git.head===null && !git.dirty && !git.blockers.length && git.inventory.digest===target.digest;
|
|
55
|
+
}
|
|
56
|
+
let state='conflict';
|
|
57
|
+
if(claimed.status==='completed') {
|
|
58
|
+
const relative='.pipeline/repository-evidence/'+path.basename(marker.value.journal)+'/'+String(i).padStart(6,'0')+'.json';
|
|
59
|
+
const record=await readRecord(resolveChild(wrapper,relative)),e=record.value;
|
|
60
|
+
if(record.digest!==claimed.evidenceDigest || !exact(e,['schemaVersion','previewDigest','repository','action','target','sourceAbsent','contentDigest','inventory']) ||
|
|
61
|
+
e.schemaVersion!==1 || e.previewDigest!==expectedDigest || e.repository!==op.repository || e.action!==op.action || e.target!==op.target ||
|
|
62
|
+
e.inventory?.root!==op.target || e.sourceAbsent!==(op.action==='move'?true:null) ||
|
|
63
|
+
e.contentDigest!==repositoryContentDigest(e.inventory))fail('repository-reconcile.evidence');
|
|
64
|
+
evidenceBindings.push({path:relative,digest:record.digest});
|
|
65
|
+
const postcondition=target && (op.action==='directory'?target.entries.length===1:
|
|
66
|
+
op.action==='clone'?cloned:
|
|
67
|
+
op.action==='init'?initialized:
|
|
68
|
+
op.action==='move'?source===null && repositoryContentDigest(target)===repositoryContentDigest(op.existing.inventory):
|
|
69
|
+
target.digest===op.existing.inventory.digest);
|
|
70
|
+
if(postcondition && contractDigest(e.inventory.kind==='repository-inventory-summary'?summarizeInventory(target):target)===contractDigest(e.inventory))state='completed-verified';
|
|
71
|
+
} else {
|
|
72
|
+
// Shape compatibility is NOT provenance or permission to repeat an effect.
|
|
73
|
+
const unchanged=op.action==='keep'?target?.digest===op.existing.inventory.digest:
|
|
74
|
+
op.action==='move'?target===null && source?.digest===op.existing.inventory.digest:target===null;
|
|
75
|
+
const compatible=op.action==='directory'?target?.entries.length===1:
|
|
76
|
+
op.action==='clone'?cloned:
|
|
77
|
+
op.action==='init'?initialized:
|
|
78
|
+
op.action==='move'?source===null && target && repositoryContentDigest(target)===repositoryContentDigest(op.existing.inventory):false;
|
|
79
|
+
if(unchanged)state='before-state-observed';
|
|
80
|
+
else if(compatible)state='effect-compatible-unconfirmed';
|
|
81
|
+
}
|
|
82
|
+
observations.push({repository:op.repository,claimedStatus:claimed.status,state,
|
|
83
|
+
targetDigest:target?.digest ?? null,sourceDigest:source?.digest ?? null});
|
|
84
|
+
}
|
|
85
|
+
// Recheck anchors and observed trees before returning an approval candidate.
|
|
86
|
+
if((await readRecord(markerFile)).digest!==marker.digest)fail('repository-reconcile.drift');
|
|
87
|
+
const again=await readRepositoryJournal(wrapper,marker.value.journal,preview);
|
|
88
|
+
if(contractDigest(again)!==contractDigest(journal))fail('repository-reconcile.drift');
|
|
89
|
+
for(const binding of evidenceBindings)
|
|
90
|
+
if((await readRecord(resolveChild(wrapper,binding.path))).digest!==binding.digest)fail('repository-reconcile.drift');
|
|
91
|
+
for(let i=0;i<observations.length;i++) {
|
|
92
|
+
const op=preview.operations[i],o=observations[i];
|
|
93
|
+
if(((await observe(op.target))?.digest ?? null)!==o.targetDigest ||
|
|
94
|
+
(op.action==='move' && ((await observe(op.from))?.digest ?? null)!==o.sourceDigest))fail('repository-reconcile.drift');
|
|
95
|
+
}
|
|
96
|
+
const result={kind:'repository-reconciliation',wrapper,previewDigest:expectedDigest,markerDigest:marker.digest,
|
|
97
|
+
journal:marker.value.journal,journalHead:journal.lastHash,evidenceBindings,observations,
|
|
98
|
+
canFinalize:journal.phase==='terminal' && observations.every(o=>o.state==='completed-verified'),
|
|
99
|
+
executionAuthorized:false};
|
|
100
|
+
return {...result,digest:contractDigest(result)};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Explicit exact reconciliation approval. Never resolves uncertain effects or
|
|
104
|
+
// retries an operation. Old pending marker is moved into retained history.
|
|
105
|
+
// Read-only confirmation after the pending marker was already handed off. This
|
|
106
|
+
// does not release stale locks or authorize another operation. Require the exact
|
|
107
|
+
// previously approved reconciliation, not an approval inferred from the receipt.
|
|
108
|
+
export async function verifyRepositoryCompletion({wrapper,previewText,previewDigest,journal,reconciliationDigest}) {
|
|
109
|
+
wrapper=absoluteRoot(wrapper);
|
|
110
|
+
if(typeof journal!=='string' || !/^\.pipeline\/repository-journals\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(journal))
|
|
111
|
+
fail('repository-reconcile.completion-journal');
|
|
112
|
+
const id=path.basename(journal),base='.pipeline/repository-completions/'+id;
|
|
113
|
+
const receiptFile=resolveChild(wrapper,base+'.json'),historyFile=resolveChild(wrapper,base+'.pending.json');
|
|
114
|
+
const absent=async()=>{
|
|
115
|
+
try{await lstat(resolveChild(wrapper,'.pipeline/repository-operation.json'));fail('repository-reconcile.completion-pending');}
|
|
116
|
+
catch(e){if(e.code!=='ENOENT')throw e;}
|
|
117
|
+
};
|
|
118
|
+
await absent();
|
|
119
|
+
const receipt=await readRecord(receiptFile),value=receipt.value;
|
|
120
|
+
if(!exact(value,['schemaVersion','status','reconciliation','pipelineActivated']) || value.schemaVersion!==1 ||
|
|
121
|
+
value.status!=='repository-effects-verified' || value.pipelineActivated!==false)fail('repository-reconcile.completion-receipt');
|
|
122
|
+
const checked=await inspectReconciliation(wrapper,previewText,previewDigest,base+'.pending.json');
|
|
123
|
+
if(!checked.canFinalize || checked.journal!==journal || checked.digest!==reconciliationDigest ||
|
|
124
|
+
contractDigest(value.reconciliation)!==contractDigest(checked))fail('repository-reconcile.completion-drift');
|
|
125
|
+
if((await readRecord(receiptFile)).digest!==receipt.digest)fail('repository-reconcile.receipt-drift');
|
|
126
|
+
await absent();
|
|
127
|
+
return {status:'repository-effects-verified',receiptFile,historyFile,receiptDigest:receipt.digest,
|
|
128
|
+
reconciliationDigest:checked.digest,pipelineActivated:false,executionAuthorized:false};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function finalizeRepositoryOperations(args) {return finalize(args,false);}
|
|
132
|
+
export async function resumeRepositoryFinalization(args) {return finalize(args,true);}
|
|
133
|
+
async function finalize({wrapper,previewText,previewDigest,approval,lease,ioBoundary=async()=>{}},resume) {
|
|
134
|
+
// Ordinary first-attempt callers hold persistent locks; recovery callers also
|
|
135
|
+
// supply the ephemeral capability. A supplied invalid capability never falls back.
|
|
136
|
+
const checkLease=()=>{if(lease!==undefined)assertRecoveryLeaseHeld(lease);};
|
|
137
|
+
checkLease();
|
|
138
|
+
if(!exact(approval,['decision','reconciliationDigest']) || approval.decision!=='approve')fail('repository-reconcile.approval');
|
|
139
|
+
const inspect=()=>inspectRepositoryReconciliation(wrapper,previewText,previewDigest);
|
|
140
|
+
const check=result=>{
|
|
141
|
+
checkLease();
|
|
142
|
+
if(result.digest!==approval.reconciliationDigest)fail('repository-reconcile.approval-drift');
|
|
143
|
+
if(!result.canFinalize)fail('repository-reconcile.unresolved');
|
|
144
|
+
};
|
|
145
|
+
check(await inspect());let bootstrap,lock;
|
|
146
|
+
try {
|
|
147
|
+
checkLease();bootstrap=await acquireBootstrapLock(wrapper);checkLease();
|
|
148
|
+
lock=await acquireWorkspaceLock(wrapper,{repositoryOperation:bootstrap});
|
|
149
|
+
const verified=await inspect();check(verified);
|
|
150
|
+
await assertBootstrapLockHeld(bootstrap);await assertLockHeld(lock);
|
|
151
|
+
const directory=resolveChild(wrapper,'.pipeline/repository-completions');
|
|
152
|
+
checkLease();
|
|
153
|
+
try{await mkdir(directory);}catch(e){if(e.code!=='EEXIST')throw e;}
|
|
154
|
+
await inspectDirectory(directory);
|
|
155
|
+
const id=path.basename(verified.journal),receiptFile=path.join(directory,id+'.json'),historyFile=path.join(directory,id+'.pending.json');
|
|
156
|
+
// A partial previous finalization must be investigated, never overwritten.
|
|
157
|
+
for(const filename of resume?[historyFile]:[receiptFile,historyFile]) {
|
|
158
|
+
try {await lstat(filename);fail('repository-reconcile.finalization-exists');}
|
|
159
|
+
catch(e){if(e.code!=='ENOENT')throw e;}
|
|
160
|
+
}
|
|
161
|
+
const receipt={schemaVersion:1,status:'repository-effects-verified',reconciliation:verified,pipelineActivated:false};
|
|
162
|
+
const bytes=Buffer.from(JSON.stringify(receipt)+'\n');if(bytes.length>MAX_INPUT_BYTES)fail('repository-reconcile.receipt-size');
|
|
163
|
+
if(!resume) {
|
|
164
|
+
checkLease();
|
|
165
|
+
const handle=await open(receiptFile,'wx',0o600);
|
|
166
|
+
try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
|
|
167
|
+
}
|
|
168
|
+
// Resumption requires the exact receipt this finalized subject would have
|
|
169
|
+
// produced; missing, partial, stale or changed bytes are never overwritten.
|
|
170
|
+
if((await readRecord(receiptFile)).digest!==sha256(bytes))fail('repository-reconcile.receipt-drift');
|
|
171
|
+
await ioBoundary('finalization-receipt-verified',{receiptFile,historyFile,resume});
|
|
172
|
+
// Reinspect effects/evidence, not just the marker, after receipt IO.
|
|
173
|
+
check(await inspect());await assertBootstrapLockHeld(bootstrap);await assertLockHeld(lock);
|
|
174
|
+
if((await readRecord(receiptFile)).digest!==sha256(bytes))fail('repository-reconcile.receipt-drift');
|
|
175
|
+
try{await lstat(historyFile);fail('repository-reconcile.finalization-exists');}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
176
|
+
checkLease();await rename(resolveChild(wrapper,'.pipeline/repository-operation.json'),historyFile);
|
|
177
|
+
await ioBoundary('finalization-marker-moved',{receiptFile,historyFile,resume});
|
|
178
|
+
if((await readRecord(historyFile)).digest!==verified.markerDigest)fail('repository-reconcile.marker-drift');
|
|
179
|
+
checkLease();
|
|
180
|
+
return {status:'repository-effects-verified',receiptFile,historyFile,receiptDigest:sha256(bytes),pipelineActivated:false};
|
|
181
|
+
}finally{try{if(lock)await lock.release();}finally{if(bootstrap)await bootstrap.release();}}
|
|
182
|
+
}
|