@volter/supercode-orchestrator 0.5.162 → 0.5.163
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 +19 -54
- package/board/files.mjs +2 -0
- package/board/listing.mjs +134 -0
- package/board/reply.mjs +51 -0
- package/board/store.mjs +18 -1
- package/board/ztrack.mjs +3 -0
- package/package.json +1 -1
package/board/cli.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { BOARD_OPERATION_VERBS, boardHome, boardOwnerSocket, CALLER_FACTS } from
|
|
|
12
12
|
import { current, isCallersCall, ownCall, within } from './call.mjs';
|
|
13
13
|
import { taskStatusOf } from './published.mjs';
|
|
14
14
|
import { executionOf } from './session-work.mjs';
|
|
15
|
+
import { listCardsInTurns, writeCardList } from './listing.mjs';
|
|
16
|
+
import { writeOwnerAnswer } from './reply.mjs';
|
|
15
17
|
import { createServer } from 'node:net';
|
|
16
18
|
import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
17
19
|
import { notRuntime, ownHome } from './owner-files.mjs';
|
|
@@ -279,9 +281,6 @@ async function requestedSponsor(args) {
|
|
|
279
281
|
return { requestedByUnverified: { observation: args['requested-by'], label: resolved?.label ?? 'unresolved', request: args.request ?? null } };
|
|
280
282
|
}
|
|
281
283
|
|
|
282
|
-
// `list --json` reads each card's parents and standing block per card up to this many cards, and in one read of each past it
|
|
283
|
-
const LIST_PER_CARD = 64;
|
|
284
|
-
|
|
285
284
|
const tagsOf = (card) => { const tags = Array.isArray(card.tags) ? card.tags : json(card.tags, null); return Array.isArray(tags) ? tags : card.tenant ? [card.tenant] : []; };
|
|
286
285
|
|
|
287
286
|
const say = (args, human, value) => { current().out(args.json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`); };
|
|
@@ -354,7 +353,8 @@ export async function runVerb(argv) {
|
|
|
354
353
|
// a caller that is a card's own session (its harness names it; the board recorded which session it launched)
|
|
355
354
|
const listing = ['list','ls'].includes(verb);
|
|
356
355
|
await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
357
|
-
|
|
356
|
+
// A listing does not authorize a transition and needs no manager's main session.
|
|
357
|
+
if (!listing) await readAgentMains(root);
|
|
358
358
|
const sid = listing && !args.mine ? null : callerSession();
|
|
359
359
|
// what this command records is the calling session's act (its address, as its subscriptions name it)
|
|
360
360
|
current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare(CALLER_RUN_ADDRESS).get(sid)?.address) ?? null : null;
|
|
@@ -503,16 +503,8 @@ export async function runVerb(argv) {
|
|
|
503
503
|
|
|
504
504
|
case 'list': case 'ls': {
|
|
505
505
|
const assignee = args.assignee ?? (args.mine ? callerName() : null);
|
|
506
|
-
const cards =
|
|
507
|
-
|
|
508
|
-
if (args.tenant) rows = rows.filter((t) => t.tenant === args.tenant);
|
|
509
|
-
// a saved view (C4) is the Org's cards that carry its tag
|
|
510
|
-
for (const tag of [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])]) rows = rows.filter((t) => tagsOf(t).includes(tag));
|
|
511
|
-
if (args.session) rows = rows.filter((t) => t.session_id === args.session);
|
|
512
|
-
if (args['workflow-template-id']) rows = rows.filter((t) => t.workflow_template_id === args['workflow-template-id']);
|
|
513
|
-
if (args['step-key']) rows = rows.filter((t) => t.current_step_key === args['step-key']);
|
|
514
|
-
return rows;
|
|
515
|
-
});
|
|
506
|
+
const cards = await withBoardAsync(b => listCardsInTurns(b.db, { detailed: Boolean(args.json), status: args.status, assignee, machine: args.machine, includeArchived: args.archived || args.all,
|
|
507
|
+
tenant: args.tenant, session: args.session, workflowTemplateId: args['workflow-template-id'], stepKey: args['step-key'], tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])] }));
|
|
516
508
|
const sorters = {
|
|
517
509
|
assignee: (a, b) => String(a.assignee ?? '').localeCompare(String(b.assignee ?? '')), created: (a, b) => a.created_at - b.created_at,
|
|
518
510
|
'created-desc': (a, b) => b.created_at - a.created_at, priority: (a, b) => a.priority - b.priority, 'priority-desc': (a, b) => b.priority - a.priority,
|
|
@@ -521,44 +513,16 @@ export async function runVerb(argv) {
|
|
|
521
513
|
};
|
|
522
514
|
if (args.sort) { if (!sorters[args.sort]) throw new Error(`--sort is one of ${Object.keys(sorters).join(', ')}`); cards.sort(sorters[args.sort]); }
|
|
523
515
|
if (args.json) {
|
|
524
|
-
|
|
525
|
-
// whole board (a board file's sync) needs no `show` per card
|
|
526
|
-
const extra = withBoard((b) => {
|
|
527
|
-
// A whole board's parents and standing blocks are read once, not once per card. Asked per card, the block's
|
|
528
|
-
// read fetched every event row of every card through the card's index to answer from the few of these
|
|
529
|
-
// kinds (on 2026-10-11: 45,670 rows of a 17 MB table for 2,154 cards, to find 332), on the owner's event
|
|
530
|
-
// loop, for as long as the listing took. A short listing still asks per card: it reads less than the scan.
|
|
531
|
-
const whole = cards.length > LIST_PER_CARD;
|
|
532
|
-
const parentsOf = new Map(), standing = new Map();
|
|
533
|
-
if (whole) {
|
|
534
|
-
for (const row of b.db.prepare('SELECT child_id, parent_id FROM task_links ORDER BY child_id, parent_id').all()) {
|
|
535
|
-
if (!parentsOf.has(row.child_id)) parentsOf.set(row.child_id, []);
|
|
536
|
-
parentsOf.get(row.child_id).push(row.parent_id);
|
|
537
|
-
}
|
|
538
|
-
// in id order, so the last one kept for a card is its latest
|
|
539
|
-
for (const row of b.db.prepare("SELECT task_id, kind, payload FROM task_events WHERE kind IN ('blocked', 'unblocked', 'dependency_wait') ORDER BY id").all()) standing.set(row.task_id, row);
|
|
540
|
-
}
|
|
541
|
-
return new Map(cards.map((t) => {
|
|
542
|
-
const run = t.current_run_id ? b.db.prepare('SELECT id, status, started_at, metadata FROM task_runs WHERE id = ?').get(t.current_run_id) : null;
|
|
543
|
-
const session = run ? json(run.metadata, {})?.supercode ?? {} : {};
|
|
544
|
-
return [t.id, {
|
|
545
|
-
workflow_status: t.stage ?? t.status,
|
|
546
|
-
parents: whole ? parentsOf.get(t.id) ?? [] : b.db.prepare('SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id').all(t.id).map((r) => r.parent_id),
|
|
547
|
-
run: run ? { id: run.id, status: run.status, started_at: run.started_at, session: session.address ?? session.session_id ?? null, execution: executionOf(run) } : null,
|
|
548
|
-
// the message whose answer its standing block waits for (`block --waiting-on`)
|
|
549
|
-
waiting_on: (() => {
|
|
550
|
-
const last = whole ? standing.get(t.id) : b.db.prepare("SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked', 'dependency_wait') ORDER BY id DESC LIMIT 1").get(t.id);
|
|
551
|
-
return last?.kind === 'blocked' ? json(last.payload, {})?.waiting_on ?? null : null;
|
|
552
|
-
})(),
|
|
553
|
-
}];
|
|
554
|
-
}));
|
|
555
|
-
});
|
|
556
|
-
say(args, '', cards.map((t) => ({ ...cardDict(t), ...extra.get(t.id) })));
|
|
516
|
+
await writeCardList(cards, t => ({ ...cardDict(t), workflow_status: t.workflow_status, parents: t.parents, run: t.run, waiting_on: t.waiting_on }), current().out);
|
|
557
517
|
return 0;
|
|
558
518
|
}
|
|
559
519
|
const others = boardsOf(root, { includeArchived: false }).length - 1;
|
|
560
520
|
if (others > 0) current().out(`Board: ${slug} (${others} other board${others === 1 ? '' : 's'} — \`supercode workflow boards list\`)\n\n`);
|
|
561
|
-
|
|
521
|
+
if (!cards.length) current().out('(no matching tasks)\n');
|
|
522
|
+
for (let offset = 0; offset < cards.length; offset += 64) {
|
|
523
|
+
current().out(`${cards.slice(offset, offset + 64).map(line).join('\n')}\n`);
|
|
524
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
525
|
+
}
|
|
562
526
|
return 0;
|
|
563
527
|
}
|
|
564
528
|
|
|
@@ -1599,7 +1563,8 @@ async function answerVerbs(root, log) {
|
|
|
1599
1563
|
// one answer per call, whatever the request did: a request that fails is a failed answer, never the owner's end
|
|
1600
1564
|
answerOne(root, line)
|
|
1601
1565
|
.catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
|
|
1602
|
-
.then((answer) =>
|
|
1566
|
+
.then((answer) => writeOwnerAnswer(socket, answer))
|
|
1567
|
+
.catch(() => { socket.destroy(); }); // a disconnected caller leaves no queued reply
|
|
1603
1568
|
});
|
|
1604
1569
|
});
|
|
1605
1570
|
await new Promise((done, fail) => { server.once('error', fail); server.listen(path, () => { server.off('error', fail); done(); }); });
|
|
@@ -1642,14 +1607,14 @@ async function answerRequest(root, line, naming) {
|
|
|
1642
1607
|
if (!facts.SUPERCODE_CONTEXT) return { code: 1, stdout: '', stderr: 'supercode workflow: the request names no Teams context (facts.SUPERCODE_CONTEXT); nothing was done\n' };
|
|
1643
1608
|
const cwd = typeof request.cwd === 'string' && request.cwd ? request.cwd : root;
|
|
1644
1609
|
const stdin = typeof request.stdin === 'string' ? request.stdin : null;
|
|
1645
|
-
|
|
1646
|
-
|
|
1610
|
+
const stdout = [];
|
|
1611
|
+
const stderr = [];
|
|
1647
1612
|
let code;
|
|
1648
|
-
await within({ caller, facts, cwd, stdin, out: (text) => { stdout
|
|
1613
|
+
await within({ caller, facts, cwd, stdin, out: (text) => { stdout.push(text); return true; }, err: (text) => { stderr.push(text); return true; } }, async () => {
|
|
1649
1614
|
// a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
|
|
1650
|
-
if (tool) { stdout
|
|
1615
|
+
if (tool) { stdout.push(await boardTool(root, tool.name, tool.args && typeof tool.args === 'object' ? tool.args : {}, Object.fromEntries(Object.entries(tool.env ?? {}).filter(([key, value]) => /^HERMES_(KANBAN_TASK|KANBAN_RUN_ID|KANBAN_BOARD|PROFILE)$/.test(key) && typeof value === 'string')))); code = 0; return; }
|
|
1651
1616
|
try { code = await runVerb([...argv, '--root', root, ...(typeof request.board === 'string' && request.board ? ['--board', request.board] : [])]); }
|
|
1652
|
-
catch (error) { stderr
|
|
1617
|
+
catch (error) { stderr.push(`supercode workflow: ${error?.message ?? error}\n`); code = 1; }
|
|
1653
1618
|
});
|
|
1654
1619
|
return { code: code ?? 0, stdout, stderr };
|
|
1655
1620
|
} catch (error) {
|
package/board/files.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { markRemovedHeads } from './published.mjs';
|
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { DatabaseSync } from 'node:sqlite';
|
|
17
|
+
import { LIST_REVISION, listRevision } from './listing.mjs';
|
|
17
18
|
|
|
18
19
|
const STATE = '_board.json';
|
|
19
20
|
|
|
@@ -58,6 +59,7 @@ class FilesBoard {
|
|
|
58
59
|
const self = this;
|
|
59
60
|
// the connection the engine holds: SQLite's statement API over whatever snapshot is current
|
|
60
61
|
this.db = {
|
|
62
|
+
[LIST_REVISION]: () => self.read(() => listRevision(self.mem)),
|
|
61
63
|
prepare(sql) {
|
|
62
64
|
return {
|
|
63
65
|
get: (...args) => self.read(() => self.statement(sql).get(...args)),
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// A listing reads at most 64 candidate tasks per turn, even when its filters match none.
|
|
2
|
+
// Native SQLite calls still run on the caller's thread; this is a row bound, not a time bound.
|
|
3
|
+
import { setImmediate as nextTurn } from 'node:timers/promises';
|
|
4
|
+
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
5
|
+
import { executionOf } from './session-work.mjs';
|
|
6
|
+
|
|
7
|
+
export const LIST_BATCH = 64;
|
|
8
|
+
export const LIST_CONNECTION = Symbol.for('supercode.board.listing.connection');
|
|
9
|
+
export const LIST_REVISION = Symbol.for('supercode.board.listing.revision');
|
|
10
|
+
const parse = (text, fallback) => { try { return JSON.parse(text) ?? fallback; } catch { return fallback; } };
|
|
11
|
+
const statements = new WeakMap();
|
|
12
|
+
const prepared = (db, sql) => {
|
|
13
|
+
const connection = db[LIST_CONNECTION] ?? db;
|
|
14
|
+
let held = statements.get(connection);
|
|
15
|
+
if (!held) { held = new Map(); statements.set(connection, held); }
|
|
16
|
+
if (!held.has(sql)) held.set(sql, db.prepare(sql));
|
|
17
|
+
return held.get(sql);
|
|
18
|
+
};
|
|
19
|
+
const measured = (name, fn) => {
|
|
20
|
+
const at = performance.now();
|
|
21
|
+
try { return fn(); } finally { noteStep(`board list: ${name}`, performance.now() - at); }
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Validate an optimistic read, without leaving a transaction open on the owner's shared
|
|
25
|
+
// connection across awaits. data_version names commits from other connections; total_changes
|
|
26
|
+
// also catches this connection's writes. Schema versions catch TEMP overlay creation/removal.
|
|
27
|
+
// Files backings additionally change connection identity when they reload their projection.
|
|
28
|
+
export function listRevision(db) {
|
|
29
|
+
if (db[LIST_REVISION]) return db[LIST_REVISION]();
|
|
30
|
+
const connection = db[LIST_CONNECTION] ?? db;
|
|
31
|
+
return {
|
|
32
|
+
connection,
|
|
33
|
+
data: prepared(connection, 'PRAGMA main.data_version').get().data_version,
|
|
34
|
+
changes: prepared(connection, 'SELECT total_changes() AS changes').get().changes,
|
|
35
|
+
mainSchema: prepared(connection, 'PRAGMA main.schema_version').get().schema_version,
|
|
36
|
+
tempSchema: prepared(connection, 'PRAGMA temp.schema_version').get().schema_version,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const sameRevision = (a, b) => Object.keys(a).every(key => a[key] === b[key]);
|
|
40
|
+
|
|
41
|
+
// Only the fields cardDict, human lines, filters and sorters use. A human listing never reads
|
|
42
|
+
// body/result/skills or run history. A JSON listing retains its existing full-text contract.
|
|
43
|
+
const HUMAN_FIELDS = 'id,title,assignee,status,priority,tenant,created_at,started_at,completed_at';
|
|
44
|
+
const JSON_FIELDS = `${HUMAN_FIELDS},body,result,workspace_kind,workspace_path,branch_name,project_id,created_by,skills,max_retries,model_override,provider_override,session_id,workflow_template_id,current_step_key`;
|
|
45
|
+
export const TASK_SUMMARY_INDEX = 'idx_tasks_list_summary';
|
|
46
|
+
export const TASK_SUMMARY_INDEX_SQL = `CREATE INDEX IF NOT EXISTS ${TASK_SUMMARY_INDEX} ON tasks(${HUMAN_FIELDS},current_run_id,session_id,workflow_template_id,current_step_key)`;
|
|
47
|
+
const RUN_METADATA = `CASE WHEN json_valid(r.metadata) THEN json_object('supercode', json_object(
|
|
48
|
+
'address',json_extract(r.metadata,'$.supercode.address'),'session_id',json_extract(r.metadata,'$.supercode.session_id'),
|
|
49
|
+
'close_confirmed_at',json_extract(r.metadata,'$.supercode.close_confirmed_at'),'released',json_extract(r.metadata,'$.supercode.released'),
|
|
50
|
+
'presence_observation',json_extract(r.metadata,'$.supercode.presence_observation'),'idle_observation',json_extract(r.metadata,'$.supercode.idle_observation')
|
|
51
|
+
)) END`;
|
|
52
|
+
export const LIST_CANDIDATES_SQL = `SELECT MAX(id) AS last, json_group_array(id) AS ids FROM (
|
|
53
|
+
SELECT id FROM tasks WHERE id > ? AND id <= ? ORDER BY id LIMIT ${LIST_BATCH})`;
|
|
54
|
+
|
|
55
|
+
// Three reverse index seeks find the latest event of the relevant kinds. Only that row's
|
|
56
|
+
// blocked payload is returned, rather than every historical payload for the whole board.
|
|
57
|
+
export const LAST_BLOCK_SQL = `SELECT CASE WHEN kind = 'blocked' THEN payload END FROM task_events WHERE id = (
|
|
58
|
+
SELECT MAX(id) FROM (
|
|
59
|
+
SELECT (SELECT id FROM task_events WHERE task_id = t.id AND kind = 'blocked' ORDER BY id DESC LIMIT 1) AS id
|
|
60
|
+
UNION ALL SELECT (SELECT id FROM task_events WHERE task_id = t.id AND kind = 'unblocked' ORDER BY id DESC LIMIT 1)
|
|
61
|
+
UNION ALL SELECT (SELECT id FROM task_events WHERE task_id = t.id AND kind = 'dependency_wait' ORDER BY id DESC LIMIT 1)
|
|
62
|
+
))`;
|
|
63
|
+
|
|
64
|
+
export function listPageSql(detailed, tagCount = 0) {
|
|
65
|
+
return `SELECT t.rowid AS list_order,${(detailed ? JSON_FIELDS : HUMAN_FIELDS).split(',').map(name => `t.${name}`).join(',')},
|
|
66
|
+
c.machine,c.stage${detailed ? ',c.subtask_of' : ''},
|
|
67
|
+
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
|
|
68
|
+
${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,
|
|
69
|
+
(${LAST_BLOCK_SQL}) AS block_payload` : ''}
|
|
70
|
+
FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN task_runs r ON r.id = t.current_run_id
|
|
71
|
+
WHERE t.id IN (SELECT value FROM json_each(?)) AND (? IS NULL OR t.status = ? OR c.stage = ?)
|
|
72
|
+
AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
|
|
73
|
+
AND (? IS NULL OR t.tenant = ?) AND (? IS NULL OR t.session_id = ?)
|
|
74
|
+
AND (? IS NULL OR t.workflow_template_id = ?) AND (? IS NULL OR t.current_step_key = ?)
|
|
75
|
+
${Array.from({ length: tagCount }, () => `AND CASE WHEN json_valid(c.tags) THEN
|
|
76
|
+
CASE WHEN json_type(c.tags) = 'array' THEN EXISTS (SELECT 1 FROM json_each(c.tags) WHERE value = ?) ELSE t.tenant = ? END
|
|
77
|
+
ELSE t.tenant = ? END`).join('\n')}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function listCardsInTurns(db, options = {}) {
|
|
81
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
82
|
+
const revision = measured('read revision', () => listRevision(db));
|
|
83
|
+
const cards = await readCardsInTurns(db, options, revision);
|
|
84
|
+
if (cards !== null) return cards;
|
|
85
|
+
}
|
|
86
|
+
throw Object.assign(new Error('board changed during both listing reads; run list again'), { code: 'board_listing_changed' });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readCardsInTurns(db, { detailed = false, status = null, assignee = null, machine = null, includeArchived = false,
|
|
90
|
+
tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [] }, revision) {
|
|
91
|
+
const out = [];
|
|
92
|
+
const page = prepared(db, listPageSql(detailed, tags.length)), candidates = prepared(db, LIST_CANDIDATES_SQL);
|
|
93
|
+
const ceiling = measured('last candidate', () => prepared(db, "SELECT COALESCE(MAX(id), '') AS cursor FROM tasks").get().cursor);
|
|
94
|
+
let cursor = '';
|
|
95
|
+
while (cursor < ceiling) {
|
|
96
|
+
if (!measured('validate revision', () => sameRevision(revision, listRevision(db)))) return null;
|
|
97
|
+
const range = measured('candidate range', () => candidates.get(cursor, ceiling));
|
|
98
|
+
if (range.last == null) break;
|
|
99
|
+
const rows = measured(detailed ? 'JSON task rows and latest blocks' : 'human task rows', () => page.all(range.ids,
|
|
100
|
+
status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0,
|
|
101
|
+
tenant, tenant, session, session, workflowTemplateId, workflowTemplateId, stepKey, stepKey, ...tags.flatMap(tag => [tag, tag, tag])));
|
|
102
|
+
measured('row projection', () => {
|
|
103
|
+
for (const { run_id, run_status, run_started_at, run_ended_at, run_metadata, parents_json, block_payload, ...task } of rows) {
|
|
104
|
+
const metadata = run_id ? parse(run_metadata, {}) : null;
|
|
105
|
+
const run = run_id ? { id: run_id, status: run_status, started_at: run_started_at, ended_at: run_ended_at, metadata } : null;
|
|
106
|
+
task.execution = executionOf(run);
|
|
107
|
+
if (detailed) {
|
|
108
|
+
task.skills = parse(task.skills, []);
|
|
109
|
+
task.workflow_status = task.stage ?? task.status;
|
|
110
|
+
task.parents = parse(parents_json, []);
|
|
111
|
+
task.waiting_on = parse(block_payload, {})?.waiting_on ?? null;
|
|
112
|
+
task.run = run ? { id: run.id, status: run.status, started_at: run.started_at, session: metadata.supercode?.address ?? metadata.supercode?.session_id ?? null, execution: task.execution } : null;
|
|
113
|
+
}
|
|
114
|
+
out.push(task);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
cursor = range.last;
|
|
118
|
+
await nextTurn();
|
|
119
|
+
}
|
|
120
|
+
if (!measured('validate revision', () => sameRevision(revision, listRevision(db)))) return null;
|
|
121
|
+
// The candidate index uses id order; restore the original task row order for tied sorts.
|
|
122
|
+
measured('ordering', () => out.sort((a, b) => b.priority - a.priority || a.created_at - b.created_at || a.list_order - b.list_order));
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Keep pretty JSON and its newline while avoiding one whole-board JSON.stringify allocation.
|
|
127
|
+
export async function writeCardList(cards, project, out) {
|
|
128
|
+
out(cards.length ? '[\n' : '[]\n');
|
|
129
|
+
for (let index = 0; index < cards.length; index++) {
|
|
130
|
+
measured('serialize card', () => out(`${JSON.stringify(project(cards[index]), null, 2).split('\n').map(line => ` ${line}`).join('\n')}${index + 1 < cards.length ? ',' : ''}\n`));
|
|
131
|
+
if ((index + 1) % LIST_BATCH === 0) await nextTurn();
|
|
132
|
+
}
|
|
133
|
+
if (cards.length) out(']\n');
|
|
134
|
+
}
|
package/board/reply.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The owner's existing one-line answer protocol, escaped and written in bounded pieces.
|
|
2
|
+
// No whole stdout rope is flattened, escaped, or converted to a socket buffer here.
|
|
3
|
+
import { setImmediate as nextTurn } from 'node:timers/promises';
|
|
4
|
+
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
5
|
+
|
|
6
|
+
export const REPLY_CHARS = 8192;
|
|
7
|
+
const measured = (name, fn) => {
|
|
8
|
+
const at = performance.now();
|
|
9
|
+
try { return fn(); } finally { noteStep(`owner reply ${name}`, performance.now() - at); }
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async function write(socket, text) {
|
|
13
|
+
if (socket.destroyed) return false;
|
|
14
|
+
// One buffer in flight. The write callback waits for this write instead of accumulating
|
|
15
|
+
// a whole answer behind a reader that has stopped taking bytes.
|
|
16
|
+
const buffer = measured('encode buffer', () => Buffer.from(text));
|
|
17
|
+
await new Promise((resolve, reject) => {
|
|
18
|
+
const closed = () => finish(new Error('board caller closed before its answer was written'));
|
|
19
|
+
const finish = (error) => { socket.off('close', closed); error ? reject(error) : resolve(); };
|
|
20
|
+
socket.once('close', closed);
|
|
21
|
+
const at = performance.now();
|
|
22
|
+
try { socket.write(buffer, finish); } catch (error) { finish(error); }
|
|
23
|
+
finally { noteStep('owner reply socket.write', performance.now() - at); }
|
|
24
|
+
});
|
|
25
|
+
await nextTurn();
|
|
26
|
+
return !socket.destroyed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function writeOwnerAnswer(socket, { code = 0, stdout = '', stderr = '' }) {
|
|
30
|
+
if (!await write(socket, `{"code":${JSON.stringify(code)},"stdout":"`)) return;
|
|
31
|
+
for (const [index, value] of [stdout, stderr].entries()) {
|
|
32
|
+
let carry = '';
|
|
33
|
+
for (const part of Array.isArray(value) ? value : [value]) {
|
|
34
|
+
for (let offset = 0; offset < part.length;) {
|
|
35
|
+
const end = Math.min(part.length, offset + REPLY_CHARS - carry.length);
|
|
36
|
+
let chunk = carry + part.slice(offset, end);
|
|
37
|
+
carry = '';
|
|
38
|
+
offset = end;
|
|
39
|
+
// A pair can straddle either a fragment or an output part. Keep its high half
|
|
40
|
+
// until the next fragment so JSON.stringify emits the original UTF-8 wire bytes.
|
|
41
|
+
const last = chunk.charCodeAt(chunk.length - 1);
|
|
42
|
+
if (last >= 0xd800 && last <= 0xdbff) { carry = chunk.slice(-1); chunk = chunk.slice(0, -1); }
|
|
43
|
+
if (chunk && !await write(socket, measured('escape string chunk', () => JSON.stringify(chunk).slice(1, -1)))) return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (carry && !await write(socket, measured('escape string chunk', () => JSON.stringify(carry).slice(1, -1)))) return;
|
|
47
|
+
if (index === 0 && !await write(socket, '","stderr":"')) return;
|
|
48
|
+
}
|
|
49
|
+
if (!await write(socket, '"}\n')) return;
|
|
50
|
+
measured('socket.end', () => socket.end());
|
|
51
|
+
}
|
package/board/store.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { hostname } from 'node:os';
|
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
import { DatabaseSync } from 'node:sqlite';
|
|
21
21
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
22
|
+
import { LIST_CONNECTION, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
22
23
|
import { isFilesBoard, openFilesBoard } from './files.mjs';
|
|
23
24
|
import { signalBoardWrite } from './wake.mjs';
|
|
24
25
|
import { PUBLICATION_SCHEMA, pruneDue, prunePublications, publicationTriggers, recordCardPublications } from './published.mjs';
|
|
@@ -222,6 +223,20 @@ function layOutEventIndex(db) {
|
|
|
222
223
|
}
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
function layOutSummaryIndex(db) {
|
|
227
|
+
if (indexed(db, TASK_SUMMARY_INDEX)) return;
|
|
228
|
+
const report = text => { writeSync(2, text); if (current().kind === 'caller') current().err(text); };
|
|
229
|
+
report(`board: creating index ${TASK_SUMMARY_INDEX}: one-time task scan and summary index write; this synchronous migration holds the owner event loop until it finishes\n`);
|
|
230
|
+
const began = performance.now();
|
|
231
|
+
let made = false;
|
|
232
|
+
try { db.exec(TASK_SUMMARY_INDEX_SQL); made = true; }
|
|
233
|
+
finally {
|
|
234
|
+
const ms = performance.now() - began;
|
|
235
|
+
noteStep(`board open index ${TASK_SUMMARY_INDEX}`, ms);
|
|
236
|
+
report(`board: index ${TASK_SUMMARY_INDEX} ${made ? 'created' : 'creation failed'} after ${Math.round(ms)} ms\n`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
225
240
|
/** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
|
|
226
241
|
export function layOut(db, { fresh }) {
|
|
227
242
|
if (fresh) db.exec(SCHEMA);
|
|
@@ -234,6 +249,8 @@ export function layOut(db, { fresh }) {
|
|
|
234
249
|
// any statement is prepared; no statement names it, so a store without it is only slower.
|
|
235
250
|
db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
|
|
236
251
|
layOutEventIndex(db);
|
|
252
|
+
// A covering summary index keeps human listings out of task records' body overflow pages.
|
|
253
|
+
layOutSummaryIndex(db);
|
|
237
254
|
// The calling session's run (CALLER_RUN_ADDRESS) and its cards (SESSION_CURRENT_RUNS, SESSION_BOUND, SESSION_RUNS):
|
|
238
255
|
// without the session's index each lookup read every run's whole metadata, overflow pages included. `id` keeps a
|
|
239
256
|
// session's runs in the order the lookups ask for, and the address makes the index answer the caller's lookup without
|
|
@@ -357,7 +374,7 @@ function heldHandle(entry, slug) {
|
|
|
357
374
|
const guard = () => { if (entry.retired) throw Object.assign(new Error(`board ${slug}'s configuration changed while this call held it; nothing was written (read it again)`), { code: 'board_retired' }); };
|
|
358
375
|
const bound = (target, key) => { const value = target[key]; return typeof value === 'function' ? value.bind(target) : value; };
|
|
359
376
|
const statement = (st) => new Proxy(st, { get: (target, key) => key === 'run' ? (...args) => { guard(); return target.run(...args); } : bound(target, key) });
|
|
360
|
-
const db = new Proxy(entry.board.db, { get: (target, key) => key === 'prepare' ? (sql) => statement(target.prepare(sql)) : key === 'exec' ? (sql) => { guard(); return target.exec(sql); } : bound(target, key) });
|
|
377
|
+
const db = new Proxy(entry.board.db, { get: (target, key) => key === LIST_CONNECTION ? target : key === 'prepare' ? (sql) => statement(target.prepare(sql)) : key === 'exec' ? (sql) => { guard(); return target.exec(sql); } : bound(target, key) });
|
|
361
378
|
let closed = false;
|
|
362
379
|
return { ...entry.board, db, tx: (fn) => { guard(); return entry.board.tx(fn); },
|
|
363
380
|
close() { if (closed) return; closed = true; entry.uses -= 1; if (entry.retired && !entry.uses) try { entry.board.close(); } catch { /* closed */ } } };
|
package/board/ztrack.mjs
CHANGED
|
@@ -68,6 +68,7 @@ const timedStatement = (sql, fn) => {
|
|
|
68
68
|
finally { noteStatement(sql, started, before); }
|
|
69
69
|
};
|
|
70
70
|
import { fire, workflowFor } from './workflow.mjs';
|
|
71
|
+
import { LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
71
72
|
|
|
72
73
|
const require = createRequire(import.meta.url);
|
|
73
74
|
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
@@ -171,6 +172,7 @@ class ZtrackBoard {
|
|
|
171
172
|
timedStep('board open errors', () => this.loadErrors());
|
|
172
173
|
const self = this;
|
|
173
174
|
this.db = {
|
|
175
|
+
[LIST_REVISION]: () => self.read(() => listRevision(self.conn)),
|
|
174
176
|
boardErrors: self.errors,
|
|
175
177
|
prepare(sql) {
|
|
176
178
|
return {
|
|
@@ -768,6 +770,7 @@ class ZtrackBoard {
|
|
|
768
770
|
if (overlay) {
|
|
769
771
|
this.dropOverlay();
|
|
770
772
|
this.conn.exec('CREATE TEMP TABLE tasks AS SELECT * FROM main.tasks; CREATE TEMP TABLE supercode_cards AS SELECT * FROM main.supercode_cards; CREATE TEMP TABLE task_links AS SELECT * FROM main.task_links');
|
|
773
|
+
this.conn.exec(TASK_SUMMARY_INDEX_SQL.replace(TASK_SUMMARY_INDEX, `temp.${TASK_SUMMARY_INDEX}`));
|
|
771
774
|
this.overlay = true; this.statements.clear();
|
|
772
775
|
}
|
|
773
776
|
const initial = !overlay && !this.one('SELECT 1 FROM main.tasks LIMIT 1');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.163",
|
|
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": {
|