@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.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/docs/collaboration.md +59 -0
- package/docs/config-fields.md +65 -0
- package/docs/contracts.md +149 -0
- package/docs/doctor.md +165 -0
- package/docs/launch.md +50 -0
- package/docs/lifecycle-cli.md +141 -0
- package/docs/lifecycle.md +42 -0
- package/docs/migrations/unity.md +288 -0
- package/docs/native-provider-format.md +149 -0
- package/docs/provider-bundles.md +81 -0
- package/docs/provider-resources.md +37 -0
- package/docs/release.md +25 -0
- package/docs/remove.md +70 -0
- package/docs/repair.md +80 -0
- package/docs/repositories.md +209 -0
- package/docs/repository-manual-recovery.md +102 -0
- package/docs/repository-observations.md +29 -0
- package/docs/repository-recovery.md +204 -0
- package/docs/repository-retention.md +46 -0
- package/docs/repository-transport-budgets.md +26 -0
- package/docs/retention.md +237 -0
- package/docs/source.md +50 -0
- package/docs/switch.md +412 -0
- package/package.json +39 -0
- package/schemas/common.schema.json +251 -0
- package/schemas/inventory.schema.json +15 -0
- package/schemas/operation.schema.json +286 -0
- package/schemas/pipeline.schema.json +317 -0
- package/schemas/state.schema.json +302 -0
- package/schemas/workspace.schema.json +67 -0
- package/src/cli.js +7 -0
- package/src/commands/adopt.js +2 -0
- package/src/commands/bootstrap-recovery.js +90 -0
- package/src/commands/dispatch.js +394 -0
- package/src/commands/init.js +84 -0
- package/src/commands/launch.js +69 -0
- package/src/commands/migration-apply.js +53 -0
- package/src/commands/migration.js +50 -0
- package/src/commands/repositories.js +61 -0
- package/src/commands/repository-abandon.js +16 -0
- package/src/commands/repository-ancestors.js +21 -0
- package/src/commands/repository-locks.js +77 -0
- package/src/contracts/parse.js +57 -0
- package/src/contracts/semantic.js +240 -0
- package/src/contracts/validate.js +21 -0
- package/src/launch/grok.js +20 -0
- package/src/migrations/legacy-unity-begin.js +35 -0
- package/src/migrations/legacy-unity-compensate.js +82 -0
- package/src/migrations/legacy-unity-deactivate.js +60 -0
- package/src/migrations/legacy-unity-deactivation-resume-apply.js +62 -0
- package/src/migrations/legacy-unity-deactivation-resume.js +81 -0
- package/src/migrations/legacy-unity-finalize.js +88 -0
- package/src/migrations/legacy-unity-install-recovery.js +76 -0
- package/src/migrations/legacy-unity-install-resume.js +60 -0
- package/src/migrations/legacy-unity-install.js +88 -0
- package/src/migrations/legacy-unity-lease.js +136 -0
- package/src/migrations/legacy-unity-preflight.js +67 -0
- package/src/migrations/legacy-unity-preview.js +91 -0
- package/src/migrations/legacy-unity-resume-apply.js +39 -0
- package/src/migrations/legacy-unity-resume.js +41 -0
- package/src/migrations/legacy-unity-resumed-evidence.js +88 -0
- package/src/migrations/legacy-unity.js +99 -0
- package/src/operations/apply.js +576 -0
- package/src/operations/backup.js +94 -0
- package/src/operations/bootstrap-lock.js +87 -0
- package/src/operations/bootstrap-owner-retirement.js +121 -0
- package/src/operations/bundle-update.js +54 -0
- package/src/operations/config-fields.js +24 -0
- package/src/operations/continuation-lifecycle.js +77 -0
- package/src/operations/doctor.js +214 -0
- package/src/operations/history.js +170 -0
- package/src/operations/installer-identity.js +49 -0
- package/src/operations/journal.js +142 -0
- package/src/operations/lifecycle.js +133 -0
- package/src/operations/lineage-guard.js +20 -0
- package/src/operations/lock.js +109 -0
- package/src/operations/maintenance.js +62 -0
- package/src/operations/migration-pending.js +22 -0
- package/src/operations/ownership.js +122 -0
- package/src/operations/plan.js +278 -0
- package/src/operations/reconciliation.js +112 -0
- package/src/operations/recovery-lease.js +66 -0
- package/src/operations/remove.js +140 -0
- package/src/operations/repair.js +188 -0
- package/src/operations/repository-abandon.js +192 -0
- package/src/operations/repository-ancestors.js +158 -0
- package/src/operations/repository-apply.js +122 -0
- package/src/operations/repository-authorization.js +48 -0
- package/src/operations/repository-bootstrap-continuation.js +190 -0
- package/src/operations/repository-bootstrap-reconcile.js +106 -0
- package/src/operations/repository-bootstrap-recover.js +114 -0
- package/src/operations/repository-bootstrap.js +82 -0
- package/src/operations/repository-clone.js +63 -0
- package/src/operations/repository-history.js +108 -0
- package/src/operations/repository-inputs.js +41 -0
- package/src/operations/repository-journal.js +127 -0
- package/src/operations/repository-lock-reconcile.js +401 -0
- package/src/operations/repository-pending.js +29 -0
- package/src/operations/repository-reconcile.js +182 -0
- package/src/operations/repository-resumption-approvals.js +77 -0
- package/src/operations/repository-retention-apply.js +75 -0
- package/src/operations/repository-retention.js +137 -0
- package/src/operations/repository-workspace.js +78 -0
- package/src/operations/retention-apply.js +133 -0
- package/src/operations/retention-combined-scan.js +30 -0
- package/src/operations/retention-combined.js +41 -0
- package/src/operations/retention-policy.js +65 -0
- package/src/operations/retention-receipts.js +126 -0
- package/src/operations/retention-scan.js +86 -0
- package/src/operations/retention.js +56 -0
- package/src/operations/state.js +210 -0
- package/src/operations/switch-activate.js +64 -0
- package/src/operations/switch-backups.js +29 -0
- package/src/operations/switch-continuation-journal.js +94 -0
- package/src/operations/switch-continuation-pending.js +60 -0
- package/src/operations/switch-continuation-records.js +109 -0
- package/src/operations/switch-continuation-recovery.js +91 -0
- package/src/operations/switch-continuation-runtime.js +135 -0
- package/src/operations/switch-continuation-store.js +120 -0
- package/src/operations/switch-continuation.js +76 -0
- package/src/operations/switch-execute.js +68 -0
- package/src/operations/switch-inspect.js +45 -0
- package/src/operations/switch-journal-store.js +109 -0
- package/src/operations/switch-journal.js +71 -0
- package/src/operations/switch-lifecycle.js +72 -0
- package/src/operations/switch-pending.js +43 -0
- package/src/operations/switch-preflight.js +73 -0
- package/src/operations/switch-prepare.js +75 -0
- package/src/operations/switch-records.js +60 -0
- package/src/operations/switch-recovery-store.js +74 -0
- package/src/operations/switch.js +52 -0
- package/src/operations/toml-fields.js +133 -0
- package/src/providers/bundles.js +42 -0
- package/src/providers/common-entry.js +16 -0
- package/src/providers/grok.js +26 -0
- package/src/providers/interface.js +25 -0
- package/src/providers/kimi.js +26 -0
- package/src/providers/native.js +155 -0
- package/src/providers/registry.js +10 -0
- package/src/providers/shared.js +51 -0
- package/src/providers/source.js +30 -0
- package/src/source/git.js +303 -0
- package/src/source/inventory.js +87 -0
- package/src/source/repository-budget.js +18 -0
- package/src/source/snapshot.js +37 -0
- package/src/workspace/paths.js +54 -0
- package/src/workspace/profiles.js +8 -0
- package/src/workspace/repositories.js +57 -0
- package/src/workspace/repository-inventory.js +77 -0
- package/src/workspace/repository-observation.js +38 -0
- package/src/workspace/repository-preflight.js +129 -0
- package/src/workspace/repository-preview.js +196 -0
- package/src/workspace/repository-tree.js +57 -0
- package/src/workspace/reserved.js +11 -0
- package/src/workspace/resolve.js +53 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdir, open, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { fail, parse, ContractError } from '../contracts/parse.js';
|
|
5
|
+
import { contractDigest, validateState, validateTransition } from '../contracts/semantic.js';
|
|
6
|
+
import { resolveChild, absoluteRoot, inspectDirectory } from '../workspace/paths.js';
|
|
7
|
+
import { readRecord, readState, observeTargets, resolveOrigin, resolveApprovedRebind, verifyPreparedSnapshot, verifyInstalledSnapshot } from './state.js';
|
|
8
|
+
import { checkPreview, composePlan } from './plan.js';
|
|
9
|
+
import { assertLockHeld } from './lock.js';
|
|
10
|
+
import { requestShape } from './ownership.js';
|
|
11
|
+
import {planObservationPaths,retiredBundleOwnership} from './bundle-update.js';
|
|
12
|
+
import {readConfigField} from './config-fields.js';
|
|
13
|
+
import { planLayout } from '../workspace/resolve.js';
|
|
14
|
+
import { cap, LIMITS, sha256 } from '../source/inventory.js';
|
|
15
|
+
import { copySnapshot, saveBackup, verifyBackup } from './backup.js';
|
|
16
|
+
import { createJournal, readJournal } from './journal.js';
|
|
17
|
+
import {verifyRepairApproval,validateRepairRecord} from './repair.js';
|
|
18
|
+
import {verifyContinuationApproval,validateContinuationRecord,continuationTargetAction,continuationCommand} from './reconciliation.js';
|
|
19
|
+
import {createLineageGuard} from './lineage-guard.js';
|
|
20
|
+
import {verifyRemovalApproval,validateRemovalRecord} from './remove.js';
|
|
21
|
+
|
|
22
|
+
const fileHash=bytes=>bytes===null?null:sha256(bytes);
|
|
23
|
+
const recordBytes=value=>{
|
|
24
|
+
const text=JSON.stringify(value)+'\n';
|
|
25
|
+
// Use the same size/complexity limits as the recovery reader. Reject before
|
|
26
|
+
// pending/target writes rather than persist evidence we cannot read back.
|
|
27
|
+
parse(text,'json');return Buffer.from(text);
|
|
28
|
+
};
|
|
29
|
+
const scratchName=relative=>path.posix.join(path.posix.dirname(relative),'.wpc-'+randomUUID()+'.tmp');
|
|
30
|
+
|
|
31
|
+
async function ensureParent(lock,relative) {
|
|
32
|
+
const filename=resolveChild(lock.workspace,relative);
|
|
33
|
+
let directory=lock.workspace;
|
|
34
|
+
for(const segment of relative.split('/').slice(0,-1)) {
|
|
35
|
+
directory=path.join(directory,segment);await assertLockHeld(lock);
|
|
36
|
+
try{await mkdir(directory);}catch(error){if(error.code!=='EEXIST')throw error;}
|
|
37
|
+
await inspectDirectory(directory);
|
|
38
|
+
}
|
|
39
|
+
return filename;
|
|
40
|
+
}
|
|
41
|
+
async function currentHash(lock,relative) {
|
|
42
|
+
return fileHash((await observeTargets(lock.workspace,[relative]))[0].bytes);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Read-only recovery inspection: never acquires/clears a lock, contacts the
|
|
46
|
+
// original source, grants approval, promotes an uncertain receipt or writes state.
|
|
47
|
+
// Caller must select the exact recovery record; no guessed "latest" transaction.
|
|
48
|
+
export async function inspectRecovery(workspace,recoveryPath,options={}) {
|
|
49
|
+
requestShape(options,[],['fieldOwnershipOnly','evidenceOnly'],'recovery.options');
|
|
50
|
+
if(Object.hasOwn(options,'fieldOwnershipOnly') && typeof options.fieldOwnershipOnly!=='boolean')fail('recovery.options');
|
|
51
|
+
if(Object.hasOwn(options,'evidenceOnly') && typeof options.evidenceOnly!=='boolean')fail('recovery.options');
|
|
52
|
+
if(options.evidenceOnly && options.fieldOwnershipOnly)fail('recovery.options');
|
|
53
|
+
workspace=absoluteRoot(workspace);
|
|
54
|
+
const match=/^\.pipeline\/transactions\/([a-f0-9-]{36})\/recovery\.json$/.exec(recoveryPath);
|
|
55
|
+
if(!match)fail('recovery.path');
|
|
56
|
+
const filename=resolveChild(workspace,recoveryPath),record=await readRecord(filename),recovery=record.value;
|
|
57
|
+
requestShape(recovery,['schemaVersion','prepared','approval','previous','backups'],[],'recovery.record');
|
|
58
|
+
if(recovery.schemaVersion!==1 || !Array.isArray(recovery.backups))fail('recovery.record');
|
|
59
|
+
const repair=recovery.prepared?.kind==='prepared-repair';
|
|
60
|
+
const continuation=recovery.prepared?.kind==='prepared-continuation';
|
|
61
|
+
const removal=recovery.prepared?.kind==='prepared-removal';
|
|
62
|
+
const prepared=removal?validateRemovalRecord(recovery.prepared,recovery.approval):continuation?validateContinuationRecord(recovery.prepared,recovery.approval):repair?validateRepairRecord(recovery.prepared,recovery.approval):validatePreparedRecord(recovery.prepared,recovery.approval),previous=structuredClone(recovery.previous);
|
|
63
|
+
const declared=prepared.preview.observations;
|
|
64
|
+
if(!Array.isArray(declared))fail('recovery.record');
|
|
65
|
+
const before=declared.map(o=>({path:o.path,bytes:o.bytes===null?null:Buffer.from(o.bytes,'base64')}));
|
|
66
|
+
checkPreview(prepared.preview,previous,before);
|
|
67
|
+
const plan=prepared.preview.plan;
|
|
68
|
+
const removalFlow=removal || (continuation && plan.command==='remove');
|
|
69
|
+
const deployment=removalFlow?previous?.active:plan.desired;
|
|
70
|
+
if(plan.workspace!==workspace || !(removalFlow?['remove']:continuation?['repair','update']:repair?['repair']:['setup','update']).includes(plan.command) || !deployment || !plan.source ||
|
|
71
|
+
contractDigest(plan.source)!==contractDigest(deployment.snapshot) ||
|
|
72
|
+
plan.source.path!=='.pipeline/snapshots/'+plan.source.digest.slice(7))fail('recovery.binding');
|
|
73
|
+
if(repair && (!previous?.active || contractDigest(plan.desired)!==contractDigest(previous.active) ||
|
|
74
|
+
plan.targets.some(t=>!['create','edit-fields'].includes(t.action) || t.fields.some(f=>f.beforeHash!==null))))fail('recovery.binding');
|
|
75
|
+
const lineage=continuation?await verifyContinuationLineage(workspace,prepared,previous):[];
|
|
76
|
+
const expectedBackups=new Set(deployment.owned.filter(o=>o.backup!==null).map(o=>o.backup)),seen=new Set();
|
|
77
|
+
for(const backup of recovery.backups) {
|
|
78
|
+
requestShape(backup,['path','hash','existing'],[],'recovery.backup');
|
|
79
|
+
if(!expectedBackups.has(backup.path) || seen.has(backup.path) || typeof backup.existing!=='boolean' ||
|
|
80
|
+
!/^sha256:[a-f0-9]{64}$/.test(backup.hash))fail('recovery.backup');
|
|
81
|
+
seen.add(backup.path);
|
|
82
|
+
}
|
|
83
|
+
if(seen.size!==expectedBackups.size)fail('recovery.backup');
|
|
84
|
+
const journalPath='.pipeline/journals/'+match[1];
|
|
85
|
+
const journal=await readJournal(workspace,journalPath,plan,previous);
|
|
86
|
+
if(options.evidenceOnly) {
|
|
87
|
+
// Historical completed readbacks, NOT current installation/activation proof.
|
|
88
|
+
// Do not compare an old transaction's foreign/full bytes with today's config.
|
|
89
|
+
if(journal.receipt?.status!=='completed')fail('recovery.evidence-unfinished');
|
|
90
|
+
await verifyPreparedSnapshot({snapshotPath:resolveChild(workspace,plan.source.path),
|
|
91
|
+
manifest:{id:deployment.pipelineId,version:deployment.version},digest:plan.source.digest,inventoryDigest:plan.source.inventoryDigest});
|
|
92
|
+
for(const backup of recovery.backups)await verifyBackup(workspace,backup.path,backup.hash);
|
|
93
|
+
if(continuation)await verifyContinuationLineage(workspace,prepared,previous);
|
|
94
|
+
const final=await readJournal(workspace,journalPath,plan,previous);
|
|
95
|
+
if((await readRecord(filename)).digest!==record.digest || contractDigest(final)!==contractDigest(journal))fail('recovery.observation-drift');
|
|
96
|
+
return {status:'completed-evidence',recoveryHash:record.digest,journal:journalPath,
|
|
97
|
+
journalHead:{sequence:journal.sequence,hash:journal.lastHash},runtime:'not-run',automaticActions:false,
|
|
98
|
+
activation:'not-verified',currentTargets:'not-verified',lineage,
|
|
99
|
+
resolves:continuation?{recovery:prepared.evidence.recoveryPath,recoveryHash:prepared.evidence.recoveryHash,
|
|
100
|
+
journalHead:prepared.evidence.journalHead}:null};
|
|
101
|
+
}
|
|
102
|
+
const readCurrent=async()=>{
|
|
103
|
+
try{return await readState(resolveChild(workspace,'.pipeline/state.json'));}
|
|
104
|
+
catch(error){if(error.code==='record.missing')return null;throw error;}
|
|
105
|
+
};
|
|
106
|
+
const current=await readCurrent(),state=current?.value??null,diagnostics=[];
|
|
107
|
+
let statePhase='unrelated';
|
|
108
|
+
// A no-op update can have identical before/after state bytes. A completed
|
|
109
|
+
// journal plus exact ready deployment establishes its outcome; byte equality
|
|
110
|
+
// alone must neither hide that completion nor promote an unfinished journal.
|
|
111
|
+
const completedStatus=plan.desired===null?'not-installed':'ready';
|
|
112
|
+
if(state?.status===completedStatus && state.pending===null && journal.receipt?.status==='completed' &&
|
|
113
|
+
contractDigest(state.active)===contractDigest(plan.desired))statePhase='active';
|
|
114
|
+
else if((current?.digest??null)===prepared.stateFileHash && contractDigest(state)===contractDigest(previous))statePhase='before';
|
|
115
|
+
else if(state?.status==='needs-reconciliation' && state.pending===contractDigest(plan) &&
|
|
116
|
+
contractDigest(state.active)===contractDigest(previous?.active??null))statePhase='pending';
|
|
117
|
+
else if(state?.status===completedStatus && state.pending===null && contractDigest(state.active)===contractDigest(plan.desired))statePhase='active';
|
|
118
|
+
else diagnostics.push('recovery.state-binding');
|
|
119
|
+
if(statePhase==='active' && state.activation &&
|
|
120
|
+
(state.activation.recovery!==recoveryPath || state.activation.recoveryHash!==record.digest ||
|
|
121
|
+
state.activation.journalHead.sequence!==journal.sequence || state.activation.journalHead.hash!==journal.lastHash))
|
|
122
|
+
diagnostics.push('recovery.activation-binding');
|
|
123
|
+
const observations=await observeTargets(workspace,declared.map(o=>o.path));
|
|
124
|
+
const actual=new Map(observations.map(o=>[o.path,fileHash(o.bytes)]));
|
|
125
|
+
const projected=new Set();
|
|
126
|
+
// Doctor's configuration scope may ignore foreign JSON siblings, but only
|
|
127
|
+
// after a completed journal and exact active deployment binding. Neither the
|
|
128
|
+
// historical hashes nor raw current hashes are replaced in the report.
|
|
129
|
+
if(options.fieldOwnershipOnly && statePhase==='active' && journal.receipt?.status==='completed') {
|
|
130
|
+
const fieldHash=(name,bytes,pointer)=>{
|
|
131
|
+
if(bytes===null)return null;
|
|
132
|
+
const field=readConfigField(name,bytes,pointer);
|
|
133
|
+
return field.present?contractDigest(field.value):null;
|
|
134
|
+
};
|
|
135
|
+
for(const observation of observations) {
|
|
136
|
+
const owned=(plan.desired?.owned??[]).filter(o=>o.path===observation.path);
|
|
137
|
+
const target=plan.targets.find(t=>t.path===observation.path);
|
|
138
|
+
const historical=target?prepared.preview.outputs.find(o=>o.path===observation.path):declared.find(o=>o.path===observation.path);
|
|
139
|
+
if(!historical || historical.bytes===null || actual.get(observation.path)===historical.hash ||
|
|
140
|
+
!owned.length || owned.some(o=>o.kind!=='field'))continue;
|
|
141
|
+
try {
|
|
142
|
+
const expected=Buffer.from(historical.bytes,'base64');
|
|
143
|
+
if(owned.every(o=>fieldHash(o.path,expected,o.pointer)===o.managedHash &&
|
|
144
|
+
fieldHash(o.path,observation.bytes,o.pointer)===o.managedHash))projected.add(observation.path);
|
|
145
|
+
}catch{/* Malformed JSON never qualifies for projection. */}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const targets=plan.targets.map((target,index)=>{
|
|
149
|
+
const observedHash=actual.get(target.path);
|
|
150
|
+
const recorded=journal.receipt?.operations[index].status ?? (index<journal.nextIndex?'completed':index===journal.pending?'uncertain':'skipped');
|
|
151
|
+
const position=observedHash===target.desiredHash || projected.has(target.path)?'desired':observedHash===target.beforeHash?'before':'other';
|
|
152
|
+
if(position==='other' || (recorded==='completed' && position!=='desired') ||
|
|
153
|
+
(recorded==='failed' && position!=='before') || (recorded==='skipped' && position!=='before'))diagnostics.push('recovery.target-drift');
|
|
154
|
+
return {id:target.id,path:target.path,recorded,observedHash,position:projected.has(target.path)?'desired-owned-fields':position};
|
|
155
|
+
});
|
|
156
|
+
const changed=new Set(plan.targets.map(t=>t.path));
|
|
157
|
+
for(const item of declared)if(!changed.has(item.path) && actual.get(item.path)!==item.hash && !projected.has(item.path))diagnostics.push('recovery.dependency-drift');
|
|
158
|
+
try {
|
|
159
|
+
await verifyPreparedSnapshot({snapshotPath:resolveChild(workspace,plan.source.path),manifest:{id:deployment.pipelineId,version:deployment.version},
|
|
160
|
+
digest:plan.source.digest,inventoryDigest:plan.source.inventoryDigest});
|
|
161
|
+
}catch(error){diagnostics.push(error instanceof ContractError?error.code:'recovery.snapshot');}
|
|
162
|
+
if(statePhase!=='active' && previous?.active) {
|
|
163
|
+
// Validate historical installed bytes without interpreting a continuation's
|
|
164
|
+
// pending predecessor as an active/ready state transition.
|
|
165
|
+
try{await verifyInstalledSnapshot({...previous,status:'ready',pending:null});}
|
|
166
|
+
catch{diagnostics.push('recovery.previous-snapshot');}
|
|
167
|
+
}
|
|
168
|
+
for(const backup of recovery.backups) {
|
|
169
|
+
try{await verifyBackup(workspace,backup.path,backup.hash);}
|
|
170
|
+
catch(error){diagnostics.push(error instanceof ContractError?error.code:'recovery.backup');}
|
|
171
|
+
}
|
|
172
|
+
if(statePhase==='active' && (journal.receipt?.status!=='completed' || targets.some(t=>!['desired','desired-owned-fields'].includes(t.position))))diagnostics.push('recovery.activation-unproven');
|
|
173
|
+
if(statePhase==='before' && (journal.nextIndex!==0 || journal.pending!==null || targets.some(t=>t.position!=='before')))diagnostics.push('recovery.before-state-unproven');
|
|
174
|
+
// Detect an inconsistent mixed-time report. This is not a global filesystem
|
|
175
|
+
// snapshot; noncooperative processes can still race after the final reads.
|
|
176
|
+
const after=await readCurrent(),finalJournal=await readJournal(workspace,journalPath,plan,previous);
|
|
177
|
+
if(continuation)await verifyContinuationLineage(workspace,prepared,previous);
|
|
178
|
+
if((after?.digest??null)!==(current?.digest??null) || (await readRecord(filename)).digest!==record.digest ||
|
|
179
|
+
contractDigest(finalJournal)!==contractDigest(journal))fail('recovery.observation-drift');
|
|
180
|
+
for(const item of await observeTargets(workspace,declared.map(o=>o.path)))
|
|
181
|
+
if(fileHash(item.bytes)!==actual.get(item.path))fail('recovery.observation-drift');
|
|
182
|
+
const status=diagnostics.length?'needs-reconciliation':statePhase==='active'?'applied':
|
|
183
|
+
statePhase==='before'?'before-state':'needs-reconciliation';
|
|
184
|
+
return {status,statePhase,diagnostics:[...new Set(diagnostics)],targets,journal:journalPath,
|
|
185
|
+
receipt:journal.receipt,recoveryHash:record.digest,journalHead:{sequence:journal.sequence,hash:journal.lastHash},lineage,runtime:'not-run',
|
|
186
|
+
comparisonScope:options.fieldOwnershipOnly?'owned-configuration':'exact-bytes',fieldProjections:[...projected].sort(),
|
|
187
|
+
requiresFreshPreview:true,automaticActions:false};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Existing files use a same-directory temp + rename; no unlink-then-rename
|
|
191
|
+
// fallback. New files use exclusive creation (not an atomic publication); a
|
|
192
|
+
// crash may leave a partial file and the pending journal marks it uncertain.
|
|
193
|
+
// Same-user hostile OS races cannot be excluded with portable Node filesystem APIs.
|
|
194
|
+
export async function writeCheckedFile(lock,relative,beforeHash,bytes,scratch=scratchName(relative),ioBoundary=async()=>{}) {
|
|
195
|
+
await assertLockHeld(lock);
|
|
196
|
+
if(await currentHash(lock,relative)!==beforeHash)fail('apply.target-drift');
|
|
197
|
+
const filename=await ensureParent(lock,relative);
|
|
198
|
+
const staged=beforeHash===null?filename:resolveChild(lock.workspace,scratch);
|
|
199
|
+
const detail={path:relative,stagedPath:beforeHash===null?relative:scratch};
|
|
200
|
+
let handle;
|
|
201
|
+
try {
|
|
202
|
+
handle=await open(staged,'wx',0o600);
|
|
203
|
+
try{
|
|
204
|
+
await ioBoundary('opened',detail);
|
|
205
|
+
await handle.writeFile(bytes);await ioBoundary('written',detail);
|
|
206
|
+
await handle.sync();await ioBoundary('synced',detail);
|
|
207
|
+
}finally{await handle.close();handle=null;}
|
|
208
|
+
if(beforeHash!==null) {
|
|
209
|
+
await ioBoundary('before-rename',detail);
|
|
210
|
+
await assertLockHeld(lock);await inspectDirectory(path.dirname(filename));
|
|
211
|
+
if(await currentHash(lock,relative)!==beforeHash)fail('apply.target-drift');
|
|
212
|
+
const [temp]=await observeTargets(lock.workspace,[scratch]);
|
|
213
|
+
if(fileHash(temp.bytes)!==sha256(bytes))fail('apply.staging-drift');
|
|
214
|
+
await rename(staged,filename);
|
|
215
|
+
await ioBoundary('renamed',detail);
|
|
216
|
+
}
|
|
217
|
+
if(await currentHash(lock,relative)!==sha256(bytes))fail('apply.readback');
|
|
218
|
+
await ioBoundary('readback',detail);
|
|
219
|
+
return sha256(bytes);
|
|
220
|
+
}catch(error){throw error instanceof ContractError?error:new ContractError('apply.write');}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Internal setup/update coordinator. CLI command wiring is later lifecycle work.
|
|
224
|
+
// Caller owns lock acquisition/release and genuine user approval. The optional
|
|
225
|
+
// boundary callback is a trusted test fault seam, never pipeline-supplied code.
|
|
226
|
+
export async function applyPrepared(lock,prepared,approval,registry,previous=null,{boundary=async()=>{},ioBoundary=async()=>{},onJournal=async()=>{}}={}) {
|
|
227
|
+
const checked=await preflightApply(lock,prepared,approval,registry,previous);
|
|
228
|
+
return executeChecked(lock,checked,approval,()=>verifyPreparedApproval(lock,checked.prepared,approval,checked.previous),{boundary,ioBoundary,onJournal});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Installed-snapshot repair has its own replay/approval entrypoint. The shared
|
|
232
|
+
// private transaction engine cannot be invoked with a caller-invented preflight.
|
|
233
|
+
export async function applyRemoval(lock,prepared,approval,registry,options={}) {
|
|
234
|
+
const copy=await verifyRemovalApproval(lock,prepared,approval,registry);
|
|
235
|
+
approval=structuredClone(approval);
|
|
236
|
+
const previous=(await readState(resolveChild(lock.workspace,'.pipeline/state.json'))).value;
|
|
237
|
+
await preflightPreview(lock,copy.preview,previous,copy.stateFileHash);
|
|
238
|
+
const active=previous.active,source=active.snapshot;
|
|
239
|
+
const snapshot={snapshotPath:resolveChild(lock.workspace,source.path),manifest:{id:active.pipelineId,version:active.version},
|
|
240
|
+
digest:source.digest,inventoryDigest:source.inventoryDigest};
|
|
241
|
+
// Retain and authenticate original backups, including surviving providers.
|
|
242
|
+
const backups=await planBackups(lock,{plan:{desired:active}},previous);
|
|
243
|
+
return executeChecked(lock,{prepared:copy,previous,snapshot,backups},approval,
|
|
244
|
+
()=>verifyRemovalApproval(lock,copy,approval,registry),options);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function applyRepair(lock,prepared,approval,registry,options={}) {
|
|
248
|
+
const copy=await verifyRepairApproval(lock,prepared,approval,registry);
|
|
249
|
+
approval=structuredClone(approval);
|
|
250
|
+
const previous=(await readState(resolveChild(lock.workspace,'.pipeline/state.json'))).value;
|
|
251
|
+
await preflightPreview(lock,copy.preview,previous,copy.stateFileHash);
|
|
252
|
+
const source=previous.active.snapshot;
|
|
253
|
+
const snapshot={snapshotPath:resolveChild(lock.workspace,source.path),
|
|
254
|
+
manifest:{id:previous.active.pipelineId,version:previous.active.version},
|
|
255
|
+
digest:source.digest,inventoryDigest:source.inventoryDigest};
|
|
256
|
+
const backups=await planBackups(lock,copy.preview,previous);
|
|
257
|
+
return executeChecked(lock,{prepared:copy,previous:structuredClone(previous),snapshot,backups},approval,
|
|
258
|
+
()=>verifyRepairApproval(lock,copy,approval,registry),options);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A new transaction records readback outcomes without changing the old journal.
|
|
262
|
+
// Source/adapter replay was authorized by the original immutable operation;
|
|
263
|
+
// current bytes and exact old evidence require a separate fresh approval here.
|
|
264
|
+
export async function applyContinuation(lock,prepared,approval,options={}) {
|
|
265
|
+
const copy=await verifyContinuationApproval(lock,prepared,approval);
|
|
266
|
+
approval=structuredClone(approval);
|
|
267
|
+
let previous=null;
|
|
268
|
+
try { previous=structuredClone((await readState(resolveChild(lock.workspace,'.pipeline/state.json'))).value); }
|
|
269
|
+
catch(error) { if(copy.evidence.statePhase!=='before' || copy.stateFileHash!==null || error.code!=='record.missing')throw error; }
|
|
270
|
+
const old=await readRecord(resolveChild(lock.workspace,copy.evidence.recoveryPath));
|
|
271
|
+
const backups=[];
|
|
272
|
+
for(const backup of old.value.backups) {
|
|
273
|
+
await verifyBackup(lock.workspace,backup.path,backup.hash);
|
|
274
|
+
const [{bytes}]=await observeTargets(lock.workspace,[backup.path]);
|
|
275
|
+
backups.push({...backup,bytes,existing:true});
|
|
276
|
+
}
|
|
277
|
+
const deployment=copy.preview.plan.command==='remove'?previous.active:copy.desired;
|
|
278
|
+
const source=copy.preview.plan.source,snapshot={snapshotPath:resolveChild(lock.workspace,source.path),
|
|
279
|
+
manifest:{id:deployment.pipelineId,version:deployment.version},digest:source.digest,inventoryDigest:source.inventoryDigest};
|
|
280
|
+
return executeChecked(lock,{prepared:copy,previous,snapshot,backups},approval,
|
|
281
|
+
()=>verifyContinuationApproval(lock,copy,approval),options,
|
|
282
|
+
new Set(copy.actions.filter(a=>a.action==='verify-readback').map(a=>a.path)),
|
|
283
|
+
()=>verifyContinuationLineage(lock.workspace,copy,previous));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function verifyContinuationLineage(workspace,prepared,previous) {
|
|
287
|
+
const guard=createLineageGuard(),lineage=[];
|
|
288
|
+
while(prepared.kind==='prepared-continuation') {
|
|
289
|
+
const evidence=prepared.evidence;
|
|
290
|
+
guard.visit(evidence.recoveryPath);
|
|
291
|
+
const old=await readRecord(resolveChild(workspace,evidence.recoveryPath));
|
|
292
|
+
if(old.digest!==evidence.recoveryHash)fail('reconciliation.lineage');
|
|
293
|
+
const original=old.value.prepared?.kind==='prepared-removal'?validateRemovalRecord(old.value.prepared,old.value.approval):old.value.prepared?.kind==='prepared-continuation'?validateContinuationRecord(old.value.prepared,old.value.approval):old.value.prepared?.kind==='prepared-repair'?validateRepairRecord(old.value.prepared,old.value.approval):validatePreparedRecord(old.value.prepared,old.value.approval);
|
|
294
|
+
const plan=original.preview.plan;
|
|
295
|
+
if(prepared.preview.plan.command!==continuationCommand(plan) ||
|
|
296
|
+
contractDigest(prepared.preview.plan.source)!==contractDigest(plan.source))fail('reconciliation.lineage');
|
|
297
|
+
checkPreview(original.preview,old.value.previous,original.preview.observations.map(o=>({path:o.path,bytes:o.bytes===null?null:Buffer.from(o.bytes,'base64')})));
|
|
298
|
+
const beforeSetup=evidence.statePhase==='before';
|
|
299
|
+
if(beforeSetup ? (previous!==null || old.value.previous!==null || evidence.stateHash!==null ||
|
|
300
|
+
original.kind!=='prepared-plan' || original.stateFileHash!==null || plan.command!=='setup') :
|
|
301
|
+
(previous?.pending!==contractDigest(plan) || contractDigest(previous?.active)!==contractDigest(old.value.previous?.active??null)))fail('reconciliation.lineage');
|
|
302
|
+
if(contractDigest(prepared.desired)!==contractDigest(plan.desired))fail('reconciliation.lineage');
|
|
303
|
+
const expectedJournal=evidence.recoveryPath.replace('/transactions/','/journals/').replace('/recovery.json','');
|
|
304
|
+
if(evidence.journal!==expectedJournal)fail('reconciliation.lineage');
|
|
305
|
+
const head=await readJournal(workspace,expectedJournal,plan,old.value.previous);
|
|
306
|
+
if(beforeSetup && (head.nextIndex!==0 || head.pending!==null || head.receipt!==null ||
|
|
307
|
+
prepared.actions.some(a=>a.action!=='write-desired' || a.recorded!=='skipped')))fail('reconciliation.lineage');
|
|
308
|
+
if(head.sequence!==evidence.journalHead.sequence || head.lastHash!==evidence.journalHead.hash)fail('reconciliation.lineage');
|
|
309
|
+
if(prepared.stateFileHash!==evidence.stateHash || evidence.workspace!==workspace ||
|
|
310
|
+
contractDigest(prepared.preview.plan.desired)!==contractDigest(plan.desired) ||
|
|
311
|
+
prepared.actions.length!==plan.targets.length || prepared.preview.plan.targets.length!==plan.targets.length)fail('reconciliation.lineage');
|
|
312
|
+
for(let i=0;i<plan.targets.length;i++) {
|
|
313
|
+
const oldTarget=plan.targets[i],action=prepared.actions[i],target=prepared.preview.plan.targets[i];
|
|
314
|
+
const output=original.preview.outputs.find(o=>o.path===oldTarget.path);
|
|
315
|
+
if(action.id!==oldTarget.id || action.path!==oldTarget.path || action.owner!==oldTarget.owner ||
|
|
316
|
+
action.desiredHash!==oldTarget.desiredHash || action.bytes!==(output?.bytes??null) ||
|
|
317
|
+
!['verify-readback','write-desired'].includes(action.action) ||
|
|
318
|
+
action.beforeHash!==(action.action==='verify-readback'?oldTarget.desiredHash:oldTarget.beforeHash) ||
|
|
319
|
+
contractDigest(target)!==contractDigest({id:action.id,path:action.path,owner:action.owner,
|
|
320
|
+
action:continuationTargetAction(action),beforeHash:action.beforeHash,desiredHash:action.desiredHash,fields:[]}))fail('reconciliation.lineage');
|
|
321
|
+
}
|
|
322
|
+
const expectedObservations=original.preview.observations.map(o=>{
|
|
323
|
+
const action=prepared.actions.find(a=>a.path===o.path);
|
|
324
|
+
return action?.action==='verify-readback'?{path:o.path,hash:action.desiredHash,bytes:action.bytes}:o;
|
|
325
|
+
});
|
|
326
|
+
if(contractDigest(expectedObservations)!==contractDigest(prepared.preview.observations) ||
|
|
327
|
+
contractDigest(original.preview.outputs)!==contractDigest(prepared.preview.outputs) ||
|
|
328
|
+
contractDigest(prepared.dependencies)!==contractDigest(original.preview.observations.filter(o=>!plan.targets.some(t=>t.path===o.path))))fail('reconciliation.lineage');
|
|
329
|
+
lineage.push({recovery:evidence.recoveryPath,recoveryHash:evidence.recoveryHash,journalHead:evidence.journalHead});
|
|
330
|
+
prepared=original;previous=old.value.previous;
|
|
331
|
+
}
|
|
332
|
+
return lineage;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function executeChecked(lock,checked,approval,recheck,{boundary=async()=>{},ioBoundary=async()=>{},onJournal=async()=>{}}={},readbackOnly=new Set(),verifyLineage=async()=>{}) {
|
|
336
|
+
let prepared,previous;
|
|
337
|
+
prepared=checked.prepared;previous=checked.previous;approval=structuredClone(approval);
|
|
338
|
+
const plan=prepared.preview.plan,digest=contractDigest(plan);
|
|
339
|
+
const retired=new Set(plan.command==='update'?retiredBundleOwnership(previous,plan.desired).map(o=>o.path):[]);
|
|
340
|
+
if(plan.targets.some(t=>!['create','replace','edit-fields',...((plan.command==='remove' || retired.has(t.path))?['delete','verify-absent']:[])].includes(t.action)))fail('apply.unsupported-action');
|
|
341
|
+
if(plan.targets.some(t=>t.action==='verify-absent' && !readbackOnly.has(t.path)))fail('apply.unsupported-action');
|
|
342
|
+
const pending={schemaVersion:1,workspace:lock.workspace,status:'needs-reconciliation',runtime:'not-run',
|
|
343
|
+
active:previous?.active??null,pending:digest,...(previous?.activation?{activation:structuredClone(previous.activation)}:{})};
|
|
344
|
+
validateState(pending);
|
|
345
|
+
const pendingBytes=recordBytes(pending),ready={...pending,status:plan.desired===null?'not-installed':'ready',active:plan.desired,pending:null};
|
|
346
|
+
validateState(ready);
|
|
347
|
+
// Reserve the complete bounded final-state envelope before any writes.
|
|
348
|
+
recordBytes({...ready,activation:{recovery:'.pipeline/transactions/00000000-0000-0000-0000-000000000000/recovery.json',
|
|
349
|
+
recoveryHash:'sha256:'+'0'.repeat(64),journalHead:{sequence:20001,hash:'sha256:'+'0'.repeat(64)}}});
|
|
350
|
+
const outputs=new Map(prepared.preview.outputs.map(o=>[o.path,Buffer.from(o.bytes,'base64')]));
|
|
351
|
+
const scratch=new Map(plan.targets.map(t=>[t.path,scratchName(t.path)])),stateScratch=scratchName('.pipeline/state.json');
|
|
352
|
+
for(const name of [...scratch.values(),stateScratch])resolveChild(lock.workspace,name);
|
|
353
|
+
const recovery={schemaVersion:1,prepared,approval,previous,backups:checked.backups.map(({path,hash,existing})=>({path,hash,existing}))};
|
|
354
|
+
// Record parsing limits are explicit. Large recovery envelopes fail closed
|
|
355
|
+
// here; split-record storage can extend this later without changing approvals.
|
|
356
|
+
const recoveryBytes=recordBytes(recovery);
|
|
357
|
+
// Fault instrumentation is trusted caller code only, not a package hook.
|
|
358
|
+
// No contents, secrets or open file handles are passed to it.
|
|
359
|
+
const io=purpose=>(phase,detail)=>ioBoundary({phase,purpose,...detail});
|
|
360
|
+
await boundary('preflight');
|
|
361
|
+
await copySnapshot(lock,checked.snapshot);
|
|
362
|
+
for(const backup of checked.backups)await saveBackup(lock,backup.path,backup.bytes,backup.hash);
|
|
363
|
+
await boundary('backups');
|
|
364
|
+
const journal=await createJournal(lock,plan,previous,{ioBoundary:io('journal'),onLocation:onJournal});
|
|
365
|
+
const recoveryPath='.pipeline/transactions/'+journal.relative.split('/').at(-1)+'/recovery.json';
|
|
366
|
+
await writeCheckedFile(lock,recoveryPath,null,recoveryBytes,undefined,io('recovery'));
|
|
367
|
+
await boundary('recovery');
|
|
368
|
+
// Nothing above has changed a provider target or active state. Recheck after
|
|
369
|
+
// staging and before publishing pending, including unchanged dependencies.
|
|
370
|
+
await recheck();
|
|
371
|
+
await verifyPreparedSnapshot({...checked.snapshot,snapshotPath:resolveChild(lock.workspace,plan.source.path)});
|
|
372
|
+
if(await currentHash(lock,recoveryPath)!==sha256(recoveryBytes))fail('apply.recovery-drift');
|
|
373
|
+
for(const backup of checked.backups)await verifyBackup(lock.workspace,backup.path,backup.hash);
|
|
374
|
+
await writeCheckedFile(lock,'.pipeline/state.json',prepared.stateFileHash,pendingBytes,stateScratch,io('pending'));
|
|
375
|
+
await boundary('pending');
|
|
376
|
+
for(let index=0;index<plan.targets.length;index++) {
|
|
377
|
+
const target=plan.targets[index];
|
|
378
|
+
await journal.intent(target.id);await boundary('intent',index);
|
|
379
|
+
let error=null;
|
|
380
|
+
try {
|
|
381
|
+
if(await currentHash(lock,'.pipeline/state.json')!==sha256(pendingBytes))fail('apply.state-drift');
|
|
382
|
+
if(readbackOnly.has(target.path)) {
|
|
383
|
+
await assertLockHeld(lock);
|
|
384
|
+
if(await currentHash(lock,target.path)!==target.desiredHash)fail('apply.target-drift');
|
|
385
|
+
} else if(target.action==='delete')await deleteCheckedFile(lock,target.path,target.beforeHash,io('target'));
|
|
386
|
+
else await writeCheckedFile(lock,target.path,target.beforeHash,outputs.get(target.path),scratch.get(target.path),io('target'));
|
|
387
|
+
}catch(caught){error=caught;}
|
|
388
|
+
// A thrown fault here simulates process death: intent remains uncertain.
|
|
389
|
+
await boundary('write',index);
|
|
390
|
+
let observedHash=null,observed=false;
|
|
391
|
+
try{observedHash=await currentHash(lock,target.path);observed=true;}catch{}
|
|
392
|
+
const status=!error && observed && observedHash===target.desiredHash?'completed':
|
|
393
|
+
observed && observedHash===target.beforeHash?'failed':'uncertain';
|
|
394
|
+
const result=await journal.outcome(status,observedHash);await boundary('outcome',index);
|
|
395
|
+
if(status!=='completed') {
|
|
396
|
+
validateTransition(previous,pending,plan,result.receipt);
|
|
397
|
+
return {status:'needs-reconciliation',receipt:result.receipt,journal:journal.relative,recoveryPath,runtime:'not-run'};
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
await boundary('before-active');
|
|
401
|
+
const final=await readJournal(lock.workspace,journal.relative,plan,previous);
|
|
402
|
+
if(final.receipt?.status!=='completed')fail('apply.journal-incomplete');
|
|
403
|
+
const expected=new Map(prepared.preview.observations.map(o=>[o.path,o.hash]));
|
|
404
|
+
for(const target of plan.targets)expected.set(target.path,target.desiredHash);
|
|
405
|
+
for(const observation of await observeTargets(lock.workspace,[...expected.keys()]))
|
|
406
|
+
if(fileHash(observation.bytes)!==expected.get(observation.path))fail('apply.final-drift');
|
|
407
|
+
await verifyPreparedSnapshot({...checked.snapshot,snapshotPath:resolveChild(lock.workspace,plan.source.path)});
|
|
408
|
+
if(await currentHash(lock,recoveryPath)!==sha256(recoveryBytes))fail('apply.recovery-drift');
|
|
409
|
+
for(const backup of checked.backups)await verifyBackup(lock.workspace,backup.path,backup.hash);
|
|
410
|
+
await verifyLineage();
|
|
411
|
+
ready.activation={recovery:recoveryPath,recoveryHash:sha256(recoveryBytes),journalHead:{sequence:final.sequence,hash:final.lastHash}};
|
|
412
|
+
const readyBytes=recordBytes(ready);
|
|
413
|
+
validateTransition(previous,ready,plan,final.receipt);
|
|
414
|
+
await writeCheckedFile(lock,'.pipeline/state.json',sha256(pendingBytes),readyBytes,stateScratch,io('active'));
|
|
415
|
+
await boundary('active');
|
|
416
|
+
return {status:ready.status,state:ready,receipt:final.receipt,journal:journal.relative,recoveryPath,runtime:'not-run'};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Exact owned file only; never recurse, remove directories or follow a link.
|
|
420
|
+
// Portable Node APIs cannot exclude hostile same-user races after the last check.
|
|
421
|
+
export async function deleteCheckedFile(lock,relative,beforeHash,ioBoundary) {
|
|
422
|
+
if(beforeHash===null)fail('apply.delete-missing');
|
|
423
|
+
const filename=resolveChild(lock.workspace,relative),detail={path:relative};
|
|
424
|
+
await ioBoundary('before-delete',detail);
|
|
425
|
+
await assertLockHeld(lock);
|
|
426
|
+
if(await currentHash(lock,relative)!==beforeHash)fail('apply.target-drift');
|
|
427
|
+
try {
|
|
428
|
+
await unlink(filename);await ioBoundary('deleted',detail);
|
|
429
|
+
if(await currentHash(lock,relative)!==null)fail('apply.readback');
|
|
430
|
+
await ioBoundary('readback',detail);
|
|
431
|
+
}catch(error){throw error instanceof ContractError?error:new ContractError('apply.delete');}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Registry functions are trusted installer code supplied by the caller. They are
|
|
435
|
+
// never imported/evaluated from a pipeline package or serialized approval record.
|
|
436
|
+
// Re-rendering is mandatory: even a self-consistent envelope is not a path grant.
|
|
437
|
+
export async function preflightApply(lock, prepared, approval, registry, previous=null) {
|
|
438
|
+
if(!registry || typeof registry!=='object' || Array.isArray(registry))fail('provider.interface');
|
|
439
|
+
const {adapters,sharedAdapter}=registry;
|
|
440
|
+
if(!adapters || typeof adapters!=='object' || Array.isArray(adapters))fail('provider.interface');
|
|
441
|
+
previous=structuredClone(previous);
|
|
442
|
+
const checked=await verifyPreparedApproval(lock,prepared,approval,previous);
|
|
443
|
+
const copy=checked.prepared,plan=copy.preview.plan;
|
|
444
|
+
for(const candidate of Object.values(copy.preparation)) {
|
|
445
|
+
const root=absoluteRoot(candidate),relative=path.relative(lock.workspace,root),reverse=path.relative(root,lock.workspace);
|
|
446
|
+
const nested=r=>!r || (!path.isAbsolute(r) && r!=='..' && !r.startsWith('..'+path.sep));
|
|
447
|
+
if(nested(relative)||nested(reverse))fail('apply.preparation-location');
|
|
448
|
+
if(!(await inspectDirectory(root)).exists)fail('apply.preparation-missing');
|
|
449
|
+
}
|
|
450
|
+
const options={wrapper:lock.workspace,previous,manifestPath:plan.source.origin.path,command:plan.command};
|
|
451
|
+
const origin=copy.rebind===null?await resolveOrigin(options):await resolveApprovedRebind({...options,...copy.rebind});
|
|
452
|
+
if(contractDigest(origin.origin)!==contractDigest(plan.source.origin))fail('apply.origin-drift');
|
|
453
|
+
const verified=await verifyPreparedSnapshot(checked.snapshot);
|
|
454
|
+
const layout=planLayout(verified.manifest,origin.manifest,lock.workspace,{adapters});
|
|
455
|
+
if(!sharedAdapter || typeof sharedAdapter.plan!=='function')fail('plan.shared-adapter');
|
|
456
|
+
const context=()=>({pipeline:structuredClone(verified.manifest),workspace:structuredClone(origin.manifest),
|
|
457
|
+
layout:structuredClone(layout),snapshot:structuredClone(plan.source),
|
|
458
|
+
files:new Map([...verified.files].map(([name,bytes])=>[name,Buffer.from(bytes)]))});
|
|
459
|
+
const requests=[];
|
|
460
|
+
for(const [owner,adapter] of [['shared',sharedAdapter],...layout.providers.map(id=>[id,adapters[id]])]) {
|
|
461
|
+
if(owner!=='shared' && (await adapter.validate(context()))?.valid===false)fail('provider.validation');
|
|
462
|
+
const batch=await adapter.plan(context());
|
|
463
|
+
if(!Array.isArray(batch))fail('plan.adapter-output');
|
|
464
|
+
for(const request of batch) {
|
|
465
|
+
requestShape(request,['path','owner','kind'],['bytes','fields','takeover'],'plan.request');
|
|
466
|
+
if(request.owner!==owner)fail('plan.adapter-owner');
|
|
467
|
+
requests.push(request);
|
|
468
|
+
}
|
|
469
|
+
cap(requests.length,LIMITS.files,'plan.count');
|
|
470
|
+
}
|
|
471
|
+
// Use a validated envelope's observations. composePlan independently enforces
|
|
472
|
+
// provider roots, ownership/takeover, repository overlap and exact field edits.
|
|
473
|
+
const observations=copy.preview.observations.map(item=>({path:item.path,bytes:item.bytes===null?null:Buffer.from(item.bytes,'base64')}));
|
|
474
|
+
const rendered=composePlan({pipeline:verified.manifest,workspace:origin.manifest,wrapper:lock.workspace,previous,
|
|
475
|
+
snapshot:plan.source,adapters,requests,observations});
|
|
476
|
+
if(contractDigest(rendered)!==contractDigest(copy.preview))fail('apply.adapter-drift');
|
|
477
|
+
const expectedPaths=planObservationPaths(requests,previous,layout);
|
|
478
|
+
if(expectedPaths.length!==observations.length || observations.some(o=>!expectedPaths.includes(o.path)))fail('apply.observation-scope');
|
|
479
|
+
// Adapters may await: repeat all source/state/target observations afterwards.
|
|
480
|
+
await verifyPreparedApproval(lock,copy,approval,previous);
|
|
481
|
+
const backups=await planBackups(lock,rendered,previous);
|
|
482
|
+
return {...checked,previous,backups};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Backup hashes always refer to complete byte files. For historical field-owned
|
|
486
|
+
// backups, state authenticates only the owned field value, not foreign siblings.
|
|
487
|
+
// Capture their current whole-file hash for this transaction; removal must never
|
|
488
|
+
// restore the entire historical file for a field-owned entry.
|
|
489
|
+
async function planBackups(lock,preview,previous) {
|
|
490
|
+
const backups=new Map();
|
|
491
|
+
for(const item of preview.plan.desired.owned) {
|
|
492
|
+
if(item.backup===null)continue;
|
|
493
|
+
const old=previous?.active?.owned.find(r=>r.path===item.path && r.kind===item.kind && r.pointer===item.pointer);
|
|
494
|
+
let bytes,existing=false;
|
|
495
|
+
if(old) {
|
|
496
|
+
if(old.backup!==item.backup || old.beforeHash!==item.beforeHash)fail('apply.backup-lineage');
|
|
497
|
+
[{bytes}]=await observeTargets(lock.workspace,[item.backup]);existing=true;
|
|
498
|
+
} else {
|
|
499
|
+
const observation=preview.observations.find(o=>o.path===item.path);
|
|
500
|
+
bytes=observation?.bytes===null || !observation?null:Buffer.from(observation.bytes,'base64');
|
|
501
|
+
}
|
|
502
|
+
if(bytes===null)fail('apply.backup-missing');
|
|
503
|
+
const hash=sha256(bytes);
|
|
504
|
+
if(item.kind==='file') {if(hash!==item.beforeHash)fail('apply.backup-lineage');}
|
|
505
|
+
else {
|
|
506
|
+
const field=readConfigField(item.path,bytes,item.pointer);
|
|
507
|
+
if(!field.present || contractDigest(field.value)!==item.beforeHash)fail('apply.backup-lineage');
|
|
508
|
+
}
|
|
509
|
+
const [stored]=await observeTargets(lock.workspace,[item.backup]);
|
|
510
|
+
if(stored.bytes!==null && sha256(stored.bytes)!==hash)fail('apply.backup-conflict');
|
|
511
|
+
if(backups.has(item.backup) && backups.get(item.backup).hash!==hash)fail('apply.backup-conflict');
|
|
512
|
+
backups.set(item.backup,{path:item.backup,hash,bytes:Buffer.from(bytes),existing});
|
|
513
|
+
}
|
|
514
|
+
return [...backups.values()];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Binds an explicit caller decision to the exact prepared envelope. The caller
|
|
518
|
+
// must obtain this decision from the user, not manufacture it from a digest.
|
|
519
|
+
// Internal only: no target authorization, state activation, Git or network I/O.
|
|
520
|
+
export async function verifyPreparedApproval(lock, prepared, approval, previous=null) {
|
|
521
|
+
const copy=validatePreparedRecord(prepared,approval);previous=structuredClone(previous);
|
|
522
|
+
await preflightPreview(lock,copy.preview,previous,copy.stateFileHash);
|
|
523
|
+
const plan=copy.preview.plan,source=plan.source;
|
|
524
|
+
if(!['setup','update'].includes(plan.command) || !source || !plan.desired ||
|
|
525
|
+
contractDigest(source)!==contractDigest(plan.desired.snapshot))fail('apply.source');
|
|
526
|
+
const options={wrapper:lock.workspace,previous,manifestPath:source.origin.path,command:plan.command};
|
|
527
|
+
const origin=copy.rebind===null ? await resolveOrigin(options) :
|
|
528
|
+
await resolveApprovedRebind({...options,...copy.rebind});
|
|
529
|
+
if(contractDigest(origin.origin)!==contractDigest(source.origin) ||
|
|
530
|
+
contractDigest(origin.manifest.pipeline)!==contractDigest(source.source) ||
|
|
531
|
+
contractDigest(origin.rebind??null)!==contractDigest(copy.rebind))fail('apply.origin-drift');
|
|
532
|
+
const snapshot={snapshotPath:copy.preparation.snapshot,manifest:{id:plan.desired.pipelineId,version:plan.desired.version},
|
|
533
|
+
digest:source.digest,inventoryDigest:source.inventoryDigest};
|
|
534
|
+
if(source.path!=='.pipeline/snapshots/'+source.digest.slice(7))fail('apply.source');
|
|
535
|
+
await verifyPreparedSnapshot(snapshot);
|
|
536
|
+
if(previous?.active)await verifyInstalledSnapshot(previous);
|
|
537
|
+
await preflightPreview(lock,copy.preview,previous,copy.stateFileHash);
|
|
538
|
+
return {prepared:copy,snapshot,runtime:'not-run'};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function validatePreparedRecord(prepared,approval) {
|
|
542
|
+
requestShape(prepared,['kind','preview','stateFileHash','rebind','preparation','runtime','digest'],[],'apply.prepared');
|
|
543
|
+
requestShape(approval,['decision','preparedDigest'],[],'apply.approval');
|
|
544
|
+
requestShape(prepared.preparation,['objects','snapshot'],[],'apply.prepared');
|
|
545
|
+
if(prepared.rebind!==null)requestShape(prepared.rebind,['proposal','approval'],[],'apply.prepared');
|
|
546
|
+
const {digest,...body}=prepared;
|
|
547
|
+
if(prepared.kind!=='prepared-plan' || prepared.runtime!=='not-run' || contractDigest(body)!==digest)fail('apply.prepared');
|
|
548
|
+
if(approval.decision!=='approve' || approval.preparedDigest!==digest)fail('apply.approval');
|
|
549
|
+
// Detach before the first await so later caller mutation cannot change inputs.
|
|
550
|
+
return structuredClone(prepared);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Internal read-only preflight primitive, NOT an apply entrypoint or path grant.
|
|
554
|
+
// The coordinator must first establish trusted adapter/source/approval binding.
|
|
555
|
+
// Checks unchanged dependencies too; a conflict anywhere causes zero target writes.
|
|
556
|
+
// The lock is advisory: every target still needs an immediate pre-write recheck.
|
|
557
|
+
export async function preflightPreview(lock, envelope, previous, stateFileHash) {
|
|
558
|
+
await assertLockHeld(lock);
|
|
559
|
+
if (envelope?.plan?.workspace !== lock.workspace) fail('apply.workspace');
|
|
560
|
+
const filename=resolveChild(lock.workspace,'.pipeline/state.json');
|
|
561
|
+
async function checkState() {
|
|
562
|
+
let current;
|
|
563
|
+
try { current=await readState(filename); }
|
|
564
|
+
catch(error) { if(error.code!=='record.missing')throw error;current=null; }
|
|
565
|
+
if ((current?.digest ?? null)!==stateFileHash ||
|
|
566
|
+
(current===null)!==(previous===null) ||
|
|
567
|
+
(current!==null && contractDigest(current.value)!==contractDigest(previous))) fail('apply.state-drift');
|
|
568
|
+
if(current?.value.pending!==null && current!==null)fail('state.pending');
|
|
569
|
+
}
|
|
570
|
+
await checkState();
|
|
571
|
+
if(!Array.isArray(envelope.observations))fail('preview.envelope');
|
|
572
|
+
const observations=await observeTargets(lock.workspace,envelope.observations.map(item=>item.path));
|
|
573
|
+
const checked=checkPreview(envelope,previous,observations);
|
|
574
|
+
await checkState();await assertLockHeld(lock);
|
|
575
|
+
return checked;
|
|
576
|
+
}
|