@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,133 @@
1
+ import {preparePlan} from './plan.js';
2
+ import {applyPrepared,preflightApply} from './apply.js';
3
+ import {applyRetention} from './retention-apply.js';
4
+ import {readRetentionPolicy,retentionLimits} from './retention-policy.js';
5
+ import {scanRetention} from './retention-scan.js';
6
+ import {scanCombinedRetention} from './retention-combined-scan.js';
7
+ import {readState} from './state.js';
8
+ import {acquireWorkspaceLock} from './lock.js';
9
+ import {inspectHistory} from './history.js';
10
+ import {requestShape} from './ownership.js';
11
+ import {absoluteRoot,resolveChild} from '../workspace/paths.js';
12
+ import {ContractError,fail} from '../contracts/parse.js';
13
+
14
+ const commandGuard=command=>{if(!['setup','update'].includes(command))fail('lifecycle.command');};
15
+ function registryGuard(registry) {
16
+ requestShape(registry,['adapters','sharedAdapter'],[],'provider.interface');
17
+ }
18
+ async function previousState(wrapper) {
19
+ try{return (await readState(resolveChild(wrapper,'.pipeline/state.json'))).value;}
20
+ catch(e){if(e.code==='record.missing')return null;throw e;}
21
+ }
22
+ function stateCommand(command,previous) {
23
+ if (command!==(previous?.active?'update':'setup')) fail('lifecycle.command-state');
24
+ }
25
+ async function historyGuard(wrapper) {
26
+ const history=await inspectHistory(wrapper);
27
+ if (!history.complete || history.diagnostics.length) fail('lifecycle.history');
28
+ }
29
+
30
+ // Preview may acquire source into temporary storage outside the workspace.
31
+ // It never authorizes apply, edits provider files or manufactures user approval.
32
+ export async function prepareLifecycle(input,registry) {
33
+ requestShape(input,['command','wrapper'],['manifestPath','network','tempRoot','rebind'],'lifecycle.input');
34
+ commandGuard(input.command);registryGuard(registry);
35
+ const wrapper=absoluteRoot(input.wrapper);
36
+ stateCommand(input.command,await previousState(wrapper));await historyGuard(wrapper);
37
+ const {command,...options}=input;
38
+ const prepared=await preparePlan({...options,wrapper,...registry});
39
+ if(prepared.preview.plan.command!==command)fail('lifecycle.command-drift');
40
+ return prepared;
41
+ }
42
+
43
+ // Trusted caller supplies both provider registry and output transport. Neither
44
+ // callback nor executable adapters are accepted from pipeline package data.
45
+ // The returned report contains locations/status codes, never raw config values.
46
+ export async function applyLifecycle(input,registry,{report=async()=>{},retention=null}={}) {
47
+ requestShape(input,['command','wrapper','prepared','approval'],[],'lifecycle.input');
48
+ commandGuard(input.command);registryGuard(registry);
49
+ if(typeof report!=='function')fail('lifecycle.reporter');
50
+ // This is a separately approved exact cleanup, never a supplier policy or an
51
+ // implied consequence of approving setup/update. Detach before asynchronous IO.
52
+ if(retention!==null) {
53
+ requestShape(retention,['preview','approval'],[],'lifecycle.retention');
54
+ retention=structuredClone(retention);
55
+ }
56
+ requestShape(input.prepared,['kind','preview','stateFileHash','rebind','preparation','runtime','digest'],[],'lifecycle.prepared');
57
+ requestShape(input.prepared.preview,['schemaVersion','plan','observations','outputs','digest'],[],'lifecycle.preview');
58
+ requestShape(input.prepared.preview.plan,['schemaVersion','kind','workspace','command','beforeStateHash','source','desired','targets'],[],'lifecycle.plan');
59
+ const wrapper=absoluteRoot(input.wrapper),result={command:input.command,workspace:wrapper,status:'failed',
60
+ journal:null,recovery:null,error:null,lockRelease:'not-acquired',runtime:'not-run'};
61
+ let lock;
62
+ const emit=async event=>{try{await report(structuredClone(event));}catch{fail('lifecycle.report');}};
63
+ try {
64
+ // Reject wrong verb/root before acquiring a lock or creating metadata.
65
+ if(input.prepared?.preview?.plan?.command!==input.command || input.prepared?.preview?.plan?.workspace!==wrapper)
66
+ fail('lifecycle.prepared-binding');
67
+ lock=await acquireWorkspaceLock(wrapper);result.lockRelease='pending';
68
+ const previous=await previousState(wrapper);stateCommand(input.command,previous);
69
+ await historyGuard(wrapper);
70
+ const localPolicy=await readRetentionPolicy(wrapper);
71
+ if(localPolicy.mode==='automatic' && retention!==null)fail('lifecycle.retention-conflict');
72
+ if(localPolicy.mode==='automatic') {
73
+ await preflightApply(lock,input.prepared,input.approval,registry,previous);
74
+ retention={preview:await (localPolicy.policy.schemaVersion===2?scanCombinedRetention:scanRetention)(wrapper,{policy:retentionLimits(localPolicy.policy),now:Date.now()}),
75
+ approval:{decision:'workspace-policy',policyHash:localPolicy.hash}};
76
+ }
77
+ if(retention!==null) {
78
+ // No cleanup for a stale/unapproved main operation. Preflight is read-only.
79
+ await preflightApply(lock,input.prepared,input.approval,registry,previous);
80
+ result.cleanup={status:'not-started'};
81
+ try {
82
+ let announced=false;
83
+ result.cleanup=await applyRetention(lock,retention.preview,retention.approval,{report:async event=>{
84
+ if(!announced) {
85
+ announced=true;
86
+ await emit({kind:'startup-retention',previewDigest:retention.preview.digest,
87
+ policy:retention.preview.retention.policy,policyPath:localPolicy.path,policyHash:localPolicy.hash,
88
+ authorization:retention.approval.decision,totals:retention.preview.retention.totals,
89
+ protectedCount:retention.preview.retention.protected.length});
90
+ }
91
+ await emit(event);
92
+ }});
93
+ }catch(e) {
94
+ result.cleanup={status:'failed',error:e instanceof ContractError?e.code:'retention-apply.io'};
95
+ }
96
+ // Cleanup failure blocks new target writes. Keep its outcome separate:
97
+ // partial cleanup must not be described as a failed setup execution.
98
+ if(result.cleanup.status!=='completed' || result.cleanup.outputError) {
99
+ result.status='not-started';fail('lifecycle.retention');
100
+ }
101
+ await historyGuard(wrapper);
102
+ }
103
+ // Fresh preflight inside applyPrepared rechecks the main operation after
104
+ // cleanup. Its new journal does not exist during startup cleanup.
105
+ const applied=await applyPrepared(lock,input.prepared,input.approval,registry,previous,{
106
+ onJournal:async location=>{
107
+ const runId=location.relative.split('/').at(-1);
108
+ result.journal={runId,path:location.directory,status:location.status};
109
+ result.recovery={path:resolveChild(wrapper,'.pipeline/transactions/'+runId+'/recovery.json'),status:'not-created'};
110
+ await emit({kind:'journal-location',...result.journal,recovery:result.recovery});
111
+ },
112
+ ioBoundary:async detail=>{
113
+ if(detail.purpose==='recovery' && detail.phase==='opened') {
114
+ result.recovery.status='created-unverified';await emit({kind:'recovery-location',...result.recovery});
115
+ }
116
+ if(detail.purpose==='recovery' && detail.phase==='readback') {
117
+ result.recovery.status='verified';await emit({kind:'recovery-location',...result.recovery});
118
+ }
119
+ }
120
+ });
121
+ result.status=applied.status;
122
+ } catch(e) {result.error=e instanceof ContractError?e.code:'lifecycle.io';}
123
+ finally {
124
+ if(lock) {
125
+ try{await lock.release();result.lockRelease='released';}
126
+ catch(e){result.lockRelease='failed';result.releaseError=e instanceof ContractError?e.code:'lock.release';}
127
+ }
128
+ }
129
+ // Output failure after a committed installation must not rewrite its outcome.
130
+ try{await emit({kind:'operation-result',...result});}
131
+ catch{result.outputError='lifecycle.report';}
132
+ return result;
133
+ }
@@ -0,0 +1,20 @@
1
+ import {fail} from '../contracts/parse.js';
2
+
3
+ export const MAX_CONTINUATION_DEPTH=32;
4
+ const recoveryPath=/^\.pipeline\/transactions\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\/recovery\.json$/;
5
+
6
+ export function assertContinuationCapacity(depth) {
7
+ if(!Number.isSafeInteger(depth) || depth<0 || depth>=MAX_CONTINUATION_DEPTH)fail('reconciliation.depth');
8
+ }
9
+
10
+ // One guard per traversal. Reject before opening the next record; package data
11
+ // cannot change this limit or supply an alternative identity for the same path.
12
+ export function createLineageGuard() {
13
+ const seen=new Set();
14
+ return Object.freeze({visit(path) {
15
+ if(typeof path!=='string' || !recoveryPath.test(path))fail('reconciliation.lineage');
16
+ if(seen.has(path))fail('reconciliation.cycle');
17
+ assertContinuationCapacity(seen.size);
18
+ seen.add(path);
19
+ }});
20
+ }
@@ -0,0 +1,109 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { hostname } from 'node:os';
3
+ import { mkdir, open, readdir, unlink, rmdir, lstat } from 'node:fs/promises';
4
+ import { ContractError, fail } from '../contracts/parse.js';
5
+ import { absoluteRoot, inspectDirectory, resolveChild } from '../workspace/paths.js';
6
+ import { readRecord } from './state.js';
7
+ import { sha256 } from '../source/inventory.js';
8
+ import { assertNoRepositoryPending,inspectRepositoryPending } from './repository-pending.js';
9
+ import { assertBootstrapLockHeld } from './bootstrap-lock.js';
10
+ import {assertNoMigrationPending} from './migration-pending.js';
11
+ import {assertLegacyUnityLease,legacyUnityLeasePayload} from '../migrations/legacy-unity-lease.js';
12
+
13
+ const heldLocks = new WeakMap();
14
+ const migrationLocks = new WeakMap();
15
+ export async function assertMigrationLockHeld(lock,recoveryPath,recoveryHash){
16
+ await assertLockHeld(lock);
17
+ const lease=migrationLocks.get(lock);
18
+ if(!lease)fail('migration.lock-capability');
19
+ const expected=legacyUnityLeasePayload(lease,lock.workspace);
20
+ if(expected.recoveryPath!==recoveryPath||expected.recoveryHash!==recoveryHash)fail('migration.lock-binding');
21
+ }
22
+ export async function assertLockHeld(lock) {
23
+ const check = lock && heldLocks.get(lock);
24
+ if (!check) fail('lock.capability');
25
+ await check();
26
+ }
27
+
28
+ // Cooperative workspace-local exclusion. Existing/stale locks are never stolen.
29
+ // This does not prevent an editor or hostile process from replacing filesystem
30
+ // entries; S5 apply must independently recheck each subject before every write.
31
+ export async function acquireWorkspaceLock(workspace,{repositoryOperation,migrationOperation}={}) {
32
+ workspace = absoluteRoot(workspace);
33
+ if(repositoryOperation!==undefined&&migrationOperation!==undefined)fail('lock.capability-conflict');
34
+ const guard=async()=>{
35
+ if(migrationOperation!==undefined){
36
+ await assertLegacyUnityLease(migrationOperation,workspace);
37
+ const pending=await inspectRepositoryPending(workspace);
38
+ if(pending.blockers.some(b=>b.code!=='migration.pending'))fail('lock.repository-pending');
39
+ }else if(repositoryOperation!==undefined) {
40
+ // A repository-operation capability does not authorize migration recovery.
41
+ await assertNoMigrationPending(workspace);
42
+ await assertBootstrapLockHeld(repositoryOperation);
43
+ if(repositoryOperation.workspace!==workspace)fail('lock.repository-capability');
44
+ } else await assertNoRepositoryPending(workspace);
45
+ };
46
+ if (!(await inspectDirectory(workspace)).exists) fail('lock.workspace-missing');
47
+ await guard();
48
+ const metadata = resolveChild(workspace, '.pipeline');
49
+ const directory = resolveChild(workspace, '.pipeline/lock');
50
+ const ownerFile = resolveChild(workspace, '.pipeline/lock/owner.json');
51
+ let createdMetadata = null;
52
+ try {
53
+ try { await mkdir(metadata); createdMetadata = await lstat(metadata); } catch (error) { if (error.code !== 'EEXIST') throw error; }
54
+ await inspectDirectory(metadata);
55
+ try { await mkdir(directory); }
56
+ catch (error) { if (error.code === 'EEXIST') fail('lock.busy'); throw error; }
57
+ // Failure after mkdir deliberately leaves a non-acquirable lock for review.
58
+ await inspectDirectory(directory);
59
+ const owner = { schemaVersion: 1, workspace, token: randomUUID(), pid: process.pid,
60
+ host: hostname(), createdAt: new Date().toISOString() };
61
+ const bytes = Buffer.from(JSON.stringify(owner) + '\n');
62
+ const handle = await open(ownerFile, 'wx', 0o600);
63
+ try { await handle.writeFile(bytes); await handle.sync(); }
64
+ finally { await handle.close(); }
65
+ const digest = sha256(bytes);
66
+ if ((await readRecord(ownerFile)).digest !== digest) fail('lock.readback');
67
+ await guard();
68
+ let released = false;
69
+ const check = async () => {
70
+ if (released) fail('lock.released');
71
+ await inspectDirectory(directory);
72
+ if ((await readRecord(ownerFile)).digest !== digest) fail('lock.owner-changed');
73
+ await guard();
74
+ };
75
+ const lock = Object.freeze({ workspace, directory, token: owner.token,
76
+ async release({removeEmptyMetadata=false}={}) {
77
+ if (released) fail('lock.released');
78
+ try {
79
+ await inspectDirectory(directory);
80
+ if ((await readRecord(ownerFile)).digest !== digest) fail('lock.owner-changed');
81
+ const entries = await readdir(directory);
82
+ if (entries.length !== 1 || entries[0] !== 'owner.json') fail('lock.foreign-entry');
83
+ await unlink(ownerFile);
84
+ // Non-recursive only: concurrent/foreign files prevent removal.
85
+ await rmdir(directory);
86
+ released = true;
87
+ // Only a failed migration start may remove metadata created by this
88
+ // lock. Never recursively delete, remove pre-existing metadata, or
89
+ // discard a marker/recovery/foreign entry left by an interrupted write.
90
+ if(removeEmptyMetadata && migrationOperation!==undefined && createdMetadata) {
91
+ const current=await lstat(metadata);
92
+ if(current.isDirectory() && !current.isSymbolicLink() &&
93
+ current.dev===createdMetadata.dev && current.ino===createdMetadata.ino) {
94
+ try { await rmdir(metadata); }
95
+ catch(error) { if(!['ENOTEMPTY','EEXIST'].includes(error.code))throw error; }
96
+ }
97
+ }
98
+ } catch (error) {
99
+ throw error instanceof ContractError ? error : new ContractError('lock.release-failed');
100
+ }
101
+ }
102
+ });
103
+ heldLocks.set(lock, check);
104
+ if(migrationOperation!==undefined)migrationLocks.set(lock,migrationOperation);
105
+ return lock;
106
+ } catch (error) {
107
+ throw error instanceof ContractError ? error : new ContractError('lock.io');
108
+ }
109
+ }
@@ -0,0 +1,62 @@
1
+ import {prepareRepairPlan,validateRepairRecord} from './repair.js';
2
+ import {prepareRemoval,validateRemovalRecord} from './remove.js';
3
+ import {applyRepair,applyRemoval} from './apply.js';
4
+ import {acquireWorkspaceLock} from './lock.js';
5
+ import {inspectHistory} from './history.js';
6
+ import {requestShape} from './ownership.js';
7
+ import {absoluteRoot,resolveChild} from '../workspace/paths.js';
8
+ import {ContractError,fail} from '../contracts/parse.js';
9
+
10
+ function guard(input,registry,apply=false) {
11
+ requestShape(input,apply?['command','wrapper','prepared','approval']:['command','wrapper'],apply?[]:['providers','bundles'],'maintenance.input');
12
+ if(!['repair','remove'].includes(input.command) || (input.command==='repair' && (input.providers!==undefined || input.bundles!==undefined)))fail('maintenance.command');
13
+ requestShape(registry,['adapters','sharedAdapter'],[],'provider.interface');
14
+ return absoluteRoot(input.wrapper);
15
+ }
16
+ async function historyGuard(workspace) {
17
+ const history=await inspectHistory(workspace);
18
+ if(!history.complete || history.diagnostics.length)fail('maintenance.history');
19
+ }
20
+
21
+ // Installed-snapshot operations only: no source acquisition or manifest rebind.
22
+ export async function prepareMaintenance(input,registry) {
23
+ const workspace=guard(input,registry);await historyGuard(workspace);
24
+ return input.command==='repair'?prepareRepairPlan(workspace,registry):
25
+ prepareRemoval(workspace,registry,{...(input.providers===undefined?{}:{providers:input.providers}),
26
+ ...(input.bundles===undefined?{}:{bundles:input.bundles})});
27
+ }
28
+
29
+ export async function applyMaintenance(input,registry,{report=async()=>{}}={}) {
30
+ const workspace=guard(input,registry,true);
31
+ if(typeof report!=='function')fail('maintenance.reporter');
32
+ const result={command:input.command,workspace,status:'failed',journal:null,recovery:null,
33
+ error:null,lockRelease:'not-acquired',runtime:'not-run'};
34
+ const emit=async event=>{try{await report(structuredClone(event));}catch{fail('maintenance.report');}};
35
+ let lock;
36
+ try {
37
+ const prepared=(input.command==='repair'?validateRepairRecord:validateRemovalRecord)(input.prepared,input.approval);
38
+ if(prepared.preview?.plan?.workspace!==workspace || prepared.preview.plan.command!==input.command)fail('maintenance.prepared-binding');
39
+ lock=await acquireWorkspaceLock(workspace);result.lockRelease='pending';await historyGuard(workspace);
40
+ const applied=await (input.command==='repair'?applyRepair:applyRemoval)(lock,prepared,input.approval,registry,{
41
+ onJournal:async location=>{
42
+ const runId=location.relative.split('/').at(-1);
43
+ result.journal={runId,path:location.directory,status:location.status};
44
+ result.recovery={path:resolveChild(workspace,'.pipeline/transactions/'+runId+'/recovery.json'),status:'not-created'};
45
+ await emit({kind:'journal-location',...result.journal,recovery:result.recovery});
46
+ },
47
+ ioBoundary:async detail=>{
48
+ if(detail.purpose==='recovery' && ['opened','readback'].includes(detail.phase)) {
49
+ result.recovery.status=detail.phase==='opened'?'created-unverified':'verified';
50
+ await emit({kind:'recovery-location',...result.recovery});
51
+ }
52
+ }
53
+ });
54
+ result.status=applied.status;
55
+ }catch(error){result.error=error instanceof ContractError?error.code:'maintenance.io';}
56
+ finally {
57
+ if(lock)try{await lock.release();result.lockRelease='released';}
58
+ catch(error){result.lockRelease='failed';result.releaseError=error instanceof ContractError?error.code:'lock.release';}
59
+ }
60
+ try{await emit({kind:'operation-result',...result});}catch{result.outputError='maintenance.report';}
61
+ return result;
62
+ }
@@ -0,0 +1,22 @@
1
+ import {lstat} from 'node:fs/promises';
2
+ import {absoluteRoot,resolveChild,inspectDirectory} from '../workspace/paths.js';
3
+ import {fail,ContractError} from '../contracts/parse.js';
4
+
5
+ // Presence is a blocker regardless of record validity. A partial write, directory
6
+ // or link must never be interpreted as permission to start a different lifecycle.
7
+ // This reader does not follow the marker, delete it or infer recovery authority.
8
+ export async function inspectMigrationPending(workspace){
9
+ workspace=absoluteRoot(workspace);
10
+ const relative='.pipeline/migration-operation.json',subject=resolveChild(workspace,relative);
11
+ try{
12
+ await inspectDirectory(resolveChild(workspace,'.pipeline'));
13
+ await lstat(subject);
14
+ return {workspace,blocked:true,blockers:[{code:'migration.pending',subject}]};
15
+ }catch(error){
16
+ if(error.code==='ENOENT')return {workspace,blocked:false,blockers:[]};
17
+ throw error instanceof ContractError?error:new ContractError('migration.pending-io');
18
+ }
19
+ }
20
+ export async function assertNoMigrationPending(workspace){
21
+ if((await inspectMigrationPending(workspace)).blocked)fail('migration.pending');
22
+ }
@@ -0,0 +1,122 @@
1
+ import { fail } from '../contracts/parse.js';
2
+ import { contractDigest } from '../contracts/semantic.js';
3
+
4
+ const hashPattern = /^sha256:[a-f0-9]{64}$/;
5
+ const validHash = value => value === null || (typeof value === 'string' && hashPattern.test(value));
6
+
7
+ // Only plain own data properties: fail before invoking getters or reading fields.
8
+ export function requestShape(value, required, optional, code) {
9
+ if (value === null || typeof value !== 'object' || Array.isArray(value) ||
10
+ ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail(code);
11
+ const allowed = new Set([...required, ...optional]);
12
+ for (const key of Reflect.ownKeys(value)) {
13
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
14
+ if (!allowed.has(key) || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) fail(code);
15
+ }
16
+ if (required.some(key => !Object.hasOwn(value, key))) fail(code);
17
+ }
18
+
19
+ // Pure reconciliation, not authorization or a state writer. Null means absent;
20
+ // JSON null has a non-null digest. Explicit takeover is bound to observed bytes
21
+ // (or field value); the final preview still needs user approval before apply.
22
+ export function reconcileOwnership({ currentHash, desiredHash, managedHash = null, takeover = null }) {
23
+ if (![currentHash, desiredHash, managedHash].every(validHash)) fail('ownership.hash');
24
+ if (takeover !== null && (typeof takeover !== 'object' ||
25
+ Object.keys(takeover).sort().join(',') !== 'beforeHash,desiredHash' ||
26
+ takeover.beforeHash !== currentHash || takeover.desiredHash !== desiredHash)) fail('ownership.takeover');
27
+
28
+ if (managedHash !== null && currentHash !== managedHash) fail('ownership.drift');
29
+ if (desiredHash === null) {
30
+ if (managedHash === null) return { action: 'preserve', owned: false, backupRequired: false };
31
+ return { action: 'delete', owned: false, backupRequired: true };
32
+ }
33
+ if (managedHash !== null) return {
34
+ action: currentHash === desiredHash ? 'preserve' : 'replace', owned: true,
35
+ backupRequired: currentHash !== desiredHash
36
+ };
37
+ if (currentHash === null) return { action: 'create', owned: true, backupRequired: false };
38
+ if (takeover !== null) return {
39
+ action: currentHash === desiredHash ? 'preserve' : 'replace', owned: true, backupRequired: true
40
+ };
41
+ if (currentHash === desiredHash) return { action: 'preserve', owned: false, backupRequired: false };
42
+ fail('ownership.foreign');
43
+ }
44
+
45
+ function tokens(pointer) {
46
+ if (typeof pointer !== 'string' || !pointer.startsWith('/') || /~(?![01])/.test(pointer)) fail('ownership.pointer');
47
+ const result = pointer.slice(1).split('/').map(s => s.replace(/~1/g, '/').replace(/~0/g, '~'));
48
+ if (result.some(s => ['__proto__', 'prototype', 'constructor'].includes(s))) fail('ownership.pointer');
49
+ return result;
50
+ }
51
+
52
+ // Accept only JSON-domain data, including null-prototype parser objects. No
53
+ // getters, exotic prototypes, sparse arrays, cycles, or implicit JSON coercions.
54
+ function jsonCopy(value, depth = 0, seen = new Set()) {
55
+ if (depth > 64) fail('ownership.value');
56
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
57
+ if (typeof value === 'number' && Number.isFinite(value) && (!Number.isInteger(value) || Number.isSafeInteger(value))) return value;
58
+ if (typeof value !== 'object' || seen.has(value)) fail('ownership.value');
59
+ const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
60
+ if (!array && proto !== null && proto !== Object.prototype) fail('ownership.value');
61
+ seen.add(value);
62
+ const result = array ? [] : Object.create(null);
63
+ const keys = Reflect.ownKeys(value).filter(k => !(array && k === 'length'));
64
+ if (array && (keys.length !== value.length || keys.some((k, i) => k !== String(i)))) fail('ownership.value');
65
+ for (const key of keys) {
66
+ const d = Object.getOwnPropertyDescriptor(value, key);
67
+ if (typeof key !== 'string' || !d.enumerable || !Object.hasOwn(d, 'value') ||
68
+ ['__proto__', 'prototype', 'constructor'].includes(key)) fail('ownership.value');
69
+ result[key] = jsonCopy(d.value, depth + 1, seen);
70
+ }
71
+ seen.delete(value);
72
+ return result;
73
+ }
74
+
75
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
76
+ function field(root, parts) {
77
+ let node = root;
78
+ for (const part of parts) {
79
+ if (!object(node)) fail('ownership.ancestor');
80
+ if (!Object.hasOwn(node, part)) return { present: false };
81
+ node = node[part];
82
+ }
83
+ return { present: true, value: node };
84
+ }
85
+
86
+ // Trusted adapters supply requests for exact JSON pointers. Arrays may be owned
87
+ // as whole values, never addressed by index. Foreign siblings stay intact.
88
+ // This layer does not infer provider/path permissions or persist backup records.
89
+ export function reconcileFields(current, requests) {
90
+ const result = jsonCopy(current);
91
+ if (!object(result) || !Array.isArray(requests) || requests.length > 1000) fail('ownership.fields');
92
+ const ordered = Array.from(requests, request => {
93
+ requestShape(request, ['pointer', 'present'], ['value', 'managedHash', 'takeover'], 'ownership.fields');
94
+ const parts = tokens(request.pointer);
95
+ if (typeof request.present !== 'boolean' || (request.present && !Object.hasOwn(request, 'value'))) fail('ownership.fields');
96
+ return { request, parts };
97
+ }).sort((a, b) => a.request.pointer < b.request.pointer ? -1 : a.request.pointer > b.request.pointer ? 1 : 0);
98
+ for (let i = 0; i < ordered.length; i++) for (let j = 0; j < i; j++) {
99
+ const a = ordered[i].parts, b = ordered[j].parts;
100
+ if (a.slice(0, Math.min(a.length, b.length)).every((p, k) => p === b[k])) fail('ownership.overlap');
101
+ }
102
+ const decisions = [];
103
+ for (const { request, parts } of ordered) {
104
+ const before = field(result, parts), desired = request.present ? jsonCopy(request.value) : undefined;
105
+ const currentHash = before.present ? contractDigest(before.value) : null;
106
+ const desiredHash = request.present ? contractDigest(desired) : null;
107
+ const decision = reconcileOwnership({ currentHash, desiredHash, managedHash: request.managedHash ?? null, takeover: request.takeover ?? null });
108
+ if (decision.action !== 'preserve') {
109
+ let parent = result;
110
+ for (const part of parts.slice(0, -1)) {
111
+ if (!Object.hasOwn(parent, part)) parent[part] = Object.create(null);
112
+ if (!object(parent[part])) fail('ownership.ancestor');
113
+ parent = parent[part];
114
+ }
115
+ const last = parts.at(-1);
116
+ if (decision.action === 'delete') delete parent[last];
117
+ else parent[last] = desired;
118
+ }
119
+ decisions.push({ pointer: request.pointer, currentHash, desiredHash, ...decision });
120
+ }
121
+ return { value: result, decisions };
122
+ }