@volter/supercode-orchestrator 0.5.226 → 0.5.227
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 +29 -18
- package/adapters/rh2.mjs +6 -5
- package/bin/connection-runner.mjs +46 -12
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +6 -1
- package/board/agent-declarations.mjs +1 -1
- package/board/args.mjs +8 -1
- package/board/call.mjs +2 -2
- package/board/ci.mjs +158 -54
- package/board/cli.mjs +175 -53
- package/board/custody.mjs +5 -1
- package/board/dispatch.mjs +969 -256
- package/board/doors.mjs +7 -2
- package/board/engine.mjs +264 -48
- package/board/event-stream.mjs +3 -7
- package/board/files.mjs +4 -1
- package/board/fleet-listeners.mjs +3 -1
- package/board/fleet-observer.mjs +3 -2
- package/board/generation-owner.mjs +40 -10
- package/board/generation.mjs +17 -4
- package/board/machine-fleet.mjs +33 -9
- package/board/notices.mjs +10 -2
- package/board/published.mjs +107 -2
- package/board/readable-id.mjs +25 -2
- package/board/runtime-door.mjs +2 -2
- package/board/session-work.mjs +15 -1
- package/board/storage-contract.mjs +41 -3
- package/board/store.mjs +49 -7
- package/board/tools.mjs +1 -1
- package/board/workflow.mjs +197 -70
- package/board/workspace.mjs +99 -13
- package/board/ztrack.mjs +42 -14
- package/connection.mjs +252 -32
- package/index.d.ts +5 -1
- package/installed-apps.mjs +11 -5
- package/loop.mjs +6 -0
- package/mcp/hermes-kanban-tools.json +10 -1
- package/package.json +6 -6
- package/reducer.mjs +31 -2
package/connection.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// The kit's connection coordinator. Module manifests and programs belong to their application publisher.
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { createHash, randomBytes } from 'node:crypto';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
6
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
7
7
|
import { fromContext } from '@volter/teams/context';
|
|
8
8
|
import { supercodeHome } from '@volter/teams/home';
|
|
9
9
|
|
|
@@ -36,7 +36,7 @@ export async function connectionCall(record, side, method, path, body, token) {
|
|
|
36
36
|
const text=await response.text();
|
|
37
37
|
record.calls??=[];record.calls.push({side,method,path,status:response.status,ms:Date.now()-began,bytes:Buffer.byteLength(text)});record.calls=record.calls.slice(-200);
|
|
38
38
|
let payload;try{payload=text===''&&response.status===204?{}:JSON.parse(text);}catch{throw new Error(`${side} ${method} ${path}: invalid JSON (${response.status})`);}
|
|
39
|
-
if(!response.ok)throw Object.assign(new Error(`${side} ${method} ${path}: ${payload.error?.code??response.status}`),{code:payload.error?.code,status:response.status});
|
|
39
|
+
if(!response.ok)throw Object.assign(new Error(`${side} ${method} ${path}: ${payload.error?.code??response.status}`),{code:payload.error?.code,status:response.status,detail:typeof payload.error?.message==='string'?payload.error.message:null});
|
|
40
40
|
return payload;
|
|
41
41
|
}
|
|
42
42
|
/** A registration that trusts a Workplace as its issuer trusts this connection's Workplace and organization, never the
|
|
@@ -46,6 +46,7 @@ export function trustedIssuer(manifest,record){return manifest.issuer===undefine
|
|
|
46
46
|
export function connectionPath(root,id){return join(root,'apps','connections',id,'connection.json');}
|
|
47
47
|
export function generateModuleConfig(root, record, grant) {
|
|
48
48
|
if(SERVICE_APPS.includes(grant.name))return generateServiceConfig(root,record,grant);
|
|
49
|
+
if(grant.adopted)return generateAdoptedConfig(root,record,grant);
|
|
49
50
|
const folder=dirname(connectionPath(root,record.id)), dir=join(folder,grant.name);
|
|
50
51
|
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
51
52
|
const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
|
|
@@ -58,8 +59,22 @@ export function generateModuleConfig(root, record, grant) {
|
|
|
58
59
|
origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
|
|
59
60
|
return configPath;
|
|
60
61
|
}
|
|
62
|
+
/** An adopted module's config is its own, as it ran (adoptWorkplaceService): only its custody (the token names it reads,
|
|
63
|
+
* now holding the connection's current tokens) and its state move into the connection. */
|
|
64
|
+
function generateAdoptedConfig(root, record, grant) {
|
|
65
|
+
const dir=join(dirname(connectionPath(root,record.id)),grant.name);
|
|
66
|
+
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
67
|
+
const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
|
|
68
|
+
writeFileSync(custody,`${grant.config.rh2.tokenEnv}=${grant.rh2.accessToken}\n${grant.teams?`${grant.config.teams.tokenEnv}=${grant.teams.credential.token}\n`:''}`,{mode:0o600});chmodSync(custody,0o600);
|
|
69
|
+
privateJson(configPath,{...grant.config,envFile:custody,...(grant.config.stateFile?{stateFile:join(dir,'state.json')}:{})});
|
|
70
|
+
privateJson(join(root,'apps',`${grant.name}.${record.id}.json`),{app:grant.name,connection:record.id,configPath,program:grant.program,adopted:true,
|
|
71
|
+
origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
|
|
72
|
+
return configPath;
|
|
73
|
+
}
|
|
61
74
|
/** The Workplace service's config and custody, written from the connection alone (no hand-written config): every installed
|
|
62
|
-
* service registration's credentials, ids and state, and the home's registry entry for `grant`. One config serves all.
|
|
75
|
+
* service registration's credentials, ids and state, and the home's registry entry for `grant`. One config serves all.
|
|
76
|
+
* A connection's keys are made under its namespace (`cx<scope>`); an adopted service (adoptWorkplaceService) has no scope
|
|
77
|
+
* and keeps the unqualified keys its installations have always written, with the settings it ran with. */
|
|
63
78
|
export function generateServiceConfig(root, record, grant) {
|
|
64
79
|
const folder=dirname(connectionPath(root,record.id)), dir=join(folder,'service');
|
|
65
80
|
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
@@ -68,18 +83,23 @@ export function generateServiceConfig(root, record, grant) {
|
|
|
68
83
|
writeFileSync(custody,Object.entries(held).map(([name,g])=>`${SERVICE_TOKENS[name][0]}=${g.rh2.accessToken}\n${SERVICE_TOKENS[name][1]}=${g.teams.credential.token}\n`).join(''),{mode:0o600});chmodSync(custody,0o600);
|
|
69
84
|
const agents=held.agents;
|
|
70
85
|
if(agents){
|
|
71
|
-
const section=name=>({registration:held[name].clientId,principal:held[name].rh2.principalId,tokenEnv:SERVICE_TOKENS[name][0],teamsTokenEnv:SERVICE_TOKENS[name][1],stateFile:join(dir,`${name}.state.json`)});
|
|
86
|
+
const section=name=>({registration:held[name].clientId,principal:held[name].rh2.principalId,tokenEnv:SERVICE_TOKENS[name][0],teamsTokenEnv:SERVICE_TOKENS[name][1],stateFile:join(dir,`${name}.state.json`),...record.settings?.[name]});
|
|
72
87
|
const config={
|
|
73
88
|
teams:{server:record.teams.server,team:record.teams.team,installation:agents.teams.installation.id,source:record.source,tokenEnv:SERVICE_TOKENS.agents[1]},
|
|
74
89
|
rh2:{baseUrl:record.origin,organization:record.organization,registration:agents.clientId,...(record.issuer!==record.origin?{issuer:record.issuer}:{}),tokenEnv:SERVICE_TOKENS.agents[0],principal:agents.rh2.principalId},
|
|
75
|
-
source:{namespace:`cx${record.scope}`},envFile:custody,stateFile:join(dir,'agents.state.json'),
|
|
90
|
+
...(record.scope?{source:{namespace:`cx${record.scope}`}}:{}),envFile:custody,stateFile:join(dir,'agents.state.json'),
|
|
91
|
+
...record.settings?.service,
|
|
76
92
|
...(record.webhook?{webhook:record.webhook}:{}),
|
|
77
93
|
...(held.cards?{cards:section('cards')}:{}),...(held.machines?{machines:section('machines')}:{}),
|
|
78
94
|
};
|
|
79
95
|
privateJson(configPath,config);
|
|
80
96
|
}
|
|
81
|
-
|
|
82
|
-
origin:record.origin,organizationId:record.organization,principalId:
|
|
97
|
+
const entry=g=>({app:g.name,connection:record.id,configPath,program:SERVICE_PROGRAM,
|
|
98
|
+
origin:record.origin,organizationId:record.organization,principalId:g.rh2.principalId,installationId:g.rh2.installationId,expiresAt:g.rh2.expiresAt});
|
|
99
|
+
privateJson(join(root,'apps',`${grant.name}.${record.id}.json`),entry(grant));
|
|
100
|
+
// The one config restarts for every renewal, so each registration's entry shows its own current expiry (`apps list`); an
|
|
101
|
+
// entry that is gone stays gone (it is what stops that registration).
|
|
102
|
+
for(const other of Object.values(held))if(other.name!==grant.name&&existsSync(join(root,'apps',`${other.name}.${record.id}.json`)))privateJson(join(root,'apps',`${other.name}.${record.id}.json`),entry(other));
|
|
83
103
|
return configPath;
|
|
84
104
|
}
|
|
85
105
|
export function startConnectionRunner(root,id) {
|
|
@@ -89,8 +109,49 @@ export function startConnectionRunner(root,id) {
|
|
|
89
109
|
const child=spawn(process.execPath,[fileURLToPath(new URL('./bin/connection-runner.mjs',import.meta.url)),'--root',root,'--connection',id],{detached:true,stdio:['ignore',fd,fd],env:process.env});
|
|
90
110
|
closeSync(fd);child.unref();return child.pid;
|
|
91
111
|
}
|
|
112
|
+
/** The file a runner that names its tree in its lock watches for its stop: Windows has no signal a process can catch. */
|
|
113
|
+
export const runnerStopPath=lock=>join(dirname(lock),'runner.stop');
|
|
114
|
+
/** Stop a connection's runner as it stops itself, its modules first and then its lock, and answer whether it is gone
|
|
115
|
+
* within the wait. POSIX delivers SIGTERM to its handler. On Windows process.kill ends the runner alone and leaves its
|
|
116
|
+
* modules serving with nobody stopping them, so a runner whose lock names its tree (it watches runnerStopPath) is asked
|
|
117
|
+
* through that file, and an older one is ended with its whole process tree. */
|
|
118
|
+
export async function stopConnectionRunner(lock,running,wait=20000){
|
|
119
|
+
if(!alive(running.pid))return true;
|
|
120
|
+
if(process.platform!=='win32'){try{process.kill(running.pid,'SIGTERM');}catch(error){if(error.code!=='ESRCH')throw error;}}
|
|
121
|
+
else if(running.tree)privateJson(runnerStopPath(lock),{pid:running.pid,at:new Date().toISOString()});
|
|
122
|
+
else spawnSync('taskkill',['/PID',String(running.pid),'/T','/F'],{stdio:'ignore',windowsHide:true});
|
|
123
|
+
for(let waited=0;waited<wait&&alive(running.pid);waited+=250)await new Promise(done=>setTimeout(done,250));
|
|
124
|
+
return !alive(running.pid);
|
|
125
|
+
}
|
|
92
126
|
export function connectionCanRun(record){return record.preparing===false&&record.paused===false&&typeof record.connectedAt==='string'&&!record.disconnectedAt;}
|
|
127
|
+
/** The Teams team a connection installs into and the admin credential that installs there: an explicit local credential
|
|
128
|
+
* (useful for an owned stack), else the existing Teams context. */
|
|
129
|
+
async function teamsAccess(flags) {
|
|
130
|
+
if(flags['teams-token-file']){
|
|
131
|
+
if(!flags['teams-url']||!flags.team)throw new Error('--teams-token-file needs --teams-url and --team');
|
|
132
|
+
return {teams:{server:new URL(flags['teams-url']).origin,team:flags.team},owner:readFileSync(flags['teams-token-file'],'utf8').trim()};
|
|
133
|
+
}
|
|
134
|
+
// An agent session connects as its agent: its delegated context, never the person's who signed the box in (the
|
|
135
|
+
// Teams verbs' rule, agentContextName). A person's context named from an agent session is refused, as a grant is.
|
|
136
|
+
const {WorkspaceContexts}=await import('@volter/teams/context');
|
|
137
|
+
const {agentSessionEnvironment,agentSessionHint,agentContextName,doorGlass}=await import('@volter/supercode-teams/agent-session');
|
|
138
|
+
const contexts=new WorkspaceContexts(supercodeHome());
|
|
139
|
+
// Asked of the daemon whatever the environment says: a resumed session can lack its launch's SUPERCODE_AGENT_ID,
|
|
140
|
+
// and the daemon's retained binding is what names its agent (workspace.mjs does the same for every Teams verb).
|
|
141
|
+
// On purpose (SUPERCODE_BREAK_GLASS="<reason>"), its guards let the connection act for another and record it.
|
|
142
|
+
const glass=doorGlass(process.env,'orchestrator connect');
|
|
143
|
+
const env=await agentSessionEnvironment(process.env,agentSessionHint(process.env),glass);
|
|
144
|
+
const teamsContext=agentContextName(contexts,env,flags.context??null,glass);
|
|
145
|
+
const {context}=fromContext({supercodeHome:supercodeHome(),name:teamsContext});
|
|
146
|
+
return {teams:{server:context.origin,team:context.team_id},owner:contexts.credential(context.credential_ref).token};
|
|
147
|
+
}
|
|
148
|
+
function exactOrigins(origins){
|
|
149
|
+
for(const origin of origins){
|
|
150
|
+
const url=new URL(origin);if(url.origin!==origin||url.username||url.password||(url.protocol!=='https:'&&!(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))throw new Error('connection endpoints must be exact HTTPS origins or loopback HTTP');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
93
153
|
export async function connectWorkplace(flags) {
|
|
154
|
+
if(flags.adopt||flags['adopt-module'])return adoptWorkplaceService(flags);
|
|
94
155
|
if(!flags.root||!flags['ticket-file']||!flags.source)throw new Error('connect needs --root, --ticket-file and --source (one board event source)');
|
|
95
156
|
const root=resolve(flags.root), ticket=JSON.parse(readFileSync(flags['ticket-file'],'utf8'));
|
|
96
157
|
if(!/^[0-9a-f]{24}$/.test(ticket.scope??''))throw new Error('connection ticket needs a source namespace; apply to a new private output path');
|
|
@@ -101,27 +162,8 @@ export async function connectWorkplace(flags) {
|
|
|
101
162
|
if(names.some(name=>MODULE_APPS.includes(name))&&!flags['modules-root'])throw new Error(`--modules-root names where ${names.filter(name=>MODULE_APPS.includes(name)).join(' and ')} are`);
|
|
102
163
|
if(Boolean(flags['webhook-url'])!==Boolean(flags['webhook-listen']))throw new Error('--webhook-url and --webhook-listen go together: where the Workplace sends the agents registration\'s events, and the loopback address the service listens on');
|
|
103
164
|
const moduleRoot=flags['modules-root']?resolve(flags['modules-root']):null;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if(flags['teams-token-file']){
|
|
107
|
-
if(!flags['teams-url']||!flags.team)throw new Error('--teams-token-file needs --teams-url and --team');
|
|
108
|
-
teams={server:new URL(flags['teams-url']).origin,team:flags.team};owner=readFileSync(flags['teams-token-file'],'utf8').trim();
|
|
109
|
-
}else{
|
|
110
|
-
// An agent session connects as its agent: its delegated context, never the person's who signed the box in (the
|
|
111
|
-
// Teams verbs' rule, agentContextName). A person's context named from an agent session is refused, as a grant is.
|
|
112
|
-
const {WorkspaceContexts}=await import('@volter/teams/context');
|
|
113
|
-
const {agentSessionEnvironment,agentSessionHint,agentContextName}=await import('@volter/supercode-teams/agent-session');
|
|
114
|
-
const contexts=new WorkspaceContexts(supercodeHome());
|
|
115
|
-
// Asked of the daemon whatever the environment says: a resumed session can lack its launch's SUPERCODE_AGENT_ID,
|
|
116
|
-
// and the daemon's retained binding is what names its agent (workspace.mjs does the same for every Teams verb).
|
|
117
|
-
const env=await agentSessionEnvironment(process.env,agentSessionHint(process.env));
|
|
118
|
-
const teamsContext=agentContextName(contexts,env,flags.context??null);
|
|
119
|
-
const {context}=fromContext({supercodeHome:supercodeHome(),name:teamsContext});
|
|
120
|
-
teams={server:context.origin,team:context.team_id};owner=contexts.credential(context.credential_ref).token;
|
|
121
|
-
}
|
|
122
|
-
for(const origin of [ticket.origin,ticket.issuer,teams.server]){
|
|
123
|
-
const url=new URL(origin);if(url.origin!==origin||url.username||url.password||(url.protocol!=='https:'&&!(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))throw new Error('connection endpoints must be exact HTTPS origins or loopback HTTP');
|
|
124
|
-
}
|
|
165
|
+
const {teams,owner}=await teamsAccess(flags);
|
|
166
|
+
exactOrigins([ticket.origin,ticket.issuer,teams.server]);
|
|
125
167
|
const id=createHash('sha256').update(`${ticket.scope}\0${ticket.issuer}\0${ticket.organization}\0${teams.server}\0${teams.team}\0${flags.source}`).digest('hex').slice(0,24);
|
|
126
168
|
const path=connectionPath(root,id),prior=existsSync(path)?JSON.parse(readFileSync(path,'utf8')):null;
|
|
127
169
|
if(!prior&&!(Date.parse(ticket.expiresAt)>Date.now()))throw new Error('connection ticket expired; apply the organization file again');
|
|
@@ -192,6 +234,184 @@ export async function connectWorkplace(flags) {
|
|
|
192
234
|
return {connection:id,root,modules:record.grants.map(g=>g.name),runner:pid,calls:record.calls.map(({side,method,path,status,ms,bytes})=>({side,method,path,status,ms,bytes}))};
|
|
193
235
|
}
|
|
194
236
|
|
|
237
|
+
/** The service settings an adopted config carries as it ran (main.mjs names every key it accepts); everything else in its
|
|
238
|
+
* config is rebuilt from the connection's own custody, and any key that is neither is named in the answer as dropped. */
|
|
239
|
+
const ADOPTED_SETTINGS={service:['closedLanes','leadProfiles','launch','teamsPerMinute'],cards:['floorTagPrefix'],machines:[]};
|
|
240
|
+
const REBUILT={teams:['server','team','installation','source','tokenEnv'],rh2:['baseUrl','organization','registration','issuer','tokenEnv','principal','cardsPrincipalId'],
|
|
241
|
+
source:['namespace'],envFile:null,stateFile:null,webhook:['url','listen'],cards:['registration','principal','tokenEnv','teamsTokenEnv','stateFile'],machines:['registration','principal','tokenEnv','teamsTokenEnv','stateFile']};
|
|
242
|
+
function dropped(config){
|
|
243
|
+
const out=[];
|
|
244
|
+
for(const [key,value] of Object.entries(config)){
|
|
245
|
+
if(ADOPTED_SETTINGS.service.includes(key))continue;
|
|
246
|
+
if(!Object.hasOwn(REBUILT,key)){out.push(key);continue;}
|
|
247
|
+
if(REBUILT[key]===null||value===null||typeof value!=='object')continue;
|
|
248
|
+
for(const inner of Object.keys(value))if(!REBUILT[key].includes(inner)&&!(ADOPTED_SETTINGS[key]??[]).includes(inner))out.push(`${key}.${inner}`);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
const envValues=path=>Object.fromEntries(readFileSync(path,'utf8').split('\n').filter(line=>line.includes('=')).map(line=>[line.slice(0,line.indexOf('=')),line.slice(line.indexOf('=')+1)]));
|
|
253
|
+
function alive(pid){if(!Number.isInteger(pid)||pid<=0)return false;try{process.kill(pid,0);return true;}catch(error){if(error.code==='ESRCH')return false;throw error;}}
|
|
254
|
+
/** The pids of processes whose arguments name `path` (a config only its own program is started with). */
|
|
255
|
+
function runningWith(path){
|
|
256
|
+
if(process.platform==='win32')return [];
|
|
257
|
+
return execFileSync('/bin/ps',['-axww','-o','pid=,command='],{encoding:'utf8',timeout:5000}).split('\n')
|
|
258
|
+
.map(line=>line.trim().match(/^(\d+)\s+(.*)$/)).filter(Boolean).filter(([,pid,command])=>Number(pid)!==process.pid&&` ${command} `.includes(` ${path} `)).map(([,pid])=>Number(pid));
|
|
259
|
+
}
|
|
260
|
+
/** Every `…Env` field of a config, by its dotted path: the custody names its program reads. */
|
|
261
|
+
function envFields(value,at=''){
|
|
262
|
+
if(value===null||typeof value!=='object'||Array.isArray(value))return [];
|
|
263
|
+
return Object.entries(value).flatMap(([key,inner])=>/Env$/.test(key)&&typeof inner==='string'?[[`${at}${key}`,inner]]:envFields(inner,`${at}${key}.`));
|
|
264
|
+
}
|
|
265
|
+
// A Teams installation's credential lives TOKEN_MS (sdk/volter-teams/server/apps.mjs, 90 days) from its exchange, which
|
|
266
|
+
// follows the installation's (re)creation within its ten-minute code: created_at plus that lifetime is its earliest end.
|
|
267
|
+
const TEAMS_CREDENTIAL_MS=90*86400000;
|
|
268
|
+
|
|
269
|
+
/** `connect --adopt <process.json>` and `--adopt-module NAME=CONFIG`: programs already running on their own installations
|
|
270
|
+
* (the Workplace service from its process record, a module from its config, each with hand-written custody) move under
|
|
271
|
+
* this home's installed-app runner as the connection of the installations they already hold. Nothing is registered,
|
|
272
|
+
* consented or exchanged: the adoption ticket (`rh2 apply-org … --connection-out … --adopt …`) names each registration's
|
|
273
|
+
* one active installation, and each token proves itself through its own installation's doors (RH2's renew, which keeps
|
|
274
|
+
* the expiry outside its last seven days and answers which installation and principal the token is; Teams' /me, which
|
|
275
|
+
* names the token's installation, that installation read with the token itself, and its renew door asked with the
|
|
276
|
+
* credential as its own replacement, which only an installation root is answered for and which changes nothing). No
|
|
277
|
+
* admin is needed: the Teams context only names the team. There is no scope: the same installations keep writing
|
|
278
|
+
* the keys they always have, so nothing is made twice and no history changes hands (ADR 0032, "Adoption"). Every program
|
|
279
|
+
* adopted must be stopped, and is checked again before its state is copied and before the runner starts: one writer at a
|
|
280
|
+
* time. State is copied into the connection until the connection first runs, the originals kept as they were. No
|
|
281
|
+
* credential is renewed by adopting: the old custody stays usable for a way back until the first renewal, in the last
|
|
282
|
+
* seven days. A Teams credential's expiry was never kept by hand, so it is its earliest possible end (TEAMS_CREDENTIAL_MS). */
|
|
283
|
+
export async function adoptWorkplaceService(flags) {
|
|
284
|
+
if(!flags.root||!flags['ticket-file'])throw new Error('connect --adopt needs --root and --ticket-file (the ticket of rh2 apply-org … --connection-out … --adopt)');
|
|
285
|
+
for(const flag of ['source','webhook-url','webhook-listen','alert-role'])if(flags[flag]!==undefined)throw new Error(`connect --adopt takes no --${flag}: the adopted programs' own configs name it`);
|
|
286
|
+
const root=resolve(flags.root), ticket=JSON.parse(readFileSync(flags['ticket-file'],'utf8'));
|
|
287
|
+
const processPath=flags.adopt?realpathSync(resolve(flags.adopt)):null;
|
|
288
|
+
const pairs=(list,flag)=>Object.fromEntries([].concat(list??[]).map(value=>{const at=value.indexOf('=');if(at<1)throw new Error(`--${flag} is NAME=PATH`);return [value.slice(0,at),resolve(value.slice(at+1))];}));
|
|
289
|
+
const moduleConfigs=pairs(flags['adopt-module'],'adopt-module'), programs=pairs(flags['adopt-program'],'adopt-program');
|
|
290
|
+
if(Object.keys(programs).some(name=>!moduleConfigs[name]))throw new Error('--adopt-program names a module that --adopt-module adopts');
|
|
291
|
+
if(!processPath&&!Object.keys(moduleConfigs).length)throw new Error('connect --adopt names the service\'s process record, --adopt-module NAME=CONFIG each module, or both');
|
|
292
|
+
if(ticket.version!==1||ticket.adopt!==true||ticket.scope!==undefined||!Array.isArray(ticket.grants)||!ticket.grants.length)throw new Error('connect --adopt takes an adoption ticket (rh2 apply-org … --connection-out … --adopt): it names existing installations and has no scope');
|
|
293
|
+
const names=ticket.grants.map(g=>g.name);
|
|
294
|
+
if(new Set(names).size!==names.length)throw new Error('an adoption ticket names each app once');
|
|
295
|
+
for(const g of ticket.grants)if(!/^app_[a-f0-9]{16}$/.test(g.clientId??'')||!/^prn_[a-z0-9]+$/.test(g.principalId??'')||!/^installation_[a-z0-9]+$/.test(g.installationId??'')||!Array.isArray(g.manifest?.actions))throw new Error(`${g.name}: the adoption ticket names its registration, principal, installation and consented actions`);
|
|
296
|
+
if(Object.keys(moduleConfigs).some(name=>SERVICE_APPS.includes(name)))throw new Error(`${SERVICE_APPS.join(', ')} run in the Workplace service: adopt it with --adopt <its process record>`);
|
|
297
|
+
// What each grant runs as now: a section of the service's config, or a module's own config.
|
|
298
|
+
const service=processPath?JSON.parse(readFileSync(processPath,'utf8')):null, config=service?JSON.parse(readFileSync(service.configPath,'utf8')):null;
|
|
299
|
+
const sections=config?{agents:{registration:config.rh2?.registration,principal:config.rh2?.principal,tokenEnv:config.rh2?.tokenEnv,teamsTokenEnv:config.teams?.tokenEnv,stateFile:config.stateFile},
|
|
300
|
+
...(config.cards?{cards:config.cards}:{}),...(config.machines?{machines:config.machines}:{})}:{};
|
|
301
|
+
const modules={};
|
|
302
|
+
for(const [name,path] of Object.entries(moduleConfigs)){
|
|
303
|
+
const own=JSON.parse(readFileSync(path,'utf8')), fields=envFields(own);
|
|
304
|
+
const unknown=fields.filter(([at])=>!['rh2.tokenEnv','teams.tokenEnv'].includes(at));
|
|
305
|
+
if(unknown.length)throw new Error(`${name}: ${unknown.map(([at])=>at).join(', ')} name custody adopt does not renew`);
|
|
306
|
+
if(!own.rh2?.tokenEnv||!own.envFile)throw new Error(`${name}: its config names no rh2.tokenEnv and envFile`);
|
|
307
|
+
const program=programs[name]??(flags['modules-root']?join(resolve(flags['modules-root']),name,'main.mjs'):null);
|
|
308
|
+
if(!program||!existsSync(program))throw new Error(`${name}: its program is --adopt-program ${name}=PATH or <--modules-root>/${name}/main.mjs`);
|
|
309
|
+
modules[name]={path,config:own,program:realpathSync(program),tokenEnv:own.rh2.tokenEnv,teamsTokenEnv:own.teams?.tokenEnv,stateFile:own.stateFile,principal:own.rh2.principalId};
|
|
310
|
+
}
|
|
311
|
+
const expected=[...Object.keys(sections),...Object.keys(modules)].sort().join(',');
|
|
312
|
+
if(expected!==[...names].sort().join(','))throw new Error(`the ticket names ${names.join(', ')}; the programs adopted run ${expected}: adopt exactly those`);
|
|
313
|
+
if(Object.keys(sections).length&&!sections.agents)throw new Error('the service is the agents registration\'s host');
|
|
314
|
+
for(const g of ticket.grants){
|
|
315
|
+
const section=sections[g.name];
|
|
316
|
+
if(section&&(section.registration!==g.clientId||(section.principal!==undefined&§ion.principal!==g.principalId)))throw new Error(`${g.name}: the ticket's registration ${g.clientId} (${g.principalId}) is not the one the service runs (${section.registration})`);
|
|
317
|
+
if(modules[g.name]?.principal!==undefined&&modules[g.name].principal!==g.principalId)throw new Error(`${g.name}: the ticket's principal ${g.principalId} is not the one its config names (${modules[g.name].principal})`);
|
|
318
|
+
}
|
|
319
|
+
if(config){
|
|
320
|
+
if(ticket.organization!==config.rh2.organization)throw new Error(`the ticket is for ${ticket.organization}; the service writes ${config.rh2.organization}`);
|
|
321
|
+
// The service's state is bound to the Workplace origin its tokens are issued by (main.mjs identity): the same one.
|
|
322
|
+
if(ticket.issuer!==new URL(config.rh2.issuer??config.rh2.baseUrl).origin)throw new Error(`the ticket's issuer ${ticket.issuer} is not the service's Workplace (${new URL(config.rh2.issuer??config.rh2.baseUrl).origin}); apply with --issuer naming it`);
|
|
323
|
+
if(config.source?.namespace)throw new Error('this service already writes under a connection namespace; adopt takes a service with the unqualified keys');
|
|
324
|
+
if(config.webhook?.secretEnv)throw new Error('adopt does not carry webhook.secretEnv; the adopted service keeps its webhook secret in its state');
|
|
325
|
+
}
|
|
326
|
+
for(const [name,m] of Object.entries(modules))if(m.config.rh2.organization!==ticket.organization)throw new Error(`${name}: its config writes ${m.config.rh2.organization}, not ${ticket.organization}`);
|
|
327
|
+
// One writer at a time: nothing adopted may still run, asked again before its state is copied and before the runner starts.
|
|
328
|
+
const stopped=()=>{
|
|
329
|
+
if(service&&alive(service.servicePid))throw new Error(`the service ${processPath} names still runs (pid ${service.servicePid}): stop it and whatever restarts it first (one writer at a time)`);
|
|
330
|
+
for(const path of new Set([...(service?[service.configPath,realpathSync(service.configPath)]:[]),...Object.values(modules).flatMap(m=>[m.path,realpathSync(m.path)])])){const pids=runningWith(path);if(pids.length)throw new Error(`pid ${pids.join(', ')} still runs with ${path}: stop it and whatever restarts it first (one writer at a time)`);}
|
|
331
|
+
};
|
|
332
|
+
stopped();
|
|
333
|
+
// The context names the team only: no call is made with its credential, so a member's context adopts as an admin's does.
|
|
334
|
+
const {teams}=await teamsAccess(flags);
|
|
335
|
+
exactOrigins([ticket.origin,ticket.issuer,teams.server]);
|
|
336
|
+
if(config&&(teams.server!==new URL(config.teams.server).origin||teams.team!==config.teams.team))throw new Error(`the Teams context is ${teams.server} ${teams.team}; the service reads ${config.teams.server} ${config.teams.team}`);
|
|
337
|
+
for(const [name,m] of Object.entries(modules))if(m.teamsTokenEnv&&(teams.server!==new URL(m.config.teams.server).origin||teams.team!==m.config.teams.team))throw new Error(`${name}: the Teams context is ${teams.server} ${teams.team}; it reads ${m.config.teams.server} ${m.config.teams.team}`);
|
|
338
|
+
const held={};
|
|
339
|
+
for(const [name,s] of Object.entries(sections))held[name]={tokenEnv:s.tokenEnv,teamsTokenEnv:s.teamsTokenEnv,stateFile:s.stateFile,env:envValues(config.envFile),envFile:config.envFile};
|
|
340
|
+
for(const [name,m] of Object.entries(modules))held[name]={tokenEnv:m.tokenEnv,teamsTokenEnv:m.teamsTokenEnv,stateFile:m.stateFile,env:envValues(m.config.envFile),envFile:m.config.envFile};
|
|
341
|
+
for(const [name,h] of Object.entries(held))for(const key of [h.tokenEnv,h.teamsTokenEnv].filter(Boolean))if(!h.env[key])throw new Error(`${name}: ${key} is not in ${h.envFile}`);
|
|
342
|
+
const source=config?.teams.source??null;
|
|
343
|
+
const id=createHash('sha256').update(`adopt\0${processPath??''}\0${Object.entries(moduleConfigs).sort().map(([n,p])=>`${n}=${p}`).join('\0')}\0${ticket.issuer}\0${ticket.organization}\0${teams.server}\0${teams.team}\0${source??''}`).digest('hex').slice(0,24);
|
|
344
|
+
const path=connectionPath(root,id),prior=existsSync(path)?JSON.parse(readFileSync(path,'utf8')):null;
|
|
345
|
+
if(!prior&&!(Date.parse(ticket.expiresAt)>Date.now()))throw new Error('adoption ticket expired; apply the organization file again');
|
|
346
|
+
if(prior?.disconnectedAt)throw new Error('this connection was disconnected; it is never adopted again');
|
|
347
|
+
if(prior&&((prior.adopted?.process??null)!==processPath||prior.selection.join(',')!==names.join(',')))throw new Error('the existing connection adopted other programs or another selection');
|
|
348
|
+
const lock=join(dirname(path),'runner.lock');
|
|
349
|
+
if(prior&&existsSync(lock)){
|
|
350
|
+
const running=JSON.parse(readFileSync(lock,'utf8'));
|
|
351
|
+
if(alive(running.pid)){if(flags['no-start'])throw new Error('stop this connection runner before preparing an owner handover');return {connection:id,root,adopted:processPath,modules:prior.grants.map(g=>g.name),runner:running.pid,calls:[]};}
|
|
352
|
+
}
|
|
353
|
+
const carry=(from,keys)=>Object.fromEntries(keys.filter(key=>from?.[key]!==undefined).map(key=>[key,from[key]]));
|
|
354
|
+
const record=prior??{version:1,id,adopted:{process:processPath,modules:moduleConfigs,source:service?.source??null,at:new Date().toISOString()},origin:ticket.origin,issuer:ticket.issuer,organization:ticket.organization,teams,
|
|
355
|
+
source,selection:names,moduleRoot:null,...(config?.webhook?{webhook:{url:config.webhook.url,listen:config.webhook.listen}}:{}),alertRole:'admin',
|
|
356
|
+
...(config?{settings:{service:carry(config,ADOPTED_SETTINGS.service),cards:carry(config.cards,ADOPTED_SETTINGS.cards)}}:{}),grants:[]};
|
|
357
|
+
record.root=root;
|
|
358
|
+
record.preparing=true;record.paused=true;
|
|
359
|
+
privateJson(path,record);
|
|
360
|
+
for(const selected of ticket.grants){
|
|
361
|
+
const h=held[selected.name];
|
|
362
|
+
let grant=record.grants.find(g=>g.name===selected.name);
|
|
363
|
+
if(!grant){grant={name:selected.name,clientId:selected.clientId,adopted:true,manifest:selected.manifest,teamsManifest:selected.teamsManifest??null,
|
|
364
|
+
...(modules[selected.name]?{program:modules[selected.name].program,config:modules[selected.name].config}:{})};record.grants.push(grant);privateJson(path,record);}
|
|
365
|
+
if(!grant.rh2){
|
|
366
|
+
// Only this installation's current token is answered here; outside the last seven days the expiry stays as it is.
|
|
367
|
+
const renewed=(await connectionCall(record,'rh2','POST',`/api/v3/automation/installations/${encodeURIComponent(selected.installationId)}/renew`,{},h.env[h.tokenEnv])).data;
|
|
368
|
+
if(renewed.installationId!==selected.installationId||renewed.principalId!==selected.principalId||renewed.organizationId!==ticket.organization)throw new Error(`${selected.name}: ${h.tokenEnv} answers as ${renewed.principalId} on ${renewed.installationId}, not the ticket's installation`);
|
|
369
|
+
grant.rh2={accessToken:renewed.accessToken,expiresAt:renewed.expiresAt,installationId:renewed.installationId,organizationId:renewed.organizationId,principalId:renewed.principalId};privateJson(path,record);
|
|
370
|
+
}
|
|
371
|
+
if(h.teamsTokenEnv&&!grant.teams){
|
|
372
|
+
// The token names its own installation (/me) and reads it in the team (a credential of another team is refused, and
|
|
373
|
+
// one whose installation or app is no longer active authenticates as no one): the installation's own doors, no admin's.
|
|
374
|
+
const token=h.env[h.teamsTokenEnv], me=await connectionCall(record,'teams','GET','/api/v1/me',undefined,token);
|
|
375
|
+
if(!me.installation_id)throw new Error(`${selected.name}: ${h.teamsTokenEnv} is no installation's credential`);
|
|
376
|
+
const installation=(await connectionCall(record,'teams','GET',`/api/v1/teams/${encodeURIComponent(teams.team)}/app-installations/${encodeURIComponent(me.installation_id)}`,undefined,token)).data;
|
|
377
|
+
if(installation.team_id!==teams.team||installation.principal_id!==me.principal?.id||installation.state!=='active')throw new Error(`${selected.name}: ${h.teamsTokenEnv} is no active installation of team ${teams.team}`);
|
|
378
|
+
if(selected.name==='agents'&&installation.id!==config.teams.installation)throw new Error(`agents: ${h.teamsTokenEnv} is installation ${installation.id}, not the service's ${config.teams.installation}`);
|
|
379
|
+
// Renewal is answered only for an installation's root credential (issuer-connection.mjs root()); asked with itself as
|
|
380
|
+
// its replacement, an unrenewed root with no predecessor is refused as "this root has no renewal predecessor" and
|
|
381
|
+
// nothing changes. Any other answer refuses the adoption: a non-root is forbidden, and a root already renewed (its
|
|
382
|
+
// replacement elsewhere, its own expiry cut to five minutes) is "already has a replacement".
|
|
383
|
+
let probe=null;
|
|
384
|
+
try{await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/app-installations/${encodeURIComponent(installation.id)}/renew`,{replacement_token:token},token);probe='answered';}
|
|
385
|
+
catch(error){probe=error;}
|
|
386
|
+
if(!(probe instanceof Error&&probe.status===409&&/no renewal predecessor/.test(probe.detail??'')))throw new Error(`${selected.name}: ${h.teamsTokenEnv} is not its installation's unrenewed root credential (${probe instanceof Error?`${probe.code??probe.status}${probe.detail?`: ${probe.detail}`:''}`:probe}), so it could not be renewed from here; reconsent the installation`);
|
|
387
|
+
grant.registration={id:installation.app_id};
|
|
388
|
+
grant.teams={installation,credential:{token,kind:'service',expires_at:installation.created_at+TEAMS_CREDENTIAL_MS,expiresAtDerived:true}};privateJson(path,record);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// State: what each adopted program wrote up to its stop, with the ledger of its writes whose answers were lost. Copied
|
|
392
|
+
// again on every run until the connection first runs (a run that stopped before its runner started copies afresh); once
|
|
393
|
+
// it has run, its own state stands, and adopting the old programs' later state means setting the connection's folder aside.
|
|
394
|
+
stopped();
|
|
395
|
+
const folder=dirname(path);
|
|
396
|
+
for(const grant of record.grants){
|
|
397
|
+
const from=held[grant.name]?.stateFile;
|
|
398
|
+
if(!from||record.connectedAt)continue;
|
|
399
|
+
const into=SERVICE_APPS.includes(grant.name)?join(folder,'service',`${grant.name}.state.json`):join(folder,grant.name,'state.json');
|
|
400
|
+
mkdirSync(dirname(into),{recursive:true,mode:0o700});
|
|
401
|
+
for(const [a,b] of [[`${from}.unknown-writes.json`,`${into}.unknown-writes.json`],[from,into],[`${from}.cursor.json`,`${into}.cursor.json`]]){
|
|
402
|
+
if(existsSync(a)){copyFileSync(a,`${b}.${process.pid}.tmp`);chmodSync(`${b}.${process.pid}.tmp`,0o600);renameSync(`${b}.${process.pid}.tmp`,b);}else if(existsSync(b))unlinkSync(b);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for(const grant of record.grants)if(SERVICE_APPS.includes(grant.name))generateServiceConfig(root,record,grant);else generateModuleConfig(root,record,grant);
|
|
406
|
+
stopped();
|
|
407
|
+
record.connectedAt??=new Date().toISOString();
|
|
408
|
+
record.preparing=false;record.paused=Boolean(flags['no-start']);privateJson(path,record);
|
|
409
|
+
const pid=record.paused?null:startConnectionRunner(root,id);
|
|
410
|
+
return {connection:id,root,adopted:{process:processPath,modules:moduleConfigs},modules:record.grants.map(g=>g.name),runner:pid,dropped:config?dropped(config):[],
|
|
411
|
+
expiry:record.grants.map(g=>({name:g.name,rh2:g.rh2.expiresAt,teams:g.teams?new Date(g.teams.credential.expires_at).toISOString():null})),
|
|
412
|
+
calls:(record.calls??[]).map(({side,method,path,status,ms,bytes})=>({side,method,path,status,ms,bytes}))};
|
|
413
|
+
}
|
|
414
|
+
|
|
195
415
|
/** Undo a connection with its own credentials, no admin needed (runhuman-2 ADR 0055 decision 3): its runner stops, every
|
|
196
416
|
* installation it holds leaves both sides by itself (each Workplace installation through its own leave door, each
|
|
197
417
|
* Teams installation and the issuer's by deleting itself), and the home's registry entries go. The registrations stay, as
|
|
@@ -202,12 +422,12 @@ export async function disconnectWorkplace(flags) {
|
|
|
202
422
|
const root=resolve(flags.root), path=connectionPath(root,flags.connection);
|
|
203
423
|
if(!existsSync(path))throw new Error(`no connection ${flags.connection} in ${root}`);
|
|
204
424
|
const record=JSON.parse(readFileSync(path,'utf8'));record.root=root;
|
|
425
|
+
// An adopted connection's installations predate it: leaving uninstalls what the adopted programs ran on before.
|
|
426
|
+
if(record.adopted&&!flags['uninstall-adopted'])throw new Error(`connection ${record.id} adopted installations made before it (${record.grants.map(g=>`${g.name} ${g.rh2?.installationId}`).join(', ')}); disconnect uninstalls them on both sides. Stopping its runner (kill -TERM the pid in its runner.lock) hands nothing back; pass --uninstall-adopted to uninstall them`);
|
|
205
427
|
record.paused=true;privateJson(path,record);
|
|
206
428
|
const lock=join(dirname(path),'runner.lock');
|
|
207
429
|
if(existsSync(lock)){
|
|
208
|
-
|
|
209
|
-
try{process.kill(running.pid,'SIGTERM');}catch(error){if(error.code!=='ESRCH')throw error;}
|
|
210
|
-
for(let waited=0;waited<20000;waited+=250){try{process.kill(running.pid,0);}catch{break;}await new Promise(done=>setTimeout(done,250));}
|
|
430
|
+
await stopConnectionRunner(lock,JSON.parse(readFileSync(lock,'utf8')));
|
|
211
431
|
}
|
|
212
432
|
const team=`/api/v1/teams/${encodeURIComponent(record.teams.team)}`, left=[];
|
|
213
433
|
const leave=async(name,side,method,door,token)=>{
|
package/index.d.ts
CHANGED
|
@@ -151,7 +151,9 @@ export interface RuntimeState {
|
|
|
151
151
|
/** The last worker event per lease (§4.3's inactivity limit). */
|
|
152
152
|
activity: Record<string, Instant>;
|
|
153
153
|
adapters: Record<Platform, { state: 'up' | 'down' | 'paused'; breaker: { failures: number; until?: Instant } }>;
|
|
154
|
-
pending_requests: Record<string, { profile: ProfileName; keyString: string; runtime_id: string | null; asked_at: Instant; options: RequestOption[] }>;
|
|
154
|
+
pending_requests: Record<string, { profile: ProfileName; keyString: string; runtime_id: string | null; asked_at: Instant; options: RequestOption[]; subject?: string | null }>;
|
|
155
|
+
/** The worker's settlements of requests not yet queued (`settled` before its `request`), as `runtime_id\nrequest id`: the most recent 256. */
|
|
156
|
+
settled_requests?: string[];
|
|
155
157
|
clock: Instant | null;
|
|
156
158
|
hostedJobs?: boolean;
|
|
157
159
|
hostedFires?: Record<string, boolean>;
|
|
@@ -169,6 +171,8 @@ export type WorkerEvent =
|
|
|
169
171
|
| { kind: 'steer_refused'; text: string; webhook_input_id?: string | null }
|
|
170
172
|
| { kind: 'final'; text: string | null; outcome?: 'completed' | 'error'; message?: string | null }
|
|
171
173
|
| { kind: 'request'; id: string; subject: string; options: RequestOption[] }
|
|
174
|
+
/** The worker settled request `id` itself (Claude Code's `control_cancel_request`, Codex's `serverRequest/resolved`). */
|
|
175
|
+
| { kind: 'settled'; id: string }
|
|
172
176
|
| { kind: 'ended'; reason: string }
|
|
173
177
|
| { kind: 'error'; message: string };
|
|
174
178
|
|
package/installed-apps.mjs
CHANGED
|
@@ -10,6 +10,10 @@ export const APPS_DIR = 'apps';
|
|
|
10
10
|
/** The apps this orchestrator can host, by name: what each installs and which adapter it attaches. */
|
|
11
11
|
export const INSTALLABLE_APPS = Object.freeze({
|
|
12
12
|
'orchestration-rh2': { side: 'rh2', module: true, describe: 'orchestration-rh2 installed module' },
|
|
13
|
+
// The Workplace service's registrations (connection.mjs SERVICE_APPS): one program the connection runner hosts.
|
|
14
|
+
agents: { side: 'rh2', service: true, describe: 'Agents → Room agents, in the connection\'s Workplace service' },
|
|
15
|
+
cards: { side: 'rh2', service: true, describe: 'Cards → Tasks, in the connection\'s Workplace service' },
|
|
16
|
+
machines: { side: 'rh2', service: true, describe: 'Machines → RH2 resources, in the connection\'s Workplace service' },
|
|
13
17
|
'room-chat': { side: 'rh2', adapter: 'rh2', describe: 'Room chat → orchestrator: a person\'s line in an RH2 Room reaches the agent seated there' },
|
|
14
18
|
// An agent the RH2 organization declares is one participant there: its chat and its call are its own agent principal,
|
|
15
19
|
// never an automation beside it. One record per agent per Workplace, bound to its Teams id (`agentId`) and that
|
|
@@ -26,7 +30,7 @@ export function installedApps(root) {
|
|
|
26
30
|
return readdirSync(dir).filter((file) => file.endsWith('.json')).map((file) => {
|
|
27
31
|
const record = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
28
32
|
return { ...record, name: file.slice(0, -5) };
|
|
29
|
-
}).filter((record) => INSTALLABLE_APPS[record.app ?? record.name]);
|
|
33
|
+
}).filter((record) => INSTALLABLE_APPS[record.app ?? record.name] || typeof record.connection === 'string');
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/** An RH2 agent's own identity: the token the organization's admin issued that agent principal (RH2's agent-token door),
|
|
@@ -104,7 +108,7 @@ export function rh2AgentEnv(record, root = null) {
|
|
|
104
108
|
/** Exchange an RH2 install code for the installation's token and keep the record. */
|
|
105
109
|
export async function installApp({ root, name, origin, clientId, clientSecret, code, profile = 'default', fetchImpl = globalThis.fetch }) {
|
|
106
110
|
const app = INSTALLABLE_APPS[name];
|
|
107
|
-
if(app?.module)throw new Error('module apps are consented and installed together by orchestrator connect');
|
|
111
|
+
if(app?.module||app?.service)throw new Error('module and service apps are consented and installed together by orchestrator connect');
|
|
108
112
|
if (!app) throw new Error(`no installable app named ${name} (${Object.keys(INSTALLABLE_APPS).join(', ')})`);
|
|
109
113
|
if (!rh2Origin(origin)) throw new Error('the RH2 origin is an https URL');
|
|
110
114
|
const base = new URL(origin).origin;
|
|
@@ -128,12 +132,14 @@ export function removeApp(root, name) {
|
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
/** What `apps list` shows: never the token. */
|
|
131
|
-
export const appView = (record) => ({ name: record.name, side: record.side, origin: record.origin, organization: record.organizationId, principal: record.principalId, agent: record.agentId, installation: record.installationId, profile: record.profile, expires_at: record.expiresAt });
|
|
135
|
+
export const appView = (record) => ({ name: record.name, side: record.side ?? INSTALLABLE_APPS[record.app]?.side, origin: record.origin, organization: record.organizationId, principal: record.principalId, agent: record.agentId, installation: record.installationId, profile: record.profile, expires_at: record.expiresAt });
|
|
132
136
|
|
|
133
|
-
/** The same home's app registry drives
|
|
137
|
+
/** The same home's app registry drives every connection's hosting after a daemon restart: each connection with an entry
|
|
138
|
+
* (its modules, its Workplace service, the programs it adopted) gets its runner back, so its renewals go on after a reboot.
|
|
139
|
+
* A source activation's restart does the same: a live runner of another tree yields to the serving tree's (connection-runner.mjs). */
|
|
134
140
|
export async function startInstalledModules(root) {
|
|
135
141
|
if(!root)return [];
|
|
136
142
|
const {startConnectionRunner}=await import('./connection.mjs');
|
|
137
|
-
const ids=[...new Set(installedApps(root).filter(app=>
|
|
143
|
+
const ids=[...new Set(installedApps(root).filter(app=>typeof app.connection==='string').map(app=>app.connection))];
|
|
138
144
|
return ids.map(id=>({connection:id,pid:startConnectionRunner(root,id)})).filter(row=>row.pid!==null);
|
|
139
145
|
}
|
package/loop.mjs
CHANGED
|
@@ -1363,8 +1363,14 @@ export function normalizeWorkerEvent(ev) {
|
|
|
1363
1363
|
case 'native':
|
|
1364
1364
|
if (ev.kind === 'supercode/acp_request_completed') return { kind: 'final', text: null };
|
|
1365
1365
|
if (ev.kind === 'control_request') return claudePermissionRequest(ev.payload);
|
|
1366
|
+
// Claude Code withdraws a `can_use_tool` it settled without this host (a PermissionRequest hook or permission
|
|
1367
|
+
// rule decided it, an answer arrived another way, or its turn was stopped): the request no longer waits on an answer
|
|
1368
|
+
if (ev.kind === 'control_cancel_request') return typeof ev.payload?.request_id === 'string' ? { kind: 'settled', id: JSON.stringify(ev.payload.request_id) } : null;
|
|
1366
1369
|
return null;
|
|
1367
1370
|
case 'request': return { kind: 'request', id: JSON.stringify(ev.requestId), command: requestCommand(ev.payload), ...requestTool(ev), subject: ev.requestKind ?? ev.kind, options: ev.options ?? [] };
|
|
1371
|
+
// Codex's `serverRequest/resolved` (`params.requestId`, the request's own id): the request was settled, by this host's
|
|
1372
|
+
// answer or without it
|
|
1373
|
+
case 'request_resolved': return ev.requestId == null ? null : { kind: 'settled', id: JSON.stringify(ev.requestId) };
|
|
1368
1374
|
case 'error': return ev.terminal ? { kind: 'error', message: ev.message } : null;
|
|
1369
1375
|
case 'closed': return { kind: 'ended', reason: ev.message ?? ev.kind };
|
|
1370
1376
|
default: return null;
|
|
@@ -202,6 +202,10 @@
|
|
|
202
202
|
"type": "string"
|
|
203
203
|
},
|
|
204
204
|
"description": "The full 40-character pushed head SHA of each PR, in the same order as pr."
|
|
205
|
+
},
|
|
206
|
+
"plan": {
|
|
207
|
+
"type": "boolean",
|
|
208
|
+
"description": "Ask the plan review of a big arc's proposed ADR (its PR in pr, its pushed head in pr_head) before any code. Without it, one PR named with its head is that PR's review."
|
|
205
209
|
}
|
|
206
210
|
},
|
|
207
211
|
"required": [
|
|
@@ -400,6 +404,10 @@
|
|
|
400
404
|
"type": "string",
|
|
401
405
|
"description": "Short task title (required)."
|
|
402
406
|
},
|
|
407
|
+
"readable_id": {
|
|
408
|
+
"type": "string",
|
|
409
|
+
"description": "The card's readable id (required): a few lowercase words joined by hyphens naming its outcome (e.g. 'voice-calls'), at most 32 characters, unique on the board. It names the card's branch, worktree folder, sessions and agents."
|
|
410
|
+
},
|
|
403
411
|
"assignee": {
|
|
404
412
|
"type": "string",
|
|
405
413
|
"description": "Profile name that should execute this task (e.g. 'researcher-a', 'reviewer', 'writer'). Required \u2014 tasks without an assignee are never dispatched."
|
|
@@ -438,7 +446,7 @@
|
|
|
438
446
|
},
|
|
439
447
|
"project": {
|
|
440
448
|
"type": "string",
|
|
441
|
-
"description": "Optional project id or slug to link the task to. When set, the task becomes a git worktree under the project's primary repo with a deterministic branch (project slug +
|
|
449
|
+
"description": "Optional project id or slug to link the task to. When set, the task becomes a git worktree under the project's primary repo with a deterministic branch (project slug + readable id), instead of a random branch."
|
|
442
450
|
},
|
|
443
451
|
"triage": {
|
|
444
452
|
"type": "boolean",
|
|
@@ -490,6 +498,7 @@
|
|
|
490
498
|
},
|
|
491
499
|
"required": [
|
|
492
500
|
"title",
|
|
501
|
+
"readable_id",
|
|
493
502
|
"assignee"
|
|
494
503
|
]
|
|
495
504
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.227",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@volter/supercode-teams": "^0.3.
|
|
40
|
-
"@volter/supercode-terminal": "^0.3.
|
|
41
|
-
"@volter/supercode-harness-sdk": "^0.3.
|
|
42
|
-
"@volter/teams": "0.2.
|
|
43
|
-
"@volter/ztrack": "
|
|
39
|
+
"@volter/supercode-teams": "^0.3.344",
|
|
40
|
+
"@volter/supercode-terminal": "^0.3.10",
|
|
41
|
+
"@volter/supercode-harness-sdk": "^0.3.104",
|
|
42
|
+
"@volter/teams": "0.2.99",
|
|
43
|
+
"@volter/ztrack": "2.4.23",
|
|
44
44
|
"yaml": "^2.9.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/reducer.mjs
CHANGED
|
@@ -29,6 +29,11 @@ export function ensureRuntime(state) {
|
|
|
29
29
|
return r;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// The settlements of requests not yet queued that the reducer remembers (`settled` in handleWorker): the most recent
|
|
33
|
+
// this many. A request id is its runtime's (Codex numbers them per connection), so each is kept with its runtime.
|
|
34
|
+
const SETTLED_KEPT = 256;
|
|
35
|
+
const settledKey = (runtimeId, requestId) => `${runtimeId ?? ''}\n${requestId}`;
|
|
36
|
+
|
|
32
37
|
function leaseId(profile, keyString) {
|
|
33
38
|
return `${profile} ${keyString}`;
|
|
34
39
|
}
|
|
@@ -564,7 +569,31 @@ function handleWorker(state, ev, effects) {
|
|
|
564
569
|
effects.push({ type: 'save' });
|
|
565
570
|
break;
|
|
566
571
|
}
|
|
572
|
+
case 'settled': {
|
|
573
|
+
// The worker settled one of its requests itself (Claude Code's `control_cancel_request`, Codex's
|
|
574
|
+
// `serverRequest/resolved`). One still asked on the surface retires, so no later number answers it and its timeout
|
|
575
|
+
// sends nothing. One not asked yet is remembered: Hermes's approval rule runs before a request is queued here
|
|
576
|
+
// (loop.mjs onWorkerEvent), so its settlement can arrive first.
|
|
577
|
+
const id = String(w.id);
|
|
578
|
+
const p = rt.pending_requests[id];
|
|
579
|
+
if (p && p.profile === profile.name && p.keyString === ev.keyString && (!p.runtime_id || !ev.runtime_id || p.runtime_id === ev.runtime_id)) {
|
|
580
|
+
delete rt.pending_requests[id];
|
|
581
|
+
if (binding) reply(state, profile, binding.key, `Permission no longer needed: ${p.subject ?? 'the worker settled its request'}. The worker settled it itself; no reply is needed.`, now, effects);
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
const settled = (rt.settled_requests ??= []);
|
|
585
|
+
settled.push(settledKey(ev.runtime_id, id));
|
|
586
|
+
if (settled.length > SETTLED_KEPT) settled.splice(0, settled.length - SETTLED_KEPT);
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
567
589
|
case 'request': {
|
|
590
|
+
// a request the worker settled before it was queued here is asked of nobody and answered by nobody
|
|
591
|
+
const settled = rt.settled_requests?.indexOf(settledKey(ev.runtime_id, String(w.id))) ?? -1;
|
|
592
|
+
if (settled >= 0) {
|
|
593
|
+
rt.settled_requests.splice(settled, 1);
|
|
594
|
+
effects.push({ type: 'log', level: 'info', message: `session ${ev.keyString}: the worker settled ${String(w.subject ?? 'a request').slice(0, 200)} itself` });
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
568
597
|
if (!binding) break;
|
|
569
598
|
// Hermes's approval rule allowed it (a safe command, any other tool): it runs without asking, as under Hermes
|
|
570
599
|
if (w.hermes?.verdict === 'allow') {
|
|
@@ -580,7 +609,7 @@ function handleWorker(state, ev, effects) {
|
|
|
580
609
|
effects.push({ type: 'log', level: 'info', message: `launched session ${ev.keyString}: ${approve ? 'approved' : 'denied'} ${String(w.subject ?? 'a tool call').slice(0, 200)}` });
|
|
581
610
|
break;
|
|
582
611
|
}
|
|
583
|
-
rt.pending_requests[String(w.id)] = { profile: profile.name, keyString: ev.keyString, runtime_id: rt.leases[lid]?.runtime_id ?? null, asked_at: now, options: w.options ?? [] };
|
|
612
|
+
rt.pending_requests[String(w.id)] = { profile: profile.name, keyString: ev.keyString, runtime_id: rt.leases[lid]?.runtime_id ?? null, asked_at: now, options: w.options ?? [], subject: w.subject ?? null };
|
|
584
613
|
const options = (w.options ?? []).map((o, i) => `${i + 1}. ${o.name}`).join('\n');
|
|
585
614
|
reply(state, profile, binding.key, `Permission needed: ${w.subject ?? 'the worker wants to act'}\nReply with a number:\n${options}`, now, effects);
|
|
586
615
|
break;
|
|
@@ -723,7 +752,7 @@ function handleStopping(state, ev, effects) {
|
|
|
723
752
|
|
|
724
753
|
function handleRestart(state, ev, effects) {
|
|
725
754
|
const rt = ensureRuntime(state);
|
|
726
|
-
rt.leases = {}; rt.turns = {}; rt.queues = {}; rt.pending_requests = {};
|
|
755
|
+
rt.leases = {}; rt.turns = {}; rt.queues = {}; rt.pending_requests = {}; rt.settled_requests = [];
|
|
727
756
|
let live = 0;
|
|
728
757
|
const replays = [];
|
|
729
758
|
for (const profile of Object.values(state.profiles)) {
|