@volter/supercode-orchestrator 0.5.2 → 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/dispatch.mjs +15 -6
- package/board/git-document.mjs +97 -0
- package/board/ztrack.mjs +82 -17
- package/gates.mjs +3 -0
- package/package.json +3 -3
package/board/dispatch.mjs
CHANGED
|
@@ -154,10 +154,10 @@ function unpublishedAlive(session) {
|
|
|
154
154
|
/** Native activity comes from the machine's harness discovery, not catalogue age. */
|
|
155
155
|
function nativeSessions(machine, harness) {
|
|
156
156
|
if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
// This machine's discovery is its harness door, as every other harness read here is; another machine's is supercode's fleet arm
|
|
158
|
+
const result = normalMachine(machine) === localMachine()
|
|
159
|
+
? doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], limit: 2000 }, { timeout: 120_000 })
|
|
160
|
+
: sc(['discover', '--on', machine, '--harness', harness, '--limit', '2000'], { timeout: 120_000 });
|
|
161
161
|
const page = result.code === 0 ? lastJson(result.stdout) : null;
|
|
162
162
|
if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
|
|
163
163
|
return page.sessions;
|
|
@@ -1516,9 +1516,12 @@ export function requestAdoption(board, id, who, { workspace = null, assignee = n
|
|
|
1516
1516
|
});
|
|
1517
1517
|
}
|
|
1518
1518
|
|
|
1519
|
+
/** How long a requested adoption waits for its session to appear in the fleet before it fails. */
|
|
1520
|
+
const ADOPTION_LISTING_WINDOW_S = 180;
|
|
1521
|
+
|
|
1519
1522
|
/** Adoptions the manager requested and no tick has answered yet, completed from this tick's fleet reading. */
|
|
1520
1523
|
export function completeAdoptions(board, fleet, settings, report, lanes = null) {
|
|
1521
|
-
const pending = view(board, db => db.prepare(`SELECT e.task_id, e.payload FROM task_events e WHERE e.kind = 'dispatch_held'
|
|
1524
|
+
const pending = view(board, db => db.prepare(`SELECT e.task_id, e.payload, e.created_at FROM task_events e WHERE e.kind = 'dispatch_held'
|
|
1522
1525
|
AND json_extract(e.payload, '$.reason') = 'adoption requested'
|
|
1523
1526
|
AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ('dispatch_held','dispatch_released','session_adopted','adoption_failed'))`).all());
|
|
1524
1527
|
for (const row of pending) {
|
|
@@ -1528,6 +1531,12 @@ export function completeAdoptions(board, fleet, settings, report, lanes = null)
|
|
|
1528
1531
|
(report.adopted ??= []).push([row.task_id, got.address]);
|
|
1529
1532
|
} catch (error) {
|
|
1530
1533
|
const message = String(error.message).slice(-400);
|
|
1534
|
+
// A session opened moments before its adoption is asked may not be in the fleet's published set yet: the
|
|
1535
|
+
// card stays held and each tick looks again, until the window closes
|
|
1536
|
+
if (error.notListed && Date.now() / 1000 - Number(row.created_at) < ADOPTION_LISTING_WINDOW_S) {
|
|
1537
|
+
report.queued.push([row.task_id, `adoption of ${asked.session} waits: ${message}`]);
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1531
1540
|
tx(board, db => event(db, row.task_id, 'adoption_failed', { session: asked.session, error: message }));
|
|
1532
1541
|
report.failed.push([row.task_id, `adoption of ${asked.session}: ${message}`]);
|
|
1533
1542
|
}
|
|
@@ -1538,7 +1547,7 @@ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null,
|
|
|
1538
1547
|
fleet ??= observeFleet();
|
|
1539
1548
|
if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
|
|
1540
1549
|
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
|
|
1541
|
-
if (!found) throw new Error(`the fleet lists no live session ${who}`);
|
|
1550
|
+
if (!found) throw Object.assign(new Error(`the fleet lists no live session ${who}`), { notListed: true });
|
|
1542
1551
|
const [, host, harness, sessionId] = found.address.split(':');
|
|
1543
1552
|
if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
|
|
1544
1553
|
const native = found.pane ? null : nativeReading(nativeSessions(host, harness), { session_id: sessionId, harness });
|
|
@@ -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/gates.mjs
CHANGED
|
@@ -68,6 +68,9 @@ export function scriptTimeoutSeconds(profile, env = process.env) {
|
|
|
68
68
|
function scriptEnv() {
|
|
69
69
|
const env = {};
|
|
70
70
|
for (const k of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TZ', 'TMPDIR', 'USER', 'SHELL']) if (process.env[k] !== undefined) env[k] = process.env[k];
|
|
71
|
+
// A script's `supercode` is the one this daemon runs, as an agent worker's is: its folder leads PATH
|
|
72
|
+
const own = process.env.SUPERCODE_BIN ? dirname(process.env.SUPERCODE_BIN) : null;
|
|
73
|
+
if (own) env.PATH = [own, ...(env.PATH ?? '').split(':').filter(dir => dir && dir !== own)].join(':');
|
|
71
74
|
return env;
|
|
72
75
|
}
|
|
73
76
|
|
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",
|