@volter/supercode-orchestrator 0.4.76 → 0.4.78
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 +25 -5
- package/board/dispatch.mjs +9 -4
- package/board/engine.mjs +6 -3
- package/board/store.mjs +10 -5
- package/board/workflow.mjs +18 -7
- package/board/ztrack.mjs +366 -0
- package/package.json +3 -2
package/board/cli.mjs
CHANGED
|
@@ -10,7 +10,8 @@ import { basename, join, resolve } from 'node:path';
|
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { read } from '../orchestration.mjs';
|
|
12
12
|
import { holdDispatcherLock } from '../hermes-locks.mjs';
|
|
13
|
-
import { backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
13
|
+
import { backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
14
|
+
import { openZtrackBoard } from './ztrack.mjs';
|
|
14
15
|
import * as engine from './engine.mjs';
|
|
15
16
|
import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
|
|
16
17
|
import { loadWorkflow, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
@@ -19,7 +20,9 @@ import { evaluate } from './expr.mjs';
|
|
|
19
20
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
20
21
|
|
|
21
22
|
Hermes Kanban's verbs, with their flags:
|
|
22
|
-
init
|
|
23
|
+
init [--backing sqlite|files|ztrack] [--document <arcs.md>]
|
|
24
|
+
migrate --backing ztrack --document <arcs.md> --status-map <json>
|
|
25
|
+
boards list|create|rm|switch|show|rename|set-default-workdir|export|import
|
|
23
26
|
create <title> [--body] [--assignee] [--parent]... [--workspace scratch|worktree[:<path>]|dir:<path>] [--branch] [--project]
|
|
24
27
|
[--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
|
|
25
28
|
[--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
|
|
@@ -226,7 +229,7 @@ export async function runWorkflow(argv) {
|
|
|
226
229
|
if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
|
|
227
230
|
const root = homeOf(args);
|
|
228
231
|
const slug = boardOf(root, args);
|
|
229
|
-
const open = (create = false) => { const b = openBoard(root, slug, { create, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
|
|
232
|
+
const open = (create = false) => { const b = openBoard(root, slug, { create, document: args.document ?? null, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
|
|
230
233
|
const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
|
|
231
234
|
const withBoardAsync = async (fn) => { const b = open(); try { return await fn(b); } finally { b.close(); } };
|
|
232
235
|
const id = rest[0];
|
|
@@ -248,6 +251,23 @@ export async function runWorkflow(argv) {
|
|
|
248
251
|
if (!sid && verb !== 'help') { try { setActor([].concat(workflowFor(root).params?.managers ?? [])[0] ?? null); } catch { /* no workflow */ } }
|
|
249
252
|
|
|
250
253
|
switch (verb) {
|
|
254
|
+
case 'migrate': {
|
|
255
|
+
if (args.backing !== 'ztrack' || !args.document || !args['status-map']) throw new Error('migrate needs --backing ztrack --document <arcs.md> --status-map <json>');
|
|
256
|
+
if (backingOf(root, slug) !== 'sqlite') throw new Error('migration starts from a SQLite board');
|
|
257
|
+
const paths = backingPaths(root, slug);
|
|
258
|
+
const preparing = `${paths.ztrack}.preparing`;
|
|
259
|
+
if (existsSync(preparing)) throw new Error(`unfinished migration retained at ${preparing}; inspect it before retrying`);
|
|
260
|
+
const mapping = JSON.parse(readFileSync(args['status-map'], 'utf8'));
|
|
261
|
+
const source = openBoard(root, slug);
|
|
262
|
+
try {
|
|
263
|
+
const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug, compatPath: paths.sqlite });
|
|
264
|
+
migrated.close();
|
|
265
|
+
} finally { source.close(); }
|
|
266
|
+
renameSync(`${preparing}.runtime.db`, `${paths.ztrack}.runtime.db`);
|
|
267
|
+
renameSync(preparing, paths.ztrack);
|
|
268
|
+
say(args, `Board migrated to ${args.document}; kanban.db is now a Hermes export`, { ok: true, backing: 'ztrack', document: args.document });
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
251
271
|
case 'init': {
|
|
252
272
|
const backing = withBoard((b) => b.backing, true);
|
|
253
273
|
const { profiles } = await laneState(root).catch(() => ({ profiles: [] }));
|
|
@@ -532,7 +552,7 @@ export async function runWorkflow(argv) {
|
|
|
532
552
|
case 'unblock': {
|
|
533
553
|
need(1, 'a card id');
|
|
534
554
|
const results = [];
|
|
535
|
-
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null }))).to]);
|
|
555
|
+
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? null }))).to]);
|
|
536
556
|
// Hermes records the reason as a comment on each card it unblocks
|
|
537
557
|
if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
|
|
538
558
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
@@ -841,7 +861,7 @@ async function boardsVerb(root, rest, args) {
|
|
|
841
861
|
case 'create': case 'new': {
|
|
842
862
|
const slug = normalSlug(target);
|
|
843
863
|
const meta = writeMeta(root, slug, { name: args.name, description: args.description, icon: args.icon, color: args.color, default_workdir: args['default-workdir'] });
|
|
844
|
-
openBoard(root, slug, { create: true, backing: args.backing ?? null }).close();
|
|
864
|
+
openBoard(root, slug, { create: true, document: args.document ?? null, backing: args.backing ?? null }).close();
|
|
845
865
|
if (args.switch) { mkdirSync(join(root, 'kanban'), { recursive: true }); writeFileSync(currentFile(root), `${slug}\n`); }
|
|
846
866
|
say(args, `Board '${slug}' ready.\n Display name: ${meta.name}\n DB path: ${meta.db_path}${args.switch ? `\n Switched to '${slug}'.` : ''}`, meta);
|
|
847
867
|
return 0;
|
package/board/dispatch.mjs
CHANGED
|
@@ -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(runSpec(entry, status)?.limits
|
|
271
|
+
for (const [name, expr] of Object.entries({ ...entry.workflow.run?.limits, ...runSpec(entry, status, task)?.limits })) {
|
|
272
272
|
const v = evaluate(expr, scope);
|
|
273
273
|
out[name] = v === null || v === undefined || v === '' ? null : Number(v);
|
|
274
274
|
}
|
|
@@ -321,7 +321,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
321
321
|
const t = transitionsFor(workflow, status, d.start).find((x) => (!x.by?.length || x.by.includes('platform')) && holds(x.guard ?? null, scope));
|
|
322
322
|
if (!t) return null;
|
|
323
323
|
const target = t.target ? String(templateValue(t.target, scope)) : status;
|
|
324
|
-
const spec = runSpec(entry, target);
|
|
324
|
+
const spec = runSpec(entry, target, scope.card);
|
|
325
325
|
const profile = spec ? evaluate(workflow.roles?.[spec.role] ?? 'card.assignee', scope) : null;
|
|
326
326
|
return { target, runSpec: spec, profile, lane: profile ? lanes[profile] ?? null : null };
|
|
327
327
|
};
|
|
@@ -419,7 +419,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
419
419
|
export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
420
420
|
const task = getTaskNow(board, id);
|
|
421
421
|
const status = tx(board, (db) => statusOf(db, task));
|
|
422
|
-
const spec = runSpec(entry, status);
|
|
422
|
+
const spec = runSpec(entry, status, task);
|
|
423
423
|
if (!spec) return null;
|
|
424
424
|
const runId = task.current_run_id;
|
|
425
425
|
const surface = lane.surface === 'pane' ? 'pane' : 'headless';
|
|
@@ -431,6 +431,11 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
|
431
431
|
// the run's session record knows its status, role and slot (a reviewer's slot is never the implementer's); the slot
|
|
432
432
|
// is a template, so a step can ask for isolation by naming a new one
|
|
433
433
|
const slot = String(templateValue(spec.session ?? 'worker', scope) ?? 'worker');
|
|
434
|
+
if (spec.role === 'reviewer') {
|
|
435
|
+
const worker = tx(board, (db) => lastSession(db, id, 'worker'));
|
|
436
|
+
const reviewer = tx(board, (db) => lastSession(db, id, slot));
|
|
437
|
+
if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw new Error('a reviewer must have a different session from the implementer');
|
|
438
|
+
}
|
|
434
439
|
const meta = { status, role: spec.role, slot };
|
|
435
440
|
return surface === 'pane'
|
|
436
441
|
? launchPane(board, task, runId, lane, fleet, { input: prompt, slot, meta })
|
|
@@ -443,7 +448,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
443
448
|
const fleet = observeFleet();
|
|
444
449
|
const moved = tx(board, (db) => fire(db, board, entry, id, entry.workflow.dispatch.start, { roles, payload: { lock: claimer() } }));
|
|
445
450
|
const task = getTaskNow(board, id);
|
|
446
|
-
const spec = runSpec(entry, moved.to);
|
|
451
|
+
const spec = runSpec(entry, moved.to, task);
|
|
447
452
|
const profile = spec ? tx(board, (db) => evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scopeOf(db, board, task, { params: entry.params }))) : null;
|
|
448
453
|
const lane = profile ? lanes[profile] : null;
|
|
449
454
|
if (spec && !lane) throw new Error(`${id} is ${moved.to}, run by ${spec.role} (${profile ?? 'nobody'}), which is no lane of this home`);
|
package/board/engine.mjs
CHANGED
|
@@ -325,7 +325,8 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
325
325
|
roles ??= callerRoles(db, id);
|
|
326
326
|
const r = fire(db, board, entry, id, name, { roles, payload, extra });
|
|
327
327
|
// the cards that wait on it, and its subtasks, see it change
|
|
328
|
-
const
|
|
328
|
+
const related = all(db, 'SELECT task_id, blocked_by FROM supercode_cards').filter((c) => json(c.blocked_by, []).some((b) => b.id === id)).map((c) => c.task_id);
|
|
329
|
+
const around = [...related, ...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
|
|
329
330
|
for (const other of new Set(around)) settle(db, board, entry, other, r.effects);
|
|
330
331
|
return r;
|
|
331
332
|
});
|
|
@@ -347,7 +348,8 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
|
|
|
347
348
|
|
|
348
349
|
/** The I/O a committed move asked for. */
|
|
349
350
|
export async function runEffects(board, effects = []) {
|
|
350
|
-
|
|
351
|
+
const pending = tx(board, (db) => db.prepare('SELECT id, effect FROM supercode_effects ORDER BY id').all());
|
|
352
|
+
for (const e of [...pending.map((p) => ({ ...json(p.effect, {}), durableId: p.id })), ...effects]) {
|
|
351
353
|
if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
|
|
352
354
|
else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
|
|
353
355
|
else if ((e.kind === 'run_ended' && e.outcome === 'reclaimed') || e.kind === 'stop_run') stopWorker(board, e.runId);
|
|
@@ -355,6 +357,7 @@ export async function runEffects(board, effects = []) {
|
|
|
355
357
|
const { sessionEffect } = await import('./dispatch.mjs');
|
|
356
358
|
await sessionEffect(board, e);
|
|
357
359
|
}
|
|
360
|
+
if (e.durableId) tx(board, (db) => db.prepare('DELETE FROM supercode_effects WHERE id = ?').run(e.durableId));
|
|
358
361
|
}
|
|
359
362
|
}
|
|
360
363
|
|
|
@@ -414,7 +417,7 @@ export const complete = (board, id, { result = null, summary = null, metadata =
|
|
|
414
417
|
// a block names the one thing that ends it: a card it waits on (`on`), or the message whose answer it waits for (`waiting_on`)
|
|
415
418
|
export const block = (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
|
|
416
419
|
act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
|
|
417
|
-
export const unblock = (board, id, { reason = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason } });
|
|
420
|
+
export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
|
|
418
421
|
export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles } = {}) =>
|
|
419
422
|
act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId } });
|
|
420
423
|
export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
|
package/board/store.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
|
14
14
|
import { hostname } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { DatabaseSync } from 'node:sqlite';
|
|
17
|
+
import { openZtrackBoard } from './ztrack.mjs';
|
|
17
18
|
import { isFilesBoard, openFilesBoard } from './files.mjs';
|
|
18
19
|
|
|
19
20
|
export const DEFAULT_BOARD = 'default';
|
|
@@ -67,7 +68,8 @@ CREATE INDEX IF NOT EXISTS idx_events_run ON task_events(run_id, id);
|
|
|
67
68
|
|
|
68
69
|
// supercode's part of a card, beside Hermes's row: where its session runs, and the arc a subtask belongs to (a
|
|
69
70
|
// subtask is worked by its arc's session and never started on its own).
|
|
70
|
-
const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY KEY, machine TEXT, subtask_of TEXT, stage TEXT)
|
|
71
|
+
const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY KEY, machine TEXT, subtask_of TEXT, stage TEXT);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
|
|
71
73
|
// `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
|
|
72
74
|
// `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
|
|
73
75
|
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' };
|
|
@@ -78,7 +80,7 @@ const OURS_COLUMNS = { subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', afte
|
|
|
78
80
|
// board. `sqlite` is Hermes's own `kanban.db`, one SQLite file Hermes and supercode both write; `files` (`files.mjs`)
|
|
79
81
|
// is a directory with one JSON file per card that only supercode writes. A board is whichever backing is on disk; a
|
|
80
82
|
// new one is `sqlite` unless its creator names another (`init --backing files`, `boards create <slug> --backing files`).
|
|
81
|
-
export const BACKINGS = ['sqlite', 'files'];
|
|
83
|
+
export const BACKINGS = ['sqlite', 'files', 'ztrack'];
|
|
82
84
|
|
|
83
85
|
const boardDirOf = (root, slug) => (!slug || slug === DEFAULT_BOARD ? join(root, 'kanban') : join(root, 'kanban', 'boards', slug));
|
|
84
86
|
|
|
@@ -87,13 +89,15 @@ export function backingPaths(root, slug = DEFAULT_BOARD) {
|
|
|
87
89
|
return {
|
|
88
90
|
sqlite: !slug || slug === DEFAULT_BOARD ? join(root, 'kanban.db') : join(boardDirOf(root, slug), 'kanban.db'),
|
|
89
91
|
files: join(boardDirOf(root, slug), 'board'),
|
|
92
|
+
ztrack: join(boardDirOf(root, slug), 'ztrack.json'),
|
|
90
93
|
};
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
/** The backing that keeps board `slug` now; null when it does not exist. */
|
|
94
97
|
export function backingOf(root, slug = DEFAULT_BOARD) {
|
|
95
98
|
const at = backingPaths(root, slug);
|
|
96
|
-
|
|
99
|
+
if (existsSync(at.ztrack)) return 'ztrack'; // kanban.db is this backing's projection
|
|
100
|
+
const have = BACKINGS.filter((b) => b !== 'ztrack').filter((b) => (b === 'files' ? isFilesBoard(at.files) : existsSync(at.sqlite)));
|
|
97
101
|
if (have.length > 1) throw new Error(`board ${slug} in ${root} is kept twice (${have.map((b) => at[b]).join(' and ')}); one backing keeps a board`);
|
|
98
102
|
return have[0] ?? null;
|
|
99
103
|
}
|
|
@@ -126,7 +130,7 @@ export function listBoards(root) {
|
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
/** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
|
|
129
|
-
function layOut(db, { fresh }) {
|
|
133
|
+
export function layOut(db, { fresh }) {
|
|
130
134
|
if (fresh) db.exec(SCHEMA);
|
|
131
135
|
db.exec(OURS);
|
|
132
136
|
const have = new Set(db.prepare('PRAGMA table_info(supercode_cards)').all().map((c) => c.name));
|
|
@@ -137,13 +141,14 @@ function layOut(db, { fresh }) {
|
|
|
137
141
|
* Open (creating when `create`, in `backing`) board `slug`: `{ db, root, slug, path, backing, tx, close }`. `db` answers
|
|
138
142
|
* SQLite's statement API (`prepare(sql).get|all|run`, `exec`) whatever the backing.
|
|
139
143
|
*/
|
|
140
|
-
export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null } = {}) {
|
|
144
|
+
export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null, document = null } = {}) {
|
|
141
145
|
const kept = backingOf(root, slug);
|
|
142
146
|
if (!kept && !create) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
|
|
143
147
|
if (kept && backing && kept !== backing) throw new Error(`board ${slug} in ${root} is kept by the ${kept} backing, not ${backing}`);
|
|
144
148
|
const use = kept ?? backing ?? 'sqlite';
|
|
145
149
|
if (!BACKINGS.includes(use)) throw new Error(`a board's backing is ${BACKINGS.join(' | ')}, not ${use}`);
|
|
146
150
|
const path = backingPaths(root, slug)[use];
|
|
151
|
+
if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, init: layOut, root, slug, compatPath: backingPaths(root, slug).sqlite }), root, slug, path, backing: use };
|
|
147
152
|
if (use === 'files') return { ...openFilesBoard(path, { create, init: layOut }), root, slug, path, backing: use };
|
|
148
153
|
if (!kept) mkdirSync(dirname(path), { recursive: true });
|
|
149
154
|
const db = new DatabaseSync(path);
|
package/board/workflow.mjs
CHANGED
|
@@ -472,7 +472,7 @@ const ACTIONS = {
|
|
|
472
472
|
}
|
|
473
473
|
if (remove) {
|
|
474
474
|
if (!String(reason ?? '').trim()) throw new Error('unblock must say what arrived');
|
|
475
|
-
blocks = blocks.filter((b) => remove !== '*' && b.id !== remove);
|
|
475
|
+
blocks = blocks.filter((b) => remove === 'messages' ? b.kind !== 'message' : remove !== '*' && b.id !== remove);
|
|
476
476
|
}
|
|
477
477
|
cardField(ctx, 'blocked_by', JSON.stringify(blocks));
|
|
478
478
|
},
|
|
@@ -480,7 +480,7 @@ const ACTIONS = {
|
|
|
480
480
|
if (!['paused', 'archived'].includes(name)) throw new Error(`unknown flag ${name}`);
|
|
481
481
|
if (name === 'paused') {
|
|
482
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');
|
|
483
|
+
if (!value && was && was.by !== actorOf() && !ctx.scope.sender?.includes('owner') && !(ctx.scope.sender?.includes('hermes') && ctx.scope.event?.hermes)) throw new Error('only the pauser or owner may resume this card');
|
|
484
484
|
if (value && !String(reason ?? '').trim()) throw new Error('pause needs a reason');
|
|
485
485
|
cardField(ctx, name, value ? JSON.stringify({ by: actorOf(), reason }) : null);
|
|
486
486
|
} else cardField(ctx, name, Number(Boolean(value)));
|
|
@@ -490,8 +490,8 @@ const ACTIONS = {
|
|
|
490
490
|
cardField(ctx, 'every', interval == null ? null : String(interval));
|
|
491
491
|
cardField(ctx, 'next_run_at', interval == null ? null : now());
|
|
492
492
|
},
|
|
493
|
-
card_event(ctx, { name, payload = {} }) {
|
|
494
|
-
const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [
|
|
493
|
+
card_event(ctx, { name, payload = {}, as = ['platform'] }) {
|
|
494
|
+
const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [].concat(as), payload, extra: { lanes: lanesIn(ctx.entry) } });
|
|
495
495
|
ctx.effects.push(...result.effects);
|
|
496
496
|
},
|
|
497
497
|
record(ctx, args, scope) {
|
|
@@ -566,8 +566,9 @@ const ACTIONS = {
|
|
|
566
566
|
run(ctx.db, "UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, 'invariant recovery on re-claim'), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND ended_at IS NULL", t, task.current_run_id);
|
|
567
567
|
}
|
|
568
568
|
const expires = t + ttl;
|
|
569
|
+
role = runSpec(ctx.entry, ctx.to, scope.card)?.role ?? role;
|
|
569
570
|
const profile = evaluate(ctx.entry.workflow.roles?.[role] ?? 'card.assignee', scope) ?? task.assignee;
|
|
570
|
-
const slot = String(templateValue(runSpec(ctx.entry, ctx.to)?.session ?? 'worker', scope) ?? 'worker');
|
|
571
|
+
const slot = String(templateValue(runSpec(ctx.entry, ctx.to, scope.card)?.session ?? 'worker', scope) ?? 'worker');
|
|
571
572
|
run(ctx.db, 'UPDATE tasks SET claim_lock = ?, claim_expires = ?, started_at = COALESCE(started_at, ?) WHERE id = ?', lock, expires, t, ctx.id);
|
|
572
573
|
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', ?, ?, ?, ?, ?)",
|
|
573
574
|
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 } }));
|
|
@@ -705,9 +706,10 @@ export function intervalSeconds(value) {
|
|
|
705
706
|
return seconds;
|
|
706
707
|
}
|
|
707
708
|
|
|
708
|
-
export function runSpec(entry, status) {
|
|
709
|
+
export function runSpec(entry, status, card = null) {
|
|
709
710
|
const st = entry.workflow.statuses[status];
|
|
710
|
-
const
|
|
711
|
+
const name = card?.every && entry.workflow.run?.recurring_role ? entry.workflow.run.recurring_role : st?.run_role;
|
|
712
|
+
const spec = name ? entry.workflow.run?.roles?.[name] : st?.run;
|
|
711
713
|
if (!spec?.prompt_file) return spec;
|
|
712
714
|
const file = resolve(entry.root, spec.prompt_file);
|
|
713
715
|
const rel = relative(entry.root, file);
|
|
@@ -760,12 +762,21 @@ function settleRelations(db, board, entry, id, effects) {
|
|
|
760
762
|
const row = one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id);
|
|
761
763
|
const blocks = json(row?.blocked_by, []) ?? [];
|
|
762
764
|
for (const block of blocks) {
|
|
765
|
+
if (!json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []).some((b) => b.id === block.id)) continue;
|
|
763
766
|
const dependency = block.kind === 'card' ? one(db, 'SELECT * FROM tasks WHERE id = ?', block.id) : null;
|
|
764
767
|
const status = dependency ? statusOf(db, dependency) : null;
|
|
765
768
|
const answer = block.kind === 'message' ? answerTo(block.id) : null;
|
|
766
769
|
const policy = entry.workflow.relations.blocked_by;
|
|
767
770
|
const name = answer ? policy.answered : status === 'done' ? policy.completed : status === 'cancelled' ? policy.cancelled : null;
|
|
768
771
|
if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}` } }).effects);
|
|
772
|
+
else if (policy.aged && policy.age_seconds != null) {
|
|
773
|
+
const seconds = Number(evaluate(String(policy.age_seconds), { params: entry.params }));
|
|
774
|
+
if (seconds > 0 && now() - Number(block.notified_at ?? block.since ?? now()) >= seconds) {
|
|
775
|
+
const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
|
|
776
|
+
run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, notified_at: now() } : b)), id);
|
|
777
|
+
effects.push(...fire(db, board, entry, id, policy.aged, { roles: ['platform'], payload: { on: block.id, reason: block.reason } }).effects);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
769
780
|
}
|
|
770
781
|
}
|
|
771
782
|
|
package/board/ztrack.mjs
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// The document is card state; SQLite here is only a runtime journal and Hermes projection.
|
|
2
|
+
// ADR 0001. All markdown interpretation belongs to ztrack's public codec and kanban preset.
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
import { actorOf, event, json, now, setActor } from './store.mjs';
|
|
10
|
+
import { fire, projectStatus, statusOf, workflowFor } from './workflow.mjs';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
14
|
+
const read = (path) => existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
15
|
+
const quote = (s) => `"${s.replaceAll('"', '""')}"`;
|
|
16
|
+
const nativeId = (id) => /^t-[a-f0-9]+$/.test(id) ? id.replace('t-', 't_') : id;
|
|
17
|
+
const RUNTIME = new Set(['claim_lock', 'claim_expires', 'worker_pid', 'last_heartbeat_at', 'current_run_id', 'consecutive_failures', 'last_failure_error', 'block_recurrences', 'session_id']);
|
|
18
|
+
const CARD_MAIN = new Set(['id', 'title', 'body', 'status', 'assignee', 'priority', 'workspace_kind', 'workspace_path', 'branch_name']);
|
|
19
|
+
const EPHEMERAL = new Set(['tasks', 'supercode_cards', 'task_links', 'sqlite_sequence']);
|
|
20
|
+
const atomic = (path, text) => { const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, text, { mode: 0o600 }); renameSync(tmp, path); };
|
|
21
|
+
|
|
22
|
+
export function openZtrackBoard(marker, { create = false, document = null, seed = null, init, root, slug, compatPath }) {
|
|
23
|
+
if (!existsSync(marker)) {
|
|
24
|
+
if (!create) throw new Error(`no ztrack board ${marker}`);
|
|
25
|
+
mkdirSync(dirname(marker), { recursive: true });
|
|
26
|
+
atomic(marker, JSON.stringify({ format: 1, document: resolve(document ?? resolve(dirname(marker), 'arcs.md')) }) + '\n');
|
|
27
|
+
}
|
|
28
|
+
const config = JSON.parse(read(marker));
|
|
29
|
+
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, compatPath });
|
|
30
|
+
return { db: backing.db, tx: (fn) => backing.tx(fn), close: () => backing.close(), document: backing.path };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class ZtrackBoard {
|
|
34
|
+
constructor({ document, seed, marker, init, root, slug, compatPath }) {
|
|
35
|
+
this.path = resolve(dirname(marker), document);
|
|
36
|
+
this.archive = `${this.path}.archive.md`;
|
|
37
|
+
this.lockPath = `${marker}.lock`;
|
|
38
|
+
this.seed = seed;
|
|
39
|
+
this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
|
|
40
|
+
this.codec = require('@volter/ztrack/board-document');
|
|
41
|
+
this.preset = require('@volter/ztrack/kanban-preset');
|
|
42
|
+
this.runtime = new DatabaseSync(`${marker}.runtime.db`);
|
|
43
|
+
this.runtime.exec('PRAGMA busy_timeout = 10000; CREATE TABLE IF NOT EXISTS state (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
|
44
|
+
this.mem = null; this.active = false;
|
|
45
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
46
|
+
const self = this;
|
|
47
|
+
this.db = {
|
|
48
|
+
prepare(sql) { return Object.fromEntries(['get', 'all', 'run', 'iterate'].map((method) => [method, (...args) => self.access(() => method === 'iterate' ? self.mem.prepare(sql).all(...args)[Symbol.iterator]() : self.mem.prepare(sql)[method](...args), method !== 'run')])); },
|
|
49
|
+
exec(sql) { return self.access(() => self.mem.exec(sql)); },
|
|
50
|
+
close() { self.close(); },
|
|
51
|
+
};
|
|
52
|
+
this.tx(() => {});
|
|
53
|
+
}
|
|
54
|
+
get(key, fallback = null) { return json(this.runtime.prepare('SELECT value FROM state WHERE key = ?').get(key)?.value, fallback); }
|
|
55
|
+
put(key, value) { this.runtime.prepare('INSERT INTO state VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, JSON.stringify(value)); }
|
|
56
|
+
stamp() {
|
|
57
|
+
const fileStamp = (path) => { try { const s = statSync(path); return `${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return 'missing'; } };
|
|
58
|
+
return [fileStamp(this.path), fileStamp(this.archive), fileStamp(this.compatPath), this.get('version', '')].join('|');
|
|
59
|
+
}
|
|
60
|
+
access(fn, reading = false) {
|
|
61
|
+
if (this.active || (reading && this.mem && this.loadedStamp === this.stamp())) return fn();
|
|
62
|
+
return this.tx(fn);
|
|
63
|
+
}
|
|
64
|
+
close() { this.mem?.close(); this.runtime.close(); }
|
|
65
|
+
board() { return { root: this.root, slug: this.slug, label: this.slug, db: this.db }; }
|
|
66
|
+
recover() {
|
|
67
|
+
const pending = this.get('pending');
|
|
68
|
+
if (!pending) return;
|
|
69
|
+
for (const file of pending.files) {
|
|
70
|
+
const current = hash(read(file.path));
|
|
71
|
+
if (current !== file.before && current !== hash(file.text)) throw new Error(`unfinished board write conflicts with an edit to ${file.path}; retained runtime journal`);
|
|
72
|
+
if (current === file.before) atomic(file.path, file.text);
|
|
73
|
+
}
|
|
74
|
+
this.put('snapshot', pending.snapshot);
|
|
75
|
+
this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run();
|
|
76
|
+
}
|
|
77
|
+
tx(fn) {
|
|
78
|
+
if (this.active) return fn(this.db);
|
|
79
|
+
let lock;
|
|
80
|
+
for (let n = 0; ; n++) {
|
|
81
|
+
try { lock = openSync(this.lockPath, 'wx', 0o600); writeFileSync(lock, String(process.pid)); break; }
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code !== 'EEXIST' || n >= 100) throw error;
|
|
84
|
+
const pid = Number(read(this.lockPath));
|
|
85
|
+
if (pid) { try { process.kill(pid, 0); } catch (e) { if (e.code === 'ESRCH') { unlinkSync(this.lockPath); continue; } } }
|
|
86
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
try { return this.transaction(fn); } finally { closeSync(lock); unlinkSync(this.lockPath); }
|
|
90
|
+
}
|
|
91
|
+
transaction(fn) {
|
|
92
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
93
|
+
this.runtime.exec('BEGIN IMMEDIATE'); this.active = true;
|
|
94
|
+
let committed = false;
|
|
95
|
+
try {
|
|
96
|
+
this.recover();
|
|
97
|
+
this.load();
|
|
98
|
+
if (this.seed) this.seedIntoMemory();
|
|
99
|
+
else this.lift();
|
|
100
|
+
const result = fn(this.db);
|
|
101
|
+
const documents = this.documents();
|
|
102
|
+
const files = [{ path: this.path, before: hash(this.text), text: documents.open }, { path: this.archive, before: hash(this.archiveText), text: documents.archived }];
|
|
103
|
+
if (files.some((f) => hash(read(f.path)) !== f.before)) { this.runtime.exec('ROLLBACK'); continue; }
|
|
104
|
+
const snapshot = this.snapshot();
|
|
105
|
+
// A durable intent precedes filesystem writes; recovery completes exactly this version.
|
|
106
|
+
this.put('pending', { files, snapshot });
|
|
107
|
+
this.runtime.exec('COMMIT'); committed = true;
|
|
108
|
+
// Detect an editor racing the journal commit before replacing either document.
|
|
109
|
+
if (files.some((f) => hash(read(f.path)) !== f.before)) {
|
|
110
|
+
this.runtime.exec('BEGIN IMMEDIATE'); this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run(); this.runtime.exec('COMMIT');
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (const f of files) if (hash(f.text) !== f.before) atomic(f.path, f.text);
|
|
114
|
+
this.runtime.exec('BEGIN IMMEDIATE');
|
|
115
|
+
this.put('snapshot', snapshot); this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run();
|
|
116
|
+
this.export();
|
|
117
|
+
this.put('version', randomBytes(8).toString('hex'));
|
|
118
|
+
this.runtime.exec('COMMIT');
|
|
119
|
+
this.loadedStamp = this.stamp();
|
|
120
|
+
this.seed = null;
|
|
121
|
+
return result;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
try { this.runtime.exec('ROLLBACK'); } catch { /* intent already durable */ }
|
|
124
|
+
if (committed) error.message += ' (the runtime journal retains any unfinished document write)';
|
|
125
|
+
throw error;
|
|
126
|
+
} finally { this.active = false; }
|
|
127
|
+
}
|
|
128
|
+
throw new Error('board document kept changing during eight version-checked retries; no edit was overwritten');
|
|
129
|
+
}
|
|
130
|
+
seedIntoMemory() {
|
|
131
|
+
const source = this.seed;
|
|
132
|
+
for (const table of this.mem.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) this.mem.exec(`DELETE FROM ${quote(table.name)}`);
|
|
133
|
+
for (const table of source.db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
|
|
134
|
+
if (!this.mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table.name)) this.mem.exec(table.sql);
|
|
135
|
+
for (const row of source.db.prepare(`SELECT * FROM ${quote(table.name)}`).all()) this.insert(table.name, row);
|
|
136
|
+
}
|
|
137
|
+
for (const task of this.mem.prepare('SELECT * FROM tasks').all()) {
|
|
138
|
+
const old = this.mem.prepare('SELECT * FROM supercode_cards WHERE task_id = ?').get(task.id) ?? {};
|
|
139
|
+
if (old.subtask_of) { const key = /^([a-z]+\d+)\s/.exec(task.title)?.[1]; if (key) this.taskKeys.set(task.id, `${old.subtask_of}:${key}`); }
|
|
140
|
+
const status = old.stage ?? task.status;
|
|
141
|
+
const mapped = source.mapping[status];
|
|
142
|
+
if (!mapped) throw new Error(`migration needs an explicit mapping for ${status} (${task.id})`);
|
|
143
|
+
const stage = typeof mapped === 'string' ? mapped : mapped.status;
|
|
144
|
+
const parents = this.mem.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(task.id).map((p) => ({ id: p.parent_id, kind: 'card' }));
|
|
145
|
+
const last = this.mem.prepare("SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('blocked','unblocked') ORDER BY id DESC LIMIT 1").get(task.id);
|
|
146
|
+
const wait = last?.kind === 'blocked' ? json(last.payload, {})?.waiting_on : null;
|
|
147
|
+
if (wait) parents.push({ id: wait, kind: 'message' });
|
|
148
|
+
this.insert('supercode_cards', { ...old, task_id: task.id, stage, archived: Number(Boolean(mapped.archived) || stage === 'cancelled'), blocked_by: JSON.stringify(parents),
|
|
149
|
+
paused: mapped.paused ? JSON.stringify({ by: 'migration', reason: `migrated ${status}: ${json(last?.payload, {})?.reason ?? 'held by previous workflow'}` }) : null });
|
|
150
|
+
this.mem.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(stage, task.id);
|
|
151
|
+
if (task.current_run_id && ['done','cancelled'].includes(stage)) {
|
|
152
|
+
this.mem.prepare("UPDATE task_runs SET ended_at = COALESCE(ended_at, ?), status = 'reclaimed', outcome = COALESCE(outcome, 'reclaimed') WHERE id = ?").run(now(), task.current_run_id);
|
|
153
|
+
this.mem.prepare('UPDATE tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL WHERE id = ?').run(task.id);
|
|
154
|
+
} else if (task.current_run_id) {
|
|
155
|
+
const run = this.mem.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id);
|
|
156
|
+
const meta = json(run?.metadata, {}) ?? {}; meta.supercode = { ...meta.supercode, state: 'active' };
|
|
157
|
+
this.mem.prepare('UPDATE task_runs SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), task.current_run_id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.put('hermes_cursor', Number(source.db.prepare('SELECT COALESCE(MAX(id),0) AS id FROM task_events').get().id));
|
|
161
|
+
}
|
|
162
|
+
insert(table, row) {
|
|
163
|
+
const columns = new Set(this.mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
|
|
164
|
+
const keys = Object.keys(row).filter((k) => columns.has(k));
|
|
165
|
+
if (!keys.length) return;
|
|
166
|
+
this.mem.prepare(`INSERT OR REPLACE INTO ${quote(table)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...keys.map((k) => row[k] ?? null));
|
|
167
|
+
}
|
|
168
|
+
load() {
|
|
169
|
+
this.text = read(this.path); this.archiveText = read(this.archive);
|
|
170
|
+
this.mem?.close(); this.mem = new DatabaseSync(':memory:'); this.init(this.mem, { fresh: true });
|
|
171
|
+
this.saved = this.get('snapshot', { tables: {}, runtime: {}, clocks: {}, fingerprints: {} });
|
|
172
|
+
for (const item of Object.values(this.saved.tables ?? {})) {
|
|
173
|
+
if (item.sql) this.mem.exec(item.sql.replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '));
|
|
174
|
+
for (const row of item.rows ?? []) this.insert(item.name, row);
|
|
175
|
+
}
|
|
176
|
+
const records = [...this.codec.parseBoardDocument(this.text, this.path), ...this.codec.parseBoardDocument(this.archiveText, this.archive)];
|
|
177
|
+
const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban(records));
|
|
178
|
+
this.originals = new Map(); this.fileIds = new Map(); this.taskKeys = new Map();
|
|
179
|
+
for (const card of parsed.issues) {
|
|
180
|
+
const id = nativeId(card.id);
|
|
181
|
+
if (this.originals.has(id)) throw new Error(`duplicate board card ${id}`);
|
|
182
|
+
this.originals.set(id, card); this.fileIds.set(id, card.id);
|
|
183
|
+
this.loadCard(id, card);
|
|
184
|
+
for (const task of card.acceptanceCriteria) {
|
|
185
|
+
const tid = task.metadata?.id ?? this.saved.taskIds?.[`${id}:${task.id}`] ?? `t_${randomBytes(4).toString('hex')}`;
|
|
186
|
+
this.taskKeys.set(tid, `${id}:${task.id}`);
|
|
187
|
+
const body = task.metadata?.body ?? '';
|
|
188
|
+
const taskCard = { ...task.metadata?.card, id: tid, title: `${task.id} ${task.text}`, body,
|
|
189
|
+
status: task.status === 'passed' ? 'done' : (task.metadata?.status === 'done' ? 'todo' : task.metadata?.status ?? 'todo'),
|
|
190
|
+
metadata: task.metadata?.properties ?? {}, assignee: task.metadata?.assignee ?? card.assignee,
|
|
191
|
+
machine: task.metadata?.machine ?? card.machine, acceptanceCriteria: [],
|
|
192
|
+
acceptance: task.lines ?? [], relations: (task.blockedBy ?? []).map((b) => ({ type: 'blocked-by', issueId: b.ac ? `${nativeId(b.issue)}:${b.ac}` : nativeId(b.issue) })) };
|
|
193
|
+
if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
|
|
194
|
+
this.loadCard(tid, taskCard, id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Resolve local task refs only after all subtask identities are available.
|
|
198
|
+
const taskIds = new Map([...this.taskKeys].map(([id, key]) => [key, id]));
|
|
199
|
+
for (const row of this.mem.prepare('SELECT task_id, blocked_by FROM supercode_cards').all()) {
|
|
200
|
+
const blocks = json(row.blocked_by, []).map((b) => ({ ...b, id: taskIds.get(b.id) ?? b.id }));
|
|
201
|
+
this.mem.prepare('UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?').run(JSON.stringify(blocks), row.task_id);
|
|
202
|
+
for (const b of blocks.filter((b) => b.kind === 'card')) this.insert('task_links', { parent_id: b.id, child_id: row.task_id });
|
|
203
|
+
}
|
|
204
|
+
this.documentEvents();
|
|
205
|
+
}
|
|
206
|
+
loadCard(id, card, parent = null) {
|
|
207
|
+
const meta = card.metadata ?? {};
|
|
208
|
+
const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
|
|
209
|
+
const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
|
|
210
|
+
this.insert('tasks', { ...meta, ...this.saved.runtime?.[id], id, title: card.title, body: stored.body,
|
|
211
|
+
status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? now(), priority: card.priority ?? 0,
|
|
212
|
+
workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: workspace?.[2] ?? null, branch_name: card.branch ?? null });
|
|
213
|
+
this.insert('supercode_cards', { ...this.saved.clocks?.[id], task_id: id, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
|
|
214
|
+
blocked_by: JSON.stringify((card.relations ?? []).map((r) => ({ ...(meta.blocked_by ?? []).find((b) => b.id === nativeId(r.issueId)), id: nativeId(r.issueId), kind: /^[ma]-/.test(r.issueId) ? 'message' : 'card' }))),
|
|
215
|
+
paused: card.paused ? JSON.stringify(card.paused) : null, archived: meta.archived ? 1 : 0, every: card.every ?? null });
|
|
216
|
+
}
|
|
217
|
+
fingerprints() {
|
|
218
|
+
return Object.fromEntries(this.mem.prepare('SELECT * FROM tasks').all().map((t) => {
|
|
219
|
+
const c = this.mem.prepare('SELECT * FROM supercode_cards WHERE task_id = ?').get(t.id) ?? {};
|
|
220
|
+
return [t.id, { hash: hash(JSON.stringify([t.title, t.body, c.stage, t.assignee, c.machine, c.blocked_by, c.paused, c.every])), title: hash(t.title), body: hash(t.body ?? ''), status: c.stage, assignee: t.assignee, machine: c.machine, parent: c.subtask_of }];
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
documentEvents() {
|
|
224
|
+
const previous = this.saved.fingerprints ?? {};
|
|
225
|
+
const current = this.fingerprints();
|
|
226
|
+
if (!Object.keys(previous).length) return;
|
|
227
|
+
const by = this.committer();
|
|
228
|
+
for (const [id, value] of Object.entries(current)) {
|
|
229
|
+
const old = previous[id];
|
|
230
|
+
if (old?.hash === value.hash) continue;
|
|
231
|
+
let entry; try { entry = workflowFor(this.root); } catch {}
|
|
232
|
+
if (old && entry?.workflow.verbs?.document_changed) {
|
|
233
|
+
const change = fire(this.mem, this.board(), entry, id, 'document_changed', { roles: ['platform'], payload: { previous_status: old.status, by, source: 'document' } });
|
|
234
|
+
for (const effect of change.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
235
|
+
}
|
|
236
|
+
const kind = !old ? 'created' : old.status !== value.status ? 'status' : old.assignee !== value.assignee ? 'assigned' : 'specified';
|
|
237
|
+
event(this.mem, id, kind, { by, source: 'document', status: value.status, assignee: value.assignee, author: by });
|
|
238
|
+
if (!old && value.parent) event(this.mem, value.parent, 'subtask_added', { by, subtask: id, title: this.mem.prepare('SELECT title FROM tasks WHERE id = ?').get(id).title });
|
|
239
|
+
}
|
|
240
|
+
for (const id of Object.keys(previous)) if (!current[id]) {
|
|
241
|
+
if (this.saved.runtime?.[id]?.current_run_id) throw new Error(`document removed ${id} with a live run; cancel it through its workflow first`);
|
|
242
|
+
event(this.mem, id, 'removed', { by, source: 'document' });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
committer() {
|
|
246
|
+
const r = spawnSync('git', ['-C', dirname(this.path), 'log', '-1', '--format=%an <%ae>', '--', this.path], { encoding: 'utf8', timeout: 5000 });
|
|
247
|
+
return r.status === 0 && r.stdout.trim() ? `git:${r.stdout.trim()}` : 'document-editor';
|
|
248
|
+
}
|
|
249
|
+
documents() {
|
|
250
|
+
const rows = this.mem.prepare('SELECT * FROM tasks ORDER BY created_at, id').all();
|
|
251
|
+
const states = new Map(this.mem.prepare('SELECT * FROM supercode_cards').all().map((c) => [c.task_id, c]));
|
|
252
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
253
|
+
const cards = new Map();
|
|
254
|
+
for (const row of rows.filter((r) => !states.get(r.id)?.subtask_of)) {
|
|
255
|
+
const c = states.get(row.id) ?? {};
|
|
256
|
+
const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: this.fileIds.get(row.id) ?? row.id, title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' }])).issues[0];
|
|
257
|
+
const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
|
|
258
|
+
if (c.archived) metadata.archived = true;
|
|
259
|
+
if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
|
|
260
|
+
cards.set(row.id, { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_path ? `:${row.workspace_path}` : ''),
|
|
261
|
+
branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
|
|
262
|
+
relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: this.fileIds.get(b.id) ?? b.id })), acceptanceCriteria: [] });
|
|
263
|
+
}
|
|
264
|
+
const occupied = new Map();
|
|
265
|
+
for (const row of rows) {
|
|
266
|
+
const parent = states.get(row.id)?.subtask_of;
|
|
267
|
+
if (!parent) continue;
|
|
268
|
+
const key = this.taskKeys.get(row.id)?.split(':').at(-1) ?? /^([a-z]+\d+)\s/.exec(row.title)?.[1];
|
|
269
|
+
if (key) { const set = occupied.get(parent) ?? new Set(); set.add(key); occupied.set(parent, set); }
|
|
270
|
+
}
|
|
271
|
+
const renderedKeys = new Set();
|
|
272
|
+
for (const row of rows.filter((r) => states.get(r.id)?.subtask_of)) {
|
|
273
|
+
const c = states.get(row.id); const parent = cards.get(c.subtask_of);
|
|
274
|
+
if (!parent) throw new Error(`subtask ${row.id} has no arc ${c.subtask_of}`);
|
|
275
|
+
const taken = occupied.get(c.subtask_of) ?? new Set(); occupied.set(c.subtask_of, taken);
|
|
276
|
+
const match = /^([a-z]+\d+)\s+(.+)$/.exec(row.title);
|
|
277
|
+
let key = this.taskKeys.get(row.id)?.split(':').at(-1) ?? match?.[1];
|
|
278
|
+
if (!key || renderedKeys.has(`${c.subtask_of}:${key}`)) { let n = 1; while (taken.has(`c${n}`)) n++; key = `c${n}`; }
|
|
279
|
+
taken.add(key); renderedKeys.add(`${c.subtask_of}:${key}`); this.taskKeys.set(row.id, `${c.subtask_of}:${key}`);
|
|
280
|
+
const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: row.id, title: row.title, status: c.stage ?? row.status, body: row.body ?? '' }])).issues[0];
|
|
281
|
+
const originalTask = this.originals.get(c.subtask_of)?.acceptanceCriteria.find((t) => t.id === key);
|
|
282
|
+
parent.acceptanceCriteria.push({ sources: originalTask?.sources, id: key, status: ['done', 'cancelled'].includes(c.stage ?? row.status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? row.title,
|
|
283
|
+
metadata: { id: row.id, status: c.stage ?? row.status, body: parsed.body, assignee: row.assignee, machine: c.machine,
|
|
284
|
+
properties: Object.fromEntries(Object.entries(row).filter(([k,v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null)),
|
|
285
|
+
card: { paused: json(c.paused, null) ?? undefined, every: c.every || undefined } }, lines: parsed.acceptance,
|
|
286
|
+
blockedBy: json(c.blocked_by, []).map((b) => ({ issue: b.id })) });
|
|
287
|
+
}
|
|
288
|
+
const open = [], archived = [];
|
|
289
|
+
for (const [id, card] of cards) {
|
|
290
|
+
const serialized = this.preset.serializeKanbanCard(card);
|
|
291
|
+
const record = { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
292
|
+
(states.get(id)?.archived || ['done', 'cancelled'].includes(card.status) ? archived : open).push(record);
|
|
293
|
+
}
|
|
294
|
+
return { open: this.codec.renderBoardDocument(this.text, open, this.path), archived: this.codec.renderBoardDocument(this.archiveText, archived, this.archive) };
|
|
295
|
+
}
|
|
296
|
+
snapshot() {
|
|
297
|
+
const tables = {};
|
|
298
|
+
for (const table of this.mem.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
|
|
299
|
+
if (!EPHEMERAL.has(table.name)) tables[table.name] = { ...table, rows: this.mem.prepare(`SELECT * FROM ${quote(table.name)}`).all() };
|
|
300
|
+
}
|
|
301
|
+
const runtime = Object.fromEntries(this.mem.prepare('SELECT * FROM tasks').all().map((t) => [t.id, Object.fromEntries(Object.entries(t).filter(([k]) => RUNTIME.has(k)))]));
|
|
302
|
+
const clocks = Object.fromEntries(this.mem.prepare('SELECT * FROM supercode_cards').all().map((c) => [c.task_id, { since: c.since, after_fired: c.after_fired, next_run_at: c.next_run_at }]));
|
|
303
|
+
return { tables, runtime, clocks, fingerprints: this.fingerprints(), taskIds: Object.fromEntries([...this.taskKeys].map(([id, key]) => [key, id])) };
|
|
304
|
+
}
|
|
305
|
+
lift() {
|
|
306
|
+
if (!existsSync(this.compatPath)) return;
|
|
307
|
+
let entry; try { entry = workflowFor(this.root); } catch { return; }
|
|
308
|
+
const compat = new DatabaseSync(this.compatPath);
|
|
309
|
+
try {
|
|
310
|
+
const cursor = this.get('hermes_cursor', 0);
|
|
311
|
+
const rows = compat.prepare('SELECT * FROM task_events WHERE id > ? ORDER BY id').all(cursor);
|
|
312
|
+
const before = actorOf();
|
|
313
|
+
try {
|
|
314
|
+
for (const row of rows) {
|
|
315
|
+
const payload = json(row.payload, {}) ?? {};
|
|
316
|
+
if (payload.__supercode_export) continue;
|
|
317
|
+
const task = this.mem.prepare('SELECT * FROM tasks WHERE id = ?').get(row.task_id);
|
|
318
|
+
if (!task) continue;
|
|
319
|
+
const roles = ['manager', 'reviewer', 'platform', 'hermes'];
|
|
320
|
+
setActor(payload.by ?? 'hermes');
|
|
321
|
+
let name = null; let words = { ...payload, reason: payload.reason ?? 'unblocked in Hermes' };
|
|
322
|
+
if (row.kind === 'unblocked') { name = 'unblock'; words = { ...words, reason: 'unblocked in Hermes', hermes: true }; }
|
|
323
|
+
else if (row.kind === 'completed') { name = 'complete'; words.summary = payload.summary ?? task.result; }
|
|
324
|
+
else if (row.kind === 'blocked' || row.kind === 'dependency_wait') { name = 'block'; words.on = payload.on ?? payload.parent ?? null; words.hermes = true; }
|
|
325
|
+
else if (row.kind === 'archived' && statusOf(this.mem, task) !== 'done') name = 'cancel';
|
|
326
|
+
if (row.kind === 'unblocked' && entry.workflow.verbs?.hermes_unpause) {
|
|
327
|
+
const resumed = fire(this.mem, this.board(), entry, task.id, 'hermes_unpause', { roles, payload: { hermes: true, reason: 'unblocked in Hermes' } });
|
|
328
|
+
for (const effect of resumed.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
329
|
+
}
|
|
330
|
+
if (name) {
|
|
331
|
+
const result = fire(this.mem, this.board(), entry, task.id, name, { roles, payload: words });
|
|
332
|
+
for (const effect of result.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
333
|
+
}
|
|
334
|
+
event(this.mem, task.id, 'hermes_lifted', { by: 'hermes', source_event: row.id, verb: name, kind: row.kind });
|
|
335
|
+
}
|
|
336
|
+
} finally { setActor(before); }
|
|
337
|
+
if (rows.length) this.put('hermes_cursor', rows.at(-1).id);
|
|
338
|
+
} finally { compat.close(); }
|
|
339
|
+
}
|
|
340
|
+
export() {
|
|
341
|
+
let entry; try { entry = workflowFor(this.root); } catch { return; }
|
|
342
|
+
for (const { id } of this.mem.prepare('SELECT id FROM tasks').all()) projectStatus(this.mem, this.board(), entry, id);
|
|
343
|
+
const compat = new DatabaseSync(this.compatPath);
|
|
344
|
+
compat.exec('PRAGMA busy_timeout = 10000');
|
|
345
|
+
try {
|
|
346
|
+
compat.exec('BEGIN IMMEDIATE'); this.init(compat, { fresh: true });
|
|
347
|
+
// Every import is event-based. Existing unconsumed acts must never be erased by an export race.
|
|
348
|
+
const cursor = this.get('hermes_cursor', 0);
|
|
349
|
+
const unseen = compat.prepare('SELECT payload FROM task_events WHERE id > ?').all(cursor).some((r) => !json(r.payload, {})?.__supercode_export);
|
|
350
|
+
if (unseen) throw new Error('Hermes wrote during board export; retained its events for the next read');
|
|
351
|
+
const tables = this.mem.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
352
|
+
for (const { name } of tables) {
|
|
353
|
+
if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)) continue;
|
|
354
|
+
compat.exec(`DELETE FROM ${quote(name)}`);
|
|
355
|
+
for (const source of this.mem.prepare(`SELECT * FROM ${quote(name)}`).all()) {
|
|
356
|
+
const row = { ...source };
|
|
357
|
+
if (name === 'task_events') row.payload = JSON.stringify({ ...json(row.payload, {}), __supercode_export: true });
|
|
358
|
+
const keys = Object.keys(row); compat.prepare(`INSERT INTO ${quote(name)} (${keys.map(quote)}) VALUES (${keys.map(() => '?')})`).run(...keys.map((k) => row[k]));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
compat.exec('COMMIT');
|
|
362
|
+
this.put('hermes_cursor', Number(compat.prepare('SELECT COALESCE(MAX(id),0) AS id FROM task_events').get().id));
|
|
363
|
+
} catch (error) { try { compat.exec('ROLLBACK'); } catch {} throw error; }
|
|
364
|
+
finally { compat.close(); }
|
|
365
|
+
}
|
|
366
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.78",
|
|
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": {
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"yaml": "^2.9.0",
|
|
28
|
-
"@volter/supercode-harness-sdk": "^0.3.41"
|
|
28
|
+
"@volter/supercode-harness-sdk": "^0.3.41",
|
|
29
|
+
"@volter/ztrack": "^2.4.14"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
32
|
"@chat-adapter/state-memory": "4.39.0",
|