@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,94 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdir, open } from 'node:fs/promises';
|
|
3
|
+
import { fail, ContractError } from '../contracts/parse.js';
|
|
4
|
+
import { portablePath } from '../contracts/semantic.js';
|
|
5
|
+
import { sha256, LIMITS, cap } from '../source/inventory.js';
|
|
6
|
+
import { resolveChild, inspectDirectory } from '../workspace/paths.js';
|
|
7
|
+
import { observeTargets, verifyPreparedSnapshot } from './state.js';
|
|
8
|
+
import { assertLockHeld } from './lock.js';
|
|
9
|
+
|
|
10
|
+
function backupPath(workspace, relative) {
|
|
11
|
+
portablePath(relative);
|
|
12
|
+
if (!relative.startsWith('.pipeline/backups/')) fail('backup.path');
|
|
13
|
+
return resolveChild(workspace,relative);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Read-only exact byte verification; digest refers to the whole containing file,
|
|
17
|
+
// not a JSON-field value. Field ownership hashes cannot substitute for this hash.
|
|
18
|
+
export async function verifyBackup(workspace, relative, expectedHash) {
|
|
19
|
+
backupPath(workspace,relative);
|
|
20
|
+
if(typeof expectedHash!=='string' || !/^sha256:[a-f0-9]{64}$/.test(expectedHash))fail('backup.hash');
|
|
21
|
+
const [{bytes}]=await observeTargets(workspace,[relative]);
|
|
22
|
+
if(bytes===null)fail('backup.missing');
|
|
23
|
+
if(sha256(bytes)!==expectedHash)fail('backup.mismatch');
|
|
24
|
+
return {path:relative,hash:expectedHash,size:bytes.length};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Internal primitive, not plan authorization. Coordinator must bind relative and
|
|
28
|
+
// expectedHash to an approved preview and run full preflight before target writes.
|
|
29
|
+
// Copies bytes; no hardlink/shared mutable source. Existing entries are preserved,
|
|
30
|
+
// even when mismatching. A torn newly created backup is retained, never auto-repaired.
|
|
31
|
+
export async function saveBackup(lock, relative, bytes, expectedHash) {
|
|
32
|
+
await assertLockHeld(lock);
|
|
33
|
+
const filename=backupPath(lock.workspace,relative);
|
|
34
|
+
if(!Buffer.isBuffer(bytes))fail('backup.bytes');
|
|
35
|
+
cap(bytes.length,LIMITS.blob,'backup.size');
|
|
36
|
+
bytes=Buffer.from(bytes);
|
|
37
|
+
if(sha256(bytes)!==expectedHash)fail('backup.hash');
|
|
38
|
+
try {
|
|
39
|
+
const [{bytes:existing}]=await observeTargets(lock.workspace,[relative]);
|
|
40
|
+
if(existing!==null) return {...await verifyBackup(lock.workspace,relative,expectedHash),created:false};
|
|
41
|
+
// Create one inspected parent at a time; never recursive mkdir through a link.
|
|
42
|
+
let parent=lock.workspace;
|
|
43
|
+
for(const part of path.relative(lock.workspace,path.dirname(filename)).split(path.sep)) {
|
|
44
|
+
parent=path.join(parent,part);await assertLockHeld(lock);
|
|
45
|
+
try{await mkdir(parent);}catch(error){if(error.code!=='EEXIST')throw error;}
|
|
46
|
+
await inspectDirectory(parent);
|
|
47
|
+
}
|
|
48
|
+
await assertLockHeld(lock);
|
|
49
|
+
let handle;
|
|
50
|
+
try{handle=await open(filename,'wx',0o600);}
|
|
51
|
+
catch(error){if(error.code==='EEXIST')fail('backup.conflict');throw error;}
|
|
52
|
+
try{await handle.writeFile(bytes);await handle.sync();}
|
|
53
|
+
finally{await handle.close();}
|
|
54
|
+
return {...await verifyBackup(lock.workspace,relative,expectedHash),created:true};
|
|
55
|
+
}catch(error){throw error instanceof ContractError?error:new ContractError('backup.io');}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Install verified file bytes, never copy a tree recursively or link source files.
|
|
59
|
+
// No active-state change. A partially populated directory stays unusable until
|
|
60
|
+
// verification succeeds; mismatching existing directories are never repaired here.
|
|
61
|
+
export async function copySnapshot(lock, prepared) {
|
|
62
|
+
await assertLockHeld(lock);
|
|
63
|
+
const verified=await verifyPreparedSnapshot(prepared);
|
|
64
|
+
const relative='.pipeline/snapshots/'+verified.digest.slice(7);
|
|
65
|
+
const destination=resolveChild(lock.workspace,relative);
|
|
66
|
+
const expected={snapshotPath:destination,manifest:verified.manifest,digest:verified.digest,inventoryDigest:verified.inventoryDigest};
|
|
67
|
+
// Validate the complete resolved path budget before creating snapshot metadata.
|
|
68
|
+
for(const name of verified.files.keys())resolveChild(lock.workspace,relative+'/'+name);
|
|
69
|
+
try {
|
|
70
|
+
if((await inspectDirectory(destination)).exists) {
|
|
71
|
+
await verifyPreparedSnapshot(expected);
|
|
72
|
+
return {path:relative,digest:verified.digest,created:false};
|
|
73
|
+
}
|
|
74
|
+
const parent=resolveChild(lock.workspace,'.pipeline/snapshots');
|
|
75
|
+
await assertLockHeld(lock);
|
|
76
|
+
try{await mkdir(parent);}catch(error){if(error.code!=='EEXIST')throw error;}
|
|
77
|
+
await inspectDirectory(parent);
|
|
78
|
+
try{await mkdir(destination);}catch(error){if(error.code==='EEXIST')fail('snapshot.conflict');throw error;}
|
|
79
|
+
for(const [name,bytes] of verified.files) {
|
|
80
|
+
await assertLockHeld(lock);await inspectDirectory(destination);
|
|
81
|
+
const filename=resolveChild(lock.workspace,relative+'/'+name);
|
|
82
|
+
let directory=destination;
|
|
83
|
+
for(const segment of name.split('/').slice(0,-1)) {
|
|
84
|
+
directory=path.join(directory,segment);
|
|
85
|
+
try{await mkdir(directory);}catch(error){if(error.code!=='EEXIST')throw error;}
|
|
86
|
+
await inspectDirectory(directory);
|
|
87
|
+
}
|
|
88
|
+
const handle=await open(filename,'wx',0o600);
|
|
89
|
+
try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
|
|
90
|
+
}
|
|
91
|
+
await verifyPreparedSnapshot(expected);
|
|
92
|
+
return {path:relative,digest:verified.digest,created:true};
|
|
93
|
+
}catch(error){throw error instanceof ContractError?error:new ContractError('snapshot.copy');}
|
|
94
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { lstat,mkdir,open,readdir,unlink,rmdir } from 'node:fs/promises';
|
|
5
|
+
import { fail,ContractError } from '../contracts/parse.js';
|
|
6
|
+
import { sha256 } from '../source/inventory.js';
|
|
7
|
+
import { absoluteRoot,inspectDirectory,pathBudget } from '../workspace/paths.js';
|
|
8
|
+
import { readRecord } from './state.js';
|
|
9
|
+
|
|
10
|
+
const held=new WeakMap();
|
|
11
|
+
const identity=s=>String(s.dev)+':'+String(s.ino);
|
|
12
|
+
export function bootstrapLockDirectory(workspace) {
|
|
13
|
+
workspace=absoluteRoot(workspace);pathBudget(workspace);
|
|
14
|
+
if(path.dirname(workspace)===workspace)fail('bootstrap-lock.root');
|
|
15
|
+
const directory=path.join(path.dirname(workspace),'.wpc-bootstrap-'+sha256(workspace.toLowerCase()).slice(7));
|
|
16
|
+
pathBudget(directory);return directory;
|
|
17
|
+
}
|
|
18
|
+
export async function assertNoBootstrapRecovery(workspace) {
|
|
19
|
+
for(const suffix of ['.recovery','.recovery-resume']) {
|
|
20
|
+
try {await lstat(bootstrapLockDirectory(workspace)+suffix);}
|
|
21
|
+
catch(e){if(e.code==='ENOENT')continue;throw e;}
|
|
22
|
+
fail('bootstrap-lock.recovery-pending');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function assertBootstrapLockHeld(lock) {
|
|
26
|
+
const check=lock && held.get(lock);
|
|
27
|
+
if(!check)fail('bootstrap-lock.capability');
|
|
28
|
+
await check();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Init/adopt exclusion before wrapper exists. Requires an existing direct parent;
|
|
32
|
+
// ancestor creation must have its own approved bootstrap sequence, never recursive
|
|
33
|
+
// mkdir here. Keep held through handoff to the ordinary S5 workspace lock.
|
|
34
|
+
export async function acquireBootstrapLock(workspace,{onLocation=async()=>{},ioBoundary=async()=>{}}={}) {
|
|
35
|
+
workspace=absoluteRoot(workspace);pathBudget(workspace);
|
|
36
|
+
const parent=path.dirname(workspace);
|
|
37
|
+
if(parent===workspace)fail('bootstrap-lock.root');
|
|
38
|
+
await inspectDirectory(workspace);
|
|
39
|
+
if(!(await inspectDirectory(parent)).exists)fail('bootstrap-lock.parent-missing');
|
|
40
|
+
const parentId=identity(await lstat(parent,{bigint:true}));
|
|
41
|
+
const directory=bootstrapLockDirectory(workspace);
|
|
42
|
+
pathBudget(directory);
|
|
43
|
+
const ownerFile=path.join(directory,'owner.json');
|
|
44
|
+
let created=false;
|
|
45
|
+
try {
|
|
46
|
+
await assertNoBootstrapRecovery(workspace);
|
|
47
|
+
await onLocation({workspace,directory,status:'planned'});
|
|
48
|
+
await inspectDirectory(parent);
|
|
49
|
+
if(identity(await lstat(parent,{bigint:true}))!==parentId)fail('bootstrap-lock.parent-drift');
|
|
50
|
+
try {await mkdir(directory,{mode:0o700});created=true;}
|
|
51
|
+
catch(error){if(error.code==='EEXIST')fail('bootstrap-lock.busy');throw error;}
|
|
52
|
+
await ioBoundary('directory-created',{directory});
|
|
53
|
+
await inspectDirectory(directory);
|
|
54
|
+
const directoryId=identity(await lstat(directory,{bigint:true}));
|
|
55
|
+
const owner={schemaVersion:1,workspace,token:randomUUID(),pid:process.pid,host:hostname(),createdAt:new Date().toISOString()};
|
|
56
|
+
const bytes=Buffer.from(JSON.stringify(owner)+'\n'),digest=sha256(bytes);
|
|
57
|
+
const handle=await open(ownerFile,'wx',0o600);
|
|
58
|
+
try {await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
|
|
59
|
+
if((await readRecord(ownerFile)).digest!==digest)fail('bootstrap-lock.readback');
|
|
60
|
+
await assertNoBootstrapRecovery(workspace);
|
|
61
|
+
let released=false;
|
|
62
|
+
const check=async()=>{
|
|
63
|
+
if(released)fail('bootstrap-lock.released');
|
|
64
|
+
await inspectDirectory(directory);
|
|
65
|
+
if(identity(await lstat(parent,{bigint:true}))!==parentId || identity(await lstat(directory,{bigint:true}))!==directoryId)
|
|
66
|
+
fail('bootstrap-lock.identity');
|
|
67
|
+
if((await readRecord(ownerFile)).digest!==digest)fail('bootstrap-lock.owner-changed');
|
|
68
|
+
};
|
|
69
|
+
const lock=Object.freeze({workspace,directory,token:owner.token,
|
|
70
|
+
async release() {
|
|
71
|
+
try {
|
|
72
|
+
await check();
|
|
73
|
+
const names=await readdir(directory);
|
|
74
|
+
if(names.length!==1 || names[0]!=='owner.json')fail('bootstrap-lock.foreign-entry');
|
|
75
|
+
await unlink(ownerFile);await rmdir(directory);released=true;
|
|
76
|
+
}catch(error){throw error instanceof ContractError?error:new ContractError('bootstrap-lock.release-failed');}
|
|
77
|
+
}});
|
|
78
|
+
held.set(lock,check);
|
|
79
|
+
await onLocation({workspace,directory,status:'held'});
|
|
80
|
+
return lock;
|
|
81
|
+
}catch(cause){
|
|
82
|
+
const error=cause instanceof ContractError?cause:new ContractError('bootstrap-lock.io');
|
|
83
|
+
// No rollback of an incomplete/unknown owner record. Location enables review.
|
|
84
|
+
if(created)error.bootstrapDirectory=directory;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {hostname} from 'node:os';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {lstat,mkdir,open,rename,readdir} from 'node:fs/promises';
|
|
5
|
+
import {fail,MAX_INPUT_BYTES} from '../contracts/parse.js';
|
|
6
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
7
|
+
import {absoluteRoot,inspectDirectory,pathBudget} from '../workspace/paths.js';
|
|
8
|
+
import {inventoryRepository} from '../workspace/repository-inventory.js';
|
|
9
|
+
import {readRecord} from './state.js';
|
|
10
|
+
import {bootstrapLockDirectory,assertNoBootstrapRecovery} from './bootstrap-lock.js';
|
|
11
|
+
import {withRecoveryLease,assertRecoveryLeaseHeld} from './recovery-lease.js';
|
|
12
|
+
import {sha256} from '../source/inventory.js';
|
|
13
|
+
|
|
14
|
+
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
15
|
+
const equal=(a,b)=>contractDigest(a)===contractDigest(b);
|
|
16
|
+
const id=s=>({dev:String(s.dev),ino:String(s.ino)});
|
|
17
|
+
function stopped(owner){
|
|
18
|
+
if(owner.host!==hostname())return false;
|
|
19
|
+
try{process.kill(owner.pid,0);return false;}catch(e){return e.code==='ESRCH';}
|
|
20
|
+
}
|
|
21
|
+
function historyPath(wrapper,attempt){
|
|
22
|
+
if(!uuid.test(attempt??''))fail('bootstrap-retire.attempt');
|
|
23
|
+
const result=path.join(path.dirname(wrapper),'.wpc-bootstrap-abandoned-'+attempt);pathBudget(result);return result;
|
|
24
|
+
}
|
|
25
|
+
function ownerBinding(owner,wrapper){
|
|
26
|
+
if(!owner || Object.keys(owner).sort().join(',')!=='createdAt,host,pid,schemaVersion,token,workspace' ||
|
|
27
|
+
owner.schemaVersion!==1 || owner.workspace!==wrapper || !uuid.test(owner.token??'') ||
|
|
28
|
+
!Number.isSafeInteger(owner.pid) || owner.pid<=0 || typeof owner.host!=='string' ||
|
|
29
|
+
typeof owner.createdAt!=='string' || !Number.isFinite(Date.parse(owner.createdAt)))fail('bootstrap-retire.owner');
|
|
30
|
+
}
|
|
31
|
+
async function absent(filename){try{await lstat(filename);}catch(e){if(e.code==='ENOENT')return;throw e;}fail('bootstrap-retire.destination-exists');}
|
|
32
|
+
|
|
33
|
+
// Only an absent wrapper and complete, stopped owner-only lock. Empty/torn owner
|
|
34
|
+
// records cannot prove liveness and remain blocked. Never infer historical effects.
|
|
35
|
+
export async function inspectBootstrapOwner(wrapper){
|
|
36
|
+
wrapper=absoluteRoot(wrapper);
|
|
37
|
+
const observe=async()=>{
|
|
38
|
+
await assertNoBootstrapRecovery(wrapper);
|
|
39
|
+
if((await inspectDirectory(wrapper)).exists)fail('bootstrap-retire.wrapper-exists');
|
|
40
|
+
const parent=path.dirname(wrapper);if(!(await inspectDirectory(parent)).exists)fail('bootstrap-retire.parent-missing');
|
|
41
|
+
const directory=bootstrapLockDirectory(wrapper);
|
|
42
|
+
const inventory=await inventoryRepository(directory,{entries:4,bytes:MAX_INPUT_BYTES,depth:1});
|
|
43
|
+
if(inventory.entries.map(e=>e.path).sort().join(',')!=='.,owner.json')fail('bootstrap-retire.not-owner-only');
|
|
44
|
+
const record=await readRecord(path.join(directory,'owner.json'));ownerBinding(record.value,wrapper);
|
|
45
|
+
await assertNoBootstrapRecovery(wrapper);
|
|
46
|
+
return {kind:'bootstrap-owner-observation',wrapper,directory,inventory,ownerDigest:record.digest,
|
|
47
|
+
parentIdentity:id(await lstat(parent,{bigint:true})),ownerStopped:stopped(record.value),
|
|
48
|
+
wrapperAbsentAtCheck:true,executionAuthorized:false};
|
|
49
|
+
};
|
|
50
|
+
const first=await observe();if(!equal(first,await observe()))fail('bootstrap-retire.drift');
|
|
51
|
+
return {...first,digest:contractDigest(first)};
|
|
52
|
+
}
|
|
53
|
+
export async function prepareBootstrapOwnerRetirement(wrapper){
|
|
54
|
+
const observation=await inspectBootstrapOwner(wrapper),attempt=randomUUID();
|
|
55
|
+
const body={schemaVersion:1,kind:'bootstrap-owner-retirement-preview',wrapper:observation.wrapper,attempt,
|
|
56
|
+
history:historyPath(observation.wrapper,attempt),observation,status:observation.ownerStopped?'review-only':'blocked',
|
|
57
|
+
executionAuthorized:false,pipelineActivated:false};
|
|
58
|
+
return {...body,digest:contractDigest(body)};
|
|
59
|
+
}
|
|
60
|
+
function validatePreview(wrapper,preview){
|
|
61
|
+
const {digest,...body}=preview??{};
|
|
62
|
+
if(body.kind!=='bootstrap-owner-retirement-preview' || body.schemaVersion!==1 || body.wrapper!==wrapper ||
|
|
63
|
+
body.status!=='review-only' || body.executionAuthorized!==false || body.pipelineActivated!==false ||
|
|
64
|
+
contractDigest(body)!==digest || body.history!==historyPath(wrapper,body.attempt))fail('bootstrap-retire.approval');
|
|
65
|
+
const {digest:od,...ob}=body.observation??{};
|
|
66
|
+
if(contractDigest(ob)!==od || ob.wrapper!==wrapper || ob.directory!==bootstrapLockDirectory(wrapper) ||
|
|
67
|
+
ob.inventory?.root!==ob.directory ||
|
|
68
|
+
ob.kind!=='bootstrap-owner-observation' || ob.ownerStopped!==true || ob.wrapperAbsentAtCheck!==true ||
|
|
69
|
+
ob.executionAuthorized!==false)fail('bootstrap-retire.approval');
|
|
70
|
+
}
|
|
71
|
+
export async function verifyBootstrapOwnerRetirement(wrapper,history){
|
|
72
|
+
wrapper=absoluteRoot(wrapper);history=absoluteRoot(history);
|
|
73
|
+
const attempt=path.basename(history).replace(/^\.wpc-bootstrap-abandoned-/,'');
|
|
74
|
+
if(history!==historyPath(wrapper,attempt))fail('bootstrap-retire.history-path');
|
|
75
|
+
await inspectDirectory(history);
|
|
76
|
+
if((await readdir(history)).sort().join(',')!=='lock,request.json')fail('bootstrap-retire.history-incomplete');
|
|
77
|
+
const request=await readRecord(path.join(history,'request.json')),v=request.value;
|
|
78
|
+
if(!equal(v,{schemaVersion:1,kind:'bootstrap-owner-retirement-request',preview:v.preview}))fail('bootstrap-retire.request');
|
|
79
|
+
validatePreview(wrapper,v.preview);if(v.preview.history!==history)fail('bootstrap-retire.history-binding');
|
|
80
|
+
const saved=await inventoryRepository(path.join(history,'lock'),{entries:4,bytes:MAX_INPUT_BYTES,depth:1});
|
|
81
|
+
const projection=inv=>inv.entries.map(e=>({path:e.path,type:e.type,dev:e.identity.dev,ino:e.identity.ino,
|
|
82
|
+
mode:e.identity.mode,sha256:e.sha256??null}));
|
|
83
|
+
const archivedOwner=await readRecord(path.join(history,'lock','owner.json'));ownerBinding(archivedOwner.value,wrapper);
|
|
84
|
+
if(!equal(projection(saved),projection(v.preview.observation.inventory)) ||
|
|
85
|
+
archivedOwner.digest!==v.preview.observation.ownerDigest)
|
|
86
|
+
fail('bootstrap-retire.history-drift');
|
|
87
|
+
if((await readRecord(request.path)).digest!==request.digest)fail('bootstrap-retire.history-drift');
|
|
88
|
+
return {status:'bootstrap-owner-attempt-archived',history,requestDigest:request.digest,
|
|
89
|
+
repositoryEffectsPerformed:false,pipelineActivated:false,wrapperStateNotVerified:true};
|
|
90
|
+
}
|
|
91
|
+
export async function applyBootstrapOwnerRetirement(wrapper,preview,{ioBoundary=async()=>{}}={}){
|
|
92
|
+
wrapper=absoluteRoot(wrapper);validatePreview(wrapper,preview);
|
|
93
|
+
return withRecoveryLease(wrapper,async lease=>{
|
|
94
|
+
const check=async()=>{
|
|
95
|
+
assertRecoveryLeaseHeld(lease);
|
|
96
|
+
if(!equal(await inspectBootstrapOwner(wrapper),preview.observation))fail('bootstrap-retire.stale');
|
|
97
|
+
};
|
|
98
|
+
await check();await absent(preview.history);await mkdir(preview.history,{mode:0o700});
|
|
99
|
+
const historyIdentity=id(await lstat(preview.history,{bigint:true}));
|
|
100
|
+
await ioBoundary('bootstrap-retirement-history-created',{history:preview.history});
|
|
101
|
+
await inspectDirectory(preview.history);
|
|
102
|
+
if(!equal(id(await lstat(preview.history,{bigint:true})),historyIdentity) || (await readdir(preview.history)).length)
|
|
103
|
+
fail('bootstrap-retire.history-drift');
|
|
104
|
+
const value={schemaVersion:1,kind:'bootstrap-owner-retirement-request',preview};
|
|
105
|
+
const bytes=Buffer.from(JSON.stringify(value)+'\n');if(bytes.length>MAX_INPUT_BYTES)fail('bootstrap-retire.input-size');
|
|
106
|
+
const file=path.join(preview.history,'request.json'),h=await open(file,'wx',0o600);
|
|
107
|
+
try{await h.writeFile(bytes);await h.sync();}finally{await h.close();}
|
|
108
|
+
if((await readRecord(file)).digest!==sha256(bytes))fail('bootstrap-retire.readback');
|
|
109
|
+
await ioBoundary('bootstrap-retirement-request-retained',{history:preview.history});
|
|
110
|
+
await check();await inspectDirectory(preview.history);
|
|
111
|
+
if(!equal(id(await lstat(preview.history,{bigint:true})),historyIdentity) ||
|
|
112
|
+
(await readdir(preview.history)).join(',')!=='request.json' || (await readRecord(file)).digest!==sha256(bytes))
|
|
113
|
+
fail('bootstrap-retire.history-drift');
|
|
114
|
+
// Single handoff: the original lock blocks normal writers until this rename.
|
|
115
|
+
// All history inputs already exist; no guard needs later unlocking.
|
|
116
|
+
assertRecoveryLeaseHeld(lease);await rename(preview.observation.directory,path.join(preview.history,'lock'));
|
|
117
|
+
await ioBoundary('bootstrap-retirement-lock-archived',{history:preview.history});
|
|
118
|
+
const result=await verifyBootstrapOwnerRetirement(wrapper,preview.history);
|
|
119
|
+
assertRecoveryLeaseHeld(lease);return result;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { fail } from '../contracts/parse.js';
|
|
2
|
+
import { contractDigest } from '../contracts/semantic.js';
|
|
3
|
+
import { sha256 } from '../source/inventory.js';
|
|
4
|
+
import { readConfigField, reconcileConfigFields } from './config-fields.js';
|
|
5
|
+
|
|
6
|
+
// Only a member removed from a still-selected bundle may retire during update.
|
|
7
|
+
// Standalone removal and removal of an entire bundle remain explicit operations.
|
|
8
|
+
export function retiredBundleOwnership(previous, selection) {
|
|
9
|
+
const owners=new Set();
|
|
10
|
+
for(const [id,bundle] of Object.entries(previous?.active?.bundles??{})) {
|
|
11
|
+
if(!selection.bundles?.[id])continue;
|
|
12
|
+
for(const member of bundle.providers) if(!selection.providers.includes(member))owners.add(member);
|
|
13
|
+
}
|
|
14
|
+
return (previous?.active?.owned??[]).filter(o=>owners.has(o.owner));
|
|
15
|
+
}
|
|
16
|
+
export function planObservationPaths(requests, previous, selection) {
|
|
17
|
+
const retired=retiredBundleOwnership(previous,selection);
|
|
18
|
+
return [...new Set([...requests.map(r=>r.path),...retired.flatMap(o=>[o.path,...(o.backup?[o.backup]:[])])])].sort();
|
|
19
|
+
}
|
|
20
|
+
export function restoreRetired(entries, observations) {
|
|
21
|
+
const outputs=[];
|
|
22
|
+
const bytesAt=name=>{
|
|
23
|
+
if(!observations.has(name) || !Buffer.isBuffer(observations.get(name)))fail('bundle.retirement-missing');
|
|
24
|
+
return observations.get(name);
|
|
25
|
+
};
|
|
26
|
+
for(const name of [...new Set(entries.map(o=>o.path))].sort()) {
|
|
27
|
+
const owned=entries.filter(o=>o.path===name),before=bytesAt(name);
|
|
28
|
+
let bytes=null,fields=[],action;
|
|
29
|
+
if(owned[0].kind==='file') {
|
|
30
|
+
if(sha256(before)!==owned[0].managedHash)fail('ownership.drift');
|
|
31
|
+
if(owned[0].beforeHash!==null) {
|
|
32
|
+
bytes=bytesAt(owned[0].backup);
|
|
33
|
+
if(sha256(bytes)!==owned[0].beforeHash)fail('bundle.backup-mismatch');
|
|
34
|
+
}
|
|
35
|
+
action=bytes===null?'delete':'replace';
|
|
36
|
+
} else {
|
|
37
|
+
const requests=owned.map(o=>{
|
|
38
|
+
const request={pointer:o.pointer,present:o.beforeHash!==null,managedHash:o.managedHash};
|
|
39
|
+
if(request.present) {
|
|
40
|
+
const found=readConfigField(name,bytesAt(o.backup),o.pointer);
|
|
41
|
+
if(!found.present || contractDigest(found.value)!==o.beforeHash)fail('bundle.backup-mismatch');
|
|
42
|
+
request.value=found.value;
|
|
43
|
+
}
|
|
44
|
+
return request;
|
|
45
|
+
});
|
|
46
|
+
const result=reconcileConfigFields(name,before,requests);bytes=result.bytes;
|
|
47
|
+
fields=result.decisions.map(f=>({pointer:f.pointer,beforeHash:f.currentHash,desiredHash:f.desiredHash}));
|
|
48
|
+
action='edit-fields';
|
|
49
|
+
}
|
|
50
|
+
const beforeHash=sha256(before),desiredHash=bytes===null?null:sha256(bytes);
|
|
51
|
+
outputs.push({path:name,owner:owned[0].owner,action,beforeHash,desiredHash,fields,bytes});
|
|
52
|
+
}
|
|
53
|
+
return outputs;
|
|
54
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {parse,fail} from '../contracts/parse.js';
|
|
2
|
+
import {utf8} from '../source/inventory.js';
|
|
3
|
+
import {reconcileFields} from './ownership.js';
|
|
4
|
+
import {readTOMLField,reconcileTOMLFields} from './toml-fields.js';
|
|
5
|
+
|
|
6
|
+
// The codec is selected by a trusted exact destination, not source-provided code
|
|
7
|
+
// or guessed bytes. Historical JSON field paths retain their existing format.
|
|
8
|
+
export function readConfigField(name,bytes,pointer) {
|
|
9
|
+
if(['.codex/config.toml','.grok/config.toml'].includes(name))return readTOMLField(bytes,pointer);
|
|
10
|
+
if(bytes===null)return {present:false};
|
|
11
|
+
let node=parse(utf8(bytes),'json');
|
|
12
|
+
for(const part of pointer.slice(1).split('/').map(p=>p.replace(/~1/g,'/').replace(/~0/g,'~'))) {
|
|
13
|
+
if(node===null || typeof node!=='object' || Array.isArray(node))fail('ownership.ancestor');
|
|
14
|
+
if(!Object.hasOwn(node,part))return {present:false};
|
|
15
|
+
node=node[part];
|
|
16
|
+
}
|
|
17
|
+
return {present:true,value:node};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function reconcileConfigFields(name,bytes,requests) {
|
|
21
|
+
if(['.codex/config.toml','.grok/config.toml'].includes(name))return reconcileTOMLFields(bytes,requests);
|
|
22
|
+
const result=reconcileFields(bytes===null?{}:parse(utf8(bytes),'json'),requests);
|
|
23
|
+
return {decisions:result.decisions,bytes:Buffer.from(JSON.stringify(result.value,null,2)+'\n')};
|
|
24
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {prepareContinuation,validateContinuationRecord} from './reconciliation.js';
|
|
2
|
+
import {applyContinuation} from './apply.js';
|
|
3
|
+
import {prepareSwitchContinuation,verifySwitchContinuationApproval} from './switch-continuation.js';
|
|
4
|
+
import {persistSwitchContinuationRecovery} from './switch-continuation-recovery.js';
|
|
5
|
+
import {markSwitchContinuationPending} from './switch-continuation-pending.js';
|
|
6
|
+
import {executeSwitchContinuationPhase,activateSwitchContinuation} from './switch-continuation-runtime.js';
|
|
7
|
+
import {readRecord} from './state.js';
|
|
8
|
+
import {acquireWorkspaceLock} from './lock.js';
|
|
9
|
+
import {requestShape} from './ownership.js';
|
|
10
|
+
import {absoluteRoot,resolveChild} from '../workspace/paths.js';
|
|
11
|
+
import {ContractError,fail} from '../contracts/parse.js';
|
|
12
|
+
|
|
13
|
+
function guard(input,registry,apply=false) {
|
|
14
|
+
requestShape(input,apply?['command','wrapper','prepared','approval']:['command','wrapper','recoveryPath'],[],'continuation.input');
|
|
15
|
+
if(input.command!=='continue')fail('continuation.command');
|
|
16
|
+
requestShape(registry,['adapters','sharedAdapter'],[],'provider.interface');
|
|
17
|
+
return absoluteRoot(input.wrapper);
|
|
18
|
+
}
|
|
19
|
+
export async function prepareContinuationLifecycle(input,registry) {
|
|
20
|
+
const workspace=guard(input,registry);
|
|
21
|
+
if(typeof input.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$(?![\s\S])/.test(input.recoveryPath))fail('recovery.path');
|
|
22
|
+
const record=await readRecord(resolveChild(workspace,input.recoveryPath));
|
|
23
|
+
return ['switch-recovery','switch-continuation-recovery'].includes(record.value.kind)?
|
|
24
|
+
prepareSwitchContinuation(workspace,input.recoveryPath):prepareContinuation(workspace,input.recoveryPath);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fresh exact approval, fresh journal, preserved ancestry. Native readers validate
|
|
28
|
+
// pending state/lineage; the ordinary clean-history guard would reject that state.
|
|
29
|
+
export async function applyContinuationLifecycle(input,registry,{report=async()=>{}}={}) {
|
|
30
|
+
const workspace=guard(input,registry,true);
|
|
31
|
+
if(typeof report!=='function')fail('continuation.reporter');
|
|
32
|
+
const result={command:'continue',workspace,status:'failed',journal:null,recovery:null,error:null,lockRelease:'not-acquired',runtime:'not-run'};
|
|
33
|
+
const emit=async event=>{try{await report(structuredClone(event));}catch{fail('continuation.report');}};
|
|
34
|
+
const onJournal=async location=>{
|
|
35
|
+
const runId=location.relative.split('/').at(-1);
|
|
36
|
+
result.journal={runId,path:location.directory,status:location.status};
|
|
37
|
+
result.recovery={path:resolveChild(workspace,'.pipeline/transactions/'+runId+'/recovery.json'),status:'not-created'};
|
|
38
|
+
await emit({kind:'journal-location',...result.journal,recovery:result.recovery});
|
|
39
|
+
};
|
|
40
|
+
let lock;
|
|
41
|
+
try {
|
|
42
|
+
const switching=input.prepared?.kind==='switch-continuation-preview';
|
|
43
|
+
const prepared=switching?structuredClone(input.prepared):validateContinuationRecord(input.prepared,input.approval);
|
|
44
|
+
if((switching?prepared.workspace:prepared.evidence?.workspace)!==workspace)fail('continuation.prepared-binding');
|
|
45
|
+
lock=await acquireWorkspaceLock(workspace);result.lockRelease='pending';
|
|
46
|
+
if(switching) {
|
|
47
|
+
const approval={decision:input.approval.decision,previewDigest:input.approval.preparedDigest};
|
|
48
|
+
requestShape(input.approval,['decision','preparedDigest'],[],'continuation.approval');
|
|
49
|
+
await verifySwitchContinuationApproval(lock,prepared,approval);
|
|
50
|
+
const saved=await persistSwitchContinuationRecovery(lock,prepared,approval,{onJournal,boundary:async stage=>{
|
|
51
|
+
if(stage==='recovery-written'){result.recovery.status='created-unverified';await emit({kind:'recovery-location',...result.recovery});}
|
|
52
|
+
}});
|
|
53
|
+
result.recovery={path:resolveChild(workspace,saved.recoveryPath),status:'verified',hash:saved.recoveryHash};
|
|
54
|
+
await emit({kind:'recovery-location',...result.recovery});
|
|
55
|
+
await markSwitchContinuationPending(lock,saved.recoveryPath,saved.recoveryHash,approval);
|
|
56
|
+
for(const {phase} of prepared.remaining) {
|
|
57
|
+
await emit({kind:'phase-start',phase});
|
|
58
|
+
await executeSwitchContinuationPhase(lock,saved.recoveryPath,saved.recoveryHash,approval,phase);
|
|
59
|
+
await emit({kind:'phase-completed',phase});
|
|
60
|
+
}
|
|
61
|
+
await activateSwitchContinuation(lock,saved.recoveryPath,saved.recoveryHash,approval);result.status='ready';
|
|
62
|
+
} else {
|
|
63
|
+
const applied=await applyContinuation(lock,prepared,input.approval,{onJournal,ioBoundary:async detail=>{
|
|
64
|
+
if(detail.purpose==='recovery' && ['opened','readback'].includes(detail.phase)) {
|
|
65
|
+
result.recovery.status=detail.phase==='opened'?'created-unverified':'verified';
|
|
66
|
+
await emit({kind:'recovery-location',...result.recovery});
|
|
67
|
+
}
|
|
68
|
+
}});result.status=applied.status;
|
|
69
|
+
}
|
|
70
|
+
}catch(error){result.error=error instanceof ContractError?error.code:'continuation.io';}
|
|
71
|
+
finally {
|
|
72
|
+
if(lock)try{await lock.release();result.lockRelease='released';}
|
|
73
|
+
catch(error){result.lockRelease='failed';result.releaseError=error instanceof ContractError?error.code:'lock.release';}
|
|
74
|
+
}
|
|
75
|
+
try{await emit({kind:'operation-result',...result});}catch{result.outputError='continuation.report';}
|
|
76
|
+
return result;
|
|
77
|
+
}
|