@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,29 @@
1
+ import {checkPreview} from './plan.js';
2
+ import {readConfigField} from './config-fields.js';
3
+ import {contractDigest} from '../contracts/semantic.js';
4
+ import {fail} from '../contracts/parse.js';
5
+ import {sha256} from '../source/inventory.js';
6
+
7
+ // Derive incoming backups from the approved post-removal observations, never
8
+ // from still-installed old provider bytes. Pure; not an authorization to write.
9
+ export function incomingSwitchBackups(preview) {
10
+ const observations=preview.observations.map(o=>({path:o.path,bytes:o.bytes===null?null:Buffer.from(o.bytes,'base64')}));
11
+ const checked=checkPreview(preview,null,observations);
12
+ if(checked.plan.command!=='setup' || !checked.plan.desired)fail('switch-backup.phase');
13
+ const observed=new Map(observations.map(o=>[o.path,o.bytes])),backups=new Map();
14
+ for(const owned of checked.plan.desired.owned) {
15
+ if(owned.backup===null)continue;
16
+ const bytes=observed.get(owned.path);
17
+ if(!Buffer.isBuffer(bytes))fail('switch-backup.missing');
18
+ const hash=sha256(bytes);
19
+ if(owned.kind==='file') {
20
+ if(hash!==owned.beforeHash)fail('switch-backup.before');
21
+ }else {
22
+ const field=readConfigField(owned.path,bytes,owned.pointer);
23
+ if(!field.present || contractDigest(field.value)!==owned.beforeHash)fail('switch-backup.before');
24
+ }
25
+ if(backups.has(owned.backup) && backups.get(owned.backup).hash!==hash)fail('switch-backup.conflict');
26
+ backups.set(owned.backup,{path:owned.backup,hash,bytes:Buffer.from(bytes)});
27
+ }
28
+ return [...backups.values()].sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);
29
+ }
@@ -0,0 +1,94 @@
1
+ import {contractDigest,portablePath} from '../contracts/semantic.js';
2
+ import {parse,fail} from '../contracts/parse.js';
3
+ import {requestShape} from './ownership.js';
4
+
5
+ const hash=v=>typeof v==='string' && /^sha256:[a-f0-9]{64}(?![\s\S])/.test(v);
6
+ const nullableHash=v=>v===null || hash(v);
7
+ const detach=v=>parse(JSON.stringify(v),'json');
8
+
9
+ // Pure interpretation of an already verified continuation preview. This does
10
+ // not establish source provenance, approval, locking or actual disk readback.
11
+ // The future durable executor must verifySwitchContinuationApproval first.
12
+ export function createSwitchContinuationCursor(preview) {
13
+ const p=detach(preview),{digest,...body}=p;
14
+ if(p.kind!=='switch-continuation-preview' || !hash(digest) || contractDigest(body)!==digest ||
15
+ p.applySupported!==false || p.requiresFreshApproval!==true || p.runtime!=='not-run' ||
16
+ !Array.isArray(p.observations) || !Array.isArray(p.remaining) || (p.uncertain!==null && !p.remaining.length) || p.remaining.length>2)
17
+ fail('switch-continuation-journal.preview');
18
+ const projected=new Map();
19
+ for(const o of p.observations) {
20
+ requestShape(o,['path','hash'],[],'switch-continuation-journal.observation');portablePath(o.path);
21
+ if(projected.has(o.path) || !nullableHash(o.hash))fail('switch-continuation-journal.observation');
22
+ projected.set(o.path,o.hash);
23
+ }
24
+ let count=0;
25
+ for(let i=0;i<p.remaining.length;i++) {
26
+ const phase=p.remaining[i],seen=new Set();
27
+ requestShape(phase,['phase','operations','phaseCheckRequired'],[],'switch-continuation-journal.phase');
28
+ if(phase.phase!==(p.remaining.length===2 && i===0?'remove-old':'install-new') ||
29
+ phase.phaseCheckRequired!==true || !Array.isArray(phase.operations))fail('switch-continuation-journal.phase');
30
+ for(let j=0;j<phase.operations.length;j++) {
31
+ const t=phase.operations[j];
32
+ requestShape(t,['operationId','path','resolution','beforeHash','desiredHash'],[],'switch-continuation-journal.target');
33
+ if(typeof t.operationId!=='string' || !t.operationId || seen.has(t.operationId) ||
34
+ !projected.has(t.path) || !nullableHash(t.beforeHash) || !nullableHash(t.desiredHash) ||
35
+ !(i===0 && j===0?(p.uncertain===null?['verify-desired','apply-approved-target']:['verify-desired','retry-approved-target']):['apply-approved-target']).includes(t.resolution))
36
+ fail('switch-continuation-journal.target');
37
+ seen.add(t.operationId);count++;
38
+ }
39
+ }
40
+ const first=p.remaining[0]?.operations[0];
41
+ if((p.uncertain!==null && !first) || (first && projected.get(first.path)!==(first.resolution==='verify-desired'?first.desiredHash:first.beforeHash)))
42
+ fail('switch-continuation-journal.observation');
43
+ const projectionDigest=()=>contractDigest([...projected].sort(([a],[b])=>a<b?-1:a>b?1:0).map(([path,hash])=>({path,hash})));
44
+ const maxEvents=1+p.remaining.length+2*count;
45
+ let sequence=0,head=null,phaseIndex=0,nextIndex=0,pending=null,stopped=null,broken=false;
46
+ const inspect=()=>({kind:'switch-continuation-event-inspection',previewDigest:digest,sequence,head,
47
+ status:sequence===0?'not-started':stopped??(pending?'uncertain':phaseIndex===p.remaining.length?'completed':'open'),
48
+ phase:phaseIndex===p.remaining.length?null:p.remaining[phaseIndex].phase,nextIndex,
49
+ pending:pending?{...pending}:null,verifiedPhases:phaseIndex,expectedProjectionDigest:projectionDigest(),
50
+ activationSupported:false,runtime:'not-run'});
51
+ function append(value) {
52
+ if(broken)fail('switch-continuation-journal.unavailable');
53
+ try {
54
+ const e=detach(value);
55
+ requestShape(e,['schemaVersion','seq','previous','previewDigest','kind','payload'],[],'switch-continuation-journal.event');
56
+ if(sequence>=maxEvents || e.schemaVersion!==1 || e.seq!==sequence || e.previous!==head || e.previewDigest!==digest)
57
+ fail('switch-continuation-journal.binding');
58
+ if(sequence===0) {
59
+ if(e.kind!=='start' || contractDigest(e.payload)!==contractDigest(p.predecessor))fail('switch-continuation-journal.start');
60
+ }else {
61
+ if(stopped || phaseIndex===p.remaining.length)fail('switch-continuation-journal.sequence');
62
+ const phase=p.remaining[phaseIndex],t=phase.operations[nextIndex];
63
+ if(e.kind==='phase-checked') {
64
+ requestShape(e.payload,['phase','projectionDigest'],[],'switch-continuation-journal.payload');
65
+ if(pending || t || e.payload.phase!==phase.phase || e.payload.projectionDigest!==projectionDigest())
66
+ fail('switch-continuation-journal.phase-check');
67
+ phaseIndex++;nextIndex=0;
68
+ }else {
69
+ const intent=e.kind==='intent',readback=e.kind==='readback';
70
+ requestShape(e.payload,intent?['phase','operationId']:['phase','operationId','status','observedHash'],[],
71
+ 'switch-continuation-journal.payload');
72
+ const v=e.payload;
73
+ if(!t || v.phase!==phase.phase || v.operationId!==t.operationId)fail('switch-continuation-journal.sequence');
74
+ if(intent) {
75
+ if(pending || t.resolution==='verify-desired' || projected.get(t.path)!==t.beforeHash)
76
+ fail('switch-continuation-journal.sequence');
77
+ pending={phase:phase.phase,operationId:t.operationId};
78
+ }else {
79
+ if(readback?pending || t.resolution!=='verify-desired':e.kind!=='outcome' || !pending)
80
+ fail('switch-continuation-journal.sequence');
81
+ if(!['completed','failed','uncertain'].includes(v.status) || !nullableHash(v.observedHash) ||
82
+ (v.status==='completed' && v.observedHash!==t.desiredHash) ||
83
+ (v.status==='failed' && v.observedHash!==(readback?t.desiredHash:t.beforeHash)))
84
+ fail('switch-continuation-journal.outcome');
85
+ if(v.status==='completed')projected.set(t.path,t.desiredHash);else stopped=v.status;
86
+ nextIndex++;pending=null;
87
+ }
88
+ }
89
+ }
90
+ head=contractDigest(e);sequence++;return inspect();
91
+ }catch(error){broken=true;throw error;}
92
+ }
93
+ return Object.freeze({append,inspect,maxEvents});
94
+ }
@@ -0,0 +1,60 @@
1
+ import {open,rename} from 'node:fs/promises';
2
+ import {randomUUID} from 'node:crypto';
3
+ import {readSwitchContinuationRecovery} from './switch-continuation-recovery.js';
4
+ import {verifySwitchContinuationApproval} from './switch-continuation.js';
5
+ import {assertLockHeld} from './lock.js';
6
+ import {readState,observeTargets} from './state.js';
7
+ import {resolveChild,inspectDirectory} from '../workspace/paths.js';
8
+ import {validateState,contractDigest} from '../contracts/semantic.js';
9
+ import {parse,fail,ContractError} from '../contracts/parse.js';
10
+ import {sha256} from '../source/inventory.js';
11
+
12
+ // Exact selection readback only, not a target-state or execution-readiness check.
13
+ export async function inspectSelectedSwitchContinuation(workspace,recoveryPath,recoveryHash) {
14
+ const filename=resolveChild(workspace,'.pipeline/state.json'),before=await readState(filename);
15
+ const evidence=await readSwitchContinuationRecovery(workspace,recoveryPath),record=evidence.record;
16
+ if(evidence.fileHash!==recoveryHash)fail('switch-continuation-pending.evidence');
17
+ const parent=evidence.root;
18
+ const expected={...parent.record.previous,status:'needs-reconciliation',pending:record.digest,runtime:'not-run'};
19
+ if(contractDigest(before.value)!==contractDigest(expected))fail('switch-continuation-pending.selection');
20
+ const repeated=await readSwitchContinuationRecovery(workspace,recoveryPath);
21
+ if(repeated.fileHash!==evidence.fileHash || contractDigest(repeated.journal)!==contractDigest(evidence.journal) ||
22
+ (await readState(filename)).digest!==before.digest)fail('switch-continuation-pending.drift');
23
+ return {status:'needs-reconciliation',pending:record.digest,stateFileHash:before.digest,recoveryPath,
24
+ recoveryHash:evidence.fileHash,journalStatus:evidence.journal.status,executionAllowed:false,runtime:'not-run'};
25
+ }
26
+
27
+ // Select an initialized continuation from the exact old pending state. Neither
28
+ // targets nor active deployment are changed; failed writes are never rolled back.
29
+ export async function markSwitchContinuationPending(lock,recoveryPath,recoveryHash,approval,{boundary=async()=>{}}={}) {
30
+ approval=parse(JSON.stringify(approval),'json');
31
+ await assertLockHeld(lock);
32
+ const evidence=await readSwitchContinuationRecovery(lock.workspace,recoveryPath),record=evidence.record;
33
+ if(evidence.fileHash!==recoveryHash || evidence.journal.sequence!==1 || evidence.journal.status!==(record.preview.remaining.length?'open':'completed') ||
34
+ contractDigest(record.approval)!==contractDigest(approval))fail('switch-continuation-pending.evidence');
35
+ await verifySwitchContinuationApproval(lock,record.preview,approval);
36
+ const filename=resolveChild(lock.workspace,'.pipeline/state.json'),before=await readState(filename);
37
+ if(before.digest!==record.preview.stateFileHash)fail('switch-continuation-pending.state');
38
+ const pending={...before.value,pending:record.digest};validateState(pending);
39
+ const bytes=Buffer.from(JSON.stringify(pending)+'\n'),scratch='.pipeline/.continuation-state-'+randomUUID()+'.tmp';
40
+ const staged=resolveChild(lock.workspace,scratch);
41
+ try {
42
+ await assertLockHeld(lock);await inspectDirectory(resolveChild(lock.workspace,'.pipeline'));
43
+ const handle=await open(staged,'wx',0o600);
44
+ try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
45
+ await boundary('before-rename',{path:'.pipeline/state.json',stagedPath:scratch});
46
+ await verifySwitchContinuationApproval(lock,record.preview,approval);
47
+ const fresh=await readSwitchContinuationRecovery(lock.workspace,recoveryPath);
48
+ if(fresh.fileHash!==recoveryHash || contractDigest(fresh.journal)!==contractDigest(evidence.journal))
49
+ fail('switch-continuation-pending.evidence');
50
+ const [temp]=await observeTargets(lock.workspace,[scratch]);
51
+ if(temp.bytes===null || sha256(temp.bytes)!==sha256(bytes))fail('switch-continuation-pending.staging');
52
+ await assertLockHeld(lock);
53
+ if((await readState(filename)).digest!==before.digest)fail('switch-continuation-pending.state');
54
+ await rename(staged,filename);
55
+ await boundary('renamed',{path:'.pipeline/state.json'});
56
+ if((await readState(filename)).digest!==sha256(bytes))fail('switch-continuation-pending.readback');
57
+ const result=await inspectSelectedSwitchContinuation(lock.workspace,recoveryPath,recoveryHash);
58
+ await assertLockHeld(lock);return result;
59
+ }catch(error){throw error instanceof ContractError?error:new ContractError('switch-continuation-pending.io');}
60
+ }
@@ -0,0 +1,109 @@
1
+ import {parse,fail} from '../contracts/parse.js';
2
+ import {requestShape} from './ownership.js';
3
+ import {contractDigest} from '../contracts/semantic.js';
4
+ import {absoluteRoot} from '../workspace/paths.js';
5
+ import {createSwitchContinuationCursor} from './switch-continuation-journal.js';
6
+
7
+ const hash=v=>typeof v==='string' && /^sha256:[a-f0-9]{64}(?![\s\S])/.test(v);
8
+ const uuid='[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}';
9
+ const journalPath=new RegExp('^\\.pipeline/journals/'+uuid+'(?![\\s\\S])');
10
+ const recoveryPath=new RegExp('^\\.pipeline/transactions/('+uuid+')/recovery.json(?![\\s\\S])');
11
+ const detach=v=>parse(JSON.stringify(v),'json');
12
+
13
+ // Structural envelope only. Durable validation must also check the predecessor.
14
+ export function createSwitchContinuationRecoveryRecord({preview,approval,journal}) {
15
+ const p=detach(preview),a=detach(approval);
16
+ requestShape(p,['kind','workspace','recoveryPath','recoveryHash','predecessor','stateFileHash','observations','uncertain','remaining',
17
+ 'requiresFreshApproval','applySupported','runtime','digest'],[],'switch-continuation-record.preview');
18
+ requestShape(a,['decision','previewDigest'],[],'switch-continuation-record.approval');
19
+ requestShape(p.predecessor,['pending','journal','sequence','head','lastFileHash'],[],'switch-continuation-record.predecessor');
20
+ if(p.uncertain!==null)requestShape(p.uncertain,['phase','operationId','path','beforeHash','desiredHash','observedHash','resolution'],[],
21
+ 'switch-continuation-record.uncertain');
22
+ absoluteRoot(p.workspace);createSwitchContinuationCursor(p);
23
+ const source=typeof p.recoveryPath==='string' && recoveryPath.exec(p.recoveryPath);
24
+ if(!source || p.predecessor.journal!=='.pipeline/journals/'+source[1] ||
25
+ ![p.recoveryHash,p.stateFileHash,p.predecessor.pending,p.predecessor.head,p.predecessor.lastFileHash].every(hash) ||
26
+ !Number.isSafeInteger(p.predecessor.sequence) || p.predecessor.sequence<(p.uncertain===null?1:2))fail('switch-continuation-record.predecessor');
27
+ if(a.decision!=='approve' || a.previewDigest!==p.digest)fail('switch-continuation-record.approval');
28
+ if(typeof journal!=='string' || !journalPath.test(journal) || journal===p.predecessor.journal)fail('switch-continuation-record.journal');
29
+ const body={schemaVersion:1,kind:'switch-continuation-recovery',preview:p,approval:a,journal,
30
+ activationSupported:false,runtime:'not-run'};
31
+ return detach({...body,digest:contractDigest(body)});
32
+ }
33
+
34
+ export function validateSwitchContinuationRecoveryRecord(value) {
35
+ const record=detach(value);
36
+ requestShape(record,['schemaVersion','kind','preview','approval','journal','activationSupported','runtime','digest'],[],
37
+ 'switch-continuation-record.recovery');
38
+ const fresh=createSwitchContinuationRecoveryRecord(record);
39
+ if(contractDigest(record)!==contractDigest(fresh))fail('switch-continuation-record.recovery');
40
+ return fresh;
41
+ }
42
+
43
+ // Compare to verified immutable predecessor evidence, not current target bytes.
44
+ export function assertSwitchContinuationPredecessor(preview,evidence) {
45
+ const {record,journal}=evidence,p=preview;
46
+ // The caller must first verify the predecessor envelope, journal and ancestry.
47
+ // This pure check accepts either kind but grants no filesystem authority.
48
+ const continuation=record.kind==='switch-continuation-recovery';
49
+ if(!continuation && record.kind!=='switch-recovery')fail('switch-continuation-record.lineage');
50
+ const base=continuation?{
51
+ observations:record.preview.observations,
52
+ phases:record.preview.remaining.map(phase=>({name:phase.phase,preview:{plan:{targets:
53
+ phase.operations.map(t=>({...t,id:t.operationId}))}}}))
54
+ }:record.prepared.preview;
55
+ const workspace=continuation?record.preview.workspace:record.previous.workspace;
56
+ const expectedParent={pending:record.digest,journal:record.journal,sequence:journal.sequence,
57
+ head:journal.head,lastFileHash:journal.lastFileHash};
58
+ if(!(p.uncertain===null?['open','completed']:['uncertain']).includes(journal.status) || evidence.fileHash!==p.recoveryHash || workspace!==p.workspace ||
59
+ contractDigest(expectedParent)!==contractDigest(p.predecessor))fail('switch-continuation-record.lineage');
60
+ if(p.uncertain===null) {
61
+ if(journal.pending)fail('switch-continuation-record.lineage');
62
+ const expected=new Map(base.observations.map(o=>[o.path,o.hash]));
63
+ for(let i=0;i<base.phases.length;i++) {
64
+ const targets=base.phases[i].preview.plan.targets;
65
+ const count=i<journal.verifiedPhases?targets.length:i===journal.verifiedPhases?journal.nextIndex:0;
66
+ for(let j=0;j<count;j++)expected.set(targets[j].path,targets[j].desiredHash);
67
+ }
68
+ const observations=[...expected].sort(([a],[b])=>a<b?-1:a>b?1:0).map(([path,hash])=>({path,hash}));
69
+ const remaining=boundaryRemaining(base.phases,journal);
70
+ if(contractDigest(observations)!==contractDigest(p.observations))fail('switch-continuation-record.observations');
71
+ if(contractDigest(remaining)!==contractDigest(p.remaining))fail('switch-continuation-record.plan');
72
+ return;
73
+ }
74
+ const phaseIndex=journal.verifiedPhases,phase=base.phases[phaseIndex];
75
+ const targetIndex=journal.pending?journal.nextIndex:journal.nextIndex-1;
76
+ const target=phase?.preview.plan.targets[targetIndex];
77
+ if(!target)fail('switch-continuation-record.lineage');
78
+ const expected=new Map(base.observations.map(o=>[o.path,o.hash]));
79
+ for(let i=0;i<=phaseIndex;i++) {
80
+ const targets=base.phases[i].preview.plan.targets,count=i<phaseIndex?targets.length:targetIndex;
81
+ for(let j=0;j<count;j++)expected.set(targets[j].path,targets[j].desiredHash);
82
+ }
83
+ const observed=p.observations.find(o=>o.path===target.path)?.hash;
84
+ if(observed!==target.beforeHash && observed!==target.desiredHash)fail('switch-continuation-record.observations');
85
+ // A previous readback-only approval cannot be widened into a write retry.
86
+ if(continuation && target.resolution==='verify-desired' && observed!==target.desiredHash)
87
+ fail('switch-continuation-record.observations');
88
+ expected.set(target.path,observed);
89
+ if(contractDigest(p.observations)!==contractDigest([...expected].sort(([a],[b])=>a<b?-1:a>b?1:0).map(([path,hash])=>({path,hash}))))
90
+ fail('switch-continuation-record.observations');
91
+ const resolution=observed===target.desiredHash?'verify-desired':'retry-approved-target';
92
+ const uncertain={phase:phase.name,operationId:target.id,path:target.path,beforeHash:target.beforeHash,
93
+ desiredHash:target.desiredHash,observedHash:observed,resolution};
94
+ const remaining=base.phases.slice(phaseIndex).map((phase,index)=>({phase:phase.name,
95
+ operations:phase.preview.plan.targets.slice(index===0?targetIndex:0).map((t,i)=>({operationId:t.id,path:t.path,
96
+ resolution:index===0 && i===0?resolution:'apply-approved-target',beforeHash:t.beforeHash,desiredHash:t.desiredHash})),
97
+ phaseCheckRequired:true}));
98
+ if(contractDigest(uncertain)!==contractDigest(p.uncertain) || contractDigest(remaining)!==contractDigest(p.remaining))
99
+ fail('switch-continuation-record.plan');
100
+ }
101
+
102
+ // Completed outcomes are skipped; unrecorded phase checks remain mandatory.
103
+ // Empty remaining means both phase checks already exist, not inferred success.
104
+ export function boundaryRemaining(phases,journal) {
105
+ return phases.slice(journal.verifiedPhases).map((phase,index)=>({phase:phase.name,
106
+ operations:phase.preview.plan.targets.slice(index===0?journal.nextIndex:0).map(t=>({operationId:t.id,path:t.path,
107
+ resolution:t.resolution==='verify-desired'?'verify-desired':'apply-approved-target',beforeHash:t.beforeHash,desiredHash:t.desiredHash})),
108
+ phaseCheckRequired:true}));
109
+ }
@@ -0,0 +1,91 @@
1
+ import {mkdir,open} from 'node:fs/promises';
2
+ import {createSwitchContinuationRecoveryRecord,validateSwitchContinuationRecoveryRecord,assertSwitchContinuationPredecessor} from './switch-continuation-records.js';
3
+ import {createSwitchContinuationJournal,readSwitchContinuationJournal} from './switch-continuation-store.js';
4
+ import {verifySwitchContinuationApproval} from './switch-continuation.js';
5
+ import {readSwitchRecovery} from './switch-recovery-store.js';
6
+ import {readRecord} from './state.js';
7
+ import {assertLockHeld} from './lock.js';
8
+ import {resolveChild,inspectDirectory} from '../workspace/paths.js';
9
+ import {contractDigest} from '../contracts/semantic.js';
10
+ import {sha256} from '../source/inventory.js';
11
+ import {fail,ContractError} from '../contracts/parse.js';
12
+ import {MAX_CONTINUATION_DEPTH} from './lineage-guard.js';
13
+
14
+ function checkPath(relative) {
15
+ const match=typeof relative==='string' && /^\.pipeline\/transactions\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\/recovery.json(?![\s\S])/.exec(relative);
16
+ if(!match)fail('switch-continuation-recovery.path');
17
+ return match[1];
18
+ }
19
+
20
+ // Two bounded iterative passes, not recursively repeated ancestry reads.
21
+ // Shared continuation limit plus the original switch; over-limit is fail-closed.
22
+ async function readChain(workspace,relative) {
23
+ const chain=[],seen=new Set();
24
+ while(true) {
25
+ const id=checkPath(relative);
26
+ if(seen.has(relative))fail('switch-continuation-recovery.cycle');
27
+ if(chain.length>MAX_CONTINUATION_DEPTH)fail('switch-continuation-recovery.depth');
28
+ seen.add(relative);
29
+ const stored=await readRecord(resolveChild(workspace,relative));
30
+ if(stored.value?.kind==='switch-recovery') {
31
+ const root=await readSwitchRecovery(workspace,relative);
32
+ if(root.fileHash!==stored.digest)fail('switch-continuation-recovery.drift');
33
+ chain.push({relative,...root});break;
34
+ }
35
+ const record=validateSwitchContinuationRecoveryRecord(stored.value);
36
+ if(record.preview.workspace!==workspace || record.journal!=='.pipeline/journals/'+id)
37
+ fail('switch-continuation-recovery.binding');
38
+ const journal=await readSwitchContinuationJournal(workspace,record.journal,record.preview);
39
+ chain.push({relative,record,fileHash:stored.digest,journal});
40
+ relative=record.preview.recoveryPath;
41
+ }
42
+ for(let i=chain.length-2;i>=0;i--)assertSwitchContinuationPredecessor(chain[i].record.preview,chain[i+1]);
43
+ return chain;
44
+ }
45
+
46
+ export async function readSwitchLineage(workspace,relative) {
47
+ const before=await readChain(workspace,relative),after=await readChain(workspace,relative);
48
+ const binding=chain=>chain.map(e=>({relative:e.relative,fileHash:e.fileHash,journal:e.journal}));
49
+ if(contractDigest(binding(before))!==contractDigest(binding(after)))fail('switch-continuation-recovery.drift');
50
+ return {...after[0],root:after.at(-1),ancestors:after.slice(1),applySupported:false,runtime:'not-run'};
51
+ }
52
+
53
+ // Historical evidence audit, not current-target approval or active-chain selection.
54
+ export async function readSwitchContinuationRecovery(workspace,relative) {
55
+ const evidence=await readSwitchLineage(workspace,relative);
56
+ if(evidence.record.kind!=='switch-continuation-recovery')fail('switch-continuation-recovery.binding');
57
+ return evidence;
58
+ }
59
+
60
+ // Persist prerequisites only. Partial evidence remains on failure. No mutable
61
+ // journal writer is returned and no targets or active/pending state are changed.
62
+ export async function persistSwitchContinuationRecovery(lock,preview,approval,{boundary=async()=>{},onJournal=async()=>{}}={}) {
63
+ const checked=await verifySwitchContinuationApproval(lock,preview,approval);
64
+ // Bound the whole envelope before writing. A different placeholder UUID avoids
65
+ // accidentally selecting the predecessor as the destination.
66
+ const placeholders=['00000000-0000-0000-0000-000000000000','00000000-0000-0000-0000-000000000001'];
67
+ const placeholder=placeholders.map(id=>'.pipeline/journals/'+id).find(p=>p!==checked.preview.predecessor.journal);
68
+ createSwitchContinuationRecoveryRecord({...checked,journal:placeholder});
69
+ const journal=await createSwitchContinuationJournal(lock,checked.preview,checked.approval,{onLocation:onJournal});
70
+ const uuid=journal.relative.split('/').at(-1),relative='.pipeline/transactions/'+uuid+'/recovery.json';
71
+ const record=createSwitchContinuationRecoveryRecord({...checked,journal:journal.relative});
72
+ const bytes=Buffer.from(JSON.stringify(record)+'\n');
73
+ try {
74
+ await verifySwitchContinuationApproval(lock,checked.preview,checked.approval);
75
+ const parent=resolveChild(lock.workspace,'.pipeline/transactions'),directory=resolveChild(lock.workspace,'.pipeline/transactions/'+uuid);
76
+ try{await mkdir(parent);}catch(error){if(error.code!=='EEXIST')throw error;}
77
+ await inspectDirectory(parent);await mkdir(directory);await inspectDirectory(directory);
78
+ const handle=await open(resolveChild(lock.workspace,relative),'wx',0o600);
79
+ try {
80
+ await boundary('opened',{path:relative});await handle.writeFile(bytes);await handle.sync();
81
+ }finally{await handle.close();}
82
+ await boundary('recovery-written',{path:relative});
83
+ if((await readRecord(resolveChild(lock.workspace,relative))).digest!==sha256(bytes))fail('switch-continuation-recovery.readback');
84
+ await verifySwitchContinuationApproval(lock,checked.preview,checked.approval);
85
+ const evidence=await readSwitchContinuationRecovery(lock.workspace,relative);
86
+ if(evidence.journal.sequence!==1 || evidence.journal.status!==(record.preview.remaining.length?'open':'completed'))fail('switch-continuation-recovery.journal');
87
+ await assertLockHeld(lock);
88
+ return {recoveryPath:relative,recoveryHash:evidence.fileHash,journalPath:record.journal,
89
+ applySupported:false,runtime:'not-run'};
90
+ }catch(error){throw error instanceof ContractError?error:new ContractError('switch-continuation-recovery.io');}
91
+ }
@@ -0,0 +1,135 @@
1
+ import {readSwitchContinuationRecovery} from './switch-continuation-recovery.js';
2
+ import {openSwitchContinuationJournal} from './switch-continuation-store.js';
3
+ import {assertLockHeld} from './lock.js';
4
+ import {writeCheckedFile,deleteCheckedFile} from './apply.js';
5
+ import {readState,readRecord,observeTargets} from './state.js';
6
+ import {resolveChild} from '../workspace/paths.js';
7
+ import {contractDigest,validateState} from '../contracts/semantic.js';
8
+ import {fail} from '../contracts/parse.js';
9
+ import {sha256} from '../source/inventory.js';
10
+
11
+ const hashes=observations=>observations.map(o=>({path:o.path,hash:o.bytes===null?null:sha256(o.bytes)}));
12
+ async function context(workspace,recoveryPath) {
13
+ const evidence=await readSwitchContinuationRecovery(workspace,recoveryPath);
14
+ const parent=evidence.root;
15
+ return {evidence,parent};
16
+ }
17
+ function pendingState(evidence,parent) {
18
+ return {...parent.record.previous,status:'needs-reconciliation',pending:evidence.record.digest,runtime:'not-run'};
19
+ }
20
+ function readyState(evidence,parent,recoveryPath) {
21
+ if(evidence.journal.status!=='completed' || evidence.journal.verifiedPhases!==evidence.record.preview.remaining.length)
22
+ fail('switch-continuation-runtime.incomplete');
23
+ const state={...parent.record.previous,status:'ready',pending:null,runtime:'not-run',
24
+ active:parent.record.prepared.preview.phases[1].preview.plan.desired,
25
+ activation:{recovery:recoveryPath,recoveryHash:evidence.fileHash,
26
+ journalHead:{sequence:evidence.journal.sequence,hash:evidence.journal.lastFileHash}}};
27
+ validateState(state);return state;
28
+ }
29
+ function projection(evidence) {
30
+ const p=evidence.record.preview,j=evidence.journal,map=new Map(p.observations.map(o=>[o.path,o.hash]));
31
+ const stopped=!j.pending && ['failed','uncertain'].includes(j.status);
32
+ let uncertain=null;
33
+ for(let i=0;i<p.remaining.length;i++) {
34
+ const phase=p.remaining[i],count=i<j.verifiedPhases?phase.operations.length:
35
+ i===j.verifiedPhases?j.nextIndex-(stopped?1:0):0;
36
+ for(let n=0;n<count;n++)map.set(phase.operations[n].path,phase.operations[n].desiredHash);
37
+ if(i===j.verifiedPhases && j.status==='uncertain')uncertain={...phase.operations[j.pending?j.nextIndex:j.nextIndex-1],phase:phase.phase};
38
+ }
39
+ return {map,uncertain};
40
+ }
41
+
42
+ export async function inspectSwitchContinuation(workspace,recoveryPath,{active=false}={}) {
43
+ const filename=resolveChild(workspace,'.pipeline/state.json'),before=await readState(filename);
44
+ const {evidence,parent}=await context(workspace,recoveryPath);
45
+ const expected=active?readyState(evidence,parent,recoveryPath):pendingState(evidence,parent);
46
+ if(contractDigest(before.value)!==contractDigest(expected))fail('switch-continuation-runtime.selection');
47
+ const {map,uncertain}=projection(evidence);
48
+ const observations=hashes(await observeTargets(workspace,[...map.keys()]));
49
+ const conflicts=observations.filter(o=>uncertain?.path===o.path?
50
+ o.hash!==uncertain.beforeHash && o.hash!==uncertain.desiredHash:o.hash!==map.get(o.path)).map(o=>o.path);
51
+ const after=await readSwitchContinuationRecovery(workspace,recoveryPath);
52
+ if(after.fileHash!==evidence.fileHash || contractDigest(after.journal)!==contractDigest(evidence.journal) ||
53
+ (await readState(filename)).digest!==before.digest ||
54
+ contractDigest(hashes(await observeTargets(workspace,[...map.keys()])))!==contractDigest(observations))
55
+ fail('switch-continuation-runtime.drift');
56
+ if(active && conflicts.length)fail('switch-continuation-runtime.target-drift');
57
+ return {status:active?'applied':'needs-reconciliation',recoveryHash:evidence.fileHash,recoveryPath,
58
+ stateFileHash:before.digest,observations,conflicts,uncertain,phase:evidence.journal.phase,
59
+ journalStatus:evidence.journal.status,journalCompleted:evidence.journal.status==='completed',runtime:'not-run'};
60
+ }
61
+
62
+ export async function executeSwitchContinuationPhase(lock,recoveryPath,recoveryHash,approval,phaseName,{boundary=async()=>{}}={}) {
63
+ approval=structuredClone(approval);await assertLockHeld(lock);
64
+ const {evidence,parent}=await context(lock.workspace,recoveryPath),record=evidence.record;
65
+ if(evidence.fileHash!==recoveryHash || contractDigest(record.approval)!==contractDigest(approval))fail('switch-continuation-runtime.approval');
66
+ const phase=record.preview.remaining.find(p=>p.phase===phaseName);
67
+ const original=parent.record.prepared.preview.phases.find(p=>p.name===phaseName);
68
+ if(!phase || !original)fail('switch-continuation-runtime.phase');
69
+ const initial=await inspectSwitchContinuation(lock.workspace,recoveryPath);
70
+ if(initial.conflicts.length || initial.uncertain || initial.journalStatus!=='open' || initial.phase!==phaseName)
71
+ fail('switch-continuation-runtime.state');
72
+ const expected=new Map(initial.observations.map(o=>[o.path,o.hash]));
73
+ async function guard() {
74
+ await assertLockHeld(lock);
75
+ if((await readState(resolveChild(lock.workspace,'.pipeline/state.json'))).digest!==initial.stateFileHash ||
76
+ (await readRecord(resolveChild(lock.workspace,recoveryPath))).digest!==recoveryHash ||
77
+ (await readRecord(resolveChild(lock.workspace,record.preview.recoveryPath))).digest!==record.preview.recoveryHash)
78
+ fail('switch-continuation-runtime.state');
79
+ for(const ancestor of evidence.ancestors) {
80
+ if((await readRecord(resolveChild(lock.workspace,ancestor.relative))).digest!==ancestor.fileHash)
81
+ fail('switch-continuation-runtime.state');
82
+ }
83
+ if(hashes(await observeTargets(lock.workspace,[...expected.keys()])).some(o=>o.hash!==expected.get(o.path)))
84
+ fail('switch-continuation-runtime.target-drift');
85
+ }
86
+ const journal=await openSwitchContinuationJournal(lock,record.preview,record.journal,evidence.journal.lastFileHash);
87
+ for(const op of phase.operations.slice(evidence.journal.nextIndex)) {
88
+ await guard();
89
+ if(op.resolution==='verify-desired') {
90
+ if(expected.get(op.path)!==op.desiredHash)fail('switch-continuation-runtime.target-drift');
91
+ await boundary('readback',{phase:phaseName,path:op.path});await guard();
92
+ await journal.readback(phaseName,op.operationId,'completed',op.desiredHash);
93
+ }else {
94
+ const target=original.preview.plan.targets.find(t=>t.id===op.operationId);
95
+ if(!target || !['delete','create','replace','edit-fields'].includes(target.action))fail('switch-continuation-runtime.action');
96
+ await journal.intent(phaseName,op.operationId);await boundary('intent',{phase:phaseName,path:op.path});await guard();
97
+ if(expected.get(op.path)!==op.beforeHash)fail('switch-continuation-runtime.target-drift');
98
+ if(target.action==='delete')await deleteCheckedFile(lock,op.path,op.beforeHash,async()=>{});
99
+ else {
100
+ const output=original.preview.outputs.find(o=>o.path===op.path);
101
+ if(!output)fail('switch-continuation-runtime.output');
102
+ await writeCheckedFile(lock,op.path,op.beforeHash,Buffer.from(output.bytes,'base64'));
103
+ }
104
+ await boundary('target-written',{phase:phaseName,path:op.path});expected.set(op.path,op.desiredHash);await guard();
105
+ await journal.outcome(phaseName,op.operationId,'completed',op.desiredHash);
106
+ }
107
+ }
108
+ await guard();
109
+ const fresh=await readSwitchContinuationRecovery(lock.workspace,recoveryPath);
110
+ await journal.phaseChecked(phaseName,fresh.journal.expectedProjectionDigest);
111
+ const result=await inspectSwitchContinuation(lock.workspace,recoveryPath);await assertLockHeld(lock);
112
+ if(result.conflicts.length)fail('switch-continuation-runtime.target-drift');
113
+ return result;
114
+ }
115
+
116
+ export async function activateSwitchContinuation(lock,recoveryPath,recoveryHash,approval,{boundary=async()=>{}}={}) {
117
+ approval=structuredClone(approval);
118
+ async function check() {
119
+ await assertLockHeld(lock);
120
+ const {evidence,parent}=await context(lock.workspace,recoveryPath);
121
+ if(evidence.fileHash!==recoveryHash || contractDigest(evidence.record.approval)!==contractDigest(approval))fail('switch-continuation-runtime.approval');
122
+ const ready=readyState(evidence,parent,recoveryPath),inspection=await inspectSwitchContinuation(lock.workspace,recoveryPath);
123
+ if(inspection.conflicts.length || inspection.uncertain)fail('switch-continuation-runtime.target-drift');
124
+ return {ready,stateHash:inspection.stateFileHash};
125
+ }
126
+ const checked=await check(),bytes=Buffer.from(JSON.stringify(checked.ready)+'\n');
127
+ await writeCheckedFile(lock,'.pipeline/state.json',checked.stateHash,bytes,undefined,async(phase,detail)=>{
128
+ await boundary(phase,detail);
129
+ if(phase==='before-rename') {
130
+ const fresh=await check();
131
+ if(fresh.stateHash!==checked.stateHash || contractDigest(fresh.ready)!==contractDigest(checked.ready))fail('switch-continuation-runtime.drift');
132
+ }
133
+ });
134
+ const result=await inspectSwitchContinuation(lock.workspace,recoveryPath,{active:true});await assertLockHeld(lock);return result;
135
+ }