@volter/supercode-orchestrator 0.5.208 → 0.5.210
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/bin/orchestrator.mjs +1 -1
- package/board/event-stream.mjs +38 -4
- package/board/published.mjs +5 -1
- package/board/ztrack.mjs +8 -4
- package/connection.mjs +16 -7
- package/package.json +3 -3
- package/source-lock.mjs +7 -2
package/bin/orchestrator.mjs
CHANGED
|
@@ -34,7 +34,7 @@ if (process.argv[2] === 'workflow') {
|
|
|
34
34
|
catch(error) {noteCommandError(error);process.stderr.write(`supercode workflow: ${error.message??error}\n${error?.errcode!=null&&error.stack?`${error.stack}\n`:''}`);code=1;}
|
|
35
35
|
// the board documents' git work this command queued (a ztrack board's commit and push) is finished before it exits
|
|
36
36
|
try{const{settleDocuments}=await import('../board/git-document.mjs');await settleDocuments();}catch{/* nothing queued */}
|
|
37
|
-
await Promise.all([process.stdout,process.stderr].map(stream=>new Promise(done=>stream.write('',done))));
|
|
37
|
+
await Promise.all([process.stdout,process.stderr].map(stream=>new Promise(done=>stream.destroyed?done():stream.write('',done))));
|
|
38
38
|
process.exit(code);
|
|
39
39
|
} else if (process.argv[2] === 'connect') {
|
|
40
40
|
const {parseArgs}=await import('node:util');
|
package/board/event-stream.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { watch, existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
4
4
|
import { dirname, basename, join, resolve } from 'node:path';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
import { once } from 'node:events';
|
|
6
7
|
import { listBoards, openBoard, backingPaths } from './store.mjs';
|
|
7
8
|
|
|
8
9
|
const identity = root => createHash('sha256').update(realpathSync(root)).digest('hex');
|
|
@@ -117,14 +118,47 @@ export async function* followBoardPublications({root,after,signal}) {
|
|
|
117
118
|
|
|
118
119
|
export async function streamBoardPublications(options) {
|
|
119
120
|
if(options.once&&options.after)throw new Error('--once requires a fresh snapshot; omit --after');
|
|
121
|
+
const parent=process.ppid, reader=Number(process.env.SUPERCODE_WORKFLOW_READER_PID??parent);
|
|
122
|
+
if(!Number.isSafeInteger(reader)||reader<1||reader>2147483647)throw new Error('Invalid SUPERCODE_WORKFLOW_READER_PID');
|
|
120
123
|
const abort=new AbortController();const stop=()=>abort.abort();
|
|
124
|
+
let outputError=null;
|
|
125
|
+
const failed=error=>{outputError=error;stop();};
|
|
121
126
|
process.once('SIGINT',stop);process.once('SIGTERM',stop);
|
|
127
|
+
process.stdout.on('error',failed);process.stdout.once('close',stop);
|
|
128
|
+
// Quiet streams never meet EPIPE. The connector names the real reader across native/npm/home hand-offs; direct
|
|
129
|
+
// callers use their parent. Unix also detects reparenting (including death before this command finished starting).
|
|
130
|
+
// On Windows ppid does not change, but probing the named reader still detects its exit.
|
|
131
|
+
const checkReader=()=>{
|
|
132
|
+
if(process.platform!=='win32'&&(parent===1||process.ppid!==parent)){stop();return;}
|
|
133
|
+
try{process.kill(reader,0);}catch(error){if(error.code==='ESRCH')stop();}
|
|
134
|
+
};
|
|
135
|
+
const lifetime=setInterval(checkReader,1000);lifetime.unref();checkReader();
|
|
136
|
+
const write=async frame=>{
|
|
137
|
+
if(abort.signal.aborted)return;
|
|
138
|
+
try{
|
|
139
|
+
if(!process.stdout.write(`${JSON.stringify(frame)}\n`))await once(process.stdout,'drain',{signal:abort.signal});
|
|
140
|
+
}catch(error){
|
|
141
|
+
if(error.name==='AbortError'&&abort.signal.aborted)return;
|
|
142
|
+
if(error.code==='EPIPE'){stop();return;}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
let code=0;
|
|
122
147
|
try {
|
|
123
148
|
for await(const frame of followBoardPublications({...options,signal:abort.signal})){
|
|
124
|
-
if(
|
|
149
|
+
if(abort.signal.aborted)break;
|
|
150
|
+
await write(frame);
|
|
151
|
+
if(abort.signal.aborted)break;
|
|
125
152
|
if(options.once&&frame.type==='snapshot_end')break;
|
|
126
153
|
}
|
|
127
|
-
}catch(error){if(error.code==='resync_required'){
|
|
128
|
-
finally{
|
|
129
|
-
|
|
154
|
+
}catch(error){if(error.code==='resync_required'){await write({type:'resync_required',reason:error.message});code=1;}else throw error;}
|
|
155
|
+
finally{
|
|
156
|
+
clearInterval(lifetime);process.removeListener('SIGINT',stop);process.removeListener('SIGTERM',stop);
|
|
157
|
+
// No final flush can drain into a dead or stalled reader. Returning the iterator above closes its board and
|
|
158
|
+
// watchers first; discard pending bytes on cancellation so the public entry can finish too.
|
|
159
|
+
if(abort.signal.aborted)process.stdout.destroy();
|
|
160
|
+
process.stdout.removeListener('error',failed);process.stdout.removeListener('close',stop);
|
|
161
|
+
}
|
|
162
|
+
if(outputError&&outputError.code!=='EPIPE')throw outputError;
|
|
163
|
+
return code;
|
|
130
164
|
}
|
package/board/published.mjs
CHANGED
|
@@ -125,7 +125,11 @@ function agentFacts(db,taskId){
|
|
|
125
125
|
const agents=new Map(parse(checkpoint?.facts_json,[]).map(agent=>[JSON.stringify([agent.org,agent.assignment,agent.id]),agent]));
|
|
126
126
|
let scope=db.prepare('SELECT from_org,org,changed_at FROM supercode_card_agent_scopes WHERE task_id=?').get(taskId)??null;
|
|
127
127
|
let receipt=checkpoint?.receipt_sequence??0,declared=checkpoint?.declared_event_id??0;
|
|
128
|
-
|
|
128
|
+
// Match idx_events_task_kind's order. ORDER BY id alone lets SQLite choose idx_events_task_id and fetch every
|
|
129
|
+
// event's row just to reject its kind (including cards with no agent events). Those synchronous page reads held
|
|
130
|
+
// the owner's loop inside synchronizeAgents and expired unrelated manager lookups. Sort only the matching events.
|
|
131
|
+
const events=db.prepare(`SELECT id,kind,payload FROM task_events WHERE task_id=? AND kind IN ${AGENT_KINDS} ORDER BY kind,id`).all(taskId);
|
|
132
|
+
for(const event of events.sort((a,b)=>a.id-b.id)){
|
|
129
133
|
if(event.kind==='agent_declared')declared=Math.max(declared,event.id);
|
|
130
134
|
const value=parse(event.payload,{});
|
|
131
135
|
if(Number.isSafeInteger(value.receipt))receipt=Math.max(receipt,value.receipt);
|
package/board/ztrack.mjs
CHANGED
|
@@ -830,7 +830,6 @@ class ZtrackBoard {
|
|
|
830
830
|
if (this.get('rebuild_from_documents') || !this.one('SELECT 1 FROM main.tasks LIMIT 1')) return;
|
|
831
831
|
let fields = [];
|
|
832
832
|
try { fields = workflowFor(this.root).params.document_control_fields ?? []; } catch { /* no home policy */ }
|
|
833
|
-
if (!fields.length) return;
|
|
834
833
|
const value = (task, state, field) => field === 'status' ? state?.stage ?? task?.status ?? 'todo'
|
|
835
834
|
: field === 'blocked_by' ? json(state?.blocked_by, []).map((b) => [b.kind, b.id]).sort()
|
|
836
835
|
: field === 'paused' ? json(state?.paused, null)
|
|
@@ -862,15 +861,20 @@ class ZtrackBoard {
|
|
|
862
861
|
};
|
|
863
862
|
for (const [id, rows] of plan.rows) {
|
|
864
863
|
const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id), state = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', id);
|
|
864
|
+
// Stale status recovery is independent of a home's document-control policy. In particular, render() re-reads a
|
|
865
|
+
// refused span after a verb completes its card: its unchanged running line must not undo that completion.
|
|
866
|
+
// Reopening an ended card requires its workflow verb, even in a home that otherwise permits document edits.
|
|
867
|
+
const controlled = new Set(fields);
|
|
868
|
+
if (task && ENDED.has(value(task, state, 'status'))) controlled.add('status');
|
|
865
869
|
// A new display author is not an authenticated creator.
|
|
866
|
-
const differ =
|
|
870
|
+
const differ = [...new Set(['status', ...controlled])].filter((field) => (task || field !== 'created_by') && !same(value(rows.task, rows.state, field), value(task, state, field)));
|
|
867
871
|
const prior = task && differ.length ? rowsBefore(rows.state.subtask_of ?? id).get(id) : null;
|
|
868
872
|
const stale = prior ? differ.filter((field) => same(value(prior.task, prior.state, field), value(rows.task, rows.state, field))) : [];
|
|
869
873
|
for (const field of stale) adopt(rows, task, state, field);
|
|
870
|
-
const forbidden = differ.filter((field) => !stale.includes(field));
|
|
874
|
+
const forbidden = differ.filter((field) => controlled.has(field) && !stale.includes(field));
|
|
871
875
|
if (forbidden.length) plan.errors.set(id, `Document edit cannot change ${forbidden.join(', ')}; use this card's role-checked workflow verbs or request-state.`);
|
|
872
876
|
}
|
|
873
|
-
for (const id of plan.remove) {
|
|
877
|
+
for (const id of fields.length ? plan.remove : []) {
|
|
874
878
|
// A task line already absent from the replaced span was not removed by this edit: the store keeps it.
|
|
875
879
|
const parent = this.one('SELECT subtask_of FROM main.supercode_cards WHERE task_id = ?', id)?.subtask_of;
|
|
876
880
|
const prior = parent && !plan.remove.has(parent) ? rowsBefore(parent) : null;
|
package/connection.mjs
CHANGED
|
@@ -101,14 +101,22 @@ export async function connectWorkplace(flags) {
|
|
|
101
101
|
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');
|
|
102
102
|
const moduleRoot=flags['modules-root']?resolve(flags['modules-root']):null;
|
|
103
103
|
// An explicit local credential input is useful for an owned stack; a normal connect uses its existing Teams context.
|
|
104
|
-
let teams,owner;
|
|
104
|
+
let teams,owner,teamsContext=flags.context??process.env.SUPERCODE_CONTEXT;
|
|
105
105
|
if(flags['teams-token-file']){
|
|
106
106
|
if(!flags['teams-url']||!flags.team)throw new Error('--teams-token-file needs --teams-url and --team');
|
|
107
107
|
teams={server:new URL(flags['teams-url']).origin,team:flags.team};owner=readFileSync(flags['teams-token-file'],'utf8').trim();
|
|
108
108
|
}else{
|
|
109
|
-
|
|
109
|
+
// An agent session connects as its agent: its delegated context, never the person's who signed the box in (the
|
|
110
|
+
// Teams verbs' rule, agentContextName). A person's context named from an agent session is refused, as a grant is.
|
|
110
111
|
const {WorkspaceContexts}=await import('@volter/teams/context');
|
|
111
|
-
|
|
112
|
+
const {agentSessionEnvironment,agentSessionHint,agentContextName}=await import('@volter/supercode-teams/agent-session');
|
|
113
|
+
const contexts=new WorkspaceContexts(supercodeHome());
|
|
114
|
+
// Asked of the daemon whatever the environment says: a resumed session can lack its launch's SUPERCODE_AGENT_ID,
|
|
115
|
+
// and the daemon's retained binding is what names its agent (workspace.mjs does the same for every Teams verb).
|
|
116
|
+
const env=await agentSessionEnvironment(process.env,agentSessionHint(process.env));
|
|
117
|
+
teamsContext=agentContextName(contexts,env,flags.context??null);
|
|
118
|
+
const {context}=fromContext({supercodeHome:supercodeHome(),name:teamsContext});
|
|
119
|
+
teams={server:context.origin,team:context.team_id};owner=contexts.credential(context.credential_ref).token;
|
|
112
120
|
}
|
|
113
121
|
for(const origin of [ticket.origin,ticket.issuer,teams.server]){
|
|
114
122
|
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');
|
|
@@ -116,7 +124,7 @@ export async function connectWorkplace(flags) {
|
|
|
116
124
|
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);
|
|
117
125
|
const path=connectionPath(root,id),prior=existsSync(path)?JSON.parse(readFileSync(path,'utf8')):null;
|
|
118
126
|
if(!prior&&!(Date.parse(ticket.expiresAt)>Date.now()))throw new Error('connection ticket expired; apply the organization file again');
|
|
119
|
-
const record=prior??{version:1,id,scope:ticket.scope,teamsContext
|
|
127
|
+
const record=prior??{version:1,id,scope:ticket.scope,teamsContext,origin:ticket.origin,issuer:ticket.issuer,organization:ticket.organization,teams,source:flags.source,
|
|
120
128
|
selection:ticket.grants.map(g=>g.name),moduleRoot,...(flags['webhook-url']?{webhook:{url:flags['webhook-url'],listen:flags['webhook-listen']}}:{}),floor:flags.floor??ticket.floor??'Work',alertRole:flags['alert-role']??'admin',grants:[]};
|
|
121
129
|
record.root=root;
|
|
122
130
|
if(prior?.disconnectedAt)throw new Error('this connection was disconnected; apply the organization file again for a new ticket');
|
|
@@ -187,7 +195,7 @@ export async function connectWorkplace(flags) {
|
|
|
187
195
|
}
|
|
188
196
|
|
|
189
197
|
/** Undo a connection with its own credentials, no admin needed (runhuman-2 ADR 0055 decision 3): its runner stops, every
|
|
190
|
-
* installation it holds leaves both sides by itself (each Workplace installation through its own
|
|
198
|
+
* installation it holds leaves both sides by itself (each Workplace installation through its own leave door, each
|
|
191
199
|
* Teams installation and the issuer's by deleting itself), and the home's registry entries go. The registrations stay, as
|
|
192
200
|
* consented apps with no installation; an admin deregisters them. The custody record stays, marked disconnected, for
|
|
193
201
|
* audit; it can never run again. */
|
|
@@ -211,10 +219,11 @@ export async function disconnectWorkplace(flags) {
|
|
|
211
219
|
};
|
|
212
220
|
for(const grant of record.grants){
|
|
213
221
|
if(grant.teams?.installation?.id)await leave(grant.name,'teams','DELETE',`${team}/app-installations/${encodeURIComponent(grant.teams.installation.id)}`,grant.teams.credential.token);
|
|
214
|
-
if(grant.rh2?.installationId)await leave(grant.name,'rh2','POST',`/api/v3/automation/installations/${encodeURIComponent(grant.rh2.installationId)}/
|
|
222
|
+
if(grant.rh2?.installationId)await leave(grant.name,'rh2','POST',`/api/v3/automation/installations/${encodeURIComponent(grant.rh2.installationId)}/leave`,grant.rh2.accessToken);
|
|
215
223
|
const entry=join(root,'apps',`${grant.name}.${record.id}.json`);if(existsSync(entry))unlinkSync(entry);
|
|
216
224
|
}
|
|
217
|
-
|
|
225
|
+
// The token exchange answers {installation, credential: {token, …}} (renewTeams reads the same shape).
|
|
226
|
+
if(record.issuerCredential?.installation?.id)await leave('issuer','teams','DELETE',`${team}/app-installations/${encodeURIComponent(record.issuerCredential.installation.id)}`,record.issuerCredential.credential?.token);
|
|
218
227
|
record.disconnectedAt=new Date().toISOString();record.left=left;privateJson(path,record);
|
|
219
228
|
return {connection:record.id,root,left,registrations:[...record.grants.map(g=>g.registration?.id).filter(Boolean),record.issuerRegistration?.id].filter(Boolean)};
|
|
220
229
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.210",
|
|
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": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@volter/supercode-teams": "^0.3.
|
|
35
|
+
"@volter/supercode-teams": "^0.3.346",
|
|
36
36
|
"@volter/supercode-terminal": "^0.3.10",
|
|
37
37
|
"@volter/supercode-harness-sdk": "^0.3.104",
|
|
38
38
|
"@volter/teams": "0.2.98",
|
|
@@ -62,5 +62,5 @@
|
|
|
62
62
|
"apply",
|
|
63
63
|
"README.md"
|
|
64
64
|
],
|
|
65
|
-
"gitHead": "
|
|
65
|
+
"gitHead": "b2585dc5c6b40d48be96fd3c4148f2c53d7a7ef2"
|
|
66
66
|
}
|
package/source-lock.mjs
CHANGED
|
@@ -10,6 +10,8 @@ if (lock) {
|
|
|
10
10
|
enforceSourceLock(import.meta.resolve('@volter/supercode-teams/source-lock'), 'the orchestrator Teams package');
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
let driftSaid = false;
|
|
14
|
+
|
|
13
15
|
/** The locked tree's release native, proved against the same lock before any codec door opens. */
|
|
14
16
|
export function lockedNative() {
|
|
15
17
|
const lock = enforceSourceLock(import.meta.url, 'the orchestrator');
|
|
@@ -27,8 +29,11 @@ export function lockedNative() {
|
|
|
27
29
|
const result = spawnSync(binary, ['source', 'show', '--json'], { encoding: 'utf8', timeout: 5000 });
|
|
28
30
|
if (result.error || result.status !== 0) throw new SourceLockError(`supercode source lock: the orchestrator native ${binary} cannot be verified: ${result.error?.message || result.stderr}`);
|
|
29
31
|
const receipt = JSON.parse(result.stdout);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
// The native is judged by its binding alone: a retired copy that came back (drift) is said, and never stops the board.
|
|
33
|
+
const binding = receipt.binding ?? receipt.verdict;
|
|
34
|
+
if (binding !== 'matches' || receipt.native.version !== lock.supercode || receipt.native.commit !== lock.commit || receipt.native.dirty) {
|
|
35
|
+
throw new SourceLockError(`supercode source lock: the orchestrator native ${binary} does not match ${lock.path}: ${binding}`);
|
|
32
36
|
}
|
|
37
|
+
if (Array.isArray(receipt.drift) && receipt.drift.length && !driftSaid) { driftSaid = true; process.stderr.write(`supercode source lock: ${receipt.drift.join('; ')}\n`); }
|
|
33
38
|
return binary;
|
|
34
39
|
}
|