@volter/supercode-orchestrator 0.5.206 → 0.5.207
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/cli.mjs +54 -11
- package/board/dispatch.mjs +9 -6
- package/board/engine.mjs +15 -4
- package/board/files.mjs +17 -3
- package/board/listing.mjs +3 -2
- package/board/notices.mjs +5 -0
- package/board/store.mjs +29 -0
- package/board/workflow.mjs +6 -4
- package/board/ztrack.mjs +121 -12
- package/package.json +5 -5
package/board/cli.mjs
CHANGED
|
@@ -93,6 +93,8 @@ const callerPath = (named) => notRuntime(resolve(current().cwd, named));
|
|
|
93
93
|
const SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
94
94
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
95
95
|
|
|
96
|
+
A card is named by its id (t_4406c88a) or by its number (42 or #42): the short number the board gave it when it was made.
|
|
97
|
+
|
|
96
98
|
The board's verbs, with their flags:
|
|
97
99
|
init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--dispatching on|off] [--archive-max-lines N] [--archive-period month]
|
|
98
100
|
migrate --backing ztrack --document <arcs.md> --status-map <json>
|
|
@@ -329,9 +331,9 @@ const ts = (t) => {
|
|
|
329
331
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
330
332
|
};
|
|
331
333
|
// a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
|
|
332
|
-
const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)}${t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
|
|
334
|
+
const line = (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(t.number, t.id).padEnd(16)} ${(t.stage ?? t.status).padEnd(8)}${t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
|
|
333
335
|
const cardDict = (t) => ({
|
|
334
|
-
id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
|
|
336
|
+
id: t.id, number: t.number ?? null, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
|
|
335
337
|
workspace_kind: t.workspace_kind, repository: t.workspace_anchor ?? null, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
|
|
336
338
|
created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
|
|
337
339
|
skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
|
|
@@ -366,6 +368,36 @@ const metadataOf = (v) => {
|
|
|
366
368
|
return got;
|
|
367
369
|
};
|
|
368
370
|
|
|
371
|
+
// A card is named by its id or by its number (`42`, `#42`, store.mjs layOutNumbers) in every place a verb takes a card:
|
|
372
|
+
// its card positionals (none, the first, the first two, or all of them) and its card flags. A number is read as its
|
|
373
|
+
// card's id before anything else reads the verb (its authorization included); a number no card here has is refused.
|
|
374
|
+
const NO_CARD_WORDS = new Set(['create', 'new', 'swarm', 'list', 'ls', 'migrate', 'init', 'boards', 'usage', 'events', 'listeners', 'dispatch',
|
|
375
|
+
'capacity', 'deliver', 'daemon', 'serve', 'gc', 'repair', 'dispatching', 'attach-rm', 'run-session', 'stats', 'assignees', 'diagnostics', 'diag',
|
|
376
|
+
'watch', 'help', 'github-usage']);
|
|
377
|
+
// the verbs that act on every positional as a card (each loops over them all)
|
|
378
|
+
const ALL_CARD_WORDS = new Set(['complete', 'archive', 'request-state', 'request_state', 'set-goal', 'unblock', 'reopen', 'reopen-review']);
|
|
379
|
+
const CARD_FLAGS = ['ids', 'subtask-of', 'parent', 'on', 'for-card', 'task', 'rm'];
|
|
380
|
+
const CARD_NUMBER = /^#?([1-9]\d*)$/;
|
|
381
|
+
function resolveCardNumbers(verb, rest, args, withBoard) {
|
|
382
|
+
const words = NO_CARD_WORDS.has(verb) ? 0 : ALL_CARD_WORDS.has(verb) ? rest.length : ['link', 'unlink'].includes(verb) ? 2 : 1;
|
|
383
|
+
const places = [];
|
|
384
|
+
for (let i = 0; i < Math.min(words, rest.length); i++) places.push([rest, i]);
|
|
385
|
+
for (const flag of CARD_FLAGS) {
|
|
386
|
+
if (Array.isArray(args[flag])) args[flag].forEach((_, i) => places.push([args[flag], i]));
|
|
387
|
+
else if (typeof args[flag] === 'string') places.push([args, flag]);
|
|
388
|
+
}
|
|
389
|
+
const numbered = places.filter(([holder, key]) => CARD_NUMBER.test(String(holder[key])));
|
|
390
|
+
if (!numbered.length) return;
|
|
391
|
+
withBoard((b) => view(b, (db) => {
|
|
392
|
+
for (const [holder, key] of numbered) {
|
|
393
|
+
const n = Number(CARD_NUMBER.exec(String(holder[key]))[1]);
|
|
394
|
+
const id = engine.cardByNumber(db, n);
|
|
395
|
+
if (!id) throw new Error(`no card #${n} on this board`);
|
|
396
|
+
holder[key] = id;
|
|
397
|
+
}
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
|
|
369
401
|
/** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. */
|
|
370
402
|
export async function runVerb(argv) {
|
|
371
403
|
const args = parse(argv);
|
|
@@ -390,6 +422,7 @@ export async function runVerb(argv) {
|
|
|
390
422
|
const open = (create = false) => { const b = openBoard(root, slug, { create, document: args.document ?? null, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
|
|
391
423
|
const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
|
|
392
424
|
const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
|
|
425
|
+
resolveCardNumbers(verb, rest, args, withBoard);
|
|
393
426
|
const id = rest[0];
|
|
394
427
|
const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
|
|
395
428
|
const ids = (first = rest) => [...first, ...(args.ids ?? [])];
|
|
@@ -546,7 +579,7 @@ export async function runVerb(argv) {
|
|
|
546
579
|
const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
|
|
547
580
|
if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
|
|
548
581
|
const said = answer.adoption_requested ? `; held for ${answer.adoption_requested}, which the dispatcher binds on its next tick` : answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
|
|
549
|
-
say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
|
|
582
|
+
say(args, `${created.existing ? 'Existing' : 'Created'} ${engine.cardLabel(task.number, created.id)} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
|
|
550
583
|
return answer.failed ? 1 : 0;
|
|
551
584
|
}
|
|
552
585
|
|
|
@@ -612,14 +645,16 @@ export async function runVerb(argv) {
|
|
|
612
645
|
say(args, '', { task: cardDict(t), side_sessions: withBoard((b) => sideSessions(b.db, t.id)), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, time: card.time, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
|
|
613
646
|
return 0;
|
|
614
647
|
}
|
|
615
|
-
const
|
|
648
|
+
const labels = withBoard((b) => view(b, (db) => engine.numbersOf(db, [t.subtask_of, ...card.parents.map((p) => p.id), ...card.children].filter(Boolean))));
|
|
649
|
+
const label = (cardId) => engine.cardLabel(labels.get(cardId), cardId);
|
|
650
|
+
const out = [`Task ${engine.cardLabel(t.number, t.id)}: ${t.title}`, ` status: ${taskStatusOf(card.stage, t.completed_at)} (lane ${card.stage}${card.stage !== t.status ? `, ${t.status} in kanban.db` : ''})`, ` assignee: ${t.assignee || '-'}`];
|
|
616
651
|
if (t.machine) out.push(` machine: ${t.machine}`);
|
|
617
652
|
if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
|
|
618
653
|
if (t.detached) out.push(` session: ${t.detached.session.address} belongs to ${t.detached.person} (detached ${ts(t.detached.at)}; dispatcher does not own it)`);
|
|
619
654
|
// where the card's time went: each role's runs, and the rest waiting
|
|
620
655
|
const mins = (sec) => `${Math.round(sec / 60)}m`;
|
|
621
656
|
out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
|
|
622
|
-
if (t.subtask_of) out.push(` subtask of: ${t.subtask_of}`);
|
|
657
|
+
if (t.subtask_of) out.push(` subtask of: ${label(t.subtask_of)}`);
|
|
623
658
|
if (t.tenant) out.push(` tenant: ${t.tenant}`);
|
|
624
659
|
out.push(` workspace: ${t.workspace_kind}${t.workspace_anchor ? `:${t.workspace_anchor}` : ''}`);
|
|
625
660
|
if (t.workspace_path) out.push(` resolved: ${t.workspace_path}`);
|
|
@@ -638,9 +673,9 @@ export async function runVerb(argv) {
|
|
|
638
673
|
}
|
|
639
674
|
if (t.started_at) out.push(` started: ${ts(t.started_at)}`);
|
|
640
675
|
if (t.completed_at) out.push(` completed: ${ts(t.completed_at)}`);
|
|
641
|
-
if (card.parents.length) out.push(` parents: ${card.parents.map((p) => p.id).join(', ')}`);
|
|
642
|
-
if (card.children.length) out.push(` children: ${card.children.join(', ')}`);
|
|
643
|
-
if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${s.id} (${s.status})`).join(', ')}`);
|
|
676
|
+
if (card.parents.length) out.push(` parents: ${card.parents.map((p) => label(p.id)).join(', ')}`);
|
|
677
|
+
if (card.children.length) out.push(` children: ${card.children.map(label).join(', ')}`);
|
|
678
|
+
if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${engine.cardLabel(s.number, s.id)} (${s.status})`).join(', ')}`);
|
|
644
679
|
const side = withBoard((b) => sideSessions(b.db, t.id));
|
|
645
680
|
if (side.length) out.push(` side sessions: ${side.map((s) => s.address).join(', ')}`);
|
|
646
681
|
if (t.body) out.push('', 'Body:', t.body);
|
|
@@ -1063,16 +1098,24 @@ export async function runVerb(argv) {
|
|
|
1063
1098
|
const reports = await plan({ root, lanes, settings });
|
|
1064
1099
|
const out = { capacity: reports.capacity, would: reports.flatMap((r) => r.would.map((w) => w.id)), waiting: reports.flatMap((r) => r.queued.map(([id, why]) => ({ id, why }))) };
|
|
1065
1100
|
if (args.json) { say(args, '', out); return 0; }
|
|
1101
|
+
// each card as a person reads it, by the number its own board gave it
|
|
1102
|
+
const shown = [...out.would, ...out.waiting.map((w) => w.id), ...out.capacity.flatMap((cap) => cap.groups.flatMap((g) => g.cards))];
|
|
1103
|
+
const labels = new Map();
|
|
1104
|
+
for (const board of listBoards(root)) {
|
|
1105
|
+
const b = openBoard(root, board);
|
|
1106
|
+
try { for (const [cardId, n] of view(b, (db) => engine.numbersOf(db, shown))) labels.set(cardId, engine.cardLabel(n, cardId)); } finally { b.close(); }
|
|
1107
|
+
}
|
|
1108
|
+
const label = (cardId) => labels.get(cardId) ?? cardId;
|
|
1066
1109
|
const lines = [];
|
|
1067
1110
|
for (const cap of out.capacity) {
|
|
1068
1111
|
const filter = Object.entries(cap.where ?? {}).map(([k, v]) => `${k} ${[].concat(v).join('|')}`).join(', ');
|
|
1069
1112
|
const except = Object.entries(cap.unless ?? {}).map(([k, v]) => `${k} ${[].concat(v).join('|')}`).join(', ');
|
|
1070
1113
|
lines.push(`cap "${cap.name}": ${cap.counts} per ${cap.scope}${filter ? `, where ${filter}` : ''}${except ? `, unless ${except}` : ''}, max ${cap.max}`);
|
|
1071
1114
|
if (!cap.groups.length) lines.push(' (nothing counted)');
|
|
1072
|
-
for (const g of cap.groups) lines.push(` ${cap.scope === 'global' ? 'home' : g.group ?? '(none)'}: ${g.count} of ${g.max ?? 'no max'} ${[...new Set(g.cards)].join(' ')}`);
|
|
1115
|
+
for (const g of cap.groups) lines.push(` ${cap.scope === 'global' ? 'home' : g.group ?? '(none)'}: ${g.count} of ${g.max ?? 'no max'} ${[...new Set(g.cards)].map(label).join(', ')}`);
|
|
1073
1116
|
}
|
|
1074
|
-
lines.push(out.would.length ? `would start: ${out.would.join(' ')}` : 'would start: nothing');
|
|
1075
|
-
for (const w of out.waiting) lines.push(`${w.id} ${w.why}`);
|
|
1117
|
+
lines.push(out.would.length ? `would start: ${out.would.map(label).join(', ')}` : 'would start: nothing');
|
|
1118
|
+
for (const w of out.waiting) lines.push(`${label(w.id)} ${w.why}`);
|
|
1076
1119
|
current().out(`${lines.join('\n')}\n`);
|
|
1077
1120
|
return 0;
|
|
1078
1121
|
}
|
package/board/dispatch.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, reques
|
|
|
51
51
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
52
52
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
53
53
|
import { evaluate, holds, render } from './expr.mjs';
|
|
54
|
-
import { identifyNotice } from './notices.mjs';
|
|
54
|
+
import { identifyNotice, numbered } from './notices.mjs';
|
|
55
55
|
import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
|
|
56
56
|
import { pendingRequestMail } from './request-mail.mjs';
|
|
57
57
|
import {
|
|
@@ -1800,7 +1800,9 @@ export async function sessionNotice(board, e) {
|
|
|
1800
1800
|
}
|
|
1801
1801
|
const address = e.address ?? addressOf(prior);
|
|
1802
1802
|
if (!address) throw new Error('lifecycle notice awaits the owned launch session identity');
|
|
1803
|
-
const
|
|
1803
|
+
const card = view(board, (db) => getTask(db, e.id));
|
|
1804
|
+
const subject = numbered(card?.number, `${String(e.verb ?? 'run_changed').replaceAll('_', ' ')} · run ${e.runId} · event ${e.sourceEvent ?? 'none'} · ${card?.title || e.id}`);
|
|
1805
|
+
const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject });
|
|
1804
1806
|
tx(board, (db) => {
|
|
1805
1807
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
|
|
1806
1808
|
if (session?.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
|
|
@@ -1819,7 +1821,8 @@ export async function sessionEffect(board, e) {
|
|
|
1819
1821
|
const address = addressOf(session);
|
|
1820
1822
|
if (!address) throw new Error('actor session identity unavailable');
|
|
1821
1823
|
// filed in the actor's mailbox, which delivers it
|
|
1822
|
-
const
|
|
1824
|
+
const card = view(board, (db) => getTask(db, e.id));
|
|
1825
|
+
const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject: numbered(card?.number, `actor notice · run ${e.runId ?? 'none'} · ${card?.title || e.id}`) });
|
|
1823
1826
|
if (filed.new) tx(board, db => event(db, e.id, 'actor_notice_filed', { notice: e.durableId, address, message_id: filed.message_id }, e.runId));
|
|
1824
1827
|
return true;
|
|
1825
1828
|
}
|
|
@@ -2862,7 +2865,7 @@ export async function fileBoardMail(board, report, taskId = null) {
|
|
|
2862
2865
|
const first = mail.members[0];
|
|
2863
2866
|
try {
|
|
2864
2867
|
const task = view(board, db => getTask(db, first.task_id));
|
|
2865
|
-
const subject = mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}
|
|
2868
|
+
const subject = numbered(task?.number, mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`);
|
|
2866
2869
|
const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
|
|
2867
2870
|
tx(board, db => {
|
|
2868
2871
|
if (filed.detached) {
|
|
@@ -2893,7 +2896,7 @@ export function noticeOf(entry, audience, kind, task, payload, comment = null) {
|
|
|
2893
2896
|
if (template === undefined || template === null) return null;
|
|
2894
2897
|
const cut = (text, n) => String(text ?? '').slice(0, Number(n));
|
|
2895
2898
|
const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut });
|
|
2896
|
-
return text.trim() ? text : null;
|
|
2899
|
+
return text.trim() ? numbered(task?.number, text) : null;
|
|
2897
2900
|
}
|
|
2898
2901
|
|
|
2899
2902
|
/**
|
|
@@ -3067,7 +3070,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
3067
3070
|
if (Number(currentSub.last_event_id) >= ev.id) { cursor = Math.max(cursor, Number(currentSub.last_event_id)); continue; }
|
|
3068
3071
|
const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
|
|
3069
3072
|
// filed under its fixed key: a second pass filing the same notice files nothing new
|
|
3070
|
-
const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}
|
|
3073
|
+
const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: numbered(task?.number, `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}`),
|
|
3071
3074
|
eligible: () => view(board, db => {
|
|
3072
3075
|
const currentSub = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
|
|
3073
3076
|
return Boolean(currentSub) && Number(currentSub.last_event_id) < ev.id;
|
package/board/engine.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { existsSync, rmSync } from 'node:fs';
|
|
|
15
15
|
import { join, resolve } from 'node:path';
|
|
16
16
|
import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
|
|
17
17
|
import { DatabaseSync } from 'node:sqlite';
|
|
18
|
-
import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
18
|
+
import { view, actorOf, principalOf, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
19
19
|
import { assertSourceReceipt, completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
20
20
|
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
21
21
|
import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
|
|
@@ -38,11 +38,22 @@ const one = (db, sql, ...args) => db.prepare(sql).get(...args);
|
|
|
38
38
|
const all = (db, sql, ...args) => db.prepare(sql).all(...args);
|
|
39
39
|
const run = (db, sql, ...args) => db.prepare(sql).run(...args);
|
|
40
40
|
|
|
41
|
+
// A card's number (#42): what the store gave it when it was made (store.mjs layOutNumbers). A person names a card by
|
|
42
|
+
// it wherever its id is taken (`42` or `#42`), and every view of a card shows it first, before the id.
|
|
43
|
+
/** Card `n`'s id on this board, or null when no card here has that number. */
|
|
44
|
+
export const cardByNumber = (db, n) => one(db, `SELECT t.id FROM ${CARD_NUMBERS} n JOIN tasks t ON t.id = n.task_id WHERE n.number = ?`, Number(n))?.id ?? null;
|
|
45
|
+
/** The number of card `id`, or null. */
|
|
46
|
+
export const numberOf = (db, id) => one(db, `SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`, id)?.number ?? null;
|
|
47
|
+
/** The numbers of cards `ids`: id → number. */
|
|
48
|
+
export const numbersOf = (db, ids) => new Map(ids.length ? all(db, `SELECT task_id, number FROM ${CARD_NUMBERS} WHERE task_id IN (SELECT value FROM json_each(?))`, JSON.stringify([...new Set(ids)])).map((r) => [r.task_id, r.number]) : []);
|
|
49
|
+
/** A card as a person reads it: its number first, then its id (`#42 t_4406c88a`). */
|
|
50
|
+
export const cardLabel = (number, id) => (number ? `#${number} ${id}` : String(id));
|
|
51
|
+
|
|
41
52
|
export function getTask(db, id) {
|
|
42
53
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
43
54
|
if (!row) return null;
|
|
44
55
|
const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
|
|
45
|
-
return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
|
|
56
|
+
return { ...row, number: numberOf(db, id), workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
|
|
46
57
|
tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), sponsor: ours?.sponsor ?? null, sponsor_evidence: json(ours?.sponsor_evidence, null), requested_by_unverified: json(ours?.requested_by_unverified, null) };
|
|
47
58
|
}
|
|
48
59
|
|
|
@@ -880,7 +891,7 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
|
|
|
880
891
|
// ------------------------------------------------------------------ reading
|
|
881
892
|
|
|
882
893
|
export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
|
|
883
|
-
const rows = all(db, `SELECT t.*, c.machine, c.subtask_of, c.stage, c.tags, c.sponsor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
|
|
894
|
+
const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.sponsor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
|
|
884
895
|
WHERE (? IS NULL OR t.status = ? OR c.stage = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
|
|
885
896
|
ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
|
|
886
897
|
return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null) }));
|
|
@@ -892,7 +903,7 @@ export function showCard(db, id) {
|
|
|
892
903
|
task,
|
|
893
904
|
parents: parentsOf(db, id),
|
|
894
905
|
children: childrenOf(db, id),
|
|
895
|
-
subtasks: all(db,
|
|
906
|
+
subtasks: all(db, `SELECT t.id, n.number, t.status, t.title FROM tasks t JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, id),
|
|
896
907
|
comments: all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY id', id),
|
|
897
908
|
runs: all(db, 'SELECT * FROM task_runs WHERE task_id = ? ORDER BY id', id).map((r) => ({ ...r, metadata: json(r.metadata, null) })),
|
|
898
909
|
events: all(db, 'SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? ORDER BY id', id).map((e) => ({ ...e, payload: json(e.payload, null) })),
|
package/board/files.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { fenced } from './fence.mjs';
|
|
13
13
|
import { markRemovedHeads } from './published.mjs';
|
|
14
|
+
import { CARD_NUMBERS } from './store.mjs';
|
|
14
15
|
import { createHash } from 'node:crypto';
|
|
15
16
|
import { join } from 'node:path';
|
|
16
17
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -98,6 +99,9 @@ class FilesBoard {
|
|
|
98
99
|
const state = this.stateText === null ? null : JSON.parse(this.stateText);
|
|
99
100
|
for (const sql of state?.schema ?? []) mem.exec(sql);
|
|
100
101
|
this.init(mem, { fresh: !state?.schema?.length });
|
|
102
|
+
// the schema as the directory keeps it: a layout this load added (a table, a column, the card numbers) is written to
|
|
103
|
+
// every card's file by the next write (persist)
|
|
104
|
+
this.keptSchema = state?.schema ?? [];
|
|
101
105
|
const columns = (table) => new Set(mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
|
|
102
106
|
const insert = (table, rows) => {
|
|
103
107
|
const have = columns(table);
|
|
@@ -108,17 +112,26 @@ class FilesBoard {
|
|
|
108
112
|
};
|
|
109
113
|
const previous = this.texts;
|
|
110
114
|
this.texts = new Map();
|
|
115
|
+
const cards = [];
|
|
111
116
|
for (const name of readdirSync(this.dir)) {
|
|
112
117
|
if (!name.endsWith('.json') || name.startsWith('_')) continue;
|
|
113
118
|
const text = readFileSync(join(this.dir, name), 'utf8');
|
|
114
119
|
const card = JSON.parse(text);
|
|
115
120
|
this.texts.set(card.id, text);
|
|
116
|
-
|
|
121
|
+
cards.push(card);
|
|
117
122
|
}
|
|
118
|
-
|
|
123
|
+
// The id counters first: a number handed out before stays handed out.
|
|
119
124
|
for (const { name, seq } of state?.sequence ?? []) {
|
|
120
125
|
if (mem.prepare('UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?').run(seq, name).changes === 0) mem.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(name, seq);
|
|
121
126
|
}
|
|
127
|
+
// Inserting a card's row numbers it when it has no number yet (store.mjs layOutNumbers): every number a card file
|
|
128
|
+
// holds loads first, so each card keeps its own and a new one is past them all, and then the cards load in the
|
|
129
|
+
// order they were made.
|
|
130
|
+
for (const card of cards) if (card.tables?.[CARD_NUMBERS]) insert(CARD_NUMBERS, card.tables[CARD_NUMBERS]);
|
|
131
|
+
const madeAt = (card) => Number(card.tables?.tasks?.[0]?.created_at ?? 0);
|
|
132
|
+
cards.sort((a, b) => madeAt(a) - madeAt(b) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0));
|
|
133
|
+
for (const card of cards) for (const [table, rows] of Object.entries(card.tables ?? {})) if (table !== CARD_NUMBERS) insert(table, rows);
|
|
134
|
+
for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
|
|
122
135
|
// Loading inserts every card, which marks every card changed (published.mjs). The first load of this projection keeps
|
|
123
136
|
// those marks: its next write compares each card with its published head once (a named limit: each projection opened
|
|
124
137
|
// compares every card once, as each load already reads every card's file). A reload keeps the marks not yet
|
|
@@ -172,7 +185,8 @@ class FilesBoard {
|
|
|
172
185
|
/** Write the files of the cards the transaction touched, then `_board.json`; writes compare the content hash. */
|
|
173
186
|
persist(schemaBefore) {
|
|
174
187
|
const schema = this.schema();
|
|
175
|
-
|
|
188
|
+
// reshaped by this transaction, or by the load it runs on (a layout the directory does not keep yet)
|
|
189
|
+
const reshaped = schema.join('\n') !== schemaBefore.join('\n') || schema.join('\n') !== (this.keptSchema ?? []).join('\n');
|
|
176
190
|
const touched = new Set(this.mem.prepare('SELECT DISTINCT id FROM _touched').all().map((r) => r.id).filter((id) => id !== null));
|
|
177
191
|
if (reshaped) this.watch(); // a table made in the transaction gets its triggers; every card is written again
|
|
178
192
|
const keys = this.keys;
|
package/board/listing.mjs
CHANGED
|
@@ -68,11 +68,12 @@ export const LAST_BLOCK_SQL = `SELECT CASE WHEN kind = 'blocked' THEN payload EN
|
|
|
68
68
|
|
|
69
69
|
export function listPageSql(detailed, tagCount = 0) {
|
|
70
70
|
return `SELECT t.rowid AS list_order,${(detailed ? JSON_FIELDS : HUMAN_FIELDS).split(',').map(name => `t.${name}`).join(',')},
|
|
71
|
-
c.machine,c.stage${detailed ? ',c.subtask_of' : ''},
|
|
71
|
+
n.number,c.machine,c.stage${detailed ? ',c.subtask_of' : ''},
|
|
72
72
|
r.id AS run_id,r.status AS run_status,r.started_at AS run_started_at,r.ended_at AS run_ended_at,${RUN_METADATA} AS run_metadata
|
|
73
73
|
${detailed ? `,(SELECT json_group_array(parent_id) FROM (SELECT parent_id FROM task_links WHERE child_id = t.id ORDER BY parent_id)) AS parents_json,
|
|
74
74
|
(${LAST_BLOCK_SQL}) AS block_payload` : ''}
|
|
75
|
-
FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN
|
|
75
|
+
FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN supercode_card_numbers n ON n.task_id = t.id
|
|
76
|
+
LEFT JOIN task_runs r ON r.id = t.current_run_id
|
|
76
77
|
WHERE t.id IN (SELECT value FROM json_each(?)) AND (? IS NULL OR t.status = ? OR c.stage = ?)
|
|
77
78
|
AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
|
|
78
79
|
AND (? IS NULL OR t.tenant = ?) AND (? IS NULL OR t.session_id = ?)
|
package/board/notices.mjs
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** A notice's text or a mail's subject about a card, with the card's number first (`#42 …`; store.mjs layOutNumbers). */
|
|
2
|
+
export const numbered = (number, text) => (number ? `#${number} ${text}` : text);
|
|
3
|
+
/** The number of card `id` in the board's store, or null. */
|
|
4
|
+
export const cardNumber = (db, id) => db.prepare('SELECT number FROM supercode_card_numbers WHERE task_id = ?').get(id)?.number ?? null;
|
|
5
|
+
|
|
1
6
|
export function identifyNotice(event, text) {
|
|
2
7
|
const time = new Date(Number(event.created_at) * 1000).toISOString();
|
|
3
8
|
const kind = String(event.kind).replaceAll('_', ' ');
|
package/board/store.mjs
CHANGED
|
@@ -276,6 +276,34 @@ function layOutHistoryIndexes(db) {
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// A card's number (#42): short and sequential, given once when the card is made, never changed and never given to
|
|
280
|
+
// another card, as an issue tracker numbers its issues. The store gives it, whoever makes the card (a verb, a document
|
|
281
|
+
// import, Hermes's own writer): a trigger on `tasks` numbers each new card id. AUTOINCREMENT never hands a number out
|
|
282
|
+
// twice, and a number stays with its card id while the card's row is replaced or removed. The trigger inserts only
|
|
283
|
+
// where the id has no number yet, so no conflict arises in it (a writing statement's own conflict clause would govern
|
|
284
|
+
// the trigger's insert: published.mjs). Its first installation numbers the cards already there, in the order they were
|
|
285
|
+
// made (created_at, then id), in the same savepoint.
|
|
286
|
+
export const CARD_NUMBERS = 'supercode_card_numbers';
|
|
287
|
+
function layOutNumbers(db) {
|
|
288
|
+
if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'supercode_number_cards'").get()) return;
|
|
289
|
+
db.exec('SAVEPOINT supercode_card_numbering');
|
|
290
|
+
try {
|
|
291
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${CARD_NUMBERS} (number INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL UNIQUE);
|
|
292
|
+
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;
|
|
293
|
+
CREATE TRIGGER IF NOT EXISTS supercode_number_cards AFTER INSERT ON tasks BEGIN
|
|
294
|
+
INSERT INTO ${CARD_NUMBERS} (task_id) SELECT NEW.id WHERE NOT EXISTS (SELECT 1 FROM ${CARD_NUMBERS} WHERE task_id = NEW.id);
|
|
295
|
+
END;`);
|
|
296
|
+
db.exec('RELEASE supercode_card_numbering');
|
|
297
|
+
} catch (error) { db.exec('ROLLBACK TO supercode_card_numbering; RELEASE supercode_card_numbering'); throw error; }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Raise the numbers' high-water mark to the highest number held, and to `floor`: AUTOINCREMENT hands out only numbers
|
|
301
|
+
* above it. For a store whose numbers were given or copied explicitly. */
|
|
302
|
+
export function raiseCardNumbers(db, floor = 0) {
|
|
303
|
+
db.prepare(`INSERT INTO sqlite_sequence (name, seq) SELECT ?, 0 WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = ?)`).run(CARD_NUMBERS, CARD_NUMBERS);
|
|
304
|
+
db.prepare(`UPDATE sqlite_sequence SET seq = MAX(seq, ?, (SELECT COALESCE(MAX(number), 0) FROM ${CARD_NUMBERS})) WHERE name = ?`).run(Number(floor) || 0, CARD_NUMBERS);
|
|
305
|
+
}
|
|
306
|
+
|
|
279
307
|
/** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
|
|
280
308
|
export function layOut(db, { fresh }) {
|
|
281
309
|
if (fresh) db.exec(SCHEMA);
|
|
@@ -287,6 +315,7 @@ export function layOut(db, { fresh }) {
|
|
|
287
315
|
// each read walked every card's row. Laid out here, after the column it names exists on a store of any age and before
|
|
288
316
|
// any statement is prepared; no statement names it, so a store without it is only slower.
|
|
289
317
|
db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
|
|
318
|
+
layOutNumbers(db);
|
|
290
319
|
layOutEventIndex(db);
|
|
291
320
|
layOutHistoryIndexes(db);
|
|
292
321
|
// A covering summary index keeps human listings out of task records' body overflow pages.
|
package/board/workflow.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { releaseAllSideSessions } from './side-sessions.mjs';
|
|
|
18
18
|
import { evaluate, holds, render, needsSources } from './expr.mjs';
|
|
19
19
|
import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
|
|
20
20
|
import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
|
|
21
|
-
import { identifyNotice } from './notices.mjs';
|
|
21
|
+
import { cardNumber, identifyNotice, numbered } from './notices.mjs';
|
|
22
22
|
|
|
23
23
|
const loaded = new Map();
|
|
24
24
|
// the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
|
|
@@ -593,7 +593,7 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
|
|
|
593
593
|
const defaultNotice = noticeSession
|
|
594
594
|
? '{{card.id}}: verified adoption started run {{run.id}} ({{event.from}} → {{event.to}}). Follow the board’s current state before continuing.'
|
|
595
595
|
: '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}';
|
|
596
|
-
const body = render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope);
|
|
596
|
+
const body = numbered(cardNumber(db, task.id), render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope));
|
|
597
597
|
const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
|
|
598
598
|
const text = identifyNotice({ id: source, kind: name, run_id: targetRun.id, created_at: now() }, body);
|
|
599
599
|
const effect = { kind: 'session_notice', id: task.id, runId: targetRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
|
|
@@ -825,12 +825,14 @@ function dueAfter(db, workflow, task, status, scope) {
|
|
|
825
825
|
|
|
826
826
|
/**
|
|
827
827
|
* Where a new card starts: the first `initial` transition that holds (`event` is the create's own words), its status
|
|
828
|
-
* set and its actions run (a goal card by the workflow's default, for one). Answers the status.
|
|
828
|
+
* set and its actions run (a goal card by the workflow's default, for one). Answers the status. A validator that fails
|
|
829
|
+
* refuses the create, as it refuses any other event (a home that keeps its root unassigned says so here).
|
|
829
830
|
*/
|
|
830
831
|
export function startNew(db, board, entry, task, event) {
|
|
831
832
|
const scope = scopeOf(db, board, task, { params: entry.params, event, extra: { lanes: lanesIn(entry) } });
|
|
832
833
|
const t = entry.workflow.initial.find((x) => holds(x.guard ?? null, scope));
|
|
833
834
|
if (!t?.target) throw new Error('the workflow says nowhere for a new card to start');
|
|
835
|
+
for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw new Error(render(v.message, scope));
|
|
834
836
|
const status = String(templateValue(t.target, scope));
|
|
835
837
|
if (!entry.workflow.statuses[status]) throw new Error(`the workflow starts a new card in ${status}, which it does not declare`);
|
|
836
838
|
setStatus(db, entry.workflow, task.id, status);
|
|
@@ -1186,7 +1188,7 @@ const ACTIONS = {
|
|
|
1186
1188
|
send_to_actor(ctx, text) {
|
|
1187
1189
|
const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
|
|
1188
1190
|
const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
1189
|
-
effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, text);
|
|
1191
|
+
effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, numbered(cardNumber(ctx.db, ctx.id), text));
|
|
1190
1192
|
run(ctx.db, 'UPDATE supercode_effects SET effect=? WHERE id=?', JSON.stringify(effect), Number(inserted.lastInsertRowid));
|
|
1191
1193
|
ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
1192
1194
|
},
|
package/board/ztrack.mjs
CHANGED
|
@@ -15,10 +15,10 @@ import { current } from './call.mjs';
|
|
|
15
15
|
import { fenced } from './fence.mjs';
|
|
16
16
|
import { createRequire } from 'node:module';
|
|
17
17
|
import { createHash, randomBytes } from 'node:crypto';
|
|
18
|
-
import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
20
20
|
import { DatabaseSync } from 'node:sqlite';
|
|
21
|
-
import { actorOf, contention, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
|
|
21
|
+
import { actorOf, CARD_NUMBERS, contention, raiseCardNumbers, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
|
|
22
22
|
import { GitDocument } from './git-document.mjs';
|
|
23
23
|
import { unimported } from './legacy-store.mjs';
|
|
24
24
|
import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
|
|
@@ -104,6 +104,9 @@ const ARCHIVE_LINES = 2000;
|
|
|
104
104
|
// document changed while it was read (readSnapshot).
|
|
105
105
|
const SNAPSHOT_TRIES = 8;
|
|
106
106
|
const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith('archived:');
|
|
107
|
+
// The open document's record of the numbers the board has given (numberMark), and a task line's number (`- [ ] #2836 c3`).
|
|
108
|
+
const NUMBER_MARK = /^The board has given card numbers up to #(\d+)\.[ \t]*$/m;
|
|
109
|
+
const TASK_NUMBER = /^\s*[-*] \[[ xX]\]\s+#(\d+)\s+[a-z]+\d+\s/gm;
|
|
107
110
|
|
|
108
111
|
const BOARD_TABLES = `
|
|
109
112
|
CREATE TABLE IF NOT EXISTS board_state (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
@@ -440,6 +443,13 @@ class ZtrackBoard {
|
|
|
440
443
|
put(`chunks:${role}`, entries);
|
|
441
444
|
}
|
|
442
445
|
put('published', published);
|
|
446
|
+
// The earlier store held no numbers: its cards take the numbers their documents' headings give them, and the rest
|
|
447
|
+
// are numbered in the order they were made, after every number the documents name (documentNumbers).
|
|
448
|
+
db.exec(`DELETE FROM ${CARD_NUMBERS}; DELETE FROM sqlite_sequence WHERE name = '${CARD_NUMBERS}'`);
|
|
449
|
+
const named = this.documentNumbers();
|
|
450
|
+
this.numberFromHeadings([...named.headings].filter(([id]) => db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(id)), db);
|
|
451
|
+
raiseCardNumbers(db, named.highest);
|
|
452
|
+
db.exec(`INSERT INTO ${CARD_NUMBERS} (task_id) SELECT id FROM tasks WHERE id NOT IN (SELECT task_id FROM ${CARD_NUMBERS}) ORDER BY created_at, id`);
|
|
443
453
|
db.exec('COMMIT');
|
|
444
454
|
}
|
|
445
455
|
|
|
@@ -556,6 +566,13 @@ class ZtrackBoard {
|
|
|
556
566
|
timedStep('board tx snapshot', () => this.snapshotHeld());
|
|
557
567
|
const result = timedStep('board tx body', () => fn(this.db));
|
|
558
568
|
if (this.contended) throw RETRY;
|
|
569
|
+
// The first write made with a codec that renders numbers (numbersRendered) after the store numbered its cards
|
|
570
|
+
// (store.mjs layOutNumbers) writes every card of the open document again, with its number in its heading and each
|
|
571
|
+
// open subtask's on its task line; a card made since is written with its number when it is made.
|
|
572
|
+
if (this.numbersRendered && !this.stateText('numbers_written')) {
|
|
573
|
+
this.statement('INSERT INTO temp.board_touched SELECT value FROM json_each(?)').run(JSON.stringify(this.entries('open').filter((e) => e.id).map((e) => nativeId(e.id))));
|
|
574
|
+
this.put('numbers_written', true);
|
|
575
|
+
}
|
|
559
576
|
const files = timedStep('board tx render', () => this.render());
|
|
560
577
|
const intent = files.length ? randomBytes(16).toString('hex') : null;
|
|
561
578
|
if (intent) {
|
|
@@ -658,7 +675,7 @@ class ZtrackBoard {
|
|
|
658
675
|
// connection-local copies by a read. Only the spans the edit touched are parsed.
|
|
659
676
|
// `held` names cards read again from their indexed span with no edit (see render).
|
|
660
677
|
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() };
|
|
678
|
+
const plan = { layout: new Map(), dropped: [], published: {}, cards: new Map(), numbers: new Map(), rows: new Map(), subtasks: new Map(), tasks: new Map(), remove: new Set(), errors: new Map(), checked: new Set(), before: new Map() };
|
|
662
679
|
const changed = new Set();
|
|
663
680
|
for (const doc of this.docs) {
|
|
664
681
|
const entries = this.entries(doc.role);
|
|
@@ -699,11 +716,13 @@ class ZtrackBoard {
|
|
|
699
716
|
catch (error) { plan.errors.set(id, error.message); continue; }
|
|
700
717
|
entry.rec = hash(JSON.stringify(records[0]));
|
|
701
718
|
plan.cards.set(id, card);
|
|
719
|
+
if (records[0].number) plan.numbers.set(id, records[0].number);
|
|
702
720
|
}
|
|
703
721
|
for (const [id, card] of plan.cards) {
|
|
704
722
|
plan.rows.set(id, this.cardRows(id, card, null));
|
|
705
723
|
const subtasks = [];
|
|
706
|
-
for (const [tid, taskCard, key, sources] of this.taskCards(id, card)) {
|
|
724
|
+
for (const [tid, taskCard, key, sources, number] of this.taskCards(id, card)) {
|
|
725
|
+
if (number) plan.numbers.set(tid, number);
|
|
707
726
|
plan.rows.set(tid, this.cardRows(tid, taskCard, id));
|
|
708
727
|
plan.tasks.set(tid, { key, sources });
|
|
709
728
|
subtasks.push(tid);
|
|
@@ -852,6 +871,13 @@ class ZtrackBoard {
|
|
|
852
871
|
this.statement(`DELETE FROM ${schema}.task_links WHERE child_id = ?`).run(id);
|
|
853
872
|
if (!overlay) { this.statement('DELETE FROM main.board_tasks WHERE id = ?').run(id); if (!initial) events.push({ id, kind: 'removed', payload: { by, source: 'document' } }); }
|
|
854
873
|
}
|
|
874
|
+
// A store made again from its documents (an empty one reading them whole) gives each card and subtask the number its
|
|
875
|
+
// heading or task line says, and hands out none at or below any number the documents name. Any other import keeps
|
|
876
|
+
// the store's numbers: a number typed into a heading or pasted with a section changes nothing.
|
|
877
|
+
if (initial) {
|
|
878
|
+
this.numberFromHeadings([...plan.numbers].filter(([id]) => plan.rows.has(id)));
|
|
879
|
+
raiseCardNumbers(this.conn, this.documentNumbers().highest);
|
|
880
|
+
}
|
|
855
881
|
for (const [id, rows] of plan.rows) {
|
|
856
882
|
const before = fingerprintOf(id);
|
|
857
883
|
const oldTask = this.one(`SELECT * FROM ${schema}.tasks WHERE id = ?`, id) ?? {};
|
|
@@ -927,7 +953,7 @@ class ZtrackBoard {
|
|
|
927
953
|
return { task, state };
|
|
928
954
|
}
|
|
929
955
|
|
|
930
|
-
// A card's Tasks as subtask cards: [id, card, key, sources].
|
|
956
|
+
// A card's Tasks as subtask cards: [id, card, key, sources, the number its line gives].
|
|
931
957
|
taskCards(id, card) {
|
|
932
958
|
return card.acceptanceCriteria.map((task) => {
|
|
933
959
|
const key = `${id}:${task.id}`;
|
|
@@ -945,7 +971,7 @@ class ZtrackBoard {
|
|
|
945
971
|
machine: task.metadata?.machine ?? storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
|
|
946
972
|
relations: (task.blockedBy ?? []).map((b) => ({ type: 'blocked-by', issueId: b.ac && /^(?:[ma]-[0-9a-f]{6,}|t_[0-9a-f]+)$/.test(b.ac) ? b.ac : b.ac ? `${nativeId(b.issue)}:${b.ac}` : nativeId(b.issue) })) };
|
|
947
973
|
if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
|
|
948
|
-
return [tid, taskCard, key, task.sources];
|
|
974
|
+
return [tid, taskCard, key, task.sources, task.number];
|
|
949
975
|
});
|
|
950
976
|
}
|
|
951
977
|
|
|
@@ -965,7 +991,8 @@ class ZtrackBoard {
|
|
|
965
991
|
return parents;
|
|
966
992
|
};
|
|
967
993
|
let parents = cardsTouched();
|
|
968
|
-
|
|
994
|
+
const mark = this.numbersRendered ? this.numberMark() : null;
|
|
995
|
+
if (!parents.size && !mark) return [];
|
|
969
996
|
const heldBy = (id) => this.errors.has(id) || this.one('SELECT 1 FROM main.supercode_cards WHERE subtask_of = ? AND task_id IN (SELECT id FROM main.board_errors)', id);
|
|
970
997
|
// A card held by a recorded refusal is not rendered, so the store's verbs move its tasks past its lines. When
|
|
971
998
|
// the store moves it, its span is read again: what the store moved past is stale and takes the store's value
|
|
@@ -978,6 +1005,7 @@ class ZtrackBoard {
|
|
|
978
1005
|
const where = new Map();
|
|
979
1006
|
for (const role of roles) layout[role].forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
|
|
980
1007
|
const edits = Object.fromEntries(roles.map((role) => [role, []]));
|
|
1008
|
+
if (mark) edits.open.push(mark);
|
|
981
1009
|
for (const id of parents) {
|
|
982
1010
|
if (heldBy(id)) continue;
|
|
983
1011
|
const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id);
|
|
@@ -1041,7 +1069,8 @@ class ZtrackBoard {
|
|
|
1041
1069
|
dropped.push(old.cid);
|
|
1042
1070
|
if (edit.remove) entries.splice(edit.index, 1); else entries[edit.index] = edit.entry;
|
|
1043
1071
|
}
|
|
1044
|
-
for (const edit of edits[role].filter((e) => e.
|
|
1072
|
+
for (const edit of edits[role].filter((e) => e.prepend)) { text = edit.entry.text + text; entries.unshift(edit.entry); }
|
|
1073
|
+
for (const edit of edits[role].filter((e) => e.index === undefined && !e.prepend)) {
|
|
1045
1074
|
if (text && !text.endsWith('\n\n')) {
|
|
1046
1075
|
// the blank line before a new section belongs to the span above it
|
|
1047
1076
|
const last = entries.at(-1), separator = text.endsWith('\n') ? '\n' : '\n\n';
|
|
@@ -1129,7 +1158,7 @@ class ZtrackBoard {
|
|
|
1129
1158
|
const messages = blocks.filter((block) => block.kind === 'message');
|
|
1130
1159
|
// A subtask is its line and its brief (its body, as Markdown under it); its id, status words, assignee and run
|
|
1131
1160
|
// output stay in the store (company RFC 0025 decision 4).
|
|
1132
|
-
card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, status: ENDED.has(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
|
|
1161
|
+
card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, ...this.numberOf(r.id), status: ENDED.has(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
|
|
1133
1162
|
...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
|
|
1134
1163
|
waitingOn: messages[0]?.id,
|
|
1135
1164
|
blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
|
|
@@ -1140,7 +1169,82 @@ class ZtrackBoard {
|
|
|
1140
1169
|
}) });
|
|
1141
1170
|
}
|
|
1142
1171
|
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 };
|
|
1172
|
+
return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// A card's number, shown first in its heading (`## #42 t_… — …`): the store's (store.mjs layOutNumbers). An edit to a
|
|
1176
|
+
// heading's number changes nothing; a store made again from its documents takes it back (numberFromHeadings). A card's
|
|
1177
|
+
// record keeps the key where the document codec reads it, after the id.
|
|
1178
|
+
numberOf(id) {
|
|
1179
|
+
if (!this.numbersRendered) return {};
|
|
1180
|
+
const number = this.one(`SELECT number FROM main.${CARD_NUMBERS} WHERE task_id = ?`, id)?.number;
|
|
1181
|
+
return number ? { number } : {};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Whether the document codec (@volter/ztrack) writes a card's number in its heading and a subtask's on its task
|
|
1185
|
+
// line. One that does not renders, reads and indexes cards as it always did; the open document is written with the
|
|
1186
|
+
// numbers by the first write made with one that does (tx), whichever was installed first.
|
|
1187
|
+
get numbersRendered() {
|
|
1188
|
+
if (this._numbersRendered === undefined) {
|
|
1189
|
+
try {
|
|
1190
|
+
const task = this.preset.serializeKanbanCard({ id: 't-00000000', title: 'number', summary: '', status: 'todo', body: '', acceptanceCriteria: [{ id: 'c1', number: 2, status: 'pending', evidence: [], text: 'number' }] }).body;
|
|
1191
|
+
this._numbersRendered = this.codec.renderBoardCard({ id: 't_00000000', number: 1, title: 'number', status: 'todo', body: '' }, 2).startsWith('## #1 t_00000000 ') && task.includes('- [ ] #2 c1 number');
|
|
1192
|
+
} catch { this._numbersRendered = false; }
|
|
1193
|
+
}
|
|
1194
|
+
return this._numbersRendered;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Give each card the number its document gives it (its heading, or its task line), where no other card holds that
|
|
1198
|
+
// number and the card has none yet; the numbers' high-water mark rises with them, so none is handed out again. Only
|
|
1199
|
+
// for a store made again from its documents (applyPlan, importRuntime).
|
|
1200
|
+
numberFromHeadings(numbers, db = this.conn) {
|
|
1201
|
+
if (!numbers.length) return;
|
|
1202
|
+
const give = db.prepare(`INSERT INTO ${CARD_NUMBERS} (number, task_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM ${CARD_NUMBERS} WHERE task_id = ? OR number = ?)`);
|
|
1203
|
+
for (const [id, number] of numbers) give.run(number, id, id, number);
|
|
1204
|
+
raiseCardNumbers(db);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// What the board's documents (the open one, the current archive and each rotated one beside it) say of numbers:
|
|
1208
|
+
// `headings`, card id → the number its heading gives, and `highest`, the highest number any of them names (a
|
|
1209
|
+
// heading, a task line, or the open document's record of the numbers given, numberMark).
|
|
1210
|
+
documentNumbers() {
|
|
1211
|
+
const headings = new Map();
|
|
1212
|
+
let highest = 0;
|
|
1213
|
+
const dir = dirname(this.path), named = basename(this.path);
|
|
1214
|
+
let rotated = [];
|
|
1215
|
+
try { rotated = readdirSync(dir).filter((name) => name.startsWith(`${named}.archive-`) && name.endsWith('.md')).map((name) => join(dir, name)); } catch { /* none */ }
|
|
1216
|
+
for (const path of [this.path, this.archive, ...rotated]) {
|
|
1217
|
+
const text = lf(read(path));
|
|
1218
|
+
for (const line of text.matchAll(TASK_NUMBER)) highest = Math.max(highest, Number(line[1]));
|
|
1219
|
+
if (path === this.path) highest = Math.max(highest, Number(NUMBER_MARK.exec(text)?.[1] ?? 0));
|
|
1220
|
+
let records = [];
|
|
1221
|
+
try { ({ records } = this.codec.inspectBoardDocument(text, path)); } catch { continue; }
|
|
1222
|
+
for (const record of records) {
|
|
1223
|
+
if (!record.number) continue;
|
|
1224
|
+
highest = Math.max(highest, record.number);
|
|
1225
|
+
if (!headings.has(nativeId(record.id))) headings.set(nativeId(record.id), record.number);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return { headings, highest };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// The open document records the highest number the store has given (a line of its preamble, NUMBER_MARK), so a store
|
|
1232
|
+
// made again from the documents hands out none at or below it, whatever card held it: a subtask filed and gone, or
|
|
1233
|
+
// a card the documents no longer show. The edit that brings the line up to the store's count, or null.
|
|
1234
|
+
numberMark() {
|
|
1235
|
+
const given = Number(this.one('SELECT seq FROM main.sqlite_sequence WHERE name = ?', CARD_NUMBERS)?.seq ?? 0);
|
|
1236
|
+
if (!given) return null;
|
|
1237
|
+
const line = `The board has given card numbers up to #${given}.`;
|
|
1238
|
+
const first = this.entries('open')[0];
|
|
1239
|
+
if (first && first.id === null) {
|
|
1240
|
+
const text = this.texts([first])[0].text;
|
|
1241
|
+
const said = NUMBER_MARK.exec(text);
|
|
1242
|
+
if (said && Number(said[1]) >= given) return null;
|
|
1243
|
+
const next = said ? text.replace(NUMBER_MARK, line) : `${text.replace(/\n*$/, '')}\n\n${line}\n\n`;
|
|
1244
|
+
return { index: 0, entry: { cid: null, id: null, level: 0, len: next.length, rec: null, text: next } };
|
|
1245
|
+
}
|
|
1246
|
+
const text = `${line}\n\n`;
|
|
1247
|
+
return { prepend: true, entry: { cid: null, id: null, level: 0, len: text.length, rec: null, text } };
|
|
1144
1248
|
}
|
|
1145
1249
|
|
|
1146
1250
|
// An ended subtask's own record in the archive document: its title and brief, and the card it belongs to.
|
|
@@ -1150,7 +1254,7 @@ class ZtrackBoard {
|
|
|
1150
1254
|
const metadata = { subtask_of: fileId(parentId), ...Object.fromEntries(Object.entries(task).filter(([k, v]) => declared(k, v))) };
|
|
1151
1255
|
const card = { ...parsed, metadata, machine: state.machine || undefined, relations: [] };
|
|
1152
1256
|
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 };
|
|
1257
|
+
return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1154
1258
|
}
|
|
1155
1259
|
|
|
1156
1260
|
// ------------------------------------------------------------------ migration from a SQLite board
|
|
@@ -1176,10 +1280,15 @@ class ZtrackBoard {
|
|
|
1176
1280
|
if (keys.length) this.statement(`INSERT OR REPLACE INTO main.${quote(table)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...keys.map((k) => row[k] ?? null));
|
|
1177
1281
|
};
|
|
1178
1282
|
for (const table of this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all()) this.conn.exec(`DELETE FROM main.${quote(table.name)}`);
|
|
1179
|
-
|
|
1283
|
+
// a card's number is copied before its row, which keeps it; the numbers' high-water mark comes with them, so no
|
|
1284
|
+
// number the old board handed out is handed out again
|
|
1285
|
+
const sourceTables = source.db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()
|
|
1286
|
+
.sort((a, b) => Number(b.name === CARD_NUMBERS) - Number(a.name === CARD_NUMBERS));
|
|
1287
|
+
for (const table of sourceTables) {
|
|
1180
1288
|
if (!this.tables.has(table.name)) continue;
|
|
1181
1289
|
for (const row of source.db.prepare(`SELECT * FROM ${quote(table.name)}`).all()) insert(table.name, row);
|
|
1182
1290
|
}
|
|
1291
|
+
raiseCardNumbers(this.conn, Number(source.db.prepare('SELECT seq FROM sqlite_sequence WHERE name = ?').get(CARD_NUMBERS)?.seq ?? 0));
|
|
1183
1292
|
for (const task of this.conn.prepare('SELECT * FROM main.tasks').all()) {
|
|
1184
1293
|
const old = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', task.id) ?? {};
|
|
1185
1294
|
if (old.subtask_of) { const key = /^([a-z]+\d+)\s/.exec(task.title)?.[1]; if (key) this.statement('INSERT OR REPLACE INTO main.board_tasks (id, key) VALUES (?, ?)').run(task.id, `${old.subtask_of}:${key}`); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.207",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@volter/supercode-teams": "^0.3.
|
|
36
|
-
"@volter/supercode-terminal": "^0.3.
|
|
37
|
-
"@volter/supercode-harness-sdk": "^0.3.
|
|
38
|
-
"@volter/teams": "0.2.
|
|
35
|
+
"@volter/supercode-teams": "^0.3.344",
|
|
36
|
+
"@volter/supercode-terminal": "^0.3.10",
|
|
37
|
+
"@volter/supercode-harness-sdk": "^0.3.104",
|
|
38
|
+
"@volter/teams": "0.2.96",
|
|
39
39
|
"@volter/ztrack": "^2.4.23",
|
|
40
40
|
"yaml": "^2.9.0"
|
|
41
41
|
},
|