@volter/supercode-orchestrator 0.4.15 → 0.4.16

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/board/cli.mjs ADDED
@@ -0,0 +1,318 @@
1
+ // `supercode workflow <verb>` (docs/architecture/orchestrator.md §2.9): supercode's own board, in Hermes Kanban's
2
+ // format, and its dispatcher. The Rust CLI hands every verb but `load` to `bin/orchestrator.mjs workflow`, which
3
+ // lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`, else `HERMES_HOME`; the board is
4
+ // `--board <slug>` (default `default`). Every verb prints a line for a person, or one JSON object with `--json`.
5
+ import { existsSync } from 'node:fs';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { tmpdir } from 'node:os';
8
+ import { resolve } from 'node:path';
9
+ import { read } from '../orchestration.mjs';
10
+ import { holdDispatcherLock } from '../hermes-locks.mjs';
11
+ import { boardPath, DEFAULT_BOARD, event, listBoards, openBoard, tx } from './store.mjs';
12
+ import * as engine from './engine.mjs';
13
+ import { attachSession, boardLabel, boardSettings, lanesOf, observeFleet, tick } from './dispatch.mjs';
14
+
15
+ const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
16
+
17
+ create <title> [--body T] [--assignee PROFILE] [--machine M] [--workspace dir:<path>|scratch|worktree[:<path>]]
18
+ [--branch B] [--parent ID]... [--priority N] [--tenant T] [--triage] [--idempotency-key K] [--max-runtime SECONDS]
19
+ [--max-retries N] [--skill S]... [--model M] [--provider P] [--created-by NAME] [--notify ADDRESS | --no-notify]
20
+ [--subtask-of ARC] [--no-start]
21
+ File a card. With a lane and capacity it starts at once and answers "started" with its session; otherwise it
22
+ answers why it waits, and its creator is told when it starts. A subtask is worked by its arc's session and never
23
+ starts on its own; another card may wait on it (link).
24
+ list [--status S] [--assignee P] [--machine M] [--all] show <id> context <id>
25
+ comment <id> <text> [--author NAME] complete <id> [--summary T] [--result T]
26
+ block <id> [reason] [--kind dependency|needs_input|capability|transient] unblock <id>
27
+ review <id> [--summary T] [--reviewer P] changes <id> [--reason T] [--reviewer P]
28
+ schedule <id> [reason] promote <id> [--force] reclaim <id> [reason] archive <id>
29
+ assign <id> <profile|none> move <id> <machine|none> link <parent> <child> unlink <parent> <child>
30
+ subscribe <id> [--address A] [--role creator|worker|dependency] unsubscribe <id> [--address A]
31
+ close <id> close the session of a card that is not running (an idle session holds its machine's place)
32
+ attach <id> <session> [--workspace dir:<path>]
33
+ put a live session the board did not start on the card (an address, id or name@machine the
34
+ fleet lists); the board watches, resumes and tells it from then on
35
+
36
+ A card's own session may comment on, block (with --kind) or send to review its card, and create, complete, block and
37
+ comment on its subtasks; its arc is marked done by its reviewers, never by itself.
38
+ init create the board if it does not exist
39
+ dispatch one dispatcher tick over every board of the home
40
+ serve the dispatcher, a tick every kanban.dispatch_interval_seconds, until SIGTERM
41
+ `;
42
+
43
+ const MULTI = new Set(['parent', 'skill']);
44
+ const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help']);
45
+
46
+ function parse(argv) {
47
+ const out = { _: [] };
48
+ for (let i = 0; i < argv.length; i += 1) {
49
+ const a = argv[i];
50
+ if (!a.startsWith('--')) { out._.push(a); continue; }
51
+ const [key, inline] = a.slice(2).split(/=(.*)/s);
52
+ if (FLAGS.has(key)) { out[key] = true; continue; }
53
+ const value = inline ?? argv[++i];
54
+ if (value === undefined) throw new Error(`--${key} needs a value`);
55
+ if (MULTI.has(key)) (out[key] ??= []).push(value);
56
+ else out[key] = value;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function homeOf(args) {
62
+ const root = args.root ?? process.env.SUPERCODE_ORCHESTRATOR_HOME ?? process.env.HERMES_HOME;
63
+ if (!root) throw new Error('which home? --root <home> (or SUPERCODE_ORCHESTRATOR_HOME)');
64
+ return resolve(root);
65
+ }
66
+
67
+ /** The home's lanes (profiles' workers) and board settings (the root config's `kanban:` block), as the IR reads them. */
68
+ async function laneState(root) {
69
+ const orchestration = await read(root);
70
+ const kanban = orchestration.profiles?.default?.residue?.config?.kanban ?? {};
71
+ return { lanes: lanesOf(orchestration.profiles), settings: boardSettings(kanban), kanban };
72
+ }
73
+
74
+ /** The caller's own supercode address, when it is a session supercode can reach (for its card's notices). */
75
+ function callerAddress() {
76
+ const id = process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID;
77
+ if (!id) return null;
78
+ const fleet = observeFleet();
79
+ return [...fleet.alive].find((a) => a.endsWith(`:${id}`)) ?? null;
80
+ }
81
+
82
+ const say = (args, human, value) => { process.stdout.write(args.json ? `${JSON.stringify(value)}\n` : `${human}\n`); };
83
+
84
+ function row(t) {
85
+ return `${t.id} ${t.status.padEnd(9)} ${(t.assignee ?? '-').padEnd(14)} ${(t.machine ?? '-').padEnd(20)} ${t.title}`;
86
+ }
87
+
88
+ export async function runWorkflow(argv) {
89
+ const args = parse(argv);
90
+ const [verb, ...rest] = args._;
91
+ if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
92
+ const root = homeOf(args);
93
+ const slug = args.board ?? DEFAULT_BOARD;
94
+ const open = (create = false) => { const b = openBoard(root, slug, { create }); b.label = boardLabel(root, slug); return b; };
95
+ const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
96
+ const id = rest[0];
97
+ const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
98
+
99
+ // a caller that is a card's own session (its harness names it; the board recorded which session it launched)
100
+ const sid = process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID;
101
+ if (sid && !READS.has(verb) && existsSync(boardPath(root, slug))) {
102
+ withBoard((b) => { const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
103
+ }
104
+
105
+ switch (verb) {
106
+ case 'close': {
107
+ need(1, 'a card id');
108
+ const closed = withBoard((b) => closeSession(b, id));
109
+ say(args, closed ? `closed ${closed.session_id} (pane ${closed.pane} on ${closed.machine})` : `${id} has no live session`, { ok: true, closed });
110
+ return 0;
111
+ }
112
+ case 'attach': {
113
+ need(2, 'a card id and a session');
114
+ const { settings } = await laneState(root);
115
+ const got = withBoard((b) => attachSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl }));
116
+ say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
117
+ return 0;
118
+ }
119
+ case 'init': withBoard(() => {}, true); say(args, `board ${slug} ready in ${root}`, { ok: true, board: slug }); return 0;
120
+ case 'create': {
121
+ need(1, 'a title');
122
+ const { lanes, settings } = await laneState(root);
123
+ const created = withBoard((b) => engine.createCard(b, {
124
+ title: rest.join(' '), body: args.body, assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
125
+ workspace: args.workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
126
+ triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: args['max-runtime'] ? Number(args['max-runtime']) : null,
127
+ maxRetries: args['max-retries'] ? Number(args['max-retries']) : null, skills: args.skill, model: args.model,
128
+ provider: args.provider, createdBy: args['created-by'] ?? callerName(), sessionId: process.env.CLAUDE_CODE_SESSION_ID ?? null,
129
+ subtaskOf: args['subtask-of'] ?? null,
130
+ }), true);
131
+ let answer = { id: created.id, existing: created.existing };
132
+ if (!args['no-start'] && !created.existing && !args['subtask-of']) {
133
+ const [report] = tick({ root, lanes, settings, only: created.id, slugs: [slug] });
134
+ const started = report.started.find(([t]) => t === created.id);
135
+ const queued = report.queued.find(([t]) => t === created.id);
136
+ const failed = report.failed.find(([t]) => t === created.id);
137
+ answer = { ...answer, ...(started ? { started: true, session: started[1], mode: started[2] } : {}), ...(queued ? { started: false, waits: queued[1] } : {}), ...(failed ? { started: false, failed: failed[1] } : {}) };
138
+ }
139
+ // the creator hears what happens from here on; a start answered above is not told twice
140
+ const address = args['no-notify'] ? null : args.notify ?? callerAddress();
141
+ if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator'));
142
+ const status = withBoard((b) => tx(b, (db) => engine.getTask(db, created.id)));
143
+ answer = { ...answer, status: status.status, notify: address ?? null };
144
+ const line = answer.started ? `started ${created.id}: session ${answer.session} (${answer.mode})`
145
+ : answer.failed ? `${created.id} did not start: ${answer.failed}`
146
+ : answer.waits ? `${created.id} waits: ${answer.waits}`
147
+ : `${created.existing ? 'existing' : 'created'} ${created.id} (${status.status})`;
148
+ say(args, `${line}${address ? `; notices go to ${address}` : ''}`, answer);
149
+ return answer.failed ? 1 : 0;
150
+ }
151
+ case 'list': {
152
+ const cards = withBoard((b) => engine.listCards(b.db, { status: args.status, assignee: args.assignee, machine: args.machine, includeArchived: args.all }));
153
+ if (args.json) say(args, '', cards); else process.stdout.write(`${cards.map(row).join('\n')}${cards.length ? '\n' : ''}`);
154
+ return 0;
155
+ }
156
+ case 'show': {
157
+ need(1, 'a card id');
158
+ const card = withBoard((b) => engine.showCard(b.db, id));
159
+ if (args.json) { say(args, '', card); return 0; }
160
+ const t = card.task;
161
+ const lines = [row(t), t.body ? `\n${t.body}\n` : ''];
162
+ if (card.parents.length) lines.push(`parents: ${card.parents.map((p) => `${p.id} (${p.status})`).join(', ')}`);
163
+ if (card.children.length) lines.push(`children: ${card.children.join(', ')}`);
164
+ if (t.subtask_of) lines.push(`subtask of ${t.subtask_of}`);
165
+ for (const st of card.subtasks) lines.push(`subtask ${st.id} [${st.status}] ${st.title}`);
166
+ for (const r of card.runs) {
167
+ const s = r.metadata?.supercode;
168
+ const who = s ? (s.session_id ? `session ${s.session_id}${s.pane ? ` in pane ${s.pane}` : ''}` : `worker pid ${s.pid}`) : '';
169
+ lines.push(`run ${r.id}: ${r.status}${r.outcome ? ` / ${r.outcome}` : ''}${s ? ` — ${who} on ${s.machine} (${s.mode})` : ''}${r.summary ? ` — ${r.summary.split('\n')[0]}` : ''}`);
170
+ }
171
+ for (const c of card.comments) lines.push(`${c.author}: ${c.body}`);
172
+ for (const s of card.subscriptions) lines.push(`notices → ${s.chat_id} (${s.delivery_metadata?.role ?? s.platform})`);
173
+ process.stdout.write(`${lines.filter(Boolean).join('\n')}\n`);
174
+ return 0;
175
+ }
176
+ case 'context': need(1, 'a card id'); process.stdout.write(`${withBoard((b) => tx(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
177
+ case 'comment': {
178
+ need(2, 'a card id and the text');
179
+ const n = withBoard((b) => engine.comment(b, id, args.author ?? callerAddress() ?? callerName(), rest.slice(1).join(' ')));
180
+ say(args, `commented on ${id}`, { ok: true, comment: n }); return 0;
181
+ }
182
+ case 'complete': need(1, 'a card id'); withBoard((b) => engine.complete(b, id, { summary: args.summary ?? null, result: args.result ?? null })); say(args, `${id} done`, { ok: true }); return 0;
183
+ case 'block': { need(1, 'a card id'); const s = withBoard((b) => engine.block(b, id, { reason: rest.slice(1).join(' ') || null, kind: args.kind ?? null })); say(args, `${id} ${s}`, { ok: true, status: s }); return 0; }
184
+ case 'unblock': { need(1, 'a card id'); const s = withBoard((b) => engine.unblock(b, id)); say(args, `${id} ${s}`, { ok: true, status: s }); return 0; }
185
+ case 'review': need(1, 'a card id'); withBoard((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null })); say(args, `${id} in review`, { ok: true }); return 0;
186
+ case 'changes': need(1, 'a card id'); withBoard((b) => engine.requestChanges(b, id, { reason: args.reason ?? null, reviewer: args.reviewer ?? null })); say(args, `${id} sent back`, { ok: true }); return 0;
187
+ case 'schedule': need(1, 'a card id'); withBoard((b) => engine.schedule(b, id, rest.slice(1).join(' ') || null)); say(args, `${id} scheduled`, { ok: true }); return 0;
188
+ case 'promote': need(1, 'a card id'); withBoard((b) => engine.promote(b, id, { actor: callerName(), force: args.force })); say(args, `${id} ready`, { ok: true }); return 0;
189
+ case 'reclaim': need(1, 'a card id'); withBoard((b) => engine.reclaim(b, id, rest.slice(1).join(' ') || null)); say(args, `${id} reclaimed`, { ok: true }); return 0;
190
+ case 'archive': need(1, 'a card id'); withBoard((b) => engine.archive(b, id)); say(args, `${id} archived`, { ok: true }); return 0;
191
+ case 'assign': need(2, 'a card id and a profile (or none)'); withBoard((b) => engine.assign(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} → ${rest[1]}`, { ok: true }); return 0;
192
+ case 'move': need(2, 'a card id and a machine (or none)'); withBoard((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
193
+ case 'link': need(2, 'a parent and a child'); withBoard((b) => engine.link(b, rest[0], rest[1])); say(args, `${rest[1]} waits on ${rest[0]}`, { ok: true }); return 0;
194
+ case 'unlink': need(2, 'a parent and a child'); withBoard((b) => engine.unlink(b, rest[0], rest[1])); say(args, `${rest[1]} no longer waits on ${rest[0]}`, { ok: true }); return 0;
195
+ case 'subscribe': {
196
+ need(1, 'a card id');
197
+ const address = args.address ?? callerAddress();
198
+ if (!address) throw new Error('whose notices? --address sc:<machine>:<harness>:<id>');
199
+ withBoard((b) => engine.subscribe(b, id, address, args.role ?? 'creator'));
200
+ say(args, `${address} hears ${id}`, { ok: true, address }); return 0;
201
+ }
202
+ case 'unsubscribe': {
203
+ need(1, 'a card id');
204
+ const address = args.address ?? callerAddress();
205
+ const gone = withBoard((b) => engine.unsubscribe(b, id, address));
206
+ say(args, gone ? `${address} no longer hears ${id}` : `${address} did not hear ${id}`, { ok: gone }); return 0;
207
+ }
208
+ case 'dispatch': {
209
+ const { lanes, settings } = await laneState(root);
210
+ const reports = tick({ root, lanes, settings });
211
+ if (args.json) say(args, '', reports);
212
+ else for (const r of reports) process.stdout.write(`${summary(r)}\n`);
213
+ return reports.some((r) => r.error) ? 1 : 0;
214
+ }
215
+ case 'serve': return serve(root);
216
+ default: throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
217
+ }
218
+ }
219
+
220
+ /** The card whose latest session is `sessionId` (the caller works it), or null. */
221
+ function ownCard(db, sessionId) {
222
+ const hit = db.prepare("SELECT task_id FROM task_runs WHERE metadata LIKE ? ORDER BY id DESC LIMIT 1").get(`%"session_id":"${sessionId}"%`);
223
+ if (!hit) return null;
224
+ return engine.lastSession(db, hit.task_id)?.session_id === sessionId ? engine.getTask(db, hit.task_id) : null;
225
+ }
226
+
227
+ const READS = new Set(['list', 'show', 'context', 'help', 'init', 'dispatch']);
228
+
229
+ /**
230
+ * What a card's own session may write (the owner's rule): on its card, comment, block with a typed reason, and send
231
+ * it to review; on its subtasks, create, complete, block, unblock and comment. Never its arc's completion, and never
232
+ * another arc's card: anything across arcs goes through the fleet's manager.
233
+ */
234
+ function authorize(db, verb, rest, args, own) {
235
+ if (READS.has(verb)) return;
236
+ const refuse = (why) => { throw new Error(`the session working ${own.id} ${why}`); };
237
+ if (verb === 'create') {
238
+ if (args['subtask-of'] !== own.id) refuse(`files subtasks of its own card only (--subtask-of ${own.id})`);
239
+ return;
240
+ }
241
+ const card = engine.getTask(db, rest[0]);
242
+ if (!card) return; // the verb itself answers "no card"
243
+ const mine = card.id === own.id;
244
+ const sub = card.subtask_of === own.id;
245
+ const allowed = {
246
+ comment: mine || sub, block: mine || sub, review: mine || sub, complete: sub, unblock: sub,
247
+ subscribe: mine || sub, unsubscribe: mine || sub,
248
+ }[verb];
249
+ if (!allowed) refuse(mine ? `cannot ${verb} its own card; its reviewers and the fleet's manager do` : `writes only its own card and its subtasks, not ${card.id}`);
250
+ if (verb === 'block' && mine && !args.kind) refuse('blocks its card with a typed reason (--kind needs_input|capability|transient|dependency)');
251
+ }
252
+
253
+ /** Close the live session of a card that is not running, freeing its machine's place. */
254
+ function closeSession(board, id) {
255
+ const { task, session } = tx(board, (db) => ({ task: engine.getTask(db, id), session: engine.lastSession(db, id) }));
256
+ if (!task) throw new Error(`no card ${id}`);
257
+ if (task.status === 'running') throw new Error(`${id} is running; block, complete or reclaim it before closing its session`);
258
+ if (!session?.pane) return null;
259
+ const r = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['teams', 'panes', 'close', session.pane, '--machine', session.machine], { cwd: tmpdir(), encoding: 'utf8', timeout: 60_000 });
260
+ if (r.status !== 0) throw new Error(`closing pane ${session.pane} on ${session.machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
261
+ tx(board, (db) => event(db, id, 'session_closed', { session_id: session.session_id, machine: session.machine, pane: session.pane }));
262
+ return session;
263
+ }
264
+
265
+ function callerName() {
266
+ return process.env.HERMES_PROFILE || process.env.USER || 'user';
267
+ }
268
+
269
+ export function summary(r) {
270
+ const parts = [`board ${r.board}:`];
271
+ if (r.error) parts.push(`error ${r.error}`);
272
+ for (const [k, label] of [['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['auto_blocked', 'gave up'], ['notified', 'notices']]) {
273
+ if (r[k]?.length) parts.push(`${label} ${r[k].length}`);
274
+ }
275
+ if (r.promoted) parts.push(`promoted ${r.promoted}`);
276
+ if (parts.length === 1) parts.push('nothing to do');
277
+ return parts.join(' ');
278
+ }
279
+
280
+ /**
281
+ * The dispatcher alone, for a home whose board is dispatched without the rest of the orchestrator (the lanes are
282
+ * re-read every tick, so a profile's change takes effect without a restart). It holds the board's one-dispatcher
283
+ * lock, as the daemon and Hermes's gateways do, so one dispatcher serves a board.
284
+ */
285
+ async function serve(root) {
286
+ const lock = await holdDispatcherLock(root);
287
+ let stopped = false;
288
+ const stop = () => { stopped = true; };
289
+ process.on('SIGTERM', stop);
290
+ process.on('SIGINT', stop);
291
+ const log = (line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`);
292
+ log(`dispatching the boards of ${root}`);
293
+ try {
294
+ while (!stopped) {
295
+ let interval = 60;
296
+ try {
297
+ const { lanes, settings } = await laneState(root);
298
+ interval = settings.interval;
299
+ for (const r of tick({ root, lanes, settings })) {
300
+ const said = summary(r);
301
+ if (!said.endsWith('nothing to do')) log(said);
302
+ for (const [t, why] of r.failed) log(` ${t} did not start: ${why}`);
303
+ for (const [t, s, m] of r.started) log(` ${t} started ${s} (${m})`);
304
+ for (const [t, to, why] of r.undelivered) log(` notice for ${t} to ${to} not delivered: ${why}`);
305
+ }
306
+ } catch (error) {
307
+ log(`tick failed: ${error.message}`);
308
+ }
309
+ for (let waited = 0; waited < interval && !stopped; waited += 1) await new Promise((done) => setTimeout(done, 1000));
310
+ }
311
+ } finally {
312
+ lock.release();
313
+ }
314
+ log('stopped');
315
+ return 0;
316
+ }
317
+
318
+ export { listBoards };