@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 +24 -4
- package/board/dispatch.mjs +21 -19
- package/board/engine.mjs +7 -7
- package/board/store.mjs +2 -1
- package/board/workflow.mjs +160 -10
- package/package.json +1 -1
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'
|
|
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"
|
package/board/dispatch.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
|
322
|
-
if (!t
|
|
323
|
-
const target = String(templateValue(t.target, scope));
|
|
324
|
-
const
|
|
325
|
-
const profile =
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 = ?
|
|
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
|
-
|
|
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) {
|
package/board/workflow.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": {
|