@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,214 @@
1
+ import {readConfigField} from './config-fields.js';
2
+ import { absoluteRoot, resolveChild, inspectDirectory } from '../workspace/paths.js';
3
+ import { inspectRepositoryPending } from './repository-pending.js';
4
+ import { readRecord, readState, observeTargets, verifyInstalledSnapshot } from './state.js';
5
+ import { ContractError, fail } from '../contracts/parse.js';
6
+ import { contractDigest } from '../contracts/semantic.js';
7
+ import { sha256 } from '../source/inventory.js';
8
+ import { inspectRecovery } from './apply.js';
9
+ import { requestShape } from './ownership.js';
10
+ import { inspectHistory } from './history.js';
11
+ import {inspectPendingSwitch} from './switch-inspect.js';
12
+ import {inspectActivatedSwitch} from './switch-activate.js';
13
+ import {inspectSwitchContinuation} from './switch-continuation-runtime.js';
14
+
15
+ async function inspectDoctorRecovery(workspace,relative,options={}) {
16
+ const record=await readRecord(resolveChild(workspace,relative));
17
+ if(record.value?.kind==='switch-continuation-recovery') {
18
+ const state=await readState(resolveChild(workspace,'.pipeline/state.json')),pending=state.value.pending!==null;
19
+ const checked=await inspectSwitchContinuation(workspace,relative,{active:!pending});
20
+ if(checked.recoveryHash!==record.digest)fail('doctor.observation-drift');
21
+ return {status:checked.status,statePhase:pending?'pending':'active',journal:record.value.journal,
22
+ recoveryHash:record.digest,comparisonScope:'switch-exact-projection',fieldProjections:[],
23
+ diagnostics:pending?['doctor.switch-pending',...(checked.conflicts.length?['doctor.switch-target-drift']:[])]:[]};
24
+ }
25
+ if(record.value?.kind!=='switch-recovery')return inspectRecovery(workspace,relative,options);
26
+ const state=await readState(resolveChild(workspace,'.pipeline/state.json'));
27
+ const pending=state.value.pending!==null;
28
+ const checked=pending?await inspectPendingSwitch(workspace,relative):await inspectActivatedSwitch(workspace,relative);
29
+ if(checked.recoveryHash!==record.digest)fail('doctor.observation-drift');
30
+ return {status:checked.status,statePhase:pending?'pending':'active',journal:record.value.journal,
31
+ recoveryHash:record.digest,comparisonScope:'switch-exact-projection',fieldProjections:[],
32
+ diagnostics:pending?['doctor.switch-pending',...(checked.conflicts.length?['doctor.switch-target-drift']:[])]:[]};
33
+ }
34
+
35
+ // Configuration readiness only: never runtime, acceptance, cleanup eligibility
36
+ // or authority to repair. Active transactions are matched by content binding,
37
+ // never timestamps. Repeated no-op applications can corroborate the same state;
38
+ // all matching records are checked, not hidden behind one explicit selection.
39
+ export async function inspectInstallation(workspace, options = {}) {
40
+ requestShape(options,[],['recoveryPath'],'doctor.options');
41
+ if (Object.hasOwn(options,'recoveryPath') && (typeof options.recoveryPath!=='string' ||
42
+ !/^\.pipeline\/transactions\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\/recovery\.json$/.test(options.recoveryPath))) fail('recovery.path');
43
+ workspace = absoluteRoot(workspace);
44
+ const diagnostics = [], targets = [];
45
+ const result = {workspace, status:'incomplete', configuration:'not-verified',
46
+ diagnostics, targets, transactionEvidence:'not-verified', runtime:'not-run',
47
+ automaticActions:false, ready:false,fieldProjections:[]};
48
+ const issue = (error, subject) => diagnostics.push({code:error instanceof ContractError ? error.code : 'doctor.io',subject});
49
+ let selectedPath=options.recoveryPath;
50
+ const inspectSelectedRecovery = async () => {
51
+ if (selectedPath===undefined) return;
52
+ try {
53
+ const recovery=await inspectDoctorRecovery(workspace,selectedPath,{fieldOwnershipOnly:true});
54
+ for(const path of recovery.fieldProjections)result.fieldProjections.push({recovery:selectedPath,path});
55
+ result.recovery={path:selectedPath,status:recovery.status,statePhase:recovery.statePhase,
56
+ journal:recovery.journal,hash:recovery.recoveryHash,comparisonScope:recovery.comparisonScope,fieldProjections:recovery.fieldProjections};
57
+ result.transactionEvidence=recovery.status==='applied'?'pass':'fail';
58
+ for (const code of recovery.diagnostics) diagnostics.push({code,subject:selectedPath});
59
+ if (recovery.status!=='applied') diagnostics.push({code:'doctor.transaction-not-applied',subject:selectedPath});
60
+ } catch (error) {
61
+ result.transactionEvidence='fail';issue(error,'selected-recovery');
62
+ }
63
+ };
64
+ let record;
65
+ try {
66
+ const repositories=await inspectRepositoryPending(workspace);
67
+ if(repositories.blocked){
68
+ diagnostics.push(...repositories.blockers);
69
+ if(repositories.blockers.some(b=>b.code==='migration.pending'))result.status='needs-reconciliation';
70
+ return result;
71
+ }
72
+ if (!(await inspectDirectory(workspace)).exists) {
73
+ diagnostics.push({code:'doctor.workspace-missing',subject:workspace});return result;
74
+ }
75
+ result.history=await inspectHistory(workspace);
76
+ diagnostics.push(...result.history.diagnostics);
77
+ if ((await inspectDirectory(resolveChild(workspace,'.pipeline/lock'))).exists)
78
+ diagnostics.push({code:'doctor.lock-present',subject:'.pipeline/lock'});
79
+ record = await readState(resolveChild(workspace,'.pipeline/state.json'));
80
+ } catch (error) {
81
+ issue(error,'.pipeline/state.json');
82
+ if (error.code==='record.missing' && diagnostics.length===1 &&
83
+ !(await inspectDirectory(resolveChild(workspace,'.pipeline'))).exists) result.status='not-installed';
84
+ return result;
85
+ }
86
+ const state = record.value;
87
+ if (absoluteRoot(state.workspace) !== workspace) {
88
+ diagnostics.push({code:'doctor.workspace-binding',subject:'.pipeline/state.json'});return result;
89
+ }
90
+ result.recordedStatus = state.status;
91
+ if (state.pending !== null) {
92
+ result.status='needs-reconciliation';
93
+ diagnostics.push({code:'doctor.pending',subject:'.pipeline/state.json'});
94
+ if(selectedPath===undefined) {
95
+ const matches=result.history.entries.filter(e=>['switch','switch-continuation'].includes(e.kind) && e.pendingDigest===state.pending);
96
+ if(matches.length===1)selectedPath=matches[0].recovery;
97
+ else if(matches.length>1)diagnostics.push({code:'doctor.switch-pending-binding',subject:'history'});
98
+ }
99
+ await inspectSelectedRecovery();return result;
100
+ }
101
+ const matchingTransactions=()=>{
102
+ if(!state.activation)return result.history.entries.filter(item=>item.status==='journal-completed' && item.desiredHash===contractDigest(state.active));
103
+ const anchor=state.activation,item=result.history.entries.find(e=>e.recovery===anchor.recovery);
104
+ if(!item || item.status!=='journal-completed' || item.recoveryHash!==anchor.recoveryHash ||
105
+ item.journalHash!==anchor.journalHead.hash || item.journalSequence!==anchor.journalHead.sequence ||
106
+ item.desiredHash!==contractDigest(state.active)) {
107
+ diagnostics.push({code:'doctor.activation-binding',subject:'.pipeline/state.json'});return [];
108
+ }
109
+ if(selectedPath!==undefined && selectedPath!==anchor.recovery)
110
+ diagnostics.push({code:'doctor.activation-selection',subject:selectedPath});
111
+ return [item];
112
+ };
113
+ if (!state.active) {
114
+ const matches=matchingTransactions();
115
+ result.matchingTransactions=matches.map(item=>item.recovery);
116
+ // Absence of an active deployment alone is not proof of completed removal.
117
+ // Check every matching transaction, never choose one by timestamp or allow
118
+ // explicit selection to hide another invalid matching record.
119
+ if(result.history.entries.length && !matches.length)
120
+ diagnostics.push({code:'doctor.transaction-missing',subject:'history'});
121
+ if(selectedPath===undefined && matches.length)selectedPath=matches[0].recovery;
122
+ await inspectSelectedRecovery();
123
+ for(const match of matches)if(match.recovery!==selectedPath) {
124
+ try {
125
+ const checked=await inspectDoctorRecovery(workspace,match.recovery);
126
+ if(checked.status!=='applied')diagnostics.push({code:'doctor.transaction-not-applied',subject:match.recovery});
127
+ for(const code of checked.diagnostics)diagnostics.push({code,subject:match.recovery});
128
+ }catch(error){issue(error,match.recovery);}
129
+ }
130
+ try {
131
+ if(contractDigest(await inspectHistory(workspace))!==contractDigest(result.history))
132
+ diagnostics.push({code:'doctor.observation-drift',subject:'history'});
133
+ if((await readState(resolveChild(workspace,'.pipeline/state.json'))).digest!==record.digest)
134
+ diagnostics.push({code:'doctor.observation-drift',subject:'.pipeline/state.json'});
135
+ if((await inspectDirectory(resolveChild(workspace,'.pipeline/lock'))).exists &&
136
+ !diagnostics.some(d=>d.code==='doctor.lock-present'))
137
+ diagnostics.push({code:'doctor.lock-present',subject:'.pipeline/lock'});
138
+ }catch(error){issue(error,'readback');}
139
+ const verified=state.status==='not-installed' && diagnostics.length===0 && result.history.complete &&
140
+ (!result.history.entries.length || result.transactionEvidence==='pass');
141
+ result.status=verified?'not-installed':'needs-reconciliation';
142
+ result.configuration=verified?'not-installed':'fail';
143
+ if(diagnostics.length && selectedPath!==undefined)result.transactionEvidence='fail';
144
+ // ready remains false: successful removal is not an installed pipeline.
145
+ return result;
146
+ }
147
+ const matches=matchingTransactions();
148
+ if (matches.length===0) diagnostics.push({code:'doctor.transaction-missing',subject:'history'});
149
+ else if (selectedPath===undefined) selectedPath=matches[0].recovery;
150
+ result.matchingTransactions=matches.map(item=>item.recovery);
151
+ result.pipeline = {id:state.active.pipelineId,version:state.active.version,providers:[...state.active.providers]};
152
+ if(state.active.bundles) result.pipeline.bundles=Object.entries(state.active.bundles).map(([id,b])=>({id,
153
+ suppliedProviders:[...b.providers],installedProviders:b.providers.filter(p=>state.active.providers.includes(p)),
154
+ entry:b.entry.target,runtime:'not-run'}));
155
+ try { await verifyInstalledSnapshot(state); }
156
+ catch (error) { issue(error,'snapshot'); }
157
+ const names = [...new Set(state.active.owned.map(item=>item.path))];
158
+ let observations;
159
+ try { observations = await observeTargets(workspace,names); }
160
+ catch (error) { issue(error,'targets');return result; }
161
+ const bytes = new Map(observations.map(item=>[item.path,item.bytes]));
162
+ for (const owned of state.active.owned) {
163
+ let actual = null;
164
+ try {
165
+ const value = bytes.get(owned.path);
166
+ if (value !== null && owned.kind === 'file') actual=sha256(value);
167
+ if (value !== null && owned.kind === 'field') {
168
+ const field=readConfigField(owned.path,value,owned.pointer);
169
+ if (field.present) actual=contractDigest(field.value);
170
+ }
171
+ if (actual!==owned.managedHash) diagnostics.push({code:'doctor.owned-drift',subject:owned.path,pointer:owned.pointer});
172
+ } catch (error) {issue(error,owned.path);}
173
+ targets.push({path:owned.path,pointer:owned.pointer,expectedHash:owned.managedHash,observedHash:actual});
174
+ }
175
+ // Bind the selected historical record to the current active deployment, not
176
+ // merely a valid old journal. inspectRecovery validates that relationship.
177
+ result.configuration=diagnostics.length?'fail':'pass';
178
+ await inspectSelectedRecovery();
179
+ for(const match of matches) if(match.recovery!==selectedPath) {
180
+ try {
181
+ const checked=await inspectDoctorRecovery(workspace,match.recovery,{fieldOwnershipOnly:true});
182
+ for(const path of checked.fieldProjections)result.fieldProjections.push({recovery:match.recovery,path});
183
+ if(checked.status!=='applied') {
184
+ result.transactionEvidence='fail';
185
+ diagnostics.push({code:'doctor.transaction-not-applied',subject:match.recovery});
186
+ }
187
+ for(const code of checked.diagnostics) diagnostics.push({code,subject:match.recovery});
188
+ }catch(error){result.transactionEvidence='fail';issue(error,match.recovery);}
189
+ }
190
+ try {
191
+ const finalHistory=await inspectHistory(workspace);
192
+ if (contractDigest(finalHistory)!==contractDigest(result.history))
193
+ diagnostics.push({code:'doctor.observation-drift',subject:'history'});
194
+ if ((await inspectDirectory(resolveChild(workspace,'.pipeline/lock'))).exists &&
195
+ !diagnostics.some(d=>d.code==='doctor.lock-present'))
196
+ diagnostics.push({code:'doctor.lock-present',subject:'.pipeline/lock'});
197
+ if ((await readState(resolveChild(workspace,'.pipeline/state.json'))).digest!==record.digest)
198
+ diagnostics.push({code:'doctor.observation-drift',subject:'.pipeline/state.json'});
199
+ for (const item of await observeTargets(workspace,names)) {
200
+ const before=bytes.get(item.path);
201
+ if ((before===null?null:sha256(before))!==(item.bytes===null?null:sha256(item.bytes)))
202
+ diagnostics.push({code:'doctor.observation-drift',subject:item.path});
203
+ }
204
+ } catch (error) {issue(error,'readback');}
205
+ if (diagnostics.some(d=>d.code==='doctor.observation-drift' || d.subject==='readback'))
206
+ result.transactionEvidence=selectedPath===undefined?'not-verified':'fail';
207
+ if (!result.history.complete || result.history.diagnostics.length)
208
+ result.transactionEvidence='fail';
209
+ if (state.status!=='ready') diagnostics.push({code:'doctor.state-not-ready',subject:'.pipeline/state.json'});
210
+ result.configuration=diagnostics.length?'fail':'pass';
211
+ result.ready=diagnostics.length===0 && result.history.complete && result.transactionEvidence==='pass';
212
+ result.status=result.ready?'ready':diagnostics.some(d=>d.code==='doctor.owned-drift')?'drift':'needs-reconciliation';
213
+ return result;
214
+ }
@@ -0,0 +1,170 @@
1
+ import {opendir} from 'node:fs/promises';
2
+ import {absoluteRoot,resolveChild,inspectDirectory} from '../workspace/paths.js';
3
+ import {readRecord} from './state.js';
4
+ import {readJournal} from './journal.js';
5
+ import {ContractError,fail} from '../contracts/parse.js';
6
+ import {contractDigest} from '../contracts/semantic.js';
7
+ import {inspectRecovery} from './apply.js';
8
+ import {readSwitchRecovery} from './switch-recovery-store.js';
9
+ import {readSwitchContinuationRecovery} from './switch-continuation-recovery.js';
10
+ import {inspectMigrationPending} from './migration-pending.js';
11
+
12
+ const idPattern=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
13
+ const maxEntries=1000;
14
+ async function list(workspace,relative) {
15
+ const directory=resolveChild(workspace,relative),names=[];
16
+ if (!(await inspectDirectory(directory)).exists) return names;
17
+ for await (const entry of await opendir(directory)) {
18
+ if (names.length>=maxEntries) fail('history.limit');
19
+ // Inspect exact children rather than following directory entry links.
20
+ if (!idPattern.test(entry.name)) fail('history.entry-name');
21
+ await inspectDirectory(resolveChild(workspace,relative+'/'+entry.name));
22
+ names.push(entry.name);
23
+ }
24
+ return names.sort();
25
+ }
26
+
27
+ // Read-only metadata inventory. A terminal journal is NOT proof of activation,
28
+ // approval, backup validity or current target bytes. All entries remain protected
29
+ // from cleanup until a separate dependency-aware retention scanner certifies them.
30
+ export async function inspectHistory(workspace) {
31
+ workspace=absoluteRoot(workspace);
32
+ const entries=[],diagnostics=[],continuations=[],switchContinuations=[];
33
+ const records=new Map(),predecessors=new Map();
34
+ const result={workspace,entries,diagnostics,complete:false,automaticActions:false};
35
+ const issue=(e,subject)=>diagnostics.push({code:e instanceof ContractError?e.code:'history.io',subject});
36
+ let journals,transactions;
37
+ try {
38
+ if (!(await inspectDirectory(workspace)).exists) fail('history.workspace-missing');
39
+ const migration=await inspectMigrationPending(workspace);
40
+ if(migration.blocked){diagnostics.push(...migration.blockers);return result;}
41
+ journals=await list(workspace,'.pipeline/journals');
42
+ transactions=await list(workspace,'.pipeline/transactions');
43
+ } catch(e) {issue(e,'inventory');return result;}
44
+ const js=new Set(journals),ts=new Set(transactions);
45
+ for (const id of [...new Set([...journals,...transactions])].sort()) {
46
+ const item={id,journal:js.has(id)?'.pipeline/journals/'+id:null,
47
+ recovery:ts.has(id)?'.pipeline/transactions/'+id+'/recovery.json':null,
48
+ status:'unknown',protected:true};
49
+ entries.push(item);
50
+ if (!item.journal || !item.recovery) {
51
+ item.status='orphan';diagnostics.push({code:'history.orphan',subject:id});continue;
52
+ }
53
+ try {
54
+ const filename=resolveChild(workspace,item.recovery),record=await readRecord(filename);
55
+ records.set(item.recovery,record.value);
56
+ if(record.value?.kind==='switch-continuation-recovery') {
57
+ const checked=await readSwitchContinuationRecovery(workspace,item.recovery),journal=checked.journal;
58
+ const parent=checked.root;
59
+ if(checked.fileHash!==record.digest)fail('history.observation-drift');
60
+ records.set(item.recovery,{...record.value,previous:parent.record.previous});
61
+ item.kind='switch-continuation';item.pendingDigest=checked.record.digest;
62
+ item.status=journal.status==='completed'?'journal-completed':journal.status==='uncertain'?'uncertain':journal.status==='failed'?'failed':'open';
63
+ item.recoveryHash=record.digest;item.journalHash=journal.lastFileHash;item.journalSequence=journal.sequence;
64
+ item.desiredHash=contractDigest(parent.record.prepared.preview.phases[1].preview.plan.desired);
65
+ switchContinuations.push(item);
66
+ if(item.status!=='journal-completed')diagnostics.push({code:'history.unfinished',subject:id});
67
+ continue;
68
+ }
69
+ if(record.value?.kind==='switch-recovery') {
70
+ const checked=await readSwitchRecovery(workspace,item.recovery),journal=checked.journal;
71
+ if(checked.fileHash!==record.digest)fail('history.observation-drift');
72
+ item.kind='switch';item.pendingDigest=checked.record.digest;
73
+ item.status=journal.status==='completed'?'journal-completed':journal.status==='uncertain'?'uncertain':journal.status==='failed'?'failed':'open';
74
+ item.recoveryHash=record.digest;item.journalHash=journal.lastFileHash;item.journalSequence=journal.sequence;
75
+ item.desiredHash=contractDigest(checked.record.prepared.preview.phases[1].preview.plan.desired);
76
+ if(item.status!=='journal-completed')diagnostics.push({code:'history.unfinished',subject:id});
77
+ continue;
78
+ }
79
+ const plan=record.value?.prepared?.preview?.plan,previous=record.value?.previous;
80
+ const journal=await readJournal(workspace,item.journal,plan,previous);
81
+ item.status=journal.receipt?.status==='completed'?'journal-completed':
82
+ journal.phase==='interrupted'?'uncertain':journal.phase==='terminal'?'failed':'open';
83
+ item.recoveryHash=record.digest;item.journalHash=journal.lastHash;item.journalSequence=journal.sequence;
84
+ item.desiredHash=contractDigest(plan.desired);
85
+ if(record.value?.prepared?.kind==='prepared-continuation')continuations.push(item);
86
+ if (item.status!=='journal-completed') diagnostics.push({code:'history.unfinished',subject:id});
87
+ if ((await readRecord(filename)).digest!==record.digest) fail('history.observation-drift');
88
+ } catch(e) {item.status='unknown';issue(e,id);}
89
+ }
90
+ // Completed history is checked as historical evidence, not against today's
91
+ // target bytes. Validate explicit predecessor bindings before using any anchor
92
+ // to distinguish repeated equal deployments or removal cycles.
93
+ const byPath=new Map(entries.map(e=>[e.recovery,e]));
94
+ for(const item of entries) {
95
+ try {
96
+ if(item.status==='journal-completed' && !continuations.includes(item)) {
97
+ if(['switch','switch-continuation'].includes(item.kind)) {
98
+ const evidence=await (item.kind==='switch'?readSwitchRecovery:readSwitchContinuationRecovery)(workspace,item.recovery);
99
+ if(evidence.fileHash!==item.recoveryHash || evidence.journal.lastFileHash!==item.journalHash ||
100
+ evidence.journal.sequence!==item.journalSequence)fail('history.observation-drift');
101
+ }else {
102
+ const evidence=await inspectRecovery(workspace,item.recovery,{evidenceOnly:true});
103
+ if(evidence.recoveryHash!==item.recoveryHash || evidence.journalHead.hash!==item.journalHash ||
104
+ evidence.journalHead.sequence!==item.journalSequence)fail('history.observation-drift');
105
+ }
106
+ }
107
+ const previous=records.get(item.recovery)?.previous,anchor=previous?.activation;
108
+ if(anchor) {
109
+ const parent=byPath.get(anchor.recovery);
110
+ if(!parent || parent.status!=='journal-completed' || parent.recoveryHash!==anchor.recoveryHash ||
111
+ parent.journalHash!==anchor.journalHead.hash || parent.journalSequence!==anchor.journalHead.sequence ||
112
+ parent.desiredHash!==contractDigest(previous.active))fail('history.activation-binding');
113
+ predecessors.set(item.recovery,anchor.recovery);
114
+ }
115
+ }catch(e){issue(e,item.id);}
116
+ }
117
+ // Linear graph walk; identical deployment hashes do not define graph edges.
118
+ const done=new Set();
119
+ for(const start of predecessors.keys()) {
120
+ const visiting=new Set();let current=start;
121
+ while(current && !done.has(current)) {
122
+ if(visiting.has(current)){diagnostics.push({code:'history.activation-cycle',subject:start});break;}
123
+ visiting.add(current);current=predecessors.get(current);
124
+ }
125
+ for(const name of visiting)done.add(name);
126
+ }
127
+ // Keep original status and hashes; only an independently validated completed
128
+ // continuation can resolve its exact predecessor's unfinished diagnostic.
129
+ // This proves readbacks, not activation or current target bytes.
130
+ for(const item of continuations)if(item.status==='journal-completed') {
131
+ try {
132
+ const evidence=await inspectRecovery(workspace,item.recovery,{evidenceOnly:true});
133
+ if(evidence.recoveryHash!==item.recoveryHash)fail('history.observation-drift');
134
+ const ancestors=evidence.lineage.map(link=>({link,old:entries.find(e=>e.recovery===link.recovery)}));
135
+ for(const {link,old} of ancestors)if(!old || old.recoveryHash!==link.recoveryHash || old.journalHash!==link.journalHead.hash ||
136
+ !['open','uncertain','failed','journal-completed'].includes(old.status))fail('history.continuation-binding');
137
+ for(const {old} of ancestors) {
138
+ old.resolvedBy=[...(old.resolvedBy??[]),item.recovery].sort();
139
+ old.resolution='continued';
140
+ }
141
+ item.resolves=evidence.resolves.recovery;
142
+ item.resolvesChain=ancestors.map(({old})=>old.recovery);
143
+ }catch(e){issue(e,item.id);}
144
+ }
145
+ for(const item of switchContinuations)if(item.status==='journal-completed') {
146
+ try {
147
+ const evidence=await readSwitchContinuationRecovery(workspace,item.recovery),p=evidence.record.preview;
148
+ if(evidence.fileHash!==item.recoveryHash || evidence.journal.lastFileHash!==item.journalHash ||
149
+ evidence.journal.sequence!==item.journalSequence)fail('history.continuation-binding');
150
+ const ancestors=evidence.ancestors.map(link=>({link,old:byPath.get(link.relative)}));
151
+ for(const {link,old} of ancestors)if(!old || !['switch','switch-continuation'].includes(old.kind) || !['uncertain','open','journal-completed'].includes(old.status) ||
152
+ old.recoveryHash!==link.fileHash || old.journalHash!==link.journal.lastFileHash ||
153
+ old.journalSequence!==link.journal.sequence)fail('history.continuation-binding');
154
+ for(const {old} of ancestors) {
155
+ old.resolvedBy=[...(old.resolvedBy??[]),item.recovery].sort();old.resolution='continued';
156
+ }
157
+ item.resolves=p.recoveryPath;item.resolvesChain=ancestors.map(({link})=>link.relative);
158
+ }catch(e){issue(e,item.id);}
159
+ }
160
+ for(let i=diagnostics.length-1;i>=0;i--)if(diagnostics[i].code==='history.unfinished' &&
161
+ entries.some(e=>e.id===diagnostics[i].subject && e.resolution==='continued'))diagnostics.splice(i,1);
162
+ try {
163
+ for(const item of entries)if(item.recoveryHash &&
164
+ (await readRecord(resolveChild(workspace,item.recovery))).digest!==item.recoveryHash)fail('history.observation-drift');
165
+ if (JSON.stringify(journals)!==JSON.stringify(await list(workspace,'.pipeline/journals')) ||
166
+ JSON.stringify(transactions)!==JSON.stringify(await list(workspace,'.pipeline/transactions'))) fail('history.observation-drift');
167
+ result.complete=true;
168
+ } catch(e) {issue(e,'readback');}
169
+ return result;
170
+ }
@@ -0,0 +1,49 @@
1
+ import {readdir,lstat,readFile} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {fileURLToPath} from 'node:url';
4
+ import {sha256} from '../source/inventory.js';
5
+ import {contractDigest} from '../contracts/semantic.js';
6
+ import {parse,fail} from '../contracts/parse.js';
7
+
8
+ const installedRoot=fileURLToPath(new URL('../../',import.meta.url));
9
+ // Root override is a trusted test seam, never an argv/source-package option.
10
+ export async function readInstallerIdentity(root=installedRoot){
11
+ root=path.resolve(root);const files=[];let total=0;
12
+ const walk=async(relative,depth=0)=>{
13
+ if(depth>32||files.length>=2048)fail('installer.inventory-limit');
14
+ const filename=path.join(root,relative),before=await lstat(filename);
15
+ if(before.isSymbolicLink())fail('installer.inventory-link');
16
+ if(before.isDirectory()){
17
+ const names=(await readdir(filename)).sort();if(names.length>2048)fail('installer.inventory-limit');
18
+ for(const name of names)await walk(relative+'/'+name,depth+1);
19
+ }else{
20
+ if(!before.isFile()||before.size>2*1024*1024||total+before.size>64*1024*1024)fail('installer.inventory-limit');
21
+ const bytes=await readFile(filename),after=await lstat(filename);
22
+ if(!after.isFile()||after.isSymbolicLink()||before.ino!==after.ino||before.dev!==after.dev||
23
+ before.mtimeMs!==after.mtimeMs||before.size!==after.size||bytes.length!==before.size)fail('installer.inventory-drift');
24
+ total+=bytes.length;files.push({path:relative,hash:sha256(bytes)});
25
+ }
26
+ };
27
+ await walk('package.json');await walk('src');await walk('schemas');
28
+ const packageBytes=await readFile(path.join(root,'package.json'));
29
+ if(sha256(packageBytes)!==files.find(f=>f.path==='package.json').hash)fail('installer.inventory-drift');
30
+ const manifest=parse(packageBytes.toString('utf8'),'json');
31
+ if(typeof manifest.name!=='string'||typeof manifest.version!=='string')fail('installer.package');
32
+ const body={schemaVersion:1,kind:'installer-payload-identity',name:manifest.name,version:manifest.version,
33
+ files:files.sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0)};
34
+ return {...body,digest:contractDigest(body)};
35
+ }
36
+
37
+ export async function bindMigrationInstaller(preview,root=installedRoot){
38
+ const installer=await readInstallerIdentity(root);
39
+ const body={schemaVersion:1,kind:'installer-bound-migration-preview',installer,preview};
40
+ const record={...body,digest:contractDigest(body)};
41
+ // Enforce saved-record reader bounds before emitting an unusable preview.
42
+ parse(JSON.stringify(record),'json');return record;
43
+ }
44
+ export async function verifyMigrationInstaller(record,root=installedRoot){
45
+ const copy=parse(JSON.stringify(record),'json'),{digest,...body}=copy;
46
+ if(copy.kind!=='installer-bound-migration-preview'||copy.schemaVersion!==1||contractDigest(body)!==digest)fail('installer.preview-binding');
47
+ if(contractDigest(await readInstallerIdentity(root))!==contractDigest(copy.installer))fail('installer.changed');
48
+ return copy.preview;
49
+ }
@@ -0,0 +1,142 @@
1
+ import { mkdir, open, readdir } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { fail, ContractError } from '../contracts/parse.js';
4
+ import { validateOperation, validateReceiptForPlan, contractDigest } from '../contracts/semantic.js';
5
+ import { resolveChild, inspectDirectory } from '../workspace/paths.js';
6
+ import { readRecord } from './state.js';
7
+ import { assertLockHeld } from './lock.js';
8
+ import { sha256 } from '../source/inventory.js';
9
+
10
+ const nameFor = seq => String(seq).padStart(6, '0') + '.json';
11
+ const exact = (record, keys) => record !== null && typeof record === 'object' &&
12
+ Object.keys(record).sort().join(',') === [...keys].sort().join(',');
13
+
14
+ function assertOutcome(target,payload) {
15
+ if(!exact(payload,['status','observedHash']) || !['completed','failed','uncertain'].includes(payload.status))fail('journal.sequence');
16
+ if((payload.status==='completed' && payload.observedHash!==target.desiredHash) ||
17
+ (payload.status==='failed' && payload.observedHash!==target.beforeHash) ||
18
+ (payload.observedHash!==null && !/^sha256:[a-f0-9]{64}$/.test(payload.observedHash)))fail('journal.outcome');
19
+ }
20
+
21
+ function receipt(plan, previous, outcomes, pending) {
22
+ if (pending !== null) outcomes = [...outcomes, {status:'uncertain',observedHash:null}];
23
+ const operations = plan.targets.map((target, i) => ({operationId:target.id,
24
+ beforeHash:target.beforeHash,desiredHash:target.desiredHash,
25
+ ...(outcomes[i] ?? {status:'skipped',observedHash:null})}));
26
+ const status = operations.every(op=>op.status==='completed') ? 'completed' :
27
+ operations.some(op=>op.status==='uncertain') ? 'uncertain' : 'failed';
28
+ return validateReceiptForPlan({schemaVersion:1,kind:'receipt',planDigest:contractDigest(plan),status,operations},plan,previous);
29
+ }
30
+
31
+ // Read-only interpretation of persisted evidence. No rollback, continuation or
32
+ // ready-state activation. An intent without outcome is uncertain, not failed.
33
+ export async function readJournal(workspace, relative, plan, previous) {
34
+ validateOperation(plan,previous);
35
+ if(plan.kind!=='plan')fail('journal.plan');
36
+ if (plan.workspace !== workspace || !/^\.pipeline\/journals\/[a-f0-9-]{36}$/.test(relative)) fail('journal.path');
37
+ const directory = resolveChild(workspace,relative);
38
+ try {
39
+ await inspectDirectory(directory);
40
+ const names = (await readdir(directory)).sort();
41
+ if (!names.length || names.length > 1+2*plan.targets.length) fail('journal.sequence');
42
+ let chain=null, pending=null, stopped=false; const outcomes=[],planDigest=contractDigest(plan);
43
+ for (let seq=0;seq<names.length;seq++) {
44
+ if(names[seq]!==nameFor(seq)) fail('journal.sequence');
45
+ const record=await readRecord(resolveChild(workspace,relative+'/'+names[seq]));
46
+ const event=record.value;
47
+ if(!exact(event,['schemaVersion','seq','previous','planDigest','kind','payload']) || event.schemaVersion!==1 ||
48
+ event.seq!==seq || event.previous!==chain || event.planDigest!==planDigest) fail('journal.binding');
49
+ if(seq===0) {
50
+ if(event.kind!=='start' || event.payload!==null)fail('journal.sequence');
51
+ } else if(event.kind==='intent') {
52
+ if(pending!==null || outcomes.length>=plan.targets.length || stopped ||
53
+ !exact(event.payload,['operationId']) || event.payload.operationId!==plan.targets[outcomes.length].id) fail('journal.sequence');
54
+ pending=outcomes.length;
55
+ } else if(event.kind==='outcome') {
56
+ if(pending===null)fail('journal.sequence');
57
+ assertOutcome(plan.targets[pending],event.payload);
58
+ outcomes.push(event.payload);pending=null;stopped=event.payload.status!=='completed';
59
+ } else fail('journal.sequence');
60
+ chain=record.digest;
61
+ }
62
+ const terminal=outcomes.length===plan.targets.length || stopped;
63
+ return {sequence:names.length,lastHash:chain,nextIndex:outcomes.length,pending,
64
+ phase:pending!==null?'interrupted':terminal?'terminal':'open',
65
+ receipt:terminal || pending!==null ? receipt(plan,previous,outcomes,pending) : null};
66
+ } catch(error) {throw error instanceof ContractError ? error : new ContractError('journal.io');}
67
+ }
68
+
69
+ export async function createJournal(lock, plan, previous=null, {ioBoundary=async()=>{},onLocation=async()=>{}}={}) {
70
+ validateOperation(plan,previous);
71
+ if(plan.kind!=='plan')fail('journal.plan');
72
+ await assertLockHeld(lock);
73
+ if(plan.workspace!==lock.workspace) fail('journal.workspace');
74
+ plan=structuredClone(plan);previous=structuredClone(previous);
75
+ const relative='.pipeline/journals/'+randomUUID();
76
+ const parent=resolveChild(lock.workspace,'.pipeline/journals'), directory=resolveChild(lock.workspace,relative);
77
+ const planDigest=contractDigest(plan),metrics={headReads:0,recordReadbacks:0,fullAudits:0};
78
+ async function persist(event) {
79
+ await assertLockHeld(lock);await inspectDirectory(directory);
80
+ const filename=resolveChild(lock.workspace,relative+'/'+nameFor(event.seq));
81
+ const detail={path:relative+'/'+nameFor(event.seq),stagedPath:relative+'/'+nameFor(event.seq)};
82
+ const bytes=Buffer.from(JSON.stringify(event)+'\n'),handle=await open(filename,'wx',0o600);
83
+ try {
84
+ await ioBoundary('opened',detail);
85
+ await handle.writeFile(bytes);await ioBoundary('written',detail);
86
+ await handle.sync();await ioBoundary('synced',detail);
87
+ }
88
+ finally {await handle.close();}
89
+ const record=await readRecord(filename);metrics.recordReadbacks++;
90
+ if(record.digest!==sha256(bytes))fail('journal.readback');
91
+ await ioBoundary('readback',detail);
92
+ return record.digest;
93
+ }
94
+ async function audit(){metrics.fullAudits++;return readJournal(lock.workspace,relative,plan,previous);}
95
+ try {
96
+ await onLocation({relative,directory,status:'planned'});
97
+ try {await mkdir(parent);}catch(error){if(error.code!=='EEXIST')throw error;}
98
+ await inspectDirectory(parent);await mkdir(directory);
99
+ await onLocation({relative,directory,status:'created'});
100
+ await persist({schemaVersion:1,seq:0,previous:null,planDigest,kind:'start',payload:null});
101
+ let state=await audit(), busy=false, broken=false;
102
+ await onLocation({relative,directory,status:'initialized'});
103
+ async function append(kind,payload) {
104
+ if(busy || broken)fail('journal.unavailable');
105
+ busy=true;
106
+ try {
107
+ await assertLockHeld(lock);
108
+ // Constant-size append verification. Older history is fully audited on
109
+ // terminal outcome and by readJournal (including apply's activation gate).
110
+ // A changed historical prefix may be detected at that later gate, not at
111
+ // the next append. This is not protection against hostile OS access.
112
+ const head=await readRecord(resolveChild(lock.workspace,relative+'/'+nameFor(state.sequence-1)));metrics.headReads++;
113
+ if(head.digest!==state.lastHash)fail('journal.drift');
114
+ const current=state;
115
+ if(kind==='intent') {
116
+ if(current.phase!=='open' || payload.operationId!==plan.targets[current.nextIndex]?.id)fail('journal.sequence');
117
+ } else {
118
+ if(current.pending===null)fail('journal.sequence');
119
+ assertOutcome(plan.targets[current.pending],payload);
120
+ }
121
+ const lastHash=await persist({schemaVersion:1,seq:state.sequence,previous:state.lastHash,planDigest,kind,payload});
122
+ const nextIndex=state.nextIndex+(kind==='outcome'?1:0),pending=kind==='intent'?state.nextIndex:null;
123
+ const terminal=nextIndex===plan.targets.length || (kind==='outcome' && payload.status!=='completed');
124
+ state={sequence:state.sequence+1,lastHash,nextIndex,pending,phase:pending!==null?'interrupted':terminal?'terminal':'open',receipt:null};
125
+ if(terminal) {
126
+ const verified=await audit();
127
+ if(verified.sequence!==state.sequence || verified.lastHash!==state.lastHash || verified.nextIndex!==state.nextIndex ||
128
+ verified.pending!==state.pending || verified.phase!==state.phase)fail('journal.drift');
129
+ state=verified;
130
+ }
131
+ // Nonterminal append results are compact observations, not receipts.
132
+ // Recovery reconstructs uncertainty using the full read-only reader.
133
+ return structuredClone(state);
134
+ }catch(error){broken=true;throw error instanceof ContractError?error:new ContractError('journal.io');}
135
+ finally{busy=false;}
136
+ }
137
+ return Object.freeze({relative,directory,
138
+ metrics:()=>({...metrics}),
139
+ intent: operationId=>append('intent',{operationId}),
140
+ outcome: (status,observedHash)=>append('outcome',{status,observedHash})});
141
+ }catch(error){throw error instanceof ContractError?error:new ContractError('journal.io');}
142
+ }