@volter/supercode-orchestrator 0.4.29 → 0.4.30
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 +65 -42
- package/board/dispatch.mjs +290 -274
- package/board/engine.mjs +151 -310
- package/board/expr.mjs +212 -0
- package/board/store.mjs +4 -2
- package/board/tools.mjs +17 -8
- package/board/workflow.mjs +442 -0
- package/board.mjs +8 -4
- package/kanban-worker.mjs +2 -1
- package/loop.mjs +1 -1
- package/package.json +1 -1
package/board/cli.mjs
CHANGED
|
@@ -12,7 +12,9 @@ import { read } from '../orchestration.mjs';
|
|
|
12
12
|
import { holdDispatcherLock } from '../hermes-locks.mjs';
|
|
13
13
|
import { boardPath, DEFAULT_BOARD, event, json, listBoards, now, openBoard, tx } from './store.mjs';
|
|
14
14
|
import * as engine from './engine.mjs';
|
|
15
|
-
import { adoptSession, boardLabel, boardSettings, lanesOf, localMachine, observeFleet, paused,
|
|
15
|
+
import { adoptSession, boardLabel, boardSettings, lanesOf, localMachine, observeFleet, paused, startCard, tick } from './dispatch.mjs';
|
|
16
|
+
import { loadWorkflow, useWorkflow, workflowEntry } from './workflow.mjs';
|
|
17
|
+
import { evaluate } from './expr.mjs';
|
|
16
18
|
|
|
17
19
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
18
20
|
|
|
@@ -148,8 +150,12 @@ function counts(root, slug) {
|
|
|
148
150
|
/** The home's lanes (profiles' workers) and board settings (the root config's `kanban:` block), as the IR reads them. */
|
|
149
151
|
async function laneState(root) {
|
|
150
152
|
const orchestration = await read(root);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
// the board's settings are its workflow's params (the instance's, under the home's own `kanban:` keys)
|
|
154
|
+
const entry = useWorkflow(root, workflowEntry(orchestration));
|
|
155
|
+
const settings = boardSettings(entry.params);
|
|
156
|
+
const tick = Number(evaluate(entry.workflow.dispatch.tick, { params: entry.params }));
|
|
157
|
+
if (tick > 0) settings.interval = tick;
|
|
158
|
+
return { lanes: lanesOf(orchestration.profiles), settings, kanban: entry.params, profiles: Object.keys(orchestration.profiles ?? {}) };
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
/** The caller's own supercode address, when it is a session supercode can reach (for its card's notices). */
|
|
@@ -207,6 +213,7 @@ export async function runWorkflow(argv) {
|
|
|
207
213
|
const slug = boardOf(root, args);
|
|
208
214
|
const open = (create = false) => { const b = openBoard(root, slug, { create }); b.label = boardLabel(root, slug); return b; };
|
|
209
215
|
const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
|
|
216
|
+
const withBoardAsync = async (fn) => { const b = open(); try { return await fn(b); } finally { b.close(); } };
|
|
210
217
|
const id = rest[0];
|
|
211
218
|
const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
|
|
212
219
|
const ids = (first = rest) => [...first, ...(args.ids ?? [])];
|
|
@@ -216,6 +223,9 @@ export async function runWorkflow(argv) {
|
|
|
216
223
|
if (sid && !READS.has(verb) && existsSync(boardPath(root, slug))) {
|
|
217
224
|
withBoard((b) => { const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
|
|
218
225
|
}
|
|
226
|
+
// the board's workflow (the IR's): what each verb does, and who may send it, is its
|
|
227
|
+
if (!READS.has(verb) && verb !== 'help') await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
228
|
+
engine.setCaller((db, cardId) => rolesFor(db, cardId, sid));
|
|
219
229
|
|
|
220
230
|
switch (verb) {
|
|
221
231
|
case 'init': {
|
|
@@ -368,23 +378,23 @@ export async function runWorkflow(argv) {
|
|
|
368
378
|
return 0;
|
|
369
379
|
}
|
|
370
380
|
|
|
371
|
-
case 'assign': need(2, 'a card id and a profile (or none)');
|
|
381
|
+
case 'assign': need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.assign(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
|
|
372
382
|
case 'reassign': {
|
|
373
383
|
need(2, 'a card id and a profile');
|
|
374
|
-
|
|
384
|
+
await withBoardAsync(async (b) => {
|
|
375
385
|
const task = tx(b, (db) => engine.getTask(db, id));
|
|
376
|
-
if (task?.status === 'running' && args.reclaim) engine.reclaim(b, id, args.reason ?? 'reassign');
|
|
377
|
-
engine.assign(b, id, rest[1] === 'none' ? null : rest[1]);
|
|
386
|
+
if (task?.status === 'running' && args.reclaim) await engine.reclaim(b, id, args.reason ?? 'reassign');
|
|
387
|
+
await engine.assign(b, id, rest[1] === 'none' ? null : rest[1]);
|
|
378
388
|
});
|
|
379
389
|
say(args, `Reassigned ${id} to ${rest[1]}`, { ok: true }); return 0;
|
|
380
390
|
}
|
|
381
|
-
case 'set-model': need(1, 'a card id');
|
|
391
|
+
case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.setModel(b, id, rest[1] ?? null, args.provider ?? null)); say(args, rest[1] ? `${id} runs on model ${rest[1]}${args.provider ? ` (${args.provider})` : ''} from its next start` : `${id} uses its profile's model`, { ok: true }); return 0;
|
|
382
392
|
case 'set-goal': {
|
|
383
393
|
// a card into goal mode (or out of it with --off) from its next idle turn: the heartbeat keeps its session going
|
|
384
394
|
need(1, 'a card id');
|
|
385
395
|
const maxTurns = args['goal-max-turns'] ? Number(args['goal-max-turns']) : null;
|
|
386
396
|
if (maxTurns !== null && !(Number.isInteger(maxTurns) && maxTurns >= 1)) throw new Error('--goal-max-turns is a whole number of turns, at least 1');
|
|
387
|
-
for (const t of ids())
|
|
397
|
+
for (const t of ids()) await withBoardAsync((b) => engine.setGoal(b, t, { on: !args.off, maxTurns }));
|
|
388
398
|
say(args, ids().map((t) => args.off ? `${t} is no longer a goal card` : `${t} is a goal card${maxTurns ? ` (${maxTurns} turns)` : ''}`).join('\n'), { ok: true }); return 0;
|
|
389
399
|
}
|
|
390
400
|
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;
|
|
@@ -405,17 +415,17 @@ export async function runWorkflow(argv) {
|
|
|
405
415
|
say(args, workspace ?? `(claimed ${id}; its ${task.workspace_kind} workspace is made where it starts)`, { ok: true, run_id: runId, workspace });
|
|
406
416
|
return 0;
|
|
407
417
|
}
|
|
408
|
-
case 'reclaim': need(1, 'a card id');
|
|
409
|
-
case 'heartbeat': need(1, 'a card id');
|
|
418
|
+
case 'reclaim': need(1, 'a card id'); await withBoardAsync((b) => engine.reclaim(b, id, args.reason ?? (rest.slice(1).join(' ') || null))); say(args, `Reclaimed ${id}`, { ok: true }); return 0;
|
|
419
|
+
case 'heartbeat': need(1, 'a card id'); await withBoardAsync((b) => engine.heartbeat(b, id, args.note ?? null)); say(args, `Heartbeat recorded for ${id}`, { ok: true }); return 0;
|
|
410
420
|
|
|
411
|
-
case 'link': need(2, 'a parent and a child');
|
|
412
|
-
case 'unlink': { need(2, 'a parent and a child'); const gone =
|
|
421
|
+
case 'link': need(2, 'a parent and a child'); await withBoardAsync((b) => engine.link(b, rest[0], rest[1])); say(args, `Linked ${rest[0]} -> ${rest[1]}`, { ok: true }); return 0;
|
|
422
|
+
case 'unlink': { need(2, 'a parent and a child'); const gone = await withBoardAsync((b) => engine.unlink(b, rest[0], rest[1])); say(args, gone ? `Unlinked ${rest[0]} -> ${rest[1]}` : `no link ${rest[0]} -> ${rest[1]}`, { ok: gone }); return gone ? 0 : 1; }
|
|
413
423
|
|
|
414
424
|
case 'comment': {
|
|
415
425
|
need(2, 'a card id and the text');
|
|
416
426
|
let text = rest.slice(1).join(' ');
|
|
417
427
|
if (args['max-len']) text = text.slice(0, Number(args['max-len']));
|
|
418
|
-
const n =
|
|
428
|
+
const n = await withBoardAsync((b) => engine.comment(b, id, args.author ?? callerAddress() ?? callerName(), text));
|
|
419
429
|
say(args, `Comment added to ${id}`, { ok: true, comment: n }); return 0;
|
|
420
430
|
}
|
|
421
431
|
case 'attach': {
|
|
@@ -448,45 +458,49 @@ export async function runWorkflow(argv) {
|
|
|
448
458
|
case 'complete': {
|
|
449
459
|
need(1, 'a card id');
|
|
450
460
|
const metadata = metadataOf(args.metadata);
|
|
451
|
-
for (const t of rest)
|
|
461
|
+
for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null }));
|
|
452
462
|
say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
|
|
453
463
|
}
|
|
454
464
|
case 'edit': {
|
|
455
465
|
need(1, 'a card id');
|
|
456
466
|
if (args.result === undefined) throw new Error('edit needs --result');
|
|
457
|
-
|
|
467
|
+
await withBoardAsync((b) => engine.editResult(b, id, { result: args.result, summary: args.summary ?? null, metadata: metadataOf(args.metadata) }));
|
|
458
468
|
say(args, `Edited ${id}`, { ok: true }); return 0;
|
|
459
469
|
}
|
|
460
470
|
case 'block': {
|
|
461
471
|
need(1, 'a card id');
|
|
462
472
|
const reason = rest.slice(1).join(' ') || null;
|
|
463
|
-
const results = [
|
|
473
|
+
const results = [];
|
|
474
|
+
for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.block(b, t, { reason, kind: args.kind ?? null }))).to]);
|
|
464
475
|
say(args, results.map(([t, s]) => `${s === 'todo' ? 'Waiting on dependencies' : s === 'triage' ? 'Routed to triage' : 'Blocked'} ${t}`).join('\n'), { ok: true, results }); return 0;
|
|
465
476
|
}
|
|
466
477
|
case 'schedule': {
|
|
467
478
|
need(1, 'a card id');
|
|
468
479
|
const reason = rest.slice(1).join(' ') || null;
|
|
469
|
-
for (const t of [id, ...(args.ids ?? [])])
|
|
480
|
+
for (const t of [id, ...(args.ids ?? [])]) await withBoardAsync((b) => engine.schedule(b, t, reason));
|
|
470
481
|
say(args, [id, ...(args.ids ?? [])].map((t) => `Scheduled ${t}`).join('\n'), { ok: true }); return 0;
|
|
471
482
|
}
|
|
472
483
|
case 'unblock': {
|
|
473
484
|
need(1, 'a card id');
|
|
474
|
-
const results =
|
|
485
|
+
const results = [];
|
|
486
|
+
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null }))).to]);
|
|
475
487
|
// Hermes records the reason as a comment on each card it unblocks
|
|
476
|
-
if (args.reason) for (const [t] of results)
|
|
488
|
+
if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
|
|
477
489
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
478
490
|
}
|
|
479
|
-
case 'request-review': need(1, 'a card id');
|
|
480
|
-
case 'request-changes': need(2, 'a card id and the reason');
|
|
491
|
+
case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
|
|
492
|
+
case 'request-changes': need(2, 'a card id and the reason'); await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? callerName() })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
|
|
481
493
|
case 'reopen': {
|
|
482
494
|
// a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
|
|
483
495
|
need(1, 'a card id');
|
|
484
|
-
const results =
|
|
485
|
-
|
|
496
|
+
const results = [];
|
|
497
|
+
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.reopen(b, t, { author: callerName(), reason: args.reason ?? null }))).to]);
|
|
498
|
+
say(args, results.map(([t, s]) => `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
486
499
|
}
|
|
487
500
|
case 'reopen-review': {
|
|
488
501
|
need(1, 'a card id');
|
|
489
|
-
const results =
|
|
502
|
+
const results = [];
|
|
503
|
+
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.reopenReview(b, t))).to]);
|
|
490
504
|
say(args, results.map(([t, s]) => `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
491
505
|
}
|
|
492
506
|
case 'promote': {
|
|
@@ -503,11 +517,11 @@ export async function runWorkflow(argv) {
|
|
|
503
517
|
say(args, verdicts.map((v) => `${v.ok ? 'would promote' : 'would refuse'} ${v.id}${v.reason ? `: ${v.reason}` : ''}`).join('\n'), verdicts);
|
|
504
518
|
return 0;
|
|
505
519
|
}
|
|
506
|
-
for (const t of targets)
|
|
520
|
+
for (const t of targets) await withBoardAsync((b) => engine.promote(b, t, { actor: callerName(), reason, force: args.force }));
|
|
507
521
|
say(args, targets.map((t) => `Promoted ${t} → ready`).join('\n'), { ok: true }); return 0;
|
|
508
522
|
}
|
|
509
523
|
case 'archive': {
|
|
510
|
-
for (const t of rest)
|
|
524
|
+
for (const t of rest) await withBoardAsync((b) => engine.archive(b, t));
|
|
511
525
|
const purged = [];
|
|
512
526
|
for (const t of args.rm ?? []) {
|
|
513
527
|
withBoard((b) => tx(b, (db) => {
|
|
@@ -530,13 +544,13 @@ export async function runWorkflow(argv) {
|
|
|
530
544
|
const results = [];
|
|
531
545
|
for (const t of targets) {
|
|
532
546
|
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined)) {
|
|
533
|
-
|
|
547
|
+
await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, author: args.author ?? callerAddress() ?? callerName() }));
|
|
534
548
|
results.push({ id: t, specified: true });
|
|
535
549
|
} else if (verb === 'decompose' && args.child?.length) {
|
|
536
550
|
const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
|
|
537
|
-
results.push({ id: t, children:
|
|
551
|
+
results.push({ id: t, children: await withBoardAsync((b) => engine.decompose(b, t, children, { author: args.author ?? callerAddress() ?? callerName() })) });
|
|
538
552
|
} else {
|
|
539
|
-
const session =
|
|
553
|
+
const session = await withBoardAsync((b) => startCard(b, t, { lanes, settings }));
|
|
540
554
|
results.push({ id: t, session: session.address, mode: session.mode });
|
|
541
555
|
}
|
|
542
556
|
}
|
|
@@ -944,6 +958,23 @@ const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', '
|
|
|
944
958
|
* comment and attach. Never its arc's completion, and never another arc's card: anything across arcs goes through the
|
|
945
959
|
* fleet's manager.
|
|
946
960
|
*/
|
|
961
|
+
/**
|
|
962
|
+
* Who a caller is on card `cardId`, in the workflow's roles: a person or the fleet's manager at the CLI (no session)
|
|
963
|
+
* is `manager`; the session of the card's current run has that run's role; the card's working session between runs
|
|
964
|
+
* is its `implementer`; the session working a subtask's arc is `arc`; any other session is nobody on it.
|
|
965
|
+
*/
|
|
966
|
+
function rolesFor(db, cardId, sessionId) {
|
|
967
|
+
if (!sessionId) return ['manager'];
|
|
968
|
+
const task = engine.getTask(db, cardId);
|
|
969
|
+
if (!task) return [];
|
|
970
|
+
const roles = [];
|
|
971
|
+
const current = task.current_run_id ? json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id)?.metadata, {})?.supercode : null;
|
|
972
|
+
if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
|
|
973
|
+
else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
|
|
974
|
+
if (task.subtask_of && engine.lastSession(db, task.subtask_of)?.session_id === sessionId) roles.push('arc');
|
|
975
|
+
return roles;
|
|
976
|
+
}
|
|
977
|
+
|
|
947
978
|
function authorize(db, verb, rest, args, own) {
|
|
948
979
|
const refuse = (why) => { throw new Error(`the session working ${own.id} ${why}`); };
|
|
949
980
|
if (verb === 'create') {
|
|
@@ -952,21 +983,13 @@ function authorize(db, verb, rest, args, own) {
|
|
|
952
983
|
}
|
|
953
984
|
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
954
985
|
// verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
|
|
955
|
-
|
|
986
|
+
// (what a session may do to a card is its workflow's: each transition's `by`)
|
|
987
|
+
if (['pause', 'resume', 'swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'move', 'claim', 'attach-rm'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
|
|
956
988
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
957
989
|
const card = engine.getTask(db, rest[0]);
|
|
958
990
|
if (!card) return; // the verb itself answers "no card"
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
// a session started from the review lane is the card's reviewer: its verdict is its to give
|
|
962
|
-
const reviewing = mine && json(db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind = 'claimed' ORDER BY id DESC LIMIT 1").get(card.id)?.payload, {})?.source_status === 'review';
|
|
963
|
-
if (reviewing && ['complete', 'request-changes'].includes(verb)) return;
|
|
964
|
-
const allowed = {
|
|
965
|
-
comment: mine || sub, block: mine || sub, heartbeat: mine, 'request-review': mine || sub, attach: mine || sub,
|
|
966
|
-
specify: mine, decompose: mine, complete: sub, unblock: sub, 'notify-subscribe': mine || sub, 'notify-unsubscribe': mine || sub,
|
|
967
|
-
}[verb];
|
|
968
|
-
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}`);
|
|
969
|
-
if (verb === 'block' && mine && !args.kind) refuse('blocks its card with a typed reason (--kind needs_input|capability|transient|dependency)');
|
|
991
|
+
// a card's session writes its own card and its subtasks; what it may do with them is the workflow's (`by`)
|
|
992
|
+
if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
|
|
970
993
|
for (const other of args.ids ?? []) if (other !== own.id && engine.getTask(db, other)?.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${other}`);
|
|
971
994
|
}
|
|
972
995
|
|