@volter/supercode-orchestrator 0.4.28 → 0.4.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/cli.mjs +70 -43
- package/board/dispatch.mjs +342 -287
- package/board/engine.mjs +151 -310
- package/board/expr.mjs +212 -0
- package/board/store.mjs +4 -2
- package/board/tools.mjs +17 -8
- package/board/workflow.mjs +442 -0
- package/board.mjs +8 -4
- package/kanban-worker.mjs +2 -1
- package/loop.mjs +1 -1
- package/package.json +1 -1
package/board/expr.mjs
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The workflow's expressions (crates/interchange/src/orchestration/workflow.rs `Expr`): a small CEL-like language
|
|
2
|
+
// evaluated over a context the board engine builds (`card`, `event`, `params`, `now`, `host`, `lane`, `run`, …).
|
|
3
|
+
// Literals (numbers, 'strings' or "strings", true, false, null, [lists]); names and member access (`a.b`, `a[b]`);
|
|
4
|
+
// calls of the context's functions (`matches(text, pattern)`); the macros `list.all(x, expr)`, `list.exists(x, expr)`,
|
|
5
|
+
// `list.map(x, expr)`, `list.filter(x, expr)` and `.size()`; operators `!` `-` `*` `/` `%` `+` `-` `<` `<=` `>` `>=`
|
|
6
|
+
// `==` `!=` `in` `&&` `||` `??` and `c ? a : b`. A template is a string with `{{expr}}` parts. Nothing reaches the
|
|
7
|
+
// host: an expression reads only what its context holds.
|
|
8
|
+
|
|
9
|
+
const TOKEN = /\s*(?:(\d+(?:\.\d+)?)|('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|([A-Za-z_][A-Za-z0-9_]*)|(\?\?|&&|\|\||==|!=|<=|>=|[-+*/%<>!?:.,()[\]]))/y;
|
|
10
|
+
|
|
11
|
+
function tokenize(text) {
|
|
12
|
+
const out = [];
|
|
13
|
+
TOKEN.lastIndex = 0;
|
|
14
|
+
let at = 0;
|
|
15
|
+
while (at < text.length) {
|
|
16
|
+
if (/^\s*$/.test(text.slice(at))) break;
|
|
17
|
+
TOKEN.lastIndex = at;
|
|
18
|
+
const m = TOKEN.exec(text);
|
|
19
|
+
if (!m) throw new Error(`cannot read the expression at "${text.slice(at, at + 20)}"`);
|
|
20
|
+
at = TOKEN.lastIndex;
|
|
21
|
+
if (m[1] !== undefined) out.push({ t: 'num', v: Number(m[1]) });
|
|
22
|
+
else if (m[2] !== undefined) out.push({ t: 'str', v: m[2].slice(1, -1).replace(/\\(.)/g, '$1') });
|
|
23
|
+
else if (m[3] !== undefined) out.push({ t: 'id', v: m[3] });
|
|
24
|
+
else out.push({ t: 'op', v: m[4] });
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// precedence climbing over the binary operators, lowest first
|
|
30
|
+
const BINARY = [['??'], ['||'], ['&&'], ['==', '!=', 'in'], ['<', '<=', '>', '>='], ['+', '-'], ['*', '/', '%']];
|
|
31
|
+
const MACROS = new Set(['all', 'exists', 'map', 'filter']);
|
|
32
|
+
|
|
33
|
+
function parse(text) {
|
|
34
|
+
const tokens = tokenize(text);
|
|
35
|
+
let i = 0;
|
|
36
|
+
const peek = () => tokens[i];
|
|
37
|
+
const is = (v) => tokens[i]?.t === 'op' && tokens[i].v === v;
|
|
38
|
+
const take = (v) => { if (!is(v)) throw new Error(`expected "${v}" in ${text}`); i += 1; };
|
|
39
|
+
const ternary = () => {
|
|
40
|
+
const c = binary(0);
|
|
41
|
+
if (!is('?')) return c;
|
|
42
|
+
i += 1;
|
|
43
|
+
const a = ternary();
|
|
44
|
+
take(':');
|
|
45
|
+
return { k: 'if', c, a, b: ternary() };
|
|
46
|
+
};
|
|
47
|
+
const binary = (level) => {
|
|
48
|
+
if (level === BINARY.length) return unary();
|
|
49
|
+
let left = binary(level + 1);
|
|
50
|
+
for (;;) {
|
|
51
|
+
const tok = peek();
|
|
52
|
+
const op = tok && ((tok.t === 'op' || (tok.t === 'id' && tok.v === 'in')) ? tok.v : null);
|
|
53
|
+
if (!op || !BINARY[level].includes(op)) return left;
|
|
54
|
+
i += 1;
|
|
55
|
+
left = { k: 'bin', op, l: left, r: binary(level + 1) };
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const unary = () => {
|
|
59
|
+
if (is('!')) { i += 1; return { k: 'not', e: unary() }; }
|
|
60
|
+
if (is('-')) { i += 1; return { k: 'neg', e: unary() }; }
|
|
61
|
+
return postfix(primary());
|
|
62
|
+
};
|
|
63
|
+
const args = () => {
|
|
64
|
+
take('(');
|
|
65
|
+
const out = [];
|
|
66
|
+
while (!is(')')) { out.push(ternary()); if (is(',')) i += 1; else break; }
|
|
67
|
+
take(')');
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
const postfix = (e) => {
|
|
71
|
+
for (;;) {
|
|
72
|
+
if (is('.')) {
|
|
73
|
+
i += 1;
|
|
74
|
+
const name = peek();
|
|
75
|
+
if (name?.t !== 'id') throw new Error(`expected a name after "." in ${text}`);
|
|
76
|
+
i += 1;
|
|
77
|
+
if (is('(')) {
|
|
78
|
+
if (MACROS.has(name.v)) {
|
|
79
|
+
take('(');
|
|
80
|
+
const v = peek();
|
|
81
|
+
if (v?.t !== 'id') throw new Error(`${name.v} takes a variable name first in ${text}`);
|
|
82
|
+
i += 1;
|
|
83
|
+
take(',');
|
|
84
|
+
const body = ternary();
|
|
85
|
+
take(')');
|
|
86
|
+
e = { k: 'macro', m: name.v, on: e, v: v.v, body };
|
|
87
|
+
} else e = { k: 'method', m: name.v, on: e, args: args() };
|
|
88
|
+
} else e = { k: 'get', on: e, key: { k: 'lit', v: name.v } };
|
|
89
|
+
} else if (is('[')) {
|
|
90
|
+
i += 1;
|
|
91
|
+
const key = ternary();
|
|
92
|
+
take(']');
|
|
93
|
+
e = { k: 'get', on: e, key };
|
|
94
|
+
} else return e;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const primary = () => {
|
|
98
|
+
const tok = peek();
|
|
99
|
+
if (!tok) throw new Error(`the expression ends early: ${text}`);
|
|
100
|
+
i += 1;
|
|
101
|
+
if (tok.t === 'num' || tok.t === 'str') return { k: 'lit', v: tok.v };
|
|
102
|
+
if (tok.t === 'op' && tok.v === '(') { const e = ternary(); take(')'); return e; }
|
|
103
|
+
if (tok.t === 'op' && tok.v === '[') {
|
|
104
|
+
const items = [];
|
|
105
|
+
while (!is(']')) { items.push(ternary()); if (is(',')) i += 1; else break; }
|
|
106
|
+
take(']');
|
|
107
|
+
return { k: 'list', items };
|
|
108
|
+
}
|
|
109
|
+
if (tok.t === 'id') {
|
|
110
|
+
if (tok.v === 'true') return { k: 'lit', v: true };
|
|
111
|
+
if (tok.v === 'false') return { k: 'lit', v: false };
|
|
112
|
+
if (tok.v === 'null') return { k: 'lit', v: null };
|
|
113
|
+
if (is('(')) return { k: 'call', f: tok.v, args: args() };
|
|
114
|
+
return { k: 'name', v: tok.v };
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`unexpected "${tok.v}" in ${text}`);
|
|
117
|
+
};
|
|
118
|
+
const tree = ternary();
|
|
119
|
+
if (i < tokens.length) throw new Error(`unexpected "${tokens[i].v}" in ${text}`);
|
|
120
|
+
return tree;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cache = new Map();
|
|
124
|
+
function compiled(text) {
|
|
125
|
+
let tree = cache.get(text);
|
|
126
|
+
if (!tree) { tree = parse(text); cache.set(text, tree); }
|
|
127
|
+
return tree;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const own = (o, k) => o !== null && o !== undefined && (typeof o === 'object' || typeof o === 'function') && Object.prototype.hasOwnProperty.call(o, k);
|
|
131
|
+
|
|
132
|
+
function run(e, scope) {
|
|
133
|
+
switch (e.k) {
|
|
134
|
+
case 'lit': return e.v;
|
|
135
|
+
case 'list': return e.items.map((x) => run(x, scope));
|
|
136
|
+
case 'name': return own(scope, e.v) ? scope[e.v] : undefined;
|
|
137
|
+
case 'get': {
|
|
138
|
+
const on = run(e.on, scope);
|
|
139
|
+
const key = run(e.key, scope);
|
|
140
|
+
return own(on, key) || (Array.isArray(on) && Number.isInteger(key)) ? on[key] : undefined;
|
|
141
|
+
}
|
|
142
|
+
case 'not': return !truthy(run(e.e, scope));
|
|
143
|
+
case 'neg': return -Number(run(e.e, scope));
|
|
144
|
+
case 'if': return truthy(run(e.c, scope)) ? run(e.a, scope) : run(e.b, scope);
|
|
145
|
+
case 'bin': {
|
|
146
|
+
if (e.op === '&&') return truthy(run(e.l, scope)) && truthy(run(e.r, scope));
|
|
147
|
+
if (e.op === '||') return truthy(run(e.l, scope)) || truthy(run(e.r, scope));
|
|
148
|
+
if (e.op === '??') { const l = run(e.l, scope); return l === null || l === undefined ? run(e.r, scope) : l; }
|
|
149
|
+
const l = run(e.l, scope);
|
|
150
|
+
const r = run(e.r, scope);
|
|
151
|
+
switch (e.op) {
|
|
152
|
+
case '==': return (l ?? null) === (r ?? null);
|
|
153
|
+
case '!=': return (l ?? null) !== (r ?? null);
|
|
154
|
+
case 'in': return Array.isArray(r) ? r.includes(l) : r && typeof r === 'object' ? own(r, l) : false;
|
|
155
|
+
case '<': return l < r;
|
|
156
|
+
case '<=': return l <= r;
|
|
157
|
+
case '>': return l > r;
|
|
158
|
+
case '>=': return l >= r;
|
|
159
|
+
case '+': return typeof l === 'string' || typeof r === 'string' ? `${l ?? ''}${r ?? ''}` : Number(l) + Number(r);
|
|
160
|
+
case '-': return Number(l) - Number(r);
|
|
161
|
+
case '*': return Number(l) * Number(r);
|
|
162
|
+
case '/': return Number(l) / Number(r);
|
|
163
|
+
case '%': return Number(l) % Number(r);
|
|
164
|
+
default: throw new Error(`no operator ${e.op}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
case 'call': {
|
|
168
|
+
const f = own(scope, e.f) ? scope[e.f] : undefined;
|
|
169
|
+
if (typeof f !== 'function') throw new Error(`no function ${e.f}`);
|
|
170
|
+
return f(...e.args.map((a) => run(a, scope)));
|
|
171
|
+
}
|
|
172
|
+
case 'method': {
|
|
173
|
+
const on = run(e.on, scope);
|
|
174
|
+
if (e.m === 'size') return Array.isArray(on) || typeof on === 'string' ? on.length : on && typeof on === 'object' ? Object.keys(on).length : 0;
|
|
175
|
+
const f = on && typeof on[e.m] === 'function' && own(on, e.m) ? on[e.m] : undefined;
|
|
176
|
+
if (!f) throw new Error(`no method ${e.m}`);
|
|
177
|
+
return f(...e.args.map((a) => run(a, scope)));
|
|
178
|
+
}
|
|
179
|
+
case 'macro': {
|
|
180
|
+
const list = Array.isArray(run(e.on, scope)) ? run(e.on, scope) : [];
|
|
181
|
+
const each = (x) => run(e.body, { ...scope, [e.v]: x });
|
|
182
|
+
if (e.m === 'all') return list.every((x) => truthy(each(x)));
|
|
183
|
+
if (e.m === 'exists') return list.some((x) => truthy(each(x)));
|
|
184
|
+
if (e.m === 'map') return list.map(each);
|
|
185
|
+
return list.filter((x) => truthy(each(x)));
|
|
186
|
+
}
|
|
187
|
+
default: throw new Error(`cannot evaluate ${e.k}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const truthy = (v) => v !== null && v !== undefined && v !== false && v !== 0 && v !== '';
|
|
192
|
+
|
|
193
|
+
/** The value of `text` in `scope`; null for an empty expression. */
|
|
194
|
+
export function evaluate(text, scope) {
|
|
195
|
+
if (text === null || text === undefined || String(text).trim() === '') return null;
|
|
196
|
+
return run(compiled(String(text)), scope);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Whether `text` holds in `scope` (no expression holds). */
|
|
200
|
+
export function holds(text, scope) {
|
|
201
|
+
return text === null || text === undefined ? true : truthy(evaluate(text, scope));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A template with `{{expr}}` parts, each replaced by its value (nothing for null). */
|
|
205
|
+
export function render(template, scope) {
|
|
206
|
+
return String(template ?? '').replace(/\{\{([\s\S]+?)\}\}/g, (_, e) => { const v = evaluate(e, scope); return v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v); });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Parse every expression up front: answers the first that does not parse, or null. */
|
|
210
|
+
export function check(text) {
|
|
211
|
+
try { compiled(String(text)); return null; } catch (error) { return error.message; }
|
|
212
|
+
}
|
package/board/store.mjs
CHANGED
|
@@ -65,8 +65,10 @@ CREATE INDEX IF NOT EXISTS idx_events_run ON task_events(run_id, id);
|
|
|
65
65
|
|
|
66
66
|
// supercode's part of a card, beside Hermes's row: where its session runs, and the arc a subtask belongs to (a
|
|
67
67
|
// subtask is worked by its arc's session and never started on its own).
|
|
68
|
-
const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY KEY, machine TEXT, subtask_of TEXT);`;
|
|
69
|
-
|
|
68
|
+
const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY KEY, machine TEXT, subtask_of TEXT, stage TEXT);`;
|
|
69
|
+
// `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
|
|
70
|
+
// `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
|
|
71
|
+
const OURS_COLUMNS = { subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT' };
|
|
70
72
|
|
|
71
73
|
/** The database file of board `slug` in the home at `root`. */
|
|
72
74
|
export function boardPath(root, slug = DEFAULT_BOARD) {
|
package/board/tools.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
|
7
7
|
import { basename, join } from 'node:path';
|
|
8
8
|
import { DEFAULT_BOARD, event, json, openBoard, tx } from './store.mjs';
|
|
9
9
|
import * as engine from './engine.mjs';
|
|
10
|
+
import { loadWorkflow } from './workflow.mjs';
|
|
10
11
|
|
|
11
12
|
const ok = (fields = {}) => JSON.stringify({ ok: true, ...fields });
|
|
12
13
|
const error = (message) => JSON.stringify({ error: message });
|
|
@@ -37,6 +38,14 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
37
38
|
if (!['kanban_list', 'kanban_create', 'kanban_link'].includes(name) && !tid) return error('task_id is required (or HERMES_KANBAN_TASK in the environment)');
|
|
38
39
|
if (own && LIFECYCLE.has(name) && tid !== own) return error(`worker is scoped to task ${own}; refusing to change ${tid}. Use kanban_comment to hand off to other tasks, or kanban_create for follow-up work.`);
|
|
39
40
|
const slug = args.board || env.HERMES_KANBAN_BOARD || DEFAULT_BOARD;
|
|
41
|
+
// the board's workflow decides what each tool's event does; the worker is its run's role on its own task, and
|
|
42
|
+
// nobody on another (Hermes's worker scoping)
|
|
43
|
+
try { await loadWorkflow(root); } catch (e) { return error(e.message); }
|
|
44
|
+
engine.setCaller((db, cardId) => {
|
|
45
|
+
if (!own || cardId !== own || !runId) return own ? [] : ['manager'];
|
|
46
|
+
const meta = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId)?.metadata, {})?.supercode ?? {};
|
|
47
|
+
return [meta.role ?? 'implementer'];
|
|
48
|
+
});
|
|
40
49
|
let board;
|
|
41
50
|
try { board = openBoard(root, slug); } catch (e) { return error(e.message); }
|
|
42
51
|
board.label = slug;
|
|
@@ -87,32 +96,32 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
87
96
|
}
|
|
88
97
|
}
|
|
89
98
|
if (env.HERMES_SESSION_ID && own === tid) metadata = { ...(metadata ?? {}), worker_session_id: env.HERMES_SESSION_ID };
|
|
90
|
-
engine.complete(board, tid, { summary, result, metadata });
|
|
99
|
+
await engine.complete(board, tid, { summary, result, metadata });
|
|
91
100
|
return ok({ task_id: tid, status: 'done' });
|
|
92
101
|
}
|
|
93
102
|
case 'kanban_block': {
|
|
94
103
|
const stale = ownRun(); if (stale) return error(stale);
|
|
95
|
-
const status = engine.block(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, kind: args.kind ?? null });
|
|
104
|
+
const { to: status } = await engine.block(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, kind: args.kind ?? null });
|
|
96
105
|
return ok({ task_id: tid, status });
|
|
97
106
|
}
|
|
98
107
|
case 'kanban_request_review': {
|
|
99
108
|
const stale = ownRun(); if (stale) return error(stale);
|
|
100
|
-
engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
|
|
109
|
+
await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
|
|
101
110
|
return ok({ task_id: tid, status: 'review' });
|
|
102
111
|
}
|
|
103
112
|
case 'kanban_request_changes': {
|
|
104
113
|
const stale = ownRun(); if (stale) return error(stale);
|
|
105
|
-
engine.requestChanges(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, reviewer: author });
|
|
114
|
+
await engine.requestChanges(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, reviewer: author });
|
|
106
115
|
return ok({ task_id: tid });
|
|
107
116
|
}
|
|
108
117
|
case 'kanban_heartbeat': {
|
|
109
118
|
const stale = ownRun(); if (stale) return error(stale);
|
|
110
|
-
engine.heartbeat(board, tid, args.note ?? null);
|
|
119
|
+
await engine.heartbeat(board, tid, args.note ?? null);
|
|
111
120
|
return ok({ task_id: tid });
|
|
112
121
|
}
|
|
113
122
|
case 'kanban_comment': {
|
|
114
123
|
if (!String(args.body ?? '').trim()) return error('body is required');
|
|
115
|
-
const n = engine.comment(board, tid, author, redact(String(args.body)));
|
|
124
|
+
const n = await engine.comment(board, tid, author, redact(String(args.body)));
|
|
116
125
|
return ok({ task_id: tid, comment_id: n });
|
|
117
126
|
}
|
|
118
127
|
case 'kanban_attach': case 'kanban_attach_url': {
|
|
@@ -151,10 +160,10 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
151
160
|
return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
|
|
152
161
|
}
|
|
153
162
|
case 'kanban_unblock':
|
|
154
|
-
return ok({ task_id: tid, status: engine.unblock(board, tid) });
|
|
163
|
+
return ok({ task_id: tid, status: (await engine.unblock(board, tid)).to });
|
|
155
164
|
case 'kanban_link':
|
|
156
165
|
if (!args.parent_id || !args.child_id) return error('parent_id and child_id are required');
|
|
157
|
-
engine.link(board, args.parent_id, args.child_id);
|
|
166
|
+
await engine.link(board, args.parent_id, args.child_id);
|
|
158
167
|
return ok({ parent_id: args.parent_id, child_id: args.child_id });
|
|
159
168
|
default:
|
|
160
169
|
return error(`no board tool ${name}`);
|