@volter/supercode-orchestrator 0.4.74 → 0.4.76
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 +22 -3
- package/board/dispatch.mjs +21 -19
- package/board/engine.mjs +8 -8
- package/board/store.mjs +13 -1
- package/board/workflow.mjs +179 -11
- 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 };
|
|
@@ -758,13 +759,31 @@ export async function runWorkflow(argv) {
|
|
|
758
759
|
return report.status === 'corrupt' ? 1 : 0;
|
|
759
760
|
}
|
|
760
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
|
+
}
|
|
761
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
|
+
}
|
|
762
777
|
// Hermes's emergency stop: new starts stop on every board of the home; running sessions go on
|
|
763
778
|
writeFileSync(join(root, 'ESTOP'), `${JSON.stringify({ engaged_at: new Date().toISOString(), reason: rest.join(' ') || null })}\n`);
|
|
764
779
|
say(args, `New starts paused (${join(root, 'ESTOP')}); \`supercode workflow resume\` lifts it`, { ok: true, paused: join(root, 'ESTOP') });
|
|
765
780
|
return 0;
|
|
766
781
|
}
|
|
767
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
|
+
}
|
|
768
787
|
const lifted = [join(root, 'ESTOP'), join(process.env.HOME ?? '', '.hermes', 'ESTOP')].filter((p) => existsSync(p));
|
|
769
788
|
for (const p of lifted) unlinkSync(p);
|
|
770
789
|
say(args, lifted.length ? `Resumed (removed ${lifted.join(', ')})` : 'Not paused', { ok: true, lifted });
|
|
@@ -1031,7 +1050,7 @@ const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', '
|
|
|
1031
1050
|
* is its `implementer`; the session working a subtask's arc is `arc`; any other session is nobody on it.
|
|
1032
1051
|
*/
|
|
1033
1052
|
function rolesFor(db, cardId, sessionId, params = {}) {
|
|
1034
|
-
if (!sessionId) return ['manager'];
|
|
1053
|
+
if (!sessionId) return ['manager', 'owner'];
|
|
1035
1054
|
// a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
|
|
1036
1055
|
const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
|
|
1037
1056
|
if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
|
|
@@ -1054,7 +1073,7 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1054
1073
|
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
1055
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
|
|
1056
1075
|
// (what a session may do to a card is its workflow's: each transition's `by`)
|
|
1057
|
-
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`);
|
|
1058
1077
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1059
1078
|
const card = engine.getTask(db, rest[0]);
|
|
1060
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) {
|
|
@@ -105,7 +105,7 @@ export function currentRun(db, taskId) {
|
|
|
105
105
|
export function setRunSession(db, runId, session) {
|
|
106
106
|
const row = one(db, 'SELECT metadata FROM task_runs WHERE id = ?', runId);
|
|
107
107
|
const whole = json(row?.metadata, {}) ?? {};
|
|
108
|
-
whole.supercode = session;
|
|
108
|
+
whole.supercode = { ...whole.supercode, ...session };
|
|
109
109
|
run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(whole), runId);
|
|
110
110
|
return whole;
|
|
111
111
|
}
|
|
@@ -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,9 +199,21 @@ 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) {
|
|
206
|
+
// Session observations belong to the run machine. Legacy runs without a machine state retain
|
|
207
|
+
// their Hermes event stream; a new instance never sends supervision as card notices.
|
|
208
|
+
if (runId && ['claimed', 'spawned', 'session_started', 'session_resumed', 'session_replaced', 'session_adopted', 'claim_extended'].includes(kind)) {
|
|
209
|
+
const row = db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId);
|
|
210
|
+
const meta = json(row?.metadata, {}) ?? {};
|
|
211
|
+
if (meta.supercode?.state) {
|
|
212
|
+
meta.events = [...(meta.events ?? []), { kind, payload, by: actor, at: now() }];
|
|
213
|
+
db.prepare('UPDATE task_runs SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), runId);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
205
217
|
const said = actor && !(payload && 'by' in payload) ? { ...(payload ?? {}), by: actor } : payload;
|
|
206
218
|
const r = db.prepare('INSERT INTO task_events (task_id, run_id, kind, payload, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
207
219
|
.run(taskId, runId, kind, said ? JSON.stringify(said) : null, now());
|
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 { existsSync, 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
|
}
|
|
@@ -60,6 +64,7 @@ export function storedAs(workflow, status) {
|
|
|
60
64
|
|
|
61
65
|
/** Where a card interrupted in `status` resumes: its `exited` transition's unguarded target (data, not a rule here). */
|
|
62
66
|
export function resumeOf(workflow, status) {
|
|
67
|
+
if (workflow.statuses[status]?.resume_as) return workflow.statuses[status].resume_as;
|
|
63
68
|
const exited = workflow.statuses[status]?.on?.exited ?? [];
|
|
64
69
|
const fallback = [...exited].reverse().find((t) => !t.guard && t.target && !t.target.includes('{{'));
|
|
65
70
|
return fallback?.target ?? status;
|
|
@@ -157,13 +162,31 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
|
|
|
157
162
|
const waitingOn = lastBlock?.kind === 'blocked' ? json(lastBlock.payload, {})?.waiting_on ?? null : null;
|
|
158
163
|
const currentRun = task.current_run_id ? one(db, 'SELECT metadata FROM task_runs WHERE id = ?', task.current_run_id) : null;
|
|
159
164
|
const session = json(currentRun?.metadata, {})?.supercode ?? {};
|
|
165
|
+
const state = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', task.id) ?? {};
|
|
166
|
+
const blocks = json(state.blocked_by, []) ?? [];
|
|
160
167
|
const card = {
|
|
161
168
|
...task,
|
|
162
169
|
status,
|
|
170
|
+
blocked_by: blocks,
|
|
171
|
+
paused: json(state.paused, null),
|
|
172
|
+
archived: Boolean(state.archived),
|
|
173
|
+
every: state.every ?? null,
|
|
174
|
+
next_run_at: state.next_run_at ?? null,
|
|
175
|
+
|
|
163
176
|
stored_as: task.status,
|
|
164
177
|
board: board.slug,
|
|
165
178
|
parents,
|
|
166
179
|
children,
|
|
180
|
+
acceptance_complete: () => {
|
|
181
|
+
const lines = String(task.body ?? '').split('\n');
|
|
182
|
+
let acceptance = false;
|
|
183
|
+
return lines.every((line) => {
|
|
184
|
+
const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
|
|
185
|
+
if (heading) acceptance = heading[1].toLowerCase() === 'acceptance';
|
|
186
|
+
if (!acceptance || !/^\s*[-*] \[[ xX]\]/.test(line)) return true;
|
|
187
|
+
return /^\s*[-*] \[[xX]\] .+: \S.+$/.test(line);
|
|
188
|
+
});
|
|
189
|
+
},
|
|
167
190
|
counters: { failures: Number(task.consecutive_failures || 0), block_recurrences: Number(task.block_recurrences || 0), goal_turns: Number(session.goal_turns || 0) },
|
|
168
191
|
goal_mode: Boolean(task.goal_mode),
|
|
169
192
|
implementer: asked.implementer ?? task.assignee,
|
|
@@ -199,6 +222,9 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
|
|
|
199
222
|
return lastCommitAt(own ?? arcWs ?? null, task.id);
|
|
200
223
|
},
|
|
201
224
|
};
|
|
225
|
+
const runMeta = json(currentRun?.metadata, {}) ?? {};
|
|
226
|
+
for (const e of runMeta.events ?? []) card.last_events[e.kind] = { created_at: e.at, payload: e.payload ?? {} };
|
|
227
|
+
for (const [key, expr] of Object.entries(loaded.get(board.root)?.workflow?.derived ?? {})) card[key] = evaluate(expr, { card, event, params, now: now() });
|
|
202
228
|
return {
|
|
203
229
|
card,
|
|
204
230
|
event,
|
|
@@ -227,7 +253,7 @@ const argsValue = (args, scope) => {
|
|
|
227
253
|
|
|
228
254
|
/** The transitions `name` may take from `status`: the status's own, then the workflow's for every status. */
|
|
229
255
|
export function transitionsFor(workflow, status, name) {
|
|
230
|
-
return [...(workflow.statuses[status]?.on?.[name] ?? []), ...(workflow.on?.[name] ?? [])];
|
|
256
|
+
return [...(workflow.verbs?.[name] ?? []).filter((t) => !t.from?.length || t.from.includes(status)), ...(workflow.statuses[status]?.on?.[name] ?? []), ...(workflow.on?.[name] ?? [])];
|
|
231
257
|
}
|
|
232
258
|
|
|
233
259
|
/**
|
|
@@ -237,6 +263,7 @@ export function transitionsFor(workflow, status, name) {
|
|
|
237
263
|
*/
|
|
238
264
|
export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
|
|
239
265
|
const { workflow, params } = entry;
|
|
266
|
+
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
267
|
const task = cardOf(db, id);
|
|
241
268
|
const from = statusOf(db, task);
|
|
242
269
|
if (!workflow.statuses[from]) throw new Error(`${id} is ${from}, which the board's workflow does not declare`);
|
|
@@ -254,6 +281,7 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
254
281
|
const effects = [];
|
|
255
282
|
const to = take(db, board, entry, task, from, taken, scope, effects);
|
|
256
283
|
settle(db, board, entry, id, effects);
|
|
284
|
+
projectStatus(db, board, entry, id);
|
|
257
285
|
return { from, to: statusOf(db, cardOf(db, id)), moved: to !== from, effects };
|
|
258
286
|
}
|
|
259
287
|
|
|
@@ -270,7 +298,7 @@ export function eventTo(db, board, entry, id, status, { roles = [], payload = {}
|
|
|
270
298
|
const from = statusOf(db, task);
|
|
271
299
|
const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), sender: roles } });
|
|
272
300
|
let refusal = null;
|
|
273
|
-
for (const name of new Set([...Object.keys(workflow.statuses[from]?.on ?? {}), ...Object.keys(workflow.on ?? {})])) {
|
|
301
|
+
for (const name of new Set([...Object.keys(workflow.statuses[from]?.on ?? {}), ...Object.keys(workflow.on ?? {}), ...Object.keys(workflow.verbs ?? {})])) {
|
|
274
302
|
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
303
|
if (!taken?.target || String(templateValue(taken.target, scope) ?? '') !== status) continue;
|
|
276
304
|
const failed = (taken.validate ?? []).find((v) => !holds(v.expr, scope));
|
|
@@ -283,7 +311,7 @@ export function eventTo(db, board, entry, id, status, { roles = [], payload = {}
|
|
|
283
311
|
function cardOf(db, id) {
|
|
284
312
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
285
313
|
if (!row) throw new Error(`no card ${id}`);
|
|
286
|
-
const ours = one(db, 'SELECT
|
|
314
|
+
const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
|
|
287
315
|
return { ...row, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
|
|
288
316
|
}
|
|
289
317
|
|
|
@@ -299,7 +327,7 @@ function take(db, board, entry, task, from, transition, scope, effects) {
|
|
|
299
327
|
for (const a of transition.actions ?? []) act(ctx, a);
|
|
300
328
|
if (moves) {
|
|
301
329
|
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 });
|
|
330
|
+
if (workflow.statuses[target].run || workflow.statuses[target].run_role) effects.push({ kind: 'run', id: task.id, status: target });
|
|
303
331
|
}
|
|
304
332
|
return target ?? from;
|
|
305
333
|
}
|
|
@@ -315,13 +343,14 @@ export function setStatus(db, workflow, id, status) {
|
|
|
315
343
|
/** Take every `always` transition that holds, from wherever the card is, until none does. */
|
|
316
344
|
export function settle(db, board, entry, id, effects = []) {
|
|
317
345
|
const { workflow, params } = entry;
|
|
346
|
+
settleRelations(db, board, entry, id, effects);
|
|
318
347
|
for (let hops = 0; hops < 16; hops += 1) {
|
|
319
348
|
const task = cardOf(db, id);
|
|
320
349
|
const from = statusOf(db, task);
|
|
321
350
|
const scope = scopeOf(db, board, task, { params, extra: { lanes: lanesIn(entry) } });
|
|
322
351
|
let t = (workflow.statuses[from]?.always ?? []).find((x) => holds(x.guard ?? null, scope));
|
|
323
352
|
if (!t) t = dueAfter(db, workflow, task, from, scope);
|
|
324
|
-
if (!t) return effects;
|
|
353
|
+
if (!t) { projectStatus(db, board, entry, id); return effects; }
|
|
325
354
|
const to = take(db, board, entry, task, from, t, scope, effects);
|
|
326
355
|
if (to === from) return effects;
|
|
327
356
|
}
|
|
@@ -366,6 +395,7 @@ export function startNew(db, board, entry, task, event) {
|
|
|
366
395
|
setStatus(db, entry.workflow, task.id, status);
|
|
367
396
|
const ctx = { db, board, entry, id: task.id, scope: { ...scope, before: scope.card }, from: status, to: status, effects: [], runId: null };
|
|
368
397
|
for (const a of t.actions ?? []) act(ctx, a);
|
|
398
|
+
if (event.every != null) ACTIONS.every(ctx, event.every);
|
|
369
399
|
return status;
|
|
370
400
|
}
|
|
371
401
|
|
|
@@ -419,6 +449,51 @@ const FORMAT = {
|
|
|
419
449
|
const RETRY_KINDS = new Set(['crashed', 'timed_out', 'stale', 'rate_limited', 'session_lost', 'reclaimed', 'reconciled', 'spawn_failed']);
|
|
420
450
|
|
|
421
451
|
const ACTIONS = {
|
|
452
|
+
relation(ctx, { add = null, remove = null, reason = null } = {}) {
|
|
453
|
+
const row = one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', ctx.id);
|
|
454
|
+
let blocks = json(row?.blocked_by, []) ?? [];
|
|
455
|
+
if (add) {
|
|
456
|
+
const id = String(add);
|
|
457
|
+
if (id === ctx.id) throw new Error('a card cannot block itself');
|
|
458
|
+
const kind = id.startsWith('m-') ? 'message' : 'card';
|
|
459
|
+
if (kind === 'card' && !one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', id)) throw new Error(`no blocking card ${id}`);
|
|
460
|
+
if (kind === 'card') {
|
|
461
|
+
const seen = new Set(); const pending = [id];
|
|
462
|
+
while (pending.length) {
|
|
463
|
+
const node = pending.pop();
|
|
464
|
+
if (node === ctx.id) throw new Error('blocking relation would create a cycle');
|
|
465
|
+
if (seen.has(node)) continue;
|
|
466
|
+
seen.add(node);
|
|
467
|
+
const related = json(one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', node)?.blocked_by, []) ?? [];
|
|
468
|
+
pending.push(...related.filter((b) => b.kind === 'card').map((b) => b.id));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (!blocks.some((b) => b.id === id)) blocks.push({ id, kind, reason, by: actorOf(), since: now() });
|
|
472
|
+
}
|
|
473
|
+
if (remove) {
|
|
474
|
+
if (!String(reason ?? '').trim()) throw new Error('unblock must say what arrived');
|
|
475
|
+
blocks = blocks.filter((b) => remove !== '*' && b.id !== remove);
|
|
476
|
+
}
|
|
477
|
+
cardField(ctx, 'blocked_by', JSON.stringify(blocks));
|
|
478
|
+
},
|
|
479
|
+
flag(ctx, { name, value, reason = null }) {
|
|
480
|
+
if (!['paused', 'archived'].includes(name)) throw new Error(`unknown flag ${name}`);
|
|
481
|
+
if (name === 'paused') {
|
|
482
|
+
const was = json(one(ctx.db, 'SELECT paused FROM supercode_cards WHERE task_id = ?', ctx.id)?.paused, null);
|
|
483
|
+
if (!value && was && was.by !== actorOf() && !ctx.scope.sender?.includes('owner')) throw new Error('only the pauser or owner may resume this card');
|
|
484
|
+
if (value && !String(reason ?? '').trim()) throw new Error('pause needs a reason');
|
|
485
|
+
cardField(ctx, name, value ? JSON.stringify({ by: actorOf(), reason }) : null);
|
|
486
|
+
} else cardField(ctx, name, Number(Boolean(value)));
|
|
487
|
+
},
|
|
488
|
+
every(ctx, interval) {
|
|
489
|
+
intervalSeconds(interval); // refuse malformed intervals before storing
|
|
490
|
+
cardField(ctx, 'every', interval == null ? null : String(interval));
|
|
491
|
+
cardField(ctx, 'next_run_at', interval == null ? null : now());
|
|
492
|
+
},
|
|
493
|
+
card_event(ctx, { name, payload = {} }) {
|
|
494
|
+
const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: ['platform'], payload, extra: ctx.scope });
|
|
495
|
+
ctx.effects.push(...result.effects);
|
|
496
|
+
},
|
|
422
497
|
record(ctx, args, scope) {
|
|
423
498
|
const kind = typeof args === 'string' ? args : args?.kind;
|
|
424
499
|
const given = typeof args === 'object' && args?.payload ? args.payload : null;
|
|
@@ -436,7 +511,9 @@ const ACTIONS = {
|
|
|
436
511
|
// `on`: the event is another card's (a dependency hears what waits on it), recorded there with no run
|
|
437
512
|
const on = typeof args === 'object' && args?.on ? String(args.on) : null;
|
|
438
513
|
if (on && !one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', on)) return;
|
|
439
|
-
|
|
514
|
+
if (ctx.machine === 'run' && !on) {
|
|
515
|
+
runEvent(ctx.db, ctx.runId, kind, payload);
|
|
516
|
+
} else record(ctx.db, on ?? ctx.id, kind, payload && Object.keys(payload).length ? payload : null, on ? null : ctx.runId);
|
|
440
517
|
},
|
|
441
518
|
assign(ctx, { field, value }) {
|
|
442
519
|
if (!ASSIGNABLE.has(field)) throw new Error(`the workflow assigns ${field}, which is not a card field it may set`);
|
|
@@ -482,6 +559,7 @@ const ACTIONS = {
|
|
|
482
559
|
const task = cardOf(ctx.db, ctx.id);
|
|
483
560
|
const t = now();
|
|
484
561
|
const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
|
|
562
|
+
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
563
|
const lock = ctx.scope.event?.lock;
|
|
486
564
|
if (!lock) throw new Error('a claim needs the dispatcher\'s lock');
|
|
487
565
|
if (task.current_run_id) {
|
|
@@ -489,13 +567,14 @@ const ACTIONS = {
|
|
|
489
567
|
}
|
|
490
568
|
const expires = t + ttl;
|
|
491
569
|
const profile = evaluate(ctx.entry.workflow.roles?.[role] ?? 'card.assignee', scope) ?? task.assignee;
|
|
492
|
-
const slot = String(templateValue(ctx.entry
|
|
570
|
+
const slot = String(templateValue(runSpec(ctx.entry, ctx.to)?.session ?? 'worker', scope) ?? 'worker');
|
|
493
571
|
run(ctx.db, 'UPDATE tasks SET claim_lock = ?, claim_expires = ?, started_at = COALESCE(started_at, ?) WHERE id = ?', lock, expires, t, ctx.id);
|
|
494
572
|
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 } }));
|
|
573
|
+
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
574
|
ctx.runId = Number(r.lastInsertRowid);
|
|
497
575
|
run(ctx.db, 'UPDATE tasks SET current_run_id = ? WHERE id = ?', ctx.runId, ctx.id);
|
|
498
|
-
|
|
576
|
+
if (scope.card.every) cardField(ctx, 'next_run_at', t + intervalSeconds(scope.card.every));
|
|
577
|
+
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
578
|
},
|
|
500
579
|
extend_claim(ctx) {
|
|
501
580
|
const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
|
|
@@ -601,7 +680,7 @@ const TOOL_OF = { complete: 'kanban_complete', block: 'kanban_block', request_re
|
|
|
601
680
|
|
|
602
681
|
export function doors(workflow, status, role, id, where, surface = 'pane') {
|
|
603
682
|
const events = new Map();
|
|
604
|
-
for (const [name, ts] of [...Object.entries(workflow.statuses[status]?.on ?? {}), ...Object.entries(workflow.on ?? {})]) {
|
|
683
|
+
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
684
|
for (const t of ts) {
|
|
606
685
|
if (!t.by?.length || !t.by.includes(role)) continue;
|
|
607
686
|
if (!events.has(name)) events.set(name, t.description ?? null);
|
|
@@ -612,3 +691,92 @@ export function doors(workflow, status, role, id, where, surface = 'pane') {
|
|
|
612
691
|
.map(([name, what]) => (surface === 'pane' ? `- \`supercode workflow ${name.replace(/_/g, '-')} ${id} … ${where}\`` : `- \`${TOOL_OF[name]}\``) + (what ? `: ${what}` : ''));
|
|
613
692
|
return ['What you may do with the card:', ...lines].join('\n');
|
|
614
693
|
}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
function cardField(ctx, field, value) {
|
|
697
|
+
run(ctx.db, `INSERT INTO supercode_cards (task_id, ${field}) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET ${field} = excluded.${field}`, ctx.id, value);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function intervalSeconds(value) {
|
|
701
|
+
if (value == null) return null;
|
|
702
|
+
const match = /^(\d+(?:\.\d+)?)\s*(s|m|h|d|w)?$/.exec(String(value));
|
|
703
|
+
const seconds = match && Number(match[1]) * ({ s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[match[2] ?? 's']);
|
|
704
|
+
if (!(seconds > 0)) throw new Error(`invalid recurring interval ${value}`);
|
|
705
|
+
return seconds;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export function runSpec(entry, status) {
|
|
709
|
+
const st = entry.workflow.statuses[status];
|
|
710
|
+
const spec = st?.run_role ? entry.workflow.run?.roles?.[st.run_role] : st?.run;
|
|
711
|
+
if (!spec?.prompt_file) return spec;
|
|
712
|
+
const file = resolve(entry.root, spec.prompt_file);
|
|
713
|
+
const rel = relative(entry.root, file);
|
|
714
|
+
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('prompt file must live in the orchestration home');
|
|
715
|
+
if (!existsSync(file) && spec.prompt) return spec; // embedded prompts of the built-in instance
|
|
716
|
+
return { ...spec, prompt: readFileSync(file, 'utf8') };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function runEvent(db, id, kind, payload) {
|
|
720
|
+
if (!id) return;
|
|
721
|
+
const row = one(db, 'SELECT metadata FROM task_runs WHERE id = ?', id);
|
|
722
|
+
const meta = json(row?.metadata, {}) ?? {};
|
|
723
|
+
meta.events = [...(meta.events ?? []), { kind, payload, by: actorOf(), at: now() }];
|
|
724
|
+
run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(meta), id);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function fireRun(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
|
|
728
|
+
const task = cardOf(db, id);
|
|
729
|
+
const row = task.current_run_id && one(db, 'SELECT * FROM task_runs WHERE id = ?', task.current_run_id);
|
|
730
|
+
if (!row) throw new Error(`${id} has no current run for ${name}`);
|
|
731
|
+
const machine = entry.workflow.run;
|
|
732
|
+
const meta = json(row.metadata, {}) ?? {};
|
|
733
|
+
const from = meta.supercode?.state ?? machine.initial;
|
|
734
|
+
const scope = scopeOf(db, board, task, { params: entry.params, event: payload, extra: { ...extra, sender: roles, run: { ...row, state: from } } });
|
|
735
|
+
const candidates = [...(machine.states[from]?.on?.[name] ?? []), ...(machine.on?.[name] ?? [])];
|
|
736
|
+
const t = candidates.find((t) => (!t.from?.length || t.from.includes(from)) && (!t.by?.length || t.by.some((r) => roles.includes(r))) && holds(t.guard ?? null, scope));
|
|
737
|
+
if (!t) { const error = new Error(`${id}: run event ${name} does not apply`); error.noTransition = true; throw error; }
|
|
738
|
+
for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw new Error(v.message);
|
|
739
|
+
const to = t.target ? String(templateValue(t.target, scope)) : from;
|
|
740
|
+
if (!machine.states[to]) throw new Error(`unknown run state ${to}`);
|
|
741
|
+
const effects = [];
|
|
742
|
+
const status = statusOf(db, task);
|
|
743
|
+
const ctx = { db, board, entry, id, scope: { ...scope, before: scope.card }, from: status, to: status, effects, runId: row.id, machine: 'run' };
|
|
744
|
+
if (to !== from) for (const a of machine.states[from]?.exit ?? []) act(ctx, a);
|
|
745
|
+
runSession(ctx, (s) => ({ ...s, state: to }));
|
|
746
|
+
runEvent(db, row.id, name, payload);
|
|
747
|
+
for (const a of t.actions ?? []) act(ctx, a);
|
|
748
|
+
if (to !== from) for (const a of machine.states[to]?.entry ?? []) act(ctx, a);
|
|
749
|
+
if (to !== from) {
|
|
750
|
+
const after = scopeOf(db, board, cardOf(db, id), { params: entry.params, event: payload, extra: { ...extra, sender: roles } });
|
|
751
|
+
const outcome = (machine.outcomes?.[to] ?? []).find((t) => holds(t.guard ?? null, after));
|
|
752
|
+
if (outcome) take(db, board, entry, cardOf(db, id), statusOf(db, cardOf(db, id)), outcome, after, effects);
|
|
753
|
+
}
|
|
754
|
+
projectStatus(db, board, entry, id);
|
|
755
|
+
return { from: status, to: statusOf(db, cardOf(db, id)), effects };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function settleRelations(db, board, entry, id, effects) {
|
|
759
|
+
if (!entry.workflow.relations?.blocked_by) return;
|
|
760
|
+
const row = one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id);
|
|
761
|
+
const blocks = json(row?.blocked_by, []) ?? [];
|
|
762
|
+
for (const block of blocks) {
|
|
763
|
+
const dependency = block.kind === 'card' ? one(db, 'SELECT * FROM tasks WHERE id = ?', block.id) : null;
|
|
764
|
+
const status = dependency ? statusOf(db, dependency) : null;
|
|
765
|
+
const answer = block.kind === 'message' ? answerTo(block.id) : null;
|
|
766
|
+
const policy = entry.workflow.relations.blocked_by;
|
|
767
|
+
const name = answer ? policy.answered : status === 'done' ? policy.completed : status === 'cancelled' ? policy.cancelled : null;
|
|
768
|
+
if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}` } }).effects);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
export function projectStatus(db, board, entry, id) {
|
|
774
|
+
if (!entry.workflow.stored_as?.length) return;
|
|
775
|
+
const task = cardOf(db, id);
|
|
776
|
+
const status = statusOf(db, task);
|
|
777
|
+
const scope = scopeOf(db, board, task, { params: entry.params });
|
|
778
|
+
const rule = entry.workflow.stored_as.find((r) => holds(r.guard, scope));
|
|
779
|
+
const stored = rule?.status ?? storedAs(entry.workflow, status);
|
|
780
|
+
run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
|
|
781
|
+
run(db, 'INSERT INTO supercode_cards (task_id, stage) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage', id, status);
|
|
782
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.76",
|
|
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": {
|