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