@6reduk/workspace-pipeline 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +19 -1
  2. package/docs/desired-state.md +243 -0
  3. package/docs/lifecycle-cli.md +57 -6
  4. package/docs/migrations/unity.md +1 -1
  5. package/docs/rebind.md +1 -1
  6. package/docs/repositories.md +2 -2
  7. package/package.json +1 -1
  8. package/schemas/pipeline-v2.schema.json +43 -0
  9. package/src/cli.js +18 -2
  10. package/src/commands/desired-lifecycle.js +93 -0
  11. package/src/commands/desired-recover.js +28 -0
  12. package/src/commands/desired-remove.js +48 -0
  13. package/src/commands/desired-setup.js +47 -0
  14. package/src/commands/dispatch.js +28 -16
  15. package/src/commands/init.js +1 -1
  16. package/src/commands/interactive-update.js +59 -0
  17. package/src/commands/migration.js +2 -3
  18. package/src/commands/output.js +12 -1
  19. package/src/contracts/desired-state.js +56 -0
  20. package/src/desired-state/apply-files.js +172 -0
  21. package/src/desired-state/binding.js +23 -0
  22. package/src/desired-state/doctor.js +106 -0
  23. package/src/desired-state/global-settings.js +74 -0
  24. package/src/desired-state/inventory.js +102 -0
  25. package/src/desired-state/legacy-state.js +37 -0
  26. package/src/desired-state/local-settings.js +40 -0
  27. package/src/desired-state/lock-recovery.js +78 -0
  28. package/src/desired-state/records.js +75 -0
  29. package/src/desired-state/removal.js +32 -0
  30. package/src/desired-state/reset.js +27 -0
  31. package/src/desired-state/retirement.js +37 -0
  32. package/src/desired-state/settings.js +125 -0
  33. package/src/desired-state/source.js +41 -0
  34. package/src/operations/lock.js +3 -2
  35. package/src/source/desired-package.js +33 -0
  36. package/src/source/git.js +7 -3
@@ -0,0 +1,106 @@
1
+ import {homedir} from 'node:os';
2
+ import {parseTOML,getStaticTOMLValue} from 'toml-eslint-parser';
3
+ import {parse,fail,ContractError,MAX_INPUT_BYTES} from '../contracts/parse.js';
4
+ import {contractDigest} from '../contracts/semantic.js';
5
+ import {absoluteRoot} from '../workspace/paths.js';
6
+ import {observeTargets} from '../operations/state.js';
7
+ import {readTOMLField} from '../operations/toml-fields.js';
8
+ import {utf8} from '../source/inventory.js';
9
+ import {readDesiredRecords,installedPath,pendingPath} from './records.js';
10
+ import {includeRetiredTargets} from './retirement.js';
11
+ import {inspectDesiredFiles} from './inventory.js';
12
+ import {parseDesiredWorkspace} from './source.js';
13
+ import {inspectDesiredLock} from './lock-recovery.js';
14
+
15
+ const paths={
16
+ 'codex.workspace':'.codex/config.toml','claude.workspace':'.claude/settings.local.json',
17
+ 'claude.mcp':'.mcp.json','grok.workspace':'.grok/config.toml','grok.user':'.grok/config.toml'
18
+ };
19
+ function field(bytes,setting) {
20
+ if(bytes===null)return {present:false};
21
+ if(bytes.length>MAX_INPUT_BYTES)fail('desired.config-size');
22
+ if(['codex.workspace','grok.workspace'].includes(setting.target))return readTOMLField(bytes,setting.pointer);
23
+ let value;
24
+ if(setting.target==='grok.user') {
25
+ try{value=getStaticTOMLValue(parseTOML(utf8(bytes),{tomlVersion:'1.0'}));}catch{fail('desired.config-syntax');}
26
+ } else value=parse(utf8(bytes),'json');
27
+ for(const part of setting.pointer.slice(1).split('/')) {
28
+ if(value===null || typeof value!=='object' || Array.isArray(value))fail('desired.config-ancestor');
29
+ if(!Object.hasOwn(value,part))return {present:false};
30
+ value=value[part];
31
+ }
32
+ return {present:true,value};
33
+ }
34
+
35
+ // Read-only and local: no source fetch, lock, settings change, harness or MCP.
36
+ // null routes old-format installations to the existing doctor implementation.
37
+ export async function inspectDesiredInstallation(workspace,{userHome=homedir(),protectedPaths=[]}={}) {
38
+ workspace=absoluteRoot(workspace);
39
+ const markers=await observeTargets(workspace,[installedPath,pendingPath]);
40
+ if(markers.every(item=>item.bytes===null))return null;
41
+ const records=await readDesiredRecords(workspace),record=(records.pending??records.installed).value;
42
+ if(record.intent==='removed')return {workspace,ready:false,status:'not-installed',
43
+ pipeline:{...record.pipeline,providers:[]},binding:record.binding??null,configuration:'not-installed',
44
+ diagnostics:[{code:'desired.not-installed',subject:installedPath}],globalSettings:'preserved',
45
+ limits:'Delivery removed; saved workspace declaration and shared user settings were preserved. No runtime checks.'};
46
+ const diagnostics=[];
47
+ const locks=[];
48
+ for(const global of [false,...(record.settings.some(s=>s.target==='grok.user')?[true]:[])]) {
49
+ const target=global?'global':'workspace';
50
+ try {
51
+ const observed=await inspectDesiredLock(workspace,{userHome,global});
52
+ locks.push({target,path:observed.path,status:observed.status});
53
+ if(observed.status!=='absent')diagnostics.push({code:'desired.'+target+'-lock-present',subject:observed.path});
54
+ }catch(error){
55
+ locks.push({target,status:'unverified'});
56
+ diagnostics.push({code:error instanceof ContractError?error.code:'desired.lock-read',subject:target+' lock'});
57
+ }
58
+ }
59
+ const protectedRoots=new Set(protectedPaths);
60
+ if(record.binding) {
61
+ for(const repo of Object.values(record.binding.layout.repositories))protectedRoots.add(repo.path);
62
+ try {
63
+ const [entry]=await observeTargets(workspace,['workspace.json']);
64
+ if(entry.bytes===null)diagnostics.push({code:'desired.workspace-missing',subject:'workspace.json'});
65
+ else {
66
+ const descriptor=parseDesiredWorkspace(utf8(entry.bytes));
67
+ for(const repo of Object.values(descriptor.layout.repositories))protectedRoots.add(repo.path);
68
+ if(contractDigest(descriptor.pipeline)!==contractDigest(record.binding.source) ||
69
+ contractDigest(descriptor.layout)!==contractDigest(record.binding.layout) ||
70
+ (record.adapters && contractDigest([...descriptor.adapters].sort())!==contractDigest([...record.adapters].sort())))
71
+ diagnostics.push({code:'desired.workspace-different',subject:'workspace.json'});
72
+ }
73
+ }catch(error){
74
+ diagnostics.push({code:'desired.workspace-invalid',subject:'workspace.json',
75
+ reason:error instanceof ContractError?error.code:'desired.workspace-read'});
76
+ }
77
+ }
78
+ const desired={scopes:record.scopes,entries:record.files};
79
+ includeRetiredTargets(record,{settings:[]},desired,[...protectedRoots]);
80
+ const files=await inspectDesiredFiles(workspace,desired);
81
+ if(records.pending)diagnostics.push({code:'desired.installation-incomplete',subject:pendingPath});
82
+ for(const [category,items] of Object.entries(files)) {
83
+ if(!['extra','modified','missing','blocked'].includes(category))continue;
84
+ for(const item of items)diagnostics.push({code:'desired.file-'+category,subject:item.path,...(item.reason?{reason:item.reason}:{})});
85
+ }
86
+ const cache=new Map(),settings=[];
87
+ for(const setting of record.settings) {
88
+ try {
89
+ if(!cache.has(setting.target)) {
90
+ const [observed]=await observeTargets(setting.target==='grok.user'?absoluteRoot(userHome):workspace,[paths[setting.target]]);
91
+ cache.set(setting.target,observed.bytes);
92
+ }
93
+ const found=field(cache.get(setting.target),setting);
94
+ const matches=setting.operation==='remove'?!found.present:found.present && contractDigest(found.value)===setting.valueHash;
95
+ settings.push({target:setting.target,pointer:setting.pointer,status:matches?'pass':'different'});
96
+ if(!matches)diagnostics.push({code:'desired.setting-different',subject:setting.target,pointer:setting.pointer});
97
+ }catch(error){
98
+ settings.push({target:setting.target,pointer:setting.pointer,status:'unverified'});
99
+ diagnostics.push({code:error instanceof ContractError?error.code:'desired.config-read',subject:setting.target,pointer:setting.pointer});
100
+ }
101
+ }
102
+ return {workspace,ready:diagnostics.length===0,status:records.pending?'incomplete':diagnostics.length?'drift':'ready',
103
+ pipeline:{...record.pipeline,providers:record.providers},binding:record.binding??null,configuration:diagnostics.length?'fail':'pass',
104
+ diagnostics,files,settings,locks,backupDefault:false,
105
+ limits:'Installed-record comparison only; no source authenticity, runtime, model-visible skills or MCP certification.'};
106
+ }
@@ -0,0 +1,74 @@
1
+ import path from 'node:path';
2
+ import {homedir,hostname} from 'node:os';
3
+ import {randomUUID} from 'node:crypto';
4
+ import {mkdir,mkdtemp,writeFile,readFile,readdir,unlink,rmdir,open,rename} from 'node:fs/promises';
5
+ import {fail} from '../contracts/parse.js';
6
+ import {absoluteRoot,inspectDirectory} from '../workspace/paths.js';
7
+ import {observeTargets} from '../operations/state.js';
8
+ import {sha256} from '../source/inventory.js';
9
+ import {compileDesiredSettings} from './settings.js';
10
+
11
+ // userHome is a trusted host/test dependency, never a source manifest field.
12
+ // Cooperative profile lock serializes installations from different workspaces.
13
+ export async function acquireGlobalSettings(operations,{userHome=homedir(),workspace}={}) {
14
+ if(!operations.length)return null;
15
+ if(operations.some(op=>op.target!=='grok.user'))fail('desired.global-target');
16
+ // Validate scope/value declarations before creating profile metadata.
17
+ compileDesiredSettings('grok.user',null,operations);
18
+ const root=absoluteRoot(userHome);
19
+ if(!(await inspectDirectory(root)).exists)fail('desired.profile-missing');
20
+ const directory=path.join(root,'.grok'),lockDirectory=path.join(directory,'.wpc-config-lock');
21
+ await inspectDirectory(directory);await mkdir(directory,{recursive:true});await inspectDirectory(directory);
22
+ try{await mkdir(lockDirectory);}catch(e){if(e.code==='EEXIST')fail('desired.profile-busy');throw e;}
23
+ const token=randomUUID(),owner=Buffer.from(JSON.stringify({schemaVersion:1,token,pid:process.pid,host:hostname(),
24
+ createdAt:new Date().toISOString(),workspace:workspace===undefined?null:absoluteRoot(workspace),purpose:'desired-global-settings'})+'\n'),ownerPath=path.join(lockDirectory,'owner');
25
+ await writeFile(ownerPath,owner,{flag:'wx',mode:0o600});
26
+ let released=false;
27
+ async function held() {
28
+ if(released)fail('desired.profile-lock-released');
29
+ await inspectDirectory(lockDirectory);
30
+ const [observed]=await observeTargets(root,['.grok/.wpc-config-lock/owner']);
31
+ if(observed.bytes===null || !observed.bytes.equals(owner))fail('desired.profile-lock-changed');
32
+ }
33
+ async function release() {
34
+ await held();
35
+ const names=await readdir(lockDirectory);
36
+ if(names.length!==1 || names[0]!=='owner')fail('desired.profile-lock-changed');
37
+ await unlink(ownerPath);await rmdir(lockDirectory);released=true;
38
+ }
39
+ try {
40
+ const relative='.grok/config.toml',filename=path.join(directory,'config.toml');
41
+ const [initial]=await observeTargets(root,[relative]);
42
+ const edited=compileDesiredSettings('grok.user',initial.bytes,operations);
43
+ const beforeHash=initial.bytes===null?null:sha256(initial.bytes);
44
+ let backupDirectory=null;
45
+ async function check() {
46
+ await held();const [current]=await observeTargets(root,[relative]);
47
+ if((current.bytes===null?null:sha256(current.bytes))!==beforeHash)fail('desired.global-config-changed');
48
+ return current.bytes;
49
+ }
50
+ return {
51
+ path:filename,changed:edited.changed,release,check,
52
+ async backup() {
53
+ const bytes=await check();
54
+ if(!edited.changed || bytes===null)return null;
55
+ const parent=path.join(directory,'workspace-pipeline-backups');
56
+ await inspectDirectory(parent);await mkdir(parent,{recursive:true});await inspectDirectory(parent);
57
+ backupDirectory=await mkdtemp(path.join(parent,'desired-'));
58
+ const destination=path.join(backupDirectory,'config.toml');
59
+ await writeFile(destination,bytes,{flag:'wx',mode:0o600});
60
+ if(sha256(await readFile(destination))!==beforeHash)fail('desired.backup-verification');
61
+ return backupDirectory;
62
+ },
63
+ async apply() {
64
+ await check();if(!edited.changed)return;
65
+ const staging=beforeHash===null?filename:path.join(directory,'config.toml.wpc-'+token+'.tmp');
66
+ const handle=await open(staging,'wx',0o600);
67
+ try{await handle.writeFile(edited.bytes);await handle.sync();}finally{await handle.close();}
68
+ if(beforeHash!==null){await check();await rename(staging,filename);}
69
+ const [after]=await observeTargets(root,[relative]);
70
+ if(after.bytes===null || !after.bytes.equals(edited.bytes))fail('desired.global-readback');
71
+ }
72
+ };
73
+ } catch(error){await release();throw error;}
74
+ }
@@ -0,0 +1,102 @@
1
+ import path from 'node:path';
2
+ import {createHash} from 'node:crypto';
3
+ import {lstat,readdir,readFile} from 'node:fs/promises';
4
+ import {fail} from '../contracts/parse.js';
5
+ import {portablePath} from '../contracts/semantic.js';
6
+ import {absoluteRoot,inspectDirectory,resolveChild} from '../workspace/paths.js';
7
+
8
+ const hash=bytes=>'sha256:'+createHash('sha256').update(bytes).digest('hex');
9
+ const order=(a,b)=>a.path<b.path?-1:a.path>b.path?1:0;
10
+ const maxFiles=10000,maxBytes=64*1024*1024;
11
+
12
+ // Compiled manifest and authenticated source map are supplied by the caller.
13
+ // This function performs no writes and copies source buffers before returning.
14
+ export function materializeDesiredFiles(compiled,source) {
15
+ if(!compiled || compiled.schemaVersion!==2 || !Array.isArray(compiled.files) || !(source instanceof Map))fail('desired.input');
16
+ const entries=new Map(),aliases=new Map();let bytes=0;
17
+ function add(name,kind,content) {
18
+ portablePath(name);
19
+ const key=name.toLowerCase(),old=entries.get(name);
20
+ if(aliases.has(key) && aliases.get(key)!==name)fail('desired.case-alias');
21
+ if(old){if(kind==='directory' && old.kind===kind)return;fail('desired.materialized-overlap');}
22
+ aliases.set(key,name);const entry={path:name,kind};
23
+ if(kind==='file') {
24
+ if(!Buffer.isBuffer(content))fail('desired.source-bytes');
25
+ bytes+=content.length;if(bytes>maxBytes)fail('desired.byte-limit');
26
+ entry.bytes=Buffer.from(content);entry.hash=hash(entry.bytes);
27
+ }
28
+ entries.set(name,entry);if(entries.size>maxFiles)fail('desired.file-limit');
29
+ }
30
+ for(const mapping of compiled.files) {
31
+ portablePath(mapping.source);portablePath(mapping.target);
32
+ if(mapping.kind==='file') {
33
+ if(!source.has(mapping.source))fail('desired.source-missing');
34
+ add(mapping.target,'file',source.get(mapping.source));
35
+ } else if(mapping.kind==='directory') {
36
+ add(mapping.target,'directory');let found=false;
37
+ for(const [name,content] of source) {
38
+ portablePath(name);if(!name.startsWith(mapping.source+'/'))continue;
39
+ found=true;const suffix=name.slice(mapping.source.length+1),parts=suffix.split('/');
40
+ for(let i=1;i<parts.length;i++)add(mapping.target+'/'+parts.slice(0,i).join('/'),'directory');
41
+ add(mapping.target+'/'+suffix,'file',content);
42
+ }
43
+ // Git has no empty directory entries: a missing source must not become
44
+ // an empty desired tree that deletes all installed contents.
45
+ if(!found)fail('desired.source-missing');
46
+ } else fail('desired.kind');
47
+ }
48
+ return {scopes:compiled.files.map(({target,kind})=>({path:target,kind})),entries:[...entries.values()].sort(order)};
49
+ }
50
+
51
+ // Read-only observation, not a lease or write authorization. Apply must hold
52
+ // its lock and recheck safety. Only declared file scopes are traversed.
53
+ export async function inspectDesiredFiles(workspace,desired) {
54
+ workspace=absoluteRoot(workspace);
55
+ if(!(await inspectDirectory(workspace)).exists)fail('desired.workspace-missing');
56
+ const actual=new Map(),blocked=[];let totalBytes=0,count=0;
57
+ async function visit(relative,absolute,depth=0) {
58
+ if(++count>maxFiles || depth>64)fail('desired.file-limit');
59
+ let stat;
60
+ try{stat=await lstat(absolute);}catch(error){if(error.code==='ENOENT')return;throw error;}
61
+ if(stat.isSymbolicLink()){blocked.push({path:relative,reason:'link'});return;}
62
+ if(stat.isDirectory()) {
63
+ await inspectDirectory(absolute);actual.set(relative,{path:relative,kind:'directory'});
64
+ const names=await readdir(absolute),seen=new Set();
65
+ for(const name of names.sort()) {
66
+ const child=relative+'/'+name,key=name.toLowerCase();
67
+ if(seen.has(key)){blocked.push({path:child,reason:'case-alias'});continue;}
68
+ seen.add(key);
69
+ if(key==='.git'){blocked.push({path:child,reason:'nested-repository'});continue;}
70
+ await visit(child,path.join(absolute,name),depth+1);
71
+ }
72
+ } else if(stat.isFile()) {
73
+ if(stat.nlink>1){blocked.push({path:relative,reason:'hardlink'});return;}
74
+ totalBytes+=stat.size;if(totalBytes>maxBytes)fail('desired.byte-limit');
75
+ const bytes=await readFile(absolute);
76
+ if(bytes.length!==stat.size)fail('desired.observation-changed');
77
+ actual.set(relative,{path:relative,kind:'file',hash:hash(bytes)});
78
+ } else blocked.push({path:relative,reason:'special-file'});
79
+ }
80
+ for(const scope of desired.scopes) {
81
+ const absolute=resolveChild(workspace,scope.path),parent=await inspectDirectory(path.dirname(absolute));
82
+ if(!parent.exists)continue;
83
+ const matches=(await readdir(parent.path)).filter(n=>n.toLowerCase()===path.basename(absolute).toLowerCase());
84
+ if(matches.length>1 || (matches.length===1 && matches[0]!==path.basename(absolute))) {
85
+ blocked.push({path:scope.path,reason:'case-alias'});continue;
86
+ }
87
+ await visit(scope.path,absolute);
88
+ }
89
+ const expected=new Map(desired.entries.map(e=>[e.path,e]));
90
+ const extra=[],modified=[],missing=[],unchanged=[];
91
+ for(const [name,want] of expected) {
92
+ const have=actual.get(name);
93
+ if(!have){missing.push({path:name,kind:want.kind});continue;}
94
+ if(have.kind!==want.kind || (want.kind==='file' && have.hash!==want.hash))
95
+ modified.push({path:name,kind:want.kind,currentKind:have.kind,beforeHash:have.hash??null,desiredHash:want.hash??null});
96
+ else unchanged.push({path:name,kind:want.kind});
97
+ }
98
+ for(const [name,have] of actual)if(!expected.has(name))extra.push(have);
99
+ return {extra:extra.sort(order),modified:modified.sort(order),missing:missing.sort(order),
100
+ unchanged:unchanged.sort(order),blocked:blocked.sort(order),ready:blocked.length===0,
101
+ backupDefault:false,scope:'declared-file-targets-only'};
102
+ }
@@ -0,0 +1,37 @@
1
+ import path from 'node:path';
2
+ import {parse,fail} from '../contracts/parse.js';
3
+ import {validateState} from '../contracts/semantic.js';
4
+ import {sha256,utf8} from '../source/inventory.js';
5
+ import {observeTargets} from '../operations/state.js';
6
+ import {writeCheckedFile,deleteCheckedFile} from '../operations/apply.js';
7
+
8
+ const configTargets={'.codex/config.toml':'codex.workspace','.claude/settings.local.json':'claude.workspace',
9
+ '.mcp.json':'claude.mcp','.grok/config.toml':'grok.workspace'};
10
+
11
+ export function decodeLegacyState(workspace,bytes) {
12
+ const value=parse(utf8(bytes),'json');validateState(value);
13
+ if(path.resolve(value.workspace)!==path.resolve(workspace))fail('desired.legacy-workspace');
14
+ if(value.pending!==null || !value.active || !['ready','drift','conflict'].includes(value.status))fail('desired.legacy-unfinished');
15
+ const scopes=[],settings=[];
16
+ for(const owned of value.active.owned) {
17
+ if(owned.kind==='file')scopes.push({path:owned.path,kind:'file'});
18
+ else {
19
+ const target=configTargets[owned.path];
20
+ if(!target)fail('desired.legacy-field-target');
21
+ settings.push({target,pointer:owned.pointer,operation:'set',valueHash:owned.managedHash});
22
+ }
23
+ }
24
+ return {value,bytes,hash:sha256(bytes),protectedPaths:Object.values(value.active.layout.repositories).map(repo=>repo.path),
25
+ ownership:{pipeline:{id:value.active.pipelineId,version:value.active.version},scopes,settings}};
26
+ }
27
+
28
+ export async function retireLegacyState(lock,legacy) {
29
+ if(!legacy)return;
30
+ const history='.pipeline/history/state-v1-'+legacy.hash.slice(7)+'.json';
31
+ const [existing]=await observeTargets(lock.workspace,[history]);
32
+ if(existing.bytes===null)await writeCheckedFile(lock,history,null,legacy.bytes);
33
+ else if(sha256(existing.bytes)!==legacy.hash)fail('desired.legacy-history-conflict');
34
+ // Preserve the pre-existing operational record as history, not a backup of
35
+ // overwritten adapter files. Original journals/snapshots are not rewritten.
36
+ await deleteCheckedFile(lock,'.pipeline/state.json',legacy.hash,async()=>{});
37
+ }
@@ -0,0 +1,40 @@
1
+ import {fail} from '../contracts/parse.js';
2
+ import {observeTargets} from '../operations/state.js';
3
+ import {sha256} from '../source/inventory.js';
4
+ import {compileDesiredSettings} from './settings.js';
5
+
6
+ const destinations=Object.freeze({
7
+ 'codex.workspace':'.codex/config.toml',
8
+ 'claude.workspace':'.claude/settings.local.json',
9
+ 'claude.mcp':'.mcp.json',
10
+ 'grok.workspace':'.grok/config.toml'
11
+ });
12
+
13
+ // Read and compile every configuration before any adapter file is deleted.
14
+ // Global configuration is intentionally not resolved through workspace paths.
15
+ export async function prepareLocalSettings(workspace,settings) {
16
+ const groups=new Map();
17
+ for(const operation of settings) {
18
+ if(!Object.hasOwn(destinations,operation.target))fail('desired.global-settings-not-integrated');
19
+ if(!groups.has(operation.target))groups.set(operation.target,[]);
20
+ groups.get(operation.target).push(operation);
21
+ }
22
+ const observations=await observeTargets(workspace,[...groups.keys()].map(key=>destinations[key]));
23
+ const result=[];
24
+ for(const [target,operations] of groups) {
25
+ const relative=destinations[target],before=observations.find(o=>o.path===relative).bytes;
26
+ const edited=compileDesiredSettings(target,before,operations);
27
+ result.push({path:relative,beforeHash:before===null?null:sha256(before),
28
+ bytes:edited.bytes,afterHash:sha256(edited.bytes),changed:edited.changed});
29
+ }
30
+ return result;
31
+ }
32
+
33
+ export async function checkLocalSettings(workspace,prepared) {
34
+ const observed=await observeTargets(workspace,prepared.map(item=>item.path));
35
+ for(const item of prepared) {
36
+ const current=observed.find(o=>o.path===item.path);
37
+ if((current.bytes===null?null:sha256(current.bytes))!==item.beforeHash)fail('desired.config-changed');
38
+ }
39
+ return observed;
40
+ }
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import {homedir,hostname} from 'node:os';
3
+ import {randomUUID} from 'node:crypto';
4
+ import {readdir,rename,unlink,rmdir} from 'node:fs/promises';
5
+ import {fail,parse} from '../contracts/parse.js';
6
+ import {contractDigest} from '../contracts/semantic.js';
7
+ import {absoluteRoot,inspectDirectory} from '../workspace/paths.js';
8
+ import {observeTargets} from '../operations/state.js';
9
+ import {assertNoRepositoryPending} from '../operations/repository-pending.js';
10
+ import {withRecoveryLease,assertRecoveryLeaseHeld} from '../operations/recovery-lease.js';
11
+ import {sha256,utf8} from '../source/inventory.js';
12
+ import {readDesiredRecords} from './records.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
+ function liveness(owner) {
16
+ if(owner.host!==hostname())return 'unknown-host';
17
+ try{process.kill(owner.pid,0);return 'live-or-reused-pid';}
18
+ catch(e){return e.code==='ESRCH'?'local-pid-absent':'unknown';}
19
+ }
20
+ function location(workspace,{global=false,userHome=homedir()}={}) {
21
+ if(typeof global!=='boolean')fail('desired.recovery-options');
22
+ const root=global?absoluteRoot(userHome):workspace;
23
+ return {root,relative:global?'.grok/.wpc-config-lock':'.pipeline/lock',
24
+ filename:global?'owner':'owner.json',purpose:global?'desired-global-settings':'desired-state'};
25
+ }
26
+ async function ownerAt(loc,relative,workspace) {
27
+ const directory=path.join(loc.root,...relative.split('/'));
28
+ if(!(await inspectDirectory(directory)).exists)return null;
29
+ const names=await readdir(directory);
30
+ if(names.length!==1||names[0]!==loc.filename)fail('desired.recovery-foreign-entry');
31
+ const [observed]=await observeTargets(loc.root,[relative+'/'+loc.filename]);
32
+ if(observed.bytes===null)fail('desired.recovery-owner-missing');
33
+ const owner=parse(utf8(observed.bytes),'json');
34
+ if(!owner || Object.keys(owner).sort().join(',')!=='createdAt,host,pid,purpose,schemaVersion,token,workspace' ||
35
+ owner.schemaVersion!==1||owner.purpose!==loc.purpose||owner.workspace!==workspace||!uuid.test(owner.token??'')||
36
+ !Number.isSafeInteger(owner.pid)||owner.pid<=0||typeof owner.host!=='string'||
37
+ typeof owner.createdAt!=='string'||!Number.isFinite(Date.parse(owner.createdAt)))fail('desired.recovery-owner-invalid');
38
+ return {owner,hash:sha256(observed.bytes),liveness:liveness(owner)};
39
+ }
40
+
41
+ // Read-only: age alone never authorizes release, and signal 0 does not kill.
42
+ export async function inspectDesiredLock(workspace,options={}) {
43
+ workspace=absoluteRoot(workspace);const loc=location(workspace,options);
44
+ await assertNoRepositoryPending(workspace);
45
+ const records=await readDesiredRecords(workspace);
46
+ const state={installed:records.installed?.hash??null,pending:records.pending?.hash??null};
47
+ const lock=await ownerAt(loc,loc.relative,workspace);
48
+ const result={workspace,global:options.global===true,path:path.join(loc.root,...loc.relative.split('/')),state,
49
+ status:lock===null?'absent':lock.liveness==='local-pid-absent'?'stopped-owner-observed':'owner-unconfirmed',lock};
50
+ return {...result,digest:contractDigest(result)};
51
+ }
52
+
53
+ // The existing OS lease serializes recoverers, without another stale lockfile.
54
+ // Rename the exact stale directory atomically; never delete/reuse its old path
55
+ // after rename, since a fresh cooperative writer may immediately acquire it.
56
+ export async function recoverDesiredLock(workspace,expected,options={}) {
57
+ workspace=absoluteRoot(workspace);const loc=location(workspace,options);
58
+ return withRecoveryLease(loc.root,async lease=>{
59
+ let current=await inspectDesiredLock(workspace,options);
60
+ if(current.digest!==expected.digest)fail('desired.recovery-observation-changed');
61
+ if(current.status==='absent')return {status:'no-lock',workspace,global:options.global===true};
62
+ if(current.status!=='stopped-owner-observed')fail('desired.recovery-owner-unconfirmed');
63
+ current=await inspectDesiredLock(workspace,options);
64
+ if(current.digest!==expected.digest)fail('desired.recovery-observation-changed');
65
+ assertRecoveryLeaseHeld(lease);
66
+ const relative=loc.relative+'-retiring-'+randomUUID(),temporary=path.join(loc.root,...relative.split('/'));
67
+ await rename(current.path,temporary);
68
+ try {
69
+ const saved=await ownerAt(loc,relative,workspace);
70
+ if(!saved || saved.hash!==current.lock.hash)fail('desired.recovery-retired-mismatch');
71
+ assertRecoveryLeaseHeld(lease);
72
+ await unlink(path.join(temporary,loc.filename));
73
+ await rmdir(temporary); // Never recursively remove unexpected children.
74
+ }catch(error){error.recovery={lockReleased:true,residualPath:temporary};throw error;}
75
+ return {status:'lock-retired',workspace,global:options.global===true,
76
+ next:'Inspect doctor, then explicitly retry reset or remove; operation records and configuration were not changed.'};
77
+ });
78
+ }
@@ -0,0 +1,75 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ import {fail,parse} from '../contracts/parse.js';
3
+ import {contractDigest,portablePath} from '../contracts/semantic.js';
4
+ import {observeTargets} from '../operations/state.js';
5
+ import {writeCheckedFile,deleteCheckedFile} from '../operations/apply.js';
6
+ import {sha256,utf8} from '../source/inventory.js';
7
+ import {decodeLegacyState,retireLegacyState} from './legacy-state.js';
8
+ import {validateDesiredBinding} from './binding.js';
9
+
10
+ export const installedPath='.pipeline/desired-install.json';
11
+ export const pendingPath='.pipeline/desired-pending.json';
12
+ const hash={type:'string',pattern:'^sha256:[a-f0-9]{64}$'};
13
+ const validate=new Ajv2020({strict:true}).compile({type:'object',additionalProperties:false,
14
+ required:['schemaVersion','operationHash','pipeline','providers','scopes','files','settings'],properties:{
15
+ schemaVersion:{const:1},operationHash:hash,binding:{type:'object'},
16
+ intent:{enum:['remove','removed']},
17
+ adapters:{type:'array',minItems:1,uniqueItems:true,items:{type:'string',pattern:'^[a-z][a-z0-9-]{0,62}$'}},
18
+ pipeline:{type:'object',additionalProperties:false,required:['id','version'],properties:{id:{type:'string'},version:{type:'string'}}},
19
+ providers:{type:'array',uniqueItems:true,items:{enum:['codex','claude','grok','kimi']}},
20
+ scopes:{type:'array',maxItems:10000,items:{type:'object',additionalProperties:false,required:['path','kind'],properties:{path:{type:'string'},kind:{enum:['file','directory']}}}},
21
+ files:{type:'array',maxItems:10000,items:{type:'object',additionalProperties:false,required:['path','kind','hash'],properties:{path:{type:'string'},kind:{enum:['file','directory']},hash:{anyOf:[hash,{type:'null'}]}}}},
22
+ settings:{type:'array',maxItems:1000,items:{type:'object',additionalProperties:false,required:['target','pointer','operation','valueHash'],properties:{target:{type:'string'},pointer:{type:'string'},operation:{enum:['set','remove']},valueHash:{anyOf:[hash,{type:'null'}]}}}}
23
+ }});
24
+
25
+ // Only desired identities/digests, never old contents or secret setting values.
26
+ export function installationRecord(compiled,desired,{protectedPaths,globalConfigPath,binding}) {
27
+ const files=desired.entries.map(e=>({path:e.path,kind:e.kind,hash:e.hash??null}));
28
+ const provenance=binding===undefined?{}:{binding:validateDesiredBinding(binding)};
29
+ return {schemaVersion:1,operationHash:contractDigest({compiled,files,protectedPaths,globalConfigPath,...provenance}),...provenance,
30
+ pipeline:compiled.pipeline,adapters:compiled.adapters,providers:compiled.providers,scopes:desired.scopes,files,
31
+ settings:compiled.settings.map(s=>({target:s.target,pointer:s.pointer,operation:s.operation,
32
+ valueHash:s.operation==='set'?contractDigest(s.value):null}))};
33
+ }
34
+
35
+ export async function readDesiredRecords(workspace,{allowLegacyMigration=false}={}) {
36
+ const observations=await observeTargets(workspace,[installedPath,pendingPath,'.pipeline/state.json']);
37
+ // Migration is explicit; never silently abandon the old lifecycle state.
38
+ const legacyBytes=observations.find(o=>o.path==='.pipeline/state.json').bytes;
39
+ if(legacyBytes!==null && !allowLegacyMigration)fail('desired.legacy-migration-required');
40
+ const result={legacy:legacyBytes===null?null:decodeLegacyState(workspace,legacyBytes)};
41
+ for(const [key,name] of [['installed',installedPath],['pending',pendingPath]]) {
42
+ const bytes=observations.find(o=>o.path===name).bytes;
43
+ if(bytes===null){result[key]=null;continue;}
44
+ const value=parse(utf8(bytes),'json');
45
+ if(!validate(value))fail('desired.record-invalid');
46
+ if(value.intent==='removed' && (key==='pending'||value.scopes.length||value.files.length||value.settings.length||value.providers.length))fail('desired.record-invalid');
47
+ if(value.intent==='remove' && (key==='installed'||value.files.length||value.settings.some(s=>s.operation!=='remove'||s.target==='grok.user')))fail('desired.record-invalid');
48
+ if(value.binding!==undefined)validateDesiredBinding(value.binding);
49
+ for(const e of [...value.scopes,...value.files])portablePath(e.path);
50
+ result[key]={value,hash:sha256(bytes)};
51
+ }
52
+ return result;
53
+ }
54
+
55
+ export function assertDesiredTransition(records,next) {
56
+ if(records.pending?.value.intent==='remove' && next.intent!=='remove')fail('desired.finish-remove-before-update');
57
+ if(records.pending && records.pending.value.operationHash!==next.operationHash)fail('desired.retry-same-source-required');
58
+ const previous=records.installed?.value??records.legacy?.ownership;
59
+ if(!previous || previous.intent==='removed')return;
60
+ if(previous.pipeline.id!==next.pipeline.id)fail('desired.pipeline-switch-not-integrated');
61
+ }
62
+
63
+ const encode=record=>Buffer.from(JSON.stringify(record,null,2)+'\n');
64
+ export async function beginDesiredInstall(lock,records,next) {
65
+ if(records.pending)return records.pending.hash;
66
+ return writeCheckedFile(lock,pendingPath,null,encode(next));
67
+ }
68
+ export async function completeDesiredInstall(lock,records,next,pendingHash) {
69
+ const completed=next.intent==='remove'?{...next,intent:'removed',scopes:[],files:[],settings:[],providers:[]}:next;
70
+ const bytes=encode(completed);
71
+ if(records.installed?.hash!==sha256(bytes))
72
+ await writeCheckedFile(lock,installedPath,records.installed?.hash??null,bytes);
73
+ await retireLegacyState(lock,records.legacy);
74
+ if(pendingHash)await deleteCheckedFile(lock,pendingPath,pendingHash,async()=>{});
75
+ }
@@ -0,0 +1,32 @@
1
+ import {fail} from '../contracts/parse.js';
2
+ import {contractDigest} from '../contracts/semantic.js';
3
+ import {observeTargets} from '../operations/state.js';
4
+ import {utf8} from '../source/inventory.js';
5
+ import {parseDesiredWorkspace} from './source.js';
6
+ import {includeRetiredTargets} from './retirement.js';
7
+
8
+ // Offline full-delivery removal. Historical ownership never grants deletion of
9
+ // repositories. User-wide compatibility remains shared, not workspace-owned.
10
+ export async function prepareDesiredRemoval(workspace,records,protectedPaths=[]) {
11
+ if(records.pending && records.pending.value.intent!=='remove')fail('desired.finish-install-before-remove');
12
+ const previous=(records.pending??records.installed)?.value;
13
+ if(!previous)fail('desired.not-installed');
14
+ if(previous.intent==='removed')return {alreadyRemoved:true};
15
+ if(!previous.binding)fail('desired.removal-binding-required');
16
+ const protectedRoots=new Set(protectedPaths);
17
+ for(const repo of Object.values(previous.binding.layout.repositories))protectedRoots.add(repo.path);
18
+ const [entry]=await observeTargets(workspace,['workspace.json']);
19
+ if(entry.bytes!==null) {
20
+ const descriptor=parseDesiredWorkspace(utf8(entry.bytes));
21
+ for(const repo of Object.values(descriptor.layout.repositories))protectedRoots.add(repo.path);
22
+ }
23
+ const desired={scopes:[],entries:[]};
24
+ const retired=includeRetiredTargets(previous,{settings:[]},desired,[...protectedRoots]);
25
+ const settings=records.pending?previous.settings.map(s=>({target:s.target,pointer:s.pointer,operation:'remove'})):
26
+ retired.settings.filter(s=>s.target!=='grok.user');
27
+ const next=records.pending?.value??{...previous,intent:'remove',files:[],
28
+ settings:settings.map(s=>({...s,valueHash:null})),
29
+ operationHash:contractDigest({intent:'remove',previous,preserveGlobalSettings:true})};
30
+ return {alreadyRemoved:false,next,desired:retired.desired,settings,
31
+ protectedPaths:[...protectedRoots]};
32
+ }
@@ -0,0 +1,27 @@
1
+ import {fail} from '../contracts/parse.js';
2
+ import {contractDigest} from '../contracts/semantic.js';
3
+ import {readDesiredRecords} from './records.js';
4
+ import {parseDesiredWorkspace,prepareDesiredWorkspace} from './source.js';
5
+
6
+ // Reset is restoration of the recorded Git package, not a fetch of today's ref.
7
+ // The source remains declarative: updates keep using its original branch/ref.
8
+ export async function prepareDesiredReset(workspace,text,options={}) {
9
+ const descriptor=parseDesiredWorkspace(text),records=await readDesiredRecords(workspace);
10
+ if(records.pending?.value.intent==='remove')fail('desired.finish-remove-before-reset');
11
+ const record=(records.pending??records.installed)?.value;
12
+ if(!record?.binding || !record.adapters)fail('desired.reset-binding-required');
13
+ if(contractDigest(descriptor.pipeline)!==contractDigest(record.binding.source) ||
14
+ contractDigest(descriptor.layout)!==contractDigest(record.binding.layout) ||
15
+ contractDigest([...descriptor.adapters].sort())!==contractDigest([...record.adapters].sort()))
16
+ fail('desired.reset-workspace-different');
17
+ const pinned={...descriptor,pipeline:{...record.binding.source,ref:record.binding.commit}};
18
+ const prepared=await prepareDesiredWorkspace(workspace,JSON.stringify(pinned),options);
19
+ if(prepared.provenance.commit!==record.binding.commit || prepared.provenance.digest!==record.binding.digest)
20
+ fail('desired.reset-source-mismatch');
21
+ prepared.input.binding=record.binding;
22
+ prepared.provenance={source:record.binding.source,commit:record.binding.commit,digest:record.binding.digest};
23
+ prepared.descriptor=descriptor;
24
+ prepared.reset={mode:records.pending?'pending-installation':'installed',commit:record.binding.commit};
25
+ prepared.expectedRecords={installed:records.installed?.hash??null,pending:records.pending?.hash??null};
26
+ return prepared;
27
+ }
@@ -0,0 +1,37 @@
1
+ import {compileDesiredManifest} from '../contracts/desired-state.js';
2
+ import {compileDesiredSettings} from './settings.js';
3
+ import {fail} from '../contracts/parse.js';
4
+
5
+ const contains=(parent,child)=>parent===child || child.startsWith(parent+'/');
6
+
7
+ // Revalidate stored ownership through today's scope policy. A historical record
8
+ // is not a bypass for repository exclusions or config-field permissions.
9
+ export function includeRetiredTargets(previous,compiled,desired,protectedPaths) {
10
+ if(!previous)return {desired,settings:compiled.settings};
11
+ const priorSettings=previous.settings.map(s=>({target:s.target,pointer:s.pointer,operation:'remove'}));
12
+ compileDesiredManifest(JSON.stringify({schemaVersion:2,id:'retirement-check',version:'0.0.0',adapters:{prior:{
13
+ providers:['codex'],files:previous.scopes.map(s=>({source:'unused',target:s.path,kind:s.kind})),settings:priorSettings
14
+ }}}),{selected:['prior'],protectedPaths});
15
+ for(const op of priorSettings)compileDesiredSettings(op.target,null,[op]);
16
+ const settings=[...compiled.settings,...priorSettings.filter(old=>
17
+ previous.settings.find(s=>s.target===old.target && s.pointer===old.pointer).operation==='set' &&
18
+ !compiled.settings.some(s=>s.target===old.target && s.pointer===old.pointer))];
19
+ const all=[...previous.scopes,...desired.scopes];
20
+ // Different spellings of intersecting scopes cannot be resolved portably.
21
+ for(const a of all)for(const b of all) {
22
+ if(contains(a.path.toLowerCase(),b.path.toLowerCase()) && !contains(a.path,b.path))fail('desired.retirement-case-alias');
23
+ }
24
+ const unique=[...new Map(all.map(s=>[s.path,s])).values()];
25
+ const scopes=unique.filter(s=>!unique.some(parent=>parent.path!==s.path && contains(parent.path,s.path)));
26
+ const entries=new Map(desired.entries.map(e=>[e.path,e]));
27
+ // A narrowed former directory still needs structural parents while deleting
28
+ // its obsolete siblings. Those parents are not new ownership grants.
29
+ for(const entry of desired.entries) {
30
+ const parts=entry.path.split('/');
31
+ for(let i=1;i<parts.length;i++) {
32
+ const name=parts.slice(0,i).join('/');
33
+ if(scopes.some(s=>contains(s.path,name)) && !entries.has(name))entries.set(name,{path:name,kind:'directory'});
34
+ }
35
+ }
36
+ return {desired:{scopes,entries:[...entries.values()]},settings};
37
+ }