@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,120 @@
1
+ import {mkdir,open,readdir} from 'node:fs/promises';
2
+ import {randomUUID} from 'node:crypto';
3
+ import {createSwitchContinuationCursor} from './switch-continuation-journal.js';
4
+ import {verifySwitchContinuationApproval} from './switch-continuation.js';
5
+ import {assertLockHeld} from './lock.js';
6
+ import {resolveChild,inspectDirectory} from '../workspace/paths.js';
7
+ import {readRecord} from './state.js';
8
+ import {sha256} from '../source/inventory.js';
9
+ import {fail,ContractError} from '../contracts/parse.js';
10
+
11
+ const nameFor=n=>String(n).padStart(6,'0')+'.json';
12
+ const ioError=e=>e instanceof ContractError?e:new ContractError('switch-continuation-store.io');
13
+ function directoryFor(workspace,relative,preview) {
14
+ if(preview.workspace!==workspace || typeof relative!=='string' ||
15
+ !/^\.pipeline\/journals\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?![\s\S])/.test(relative) ||
16
+ relative===preview.predecessor.journal)fail('switch-continuation-store.path');
17
+ return resolveChild(workspace,relative);
18
+ }
19
+
20
+ // Audit events only; caller supplies the verified preview. No history resolution
21
+ // or proof of provider writes is inferred from a valid event stream.
22
+ export async function readSwitchContinuationJournal(workspace,relative,preview) {
23
+ const cursor=createSwitchContinuationCursor(preview),directory=directoryFor(workspace,relative,preview);
24
+ try {
25
+ await inspectDirectory(directory);
26
+ const names=(await readdir(directory)).sort();
27
+ if(!names.length || names.length>cursor.maxEvents)fail('switch-continuation-store.count');
28
+ let lastFileHash=null;
29
+ for(let seq=0;seq<names.length;seq++) {
30
+ if(names[seq]!==nameFor(seq))fail('switch-continuation-store.sequence');
31
+ const record=await readRecord(resolveChild(workspace,relative+'/'+names[seq]));
32
+ cursor.append(record.value);lastFileHash=record.digest;
33
+ }
34
+ if(JSON.stringify((await readdir(directory)).sort())!==JSON.stringify(names))fail('switch-continuation-store.drift');
35
+ return {...cursor.inspect(),lastFileHash};
36
+ }catch(error){throw ioError(error);}
37
+ }
38
+
39
+ // Internal evidence writer only. No target writes or pending-state transition.
40
+ // New chain only: no reopen, retry or automatic deletion of partial evidence.
41
+ export async function createSwitchContinuationJournal(lock,preview,approval,{ioBoundary=async()=>{},onLocation=async()=>{}}={}) {
42
+ return continuationWriter(lock,preview,approval,{ioBoundary,onLocation});
43
+ }
44
+
45
+ // Reopen only a fully audited open chain with an exact byte head. Selection and
46
+ // target checks belong to the executor; uncertain/failed chains never reopen.
47
+ export async function openSwitchContinuationJournal(lock,preview,relative,expectedFileHash) {
48
+ return continuationWriter(lock,preview,null,{}, {relative,expectedFileHash});
49
+ }
50
+
51
+ async function continuationWriter(lock,preview,approval,{ioBoundary=async()=>{},onLocation=async()=>{}}={},existing=null) {
52
+ if(existing) {preview=structuredClone(preview);await assertLockHeld(lock);}
53
+ else {
54
+ const checked=await verifySwitchContinuationApproval(lock,preview,approval);
55
+ preview=checked.preview;approval=checked.approval;
56
+ }
57
+ const cursor=createSwitchContinuationCursor(preview),relative=existing?.relative??'.pipeline/journals/'+randomUUID();
58
+ const directory=directoryFor(lock.workspace,relative,preview);
59
+ let state=cursor.inspect(),lastFileHash=null,busy=false,broken=false;
60
+ const metrics={headReads:0,recordReadbacks:0,fullAudits:0};
61
+ async function audit() {
62
+ metrics.fullAudits++;
63
+ const result=await readSwitchContinuationJournal(lock.workspace,relative,preview);
64
+ if(result.sequence!==state.sequence || result.head!==state.head || result.lastFileHash!==lastFileHash)
65
+ fail('switch-continuation-store.drift');
66
+ }
67
+ async function append(kind,payload) {
68
+ if(busy || broken)fail('switch-continuation-store.unavailable');busy=true;
69
+ try {
70
+ await assertLockHeld(lock);await inspectDirectory(directory);
71
+ if(state.sequence) {
72
+ const record=await readRecord(resolveChild(lock.workspace,relative+'/'+nameFor(state.sequence-1)));metrics.headReads++;
73
+ if(record.digest!==lastFileHash)fail('switch-continuation-store.drift');
74
+ }
75
+ const event={schemaVersion:1,seq:state.sequence,previous:state.head,previewDigest:preview.digest,kind,payload};
76
+ const next=cursor.append(event),bytes=Buffer.from(JSON.stringify(event)+'\n');
77
+ const detail={path:relative+'/'+nameFor(state.sequence)};
78
+ const handle=await open(resolveChild(lock.workspace,detail.path),'wx',0o600);
79
+ try {
80
+ await ioBoundary('opened',detail);await handle.writeFile(bytes);await ioBoundary('written',detail);
81
+ await handle.sync();await ioBoundary('synced',detail);
82
+ }finally{await handle.close();}
83
+ const record=await readRecord(resolveChild(lock.workspace,detail.path));metrics.recordReadbacks++;
84
+ if(record.digest!==sha256(bytes))fail('switch-continuation-store.readback');
85
+ await ioBoundary('readback',detail);
86
+ await assertLockHeld(lock);
87
+ state=next;lastFileHash=record.digest;
88
+ if(kind==='phase-checked' || ['failed','uncertain'].includes(payload?.status))await audit();
89
+ return {...state,pending:state.pending?{...state.pending}:null,lastFileHash};
90
+ }catch(error){broken=true;throw ioError(error);}finally{busy=false;}
91
+ }
92
+ try {
93
+ if(existing) {
94
+ const inspected=await readSwitchContinuationJournal(lock.workspace,relative,preview);
95
+ if(inspected.status!=='open' || inspected.lastFileHash!==existing.expectedFileHash)fail('switch-continuation-store.reopen');
96
+ for(let seq=0;seq<inspected.sequence;seq++) {
97
+ const entry=await readRecord(resolveChild(lock.workspace,relative+'/'+nameFor(seq)));
98
+ state=cursor.append(entry.value);lastFileHash=entry.digest;
99
+ }
100
+ if(state.head!==inspected.head || state.sequence!==inspected.sequence)fail('switch-continuation-store.drift');
101
+ await audit();await assertLockHeld(lock);
102
+ }else {
103
+ await onLocation({relative,directory,status:'planned'});
104
+ await verifySwitchContinuationApproval(lock,preview,approval);
105
+ const parent=resolveChild(lock.workspace,'.pipeline/journals');
106
+ try{await mkdir(parent);}catch(error){if(error.code!=='EEXIST')throw error;}
107
+ await inspectDirectory(parent);await mkdir(directory);
108
+ await onLocation({relative,directory,status:'created'});
109
+ await verifySwitchContinuationApproval(lock,preview,approval);
110
+ await append('start',preview.predecessor);await audit();
111
+ await onLocation({relative,directory,status:'initialized'});
112
+ await verifySwitchContinuationApproval(lock,preview,approval);
113
+ }
114
+ return Object.freeze({relative,directory,metrics:()=>({...metrics}),
115
+ intent:(phase,operationId)=>append('intent',{phase,operationId}),
116
+ outcome:(phase,operationId,status,observedHash)=>append('outcome',{phase,operationId,status,observedHash}),
117
+ readback:(phase,operationId,status,observedHash)=>append('readback',{phase,operationId,status,observedHash}),
118
+ phaseChecked:(phase,projectionDigest)=>append('phase-checked',{phase,projectionDigest})});
119
+ }catch(error){broken=true;throw ioError(error);}
120
+ }
@@ -0,0 +1,76 @@
1
+ import {inspectPendingSwitch} from './switch-inspect.js';
2
+ import {readSwitchLineage} from './switch-continuation-recovery.js';
3
+ import {inspectSwitchContinuation} from './switch-continuation-runtime.js';
4
+ import {assertSwitchContinuationPredecessor,boundaryRemaining} from './switch-continuation-records.js';
5
+ import {readState} from './state.js';
6
+ import {absoluteRoot,resolveChild} from '../workspace/paths.js';
7
+ import {contractDigest} from '../contracts/semantic.js';
8
+ import {parse,fail} from '../contracts/parse.js';
9
+ import {requestShape} from './ownership.js';
10
+ import {assertLockHeld} from './lock.js';
11
+ import {assertContinuationCapacity} from './lineage-guard.js';
12
+
13
+ // Exact approval is checked against a freshly reconstructed preview under lock.
14
+ // This remains read-only and does not authorize an old journal to be amended.
15
+ export async function verifySwitchContinuationApproval(lock,preview,approval) {
16
+ const copy=parse(JSON.stringify(preview),'json'),decision=parse(JSON.stringify(approval),'json');
17
+ requestShape(copy,['kind','workspace','recoveryPath','recoveryHash','predecessor','stateFileHash','observations','uncertain','remaining',
18
+ 'requiresFreshApproval','applySupported','runtime','digest'],[],'switch-continuation.preview');
19
+ requestShape(decision,['decision','previewDigest'],[],'switch-continuation.approval');
20
+ const {digest,...body}=copy;
21
+ if(copy.kind!=='switch-continuation-preview' || copy.requiresFreshApproval!==true || copy.applySupported!==false ||
22
+ copy.runtime!=='not-run' || contractDigest(body)!==digest)fail('switch-continuation.preview');
23
+ if(decision.decision!=='approve' || decision.previewDigest!==digest)fail('switch-continuation.approval');
24
+ await assertLockHeld(lock);
25
+ if(copy.workspace!==lock.workspace)fail('switch-continuation.workspace');
26
+ const fresh=await prepareSwitchContinuation(lock.workspace,copy.recoveryPath);
27
+ if(contractDigest(fresh)!==contractDigest(copy))fail('switch-continuation.plan-drift');
28
+ await assertLockHeld(lock);
29
+ return {preview:fresh,approval:decision,applySupported:false,runtime:'not-run'};
30
+ }
31
+
32
+ // Read-only proposal for a NEW continuation, never an amendment to the old
33
+ // uncertain journal. A future executor needs separate approval and evidence.
34
+ export async function prepareSwitchContinuation(workspace,recoveryPath) {
35
+ workspace=absoluteRoot(workspace);
36
+ const evidence=await readSwitchLineage(workspace,recoveryPath),record=evidence.record;
37
+ assertContinuationCapacity(evidence.ancestors.length);
38
+ const continuation=record.kind==='switch-continuation-recovery';
39
+ const inspect=async()=>{
40
+ if(!continuation)return inspectPendingSwitch(workspace,recoveryPath);
41
+ const result=await inspectSwitchContinuation(workspace,recoveryPath);
42
+ return {...result,targets:result.observations};
43
+ };
44
+ const inspection=await inspect();
45
+ if(inspection.conflicts.length || !(inspection.uncertain?['uncertain']:['open','completed']).includes(inspection.journalStatus))fail('switch-continuation.state');
46
+ if(evidence.fileHash!==inspection.recoveryHash)fail('switch-continuation.drift');
47
+ const state=await readState(resolveChild(workspace,'.pipeline/state.json'));
48
+ const expected={...evidence.root.record.previous,status:'needs-reconciliation',pending:record.digest,runtime:'not-run'};
49
+ if(contractDigest(state.value)!==contractDigest(expected))fail('switch-continuation.binding');
50
+ const {phase,operationId,path,beforeHash,desiredHash}=inspection.uncertain??{};
51
+ const uncertain={phase,operationId,path,beforeHash,desiredHash},observed=inspection.targets.find(t=>t.path===uncertain.path);
52
+ // Equal before/desired bytes need readback only, not an unnecessary write.
53
+ const resolution=observed?.hash===uncertain.desiredHash?'verify-desired':'retry-approved-target';
54
+ const phases=continuation?record.preview.remaining.map(p=>({name:p.phase,preview:{plan:{targets:
55
+ p.operations.map(t=>({...t,id:t.operationId}))}}})):record.prepared.preview.phases;
56
+ const phaseIndex=phases.findIndex(p=>p.name===uncertain.phase);
57
+ if(inspection.uncertain && phaseIndex<0)fail('switch-continuation.target');
58
+ const targetIndex=inspection.uncertain?phases[phaseIndex].preview.plan.targets.findIndex(t=>t.id===uncertain.operationId):0;
59
+ if(inspection.uncertain && targetIndex<0)fail('switch-continuation.target');
60
+ const remaining=!inspection.uncertain?boundaryRemaining(phases,evidence.journal):phases.slice(phaseIndex).map((phase,index)=>({phase:phase.name,
61
+ operations:phase.preview.plan.targets.slice(index===0?targetIndex:0).map((t,i)=>({operationId:t.id,path:t.path,
62
+ resolution:index===0 && i===0?resolution:'apply-approved-target',beforeHash:t.beforeHash,desiredHash:t.desiredHash})),
63
+ phaseCheckRequired:true}));
64
+ const body={kind:'switch-continuation-preview',workspace,recoveryPath,recoveryHash:evidence.fileHash,
65
+ predecessor:{pending:record.digest,journal:record.journal,sequence:evidence.journal.sequence,
66
+ head:evidence.journal.head,lastFileHash:evidence.journal.lastFileHash},
67
+ stateFileHash:state.digest,observations:inspection.targets.map(t=>({path:t.path,hash:t.hash})),
68
+ uncertain:inspection.uncertain?{...uncertain,observedHash:observed.hash,resolution}:null,remaining,
69
+ requiresFreshApproval:true,applySupported:false,runtime:'not-run'};
70
+ assertSwitchContinuationPredecessor(body,evidence);
71
+ const repeated=await inspect(),after=await readSwitchLineage(workspace,recoveryPath);
72
+ if(contractDigest(repeated)!==contractDigest(inspection) || after.fileHash!==evidence.fileHash ||
73
+ contractDigest(after.journal)!==contractDigest(evidence.journal) ||
74
+ (await readState(resolveChild(workspace,'.pipeline/state.json'))).digest!==state.digest)fail('switch-continuation.drift');
75
+ const copy=parse(JSON.stringify(body),'json');return {...copy,digest:contractDigest(copy)};
76
+ }
@@ -0,0 +1,68 @@
1
+ import {readSwitchRecovery} from './switch-recovery-store.js';
2
+ import {validateSwitchRecord} from './switch-records.js';
3
+ import {inspectPendingSwitch} from './switch-inspect.js';
4
+ import {openSwitchJournal} from './switch-journal-store.js';
5
+ import {assertLockHeld} from './lock.js';
6
+ import {writeCheckedFile,deleteCheckedFile} from './apply.js';
7
+ import {observeTargets,readState,readRecord} from './state.js';
8
+ import {resolveChild} from '../workspace/paths.js';
9
+ import {sha256} from '../source/inventory.js';
10
+ import {fail} from '../contracts/parse.js';
11
+ import {contractDigest} from '../contracts/semantic.js';
12
+
13
+ // Execute exactly the explicitly selected current phase. No activation, automatic
14
+ // phase chaining or uncertain-operation continuation. Public routing is absent.
15
+ export async function executeSwitchPhase(lock,recoveryPath,recoveryHash,approval,phaseName,{boundary=async()=>{}}={}) {
16
+ approval=structuredClone(approval);
17
+ await assertLockHeld(lock);
18
+ const evidence=await readSwitchRecovery(lock.workspace,recoveryPath),record=evidence.record;
19
+ if(evidence.fileHash!==recoveryHash)fail('switch-execute.recovery');
20
+ validateSwitchRecord(record.prepared,approval,record.previous);
21
+ const phase=record.prepared.preview.phases.find(p=>p.name===phaseName);
22
+ if(!phase)fail('switch-execute.phase');
23
+ if(phase.preview.plan.targets.some(t=>!['delete','create','replace','edit-fields'].includes(t.action)))fail('switch-execute.action');
24
+ async function inspect(allowIntent=false) {
25
+ await assertLockHeld(lock);
26
+ const current=await inspectPendingSwitch(lock.workspace,recoveryPath);
27
+ if(current.recoveryHash!==recoveryHash || current.conflicts.length || current.phase!==phaseName ||
28
+ (!allowIntent && current.journalStatus!=='open'))fail('switch-execute.state');
29
+ return current;
30
+ }
31
+ const initial=await inspect();
32
+ const currentState=await readState(resolveChild(lock.workspace,'.pipeline/state.json'));
33
+ if(contractDigest(currentState.value)!==contractDigest({...record.previous,status:'needs-reconciliation',pending:record.digest,runtime:'not-run'}))fail('switch-execute.state');
34
+ const stateHash=currentState.digest;
35
+ const expected=new Map(initial.targets.map(t=>[t.path,t.hash]));
36
+ async function guard() {
37
+ await assertLockHeld(lock);
38
+ if((await readState(resolveChild(lock.workspace,'.pipeline/state.json'))).digest!==stateHash ||
39
+ (await readRecord(resolveChild(lock.workspace,recoveryPath))).digest!==recoveryHash)fail('switch-execute.state');
40
+ const observations=await observeTargets(lock.workspace,[...expected.keys()]);
41
+ if(observations.some(o=>(o.bytes===null?null:sha256(o.bytes))!==expected.get(o.path)))fail('switch-execute.before');
42
+ }
43
+ const journal=await openSwitchJournal(lock,record.prepared.preview,record.previous,record.journal,evidence.journal.lastFileHash);
44
+ const outputs=new Map(phase.preview.outputs.map(o=>[o.path,Buffer.from(o.bytes,'base64')]));
45
+ for(const target of phase.preview.plan.targets.slice(evidence.journal.nextIndex)) {
46
+ await guard();
47
+ await journal.intent(phaseName,target.id);
48
+ await boundary('intent',{phase:phaseName,path:target.path});
49
+ await guard();
50
+ if(expected.get(target.path)!==target.beforeHash)fail('switch-execute.before');
51
+ if(target.action==='delete')await deleteCheckedFile(lock,target.path,target.beforeHash,async()=>{});
52
+ else if(['create','replace','edit-fields'].includes(target.action))await writeCheckedFile(lock,target.path,target.beforeHash,outputs.get(target.path));
53
+ else fail('switch-execute.action');
54
+ await boundary('target-written',{phase:phaseName,path:target.path});
55
+ expected.set(target.path,target.desiredHash);
56
+ await guard();
57
+ const [observed]=await observeTargets(lock.workspace,[target.path]);
58
+ if((observed.bytes===null?null:sha256(observed.bytes))!==target.desiredHash)fail('switch-execute.readback');
59
+ await journal.outcome(phaseName,target.id,'completed',target.desiredHash);
60
+ }
61
+ await inspect();
62
+ const fresh=await readSwitchRecovery(lock.workspace,recoveryPath);
63
+ await journal.phaseChecked(phaseName,fresh.journal.expectedProjectionDigest);
64
+ const result=await inspectPendingSwitch(lock.workspace,recoveryPath);
65
+ if(result.conflicts.length)fail('switch-execute.final-drift');
66
+ return {status:'needs-reconciliation',completedPhase:phaseName,nextPhase:result.phase,
67
+ journalCompleted:result.journalCompleted,recoveryPath,activationSupported:false,runtime:'not-run'};
68
+ }
@@ -0,0 +1,45 @@
1
+ import {readSwitchRecovery} from './switch-recovery-store.js';
2
+ import {readState,observeTargets} from './state.js';
3
+ import {absoluteRoot,resolveChild} from '../workspace/paths.js';
4
+ import {contractDigest} from '../contracts/semantic.js';
5
+ import {sha256} from '../source/inventory.js';
6
+ import {fail} from '../contracts/parse.js';
7
+
8
+ // Read-only pending inspection; observed desired bytes cannot turn an unfinished
9
+ // intent into a successful outcome. No continuation, repair or activation grant.
10
+ export async function inspectPendingSwitch(workspace,recoveryPath) {
11
+ workspace=absoluteRoot(workspace);
12
+ const before=await readState(resolveChild(workspace,'.pipeline/state.json'));
13
+ const evidence=await readSwitchRecovery(workspace,recoveryPath),{record,journal}=evidence;
14
+ const pending={...record.previous,status:'needs-reconciliation',pending:record.digest,runtime:'not-run'};
15
+ if(contractDigest(before.value)!==contractDigest(pending))fail('switch-inspect.activation-binding');
16
+ const preview=record.prepared.preview,expected=new Map(preview.observations.map(o=>[o.path,o.hash]));
17
+ const stoppedOutcome=journal.pending===null && ['failed','uncertain'].includes(journal.status);
18
+ for(let phase=0;phase<preview.phases.length;phase++) {
19
+ const targets=preview.phases[phase].preview.plan.targets;
20
+ const count=phase<journal.verifiedPhases?targets.length:phase===journal.verifiedPhases?journal.nextIndex-(stoppedOutcome?1:0):0;
21
+ for(let i=0;i<count;i++)expected.set(targets[i].path,targets[i].desiredHash);
22
+ }
23
+ let uncertain=null;
24
+ if(journal.status==='uncertain') {
25
+ const phase=preview.phases[journal.verifiedPhases];
26
+ const target=phase.preview.plan.targets[journal.pending?journal.nextIndex:journal.nextIndex-1];
27
+ uncertain={phase:phase.name,operationId:target.id,path:target.path,beforeHash:target.beforeHash,desiredHash:target.desiredHash};
28
+ }
29
+ const observed=await observeTargets(workspace,[...expected.keys()]);
30
+ const hashes=items=>items.map(o=>({path:o.path,hash:o.bytes===null?null:sha256(o.bytes)}));
31
+ const targets=hashes(observed).map(o=>({...o,expectedHash:expected.get(o.path),
32
+ disposition:uncertain?.path===o.path?
33
+ o.hash===uncertain.beforeHash?'uncertain-before':o.hash===uncertain.desiredHash?'uncertain-desired':'conflict':
34
+ o.hash===expected.get(o.path)?'intact':'conflict'}));
35
+ // Repeat dependency and current-file checks; this is still not an atomic OS
36
+ // snapshot and is not a reusable capability for subsequent writes.
37
+ const after=await readSwitchRecovery(workspace,recoveryPath);
38
+ if(after.fileHash!==evidence.fileHash || contractDigest(after.journal)!==contractDigest(journal) ||
39
+ (await readState(resolveChild(workspace,'.pipeline/state.json'))).digest!==before.digest ||
40
+ contractDigest(hashes(await observeTargets(workspace,[...expected.keys()])))!==contractDigest(hashes(observed)))fail('switch-inspect.drift');
41
+ return {status:'needs-reconciliation',recoveryPath,recoveryHash:evidence.fileHash,pending:record.digest,
42
+ phase:journal.phase,journalStatus:journal.status,journalCompleted:journal.status==='completed',
43
+ targets,conflicts:targets.filter(t=>t.disposition==='conflict').map(t=>t.path),uncertain,
44
+ executionAllowed:false,runtime:'not-run'};
45
+ }
@@ -0,0 +1,109 @@
1
+ import {mkdir,open,readdir} from 'node:fs/promises';
2
+ import {randomUUID} from 'node:crypto';
3
+ import {createSwitchEventCursor} from './switch-journal.js';
4
+ import {assertLockHeld} from './lock.js';
5
+ import {resolveChild,inspectDirectory} from '../workspace/paths.js';
6
+ import {readRecord} from './state.js';
7
+ import {sha256} from '../source/inventory.js';
8
+ import {fail,ContractError} from '../contracts/parse.js';
9
+
10
+ const nameFor=n=>String(n).padStart(6,'0')+'.json';
11
+ const ioError=e=>e instanceof ContractError?e:new ContractError('switch-journal.io');
12
+ function directoryFor(workspace,relative,preview) {
13
+ if(preview.workspace!==workspace || !/^\.pipeline\/journals\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?![\s\S])/.test(relative))fail('switch-journal.path');
14
+ return resolveChild(workspace,relative);
15
+ }
16
+
17
+ // Full read-only audit of persisted events; never resumes execution or activates.
18
+ export async function readSwitchJournal(workspace,relative,preview,previous) {
19
+ const cursor=createSwitchEventCursor(preview,previous),directory=directoryFor(workspace,relative,preview);
20
+ try {
21
+ await inspectDirectory(directory);
22
+ const names=(await readdir(directory)).sort();
23
+ if(!names.length || names.length>cursor.maxEvents)fail('switch-journal.count');
24
+ let lastFileHash=null;
25
+ for(let seq=0;seq<names.length;seq++) {
26
+ if(names[seq]!==nameFor(seq))fail('switch-journal.sequence');
27
+ const record=await readRecord(resolveChild(workspace,relative+'/'+names[seq]));
28
+ cursor.append(record.value);lastFileHash=record.digest;
29
+ }
30
+ if(JSON.stringify((await readdir(directory)).sort())!==JSON.stringify(names))fail('switch-journal.drift');
31
+ return {...cursor.inspect(),lastFileHash};
32
+ }catch(error){throw ioError(error);}
33
+ }
34
+
35
+ // Internal persistence only. This writes journal evidence, not provider targets.
36
+ // No auto-resume/approval or integration with normal recovery is implied.
37
+ export async function createSwitchJournal(lock,preview,previous,{ioBoundary=async()=>{},onLocation=async()=>{}}={}) {
38
+ return journalWriter(lock,preview,previous,{ioBoundary,onLocation});
39
+ }
40
+
41
+ // Explicit reopening of an intact open chain only, never uncertain continuation.
42
+ export async function openSwitchJournal(lock,preview,previous,relative,expectedFileHash) {
43
+ return journalWriter(lock,preview,previous,{}, {relative,expectedFileHash});
44
+ }
45
+
46
+ async function journalWriter(lock,preview,previous,{ioBoundary=async()=>{},onLocation=async()=>{}}={},existing=null) {
47
+ const cursor=createSwitchEventCursor(preview,previous);
48
+ await assertLockHeld(lock);
49
+ const relative=existing?.relative??'.pipeline/journals/'+randomUUID(),directory=directoryFor(lock.workspace,relative,preview);
50
+ preview=structuredClone(preview);previous=structuredClone(previous);
51
+ let state=cursor.inspect(),lastFileHash=null,busy=false,broken=false;
52
+ const metrics={headReads:0,recordReadbacks:0,fullAudits:0};
53
+ async function audit() {
54
+ metrics.fullAudits++;
55
+ const result=await readSwitchJournal(lock.workspace,relative,preview,previous);
56
+ if(result.sequence!==state.sequence || result.head!==state.head || result.lastFileHash!==lastFileHash)fail('switch-journal.drift');
57
+ return result;
58
+ }
59
+ async function append(kind,payload) {
60
+ if(busy || broken)fail('switch-journal.unavailable');busy=true;
61
+ try {
62
+ await assertLockHeld(lock);await inspectDirectory(directory);
63
+ if(state.sequence) {
64
+ const head=await readRecord(resolveChild(lock.workspace,relative+'/'+nameFor(state.sequence-1)));metrics.headReads++;
65
+ if(head.digest!==lastFileHash)fail('switch-journal.drift');
66
+ }
67
+ const event={schemaVersion:1,seq:state.sequence,previous:state.head,previewDigest:preview.digest,kind,payload};
68
+ // Validate before disk writes. A later IO failure poisons this writer.
69
+ const next=cursor.append(event),bytes=Buffer.from(JSON.stringify(event)+'\n');
70
+ const detail={path:relative+'/'+nameFor(state.sequence)};
71
+ const handle=await open(resolveChild(lock.workspace,detail.path),'wx',0o600);
72
+ try {
73
+ await ioBoundary('opened',detail);await handle.writeFile(bytes);await ioBoundary('written',detail);
74
+ await handle.sync();await ioBoundary('synced',detail);
75
+ }finally{await handle.close();}
76
+ const record=await readRecord(resolveChild(lock.workspace,detail.path));metrics.recordReadbacks++;
77
+ if(record.digest!==sha256(bytes))fail('switch-journal.readback');
78
+ await ioBoundary('readback',detail);
79
+ state=next;lastFileHash=record.digest;
80
+ // Full audit at both phase boundaries (before allowing install) and stop.
81
+ if(kind==='phase-checked' || ['failed','uncertain'].includes(payload?.status))await audit();
82
+ return {...state,pending:state.pending?{...state.pending}:null,lastFileHash};
83
+ }catch(error){broken=true;throw ioError(error);}finally{busy=false;}
84
+ }
85
+ try {
86
+ if(existing) {
87
+ const inspected=await readSwitchJournal(lock.workspace,relative,preview,previous);
88
+ if(inspected.status!=='open' || inspected.lastFileHash!==existing.expectedFileHash)fail('switch-journal.reopen');
89
+ for(let seq=0;seq<inspected.sequence;seq++) {
90
+ const entry=await readRecord(resolveChild(lock.workspace,relative+'/'+nameFor(seq)));
91
+ state=cursor.append(entry.value);lastFileHash=entry.digest;
92
+ }
93
+ if(state.head!==inspected.head || state.sequence!==inspected.sequence)fail('switch-journal.drift');
94
+ await audit();
95
+ }else {
96
+ await onLocation({relative,directory,status:'planned'});
97
+ const parent=resolveChild(lock.workspace,'.pipeline/journals');
98
+ try{await mkdir(parent);}catch(error){if(error.code!=='EEXIST')throw error;}
99
+ await inspectDirectory(parent);await mkdir(directory);
100
+ await onLocation({relative,directory,status:'created'});
101
+ await append('start',null);await audit();
102
+ await onLocation({relative,directory,status:'initialized'});
103
+ }
104
+ return Object.freeze({relative,directory,metrics:()=>({...metrics}),
105
+ intent:(phase,operationId)=>append('intent',{phase,operationId}),
106
+ outcome:(phase,operationId,status,observedHash)=>append('outcome',{phase,operationId,status,observedHash}),
107
+ phaseChecked:(phase,projectionDigest)=>append('phase-checked',{phase,projectionDigest})});
108
+ }catch(error){throw ioError(error);}
109
+ }
@@ -0,0 +1,71 @@
1
+ import {composeSwitchPreview} from './switch.js';
2
+ import {contractDigest} from '../contracts/semantic.js';
3
+ import {parse,fail} from '../contracts/parse.js';
4
+ import {requestShape} from './ownership.js';
5
+
6
+ // Pure event interpretation only. Hash assertions do not replace filesystem
7
+ // readback, trusted preparation, approval, locking, or durable journal storage.
8
+ export function inspectSwitchEvents(preview,previous,events) {
9
+ if(!Array.isArray(events))fail('switch-journal.count');
10
+ const cursor=createSwitchEventCursor(preview,previous);
11
+ for(const event of parse(JSON.stringify(events),'json'))cursor.append(event);
12
+ return cursor.inspect();
13
+ }
14
+
15
+ // Internal incremental cursor. A failed append poisons it; durable writers must
16
+ // never continue a cursor whose disk append/readback failed either.
17
+ export function createSwitchEventCursor(preview,previous) {
18
+ const copy=parse(JSON.stringify(preview),'json');
19
+ if(!Array.isArray(copy.phases) || copy.phases.length!==2)fail('switch-journal.preview');
20
+ const fresh=composeSwitchPreview({previous,removal:copy.phases[0].preview,replacement:copy.phases[1].preview});
21
+ if(contractDigest(copy)!==contractDigest(fresh))fail('switch-journal.preview');
22
+ const maxEvents=3+2*fresh.phases.reduce((n,p)=>n+p.preview.plan.targets.length,0);
23
+ const projected=new Map(fresh.observations.map(o=>[o.path,o.hash]));
24
+ const projectionDigest=()=>contractDigest([...projected].sort(([a],[b])=>a<b?-1:a>b?1:0).map(([path,hash])=>({path,hash})));
25
+ let head=null,phaseIndex=0,nextIndex=0,pending=null,stopped=null,started=false,sequence=0,broken=false;
26
+ function append(value) {
27
+ if(broken)fail('switch-journal.unavailable');
28
+ try {
29
+ if(sequence>=maxEvents)fail('switch-journal.count');
30
+ const event=parse(JSON.stringify(value),'json'),seq=sequence;
31
+ requestShape(event,['schemaVersion','seq','previous','previewDigest','kind','payload'],[],'switch-journal.event');
32
+ if(event.schemaVersion!==1 || event.seq!==seq || event.previous!==head || event.previewDigest!==fresh.digest)fail('switch-journal.binding');
33
+ if(seq===0) {
34
+ if(event.kind!=='start' || event.payload!==null)fail('switch-journal.sequence');
35
+ started=true;
36
+ }else {
37
+ if(stopped!==null || phaseIndex===2)fail('switch-journal.sequence');
38
+ const phase=fresh.phases[phaseIndex],targets=phase.preview.plan.targets,target=targets[nextIndex];
39
+ if(event.kind==='intent') {
40
+ requestShape(event.payload,['phase','operationId'],[],'switch-journal.payload');
41
+ if(pending!==null || !target || event.payload.phase!==phase.name || event.payload.operationId!==target.id)fail('switch-journal.sequence');
42
+ pending={phase:phase.name,operationId:target.id};
43
+ }else if(event.kind==='outcome') {
44
+ requestShape(event.payload,['phase','operationId','status','observedHash'],[],'switch-journal.payload');
45
+ const p=event.payload;
46
+ if(pending===null || p.phase!==pending.phase || p.operationId!==pending.operationId)fail('switch-journal.sequence');
47
+ if(!['completed','failed','uncertain'].includes(p.status) ||
48
+ (p.observedHash!==null && !/^sha256:[a-f0-9]{64}(?![\s\S])/.test(p.observedHash)) ||
49
+ (p.status==='completed' && p.observedHash!==target.desiredHash) ||
50
+ (p.status==='failed' && p.observedHash!==target.beforeHash))fail('switch-journal.outcome');
51
+ if(p.status==='completed')projected.set(target.path,target.desiredHash);
52
+ else stopped=p.status;
53
+ nextIndex++;pending=null;
54
+ }else if(event.kind==='phase-checked') {
55
+ requestShape(event.payload,['phase','projectionDigest'],[],'switch-journal.payload');
56
+ if(pending!==null || nextIndex!==targets.length || event.payload.phase!==phase.name ||
57
+ event.payload.projectionDigest!==projectionDigest())fail('switch-journal.phase-check');
58
+ phaseIndex++;nextIndex=0;
59
+ }else fail('switch-journal.sequence');
60
+ }
61
+ head=contractDigest(event);
62
+ sequence++;
63
+ return position();
64
+ }catch(error){broken=true;throw error;}
65
+ }
66
+ function position(){return {kind:'switch-event-inspection',previewDigest:fresh.digest,sequence,head,
67
+ status:!started?'not-started':stopped??(pending!==null?'uncertain':phaseIndex===2?'completed':'open'),
68
+ phase:phaseIndex===2?null:fresh.phases[phaseIndex].name,nextIndex,pending:pending?{...pending}:null,
69
+ verifiedPhases:phaseIndex,activationSupported:false,runtime:'not-run'};}
70
+ return Object.freeze({maxEvents,append,inspect:()=>({...position(),pending:pending?{...pending}:null,expectedProjectionDigest:projectionDigest()})});
71
+ }
@@ -0,0 +1,72 @@
1
+ import {prepareSwitch} from './switch-prepare.js';
2
+ import {persistSwitchRecovery} from './switch-recovery-store.js';
3
+ import {markSwitchPending} from './switch-pending.js';
4
+ import {executeSwitchPhase} from './switch-execute.js';
5
+ import {activateSwitch} from './switch-activate.js';
6
+ import {validateSwitchRecord} from './switch-records.js';
7
+ import {acquireWorkspaceLock} from './lock.js';
8
+ import {inspectHistory} from './history.js';
9
+ import {readState} from './state.js';
10
+ import {requestShape} from './ownership.js';
11
+ import {absoluteRoot,resolveChild} from '../workspace/paths.js';
12
+ import {ContractError,fail} from '../contracts/parse.js';
13
+
14
+ function guard(input,registry,apply=false) {
15
+ requestShape(input,apply?['command','wrapper','prepared','approval']:['command','wrapper','manifestPath'],apply?[]:['network'],'switch-lifecycle.input');
16
+ if(input.command!=='switch')fail('switch-lifecycle.command');
17
+ requestShape(registry,['adapters','sharedAdapter'],[],'provider.interface');
18
+ return absoluteRoot(input.wrapper);
19
+ }
20
+ async function historyGuard(workspace) {
21
+ const history=await inspectHistory(workspace);
22
+ if(!history.complete || history.diagnostics.length)fail('switch-lifecycle.history');
23
+ }
24
+ export async function prepareSwitchLifecycle(input,registry) {
25
+ const wrapper=guard(input,registry);await historyGuard(wrapper);
26
+ const {command,...options}=input;return prepareSwitch({...options,wrapper},registry);
27
+ }
28
+
29
+ // One approved transaction, two checked phases. No resume, rollback, source
30
+ // reacquisition, provider code loading or automatic history cleanup here.
31
+ export async function applySwitchLifecycle(input,registry,{report=async()=>{}}={}) {
32
+ const workspace=guard(input,registry,true);
33
+ if(typeof report!=='function')fail('switch-lifecycle.reporter');
34
+ const result={command:'switch',workspace,status:'failed',journal:null,recovery:null,
35
+ completedPhases:[],error:null,lockRelease:'not-acquired',runtime:'not-run'};
36
+ const emit=async event=>{try{await report(structuredClone(event));}catch{fail('switch-lifecycle.report');}};
37
+ let lock;
38
+ try {
39
+ if(input.prepared?.preview?.workspace!==workspace)fail('switch-lifecycle.prepared-binding');
40
+ const previous=(await readState(resolveChild(workspace,'.pipeline/state.json'))).value;
41
+ const {prepared,approval}=validateSwitchRecord(input.prepared,input.approval,previous);
42
+ lock=await acquireWorkspaceLock(workspace);result.lockRelease='pending';await historyGuard(workspace);
43
+ const saved=await persistSwitchRecovery(lock,prepared,approval,registry,previous,{
44
+ onJournal:async location=>{
45
+ const runId=location.relative.split('/').at(-1);
46
+ result.journal={runId,path:location.directory,status:location.status};
47
+ result.recovery={path:resolveChild(workspace,'.pipeline/transactions/'+runId+'/recovery.json'),status:'not-created'};
48
+ await emit({kind:'journal-location',...result.journal,recovery:result.recovery});
49
+ },
50
+ boundary:async stage=>{
51
+ if(stage==='recovery-written') {
52
+ result.recovery.status='created-unverified';await emit({kind:'recovery-location',...result.recovery});
53
+ }
54
+ }
55
+ });
56
+ result.recovery={path:resolveChild(workspace,saved.recoveryPath),status:'verified',hash:saved.recoveryHash};
57
+ await emit({kind:'recovery-location',...result.recovery});
58
+ await markSwitchPending(lock,saved.recoveryPath,saved.recoveryHash,approval,registry);
59
+ for(const phase of ['remove-old','install-new']) {
60
+ await emit({kind:'phase-start',phase});
61
+ await executeSwitchPhase(lock,saved.recoveryPath,saved.recoveryHash,approval,phase);
62
+ result.completedPhases.push(phase);await emit({kind:'phase-completed',phase});
63
+ }
64
+ await activateSwitch(lock,saved.recoveryPath,saved.recoveryHash,approval);result.status='ready';
65
+ }catch(error){result.error=error instanceof ContractError?error.code:'switch-lifecycle.io';}
66
+ finally {
67
+ if(lock)try{await lock.release();result.lockRelease='released';}
68
+ catch(error){result.lockRelease='failed';result.releaseError=error instanceof ContractError?error.code:'lock.release';}
69
+ }
70
+ try{await emit({kind:'operation-result',...result});}catch{result.outputError='switch-lifecycle.report';}
71
+ return result;
72
+ }
@@ -0,0 +1,43 @@
1
+ import {open,rename} from 'node:fs/promises';
2
+ import {randomUUID} from 'node:crypto';
3
+ import {readSwitchRecovery} from './switch-recovery-store.js';
4
+ import {verifySwitchApproval} from './switch-preflight.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} from '../contracts/semantic.js';
9
+ import {parse,fail,ContractError} from '../contracts/parse.js';
10
+ import {sha256} from '../source/inventory.js';
11
+
12
+ // Internal state transition only. The caller supplies genuine current approval.
13
+ // No provider writes, phase execution, automatic retry or active-state promotion.
14
+ export async function markSwitchPending(lock,recoveryPath,recoveryHash,approval,registry,{boundary=async()=>{}}={}) {
15
+ await assertLockHeld(lock);
16
+ const evidence=await readSwitchRecovery(lock.workspace,recoveryPath),record=evidence.record;
17
+ if(evidence.fileHash!==recoveryHash || evidence.journal.sequence!==1 || evidence.journal.status!=='open')fail('switch-pending.evidence');
18
+ await verifySwitchApproval(lock,record.prepared,approval,registry,record.previous);
19
+ // Bind this exact transaction, not just equal desired bytes or equal preview.
20
+ // The record digest includes its UUID journal identity.
21
+ const pending={...record.previous,status:'needs-reconciliation',pending:record.digest,runtime:'not-run'};
22
+ validateState(pending);
23
+ const bytes=Buffer.from(JSON.stringify(parse(JSON.stringify(pending),'json'))+'\n');
24
+ const relative='.pipeline/state.json',scratch='.pipeline/.switch-state-'+randomUUID()+'.tmp';
25
+ const filename=resolveChild(lock.workspace,relative),staged=resolveChild(lock.workspace,scratch);
26
+ try {
27
+ await assertLockHeld(lock);await inspectDirectory(resolveChild(lock.workspace,'.pipeline'));
28
+ const handle=await open(staged,'wx',0o600);
29
+ try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
30
+ await boundary('before-rename',{path:relative,stagedPath:scratch});
31
+ await verifySwitchApproval(lock,record.prepared,approval,registry,record.previous);
32
+ const fresh=await readSwitchRecovery(lock.workspace,recoveryPath);
33
+ if(fresh.fileHash!==recoveryHash || fresh.journal.sequence!==1 || fresh.journal.head!==evidence.journal.head)fail('switch-pending.evidence');
34
+ const [temp]=await observeTargets(lock.workspace,[scratch]);
35
+ if(temp.bytes===null || sha256(temp.bytes)!==sha256(bytes))fail('switch-pending.staging');
36
+ await assertLockHeld(lock);
37
+ if((await readState(filename)).digest!==record.prepared.stateFileHash)fail('switch-pending.state-drift');
38
+ await rename(staged,filename);
39
+ await boundary('renamed',{path:relative});
40
+ if((await readState(filename)).digest!==sha256(bytes))fail('switch-pending.readback');
41
+ return {status:'needs-reconciliation',pending:record.digest,stateFileHash:sha256(bytes),recoveryPath,applySupported:false,runtime:'not-run'};
42
+ }catch(error){throw error instanceof ContractError?error:new ContractError('switch-pending.io');}
43
+ }