@volter/supercode-orchestrator 0.4.148 → 0.4.149

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/ztrack.mjs CHANGED
@@ -1,40 +1,53 @@
1
- // The document is card state; SQLite here is only a runtime journal and Hermes projection.
2
- // ADR 0001. All markdown interpretation belongs to ztrack's public codec and kanban preset.
1
+ // The document is card state. One SQLite file beside it keeps the runtime tables the engine speaks
2
+ // (Hermes's, with supercode's own) and an index of the document's card spans. ADR 0001 and 0003.
3
+ // All markdown interpretation belongs to ztrack's public codec and kanban preset.
4
+ //
5
+ // A write is SQLite's own deferred transaction, retried whole when another writer committed first
6
+ // (no file lock, no BEGIN IMMEDIATE, no busy timeout). The engine writes the tables in place;
7
+ // triggers name the rows it touched. Only the cards whose text changed are rendered, and each is
8
+ // spliced into the document at its indexed span. A document edited by hand is re-read only in the
9
+ // region the edit touched. So a write costs what it changes, not the size of the board.
3
10
  import { createRequire } from 'node:module';
4
11
  import { createHash, randomBytes } from 'node:crypto';
5
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
12
+ import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
6
13
  import { dirname, resolve } from 'node:path';
7
- import { RuntimeRows } from './rows.mjs';
8
14
  import { DatabaseSync } from 'node:sqlite';
9
15
  import { actorOf, event, json, now, setActor } from './store.mjs';
10
- import { fire, projectStatus, statusOf, workflowFor } from './workflow.mjs';
16
+ import { fire, statusOf, storedStatus, workflowFor } from './workflow.mjs';
11
17
  import { completionPhase, inspectCompletionEvidence, withCompletionEvidence } from './ci.mjs';
12
18
 
13
19
  const require = createRequire(import.meta.url);
14
20
  const hash = (text) => createHash('sha256').update(text).digest('hex');
15
21
  const read = (path) => existsSync(path) ? readFileSync(path, 'utf8') : '';
16
- const quote = (s) => `"${s.replaceAll('"', '""')}"`;
22
+ const lf = (text) => text.replace(/\r\n?/g, '\n');
23
+ const quote = (s) => `"${String(s).replaceAll('"', '""')}"`;
17
24
  const nativeId = (id) => /^t-[a-f0-9]+$/.test(id) ? id.replace('t-', 't_') : id;
18
25
  const RUNTIME = new Set(['claim_lock', 'claim_expires', 'worker_pid', 'last_heartbeat_at', 'current_run_id', 'consecutive_failures', 'last_failure_error', 'block_recurrences', 'session_id', 'workspace_path']);
19
26
  const CARD_MAIN = new Set(['id', 'title', 'body', 'status', 'assignee', 'priority', 'workspace_kind', 'workspace_path', 'branch_name']);
20
- const EPHEMERAL = new Set(['tasks', 'supercode_cards', 'task_links', 'sqlite_sequence']);
27
+ const CLOCKS = new Set(['since', 'after_fired', 'next_run_at', 'workspace_root']);
21
28
  const atomic = (path, text) => { const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, text, { mode: 0o600 }); renameSync(tmp, path); };
29
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
30
+ const backoff = (attempt) => sleep(Math.min(40, 2 ** Math.min(attempt, 5)) * (0.5 + Math.random()));
22
31
 
23
- // Only SQLite's actual contention codes retry. Rule refusals can contain words
24
- // such as "blocked" or "busy" and must reach the caller unchanged.
32
+ // Only SQLite's actual contention codes retry (BUSY, BUSY_SNAPSHOT, LOCKED). Rule refusals can
33
+ // contain words such as "blocked" or "busy" and must reach the caller unchanged.
25
34
  const sqliteContention = error => [5, 6].includes(Number(error?.errcode) & 255)
26
35
  || ['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(error?.code)
27
36
  || /^database (?:table |schema )?is locked$/i.test(error?.message ?? '');
28
- const exportConflict = error => error?.message === 'Hermes wrote during board export; retained its events for the next read';
29
- const recoveryConflict = error => error?.message === 'document changed during recovery; retry';
30
- // One-shot verbs open the same board several times. Share byte-keyed codec
31
- // work across those instances and cap retention for long-running dispatchers.
32
- const documentCache = new Map(), renderCache = new Map();
33
- function remember(cache, path, value) {
34
- cache.delete(path);
35
- cache.set(path, value);
36
- while (cache.size > 8) cache.delete(cache.keys().next().value);
37
- }
37
+ const RETRY = new Error('board changed while this operation read it; retry');
38
+
39
+ const BOARD_TABLES = `
40
+ CREATE TABLE IF NOT EXISTS board_state (key TEXT PRIMARY KEY, value TEXT NOT NULL);
41
+ CREATE TABLE IF NOT EXISTS board_chunks (cid INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL);
42
+ CREATE TABLE IF NOT EXISTS board_tasks (id TEXT PRIMARY KEY, key TEXT NOT NULL, sources TEXT);
43
+ CREATE INDEX IF NOT EXISTS board_tasks_key ON board_tasks(key);
44
+ CREATE TABLE IF NOT EXISTS board_errors (id TEXT PRIMARY KEY, reason TEXT NOT NULL);
45
+ CREATE TABLE IF NOT EXISTS board_export (name TEXT NOT NULL, key TEXT NOT NULL, rev INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (name, key));`;
46
+ // board_state: `published` (each document's hash as the tables describe it), `chunks:<role>` (the
47
+ // document's spans in order: chunk id, card id, heading level, length, and the hash of the card
48
+ // record the span reads as), `pending` and `pending_hashes` (a publication committed but not yet
49
+ // written to the document), `hermes_cursor`. board_tasks: a subtask's key on its arc's Tasks list
50
+ // (`<arc>:c3`) and its `source:` lines, which live only in the document.
38
51
 
39
52
  export function openZtrackBoard(marker, { create = false, document = null, seed = null, init, root, slug, compatPath }) {
40
53
  if (!existsSync(marker)) {
@@ -44,6 +57,8 @@ export function openZtrackBoard(marker, { create = false, document = null, seed
44
57
  }
45
58
  const config = JSON.parse(read(marker));
46
59
  const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, compatPath });
60
+ // A board made here is in Hermes's kanban.db at once, not after its first write.
61
+ if (create) backing.exportFollowing();
47
62
  return { db: backing.db, tx: (fn) => backing.tx(fn), close: () => backing.close(), document: backing.path };
48
63
  }
49
64
 
@@ -51,549 +66,731 @@ class ZtrackBoard {
51
66
  constructor({ document, seed, marker, init, root, slug, compatPath }) {
52
67
  this.path = resolve(dirname(marker), document);
53
68
  this.archive = `${this.path}.archive.md`;
54
- this.seed = seed;
55
- this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
56
- this.runtimePath = `${marker}.runtime.db`;
57
- this.runtime = new DatabaseSync(this.runtimePath);
58
- this.readOnly(()=>{ if (!this.runtime.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='state'").get()) this.runtime.exec('CREATE TABLE IF NOT EXISTS state (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); });
59
- this.errors = new Map(); this.preserve = new Map();
60
- // Codec projections depend on document bytes, not runtime revisions. Keep one
61
- // entry per document across optimistic retries; run/event churn must not
62
- // repeatedly parse an unchanged board before reaching its version check.
63
- this.mem = null; this.active = false; this.rows = new RuntimeRows(this.runtime);
69
+ this.docs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
70
+ this.seed = seed; this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
71
+ this.file = `${marker}.board.db`;
64
72
  mkdirSync(dirname(this.path), { recursive: true });
73
+ if (!existsSync(this.file)) this.create(`${marker}.runtime.db`);
74
+ this.conn = new DatabaseSync(this.file);
75
+ this.errors = new Map(); this.statements = new Map(); this.hashes = new Map();
76
+ this.active = false; this.overlay = false; this.seen = null;
77
+ this.prepareConnection();
78
+ this.loadErrors();
65
79
  const self = this;
66
80
  this.db = {
67
81
  boardErrors: self.errors,
68
- prepare(sql) { return Object.fromEntries(['get', 'all', 'run', 'iterate'].map((method) => [method, (...args) => self.access(() => method === 'iterate' ? self.mem.prepare(sql).all(...args)[Symbol.iterator]() : self.mem.prepare(sql)[method](...args), method !== 'run')])); },
69
- exec(sql) { return self.access(() => self.mem.exec(sql)); },
82
+ prepare(sql) {
83
+ return {
84
+ get: (...args) => self.read(() => self.statement(sql).get(...args)),
85
+ all: (...args) => self.read(() => self.statement(sql).all(...args)),
86
+ iterate: (...args) => self.read(() => self.statement(sql).all(...args))[Symbol.iterator](),
87
+ run: (...args) => self.active ? self.statement(sql).run(...args) : self.tx(() => self.statement(sql).run(...args)),
88
+ };
89
+ },
90
+ exec(sql) { return self.active ? self.conn.exec(sql) : self.tx(() => self.conn.exec(sql)); },
70
91
  close() { self.close(); },
71
92
  };
72
93
  if (seed) this.tx(() => {});
73
- else this.readOnly(()=>{ this.load(); this.loadedStamp = this.stamp(); });
74
94
  }
75
95
  get codec() { return this._codec ??= require('@volter/ztrack/board-document'); }
76
96
  get preset() { return this._preset ??= require('@volter/ztrack/kanban-preset'); }
77
- get(key, fallback = null) { return this.computing && this.pendingValues.has(key) ? this.pendingValues.get(key) : json(this.runtime.prepare('SELECT value FROM state WHERE key = ?').get(key)?.value, fallback); }
78
- put(key, value) { if (this.computing) { this.pendingValues.set(key, value); return; } this.runtime.prepare('INSERT INTO state VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, JSON.stringify(value)); }
79
- stamp() {
80
- const fileStamp = (path) => { try { const s = statSync(path); return `${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return 'missing'; } };
81
- return [fileStamp(this.path), fileStamp(this.archive), fileStamp(this.compatPath), this.get('version', '')].join('|');
82
- }
83
- readOnly(fn) { for (;;) { try { return fn(); } catch(error) { if(!sqliteContention(error))throw error; } } }
84
- access(fn, reading = false) {
85
- if (reading && !this.reading) { this.reading=true; try {return this.readOnly(()=>this.access(fn,true));}finally{this.reading=false;} }
86
- if (this.active || (reading && this.mem && this.loadedStamp === this.stamp())) return fn();
87
- if (reading) { this.load(); this.loadedStamp = this.stamp(); return fn(); }
88
- return this.tx(fn);
89
- }
90
- close() { this.mem?.close(); this.runtime.close(); }
91
97
  board() { return { root: this.root, slug: this.slug, label: this.slug, db: this.db }; }
92
- recover() {
93
- const pending = this.get('pending');
94
- if (!pending) return;
95
- const conflicts = new Set(), documentConflicts = new Set();
96
- const observed = pending.files.map(file=>({...file,current:read(file.path)}));
97
- for (const file of observed) {
98
- const current=hash(file.current);
99
- if(current===hash(file.text)||current===file.before)continue;
100
- const ids=file.base!=null?this.codec.mergeBoardDocument(file.base,file.current,file.text,file.path).conflicts:this.codec.inspectBoardDocument(file.text,file.path).records.map(r=>r.id);
101
- for(const id of ids){conflicts.add(nativeId(id));documentConflicts.add(id);}
102
- }
103
- for (const file of observed) {
104
- const merged=file.base!=null?this.codec.mergeBoardDocument(file.base,file.current,file.text,file.path,[...documentConflicts]).text:(hash(file.current)===file.before?file.text:file.current);
105
- if (hash(read(file.path))!==hash(file.current)) throw new Error('document changed during recovery; retry');
106
- if (merged!==file.current)atomic(file.path,merged);
107
- }
108
- const changes = (pending.changes ?? []).filter(c => !conflicts.has(c.source?.row?.task_id ?? c.source?.row?.id ?? c.key));
109
- if (pending.changes) { this.rows.write(changes); this.put('export_pending', [...this.get('export_pending', []), ...changes]); }
110
- if (conflicts.size) {
111
- const issues = this.get('recovery_conflicts', {});
112
- for (const id of conflicts) issues[id] = {intent:pending.intent,reason:'A concurrent document edit superseded an unfinished operation on this card.'};
113
- this.put('recovery_conflicts', issues);
114
- const old = this.get('snapshot', {});
115
- for (const id of conflicts) {
116
- if (old.fingerprints?.[id]) pending.snapshot.fingerprints[id] = old.fingerprints[id];
117
- else delete pending.snapshot.fingerprints?.[id];
98
+ close() { this.dropOverlay(); this.conn.close(); }
99
+
100
+ statement(sql) {
101
+ let statement = this.statements.get(sql);
102
+ if (!statement) { statement = this.conn.prepare(sql); this.statements.set(sql, statement); }
103
+ return statement;
104
+ }
105
+ one(sql, ...args) { return this.statement(sql).get(...args); }
106
+ stateText(key) { return this.one('SELECT value FROM main.board_state WHERE key = ?', key)?.value ?? null; }
107
+ get(key, fallback = null) { return json(this.stateText(key), fallback); }
108
+ put(key, value) { this.statement('INSERT INTO main.board_state VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, JSON.stringify(value)); }
109
+ drop(key) { this.statement('DELETE FROM main.board_state WHERE key = ?').run(key); }
110
+ loadErrors() {
111
+ this.errors.clear();
112
+ for (const row of this.statement('SELECT id, reason FROM main.board_errors').all()) this.errors.set(row.id, row.reason);
113
+ }
114
+
115
+ // ------------------------------------------------------------------ the connection
116
+
117
+ prepareConnection() {
118
+ this.conn.exec('PRAGMA foreign_keys = OFF');
119
+ for (let attempt = 0; ; attempt++) {
120
+ try { this.init(this.conn, { fresh: true }); this.conn.exec(BOARD_TABLES); break; }
121
+ catch (error) { if (!sqliteContention(error)) throw error; backoff(attempt); }
122
+ }
123
+ // What a transaction touched, for the Hermes export (every row) and the renderer (cards whose
124
+ // text may change: anything beyond run fields and scheduling clocks). Plain inserts: a trigger's
125
+ // own conflict clause yields to the outer statement's, so duplicates are removed when read.
126
+ this.conn.exec('CREATE TEMP TABLE IF NOT EXISTS board_dirty (name TEXT NOT NULL, key TEXT NOT NULL); CREATE TEMP TABLE IF NOT EXISTS board_touched (id TEXT)');
127
+ this.tables = new Map();
128
+ const names = this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all().map((row) => row.name);
129
+ const trigger = (name, when, body) => this.conn.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(name)} ${when} BEGIN ${body} END`);
130
+ for (const name of names) {
131
+ const columns = this.conn.prepare(`PRAGMA main.table_info(${quote(name)})`).all();
132
+ const keys = columns.filter((c) => c.pk).sort((a, b) => a.pk - b.pk).map((c) => c.name);
133
+ if (!keys.length) keys.push('rowid');
134
+ this.tables.set(name, { name, keys, columns: columns.map((c) => c.name) });
135
+ const key = (row) => `json_array(${keys.map((k) => `${row}.${quote(k)}`).join(',')})`;
136
+ const mark = (row) => `INSERT INTO board_dirty VALUES ('${name}', ${key(row)});`;
137
+ trigger(`board_i_${name}`, `AFTER INSERT ON main.${quote(name)}`, mark('NEW'));
138
+ trigger(`board_u_${name}`, `AFTER UPDATE ON main.${quote(name)}`, mark('NEW') + mark('OLD'));
139
+ trigger(`board_d_${name}`, `AFTER DELETE ON main.${quote(name)}`, mark('OLD'));
140
+ }
141
+ for (const [name, id, skip] of [['tasks', 'id', RUNTIME], ['supercode_cards', 'task_id', CLOCKS]]) {
142
+ const changed = this.tables.get(name).columns.filter((c) => !skip.has(c)).map((c) => `OLD.${quote(c)} IS NOT NEW.${quote(c)}`).join(' OR ');
143
+ trigger(`board_ti_${name}`, `AFTER INSERT ON main.${quote(name)}`, `INSERT INTO board_touched VALUES (NEW.${id});`);
144
+ trigger(`board_tu_${name}`, `AFTER UPDATE ON main.${quote(name)} WHEN ${changed}`, `INSERT INTO board_touched VALUES (NEW.${id}); INSERT INTO board_touched VALUES (OLD.${id});`);
145
+ trigger(`board_td_${name}`, `AFTER DELETE ON main.${quote(name)}`, `INSERT INTO board_touched VALUES (OLD.${id});`);
146
+ }
147
+ }
148
+
149
+ // A board created from the previous runtime store imports its rows once; the old file stays as it was.
150
+ create(legacy) {
151
+ const tmp = `${this.file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
152
+ const db = new DatabaseSync(tmp);
153
+ try {
154
+ db.exec('PRAGMA journal_mode = WAL');
155
+ const old = existsSync(legacy) ? new DatabaseSync(legacy, { readOnly: true }) : null;
156
+ try {
157
+ const has = (name) => old?.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
158
+ if (has('runtime_schema')) for (const row of old.prepare('SELECT sql FROM runtime_schema').all()) db.exec(row.sql.replace(/^CREATE TABLE (?!IF NOT EXISTS )/, 'CREATE TABLE IF NOT EXISTS '));
159
+ this.init(db, { fresh: true });
160
+ db.exec(BOARD_TABLES);
161
+ if (has('runtime_rows')) this.importRuntime(db, old, legacy);
162
+ } finally { old?.close(); }
163
+ db.close();
164
+ } catch (error) {
165
+ try { db.close(); } catch { /* closed */ }
166
+ for (const suffix of ['', '-wal', '-shm']) try { unlinkSync(tmp + suffix); } catch { /* absent */ }
167
+ throw error;
168
+ }
169
+ try { linkSync(tmp, this.file); } catch (error) { if (error.code !== 'EEXIST') throw error; } // a concurrent opener created it first
170
+ finally { unlinkSync(tmp); }
171
+ }
172
+
173
+ importRuntime(db, old, legacy) {
174
+ const state = (key) => json(old.prepare('SELECT value FROM state WHERE key = ?').get(key)?.value, null);
175
+ if (state('pending')) throw new Error(`${legacy} holds an unfinished board write; let the previous release finish it before this one opens the board`);
176
+ const columns = new Map();
177
+ const insert = (name, row) => {
178
+ if (!columns.has(name)) columns.set(name, new Set(db.prepare(`PRAGMA table_info(${quote(name)})`).all().map((c) => c.name)));
179
+ const keys = Object.keys(row).filter((k) => columns.get(name).has(k));
180
+ if (keys.length) db.prepare(`INSERT OR REPLACE INTO ${quote(name)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...keys.map((k) => row[k] ?? null));
181
+ };
182
+ db.exec('BEGIN');
183
+ const runtime = {}, clocks = {}, sequences = [];
184
+ for (const row of old.prepare('SELECT name, key, value FROM runtime_rows WHERE value IS NOT NULL').all()) {
185
+ const value = JSON.parse(row.value);
186
+ if (row.name === '@task_cache') insert('tasks', value);
187
+ else if (row.name === '@card_cache') insert('supercode_cards', value);
188
+ else if (row.name === '@runtime') runtime[row.key] = value;
189
+ else if (row.name === '@clock') clocks[row.key] = value;
190
+ else if (row.name === '@sequence') sequences.push(value);
191
+ else if (!row.name.startsWith('@') && !['tasks', 'supercode_cards', 'task_links', 'sqlite_sequence'].includes(row.name)) insert(row.name, value);
192
+ }
193
+ for (const [id, row] of Object.entries(runtime)) {
194
+ const keys = Object.keys(row).filter((k) => RUNTIME.has(k));
195
+ if (keys.length) db.prepare(`UPDATE tasks SET ${keys.map((k) => `${quote(k)} = ?`).join(',')} WHERE id = ?`).run(...keys.map((k) => row[k] ?? null), id);
196
+ }
197
+ for (const [id, row] of Object.entries(clocks)) db.prepare('UPDATE supercode_cards SET since = ?, after_fired = ?, next_run_at = ?, workspace_root = ? WHERE task_id = ?').run(row.since ?? null, row.after_fired ?? null, row.next_run_at ?? null, row.workspace_root ?? null, id);
198
+ for (const row of db.prepare('SELECT task_id, blocked_by FROM supercode_cards').all()) {
199
+ for (const b of json(row.blocked_by, []).filter((b) => b.kind === 'card')) db.prepare('INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)').run(b.id, row.task_id);
200
+ }
201
+ for (const { name, seq } of sequences) {
202
+ if (db.prepare('SELECT 1 FROM sqlite_sequence WHERE name = ?').get(name)) db.prepare('UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?').run(seq, name);
203
+ else db.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(name, seq);
204
+ }
205
+ const snapshot = state('snapshot') ?? {};
206
+ for (const [key, id] of Object.entries(snapshot.taskIds ?? {})) db.prepare('INSERT OR REPLACE INTO board_tasks (id, key) VALUES (?, ?)').run(id, key);
207
+ for (const [id, reason] of snapshot.errors ?? []) db.prepare('INSERT OR REPLACE INTO board_errors VALUES (?, ?)').run(id, reason);
208
+ for (const change of state('export_pending') ?? []) if (change.source) db.prepare('INSERT OR IGNORE INTO board_export (name, key) VALUES (?, ?)').run(change.source.name, change.source.key);
209
+ const put = (key, value) => db.prepare('INSERT OR REPLACE INTO board_state VALUES (?, ?)').run(key, JSON.stringify(value));
210
+ put('hermes_cursor', state('hermes_cursor') ?? 0);
211
+ // The document as the tables last described it is indexed now, with each task's sources. A
212
+ // document edited since is left unindexed, so the first write reads all of it as an edit.
213
+ const published = {};
214
+ for (const { role, path } of this.docs) {
215
+ const raw = read(path);
216
+ const expected = snapshot.documentHashes?.[role === 'open' ? 'open' : 'archived'];
217
+ if (!expected) continue;
218
+ published[role] = expected;
219
+ if (hash(raw) !== expected) continue;
220
+ const entries = [];
221
+ for (const chunk of this.codec.chunkBoardDocument(lf(raw), path)) {
222
+ const cid = Number(db.prepare('INSERT INTO board_chunks (text) VALUES (?)').run(chunk.text).lastInsertRowid);
223
+ let rec = null;
224
+ if (chunk.id) {
225
+ const { records, errors } = this.codec.inspectBoardDocument(chunk.text, path);
226
+ if (!errors.length && records.length === 1) {
227
+ rec = hash(JSON.stringify(records[0]));
228
+ try {
229
+ const card = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([records[0]])).issues[0];
230
+ for (const task of card.acceptanceCriteria) {
231
+ if (!task.sources?.length) continue;
232
+ const key = `${nativeId(card.id)}:${task.id}`;
233
+ const id = task.metadata?.id ?? snapshot.taskIds?.[key];
234
+ if (id) db.prepare('INSERT INTO board_tasks (id, key, sources) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET sources = excluded.sources').run(id, key, JSON.stringify(task.sources));
235
+ }
236
+ } catch { /* its verbs refuse through the recorded error */ }
237
+ }
238
+ }
239
+ entries.push({ cid, id: chunk.id, level: chunk.level, len: chunk.text.length, rec });
118
240
  }
241
+ put(`chunks:${role}`, entries);
119
242
  }
120
- this.put('snapshot', pending.snapshot);
121
- if (pending.intent) this.put(`applied:${pending.intent}`, {conflicts:[...conflicts]});
122
- this.put('version', randomBytes(8).toString('hex'));
123
- this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run();
243
+ put('published', published);
244
+ db.exec('COMMIT');
245
+ }
246
+
247
+ // ------------------------------------------------------------------ reads
248
+
249
+ read(fn) {
250
+ if (this.active) return fn();
251
+ this.observeForRead();
252
+ for (let attempt = 0; ; attempt++) {
253
+ try { return fn(); } catch (error) { if (!sqliteContention(error)) throw error; backoff(attempt); }
254
+ }
255
+ }
256
+
257
+ fileHash(path) {
258
+ let stamp;
259
+ try { const s = statSync(path); stamp = `${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`; } catch { stamp = 'missing'; }
260
+ const known = this.hashes.get(path);
261
+ if (known?.stamp === stamp) return known;
262
+ const raw = read(path);
263
+ const value = { stamp, raw, hash: hash(raw) };
264
+ this.hashes.set(path, value);
265
+ return value;
124
266
  }
267
+
268
+ // A document whose hash is neither the published one nor the one a pending publication replaces
269
+ // was edited by hand.
270
+ staleDocuments({ pending }) {
271
+ const published = this.get('published', {});
272
+ const inFlight = pending ? this.get('pending_hashes', null) : null;
273
+ return this.docs.map((doc) => ({ ...doc, ...this.fileHash(doc.path) }))
274
+ .filter((doc) => doc.hash !== published[doc.role] && !(inFlight?.[doc.role] && doc.hash === inFlight[doc.role].before))
275
+ .map((doc) => ({ ...doc, text: lf(doc.raw) }));
276
+ }
277
+
278
+ // A reader sees a hand edit at once, through connection-local copies of the card tables, and
279
+ // writes nothing: the next write records the edit.
280
+ observeForRead() {
281
+ const stamp = [...this.docs.map((doc) => this.fileHash(doc.path).stamp), this.stateText('published'), this.stateText('pending_hashes')].join('|');
282
+ if (stamp === this.seen) return;
283
+ this.seen = stamp;
284
+ this.loadErrors();
285
+ const stale = this.staleDocuments({ pending: true });
286
+ if (!stale.length) { this.dropOverlay(); return; }
287
+ let plan;
288
+ try { plan = this.observe(stale); } catch { this.dropOverlay(); return; } // the next write reports it
289
+ this.applyPlan(plan, { overlay: true });
290
+ }
291
+
292
+ dropOverlay() {
293
+ if (!this.overlay) return;
294
+ this.conn.exec('DROP TABLE IF EXISTS temp.tasks; DROP TABLE IF EXISTS temp.supercode_cards; DROP TABLE IF EXISTS temp.task_links');
295
+ this.overlay = false; this.statements.clear();
296
+ }
297
+
298
+ // ------------------------------------------------------------------ writes
299
+
125
300
  tx(fn) {
126
301
  if (this.active) return fn(this.db);
127
- return this.transaction(fn);
128
- }
129
-
130
- transaction(fn) {
131
- this.readOnly(()=>{ if(this.runtime.prepare('PRAGMA journal_mode').get().journal_mode!=='wal')this.runtime.exec('PRAGMA journal_mode=WAL'); });
132
- for (;;) {
133
- const incoming = this.readOnly(() => this.prepareHermes());
134
- if (this.cachedReader) this.invalidate();
135
- try { this.runtime.exec('BEGIN'); }
136
- catch (error) { if (!sqliteContention(error)) throw error; continue; }
137
- this.active = true;
138
- let committed = false;
302
+ for (let attempt = 0; ; attempt++) {
303
+ const hermes = this.seed ? null : this.prepareHermes();
304
+ this.dropOverlay();
305
+ let outcome;
139
306
  try {
307
+ this.conn.exec('BEGIN');
308
+ this.active = true;
309
+ this.conn.exec('DELETE FROM temp.board_dirty; DELETE FROM temp.board_touched');
310
+ this.loadErrors();
140
311
  this.recover();
141
- const version = this.get('version');
142
- this.runtime.exec('COMMIT');
143
- // Parsing, rules and rendering run against a snapshot, without a runtime writer transaction.
144
- this.pendingValues = new Map(); this.computing = true;
145
- this.load({ observe: true });
146
- if (incoming && (incoming.version !== version || incoming.open !== hash(this.text) || incoming.archived !== hash(this.archiveText))) {
147
- this.invalidate(); continue;
312
+ if (this.seed) this.indexForSeed();
313
+ else {
314
+ const stale = this.staleDocuments({ pending: false });
315
+ if (stale.length) this.applyPlan(this.observe(stale), { overlay: false });
148
316
  }
149
- if (this.seed) this.seedIntoMemory();
150
- else this.lift(incoming);
317
+ if (hermes && hermes.cursor !== this.get('hermes_cursor', 0)) throw RETRY;
318
+ if (this.seed) this.seedRows(); else this.lift(hermes);
151
319
  const result = fn(this.db);
152
- const changes = this.rows.changes(this.mem, { all: !this.rows.normalized() });
153
- const canonical = (row, omitted) => row && Object.fromEntries(Object.entries(row).filter(([key])=>!omitted.has(key)));
154
- const documentChanges = changes.filter(({source})=>{
155
- if(source.name==='tasks') {
156
- const before=this.rows.cachedTasks[JSON.parse(source.key)[0]];
157
- if(source.row?.workspace_kind==='dir' && !this.rows.cachedCards[JSON.parse(source.key)[0]]?.workspace_anchor && source.row?.workspace_path!==before?.workspace_path)return true;
158
- return JSON.stringify(canonical(source.row,new Set([...RUNTIME,'status'])))!==JSON.stringify(canonical(before,new Set([...RUNTIME,'status'])));
159
- }
160
- if(source.name==='supercode_cards')return JSON.stringify(canonical(source.row,new Set(['since','after_fired','next_run_at','workspace_root'])))!==JSON.stringify(canonical(this.rows.cachedCards[JSON.parse(source.key)[0]],new Set(['since','after_fired','next_run_at','workspace_root'])));
161
- return false;
162
- });
163
- this.publishedFingerprints = {};
164
- const documents = this.seed || documentChanges.length ? this.documents(this.seed ? null : documentChanges) : {open:this.text, archived:this.archiveText};
165
- const files = [{ path: this.path, before: hash(this.text), base: this.text, text: documents.open }, { path: this.archive, before: hash(this.archiveText), base: this.archiveText, text: documents.archived }];
166
- const snapshot = this.snapshot(documents);
167
- this.computing = false;
168
- const pendingValues=[...this.pendingValues].filter(([key,value])=>JSON.stringify(this.get(key))!==JSON.stringify(value));
169
- if(!changes.length && !pendingValues.length && files.every(f=>hash(f.text)===f.before)) {
170
- this.loadedStamp=this.stamp();return result;
171
- }
172
- this.runtime.exec('BEGIN');
173
- this.recover();
174
- if (this.get('version') !== version || files.some((f) => hash(read(f.path)) !== f.before)) { this.runtime.exec('ROLLBACK'); this.invalidate(); continue; }
175
- for (const [key, value] of pendingValues) this.put(key, value);
176
- // A durable intent precedes filesystem writes; recovery completes exactly this version.
177
- const intent = randomBytes(16).toString('hex');
178
- this.put('pending', { intent, files, snapshot, changes });
179
- this.runtime.exec('COMMIT'); committed = true;
180
- // A concurrent writer may recover this intent and advance the runtime. Re-read
181
- // its identity before touching documents, retrying from any newer version.
182
- this.runtime.exec('BEGIN');
183
- if (this.get('pending')?.intent !== intent) {
184
- const applied = this.get(`applied:${intent}`, false);
185
- this.runtime.exec('ROLLBACK');
186
- if (applied) { this.loadedStamp = null; this.seed = null; if (applied.conflicts?.length) throw new Error(`Concurrent edits superseded this operation on ${applied.conflicts.join(', ')}`); return result; }
187
- this.invalidate(); continue;
320
+ const files = this.render();
321
+ this.statement('INSERT INTO main.board_export (name, key) SELECT DISTINCT name, key FROM temp.board_dirty WHERE true ON CONFLICT(name, key) DO UPDATE SET rev = rev + 1').run();
322
+ const intent = files.length ? randomBytes(16).toString('hex') : null;
323
+ if (intent) {
324
+ this.put('pending', { intent, files });
325
+ this.put('pending_hashes', Object.fromEntries(files.map((f) => [f.role, { before: f.before, after: f.after }])));
188
326
  }
189
- // Detect an editor racing the journal commit before replacing either document.
190
- if (files.some((f) => hash(read(f.path)) !== f.before)) {
191
- this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run(); this.runtime.exec('COMMIT');
192
- this.invalidate(); continue;
193
- }
194
- for (const f of files) if (hash(f.text) !== f.before) atomic(f.path, f.text);
195
- this.rows.write(changes);
196
- this.put('snapshot', snapshot); this.put(`applied:${intent}`, true); this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run();
197
- this.put('export_pending', [...this.get('export_pending', []), ...changes]);
198
- this.put('version', randomBytes(8).toString('hex'));
199
- this.runtime.exec('COMMIT');
200
- try { this.flushExport(); } catch (error) { if (!sqliteContention(error) && !exportConflict(error)) throw error; }
201
- this.loadedStamp = this.stamp();
202
- this.seed = null;
203
- return result;
327
+ this.conn.exec('COMMIT');
328
+ outcome = { result, files, intent };
204
329
  } catch (error) {
205
- try { this.runtime.exec('ROLLBACK'); } catch { /* intent already durable */ }
206
- this.invalidate();
207
- if (sqliteContention(error) || exportConflict(error) || recoveryConflict(error)) continue;
208
- if (committed) error.message += ' (the operation reached its durable intent; inspect its recorded outcome before retrying)';
330
+ try { this.conn.exec('ROLLBACK'); } catch { /* not begun */ }
331
+ this.active = false; this.seen = null; this.loadErrors();
332
+ if (error === RETRY || sqliteContention(error)) { backoff(attempt); continue; }
209
333
  throw error;
210
- } finally { this.active = false; this.computing = false; this.pendingValues = null; }
334
+ } finally { this.active = false; }
335
+ this.seed = null;
336
+ if (outcome.intent) this.publish(outcome);
337
+ this.exportFollowing();
338
+ return outcome.result;
211
339
  }
212
340
  }
213
- invalidate() { this.mem?.close(); this.mem = null; this.rows = new RuntimeRows(this.runtime); this.loadedStamp = null; this.cachedReader=false; }
214
- seedIntoMemory() {
215
- const source = this.seed;
216
- for (const table of this.mem.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) this.mem.exec(`DELETE FROM ${quote(table.name)}`);
217
- for (const table of source.db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
218
- if (!this.mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table.name)) this.mem.exec(table.sql);
219
- for (const row of source.db.prepare(`SELECT * FROM ${quote(table.name)}`).all()) this.insert(table.name, row);
341
+
342
+ // Replace each document with its committed text. A document edited in the meantime is merged
343
+ // by the next transaction's recovery instead of being overwritten.
344
+ publish({ files, intent }) {
345
+ let raced = false;
346
+ for (const file of files) {
347
+ const current = this.fileHash(file.path).hash;
348
+ if (current === file.after) continue;
349
+ if (current !== file.before) { raced = true; continue; }
350
+ atomic(file.path, file.text);
220
351
  }
221
- for (const task of this.mem.prepare('SELECT * FROM tasks').all()) {
222
- const old = this.mem.prepare('SELECT * FROM supercode_cards WHERE task_id = ?').get(task.id) ?? {};
223
- if (old.subtask_of) { const key = /^([a-z]+\d+)\s/.exec(task.title)?.[1]; if (key) this.taskKeys.set(task.id, `${old.subtask_of}:${key}`); }
224
- const status = old.stage ?? task.status;
225
- const mapped = source.mapping[status];
226
- if (!mapped) throw new Error(`migration needs an explicit mapping for ${status} (${task.id})`);
227
- let stage = typeof mapped === 'string' ? mapped : mapped.status;
228
- if (mapped.preserve_completed && task.completed_at) stage = 'done';
229
- const parents = this.mem.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(task.id).map((p) => ({ id: p.parent_id, kind: 'card' }));
230
- const last = this.mem.prepare("SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('blocked','unblocked') ORDER BY id DESC LIMIT 1").get(task.id);
231
- const wait = last?.kind === 'blocked' ? json(last.payload, {})?.waiting_on : null;
232
- if (wait) parents.push({ id: wait, kind: 'message' });
233
- this.insert('supercode_cards', { ...old, task_id: task.id, stage, archived: Number(Boolean(mapped.archived) || stage === 'cancelled'), blocked_by: JSON.stringify(parents),
234
- every: mapped.every ?? old.every ?? null, next_run_at: mapped.every ? now() : old.next_run_at,
235
- paused: mapped.paused ? JSON.stringify({ by: actorOf() ?? 'migration', reason: `migrated ${status}: ${json(last?.payload, {})?.reason ?? 'held by previous workflow'}` }) : null });
236
- this.mem.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(stage, task.id);
237
- if (task.current_run_id && ['done','cancelled'].includes(stage)) {
238
- this.mem.prepare("UPDATE task_runs SET ended_at = COALESCE(ended_at, ?), status = 'reclaimed', outcome = COALESCE(outcome, 'reclaimed') WHERE id = ?").run(now(), task.current_run_id);
239
- this.mem.prepare('UPDATE tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL WHERE id = ?').run(task.id);
240
- } else if (task.current_run_id) {
241
- const run = this.mem.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id);
242
- const meta = json(run?.metadata, {}) ?? {}; meta.supercode = { ...meta.supercode, state: 'active' };
243
- this.mem.prepare('UPDATE task_runs SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), task.current_run_id);
244
- }
352
+ if (raced) { this.tx(() => {}); return; }
353
+ this.small(() => {
354
+ if (this.get('pending')?.intent !== intent) return; // another writer finished it
355
+ this.drop('pending'); this.drop('pending_hashes');
356
+ });
357
+ }
358
+
359
+ // A short write of board state alone, retried on contention.
360
+ small(fn) {
361
+ for (let attempt = 0; ; attempt++) {
362
+ try { this.conn.exec('BEGIN'); fn(); this.conn.exec('COMMIT'); return; }
363
+ catch (error) { try { this.conn.exec('ROLLBACK'); } catch { /* not begun */ } if (!sqliteContention(error)) throw error; backoff(attempt); }
245
364
  }
246
- this.rows.watch(this.mem);
247
- this.put('hermes_cursor', Number(source.db.prepare('SELECT COALESCE(MAX(id),0) AS id FROM task_events').get().id));
248
365
  }
249
- insert(table, row) {
250
- const columns = new Set(this.mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
251
- const keys = Object.keys(row).filter((k) => columns.has(k));
252
- if (!keys.length) return;
253
- this.mem.prepare(`INSERT OR REPLACE INTO ${quote(table)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...keys.map((k) => row[k] ?? null));
254
- }
255
- openCachedReader(saved) {
256
- const native = this.get('read_revision',-1)===this.get('rows_revision',0);
257
- if(this.cachedReader && native!==this.nativeReader)this.invalidate();
258
- if (!this.cachedReader) {
259
- this.mem?.close(); this.mem=new DatabaseSync(':memory:'); this.init(this.mem,{fresh:true});
260
- for(const row of this.runtime.prepare('SELECT name,sql FROM runtime_schema').all())this.mem.exec(row.sql.replace('CREATE TABLE ','CREATE TABLE IF NOT EXISTS '));
261
- this.mem.prepare('ATTACH DATABASE ? AS source').run(this.runtimePath);
262
- const tables=this.mem.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
263
- for(const {name} of tables) {
264
- if (native && name==='tasks') {
265
- const fields=this.mem.prepare('PRAGMA main.table_info(tasks)').all().map(({name:column})=>column==='status'?'COALESCE(c.stage,t.status) AS status':`t.${quote(column)}`);
266
- this.mem.exec(`CREATE TEMP VIEW tasks AS SELECT ${fields.join(',')} FROM source.read_tasks t LEFT JOIN source.read_supercode_cards c ON c.task_id=t.id`);
267
- continue;
268
- }
269
- if(native && name==='supercode_cards') {
270
- const have=new Set(this.mem.prepare('PRAGMA source.table_info(read_supercode_cards)').all().map(c=>c.name));
271
- const fields=this.mem.prepare('PRAGMA main.table_info(supercode_cards)').all().map(c=>have.has(c.name)?quote(c.name):`${c.dflt_value??'NULL'} AS ${quote(c.name)}`);
272
- this.mem.exec(`CREATE TEMP VIEW supercode_cards AS SELECT ${fields.join(',')} FROM source.read_supercode_cards`);continue;
273
- }
274
- if(native && name==='task_links') {this.mem.exec("CREATE TEMP VIEW task_links AS SELECT json_extract(b.value,'$.id') parent_id,r.task_id child_id FROM source.read_supercode_cards r,json_each(r.blocked_by) b WHERE json_extract(b.value,'$.kind')='card'");continue;}
275
- if(name==='task_links') {
276
- this.mem.exec(`CREATE TEMP VIEW task_links AS SELECT json_extract(b.value,'$.id') parent_id,r.key child_id FROM source.runtime_rows r,json_each(json_extract(r.value,'$.blocked_by')) b WHERE r.name='@card_cache' AND json_extract(b.value,'$.kind')='card'`);
277
- continue;
278
- }
279
- const columns=this.mem.prepare(`PRAGMA main.table_info(${quote(name)})`).all();
280
- const recordName=name==='tasks'?'@task_cache':name==='supercode_cards'?'@card_cache':name;
281
- const fields=columns.map(({name:column})=>name==='tasks'&&column==='status'?`COALESCE(json_extract(c.value,'$.stage'),json_extract(r.value,'$.status')) AS status`:`json_extract(r.value,'$.${column}') AS ${quote(column)}`);
282
- const join=name==='tasks'?" LEFT JOIN source.runtime_rows c ON c.name='@card_cache' AND c.key=r.key":'';
283
- this.mem.exec(`CREATE TEMP VIEW ${quote(name)} AS SELECT ${fields.join(',')} FROM source.runtime_rows r${join} WHERE r.name='${recordName}' AND r.value IS NOT NULL`);
284
- }
285
- this.mem.exec('PRAGMA query_only=ON');
286
- this.cachedReader=true;this.nativeReader=native;
287
- }
288
- this.saved=saved; this.errors.clear();
289
- for(const [id,reason] of saved.errors??[])this.errors.set(id,reason);
290
- this.mem.boardErrors=this.errors;
291
- }
292
- load({ observe = false } = {}) {
293
- const text = read(this.path), archiveText = read(this.archive);
294
- const saved=this.get('snapshot', {});
295
- let cached=saved.bodyProjection===2&&saved.documentHashes?.open===hash(text)&&saved.documentHashes?.archived===hash(archiveText);
296
- if(!observe && !cached && saved.bodyProjection===2 && saved.documentHashes) {
297
- // The durable intent may have replaced the documents while its runtime commit
298
- // is still pending. Readers keep the previous committed projection, never parse
299
- // an intermediate pair of files. A real hand edit does not match these hashes.
300
- const pending=this.get('pending');
301
- cached=Boolean(pending?.files?.length===2 && pending.files.every(file=>{
302
- const expected=file.path===this.path?saved.documentHashes.open:saved.documentHashes.archived;
303
- const current=hash(file.path===this.path?text:archiveText);
304
- return file.before===expected&&(current===file.before||current===hash(file.text));
305
- }));
366
+
367
+ // Finish a publication another writer committed but did not write: write its text over the
368
+ // document it replaced; merge it into a document edited since (the hand edit wins its cards).
369
+ recover() {
370
+ const pending = this.get('pending');
371
+ if (!pending) return;
372
+ for (const file of pending.files) {
373
+ const current = this.fileHash(file.path);
374
+ if (current.hash === file.after) continue;
375
+ if (current.hash === file.before) { atomic(file.path, file.text); continue; }
376
+ // A hand edit wins: a merge that cannot be made leaves the editor's text, which the next write reads.
377
+ let merged = null;
378
+ try { merged = this.codec.mergeBoardDocument(file.base, lf(current.raw), file.text, file.path); } catch { /* keep the edit */ }
379
+ if (this.fileHash(file.path).hash !== current.hash) throw RETRY;
380
+ if (merged) atomic(file.path, merged.text);
306
381
  }
307
- if (!observe && cached && this.rows.normalized()) {
308
- this.openCachedReader(saved);
309
- this.text=text;this.archiveText=archiveText;
310
- return;
311
- }
312
- if (this.cachedReader)this.invalidate();
313
- const fresh = !this.mem;
314
- const changed = fresh || text !== this.text || archiveText !== this.archiveText;
315
- this.text = text; this.archiveText = archiveText;
316
- if (fresh) { this.mem = new DatabaseSync(':memory:'); this.init(this.mem, { fresh: true }); this.mem.boardErrors=this.errors; }
317
- this.saved = this.get('snapshot', { tables: {}, runtime: {}, clocks: {}, fingerprints: {} });
318
- const normalized = this.rows.load(this.mem, { fresh });
319
- if (normalized) { this.saved.runtime = this.rows.runtime; this.saved.clocks = this.rows.clocks; }
320
- for (const item of Object.values(!normalized && fresh ? this.saved.tables ?? {} : {})) {
321
- if (item.sql) this.mem.exec(item.sql.replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '));
322
- for (const row of item.rows ?? []) this.insert(item.name, row);
323
- }
324
- if (!fresh && !changed) {
325
- for (const [id, row] of Object.entries(this.saved.runtime ?? {})) {
326
- const keys = Object.keys(row);
327
- if (keys.length) this.mem.prepare(`UPDATE tasks SET ${keys.map(k => `${quote(k)}=?`).join(',')} WHERE id=?`).run(...keys.map(k => row[k]), id);
328
- }
329
- for (const [id, row] of Object.entries(this.saved.clocks ?? {})) this.mem.prepare('UPDATE supercode_cards SET since=?,after_fired=?,next_run_at=?,workspace_root=? WHERE task_id=?').run(row.since??null,row.after_fired??null,row.next_run_at??null,row.workspace_root??null,id);
330
- this.rows.watch(this.mem);
331
- if (observe) this.documentEvents();
332
- return;
333
- }
334
- this.rows.watch(this.mem);
335
- this.mem.exec('DELETE FROM tasks; DELETE FROM supercode_cards; DELETE FROM task_links');
336
- this.errors.clear(); this.preserve.clear();
337
- const records = []; this.documentRecords=new Map();
338
- for (const [text,path] of [[this.text,this.path],[this.archiveText,this.archive]]) {
339
- const result = this.inspectDocument(text,path);
340
- const kept = new Set(result.errors.map(e=>e.id));
341
- for(const error of result.errors)this.errors.set(nativeId(error.id),error.reason);
342
- for(const record of result.records) {
343
- this.documentRecords.set(nativeId(record.id),{record,path});
344
- try { records.push(this.preset.KanbanRootSchema.parse(this.preset.parseKanban([record])).issues[0]); }
345
- catch(error) { this.errors.set(nativeId(record.id),error.message); kept.add(record.id); }
346
- }
347
- this.preserve.set(path,kept);
348
- }
349
- const counts=new Map(); for(const card of records)counts.set(nativeId(card.id),(counts.get(nativeId(card.id))??0)+1);
350
- const parsed = {issues:records.filter(card=>{
351
- const id=nativeId(card.id); if(counts.get(id)===1)return true;
352
- this.errors.set(id,'Duplicate card identity across board documents');
353
- for(const kept of this.preserve.values())kept.add(card.id);
354
- return false;
355
- })};
356
- this.originals = new Map(); this.fileIds = new Map(); this.taskKeys = new Map();
357
- for (const card of parsed.issues) {
358
- const id = nativeId(card.id);
359
- if (this.originals.has(id)) throw new Error(`duplicate board card ${id}`);
360
- this.originals.set(id, card); this.fileIds.set(id, card.id);
361
- this.loadCard(id, card);
362
- for (const [tid, taskCard] of this.taskCards(id, card)) this.loadCard(tid, taskCard, id);
363
- }
364
- // Keep the last readable projection only for the affected malformed cards.
365
- // Their raw document spans remain untouched and their verbs refuse with this reason.
366
- for (const [id] of this.errors) {
367
- for (const [tid,row] of Object.entries(this.rows.cachedTasks)) {
368
- const state=this.rows.cachedCards[tid];
369
- if (tid!==id && state?.subtask_of!==id)continue;
370
- this.insert('tasks',row); if(state)this.insert('supercode_cards',state);
371
- if(tid!==id)this.errors.set(tid,`Parent ${id} has an invalid document edit`);
382
+ this.drop('pending'); this.drop('pending_hashes');
383
+ }
384
+
385
+ // ------------------------------------------------------------------ the document index
386
+
387
+ entries(role) { return this.get(`chunks:${role}`, []); }
388
+ texts(entries) {
389
+ const cids = entries.filter((e) => e.text === undefined).map((e) => e.cid);
390
+ const found = new Map(cids.length ? this.statement('SELECT c.cid, c.text FROM main.board_chunks c JOIN json_each(?) j ON c.cid = j.value').all(JSON.stringify(cids)).map((r) => [r.cid, r.text]) : []);
391
+ return entries.map((e) => ({ id: e.id, level: e.level, text: e.text ?? found.get(e.cid) ?? '' }));
392
+ }
393
+ docOf(role) { return this.docs.find((doc) => doc.role === role); }
394
+
395
+ // What the edited documents now say, as a plan of rows; applied to the tables by a write, to
396
+ // connection-local copies by a read. Only the spans the edit touched are parsed.
397
+ observe(stale) {
398
+ const plan = { layout: new Map(), dropped: [], published: {}, cards: new Map(), rows: new Map(), subtasks: new Map(), tasks: new Map(), remove: new Set(), errors: new Map(), checked: new Set() };
399
+ const changed = new Set();
400
+ for (const doc of this.docs) {
401
+ const entries = this.entries(doc.role);
402
+ const edit = stale.find((s) => s.role === doc.role);
403
+ if (!edit) { plan.layout.set(doc.role, entries); continue; }
404
+ let base = this.get('published', {})[doc.role] !== undefined && entries.length ? entries : [];
405
+ let result;
406
+ // A region that cannot be re-read on its own is read with the whole document.
407
+ try { result = this.codec.rechunkBoardDocument(this.texts(base), edit.text, doc.path); }
408
+ catch { base = []; result = this.codec.rechunkBoardDocument([], edit.text, doc.path); }
409
+ const added = result.chunks.slice(result.from, result.to).map((c) => ({ cid: null, id: c.id, level: c.level, len: c.text.length, rec: null, text: c.text }));
410
+ plan.layout.set(doc.role, [...base.slice(0, result.from), ...added, ...base.slice(result.oldTo)]);
411
+ // The spans an edit replaced: every indexed one when the document was read whole. Each card they held is checked.
412
+ const replaced = base.length ? base.slice(result.from, result.oldTo) : entries;
413
+ plan.dropped.push(...replaced.map((e) => e.cid));
414
+ plan.published[doc.role] = edit.hash;
415
+ for (const c of [...added, ...replaced]) if (c.id) changed.add(nativeId(c.id));
416
+ }
417
+ const places = new Map();
418
+ for (const [role, entries] of plan.layout) for (const entry of entries) if (entry.id) places.set(nativeId(entry.id), [...(places.get(nativeId(entry.id)) ?? []), { role, entry }]);
419
+ for (const id of changed) {
420
+ plan.checked.add(id);
421
+ const at = places.get(id) ?? [];
422
+ if (at.length > 1) { plan.errors.set(id, 'Duplicate card identity across board documents'); continue; }
423
+ if (!at.length) { this.planRemoval(plan, id); continue; }
424
+ const { role, entry } = at[0];
425
+ const text = entry.text ?? this.texts([entry])[0].text;
426
+ const { records, errors } = this.codec.inspectBoardDocument(text, this.docOf(role).path);
427
+ if (errors.length || records.length !== 1) { plan.errors.set(id, errors.find((e) => nativeId(e.id) === id)?.reason ?? errors[0]?.reason ?? 'A card section holds another card; give each card its own section'); continue; }
428
+ let card;
429
+ try { card = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([records[0]])).issues[0]; }
430
+ catch (error) { plan.errors.set(id, error.message); continue; }
431
+ entry.rec = hash(JSON.stringify(records[0]));
432
+ plan.cards.set(id, card);
433
+ }
434
+ for (const [id, card] of plan.cards) {
435
+ plan.rows.set(id, this.cardRows(id, card, null));
436
+ const subtasks = [];
437
+ for (const [tid, taskCard, key, sources] of this.taskCards(id, card)) {
438
+ plan.rows.set(tid, this.cardRows(tid, taskCard, id));
439
+ plan.tasks.set(tid, { key, sources });
440
+ subtasks.push(tid);
372
441
  }
442
+ plan.subtasks.set(id, subtasks);
443
+ for (const old of this.statement('SELECT task_id FROM main.supercode_cards WHERE subtask_of = ?').all(id)) if (!subtasks.includes(old.task_id)) this.planRemoval(plan, old.task_id);
373
444
  }
374
- for (const [id,runtime] of Object.entries(this.saved.runtime??{})) {
375
- if (!runtime.current_run_id || this.mem.prepare('SELECT 1 FROM tasks WHERE id=?').get(id))continue;
376
- const task=this.rows.cachedTasks[id], state=this.rows.cachedCards[id];
377
- if (task) {this.insert('tasks',task);if(state)this.insert('supercode_cards',state);}
378
- this.errors.set(id,'Document removed a card with a live run; restore it and cancel its run through the workflow.');
445
+ // A task ref (`<arc>:c3`) names a subtask by its key; resolve it once every key is known.
446
+ const keyed = new Map([...plan.tasks].map(([tid, { key }]) => [key, tid]));
447
+ const resolveRef = (ref) => keyed.get(ref) ?? this.one('SELECT id FROM main.board_tasks WHERE key = ?', ref)?.id ?? ref;
448
+ for (const rows of plan.rows.values()) rows.state.blocked_by = JSON.stringify(json(rows.state.blocked_by, []).map((b) => ({ ...b, id: resolveRef(b.id) })));
449
+ this.checkControls(plan);
450
+ // A refused task line refuses its card's edit: the hand text stays in the document with the reason, never
451
+ // re-rendered without the line.
452
+ for (const [id, subtasks] of plan.subtasks) {
453
+ const bad = plan.errors.has(id) ? null : subtasks.find((tid) => plan.errors.has(tid));
454
+ if (bad) plan.errors.set(id, `Task ${plan.tasks.get(bad)?.key ?? bad}: ${plan.errors.get(bad)}`);
379
455
  }
380
- // Resolve local task refs only after all subtask identities are available.
381
- const taskIds = new Map([...this.taskKeys].map(([id, key]) => [key, id]));
382
- for (const row of this.mem.prepare('SELECT task_id, blocked_by FROM supercode_cards').all()) {
383
- const blocks = json(row.blocked_by, []).map((b) => ({ ...b, id: taskIds.get(b.id) ?? b.id }));
384
- this.mem.prepare('UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?').run(JSON.stringify(blocks), row.task_id);
385
- for (const b of blocks.filter((b) => b.kind === 'card')) this.insert('task_links', { parent_id: b.id, child_id: row.task_id });
456
+ // A card whose own edit is refused keeps its subtasks as they were.
457
+ for (const [id, subtasks] of plan.subtasks) if (plan.errors.has(id)) for (const tid of subtasks) { plan.rows.delete(tid); plan.tasks.delete(tid); }
458
+ for (const id of plan.errors.keys()) {
459
+ plan.rows.delete(id);
460
+ for (const row of this.statement('SELECT task_id FROM main.supercode_cards WHERE subtask_of = ?').all(id)) { plan.remove.delete(row.task_id); if (!plan.errors.has(row.task_id)) plan.errors.set(row.task_id, `Parent ${id} has an invalid document edit`); }
386
461
  }
387
- this.checkDocumentControls();
388
- if (observe) this.documentEvents();
462
+ return plan;
389
463
  }
390
- taskCards(id, card) {
391
- return card.acceptanceCriteria.map((task) => {
392
- const tid = task.metadata?.id ?? this.saved.taskIds?.[`${id}:${task.id}`] ?? `t_${randomBytes(4).toString('hex')}`;
393
- this.taskKeys.set(tid, `${id}:${task.id}`);
394
- const taskCard = { ...task.metadata?.card, id: tid, title: `${task.id} ${task.text}`, body: task.metadata?.body ?? '',
395
- status: task.status === 'passed' ? (task.metadata?.status === 'cancelled' ? 'cancelled' : 'done') : (['done','cancelled'].includes(task.metadata?.status) ? 'todo' : task.metadata?.status ?? 'todo'),
396
- metadata: task.metadata?.properties ?? {}, assignee: task.metadata?.assignee ?? card.assignee,
397
- machine: task.metadata?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
398
- relations: (task.blockedBy ?? []).map((b) => ({ type: 'blocked-by', issueId: b.ac && /^(?:[ma]-[0-9a-f]{6,}|t_[0-9a-f]+)$/.test(b.ac) ? b.ac : b.ac ? `${nativeId(b.issue)}:${b.ac}` : nativeId(b.issue) })) };
399
- if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
400
- return [tid, taskCard];
401
- });
402
- }
403
- loadCard(id, card, parent = null) {
404
- const rows = this.cardRows(id, card, parent);
405
- this.insert('tasks', rows.task); this.insert('supercode_cards', rows.state);
464
+
465
+ planRemoval(plan, id) {
466
+ const task = this.one('SELECT current_run_id FROM main.tasks WHERE id = ?', id);
467
+ if (!task) return;
468
+ if (task.current_run_id) { plan.errors.set(id, 'Document removed a card with a live run; restore it and cancel its run through the workflow.'); return; }
469
+ plan.remove.add(id);
470
+ for (const row of this.statement('SELECT task_id FROM main.supercode_cards WHERE subtask_of = ?').all(id)) plan.remove.add(row.task_id);
406
471
  }
407
- checkDocumentControls() {
408
- // A raw edit cannot authenticate its author. Trusted engine writes already
409
- // have matching publication hashes; check only external edits, after local
410
- // subtask references have been resolved to canonical card identities.
411
- if (this.seed || (this.saved.documentHashes?.open === hash(this.text) && this.saved.documentHashes?.archived === hash(this.archiveText))) return;
472
+
473
+ // A raw edit cannot authenticate its author: the workflow's controlled fields change only through
474
+ // role-checked verbs. The first import of a document has nothing to compare with.
475
+ checkControls(plan) {
476
+ if (!this.one('SELECT 1 FROM main.tasks LIMIT 1')) return;
412
477
  let fields = [];
413
478
  try { fields = workflowFor(this.root).params.document_control_fields ?? []; } catch { /* no home policy */ }
414
479
  if (!fields.length) return;
415
- const tasks = new Map(this.mem.prepare('SELECT * FROM tasks').all().map(t => [t.id, t]));
416
- const states = new Map(this.mem.prepare('SELECT * FROM supercode_cards').all().map(c => [c.task_id, c]));
417
480
  const value = (task, state, field) => field === 'status' ? state?.stage ?? task?.status ?? 'todo'
418
- : field === 'blocked_by' ? json(state?.blocked_by, []).map(b => [b.kind, b.id]).sort()
481
+ : field === 'blocked_by' ? json(state?.blocked_by, []).map((b) => [b.kind, b.id]).sort()
419
482
  : field === 'paused' ? json(state?.paused, null)
420
- : field === 'archived' ? Boolean(state?.archived) : field === 'review_only' ? Boolean(state?.review_only) : task?.[field] ?? null;
421
- for (const id of new Set([...tasks.keys(), ...Object.keys(this.rows.cachedTasks)])) {
422
- if (this.errors.has(id)) continue;
423
- const oldTask = this.rows.cachedTasks[id], oldState = this.rows.cachedCards[id];
424
- const task = tasks.get(id), state = states.get(id);
425
- const forbidden = !task ? ['card removal'] : fields.filter(field =>
426
- // A new display author is not an authenticated creator.
427
- (oldTask || field !== 'created_by') && JSON.stringify(value(task, state, field)) !== JSON.stringify(value(oldTask, oldState, field)));
428
- if (!forbidden.length) continue;
429
- this.errors.set(id, `Document edit cannot change ${forbidden.join(', ')}; use this card's role-checked workflow verbs or request-state.`);
430
- const source = this.documentRecords.get(state?.subtask_of ?? oldState?.subtask_of ?? id);
431
- if (source) this.preserve.get(source.path)?.add(source.record.id);
432
- this.mem.prepare('DELETE FROM tasks WHERE id=?').run(id);
433
- this.mem.prepare('DELETE FROM supercode_cards WHERE task_id=?').run(id);
434
- this.mem.prepare('DELETE FROM task_links WHERE child_id=?').run(id);
435
- if (oldTask) this.insert('tasks', oldTask);
436
- if (oldState) {
437
- this.insert('supercode_cards', oldState);
438
- for (const b of json(oldState.blocked_by, []).filter(b => b.kind === 'card')) this.insert('task_links', { parent_id: b.id, child_id: id });
483
+ : field === 'archived' ? Boolean(state?.archived) : task?.[field] ?? null;
484
+ for (const [id, rows] of plan.rows) {
485
+ const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id), state = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', id);
486
+ // A new display author is not an authenticated creator.
487
+ const forbidden = fields.filter((field) => (task || field !== 'created_by') && JSON.stringify(value(rows.task, rows.state, field)) !== JSON.stringify(value(task, state, field)));
488
+ if (forbidden.length) plan.errors.set(id, `Document edit cannot change ${forbidden.join(', ')}; use this card's role-checked workflow verbs or request-state.`);
489
+ }
490
+ for (const id of plan.remove) {
491
+ plan.errors.set(id, "Document edit cannot change card removal; use this card's role-checked workflow verbs or request-state.");
492
+ }
493
+ for (const id of plan.errors.keys()) plan.remove.delete(id);
494
+ }
495
+
496
+ applyPlan(plan, { overlay }) {
497
+ const schema = overlay ? 'temp' : 'main';
498
+ if (overlay) {
499
+ this.dropOverlay();
500
+ this.conn.exec('CREATE TEMP TABLE tasks AS SELECT * FROM main.tasks; CREATE TEMP TABLE supercode_cards AS SELECT * FROM main.supercode_cards; CREATE TEMP TABLE task_links AS SELECT * FROM main.task_links');
501
+ this.overlay = true; this.statements.clear();
502
+ }
503
+ const initial = !overlay && !this.one('SELECT 1 FROM main.tasks LIMIT 1');
504
+ const by = 'document-editor';
505
+ const events = [];
506
+ const fingerprintOf = (id) => {
507
+ const t = this.one(`SELECT * FROM ${schema}.tasks WHERE id = ?`, id);
508
+ return t ? this.fingerprint(t, this.one(`SELECT * FROM ${schema}.supercode_cards WHERE task_id = ?`, id) ?? {}) : null;
509
+ };
510
+ for (const id of plan.remove) {
511
+ this.statement(`DELETE FROM ${schema}.tasks WHERE id = ?`).run(id);
512
+ this.statement(`DELETE FROM ${schema}.supercode_cards WHERE task_id = ?`).run(id);
513
+ this.statement(`DELETE FROM ${schema}.task_links WHERE child_id = ?`).run(id);
514
+ if (!overlay) { this.statement('DELETE FROM main.board_tasks WHERE id = ?').run(id); if (!initial) events.push({ id, kind: 'removed', payload: { by, source: 'document' } }); }
515
+ }
516
+ for (const [id, rows] of plan.rows) {
517
+ const before = fingerprintOf(id);
518
+ const oldTask = this.one(`SELECT * FROM ${schema}.tasks WHERE id = ?`, id) ?? {};
519
+ const oldState = this.one(`SELECT * FROM ${schema}.supercode_cards WHERE task_id = ?`, id) ?? {};
520
+ this.replace(schema, 'tasks', { ...oldTask, ...rows.task }, 'id');
521
+ this.replace(schema, 'supercode_cards', { ...oldState, ...rows.state }, 'task_id');
522
+ this.statement(`DELETE FROM ${schema}.task_links WHERE child_id = ?`).run(id);
523
+ for (const b of json(rows.state.blocked_by, []).filter((b) => b.kind === 'card')) this.statement(`INSERT OR IGNORE INTO ${schema}.task_links (parent_id, child_id) VALUES (?, ?)`).run(b.id, id);
524
+ const after = fingerprintOf(id);
525
+ if (!overlay && !initial && before?.hash !== after.hash) {
526
+ const kind = !before ? 'created' : before.status !== after.status ? 'status' : before.assignee !== after.assignee ? 'assigned' : 'specified';
527
+ events.push({ id, kind, payload: { by, source: 'document', status: after.status, assignee: after.assignee, author: by }, before, after });
439
528
  }
440
529
  }
530
+ for (const id of plan.checked) this.errors.delete(id);
531
+ for (const [id, reason] of plan.errors) this.errors.set(id, reason);
532
+ if (overlay) return;
533
+ for (const [tid, { key, sources }] of plan.tasks) this.statement('INSERT OR REPLACE INTO main.board_tasks (id, key, sources) VALUES (?, ?, ?)').run(tid, key, sources?.length ? JSON.stringify(sources) : null);
534
+ for (const id of [...plan.checked, ...plan.rows.keys()]) if (!plan.errors.has(id)) this.statement('DELETE FROM main.board_errors WHERE id = ?').run(id);
535
+ for (const [id, reason] of plan.errors) this.statement('INSERT OR REPLACE INTO main.board_errors VALUES (?, ?)').run(id, reason);
536
+ for (const [role, entries] of plan.layout) {
537
+ if (!(role in plan.published)) continue;
538
+ this.put(`chunks:${role}`, entries.map((entry) => {
539
+ const cid = entry.cid ?? Number(this.statement('INSERT INTO main.board_chunks (text) VALUES (?)').run(entry.text).lastInsertRowid);
540
+ return { cid, id: entry.id, level: entry.level, len: entry.len, rec: entry.rec };
541
+ }));
542
+ }
543
+ for (const cid of plan.dropped) this.statement('DELETE FROM main.board_chunks WHERE cid = ?').run(cid);
544
+ this.put('published', { ...this.get('published', {}), ...plan.published });
545
+ let entry = null;
546
+ try { entry = workflowFor(this.root); } catch { /* no home workflow */ }
547
+ for (const e of events) {
548
+ if (e.kind !== 'removed' && e.kind !== 'created' && entry?.workflow.verbs?.document_changed) {
549
+ const change = fire(this.db, this.board(), entry, e.id, 'document_changed', { roles: ['platform'], payload: { previous_status: e.before.status, by, source: 'document' } });
550
+ for (const effect of change.effects) this.statement('INSERT INTO main.supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
551
+ }
552
+ event(this.db, e.id, e.kind, e.payload);
553
+ if (e.kind === 'created' && e.after.parent) event(this.db, e.after.parent, 'subtask_added', { by, subtask: e.id, title: this.one('SELECT title FROM main.tasks WHERE id = ?', e.id).title });
554
+ }
555
+ }
556
+
557
+ replace(schema, table, row, idColumn) {
558
+ const columns = this.tables.get(table).columns;
559
+ const keys = columns.filter((c) => c in row);
560
+ this.statement(`DELETE FROM ${schema}.${quote(table)} WHERE ${quote(idColumn)} = ?`).run(row[idColumn]);
561
+ this.statement(`INSERT INTO ${schema}.${quote(table)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...keys.map((k) => row[k] ?? null));
562
+ }
563
+
564
+ fingerprint(t, c) {
565
+ const status = c.stage ?? t.status;
566
+ return { hash: hash(JSON.stringify([t.title, t.body, status, t.assignee, c.machine, c.blocked_by, c.paused, c.every])), status, assignee: t.assignee, parent: c.subtask_of };
441
567
  }
568
+
569
+ // A card's rows from its parsed document form. Run fields and clocks stay the table's.
442
570
  cardRows(id, card, parent = null) {
443
571
  const meta = card.metadata ?? {};
444
572
  const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
445
573
  const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
446
- const task = { ...meta, ...this.saved.runtime?.[id], id, title: card.title, body: stored.body,
447
- status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? now(), priority: card.priority ?? 0,
448
- workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: this.saved.runtime?.[id]?.workspace_path ?? (/^(\/|~|[A-Za-z]:[\\/])/.test(workspace?.[2] ?? '') ? workspace[2] : null), branch_name: card.branch ?? null };
449
- const state = { ...this.saved.clocks?.[id], task_id: id, workspace_anchor: workspace?.[2] ?? null, review_only: meta.review_only === true ? 1 : 0, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
574
+ const old = this.one('SELECT * FROM main.tasks WHERE id = ?', id) ?? {};
575
+ const runtime = Object.fromEntries(Object.entries(old).filter(([k]) => RUNTIME.has(k)));
576
+ const task = { ...meta, ...runtime, id, title: card.title, body: stored.body,
577
+ status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? old.created_at ?? now(), priority: card.priority ?? 0,
578
+ workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: runtime.workspace_path ?? (/^(\/|~|[A-Za-z]:[\\/])/.test(workspace?.[2] ?? '') ? workspace[2] : null), branch_name: card.branch ?? null };
579
+ const state = { task_id: id, workspace_anchor: workspace?.[2] ?? null, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
450
580
  blocked_by: JSON.stringify((card.relations ?? []).map((r) => ({ ...(meta.blocked_by ?? []).find((b) => b.id === nativeId(r.issueId)), id: nativeId(r.issueId), kind: /^[ma]-/.test(r.issueId) ? 'message' : 'card' }))),
451
581
  paused: card.paused ? JSON.stringify(card.paused) : null, archived: meta.archived ? 1 : 0, every: card.every ?? null };
452
582
  return { task, state };
453
583
  }
454
- fingerprint(t, c) {
455
- const status = c.stage ?? t.status;
456
- return { hash: hash(JSON.stringify([t.title, t.body, status, t.assignee, c.machine, c.blocked_by, c.paused, c.every])), title: hash(t.title), body: hash(t.body ?? ''), status, assignee: t.assignee, machine: c.machine, parent: c.subtask_of };
457
- }
458
- fingerprints() {
459
- return Object.fromEntries(this.mem.prepare('SELECT * FROM tasks').all().map((t) => {
460
- const c = this.mem.prepare('SELECT * FROM supercode_cards WHERE task_id = ?').get(t.id) ?? {};
461
- return [t.id, this.fingerprint(t, c)];
462
- }));
463
- }
464
- documentEvents() {
465
- // A published native document is already represented by its authored events.
466
- if (this.saved.documentHashes?.open === hash(this.text) && this.saved.documentHashes?.archived === hash(this.archiveText)) return;
467
- const previous = this.saved.fingerprints ?? {};
468
- const current = this.fingerprints();
469
- if (!Object.keys(previous).length) return;
470
- const by = this.committer();
471
- for (const [id, value] of Object.entries(current)) {
472
- if (this.errors.has(id)) continue;
473
- const old = previous[id];
474
- if (old?.hash === value.hash) continue;
475
- let entry; try { entry = workflowFor(this.root); } catch {}
476
- if (old && entry?.workflow.verbs?.document_changed) {
477
- const change = fire(this.mem, this.board(), entry, id, 'document_changed', { roles: ['platform'], payload: { previous_status: old.status, by, source: 'document' } });
478
- for (const effect of change.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
584
+
585
+ // A card's Tasks as subtask cards: [id, card, key, sources].
586
+ taskCards(id, card) {
587
+ return card.acceptanceCriteria.map((task) => {
588
+ const key = `${id}:${task.id}`;
589
+ const tid = task.metadata?.id ?? this.one('SELECT id FROM main.board_tasks WHERE key = ?', key)?.id ?? `t_${randomBytes(4).toString('hex')}`;
590
+ const taskCard = { ...task.metadata?.card, id: tid, title: `${task.id} ${task.text}`, body: task.metadata?.body ?? '',
591
+ status: task.status === 'passed' ? (task.metadata?.status === 'cancelled' ? 'cancelled' : 'done') : (['done', 'cancelled'].includes(task.metadata?.status) ? 'todo' : task.metadata?.status ?? 'todo'),
592
+ metadata: task.metadata?.properties ?? {}, assignee: task.metadata?.assignee ?? card.assignee,
593
+ machine: task.metadata?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
594
+ relations: (task.blockedBy ?? []).map((b) => ({ type: 'blocked-by', issueId: b.ac && /^(?:[ma]-[0-9a-f]{6,}|t_[0-9a-f]+)$/.test(b.ac) ? b.ac : b.ac ? `${nativeId(b.issue)}:${b.ac}` : nativeId(b.issue) })) };
595
+ if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
596
+ return [tid, taskCard, key, task.sources];
597
+ });
598
+ }
599
+
600
+ // ------------------------------------------------------------------ rendering
601
+
602
+ // Cards whose text may have changed in this transaction are rendered from their rows and spliced
603
+ // into their documents; a card whose record is unchanged keeps its exact bytes. Answers the
604
+ // documents to publish.
605
+ render() {
606
+ const touched = new Set(this.statement('SELECT DISTINCT id FROM temp.board_touched WHERE id IS NOT NULL').all().map((r) => r.id));
607
+ if (!touched.size) return [];
608
+ const layout = { open: this.entries('open'), archived: this.entries('archived') };
609
+ const where = new Map();
610
+ for (const role of ['open', 'archived']) layout[role].forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
611
+ const parents = new Set();
612
+ for (const id of touched) {
613
+ const parent = this.one('SELECT subtask_of FROM main.supercode_cards WHERE task_id = ?', id)?.subtask_of;
614
+ const key = parent ? null : this.one('SELECT key FROM main.board_tasks WHERE id = ?', id)?.key;
615
+ parents.add(parent ?? (key && !this.one('SELECT 1 FROM main.tasks WHERE id = ?', id) ? key.slice(0, key.lastIndexOf(':')) : id));
616
+ }
617
+ if (this.seedIds) for (const id of this.seedIds) parents.add(id);
618
+ const edits = { open: [], archived: [] };
619
+ for (const id of parents) {
620
+ if (this.errors.has(id) || this.one('SELECT 1 FROM main.supercode_cards WHERE subtask_of = ? AND task_id IN (SELECT id FROM main.board_errors)', id)) continue;
621
+ const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id);
622
+ const state = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', id) ?? {};
623
+ if (task && state.subtask_of) continue;
624
+ const at = where.get(id);
625
+ if (!task) { if (at) edits[at.role].push({ index: at.index, remove: true }); continue; }
626
+ const role = state.archived || ['done', 'cancelled'].includes(state.stage ?? task.status) ? 'archived' : 'open';
627
+ const record = this.recordOf(id, task, state, where);
628
+ const rec = hash(JSON.stringify(record));
629
+ if (at?.role === role && at.entry.rec === rec) continue;
630
+ const level = at?.role === role ? at.entry.level : 2;
631
+ let text;
632
+ try { text = this.codec.renderBoardCard(record, level, this.docOf(role).path); }
633
+ catch (error) {
634
+ this.errors.set(id, error.message);
635
+ this.statement('INSERT OR REPLACE INTO main.board_errors VALUES (?, ?)').run(id, error.message);
636
+ continue;
637
+ }
638
+ const entry = { cid: null, id: record.id, level, len: text.length, rec, text };
639
+ if (at?.role === role) edits[role].push({ index: at.index, entry });
640
+ else { if (at) edits[at.role].push({ index: at.index, remove: true }); edits[role].push({ entry }); }
641
+ }
642
+ const files = [];
643
+ const published = this.get('published', {});
644
+ for (const role of ['open', 'archived']) {
645
+ if (!edits[role].length) continue;
646
+ const doc = this.docOf(role);
647
+ const current = this.fileHash(doc.path);
648
+ let text = lf(current.raw);
649
+ const entries = [...layout[role]];
650
+ if (entries.reduce((sum, e) => sum + e.len, 0) !== text.length) {
651
+ if (current.hash !== published[role]) throw RETRY; // edited during this transaction
652
+ throw new Error(`${doc.path} does not match its board index`);
479
653
  }
480
- const kind = !old ? 'created' : old.status !== value.status ? 'status' : old.assignee !== value.assignee ? 'assigned' : 'specified';
481
- event(this.mem, id, kind, { by, source: 'document', status: value.status, assignee: value.assignee, author: by });
482
- if (!old && value.parent) event(this.mem, value.parent, 'subtask_added', { by, subtask: id, title: this.mem.prepare('SELECT title FROM tasks WHERE id = ?').get(id).title });
483
- }
484
- for (const id of Object.keys(previous)) if (!current[id] && !this.errors.has(id)) {
485
- if (this.saved.runtime?.[id]?.current_run_id) { this.errors.set(id,`Document removed a card with a live run; restore it and cancel it through its workflow first`); continue; }
486
- event(this.mem, id, 'removed', { by, source: 'document' });
487
- }
488
- }
489
- committer() {
490
- return 'document-editor';
491
- }
492
- inspectDocument(text, path) {
493
- const previous = documentCache.get(path);
494
- if (previous?.text === text) return previous.result;
495
- const result = this.codec.inspectBoardDocument(text, path);
496
- remember(documentCache, path, { text, result });
497
- return result;
498
- }
499
- renderDocument(text, records, path, preserve) {
500
- const projection = JSON.stringify([records, preserve]);
501
- const previous = renderCache.get(path);
502
- if (previous?.text === text && previous.projection === projection) return previous.result;
503
- const result = this.codec.renderBoardDocument(text, records, path, preserve);
504
- remember(renderCache, path, { text, projection, result });
505
- return result;
506
- }
507
- documents(changes=null) {
508
- this.publishedFingerprints = {};
509
- const allRows = this.mem.prepare('SELECT * FROM tasks ORDER BY created_at, id').all();
510
- const states = new Map(this.mem.prepare('SELECT * FROM supercode_cards').all().map((c) => [c.task_id, c]));
511
- const affected=changes?new Set(changes.map(({source})=>{const id=JSON.parse(source.key)[0];return states.get(id)?.subtask_of??this.rows.cachedCards[id]?.subtask_of??id;})):null;
512
- const rows=affected?allRows.filter(row=>affected.has(states.get(row.id)?.subtask_of??row.id)):allRows;
513
- const cards = new Map();
514
- for (const row of rows.filter((r) => !states.get(r.id)?.subtask_of && !this.errors.has(r.id))) {
515
- const c = states.get(row.id) ?? {};
516
- const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: this.fileIds.get(row.id) ?? row.id, title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' }])).issues[0];
517
- const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
518
- if (c.archived) metadata.archived = true;
519
- if (c.review_only) metadata.review_only = true;
520
- if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
521
- cards.set(row.id, { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_kind !== 'scratch' && (c.workspace_anchor ?? row.workspace_path) ? `:${c.workspace_anchor ?? row.workspace_path}` : ''),
522
- branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
523
- relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: this.fileIds.get(b.id) ?? b.id })), acceptanceCriteria: [] });
524
- }
525
- const occupied = new Map();
526
- for (const row of rows) {
527
- const parent = states.get(row.id)?.subtask_of;
528
- if (!parent) continue;
529
- const key = this.taskKeys.get(row.id)?.split(':').at(-1) ?? /^([a-z]+\d+)\s/.exec(row.title)?.[1];
530
- if (key) { const set = occupied.get(parent) ?? new Set(); set.add(key); occupied.set(parent, set); }
531
- }
532
- const renderedKeys = new Set();
533
- for (const row of rows.filter((r) => states.get(r.id)?.subtask_of && !this.errors.has(r.id))) {
534
- const c = states.get(row.id);
535
- if (!cards.has(c.subtask_of)) throw new Error(`subtask ${row.id} has no arc ${c.subtask_of}`);
536
- const taken = occupied.get(c.subtask_of) ?? new Set(); occupied.set(c.subtask_of, taken);
537
- const match = /^([a-z]+\d+)\s+(.+)$/.exec(row.title);
538
- let key = this.taskKeys.get(row.id)?.split(':').at(-1) ?? match?.[1];
539
- if (!key || renderedKeys.has(`${c.subtask_of}:${key}`)) { let n = 1; while (taken.has(`c${n}`)) n++; key = `c${n}`; }
540
- taken.add(key); renderedKeys.add(`${c.subtask_of}:${key}`); this.taskKeys.set(row.id, `${c.subtask_of}:${key}`);
541
- }
542
- for (const row of rows.filter((r) => states.get(r.id)?.subtask_of && !this.errors.has(r.id))) {
543
- const c = states.get(row.id); const parent = cards.get(c.subtask_of);
544
- const key = this.taskKeys.get(row.id).split(':').at(-1);
545
- const match = /^([a-z]+\d+)\s+(.+)$/.exec(row.title);
546
- const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: row.id, title: row.title, status: c.stage ?? row.status, body: row.body ?? '' }])).issues[0];
547
- const originalTask = this.originals.get(c.subtask_of)?.acceptanceCriteria.find((t) => t.id === key);
548
- const blocks = json(c.blocked_by, []);
549
- const messages = blocks.filter(block => block.kind === 'message');
550
- parent.acceptanceCriteria.push({ sources: originalTask?.sources, id: key, status: ['done', 'cancelled'].includes(c.stage ?? row.status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? row.title,
551
- metadata: { id: row.id, status: c.stage ?? row.status, body: parsed.body, assignee: row.assignee, machine: c.machine,
552
- properties: Object.fromEntries(Object.entries(row).filter(([k,v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null)),
553
- card: { paused: json(c.paused, null) ?? undefined, every: c.every || undefined } }, lines: parsed.acceptance,
654
+ const offsets = []; let offset = 0;
655
+ for (const e of entries) { offsets.push(offset); offset += e.len; }
656
+ const dropped = [];
657
+ for (const edit of edits[role].filter((e) => e.index !== undefined).sort((a, b) => b.index - a.index)) {
658
+ const old = entries[edit.index];
659
+ text = text.slice(0, offsets[edit.index]) + (edit.remove ? '' : edit.entry.text) + text.slice(offsets[edit.index] + old.len);
660
+ dropped.push(old.cid);
661
+ if (edit.remove) entries.splice(edit.index, 1); else entries[edit.index] = edit.entry;
662
+ }
663
+ for (const edit of edits[role].filter((e) => e.index === undefined)) {
664
+ if (text && !text.endsWith('\n\n')) {
665
+ // the blank line before a new section belongs to the span above it
666
+ const last = entries.at(-1), separator = text.endsWith('\n') ? '\n' : '\n\n';
667
+ const lastText = last.text ?? this.texts([last])[0].text;
668
+ if (last.cid !== null) dropped.push(last.cid);
669
+ entries[entries.length - 1] = { ...last, cid: null, len: last.len + separator.length, text: lastText + separator };
670
+ text += separator;
671
+ }
672
+ text += edit.entry.text;
673
+ entries.push(edit.entry);
674
+ }
675
+ for (const cid of dropped) if (cid !== null) this.statement('DELETE FROM main.board_chunks WHERE cid = ?').run(cid);
676
+ this.put(`chunks:${role}`, entries.map((e) => ({ cid: e.cid ?? Number(this.statement('INSERT INTO main.board_chunks (text) VALUES (?)').run(e.text).lastInsertRowid), id: e.id, level: e.level, len: e.len, rec: e.rec })));
677
+ files.push({ role, path: doc.path, before: current.hash, after: hash(text), base: lf(current.raw), text });
678
+ published[role] = hash(text);
679
+ }
680
+ if (files.length) this.put('published', published);
681
+ return files;
682
+ }
683
+
684
+ // The document record of card `id`, from its rows and its subtasks' rows.
685
+ recordOf(id, row, c, where) {
686
+ const fileId = (x) => where.get(x)?.entry.id ?? x;
687
+ const keyOf = (x) => this.one('SELECT key FROM main.board_tasks WHERE id = ?', x)?.key ?? null;
688
+ const parse = (record) => this.preset.KanbanRootSchema.parse(this.preset.parseKanban([record])).issues[0];
689
+ const parsed = parse({ id: fileId(id), title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' });
690
+ const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
691
+ if (c.archived) metadata.archived = true;
692
+ if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
693
+ const card = { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_kind !== 'scratch' && (c.workspace_anchor ?? row.workspace_path) ? `:${c.workspace_anchor ?? row.workspace_path}` : ''),
694
+ branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
695
+ // Task lines left in a stored body (create and specify file them as subtasks) stay in its Tasks, before its subtasks.
696
+ relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: fileId(b.id) })), acceptanceCriteria: [...(parsed.acceptanceCriteria ?? [])] };
697
+ const rows = this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ? ORDER BY t.created_at, t.id').all(id);
698
+ const states = new Map(rows.map((r) => [r.id, this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', r.id)]));
699
+ const keys = new Map(), taken = new Set(card.acceptanceCriteria.map((task) => task.id));
700
+ for (const r of rows) { const key = keyOf(r.id)?.split(':').at(-1) ?? /^([a-z]+\d+)\s/.exec(r.title)?.[1]; if (key && !taken.has(key)) { keys.set(r.id, key); taken.add(key); } }
701
+ for (const r of rows) {
702
+ if (keys.has(r.id)) continue;
703
+ let n = 1; while (taken.has(`c${n}`)) n++;
704
+ keys.set(r.id, `c${n}`); taken.add(`c${n}`);
705
+ }
706
+ for (const r of rows) {
707
+ const s = states.get(r.id), key = keys.get(r.id), status = s.stage ?? r.status;
708
+ const stored = this.one('SELECT key, sources FROM main.board_tasks WHERE id = ?', r.id);
709
+ if (stored?.key !== `${id}:${key}`) this.statement('INSERT INTO main.board_tasks (id, key, sources) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET key = excluded.key').run(r.id, `${id}:${key}`, stored?.sources ?? null);
710
+ const match = /^([a-z]+\d+)\s+(.+)$/.exec(r.title);
711
+ const task = parse({ id: r.id, title: r.title, status, body: r.body ?? '' });
712
+ const blocks = json(s.blocked_by, []);
713
+ const messages = blocks.filter((block) => block.kind === 'message');
714
+ card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, status: ['done', 'cancelled'].includes(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
715
+ metadata: { id: r.id, status, body: task.body, assignee: r.assignee, machine: s.machine,
716
+ properties: Object.fromEntries(Object.entries(r).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null)),
717
+ card: { paused: json(s.paused, null) ?? undefined, every: s.every || undefined } }, lines: task.acceptance,
554
718
  waitingOn: messages[0]?.id,
555
- blockedBy: blocks.filter(block => block !== messages[0]).map(block => {
556
- const target = this.taskKeys.get(block.id);
557
- if (!target) return { issue: this.fileIds.get(block.id) ?? block.id };
558
- const [arcId, taskKey] = target.split(':');
559
- return { issue: this.fileIds.get(arcId) ?? arcId, ac: taskKey };
719
+ blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
720
+ const target = keyOf(block.id);
721
+ if (!target) return { issue: fileId(block.id) };
722
+ const at = target.lastIndexOf(':');
723
+ return { issue: fileId(target.slice(0, at)), ac: target.slice(at + 1) };
560
724
  }) });
561
725
  }
562
- const open = [], archived = [];
563
- if(affected)for(const [id,{record,path}] of this.documentRecords??[])if(!affected.has(id)&&!this.errors.has(id))(path===this.path?open:archived).push(record);
564
- for (const [id, card] of cards) {
565
- const serialized = this.preset.serializeKanbanCard(card);
566
- const record = { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
567
- // Fingerprint the codec's persisted projection, not the pre-serialization input.
568
- // The codec normalizes bodies (including their trailing newline) and subtask titles.
569
- const persisted = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([record])).issues[0];
570
- const rows = this.cardRows(id, persisted);
571
- this.publishedFingerprints[id] = this.fingerprint(rows.task, rows.state);
572
- for (const [tid, taskCard] of this.taskCards(id, persisted)) {
573
- const rows = this.cardRows(tid, taskCard, id);
574
- this.publishedFingerprints[tid] = this.fingerprint(rows.task, rows.state);
575
- }
576
- (states.get(id)?.archived || ['done', 'cancelled'].includes(card.status) ? archived : open).push(record);
726
+ const serialized = this.preset.serializeKanbanCard(card);
727
+ return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
728
+ }
729
+
730
+ // ------------------------------------------------------------------ migration from a SQLite board
731
+
732
+ indexForSeed() {
733
+ const published = {};
734
+ for (const doc of this.docs) {
735
+ const current = this.fileHash(doc.path);
736
+ for (const e of this.entries(doc.role)) this.statement('DELETE FROM main.board_chunks WHERE cid = ?').run(e.cid);
737
+ this.put(`chunks:${doc.role}`, this.codec.chunkBoardDocument(lf(current.raw), doc.path).map((chunk) => ({
738
+ cid: Number(this.statement('INSERT INTO main.board_chunks (text) VALUES (?)').run(chunk.text).lastInsertRowid), id: chunk.id, level: chunk.level, len: chunk.text.length, rec: null })));
739
+ published[doc.role] = current.hash;
577
740
  }
578
- const original=this.documentRecords;
579
- const changedPath=path=>!affected||[...affected].some(id=>original?.get(id)?.path===path||cards.has(id)&&((states.get(id)?.archived||['done','cancelled'].includes(cards.get(id).status))?this.archive:this.path)===path);
580
- return { open: !changedPath(this.path)?this.text:this.renderDocument(this.text, open, this.path, [...(this.preserve.get(this.path)??[])]), archived: !changedPath(this.archive)?this.archiveText:this.renderDocument(this.archiveText, archived, this.archive, [...(this.preserve.get(this.archive)??[])]) };
741
+ this.put('published', published);
742
+ this.seedIds = new Set(this.docs.flatMap((doc) => this.entries(doc.role).filter((e) => e.id).map((e) => nativeId(e.id))));
581
743
  }
582
- snapshot(documents) {
583
- const fingerprints = this.fingerprints();
584
- if (this.saved.documentHashes?.open === hash(this.text) && this.saved.documentHashes?.archived === hash(this.archiveText)) {
585
- for (const id of Object.keys(fingerprints)) if (this.saved.fingerprints?.[id]) fingerprints[id] = this.saved.fingerprints[id];
744
+
745
+ seedRows() {
746
+ const source = this.seed;
747
+ const insert = (table, row) => {
748
+ const columns = new Set(this.conn.prepare(`PRAGMA main.table_info(${quote(table)})`).all().map((c) => c.name));
749
+ const keys = Object.keys(row).filter((k) => columns.has(k));
750
+ if (keys.length) this.statement(`INSERT OR REPLACE INTO main.${quote(table)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...keys.map((k) => row[k] ?? null));
751
+ };
752
+ for (const table of this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all()) this.conn.exec(`DELETE FROM main.${quote(table.name)}`);
753
+ for (const table of source.db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
754
+ if (!this.tables.has(table.name)) continue;
755
+ for (const row of source.db.prepare(`SELECT * FROM ${quote(table.name)}`).all()) insert(table.name, row);
586
756
  }
587
- return { bodyProjection:2, documentHashes:{open:hash(documents.open),archived:hash(documents.archived)}, errors:[...this.errors], fingerprints: { ...fingerprints, ...this.publishedFingerprints }, taskIds: Object.fromEntries([...this.taskKeys].map(([id, key]) => [key, id])) };
757
+ for (const task of this.conn.prepare('SELECT * FROM main.tasks').all()) {
758
+ const old = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', task.id) ?? {};
759
+ if (old.subtask_of) { const key = /^([a-z]+\d+)\s/.exec(task.title)?.[1]; if (key) this.statement('INSERT OR REPLACE INTO main.board_tasks (id, key) VALUES (?, ?)').run(task.id, `${old.subtask_of}:${key}`); }
760
+ const status = old.stage ?? task.status;
761
+ const mapped = source.mapping[status];
762
+ if (!mapped) throw new Error(`migration needs an explicit mapping for ${status} (${task.id})`);
763
+ let stage = typeof mapped === 'string' ? mapped : mapped.status;
764
+ if (mapped.preserve_completed && task.completed_at) stage = 'done';
765
+ const parents = this.conn.prepare('SELECT parent_id FROM main.task_links WHERE child_id = ?').all(task.id).map((p) => ({ id: p.parent_id, kind: 'card' }));
766
+ const last = this.one("SELECT kind, payload FROM main.task_events WHERE task_id = ? AND kind IN ('blocked','unblocked') ORDER BY id DESC LIMIT 1", task.id);
767
+ const wait = last?.kind === 'blocked' ? json(last.payload, {})?.waiting_on : null;
768
+ if (wait) parents.push({ id: wait, kind: 'message' });
769
+ insert('supercode_cards', { ...old, task_id: task.id, stage, archived: Number(Boolean(mapped.archived) || stage === 'cancelled'), blocked_by: JSON.stringify(parents),
770
+ every: mapped.every ?? old.every ?? null, next_run_at: mapped.every ? now() : old.next_run_at,
771
+ paused: mapped.paused ? JSON.stringify({ by: actorOf() ?? 'migration', reason: `migrated ${status}: ${json(last?.payload, {})?.reason ?? 'held by previous workflow'}` }) : null });
772
+ this.statement('UPDATE main.tasks SET status = ? WHERE id = ?').run(stage, task.id);
773
+ if (task.current_run_id && ['done', 'cancelled'].includes(stage)) {
774
+ this.statement("UPDATE main.task_runs SET ended_at = COALESCE(ended_at, ?), status = 'reclaimed', outcome = COALESCE(outcome, 'reclaimed') WHERE id = ?").run(now(), task.current_run_id);
775
+ this.statement('UPDATE main.tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL WHERE id = ?').run(task.id);
776
+ } else if (task.current_run_id) {
777
+ const run = this.one('SELECT metadata FROM main.task_runs WHERE id = ?', task.current_run_id);
778
+ const meta = json(run?.metadata, {}) ?? {}; meta.supercode = { ...meta.supercode, state: 'active' };
779
+ this.statement('UPDATE main.task_runs SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), task.current_run_id);
780
+ }
781
+ }
782
+ this.put('hermes_cursor', Number(source.db.prepare('SELECT COALESCE(MAX(id),0) AS id FROM task_events').get().id));
588
783
  }
589
784
 
785
+ // ------------------------------------------------------------------ Hermes compatibility
786
+
590
787
  prepareHermes() {
591
- if (this.seed || !existsSync(this.compatPath)) return null;
788
+ if (!this.compatLaidOut()) return null;
592
789
  let entry; try { entry = workflowFor(this.root); } catch { return null; }
593
790
  const compat = new DatabaseSync(this.compatPath, { readOnly: true });
594
791
  try {
595
- const prepared = { entry, evidence: new Map(), version: this.get('version'), open: hash(read(this.path)), archived: hash(read(this.archive)) };
596
792
  const cursor = this.get('hermes_cursor', 0);
793
+ const prepared = { entry, evidence: new Map(), cursor };
597
794
  prepared.rows = compat.prepare('SELECT * FROM task_events WHERE id > ? ORDER BY id').all(cursor);
598
795
  for (const row of prepared.rows) {
599
796
  const payload = json(row.payload, {}) ?? {};
@@ -603,17 +800,18 @@ class ZtrackBoard {
603
800
  return prepared;
604
801
  } finally { compat.close(); }
605
802
  }
803
+
606
804
  lift(prepared) {
607
- if (!prepared) return;
805
+ if (!prepared?.rows.length) return;
608
806
  const { entry, rows, evidence } = prepared;
609
807
  const before = actorOf();
610
808
  try {
611
809
  for (const row of rows) {
612
810
  const payload = json(row.payload, {}) ?? {};
613
811
  if (payload.__supercode_export) continue;
614
- const task = this.mem.prepare('SELECT * FROM tasks WHERE id = ?').get(row.task_id);
812
+ const task = this.one('SELECT * FROM main.tasks WHERE id = ?', row.task_id);
615
813
  if (!task) continue;
616
- this.mem.exec('SAVEPOINT hermes_card');
814
+ this.conn.exec('SAVEPOINT hermes_card');
617
815
  try {
618
816
  const roles = ['manager', 'reviewer', 'platform', 'hermes'];
619
817
  setActor(payload.by ?? 'hermes');
@@ -621,70 +819,108 @@ class ZtrackBoard {
621
819
  if (row.kind === 'unblocked') { name = 'unblock'; words = { ...words, reason: 'unblocked in Hermes', hermes: true }; }
622
820
  else if (row.kind === 'completed') { name = 'complete'; words.summary = payload.summary ?? task.result; words.ci = evidence.get(row.id); }
623
821
  else if (row.kind === 'blocked' || row.kind === 'dependency_wait') { name = 'block'; words.on = payload.on ?? payload.parent ?? null; words.hermes = true; }
624
- else if (row.kind === 'archived' && statusOf(this.mem, task) !== 'done') name = 'cancel';
822
+ else if (row.kind === 'archived' && statusOf(this.db, task) !== 'done') name = 'cancel';
625
823
  if (row.kind === 'unblocked' && entry.workflow.verbs?.hermes_unpause) {
626
- const resumed = fire(this.mem, this.board(), entry, task.id, 'hermes_unpause', { roles, payload: { hermes: true, reason: 'unblocked in Hermes' } });
627
- for (const effect of resumed.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
824
+ const resumed = fire(this.db, this.board(), entry, task.id, 'hermes_unpause', { roles, payload: { hermes: true, reason: 'unblocked in Hermes' } });
825
+ for (const effect of resumed.effects) this.statement('INSERT INTO main.supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
628
826
  }
629
827
  if (name) {
630
- const apply = () => fire(this.mem, this.board(), entry, task.id, name, { roles, payload: words });
828
+ const apply = () => fire(this.db, this.board(), entry, task.id, name, { roles, payload: words });
631
829
  const result = name === 'complete' ? withCompletionEvidence(this.board(), task.id, words.ci, apply) : apply();
632
- for (const effect of result.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
830
+ for (const effect of result.effects) this.statement('INSERT INTO main.supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
633
831
  }
634
- event(this.mem, task.id, 'hermes_lifted', { by: 'hermes', source_event: row.id, verb: name, kind: row.kind });
635
- this.mem.exec('RELEASE hermes_card');
636
- } catch(error) {
637
- this.mem.exec('ROLLBACK TO hermes_card; RELEASE hermes_card');
638
- event(this.mem, task.id, 'hermes_refused', {by:'hermes',source_event:row.id,kind:row.kind,reason:error.message});
832
+ event(this.db, task.id, 'hermes_lifted', { by: 'hermes', source_event: row.id, verb: name, kind: row.kind });
833
+ this.conn.exec('RELEASE hermes_card');
834
+ } catch (error) {
835
+ this.conn.exec('ROLLBACK TO hermes_card; RELEASE hermes_card');
836
+ event(this.db, task.id, 'hermes_refused', { by: 'hermes', source_event: row.id, kind: row.kind, reason: error.message });
639
837
  }
640
838
  }
641
839
  } finally { setActor(before); }
642
- if (rows.length) this.put('hermes_cursor', rows.at(-1).id);
840
+ this.put('hermes_cursor', rows.at(-1).id);
841
+ }
842
+
843
+ // Changed rows reach kanban.db after their commit; a key stays queued until an export consumed
844
+ // the revision it read.
845
+ // The Hermes projection follows a committed write and never fails it. A failure keeps the queue for the next
846
+ // write and is recorded as `export_error` (with a line on stderr) until an export succeeds.
847
+ exportFollowing() {
848
+ try {
849
+ this.flushExport();
850
+ if (this.get('export_error', null)) this.small(() => this.drop('export_error'));
851
+ } catch (error) {
852
+ process.stderr.write(`supercode workflow: kanban.db export deferred: ${error.message}\n`);
853
+ try { this.small(() => this.put('export_error', { at: Date.now(), message: error.message })); } catch { /* the board itself is busy; the next write retries */ }
854
+ }
643
855
  }
856
+
644
857
  flushExport() {
645
- const changes = this.get('export_pending', []);
646
- if (!changes.length) return;
647
- this.export(changes);
648
- // Remove only the prefix this export consumed; another writer may append meanwhile.
649
- this.runtime.exec('BEGIN');
858
+ if (!this.compatPath) return;
859
+ const queued = this.statement('SELECT name, key, rev FROM main.board_export').all();
860
+ // A kanban.db that does not exist yet, or that Hermes has not laid out, is written whole: every card and row.
861
+ const whole = !this.compatLaidOut();
862
+ if (!whole && !queued.length) return;
863
+ this.export(whole ? this.everyRow() : queued);
864
+ if (queued.length) this.small(() => { for (const q of queued) this.statement('DELETE FROM main.board_export WHERE name = ? AND key = ? AND rev = ?').run(q.name, q.key, q.rev); });
865
+ }
866
+
867
+ // Whether kanban.db exists with Hermes's tables.
868
+ compatLaidOut() {
869
+ if (!this.compatPath || !existsSync(this.compatPath)) return false;
870
+ let compat;
650
871
  try {
651
- const pending = this.get('export_pending', []);
652
- if (JSON.stringify(pending.slice(0, changes.length)) === JSON.stringify(changes)) this.put('export_pending', pending.slice(changes.length));
653
- this.runtime.exec('COMMIT');
654
- } catch (error) { this.runtime.exec('ROLLBACK'); throw error; }
872
+ compat = new DatabaseSync(this.compatPath, { readOnly: true });
873
+ return compat.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('tasks', 'task_events')").get().n === 2;
874
+ } catch { return false; } finally { compat?.close(); }
875
+ }
876
+
877
+ everyRow() {
878
+ const rows = [];
879
+ for (const table of this.tables.values()) {
880
+ for (const row of this.statement(`SELECT ${table.keys.map(quote).join(',')} FROM main.${quote(table.name)}`).all()) rows.push({ name: table.name, key: JSON.stringify(table.keys.map((k) => row[k])) });
881
+ }
882
+ return rows;
883
+ }
884
+
885
+ rowOf(name, key) {
886
+ const table = this.tables.get(name);
887
+ if (!table) return null;
888
+ return this.one(`SELECT * FROM main.${quote(name)} WHERE ${table.keys.map((k) => `${quote(k)} IS ?`).join(' AND ')}`, ...JSON.parse(key)) ?? null;
655
889
  }
656
- export(changes) {
890
+
891
+ export(queued) {
657
892
  let entry; try { entry = workflowFor(this.root); } catch { return; }
658
- const projected=new Set(changes.filter(c=>['tasks','supercode_cards'].includes(c.source.name)).map(c=>JSON.parse(c.source.key)[0]));
659
- for(const id of projected)if(this.mem.prepare('SELECT 1 FROM tasks WHERE id=?').get(id))projectStatus(this.mem,this.board(),entry,id);
893
+ const stored = (id) => storedStatus(this.db, this.board(), entry, id);
894
+ const projected = new Set(queued.filter((q) => ['tasks', 'supercode_cards'].includes(q.name)).map((q) => JSON.parse(q.key)[0]));
660
895
  const compat = new DatabaseSync(this.compatPath);
661
896
  try {
662
897
  compat.exec('BEGIN'); this.init(compat, { fresh: true });
663
898
  // Native events and Hermes events share an export database, not an id sequence.
664
899
  // Bootstrap the old identity mapping once; new exports allocate independent Hermes ids.
665
900
  if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='supercode_export_events'").get()) {
666
- compat.exec(`CREATE TABLE supercode_export_events (source_id INTEGER PRIMARY KEY, event_id INTEGER NOT NULL UNIQUE)`);
667
- compat.exec(`INSERT INTO supercode_export_events SELECT id, id FROM task_events WHERE json_extract(payload, '$.__supercode_export') = 1`);
901
+ compat.exec('CREATE TABLE supercode_export_events (source_id INTEGER PRIMARY KEY, event_id INTEGER NOT NULL UNIQUE)');
902
+ compat.exec("INSERT INTO supercode_export_events SELECT id, id FROM task_events WHERE json_extract(payload, '$.__supercode_export') = 1");
668
903
  // Repair existing lowered holds on upgrade, once; subsequent exports stay proportional to changes.
669
- for (const state of this.mem.prepare('SELECT task_id, paused, blocked_by FROM supercode_cards').all()) {
670
- if (!json(state.paused, null) && !json(state.blocked_by, []).some((b) => b.kind === 'message')) continue;
671
- projected.add(state.task_id); projectStatus(this.mem, this.board(), entry, state.task_id);
904
+ for (const state of this.statement('SELECT task_id, paused, blocked_by FROM main.supercode_cards').all()) {
905
+ if (json(state.paused, null) || json(state.blocked_by, []).some((b) => b.kind === 'message')) projected.add(state.task_id);
672
906
  }
673
907
  }
674
908
  // Every import is event-based. Existing unconsumed acts must never be erased by an export race.
675
909
  const cursor = this.get('hermes_cursor', 0);
676
910
  const unseen = compat.prepare('SELECT payload FROM task_events WHERE id > ?').all(cursor).some((r) => !json(r.payload, {})?.__supercode_export);
677
911
  if (unseen) throw new Error('Hermes wrote during board export; retained its events for the next read');
678
- const sources = new Map(changes.map(c => [`${c.source.name}:${c.source.key}`, c.source]));
679
- for (const id of projected) sources.set(`tasks:${JSON.stringify([id])}`, { name: 'tasks', key: JSON.stringify([id]), row: this.mem.prepare('SELECT * FROM tasks WHERE id = ?').get(id) });
680
- for (const source of [...sources.values()]) {
681
- const id = source.name === 'tasks' ? source.row?.id : source.name === 'supercode_cards' ? source.row?.task_id : null;
682
- if (id) sources.set(`tasks:${JSON.stringify([id])}`, {name:'tasks',key:JSON.stringify([id]),row:this.mem.prepare('SELECT * FROM tasks WHERE id=?').get(id)});
912
+ const sources = new Map(queued.map((q) => [`${q.name}:${q.key}`, { name: q.name, key: q.key, row: this.rowOf(q.name, q.key) }]));
913
+ for (const id of projected) {
914
+ const row = this.rowOf('tasks', JSON.stringify([id]));
915
+ const state = this.rowOf('supercode_cards', JSON.stringify([id]));
916
+ const status = row ? stored(id) : null;
917
+ sources.set(`tasks:${JSON.stringify([id])}`, { name: 'tasks', key: JSON.stringify([id]), row: row && { ...row, status: status.stored } });
918
+ if (state || !row) sources.set(`supercode_cards:${JSON.stringify([id])}`, { name: 'supercode_cards', key: JSON.stringify([id]), row: state && { ...state, stage: status?.status ?? state.stage } });
683
919
  }
684
- for (const {name,key,row:source} of sources.values()) {
920
+ for (const { name, key, row: source } of sources.values()) {
685
921
  if (name === 'sqlite_sequence') continue;
686
922
  if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name)) continue;
687
- const table = this.rows.tables.get(name);
923
+ const table = this.tables.get(name);
688
924
  if (name === 'task_events') {
689
925
  const sourceId = JSON.parse(key)[0];
690
926
  const mapped = compat.prepare('SELECT event_id FROM supercode_export_events WHERE source_id = ?').get(sourceId);
@@ -706,12 +942,13 @@ class ZtrackBoard {
706
942
  }
707
943
  continue;
708
944
  }
709
- if (!source) { compat.prepare(`DELETE FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).run(...JSON.parse(key)); continue; }
710
- const row = {...source};
711
- const keys=Object.keys(row);
712
- const previous=compat.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key));
713
- if (previous && keys.every(k=>previous[k]===row[k]))continue;
714
- compat.prepare(`INSERT OR REPLACE INTO ${quote(name)} (${keys.map(quote)}) VALUES (${keys.map(()=>'?')})`).run(...keys.map(k=>row[k]));
945
+ const where = table.keys.map((k) => `${quote(k)} IS ?`).join(' AND ');
946
+ if (!source) { compat.prepare(`DELETE FROM ${quote(name)} WHERE ${where}`).run(...JSON.parse(key)); continue; }
947
+ const columns = new Set(compat.prepare(`PRAGMA table_info(${quote(name)})`).all().map((c) => c.name));
948
+ const keys = Object.keys(source).filter((k) => columns.has(k));
949
+ const previous = compat.prepare(`SELECT * FROM ${quote(name)} WHERE ${where}`).get(...JSON.parse(key));
950
+ if (previous && keys.every((k) => previous[k] === source[k])) continue;
951
+ compat.prepare(`INSERT OR REPLACE INTO ${quote(name)} (${keys.map(quote)}) VALUES (${keys.map(() => '?')})`).run(...keys.map((k) => source[k]));
715
952
  }
716
953
  // Hermes recomputes ready on read. Its sticky block is the last blocked/unblocked event,
717
954
  // so lowering the status alone cannot preserve a pause (including one made by a document edit).
@@ -730,7 +967,7 @@ class ZtrackBoard {
730
967
  }
731
968
  compat.exec('COMMIT');
732
969
  // Exported events carry an origin marker and never advance the Hermes input cursor.
733
- } catch (error) { try { compat.exec('ROLLBACK'); } catch {} throw error; }
970
+ } catch (error) { try { compat.exec('ROLLBACK'); } catch { /* not begun */ } throw error; }
734
971
  finally { compat.close(); }
735
972
  }
736
973
  }