@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,278 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { fail, parse, ContractError } from '../contracts/parse.js';
|
|
4
|
+
import { validateOperation, validateState, contractDigest, portablePath } from '../contracts/semantic.js';
|
|
5
|
+
import { sha256, LIMITS, cap } from '../source/inventory.js';
|
|
6
|
+
import { planLayout } from '../workspace/resolve.js';
|
|
7
|
+
import { resolveChild, absoluteRoot, inspectDirectory } from '../workspace/paths.js';
|
|
8
|
+
import { overlaps } from '../workspace/reserved.js';
|
|
9
|
+
import { reconcileOwnership, requestShape } from './ownership.js';
|
|
10
|
+
import {reconcileConfigFields} from './config-fields.js';
|
|
11
|
+
import { acquire } from '../source/git.js';
|
|
12
|
+
import { readState, resolveOrigin, resolveApprovedRebind, verifyPreparedSnapshot, observeTargets } from './state.js';
|
|
13
|
+
import { commonEntryPath, commonEntryText, needsCommonEntry } from '../providers/common-entry.js';
|
|
14
|
+
import {planObservationPaths,retiredBundleOwnership,restoreRetired} from './bundle-update.js';
|
|
15
|
+
|
|
16
|
+
// Trusted CLI destination policy, not a claim about tested harness discovery.
|
|
17
|
+
const roots = {shared:['AGENTS.md','CLAUDE.md'],codex:['.codex/','.agents/skills/'],
|
|
18
|
+
claude:['.claude/','CLAUDE.md','CLAUDE.local.md','.mcp.json'],kimi:['.kimi-code/'],grok:['.grok/']};
|
|
19
|
+
|
|
20
|
+
export function assertRequestScope(request, providers) {
|
|
21
|
+
assertRequestShape(request);
|
|
22
|
+
const {path:name,owner,kind}=request;
|
|
23
|
+
portablePath(name);
|
|
24
|
+
if (owner==='shared' && name===commonEntryPath &&
|
|
25
|
+
(kind!=='file' || !needsCommonEntry(providers))) fail('plan.scope');
|
|
26
|
+
if (!['file','json-fields','toml-fields'].includes(kind) || !(owner==='shared' || providers.includes(owner)) ||
|
|
27
|
+
!roots[owner]?.some(root=>root.endsWith('/') ? name.startsWith(root) : name===root)) fail('plan.scope');
|
|
28
|
+
// Project MCP config is shared with user-defined servers. Own only individual
|
|
29
|
+
// named server entries, never the complete file or mcpServers parent object.
|
|
30
|
+
if(['.mcp.json','.kimi-code/mcp.json'].includes(name) && (kind!=='json-fields' || request.fields.some(field=>
|
|
31
|
+
!/^\/mcpServers\/[a-z][a-z0-9_-]{0,99}$/.test(field.pointer)))) fail('plan.scope');
|
|
32
|
+
if(name==='.grok/config.toml' && (kind!=='toml-fields' || request.fields.some(field=>
|
|
33
|
+
!/^\/mcp_servers\/[a-z][a-z0-9_-]{0,99}$/.test(field.pointer))))fail('plan.scope');
|
|
34
|
+
if(kind==='toml-fields' && !((owner==='codex' && name==='.codex/config.toml') ||
|
|
35
|
+
(owner==='grok' && name==='.grok/config.toml')))fail('plan.scope');
|
|
36
|
+
if(kind==='json-fields' && ['.codex/config.toml','.grok/config.toml'].includes(name))fail('plan.scope');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertRequestShape(request) {
|
|
40
|
+
requestShape(request, ['path','owner','kind'], ['bytes','takeover','fields'], 'plan.request');
|
|
41
|
+
if (request.kind === 'file') requestShape(request, ['path','owner','kind','bytes'], ['takeover'], 'plan.request');
|
|
42
|
+
else if (['json-fields','toml-fields'].includes(request.kind)) {
|
|
43
|
+
requestShape(request, ['path','owner','kind','fields'], [], 'plan.request');
|
|
44
|
+
if (!Array.isArray(request.fields) || !request.fields.length) fail('plan.fields');
|
|
45
|
+
for (const field of request.fields) requestShape(field, ['pointer','present'], ['value','takeover'], 'plan.fields');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Source/setup/update preparation. Registry functions are trusted CLI code, never
|
|
50
|
+
// loaded from the source. Temporary acquisition must remain outside workspace.
|
|
51
|
+
// No harness/MCP launch, config write, trust grant or apply approval happens here.
|
|
52
|
+
export async function preparePlan({wrapper,manifestPath,adapters,sharedAdapter,network=false,tempRoot=tmpdir(),rebind}) {
|
|
53
|
+
wrapper=absoluteRoot(wrapper);
|
|
54
|
+
await inspectDirectory(wrapper);
|
|
55
|
+
const temporary=(await inspectDirectory(absoluteRoot(tempRoot))).path;
|
|
56
|
+
const rel=path.relative(wrapper,temporary);
|
|
57
|
+
if(!rel || (!rel.startsWith('..'+path.sep) && rel!=='..' && !path.isAbsolute(rel))) fail('plan.preparation-location');
|
|
58
|
+
if(!adapters || typeof adapters!=='object' || Array.isArray(adapters))fail('provider.interface');
|
|
59
|
+
const statePath=resolveChild(wrapper,'.pipeline/state.json');
|
|
60
|
+
async function currentState() {
|
|
61
|
+
try{return await readState(statePath);}catch(error){if(error.code==='record.missing')return null;throw error;}
|
|
62
|
+
}
|
|
63
|
+
const before=await currentState(), previous=before?.value ?? null;
|
|
64
|
+
if(previous!==null && previous.pending!==null) fail('state.pending');
|
|
65
|
+
const options={wrapper,previous,manifestPath,command:previous?.active?'update':'setup'};
|
|
66
|
+
const rebindOptions=()=>({...options,proposal:rebind?.proposal,approval:rebind?.approval});
|
|
67
|
+
const origin=rebind ? await resolveApprovedRebind(rebindOptions()) : await resolveOrigin(options);
|
|
68
|
+
const acquired=await acquire(origin.manifest.pipeline,{manifestBase:origin.origin.base,tempRoot:temporary,network});
|
|
69
|
+
if(acquired.resolvedSource!==origin.origin.resolvedSource) fail('source-rebind-required');
|
|
70
|
+
const verified=await verifyPreparedSnapshot(acquired);
|
|
71
|
+
const layout=planLayout(verified.manifest,origin.manifest,wrapper,{adapters});
|
|
72
|
+
if(!sharedAdapter || typeof sharedAdapter.plan!=='function') fail('plan.shared-adapter');
|
|
73
|
+
const snapshot={source:structuredClone(acquired.source),commit:acquired.commit,path:'.pipeline/snapshots/'+acquired.digest.slice(7),
|
|
74
|
+
digest:acquired.digest,inventoryDigest:acquired.inventoryDigest,origin:structuredClone(origin.origin)};
|
|
75
|
+
const requests=[];
|
|
76
|
+
const context=()=>({pipeline:structuredClone(verified.manifest),workspace:structuredClone(origin.manifest),
|
|
77
|
+
layout:structuredClone(layout),snapshot:structuredClone(snapshot),
|
|
78
|
+
files:new Map([...verified.files].map(([name,bytes])=>[name,Buffer.from(bytes)]))});
|
|
79
|
+
for(const [owner,adapter] of [['shared',sharedAdapter],...layout.providers.map(id=>[id,adapters[id]])]) {
|
|
80
|
+
if(owner!=='shared') {
|
|
81
|
+
const validation=await adapter.validate(context());
|
|
82
|
+
if(validation?.valid===false) fail('provider.validation');
|
|
83
|
+
}
|
|
84
|
+
const batch=await adapter.plan(context());
|
|
85
|
+
if(!Array.isArray(batch)) fail('plan.adapter-output');
|
|
86
|
+
for(const request of batch) {
|
|
87
|
+
assertRequestShape(request);
|
|
88
|
+
if(request.owner!==owner) fail('plan.adapter-owner');
|
|
89
|
+
assertRequestScope(request,layout.providers);requests.push(request);
|
|
90
|
+
}
|
|
91
|
+
cap(requests.length,LIMITS.files,'plan.count');
|
|
92
|
+
}
|
|
93
|
+
planLayout(verified.manifest,origin.manifest,wrapper,{adapters,managedPaths:requests.map(r=>r.path)});
|
|
94
|
+
const observations=await observeTargets(wrapper,planObservationPaths(requests,previous,layout));
|
|
95
|
+
const preview=composePlan({pipeline:verified.manifest,workspace:origin.manifest,wrapper,previous,snapshot,adapters,requests,observations});
|
|
96
|
+
const after=await currentState();
|
|
97
|
+
if((after?.digest ?? null)!==(before?.digest ?? null)) fail('plan.state-drift');
|
|
98
|
+
const finalOrigin=rebind ? await resolveApprovedRebind(rebindOptions()) : await resolveOrigin(options);
|
|
99
|
+
if(contractDigest(finalOrigin)!==contractDigest(origin)) fail('plan.origin-drift');
|
|
100
|
+
const body={kind:'prepared-plan',preview,stateFileHash:before?.digest ?? null,
|
|
101
|
+
rebind:origin.rebind ?? null,preparation:{objects:acquired.preparation,snapshot:acquired.snapshotPath},runtime:'not-run'};
|
|
102
|
+
const prepared={...body,digest:contractDigest(body)};
|
|
103
|
+
assertRecoveryBudget(prepared,previous);
|
|
104
|
+
return prepared;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Conservative bound for the exact inline recovery format used by S5. Fixed-size
|
|
108
|
+
// hashes and false (longer than true) bound backup descriptors without reading or
|
|
109
|
+
// creating backup files. No approval is granted by this sizing-only placeholder.
|
|
110
|
+
export function assertRecoveryBudget(prepared,previous=null) {
|
|
111
|
+
requestShape(prepared,['kind','preview','stateFileHash','rebind','preparation','runtime','digest'],[],'plan.recovery-input');
|
|
112
|
+
requestShape(prepared.preview,['schemaVersion','plan','observations','outputs','digest'],[],'plan.recovery-input');
|
|
113
|
+
validateOperation(prepared.preview.plan,previous);
|
|
114
|
+
if(prepared.preview.plan.kind!=='plan' || !prepared.preview.plan.desired)fail('plan.recovery-input');
|
|
115
|
+
const plan=prepared.preview.plan,paths=new Set(plan.desired.owned.map(o=>o.backup).filter(p=>p!==null));
|
|
116
|
+
const hash='sha256:'+'0'.repeat(64);
|
|
117
|
+
const backups=[...paths].map(path=>({path,hash,existing:false}));
|
|
118
|
+
const pending={schemaVersion:1,workspace:plan.workspace,status:'needs-reconciliation',runtime:'not-run',
|
|
119
|
+
active:previous?.active??null,pending:contractDigest(plan),...(previous?.activation?{activation:previous.activation}:{})};
|
|
120
|
+
const activation={recovery:'.pipeline/transactions/00000000-0000-0000-0000-000000000000/recovery.json',
|
|
121
|
+
recoveryHash:hash,journalHead:{sequence:20001,hash}};
|
|
122
|
+
try {
|
|
123
|
+
for(const value of [{schemaVersion:1,prepared,approval:{decision:'approve',preparedDigest:prepared.digest},previous,backups},
|
|
124
|
+
pending,{...pending,status:'ready',active:plan.desired,pending:null,activation}])parse(JSON.stringify(value)+'\n','json');
|
|
125
|
+
}catch(error){
|
|
126
|
+
if(error instanceof ContractError && ['parse.size','parse.complexity'].includes(error.code))fail('plan.recovery-budget');
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Pure setup/update composition. Requests must come from trusted CLI adapters,
|
|
132
|
+
// never executable source-package code. Source verification and filesystem
|
|
133
|
+
// observations must be coordinated by the outer planner before any apply.
|
|
134
|
+
export function composePlan({pipeline, workspace, wrapper, previous = null, snapshot, adapters, requests, observations}) {
|
|
135
|
+
if (previous !== null) {
|
|
136
|
+
validateState(previous);
|
|
137
|
+
if (previous.pending !== null || previous.workspace !== wrapper) fail('plan.previous');
|
|
138
|
+
if (previous.active && previous.active.pipelineId !== pipeline.id) fail('plan.switch-required');
|
|
139
|
+
}
|
|
140
|
+
if (!Array.isArray(requests) || !Array.isArray(observations)) fail('plan.input');
|
|
141
|
+
cap(requests.length,LIMITS.files,'plan.count');
|
|
142
|
+
for (const request of requests) assertRequestShape(request);
|
|
143
|
+
const ordered = [...requests].sort((a,b)=>a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
144
|
+
const layout = planLayout(pipeline,workspace,wrapper,{adapters,managedPaths:ordered.map(r=>r.path)});
|
|
145
|
+
const observed = new Map(), used = new Set(), owned = [], targets = [], outputs = [];
|
|
146
|
+
for (const observation of observations) {
|
|
147
|
+
portablePath(observation.path);
|
|
148
|
+
if (observed.has(observation.path)) fail('preview.duplicate');
|
|
149
|
+
observed.set(observation.path,observation.bytes);
|
|
150
|
+
}
|
|
151
|
+
for (const request of ordered) {
|
|
152
|
+
const {path: name,owner,kind} = request;
|
|
153
|
+
resolveChild(layout.wrapper,name);
|
|
154
|
+
assertRequestScope(request, layout.providers);
|
|
155
|
+
if ([...used].some(prior=>overlaps(prior,name))) fail('plan.overlap');
|
|
156
|
+
used.add(name);
|
|
157
|
+
if (!observed.has(name)) fail('preview.before');
|
|
158
|
+
const bytes = observed.get(name);
|
|
159
|
+
if (bytes!==null && !Buffer.isBuffer(bytes)) fail('preview.bytes');
|
|
160
|
+
const beforeHash = bytes===null ? null : sha256(bytes);
|
|
161
|
+
const prior = previous?.active?.owned.filter(r=>r.path.toLowerCase()===name.toLowerCase()) ?? [];
|
|
162
|
+
const entryMigration = name===commonEntryPath && owner==='shared' && kind==='file' &&
|
|
163
|
+
prior.length===1 && prior[0].path===name && prior[0].owner==='claude' && prior[0].kind==='file' &&
|
|
164
|
+
previous.active.adapterVersions.claude==='1' && layout.providers.includes('claude') &&
|
|
165
|
+
adapters.claude?.version==='2' && Buffer.isBuffer(request.bytes) &&
|
|
166
|
+
(layout.bundles || request.bytes.equals(Buffer.from(commonEntryText)));
|
|
167
|
+
if (prior.some(r=>r.path!==name || (!entryMigration && r.owner!==owner) || r.kind!==(kind==='file'?'file':'field'))) fail('plan.ownership-kind');
|
|
168
|
+
const retain = (old,pointer,currentHash,managedHash) => {
|
|
169
|
+
if (old) return {...structuredClone(old),owner,managedHash};
|
|
170
|
+
return {path:name,kind:pointer===null?'file':'field',pointer,owner,beforeHash:currentHash,managedHash,
|
|
171
|
+
backup:currentHash===null ? null : '.pipeline/backups/'+contractDigest({name,pointer,beforeHash}).slice(7)+'.bin'};
|
|
172
|
+
};
|
|
173
|
+
let result, action, fields=[];
|
|
174
|
+
if (kind==='file') {
|
|
175
|
+
if (!Buffer.isBuffer(request.bytes)) fail('plan.bytes');
|
|
176
|
+
const desiredHash=sha256(request.bytes);
|
|
177
|
+
const decision=reconcileOwnership({currentHash:beforeHash,desiredHash,managedHash:prior[0]?.managedHash ?? null,takeover:request.takeover ?? null});
|
|
178
|
+
if(decision.owned) owned.push(retain(prior[0],null,beforeHash,desiredHash));
|
|
179
|
+
action=decision.action;result=request.bytes;
|
|
180
|
+
} else {
|
|
181
|
+
if (!Array.isArray(request.fields) || !request.fields.length || request.fields.some(f=>Object.hasOwn(f,'managedHash'))) fail('plan.fields');
|
|
182
|
+
if (prior.some(p=>!request.fields.some(f=>f.pointer===p.pointer))) fail('plan.ownership-coverage');
|
|
183
|
+
const reconciled=reconcileConfigFields(name,bytes,request.fields.map(f=>({...f,managedHash:prior.find(p=>p.pointer===f.pointer)?.managedHash ?? null})));
|
|
184
|
+
for(const d of reconciled.decisions) if(d.owned) owned.push(retain(prior.find(p=>p.pointer===d.pointer),d.pointer,d.currentHash,d.desiredHash));
|
|
185
|
+
fields=reconciled.decisions.filter(d=>d.action!=='preserve').map(d=>({pointer:d.pointer,beforeHash:d.currentHash,desiredHash:d.desiredHash}));
|
|
186
|
+
action=fields.length ? (bytes===null?'create':'edit-fields') : 'preserve';
|
|
187
|
+
result=action==='preserve' ? bytes : reconciled.bytes;
|
|
188
|
+
if(action==='create') fields=[];
|
|
189
|
+
}
|
|
190
|
+
if(action!=='preserve') {
|
|
191
|
+
targets.push({id:'target-'+String(targets.length+1),path:name,owner,action,beforeHash,desiredHash:sha256(result),fields});
|
|
192
|
+
outputs.push({path:name,bytes:result});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Removal/provider switch is S7. Never silently abandon existing ownership.
|
|
196
|
+
for(const restored of restoreRetired(retiredBundleOwnership(previous,layout),observed)) {
|
|
197
|
+
if([...used].some(name=>overlaps(name,restored.path)))fail('plan.overlap');
|
|
198
|
+
// Retirement cannot reach a newly selected repository destination.
|
|
199
|
+
planLayout(pipeline,workspace,wrapper,{adapters,managedPaths:[...used,restored.path]});
|
|
200
|
+
used.add(restored.path);
|
|
201
|
+
if(restored.beforeHash!==restored.desiredHash) {
|
|
202
|
+
const {bytes,...target}=restored;
|
|
203
|
+
targets.push({id:'target-'+String(targets.length+1),...target});
|
|
204
|
+
if(bytes!==null)outputs.push({path:restored.path,bytes});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if(previous?.active?.owned.some(r=>!used.has(r.path))) fail('plan.ownership-coverage');
|
|
208
|
+
const providers=layout.providers;
|
|
209
|
+
const bundleState=layout.bundles?{bundles:structuredClone(layout.bundles)}:{};
|
|
210
|
+
const agentsDocument=structuredClone(layout.agentsDocument);
|
|
211
|
+
const desired={id:'deployment-'+contractDigest({snapshot,layout:layout.layout,agentsDocument,providers,owned,...bundleState}).slice(7),
|
|
212
|
+
pipelineId:pipeline.id,version:pipeline.version,snapshot:structuredClone(snapshot),layout:layout.layout,
|
|
213
|
+
agentsDocument,providers,adapterVersions:Object.fromEntries(providers.map(id=>[id,adapters[id].version])),owned,...bundleState};
|
|
214
|
+
const plan={schemaVersion:1,kind:'plan',workspace:layout.wrapper,command:previous?.active?'update':'setup',
|
|
215
|
+
beforeStateHash:previous===null?null:contractDigest(previous),source:structuredClone(snapshot),desired,targets};
|
|
216
|
+
return bindPreview(plan,previous,{observations,outputs});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// A local preview envelope supplements the S1 operation contract with exact
|
|
220
|
+
// payload bytes and observations (including unchanged targets). Not an approval,
|
|
221
|
+
// path grant, snapshot verification or S5 transaction. Never publish this envelope:
|
|
222
|
+
// observed/output bytes can contain user configuration secrets.
|
|
223
|
+
export function bindPreview(plan, previous, { observations, outputs }) {
|
|
224
|
+
validateOperation(plan, previous);
|
|
225
|
+
if (plan.kind !== 'plan' || !Array.isArray(observations) || !Array.isArray(outputs)) fail('preview.input');
|
|
226
|
+
cap(observations.length, LIMITS.files, 'preview.count');
|
|
227
|
+
cap(outputs.length, LIMITS.files, 'preview.count');
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
let total = 0;
|
|
230
|
+
const encode = (items, allowAbsent) => items.map(item => {
|
|
231
|
+
portablePath(item.path);
|
|
232
|
+
if (seen.has(item.path.toLowerCase())) fail('preview.duplicate');
|
|
233
|
+
seen.add(item.path.toLowerCase());
|
|
234
|
+
if (item.bytes === null && allowAbsent) return {path:item.path,hash:null,bytes:null};
|
|
235
|
+
if (!Buffer.isBuffer(item.bytes)) fail('preview.bytes');
|
|
236
|
+
cap(item.bytes.length, LIMITS.blob, 'preview.size');
|
|
237
|
+
total += item.bytes.length;cap(total, LIMITS.total, 'preview.total');
|
|
238
|
+
return {path:item.path,hash:sha256(item.bytes),bytes:item.bytes.toString('base64')};
|
|
239
|
+
}).sort((a,b)=>a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
240
|
+
const observed = encode(observations, true);seen.clear();
|
|
241
|
+
const payloads = encode(outputs, false);
|
|
242
|
+
const before = new Map(observed.map(item=>[item.path,item]));
|
|
243
|
+
const after = new Map(payloads.map(item=>[item.path,item]));
|
|
244
|
+
for (const target of plan.targets) {
|
|
245
|
+
if (!before.has(target.path) || before.get(target.path).hash !== target.beforeHash) fail('preview.before');
|
|
246
|
+
if (target.desiredHash === null) {
|
|
247
|
+
if (after.has(target.path)) fail('preview.output');
|
|
248
|
+
} else if (!after.has(target.path) || after.get(target.path).hash !== target.desiredHash) fail('preview.output');
|
|
249
|
+
}
|
|
250
|
+
if (payloads.length !== plan.targets.filter(target=>target.desiredHash !== null).length) fail('preview.output');
|
|
251
|
+
const body = {schemaVersion:1,plan:structuredClone(plan),observations:observed,outputs:payloads};
|
|
252
|
+
return {...body,digest:contractDigest(body)};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Reconstruct and revalidate, rather than trusting caller-provided digest strings.
|
|
256
|
+
// Fresh observations must include unchanged dependencies as well as write targets.
|
|
257
|
+
export function checkPreview(envelope, previous, observations) {
|
|
258
|
+
if (!envelope || Object.keys(envelope).sort().join(',') !== 'digest,observations,outputs,plan,schemaVersion' || envelope.schemaVersion !== 1) fail('preview.envelope');
|
|
259
|
+
const decode = items => {
|
|
260
|
+
if (!Array.isArray(items)) fail('preview.envelope');
|
|
261
|
+
return items.map(item=>{
|
|
262
|
+
if (!item || Object.keys(item).sort().join(',') !== 'bytes,hash,path') fail('preview.envelope');
|
|
263
|
+
if (item.bytes === null) {
|
|
264
|
+
if (item.hash !== null) fail('preview.envelope');
|
|
265
|
+
return {path:item.path,bytes:null};
|
|
266
|
+
}
|
|
267
|
+
if (typeof item.bytes !== 'string' || item.bytes.length > Math.ceil(LIMITS.blob/3)*4) fail('preview.bytes');
|
|
268
|
+
const bytes = Buffer.from(item.bytes,'base64');
|
|
269
|
+
if (bytes.toString('base64') !== item.bytes || sha256(bytes) !== item.hash) fail('preview.bytes');
|
|
270
|
+
return {path:item.path,bytes};
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
const rebound = bindPreview(envelope.plan, previous, {observations:decode(envelope.observations),outputs:decode(envelope.outputs)});
|
|
274
|
+
if (contractDigest(rebound) !== contractDigest(envelope)) fail('preview.binding');
|
|
275
|
+
const fresh = bindPreview(envelope.plan, previous, {observations,outputs:decode(envelope.outputs)});
|
|
276
|
+
if (fresh.digest !== rebound.digest) fail('preview.drift');
|
|
277
|
+
return rebound;
|
|
278
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {inspectRecovery} from './apply.js';
|
|
2
|
+
import {readState,readRecord} from './state.js';
|
|
3
|
+
import {absoluteRoot,resolveChild} from '../workspace/paths.js';
|
|
4
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
5
|
+
import {fail,parse} from '../contracts/parse.js';
|
|
6
|
+
import {requestShape} from './ownership.js';
|
|
7
|
+
import {assertLockHeld} from './lock.js';
|
|
8
|
+
import {bindPreview} from './plan.js';
|
|
9
|
+
import {assertContinuationCapacity} from './lineage-guard.js';
|
|
10
|
+
import {retiredBundleOwnership} from './bundle-update.js';
|
|
11
|
+
|
|
12
|
+
export const continuationCommand=plan=>plan.command==='remove'?'remove':
|
|
13
|
+
plan.command==='update' && plan.targets.some(t=>t.desiredHash===null)?'update':'repair';
|
|
14
|
+
|
|
15
|
+
// Current evidence and decisions still required, never a runnable retry/rollback.
|
|
16
|
+
export async function previewReconciliation(workspace,recoveryPath) {
|
|
17
|
+
workspace=absoluteRoot(workspace);
|
|
18
|
+
if(typeof recoveryPath!=='string' || !/^\.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(recoveryPath))fail('recovery.path');
|
|
19
|
+
const stateHash=async()=>{
|
|
20
|
+
try{return (await readState(resolveChild(workspace,'.pipeline/state.json'))).digest;}
|
|
21
|
+
catch(e){if(e.code==='record.missing')return null;throw e;}
|
|
22
|
+
};
|
|
23
|
+
const before=await stateHash(),recovery=await inspectRecovery(workspace,recoveryPath);
|
|
24
|
+
if(await stateHash()!==before || (await readRecord(resolveChild(workspace,recoveryPath))).digest!==recovery.recoveryHash)
|
|
25
|
+
fail('recovery.observation-drift');
|
|
26
|
+
const body={kind:'reconciliation-preview',workspace,stateHash:before,recoveryPath,recoveryHash:recovery.recoveryHash,
|
|
27
|
+
status:recovery.status,statePhase:recovery.statePhase,diagnostics:recovery.diagnostics,targets:recovery.targets,
|
|
28
|
+
journal:recovery.journal,journalHead:recovery.journalHead,lineage:recovery.lineage,requiresDecision:recovery.status!=='applied',requiresFreshApproval:true,
|
|
29
|
+
applySupported:false,automaticActions:false,runtime:'not-run'};
|
|
30
|
+
parse(JSON.stringify(body),'json');
|
|
31
|
+
return {...body,digest:contractDigest(body)};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Fresh continuation proposal only: do not resume an old writer or manufacture
|
|
35
|
+
// its missing outcome. Exact desired bytes require a new readback, not rewriting.
|
|
36
|
+
// Payloads and observations may contain secrets; keep this envelope local.
|
|
37
|
+
export async function prepareContinuation(workspace,recoveryPath) {
|
|
38
|
+
const evidence=await previewReconciliation(workspace,recoveryPath);
|
|
39
|
+
if(!['pending','before'].includes(evidence.statePhase))fail('reconciliation.not-pending');
|
|
40
|
+
if(evidence.diagnostics.length)fail('reconciliation.conflict');
|
|
41
|
+
const record=await readRecord(resolveChild(evidence.workspace,recoveryPath));
|
|
42
|
+
if(record.digest!==evidence.recoveryHash)fail('recovery.observation-drift');
|
|
43
|
+
const previousPlan=record.value.prepared.preview,actions=[];
|
|
44
|
+
const beforeSetup=evidence.statePhase==='before';
|
|
45
|
+
if(beforeSetup && (evidence.stateHash!==null || record.value.previous!==null ||
|
|
46
|
+
record.value.prepared.kind!=='prepared-plan' || record.value.prepared.stateFileHash!==null ||
|
|
47
|
+
previousPlan.plan.command!=='setup'))fail('reconciliation.not-pending');
|
|
48
|
+
if(beforeSetup && ((await inspectRecovery(workspace,recoveryPath)).receipt!==null ||
|
|
49
|
+
evidence.targets.some(t=>t.position!=='before' || t.recorded!=='skipped')))fail('reconciliation.conflict');
|
|
50
|
+
const removal=previousPlan.plan.command==='remove';
|
|
51
|
+
const retired=new Set(retiredBundleOwnership(record.value.previous,previousPlan.plan.desired??{}).map(o=>o.path));
|
|
52
|
+
assertContinuationCapacity(evidence.lineage.length);
|
|
53
|
+
for(const target of previousPlan.plan.targets) {
|
|
54
|
+
const observed=evidence.targets.find(t=>t.id===target.id);
|
|
55
|
+
if(!observed || !['before','desired'].includes(observed.position))fail('reconciliation.conflict');
|
|
56
|
+
if(!['create','replace','edit-fields',...((removal || retired.has(target.path))?['delete','verify-absent']:[])].includes(target.action))fail('reconciliation.unsupported');
|
|
57
|
+
const output=previousPlan.outputs.find(o=>o.path===target.path);
|
|
58
|
+
if(target.desiredHash===null?!!output:!output || output.hash!==target.desiredHash)fail('reconciliation.payload');
|
|
59
|
+
actions.push({id:target.id,path:target.path,owner:target.owner,
|
|
60
|
+
action:observed.position==='desired'?'verify-readback':'write-desired',
|
|
61
|
+
recorded:observed.recorded,beforeHash:observed.observedHash,
|
|
62
|
+
desiredHash:target.desiredHash,bytes:output?.bytes??null});
|
|
63
|
+
}
|
|
64
|
+
// Include unchanged dependencies, not just the remaining writes. The fresh
|
|
65
|
+
// evidence binding prevents an unchanged target list hiding a new journal head.
|
|
66
|
+
let state=null;
|
|
67
|
+
try { state=await readState(resolveChild(evidence.workspace,'.pipeline/state.json')); }
|
|
68
|
+
catch(error) { if(!beforeSetup || error.code!=='record.missing')throw error; }
|
|
69
|
+
if((state?.digest??null)!==evidence.stateHash)fail('recovery.observation-drift');
|
|
70
|
+
const outputs=actions.filter(a=>a.bytes!==null).map(a=>({path:a.path,bytes:Buffer.from(a.bytes,'base64')}));
|
|
71
|
+
const observations=previousPlan.observations.map(o=>{
|
|
72
|
+
const action=actions.find(a=>a.path===o.path);
|
|
73
|
+
return {path:o.path,bytes:action?.action==='verify-readback'?(action.bytes===null?null:Buffer.from(action.bytes,'base64')):o.bytes===null?null:Buffer.from(o.bytes,'base64')};
|
|
74
|
+
});
|
|
75
|
+
const plan={schemaVersion:1,kind:'plan',workspace:evidence.workspace,command:continuationCommand(previousPlan.plan),
|
|
76
|
+
beforeStateHash:state===null?null:contractDigest(state.value),source:structuredClone(previousPlan.plan.source),
|
|
77
|
+
desired:structuredClone(previousPlan.plan.desired),targets:actions.map(a=>({id:a.id,path:a.path,owner:a.owner,
|
|
78
|
+
action:continuationTargetAction(a),beforeHash:a.beforeHash,desiredHash:a.desiredHash,fields:[]}))};
|
|
79
|
+
const preview=bindPreview(plan,state?.value??null,{observations,outputs});
|
|
80
|
+
const body={kind:'prepared-continuation',evidence,actions,preview,stateFileHash:evidence.stateHash,
|
|
81
|
+
dependencies:previousPlan.observations.filter(o=>!actions.some(a=>a.path===o.path)),
|
|
82
|
+
desired:structuredClone(previousPlan.plan.desired),
|
|
83
|
+
applySupported:true,requiresFreshApproval:true,automaticActions:false,runtime:'not-run'};
|
|
84
|
+
parse(JSON.stringify(body),'json');
|
|
85
|
+
if((await previewReconciliation(evidence.workspace,recoveryPath)).digest!==evidence.digest)fail('recovery.observation-drift');
|
|
86
|
+
return {...body,digest:contractDigest(body)};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function continuationTargetAction(action) {
|
|
90
|
+
return action.desiredHash===null?(action.beforeHash===null?'verify-absent':'delete'):action.beforeHash===null?'create':'replace';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validateContinuationRecord(prepared,approval) {
|
|
94
|
+
requestShape(prepared,['kind','evidence','actions','preview','stateFileHash','dependencies','desired','applySupported','requiresFreshApproval','automaticActions','runtime','digest'],[],'reconciliation.prepared');
|
|
95
|
+
requestShape(approval,['decision','preparedDigest'],[],'reconciliation.approval');
|
|
96
|
+
const copy=structuredClone(parse(JSON.stringify(prepared),'json')),decision=structuredClone(approval);
|
|
97
|
+
const {digest,...body}=copy;
|
|
98
|
+
if(copy.kind!=='prepared-continuation' || copy.applySupported!==true || copy.requiresFreshApproval!==true ||
|
|
99
|
+
copy.automaticActions!==false || copy.runtime!=='not-run' || contractDigest(body)!==digest)fail('reconciliation.prepared');
|
|
100
|
+
if(decision.decision!=='approve' || decision.preparedDigest!==digest)fail('reconciliation.approval');
|
|
101
|
+
return copy;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function verifyContinuationApproval(lock,prepared,approval) {
|
|
105
|
+
const copy=validateContinuationRecord(prepared,approval);
|
|
106
|
+
await assertLockHeld(lock);
|
|
107
|
+
if(copy.evidence?.workspace!==lock.workspace)fail('reconciliation.workspace');
|
|
108
|
+
const fresh=await prepareContinuation(lock.workspace,copy.evidence.recoveryPath);
|
|
109
|
+
if(contractDigest(fresh)!==contractDigest(copy))fail('reconciliation.plan-drift');
|
|
110
|
+
await assertLockHeld(lock);
|
|
111
|
+
return fresh;
|
|
112
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {createServer} from 'node:net';
|
|
2
|
+
import {realpath} from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
import {absoluteRoot,inspectDirectory} from '../workspace/paths.js';
|
|
6
|
+
import {fail} from '../contracts/parse.js';
|
|
7
|
+
|
|
8
|
+
// Local-host exclusion only. The kernel releases ownership on process death;
|
|
9
|
+
// durable recovery records describe progress, never ownership of this lease.
|
|
10
|
+
// No TCP listener, data protocol, filesystem socket or persistent recovery lock.
|
|
11
|
+
// Unsupported platforms fail closed rather than falling back to a stale lockfile.
|
|
12
|
+
const held=new WeakMap();
|
|
13
|
+
async function endpoint(wrapper) {
|
|
14
|
+
wrapper=absoluteRoot(wrapper);
|
|
15
|
+
const parent=path.dirname(wrapper);
|
|
16
|
+
if(!(await inspectDirectory(parent)).exists)fail('recovery-lease.parent-missing');
|
|
17
|
+
// Existing linked/reparse wrapper roots are unsupported, just like parents.
|
|
18
|
+
// Do not give an alias a second ownership endpoint.
|
|
19
|
+
await inspectDirectory(wrapper);
|
|
20
|
+
let canonical=path.join(await realpath(parent),path.basename(wrapper));
|
|
21
|
+
if(process.platform==='win32')canonical=canonical.toLowerCase();
|
|
22
|
+
const key=createHash('sha256').update(canonical).digest('hex');
|
|
23
|
+
if(process.platform==='win32')return '\\\\.\\pipe\\wpc-recovery-'+key;
|
|
24
|
+
if(process.platform==='linux')return '\0wpc-recovery-'+key;
|
|
25
|
+
fail('recovery-lease.platform-unsupported');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function acquireRecoveryLease(wrapper) {
|
|
29
|
+
const name=await endpoint(wrapper);
|
|
30
|
+
const server=createServer(socket=>socket.destroy());
|
|
31
|
+
let failure=false,released=false;
|
|
32
|
+
server.on('error',()=>{failure=true;});
|
|
33
|
+
await new Promise((resolve,reject)=>{
|
|
34
|
+
const error=e=>{server.removeListener('listening',ready);reject(e);};
|
|
35
|
+
const ready=()=>{server.removeListener('error',error);resolve();};
|
|
36
|
+
server.once('error',error);server.once('listening',ready);
|
|
37
|
+
server.listen({path:name,exclusive:true});
|
|
38
|
+
}).catch(e=>fail(e.code==='EADDRINUSE'?'recovery-lease.busy':
|
|
39
|
+
e.code==='EACCES'?'recovery-lease.busy-or-access-denied':'recovery-lease.io'));
|
|
40
|
+
const check=()=>{if(released || failure || !server.listening)fail('recovery-lease.not-held');};
|
|
41
|
+
const lease=Object.freeze({async release(){
|
|
42
|
+
if(released)fail('recovery-lease.not-held');
|
|
43
|
+
released=true;
|
|
44
|
+
// Cleanup must run even when an asynchronous listener error invalidated check().
|
|
45
|
+
await new Promise((resolve,reject)=>server.close(e=>
|
|
46
|
+
e && e.code!=='ERR_SERVER_NOT_RUNNING'?reject(e):resolve()))
|
|
47
|
+
.catch(()=>fail('recovery-lease.release-failed-status-required'));
|
|
48
|
+
if(failure)fail('recovery-lease.lost-status-required');
|
|
49
|
+
}});
|
|
50
|
+
held.set(lease,check);return lease;
|
|
51
|
+
}
|
|
52
|
+
export function assertRecoveryLeaseHeld(lease) {
|
|
53
|
+
const check=lease && held.get(lease);
|
|
54
|
+
if(!check)fail('recovery-lease.capability');check();
|
|
55
|
+
}
|
|
56
|
+
export async function withRecoveryLease(wrapper,run) {
|
|
57
|
+
const lease=await acquireRecoveryLease(wrapper);
|
|
58
|
+
let result,runError,failed=false;
|
|
59
|
+
try{result=await run(lease);}catch(e){failed=true;runError=e;}
|
|
60
|
+
try{await lease.release();}catch(e){
|
|
61
|
+
if(failed)fail('recovery-lease.run-and-release-failed-status-required');
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
if(failed)throw runError;
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {inspectRepair} from './repair.js';
|
|
2
|
+
import {readState,observeTargets} from './state.js';
|
|
3
|
+
import {resolveChild} from '../workspace/paths.js';
|
|
4
|
+
import {requestShape} from './ownership.js';
|
|
5
|
+
import {readConfigField,reconcileConfigFields} from './config-fields.js';
|
|
6
|
+
import {bindPreview} from './plan.js';
|
|
7
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
8
|
+
import {parse,fail} from '../contracts/parse.js';
|
|
9
|
+
import {sha256} from '../source/inventory.js';
|
|
10
|
+
import {assertLockHeld} from './lock.js';
|
|
11
|
+
import {removedWithProviders} from '../providers/common-entry.js';
|
|
12
|
+
|
|
13
|
+
export function selectRemovalProviders(available,requested=available) {
|
|
14
|
+
if(!Array.isArray(available) || !Array.isArray(requested) || !requested.length ||
|
|
15
|
+
new Set(requested).size!==requested.length || requested.some(id=>typeof id!=='string' || !available.includes(id)))fail('remove.providers');
|
|
16
|
+
const providers=[...requested].sort(),remaining=available.filter(id=>!providers.includes(id)).sort();
|
|
17
|
+
return {providers,remaining,owners:[...providers,...(remaining.length?[]:['shared'])]};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function selectBundleRemoval(active, options={}) {
|
|
21
|
+
const all=options.providers===undefined && options.bundles===undefined;
|
|
22
|
+
const bundleIds=all?Object.keys(active.bundles??{}).sort():options.bundles??[];
|
|
23
|
+
if(!Array.isArray(bundleIds) || new Set(bundleIds).size!==bundleIds.length ||
|
|
24
|
+
bundleIds.some(id=>!Object.hasOwn(active.bundles??{},id)))fail('remove.bundles');
|
|
25
|
+
const grouped=new Set(Object.values(active.bundles??{}).flatMap(b=>b.providers));
|
|
26
|
+
const standalone=all?active.providers.filter(id=>!grouped.has(id)):options.providers??[];
|
|
27
|
+
if(!Array.isArray(standalone) || standalone.some(id=>grouped.has(id)))fail('remove.bundle-required');
|
|
28
|
+
const selection=selectRemovalProviders(active.providers,[...standalone,...bundleIds.flatMap(id=>active.bundles[id].providers)]);
|
|
29
|
+
return {...selection,bundles:[...bundleIds].sort()};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Internal preparation. Replay establishes owned scope from the installed
|
|
33
|
+
// package; preparation never deletes. Private byte payload.
|
|
34
|
+
export async function prepareRemoval(workspace,registry,options={}) {
|
|
35
|
+
requestShape(options,[],['providers','bundles'],'remove.options');
|
|
36
|
+
const inspection=await inspectRepair(workspace,registry);
|
|
37
|
+
if(inspection.entries.some(e=>e.disposition!=='intact'))fail('remove.conflict');
|
|
38
|
+
workspace=inspection.workspace;
|
|
39
|
+
const record=await readState(resolveChild(workspace,'.pipeline/state.json'));
|
|
40
|
+
if(record.digest!==inspection.stateHash)fail('remove.observation-drift');
|
|
41
|
+
const previous=record.value,active=previous.active,selection=selectBundleRemoval(active,options);
|
|
42
|
+
const removed=active.owned.filter(o=>removedWithProviders(o,selection));
|
|
43
|
+
const retained=active.owned.filter(o=>!removedWithProviders(o,selection));
|
|
44
|
+
const observations=await observeTargets(workspace,[...new Set(active.owned.map(o=>o.path))]);
|
|
45
|
+
const current=new Map(observations.map(o=>[o.path,o.bytes])),backups=new Map(),targets=[],outputs=[];
|
|
46
|
+
const backup=async owned=>{
|
|
47
|
+
if(owned.backup===null)fail('remove.backup-missing');
|
|
48
|
+
if(!backups.has(owned.backup)) {
|
|
49
|
+
const [observation]=await observeTargets(workspace,[owned.backup]);
|
|
50
|
+
if(observation.bytes===null)fail('remove.backup-missing');
|
|
51
|
+
backups.set(owned.backup,observation.bytes);
|
|
52
|
+
}
|
|
53
|
+
return backups.get(owned.backup);
|
|
54
|
+
};
|
|
55
|
+
for(const name of [...new Set(removed.map(o=>o.path))].sort()) {
|
|
56
|
+
const owned=removed.filter(o=>o.path===name),before=current.get(name);
|
|
57
|
+
if(before===null)fail('remove.conflict');
|
|
58
|
+
let bytes=null,fields=[],action;
|
|
59
|
+
if(owned[0].kind==='file') {
|
|
60
|
+
if(sha256(before)!==owned[0].managedHash)fail('remove.conflict');
|
|
61
|
+
if(owned[0].beforeHash!==null) {
|
|
62
|
+
bytes=await backup(owned[0]);
|
|
63
|
+
if(sha256(bytes)!==owned[0].beforeHash)fail('remove.backup-mismatch');
|
|
64
|
+
}
|
|
65
|
+
action=bytes===null?'delete':'replace';
|
|
66
|
+
}else {
|
|
67
|
+
const requests=[];
|
|
68
|
+
for(const item of owned) {
|
|
69
|
+
const request={pointer:item.pointer,present:item.beforeHash!==null,managedHash:item.managedHash};
|
|
70
|
+
if(request.present) {
|
|
71
|
+
const found=readConfigField(name,await backup(item),item.pointer);
|
|
72
|
+
if(!found.present)fail('remove.backup-field');
|
|
73
|
+
request.value=found.value;
|
|
74
|
+
if(contractDigest(request.value)!==item.beforeHash)fail('remove.backup-mismatch');
|
|
75
|
+
}
|
|
76
|
+
requests.push(request);
|
|
77
|
+
}
|
|
78
|
+
const result=reconcileConfigFields(name,before,requests);
|
|
79
|
+
fields=result.decisions.map(f=>({pointer:f.pointer,beforeHash:f.currentHash,desiredHash:f.desiredHash}));
|
|
80
|
+
bytes=result.bytes;action='edit-fields';
|
|
81
|
+
}
|
|
82
|
+
const beforeHash=sha256(before),desiredHash=bytes===null?null:sha256(bytes);
|
|
83
|
+
if(beforeHash===desiredHash)continue;
|
|
84
|
+
targets.push({id:'target-'+String(targets.length+1),path:name,owner:owned[0].owner,action,beforeHash,desiredHash,fields});
|
|
85
|
+
if(bytes!==null)outputs.push({path:name,bytes});
|
|
86
|
+
}
|
|
87
|
+
let desired=null;
|
|
88
|
+
if(selection.remaining.length) {
|
|
89
|
+
desired={...structuredClone(active),providers:selection.remaining,owned:structuredClone(retained),
|
|
90
|
+
adapterVersions:Object.fromEntries(selection.remaining.map(id=>[id,active.adapterVersions[id]]))};
|
|
91
|
+
if(desired.bundles) {
|
|
92
|
+
desired.bundles=Object.fromEntries(Object.entries(desired.bundles).filter(([id])=>!selection.bundles.includes(id)));
|
|
93
|
+
if(!Object.keys(desired.bundles).length)delete desired.bundles;
|
|
94
|
+
}
|
|
95
|
+
desired.id='deployment-'+contractDigest({snapshot:desired.snapshot,layout:desired.layout,
|
|
96
|
+
...(desired.agentsDocument===undefined?{}:{agentsDocument:desired.agentsDocument}),
|
|
97
|
+
providers:desired.providers,owned:desired.owned,...(desired.bundles?{bundles:desired.bundles}:{})}).slice(7);
|
|
98
|
+
}
|
|
99
|
+
const preview=bindPreview({schemaVersion:1,kind:'plan',command:'remove',workspace,beforeStateHash:contractDigest(previous),
|
|
100
|
+
source:structuredClone(active.snapshot),desired,targets},previous,{observations,outputs});
|
|
101
|
+
if((await inspectRepair(workspace,registry)).digest!==inspection.digest)fail('remove.observation-drift');
|
|
102
|
+
for(const fresh of await observeTargets(workspace,observations.map(o=>o.path))) {
|
|
103
|
+
const before=current.get(fresh.path);
|
|
104
|
+
if((fresh.bytes===null?null:sha256(fresh.bytes))!==(before===null?null:sha256(before)))fail('remove.observation-drift');
|
|
105
|
+
}
|
|
106
|
+
for(const [name,bytes] of backups) {
|
|
107
|
+
const [fresh]=await observeTargets(workspace,[name]);
|
|
108
|
+
if(fresh.bytes===null || sha256(fresh.bytes)!==sha256(bytes))fail('remove.observation-drift');
|
|
109
|
+
}
|
|
110
|
+
const body={kind:'prepared-removal',preview,stateFileHash:record.digest,providers:selection.providers,
|
|
111
|
+
...(selection.bundles.length?{bundles:selection.bundles}:{}),
|
|
112
|
+
backups:[...backups].map(([path,bytes])=>({path,hash:sha256(bytes)})),
|
|
113
|
+
applySupported:true,requiresFreshApproval:true,automaticActions:false,runtime:'not-run'};
|
|
114
|
+
parse(JSON.stringify(body),'json');return {...body,digest:contractDigest(body)};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Record validation is not a write grant; apply must also replay under lock.
|
|
118
|
+
export function validateRemovalRecord(prepared,approval) {
|
|
119
|
+
requestShape(prepared,['kind','preview','stateFileHash','providers','backups','applySupported','requiresFreshApproval','automaticActions','runtime','digest'],['bundles'],'remove.prepared');
|
|
120
|
+
requestShape(approval,['decision','preparedDigest'],[],'remove.approval');
|
|
121
|
+
const copy=structuredClone(parse(JSON.stringify(prepared),'json')),decision=structuredClone(approval);
|
|
122
|
+
const {digest,...body}=copy;
|
|
123
|
+
if(copy.kind!=='prepared-removal' || copy.applySupported!==true || copy.requiresFreshApproval!==true ||
|
|
124
|
+
copy.automaticActions!==false || copy.runtime!=='not-run' || contractDigest(body)!==digest)fail('remove.prepared');
|
|
125
|
+
if(decision.decision!=='approve' || decision.preparedDigest!==digest)fail('remove.approval');
|
|
126
|
+
return copy;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function verifyRemovalApproval(lock,prepared,approval,registry) {
|
|
130
|
+
const copy=validateRemovalRecord(prepared,approval);
|
|
131
|
+
await assertLockHeld(lock);
|
|
132
|
+
if(copy.preview?.plan?.workspace!==lock.workspace)fail('remove.workspace');
|
|
133
|
+
const active=(await readState(resolveChild(lock.workspace,'.pipeline/state.json'))).value.active;
|
|
134
|
+
const members=new Set((copy.bundles??[]).flatMap(id=>active?.bundles?.[id]?.providers??[]));
|
|
135
|
+
const fresh=await prepareRemoval(lock.workspace,registry,{providers:copy.providers.filter(id=>!members.has(id)),
|
|
136
|
+
...(copy.bundles?{bundles:copy.bundles}:{})});
|
|
137
|
+
if(contractDigest(fresh)!==contractDigest(copy))fail('remove.plan-drift');
|
|
138
|
+
await assertLockHeld(lock);
|
|
139
|
+
return fresh;
|
|
140
|
+
}
|