@volter/supercode-orchestrator 0.4.37 → 0.4.39

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 CHANGED
@@ -10,9 +10,9 @@ 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
- import { adoptSession, boardLabel, boardSettings, lanesOf, localMachine, observeFleet, paused, startCard, tick } from './dispatch.mjs';
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';
17
17
  import { evaluate } from './expr.mjs';
18
18
 
@@ -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 || existsSync(boardPath(root, slug))) return slug;
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 (!existsSync(boardPath(root, slug)) && !existsSync(metaPath(root, slug))) continue;
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 (!existsSync(boardPath(root, slug))) return {};
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) && existsSync(boardPath(root, slug))) {
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(() => {}, true);
247
+ const backing = withBoard((b) => b.backing, true);
246
248
  const { profiles } = await laneState(root).catch(() => ({ profiles: [] }));
247
- say(args, `Kanban DB 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), profiles });
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(slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs'), `${id}.log`);
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)));
@@ -681,8 +683,9 @@ export async function runWorkflow(argv) {
681
683
  const { lanes, settings } = await laneState(root);
682
684
  const params = { ...(args['failure-limit'] ? { failure_limit: Number(args['failure-limit']) } : {}), ...(args.max ? { max_spawn: Number(args.max) } : {}) };
683
685
  if (args['dry-run']) {
684
- const would = withBoard((b) => engine.listCards(b.db, { status: 'ready' }).filter((t) => t.assignee && lanes[t.assignee] && !t.subtask_of).map((t) => ({ id: t.id, assignee: t.assignee, machine: t.machine, surface: lanes[t.assignee].surface })));
685
- say(args, would.length ? would.map((w) => `would start ${w.id} as ${w.surface} for ${w.assignee}${w.machine ? ` on ${w.machine}` : ''}`).join('\n') : 'nothing would start', would);
686
+ // planned through the workflow's own dispatch (its start event, eligible, order and limits), writing nothing
687
+ const would = (await plan({ root, lanes, settings, params, slugs: [slug] })).flatMap((r) => r.would);
688
+ say(args, would.length ? would.map((w) => `would start ${w.id} (${w.status}${w.role ? `, its ${w.role}` : ''}) as ${w.surface} for ${w.assignee}${w.machine ? ` on ${w.machine}` : ''}`).join('\n') : 'nothing would start', would);
686
689
  return 0;
687
690
  }
688
691
  const reports = await tick({ root, lanes, settings, params });
@@ -713,7 +716,7 @@ export async function runWorkflow(argv) {
713
716
  }
714
717
  const events = withBoard((b) => engine.gcEvents(b, eventDays * 86400));
715
718
  let logs = 0;
716
- const logDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs');
719
+ const logDir = logsDir(root, slug);
717
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; } }
718
721
  say(args, `GC complete: ${removedWs} workspace(s), ${events} event row(s), ${logs} log file(s) removed`, { workspaces: removedWs, events, logs });
719
722
  return 0;
@@ -800,7 +803,7 @@ async function boardsVerb(root, rest, args) {
800
803
  case 'create': case 'new': {
801
804
  const slug = normalSlug(target);
802
805
  const meta = writeMeta(root, slug, { name: args.name, description: args.description, icon: args.icon, color: args.color, default_workdir: args['default-workdir'] });
803
- openBoard(root, slug, { create: true }).close();
806
+ openBoard(root, slug, { create: true, backing: args.backing ?? null }).close();
804
807
  if (args.switch) { mkdirSync(join(root, 'kanban'), { recursive: true }); writeFileSync(currentFile(root), `${slug}\n`); }
805
808
  say(args, `Board '${slug}' ready.\n Display name: ${meta.name}\n DB path: ${meta.db_path}${args.switch ? `\n Switched to '${slug}'.` : ''}`, meta);
806
809
  return 0;
@@ -821,7 +824,7 @@ async function boardsVerb(root, rest, args) {
821
824
  }
822
825
  case 'switch': case 'use': {
823
826
  const slug = normalSlug(target);
824
- if (slug !== DEFAULT_BOARD && !existsSync(boardPath(root, slug))) throw new Error(`board '${slug}' does not exist; \`supercode workflow boards create ${slug}\``);
827
+ if (slug !== DEFAULT_BOARD && !boardExists(root, slug)) throw new Error(`board '${slug}' does not exist; \`supercode workflow boards create ${slug}\``);
825
828
  mkdirSync(join(root, 'kanban'), { recursive: true });
826
829
  writeFileSync(currentFile(root), `${slug}\n`);
827
830
  say(args, `Active board is now '${slug}'.`, { ok: true, board: slug });
@@ -846,7 +849,7 @@ async function boardsVerb(root, rest, args) {
846
849
  }
847
850
  case 'export': {
848
851
  const slug = normalSlug(target ?? boardOf(root, args));
849
- if (!existsSync(boardPath(root, slug))) throw new Error(`board '${slug}' has no database`);
852
+ if (!boardExists(root, slug)) throw new Error(`board '${slug}' has no database`);
850
853
  const out = resolve(args.output ?? `${slug}.tar.gz`);
851
854
  const made = exportBoard(root, slug, out, { attachments: !args['no-attachments'], logs: Boolean(args['include-logs']) });
852
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);
@@ -909,12 +912,12 @@ function exportBoard(root, slug, out, { attachments, logs }) {
909
912
  const snap = openBoard(base, DEFAULT_BOARD);
910
913
  let counts;
911
914
  try { tx(snap, (db) => scrub(db)); counts = rowCounts(snap.db); } finally { snap.close(); }
912
- const { db_path: _, default_workdir: __, project_id: ___, ...meta } = boardMeta(root, slug);
915
+ const { db_path: _, backing: ____, default_workdir: __, project_id: ___, ...meta } = boardMeta(root, slug);
913
916
  writeFileSync(join(base, 'board.json'), `${JSON.stringify(meta, null, 2)}\n`);
914
917
  const attachmentsDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments');
915
- const logsDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs');
918
+ const logsFrom = logsDir(root, slug);
916
919
  const files = attachments ? copyTree(attachmentsDir, join(base, 'attachments')) : 0;
917
- const logFiles = logs ? copyTree(logsDir, join(base, 'logs')) : 0;
920
+ const logFiles = logs ? copyTree(logsFrom, join(base, 'logs')) : 0;
918
921
  const manifest = { format: ARCHIVE_FORMAT, format_version: 1, board: slug, board_name: meta.name || slug, exported_at: now(), exported_by: 'supercode',
919
922
  includes: { attachments, logs }, counts: { ...counts, attachment_files: files, log_files: logFiles } };
920
923
  writeFileSync(join(base, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -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';
@@ -266,7 +266,7 @@ const hostOf = (pressure) => ({ memory_pressure: pressure, max_in_progress: deri
266
266
  * the cards a reserve names while any waits). Each is sent the start event (its validators are Hermes's respawn
267
267
  * guard) and what its new status runs is launched.
268
268
  */
269
- function startCards(board, fleet, lanes, settings, report, entry, effects, { only = null, hostRunning = 0 } = {}) {
269
+ function startCards(board, fleet, lanes, settings, report, entry, effects, { only = null, hostRunning = 0, dry = false } = {}) {
270
270
  const stop = paused(board.root);
271
271
  if (stop) { report.paused = stop; return; }
272
272
  const { workflow, params } = entry;
@@ -321,10 +321,12 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
321
321
  if (evaluate(l.group, { card: {} }) === 'home') l.tally.set(JSON.stringify('home'), (l.tally.get(JSON.stringify('home')) ?? 0) + Math.max(0, hostRunning - here));
322
322
  }
323
323
  const reserveWaits = (l) => l.reserve && candidates.some((c) => holds(l.reserve.for, c.scope));
324
+ // a dry run (dispatch --dry-run) plans the same way and writes nothing: no notes, no moves, no launches
325
+ const note = (id, why) => { if (!dry) tx(board, (db) => noteGuard(db, id, why)); };
324
326
  for (const c of candidates) {
325
327
  const { task, plan, scope } = c;
326
- if (!plan.profile) { report.unassigned.push(task.id); tx(board, (db) => noteGuard(db, task.id, 'no profile runs it (no assignee, and the workflow names none)')); continue; }
327
- if (!plan.lane) { report.nonspawnable.push(task.id); tx(board, (db) => noteGuard(db, task.id, `${plan.profile} is no lane of this home`)); continue; }
328
+ if (!plan.profile) { report.unassigned.push(task.id); note(task.id, 'no profile runs it (no assignee, and the workflow names none)'); continue; }
329
+ if (!plan.lane) { report.nonspawnable.push(task.id); note(task.id, `${plan.profile} is no lane of this home`); continue; }
328
330
  let why = null;
329
331
  for (const l of limits) {
330
332
  const max = evaluate(l.max, scope);
@@ -335,7 +337,12 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
335
337
  }
336
338
  // the launcher's own conditions: a lane that can start this card where it names
337
339
  if (!why) why = launcherCheck(board.db, task, plan.lane, fleet);
338
- if (why) { tx(board, (db) => noteGuard(db, task.id, why)); report.queued.push([task.id, why]); continue; }
340
+ if (why) { note(task.id, why); report.queued.push([task.id, why]); continue; }
341
+ if (dry) {
342
+ report.would.push({ id: task.id, status: plan.target, role: plan.runSpec?.role ?? null, assignee: plan.profile, machine: task.machine ?? null, surface: plan.lane.surface });
343
+ for (const l of limits) if (holds(l.counts, scope)) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
344
+ continue;
345
+ }
339
346
  let moved;
340
347
  try {
341
348
  moved = tx(board, (db) => {
@@ -616,7 +623,7 @@ function launchHeadless(board, task, runId, lane, settings, { prompt = null, ski
616
623
  const args = ['-p', lane.name, '--cli', '--accept-hooks', ...skills.flatMap((s) => ['--skills', s]),
617
624
  ...(task.model_override ? ['-m', task.model_override, ...(task.provider_override ? ['--provider', task.provider_override] : [])] : []),
618
625
  ...(task.reasoning_effort ? ['--reasoning', task.reasoning_effort] : []), 'chat', '-q', `work kanban task ${task.id}`];
619
- const logDir = join(dirname(board.path), board.slug === 'default' ? 'kanban' : '', 'logs');
626
+ const logDir = logsDir(board.root, board.slug);
620
627
  mkdirSync(logDir, { recursive: true });
621
628
  const logFile = join(logDir, `${task.id}.log`);
622
629
  rotateLog(logFile, settings.logRotateBytes, settings.logBackups);
@@ -1029,14 +1036,25 @@ async function pool(items, n, fn) {
1029
1036
  }));
1030
1037
  }
1031
1038
 
1032
- /**
1033
- * One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
1034
- * `only` limits starting to one card (a create that answers whether its card started). Answers one report per board.
1035
- */
1036
1039
  /** A board file's fingerprint: a corrupt board is skipped until the file changes (Hermes retries it then). */
1037
1040
  const fingerprint = (path) => { try { const st = statSync(path); return `${st.size}:${st.mtimeMs}`; } catch { return 'missing'; } };
1038
1041
  const CORRUPT = /malformed|not a database|corrupt|disk image/i;
1039
1042
 
1043
+ /** What a tick would start on each board of the home, planned through the workflow's `dispatch`, writing nothing. */
1044
+ export async function plan({ root, lanes, settings, slugs = null, params = {} }) {
1045
+ const loaded = await loadWorkflow(root, { fresh: true });
1046
+ const entry = { ...loaded, params: { ...loaded.params, ...params } };
1047
+ const fleet = Object.values(lanes).some((l) => l.surface === 'pane') ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
1048
+ const out = [];
1049
+ for (const slug of slugs ?? listBoards(root)) {
1050
+ const report = { board: slug, would: [], queued: [], unassigned: [], nonspawnable: [] };
1051
+ const board = openBoard(root, slug);
1052
+ try { board.label = boardLabel(root, slug); startCards(board, fleet, lanes, settings, report, entry, [], { dry: true }); } finally { board.close(); }
1053
+ out.push(report);
1054
+ }
1055
+ return out;
1056
+ }
1057
+
1040
1058
  /**
1041
1059
  * One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
1042
1060
  * `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
@@ -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. What
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 are in
9
- // `engine.mjs`. Hermes's own tools may write the same file at the same time: every write takes SQLite's writer
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
- /** The database file of board `slug` in the home at `root`. */
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
- return !slug || slug === DEFAULT_BOARD ? join(root, 'kanban.db') : join(root, 'kanban', 'boards', slug, 'kanban.db');
106
+ const at = backingPaths(root, slug);
107
+ return at[backingOf(root, slug) ?? 'sqlite'];
76
108
  }
77
109
 
78
- /** The home's boards that exist: `default` when `kanban.db` does, and every named one. */
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 = existsSync(boardPath(root)) ? [DEFAULT_BOARD] : [];
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('_') || !existsSync(boardPath(root, slug))) continue;
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
- /** Open (creating when `create`) board `slug`: `{ db, root, slug, path, close }`. */
94
- export function openBoard(root, slug = DEFAULT_BOARD, { create = false } = {}) {
95
- const path = boardPath(root, slug);
96
- if (!existsSync(path)) {
97
- if (!create) throw new Error(`no board ${slug} in ${root} (${path})`);
98
- mkdirSync(dirname(path), { recursive: true });
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
- if (!create) { db.close(); throw new Error(`${path} is not a board`); }
106
- db.exec(SCHEMA);
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 (`BEGIN IMMEDIATE`: the writer lock taken up front, so a read inside the
116
- * transaction is the state it writes over). Answers what `fn` answers; rolls back on a throw.
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 function tx(board, fn) {
119
- const { db } = board;
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.37",
3
+ "version": "0.4.39",
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": {