@volter/supercode-orchestrator 0.4.73 → 0.4.75

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 CHANGED
@@ -30,6 +30,7 @@ Hermes Kanban's verbs, with their flags:
30
30
  comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
31
31
  complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
32
32
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
33
+ pause <id> --reason <why> resume <id> cancel <id> set-every <id> <interval|none>
33
34
  unblock <id>... [--reason] request-review <id> [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
34
35
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
35
36
  specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
@@ -270,7 +271,7 @@ export async function runWorkflow(argv) {
270
271
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
271
272
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
272
273
  maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
273
- sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
274
+ every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
274
275
  project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
275
276
  }), true);
276
277
  let answer = { id: created.id, existing: created.existing };
@@ -394,7 +395,8 @@ export async function runWorkflow(argv) {
394
395
  // the card's history, without the board's own bookkeeping (a claim, a session kept or started, a goal ask, a
395
396
  // message queued); `--all` shows every event, `--json` always does
396
397
  const PLUMBING = new Set(['claimed', 'session_started', 'session_resumed', 'session_adopted', 'session_replaced', 'session_closed',
397
- 'goal_set', 'goal_continued', 'decision_asked', 'board_message', 'board_message_sent', 'heartbeat', 'promoted', 'respawn_guarded']);
398
+ 'goal_set', 'goal_continued', 'decision_asked', 'board_message', 'board_message_sent', 'heartbeat', 'promoted', 'respawn_guarded',
399
+ 'claim_extended', 'reclaimed', 'reconciled', 'status']);
398
400
  const history = args.all ? card.events : card.events.filter((e) => !PLUMBING.has(e.kind));
399
401
  if (history.length) {
400
402
  out.push('', `Events (${history.length}${history.length < card.events.length ? ` of ${card.events.length}; --all shows every one` : ''}):`);
@@ -757,13 +759,31 @@ export async function runWorkflow(argv) {
757
759
  return report.status === 'corrupt' ? 1 : 0;
758
760
  }
759
761
 
762
+ case 'set-every': {
763
+ need(1, 'a card id');
764
+ await withBoardAsync((b) => engine.act(b, id, 'set_every', { payload: { every: rest[1] === 'none' ? null : rest[1] } }));
765
+ say(args, `${id} recurrence updated`, { ok: true }); return 0;
766
+ }
767
+ case 'cancel': {
768
+ need(1, 'a card id');
769
+ await withBoardAsync((b) => engine.act(b, id, 'cancel', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
770
+ say(args, `${id} cancelled`, { ok: true }); return 0;
771
+ }
760
772
  case 'pause': {
773
+ if (id) {
774
+ await withBoardAsync((b) => engine.act(b, id, 'pause', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
775
+ say(args, `${id} paused`, { ok: true }); return 0;
776
+ }
761
777
  // Hermes's emergency stop: new starts stop on every board of the home; running sessions go on
762
778
  writeFileSync(join(root, 'ESTOP'), `${JSON.stringify({ engaged_at: new Date().toISOString(), reason: rest.join(' ') || null })}\n`);
763
779
  say(args, `New starts paused (${join(root, 'ESTOP')}); \`supercode workflow resume\` lifts it`, { ok: true, paused: join(root, 'ESTOP') });
764
780
  return 0;
765
781
  }
766
782
  case 'resume': {
783
+ if (id) {
784
+ await withBoardAsync((b) => engine.act(b, id, 'resume'));
785
+ say(args, `${id} resumed`, { ok: true }); return 0;
786
+ }
767
787
  const lifted = [join(root, 'ESTOP'), join(process.env.HOME ?? '', '.hermes', 'ESTOP')].filter((p) => existsSync(p));
768
788
  for (const p of lifted) unlinkSync(p);
769
789
  say(args, lifted.length ? `Resumed (removed ${lifted.join(', ')})` : 'Not paused', { ok: true, lifted });
@@ -1030,7 +1050,7 @@ const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', '
1030
1050
  * is its `implementer`; the session working a subtask's arc is `arc`; any other session is nobody on it.
1031
1051
  */
1032
1052
  function rolesFor(db, cardId, sessionId, params = {}) {
1033
- if (!sessionId) return ['manager'];
1053
+ if (!sessionId) return ['manager', 'owner'];
1034
1054
  // a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
1035
1055
  const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
1036
1056
  if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
@@ -1053,7 +1073,7 @@ function authorize(db, verb, rest, args, own) {
1053
1073
  if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
1054
1074
  // 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
1055
1075
  // (what a session may do to a card is its workflow's: each transition's `by`)
1056
- 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`);
1076
+ if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'move', 'claim', 'attach-rm'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1057
1077
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1058
1078
  const card = engine.getTask(db, rest[0]);
1059
1079
  if (!card) return; // the verb itself answers "no card"
@@ -23,7 +23,7 @@ import { randomUUID } from 'node:crypto';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
25
25
  import { holdBoardTickLock } from '../hermes-locks.mjs';
26
- import { doors, fire, loadWorkflow, resumeOf, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
26
+ import { doors, fire, loadWorkflow, resumeOf, runSpec, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
27
27
  import { evaluate, holds, render } from './expr.mjs';
28
28
  import {
29
29
  cardContext, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
@@ -176,11 +176,11 @@ export function lanesOf(profiles = {}) {
176
176
  // ------------------------------------------------------------------ supervise
177
177
 
178
178
  function supervise(board, fleet, settings, report, entry, effects) {
179
- const rows = board.db.prepare("SELECT id FROM tasks WHERE status = 'running'").all();
179
+ const rows = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
180
180
  for (const { id } of rows) {
181
181
  tx(board, (db) => {
182
182
  const task = getTask(db, id);
183
- if (task?.status !== 'running') return;
183
+ if (!task || (entry.workflow.run ? !task.current_run_id : task.status !== 'running')) return;
184
184
  const runRow = currentRun(db, id);
185
185
  const session = runRow?.metadata?.supercode ?? null;
186
186
  const t = now();
@@ -268,7 +268,7 @@ function limitsOf(db, board, entry, task, session) {
268
268
  const status = statusOf(db, task);
269
269
  const scope = scopeOf(db, board, task, { params: entry.params, extra: { run: { surface: session?.surface ?? null, local: true } } });
270
270
  const out = {};
271
- for (const [name, expr] of Object.entries(entry.workflow.statuses[status]?.run?.limits ?? {})) {
271
+ for (const [name, expr] of Object.entries(runSpec(entry, status)?.limits ?? {})) {
272
272
  const v = evaluate(expr, scope);
273
273
  out[name] = v === null || v === undefined || v === '' ? null : Number(v);
274
274
  }
@@ -312,22 +312,24 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
312
312
  const d = workflow.dispatch;
313
313
  const pressure = memoryPressure();
314
314
  const tickState = { local_started: 0 };
315
- const startable = new Set(Object.entries(workflow.statuses).filter(([, st]) => st.on?.[d.start]).map(([name]) => name));
315
+ const startable = new Set(Object.keys(workflow.statuses).filter((status) => transitionsFor(workflow, status, d.start).length));
316
316
  const everyCard = board.db.prepare("SELECT id FROM tasks WHERE status != 'archived'").all().map((r) => getTask(board.db, r.id));
317
317
  const scopeFor = (task, extra = {}) => scopeOf(board.db, board, task, { params, extra: { host: hostOf(pressure), tick: tickState, ...extra } });
318
318
  // who would run it: the role of the status its start moves it to, and that role's lane
319
319
  const planOf = (task, scope) => {
320
320
  const status = statusOf(board.db, task);
321
- const t = (workflow.statuses[status]?.on?.[d.start] ?? []).find((x) => (!x.by?.length || x.by.includes('platform')) && holds(x.guard ?? null, scope));
322
- if (!t?.target) return null;
323
- const target = String(templateValue(t.target, scope));
324
- const runSpec = workflow.statuses[target]?.run ?? null;
325
- const profile = runSpec ? evaluate(workflow.roles?.[runSpec.role] ?? 'card.assignee', scope) : null;
326
- return { target, runSpec, profile, lane: profile ? lanes[profile] ?? null : null };
321
+ const t = transitionsFor(workflow, status, d.start).find((x) => (!x.by?.length || x.by.includes('platform')) && holds(x.guard ?? null, scope));
322
+ if (!t) return null;
323
+ const target = t.target ? String(templateValue(t.target, scope)) : status;
324
+ const spec = runSpec(entry, target);
325
+ const profile = spec ? evaluate(workflow.roles?.[spec.role] ?? 'card.assignee', scope) : null;
326
+ return { target, runSpec: spec, profile, lane: profile ? lanes[profile] ?? null : null };
327
327
  };
328
328
  const candidates = [];
329
329
  for (const task of everyCard) {
330
330
  if (only && task.id !== only) continue;
331
+ const state = scopeFor(task).card;
332
+ if (workflow.run && (task.subtask_of || state.paused || state.archived || state.blocked_by.length || task.current_run_id || (state.every && state.next_run_at > now()))) continue;
331
333
  if (!startable.has(statusOf(board.db, task))) continue;
332
334
  const plan = planOf(task, scopeFor(task));
333
335
  if (!plan) continue;
@@ -417,7 +419,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
417
419
  export function launchRun(board, entry, id, lane, fleet, settings) {
418
420
  const task = getTaskNow(board, id);
419
421
  const status = tx(board, (db) => statusOf(db, task));
420
- const spec = entry.workflow.statuses[status]?.run;
422
+ const spec = runSpec(entry, status);
421
423
  if (!spec) return null;
422
424
  const runId = task.current_run_id;
423
425
  const surface = lane.surface === 'pane' ? 'pane' : 'headless';
@@ -441,7 +443,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
441
443
  const fleet = observeFleet();
442
444
  const moved = tx(board, (db) => fire(db, board, entry, id, entry.workflow.dispatch.start, { roles, payload: { lock: claimer() } }));
443
445
  const task = getTaskNow(board, id);
444
- const spec = entry.workflow.statuses[moved.to]?.run;
446
+ const spec = runSpec(entry, moved.to);
445
447
  const profile = spec ? tx(board, (db) => evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scopeOf(db, board, task, { params: entry.params }))) : null;
446
448
  const lane = profile ? lanes[profile] : null;
447
449
  if (spec && !lane) throw new Error(`${id} is ${moved.to}, run by ${spec.role} (${profile ?? 'nobody'}), which is no lane of this home`);
@@ -469,11 +471,11 @@ function closeRequested(board, report) {
469
471
 
470
472
  function observeIdle(board, fleet, settings, report, entry, effects) {
471
473
  if (!fleet.ok) return;
472
- for (const { id } of board.db.prepare("SELECT id FROM tasks WHERE status = 'running'").all()) {
474
+ for (const { id } of board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all()) {
473
475
  tx(board, (db) => {
474
476
  const task = getTask(db, id);
475
477
  const status = statusOf(db, task);
476
- if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle)) return;
478
+ if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
477
479
  const runRow = currentRun(db, id);
478
480
  const session = runRow?.metadata?.supercode;
479
481
  if (!session?.session_id || session.surface !== 'pane') return;
@@ -487,7 +489,7 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
487
489
  try {
488
490
  const where = whereOf(board, task);
489
491
  // the ask names what the session may do, as its run's prompt did
490
- const said = doors(entry.workflow, status, session.role ?? entry.workflow.statuses[status]?.run?.role ?? 'implementer', id, where, session.surface === 'pane' ? 'pane' : 'headless');
492
+ const said = doors(entry.workflow, status, session.role ?? runSpec(entry, status)?.role ?? 'implementer', id, where, session.surface === 'pane' ? 'pane' : 'headless');
491
493
  const r = fire(db, board, entry, id, 'session_idle', { roles: ['platform'], payload: { turns: Number(session.goal_turns ?? 0), worked }, extra: { where, doors: said } });
492
494
  effects.push(...r.effects);
493
495
  const after = currentRun(db, id);
@@ -532,7 +534,7 @@ export async function sessionEffect(board, e) {
532
534
 
533
535
  // ------------------------------------------------------------------ start
534
536
 
535
- const running = (db, sql, ...args) => Number(db.prepare(`SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status = 'running' ${sql}`).get(...args).n);
537
+ const running = (db, sql, ...args) => Number(db.prepare(`SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.current_run_id IS NOT NULL ${sql}`).get(...args).n);
536
538
 
537
539
  /**
538
540
  * How many sessions the board has on `machine`: every running card's, and every other card's latest session that is
@@ -542,7 +544,7 @@ function machineLoad(db, machine, fleet) {
542
544
  let n = 0;
543
545
  for (const card of db.prepare("SELECT t.id, t.status, c.machine FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.machine IS NOT NULL AND t.status != 'archived'").all()) {
544
546
  if (normalMachine(card.machine) !== machine) continue;
545
- if (card.status === 'running' || (fleet.ok && sessionAlive(fleet, lastSession(db, card.id)))) n += 1;
547
+ if (card.current_run_id || (fleet.ok && sessionAlive(fleet, lastSession(db, card.id)))) n += 1;
546
548
  }
547
549
  return n;
548
550
  }
@@ -1213,7 +1215,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1213
1215
  // the running cards of every board of the home, for kanban.max_in_progress (host-wide, as Hermes counts it)
1214
1216
  let hostRunning = 0;
1215
1217
  for (const slug of boards) {
1216
- try { const b = openBoard(root, slug); try { hostRunning += Number(b.db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE status = 'running'").get().n); } finally { b.close(); } } catch { /* its own tick reports it */ }
1218
+ try { const b = openBoard(root, slug); try { hostRunning += Number(b.db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE current_run_id IS NOT NULL").get().n); } finally { b.close(); } } catch { /* its own tick reports it */ }
1217
1219
  }
1218
1220
  const out = [];
1219
1221
  for (const slug of boards) {
package/board/engine.mjs CHANGED
@@ -12,7 +12,7 @@ import { existsSync, rmSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { DatabaseSync } from 'node:sqlite';
14
14
  import { claimer, event, json, newTaskId, now, tx } from './store.mjs';
15
- import { eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, workflowFor } from './workflow.mjs';
15
+ import { eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
16
16
 
17
17
  export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
18
18
  export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
@@ -34,8 +34,8 @@ const run = (db, sql, ...args) => db.prepare(sql).run(...args);
34
34
  export function getTask(db, id) {
35
35
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
36
36
  if (!row) return null;
37
- const ours = one(db, 'SELECT machine, subtask_of FROM supercode_cards WHERE task_id = ?', id);
38
- return { ...row, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
37
+ const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
38
+ return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
39
39
  }
40
40
 
41
41
  function must(db, id) {
@@ -195,7 +195,7 @@ export function createCard(board, spec) {
195
195
  const entry = workflowFor(board.root);
196
196
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
197
197
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
198
- goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
198
+ every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
199
199
  event(db, id, 'created', { assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
200
200
  if (parents.length) inheritSubscriptions(db, id, parents);
201
201
  if (arc) {
@@ -271,12 +271,12 @@ export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStat
271
271
 
272
272
  /** Extend a running card's claim (the dispatcher keeps the claim of a session it sees live). */
273
273
  export function extendClaim(db, id, { ttl, reason, extra = {} }) {
274
- const task = one(db, 'SELECT claim_lock, current_run_id FROM tasks WHERE id = ? AND status = ?', id, 'running');
274
+ const task = one(db, 'SELECT claim_lock, current_run_id FROM tasks WHERE id = ? AND current_run_id IS NOT NULL', id);
275
275
  if (!task) return false;
276
276
  const expires = now() + ttl;
277
- run(db, 'UPDATE tasks SET claim_expires = ? WHERE id = ? AND status = ?', expires, id, 'running');
277
+ run(db, 'UPDATE tasks SET claim_expires = ? WHERE id = ?', expires, id);
278
278
  if (task.current_run_id) run(db, 'UPDATE task_runs SET claim_expires = ? WHERE id = ?', expires, task.current_run_id);
279
- event(db, id, 'claim_extended', { reason, claim_expires_now: expires, ...extra }, task.current_run_id);
279
+ runEvent(db, task.current_run_id, 'claim_extended', { reason, claim_expires_now: expires, ...extra });
280
280
  return true;
281
281
  }
282
282
 
package/board/store.mjs CHANGED
@@ -70,7 +70,7 @@ CREATE INDEX IF NOT EXISTS idx_events_run ON task_events(run_id, id);
70
70
  const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY KEY, machine TEXT, subtask_of TEXT, stage TEXT);`;
71
71
  // `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
72
72
  // `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
73
- const OURS_COLUMNS = { subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT' };
73
+ const OURS_COLUMNS = { subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER' };
74
74
 
75
75
  // ------------------------------------------------------------------ backings
76
76
  //
@@ -199,6 +199,7 @@ export function json(text, fallback = null) {
199
199
  // the session whose command this process runs (its address), set by the CLI: each event it records says who acted
200
200
  // (`by`), so the session is not told of its own act
201
201
  let actor = null;
202
+ export const actorOf = () => actor;
202
203
  export function setActor(address) { actor = address || null; }
203
204
 
204
205
  export function event(db, taskId, kind, payload = null, runId = null) {
@@ -5,8 +5,10 @@
5
5
  // the engine's primitives (the rows Hermes's tables hold); the I/O an action asks for (stopping a worker, closing a
6
6
  // pane, sending a session a message, cleaning a workspace) is answered as effects the caller runs after commit.
7
7
  import { spawnSync } from 'node:child_process';
8
+ import { readFileSync } from 'node:fs';
9
+ import { resolve, relative, isAbsolute } from 'node:path';
8
10
  import { randomBytes } from 'node:crypto';
9
- import { event as record, json, now } from './store.mjs';
11
+ import { actorOf, event as record, json, now } from './store.mjs';
10
12
  import { evaluate, holds, render } from './expr.mjs';
11
13
 
12
14
  const loaded = new Map();
@@ -17,6 +19,7 @@ export async function loadWorkflow(root, { fresh = false } = {}) {
17
19
  const { read } = await import('../orchestration.mjs');
18
20
  const orchestration = await read(root);
19
21
  const entry = workflowEntry(orchestration);
22
+ entry.root = root;
20
23
  loaded.set(root, entry);
21
24
  return entry;
22
25
  }
@@ -38,6 +41,7 @@ export function workflowFor(root) {
38
41
 
39
42
  /** Use `entry` for `root` (a caller that read the IR itself). */
40
43
  export function useWorkflow(root, entry) {
44
+ entry.root = root;
41
45
  loaded.set(root, entry);
42
46
  return entry;
43
47
  }
@@ -157,13 +161,31 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
157
161
  const waitingOn = lastBlock?.kind === 'blocked' ? json(lastBlock.payload, {})?.waiting_on ?? null : null;
158
162
  const currentRun = task.current_run_id ? one(db, 'SELECT metadata FROM task_runs WHERE id = ?', task.current_run_id) : null;
159
163
  const session = json(currentRun?.metadata, {})?.supercode ?? {};
164
+ const state = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', task.id) ?? {};
165
+ const blocks = json(state.blocked_by, []) ?? [];
160
166
  const card = {
161
167
  ...task,
162
168
  status,
169
+ blocked_by: blocks,
170
+ paused: json(state.paused, null),
171
+ archived: Boolean(state.archived),
172
+ every: state.every ?? null,
173
+ next_run_at: state.next_run_at ?? null,
174
+
163
175
  stored_as: task.status,
164
176
  board: board.slug,
165
177
  parents,
166
178
  children,
179
+ acceptance_complete: () => {
180
+ const lines = String(task.body ?? '').split('\n');
181
+ let acceptance = false;
182
+ return lines.every((line) => {
183
+ const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
184
+ if (heading) acceptance = heading[1].toLowerCase() === 'acceptance';
185
+ if (!acceptance || !/^\s*[-*] \[[ xX]\]/.test(line)) return true;
186
+ return /^\s*[-*] \[[xX]\] .+: \S.+$/.test(line);
187
+ });
188
+ },
167
189
  counters: { failures: Number(task.consecutive_failures || 0), block_recurrences: Number(task.block_recurrences || 0), goal_turns: Number(session.goal_turns || 0) },
168
190
  goal_mode: Boolean(task.goal_mode),
169
191
  implementer: asked.implementer ?? task.assignee,
@@ -199,6 +221,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
199
221
  return lastCommitAt(own ?? arcWs ?? null, task.id);
200
222
  },
201
223
  };
224
+ for (const [key, expr] of Object.entries(loaded.get(board.root)?.workflow?.derived ?? {})) card[key] = evaluate(expr, { card, event, params, now: now() });
202
225
  return {
203
226
  card,
204
227
  event,
@@ -227,7 +250,7 @@ const argsValue = (args, scope) => {
227
250
 
228
251
  /** The transitions `name` may take from `status`: the status's own, then the workflow's for every status. */
229
252
  export function transitionsFor(workflow, status, name) {
230
- return [...(workflow.statuses[status]?.on?.[name] ?? []), ...(workflow.on?.[name] ?? [])];
253
+ return [...(workflow.verbs?.[name] ?? []).filter((t) => !t.from?.length || t.from.includes(status)), ...(workflow.statuses[status]?.on?.[name] ?? []), ...(workflow.on?.[name] ?? [])];
231
254
  }
232
255
 
233
256
  /**
@@ -237,6 +260,7 @@ export function transitionsFor(workflow, status, name) {
237
260
  */
238
261
  export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
239
262
  const { workflow, params } = entry;
263
+ if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return fireRun(db, board, entry, id, name, { roles, payload, extra });
240
264
  const task = cardOf(db, id);
241
265
  const from = statusOf(db, task);
242
266
  if (!workflow.statuses[from]) throw new Error(`${id} is ${from}, which the board's workflow does not declare`);
@@ -270,7 +294,7 @@ export function eventTo(db, board, entry, id, status, { roles = [], payload = {}
270
294
  const from = statusOf(db, task);
271
295
  const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), sender: roles } });
272
296
  let refusal = null;
273
- for (const name of new Set([...Object.keys(workflow.statuses[from]?.on ?? {}), ...Object.keys(workflow.on ?? {})])) {
297
+ for (const name of new Set([...Object.keys(workflow.statuses[from]?.on ?? {}), ...Object.keys(workflow.on ?? {}), ...Object.keys(workflow.verbs ?? {})])) {
274
298
  const taken = transitionsFor(workflow, from, name).filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r))).find((t) => holds(t.guard ?? null, scope));
275
299
  if (!taken?.target || String(templateValue(taken.target, scope) ?? '') !== status) continue;
276
300
  const failed = (taken.validate ?? []).find((v) => !holds(v.expr, scope));
@@ -283,7 +307,7 @@ export function eventTo(db, board, entry, id, status, { roles = [], payload = {}
283
307
  function cardOf(db, id) {
284
308
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
285
309
  if (!row) throw new Error(`no card ${id}`);
286
- const ours = one(db, 'SELECT machine, subtask_of FROM supercode_cards WHERE task_id = ?', id);
310
+ const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
287
311
  return { ...row, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
288
312
  }
289
313
 
@@ -299,7 +323,7 @@ function take(db, board, entry, task, from, transition, scope, effects) {
299
323
  for (const a of transition.actions ?? []) act(ctx, a);
300
324
  if (moves) {
301
325
  for (const a of workflow.statuses[target].entry ?? []) act(ctx, a);
302
- if (workflow.statuses[target].run) effects.push({ kind: 'run', id: task.id, status: target });
326
+ if (workflow.statuses[target].run || workflow.statuses[target].run_role) effects.push({ kind: 'run', id: task.id, status: target });
303
327
  }
304
328
  return target ?? from;
305
329
  }
@@ -315,6 +339,7 @@ export function setStatus(db, workflow, id, status) {
315
339
  /** Take every `always` transition that holds, from wherever the card is, until none does. */
316
340
  export function settle(db, board, entry, id, effects = []) {
317
341
  const { workflow, params } = entry;
342
+ settleRelations(db, board, entry, id, effects);
318
343
  for (let hops = 0; hops < 16; hops += 1) {
319
344
  const task = cardOf(db, id);
320
345
  const from = statusOf(db, task);
@@ -366,6 +391,7 @@ export function startNew(db, board, entry, task, event) {
366
391
  setStatus(db, entry.workflow, task.id, status);
367
392
  const ctx = { db, board, entry, id: task.id, scope: { ...scope, before: scope.card }, from: status, to: status, effects: [], runId: null };
368
393
  for (const a of t.actions ?? []) act(ctx, a);
394
+ if (event.every != null) ACTIONS.every(ctx, event.every);
369
395
  return status;
370
396
  }
371
397
 
@@ -419,6 +445,51 @@ const FORMAT = {
419
445
  const RETRY_KINDS = new Set(['crashed', 'timed_out', 'stale', 'rate_limited', 'session_lost', 'reclaimed', 'reconciled', 'spawn_failed']);
420
446
 
421
447
  const ACTIONS = {
448
+ relation(ctx, { add = null, remove = null, reason = null } = {}) {
449
+ const row = one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', ctx.id);
450
+ let blocks = json(row?.blocked_by, []) ?? [];
451
+ if (add) {
452
+ const id = String(add);
453
+ if (id === ctx.id) throw new Error('a card cannot block itself');
454
+ const kind = id.startsWith('m-') ? 'message' : 'card';
455
+ if (kind === 'card' && !one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', id)) throw new Error(`no blocking card ${id}`);
456
+ if (kind === 'card') {
457
+ const seen = new Set(); const pending = [id];
458
+ while (pending.length) {
459
+ const node = pending.pop();
460
+ if (node === ctx.id) throw new Error('blocking relation would create a cycle');
461
+ if (seen.has(node)) continue;
462
+ seen.add(node);
463
+ const related = json(one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', node)?.blocked_by, []) ?? [];
464
+ pending.push(...related.filter((b) => b.kind === 'card').map((b) => b.id));
465
+ }
466
+ }
467
+ if (!blocks.some((b) => b.id === id)) blocks.push({ id, kind, reason, by: actorOf(), since: now() });
468
+ }
469
+ if (remove) {
470
+ if (!String(reason ?? '').trim()) throw new Error('unblock must say what arrived');
471
+ blocks = blocks.filter((b) => remove !== '*' && b.id !== remove);
472
+ }
473
+ cardField(ctx, 'blocked_by', JSON.stringify(blocks));
474
+ },
475
+ flag(ctx, { name, value, reason = null }) {
476
+ if (!['paused', 'archived'].includes(name)) throw new Error(`unknown flag ${name}`);
477
+ if (name === 'paused') {
478
+ const was = json(one(ctx.db, 'SELECT paused FROM supercode_cards WHERE task_id = ?', ctx.id)?.paused, null);
479
+ if (!value && was && was.by !== actorOf() && !ctx.scope.sender?.includes('owner')) throw new Error('only the pauser or owner may resume this card');
480
+ if (value && !String(reason ?? '').trim()) throw new Error('pause needs a reason');
481
+ cardField(ctx, name, value ? JSON.stringify({ by: actorOf(), reason }) : null);
482
+ } else cardField(ctx, name, Number(Boolean(value)));
483
+ },
484
+ every(ctx, interval) {
485
+ intervalSeconds(interval); // refuse malformed intervals before storing
486
+ cardField(ctx, 'every', interval == null ? null : String(interval));
487
+ cardField(ctx, 'next_run_at', interval == null ? null : now());
488
+ },
489
+ card_event(ctx, { name, payload = {} }) {
490
+ const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: ['platform'], payload, extra: ctx.scope });
491
+ ctx.effects.push(...result.effects);
492
+ },
422
493
  record(ctx, args, scope) {
423
494
  const kind = typeof args === 'string' ? args : args?.kind;
424
495
  const given = typeof args === 'object' && args?.payload ? args.payload : null;
@@ -436,7 +507,9 @@ const ACTIONS = {
436
507
  // `on`: the event is another card's (a dependency hears what waits on it), recorded there with no run
437
508
  const on = typeof args === 'object' && args?.on ? String(args.on) : null;
438
509
  if (on && !one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', on)) return;
439
- record(ctx.db, on ?? ctx.id, kind, payload && Object.keys(payload).length ? payload : null, on ? null : ctx.runId);
510
+ if (ctx.machine === 'run' && !on) {
511
+ runEvent(ctx.db, ctx.runId, kind, payload);
512
+ } else record(ctx.db, on ?? ctx.id, kind, payload && Object.keys(payload).length ? payload : null, on ? null : ctx.runId);
440
513
  },
441
514
  assign(ctx, { field, value }) {
442
515
  if (!ASSIGNABLE.has(field)) throw new Error(`the workflow assigns ${field}, which is not a card field it may set`);
@@ -482,6 +555,7 @@ const ACTIONS = {
482
555
  const task = cardOf(ctx.db, ctx.id);
483
556
  const t = now();
484
557
  const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
558
+ if (ctx.entry.workflow.run && (task.subtask_of || scope.card.paused || scope.card.archived || scope.card.blocked_by.length)) throw new Error('this card cannot run while blocked, paused, archived, or a subtask');
485
559
  const lock = ctx.scope.event?.lock;
486
560
  if (!lock) throw new Error('a claim needs the dispatcher\'s lock');
487
561
  if (task.current_run_id) {
@@ -489,13 +563,14 @@ const ACTIONS = {
489
563
  }
490
564
  const expires = t + ttl;
491
565
  const profile = evaluate(ctx.entry.workflow.roles?.[role] ?? 'card.assignee', scope) ?? task.assignee;
492
- const slot = String(templateValue(ctx.entry.workflow.statuses[ctx.to]?.run?.session ?? 'worker', scope) ?? 'worker');
566
+ const slot = String(templateValue(runSpec(ctx.entry, ctx.to)?.session ?? 'worker', scope) ?? 'worker');
493
567
  run(ctx.db, 'UPDATE tasks SET claim_lock = ?, claim_expires = ?, started_at = COALESCE(started_at, ?) WHERE id = ?', lock, expires, t, ctx.id);
494
568
  const r = run(ctx.db, "INSERT INTO task_runs (task_id, profile, step_key, status, claim_lock, claim_expires, max_runtime_seconds, started_at, metadata) VALUES (?, ?, ?, 'running', ?, ?, ?, ?, ?)",
495
- ctx.id, profile, ctx.to, lock, expires, task.max_runtime_seconds, t, JSON.stringify({ supercode: { role, status: ctx.to, slot } }));
569
+ ctx.id, profile, ctx.to, lock, expires, task.max_runtime_seconds, t, JSON.stringify({ supercode: { role, status: ctx.to, slot, state: ctx.entry.workflow.run?.initial ?? null } }));
496
570
  ctx.runId = Number(r.lastInsertRowid);
497
571
  run(ctx.db, 'UPDATE tasks SET current_run_id = ? WHERE id = ?', ctx.runId, ctx.id);
498
- record(ctx.db, ctx.id, 'claimed', { lock, expires, run_id: ctx.runId, source_status: resumeOf(ctx.entry.workflow, ctx.to) }, ctx.runId);
572
+ if (scope.card.every) cardField(ctx, 'next_run_at', t + intervalSeconds(scope.card.every));
573
+ if (!ctx.entry.workflow.run) record(ctx.db, ctx.id, 'claimed', { lock, expires, run_id: ctx.runId, source_status: resumeOf(ctx.entry.workflow, ctx.to) }, ctx.runId);
499
574
  },
500
575
  extend_claim(ctx) {
501
576
  const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
@@ -601,7 +676,7 @@ const TOOL_OF = { complete: 'kanban_complete', block: 'kanban_block', request_re
601
676
 
602
677
  export function doors(workflow, status, role, id, where, surface = 'pane') {
603
678
  const events = new Map();
604
- for (const [name, ts] of [...Object.entries(workflow.statuses[status]?.on ?? {}), ...Object.entries(workflow.on ?? {})]) {
679
+ for (const [name, ts] of [...Object.entries(workflow.statuses[status]?.on ?? {}), ...Object.entries(workflow.on ?? {}), ...Object.entries(workflow.run?.on ?? {}), ...Object.entries(workflow.verbs ?? {}).map(([name, ts]) => [name, ts.filter((t) => !t.from?.length || t.from.includes(status))])]) {
605
680
  for (const t of ts) {
606
681
  if (!t.by?.length || !t.by.includes(role)) continue;
607
682
  if (!events.has(name)) events.set(name, t.description ?? null);
@@ -612,3 +687,78 @@ export function doors(workflow, status, role, id, where, surface = 'pane') {
612
687
  .map(([name, what]) => (surface === 'pane' ? `- \`supercode workflow ${name.replace(/_/g, '-')} ${id} … ${where}\`` : `- \`${TOOL_OF[name]}\``) + (what ? `: ${what}` : ''));
613
688
  return ['What you may do with the card:', ...lines].join('\n');
614
689
  }
690
+
691
+
692
+ function cardField(ctx, field, value) {
693
+ run(ctx.db, `INSERT INTO supercode_cards (task_id, ${field}) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET ${field} = excluded.${field}`, ctx.id, value);
694
+ }
695
+
696
+ export function intervalSeconds(value) {
697
+ if (value == null) return null;
698
+ const match = /^(\d+(?:\.\d+)?)\s*(s|m|h|d|w)?$/.exec(String(value));
699
+ const seconds = match && Number(match[1]) * ({ s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[match[2] ?? 's']);
700
+ if (!(seconds > 0)) throw new Error(`invalid recurring interval ${value}`);
701
+ return seconds;
702
+ }
703
+
704
+ export function runSpec(entry, status) {
705
+ const st = entry.workflow.statuses[status];
706
+ const spec = st?.run_role ? entry.workflow.run?.roles?.[st.run_role] : st?.run;
707
+ if (!spec?.prompt_file) return spec;
708
+ const file = resolve(entry.root, spec.prompt_file);
709
+ const rel = relative(entry.root, file);
710
+ if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('prompt file must live in the orchestration home');
711
+ return { ...spec, prompt: readFileSync(file, 'utf8') };
712
+ }
713
+
714
+ export function runEvent(db, id, kind, payload) {
715
+ if (!id) return;
716
+ const row = one(db, 'SELECT metadata FROM task_runs WHERE id = ?', id);
717
+ const meta = json(row?.metadata, {}) ?? {};
718
+ meta.events = [...(meta.events ?? []), { kind, payload, by: actorOf(), at: now() }];
719
+ run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(meta), id);
720
+ }
721
+
722
+ export function fireRun(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
723
+ const task = cardOf(db, id);
724
+ const row = task.current_run_id && one(db, 'SELECT * FROM task_runs WHERE id = ?', task.current_run_id);
725
+ if (!row) throw new Error(`${id} has no current run for ${name}`);
726
+ const machine = entry.workflow.run;
727
+ const meta = json(row.metadata, {}) ?? {};
728
+ const from = meta.supercode?.state ?? machine.initial;
729
+ const scope = scopeOf(db, board, task, { params: entry.params, event: payload, extra: { ...extra, sender: roles, run: { ...row, state: from } } });
730
+ const candidates = [...(machine.states[from]?.on?.[name] ?? []), ...(machine.on?.[name] ?? [])];
731
+ const t = candidates.find((t) => (!t.by?.length || t.by.some((r) => roles.includes(r))) && holds(t.guard ?? null, scope));
732
+ if (!t) { const error = new Error(`${id}: run event ${name} does not apply`); error.noTransition = true; throw error; }
733
+ for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw new Error(v.message);
734
+ const to = t.target ? String(templateValue(t.target, scope)) : from;
735
+ if (!machine.states[to]) throw new Error(`unknown run state ${to}`);
736
+ const effects = [];
737
+ const status = statusOf(db, task);
738
+ const ctx = { db, board, entry, id, scope: { ...scope, before: scope.card }, from: status, to: status, effects, runId: row.id, machine: 'run' };
739
+ if (to !== from) for (const a of machine.states[from]?.exit ?? []) act(ctx, a);
740
+ runSession(ctx, (s) => ({ ...s, state: to }));
741
+ runEvent(db, row.id, name, payload);
742
+ for (const a of t.actions ?? []) act(ctx, a);
743
+ if (to !== from) for (const a of machine.states[to]?.entry ?? []) act(ctx, a);
744
+ if (to !== from) {
745
+ const after = scopeOf(db, board, cardOf(db, id), { params: entry.params, event: payload, extra: { ...extra, sender: roles } });
746
+ const outcome = (machine.outcomes?.[to] ?? []).find((t) => holds(t.guard ?? null, after));
747
+ if (outcome) take(db, board, entry, cardOf(db, id), statusOf(db, cardOf(db, id)), outcome, after, effects);
748
+ }
749
+ return { from: status, to: statusOf(db, cardOf(db, id)), effects };
750
+ }
751
+
752
+ function settleRelations(db, board, entry, id, effects) {
753
+ if (!entry.workflow.relations?.blocked_by) return;
754
+ const row = one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id);
755
+ const blocks = json(row?.blocked_by, []) ?? [];
756
+ for (const block of blocks) {
757
+ const dependency = block.kind === 'card' ? one(db, 'SELECT * FROM tasks WHERE id = ?', block.id) : null;
758
+ const status = dependency ? statusOf(db, dependency) : null;
759
+ const answer = block.kind === 'message' ? answerTo(block.id) : null;
760
+ const policy = entry.workflow.relations.blocked_by;
761
+ const name = answer ? policy.answered : status === 'done' ? policy.completed : status === 'cancelled' ? policy.cancelled : null;
762
+ if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}` } }).effects);
763
+ }
764
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.73",
3
+ "version": "0.4.75",
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": {