@volter/supercode-orchestrator 0.5.228 → 0.5.229
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 +18 -2
- package/activation.mjs +1 -1
- package/adapters/agent-mailbox.mjs +10 -0
- package/adapters/api-server.mjs +6 -0
- package/agents.mjs +149 -40
- package/apply/doors.mjs +3 -1
- package/bin/agent-channel.mjs +7 -2
- package/bin/connection-runner.mjs +60 -19
- package/bin/hermes-worker.mjs +2 -1
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +3 -2
- package/board/agent-current.mjs +82 -0
- package/board/agent-declarations.mjs +10 -3
- package/board/args.mjs +137 -6
- package/board/call.mjs +16 -1
- package/board/caller-session.mjs +3 -1
- package/board/capacity.mjs +59 -45
- package/board/card-state.mjs +14 -5
- package/board/ci.mjs +521 -178
- package/board/cli.mjs +614 -209
- package/board/client.mjs +64 -21
- package/board/close-current.mjs +36 -0
- package/board/current-records.mjs +44 -0
- package/board/custody.mjs +2 -1
- package/board/dispatch.mjs +1973 -846
- package/board/doors.mjs +2 -2
- package/board/engine.mjs +623 -85
- package/board/event-stream.mjs +10 -5
- package/board/fence.mjs +2 -0
- package/board/files.mjs +93 -62
- package/board/generation-owner.mjs +188 -53
- package/board/git-document.mjs +3 -1
- package/board/github-rest.mjs +29 -7
- package/board/incidents.mjs +5 -17
- package/board/listing.mjs +14 -8
- package/board/local-admission.mjs +59 -0
- package/board/lock-holder.mjs +18 -6
- package/board/machine-fleet.mjs +66 -13
- package/board/owner-door-worker.mjs +3 -3
- package/board/owner-door.mjs +17 -7
- package/board/owner-health.mjs +12 -15
- package/board/owner-interface.mjs +6 -0
- package/board/publication-reader.mjs +50 -0
- package/board/published.mjs +40 -12
- package/board/queries.mjs +24 -4
- package/board/record-view.mjs +30 -3
- package/board/request-mail.mjs +37 -6
- package/board/resting-admission.mjs +318 -0
- package/board/resting-codec-qualifications.json +33 -0
- package/board/resting-protocol.mjs +71 -0
- package/board/resting-state-protocol.json +120 -0
- package/board/resting-state.mjs +113 -0
- package/board/runtime-door.mjs +2 -2
- package/board/session-continuity.mjs +127 -0
- package/board/session-work.mjs +25 -13
- package/board/standalone-declaration.mjs +24 -0
- package/board/storage-contract.mjs +129 -7
- package/board/store.mjs +73 -11
- package/board/tools.mjs +56 -13
- package/board/wake.mjs +18 -5
- package/board/workflow.mjs +284 -82
- package/board/workspace.mjs +86 -9
- package/board/ztrack.mjs +344 -91
- package/connection.mjs +72 -17
- package/gates.mjs +2 -1
- package/hand-off.mjs +4 -3
- package/hermes-cli.mjs +5 -3
- package/hermes-locks.mjs +5 -2
- package/hosted.mjs +2 -1
- package/index.d.ts +2 -0
- package/index.mjs +1 -1
- package/kanban-worker.mjs +63 -13
- package/loop.mjs +33 -0
- package/mcp/hermes-kanban-tools.json +109 -50
- package/mcp/server.mjs +7 -1
- package/ontology.d.ts +26 -0
- package/orchestration.mjs +15 -4
- package/package.json +19 -12
- package/session-recovery.mjs +2 -1
- package/worker-route.mjs +37 -19
package/board/ztrack.mjs
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { registerWorkOperations, measureWork } from "@volter/supercode-harness-sdk/work-ledger";
|
|
2
|
+
registerWorkOperations([{"kind":"pass","operation":"board.transaction"}]);
|
|
3
|
+
import { assertBoardRestingDocument } from './resting-admission.mjs';
|
|
1
4
|
import { fillReadableIds, validateReadableId } from './readable-id.mjs';
|
|
5
|
+
import { assertBacklogQualified, assertPauseRecord, restingReaderBound, restingSerializationOptions } from './resting-state.mjs';
|
|
2
6
|
// The document is card state. One SQLite file beside it keeps the runtime tables the engine speaks
|
|
3
7
|
// and an index of the document's card spans. ADR 0001 and 0003.
|
|
4
8
|
// All markdown interpretation belongs to ztrack's public codec and kanban preset.
|
|
@@ -73,7 +77,7 @@ const timedStatement = (sql, fn) => {
|
|
|
73
77
|
finally { noteStatement(sql, started, before); }
|
|
74
78
|
};
|
|
75
79
|
import { fire, workflowFor } from './workflow.mjs';
|
|
76
|
-
import { byOwner, LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
80
|
+
import { byOwner, LIST_CONNECTION, LIST_REVISION, READ_BOUNDARY, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
77
81
|
|
|
78
82
|
const require = createRequire(import.meta.url);
|
|
79
83
|
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
@@ -144,7 +148,7 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
|
|
|
144
148
|
const config = JSON.parse(read(marker));
|
|
145
149
|
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held, readOnly });
|
|
146
150
|
return { db: backing.db, snapshot: (path) => backing.snapshot(path), vacuum: (sql) => backing.vacuum(sql), tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
|
|
147
|
-
documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
|
|
151
|
+
documentsChanged: () => backing.documentsChanged(), ownDocument: (path) => backing.ownDocument(path), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
|
|
@@ -155,6 +159,7 @@ class ZtrackBoard {
|
|
|
155
159
|
// watch on them (unwatched); git's lateness held and refreshed on its own commits
|
|
156
160
|
this.held = held; this.docsChanged = true; this.importDue = true; this.knownStamps = null;
|
|
157
161
|
// a listing's read-only view (readSnapshot): it makes, migrates, recovers, pulls and commits nothing
|
|
162
|
+
if (readOnly && !restingReaderBound()) throw byOwner('a document listing needs its owner’s source-bound resting-state reader');
|
|
158
163
|
this.readOnly = readOnly;
|
|
159
164
|
this.path = resolve(dirname(marker), document);
|
|
160
165
|
this.archive = `${this.path}.archive.md`;
|
|
@@ -200,6 +205,8 @@ class ZtrackBoard {
|
|
|
200
205
|
}
|
|
201
206
|
const self = this;
|
|
202
207
|
this.db = {
|
|
208
|
+
get [LIST_CONNECTION]() { return self.conn; },
|
|
209
|
+
[READ_BOUNDARY]: fn => self.read(fn),
|
|
203
210
|
[LIST_REVISION]: () => self.read(() => listRevision(self.conn)),
|
|
204
211
|
boardErrors: self.errors,
|
|
205
212
|
prepare(sql) {
|
|
@@ -270,6 +277,14 @@ class ZtrackBoard {
|
|
|
270
277
|
}
|
|
271
278
|
/** The owner's watch saw this board's documents change: their stamps are read on the next read, and only then. */
|
|
272
279
|
documentsChanged() { this.docsChanged = true; this.importDue = true; }
|
|
280
|
+
/** Whether document `path` holds the text this board published itself, and no hand edit was taken in since the
|
|
281
|
+
* owner's watch last asked: a change the owner's own write made (store.mjs ownBoardWrite). */
|
|
282
|
+
ownDocument(path) {
|
|
283
|
+
if (this.externalTaken) { this.externalTaken = false; return false; }
|
|
284
|
+
const doc = this.docs.find((d) => resolve(d.path) === resolve(path));
|
|
285
|
+
if (!doc) return false;
|
|
286
|
+
try { return this.fileHash(doc.path).hash === this.get('published', {})[doc.role]; } catch { return false; }
|
|
287
|
+
}
|
|
273
288
|
// The board's documents: the open one, the current archive, and each rotated archive (recorded in the board's own
|
|
274
289
|
// state, so a rotation committed but not yet written is still a document of the board).
|
|
275
290
|
get docs() {
|
|
@@ -278,8 +293,26 @@ class ZtrackBoard {
|
|
|
278
293
|
// recorded by file name, beside the document, so a board moved with its documents still finds them
|
|
279
294
|
return [...this.baseDocs, ...rotated.map(({ role, name }) => ({ role, path: resolve(dirname(this.path), name) }))];
|
|
280
295
|
}
|
|
281
|
-
get codec() {
|
|
282
|
-
|
|
296
|
+
get codec() {
|
|
297
|
+
if (this._codec) return this._codec;
|
|
298
|
+
const codec = require('@volter/ztrack/board-document');
|
|
299
|
+
// A generation's actual predecessor chooses one immutable read/write interpretation. The options are never
|
|
300
|
+
// accepted from a CLI caller or environment; a legacy incumbent keeps the exact frozen predecessor grammar.
|
|
301
|
+
const positions = { parseBoardDocument: 2, inspectBoardDocument: 2, chunkBoardDocument: 2,
|
|
302
|
+
renderBoardCard: 3, renderBoardDocument: 4, mergeBoardDocument: 5, rechunkBoardDocument: 3 };
|
|
303
|
+
this._codec = { ...codec };
|
|
304
|
+
for (const [name, index] of Object.entries(positions)) if (typeof codec[name] === 'function') this._codec[name] = (...args) => {
|
|
305
|
+
args[index] = restingSerializationOptions();
|
|
306
|
+
return codec[name](...args);
|
|
307
|
+
};
|
|
308
|
+
return this._codec;
|
|
309
|
+
}
|
|
310
|
+
get preset() {
|
|
311
|
+
if (this._preset) return this._preset;
|
|
312
|
+
const preset = require('@volter/ztrack/kanban-preset');
|
|
313
|
+
return this._preset = { ...preset, parseKanban: records => preset.parseKanban(records, restingSerializationOptions()),
|
|
314
|
+
serializeKanbanCard: card => preset.serializeKanbanCard(card, restingSerializationOptions()) };
|
|
315
|
+
}
|
|
283
316
|
board() { return { root: this.root, slug: this.slug, label: this.slug, db: this.db }; }
|
|
284
317
|
close() { this.closed = true; this.git?.release(); this.dropOverlay(); this.conn.close(); }
|
|
285
318
|
|
|
@@ -327,7 +360,7 @@ class ZtrackBoard {
|
|
|
327
360
|
}
|
|
328
361
|
// What a transaction touched, for the renderer (cards whose text may change: anything beyond run fields and scheduling clocks). Plain inserts: a trigger's
|
|
329
362
|
// own conflict clause yields to the outer statement's, so duplicates are removed when read.
|
|
330
|
-
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)');
|
|
363
|
+
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); CREATE TEMP TABLE IF NOT EXISTS board_body_before (id TEXT, body TEXT, present INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS temp.board_body_before_id ON board_body_before(id)');
|
|
331
364
|
this.tables = this.shapes();
|
|
332
365
|
const trigger = (name, when, body) => this.conn.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(name)} ${when} BEGIN ${body} END`);
|
|
333
366
|
for (const { name, keys } of this.tables.values()) {
|
|
@@ -343,6 +376,15 @@ class ZtrackBoard {
|
|
|
343
376
|
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});`);
|
|
344
377
|
trigger(`board_td_${name}`, `AFTER DELETE ON main.${quote(name)}`, `INSERT INTO board_touched VALUES (OLD.${id});`);
|
|
345
378
|
}
|
|
379
|
+
// Remember the first body seen in this transaction, including delete/insert replacements. An inherited row
|
|
380
|
+
// keeps its predecessor's interpretation; only newly supplied bytes need the stricter legacy admission.
|
|
381
|
+
// Plain inserts avoid an outer statement's conflict clause replacing the original snapshot.
|
|
382
|
+
const before = (id, body, present) => `INSERT INTO board_body_before SELECT ${id}, ${body}, ${present} WHERE NOT EXISTS (SELECT 1 FROM board_body_before WHERE id = ${id});`;
|
|
383
|
+
trigger('board_body_i', 'BEFORE INSERT ON main.tasks',
|
|
384
|
+
`INSERT INTO board_body_before SELECT id, body, 1 FROM main.tasks WHERE id = NEW.id AND NOT EXISTS (SELECT 1 FROM board_body_before WHERE id = NEW.id);` + before('NEW.id', 'NULL', '0'));
|
|
385
|
+
trigger('board_body_u', 'AFTER UPDATE ON main.tasks WHEN OLD.body IS NOT NEW.body OR OLD.id IS NOT NEW.id',
|
|
386
|
+
before('NEW.id', 'CASE WHEN OLD.id IS NEW.id THEN OLD.body ELSE NULL END', 'OLD.id IS NEW.id'));
|
|
387
|
+
trigger('board_body_d', 'AFTER DELETE ON main.tasks', before('OLD.id', 'OLD.body', '1'));
|
|
346
388
|
}
|
|
347
389
|
|
|
348
390
|
// Each runtime table's columns and key, as the store has them.
|
|
@@ -497,11 +539,15 @@ class ZtrackBoard {
|
|
|
497
539
|
// a listing's view is its snapshot's (readSnapshot): its statements read that, never the documents again
|
|
498
540
|
if (this.readOnly) return fn();
|
|
499
541
|
if (this.active) return this.noting(fn);
|
|
500
|
-
this.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
542
|
+
if (this.readDepth) return fn();
|
|
543
|
+
this.readDepth = 1;
|
|
544
|
+
try {
|
|
545
|
+
this.observeForRead();
|
|
546
|
+
for (let attempt = 0; ; attempt++) {
|
|
547
|
+
// each sleep between tries is a step: its count on a slow line is how many times the read was tried again
|
|
548
|
+
try { return fn(); } catch (error) { if (!sqliteContention(error)) throw error; timedStep('board read: contention backoff', () => backoff(attempt)); }
|
|
549
|
+
}
|
|
550
|
+
} finally { this.readDepth = 0; }
|
|
505
551
|
}
|
|
506
552
|
|
|
507
553
|
fileHash(path) {
|
|
@@ -509,6 +555,7 @@ class ZtrackBoard {
|
|
|
509
555
|
const known = this.hashes.get(path);
|
|
510
556
|
if (known?.stamp === stamp) return known;
|
|
511
557
|
const raw = read(path);
|
|
558
|
+
assertBoardRestingDocument(raw, path);
|
|
512
559
|
const value = { stamp, raw, hash: hash(raw) };
|
|
513
560
|
this.hashes.set(path, value);
|
|
514
561
|
return value;
|
|
@@ -533,20 +580,42 @@ class ZtrackBoard {
|
|
|
533
580
|
|
|
534
581
|
// A reader sees a hand edit at once, through connection-local copies of the card tables, and
|
|
535
582
|
// writes nothing: the next write records the edit.
|
|
583
|
+
observationRevision() {
|
|
584
|
+
return { connection: this.conn, ...this.statement(`SELECT total_changes() AS changes,
|
|
585
|
+
(SELECT data_version FROM pragma_data_version) AS data,
|
|
586
|
+
(SELECT schema_version FROM pragma_schema_version) AS mainSchema`).get(),
|
|
587
|
+
tempSchema: this.statement('PRAGMA temp.schema_version').get().schema_version };
|
|
588
|
+
}
|
|
589
|
+
|
|
536
590
|
observeForRead() {
|
|
591
|
+
// The owner's document watch and its own writes invalidate explicitly. A foreign commit
|
|
592
|
+
// invalidates through SQLite. Include this connection's writes and both schemas: a raw
|
|
593
|
+
// write or replaced TEMP overlay must not keep an older observation. SQLite's schema_version
|
|
594
|
+
// table function reads main even when qualified with temp, so sample TEMP with its own PRAGMA.
|
|
595
|
+
// Nested reads share this boundary observation instead of rereading publication state.
|
|
596
|
+
const revision = this.observationRevision(), previous = this.observedRevision;
|
|
597
|
+
const unchanged = previous && Object.keys(revision).every(key => previous[key] === revision[key]);
|
|
598
|
+
if (this.held && !this.docsChanged && !this.unwatched && unchanged && this.seen !== null) return;
|
|
537
599
|
// a held board knows its documents' stamps until the owner's watch says they changed; one opened per use reads them
|
|
538
600
|
const stamps = this.held && !this.docsChanged && !this.unwatched && this.knownStamps ? this.knownStamps
|
|
539
601
|
: timedStep('board read: document stamps', () => this.docs.map((doc) => this.fileHash(doc.path).stamp));
|
|
540
602
|
if (this.held) { this.knownStamps = stamps; this.docsChanged = false; }
|
|
541
603
|
const stamp = [...stamps, this.stateText('published'), this.stateText('pending_hashes')].join('|');
|
|
542
|
-
if (stamp === this.seen) return;
|
|
543
|
-
this.seen =
|
|
604
|
+
if (stamp === this.seen && unchanged) return;
|
|
605
|
+
this.seen = null; this.observedRevision = null;
|
|
606
|
+
if (previous && previous.mainSchema !== revision.mainSchema) this.tables = this.shapes();
|
|
544
607
|
this.loadErrors();
|
|
545
608
|
const stale = timedStep('board read: stale check', () => this.staleDocuments({ pending: true }));
|
|
546
|
-
if (!stale.length)
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
609
|
+
if (!stale.length) this.dropOverlay();
|
|
610
|
+
else {
|
|
611
|
+
let plan;
|
|
612
|
+
try { plan = timedStep('board read: observe', () => this.observe(stale)); } catch { this.dropOverlay(); } // the next write reports it
|
|
613
|
+
if (plan) timedStep('board read: overlay', () => this.applyPlan(plan, { overlay: true }));
|
|
614
|
+
}
|
|
615
|
+
this.seen = stamp;
|
|
616
|
+
// Building/removing an overlay changes TEMP schema and total_changes itself. Retain its
|
|
617
|
+
// completed revision so the next unchanged read does not rebuild it again.
|
|
618
|
+
this.observedRevision = this.observationRevision();
|
|
550
619
|
}
|
|
551
620
|
|
|
552
621
|
// Whether a document holds an edit its tables have not imported (a hand edit, a git update): the publication owner
|
|
@@ -562,6 +631,7 @@ class ZtrackBoard {
|
|
|
562
631
|
|
|
563
632
|
dropOverlay() {
|
|
564
633
|
if (!this.overlay) return;
|
|
634
|
+
this.seen = null; this.observedRevision = null;
|
|
565
635
|
this.conn.exec('DROP TABLE IF EXISTS temp.tasks; DROP TABLE IF EXISTS temp.supercode_cards; DROP TABLE IF EXISTS temp.task_links');
|
|
566
636
|
this.overlay = false; this.statements.clear();
|
|
567
637
|
}
|
|
@@ -569,9 +639,11 @@ class ZtrackBoard {
|
|
|
569
639
|
// ------------------------------------------------------------------ writes
|
|
570
640
|
|
|
571
641
|
tx(fn) {
|
|
642
|
+
return measureWork("pass", "board.transaction", () => {
|
|
572
643
|
if (this.readOnly) throw new Error('a listing reads the board and writes nothing');
|
|
573
644
|
// A write inside a write is part of it: the pull below happened once, for the outermost one.
|
|
574
645
|
if (this.active) return fn(this.db);
|
|
646
|
+
this.seen = null;
|
|
575
647
|
// A pull request merged on the remote (a page edited in Git Notes) is in the document before this write reads it.
|
|
576
648
|
if (this.git) this.git.paths = this.docs.map((doc) => doc.path);
|
|
577
649
|
try { timedStep('board git pull', () => this.git?.pull()); } catch (error) { current().err(`board: ${error.message}\n`); }
|
|
@@ -583,7 +655,7 @@ class ZtrackBoard {
|
|
|
583
655
|
this.dropOverlay();
|
|
584
656
|
timedStep('board tx begin', () => { this.conn.exec('BEGIN'); fenced(this.conn); });
|
|
585
657
|
this.active = true; this.contended = false;
|
|
586
|
-
this.conn.exec('DELETE FROM temp.board_dirty; DELETE FROM temp.board_touched');
|
|
658
|
+
this.conn.exec('DELETE FROM temp.board_dirty; DELETE FROM temp.board_touched; DELETE FROM temp.board_body_before');
|
|
587
659
|
this.refused = new Set();
|
|
588
660
|
timedStep('board tx errors', () => this.loadErrors());
|
|
589
661
|
const recovered = timedStep('board tx recover', () => this.recover());
|
|
@@ -592,9 +664,15 @@ class ZtrackBoard {
|
|
|
592
664
|
if (this.seed) this.indexForSeed();
|
|
593
665
|
else {
|
|
594
666
|
const stale = timedStep('board tx stale check', () => this.staleDocuments({ pending: false }));
|
|
595
|
-
if (stale.length) timedStep('board tx reread', () => this.applyPlan(this.observe(stale), { overlay: false }));
|
|
667
|
+
if (stale.length) { this.externalTaken = true; timedStep('board tx reread', () => this.applyPlan(this.observe(stale), { overlay: false })); }
|
|
668
|
+
}
|
|
669
|
+
if (this.seed) {
|
|
670
|
+
this.seedRows();
|
|
671
|
+
// The explicit SQLite migration copies existing predecessor rows, not newly authored bodies.
|
|
672
|
+
this.conn.exec('DELETE FROM temp.board_body_before');
|
|
596
673
|
}
|
|
597
|
-
|
|
674
|
+
this.letGo = new Set();
|
|
675
|
+
if (!this.rendersRetried) timedStep('board tx retry renders', () => this.retryRenders());
|
|
598
676
|
timedStep('board tx snapshot', () => this.snapshotHeld());
|
|
599
677
|
fillReadableIds(this.db);
|
|
600
678
|
const result = timedStep('board tx body', () => fn(this.db));
|
|
@@ -617,12 +695,16 @@ class ZtrackBoard {
|
|
|
617
695
|
this.put('task_identities_written', true);
|
|
618
696
|
}
|
|
619
697
|
const files = timedStep('board tx render', () => this.render());
|
|
698
|
+
// Rendering may reread refused spans and run document hooks. Admit the final supplied bytes outside every
|
|
699
|
+
// recoverable render catch, before commit or publication, including bodies hidden by presentation holds.
|
|
700
|
+
timedStep('board tx body admission', () => this.validateBodyChanges());
|
|
620
701
|
const intent = files.length ? randomBytes(16).toString('hex') : null;
|
|
621
702
|
if (intent) {
|
|
622
703
|
this.put('pending', { intent, files });
|
|
623
704
|
this.put('pending_hashes', Object.fromEntries(files.map((f) => [f.role, { before: f.before, after: f.after }])));
|
|
624
705
|
}
|
|
625
706
|
timedStep('board tx commit', () => this.conn.exec('COMMIT'));
|
|
707
|
+
this.rendersRetried = true;
|
|
626
708
|
// A long transaction holds every other writer of this board (the dispatcher's tick included), so it is a line
|
|
627
709
|
// of its own in the slow log, from whichever process ran it.
|
|
628
710
|
const held = performance.now() - txStarted;
|
|
@@ -658,6 +740,9 @@ class ZtrackBoard {
|
|
|
658
740
|
}
|
|
659
741
|
return outcome.result;
|
|
660
742
|
}
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
});
|
|
661
746
|
}
|
|
662
747
|
|
|
663
748
|
// Replace each document with its committed text. A document edited in the meantime is merged
|
|
@@ -691,6 +776,8 @@ class ZtrackBoard {
|
|
|
691
776
|
const pending = this.get('pending');
|
|
692
777
|
if (!pending) return false;
|
|
693
778
|
for (const file of pending.files) {
|
|
779
|
+
assertBoardRestingDocument(file.base, `${file.path} (pending base)`);
|
|
780
|
+
assertBoardRestingDocument(file.text, `${file.path} (pending publication)`);
|
|
694
781
|
const current = this.fileHash(file.path);
|
|
695
782
|
if (current.hash === file.after) continue;
|
|
696
783
|
if (current.hash === file.before) { atomic(file.path, file.text); continue; }
|
|
@@ -780,7 +867,29 @@ class ZtrackBoard {
|
|
|
780
867
|
const keyed = new Map([...plan.tasks].map(([tid, { key }]) => [key, tid]));
|
|
781
868
|
const resolveRef = (ref) => keyed.get(ref) ?? this.one('SELECT id FROM main.board_tasks WHERE key = ?', ref)?.id ?? ref;
|
|
782
869
|
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) })));
|
|
783
|
-
|
|
870
|
+
// A subtask's task line carries only its blockers' ids. A blocker the store already holds keeps the store's entry
|
|
871
|
+
// whole (answered, aged_at, reason, by, since): a hand edit of the card's text is no new block, and an answer
|
|
872
|
+
// already handed to the workflow is not handed to it again (workflow.mjs settleRelations).
|
|
873
|
+
for (const [id, rows] of plan.rows) {
|
|
874
|
+
if (!rows.state.subtask_of) continue;
|
|
875
|
+
const stored = json(this.one('SELECT blocked_by FROM main.supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
|
|
876
|
+
if (stored.length) rows.state.blocked_by = JSON.stringify(json(rows.state.blocked_by, []).map((b) => stored.find((s) => s.kind === b.kind && s.id === b.id) ?? b));
|
|
877
|
+
}
|
|
878
|
+
// A held card read again keeps what the store moved since this write began (snapshotHeld). A hand edit of a card
|
|
879
|
+
// held by a refused render (heldBy) is made over the board's last render, older than the store:
|
|
880
|
+
// it keeps what the store moved past that render, and a task line that render never showed is not removed by it.
|
|
881
|
+
if (held.length) this.keepMoved(plan, new Set(held), (id) => this.heldRows?.get(id));
|
|
882
|
+
const reads = this.refusedReads();
|
|
883
|
+
const unrendered = new Set([...plan.cards.keys()].filter((id) => !held.includes(id) && plan.before.has(id) && this.heldBy(id) && !reads.has(id)));
|
|
884
|
+
if (unrendered.size) {
|
|
885
|
+
const cardOf = (id) => plan.rows.get(id)?.state.subtask_of ?? this.one('SELECT subtask_of FROM main.supercode_cards WHERE task_id = ?', id)?.subtask_of ?? id;
|
|
886
|
+
this.keepMoved(plan, unrendered, (id) => this.spanRows(plan, cardOf(id), resolveRef).get(id), { span: true });
|
|
887
|
+
for (const id of plan.remove) {
|
|
888
|
+
const card = cardOf(id);
|
|
889
|
+
const prior = card !== id && unrendered.has(card) && !plan.remove.has(card) ? this.spanRows(plan, card, resolveRef) : null;
|
|
890
|
+
if (prior?.size && !prior.has(id)) { plan.remove.delete(id); plan.checked.add(id); }
|
|
891
|
+
}
|
|
892
|
+
}
|
|
784
893
|
this.checkControls(plan, resolveRef);
|
|
785
894
|
// A refused task line refuses its card's edit: the hand text stays in the document with the reason, never
|
|
786
895
|
// re-rendered without the line.
|
|
@@ -797,6 +906,81 @@ class ZtrackBoard {
|
|
|
797
906
|
return plan;
|
|
798
907
|
}
|
|
799
908
|
|
|
909
|
+
// A card is held while it or one of its subtasks has a recorded refusal (board_errors). A hold is one of two kinds:
|
|
910
|
+
// - a refused read: the card's span holds document text the store refused. The card is listed in `refused_reads`,
|
|
911
|
+
// and its span is read again when a write touches it (render, keepMoved).
|
|
912
|
+
// - a refused render: the store moved past the card's span and the codec could not render it. Its span is still the
|
|
913
|
+
// board's own last render (any hand edit since is read, and either taken or listed as a refused read), so the
|
|
914
|
+
// store is authoritative: the card renders again from the store, and is held again if that render fails.
|
|
915
|
+
heldBy(card) { return this.errors.has(card) || Boolean(this.one('SELECT 1 FROM main.supercode_cards WHERE subtask_of = ? AND task_id IN (SELECT id FROM main.board_errors)', card)); }
|
|
916
|
+
refusedReads() { return new Set(this.get('refused_reads', []).filter((card) => this.heldBy(card))); }
|
|
917
|
+
|
|
918
|
+
// A card held by a refused render is let go, its subtasks' refusals with it, to render from the store.
|
|
919
|
+
release(card) {
|
|
920
|
+
for (const id of [card, ...this.statement('SELECT task_id FROM main.supercode_cards WHERE subtask_of = ?').all(card).map((row) => row.task_id)]) {
|
|
921
|
+
this.errors.delete(id);
|
|
922
|
+
this.statement('DELETE FROM main.board_errors WHERE id = ?').run(id);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// A refused render is tried again on the first write of each open of the board, before that write's verb runs: a
|
|
927
|
+
// card the store renders now is let go and written by this write (render), so no verb is refused (validateEvent)
|
|
928
|
+
// for a hold the codec no longer makes. A card that still fails, its record or its subtasks' included, stays held
|
|
929
|
+
// with its recorded reason, and the write goes on. Contention and a storage fault are the write's own failure, never
|
|
930
|
+
// the card's: they end the trial and are thrown to tx(), which rolls the write back whole and tries it again.
|
|
931
|
+
retryRenders() {
|
|
932
|
+
if (!this.errors.size) return;
|
|
933
|
+
const reads = this.refusedReads();
|
|
934
|
+
const cards = new Set([...this.errors.keys()].map((id) => this.one('SELECT subtask_of FROM main.supercode_cards WHERE task_id = ?', id)?.subtask_of ?? id));
|
|
935
|
+
const where = this.placed();
|
|
936
|
+
for (const card of cards) {
|
|
937
|
+
if (reads.has(card) || !this.one('SELECT 1 FROM main.tasks WHERE id = ?', card)) continue;
|
|
938
|
+
// a trial: what building the records writes (a subtask's key) is undone, and written by render() if let go
|
|
939
|
+
this.conn.exec('SAVEPOINT retry_render');
|
|
940
|
+
let renders = false;
|
|
941
|
+
try {
|
|
942
|
+
const task = this.one('SELECT * FROM main.tasks WHERE id = ?', card);
|
|
943
|
+
const state = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', card) ?? {};
|
|
944
|
+
const { role, level } = this.placeOf(task, state, where.get(card));
|
|
945
|
+
// as render() writes it: each ended subtask's own entry while the card is open, then the card
|
|
946
|
+
for (const sub of this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ?').all(card)) {
|
|
947
|
+
const subState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', sub.id) ?? {};
|
|
948
|
+
if (role !== 'open' || !ENDED.has(subState.stage ?? sub.status)) continue;
|
|
949
|
+
const subAt = where.get(sub.id), kept = isArchive(subAt?.role);
|
|
950
|
+
this.codec.renderBoardCard(this.subtaskRecord(sub.id, sub, subState, card, where), kept ? subAt.entry.level : 2, this.docOf(kept ? subAt.role : 'archived').path);
|
|
951
|
+
}
|
|
952
|
+
this.codec.renderBoardCard(this.recordOf(card, task, state, where), level, this.docOf(role).path);
|
|
953
|
+
renders = true;
|
|
954
|
+
} catch (error) {
|
|
955
|
+
// SQLite may have rolled the whole transaction back (IOERR, FULL, BUSY): the savepoint is gone with it, and
|
|
956
|
+
// nothing after this may run outside the write's transaction (a Node before 22.16 has no isTransaction, and
|
|
957
|
+
// says nothing of it)
|
|
958
|
+
if (contention(error) || storageFault(error) || this.conn.isTransaction === false) throw error;
|
|
959
|
+
/* otherwise still unrenderable: held as it was */
|
|
960
|
+
}
|
|
961
|
+
this.conn.exec('ROLLBACK TO retry_render; RELEASE retry_render');
|
|
962
|
+
if (!renders) continue;
|
|
963
|
+
this.release(card);
|
|
964
|
+
this.letGo.add(card);
|
|
965
|
+
this.statement('INSERT INTO temp.board_touched VALUES (?)').run(card);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Every entry of every document, by card id: { role, index, entry }.
|
|
970
|
+
placed() {
|
|
971
|
+
const where = new Map();
|
|
972
|
+
for (const { role } of this.docs) this.entries(role).forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
|
|
973
|
+
return where;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// The document a card renders into, and its heading level there: an ended card stays in the archive that holds it (a
|
|
977
|
+
// rotated one included); one that newly ends goes to the current.
|
|
978
|
+
placeOf(task, state, at) {
|
|
979
|
+
const ended = state.archived || ENDED.has(state.stage ?? task.status);
|
|
980
|
+
const role = !ended ? 'open' : isArchive(at?.role) ? at.role : 'archived';
|
|
981
|
+
return { role, level: at?.role === role ? at.entry.level : 2 };
|
|
982
|
+
}
|
|
983
|
+
|
|
800
984
|
// The rows of every card held by a recorded refusal (with its subtasks) before this write's verb runs, so a held
|
|
801
985
|
// card read again after it keeps what the verb moved (keepMoved).
|
|
802
986
|
snapshotHeld() {
|
|
@@ -810,22 +994,58 @@ class ZtrackBoard {
|
|
|
810
994
|
}
|
|
811
995
|
}
|
|
812
996
|
|
|
813
|
-
// A held card's span is older than
|
|
814
|
-
// (title, body, assignee, status, ...); the span's text lands only where the store did
|
|
815
|
-
|
|
816
|
-
|
|
997
|
+
// A held card's span is older than the store: every column the store moved past `base` (the rows the span stands
|
|
998
|
+
// for) keeps the store's value (title, body, assignee, status, ...); the span's text lands only where the store did
|
|
999
|
+
// not move. `cards` are the cards whose rows this applies to; `span` says `base` is the rows parsed from the span.
|
|
1000
|
+
keepMoved(plan, cards, base, { span = false } = {}) {
|
|
1001
|
+
// A span's parse and the store's row write one value differently, and a difference of form alone moves nothing
|
|
1002
|
+
// (a person's edit is kept): blocked_by's entries are compared whole (the store's own fields included: answered,
|
|
1003
|
+
// aged_at, reason, by, since), each with its keys in one order and the entries in kind/id order, so a store change
|
|
1004
|
+
// to any field moves it; a body without its trailing newlines (a span's parse ends its body with one, a stored
|
|
1005
|
+
// body need not). Against a span, a subtask's line differs from its store row in two more ways: its blocker ids
|
|
1006
|
+
// are all the line carries, so its blocked_by is compared by kind and id; and its title carries its task key (the
|
|
1007
|
+
// line reads `c1 title`, a subtask made by a verb stores `title`; both render the same line). Against a snapshot of
|
|
1008
|
+
// the store both sides are the store's, and are compared whole.
|
|
1009
|
+
const ordered = (v) => Array.isArray(v) ? v.map(ordered)
|
|
1010
|
+
: v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, ordered(v[k])])) : v;
|
|
1011
|
+
const blocks = (v, ids) => (json(v, []) ?? []).map((b) => ids ? { id: b?.id, kind: b?.kind } : ordered(b))
|
|
1012
|
+
.sort((a, b) => String(a.kind).localeCompare(String(b.kind)) || String(a.id).localeCompare(String(b.id)));
|
|
1013
|
+
const value = (key, v, line) => key === 'blocked_by' ? blocks(v, line)
|
|
1014
|
+
: ['paused', 'backlog'].includes(key) ? json(v, null) : key === 'archived' ? Boolean(v)
|
|
1015
|
+
: key === 'body' ? String(v ?? '').replace(/\n+$/, '')
|
|
1016
|
+
: key === 'title' && line ? String(v ?? '').replace(/^[a-z]+\d+\s+(?=\S)/, '') : v ?? null;
|
|
1017
|
+
const same = (key, a, b, line) => JSON.stringify(value(key, a, line)) === JSON.stringify(value(key, b, line));
|
|
817
1018
|
for (const [id, rows] of plan.rows) {
|
|
1019
|
+
if (!cards.has(rows.state.subtask_of ?? id)) continue;
|
|
818
1020
|
const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id), state = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', id);
|
|
819
1021
|
if (!task) continue;
|
|
820
|
-
const was =
|
|
1022
|
+
const was = base(id);
|
|
821
1023
|
for (const [side, now, then] of [['task', task, was?.task], ['state', state, was?.state]]) {
|
|
822
1024
|
if (!now) continue;
|
|
823
|
-
// A row the
|
|
824
|
-
for (const key of Object.keys(now)) if (!then || !same(now[key], then[key])) rows[side][key] = now[key];
|
|
1025
|
+
// A row `base` does not hold (one the store made since) has nothing held to keep: all of it is the store's.
|
|
1026
|
+
for (const key of Object.keys(now)) if (!then || !same(key, now[key], then[key], span && Boolean(rows.state.subtask_of))) rows[side][key] = now[key];
|
|
825
1027
|
}
|
|
826
1028
|
}
|
|
827
1029
|
}
|
|
828
1030
|
|
|
1031
|
+
// The rows a card's span read as before this edit (plan.before), with its subtasks', parsed once per plan.
|
|
1032
|
+
spanRows(plan, card, resolveRef) {
|
|
1033
|
+
plan.spans ??= new Map();
|
|
1034
|
+
if (plan.spans.has(card)) return plan.spans.get(card);
|
|
1035
|
+
const rows = new Map(); plan.spans.set(card, rows);
|
|
1036
|
+
const at = plan.before.get(card);
|
|
1037
|
+
if (!at) return rows;
|
|
1038
|
+
try {
|
|
1039
|
+
const { records, errors } = this.codec.inspectBoardDocument(this.texts([at.entry])[0].text, this.docOf(at.role).path);
|
|
1040
|
+
if (errors.length || records.length !== 1 || nativeId(records[0].id) !== card) return rows;
|
|
1041
|
+
const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([records[0]])).issues[0];
|
|
1042
|
+
rows.set(card, this.cardRows(card, parsed, null));
|
|
1043
|
+
for (const [tid, taskCard] of this.taskCards(card, parsed)) rows.set(tid, this.cardRows(tid, taskCard, card));
|
|
1044
|
+
for (const r of rows.values()) r.state.blocked_by = JSON.stringify(json(r.state.blocked_by, []).map((b) => ({ ...b, id: resolveRef(b.id) })));
|
|
1045
|
+
} catch { rows.clear(); } // an unreadable span says nothing of what the lines said
|
|
1046
|
+
return rows;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
829
1049
|
planRemoval(plan, id) {
|
|
830
1050
|
const task = this.one('SELECT current_run_id FROM main.tasks WHERE id = ?', id);
|
|
831
1051
|
if (!task) return;
|
|
@@ -845,30 +1065,14 @@ class ZtrackBoard {
|
|
|
845
1065
|
try { fields = workflowFor(this.root).params.document_control_fields ?? []; } catch { /* no home policy */ }
|
|
846
1066
|
const value = (task, state, field) => field === 'status' ? state?.stage ?? task?.status ?? 'todo'
|
|
847
1067
|
: field === 'blocked_by' ? json(state?.blocked_by, []).map((b) => [b.kind, b.id]).sort()
|
|
848
|
-
:
|
|
1068
|
+
: ['paused', 'backlog'].includes(field) ? json(state?.[field], null)
|
|
849
1069
|
: field === 'archived' ? Boolean(state?.archived) : task?.[field] ?? null;
|
|
850
1070
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
851
|
-
|
|
852
|
-
const before = new Map();
|
|
853
|
-
const rowsBefore = (card) => {
|
|
854
|
-
if (before.has(card)) return before.get(card);
|
|
855
|
-
const rows = new Map(); before.set(card, rows);
|
|
856
|
-
const at = plan.before.get(card);
|
|
857
|
-
if (!at) return rows;
|
|
858
|
-
try {
|
|
859
|
-
const { records, errors } = this.codec.inspectBoardDocument(this.texts([at.entry])[0].text, this.docOf(at.role).path);
|
|
860
|
-
if (errors.length || records.length !== 1 || nativeId(records[0].id) !== card) return rows;
|
|
861
|
-
const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([records[0]])).issues[0];
|
|
862
|
-
rows.set(card, this.cardRows(card, parsed, null));
|
|
863
|
-
for (const [tid, taskCard] of this.taskCards(card, parsed)) rows.set(tid, this.cardRows(tid, taskCard, card));
|
|
864
|
-
for (const r of rows.values()) r.state.blocked_by = JSON.stringify(json(r.state.blocked_by, []).map((b) => ({ ...b, id: resolveRef(b.id) })));
|
|
865
|
-
} catch { rows.clear(); } // an unreadable span says nothing of what the lines said
|
|
866
|
-
return rows;
|
|
867
|
-
};
|
|
1071
|
+
const rowsBefore = (card) => this.spanRows(plan, card, resolveRef);
|
|
868
1072
|
const adopt = (rows, task, state, field) => {
|
|
869
1073
|
if (field === 'status') { rows.task.status = task.status; rows.state.stage = state?.stage ?? null; }
|
|
870
1074
|
else if (field === 'blocked_by') rows.state.blocked_by = state?.blocked_by ?? '[]';
|
|
871
|
-
else if (
|
|
1075
|
+
else if (['paused', 'backlog'].includes(field)) rows.state[field] = state?.[field] ?? null;
|
|
872
1076
|
else if (field === 'archived') rows.state.archived = state?.archived ?? 0;
|
|
873
1077
|
else rows.task[field] = task[field] ?? null;
|
|
874
1078
|
};
|
|
@@ -901,6 +1105,7 @@ class ZtrackBoard {
|
|
|
901
1105
|
const schema = overlay ? 'temp' : 'main';
|
|
902
1106
|
if (overlay) {
|
|
903
1107
|
this.dropOverlay();
|
|
1108
|
+
this.seen = null; this.observedRevision = null;
|
|
904
1109
|
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');
|
|
905
1110
|
this.conn.exec(TASK_SUMMARY_INDEX_SQL.replace(TASK_SUMMARY_INDEX, `temp.${TASK_SUMMARY_INDEX}`));
|
|
906
1111
|
this.conn.exec("CREATE UNIQUE INDEX temp.supercode_open_readable_ids ON tasks(readable_id) WHERE status NOT IN ('done','cancelled','archived')");
|
|
@@ -946,6 +1151,12 @@ class ZtrackBoard {
|
|
|
946
1151
|
const oldState = this.one(`SELECT * FROM ${schema}.supercode_cards WHERE task_id = ?`, id) ?? {};
|
|
947
1152
|
this.replace(schema, 'tasks', { ...oldTask, ...rows.task }, 'id');
|
|
948
1153
|
this.replace(schema, 'supercode_cards', { ...oldState, ...rows.state }, 'task_id');
|
|
1154
|
+
// Imports and rebuilt caches must satisfy the same release boundary as the manager's
|
|
1155
|
+
// backlog verb. A read overlay is discarded on refusal so it cannot hide execution holdings.
|
|
1156
|
+
try {
|
|
1157
|
+
assertPauseRecord(json(rows.state.paused, null), { id, source: 'document' });
|
|
1158
|
+
assertBacklogQualified(this.conn, id, { backlog: json(rows.state.backlog, null), source: 'document' });
|
|
1159
|
+
} catch (error) { if (overlay) this.dropOverlay(); throw error; }
|
|
949
1160
|
if (rebuilding && ENDED.has(rows.state.stage ?? rows.task.status) && oldTask.current_run_id) {
|
|
950
1161
|
this.statement("UPDATE main.task_runs SET ended_at = COALESCE(ended_at, ?), status = 'reclaimed', outcome = COALESCE(outcome, 'reclaimed') WHERE id = ? AND ended_at IS NULL").run(now(), oldTask.current_run_id);
|
|
951
1162
|
this.statement('UPDATE main.tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?').run(id);
|
|
@@ -970,6 +1181,8 @@ class ZtrackBoard {
|
|
|
970
1181
|
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);
|
|
971
1182
|
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);
|
|
972
1183
|
for (const [id, reason] of plan.errors) this.statement('INSERT OR REPLACE INTO main.board_errors VALUES (?, ?)').run(id, reason);
|
|
1184
|
+
// A card this read refused holds the refused text in its span (a refused read); one it took or let go holds none.
|
|
1185
|
+
this.put('refused_reads', [...new Set([...this.get('refused_reads', []), ...this.refused])].filter((card) => this.heldBy(card)));
|
|
973
1186
|
for (const [role, entries] of plan.layout) {
|
|
974
1187
|
if (!(role in plan.published)) continue;
|
|
975
1188
|
this.put(`chunks:${role}`, entries.map((entry) => {
|
|
@@ -1000,7 +1213,7 @@ class ZtrackBoard {
|
|
|
1000
1213
|
|
|
1001
1214
|
fingerprint(t, c) {
|
|
1002
1215
|
const status = c.stage ?? t.status;
|
|
1003
|
-
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 };
|
|
1216
|
+
return { hash: hash(JSON.stringify([t.title, t.body, status, t.assignee, c.machine, c.blocked_by, c.paused, c.backlog, c.every])), status, assignee: t.assignee, parent: c.subtask_of };
|
|
1004
1217
|
}
|
|
1005
1218
|
|
|
1006
1219
|
// A card's rows from its parsed document form. Run fields and clocks stay the table's.
|
|
@@ -1008,7 +1221,7 @@ class ZtrackBoard {
|
|
|
1008
1221
|
// An ended subtask kept in the archive document names its card (`subtask_of`); it is not a column of the task.
|
|
1009
1222
|
const { subtask_of: declaredParent, ...meta } = card.metadata ?? {};
|
|
1010
1223
|
parent ??= declaredParent ? nativeId(declaredParent) : null;
|
|
1011
|
-
const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
|
|
1224
|
+
const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, backlog: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
|
|
1012
1225
|
const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
|
|
1013
1226
|
const old = this.one('SELECT * FROM main.tasks WHERE id = ?', id) ?? {};
|
|
1014
1227
|
const runtime = Object.fromEntries(Object.entries(old).filter(([k]) => RUNTIME.has(k) || RUN_OUTPUT.has(k)));
|
|
@@ -1019,9 +1232,9 @@ class ZtrackBoard {
|
|
|
1019
1232
|
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' }))),
|
|
1020
1233
|
// A card whose text says nothing of archiving keeps the store's flag: an entry rendered before subtask lines
|
|
1021
1234
|
// carried it (or edited without it) is not an unarchive. Saying `archived: false` of an archived card is.
|
|
1022
|
-
paused: card.paused ? JSON.stringify(card.paused) : null, every: card.every ?? null,
|
|
1235
|
+
paused: card.paused ? JSON.stringify(card.paused) : null, backlog: card.backlog ? JSON.stringify(card.backlog) : null, every: card.every ?? null,
|
|
1023
1236
|
archived: (meta.archived ?? card.archived) === undefined ? Number(this.one('SELECT archived FROM main.supercode_cards WHERE task_id = ?', id)?.archived ?? 0) : ((meta.archived ?? card.archived) ? 1 : 0) };
|
|
1024
|
-
if (!state.archived && !ENDED.has(card.status)) task.status = card.paused ? 'paused' : card.relations?.length ? 'blocked' : card.status;
|
|
1237
|
+
if (!state.archived && !ENDED.has(card.status)) task.status = card.paused ? 'paused' : card.relations?.length ? 'blocked' : card.backlog ? 'todo' : card.status;
|
|
1025
1238
|
return { task, state };
|
|
1026
1239
|
}
|
|
1027
1240
|
|
|
@@ -1035,7 +1248,7 @@ class ZtrackBoard {
|
|
|
1035
1248
|
const stored = this.one('SELECT * FROM main.tasks WHERE id = ?', tid);
|
|
1036
1249
|
const storedState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', tid);
|
|
1037
1250
|
const was = task.metadata?.status ?? storedState?.stage ?? stored?.status;
|
|
1038
|
-
const taskCard = { ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, every: storedState.every || undefined } : {}), ...task.metadata?.card,
|
|
1251
|
+
const taskCard = { ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, backlog: json(storedState.backlog, null) ?? undefined, every: storedState.every || undefined } : {}), ...task.metadata?.card,
|
|
1039
1252
|
id: tid, title: `${task.id} ${task.text}`, body: task.metadata?.body ?? task.brief ?? '',
|
|
1040
1253
|
status: task.status === 'passed' ? (was === 'cancelled' ? 'cancelled' : 'done') : (ENDED.has(was) ? 'todo' : was ?? 'todo'),
|
|
1041
1254
|
metadata: task.metadata?.properties ?? Object.fromEntries(Object.entries(stored ?? {}).filter(([k, v]) => declared(k, v))),
|
|
@@ -1065,19 +1278,32 @@ class ZtrackBoard {
|
|
|
1065
1278
|
let parents = cardsTouched();
|
|
1066
1279
|
const mark = this.numberMark();
|
|
1067
1280
|
if (!parents.size && !mark) return [];
|
|
1068
|
-
const heldBy = (id) => this.
|
|
1281
|
+
const heldBy = (id) => this.heldBy(id);
|
|
1069
1282
|
// A card held by a recorded refusal is not rendered, so the store's verbs move its tasks past its lines. When
|
|
1070
|
-
// the store moves it,
|
|
1071
|
-
// (checkControls), and the card renders. A
|
|
1283
|
+
// the store moves it, a refused read's span is read again: what the store moved past is stale and takes the
|
|
1284
|
+
// store's value (keepMoved, checkControls), and the card renders. A refused render's span is the board's own and
|
|
1285
|
+
// older than the store, so it is never read back: the card renders from the store (heldBy). A card refused by this
|
|
1286
|
+
// write's own edit stays as it was written.
|
|
1072
1287
|
const held = [...parents].filter((id) => !this.refused.has(id) && heldBy(id));
|
|
1073
|
-
|
|
1288
|
+
const reads = this.refusedReads();
|
|
1289
|
+
// the refused renders let go for this write (this one's and retryRenders'): one that still cannot render is held again
|
|
1290
|
+
const released = new Set(this.letGo ?? []);
|
|
1291
|
+
for (const id of held) if (!reads.has(id)) { this.release(id); released.add(id); }
|
|
1292
|
+
const reread = held.filter((id) => reads.has(id));
|
|
1293
|
+
if (reread.length) { this.applyPlan(this.observe([], reread), { overlay: false }); parents = cardsTouched(); }
|
|
1074
1294
|
if (this.seedIds) for (const id of this.seedIds) parents.add(id);
|
|
1075
1295
|
const roles = this.docs.map((doc) => doc.role);
|
|
1076
1296
|
const layout = Object.fromEntries(roles.map((role) => [role, this.entries(role)]));
|
|
1077
|
-
const where =
|
|
1078
|
-
for (const role of roles) layout[role].forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
|
|
1297
|
+
const where = this.placed();
|
|
1079
1298
|
const edits = Object.fromEntries(roles.map((role) => [role, []]));
|
|
1080
1299
|
if (mark) edits.open.push(mark);
|
|
1300
|
+
// a refused render: the span stays the board's last render, and the store stays authoritative
|
|
1301
|
+
const hold = (id, reason) => {
|
|
1302
|
+
this.errors.set(id, reason);
|
|
1303
|
+
this.statement('INSERT OR REPLACE INTO main.board_errors VALUES (?, ?)').run(id, reason);
|
|
1304
|
+
const listed = this.get('refused_reads', []);
|
|
1305
|
+
if (listed.includes(id)) this.put('refused_reads', listed.filter((card) => card !== id));
|
|
1306
|
+
};
|
|
1081
1307
|
for (const id of parents) {
|
|
1082
1308
|
if (heldBy(id)) continue;
|
|
1083
1309
|
const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id);
|
|
@@ -1085,37 +1311,42 @@ class ZtrackBoard {
|
|
|
1085
1311
|
if (task && state.subtask_of) continue;
|
|
1086
1312
|
const at = where.get(id);
|
|
1087
1313
|
if (!task) { if (at) edits[at.role].push({ index: at.index, remove: true }); continue; }
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1314
|
+
const { role, level } = this.placeOf(task, state, at);
|
|
1315
|
+
// This card's edits, made once its record is built: a released card whose record cannot be built is held again
|
|
1316
|
+
// whole, its subtasks' entries untouched with it.
|
|
1317
|
+
const own = [];
|
|
1318
|
+
let record;
|
|
1319
|
+
try {
|
|
1320
|
+
// Its ended subtasks: each its own entry in the archive document while the card is open (the open document
|
|
1321
|
+
// lists only what is still to do); back in the card's own text once the card has ended.
|
|
1322
|
+
for (const sub of this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ?').all(id)) {
|
|
1323
|
+
const subState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', sub.id) ?? {};
|
|
1324
|
+
const subAt = where.get(sub.id);
|
|
1325
|
+
if (role !== 'open' || !ENDED.has(subState.stage ?? sub.status)) { if (subAt) own.push([subAt.role, { index: subAt.index, remove: true }]); continue; }
|
|
1326
|
+
const subRecord = this.subtaskRecord(sub.id, sub, subState, id, where);
|
|
1327
|
+
const subRec = hash(JSON.stringify(subRecord));
|
|
1328
|
+
const kept = isArchive(subAt?.role);
|
|
1329
|
+
if (kept && subAt.entry.rec === subRec) continue;
|
|
1330
|
+
let subText;
|
|
1331
|
+
try { subText = this.codec.renderBoardCard(subRecord, kept ? subAt.entry.level : 2, this.docOf(kept ? subAt.role : 'archived').path); }
|
|
1332
|
+
catch (error) { this.errors.set(sub.id, error.message); continue; }
|
|
1333
|
+
const subEntry = { cid: null, id: subRecord.id, level: kept ? subAt.entry.level : 2, len: subText.length, rec: subRec, text: subText };
|
|
1334
|
+
if (kept) own.push([subAt.role, { index: subAt.index, entry: subEntry }]);
|
|
1335
|
+
else { if (subAt) own.push([subAt.role, { index: subAt.index, remove: true }]); own.push(['archived', { entry: subEntry }]); }
|
|
1336
|
+
}
|
|
1337
|
+
record = this.recordOf(id, task, state, where);
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
// contention and a storage fault (or a transaction SQLite already rolled back) fail the write, never the card
|
|
1340
|
+
if (!released.has(id) || contention(error) || storageFault(error) || this.conn.isTransaction === false) throw error;
|
|
1341
|
+
hold(id, error.message);
|
|
1342
|
+
continue;
|
|
1107
1343
|
}
|
|
1108
|
-
|
|
1344
|
+
for (const [r, edit] of own) edits[r].push(edit);
|
|
1109
1345
|
const rec = hash(JSON.stringify(record));
|
|
1110
1346
|
if (at?.role === role && at.entry.rec === rec) continue;
|
|
1111
|
-
const level = at?.role === role ? at.entry.level : 2;
|
|
1112
1347
|
let text;
|
|
1113
1348
|
try { text = this.codec.renderBoardCard(record, level, this.docOf(role).path); }
|
|
1114
|
-
catch (error) {
|
|
1115
|
-
this.errors.set(id, error.message);
|
|
1116
|
-
this.statement('INSERT OR REPLACE INTO main.board_errors VALUES (?, ?)').run(id, error.message);
|
|
1117
|
-
continue;
|
|
1118
|
-
}
|
|
1349
|
+
catch (error) { hold(id, error.message); continue; }
|
|
1119
1350
|
const entry = { cid: null, id: record.id, level, len: text.length, rec, text };
|
|
1120
1351
|
if (at?.role === role) edits[role].push({ index: at.index, entry });
|
|
1121
1352
|
else { if (at) edits[at.role].push({ index: at.index, remove: true }); edits[role].push({ entry }); }
|
|
@@ -1191,17 +1422,38 @@ class ZtrackBoard {
|
|
|
1191
1422
|
published.archived = hash('');
|
|
1192
1423
|
}
|
|
1193
1424
|
|
|
1425
|
+
validateBodyChanges() {
|
|
1426
|
+
if (restingSerializationOptions().resting !== 'legacy') return;
|
|
1427
|
+
const rows = this.statement(`SELECT t.*, COALESCE(s.stage,t.status) AS stage FROM temp.board_body_before b
|
|
1428
|
+
JOIN main.tasks t ON t.id = b.id LEFT JOIN main.supercode_cards s ON s.task_id = t.id
|
|
1429
|
+
WHERE b.present = 0 OR t.body IS NOT b.body`).all();
|
|
1430
|
+
for (const row of rows) this.parseStoredCard({ id: row.id, title: row.title, status: row.stage, body: row.body ?? '' }, row.id);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Validate newly supplied body bytes before the frozen parser can move Acceptance annotations or remove literal
|
|
1434
|
+
// escapes. Existing bytes retain the exact predecessor meaning on metadata edits and SQLite migration; incoming
|
|
1435
|
+
// documents have their own raw admission before parsing. Use the native row id even when its file id differs.
|
|
1436
|
+
parseStoredCard(record, id) {
|
|
1437
|
+
const before = this.one('SELECT body, present FROM temp.board_body_before WHERE id = ?', id);
|
|
1438
|
+
if (restingSerializationOptions().resting === 'legacy' && before && (!before.present || before.body !== record.body)) {
|
|
1439
|
+
const preset = require('@volter/ztrack/kanban-preset');
|
|
1440
|
+
const meaning = preset.KanbanRootSchema.parse(preset.parseKanban([record], { resting: 'preserve' })).issues[0];
|
|
1441
|
+
preset.serializeKanbanCard(meaning, { resting: 'legacy' });
|
|
1442
|
+
this.codec.renderBoardCard(record, 2, this.path);
|
|
1443
|
+
}
|
|
1444
|
+
return this.preset.KanbanRootSchema.parse(this.preset.parseKanban([record])).issues[0];
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1194
1447
|
// The document record of card `id`, from its rows and its subtasks' rows.
|
|
1195
1448
|
recordOf(id, row, c, where) {
|
|
1196
1449
|
const fileId = (x) => where.get(x)?.entry.id ?? x;
|
|
1197
1450
|
const keyOf = (x) => this.one('SELECT key FROM main.board_tasks WHERE id = ?', x)?.key ?? null;
|
|
1198
|
-
const
|
|
1199
|
-
const parsed = parse({ id: fileId(id), title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' });
|
|
1451
|
+
const parsed = this.parseStoredCard({ id: fileId(id), title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' }, id);
|
|
1200
1452
|
const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => declared(k, v)));
|
|
1201
1453
|
if (c.archived) metadata.archived = true;
|
|
1202
1454
|
if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
|
|
1203
1455
|
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}` : ''),
|
|
1204
|
-
branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
|
|
1456
|
+
branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, backlog: json(c.backlog, null) ?? undefined, every: c.every || undefined,
|
|
1205
1457
|
// Task lines left in a stored body (create and specify file them as subtasks) stay in its Tasks, before its subtasks.
|
|
1206
1458
|
relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: fileId(b.id) })), acceptanceCriteria: [...(parsed.acceptanceCriteria ?? [])] };
|
|
1207
1459
|
// An open card lists its open subtasks; its ended ones are kept in the archive document (subtaskRecord), so the open
|
|
@@ -1225,7 +1477,7 @@ class ZtrackBoard {
|
|
|
1225
1477
|
const stored = this.one('SELECT key, sources FROM main.board_tasks WHERE id = ?', r.id);
|
|
1226
1478
|
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);
|
|
1227
1479
|
const match = /^([a-z]+\d+)\s+(.+)$/.exec(r.title);
|
|
1228
|
-
const task =
|
|
1480
|
+
const task = this.parseStoredCard({ id: r.id, title: r.title, status, body: r.body ?? '' }, r.id);
|
|
1229
1481
|
const blocks = json(s.blocked_by, []);
|
|
1230
1482
|
const messages = blocks.filter((block) => block.kind === 'message');
|
|
1231
1483
|
// Keep the identity-to-number binding and declared state durable with its line; a stale runtime cache cannot
|
|
@@ -1234,7 +1486,8 @@ class ZtrackBoard {
|
|
|
1234
1486
|
metadata: { id: r.id, status, assignee: r.assignee, machine: s.machine,
|
|
1235
1487
|
properties: Object.fromEntries(Object.entries(r).filter(([k, v]) => declared(k, v))),
|
|
1236
1488
|
card: { priority: r.priority ?? 0, workspace: r.workspace_kind + (r.workspace_kind !== 'scratch' && (s.workspace_anchor ?? r.workspace_path) ? `:${s.workspace_anchor ?? r.workspace_path}` : ''),
|
|
1237
|
-
branch: r.branch_name || undefined, paused: json(s.paused, null) ?? undefined, every: s.every || undefined
|
|
1489
|
+
branch: r.branch_name || undefined, paused: json(s.paused, null) ?? undefined, backlog: json(s.backlog, null) ?? undefined, every: s.every || undefined,
|
|
1490
|
+
acceptancePreamble: task.acceptancePreamble?.length ? task.acceptancePreamble : undefined } },
|
|
1238
1491
|
...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
|
|
1239
1492
|
waitingOn: messages[0]?.id,
|
|
1240
1493
|
blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
|
|
@@ -1244,7 +1497,7 @@ class ZtrackBoard {
|
|
|
1244
1497
|
return { issue: fileId(target.slice(0, at)), ac: target.slice(at + 1) };
|
|
1245
1498
|
}) });
|
|
1246
1499
|
}
|
|
1247
|
-
const serialized = this.preset.serializeKanbanCard(card);
|
|
1500
|
+
const serialized = this.preset.serializeKanbanCard(card, restingSerializationOptions());
|
|
1248
1501
|
return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1249
1502
|
}
|
|
1250
1503
|
|
|
@@ -1349,10 +1602,10 @@ class ZtrackBoard {
|
|
|
1349
1602
|
// An ended subtask's own record in the archive document: its title and brief, and the card it belongs to.
|
|
1350
1603
|
subtaskRecord(id, task, state, parentId, where) {
|
|
1351
1604
|
const fileId = (x) => where.get(x)?.entry.id ?? x;
|
|
1352
|
-
const parsed = this.
|
|
1605
|
+
const parsed = this.parseStoredCard({ id: fileId(id), title: task.title, status: state.stage ?? task.status, assignee: task.assignee || undefined, body: task.body ?? '' }, id);
|
|
1353
1606
|
const metadata = { subtask_of: fileId(parentId), ...Object.fromEntries(Object.entries(task).filter(([k, v]) => declared(k, v))) };
|
|
1354
1607
|
const card = { ...parsed, metadata, machine: state.machine || undefined, relations: [] };
|
|
1355
|
-
const serialized = this.preset.serializeKanbanCard(card);
|
|
1608
|
+
const serialized = this.preset.serializeKanbanCard(card, restingSerializationOptions());
|
|
1356
1609
|
return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1357
1610
|
}
|
|
1358
1611
|
|