@volter/supercode-orchestrator 0.5.3 → 0.5.4
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/git-document.mjs +97 -0
- package/board/ztrack.mjs +82 -17
- package/package.json +3 -3
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// THE BOARD DOCUMENT IN GIT (company RFC 0025 decision 3; ADR 0001 points 2 and 3): when the board's document lives in a
|
|
2
|
+
// git checkout and the board says so (`"commit": true` in its ztrack.json), each write the manager does not make (its
|
|
3
|
+
// `"manager"`) is committed with its doer as author and pushed, after the checkout has taken what others pushed. People edit the document both here
|
|
4
|
+
// (through the board's verbs) and as pages (Git Notes, where an edit is a pull request under their own account): a pull
|
|
5
|
+
// request merged on the remote reaches the board before its next write, and a write that raced one takes it in a merge,
|
|
6
|
+
// the two texts of a board document merged by ztrack's own document merge, in which a hand edit wins its cards. Nothing
|
|
7
|
+
// either side wrote is dropped; a merge that cannot be made stops the push and says why, leaving both texts in the
|
|
8
|
+
// checkout's history.
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { basename, dirname, relative } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const git = (cwd, args, input) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], ...(input === undefined ? {} : { input }), timeout: 120_000 });
|
|
14
|
+
const quiet = (cwd, args) => { try { return git(cwd, args); } catch { return null; } };
|
|
15
|
+
// Another board process (or the manager's own git) may hold git's index for a moment: its index.lock is waited out, not
|
|
16
|
+
// raced. The board keeps no lock of its own (ADR 0003).
|
|
17
|
+
const indexed = (cwd, args) => {
|
|
18
|
+
for (let attempt = 0; ; attempt++) {
|
|
19
|
+
try { return git(cwd, args); } catch (error) {
|
|
20
|
+
if (attempt >= 20 || !/index\.lock/.test(String(error.stderr ?? error.message))) throw error;
|
|
21
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class GitDocument {
|
|
27
|
+
constructor(paths, merge) {
|
|
28
|
+
this.paths = paths;
|
|
29
|
+
this.cwd = dirname(paths[0]);
|
|
30
|
+
this.root = quiet(this.cwd, ['rev-parse', '--show-toplevel'])?.trim() ?? null;
|
|
31
|
+
this.merge = merge; // (base, theirs, ours, path) => text | null, ztrack's mergeBoardDocument
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get usable() { return this.root !== null; }
|
|
35
|
+
rel(path) { return relative(this.root, path); }
|
|
36
|
+
branch() { return git(this.root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); }
|
|
37
|
+
|
|
38
|
+
// Before a write: take what was pushed, so a merged pull request's edit is the document the write starts from.
|
|
39
|
+
pull() {
|
|
40
|
+
if (!this.usable) return;
|
|
41
|
+
const branch = this.branch();
|
|
42
|
+
if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) return; // offline: the next write tries again
|
|
43
|
+
const behind = Number(git(this.root, ['rev-list', '--count', `HEAD..origin/${branch}`]).trim());
|
|
44
|
+
if (behind === 0) return;
|
|
45
|
+
const dirty = git(this.root, ['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))]).trim();
|
|
46
|
+
if (dirty) return; // a write in progress publishes first and takes what was pushed after
|
|
47
|
+
this.integrate(branch, 'board');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// After a write: commit the board documents as their doer, then push, taking anything pushed meanwhile.
|
|
51
|
+
commit(doer, message) {
|
|
52
|
+
if (!this.usable) return;
|
|
53
|
+
// Only documents that exist or are tracked: the archive is written when a card first ends, and a pathspec naming a
|
|
54
|
+
// file git does not know fails the whole commit.
|
|
55
|
+
const files = this.paths.map((path) => this.rel(path)).filter((file) => quiet(this.root, ['ls-files', '--error-unmatch', file]) !== null || readable(`${this.root}/${file}`));
|
|
56
|
+
if (!files.length) return;
|
|
57
|
+
indexed(this.root, ['add', '--', ...files]);
|
|
58
|
+
if (!git(this.root, ['diff', '--cached', '--name-only', '--', ...files]).trim()) return;
|
|
59
|
+
const who = doer || 'board';
|
|
60
|
+
indexed(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'commit', '--quiet', '--author', `${who} <board@supercode.invalid>`, '-m', message, '--', ...files]);
|
|
61
|
+
const branch = this.branch();
|
|
62
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
63
|
+
if (quiet(this.root, ['push', '--quiet', 'origin', `HEAD:${branch}`]) !== null) return;
|
|
64
|
+
if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) throw new Error('the board documents were committed here but could not be pushed: the remote did not answer');
|
|
65
|
+
this.integrate(branch, who);
|
|
66
|
+
}
|
|
67
|
+
throw new Error('the board documents were committed here but the push was refused three times');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Take what was pushed with a merge: it works beside the other edits in this checkout (the manager's own files), which
|
|
71
|
+
// a rebase refuses. A board document both sides changed is merged by the board's own document merge; any other
|
|
72
|
+
// conflict leaves the checkout as it was and says so.
|
|
73
|
+
integrate(branch, who) {
|
|
74
|
+
const merged = (() => { try { git(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'merge', '--no-edit', '--quiet', `origin/${branch}`]); return true; } catch (error) { return String(error.stderr ?? error.message); } })();
|
|
75
|
+
if (merged === true) return;
|
|
76
|
+
const conflicted = git(this.root, ['diff', '--name-only', '--diff-filter=U']).trim().split('\n').filter(Boolean);
|
|
77
|
+
if (!conflicted.length) {
|
|
78
|
+
quiet(this.root, ['merge', '--abort']);
|
|
79
|
+
throw new Error(`what was pushed could not be taken: ${merged.trim().split('\n')[0]}`);
|
|
80
|
+
}
|
|
81
|
+
for (const file of conflicted) {
|
|
82
|
+
const path = `${this.root}/${file}`;
|
|
83
|
+
// In a merge, stage 2 is this checkout's text and stage 3 the pushed one.
|
|
84
|
+
const stage = (n) => quiet(this.root, ['show', `:${n}:${file}`]) ?? '';
|
|
85
|
+
const text = this.paths.some((own) => this.rel(own) === file) ? this.merge(stage(1), stage(3), stage(2), path) : null;
|
|
86
|
+
if (text === null) {
|
|
87
|
+
quiet(this.root, ['merge', '--abort']);
|
|
88
|
+
throw new Error(`${basename(file)} could not be merged with what was pushed; nothing was pushed, and this checkout is as it was`);
|
|
89
|
+
}
|
|
90
|
+
writeFileSync(path, text);
|
|
91
|
+
indexed(this.root, ['add', '--', file]);
|
|
92
|
+
}
|
|
93
|
+
indexed(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'commit', '--quiet', '--no-edit']);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readable(path) { try { readFileSync(path); return true; } catch { return false; } }
|
package/board/ztrack.mjs
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import { createHash, randomBytes } from 'node:crypto';
|
|
12
12
|
import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
13
|
-
import { dirname, resolve } from 'node:path';
|
|
13
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
14
14
|
import { DatabaseSync } from 'node:sqlite';
|
|
15
|
-
import { actorOf, event, json, now, setActor } from './store.mjs';
|
|
15
|
+
import { actorOf, event, json, now, principalOf, setActor } from './store.mjs';
|
|
16
|
+
import { GitDocument } from './git-document.mjs';
|
|
16
17
|
import { fire, statusOf, storedStatus, workflowFor } from './workflow.mjs';
|
|
17
18
|
import { completionPhase, inspectCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
18
19
|
|
|
@@ -24,6 +25,11 @@ const quote = (s) => `"${String(s).replaceAll('"', '""')}"`;
|
|
|
24
25
|
const nativeId = (id) => /^t-[a-f0-9]+$/.test(id) ? id.replace('t-', 't_') : id;
|
|
25
26
|
const RUNTIME = new Set(['claim_lock', 'claim_expires', 'worker_pid', 'last_heartbeat_at', 'current_run_id', 'consecutive_failures', 'last_failure_error', 'block_recurrences', 'session_id', 'workspace_path']);
|
|
26
27
|
const CARD_MAIN = new Set(['id', 'title', 'body', 'status', 'assignee', 'priority', 'workspace_kind', 'workspace_path', 'branch_name']);
|
|
28
|
+
// What a run left on a card (its result, who and when): the runtime store's, never the document's (company RFC 0025
|
|
29
|
+
// decision 4; ADR 0001 point 1). A document edit keeps the store's values.
|
|
30
|
+
const RUN_OUTPUT = new Set(['result', 'created_by', 'created_at', 'started_at', 'completed_at', 'idempotency_key']);
|
|
31
|
+
const declared = (key, value) => value != null && !RUNTIME.has(key) && !CARD_MAIN.has(key) && !RUN_OUTPUT.has(key) && !(key === 'goal_mode' && value === 0);
|
|
32
|
+
const ENDED = new Set(['done', 'cancelled']);
|
|
27
33
|
const CLOCKS = new Set(['since', 'after_fired', 'next_run_at', 'workspace_root']);
|
|
28
34
|
const atomic = (path, text) => { const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, text, { mode: 0o600 }); renameSync(tmp, path); };
|
|
29
35
|
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
@@ -63,10 +69,16 @@ export function openZtrackBoard(marker, { create = false, document = null, seed
|
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
class ZtrackBoard {
|
|
66
|
-
constructor({ document, seed, marker, init, root, slug, compatPath }) {
|
|
72
|
+
constructor({ document, seed, marker, init, root, slug, compatPath, commit = false, manager = null }) {
|
|
67
73
|
this.path = resolve(dirname(marker), document);
|
|
68
74
|
this.archive = `${this.path}.archive.md`;
|
|
69
75
|
this.docs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
|
|
76
|
+
// `"commit": true` in the board's ztrack.json: the documents live in a git checkout, and each write is committed as
|
|
77
|
+
// its doer and pushed, after taking what was pushed (git-document.mjs; company RFC 0025 decision 3).
|
|
78
|
+
// `"manager"`: the board's manager (its principal or session address), whose writes it commits itself as part of its
|
|
79
|
+
// own work; the backing commits only the writes the manager does not make.
|
|
80
|
+
this.manager = manager;
|
|
81
|
+
this.git = commit ? new GitDocument([this.path, this.archive], (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
|
|
70
82
|
this.seed = seed; this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
|
|
71
83
|
this.file = `${marker}.board.db`;
|
|
72
84
|
mkdirSync(dirname(this.path), { recursive: true });
|
|
@@ -321,6 +333,8 @@ class ZtrackBoard {
|
|
|
321
333
|
// ------------------------------------------------------------------ writes
|
|
322
334
|
|
|
323
335
|
tx(fn) {
|
|
336
|
+
// A pull request merged on the remote (a page edited in Git Notes) is in the document before this write reads it.
|
|
337
|
+
try { this.git?.pull(); } catch (error) { process.stderr.write(`board: ${error.message}\n`); }
|
|
324
338
|
if (this.active) return fn(this.db);
|
|
325
339
|
for (let attempt = 0; ; attempt++) {
|
|
326
340
|
const hermes = this.seed ? null : this.prepareHermes();
|
|
@@ -358,7 +372,13 @@ class ZtrackBoard {
|
|
|
358
372
|
throw error;
|
|
359
373
|
} finally { this.active = false; }
|
|
360
374
|
this.seed = null;
|
|
361
|
-
if (outcome.intent)
|
|
375
|
+
if (outcome.intent) {
|
|
376
|
+
this.publish(outcome);
|
|
377
|
+
// Committed as the session (or person) whose act this write was, and pushed.
|
|
378
|
+
const byManager = this.manager !== null && (actorOf() === this.manager || principalOf() === this.manager);
|
|
379
|
+
if (!byManager) try { this.git?.commit(actorOf(), `board: ${outcome.files.map((file) => basename(file.path)).join(', ')} written for ${actorOf() ?? 'the board'}`); }
|
|
380
|
+
catch (error) { process.stderr.write(`board: ${error.message}\n`); }
|
|
381
|
+
}
|
|
362
382
|
this.exportFollowing();
|
|
363
383
|
return outcome.result;
|
|
364
384
|
}
|
|
@@ -468,7 +488,10 @@ class ZtrackBoard {
|
|
|
468
488
|
subtasks.push(tid);
|
|
469
489
|
}
|
|
470
490
|
plan.subtasks.set(id, subtasks);
|
|
471
|
-
|
|
491
|
+
// An ended subtask is kept in the archive document, not in its card's text: its absence there is no removal.
|
|
492
|
+
for (const old of this.statement('SELECT s.task_id, s.stage, t.status FROM main.supercode_cards s LEFT JOIN main.tasks t ON t.id = s.task_id WHERE s.subtask_of = ?').all(id)) {
|
|
493
|
+
if (!subtasks.includes(old.task_id) && !ENDED.has(old.stage ?? old.status)) this.planRemoval(plan, old.task_id);
|
|
494
|
+
}
|
|
472
495
|
}
|
|
473
496
|
// A task ref (`<arc>:c3`) names a subtask by its key; resolve it once every key is known.
|
|
474
497
|
const keyed = new Map([...plan.tasks].map(([tid, { key }]) => [key, tid]));
|
|
@@ -663,11 +686,13 @@ class ZtrackBoard {
|
|
|
663
686
|
|
|
664
687
|
// A card's rows from its parsed document form. Run fields and clocks stay the table's.
|
|
665
688
|
cardRows(id, card, parent = null) {
|
|
666
|
-
|
|
689
|
+
// An ended subtask kept in the archive document names its card (`subtask_of`); it is not a column of the task.
|
|
690
|
+
const { subtask_of: declaredParent, ...meta } = card.metadata ?? {};
|
|
691
|
+
parent ??= declaredParent ? nativeId(declaredParent) : null;
|
|
667
692
|
const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
|
|
668
693
|
const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
|
|
669
694
|
const old = this.one('SELECT * FROM main.tasks WHERE id = ?', id) ?? {};
|
|
670
|
-
const runtime = Object.fromEntries(Object.entries(old).filter(([k]) => RUNTIME.has(k)));
|
|
695
|
+
const runtime = Object.fromEntries(Object.entries(old).filter(([k]) => RUNTIME.has(k) || RUN_OUTPUT.has(k)));
|
|
671
696
|
const task = { ...meta, ...runtime, id, title: card.title, body: stored.body,
|
|
672
697
|
status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? old.created_at ?? now(), priority: card.priority ?? 0,
|
|
673
698
|
workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: runtime.workspace_path ?? (/^(\/|~|[A-Za-z]:[\\/])/.test(workspace?.[2] ?? '') ? workspace[2] : null), branch_name: card.branch ?? null };
|
|
@@ -685,10 +710,17 @@ class ZtrackBoard {
|
|
|
685
710
|
return card.acceptanceCriteria.map((task) => {
|
|
686
711
|
const key = `${id}:${task.id}`;
|
|
687
712
|
const tid = task.metadata?.id ?? this.one('SELECT id FROM main.board_tasks WHERE key = ?', key)?.id ?? `t_${randomBytes(4).toString('hex')}`;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
713
|
+
// A task line carries its brief and its tick; what else the subtask holds is the store's (an older document's
|
|
714
|
+
// `metadata:` line is still read).
|
|
715
|
+
const stored = task.metadata ? null : this.one('SELECT * FROM main.tasks WHERE id = ?', tid);
|
|
716
|
+
const storedState = task.metadata ? null : this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', tid);
|
|
717
|
+
const was = task.metadata?.status ?? storedState?.stage ?? stored?.status;
|
|
718
|
+
const taskCard = { ...task.metadata?.card, ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, every: storedState.every || undefined } : {}),
|
|
719
|
+
id: tid, title: `${task.id} ${task.text}`, body: task.metadata?.body ?? task.brief ?? '',
|
|
720
|
+
status: task.status === 'passed' ? (was === 'cancelled' ? 'cancelled' : 'done') : (ENDED.has(was) ? 'todo' : was ?? 'todo'),
|
|
721
|
+
metadata: task.metadata?.properties ?? Object.fromEntries(Object.entries(stored ?? {}).filter(([k, v]) => declared(k, v))),
|
|
722
|
+
assignee: task.metadata?.assignee ?? stored?.assignee ?? card.assignee,
|
|
723
|
+
machine: task.metadata?.machine ?? storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
|
|
692
724
|
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) })) };
|
|
693
725
|
if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
|
|
694
726
|
return [tid, taskCard, key, task.sources];
|
|
@@ -731,6 +763,22 @@ class ZtrackBoard {
|
|
|
731
763
|
const at = where.get(id);
|
|
732
764
|
if (!task) { if (at) edits[at.role].push({ index: at.index, remove: true }); continue; }
|
|
733
765
|
const role = state.archived || ['done', 'cancelled'].includes(state.stage ?? task.status) ? 'archived' : 'open';
|
|
766
|
+
// Its ended subtasks: each its own entry in the archive document while the card is open (the open document lists
|
|
767
|
+
// only what is still to do); back in the card's own text once the card has ended.
|
|
768
|
+
for (const sub of this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ?').all(id)) {
|
|
769
|
+
const subState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', sub.id) ?? {};
|
|
770
|
+
const subAt = where.get(sub.id);
|
|
771
|
+
if (role !== 'open' || !ENDED.has(subState.stage ?? sub.status)) { if (subAt) edits[subAt.role].push({ index: subAt.index, remove: true }); continue; }
|
|
772
|
+
const subRecord = this.subtaskRecord(sub.id, sub, subState, id, where);
|
|
773
|
+
const subRec = hash(JSON.stringify(subRecord));
|
|
774
|
+
if (subAt?.role === 'archived' && subAt.entry.rec === subRec) continue;
|
|
775
|
+
let subText;
|
|
776
|
+
try { subText = this.codec.renderBoardCard(subRecord, subAt?.role === 'archived' ? subAt.entry.level : 2, this.docOf('archived').path); }
|
|
777
|
+
catch (error) { this.errors.set(sub.id, error.message); continue; }
|
|
778
|
+
const subEntry = { cid: null, id: subRecord.id, level: subAt?.role === 'archived' ? subAt.entry.level : 2, len: subText.length, rec: subRec, text: subText };
|
|
779
|
+
if (subAt?.role === 'archived') edits.archived.push({ index: subAt.index, entry: subEntry });
|
|
780
|
+
else { if (subAt) edits[subAt.role].push({ index: subAt.index, remove: true }); edits.archived.push({ entry: subEntry }); }
|
|
781
|
+
}
|
|
734
782
|
const record = this.recordOf(id, task, state, where);
|
|
735
783
|
const rec = hash(JSON.stringify(record));
|
|
736
784
|
if (at?.role === role && at.entry.rec === rec) continue;
|
|
@@ -794,16 +842,23 @@ class ZtrackBoard {
|
|
|
794
842
|
const keyOf = (x) => this.one('SELECT key FROM main.board_tasks WHERE id = ?', x)?.key ?? null;
|
|
795
843
|
const parse = (record) => this.preset.KanbanRootSchema.parse(this.preset.parseKanban([record])).issues[0];
|
|
796
844
|
const parsed = parse({ id: fileId(id), title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' });
|
|
797
|
-
const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) =>
|
|
845
|
+
const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => declared(k, v)));
|
|
798
846
|
if (c.archived) metadata.archived = true;
|
|
799
847
|
if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
|
|
800
848
|
const card = { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_kind !== 'scratch' && (c.workspace_anchor ?? row.workspace_path) ? `:${c.workspace_anchor ?? row.workspace_path}` : ''),
|
|
801
849
|
branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
|
|
802
850
|
// Task lines left in a stored body (create and specify file them as subtasks) stay in its Tasks, before its subtasks.
|
|
803
851
|
relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: fileId(b.id) })), acceptanceCriteria: [...(parsed.acceptanceCriteria ?? [])] };
|
|
804
|
-
|
|
852
|
+
// An open card lists its open subtasks; its ended ones are kept in the archive document (subtaskRecord), so the open
|
|
853
|
+
// document stays readable and small. An ended card keeps all of them.
|
|
854
|
+
const cardEnded = c.archived || ENDED.has(c.stage ?? row.status);
|
|
855
|
+
const all = this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ? ORDER BY t.created_at, t.id').all(id);
|
|
856
|
+
const rows = all.filter((r) => cardEnded || !ENDED.has(this.one('SELECT stage FROM main.supercode_cards WHERE task_id = ?', r.id)?.stage ?? r.status));
|
|
805
857
|
const states = new Map(rows.map((r) => [r.id, this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', r.id)]));
|
|
806
858
|
const keys = new Map(), taken = new Set(card.acceptanceCriteria.map((task) => task.id));
|
|
859
|
+
// An ended subtask, kept in the archive, still holds its key: a new one never takes it, so a line can never be bound
|
|
860
|
+
// back to a finished subtask by a reused key.
|
|
861
|
+
for (const r of all) { if (rows.includes(r)) continue; const key = keyOf(r.id)?.split(':').at(-1) ?? /^([a-z]+\d+)\s/.exec(r.title)?.[1]; if (key) taken.add(key); }
|
|
807
862
|
for (const r of rows) { const key = keyOf(r.id)?.split(':').at(-1) ?? /^([a-z]+\d+)\s/.exec(r.title)?.[1]; if (key && !taken.has(key)) { keys.set(r.id, key); taken.add(key); } }
|
|
808
863
|
for (const r of rows) {
|
|
809
864
|
if (keys.has(r.id)) continue;
|
|
@@ -818,10 +873,10 @@ class ZtrackBoard {
|
|
|
818
873
|
const task = parse({ id: r.id, title: r.title, status, body: r.body ?? '' });
|
|
819
874
|
const blocks = json(s.blocked_by, []);
|
|
820
875
|
const messages = blocks.filter((block) => block.kind === 'message');
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
876
|
+
// A subtask is its line and its brief (its body, as Markdown under it); its id, status words, assignee and run
|
|
877
|
+
// output stay in the store (company RFC 0025 decision 4).
|
|
878
|
+
card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, status: ENDED.has(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
|
|
879
|
+
...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
|
|
825
880
|
waitingOn: messages[0]?.id,
|
|
826
881
|
blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
|
|
827
882
|
const target = keyOf(block.id);
|
|
@@ -834,6 +889,16 @@ class ZtrackBoard {
|
|
|
834
889
|
return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
835
890
|
}
|
|
836
891
|
|
|
892
|
+
// An ended subtask's own record in the archive document: its title and brief, and the card it belongs to.
|
|
893
|
+
subtaskRecord(id, task, state, parentId, where) {
|
|
894
|
+
const fileId = (x) => where.get(x)?.entry.id ?? x;
|
|
895
|
+
const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: fileId(id), title: task.title, status: state.stage ?? task.status, assignee: task.assignee || undefined, body: task.body ?? '' }])).issues[0];
|
|
896
|
+
const metadata = { subtask_of: fileId(parentId), ...Object.fromEntries(Object.entries(task).filter(([k, v]) => declared(k, v))) };
|
|
897
|
+
const card = { ...parsed, metadata, machine: state.machine || undefined, relations: [] };
|
|
898
|
+
const serialized = this.preset.serializeKanbanCard(card);
|
|
899
|
+
return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
900
|
+
}
|
|
901
|
+
|
|
837
902
|
// ------------------------------------------------------------------ migration from a SQLite board
|
|
838
903
|
|
|
839
904
|
indexForSeed() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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": {
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@volter/supercode-harness-sdk": "^0.3.41",
|
|
28
|
-
"@volter/ztrack": "^2.4.
|
|
28
|
+
"@volter/ztrack": "^2.4.23",
|
|
29
29
|
"yaml": "^2.9.0",
|
|
30
|
-
"@volter/teams": "0.2.
|
|
30
|
+
"@volter/teams": "0.2.2"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@chat-adapter/state-memory": "4.39.0",
|