@volter/supercode-orchestrator 0.5.205 → 0.5.206
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/README.md +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +225 -87
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +297 -75
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +78 -24
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +5 -0
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +55 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +91 -28
- package/board/ztrack.mjs +87 -21
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- package/reducer.mjs +74 -5
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The home's installed-app runner owns only children it starts, and every connection keeps separate custody.
|
|
3
|
+
import { existsSync, openSync, closeSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { activeAgentAssignments, localAgentConversation } from '../agents.mjs';
|
|
8
|
+
import { connectionCall, connectionCanRun, connectionPath, generateModuleConfig, privateJson } from '../connection.mjs';
|
|
9
|
+
|
|
10
|
+
const {values}=parseArgs({options:{root:{type:'string'},connection:{type:'string'}}});
|
|
11
|
+
const path=connectionPath(values.root,values.connection),folder=dirname(path),lock=join(folder,'runner.lock');
|
|
12
|
+
let record=JSON.parse(readFileSync(path,'utf8'));
|
|
13
|
+
record.root=values.root;
|
|
14
|
+
if(!connectionCanRun(record))process.exit(0);
|
|
15
|
+
if(existsSync(lock)){
|
|
16
|
+
const previous=JSON.parse(readFileSync(lock,'utf8'));
|
|
17
|
+
try{process.kill(previous.pid,0);process.exit(0);}catch(error){if(error.code!=='ESRCH')throw error;}
|
|
18
|
+
renameSync(lock,`${lock}.stopped-${Date.now()}`);
|
|
19
|
+
}
|
|
20
|
+
const fd=openSync(lock,'wx',0o600);closeSync(fd);privateJson(lock,{pid:process.pid,connection:record.id});
|
|
21
|
+
const children=new Map(),health=record.health??={},raised=record.raised??{};
|
|
22
|
+
let stopped=false;
|
|
23
|
+
const log=(event,data={})=>process.stdout.write(JSON.stringify({t:new Date().toISOString(),event,...data})+'\n');
|
|
24
|
+
const save=()=>{record.health=health;record.raised=raised;privateJson(path,record);};
|
|
25
|
+
const teamPath=`/api/v1/teams/${encodeURIComponent(record.teams.team)}`;
|
|
26
|
+
const issue=(name,reason)=>{health[name]={state:'unavailable',reason,at:new Date().toISOString()};save();};
|
|
27
|
+
const recover=name=>{health[name]={state:'current',at:new Date().toISOString()};save();};
|
|
28
|
+
function start(grant){
|
|
29
|
+
if(stopped||children.has(grant.name)||!existsSync(join(values.root,'apps',`${grant.name}.${record.id}.json`)))return;
|
|
30
|
+
const config=generateModuleConfig(values.root,record,grant);
|
|
31
|
+
const child=spawn(process.execPath,[join(record.moduleRoot,grant.name,'main.mjs'),config],{stdio:['ignore','pipe','pipe'],env:{...process.env,SUPERCODE_ORCHESTRATOR_HOME:values.root,HERMES_HOME:values.root}});
|
|
32
|
+
children.set(grant.name,child);let buffer='';
|
|
33
|
+
child.stdout.on('data',chunk=>{
|
|
34
|
+
process.stdout.write(chunk);if(stopped)return;buffer+=chunk.toString();let at;
|
|
35
|
+
while((at=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,at);buffer=buffer.slice(at+1);let event;try{event=JSON.parse(line);}catch{continue;}
|
|
36
|
+
if(['read-error','read-failed','tick-failed','event-stream-failed','resync_required','access_revoked'].includes(event.event))issue(grant.name,event.code??event.reason??event.event);
|
|
37
|
+
if(['source-current','snapshot-read','snapshot-empty','published'].includes(event.event)&&event.current!==false)recover(grant.name);
|
|
38
|
+
if(['arc-pending','card-pending'].includes(event.event))issue(`obligation.${grant.name}.${event.card}`,`${event.status?'HTTP '+event.status+' ':''}${event.code??event.event}${event.message?' — '+event.message:''}`.slice(0,300));
|
|
39
|
+
if(event.event==='projection-current')recover(`obligation.${grant.name}.${event.card}`);
|
|
40
|
+
}
|
|
41
|
+
if(buffer.length>65536)buffer='';
|
|
42
|
+
});
|
|
43
|
+
child.stderr.on('data',chunk=>{process.stderr.write(chunk);if(!stopped)issue(grant.name,'module wrote a startup/runtime error; inspect its runner log');});
|
|
44
|
+
child.on('error',error=>issue(grant.name,error.code??'start-failed'));
|
|
45
|
+
child.on('exit',(code,signal)=>{
|
|
46
|
+
children.delete(grant.name);if(!stopped){issue(grant.name,`module exited (${code??signal})`);setTimeout(()=>start(grant),5000);}
|
|
47
|
+
});
|
|
48
|
+
log('module-started',{module:grant.name,pid:child.pid});
|
|
49
|
+
}
|
|
50
|
+
async function stopChild(name){
|
|
51
|
+
const child=children.get(name);if(!child)return;children.delete(name);
|
|
52
|
+
child.removeAllListeners('exit');child.kill('SIGTERM');
|
|
53
|
+
await new Promise(done=>{const timer=setTimeout(()=>{child.kill('SIGKILL');done();},5000);child.once('exit',()=>{clearTimeout(timer);done();});});
|
|
54
|
+
}
|
|
55
|
+
async function syncAlerts(){
|
|
56
|
+
const grant=record.grants.find(g=>g.manifest.actions.includes('alert.raise')&&existsSync(join(values.root,'apps',`${g.name}.${record.id}.json`)));
|
|
57
|
+
if(!grant)return;
|
|
58
|
+
for(const [name,state] of Object.entries(health)){
|
|
59
|
+
const key=`connection.${record.id}.${name}`.slice(0,200),wanted=state.state==='unavailable'?state.reason:null;
|
|
60
|
+
if(raised[name]===wanted || (wanted===null && !Object.hasOwn(raised,name)))continue;
|
|
61
|
+
try{
|
|
62
|
+
await connectionCall(record,'rh2','POST',`/api/v3/organizations/${encodeURIComponent(record.organization)}/alerts${wanted?'':'/clear'}`,wanted?{key,severity:'warning',reason:wanted,targetRole:record.alertRole}:{key},grant.rh2.accessToken);
|
|
63
|
+
raised[name]=wanted;save();
|
|
64
|
+
}catch(error){log('connection-alert-pending',{module:grant.name,code:error.code??error.status??'unavailable'});}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function standing(){
|
|
68
|
+
const assignments=activeAgentAssignments(values.root);
|
|
69
|
+
const rows=(record.standingSelection??[]).map(selection=>{
|
|
70
|
+
const own=assignments.filter(row=>row.org===record.teams.team&&row.kind==='profile'&&row.target===selection.profile);
|
|
71
|
+
if(own.length!==1){issue(`standing.${selection.profile}`,own.length?'profile has multiple active agent owners':'profile has no declared conversation owner');return {...selection,reason:'conversation-owner-unknown'};}
|
|
72
|
+
const conversation=localAgentConversation(values.root,record.teams.team,own[0].agent_id);
|
|
73
|
+
if(!conversation){issue(`standing.${selection.profile}`,'the home has no conversation-session receipt for this profile');return {...selection,agent:own[0].agent_id,reason:'conversation-session-unknown'};}
|
|
74
|
+
recover(`standing.${selection.profile}`);return {...selection,agent:own[0].agent_id,session:conversation.session,startedAt:conversation.started_at,running:conversation.ended_at==null};
|
|
75
|
+
});
|
|
76
|
+
const target=join(folder,'standing.json');
|
|
77
|
+
if(!existsSync(target)||readFileSync(target,'utf8').trim()!==JSON.stringify(rows,null,2))privateJson(target,rows);
|
|
78
|
+
}
|
|
79
|
+
async function renewTeams(value){
|
|
80
|
+
if(!value.pendingRenewal&&value.credential.expires_at-Date.now()>7*86400000)return false;
|
|
81
|
+
if(value.pendingRenewal&&value.renewalRetryAt>Date.now())return false;
|
|
82
|
+
const recovering=typeof value.pendingRenewal==='string';
|
|
83
|
+
value.pendingRenewal??=`sct_cred_${randomHex(16)}.${randomBase64()}`;
|
|
84
|
+
value.renewalRetryAt=Date.now()+30000;save();
|
|
85
|
+
const path=`${teamPath}/app-installations/${value.installation.id}/renew`,body={replacement_token:value.pendingRenewal};
|
|
86
|
+
let renewed;
|
|
87
|
+
if(recovering){
|
|
88
|
+
try{renewed=(await connectionCall(record,'teams','POST',path,body,value.pendingRenewal)).data;}
|
|
89
|
+
catch(error){if(error.status!==401)throw error;} // No committed replacement yet: use the predecessor below.
|
|
90
|
+
}
|
|
91
|
+
renewed??=(await connectionCall(record,'teams','POST',path,body,value.credential.token)).data;
|
|
92
|
+
if(renewed.credential_id!==value.pendingRenewal.slice(4).split('.')[0]||renewed.installation_id!==value.installation.id||!(renewed.expires_at>Date.now()))throw new Error('renewal metadata does not admit the staged replacement');
|
|
93
|
+
value.credential={...value.credential,token:value.pendingRenewal,expires_at:renewed.expires_at};delete value.pendingRenewal;delete value.renewalRetryAt;save();return true;
|
|
94
|
+
}
|
|
95
|
+
function randomHex(size){return crypto.getRandomValues(new Uint8Array(size)).reduce((out,n)=>out+n.toString(16).padStart(2,'0'),'');}
|
|
96
|
+
function randomBase64(){return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');}
|
|
97
|
+
async function renew(){
|
|
98
|
+
for(const grant of record.grants){
|
|
99
|
+
if(!existsSync(join(values.root,'apps',`${grant.name}.${record.id}.json`)))continue;
|
|
100
|
+
try{
|
|
101
|
+
let changed=false;
|
|
102
|
+
if(Date.parse(grant.rh2.expiresAt)-Date.now()<=7*86400000){grant.rh2={...grant.rh2,...(await connectionCall(record,'rh2','POST',`/api/v3/automation/installations/${grant.rh2.installationId}/renew`,{},grant.rh2.accessToken)).data};changed=true;save();}
|
|
103
|
+
if(grant.teams)changed=await renewTeams(grant.teams)||changed;
|
|
104
|
+
if(changed&&existsSync(join(values.root,'apps',`${grant.name}.${record.id}.json`))){await stopChild(grant.name);start(grant);}
|
|
105
|
+
if(!grant.teams?.pendingRenewal)recover(`expiry.${grant.name}`);
|
|
106
|
+
}catch(error){issue(`expiry.${grant.name}`,error.code??'renewal-unavailable');}
|
|
107
|
+
}
|
|
108
|
+
try{await renewTeams(record.issuerCredential);if(!record.issuerCredential.pendingRenewal)recover('issuer-renewal');}catch(error){issue('issuer-renewal',error.code??'renewal-unavailable');}
|
|
109
|
+
}
|
|
110
|
+
for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>void stop());
|
|
111
|
+
for(const grant of record.grants)start(grant);
|
|
112
|
+
let lastRenew=0,lastStanding=0,lastKeys=0;
|
|
113
|
+
async function publishIssuerKeys(results=[]){
|
|
114
|
+
const keys=(await connectionCall(record,'rh2','GET','/.well-known/jwks.json')).keys;
|
|
115
|
+
await connectionCall(record,'teams','POST',`${teamPath}/app-installations/${record.issuerCredential.installation.id}/issuer-keyring`,{keys,results},record.issuerCredential.credential.token);
|
|
116
|
+
lastKeys=Date.now();
|
|
117
|
+
}
|
|
118
|
+
while(!stopped){
|
|
119
|
+
try{
|
|
120
|
+
const installed=record.grants.filter(grant=>existsSync(join(values.root,'apps',`${grant.name}.${record.id}.json`)));
|
|
121
|
+
for(const name of children.keys())if(!installed.some(grant=>grant.name===name))await stopChild(name);
|
|
122
|
+
if(!installed.length){await stop();break;}
|
|
123
|
+
const pendingRenewalDue=[record.issuerCredential,...installed.map(grant=>grant.teams)].some(value=>value?.pendingRenewal&&!(value.renewalRetryAt>Date.now()));
|
|
124
|
+
if(pendingRenewalDue||Date.now()-lastRenew>3600000){await renew();lastRenew=Date.now();}
|
|
125
|
+
if(Date.now()-lastStanding>15000){standing();lastStanding=Date.now();}
|
|
126
|
+
// A restart may have outlived the key lease; publish before a twenty-second idle poll can delay admission.
|
|
127
|
+
if(Date.now()-lastKeys>=30000)await publishIssuerKeys();
|
|
128
|
+
const pending=(await connectionCall(record,'teams','GET',`${teamPath}/app-installations/${record.issuerCredential.installation.id}/issuer-checks`,undefined,record.issuerCredential.credential.token)).data.checks;
|
|
129
|
+
const results=await Promise.all(pending.map(async check=>{
|
|
130
|
+
// Every generation starts its own status read AFTER this poll received it. Never share a token decision,
|
|
131
|
+
// even if two generations of the same token occur in one batch; only this generation may use this answer.
|
|
132
|
+
const generation=check.id;
|
|
133
|
+
const status=(await connectionCall(record,'rh2','POST','/api/v3/delegated/status',{token:check.token})).data;
|
|
134
|
+
return {id:generation,live:status?.live===true};
|
|
135
|
+
}));
|
|
136
|
+
if(results.length||Date.now()-lastKeys>=30000)await publishIssuerKeys(results);
|
|
137
|
+
recover('issuer');await syncAlerts();
|
|
138
|
+
}catch(error){issue('issuer',error.code??error.status??'outbound-connection-unavailable');await syncAlerts();}
|
|
139
|
+
await new Promise(done=>setTimeout(done,2000));
|
|
140
|
+
}
|
|
141
|
+
async function stop(){if(stopped)return;stopped=true;await Promise.all([...children.keys()].map(stopChild));record.stoppedAt=new Date().toISOString();save();unlinkSync(lock);process.exit(0);}
|
package/bin/orchestrator.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// before anything loads node:sqlite: Node's notice that it is experimental is not this command's to print
|
|
3
|
+
try { await import('@volter/supercode-harness-sdk/runtime-warnings'); } catch { /* a harness SDK without it: the notice is printed */ }
|
|
2
4
|
// Check before handing off or importing board/runtime dependencies.
|
|
3
5
|
let lockedNative;
|
|
4
6
|
try { ({ lockedNative } = await import('../source-lock.mjs')); }
|
|
@@ -18,7 +20,7 @@ try {
|
|
|
18
20
|
} catch (error) { process.stderr.write(`${error.message}\n`); process.exit(1); }
|
|
19
21
|
// Board commands do not load daemon transports, platform adapters or the MCP server.
|
|
20
22
|
if (process.argv[2] === 'workflow') {
|
|
21
|
-
const [{runWorkflow},{timeCommand}]=await Promise.all([import('../board/client.mjs'),import('@volter/supercode-harness-sdk/slow-log')]);
|
|
23
|
+
const [{runWorkflow},{timeCommand,noteCommandError}]=await Promise.all([import('../board/client.mjs'),import('@volter/supercode-harness-sdk/slow-log')]);
|
|
22
24
|
// A board command is timed into the slow log; `serve` runs until stopped and is not one.
|
|
23
25
|
const verb=process.argv.slice(3).find((arg,index,all)=>!arg.startsWith('-')&&!(index>0&&/^--(root|board|cwd|context|at)$/.test(all[index-1])));
|
|
24
26
|
if(verb!=='serve')timeCommand(`workflow ${verb??''}`.trim(),process.argv.slice(3));
|
|
@@ -26,11 +28,18 @@ if (process.argv[2] === 'workflow') {
|
|
|
26
28
|
try {code=await runWorkflow(process.argv.slice(3));}
|
|
27
29
|
// a store's own error (SQLite's, with its errcode) is no message written for a person: its stack says which statement
|
|
28
30
|
// met it, as a follower's end in the connector's log (board events) otherwise could not
|
|
29
|
-
catch(error) {process.stderr.write(`supercode workflow: ${error.message??error}\n${error?.errcode!=null&&error.stack?`${error.stack}\n`:''}`);code=1;}
|
|
31
|
+
catch(error) {noteCommandError(error);process.stderr.write(`supercode workflow: ${error.message??error}\n${error?.errcode!=null&&error.stack?`${error.stack}\n`:''}`);code=1;}
|
|
30
32
|
// the board documents' git work this command queued (a ztrack board's commit and push) is finished before it exits
|
|
31
33
|
try{const{settleDocuments}=await import('../board/git-document.mjs');await settleDocuments();}catch{/* nothing queued */}
|
|
32
34
|
await Promise.all([process.stdout,process.stderr].map(stream=>new Promise(done=>stream.write('',done))));
|
|
33
35
|
process.exit(code);
|
|
36
|
+
} else if (process.argv[2] === 'connect') {
|
|
37
|
+
const {parseArgs}=await import('node:util');
|
|
38
|
+
const {connectWorkplace}=await import('../connection.mjs');
|
|
39
|
+
const options=Object.fromEntries(['root','ticket-file','modules-root','source','context','teams-token-file','teams-url','team','floor','standing-file','alert-role'].map(key=>[key,{type:'string'}]));
|
|
40
|
+
options['no-start']={type:'boolean'};
|
|
41
|
+
try{const {values}=parseArgs({args:process.argv.slice(3),options});process.stdout.write(JSON.stringify(await connectWorkplace(values),null,2)+'\n');}
|
|
42
|
+
catch(error){process.stderr.write(`connect: ${error.message}\n`);process.exitCode=1;}
|
|
34
43
|
} else if (process.argv[2] === 'agent-channel') {
|
|
35
44
|
await import('./agent-channel.mjs');
|
|
36
45
|
} else {
|
package/bin/runtime.mjs
CHANGED
|
@@ -70,7 +70,7 @@ import {
|
|
|
70
70
|
TICK_MS, accessPolicy, applyOperator, performRemoveProfileDir, OPERATOR_OPS,
|
|
71
71
|
} from '../index.mjs';
|
|
72
72
|
import * as events from '../events.mjs';
|
|
73
|
-
import { appView, installAgent, installApp, installedAgentRecord, installedApps, removeApp, rh2AgentEnv, rh2Program } from '../installed-apps.mjs';
|
|
73
|
+
import { appView, installAgent, installApp, installedAgentRecord, installedApps, removeApp, rh2AgentEnv, rh2Program, startInstalledModules } from '../installed-apps.mjs';
|
|
74
74
|
import { httpJobExecutor } from '../hosted.mjs';
|
|
75
75
|
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
76
76
|
|
|
@@ -290,6 +290,11 @@ async function runOperator(op, argv) {
|
|
|
290
290
|
process.stdout.write(`${JSON.stringify({ ...answer, root, door: 'live', context_profile: args.profile })}\n`);
|
|
291
291
|
return answer.ok ? 0 : 1;
|
|
292
292
|
}
|
|
293
|
+
// A launch is a running loop's act (it starts a runtime); without one there is nothing to start it or read it.
|
|
294
|
+
if (op.startsWith('sessions.launch')) {
|
|
295
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: 'the orchestrator for this home is not running; start it before launching a session' })}\n`);
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
293
298
|
let state;
|
|
294
299
|
let vault;
|
|
295
300
|
try {
|
|
@@ -353,6 +358,7 @@ function buildAdapters(state, vault = new Map(), log = () => {}, root = null) {
|
|
|
353
358
|
report.push({ platform, profile: profileName, adapter: selector, state: 'connecting' });
|
|
354
359
|
}
|
|
355
360
|
}
|
|
361
|
+
void startInstalledModules(root).catch(error=>log('installed-modules-unavailable',{message:String(error.message)}));
|
|
356
362
|
// Installed apps (ADR 0017): each attaches its channel with the installation's own token, under the profile it was
|
|
357
363
|
// installed for. Uninstalling on the other side revokes the token and the channel stops.
|
|
358
364
|
for (const app of root ? installedApps(root) : []) {
|
|
@@ -456,11 +462,12 @@ const ACCESS_OPS = {
|
|
|
456
462
|
* Hermes's own pairing store and writers, then the home is loaded again to report what stands.
|
|
457
463
|
*/
|
|
458
464
|
/** `apps install NAME --origin URL --client-id ID --client-secret-file FILE --code CODE [--profile P]`,
|
|
459
|
-
* `apps install rh2-agent --origin URL --organization ORG --principal PRN --token-file FILE --profile AGENT`, `apps list`,
|
|
465
|
+
* `apps install rh2-agent --origin URL --organization ORG --principal PRN --agent AGENT_ID --token-file FILE --profile AGENT`, `apps list`,
|
|
460
466
|
* `apps remove NAME`. */
|
|
461
467
|
async function appsMain(argv) {
|
|
462
468
|
const [verb, ...rest] = argv;
|
|
463
|
-
// `apps mcp rh2-agent --
|
|
469
|
+
// `apps mcp rh2-agent --agent <its Teams id> [--root R]`: `rh2 mcp` as the agent whose record that id names, its
|
|
470
|
+
// credential in that one
|
|
464
471
|
// process's environment (rh2AgentEnv) and nowhere else: no config file carries the token, and the command is fixed,
|
|
465
472
|
// so nothing can be run in its place to print the token. It is how an agent's session mounts RH2 (mcp/mount.mjs).
|
|
466
473
|
if (verb === 'mcp') {
|
|
@@ -469,13 +476,13 @@ async function appsMain(argv) {
|
|
|
469
476
|
for (let i = 0; i < rest.length; i += 1) {
|
|
470
477
|
if (rest[i].startsWith('--')) { opts[rest[i].slice(2)] = rest[i + 1]; i += 1; } else words.push(rest[i]);
|
|
471
478
|
}
|
|
472
|
-
if (words[0] !== 'rh2-agent' || words.length !== 1 || !opts.
|
|
473
|
-
process.stderr.write('apps mcp rh2-agent --
|
|
479
|
+
if (words[0] !== 'rh2-agent' || words.length !== 1 || !opts.agent) {
|
|
480
|
+
process.stderr.write('apps mcp rh2-agent --agent <its Teams id> [--root R]\n');
|
|
474
481
|
process.exit(2);
|
|
475
482
|
}
|
|
476
|
-
const record = installedAgentRecord(resolve(opts.root ?? defaultRoot()), opts.
|
|
483
|
+
const record = installedAgentRecord(resolve(opts.root ?? defaultRoot()), opts.agent);
|
|
477
484
|
if (!record) {
|
|
478
|
-
process.stderr.write(`no rh2-agent is installed for ${opts.
|
|
485
|
+
process.stderr.write(`no single rh2-agent record is installed for ${opts.agent}: install it with apps install rh2-agent --agent ${opts.agent}\n`);
|
|
479
486
|
process.exit(1);
|
|
480
487
|
}
|
|
481
488
|
const program = rh2Program();
|
|
@@ -498,9 +505,9 @@ async function appsMain(argv) {
|
|
|
498
505
|
if (verb === 'list') { say('apps', { root, apps: installedApps(root).map(appView) }); return; }
|
|
499
506
|
if (verb === 'remove') { say('apps', { root, removed: removeApp(root, positional[0] ?? '') }); return; }
|
|
500
507
|
if (verb === 'install' && positional[0] === 'rh2-agent') {
|
|
501
|
-
for (const name of ['origin', 'organization', 'principal', 'token-file', 'profile']) if (!flags[name]) throw new Error(`apps install rh2-agent needs --${name}`);
|
|
508
|
+
for (const name of ['origin', 'organization', 'principal', 'agent', 'token-file', 'profile']) if (!flags[name]) throw new Error(`apps install rh2-agent needs --${name}`);
|
|
502
509
|
const token = readFileSync(flags['token-file'], 'utf8').trim();
|
|
503
|
-
say('apps', { root, installed: appView(await installAgent({ root, origin: flags.origin, organization: flags.organization, principal: flags.principal, token, profile: flags.profile })) });
|
|
510
|
+
say('apps', { root, installed: appView(await installAgent({ root, origin: flags.origin, organization: flags.organization, principal: flags.principal, agent: flags.agent, token, profile: flags.profile })) });
|
|
504
511
|
return;
|
|
505
512
|
}
|
|
506
513
|
if (verb === 'install') {
|
package/board/args.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
export const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
|
|
5
5
|
export const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
|
|
6
|
-
'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose', '
|
|
6
|
+
'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose', 'side']);
|
|
7
7
|
// flags that take every following word up to the next flag (Hermes's nargs='+')
|
|
8
8
|
export const GREEDY = new Set(['ids', 'rm']);
|
|
9
9
|
|
|
@@ -13,7 +13,8 @@ export function parse(argv) {
|
|
|
13
13
|
const a = argv[i];
|
|
14
14
|
if (!a.startsWith('--') || a === '--') { out._.push(a); continue; }
|
|
15
15
|
const [key, inline] = a.slice(2).split(/=(.*)/s);
|
|
16
|
-
|
|
16
|
+
// init toggles document commits; completion/review names a git revision.
|
|
17
|
+
if (FLAGS.has(key) || (key === 'commit' && out._[0] === 'init')) { out[key] = true; continue; }
|
|
17
18
|
if (GREEDY.has(key)) {
|
|
18
19
|
const values = inline !== undefined ? [inline] : [];
|
|
19
20
|
while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) values.push(argv[++i]);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const ATTACHMENT_CHUNK_BYTES = 1024 * 1024;
|
|
7
|
+
const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);
|
|
8
|
+
// A failed close is not permission to retry it or forget its exact handle.
|
|
9
|
+
const uncertainHandles = new Map();
|
|
10
|
+
const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino && a.birthtimeNs === b.birthtimeNs;
|
|
11
|
+
const stamp = (s) => createHash('sha256').update([s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].join(':')).digest('hex');
|
|
12
|
+
const inside = (directory, path) => {
|
|
13
|
+
const r = relative(directory, path);
|
|
14
|
+
return Boolean(r) && !isAbsolute(r) && r !== '..' && !r.startsWith(`..${sep}`);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Read a recorded attachment, never an arbitrary host pathname. `version`
|
|
18
|
+
* guards subsequent ranges against changed filesystem metadata. */
|
|
19
|
+
export async function readAttachment(root, slug, row, { offset = 0, length = ATTACHMENT_CHUNK_BYTES, version = null } = {}) {
|
|
20
|
+
if (uncertainHandles.size) throw Object.assign(new Error('attachment reader retains an unconfirmed descriptor close', {
|
|
21
|
+
cause: uncertainHandles.values().next().value,
|
|
22
|
+
}), { code: 'attachment_cleanup_unconfirmed' });
|
|
23
|
+
if (!row) throw new Error('no such attachment on this card');
|
|
24
|
+
if (!/^[A-Za-z0-9_-]+$/.test(row.task_id)) throw new Error('attachment has an invalid task id');
|
|
25
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('attachment offset must be a nonnegative safe integer');
|
|
26
|
+
if (!Number.isSafeInteger(length) || length < 1 || length > ATTACHMENT_CHUNK_BYTES) throw new Error(`attachment length must be between 1 and ${ATTACHMENT_CHUNK_BYTES}`);
|
|
27
|
+
if (offset > 0 && (typeof version !== 'string' || !version)) throw new Error('subsequent attachment ranges need the first response version');
|
|
28
|
+
const home = await realpath(root);
|
|
29
|
+
const collection = await realpath(join(home, 'kanban', ...(slug === 'default' ? [] : ['boards', slug]), 'attachments'));
|
|
30
|
+
const directory = await realpath(join(collection, row.task_id));
|
|
31
|
+
const path = await realpath(row.stored_path);
|
|
32
|
+
if (!inside(home, collection) || !inside(collection, directory) || !inside(directory, path)) throw new Error('attachment is outside this card\'s attachment directory');
|
|
33
|
+
const admitted = await lstat(path, { bigint: true });
|
|
34
|
+
if (!admitted.isFile()) throw new Error('attachment is not a readable regular file of supported size');
|
|
35
|
+
// The pathname check alone cannot prevent replacement with a FIFO before open.
|
|
36
|
+
const handle = await open(path, OPEN_FLAGS);
|
|
37
|
+
let failure, result;
|
|
38
|
+
try {
|
|
39
|
+
const before = await handle.stat({ bigint: true });
|
|
40
|
+
const size = Number(before.size);
|
|
41
|
+
if (!before.isFile() || !Number.isSafeInteger(size)) throw new Error('attachment is not a readable regular file of supported size');
|
|
42
|
+
if (!sameFile(admitted, before)) throw new Error('attachment was replaced before the read; start a new download');
|
|
43
|
+
const named = await lstat(path, { bigint: true });
|
|
44
|
+
if (!named.isFile() || !sameFile(before, named) || await realpath(row.stored_path) !== path) throw new Error('attachment pathname changed before the read; start a new download');
|
|
45
|
+
if (size !== Number(row.size)) throw new Error('attachment size differs from its recorded upload');
|
|
46
|
+
const currentVersion = stamp(before);
|
|
47
|
+
if (version !== null && version !== currentVersion) throw new Error('attachment changed since the first range; start a new download');
|
|
48
|
+
if (offset > size) throw new Error('attachment offset is beyond its end');
|
|
49
|
+
const bytes = Buffer.alloc(Math.min(length, size - offset));
|
|
50
|
+
let read = 0;
|
|
51
|
+
while (read < bytes.length) {
|
|
52
|
+
const { bytesRead: count } = await handle.read(bytes, read, bytes.length - read, offset + read);
|
|
53
|
+
if (count <= 0) throw new Error('attachment ended before its recorded extent');
|
|
54
|
+
read += count;
|
|
55
|
+
}
|
|
56
|
+
if (stamp(await handle.stat({ bigint: true })) !== currentVersion) throw new Error('attachment changed during the read; start a new download');
|
|
57
|
+
const after = await lstat(path, { bigint: true });
|
|
58
|
+
if (!after.isFile() || !sameFile(before, after) || await realpath(row.stored_path) !== path) throw new Error('attachment pathname changed during the read; start a new download');
|
|
59
|
+
result = { task_id: row.task_id, attachment_id: row.id, filename: row.filename, content_type: row.content_type,
|
|
60
|
+
size, version: currentVersion, offset, read, next_offset: offset + read, eof: offset + read === size, content_base64: bytes.toString('base64') };
|
|
61
|
+
} catch (error) { failure = error; }
|
|
62
|
+
try { await handle.close(); }
|
|
63
|
+
catch (cleanup) {
|
|
64
|
+
const error = Object.assign(new AggregateError([...(failure ? [failure] : []), cleanup], 'attachment descriptor cleanup is unconfirmed', {
|
|
65
|
+
cause: failure ?? cleanup,
|
|
66
|
+
}), { code: 'attachment_cleanup_unconfirmed' });
|
|
67
|
+
uncertainHandles.set(handle, error);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
if (failure) throw failure;
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const attachmentRecord = (db, task, id) => {
|
|
75
|
+
if (!Number.isSafeInteger(id) || id < 1) throw new Error('attachment id must be a positive safe integer');
|
|
76
|
+
return db.prepare('SELECT * FROM task_attachments WHERE task_id = ? AND id = ?').get(task, id);
|
|
77
|
+
};
|
package/board/call.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// cwd the caller's working directory, against which a path it names is read
|
|
12
12
|
// stdin what the caller sent on its standard input, for a `-` it names as a file (null: none)
|
|
13
13
|
// out, err where the verb writes its answer
|
|
14
|
+
// readOnly a listing answered on the owner's read door (owner-door.mjs): the board is read as one committed state on
|
|
15
|
+
// a connection of its own (store.mjs openBoard), and nothing is written
|
|
14
16
|
import { readFileSync } from 'node:fs';
|
|
15
17
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
18
|
|
|
@@ -25,8 +27,8 @@ const own = { kind: 'own', caller: null, facts: process.env, ...fresh(), workflo
|
|
|
25
27
|
export const current = () => calls.getStore() ?? own;
|
|
26
28
|
|
|
27
29
|
/** Run `fn` as one caller's call, its answer written through `out` and `err`. */
|
|
28
|
-
export function within({ caller = null, facts = {}, cwd, stdin = null, out, err }, fn) {
|
|
29
|
-
return calls.run({ kind: 'caller', caller, facts, ...fresh(), cwd, stdin, out, err }, fn);
|
|
30
|
+
export function within({ caller = null, facts = {}, cwd, stdin = null, out, err, readOnly = false }, fn) {
|
|
31
|
+
return calls.run({ kind: 'caller', caller, facts, ...fresh(), cwd, stdin, out, err, readOnly }, fn);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/** Run `fn` as a unit of the process's own work (a round, a delivery pass): the process's identity and output, with
|
package/board/capacity.mjs
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
// (`where`, less `unless`) and its `max`. This module counts what a home holds now and answers whether one more card may start.
|
|
4
4
|
|
|
5
5
|
import { json } from './store.mjs';
|
|
6
|
+
import { detachedSession } from './custody.mjs';
|
|
6
7
|
import { statusOf } from './workflow.mjs';
|
|
8
|
+
import { setImmediate as yieldToOwner } from 'node:timers/promises';
|
|
9
|
+
import { listRevision } from './listing.mjs';
|
|
7
10
|
|
|
8
11
|
/** The scope's group a counted thing or a card falls in. */
|
|
9
12
|
const GROUP = {
|
|
@@ -36,14 +39,45 @@ export function matches(filter, thing) {
|
|
|
36
39
|
const applies = (cap, thing) => matches(cap.where, thing) && !(Object.keys(cap.unless ?? {}).length && matches(cap.unless, thing));
|
|
37
40
|
|
|
38
41
|
/** Each slot's latest recorded session on a card (implementer `worker`, `reviewer`, …), newest run first. */
|
|
39
|
-
function sessionsOf(db, taskId) {
|
|
42
|
+
async function sessionsOf(db, taskId, unchanged) {
|
|
40
43
|
const out = new Map();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
let cursor = null;
|
|
45
|
+
for (;;) {
|
|
46
|
+
if (!unchanged()) return null;
|
|
47
|
+
// Page by the indexed run id, not an offset. Include null metadata so even a long empty history
|
|
48
|
+
// reads only 64 records per statement. Every page is visited: an older, distinct slot still counts.
|
|
49
|
+
const rows = db.prepare(`SELECT id, metadata FROM task_runs WHERE task_id = ?${cursor === null ? '' : ' AND id < ?'} ORDER BY id DESC LIMIT 64`)
|
|
50
|
+
.all(taskId, ...(cursor === null ? [] : [cursor]));
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
const s = json(row.metadata, {})?.supercode;
|
|
53
|
+
const slot = s?.slot ?? 'worker';
|
|
54
|
+
if (s?.session_id && !out.has(slot)) out.set(slot, detachedSession(db, s) ? null : { ...s, run: row.id });
|
|
55
|
+
}
|
|
56
|
+
if (rows.length < 64) break;
|
|
57
|
+
cursor = rows.at(-1).id;
|
|
58
|
+
await yieldToOwner();
|
|
59
|
+
}
|
|
60
|
+
return new Map([...out].filter(([, s]) => s));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sameRevision = (a, b) => Object.keys(a).every(key => a[key] === b[key]);
|
|
64
|
+
|
|
65
|
+
/** Read a stable board before admitting starts. An unread capacity never becomes an empty board. */
|
|
66
|
+
export async function holdings(board, options) {
|
|
67
|
+
try {
|
|
68
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
69
|
+
// Reuse the listing's guard: own/external commits, TEMP overlays and file projection reloads all count.
|
|
70
|
+
const before = listRevision(board.db);
|
|
71
|
+
const unchanged = () => sameRevision(before, listRevision(board.db));
|
|
72
|
+
const out = await readHoldings(board, options, unchanged);
|
|
73
|
+
if (out !== null && unchanged()) return out;
|
|
74
|
+
await yieldToOwner();
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Capacity of board ${board.slug} changed during its reading; this round starts no cards.`);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
error.capacityUnread = true;
|
|
79
|
+
throw error;
|
|
45
80
|
}
|
|
46
|
-
return out;
|
|
47
81
|
}
|
|
48
82
|
|
|
49
83
|
/**
|
|
@@ -53,10 +87,12 @@ function sessionsOf(db, taskId) {
|
|
|
53
87
|
* own transcript reports it runs, or null where none can be read; `machineOf` normalises a machine name; `lanes` gives
|
|
54
88
|
* a profile's harness and model.
|
|
55
89
|
*/
|
|
56
|
-
|
|
90
|
+
async function readHoldings(board, { lanes, fleetOk, alive, liveRecord, modelOf = () => null, storedOf = null, machineOf, home = null, runLaneOf = null }, unchanged) {
|
|
57
91
|
const out = [];
|
|
58
|
-
const rows = board.db.prepare("SELECT t.id, t.status, t.assignee, t.current_run_id, t.model_override, c.machine, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status != 'archived'").all();
|
|
92
|
+
const rows = board.db.prepare("SELECT t.id, t.status, t.assignee, t.current_run_id, t.model_override, c.machine, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status != 'archived' AND c.detached IS NULL").all();
|
|
59
93
|
for (const row of rows) {
|
|
94
|
+
await yieldToOwner();
|
|
95
|
+
if (!unchanged()) return null;
|
|
60
96
|
const lane = lanes[row.assignee] ?? {};
|
|
61
97
|
const card = {
|
|
62
98
|
card: row.id, board: board.slug, assignee: row.assignee ?? null, status: statusOf(board.db, row),
|
|
@@ -76,7 +112,8 @@ export function holdings(board, { lanes, fleetOk, alive, liveRecord, modelOf = (
|
|
|
76
112
|
: executionHost(runLaneOf ? runLaneOf(row) : lane, row.machine, home, machineOf), harness: s.harness ?? card.harness ?? 'claude-code',
|
|
77
113
|
model: (s.session_id ? modelOf(s) : null) ?? configured };
|
|
78
114
|
};
|
|
79
|
-
const sessions = fleetOk ? sessionsOf(board.db, row.id) : new Map();
|
|
115
|
+
const sessions = fleetOk ? await sessionsOf(board.db, row.id, unchanged) : new Map();
|
|
116
|
+
if (sessions === null) return null;
|
|
80
117
|
const held = [];
|
|
81
118
|
let current = null;
|
|
82
119
|
if (row.current_run_id) {
|