@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,65 @@
1
+ import {readRecord} from './state.js';
2
+ import {writeCheckedFile} from './apply.js';
3
+ import {assertLockHeld} from './lock.js';
4
+ import {requestShape} from './ownership.js';
5
+ import {planRetention} from './retention.js';
6
+ import {planCombinedRetention} from './retention-combined.js';
7
+ import {absoluteRoot,resolveChild} from '../workspace/paths.js';
8
+ import {contractDigest} from '../contracts/semantic.js';
9
+ import {parse,fail} from '../contracts/parse.js';
10
+
11
+ export const retentionPolicyPath='.pipeline/retention.json';
12
+ export function validateRetentionPolicy(value,workspace) {
13
+ requestShape(value,['schemaVersion','kind','workspace','mode','journals'],['cleanupReceipts','maxDeletesPerRun'],'retention-policy.shape');
14
+ if(![1,2].includes(value.schemaVersion) || value.kind!=='workspace-retention-policy' ||
15
+ value.workspace!==absoluteRoot(workspace) || !['automatic','disabled'].includes(value.mode))fail('retention-policy.binding');
16
+ if(value.schemaVersion===1) {
17
+ if(Object.hasOwn(value,'cleanupReceipts')||Object.hasOwn(value,'maxDeletesPerRun'))fail('retention-policy.shape');
18
+ planRetention({journals:[],policy:value.journals,now:0});
19
+ }else planCombinedRetention({journals:[],receipts:[],policy:retentionLimits(value),now:0});
20
+ return structuredClone(value);
21
+ }
22
+ export function retentionLimits(policy) {
23
+ return policy.schemaVersion===1?policy.journals:{journals:policy.journals,
24
+ cleanupReceipts:policy.cleanupReceipts,maxDeletesPerRun:policy.maxDeletesPerRun};
25
+ }
26
+ export async function readRetentionPolicy(workspace) {
27
+ workspace=absoluteRoot(workspace);const path=resolveChild(workspace,retentionPolicyPath);
28
+ let record;
29
+ try{record=await readRecord(path);}catch(e){if(e.code!=='record.missing')throw e;}
30
+ const policy=record?validateRetentionPolicy(record.value,workspace):null;
31
+ return {workspace,path,hash:record?.digest??null,mode:policy?.mode??'disabled',policy};
32
+ }
33
+ export async function previewRetentionPolicy(workspace,request) {
34
+ requestShape(request,['action'],['mode','journals','cleanupReceipts','maxDeletesPerRun'],'retention-policy.request');
35
+ if(!['set','disable'].includes(request.action))fail('retention-policy.request');
36
+ const before=await readRetentionPolicy(workspace);let result;
37
+ if(request.action==='disable') {
38
+ if(Object.keys(request).length!==1)fail('retention-policy.request');
39
+ result=before.policy?{...before.policy,mode:'disabled'}:null;
40
+ }else {
41
+ const v2=Object.hasOwn(request,'cleanupReceipts');
42
+ if(!v2&&Object.hasOwn(request,'maxDeletesPerRun'))fail('retention-policy.request');
43
+ result=validateRetentionPolicy({schemaVersion:v2?2:1,kind:'workspace-retention-policy',
44
+ workspace:before.workspace,mode:request.mode,journals:request.journals,
45
+ ...v2?{cleanupReceipts:request.cleanupReceipts,maxDeletesPerRun:request.maxDeletesPerRun}:{}},before.workspace);
46
+ }
47
+ const body={kind:'retention-policy-preview',workspace:before.workspace,action:request.action,
48
+ beforeHash:before.hash,result,
49
+ warning:result?.schemaVersion===2?'Automatic mode authorizes future eligible journal and cleanup-receipt deletion under one shared cap; no cleanup is performed by this policy update.':
50
+ 'Automatic mode authorizes future eligible journal deletion within these limits; no cleanup is performed by this policy update.'};
51
+ return {...body,digest:contractDigest(body)};
52
+ }
53
+ export async function applyRetentionPolicy(lock,preview,approval) {
54
+ await assertLockHeld(lock);
55
+ const p=parse(JSON.stringify(preview),'json');
56
+ requestShape(p,['kind','workspace','action','beforeHash','result','warning','digest'],[],'retention-policy.preview');
57
+ requestShape(approval,['decision','previewDigest'],[],'retention-policy.approval');
58
+ if(p.workspace!==lock.workspace || approval.decision!=='approve' || approval.previewDigest!==p.digest)fail('retention-policy.approval');
59
+ const request=p.action==='disable'?{action:'disable'}:{action:p.action,mode:p.result?.mode,journals:p.result?.journals,
60
+ ...p.result?.schemaVersion===2?{cleanupReceipts:p.result.cleanupReceipts,maxDeletesPerRun:p.result.maxDeletesPerRun}:{}};
61
+ const fresh=await previewRetentionPolicy(lock.workspace,request);
62
+ if(contractDigest(p)!==contractDigest(fresh))fail('retention-policy.drift');
63
+ if(p.result!==null)await writeCheckedFile(lock,retentionPolicyPath,p.beforeHash,Buffer.from(JSON.stringify(p.result)+'\n'));
64
+ return {status:'completed',...await readRetentionPolicy(lock.workspace),cleanupPerformed:false};
65
+ }
@@ -0,0 +1,126 @@
1
+ import {readdir,lstat} from 'node:fs/promises';
2
+ import {readRecord,readState} from './state.js';
3
+ import {inspectHistory} from './history.js';
4
+ import {absoluteRoot,resolveChild,inspectDirectory} from '../workspace/paths.js';
5
+ import {requestShape} from './ownership.js';
6
+ import {planRetention} from './retention.js';
7
+ import {planCombinedRetention} from './retention-combined.js';
8
+ import {contractDigest} from '../contracts/semantic.js';
9
+ import {fail} from '../contracts/parse.js';
10
+
11
+ const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?![\s\S])/;
12
+ const hash=/^sha256:[a-f0-9]{64}(?![\s\S])/;
13
+ const integer=n=>Number.isSafeInteger(n)&&n>=0;
14
+ function equalSet(a,b) {
15
+ return Array.isArray(a)&&Array.isArray(b)&&new Set(a).size===a.length&&
16
+ new Set(b).size===b.length&&a.length===b.length&&a.every(x=>b.includes(x));
17
+ }
18
+ // Recognizes the currently produced journal-cleanup receipt only. Future receipt
19
+ // schemas remain protected until their own provenance rules are implemented.
20
+ export function completedCleanupReceipt(r,workspace,id) {
21
+ requestShape(r,['schemaVersion','kind','runId','workspace','previewDigest','policy','status','recoverable',
22
+ 'groups','removedFiles','removedDirectories','completedGroups','currentFile','error','reclaimedBytes'],
23
+ ['authorization','receiptUpdate'],'retention-receipts.shape');
24
+ if(![1,2].includes(r.schemaVersion)||r.kind!=='retention-result'||r.runId!==id||!uuid.test(id)||r.workspace!==workspace||
25
+ !hash.test(r.previewDigest)||r.recoverable!==false||!['completed','failed','in-progress'].includes(r.status))fail('retention-receipts.binding');
26
+ const combined=r.schemaVersion===2;
27
+ if(combined)planCombinedRetention({journals:[],receipts:[],policy:r.policy,now:0});
28
+ else planRetention({journals:[],policy:r.policy,now:0});
29
+ if(r.authorization!==undefined) {
30
+ const automatic=r.authorization?.decision==='workspace-policy';
31
+ requestShape(r.authorization,automatic?['decision','policyHash']:['decision','previewDigest'],[],'retention-receipts.authorization');
32
+ if(automatic?!hash.test(r.authorization.policyHash):r.authorization.decision!=='approve'||r.authorization.previewDigest!==r.previewDigest)
33
+ fail('retention-receipts.authorization');
34
+ }
35
+ // Incomplete known records are retained; no claim of successful deletion.
36
+ if(r.status!=='completed')return false;
37
+ if(r.error!==null||r.currentFile!==null||(r.receiptUpdate!==undefined&&r.receiptUpdate!=='saved')||
38
+ !integer(r.reclaimedBytes)||!Array.isArray(r.groups))fail('retention-receipts.completion');
39
+ if(r.groups.length>r.policy.maxDeletesPerRun)fail('retention-receipts.completion');
40
+ const ids=[],files=[],directories=[];let bytes=0;
41
+ for(const group of r.groups) {
42
+ requestShape(group,combined?['id','type','paths','files']:['id','paths','files'],[],'retention-receipts.group');
43
+ const receiptGroup=combined&&group.type==='cleanup-receipt';
44
+ if(combined&&!['journal','cleanup-receipt'].includes(group.type))fail('retention-receipts.group');
45
+ if(!uuid.test(group.id)||!equalSet(group.paths,receiptGroup?[]:['.pipeline/journals/'+group.id,'.pipeline/transactions/'+group.id])||!Array.isArray(group.files))
46
+ fail('retention-receipts.group');
47
+ if(receiptGroup&&(group.id===r.runId||group.files.length!==1))fail('retention-receipts.group');
48
+ ids.push(combined?group.type+':'+group.id:group.id);directories.push(...group.paths);let recovery=0,events=0;
49
+ for(const file of group.files) {
50
+ requestShape(file,['path','hash','bytes','mtimeMs'],[],'retention-receipts.file');
51
+ if(typeof file.path!=='string'||!hash.test(file.hash)||!integer(file.bytes)||!Number.isFinite(file.mtimeMs)||file.mtimeMs<0)
52
+ fail('retention-receipts.file');
53
+ if(receiptGroup) {
54
+ if(file.path!=='.pipeline/cleanup/'+group.id+'.json')fail('retention-receipts.file');
55
+ }else if(file.path==='.pipeline/transactions/'+group.id+'/recovery.json')recovery++;
56
+ else if(file.path.startsWith('.pipeline/journals/'+group.id+'/')&&/^\d{6}\.json(?![\s\S])/.test(file.path.slice(('.pipeline/journals/'+group.id+'/').length)))events++;
57
+ else fail('retention-receipts.file');
58
+ files.push(file.path);bytes+=file.bytes;if(!integer(bytes))fail('retention-receipts.bytes');
59
+ }
60
+ if(!receiptGroup&&(recovery!==1||events<1))fail('retention-receipts.group');
61
+ }
62
+ if(!equalSet(ids,r.completedGroups)||!equalSet(files,r.removedFiles)||!equalSet(directories,r.removedDirectories)||bytes!==r.reclaimedBytes)
63
+ fail('retention-receipts.completion');
64
+ return true;
65
+ }
66
+
67
+ function references(value,out) {
68
+ if(typeof value==='string') {
69
+ for(const m of value.replaceAll('\\','/').matchAll(/\.pipeline\/cleanup\/([a-f0-9-]{36})\.json/g))if(uuid.test(m[1]))out.add(m[1]);
70
+ }else if(value&&typeof value==='object')for(const part of Object.values(value))references(part,out);
71
+ }
72
+
73
+ export async function scanCleanupReceipts(workspace,{currentRuns=[]}={}) {
74
+ workspace=absoluteRoot(workspace);
75
+ if(!Array.isArray(currentRuns)||currentRuns.some(id=>typeof id!=='string'||!uuid.test(id)))fail('retention-receipts.current-run');
76
+ const directory=resolveChild(workspace,'.pipeline/cleanup'),records=[],diagnostics=[],bindings=[],referenced=new Set();
77
+ const history=await inspectHistory(workspace);let complete=history.complete&&history.diagnostics.length===0,total=0,stateHash=null;
78
+ try {
79
+ const state=await readState(resolveChild(workspace,'.pipeline/state.json'));
80
+ stateHash=state.digest;
81
+ bindings.push({path:'.pipeline/state.json',hash:state.digest});references(state.value,referenced);
82
+ }catch(e){if(e.code!=='record.missing'){complete=false;diagnostics.push({code:e.code??'retention-receipts.io',subject:'state'});}}
83
+ if(stateHash===null&&history.entries.length)complete=false;
84
+ for(const entry of history.entries)if(entry.recovery) {
85
+ try{const r=await readRecord(resolveChild(workspace,entry.recovery));bindings.push({path:entry.recovery,hash:r.digest});references(r.value,referenced);}
86
+ catch(e){complete=false;diagnostics.push({code:e.code??'retention-receipts.io',subject:entry.id});}
87
+ }
88
+ const names=(await inspectDirectory(directory)).exists?(await readdir(directory)).sort():[];
89
+ if(names.length>1000)fail('retention-receipts.limit');
90
+ for(const name of names) {
91
+ if(!name.endsWith('.json')||!uuid.test(name.slice(0,-5))){complete=false;diagnostics.push({code:'retention-receipts.foreign',subject:'.pipeline/cleanup'});continue;}
92
+ const id=name.slice(0,-5),relative='.pipeline/cleanup/'+name,filename=resolveChild(workspace,relative);
93
+ const item={id,path:relative,status:'unknown',completedAt:null,bytes:0,hash:null,mtimeMs:null,protectionReasons:[]};
94
+ try {
95
+ const before=await lstat(filename);total+=before.size;if(total>64*1024*1024)fail('retention-receipts.limit');
96
+ if(!before.isFile()||before.isSymbolicLink()||before.nlink!==1)fail('retention-receipts.type');
97
+ const r=await readRecord(filename),after=await lstat(filename);
98
+ if(before.ino!==after.ino||before.dev!==after.dev||before.size!==after.size||before.mtimeMs!==after.mtimeMs)fail('retention-receipts.drift');
99
+ item.bytes=before.size;item.hash=r.digest;item.mtimeMs=before.mtimeMs;
100
+ item.status=completedCleanupReceipt(r.value,workspace,id)?'completed':'uncertain';
101
+ if(item.status==='completed')item.completedAt=Math.floor(before.mtimeMs);
102
+ }catch(e){complete=false;diagnostics.push({code:e.code??'retention-receipts.io',subject:id});item.protectionReasons.push('inspection-failed');}
103
+ if(currentRuns.includes(id))item.protectionReasons.push('current-run');
104
+ if(referenced.has(id))item.protectionReasons.push('retained-reference');
105
+ records.push(item);
106
+ }
107
+ // Repeat identities: this is a preview, not a lock or deletion permission.
108
+ for(const binding of [...bindings,...records.filter(r=>r.hash!==null)]) {
109
+ if((await readRecord(resolveChild(workspace,binding.path))).digest!==binding.hash)fail('retention-receipts.drift');
110
+ if(binding.mtimeMs!==undefined) {
111
+ const info=await lstat(resolveChild(workspace,binding.path));
112
+ if(!info.isFile()||info.isSymbolicLink()||info.nlink!==1||info.size!==binding.bytes||info.mtimeMs!==binding.mtimeMs)fail('retention-receipts.drift');
113
+ }
114
+ }
115
+ let stateAfter=null;
116
+ try{stateAfter=(await readState(resolveChild(workspace,'.pipeline/state.json'))).digest;}
117
+ catch(e){if(e.code!=='record.missing'&&(stateHash!==null||complete))fail('retention-receipts.drift');}
118
+ if(stateAfter!==stateHash)fail('retention-receipts.drift');
119
+ if(contractDigest(await inspectHistory(workspace))!==contractDigest(history))fail('retention-receipts.drift');
120
+ const afterNames=(await inspectDirectory(directory)).exists?(await readdir(directory)).sort():[];
121
+ if(!equalSet(names,afterNames))fail('retention-receipts.drift');
122
+ if(!complete)for(const r of records)if(!r.protectionReasons.includes('inspection-failed'))r.protectionReasons.push('inspection-failed');
123
+ const body={kind:'cleanup-receipts-inventory',workspace,records,bindings,diagnostics,complete,
124
+ requiresLockedRecheck:true,automaticActions:false};
125
+ return {...body,digest:contractDigest(body)};
126
+ }
@@ -0,0 +1,86 @@
1
+ import {readdir,lstat} from 'node:fs/promises';
2
+ import {inspectHistory} from './history.js';
3
+ import {readState,readRecord} from './state.js';
4
+ import {planRetention} from './retention.js';
5
+ import {absoluteRoot,resolveChild,inspectDirectory} from '../workspace/paths.js';
6
+ import {contractDigest} from '../contracts/semantic.js';
7
+ import {fail} from '../contracts/parse.js';
8
+
9
+ const ref=/^\.pipeline\/(?:transactions|journals)\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/;
10
+ const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?![\s\S])/;
11
+ function references(value,out=new Set()) {
12
+ if(typeof value==='string') {const m=ref.exec(value);if(m)out.add(m[1]);}
13
+ else if(value && typeof value==='object')for(const item of Object.values(value))references(item,out);
14
+ return out;
15
+ }
16
+
17
+ // Conservative read-only scan. Completed is not synonymous with disposable.
18
+ // Referenced records remain protected even if the referring record is eligible.
19
+ export async function scanRetention(workspace,{policy,now,currentRuns=[]}) {
20
+ workspace=absoluteRoot(workspace);
21
+ if(!Array.isArray(currentRuns) || currentRuns.some(id=>typeof id!=='string' || !uuid.test(id)))fail('retention-scan.current-run');
22
+ planRetention({journals:[],policy,now}); // validate policy before filesystem work
23
+ const history=await inspectHistory(workspace),groups=[],referenced=new Set(),current=new Set(currentRuns),diagnostics=[...history.diagnostics];
24
+ let state=null,unsafe=!history.complete || history.diagnostics.some(d=>d.code!=='history.unfinished'),count=0,total=0;
25
+ try {state=await readState(resolveChild(workspace,'.pipeline/state.json'));for(const id of references(state.value))current.add(id);}
26
+ catch(error){if(error.code!=='record.missing'){unsafe=true;diagnostics.push({code:error.code??'retention-scan.io',subject:'state'});}}
27
+ if((history.entries.length && !state) || (state?.value.active && !state.value.activation) ||
28
+ (state?.value.pending && !history.entries.some(e=>e.pendingDigest===state.value.pending)))unsafe=true;
29
+ const inspectGroup=async item=>{
30
+ const files=[];
31
+ for(const directory of [item.journal,item.recovery?.slice(0,-'/recovery.json'.length)].filter(Boolean)) {
32
+ await inspectDirectory(resolveChild(workspace,directory));
33
+ const names=(await readdir(resolveChild(workspace,directory))).sort();
34
+ if(names.length>10000 || count+names.length>10000)fail('retention-scan.limit');
35
+ for(const name of names) {
36
+ if(directory===item.journal?!/^\d{6}\.json(?![\s\S])/.test(name):name!=='recovery.json')fail('retention-scan.foreign');
37
+ const relative=directory+'/'+name,filename=resolveChild(workspace,relative),info=await lstat(filename);
38
+ if(!info.isFile() || info.isSymbolicLink() || info.nlink!==1)fail('retention-scan.type');
39
+ count++;total+=info.size;if(total>64*1024*1024)fail('retention-scan.limit');
40
+ const record=await readRecord(filename),after=await lstat(filename);
41
+ if(info.size!==after.size || info.mtimeMs!==after.mtimeMs || info.ino!==after.ino || info.dev!==after.dev)fail('retention-scan.drift');
42
+ files.push({path:relative,hash:record.digest,bytes:info.size,mtimeMs:info.mtimeMs});
43
+ if(name==='recovery.json')for(const id of references(record.value))if(id!==item.id)referenced.add(id);
44
+ }
45
+ }
46
+ return files;
47
+ };
48
+ for(const item of history.entries) {
49
+ const group={id:item.id,status:item.status,journalPath:item.journal?resolveChild(workspace,item.journal):null,
50
+ recoveryPath:item.recovery?resolveChild(workspace,item.recovery):null,
51
+ paths:[item.journal,item.recovery?.slice(0,-'/recovery.json'.length)].filter(Boolean),files:[],protectionReasons:[]};
52
+ try{group.files=await inspectGroup(item);}catch(error){unsafe=true;group.protectionReasons.push('inspection-failed');diagnostics.push({code:error.code??'retention-scan.io',subject:item.id});}
53
+ if(['unknown','orphan'].includes(item.status))unsafe=true;
54
+ if(state?.value.pending && item.pendingDigest===state.value.pending)current.add(item.id);
55
+ groups.push(group);
56
+ }
57
+ for(const group of groups) {
58
+ if(currentRuns.includes(group.id))group.protectionReasons.push('current-run');
59
+ if(current.has(group.id))group.protectionReasons.push('current-state');
60
+ if(referenced.has(group.id))group.protectionReasons.push('retained-reference');
61
+ if(unsafe)group.protectionReasons.push('inspection-failed');
62
+ group.protectionReasons=[...new Set(group.protectionReasons)].sort();
63
+ group.bytes=group.files.reduce((n,f)=>n+f.bytes,0);
64
+ // Local metadata age only; never used to select the active transaction.
65
+ group.completedAt=group.status==='journal-completed' && group.files.length?Math.floor(Math.max(...group.files.map(f=>f.mtimeMs))):null;
66
+ }
67
+ const after=await inspectHistory(workspace);
68
+ if(contractDigest(after)!==contractDigest(history))fail('retention-scan.drift');
69
+ for(const group of groups)for(const file of group.files) {
70
+ const filename=resolveChild(workspace,file.path),info=await lstat(filename);
71
+ if(info.size!==file.bytes || info.mtimeMs!==file.mtimeMs || (await readRecord(filename)).digest!==file.hash)fail('retention-scan.drift');
72
+ }
73
+ for(const group of groups)if(!group.protectionReasons.includes('inspection-failed'))for(const directory of group.paths) {
74
+ const expected=group.files.filter(f=>f.path.startsWith(directory+'/')).map(f=>f.path.slice(directory.length+1)).sort();
75
+ if(JSON.stringify((await readdir(resolveChild(workspace,directory))).sort())!==JSON.stringify(expected))fail('retention-scan.drift');
76
+ }
77
+ let stateAfter=null;
78
+ try{stateAfter=(await readState(resolveChild(workspace,'.pipeline/state.json'))).digest;}catch(error){if(error.code!=='record.missing')fail('retention-scan.drift');}
79
+ if(stateAfter!==(state?.digest??null))fail('retention-scan.drift');
80
+ const retention=planRetention({now,policy,journals:groups.map(g=>({id:g.id,
81
+ status:g.status==='journal-completed'?'completed':['uncertain','orphan'].includes(g.status)?g.status:g.status==='open'?'pending':'unknown',
82
+ completedAt:g.completedAt,bytes:g.bytes,protectionReasons:g.protectionReasons}))});
83
+ const body={kind:'retention-filesystem-preview',workspace,stateFileHash:stateAfter,currentRuns:[...currentRuns],groups,retention,
84
+ diagnostics,complete:history.complete && !unsafe,applySupported:false,automaticActions:false};
85
+ return {...body,digest:contractDigest(body)};
86
+ }
@@ -0,0 +1,56 @@
1
+ import { fail } from '../contracts/parse.js';
2
+ import { contractDigest } from '../contracts/semantic.js';
3
+ import { requestShape } from './ownership.js';
4
+
5
+ const dayMs = 86400000;
6
+ const statuses = new Set(['completed','active','pending','uncertain','unknown','orphan']);
7
+ const protections = new Set(['current-run','current-state','retained-reference','dependency','inspection-failed']);
8
+ const integer = n => Number.isSafeInteger(n) && n >= 0;
9
+ const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
10
+
11
+ // Pure selection over trusted, complete scanner observations. This function does
12
+ // not inspect files, prove completion/references, grant deletion or acquire locks.
13
+ // The later scanner/executor must independently establish those facts and recheck
14
+ // them. No defaults: all user-policy limits and the clock are explicit inputs.
15
+ export function planRetention(input) {
16
+ requestShape(input,['journals','policy','now'],[],'retention.input');
17
+ const {journals,policy,now}=input;
18
+ requestShape(policy,['maxAgeDays','maxJournals','maxDeletesPerRun'],[],'retention.policy');
19
+ if(!integer(now) || now>8640000000000000 || !Object.values(policy).every(integer) ||
20
+ policy.maxAgeDays>Math.floor(Number.MAX_SAFE_INTEGER/dayMs))fail('retention.policy');
21
+ if(!Array.isArray(journals))fail('retention.input');
22
+ const ids=new Set();let totalBytes=0;
23
+ const observed=journals.map(journal=>{
24
+ requestShape(journal,['id','status','completedAt','bytes','protectionReasons'],[],'retention.journal');
25
+ const {id,status,completedAt,bytes,protectionReasons}=journal;
26
+ if(typeof id!=='string' || !uuid.test(id) || ids.has(id) || !statuses.has(status) ||
27
+ !integer(bytes) || !Array.isArray(protectionReasons) ||
28
+ protectionReasons.some(r=>!protections.has(r)) || new Set(protectionReasons).size!==protectionReasons.length ||
29
+ (completedAt!==null && (!integer(completedAt) || completedAt>8640000000000000)) ||
30
+ (status==='completed' && completedAt===null))fail('retention.journal');
31
+ ids.add(id);totalBytes+=bytes;if(!integer(totalBytes))fail('retention.bytes');
32
+ const reasons=[...protectionReasons];
33
+ if(status!=='completed')reasons.push('status:'+status);
34
+ if(completedAt!==null && completedAt>now)reasons.push('future-timestamp');
35
+ return {id,status,completedAt,bytes,protectionReasons:reasons.sort()};
36
+ }).sort((a,b)=>a.id<b.id?-1:a.id>b.id?1:0);
37
+ const protectedJournals=observed.filter(j=>j.protectionReasons.length);
38
+ const eligible=observed.filter(j=>!j.protectionReasons.length)
39
+ .sort((a,b)=>a.completedAt-b.completedAt || (a.id<b.id?-1:a.id>b.id?1:0));
40
+ const excess=Math.max(0,observed.length-policy.maxJournals);
41
+ const candidates=eligible.map((j,index)=>({...j,reasons:[
42
+ ...(now-j.completedAt>policy.maxAgeDays*dayMs?['age']:[]),...(index<excess?['count']:[])
43
+ ]})).filter(j=>j.reasons.length);
44
+ const selected=candidates.slice(0,policy.maxDeletesPerRun),deferred=candidates.slice(policy.maxDeletesPerRun);
45
+ const selectedIds=new Set(selected.map(j=>j.id));
46
+ const remaining=observed.filter(j=>!selectedIds.has(j.id));
47
+ const aged=j=>j.completedAt!==null && now-j.completedAt>policy.maxAgeDays*dayMs;
48
+ const body={kind:'retention-preview',now,policy:{...policy},observed,
49
+ selected,deferred,protected:protectedJournals,
50
+ totals:{observedCount:observed.length,observedBytes:totalBytes,
51
+ selectedCount:selected.length,selectedBytes:selected.reduce((sum,j)=>sum+j.bytes,0),
52
+ remainingCount:remaining.length,remainingOverCount:Math.max(0,remaining.length-policy.maxJournals),
53
+ remainingOverAge:remaining.filter(aged).length},
54
+ requiresFilesystemValidation:true,automaticActions:false};
55
+ return {...body,digest:contractDigest(body)};
56
+ }
@@ -0,0 +1,210 @@
1
+ import path from 'node:path';
2
+ import { open, lstat, realpath, opendir } from 'node:fs/promises';
3
+ import { parse, fail, ContractError, MAX_INPUT_BYTES } from '../contracts/parse.js';
4
+ import { validateStructure } from '../contracts/validate.js';
5
+ import { validateState, contractDigest } from '../contracts/semantic.js';
6
+ import { validateSource } from '../source/git.js';
7
+ import { sha256, utf8, verifyPackage, LIMITS, cap } from '../source/inventory.js';
8
+ import { absoluteRoot, inspectDirectory, resolveChild } from '../workspace/paths.js';
9
+ import {installedSelection,resolveProviders} from '../providers/bundles.js';
10
+
11
+ // Verify the installed bytes, not merely the source identity in state. This is
12
+ // an observation for preview; apply must recheck it under the S5 transaction.
13
+ // No original manifest/source lookup or Git/network activity is required.
14
+ export async function verifyInstalledSnapshot(previous) {
15
+ validateState(previous);
16
+ if (!previous.active || previous.pending !== null) fail('snapshot.state');
17
+ const snapshot = previous.active.snapshot;
18
+ const root = resolveChild(absoluteRoot(previous.workspace), snapshot.path);
19
+ const verified = await verifySnapshotDirectory(root, {...snapshot,pipelineId:previous.active.pipelineId,version:previous.active.version});
20
+ const selection=resolveProviders(verified.manifest,installedSelection(previous.active));
21
+ if(contractDigest(selection.providers)!==contractDigest([...previous.active.providers].sort()) ||
22
+ contractDigest(selection.bundles??null)!==contractDigest(previous.active.bundles??null)) fail('bundle.snapshot-binding');
23
+ return {...verified,snapshot:structuredClone(snapshot)};
24
+ }
25
+ export async function verifyPreparedSnapshot(acquired) {
26
+ return verifySnapshotDirectory(absoluteRoot(acquired.snapshotPath), {...acquired,pipelineId:acquired.manifest.id,version:acquired.manifest.version});
27
+ }
28
+ async function verifySnapshotDirectory(root, expected) {
29
+ const entries = [], files = new Map(), directories = new Set();
30
+ let nodes = 0, total = 0;
31
+ try {
32
+ if (!(await inspectDirectory(root)).exists) fail('snapshot.missing');
33
+ async function walk(directory, prefix = '') {
34
+ for await (const item of await opendir(directory)) {
35
+ // Count directories too: an empty-directory flood is still bounded.
36
+ cap(++nodes, LIMITS.files * 2, 'snapshot.nodes');
37
+ const relative = prefix + item.name, filename = resolveChild(root, relative);
38
+ const info = await lstat(filename);
39
+ if (info.isSymbolicLink()) fail('snapshot.link');
40
+ if (info.isDirectory()) {
41
+ directories.add(relative);
42
+ await inspectDirectory(filename);
43
+ await walk(filename, relative + '/');
44
+ continue;
45
+ }
46
+ if (!info.isFile() || info.nlink !== 1) fail('snapshot.type');
47
+ cap(entries.length + 1, LIMITS.files, 'source.files');
48
+ cap(info.size, LIMITS.blob, 'source.blob');
49
+ cap(total + info.size, LIMITS.total, 'source.total');
50
+ const handle = await open(filename, 'r');
51
+ let bytes;
52
+ try {
53
+ const opened = await handle.stat();
54
+ if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.nlink !== 1) fail('snapshot.drift');
55
+ // One extra byte detects growth without unbounded readFile allocation.
56
+ const buffer = Buffer.alloc(info.size + 1);
57
+ let length = 0;
58
+ while (length < buffer.length) {
59
+ const read = await handle.read(buffer, length, buffer.length - length, null);
60
+ if (read.bytesRead === 0) break;
61
+ length += read.bytesRead;
62
+ }
63
+ if (length !== info.size) fail('snapshot.drift');
64
+ bytes = buffer.subarray(0, length);
65
+ } finally { await handle.close(); }
66
+ total += bytes.length;
67
+ entries.push({ path: relative, size: bytes.length, mode: '100644', type: 'blob' });
68
+ files.set(relative, bytes);
69
+ }
70
+ }
71
+ await walk(root);
72
+ entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
73
+ const verified = await verifyPackage(entries, async entry => files.get(entry.path));
74
+ const expectedDirectories = new Set();
75
+ for (const name of verified.files.keys()) {
76
+ const parts = name.split('/');
77
+ for (let i = 1; i < parts.length; i++) expectedDirectories.add(parts.slice(0, i).join('/'));
78
+ }
79
+ if (directories.size !== expectedDirectories.size || [...directories].some(name => !expectedDirectories.has(name))) fail('snapshot.extra-directory');
80
+ if (verified.digest !== expected.digest || verified.inventoryDigest !== expected.inventoryDigest ||
81
+ verified.manifest.id !== expected.pipelineId || verified.manifest.version !== expected.version) fail('snapshot.binding');
82
+ return { ...verified, root, runtime: 'not-run' };
83
+ } catch (error) {
84
+ throw error instanceof ContractError ? error : new ContractError('snapshot.io');
85
+ }
86
+ }
87
+
88
+ // Read-only bounded observation. Not an S5 lock or protection against concurrent
89
+ // replacement. No native Git, network or configuration writes here.
90
+ export async function readRecord(filename, format = 'json') {
91
+ filename = absoluteRoot(filename);
92
+ let handle;
93
+ try {
94
+ await inspectDirectory(path.dirname(filename));
95
+ const entry = await lstat(filename);
96
+ if (entry.isSymbolicLink() || !entry.isFile()) fail('record.type');
97
+ handle = await open(filename, 'r');
98
+ const buffer = Buffer.alloc(MAX_INPUT_BYTES + 1);
99
+ let length = 0;
100
+ while (length < buffer.length) {
101
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
102
+ if (bytesRead === 0) break;
103
+ length += bytesRead;
104
+ }
105
+ if (length > MAX_INPUT_BYTES) fail('parse.size');
106
+ const bytes = buffer.subarray(0, length);
107
+ return { path: await realpath(filename), digest: sha256(bytes), value: parse(utf8(bytes), format) };
108
+ } catch (e) {
109
+ if (e instanceof ContractError) throw e;
110
+ fail(e.code === 'ENOENT' ? 'record.missing' : 'record.io');
111
+ } finally { if (handle) await handle.close(); }
112
+ }
113
+ export async function readState(filename) {
114
+ const record = await readRecord(filename);
115
+ validateState(record.value);
116
+ return { ...record, stateDigest: contractDigest(record.value) };
117
+ }
118
+ function previousGuard(previous, wrapper) {
119
+ if (!previous) return;
120
+ validateState(previous);
121
+ if (previous.pending !== null) fail('state.pending');
122
+ if (absoluteRoot(previous.workspace) !== wrapper) fail('source-rebind-required');
123
+ }
124
+ async function manifestRecord(filename, hasOriginal = false) {
125
+ const extension = path.extname(filename).toLowerCase();
126
+ if (!['.json', '.yaml', '.yml'].includes(extension)) fail('manifest.format');
127
+ const record = await readRecord(filename, extension === '.json' ? 'json' : 'yaml');
128
+ validateStructure('workspace', record.value); validateSource(record.value.pipeline);
129
+ const base = path.dirname(record.path), source = record.value.pipeline;
130
+ let resolvedSource = source.url;
131
+ if (source.transport === 'local') {
132
+ try { resolvedSource = await realpath(path.resolve(base, source.path)); }
133
+ catch (error) { fail(hasOriginal ? 'source-rebind-required' : error.code === 'ENOENT' ? 'source.missing-repository' : 'source.repository-unavailable'); }
134
+ }
135
+ return { manifest: record.value, origin: { path: record.path, base, digest: record.digest, resolvedSource } };
136
+ }
137
+ export async function resolveOrigin({ wrapper, previous = null, manifestPath, command = 'setup' }) {
138
+ wrapper = absoluteRoot(wrapper);
139
+ previousGuard(previous, wrapper);
140
+ if (!['setup', 'update', 'repair'].includes(command)) fail('origin.command');
141
+ const active = previous?.active;
142
+ if (command === 'repair') {
143
+ if (!active || manifestPath !== undefined) fail('origin.repair');
144
+ // Repair gets identity from installed state. Snapshot bytes still need a
145
+ // separate integrity check before planning/apply; this performs no source I/O.
146
+ return { mode: 'installed', snapshot: structuredClone(active.snapshot) };
147
+ }
148
+ const original = active?.snapshot.origin;
149
+ const filename = absoluteRoot(manifestPath ?? original?.path ?? path.join(wrapper, 'workspace.yaml'));
150
+ if (original && filename !== absoluteRoot(original.path)) fail('source-rebind-required');
151
+ let current;
152
+ try { current = await manifestRecord(filename, Boolean(original)); }
153
+ catch (e) { if (original && e.code === 'record.missing') fail('source-rebind-required'); throw e; }
154
+ if (original && (current.origin.path !== original.path || current.origin.base !== original.base ||
155
+ current.origin.resolvedSource !== original.resolvedSource)) fail('source-rebind-required');
156
+ return { mode: 'source', ...current };
157
+ }
158
+ // Explicit preview only. This never grants permission to acquire the proposed
159
+ // source, changes state or bypasses resolveOrigin. Approval binding is S4 plan work.
160
+ export async function previewRebind({ wrapper, previous, manifestPath }) {
161
+ validateState(previous);
162
+ if (!previous.active || previous.pending !== null || manifestPath === undefined) fail('origin.rebind');
163
+ const proposed = await manifestRecord(absoluteRoot(manifestPath));
164
+ return { kind: 'rebind-preview', beforeStateHash: contractDigest(previous),
165
+ previousWorkspace: previous.workspace, workspace: absoluteRoot(wrapper),
166
+ previousOrigin: structuredClone(previous.active.snapshot.origin), proposed,
167
+ requiresApproval: true, sourceAccessAuthorized: false };
168
+ }
169
+
170
+ // The CLI must obtain this decision from the user, not from package contents.
171
+ // It authorizes source access only, never filesystem apply. Recompute the proposal
172
+ // before access so edited manifests, relocated source or changed state invalidate it.
173
+ export async function resolveApprovedRebind({wrapper,previous,manifestPath,proposal,approval}) {
174
+ if (!approval || approval.decision!=='approve' || Object.keys(approval).sort().join(',')!=='decision,proposalDigest' ||
175
+ approval.proposalDigest!==contractDigest(proposal)) fail('rebind.approval');
176
+ const fresh=await previewRebind({wrapper,previous,manifestPath});
177
+ if (contractDigest(fresh)!==approval.proposalDigest) fail('rebind.drift');
178
+ // Workspace relocation is S6; S1 plans cannot silently change previous.workspace.
179
+ if (fresh.workspace!==previous.workspace) fail('rebind.workspace-move');
180
+ return {mode:'source',...fresh.proposed,rebind:{proposal:fresh,approval:structuredClone(approval)}};
181
+ }
182
+
183
+ export async function observeTargets(wrapper, names) {
184
+ const result=[];let total=0;
185
+ cap(names.length,LIMITS.files,'preview.count');
186
+ for(const name of [...new Set(names)].sort()) {
187
+ const filename=resolveChild(absoluteRoot(wrapper),name);
188
+ let handle;
189
+ try {
190
+ const parent=await inspectDirectory(path.dirname(filename));
191
+ if (!parent.exists) {result.push({path:name,bytes:null});continue;}
192
+ // Probe the final filename spelling without treating it as a directory.
193
+ const directory=await opendir(path.dirname(filename));
194
+ for await(const entry of directory) if(entry.name.toLowerCase()===path.basename(filename).toLowerCase() && entry.name!==path.basename(filename)) fail('layout.case-alias');
195
+ let info;
196
+ try {info=await lstat(filename);} catch(error) {if(error.code==='ENOENT'){result.push({path:name,bytes:null});continue;}throw error;}
197
+ if(!info.isFile() || info.isSymbolicLink() || info.nlink!==1) fail('target.type');
198
+ cap(info.size,LIMITS.blob,'preview.size');cap(total+info.size,LIMITS.total,'preview.total');
199
+ handle=await open(filename,'r');
200
+ const opened=await handle.stat();
201
+ if(!opened.isFile() || opened.ino!==info.ino || opened.dev!==info.dev || opened.nlink!==1) fail('target.drift');
202
+ const buffer=Buffer.alloc(info.size+1);let length=0;
203
+ while(length<buffer.length){const read=await handle.read(buffer,length,buffer.length-length,null);if(!read.bytesRead)break;length+=read.bytesRead;}
204
+ if(length!==info.size)fail('target.drift');
205
+ total+=length;result.push({path:name,bytes:buffer.subarray(0,length)});
206
+ } catch(error) {throw error instanceof ContractError ? error : new ContractError('target.io');}
207
+ finally {if(handle)await handle.close();}
208
+ }
209
+ return result;
210
+ }
@@ -0,0 +1,64 @@
1
+ import {readSwitchRecovery} from './switch-recovery-store.js';
2
+ import {inspectPendingSwitch} from './switch-inspect.js';
3
+ import {validateSwitchRecord} from './switch-records.js';
4
+ import {assertLockHeld} from './lock.js';
5
+ import {readState,observeTargets} from './state.js';
6
+ import {writeCheckedFile} from './apply.js';
7
+ import {resolveChild,absoluteRoot} from '../workspace/paths.js';
8
+ import {contractDigest,validateState} from '../contracts/semantic.js';
9
+ import {parse,fail} from '../contracts/parse.js';
10
+ import {sha256} from '../source/inventory.js';
11
+
12
+ function readyState(evidence,recoveryPath) {
13
+ if(evidence.journal.status!=='completed' || evidence.journal.verifiedPhases!==2)fail('switch-activation.incomplete');
14
+ const state={...evidence.record.previous,status:'ready',pending:null,runtime:'not-run',
15
+ active:evidence.record.prepared.preview.phases[1].preview.plan.desired,
16
+ activation:{recovery:recoveryPath,recoveryHash:evidence.fileHash,
17
+ journalHead:{sequence:evidence.journal.sequence,hash:evidence.journal.lastFileHash}}};
18
+ validateState(state);return state;
19
+ }
20
+
21
+ // Read-only confirmation of this exact completed activation. No repair/retry.
22
+ export async function inspectActivatedSwitch(workspace,recoveryPath) {
23
+ workspace=absoluteRoot(workspace);
24
+ const before=await readState(resolveChild(workspace,'.pipeline/state.json'));
25
+ const evidence=await readSwitchRecovery(workspace,recoveryPath),expected=readyState(evidence,recoveryPath);
26
+ if(contractDigest(before.value)!==contractDigest(expected))fail('switch-activation.binding');
27
+ const results=evidence.record.prepared.preview.results;
28
+ const observations=await observeTargets(workspace,results.map(o=>o.path));
29
+ if(observations.some(o=>(o.bytes===null?null:sha256(o.bytes))!==results.find(r=>r.path===o.path).hash))fail('switch-activation.target-drift');
30
+ const after=await readSwitchRecovery(workspace,recoveryPath);
31
+ const repeated=await observeTargets(workspace,results.map(o=>o.path));
32
+ if(after.fileHash!==evidence.fileHash || contractDigest(after.journal)!==contractDigest(evidence.journal) ||
33
+ repeated.some(o=>(o.bytes===null?null:sha256(o.bytes))!==results.find(r=>r.path===o.path).hash) ||
34
+ (await readState(resolveChild(workspace,'.pipeline/state.json'))).digest!==before.digest)fail('switch-activation.drift');
35
+ return {status:'applied',recoveryPath,recoveryHash:evidence.fileHash,stateFileHash:before.digest,runtime:'not-run'};
36
+ }
37
+
38
+ // Internal final transition. Both checked phases and unchanged final bytes are
39
+ // mandatory. State activation is configuration evidence, never runtime readiness.
40
+ export async function activateSwitch(lock,recoveryPath,recoveryHash,approval,{boundary=async()=>{}}={}) {
41
+ approval=structuredClone(approval);
42
+ async function check() {
43
+ await assertLockHeld(lock);
44
+ const evidence=await readSwitchRecovery(lock.workspace,recoveryPath);
45
+ if(evidence.fileHash!==recoveryHash)fail('switch-activation.recovery');
46
+ validateSwitchRecord(evidence.record.prepared,approval,evidence.record.previous);
47
+ const ready=readyState(evidence,recoveryPath),inspection=await inspectPendingSwitch(lock.workspace,recoveryPath);
48
+ if(inspection.conflicts.length || inspection.uncertain!==null)fail('switch-activation.target-drift');
49
+ const pending={...evidence.record.previous,status:'needs-reconciliation',pending:evidence.record.digest,runtime:'not-run'};
50
+ const state=await readState(resolveChild(lock.workspace,'.pipeline/state.json'));
51
+ if(contractDigest(state.value)!==contractDigest(pending))fail('switch-activation.binding');
52
+ return {ready,stateHash:state.digest};
53
+ }
54
+ const checked=await check(),bytes=Buffer.from(JSON.stringify(parse(JSON.stringify(checked.ready),'json'))+'\n');
55
+ await writeCheckedFile(lock,'.pipeline/state.json',checked.stateHash,bytes,undefined,async(phase,detail)=>{
56
+ await boundary(phase,detail);
57
+ if(phase==='before-rename') {
58
+ const fresh=await check();
59
+ if(fresh.stateHash!==checked.stateHash || contractDigest(fresh.ready)!==contractDigest(checked.ready))fail('switch-activation.drift');
60
+ }
61
+ });
62
+ const result=await inspectActivatedSwitch(lock.workspace,recoveryPath);
63
+ await assertLockHeld(lock);return result;
64
+ }