@volter/supercode-orchestrator 0.5.205 → 0.5.207
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/README.md +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +278 -97
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +305 -80
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +92 -27
- package/board/files.mjs +17 -3
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +8 -2
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/notices.mjs +5 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +84 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +96 -31
- package/board/ztrack.mjs +207 -32
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- package/reducer.mjs +74 -5
package/board/ztrack.mjs
CHANGED
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
// triggers name the rows it touched. Only the cards whose text changed are rendered, and each is
|
|
8
8
|
// spliced into the document at its indexed span. A document edited by hand is re-read only in the
|
|
9
9
|
// region the edit touched. So a write costs what it changes, not the size of the board.
|
|
10
|
+
//
|
|
11
|
+
// A listing answered on the owner's read door (owner-door.mjs) opens the board read-only on a connection of its own and
|
|
12
|
+
// reads it in one SQLite read transaction: WAL gives it the rows as one committed state, and no writer waits on it or
|
|
13
|
+
// it on a writer. The documents are taken with that state (readSnapshot), never locked.
|
|
10
14
|
import { current } from './call.mjs';
|
|
11
15
|
import { fenced } from './fence.mjs';
|
|
12
16
|
import { createRequire } from 'node:module';
|
|
13
17
|
import { createHash, randomBytes } from 'node:crypto';
|
|
14
|
-
import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
15
19
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
16
20
|
import { DatabaseSync } from 'node:sqlite';
|
|
17
|
-
import { actorOf, contention, event, json, now, principalOf, storageFault, STORAGE_TRIES } from './store.mjs';
|
|
21
|
+
import { actorOf, CARD_NUMBERS, contention, raiseCardNumbers, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
|
|
18
22
|
import { GitDocument } from './git-document.mjs';
|
|
19
23
|
import { unimported } from './legacy-store.mjs';
|
|
20
24
|
import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
|
|
@@ -68,11 +72,13 @@ const timedStatement = (sql, fn) => {
|
|
|
68
72
|
finally { noteStatement(sql, started, before); }
|
|
69
73
|
};
|
|
70
74
|
import { fire, workflowFor } from './workflow.mjs';
|
|
71
|
-
import { LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
75
|
+
import { byOwner, LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
72
76
|
|
|
73
77
|
const require = createRequire(import.meta.url);
|
|
74
78
|
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
75
79
|
const read = (path) => existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
80
|
+
// What says a document changed: a write in place moves its size or times, a replacement its inode.
|
|
81
|
+
const stampOf = (path) => { try { const s = statSync(path); return `${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`; } catch { return 'missing'; } };
|
|
76
82
|
const lf = (text) => text.replace(/\r\n?/g, '\n');
|
|
77
83
|
const quote = (s) => `"${String(s).replaceAll('"', '""')}"`;
|
|
78
84
|
const nativeId = (id) => /^t-[a-f0-9]+$/.test(id) ? id.replace('t-', 't_') : id;
|
|
@@ -94,7 +100,13 @@ const sqliteContention = (error) => contention(error); // resolved at call time:
|
|
|
94
100
|
const RETRY = new Error('board changed while this operation read it; retry');
|
|
95
101
|
// The current archive's size before it rotates, unless the board declares its own (`archive.max_lines`).
|
|
96
102
|
const ARCHIVE_LINES = 2000;
|
|
103
|
+
// How many read transactions a listing begins before it leaves the board to its owner, each begun again only because a
|
|
104
|
+
// document changed while it was read (readSnapshot).
|
|
105
|
+
const SNAPSHOT_TRIES = 8;
|
|
97
106
|
const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith('archived:');
|
|
107
|
+
// The open document's record of the numbers the board has given (numberMark), and a task line's number (`- [ ] #2836 c3`).
|
|
108
|
+
const NUMBER_MARK = /^The board has given card numbers up to #(\d+)\.[ \t]*$/m;
|
|
109
|
+
const TASK_NUMBER = /^\s*[-*] \[[ xX]\]\s+#(\d+)\s+[a-z]+\d+\s/gm;
|
|
98
110
|
|
|
99
111
|
const BOARD_TABLES = `
|
|
100
112
|
CREATE TABLE IF NOT EXISTS board_state (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
@@ -109,7 +121,7 @@ DROP TABLE IF EXISTS board_export;`;
|
|
|
109
121
|
// written to the document). board_tasks: a subtask's key on its arc's Tasks list
|
|
110
122
|
// (`<arc>:c3`) and its `source:` lines, which live only in the document.
|
|
111
123
|
|
|
112
|
-
export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug, held = false }) {
|
|
124
|
+
export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug, held = false, readOnly = false }) {
|
|
113
125
|
if (!existsSync(marker)) {
|
|
114
126
|
if (!create) throw new Error(`no ztrack board ${marker}`);
|
|
115
127
|
mkdirSync(dirname(marker), { recursive: true });
|
|
@@ -123,18 +135,20 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
|
|
|
123
135
|
atomic(marker, JSON.stringify({ format: 1, document: join(realpathSync(dirname(named)), basename(named)), ...said }) + '\n');
|
|
124
136
|
}
|
|
125
137
|
const config = JSON.parse(read(marker));
|
|
126
|
-
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held });
|
|
138
|
+
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held, readOnly });
|
|
127
139
|
return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
|
|
128
140
|
documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
|
|
129
141
|
}
|
|
130
142
|
|
|
131
143
|
|
|
132
144
|
class ZtrackBoard {
|
|
133
|
-
constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {}, held = false }) {
|
|
145
|
+
constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {}, held = false, readOnly = false }) {
|
|
134
146
|
// held by the board owner (store.mjs holdBoards): opened once, its documents' stamps read again only when the
|
|
135
147
|
// owner's watch says they changed or may have lost events (documentsChanged), and on every read while it keeps no
|
|
136
148
|
// watch on them (unwatched); git's lateness held and refreshed on its own commits
|
|
137
149
|
this.held = held; this.docsChanged = true; this.importDue = true; this.knownStamps = null;
|
|
150
|
+
// a listing's read-only view (readSnapshot): it makes, migrates, recovers, pulls and commits nothing
|
|
151
|
+
this.readOnly = readOnly;
|
|
138
152
|
this.path = resolve(dirname(marker), document);
|
|
139
153
|
this.archive = `${this.path}.archive.md`;
|
|
140
154
|
this.baseDocs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
|
|
@@ -147,7 +161,7 @@ class ZtrackBoard {
|
|
|
147
161
|
// `"manager"`: the board's manager (its principal or session address), whose writes it commits itself as part of its
|
|
148
162
|
// own work; the backing commits only the writes the manager does not make.
|
|
149
163
|
this.manager = manager;
|
|
150
|
-
this.git = commit ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
|
|
164
|
+
this.git = commit && !readOnly ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
|
|
151
165
|
// what a fetch brought is written in the board's next transaction: one soon, on this loop, if none comes
|
|
152
166
|
if (this.git) this.git.onTaken = () => setImmediate(() => { try { if (!this.closed) this.tx(() => {}); } catch (error) { current().err(`board: ${error.message}\n`); } });
|
|
153
167
|
// A publish that is behind is said once per call, on whichever workflow verb opened the board: a refused push
|
|
@@ -158,18 +172,25 @@ class ZtrackBoard {
|
|
|
158
172
|
else if (this.git) this.sayLate(timedStep('board open git behind', () => this.git.behind()));
|
|
159
173
|
this.seed = seed; this.root = root; this.slug = slug; this.init = init;
|
|
160
174
|
this.file = `${marker}.board.db`;
|
|
161
|
-
|
|
175
|
+
// the store is the owner's to make, import and lay out: a listing that finds none leaves the board to it
|
|
176
|
+
if (readOnly && !(existsSync(this.file) && statSync(this.file).size > 0)) throw byOwner(`${this.file} is not laid out yet`);
|
|
177
|
+
if (!readOnly) mkdirSync(dirname(this.path), { recursive: true });
|
|
162
178
|
const legacy = `${marker}.runtime.db`;
|
|
163
179
|
// 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
|
|
164
180
|
// `sqlite3 <store> "select …"` made the empty file the next release then opened instead of importing).
|
|
165
|
-
if (existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
|
|
181
|
+
if (!readOnly && existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
|
|
166
182
|
if (!existsSync(this.file)) this.create(legacy);
|
|
167
|
-
this.conn = timedStep('board open connection', () =>
|
|
168
|
-
|
|
183
|
+
this.conn = timedStep('board open connection', () => {
|
|
184
|
+
if (!readOnly) return new DatabaseSync(this.file);
|
|
185
|
+
try { return new DatabaseSync(this.file, { readOnly: true }); } catch (error) { throw unreadable(error) ? byOwner(error.message) : error; }
|
|
186
|
+
});
|
|
187
|
+
if (!readOnly) this.refuseUnimported(legacy);
|
|
169
188
|
this.errors = new Map(); this.statements = new Map(); this.hashes = new Map();
|
|
170
189
|
this.active = false; this.overlay = false; this.seen = null; this.refused = new Set();
|
|
171
|
-
|
|
172
|
-
|
|
190
|
+
if (!readOnly) {
|
|
191
|
+
timedStep('board open schema', () => this.prepareConnection());
|
|
192
|
+
timedStep('board open errors', () => this.loadErrors());
|
|
193
|
+
}
|
|
173
194
|
const self = this;
|
|
174
195
|
this.db = {
|
|
175
196
|
[LIST_REVISION]: () => self.read(() => listRevision(self.conn)),
|
|
@@ -185,10 +206,49 @@ class ZtrackBoard {
|
|
|
185
206
|
exec(sql) { const run = () => timedStatement(sql, () => self.conn.exec(sql)); return self.active ? self.noting(run) : self.tx(run); },
|
|
186
207
|
close() { self.close(); },
|
|
187
208
|
};
|
|
209
|
+
if (readOnly) {
|
|
210
|
+
try { timedStep('board read: snapshot', () => this.readSnapshot()); }
|
|
211
|
+
catch (error) { this.conn.close(); throw error; }
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
188
214
|
// A board's first open in this process recovers what its store accepted and derives what git owes for it (tx), so
|
|
189
215
|
// a board that is only read, or whose owner runs no round, still finishes an earlier process's commit.
|
|
190
216
|
if (seed || this.git?.underived) this.tx(() => {});
|
|
191
217
|
}
|
|
218
|
+
|
|
219
|
+
// A listing's view, taken once at its open and kept until its close: the store's rows as of one read transaction, and
|
|
220
|
+
// every document as it stood when that transaction began. A document is written only after the transaction that
|
|
221
|
+
// published it committed, so a document that stood unchanged from before the transaction began until after it was
|
|
222
|
+
// read is one the transaction already describes: its published text, the text a pending publication replaces (the
|
|
223
|
+
// rows stay authoritative), or a hand edit, which is shown through connection-local copies of the card tables as
|
|
224
|
+
// every reader shows one. A document that changed in between is read again with a new transaction. Nothing here waits:
|
|
225
|
+
// WAL serves the read transaction beside any writer, and no busy timeout is set. A store not in WAL or not laid out,
|
|
226
|
+
// one a read-only connection cannot begin reading (store.mjs unreadable) or documents that keep changing leave the
|
|
227
|
+
// listing to the owner.
|
|
228
|
+
readSnapshot() {
|
|
229
|
+
const contended = (fn) => { try { return fn(); } catch (error) { throw unreadable(error) ? byOwner(error.message) : error; } };
|
|
230
|
+
if (String(contended(() => this.conn.prepare('PRAGMA journal_mode').get())?.journal_mode).toLowerCase() !== 'wal') throw byOwner(`${this.file} is not in WAL mode`);
|
|
231
|
+
for (let attempt = 0; attempt < SNAPSHOT_TRIES; attempt++) {
|
|
232
|
+
const stamps = () => this.docs.map((doc) => `${doc.path}\0${stampOf(doc.path)}`).join('\n');
|
|
233
|
+
const before = contended(stamps);
|
|
234
|
+
this.conn.exec('BEGIN');
|
|
235
|
+
let stale;
|
|
236
|
+
try {
|
|
237
|
+
// the transaction's first read begins its snapshot
|
|
238
|
+
if (!contended(() => this.one("SELECT 1 FROM main.sqlite_master WHERE type = 'index' AND name = ?", TASK_SUMMARY_INDEX))) throw byOwner(`${this.file} is not laid out yet`);
|
|
239
|
+
this.loadErrors();
|
|
240
|
+
stale = this.staleDocuments({ pending: true });
|
|
241
|
+
if (stamps() !== before) { this.conn.exec('ROLLBACK'); this.hashes.clear(); continue; }
|
|
242
|
+
} catch (error) { try { this.conn.exec('ROLLBACK'); } catch { /* not begun */ } throw error; }
|
|
243
|
+
if (!stale.length) return;
|
|
244
|
+
this.tables = this.shapes();
|
|
245
|
+
let plan;
|
|
246
|
+
try { plan = this.observe(stale); } catch { return; } // the next write reports it
|
|
247
|
+
this.applyPlan(plan, { overlay: true });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
throw byOwner('its documents changed during every read');
|
|
251
|
+
}
|
|
192
252
|
/** The publish's lateness, said once per call (a held board's from its held reading, never by asking git). */
|
|
193
253
|
sayLate(late = this.git?.lateness ?? null) {
|
|
194
254
|
const said = current().said;
|
|
@@ -246,14 +306,9 @@ class ZtrackBoard {
|
|
|
246
306
|
// What a transaction touched, for the renderer (cards whose text may change: anything beyond run fields and scheduling clocks). Plain inserts: a trigger's
|
|
247
307
|
// own conflict clause yields to the outer statement's, so duplicates are removed when read.
|
|
248
308
|
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)');
|
|
249
|
-
this.tables =
|
|
250
|
-
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);
|
|
309
|
+
this.tables = this.shapes();
|
|
251
310
|
const trigger = (name, when, body) => this.conn.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(name)} ${when} BEGIN ${body} END`);
|
|
252
|
-
for (const name of
|
|
253
|
-
const columns = this.conn.prepare(`PRAGMA main.table_info(${quote(name)})`).all();
|
|
254
|
-
const keys = columns.filter((c) => c.pk).sort((a, b) => a.pk - b.pk).map((c) => c.name);
|
|
255
|
-
if (!keys.length) keys.push('rowid');
|
|
256
|
-
this.tables.set(name, { name, keys, columns: columns.map((c) => c.name) });
|
|
311
|
+
for (const { name, keys } of this.tables.values()) {
|
|
257
312
|
const key = (row) => `json_array(${keys.map((k) => `${row}.${quote(k)}`).join(',')})`;
|
|
258
313
|
const mark = (row) => `INSERT INTO board_dirty VALUES ('${name}', ${key(row)});`;
|
|
259
314
|
trigger(`board_i_${name}`, `AFTER INSERT ON main.${quote(name)}`, mark('NEW'));
|
|
@@ -268,6 +323,18 @@ class ZtrackBoard {
|
|
|
268
323
|
}
|
|
269
324
|
}
|
|
270
325
|
|
|
326
|
+
// Each runtime table's columns and key, as the store has them.
|
|
327
|
+
shapes() {
|
|
328
|
+
const tables = new Map();
|
|
329
|
+
for (const { name } of this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all()) {
|
|
330
|
+
const columns = this.conn.prepare(`PRAGMA main.table_info(${quote(name)})`).all();
|
|
331
|
+
const keys = columns.filter((c) => c.pk).sort((a, b) => a.pk - b.pk).map((c) => c.name);
|
|
332
|
+
if (!keys.length) keys.push('rowid');
|
|
333
|
+
tables.set(name, { name, keys, columns: columns.map((c) => c.name) });
|
|
334
|
+
}
|
|
335
|
+
return tables;
|
|
336
|
+
}
|
|
337
|
+
|
|
271
338
|
// A store holding fewer runs than the earlier store beside it was made without the one-time import (on 2026-10-01
|
|
272
339
|
// one such store, created by an opener nobody named, sent the dispatcher on to start a second session on every
|
|
273
340
|
// running card). The board refuses it, so nothing runs on a board without its history.
|
|
@@ -376,6 +443,13 @@ class ZtrackBoard {
|
|
|
376
443
|
put(`chunks:${role}`, entries);
|
|
377
444
|
}
|
|
378
445
|
put('published', published);
|
|
446
|
+
// The earlier store held no numbers: its cards take the numbers their documents' headings give them, and the rest
|
|
447
|
+
// are numbered in the order they were made, after every number the documents name (documentNumbers).
|
|
448
|
+
db.exec(`DELETE FROM ${CARD_NUMBERS}; DELETE FROM sqlite_sequence WHERE name = '${CARD_NUMBERS}'`);
|
|
449
|
+
const named = this.documentNumbers();
|
|
450
|
+
this.numberFromHeadings([...named.headings].filter(([id]) => db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(id)), db);
|
|
451
|
+
raiseCardNumbers(db, named.highest);
|
|
452
|
+
db.exec(`INSERT INTO ${CARD_NUMBERS} (task_id) SELECT id FROM tasks WHERE id NOT IN (SELECT task_id FROM ${CARD_NUMBERS}) ORDER BY created_at, id`);
|
|
379
453
|
db.exec('COMMIT');
|
|
380
454
|
}
|
|
381
455
|
|
|
@@ -388,6 +462,8 @@ class ZtrackBoard {
|
|
|
388
462
|
}
|
|
389
463
|
|
|
390
464
|
read(fn) {
|
|
465
|
+
// a listing's view is its snapshot's (readSnapshot): its statements read that, never the documents again
|
|
466
|
+
if (this.readOnly) return fn();
|
|
391
467
|
if (this.active) return this.noting(fn);
|
|
392
468
|
this.observeForRead();
|
|
393
469
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -397,8 +473,7 @@ class ZtrackBoard {
|
|
|
397
473
|
}
|
|
398
474
|
|
|
399
475
|
fileHash(path) {
|
|
400
|
-
|
|
401
|
-
try { const s = statSync(path); stamp = `${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`; } catch { stamp = 'missing'; }
|
|
476
|
+
const stamp = stampOf(path);
|
|
402
477
|
const known = this.hashes.get(path);
|
|
403
478
|
if (known?.stamp === stamp) return known;
|
|
404
479
|
const raw = read(path);
|
|
@@ -462,6 +537,7 @@ class ZtrackBoard {
|
|
|
462
537
|
// ------------------------------------------------------------------ writes
|
|
463
538
|
|
|
464
539
|
tx(fn) {
|
|
540
|
+
if (this.readOnly) throw new Error('a listing reads the board and writes nothing');
|
|
465
541
|
// A write inside a write is part of it: the pull below happened once, for the outermost one.
|
|
466
542
|
if (this.active) return fn(this.db);
|
|
467
543
|
// A pull request merged on the remote (a page edited in Git Notes) is in the document before this write reads it.
|
|
@@ -490,6 +566,13 @@ class ZtrackBoard {
|
|
|
490
566
|
timedStep('board tx snapshot', () => this.snapshotHeld());
|
|
491
567
|
const result = timedStep('board tx body', () => fn(this.db));
|
|
492
568
|
if (this.contended) throw RETRY;
|
|
569
|
+
// The first write made with a codec that renders numbers (numbersRendered) after the store numbered its cards
|
|
570
|
+
// (store.mjs layOutNumbers) writes every card of the open document again, with its number in its heading and each
|
|
571
|
+
// open subtask's on its task line; a card made since is written with its number when it is made.
|
|
572
|
+
if (this.numbersRendered && !this.stateText('numbers_written')) {
|
|
573
|
+
this.statement('INSERT INTO temp.board_touched SELECT value FROM json_each(?)').run(JSON.stringify(this.entries('open').filter((e) => e.id).map((e) => nativeId(e.id))));
|
|
574
|
+
this.put('numbers_written', true);
|
|
575
|
+
}
|
|
493
576
|
const files = timedStep('board tx render', () => this.render());
|
|
494
577
|
const intent = files.length ? randomBytes(16).toString('hex') : null;
|
|
495
578
|
if (intent) {
|
|
@@ -592,7 +675,7 @@ class ZtrackBoard {
|
|
|
592
675
|
// connection-local copies by a read. Only the spans the edit touched are parsed.
|
|
593
676
|
// `held` names cards read again from their indexed span with no edit (see render).
|
|
594
677
|
observe(stale, held = []) {
|
|
595
|
-
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(), before: new Map() };
|
|
678
|
+
const plan = { layout: new Map(), dropped: [], published: {}, cards: new Map(), numbers: new Map(), rows: new Map(), subtasks: new Map(), tasks: new Map(), remove: new Set(), errors: new Map(), checked: new Set(), before: new Map() };
|
|
596
679
|
const changed = new Set();
|
|
597
680
|
for (const doc of this.docs) {
|
|
598
681
|
const entries = this.entries(doc.role);
|
|
@@ -633,11 +716,13 @@ class ZtrackBoard {
|
|
|
633
716
|
catch (error) { plan.errors.set(id, error.message); continue; }
|
|
634
717
|
entry.rec = hash(JSON.stringify(records[0]));
|
|
635
718
|
plan.cards.set(id, card);
|
|
719
|
+
if (records[0].number) plan.numbers.set(id, records[0].number);
|
|
636
720
|
}
|
|
637
721
|
for (const [id, card] of plan.cards) {
|
|
638
722
|
plan.rows.set(id, this.cardRows(id, card, null));
|
|
639
723
|
const subtasks = [];
|
|
640
|
-
for (const [tid, taskCard, key, sources] of this.taskCards(id, card)) {
|
|
724
|
+
for (const [tid, taskCard, key, sources, number] of this.taskCards(id, card)) {
|
|
725
|
+
if (number) plan.numbers.set(tid, number);
|
|
641
726
|
plan.rows.set(tid, this.cardRows(tid, taskCard, id));
|
|
642
727
|
plan.tasks.set(tid, { key, sources });
|
|
643
728
|
subtasks.push(tid);
|
|
@@ -786,6 +871,13 @@ class ZtrackBoard {
|
|
|
786
871
|
this.statement(`DELETE FROM ${schema}.task_links WHERE child_id = ?`).run(id);
|
|
787
872
|
if (!overlay) { this.statement('DELETE FROM main.board_tasks WHERE id = ?').run(id); if (!initial) events.push({ id, kind: 'removed', payload: { by, source: 'document' } }); }
|
|
788
873
|
}
|
|
874
|
+
// A store made again from its documents (an empty one reading them whole) gives each card and subtask the number its
|
|
875
|
+
// heading or task line says, and hands out none at or below any number the documents name. Any other import keeps
|
|
876
|
+
// the store's numbers: a number typed into a heading or pasted with a section changes nothing.
|
|
877
|
+
if (initial) {
|
|
878
|
+
this.numberFromHeadings([...plan.numbers].filter(([id]) => plan.rows.has(id)));
|
|
879
|
+
raiseCardNumbers(this.conn, this.documentNumbers().highest);
|
|
880
|
+
}
|
|
789
881
|
for (const [id, rows] of plan.rows) {
|
|
790
882
|
const before = fingerprintOf(id);
|
|
791
883
|
const oldTask = this.one(`SELECT * FROM ${schema}.tasks WHERE id = ?`, id) ?? {};
|
|
@@ -861,7 +953,7 @@ class ZtrackBoard {
|
|
|
861
953
|
return { task, state };
|
|
862
954
|
}
|
|
863
955
|
|
|
864
|
-
// A card's Tasks as subtask cards: [id, card, key, sources].
|
|
956
|
+
// A card's Tasks as subtask cards: [id, card, key, sources, the number its line gives].
|
|
865
957
|
taskCards(id, card) {
|
|
866
958
|
return card.acceptanceCriteria.map((task) => {
|
|
867
959
|
const key = `${id}:${task.id}`;
|
|
@@ -879,7 +971,7 @@ class ZtrackBoard {
|
|
|
879
971
|
machine: task.metadata?.machine ?? storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
|
|
880
972
|
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) })) };
|
|
881
973
|
if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
|
|
882
|
-
return [tid, taskCard, key, task.sources];
|
|
974
|
+
return [tid, taskCard, key, task.sources, task.number];
|
|
883
975
|
});
|
|
884
976
|
}
|
|
885
977
|
|
|
@@ -899,7 +991,8 @@ class ZtrackBoard {
|
|
|
899
991
|
return parents;
|
|
900
992
|
};
|
|
901
993
|
let parents = cardsTouched();
|
|
902
|
-
|
|
994
|
+
const mark = this.numbersRendered ? this.numberMark() : null;
|
|
995
|
+
if (!parents.size && !mark) return [];
|
|
903
996
|
const heldBy = (id) => 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);
|
|
904
997
|
// A card held by a recorded refusal is not rendered, so the store's verbs move its tasks past its lines. When
|
|
905
998
|
// the store moves it, its span is read again: what the store moved past is stale and takes the store's value
|
|
@@ -912,6 +1005,7 @@ class ZtrackBoard {
|
|
|
912
1005
|
const where = new Map();
|
|
913
1006
|
for (const role of roles) layout[role].forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
|
|
914
1007
|
const edits = Object.fromEntries(roles.map((role) => [role, []]));
|
|
1008
|
+
if (mark) edits.open.push(mark);
|
|
915
1009
|
for (const id of parents) {
|
|
916
1010
|
if (heldBy(id)) continue;
|
|
917
1011
|
const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id);
|
|
@@ -975,7 +1069,8 @@ class ZtrackBoard {
|
|
|
975
1069
|
dropped.push(old.cid);
|
|
976
1070
|
if (edit.remove) entries.splice(edit.index, 1); else entries[edit.index] = edit.entry;
|
|
977
1071
|
}
|
|
978
|
-
for (const edit of edits[role].filter((e) => e.
|
|
1072
|
+
for (const edit of edits[role].filter((e) => e.prepend)) { text = edit.entry.text + text; entries.unshift(edit.entry); }
|
|
1073
|
+
for (const edit of edits[role].filter((e) => e.index === undefined && !e.prepend)) {
|
|
979
1074
|
if (text && !text.endsWith('\n\n')) {
|
|
980
1075
|
// the blank line before a new section belongs to the span above it
|
|
981
1076
|
const last = entries.at(-1), separator = text.endsWith('\n') ? '\n' : '\n\n';
|
|
@@ -1063,7 +1158,7 @@ class ZtrackBoard {
|
|
|
1063
1158
|
const messages = blocks.filter((block) => block.kind === 'message');
|
|
1064
1159
|
// A subtask is its line and its brief (its body, as Markdown under it); its id, status words, assignee and run
|
|
1065
1160
|
// output stay in the store (company RFC 0025 decision 4).
|
|
1066
|
-
card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, status: ENDED.has(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
|
|
1161
|
+
card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, ...this.numberOf(r.id), status: ENDED.has(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
|
|
1067
1162
|
...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
|
|
1068
1163
|
waitingOn: messages[0]?.id,
|
|
1069
1164
|
blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
|
|
@@ -1074,7 +1169,82 @@ class ZtrackBoard {
|
|
|
1074
1169
|
}) });
|
|
1075
1170
|
}
|
|
1076
1171
|
const serialized = this.preset.serializeKanbanCard(card);
|
|
1077
|
-
return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1172
|
+
return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// A card's number, shown first in its heading (`## #42 t_… — …`): the store's (store.mjs layOutNumbers). An edit to a
|
|
1176
|
+
// heading's number changes nothing; a store made again from its documents takes it back (numberFromHeadings). A card's
|
|
1177
|
+
// record keeps the key where the document codec reads it, after the id.
|
|
1178
|
+
numberOf(id) {
|
|
1179
|
+
if (!this.numbersRendered) return {};
|
|
1180
|
+
const number = this.one(`SELECT number FROM main.${CARD_NUMBERS} WHERE task_id = ?`, id)?.number;
|
|
1181
|
+
return number ? { number } : {};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Whether the document codec (@volter/ztrack) writes a card's number in its heading and a subtask's on its task
|
|
1185
|
+
// line. One that does not renders, reads and indexes cards as it always did; the open document is written with the
|
|
1186
|
+
// numbers by the first write made with one that does (tx), whichever was installed first.
|
|
1187
|
+
get numbersRendered() {
|
|
1188
|
+
if (this._numbersRendered === undefined) {
|
|
1189
|
+
try {
|
|
1190
|
+
const task = this.preset.serializeKanbanCard({ id: 't-00000000', title: 'number', summary: '', status: 'todo', body: '', acceptanceCriteria: [{ id: 'c1', number: 2, status: 'pending', evidence: [], text: 'number' }] }).body;
|
|
1191
|
+
this._numbersRendered = this.codec.renderBoardCard({ id: 't_00000000', number: 1, title: 'number', status: 'todo', body: '' }, 2).startsWith('## #1 t_00000000 ') && task.includes('- [ ] #2 c1 number');
|
|
1192
|
+
} catch { this._numbersRendered = false; }
|
|
1193
|
+
}
|
|
1194
|
+
return this._numbersRendered;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Give each card the number its document gives it (its heading, or its task line), where no other card holds that
|
|
1198
|
+
// number and the card has none yet; the numbers' high-water mark rises with them, so none is handed out again. Only
|
|
1199
|
+
// for a store made again from its documents (applyPlan, importRuntime).
|
|
1200
|
+
numberFromHeadings(numbers, db = this.conn) {
|
|
1201
|
+
if (!numbers.length) return;
|
|
1202
|
+
const give = db.prepare(`INSERT INTO ${CARD_NUMBERS} (number, task_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM ${CARD_NUMBERS} WHERE task_id = ? OR number = ?)`);
|
|
1203
|
+
for (const [id, number] of numbers) give.run(number, id, id, number);
|
|
1204
|
+
raiseCardNumbers(db);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// What the board's documents (the open one, the current archive and each rotated one beside it) say of numbers:
|
|
1208
|
+
// `headings`, card id → the number its heading gives, and `highest`, the highest number any of them names (a
|
|
1209
|
+
// heading, a task line, or the open document's record of the numbers given, numberMark).
|
|
1210
|
+
documentNumbers() {
|
|
1211
|
+
const headings = new Map();
|
|
1212
|
+
let highest = 0;
|
|
1213
|
+
const dir = dirname(this.path), named = basename(this.path);
|
|
1214
|
+
let rotated = [];
|
|
1215
|
+
try { rotated = readdirSync(dir).filter((name) => name.startsWith(`${named}.archive-`) && name.endsWith('.md')).map((name) => join(dir, name)); } catch { /* none */ }
|
|
1216
|
+
for (const path of [this.path, this.archive, ...rotated]) {
|
|
1217
|
+
const text = lf(read(path));
|
|
1218
|
+
for (const line of text.matchAll(TASK_NUMBER)) highest = Math.max(highest, Number(line[1]));
|
|
1219
|
+
if (path === this.path) highest = Math.max(highest, Number(NUMBER_MARK.exec(text)?.[1] ?? 0));
|
|
1220
|
+
let records = [];
|
|
1221
|
+
try { ({ records } = this.codec.inspectBoardDocument(text, path)); } catch { continue; }
|
|
1222
|
+
for (const record of records) {
|
|
1223
|
+
if (!record.number) continue;
|
|
1224
|
+
highest = Math.max(highest, record.number);
|
|
1225
|
+
if (!headings.has(nativeId(record.id))) headings.set(nativeId(record.id), record.number);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return { headings, highest };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// The open document records the highest number the store has given (a line of its preamble, NUMBER_MARK), so a store
|
|
1232
|
+
// made again from the documents hands out none at or below it, whatever card held it: a subtask filed and gone, or
|
|
1233
|
+
// a card the documents no longer show. The edit that brings the line up to the store's count, or null.
|
|
1234
|
+
numberMark() {
|
|
1235
|
+
const given = Number(this.one('SELECT seq FROM main.sqlite_sequence WHERE name = ?', CARD_NUMBERS)?.seq ?? 0);
|
|
1236
|
+
if (!given) return null;
|
|
1237
|
+
const line = `The board has given card numbers up to #${given}.`;
|
|
1238
|
+
const first = this.entries('open')[0];
|
|
1239
|
+
if (first && first.id === null) {
|
|
1240
|
+
const text = this.texts([first])[0].text;
|
|
1241
|
+
const said = NUMBER_MARK.exec(text);
|
|
1242
|
+
if (said && Number(said[1]) >= given) return null;
|
|
1243
|
+
const next = said ? text.replace(NUMBER_MARK, line) : `${text.replace(/\n*$/, '')}\n\n${line}\n\n`;
|
|
1244
|
+
return { index: 0, entry: { cid: null, id: null, level: 0, len: next.length, rec: null, text: next } };
|
|
1245
|
+
}
|
|
1246
|
+
const text = `${line}\n\n`;
|
|
1247
|
+
return { prepend: true, entry: { cid: null, id: null, level: 0, len: text.length, rec: null, text } };
|
|
1078
1248
|
}
|
|
1079
1249
|
|
|
1080
1250
|
// An ended subtask's own record in the archive document: its title and brief, and the card it belongs to.
|
|
@@ -1084,7 +1254,7 @@ class ZtrackBoard {
|
|
|
1084
1254
|
const metadata = { subtask_of: fileId(parentId), ...Object.fromEntries(Object.entries(task).filter(([k, v]) => declared(k, v))) };
|
|
1085
1255
|
const card = { ...parsed, metadata, machine: state.machine || undefined, relations: [] };
|
|
1086
1256
|
const serialized = this.preset.serializeKanbanCard(card);
|
|
1087
|
-
return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1257
|
+
return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1088
1258
|
}
|
|
1089
1259
|
|
|
1090
1260
|
// ------------------------------------------------------------------ migration from a SQLite board
|
|
@@ -1110,10 +1280,15 @@ class ZtrackBoard {
|
|
|
1110
1280
|
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));
|
|
1111
1281
|
};
|
|
1112
1282
|
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)}`);
|
|
1113
|
-
|
|
1283
|
+
// a card's number is copied before its row, which keeps it; the numbers' high-water mark comes with them, so no
|
|
1284
|
+
// number the old board handed out is handed out again
|
|
1285
|
+
const sourceTables = source.db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()
|
|
1286
|
+
.sort((a, b) => Number(b.name === CARD_NUMBERS) - Number(a.name === CARD_NUMBERS));
|
|
1287
|
+
for (const table of sourceTables) {
|
|
1114
1288
|
if (!this.tables.has(table.name)) continue;
|
|
1115
1289
|
for (const row of source.db.prepare(`SELECT * FROM ${quote(table.name)}`).all()) insert(table.name, row);
|
|
1116
1290
|
}
|
|
1291
|
+
raiseCardNumbers(this.conn, Number(source.db.prepare('SELECT seq FROM sqlite_sequence WHERE name = ?').get(CARD_NUMBERS)?.seq ?? 0));
|
|
1117
1292
|
for (const task of this.conn.prepare('SELECT * FROM main.tasks').all()) {
|
|
1118
1293
|
const old = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', task.id) ?? {};
|
|
1119
1294
|
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}`); }
|
package/board-input.d.ts
ADDED
package/board-input.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A remote card write reads its text on the caller's machine, before the board door is called.
|
|
2
|
+
// The board's own argument parser decides which file is used; its last value wins.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { parse } from './board/args.mjs';
|
|
5
|
+
|
|
6
|
+
/** Carry a comment/create/specify text input as the owner's existing `stdin` field. */
|
|
7
|
+
export function prepareBoardText(argv) {
|
|
8
|
+
const args = parse(argv);
|
|
9
|
+
const verb = args._[0];
|
|
10
|
+
const key = verb === 'comment' ? 'file' : ['create', 'specify'].includes(verb) ? 'body-file' : null;
|
|
11
|
+
const file = key === null || args.help ? undefined : args[key];
|
|
12
|
+
if (file === undefined) return { argv, stdin: null };
|
|
13
|
+
// Appending the selected flag preserves every other argument and overrides earlier occurrences. The owner
|
|
14
|
+
// reads the carried text at `-`, never an unrelated file at the caller's original path on another machine.
|
|
15
|
+
return { argv: [...argv, `--${key}`, '-'], stdin: readFileSync(file === '-' ? 0 : file, 'utf8') };
|
|
16
|
+
}
|