@volter/supercode-orchestrator 0.4.38 → 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,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 || 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)));
@@ -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 = slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs');
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 && !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}\``);
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 (!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`);
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 logsDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs');
918
+ const logsFrom = logsDir(root, slug);
917
919
  const files = attachments ? copyTree(attachmentsDir, join(base, 'attachments')) : 0;
918
- const logFiles = logs ? copyTree(logsDir, join(base, 'logs')) : 0;
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`);
@@ -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 = join(dirname(board.path), board.slug === 'default' ? 'kanban' : '', 'logs');
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);
@@ -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.38",
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": {