@volter/supercode-orchestrator 0.5.206 → 0.5.208
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 +30 -2
- package/agents.mjs +27 -12
- package/bin/connection-runner.mjs +12 -21
- package/bin/orchestrator.mjs +16 -3
- package/board/agent-declarations.mjs +3 -3
- package/board/args.mjs +2 -2
- package/board/card-state.mjs +29 -0
- package/board/ci.mjs +87 -29
- package/board/cli.mjs +160 -83
- package/board/codex-pane-inputs.mjs +44 -0
- package/board/dispatch.mjs +358 -85
- package/board/engine.mjs +84 -46
- package/board/event-stream.mjs +6 -1
- package/board/files.mjs +20 -4
- package/board/listing.mjs +15 -12
- package/board/machine-fleet.mjs +7 -5
- package/board/notices.mjs +5 -0
- package/board/published.mjs +119 -32
- package/board/readable-id.mjs +34 -0
- package/board/record-view.mjs +10 -1
- package/board/request-mail.mjs +9 -3
- package/board/store.mjs +124 -14
- package/board/tools.mjs +10 -8
- package/board/visibility.mjs +11 -0
- package/board/workflow.mjs +62 -18
- package/board/workspace.mjs +21 -9
- package/board/ztrack.mjs +203 -27
- package/codex-pane-resume.mjs +1 -0
- package/connection.mjs +95 -26
- package/index.d.ts +6 -2
- package/loop.mjs +51 -3
- package/mcp/bridge.mjs +4 -1
- package/mcp/hermes-kanban-tools.json +5 -1
- package/ontology.d.ts +7 -24
- package/orchestration.mjs +6 -3
- package/package.json +7 -6
- package/worker-route.mjs +31 -3
package/board/ztrack.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fillReadableIds, validateReadableId } from './readable-id.mjs';
|
|
1
2
|
// The document is card state. One SQLite file beside it keeps the runtime tables the engine speaks
|
|
2
3
|
// and an index of the document's card spans. ADR 0001 and 0003.
|
|
3
4
|
// All markdown interpretation belongs to ztrack's public codec and kanban preset.
|
|
@@ -15,10 +16,10 @@ import { current } from './call.mjs';
|
|
|
15
16
|
import { fenced } from './fence.mjs';
|
|
16
17
|
import { createRequire } from 'node:module';
|
|
17
18
|
import { createHash, randomBytes } from 'node:crypto';
|
|
18
|
-
import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
20
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
20
21
|
import { DatabaseSync } from 'node:sqlite';
|
|
21
|
-
import { actorOf, contention, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
|
|
22
|
+
import { actorOf, boardKey, CARD_NUMBERS, contention, raiseCardNumbers, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
|
|
22
23
|
import { GitDocument } from './git-document.mjs';
|
|
23
24
|
import { unimported } from './legacy-store.mjs';
|
|
24
25
|
import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
|
|
@@ -104,6 +105,10 @@ const ARCHIVE_LINES = 2000;
|
|
|
104
105
|
// document changed while it was read (readSnapshot).
|
|
105
106
|
const SNAPSHOT_TRIES = 8;
|
|
106
107
|
const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith('archived:');
|
|
108
|
+
// The open document's record of the numbers the board has given (numberMark: its key, its number), and a task line's
|
|
109
|
+
// number (`- [ ] VO-2836 c3`); either written keyed, or with `#` as before a board read its numbers with a key.
|
|
110
|
+
const NUMBER_MARK = /^The board has given card numbers up to (?:#|([A-Z][A-Z0-9]+)-)(\d+)\.[ \t]*$/m;
|
|
111
|
+
const TASK_NUMBER = /^\s*[-*] \[[ xX]\]\s+(?:#|[A-Z][A-Z0-9]+-)(\d+)\s+[a-z]+\d+\s/gm;
|
|
107
112
|
|
|
108
113
|
const BOARD_TABLES = `
|
|
109
114
|
CREATE TABLE IF NOT EXISTS board_state (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
@@ -133,7 +138,7 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
|
|
|
133
138
|
}
|
|
134
139
|
const config = JSON.parse(read(marker));
|
|
135
140
|
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held, readOnly });
|
|
136
|
-
return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
|
|
141
|
+
return { db: backing.db, snapshot: (path) => backing.snapshot(path), tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
|
|
137
142
|
documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
|
|
138
143
|
}
|
|
139
144
|
|
|
@@ -210,7 +215,7 @@ class ZtrackBoard {
|
|
|
210
215
|
}
|
|
211
216
|
// A board's first open in this process recovers what its store accepted and derives what git owes for it (tx), so
|
|
212
217
|
// a board that is only read, or whose owner runs no round, still finishes an earlier process's commit.
|
|
213
|
-
if (seed || this.git?.underived) this.tx(() => {});
|
|
218
|
+
if (seed || this.git?.underived || this.get('rebuild_from_documents') || !this.get('task_identities_written') || !this.get('readable_ids_written')) this.tx(() => {});
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
// A listing's view, taken once at its open and kept until its close: the store's rows as of one read transaction, and
|
|
@@ -264,7 +269,7 @@ class ZtrackBoard {
|
|
|
264
269
|
// state, so a rotation committed but not yet written is still a document of the board).
|
|
265
270
|
get docs() {
|
|
266
271
|
let rotated = [];
|
|
267
|
-
try { rotated = this.conn ? this.get('archives', []) : []; } catch { rotated = []; }
|
|
272
|
+
try { rotated = this.conn ? this.get('archives', []) : this.rebuildArchives ?? []; } catch { rotated = []; }
|
|
268
273
|
// recorded by file name, beside the document, so a board moved with its documents still finds them
|
|
269
274
|
return [...this.baseDocs, ...rotated.map(({ role, name }) => ({ role, path: resolve(dirname(this.path), name) }))];
|
|
270
275
|
}
|
|
@@ -273,6 +278,14 @@ class ZtrackBoard {
|
|
|
273
278
|
board() { return { root: this.root, slug: this.slug, label: this.slug, db: this.db }; }
|
|
274
279
|
close() { this.closed = true; this.git?.release(); this.dropOverlay(); this.conn.close(); }
|
|
275
280
|
|
|
281
|
+
// Import pending document edits through the backing, then copy the committed
|
|
282
|
+
// projection outside its write wrapper: SQLite forbids VACUUM inside a tx.
|
|
283
|
+
snapshot(path) {
|
|
284
|
+
if (this.active || this.readOnly) throw new Error('board export needs a writable backing outside a transaction');
|
|
285
|
+
this.tx(() => {});
|
|
286
|
+
this.conn.prepare('VACUUM INTO ?').run(path);
|
|
287
|
+
}
|
|
288
|
+
|
|
276
289
|
statement(sql) {
|
|
277
290
|
let statement = this.statements.get(sql);
|
|
278
291
|
if (!statement) {
|
|
@@ -348,6 +361,8 @@ class ZtrackBoard {
|
|
|
348
361
|
|
|
349
362
|
// A board created from the previous runtime store imports its rows once; the old file stays as it was.
|
|
350
363
|
create(legacy) {
|
|
364
|
+
const named = `${basename(this.path)}.archive-`;
|
|
365
|
+
this.rebuildArchives = existsSync(dirname(this.path)) ? readdirSync(dirname(this.path)).filter(name => name.startsWith(named) && name.endsWith('.md')).sort().map(name => ({ role: `archived:${name.slice(named.length, -3)}`, name })) : [];
|
|
351
366
|
const tmp = `${this.file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
352
367
|
const db = new DatabaseSync(tmp);
|
|
353
368
|
try {
|
|
@@ -359,6 +374,13 @@ class ZtrackBoard {
|
|
|
359
374
|
this.init(db, { fresh: true });
|
|
360
375
|
db.exec(BOARD_TABLES);
|
|
361
376
|
if (has('runtime_rows')) this.importRuntime(db, old, legacy);
|
|
377
|
+
// Runtime history is retained, but an old cache is never the current document state. Persist the recovery
|
|
378
|
+
// marker before installing the store so a crash/reopen also finishes this first full reconciliation.
|
|
379
|
+
db.prepare('INSERT OR REPLACE INTO board_state VALUES (?, ?)').run('archives', JSON.stringify(this.rebuildArchives));
|
|
380
|
+
if (!this.seed) {
|
|
381
|
+
db.prepare('INSERT OR REPLACE INTO board_state VALUES (?, ?)').run('rebuild_from_documents', 'true');
|
|
382
|
+
db.prepare('INSERT OR REPLACE INTO board_state VALUES (?, ?)').run('published', '{}');
|
|
383
|
+
}
|
|
362
384
|
} finally { old?.close(); }
|
|
363
385
|
db.close();
|
|
364
386
|
} catch (error) {
|
|
@@ -440,6 +462,13 @@ class ZtrackBoard {
|
|
|
440
462
|
put(`chunks:${role}`, entries);
|
|
441
463
|
}
|
|
442
464
|
put('published', published);
|
|
465
|
+
// The earlier store held no numbers: its cards take the numbers their documents' headings give them, and the rest
|
|
466
|
+
// are numbered in the order they were made, after every number the documents name (documentNumbers).
|
|
467
|
+
db.exec(`DELETE FROM ${CARD_NUMBERS}; DELETE FROM sqlite_sequence WHERE name = '${CARD_NUMBERS}'`);
|
|
468
|
+
const named = this.documentNumbers();
|
|
469
|
+
this.numberFromHeadings([...named.headings].filter(([id]) => db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(id)), db);
|
|
470
|
+
raiseCardNumbers(db, named.highest);
|
|
471
|
+
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`);
|
|
443
472
|
db.exec('COMMIT');
|
|
444
473
|
}
|
|
445
474
|
|
|
@@ -554,8 +583,26 @@ class ZtrackBoard {
|
|
|
554
583
|
}
|
|
555
584
|
if (this.seed) this.seedRows();
|
|
556
585
|
timedStep('board tx snapshot', () => this.snapshotHeld());
|
|
586
|
+
fillReadableIds(this.db);
|
|
557
587
|
const result = timedStep('board tx body', () => fn(this.db));
|
|
588
|
+
fillReadableIds(this.db);
|
|
558
589
|
if (this.contended) throw RETRY;
|
|
590
|
+
// The first write made with a codec that renders keyed numbers (numbersRendered) once the store numbered its
|
|
591
|
+
// cards (store.mjs layOutNumbers) writes every card of the open document again, with its keyed number in its
|
|
592
|
+
// heading and each open subtask's on its task line, and again whenever the board's key changes (a renamed home);
|
|
593
|
+
// a card made since is written with its number when it is made. `numbers_written` is the key last written.
|
|
594
|
+
if (this.numbersRendered && (this.numberForm === 'keyed' ? this.get('numbers_written') !== this.cardKey : !this.get('numbers_written'))) {
|
|
595
|
+
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))));
|
|
596
|
+
this.put('numbers_written', this.numberForm === 'keyed' ? this.cardKey : true);
|
|
597
|
+
}
|
|
598
|
+
if (!this.get('readable_ids_written')) {
|
|
599
|
+
this.conn.exec('INSERT INTO temp.board_touched SELECT id FROM main.tasks');
|
|
600
|
+
this.put('readable_ids_written', true);
|
|
601
|
+
}
|
|
602
|
+
if (!this.get('task_identities_written')) {
|
|
603
|
+
this.conn.exec('INSERT INTO temp.board_touched SELECT DISTINCT subtask_of FROM main.supercode_cards WHERE subtask_of IS NOT NULL');
|
|
604
|
+
this.put('task_identities_written', true);
|
|
605
|
+
}
|
|
559
606
|
const files = timedStep('board tx render', () => this.render());
|
|
560
607
|
const intent = files.length ? randomBytes(16).toString('hex') : null;
|
|
561
608
|
if (intent) {
|
|
@@ -658,7 +705,7 @@ class ZtrackBoard {
|
|
|
658
705
|
// connection-local copies by a read. Only the spans the edit touched are parsed.
|
|
659
706
|
// `held` names cards read again from their indexed span with no edit (see render).
|
|
660
707
|
observe(stale, held = []) {
|
|
661
|
-
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() };
|
|
708
|
+
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() };
|
|
662
709
|
const changed = new Set();
|
|
663
710
|
for (const doc of this.docs) {
|
|
664
711
|
const entries = this.entries(doc.role);
|
|
@@ -699,11 +746,13 @@ class ZtrackBoard {
|
|
|
699
746
|
catch (error) { plan.errors.set(id, error.message); continue; }
|
|
700
747
|
entry.rec = hash(JSON.stringify(records[0]));
|
|
701
748
|
plan.cards.set(id, card);
|
|
749
|
+
if (records[0].number) plan.numbers.set(id, records[0].number);
|
|
702
750
|
}
|
|
703
751
|
for (const [id, card] of plan.cards) {
|
|
704
752
|
plan.rows.set(id, this.cardRows(id, card, null));
|
|
705
753
|
const subtasks = [];
|
|
706
|
-
for (const [tid, taskCard, key, sources] of this.taskCards(id, card)) {
|
|
754
|
+
for (const [tid, taskCard, key, sources, number] of this.taskCards(id, card)) {
|
|
755
|
+
if (number) plan.numbers.set(tid, number);
|
|
707
756
|
plan.rows.set(tid, this.cardRows(tid, taskCard, id));
|
|
708
757
|
plan.tasks.set(tid, { key, sources });
|
|
709
758
|
subtasks.push(tid);
|
|
@@ -778,7 +827,7 @@ class ZtrackBoard {
|
|
|
778
827
|
// while its card was not rendered, as under a recorded refusal): it takes the store's value, and the card's
|
|
779
828
|
// render writes that back into its lines (t_f3f102db).
|
|
780
829
|
checkControls(plan, resolveRef) {
|
|
781
|
-
if (!this.one('SELECT 1 FROM main.tasks LIMIT 1')) return;
|
|
830
|
+
if (this.get('rebuild_from_documents') || !this.one('SELECT 1 FROM main.tasks LIMIT 1')) return;
|
|
782
831
|
let fields = [];
|
|
783
832
|
try { fields = workflowFor(this.root).params.document_control_fields ?? []; } catch { /* no home policy */ }
|
|
784
833
|
if (!fields.length) return;
|
|
@@ -837,9 +886,17 @@ class ZtrackBoard {
|
|
|
837
886
|
this.dropOverlay();
|
|
838
887
|
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');
|
|
839
888
|
this.conn.exec(TASK_SUMMARY_INDEX_SQL.replace(TASK_SUMMARY_INDEX, `temp.${TASK_SUMMARY_INDEX}`));
|
|
889
|
+
this.conn.exec("CREATE UNIQUE INDEX temp.supercode_open_readable_ids ON tasks(readable_id) WHERE status NOT IN ('done','cancelled','archived')");
|
|
840
890
|
this.overlay = true; this.statements.clear();
|
|
841
891
|
}
|
|
842
|
-
const
|
|
892
|
+
const rebuilding = !overlay && this.get('rebuild_from_documents');
|
|
893
|
+
const initial = !overlay && (rebuilding || !this.one('SELECT 1 FROM main.tasks LIMIT 1'));
|
|
894
|
+
if (rebuilding) {
|
|
895
|
+
if (plan.errors.size) throw new Error(`cannot rebuild board from invalid documents: ${[...plan.errors].map(([id, why]) => `${id}: ${why}`).join('; ')}`);
|
|
896
|
+
// The imported cache can predate cards added, edited or removed in the documents. Keep its history, not its
|
|
897
|
+
// obsolete card state. Every document was re-read, including unchanged archive spans.
|
|
898
|
+
for (const { id } of this.statement('SELECT id FROM main.tasks').all()) if (!plan.rows.has(id)) plan.remove.add(id);
|
|
899
|
+
}
|
|
843
900
|
const by = 'document-editor';
|
|
844
901
|
const events = [];
|
|
845
902
|
const fingerprintOf = (id) => {
|
|
@@ -852,12 +909,24 @@ class ZtrackBoard {
|
|
|
852
909
|
this.statement(`DELETE FROM ${schema}.task_links WHERE child_id = ?`).run(id);
|
|
853
910
|
if (!overlay) { this.statement('DELETE FROM main.board_tasks WHERE id = ?').run(id); if (!initial) events.push({ id, kind: 'removed', payload: { by, source: 'document' } }); }
|
|
854
911
|
}
|
|
912
|
+
// A store made again from its documents (an empty one reading them whole) gives each card and subtask the number its
|
|
913
|
+
// heading or task line says, and hands out none at or below any number the documents name. Any other import keeps
|
|
914
|
+
// the store's numbers: a number typed into a heading or pasted with a section changes nothing.
|
|
915
|
+
if (initial) {
|
|
916
|
+
if (rebuilding) this.conn.exec(`DELETE FROM ${CARD_NUMBERS}`);
|
|
917
|
+
this.numberFromHeadings([...plan.numbers].filter(([id]) => plan.rows.has(id)));
|
|
918
|
+
raiseCardNumbers(this.conn, this.documentNumbers().highest);
|
|
919
|
+
}
|
|
855
920
|
for (const [id, rows] of plan.rows) {
|
|
856
921
|
const before = fingerprintOf(id);
|
|
857
922
|
const oldTask = this.one(`SELECT * FROM ${schema}.tasks WHERE id = ?`, id) ?? {};
|
|
858
923
|
const oldState = this.one(`SELECT * FROM ${schema}.supercode_cards WHERE task_id = ?`, id) ?? {};
|
|
859
924
|
this.replace(schema, 'tasks', { ...oldTask, ...rows.task }, 'id');
|
|
860
925
|
this.replace(schema, 'supercode_cards', { ...oldState, ...rows.state }, 'task_id');
|
|
926
|
+
if (rebuilding && ENDED.has(rows.state.stage ?? rows.task.status) && oldTask.current_run_id) {
|
|
927
|
+
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);
|
|
928
|
+
this.statement('UPDATE main.tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?').run(id);
|
|
929
|
+
}
|
|
861
930
|
this.statement(`DELETE FROM ${schema}.task_links WHERE child_id = ?`).run(id);
|
|
862
931
|
for (const b of json(rows.state.blocked_by, []).filter((b) => b.kind === 'card')) this.statement(`INSERT OR IGNORE INTO ${schema}.task_links (parent_id, child_id) VALUES (?, ?)`).run(b.id, id);
|
|
863
932
|
const after = fingerprintOf(id);
|
|
@@ -868,7 +937,11 @@ class ZtrackBoard {
|
|
|
868
937
|
}
|
|
869
938
|
for (const id of plan.checked) this.errors.delete(id);
|
|
870
939
|
for (const [id, reason] of plan.errors) this.errors.set(id, reason);
|
|
940
|
+
// A read overlays new document cards before the owner commits them. Give that
|
|
941
|
+
// connection-local view the same names as the subsequent write.
|
|
942
|
+
fillReadableIds(this.conn);
|
|
871
943
|
if (overlay) return;
|
|
944
|
+
if (rebuilding) this.drop('rebuild_from_documents');
|
|
872
945
|
for (const id of plan.errors.keys()) this.refused.add(this.one('SELECT subtask_of FROM main.supercode_cards WHERE task_id = ?', id)?.subtask_of ?? id);
|
|
873
946
|
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);
|
|
874
947
|
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);
|
|
@@ -915,7 +988,7 @@ class ZtrackBoard {
|
|
|
915
988
|
const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
|
|
916
989
|
const old = this.one('SELECT * FROM main.tasks WHERE id = ?', id) ?? {};
|
|
917
990
|
const runtime = Object.fromEntries(Object.entries(old).filter(([k]) => RUNTIME.has(k) || RUN_OUTPUT.has(k)));
|
|
918
|
-
const task = { ...meta, ...runtime, id, title: card.title, body: stored.body,
|
|
991
|
+
const task = { ...meta, ...runtime, id, title: card.title, readable_id: meta.readable_id == null ? old.readable_id ?? null : validateReadableId(meta.readable_id), body: stored.body,
|
|
919
992
|
status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? old.created_at ?? now(), priority: card.priority ?? 0,
|
|
920
993
|
workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: runtime.workspace_path ?? (/^(\/|~|[A-Za-z]:[\\/])/.test(workspace?.[2] ?? '') ? workspace[2] : null), branch_name: card.branch ?? null };
|
|
921
994
|
const state = { task_id: id, workspace_anchor: workspace?.[2] ?? null, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
|
|
@@ -924,28 +997,29 @@ class ZtrackBoard {
|
|
|
924
997
|
// carried it (or edited without it) is not an unarchive. Saying `archived: false` of an archived card is.
|
|
925
998
|
paused: card.paused ? JSON.stringify(card.paused) : null, every: card.every ?? null,
|
|
926
999
|
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) };
|
|
1000
|
+
if (!state.archived && !ENDED.has(card.status)) task.status = card.paused ? 'paused' : card.relations?.length ? 'blocked' : card.status;
|
|
927
1001
|
return { task, state };
|
|
928
1002
|
}
|
|
929
1003
|
|
|
930
|
-
// A card's Tasks as subtask cards: [id, card, key, sources].
|
|
1004
|
+
// A card's Tasks as subtask cards: [id, card, key, sources, the number its line gives].
|
|
931
1005
|
taskCards(id, card) {
|
|
932
1006
|
return card.acceptanceCriteria.map((task) => {
|
|
933
1007
|
const key = `${id}:${task.id}`;
|
|
934
1008
|
const tid = task.metadata?.id ?? this.one('SELECT id FROM main.board_tasks WHERE key = ?', key)?.id ?? `t_${randomBytes(4).toString('hex')}`;
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
const stored =
|
|
938
|
-
const storedState =
|
|
1009
|
+
// The native identity and declared state travel with the line. Runtime history stays in the store, joined by
|
|
1010
|
+
// this same identity, including when that store was rebuilt from a cache older than the subtask.
|
|
1011
|
+
const stored = this.one('SELECT * FROM main.tasks WHERE id = ?', tid);
|
|
1012
|
+
const storedState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', tid);
|
|
939
1013
|
const was = task.metadata?.status ?? storedState?.stage ?? stored?.status;
|
|
940
|
-
const taskCard = { ...
|
|
1014
|
+
const taskCard = { ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, every: storedState.every || undefined } : {}), ...task.metadata?.card,
|
|
941
1015
|
id: tid, title: `${task.id} ${task.text}`, body: task.metadata?.body ?? task.brief ?? '',
|
|
942
1016
|
status: task.status === 'passed' ? (was === 'cancelled' ? 'cancelled' : 'done') : (ENDED.has(was) ? 'todo' : was ?? 'todo'),
|
|
943
1017
|
metadata: task.metadata?.properties ?? Object.fromEntries(Object.entries(stored ?? {}).filter(([k, v]) => declared(k, v))),
|
|
944
|
-
assignee: task.metadata
|
|
945
|
-
machine: task.metadata
|
|
1018
|
+
assignee: task.metadata && 'assignee' in task.metadata ? task.metadata.assignee : stored?.assignee ?? card.assignee,
|
|
1019
|
+
machine: task.metadata && 'machine' in task.metadata ? task.metadata.machine : storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
|
|
946
1020
|
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) })) };
|
|
947
1021
|
if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
|
|
948
|
-
return [tid, taskCard, key, task.sources];
|
|
1022
|
+
return [tid, taskCard, key, task.sources, task.number];
|
|
949
1023
|
});
|
|
950
1024
|
}
|
|
951
1025
|
|
|
@@ -965,7 +1039,8 @@ class ZtrackBoard {
|
|
|
965
1039
|
return parents;
|
|
966
1040
|
};
|
|
967
1041
|
let parents = cardsTouched();
|
|
968
|
-
|
|
1042
|
+
const mark = this.numbersRendered ? this.numberMark() : null;
|
|
1043
|
+
if (!parents.size && !mark) return [];
|
|
969
1044
|
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);
|
|
970
1045
|
// A card held by a recorded refusal is not rendered, so the store's verbs move its tasks past its lines. When
|
|
971
1046
|
// the store moves it, its span is read again: what the store moved past is stale and takes the store's value
|
|
@@ -978,6 +1053,7 @@ class ZtrackBoard {
|
|
|
978
1053
|
const where = new Map();
|
|
979
1054
|
for (const role of roles) layout[role].forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
|
|
980
1055
|
const edits = Object.fromEntries(roles.map((role) => [role, []]));
|
|
1056
|
+
if (mark) edits.open.push(mark);
|
|
981
1057
|
for (const id of parents) {
|
|
982
1058
|
if (heldBy(id)) continue;
|
|
983
1059
|
const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id);
|
|
@@ -1041,7 +1117,8 @@ class ZtrackBoard {
|
|
|
1041
1117
|
dropped.push(old.cid);
|
|
1042
1118
|
if (edit.remove) entries.splice(edit.index, 1); else entries[edit.index] = edit.entry;
|
|
1043
1119
|
}
|
|
1044
|
-
for (const edit of edits[role].filter((e) => e.
|
|
1120
|
+
for (const edit of edits[role].filter((e) => e.prepend)) { text = edit.entry.text + text; entries.unshift(edit.entry); }
|
|
1121
|
+
for (const edit of edits[role].filter((e) => e.index === undefined && !e.prepend)) {
|
|
1045
1122
|
if (text && !text.endsWith('\n\n')) {
|
|
1046
1123
|
// the blank line before a new section belongs to the span above it
|
|
1047
1124
|
const last = entries.at(-1), separator = text.endsWith('\n') ? '\n' : '\n\n';
|
|
@@ -1127,9 +1204,13 @@ class ZtrackBoard {
|
|
|
1127
1204
|
const task = parse({ id: r.id, title: r.title, status, body: r.body ?? '' });
|
|
1128
1205
|
const blocks = json(s.blocked_by, []);
|
|
1129
1206
|
const messages = blocks.filter((block) => block.kind === 'message');
|
|
1130
|
-
//
|
|
1131
|
-
// output
|
|
1132
|
-
card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, status: ENDED.has(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
|
|
1207
|
+
// Keep the identity-to-number binding and declared state durable with its line; a stale runtime cache cannot
|
|
1208
|
+
// supply these for a newer subtask. Run output, clocks and active claims remain the runtime store's.
|
|
1209
|
+
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,
|
|
1210
|
+
metadata: { id: r.id, status, assignee: r.assignee, machine: s.machine,
|
|
1211
|
+
properties: Object.fromEntries(Object.entries(r).filter(([k, v]) => declared(k, v))),
|
|
1212
|
+
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}` : ''),
|
|
1213
|
+
branch: r.branch_name || undefined, paused: json(s.paused, null) ?? undefined, every: s.every || undefined } },
|
|
1133
1214
|
...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
|
|
1134
1215
|
waitingOn: messages[0]?.id,
|
|
1135
1216
|
blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
|
|
@@ -1140,7 +1221,97 @@ class ZtrackBoard {
|
|
|
1140
1221
|
}) });
|
|
1141
1222
|
}
|
|
1142
1223
|
const serialized = this.preset.serializeKanbanCard(card);
|
|
1143
|
-
return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1224
|
+
return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// A card's number, shown first in its heading with its board's key (`## VO-42 t_… — …`): the store's (store.mjs
|
|
1228
|
+
// layOutNumbers, boardKey). An edit to a heading's number changes nothing; a store made again from its documents takes
|
|
1229
|
+
// it back (numberFromHeadings). A card's record keeps the number and key where the document codec reads them, after
|
|
1230
|
+
// the id.
|
|
1231
|
+
numberOf(id) {
|
|
1232
|
+
if (!this.numbersRendered) return {};
|
|
1233
|
+
const number = this.one(`SELECT number FROM main.${CARD_NUMBERS} WHERE task_id = ?`, id)?.number;
|
|
1234
|
+
return number ? { number, ...(this.numberForm === 'keyed' ? { key: this.cardKey } : {}) } : {};
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** The board's key (store.mjs boardKey). */
|
|
1238
|
+
get cardKey() { return this._cardKey ??= boardKey(this.root, this.slug); }
|
|
1239
|
+
|
|
1240
|
+
// How the document codec (@volter/ztrack) writes a card's number in its heading and a subtask's on its task line:
|
|
1241
|
+
// `keyed` (`VO-42`), `plain` (`#42`, a codec from before numbers were keyed), or null (none). One that writes none
|
|
1242
|
+
// renders, reads and indexes cards as it always did; the open document is written with the numbers in the codec's form
|
|
1243
|
+
// by the first write made with it (tx), whichever was installed first.
|
|
1244
|
+
get numberForm() {
|
|
1245
|
+
if (this._numberForm === undefined) {
|
|
1246
|
+
const writes = (key, mark) => {
|
|
1247
|
+
try {
|
|
1248
|
+
const task = this.preset.serializeKanbanCard({ id: 't-00000000', title: 'number', summary: '', status: 'todo', body: '', acceptanceCriteria: [{ id: 'c1', number: 2, ...(key ? { key } : {}), status: 'pending', evidence: [], text: 'number' }] }).body;
|
|
1249
|
+
return this.codec.renderBoardCard({ id: 't_00000000', number: 1, ...(key ? { key } : {}), title: 'number', status: 'todo', body: '' }, 2).startsWith(`## ${mark}1 t_00000000 `) && task.includes(`- [ ] ${mark}2 c1 number`);
|
|
1250
|
+
} catch { return false; }
|
|
1251
|
+
};
|
|
1252
|
+
this._numberForm = writes('KB', 'KB-') ? 'keyed' : writes(null, '#') ? 'plain' : null;
|
|
1253
|
+
}
|
|
1254
|
+
return this._numberForm;
|
|
1255
|
+
}
|
|
1256
|
+
get numbersRendered() { return this.numberForm !== null; }
|
|
1257
|
+
|
|
1258
|
+
// Give each card the number its document gives it (its heading, or its task line), where no other card holds that
|
|
1259
|
+
// number and the card has none yet; the numbers' high-water mark rises with them, so none is handed out again. Only
|
|
1260
|
+
// for a store made again from its documents (applyPlan, importRuntime).
|
|
1261
|
+
numberFromHeadings(numbers, db = this.conn) {
|
|
1262
|
+
if (!numbers.length) return;
|
|
1263
|
+
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 = ?)`);
|
|
1264
|
+
const holder = db.prepare(`SELECT task_id FROM ${CARD_NUMBERS} WHERE number = ?`);
|
|
1265
|
+
for (const [id, number] of numbers) {
|
|
1266
|
+
const other = holder.get(number)?.task_id;
|
|
1267
|
+
if (other && other !== id) throw new Error(`cannot rebuild card numbers: ${number} names both ${other} and ${id}`);
|
|
1268
|
+
give.run(number, id, id, number);
|
|
1269
|
+
}
|
|
1270
|
+
raiseCardNumbers(db);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// What the board's documents (the open one, the current archive and each rotated one beside it) say of numbers:
|
|
1274
|
+
// `headings`, card id → the number its heading gives, and `highest`, the highest number any of them names (a
|
|
1275
|
+
// heading, a task line, or the open document's record of the numbers given, numberMark).
|
|
1276
|
+
documentNumbers() {
|
|
1277
|
+
const headings = new Map();
|
|
1278
|
+
let highest = 0;
|
|
1279
|
+
const dir = dirname(this.path), named = basename(this.path);
|
|
1280
|
+
let rotated = [];
|
|
1281
|
+
try { rotated = readdirSync(dir).filter((name) => name.startsWith(`${named}.archive-`) && name.endsWith('.md')).map((name) => join(dir, name)); } catch { /* none */ }
|
|
1282
|
+
for (const path of [this.path, this.archive, ...rotated]) {
|
|
1283
|
+
const text = lf(read(path));
|
|
1284
|
+
for (const line of text.matchAll(TASK_NUMBER)) highest = Math.max(highest, Number(line[1]));
|
|
1285
|
+
if (path === this.path) highest = Math.max(highest, Number(NUMBER_MARK.exec(text)?.[2] ?? 0));
|
|
1286
|
+
let records = [];
|
|
1287
|
+
try { ({ records } = this.codec.inspectBoardDocument(text, path)); } catch { continue; }
|
|
1288
|
+
for (const record of records) {
|
|
1289
|
+
if (!record.number) continue;
|
|
1290
|
+
highest = Math.max(highest, record.number);
|
|
1291
|
+
if (!headings.has(nativeId(record.id))) headings.set(nativeId(record.id), record.number);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
return { headings, highest };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// The open document records the highest number the store has given (a line of its preamble, NUMBER_MARK), so a store
|
|
1298
|
+
// made again from the documents hands out none at or below it, whatever card held it: a subtask filed and gone, or
|
|
1299
|
+
// a card the documents no longer show. The edit that brings the line up to the store's count, or null.
|
|
1300
|
+
numberMark() {
|
|
1301
|
+
const given = Number(this.one('SELECT seq FROM main.sqlite_sequence WHERE name = ?', CARD_NUMBERS)?.seq ?? 0);
|
|
1302
|
+
if (!given) return null;
|
|
1303
|
+
const keyed = this.numberForm === 'keyed';
|
|
1304
|
+
const line = (top) => `The board has given card numbers up to ${keyed ? `${this.cardKey}-` : '#'}${top}.`;
|
|
1305
|
+
const first = this.entries('open')[0];
|
|
1306
|
+
if (first && first.id === null) {
|
|
1307
|
+
const text = this.texts([first])[0].text;
|
|
1308
|
+
const said = NUMBER_MARK.exec(text);
|
|
1309
|
+
if (said && (keyed ? said[1] === this.cardKey : said[1] === undefined) && Number(said[2]) >= given) return null;
|
|
1310
|
+
const next = said ? text.replace(NUMBER_MARK, line(Math.max(given, Number(said[2])))) : `${text.replace(/\n*$/, '')}\n\n${line(given)}\n\n`;
|
|
1311
|
+
return { index: 0, entry: { cid: null, id: null, level: 0, len: next.length, rec: null, text: next } };
|
|
1312
|
+
}
|
|
1313
|
+
const text = `${line(given)}\n\n`;
|
|
1314
|
+
return { prepend: true, entry: { cid: null, id: null, level: 0, len: text.length, rec: null, text } };
|
|
1144
1315
|
}
|
|
1145
1316
|
|
|
1146
1317
|
// An ended subtask's own record in the archive document: its title and brief, and the card it belongs to.
|
|
@@ -1150,7 +1321,7 @@ class ZtrackBoard {
|
|
|
1150
1321
|
const metadata = { subtask_of: fileId(parentId), ...Object.fromEntries(Object.entries(task).filter(([k, v]) => declared(k, v))) };
|
|
1151
1322
|
const card = { ...parsed, metadata, machine: state.machine || undefined, relations: [] };
|
|
1152
1323
|
const serialized = this.preset.serializeKanbanCard(card);
|
|
1153
|
-
return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1324
|
+
return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1154
1325
|
}
|
|
1155
1326
|
|
|
1156
1327
|
// ------------------------------------------------------------------ migration from a SQLite board
|
|
@@ -1176,10 +1347,15 @@ class ZtrackBoard {
|
|
|
1176
1347
|
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));
|
|
1177
1348
|
};
|
|
1178
1349
|
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)}`);
|
|
1179
|
-
|
|
1350
|
+
// a card's number is copied before its row, which keeps it; the numbers' high-water mark comes with them, so no
|
|
1351
|
+
// number the old board handed out is handed out again
|
|
1352
|
+
const sourceTables = source.db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()
|
|
1353
|
+
.sort((a, b) => Number(b.name === CARD_NUMBERS) - Number(a.name === CARD_NUMBERS));
|
|
1354
|
+
for (const table of sourceTables) {
|
|
1180
1355
|
if (!this.tables.has(table.name)) continue;
|
|
1181
1356
|
for (const row of source.db.prepare(`SELECT * FROM ${quote(table.name)}`).all()) insert(table.name, row);
|
|
1182
1357
|
}
|
|
1358
|
+
raiseCardNumbers(this.conn, Number(source.db.prepare('SELECT seq FROM sqlite_sequence WHERE name = ?').get(CARD_NUMBERS)?.seq ?? 0));
|
|
1183
1359
|
for (const task of this.conn.prepare('SELECT * FROM main.tasks').all()) {
|
|
1184
1360
|
const old = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', task.id) ?? {};
|
|
1185
1361
|
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}`); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { projectCodexPaneResume } from '@volter/supercode-teams/codex-pane-resume';
|