@volter/supercode-orchestrator 0.4.147 → 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/ci.mjs +177 -108
- package/board/cli.mjs +58 -35
- package/board/dispatch.mjs +169 -43
- package/board/engine.mjs +130 -30
- package/board/notices.mjs +5 -0
- package/board/request-mail.mjs +59 -0
- package/board/store.mjs +6 -3
- package/board/tools.mjs +15 -9
- package/board/workflow.mjs +40 -32
- package/board/workspace.mjs +26 -0
- package/board/ztrack.mjs +781 -544
- package/hermes-locks.mjs +1 -20
- package/mcp/hermes-kanban-tools.json +76 -14
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
package/board/ztrack.mjs
CHANGED
|
@@ -1,40 +1,53 @@
|
|
|
1
|
-
// The document is card state
|
|
2
|
-
//
|
|
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,
|
|
11
|
-
import { inspectCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
16
|
+
import { fire, statusOf, storedStatus, workflowFor } from './workflow.mjs';
|
|
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
|
|
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
|
|
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
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,569 +66,752 @@ 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.
|
|
55
|
-
this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
|
|
56
|
-
this.
|
|
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) {
|
|
69
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 (
|
|
150
|
-
else this.lift(
|
|
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
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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.
|
|
206
|
-
this.
|
|
207
|
-
if (
|
|
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;
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
this.
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
this.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
for (const [tid, taskCard] of this.taskCards(id, card))
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
//
|
|
381
|
-
const
|
|
382
|
-
for (const
|
|
383
|
-
|
|
384
|
-
this.
|
|
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
|
-
|
|
388
|
-
if (observe) this.documentEvents();
|
|
462
|
+
return plan;
|
|
389
463
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if (this.
|
|
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) :
|
|
421
|
-
for (const id of
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
for (const
|
|
527
|
-
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
if (
|
|
536
|
-
const
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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 =
|
|
557
|
-
if (!target) return { issue:
|
|
558
|
-
const
|
|
559
|
-
return { issue:
|
|
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
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
for (const
|
|
573
|
-
|
|
574
|
-
this.
|
|
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
|
-
|
|
579
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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.
|
|
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, {}) ?? {};
|
|
600
797
|
if (row.kind !== 'completed' || payload.__supercode_export || !this.db.prepare('SELECT 1 FROM tasks WHERE id=?').get(row.task_id)) continue;
|
|
601
|
-
prepared.evidence.set(row.id, inspectCompletionEvidence(this.board(), row.task_id, entry, payload));
|
|
798
|
+
prepared.evidence.set(row.id, inspectCompletionEvidence(this.board(), row.task_id, entry, payload, completionPhase(this.board(), row.task_id, entry, 'complete', payload, ['hermes'])));
|
|
602
799
|
}
|
|
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.
|
|
812
|
+
const task = this.one('SELECT * FROM main.tasks WHERE id = ?', row.task_id);
|
|
615
813
|
if (!task) continue;
|
|
616
|
-
this.
|
|
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.
|
|
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.
|
|
627
|
-
for (const effect of resumed.effects) this.
|
|
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.
|
|
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.
|
|
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.
|
|
635
|
-
this.
|
|
636
|
-
} catch(error) {
|
|
637
|
-
this.
|
|
638
|
-
event(this.
|
|
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
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
890
|
+
|
|
891
|
+
export(queued) {
|
|
657
892
|
let entry; try { entry = workflowFor(this.root); } catch { return; }
|
|
658
|
-
const
|
|
659
|
-
|
|
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(
|
|
667
|
-
compat.exec(
|
|
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.
|
|
670
|
-
if (
|
|
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(
|
|
679
|
-
for (const id of projected)
|
|
680
|
-
|
|
681
|
-
const
|
|
682
|
-
|
|
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.
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
const
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
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
|
}
|