@volter/supercode-orchestrator 0.4.38 → 0.4.40
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/bin/orchestrator.mjs +9 -4
- package/board/cli.mjs +21 -19
- package/board/dispatch.mjs +2 -2
- package/board/engine.mjs +3 -3
- package/board/files.mjs +261 -0
- package/board/store.mjs +74 -28
- package/board/workflow.mjs +9 -4
- package/package.json +1 -1
package/bin/orchestrator.mjs
CHANGED
|
@@ -539,13 +539,18 @@ async function runApply(argv) {
|
|
|
539
539
|
return 0;
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
+
// exit once what was written reached the pipe: a write to a pipe is asynchronous, and process.exit() right after one
|
|
543
|
+
// cuts the output at the pipe's buffer (a board's `list --json` past 64 KiB arrived truncated)
|
|
544
|
+
const flushed = (stream) => new Promise((done) => stream.write('', done));
|
|
545
|
+
const exitAfterFlush = async (code) => { await Promise.all([flushed(process.stdout), flushed(process.stderr)]); process.exit(code); };
|
|
546
|
+
|
|
542
547
|
async function main() {
|
|
543
548
|
const argv = process.argv.slice(2);
|
|
544
549
|
if (OPERATOR_OPS.includes(argv[0])) {
|
|
545
550
|
try {
|
|
546
551
|
const code = await runOperator(argv[0], argv.slice(1));
|
|
547
552
|
await closeWorld();
|
|
548
|
-
|
|
553
|
+
await exitAfterFlush(code);
|
|
549
554
|
} catch (error) {
|
|
550
555
|
process.stdout.write(`${JSON.stringify({ ok: false, error: String(error.message ?? error) })}\n`);
|
|
551
556
|
process.exit(1);
|
|
@@ -555,7 +560,7 @@ async function main() {
|
|
|
555
560
|
try {
|
|
556
561
|
const code = await (argv[0] === 'import' ? runImport(argv.slice(1)) : runExport(argv.slice(1)));
|
|
557
562
|
await closeWorld();
|
|
558
|
-
|
|
563
|
+
await exitAfterFlush(code);
|
|
559
564
|
} catch (error) {
|
|
560
565
|
say('error', { verb: argv[0], message: String(error.message ?? error), name: error.name ?? 'Error' });
|
|
561
566
|
await closeWorld();
|
|
@@ -568,11 +573,11 @@ async function main() {
|
|
|
568
573
|
try {
|
|
569
574
|
const code = await runWorkflow(argv.slice(1));
|
|
570
575
|
await closeWorld();
|
|
571
|
-
|
|
576
|
+
await exitAfterFlush(code);
|
|
572
577
|
} catch (error) {
|
|
573
578
|
process.stderr.write(`supercode workflow: ${error.message ?? error}\n`);
|
|
574
579
|
await closeWorld();
|
|
575
|
-
|
|
580
|
+
await exitAfterFlush(1);
|
|
576
581
|
}
|
|
577
582
|
}
|
|
578
583
|
if (argv[0] === 'access') {
|
package/board/cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@ 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 { boardPath, DEFAULT_BOARD, event, json, listBoards, now, openBoard, tx } from './store.mjs';
|
|
13
|
+
import { backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, tx } from './store.mjs';
|
|
14
14
|
import * as engine from './engine.mjs';
|
|
15
15
|
import { adoptSession, boardLabel, boardSettings, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
|
|
16
16
|
import { loadWorkflow, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
@@ -39,6 +39,8 @@ Hermes Kanban's verbs, with their flags:
|
|
|
39
39
|
pause [reason] · resume (Hermes's emergency stop, the ESTOP file: new starts stop, running sessions go on)
|
|
40
40
|
|
|
41
41
|
supercode's own:
|
|
42
|
+
init --backing sqlite|files · boards create <slug> --backing sqlite|files
|
|
43
|
+
Where the board is kept: sqlite is Hermes's kanban.db (the default); files is one JSON file per card.
|
|
42
44
|
create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
|
|
43
45
|
With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
|
|
44
46
|
waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
|
|
@@ -101,7 +103,7 @@ function boardOf(root, args) {
|
|
|
101
103
|
for (const want of [args.board, process.env.HERMES_KANBAN_BOARD, existsSync(currentFile(root)) ? readFileSync(currentFile(root), 'utf8').trim() : null]) {
|
|
102
104
|
if (!want) continue;
|
|
103
105
|
const slug = normalSlug(want);
|
|
104
|
-
if (slug === DEFAULT_BOARD ||
|
|
106
|
+
if (slug === DEFAULT_BOARD || boardExists(root, slug)) return slug;
|
|
105
107
|
if (want === args.board) return slug;
|
|
106
108
|
}
|
|
107
109
|
return DEFAULT_BOARD;
|
|
@@ -113,16 +115,16 @@ const metaPath = (root, slug) => join(boardDir(root, slug), 'board.json');
|
|
|
113
115
|
function boardMeta(root, slug) {
|
|
114
116
|
const meta = { slug, name: slug === DEFAULT_BOARD ? 'Default' : slug, description: '', icon: '', color: '', default_workdir: null, project_id: null, created_at: null, archived: false };
|
|
115
117
|
try { Object.assign(meta, JSON.parse(readFileSync(metaPath(root, slug), 'utf8')), { slug }); } catch { /* no metadata yet */ }
|
|
116
|
-
return { ...meta, db_path: boardPath(root, slug) };
|
|
118
|
+
return { ...meta, db_path: boardPath(root, slug), backing: backingOf(root, slug) };
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
function writeMeta(root, slug, patch) {
|
|
120
|
-
const { db_path: _, ...meta } = boardMeta(root, slug);
|
|
122
|
+
const { db_path: _, backing: __, ...meta } = boardMeta(root, slug);
|
|
121
123
|
for (const [k, v] of Object.entries(patch)) if (v !== undefined) meta[k] = v;
|
|
122
124
|
meta.created_at ??= now();
|
|
123
125
|
mkdirSync(boardDir(root, slug), { recursive: true });
|
|
124
126
|
writeFileSync(metaPath(root, slug), `${JSON.stringify(meta, null, 2)}\n`);
|
|
125
|
-
return { ...meta, db_path: boardPath(root, slug) };
|
|
127
|
+
return { ...meta, db_path: boardPath(root, slug), backing: backingOf(root, slug) };
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
/** Hermes's list: the default board and every named one with a database or metadata. */
|
|
@@ -132,7 +134,7 @@ function boardsOf(root, { includeArchived = true } = {}) {
|
|
|
132
134
|
if (existsSync(dir)) {
|
|
133
135
|
for (const slug of readdirSync(dir).sort()) {
|
|
134
136
|
if (slug === DEFAULT_BOARD || !SLUG.test(slug)) continue;
|
|
135
|
-
if (!
|
|
137
|
+
if (!boardExists(root, slug) && !existsSync(metaPath(root, slug))) continue;
|
|
136
138
|
const meta = boardMeta(root, slug);
|
|
137
139
|
if (meta.archived && !includeArchived) continue;
|
|
138
140
|
out.push(meta);
|
|
@@ -142,7 +144,7 @@ function boardsOf(root, { includeArchived = true } = {}) {
|
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
function counts(root, slug) {
|
|
145
|
-
if (!
|
|
147
|
+
if (!boardExists(root, slug)) return {};
|
|
146
148
|
const b = openBoard(root, slug);
|
|
147
149
|
try {
|
|
148
150
|
return Object.fromEntries(b.db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status').all().map((r) => [r.status, Number(r.n)]));
|
|
@@ -223,7 +225,7 @@ export async function runWorkflow(argv) {
|
|
|
223
225
|
if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
|
|
224
226
|
const root = homeOf(args);
|
|
225
227
|
const slug = boardOf(root, args);
|
|
226
|
-
const open = (create = false) => { const b = openBoard(root, slug, { create }); b.label = boardLabel(root, slug); return b; };
|
|
228
|
+
const open = (create = false) => { const b = openBoard(root, slug, { create, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
|
|
227
229
|
const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
|
|
228
230
|
const withBoardAsync = async (fn) => { const b = open(); try { return await fn(b); } finally { b.close(); } };
|
|
229
231
|
const id = rest[0];
|
|
@@ -232,7 +234,7 @@ export async function runWorkflow(argv) {
|
|
|
232
234
|
|
|
233
235
|
// a caller that is a card's own session (its harness names it; the board recorded which session it launched)
|
|
234
236
|
const sid = process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID;
|
|
235
|
-
if (sid && !READS.has(verb) &&
|
|
237
|
+
if (sid && !READS.has(verb) && boardExists(root, slug)) {
|
|
236
238
|
withBoard((b) => { const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
|
|
237
239
|
}
|
|
238
240
|
// the board's workflow (the IR's): what each verb does, and who may send it, is its
|
|
@@ -242,9 +244,9 @@ export async function runWorkflow(argv) {
|
|
|
242
244
|
|
|
243
245
|
switch (verb) {
|
|
244
246
|
case 'init': {
|
|
245
|
-
withBoard(() =>
|
|
247
|
+
const backing = withBoard((b) => b.backing, true);
|
|
246
248
|
const { profiles } = await laneState(root).catch(() => ({ profiles: [] }));
|
|
247
|
-
say(args,
|
|
249
|
+
say(args, `${backing === 'sqlite' ? 'Kanban DB' : `Board (${backing} backing)`} initialized at ${boardPath(root, slug)}\n\n${profiles.length ? `Profiles of this home (any can be an --assignee): ${profiles.join(', ')}` : 'No profiles in this home.'}\nThe dispatcher: \`supercode workflow serve\`, or the orchestrator while it serves the home.`, { ok: true, board: slug, path: boardPath(root, slug), backing, profiles });
|
|
248
250
|
return 0;
|
|
249
251
|
}
|
|
250
252
|
|
|
@@ -615,7 +617,7 @@ export async function runWorkflow(argv) {
|
|
|
615
617
|
}
|
|
616
618
|
case 'log': {
|
|
617
619
|
need(1, 'a card id');
|
|
618
|
-
const file = join(
|
|
620
|
+
const file = join(logsDir(root, slug), `${id}.log`);
|
|
619
621
|
if (!existsSync(file)) {
|
|
620
622
|
// a pane session keeps no log file: its transcript is the harness's own, named by the run
|
|
621
623
|
const s = withBoard((b) => tx(b, (db) => engine.lastSession(db, id)));
|
|
@@ -714,7 +716,7 @@ export async function runWorkflow(argv) {
|
|
|
714
716
|
}
|
|
715
717
|
const events = withBoard((b) => engine.gcEvents(b, eventDays * 86400));
|
|
716
718
|
let logs = 0;
|
|
717
|
-
const logDir =
|
|
719
|
+
const logDir = logsDir(root, slug);
|
|
718
720
|
if (existsSync(logDir)) for (const f of readdirSync(logDir)) { const p = join(logDir, f); if (statSync(p).isFile() && statSync(p).mtimeMs < Date.now() - logDays * 86400_000) { unlinkSync(p); logs += 1; } }
|
|
719
721
|
say(args, `GC complete: ${removedWs} workspace(s), ${events} event row(s), ${logs} log file(s) removed`, { workspaces: removedWs, events, logs });
|
|
720
722
|
return 0;
|
|
@@ -801,7 +803,7 @@ async function boardsVerb(root, rest, args) {
|
|
|
801
803
|
case 'create': case 'new': {
|
|
802
804
|
const slug = normalSlug(target);
|
|
803
805
|
const meta = writeMeta(root, slug, { name: args.name, description: args.description, icon: args.icon, color: args.color, default_workdir: args['default-workdir'] });
|
|
804
|
-
openBoard(root, slug, { create: true }).close();
|
|
806
|
+
openBoard(root, slug, { create: true, backing: args.backing ?? null }).close();
|
|
805
807
|
if (args.switch) { mkdirSync(join(root, 'kanban'), { recursive: true }); writeFileSync(currentFile(root), `${slug}\n`); }
|
|
806
808
|
say(args, `Board '${slug}' ready.\n Display name: ${meta.name}\n DB path: ${meta.db_path}${args.switch ? `\n Switched to '${slug}'.` : ''}`, meta);
|
|
807
809
|
return 0;
|
|
@@ -822,7 +824,7 @@ async function boardsVerb(root, rest, args) {
|
|
|
822
824
|
}
|
|
823
825
|
case 'switch': case 'use': {
|
|
824
826
|
const slug = normalSlug(target);
|
|
825
|
-
if (slug !== DEFAULT_BOARD && !
|
|
827
|
+
if (slug !== DEFAULT_BOARD && !boardExists(root, slug)) throw new Error(`board '${slug}' does not exist; \`supercode workflow boards create ${slug}\``);
|
|
826
828
|
mkdirSync(join(root, 'kanban'), { recursive: true });
|
|
827
829
|
writeFileSync(currentFile(root), `${slug}\n`);
|
|
828
830
|
say(args, `Active board is now '${slug}'.`, { ok: true, board: slug });
|
|
@@ -847,7 +849,7 @@ async function boardsVerb(root, rest, args) {
|
|
|
847
849
|
}
|
|
848
850
|
case 'export': {
|
|
849
851
|
const slug = normalSlug(target ?? boardOf(root, args));
|
|
850
|
-
if (!
|
|
852
|
+
if (!boardExists(root, slug)) throw new Error(`board '${slug}' has no database`);
|
|
851
853
|
const out = resolve(args.output ?? `${slug}.tar.gz`);
|
|
852
854
|
const made = exportBoard(root, slug, out, { attachments: !args['no-attachments'], logs: Boolean(args['include-logs']) });
|
|
853
855
|
say(args, `Exported board '${slug}' → ${out}\n Tasks: ${made.counts.tasks}\n Comments: ${made.counts.task_comments}\n Attachments: ${made.counts.attachment_files}`, made);
|
|
@@ -910,12 +912,12 @@ function exportBoard(root, slug, out, { attachments, logs }) {
|
|
|
910
912
|
const snap = openBoard(base, DEFAULT_BOARD);
|
|
911
913
|
let counts;
|
|
912
914
|
try { tx(snap, (db) => scrub(db)); counts = rowCounts(snap.db); } finally { snap.close(); }
|
|
913
|
-
const { db_path: _, default_workdir: __, project_id: ___, ...meta } = boardMeta(root, slug);
|
|
915
|
+
const { db_path: _, backing: ____, default_workdir: __, project_id: ___, ...meta } = boardMeta(root, slug);
|
|
914
916
|
writeFileSync(join(base, 'board.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
915
917
|
const attachmentsDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments');
|
|
916
|
-
const
|
|
918
|
+
const logsFrom = logsDir(root, slug);
|
|
917
919
|
const files = attachments ? copyTree(attachmentsDir, join(base, 'attachments')) : 0;
|
|
918
|
-
const logFiles = logs ? copyTree(
|
|
920
|
+
const logFiles = logs ? copyTree(logsFrom, join(base, 'logs')) : 0;
|
|
919
921
|
const manifest = { format: ARCHIVE_FORMAT, format_version: 1, board: slug, board_name: meta.name || slug, exported_at: now(), exported_by: 'supercode',
|
|
920
922
|
includes: { attachments, logs }, counts: { ...counts, attachment_files: files, log_files: logFiles } };
|
|
921
923
|
writeFileSync(join(base, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
package/board/dispatch.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { hostname, tmpdir } from 'node:os';
|
|
|
20
20
|
import { basename, dirname, join } from 'node:path';
|
|
21
21
|
import { randomUUID } from 'node:crypto';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
|
-
import { boardPath, claimer, event, json, listBoards, now, openBoard, tx } from './store.mjs';
|
|
23
|
+
import { boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, tx } from './store.mjs';
|
|
24
24
|
import { holdBoardTickLock } from '../hermes-locks.mjs';
|
|
25
25
|
import { doors, fire, loadWorkflow, resumeOf, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
|
|
26
26
|
import { evaluate, holds, render } from './expr.mjs';
|
|
@@ -623,7 +623,7 @@ function launchHeadless(board, task, runId, lane, settings, { prompt = null, ski
|
|
|
623
623
|
const args = ['-p', lane.name, '--cli', '--accept-hooks', ...skills.flatMap((s) => ['--skills', s]),
|
|
624
624
|
...(task.model_override ? ['-m', task.model_override, ...(task.provider_override ? ['--provider', task.provider_override] : [])] : []),
|
|
625
625
|
...(task.reasoning_effort ? ['--reasoning', task.reasoning_effort] : []), 'chat', '-q', `work kanban task ${task.id}`];
|
|
626
|
-
const logDir =
|
|
626
|
+
const logDir = logsDir(board.root, board.slug);
|
|
627
627
|
mkdirSync(logDir, { recursive: true });
|
|
628
628
|
const logFile = join(logDir, `${task.id}.log`);
|
|
629
629
|
rotateLog(logFile, settings.logRotateBytes, settings.logBackups);
|
package/board/engine.mjs
CHANGED
|
@@ -337,12 +337,12 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
337
337
|
/** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
|
|
338
338
|
export async function goto(board, id, status, { roles = null, payload = {} } = {}) {
|
|
339
339
|
const entry = await loadWorkflow(board.root);
|
|
340
|
-
const { name, from } = tx(board, (db) => {
|
|
340
|
+
const { name, refusal, from } = tx(board, (db) => {
|
|
341
341
|
roles ??= callerRoles(db, id);
|
|
342
|
-
return {
|
|
342
|
+
return { ...eventTo(db, board, entry, id, status, { roles, payload }), from: statusOf(db, getTask(db, id)) };
|
|
343
343
|
});
|
|
344
344
|
if (from === status) return { from, to: status, moved: false, event: null };
|
|
345
|
-
if (!name) throw new Error(`${id} is ${from}; no event you may send takes it to ${status}`);
|
|
345
|
+
if (!name) throw new Error(refusal ?? `${id} is ${from}; no event you may send takes it to ${status}`);
|
|
346
346
|
return { ...(await act(board, id, name, { roles, payload })), event: name };
|
|
347
347
|
}
|
|
348
348
|
|
package/board/files.mjs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// The `files` backing of a board (docs/architecture/orchestrator.md §2.9, "Backings"): the same tables as a
|
|
2
|
+
// Hermes `kanban.db`, kept as a directory of JSON files instead of one SQLite file. `<id>.json` holds everything of
|
|
3
|
+
// one card (its `tasks` row, its `supercode_cards` row, the links to its parents, its comments, events, runs,
|
|
4
|
+
// attachments and subscriptions); `_board.json` holds the schema, the tables that belong to no card and the id
|
|
5
|
+
// counters. A card's history is a file a person or a diff reads, and nothing but supercode's code touches it.
|
|
6
|
+
//
|
|
7
|
+
// The engine speaks SQL to every backing, so this one answers it from an in-memory SQLite database loaded from the
|
|
8
|
+
// directory: a write transaction takes the directory's lock (`_board.lock`), reloads what another process wrote,
|
|
9
|
+
// runs, and writes back the files of the cards it touched (temporary triggers name them), `_board.json` last. A read
|
|
10
|
+
// outside a transaction reloads first when `_board.json` changed, as a SQLite reader sees the last commit.
|
|
11
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
12
|
+
import { hostname } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
15
|
+
|
|
16
|
+
const STATE = '_board.json';
|
|
17
|
+
const LOCK = '_board.lock';
|
|
18
|
+
const LOCK_WAIT_MS = 10_000; // as the sqlite backing's busy_timeout
|
|
19
|
+
|
|
20
|
+
/** Whether `dir` holds a files board. */
|
|
21
|
+
export const isFilesBoard = (dir) => existsSync(join(dir, STATE));
|
|
22
|
+
|
|
23
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
24
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
25
|
+
const quote = (name) => `"${String(name).replaceAll('"', '""')}"`;
|
|
26
|
+
|
|
27
|
+
function writeAtomic(file, text) {
|
|
28
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
29
|
+
writeFileSync(tmp, text);
|
|
30
|
+
renameSync(tmp, file);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Open the files board in `dir`. `init(db, { fresh })` lays the board's schema over a loaded database (the store's:
|
|
35
|
+
* Hermes's tables on a fresh one, supercode's own table and columns on every one). Answers `{ db, tx, close }`.
|
|
36
|
+
*/
|
|
37
|
+
export function openFilesBoard(dir, { create = false, init }) {
|
|
38
|
+
if (!isFilesBoard(dir)) {
|
|
39
|
+
if (!create) throw new Error(`${dir} is not a board`);
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
const board = new FilesBoard(dir, init);
|
|
43
|
+
if (!isFilesBoard(dir)) board.tx(() => {}, { force: true });
|
|
44
|
+
return { db: board.db, tx: (fn) => board.tx(fn), close: () => board.close() };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class FilesBoard {
|
|
48
|
+
constructor(dir, init) {
|
|
49
|
+
this.dir = dir;
|
|
50
|
+
this.init = init;
|
|
51
|
+
this.mem = null;
|
|
52
|
+
this.stamp = null;
|
|
53
|
+
this.inTx = false;
|
|
54
|
+
this.statements = new Map();
|
|
55
|
+
const self = this;
|
|
56
|
+
// the connection the engine holds: SQLite's statement API over whatever snapshot is current
|
|
57
|
+
this.db = {
|
|
58
|
+
prepare(sql) {
|
|
59
|
+
return {
|
|
60
|
+
get: (...args) => self.read(() => self.statement(sql).get(...args)),
|
|
61
|
+
all: (...args) => self.read(() => self.statement(sql).all(...args)),
|
|
62
|
+
iterate: (...args) => self.read(() => self.statement(sql).all(...args))[Symbol.iterator](),
|
|
63
|
+
run: (...args) => self.write(sql, () => self.statement(sql).run(...args)),
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
exec: (sql) => self.write(sql, () => self.mem.exec(sql)),
|
|
67
|
+
close: () => self.close(),
|
|
68
|
+
};
|
|
69
|
+
this.locked(() => this.load());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------- snapshot
|
|
73
|
+
|
|
74
|
+
stampOf() {
|
|
75
|
+
try { const s = statSync(join(this.dir, STATE)); return `${s.ino}:${s.mtimeMs}:${s.size}`; } catch { return 'none'; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** (Re)load the directory into a fresh in-memory database; the caller holds the lock. */
|
|
79
|
+
load() {
|
|
80
|
+
const stamp = this.stampOf();
|
|
81
|
+
if (this.mem && stamp === this.stamp) return;
|
|
82
|
+
this.mem?.close();
|
|
83
|
+
this.statements.clear();
|
|
84
|
+
const mem = new DatabaseSync(':memory:');
|
|
85
|
+
this.stateText = stamp === 'none' ? null : readFileSync(join(this.dir, STATE), 'utf8');
|
|
86
|
+
const state = this.stateText === null ? null : JSON.parse(this.stateText);
|
|
87
|
+
for (const sql of state?.schema ?? []) mem.exec(sql);
|
|
88
|
+
this.init(mem, { fresh: !state?.schema?.length });
|
|
89
|
+
const columns = (table) => new Set(mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
|
|
90
|
+
const insert = (table, rows) => {
|
|
91
|
+
const have = columns(table);
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
const keys = Object.keys(row).filter((k) => have.has(k));
|
|
94
|
+
mem.prepare(`INSERT INTO ${quote(table)} (${keys.map(quote).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`).run(...keys.map((k) => row[k]));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
this.texts = new Map();
|
|
98
|
+
for (const name of readdirSync(this.dir)) {
|
|
99
|
+
if (!name.endsWith('.json') || name.startsWith('_')) continue;
|
|
100
|
+
const text = readFileSync(join(this.dir, name), 'utf8');
|
|
101
|
+
const card = JSON.parse(text);
|
|
102
|
+
this.texts.set(card.id, text);
|
|
103
|
+
for (const [table, rows] of Object.entries(card.tables ?? {})) insert(table, rows);
|
|
104
|
+
}
|
|
105
|
+
for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
|
|
106
|
+
for (const { name, seq } of state?.sequence ?? []) {
|
|
107
|
+
if (mem.prepare('UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?').run(seq, name).changes === 0) mem.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(name, seq);
|
|
108
|
+
}
|
|
109
|
+
this.mem = mem;
|
|
110
|
+
this.watch();
|
|
111
|
+
this.stamp = stamp;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Each table that belongs to a card, with the column naming the card. */
|
|
115
|
+
cardTables() {
|
|
116
|
+
const out = new Map();
|
|
117
|
+
for (const { name } of this.mem.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
|
|
118
|
+
const cols = new Set(this.mem.prepare(`PRAGMA table_info(${quote(name)})`).all().map((c) => c.name));
|
|
119
|
+
if (name === 'tasks') out.set(name, 'id');
|
|
120
|
+
else if (name === 'task_links') out.set(name, 'child_id');
|
|
121
|
+
else if (cols.has('task_id')) out.set(name, 'task_id');
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Temporary triggers that note every card a write touches, so a commit writes only those cards' files. The table has
|
|
128
|
+
* no key: a statement's own conflict clause (an upsert, `OR REPLACE`) governs its triggers' inserts too.
|
|
129
|
+
*/
|
|
130
|
+
watch() {
|
|
131
|
+
this.mem.exec('CREATE TEMP TABLE IF NOT EXISTS _touched (id TEXT)');
|
|
132
|
+
this.keys = this.cardTables();
|
|
133
|
+
for (const [table, key] of this.keys) {
|
|
134
|
+
const t = quote(table);
|
|
135
|
+
this.mem.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(`_i_${table}`)} AFTER INSERT ON main.${t} BEGIN INSERT INTO _touched VALUES (NEW.${quote(key)}); END;
|
|
136
|
+
CREATE TEMP TRIGGER IF NOT EXISTS ${quote(`_u_${table}`)} AFTER UPDATE ON main.${t} BEGIN INSERT INTO _touched VALUES (OLD.${quote(key)}); INSERT INTO _touched VALUES (NEW.${quote(key)}); END;
|
|
137
|
+
CREATE TEMP TRIGGER IF NOT EXISTS ${quote(`_d_${table}`)} AFTER DELETE ON main.${t} BEGIN INSERT INTO _touched VALUES (OLD.${quote(key)}); END;`);
|
|
138
|
+
}
|
|
139
|
+
this.mem.exec('DELETE FROM _touched');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
schema() {
|
|
143
|
+
return this.mem.prepare("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, rowid").all().map((r) => r.sql);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Write the files of the cards the transaction touched, then `_board.json`; the caller holds the lock. */
|
|
147
|
+
persist(schemaBefore) {
|
|
148
|
+
const schema = this.schema();
|
|
149
|
+
const reshaped = schema.join('\n') !== schemaBefore.join('\n');
|
|
150
|
+
const touched = new Set(this.mem.prepare('SELECT DISTINCT id FROM _touched').all().map((r) => r.id).filter((id) => id !== null));
|
|
151
|
+
if (reshaped) this.watch(); // a table made in the transaction gets its triggers; every card is written again
|
|
152
|
+
const keys = this.keys;
|
|
153
|
+
if (reshaped) for (const [table, key] of keys) for (const r of this.mem.prepare(`SELECT DISTINCT ${quote(key)} AS id FROM ${quote(table)}`).all()) touched.add(r.id);
|
|
154
|
+
const boardTables = {};
|
|
155
|
+
for (const { name } of this.mem.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
|
|
156
|
+
if (!keys.has(name)) boardTables[name] = this.mem.prepare(`SELECT * FROM ${quote(name)} ORDER BY rowid`).all();
|
|
157
|
+
}
|
|
158
|
+
const state = { format: 'supercode-board-files', format_version: 1, schema, tables: boardTables, sequence: this.mem.prepare('SELECT name, seq FROM sqlite_sequence ORDER BY name').all() };
|
|
159
|
+
const stateText = `${JSON.stringify(state, null, 2)}\n`;
|
|
160
|
+
let wrote = false;
|
|
161
|
+
for (const id of touched) {
|
|
162
|
+
const tables = {};
|
|
163
|
+
for (const [table, key] of keys) {
|
|
164
|
+
const rows = this.mem.prepare(`SELECT * FROM ${quote(table)} WHERE ${quote(key)} = ? ORDER BY rowid`).all(id);
|
|
165
|
+
if (rows.length) tables[table] = rows;
|
|
166
|
+
}
|
|
167
|
+
const file = join(this.dir, `${String(id).replace(/[^A-Za-z0-9_.-]/g, '_')}.json`);
|
|
168
|
+
if (!Object.keys(tables).length) {
|
|
169
|
+
if (this.texts.has(id)) { try { unlinkSync(file); } catch { /* already gone */ } this.texts.delete(id); wrote = true; }
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const text = `${JSON.stringify({ id, tables }, null, 2)}\n`;
|
|
173
|
+
if (this.texts.get(id) === text) continue;
|
|
174
|
+
writeAtomic(file, text);
|
|
175
|
+
this.texts.set(id, text);
|
|
176
|
+
wrote = true;
|
|
177
|
+
}
|
|
178
|
+
if (wrote || stateText !== this.stateText) {
|
|
179
|
+
writeAtomic(join(this.dir, STATE), stateText);
|
|
180
|
+
this.stateText = stateText;
|
|
181
|
+
}
|
|
182
|
+
this.mem.exec('DELETE FROM _touched');
|
|
183
|
+
this.stamp = this.stampOf();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------- lock and transactions
|
|
187
|
+
|
|
188
|
+
locked(fn) {
|
|
189
|
+
const file = join(this.dir, LOCK);
|
|
190
|
+
const holder = `${hostname()}:${process.pid}`;
|
|
191
|
+
const until = Date.now() + LOCK_WAIT_MS;
|
|
192
|
+
for (;;) {
|
|
193
|
+
try {
|
|
194
|
+
const fd = openSync(file, 'wx');
|
|
195
|
+
writeSync(fd, holder);
|
|
196
|
+
closeSync(fd);
|
|
197
|
+
break;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error.code !== 'EEXIST') throw error;
|
|
200
|
+
let held = '';
|
|
201
|
+
try { held = readFileSync(file, 'utf8'); } catch { continue; }
|
|
202
|
+
const [host, pid] = held.split(':');
|
|
203
|
+
// a holder on this host that is gone left its lock behind
|
|
204
|
+
if (host === hostname() && Number(pid) && !alive(Number(pid))) { try { unlinkSync(file); } catch { /* taken by another */ } continue; }
|
|
205
|
+
if (Date.now() > until) throw new Error(`the board ${this.dir} is locked by ${held || 'another writer'}`);
|
|
206
|
+
sleep(20);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
try { return fn(); } finally { try { unlinkSync(file); } catch { /* gone */ } }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
statement(sql) {
|
|
213
|
+
let s = this.statements.get(sql);
|
|
214
|
+
if (!s) { s = this.mem.prepare(sql); this.statements.set(sql, s); }
|
|
215
|
+
return s;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
read(fn) {
|
|
219
|
+
if (!this.inTx && this.stampOf() !== this.stamp) this.locked(() => this.load());
|
|
220
|
+
return fn();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A write outside a transaction commits on its own, as SQLite's autocommit does; VACUUM and PRAGMA only read here. */
|
|
224
|
+
write(sql, fn) {
|
|
225
|
+
if (this.inTx || /^\s*(VACUUM|PRAGMA)\b/i.test(sql)) return this.read(fn);
|
|
226
|
+
return this.tx(fn);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
tx(fn, { force = false } = {}) {
|
|
230
|
+
if (this.inTx) return fn(this.db);
|
|
231
|
+
return this.locked(() => {
|
|
232
|
+
this.load();
|
|
233
|
+
const before = this.schema();
|
|
234
|
+
this.mem.exec('BEGIN');
|
|
235
|
+
this.inTx = true;
|
|
236
|
+
let out;
|
|
237
|
+
try {
|
|
238
|
+
out = fn(this.db);
|
|
239
|
+
this.mem.exec('COMMIT');
|
|
240
|
+
} catch (error) {
|
|
241
|
+
try { this.mem.exec('ROLLBACK'); } catch { /* already rolled back */ }
|
|
242
|
+
throw error;
|
|
243
|
+
} finally {
|
|
244
|
+
this.inTx = false;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
if (force) this.stateText = null;
|
|
248
|
+
this.persist(before);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
this.stamp = null; // what memory holds may not be on disk: the next reader reloads from the files
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
close() {
|
|
258
|
+
this.mem?.close();
|
|
259
|
+
this.mem = null;
|
|
260
|
+
}
|
|
261
|
+
}
|
package/board/store.mjs
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
// The board's store (docs/architecture/orchestrator.md §2.9). A board is a Kanban database in Hermes's own
|
|
2
2
|
// format, so either implementation opens the other's board: `kanban.db` at the home's root is the default board,
|
|
3
|
-
// `kanban/boards/<slug>/kanban.db` a named one; the tables, columns, statuses and event kinds are Hermes's
|
|
3
|
+
// `kanban/boards/<slug>/kanban.db` a named one; the tables, columns, statuses and event kinds are Hermes's, whichever
|
|
4
|
+
// backing keeps them (below). What
|
|
4
5
|
// supercode knows beyond Hermes lives where Hermes leaves it alone: a run's `metadata` JSON (the session the
|
|
5
6
|
// dispatcher launched) and a table of our own, `supercode_cards` (the machine a card runs on, the arc a subtask
|
|
6
7
|
// belongs to).
|
|
7
8
|
//
|
|
8
|
-
// This module opens a board and gives its callers one primitive, a write transaction; the transitions
|
|
9
|
-
// `engine.mjs`. Hermes's own tools may write
|
|
10
|
-
// lock (`BEGIN IMMEDIATE`) and retries while the other holds it.
|
|
9
|
+
// This module opens a board in its backing and gives its callers one primitive, a write transaction; the transitions
|
|
10
|
+
// are in `engine.mjs`. Hermes's own tools may write a `kanban.db` at the same time: every write there takes SQLite's
|
|
11
|
+
// writer lock (`BEGIN IMMEDIATE`) and retries while the other holds it.
|
|
11
12
|
import { randomBytes } from 'node:crypto';
|
|
12
13
|
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
13
14
|
import { hostname } from 'node:os';
|
|
14
15
|
import { dirname, join } from 'node:path';
|
|
15
16
|
import { DatabaseSync } from 'node:sqlite';
|
|
17
|
+
import { isFilesBoard, openFilesBoard } from './files.mjs';
|
|
16
18
|
|
|
17
19
|
export const DEFAULT_BOARD = 'default';
|
|
18
20
|
|
|
@@ -70,18 +72,51 @@ const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY K
|
|
|
70
72
|
// `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
|
|
71
73
|
const OURS_COLUMNS = { subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT' };
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
// ------------------------------------------------------------------ backings
|
|
76
|
+
//
|
|
77
|
+
// A board's tables are its contract: the engine and the dispatcher speak them as SQL to whatever backing keeps the
|
|
78
|
+
// board. `sqlite` is Hermes's own `kanban.db`, one SQLite file Hermes and supercode both write; `files` (`files.mjs`)
|
|
79
|
+
// is a directory with one JSON file per card that only supercode writes. A board is whichever backing is on disk; a
|
|
80
|
+
// new one is `sqlite` unless its creator names another (`init --backing files`, `boards create <slug> --backing files`).
|
|
81
|
+
export const BACKINGS = ['sqlite', 'files'];
|
|
82
|
+
|
|
83
|
+
const boardDirOf = (root, slug) => (!slug || slug === DEFAULT_BOARD ? join(root, 'kanban') : join(root, 'kanban', 'boards', slug));
|
|
84
|
+
|
|
85
|
+
/** Where each backing keeps board `slug` of the home at `root`. */
|
|
86
|
+
export function backingPaths(root, slug = DEFAULT_BOARD) {
|
|
87
|
+
return {
|
|
88
|
+
sqlite: !slug || slug === DEFAULT_BOARD ? join(root, 'kanban.db') : join(boardDirOf(root, slug), 'kanban.db'),
|
|
89
|
+
files: join(boardDirOf(root, slug), 'board'),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The backing that keeps board `slug` now; null when it does not exist. */
|
|
94
|
+
export function backingOf(root, slug = DEFAULT_BOARD) {
|
|
95
|
+
const at = backingPaths(root, slug);
|
|
96
|
+
const have = BACKINGS.filter((b) => (b === 'files' ? isFilesBoard(at.files) : existsSync(at.sqlite)));
|
|
97
|
+
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
|
+
return have[0] ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether board `slug` exists in the home at `root`, in any backing. */
|
|
102
|
+
export const boardExists = (root, slug = DEFAULT_BOARD) => backingOf(root, slug) !== null;
|
|
103
|
+
|
|
104
|
+
/** Where board `slug` is kept: Hermes's `kanban.db`, or a files board's directory. */
|
|
74
105
|
export function boardPath(root, slug = DEFAULT_BOARD) {
|
|
75
|
-
|
|
106
|
+
const at = backingPaths(root, slug);
|
|
107
|
+
return at[backingOf(root, slug) ?? 'sqlite'];
|
|
76
108
|
}
|
|
77
109
|
|
|
78
|
-
/** The
|
|
110
|
+
/** The directory of the board's worker logs, as Hermes lays them out. */
|
|
111
|
+
export const logsDir = (root, slug = DEFAULT_BOARD) => join(boardDirOf(root, slug), 'logs');
|
|
112
|
+
|
|
113
|
+
/** The home's boards that exist: `default` when it does, and every named one. */
|
|
79
114
|
export function listBoards(root) {
|
|
80
|
-
const out =
|
|
115
|
+
const out = boardExists(root) ? [DEFAULT_BOARD] : [];
|
|
81
116
|
const named = join(root, 'kanban', 'boards');
|
|
82
117
|
if (existsSync(named)) {
|
|
83
118
|
for (const slug of readdirSync(named)) {
|
|
84
|
-
if (slug.startsWith('_') || !
|
|
119
|
+
if (slug.startsWith('_') || !boardExists(root, slug)) continue;
|
|
85
120
|
// a board archived in its metadata is not dispatched (Hermes lists boards without the archived)
|
|
86
121
|
try { if (JSON.parse(readFileSync(join(named, slug, 'board.json'), 'utf8'))?.archived) continue; } catch { /* no metadata: live */ }
|
|
87
122
|
out.push(slug);
|
|
@@ -90,33 +125,44 @@ export function listBoards(root) {
|
|
|
90
125
|
return out;
|
|
91
126
|
}
|
|
92
127
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
128
|
+
/** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
|
|
129
|
+
function layOut(db, { fresh }) {
|
|
130
|
+
if (fresh) db.exec(SCHEMA);
|
|
131
|
+
db.exec(OURS);
|
|
132
|
+
const have = new Set(db.prepare('PRAGMA table_info(supercode_cards)').all().map((c) => c.name));
|
|
133
|
+
for (const [column, type] of Object.entries(OURS_COLUMNS)) if (!have.has(column)) db.exec(`ALTER TABLE supercode_cards ADD COLUMN ${column} ${type}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Open (creating when `create`, in `backing`) board `slug`: `{ db, root, slug, path, backing, tx, close }`. `db` answers
|
|
138
|
+
* SQLite's statement API (`prepare(sql).get|all|run`, `exec`) whatever the backing.
|
|
139
|
+
*/
|
|
140
|
+
export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null } = {}) {
|
|
141
|
+
const kept = backingOf(root, slug);
|
|
142
|
+
if (!kept && !create) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
|
|
143
|
+
if (kept && backing && kept !== backing) throw new Error(`board ${slug} in ${root} is kept by the ${kept} backing, not ${backing}`);
|
|
144
|
+
const use = kept ?? backing ?? 'sqlite';
|
|
145
|
+
if (!BACKINGS.includes(use)) throw new Error(`a board's backing is ${BACKINGS.join(' | ')}, not ${use}`);
|
|
146
|
+
const path = backingPaths(root, slug)[use];
|
|
147
|
+
if (use === 'files') return { ...openFilesBoard(path, { create, init: layOut }), root, slug, path, backing: use };
|
|
148
|
+
if (!kept) mkdirSync(dirname(path), { recursive: true });
|
|
100
149
|
const db = new DatabaseSync(path);
|
|
101
150
|
db.exec('PRAGMA busy_timeout = 10000');
|
|
102
151
|
db.exec('PRAGMA foreign_keys = OFF');
|
|
103
152
|
const fresh = !db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").get();
|
|
104
|
-
if (fresh) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
db.exec(OURS);
|
|
109
|
-
const have = new Set(db.prepare('PRAGMA table_info(supercode_cards)').all().map((c) => c.name));
|
|
110
|
-
for (const [column, type] of Object.entries(OURS_COLUMNS)) if (!have.has(column)) db.exec(`ALTER TABLE supercode_cards ADD COLUMN ${column} ${type}`);
|
|
111
|
-
return { db, root, slug, path, close: () => db.close() };
|
|
153
|
+
if (fresh && !create) { db.close(); throw new Error(`${path} is not a board`); }
|
|
154
|
+
layOut(db, { fresh });
|
|
155
|
+
return { db, root, slug, path, backing: use, tx: (fn) => sqliteTx(db, fn), close: () => db.close() };
|
|
112
156
|
}
|
|
113
157
|
|
|
114
158
|
/**
|
|
115
|
-
* Run `fn(db)` in one write transaction
|
|
116
|
-
* transaction is the state it writes over
|
|
159
|
+
* Run `fn(db)` in one write transaction of the board's backing: answers what `fn` answers; rolls back on a throw.
|
|
160
|
+
* A read inside the transaction is the state it writes over.
|
|
117
161
|
*/
|
|
118
|
-
export
|
|
119
|
-
|
|
162
|
+
export const tx = (board, fn) => board.tx(fn);
|
|
163
|
+
|
|
164
|
+
// SQLite's own: `BEGIN IMMEDIATE` takes the writer lock up front, retried while Hermes's writers hold it
|
|
165
|
+
function sqliteTx(db, fn) {
|
|
120
166
|
for (let attempt = 0; ; attempt += 1) {
|
|
121
167
|
try {
|
|
122
168
|
db.exec('BEGIN IMMEDIATE');
|
package/board/workflow.mjs
CHANGED
|
@@ -170,8 +170,9 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
172
|
* The event that takes card `id` to `status` for a sender in `roles`: the first the card's status (then the workflow)
|
|
173
|
-
* declares whose transition for that sender, with `payload`, goes there
|
|
174
|
-
*
|
|
173
|
+
* declares whose transition for that sender, with `payload`, goes there and whose validators hold. Answers
|
|
174
|
+
* `{ name }`, or `{ name: null, refusal }` with the first validator that stood in the way (null when no event goes
|
|
175
|
+
* there at all).
|
|
175
176
|
*/
|
|
176
177
|
export function eventTo(db, board, entry, id, status, { roles = [], payload = {} } = {}) {
|
|
177
178
|
const { workflow, params } = entry;
|
|
@@ -179,11 +180,15 @@ export function eventTo(db, board, entry, id, status, { roles = [], payload = {}
|
|
|
179
180
|
const task = cardOf(db, id);
|
|
180
181
|
const from = statusOf(db, task);
|
|
181
182
|
const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), sender: roles } });
|
|
183
|
+
let refusal = null;
|
|
182
184
|
for (const name of new Set([...Object.keys(workflow.statuses[from]?.on ?? {}), ...Object.keys(workflow.on ?? {})])) {
|
|
183
185
|
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));
|
|
184
|
-
if (taken?.target
|
|
186
|
+
if (!taken?.target || String(templateValue(taken.target, scope) ?? '') !== status) continue;
|
|
187
|
+
const failed = (taken.validate ?? []).find((v) => !holds(v.expr, scope));
|
|
188
|
+
if (!failed) return { name };
|
|
189
|
+
refusal ??= failed.message;
|
|
185
190
|
}
|
|
186
|
-
return null;
|
|
191
|
+
return { name: null, refusal };
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
function cardOf(db, id) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.40",
|
|
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": {
|