@volter/supercode-orchestrator 0.5.227 → 0.5.229
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/activation.mjs +1 -1
- package/adapters/agent-mailbox.mjs +10 -0
- package/adapters/api-server.mjs +6 -0
- package/agents.mjs +149 -40
- package/apply/doors.mjs +3 -1
- package/bin/agent-channel.mjs +7 -2
- package/bin/connection-runner.mjs +60 -19
- package/bin/hermes-worker.mjs +2 -1
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +3 -2
- package/board/agent-current.mjs +82 -0
- package/board/agent-declarations.mjs +10 -3
- package/board/args.mjs +137 -6
- package/board/call.mjs +16 -1
- package/board/caller-session.mjs +3 -1
- package/board/capacity.mjs +59 -45
- package/board/card-state.mjs +14 -5
- package/board/ci.mjs +521 -178
- package/board/cli.mjs +614 -209
- package/board/client.mjs +64 -21
- package/board/close-current.mjs +36 -0
- package/board/current-records.mjs +44 -0
- package/board/custody.mjs +2 -1
- package/board/dispatch.mjs +1973 -846
- package/board/doors.mjs +2 -2
- package/board/engine.mjs +623 -85
- package/board/event-stream.mjs +10 -5
- package/board/fence.mjs +2 -0
- package/board/files.mjs +93 -62
- package/board/generation-owner.mjs +188 -53
- package/board/git-document.mjs +3 -1
- package/board/github-rest.mjs +29 -7
- package/board/incidents.mjs +5 -17
- package/board/listing.mjs +14 -8
- package/board/local-admission.mjs +59 -0
- package/board/lock-holder.mjs +18 -6
- package/board/machine-fleet.mjs +66 -13
- package/board/owner-door-worker.mjs +3 -3
- package/board/owner-door.mjs +17 -7
- package/board/owner-health.mjs +12 -15
- package/board/owner-interface.mjs +6 -0
- package/board/publication-reader.mjs +50 -0
- package/board/published.mjs +40 -12
- package/board/queries.mjs +24 -4
- package/board/record-view.mjs +30 -3
- package/board/request-mail.mjs +37 -6
- package/board/resting-admission.mjs +318 -0
- package/board/resting-codec-qualifications.json +33 -0
- package/board/resting-protocol.mjs +71 -0
- package/board/resting-state-protocol.json +120 -0
- package/board/resting-state.mjs +113 -0
- package/board/runtime-door.mjs +2 -2
- package/board/session-continuity.mjs +127 -0
- package/board/session-work.mjs +25 -13
- package/board/standalone-declaration.mjs +24 -0
- package/board/storage-contract.mjs +129 -7
- package/board/store.mjs +73 -11
- package/board/tools.mjs +56 -13
- package/board/wake.mjs +18 -5
- package/board/workflow.mjs +284 -82
- package/board/workspace.mjs +86 -9
- package/board/ztrack.mjs +344 -91
- package/connection.mjs +72 -17
- package/gates.mjs +2 -1
- package/hand-off.mjs +4 -3
- package/hermes-cli.mjs +5 -3
- package/hermes-locks.mjs +5 -2
- package/hosted.mjs +2 -1
- package/index.d.ts +2 -0
- package/index.mjs +1 -1
- package/kanban-worker.mjs +63 -13
- package/loop.mjs +33 -0
- package/mcp/hermes-kanban-tools.json +109 -50
- package/mcp/server.mjs +7 -1
- package/ontology.d.ts +26 -0
- package/orchestration.mjs +15 -4
- package/package.json +19 -12
- package/session-recovery.mjs +2 -1
- package/worker-route.mjs +37 -19
package/board/listing.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { setImmediate as nextTurn } from 'node:timers/promises';
|
|
4
4
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
5
5
|
import { executionOf } from './session-work.mjs';
|
|
6
|
+
import { DISPATCH_WAIT_SQL } from './queries.mjs';
|
|
6
7
|
import { CARD_VISIBILITY_SQL } from './visibility.mjs';
|
|
7
8
|
import { cardState, cardStateSql } from './card-state.mjs';
|
|
8
9
|
|
|
@@ -14,6 +15,8 @@ export const READ_BY_OWNER = 'board_read_by_owner';
|
|
|
14
15
|
export const byOwner = (reason) => Object.assign(new Error(`this listing is the board owner's to answer: ${reason}`), { code: READ_BY_OWNER });
|
|
15
16
|
export const LIST_CONNECTION = Symbol.for('supercode.board.listing.connection');
|
|
16
17
|
export const LIST_REVISION = Symbol.for('supercode.board.listing.revision');
|
|
18
|
+
export const READ_BOUNDARY = Symbol.for('supercode.board.read.boundary');
|
|
19
|
+
export const readBoundary = (db, fn) => db[READ_BOUNDARY] ? db[READ_BOUNDARY](fn) : fn();
|
|
17
20
|
const parse = (text, fallback) => { try { return JSON.parse(text) ?? fallback; } catch { return fallback; } };
|
|
18
21
|
const statements = new WeakMap();
|
|
19
22
|
const prepared = (db, sql) => {
|
|
@@ -53,6 +56,8 @@ export const TASK_SUMMARY_INDEX = 'idx_tasks_list_summary_readable';
|
|
|
53
56
|
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)`;
|
|
54
57
|
const RUN_METADATA = `CASE WHEN json_valid(r.metadata) THEN json_object('supercode', json_object(
|
|
55
58
|
'address',json_extract(r.metadata,'$.supercode.address'),'session_id',json_extract(r.metadata,'$.supercode.session_id'),
|
|
59
|
+
'pane',json_extract(r.metadata,'$.supercode.pane'),'pid',json_extract(r.metadata,'$.supercode.pid'),'launch_key',json_extract(r.metadata,'$.supercode.launch_key'),
|
|
60
|
+
'launch_deferred',json_extract(r.metadata,'$.supercode.launch_deferred'),'launch_deferred_error',json_extract(r.metadata,'$.supercode.launch_deferred_error'),'launched_at',json_extract(r.metadata,'$.supercode.launched_at'),
|
|
56
61
|
'close_confirmed_at',json_extract(r.metadata,'$.supercode.close_confirmed_at'),'released',json_extract(r.metadata,'$.supercode.released'),
|
|
57
62
|
'presence_observation',json_extract(r.metadata,'$.supercode.presence_observation'),'idle_observation',json_extract(r.metadata,'$.supercode.idle_observation'),
|
|
58
63
|
'machine',json_extract(r.metadata,'$.supercode.machine'),'offline_since',json_extract(r.metadata,'$.supercode.offline_since'),
|
|
@@ -61,12 +66,13 @@ const RUN_METADATA = `CASE WHEN json_valid(r.metadata) THEN json_object('superco
|
|
|
61
66
|
), 'last_events', json_object('heartbeat', json_extract(r.metadata,'$.last_events.heartbeat'))) END`;
|
|
62
67
|
// What a working card's session reading needs beside its current run (session-work.mjs executionOf, readingContext reads
|
|
63
68
|
// the same): the current run's latest heartbeat event (a declared wait, --until) and, with no current run, its latest
|
|
64
|
-
// run.
|
|
69
|
+
// run. The dispatcher's applicable wait is read in any open lane with no current run.
|
|
65
70
|
const WORKING_LANE = "COALESCE(c.stage,t.status) IN ('running','review')";
|
|
66
71
|
const READING_CONTEXT = `CASE WHEN ${WORKING_LANE} THEN (SELECT json_object('payload',CASE WHEN json_valid(e.payload) THEN json(e.payload) END,'at',e.created_at,'run_id',e.run_id)
|
|
67
72
|
FROM task_events e WHERE e.task_id = t.id AND e.kind = 'heartbeat' AND e.run_id = t.current_run_id ORDER BY e.id DESC LIMIT 1) END AS heartbeat_event,
|
|
68
73
|
CASE WHEN ${WORKING_LANE} AND t.current_run_id IS NULL THEN (SELECT json_object('status',l.status,'outcome',l.outcome,'ended_at',l.ended_at,'error',substr(l.error,1,300))
|
|
69
|
-
FROM task_runs l WHERE l.task_id = t.id ORDER BY l.id DESC LIMIT 1) END AS last_run
|
|
74
|
+
FROM task_runs l WHERE l.task_id = t.id ORDER BY l.id DESC LIMIT 1) END AS last_run,
|
|
75
|
+
(${DISPATCH_WAIT_SQL}) AS dispatch_wait`;
|
|
70
76
|
export const LIST_CANDIDATES_SQL = `SELECT MAX(id) AS last, json_group_array(id) AS ids FROM (
|
|
71
77
|
SELECT id FROM tasks WHERE id > ? AND id <= ? ORDER BY id LIMIT ${LIST_BATCH})`;
|
|
72
78
|
|
|
@@ -81,7 +87,7 @@ export const LAST_BLOCK_SQL = `SELECT CASE WHEN kind = 'blocked' THEN payload EN
|
|
|
81
87
|
|
|
82
88
|
export function listPageSql(detailed, tagCount = 0, options = {}) {
|
|
83
89
|
return `SELECT t.rowid AS list_order,${(detailed ? JSON_FIELDS : HUMAN_FIELDS).split(',').map(name => `t.${name}`).join(',')},
|
|
84
|
-
n.number,c.machine,c.stage,c.paused,c.blocked_by,c.detached${detailed ? ',c.subtask_of' : ''},
|
|
90
|
+
n.number,c.machine,c.stage,c.paused,c.backlog,c.blocked_by,c.detached${detailed ? ',c.subtask_of' : ''},
|
|
85
91
|
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,
|
|
86
92
|
${READING_CONTEXT}
|
|
87
93
|
${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,
|
|
@@ -89,7 +95,7 @@ export function listPageSql(detailed, tagCount = 0, options = {}) {
|
|
|
89
95
|
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
|
|
90
96
|
LEFT JOIN task_runs r ON r.id = t.current_run_id
|
|
91
97
|
WHERE t.id IN (SELECT value FROM json_each(?)) AND (? IS NULL OR ${cardStateSql(options)} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?))
|
|
92
|
-
AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND ${CARD_VISIBILITY_SQL}
|
|
98
|
+
AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? IS NULL OR (c.backlog IS NOT NULL AND c.backlog != 'null') = ?) AND ${CARD_VISIBILITY_SQL}
|
|
93
99
|
AND (? IS NULL OR t.tenant = ?) AND (? IS NULL OR t.session_id = ?)
|
|
94
100
|
AND (? IS NULL OR t.workflow_template_id = ?) AND (? IS NULL OR t.current_step_key = ?)
|
|
95
101
|
${Array.from({ length: tagCount }, () => `AND CASE WHEN json_valid(c.tags) THEN
|
|
@@ -112,7 +118,7 @@ export async function listCardsInTurns(db, options = {}) {
|
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
async function readCardsInTurns(db, { detailed = false, status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = false,
|
|
115
|
-
tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [], nativeHolds = false }, revision, { inTurns = true } = {}) {
|
|
121
|
+
tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [], nativeHolds = false, backlog = null }, revision, { inTurns = true } = {}) {
|
|
116
122
|
const out = [];
|
|
117
123
|
const page = prepared(db, listPageSql(detailed, tags.length, { nativeHolds })), candidates = prepared(db, LIST_CANDIDATES_SQL);
|
|
118
124
|
const ceiling = measured('last candidate', () => prepared(db, "SELECT COALESCE(MAX(id), '') AS cursor FROM tasks").get().cursor);
|
|
@@ -122,13 +128,13 @@ async function readCardsInTurns(db, { detailed = false, status = null, assignee
|
|
|
122
128
|
const range = measured('candidate range', () => candidates.get(cursor, ceiling));
|
|
123
129
|
if (range.last == null) break;
|
|
124
130
|
const rows = measured(detailed ? 'JSON task rows and latest blocks' : 'human task rows', () => page.all(range.ids,
|
|
125
|
-
status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0,
|
|
131
|
+
status, status, status, status, assignee, assignee, machine, machine, backlog == null ? null : Number(Boolean(backlog)), backlog == null ? null : Number(Boolean(backlog)), includeSubtasks ? 1 : 0, includeArchived ? 1 : 0,
|
|
126
132
|
tenant, tenant, session, session, workflowTemplateId, workflowTemplateId, stepKey, stepKey, ...tags.flatMap(tag => [tag, tag, tag])));
|
|
127
133
|
measured('row projection', () => {
|
|
128
|
-
for (const { run_id, run_status, run_started_at, run_ended_at, run_metadata, heartbeat_event, last_run, parents_json, block_payload, ...task } of rows) {
|
|
134
|
+
for (const { run_id, run_status, run_started_at, run_ended_at, run_metadata, heartbeat_event, last_run, dispatch_wait, parents_json, block_payload, ...task } of rows) {
|
|
129
135
|
const metadata = run_id ? parse(run_metadata, {}) : null;
|
|
130
136
|
const run = run_id ? { id: run_id, status: run_status, started_at: run_started_at, ended_at: run_ended_at, metadata } : null;
|
|
131
|
-
task.execution = executionOf(run, { heartbeat: parse(heartbeat_event, null), last: parse(last_run, null) });
|
|
137
|
+
task.execution = executionOf(run, { heartbeat: parse(heartbeat_event, null), last: parse(last_run, null), queue: parse(dispatch_wait, null) });
|
|
132
138
|
if (detailed) {
|
|
133
139
|
task.skills = parse(task.skills, []);
|
|
134
140
|
task.workflow_status = task.stage ?? task.status;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A run-on-the-machine verb (client.mjs: boards, init, gc, repair, migrate, dispatch, watch, tail) runs in a process of its
|
|
2
|
+
// own, never a board generation, so prepareBoardRestingAdmission (resting-admission.mjs) never binds its reader: every
|
|
3
|
+
// fenced transaction it opened over a home holding a backlog or a bounded pause refused (`boards list`, a cold `init`).
|
|
4
|
+
// It is admitted on the incumbent a generation is: a running owner's own status, else the record the home's stable owner
|
|
5
|
+
// keeps (board-generation.json), which a cold owner lends its first generation (generation-owner.mjs). With neither, or
|
|
6
|
+
// a legacy incumbent, it stays legacy and such a home still refuses it. selectedReader and recordedReader are the
|
|
7
|
+
// owner's admission's (resting-admission.mjs), kept here so that storage module, and so the board format, stays as it is.
|
|
8
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
9
|
+
import { isAbsolute, join } from 'node:path';
|
|
10
|
+
import { askBoardOwner } from '@volter/teams/board-owner';
|
|
11
|
+
import { nativeJsonEqual } from '@volter/supercode-interchange/native-json';
|
|
12
|
+
import { readSourceCandidate } from '@volter/supercode-teams/source-selection'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
|
|
13
|
+
import { sourceLockPath } from '@volter/supercode-teams/source-lock'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
|
|
14
|
+
import { readRetainedRelease } from '@volter/supercode-teams/release-selection'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
|
|
15
|
+
import { restingContract } from './resting-protocol.mjs';
|
|
16
|
+
import { bindRestingReader, restingReaderBound } from './resting-state.mjs';
|
|
17
|
+
|
|
18
|
+
const MODE = 'backlog-bounded-pause-v1';
|
|
19
|
+
|
|
20
|
+
function selectedReader(selected, env) {
|
|
21
|
+
if (!selected || typeof selected.entry !== 'string' || !isAbsolute(selected.entry)) return null;
|
|
22
|
+
const entry = realpathSync(selected.entry);
|
|
23
|
+
if (!selected.candidate === !selected.release) return null;
|
|
24
|
+
if (selected.candidate) {
|
|
25
|
+
const source = readSourceCandidate(realpathSync(selected.candidate), sourceLockPath(env));
|
|
26
|
+
if (entry !== realpathSync(join(source.value.lock.root, 'sdk/orchestrator/bin/orchestrator.mjs')) ||
|
|
27
|
+
realpathSync(selected.native) !== realpathSync(source.program)) return null;
|
|
28
|
+
} else {
|
|
29
|
+
const release = readRetainedRelease(selected.release);
|
|
30
|
+
if (entry !== realpathSync(release.board) || realpathSync(selected.native) !== realpathSync(release.program)) return null;
|
|
31
|
+
}
|
|
32
|
+
return restingContract(entry, { optional: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function recordedReader(status, env) {
|
|
36
|
+
const prior = status.restingReaderProtocol === 1 && status.lastRestingReader;
|
|
37
|
+
if (prior?.version !== 1 || !prior.selected || !prior.resting) return null;
|
|
38
|
+
const actual = selectedReader(prior.selected, env);
|
|
39
|
+
return actual && nativeJsonEqual(actual, prior.resting) ? prior.selected : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function admitLocalRestingReader(root, env = process.env) {
|
|
43
|
+
if (restingReaderBound()) return;
|
|
44
|
+
let compatible = false;
|
|
45
|
+
try {
|
|
46
|
+
root = realpathSync(root);
|
|
47
|
+
const answer = await askBoardOwner(root, { argv: ['handover', '--action', 'status'] }, { waitMs: 0, timeoutMs: 8000 });
|
|
48
|
+
let status = null;
|
|
49
|
+
if (answer.code === 0 && !answer.unanswered) status = JSON.parse(Array.isArray(answer.stdout) ? answer.stdout.join('') : answer.stdout);
|
|
50
|
+
else if (answer.down && existsSync(join(root, 'board-generation.json'))) {
|
|
51
|
+
const record = JSON.parse(readFileSync(join(root, 'board-generation.json'), 'utf8'));
|
|
52
|
+
if (record?.version === 1 && record.restingReaderProtocol === 1) status = { restingReaderProtocol: 1, lastRestingReader: record.lastRestingReader ?? null };
|
|
53
|
+
}
|
|
54
|
+
const selected = status?.selected ?? (status ? recordedReader(status, env) : null);
|
|
55
|
+
const reader = selected ? selectedReader(selected, env) : null;
|
|
56
|
+
compatible = reader?.protocol.readModes.includes(MODE) === true && reader.codec?.formatFeatures?.includes('acceptance-layout-v1') === true;
|
|
57
|
+
} catch { compatible = false; }
|
|
58
|
+
bindRestingReader(compatible);
|
|
59
|
+
}
|
package/board/lock-holder.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
|
|
1
2
|
// The board owner's one-per-home lock, held by a process of its own. A POSIX record lock belongs to the process that took
|
|
2
3
|
// it, and any open and close of the same file in that process drops it; no reader in the owner can reach a lock another
|
|
3
4
|
// process holds. The holder takes SQLite's exclusive lock on `board.lock` (the default VFS), says so, and exits when its
|
|
@@ -11,7 +12,10 @@ let lock;
|
|
|
11
12
|
try {
|
|
12
13
|
lock = new DatabaseSync(process.argv[1]);
|
|
13
14
|
lock.exec('PRAGMA busy_timeout = 0; PRAGMA locking_mode = EXCLUSIVE; BEGIN EXCLUSIVE');
|
|
14
|
-
} catch (error) {
|
|
15
|
+
} catch (error) {
|
|
16
|
+
// SQLITE_BUSY (5) is another process holding the lock; any other failure (an unopenable path) is not an owner.
|
|
17
|
+
process.stdout.write(((error.errcode & 0xff) === 5 ? 'held ' : 'failed ') + String(error.message).replace(/\\n/g, ' ') + '\\n'); process.exit(1);
|
|
18
|
+
}
|
|
15
19
|
process.stdout.write('ready\\n');
|
|
16
20
|
process.stdin.on('data', () => {});
|
|
17
21
|
process.stdin.on('end', () => process.exit(0));
|
|
@@ -19,16 +23,18 @@ process.stdin.on('error', () => process.exit(0));
|
|
|
19
23
|
`;
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
|
-
* Hold `path` for this process's life. Resolves once the holder has the lock; rejects with its reason
|
|
23
|
-
* owner holds it. `onLost` is told if the holder ends while this process still runs (the lock is then gone).
|
|
24
|
-
* Returns `release()`, which
|
|
26
|
+
* Hold `path` for this process's life. Resolves once the holder has the lock; rejects with its reason otherwise, the
|
|
27
|
+
* rejection's `code` 'EHELD' only when another owner holds it. `onLost` is told if the holder ends while this process still runs (the lock is then gone).
|
|
28
|
+
* Returns idempotent `release()`, which joins the holder's exit.
|
|
25
29
|
*/
|
|
26
30
|
export function holdLock(path, { onLost = () => {} } = {}) {
|
|
27
31
|
return new Promise((resolve, reject) => {
|
|
28
32
|
const child = spawn(process.execPath, ['--no-warnings', '-e', HOLDER, path], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
33
|
+
const exited = new Promise(done => child.once('exit', done));
|
|
29
34
|
let said = '';
|
|
30
35
|
let ready = false;
|
|
31
36
|
let released = false;
|
|
37
|
+
let releasing;
|
|
32
38
|
child.stdout.setEncoding('utf8');
|
|
33
39
|
child.stdout.on('data', (chunk) => {
|
|
34
40
|
said += chunk;
|
|
@@ -37,8 +43,14 @@ export function holdLock(path, { onLost = () => {} } = {}) {
|
|
|
37
43
|
if (line === 'ready') {
|
|
38
44
|
ready = true;
|
|
39
45
|
child.unref(); child.stdout.unref?.(); child.stdin.unref?.();
|
|
40
|
-
resolve({ release: () =>
|
|
41
|
-
|
|
46
|
+
resolve({ release: () => releasing ??= (async () => {
|
|
47
|
+
released = true;
|
|
48
|
+
// The lock is normally unreferenced. Keep its exit alive while its owner joins retirement.
|
|
49
|
+
child.ref(); child.stdin.ref?.();
|
|
50
|
+
stopWorkChild(child, () => child.stdin.end());
|
|
51
|
+
await exited;
|
|
52
|
+
})() });
|
|
53
|
+
} else reject(line.startsWith('held ') ? Object.assign(new Error(line.slice(5)), { code: 'EHELD' }) : new Error(line.replace(/^failed /, '')));
|
|
42
54
|
});
|
|
43
55
|
child.on('error', (error) => { if (!ready) reject(error); });
|
|
44
56
|
child.on('exit', (code, signal) => {
|
package/board/machine-fleet.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { measureSyncChild } from '@volter/supercode-harness-sdk/work-ledger';
|
|
2
|
+
const execFileSync = (...args) => measureSyncChild(() => workLedgerNativeExecFileSync(...args));
|
|
1
3
|
// The fleet as each machine the board works on keeps it: its panes, the harness session beneath each live pane's
|
|
2
4
|
// process, and what a board launch needs of it (`capabilities`: its board process guard's harnesses, and whether it has
|
|
3
5
|
// the keyed launch door). Each machine pushes this as its record (order 3 PR 3, @volter/teams machine-records): this
|
|
@@ -7,7 +9,7 @@
|
|
|
7
9
|
// A machine whose record is current is `online`. One whose record is out of date (its stream closed, its panes or the
|
|
8
10
|
// sessions under them not read) or was never read is `unknown` (its runs are kept, as an unread machine's always were), unless its record says it was unlinked from the
|
|
9
11
|
// team: then it is offline. This machine's own record not being given is no reading at all.
|
|
10
|
-
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { execFileSync as workLedgerNativeExecFileSync } from 'node:child_process';
|
|
11
13
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
12
14
|
import { machineCall, machineRecordStream } from './doors.mjs';
|
|
13
15
|
import { RecordView } from './record-view.mjs';
|
|
@@ -19,9 +21,12 @@ const recordView = new RecordView(machineRecordStream);
|
|
|
19
21
|
const paneFacts = (held) => {
|
|
20
22
|
const record = held?.record;
|
|
21
23
|
if (!record?.panes) return '';
|
|
22
|
-
const under = new Map((record.panes.activities ?? []).map((
|
|
24
|
+
const under = new Map((record.panes.activities ?? []).map((row) => [row.pid, row]));
|
|
23
25
|
return JSON.stringify([
|
|
24
|
-
(record.panes.panes ?? []).map((pane) =>
|
|
26
|
+
(record.panes.panes ?? []).map((pane) => {
|
|
27
|
+
const row = under.get(pane.pid);
|
|
28
|
+
return [pane.pane, Boolean(pane.dead), row?.activity?.harness ?? null, row?.activity?.session_id ?? null, row?.locator?.storage ?? null, row?.activity?.presence === 'running'];
|
|
29
|
+
}).sort(),
|
|
25
30
|
(record.panes.ended ?? []).map((exit) => exit.pane).sort(),
|
|
26
31
|
]);
|
|
27
32
|
};
|
|
@@ -45,23 +50,46 @@ export function onPanesChanged(listener) { paneListeners.add(listener); return (
|
|
|
45
50
|
/** The admitted, committed records, including native turn/health changes which need not change a pane. */
|
|
46
51
|
export function onRecordsChanged(listener) { recordListeners.add(listener); return () => recordListeners.delete(listener); }
|
|
47
52
|
export async function readFleetRecords() { return recordView.read(DEADLINE_MS); }
|
|
53
|
+
/**
|
|
54
|
+
* One machine's panes as its held record has them at this moment, with no door call and no wait: its pane rows (as
|
|
55
|
+
* panes.list gives them: pane, dead, pid, cwd, activeCommand) and the harness session under each live pane
|
|
56
|
+
* (`activities`, by pid, with its locator). Null when that record is not current (the record stream not open and
|
|
57
|
+
* caught up, the machine's own stream out of date, its panes or the sessions under them not read): the caller then
|
|
58
|
+
* asks the machine's door, as it did before the record was read here.
|
|
59
|
+
*/
|
|
60
|
+
export function heldPanes(machine) {
|
|
61
|
+
if (!fleetRecordsCurrent()) return null;
|
|
62
|
+
const held = recordView.records?.[machine];
|
|
63
|
+
if (notCurrent(held)) return null;
|
|
64
|
+
const panes = held.record.panes.panes ?? [];
|
|
65
|
+
// a record from a daemon that does not name each pane's foreground program (an older one) is no reading of what a
|
|
66
|
+
// closing pane runs: its door is asked, as before
|
|
67
|
+
if (panes.some((pane) => !Object.hasOwn(pane, 'activeCommand'))) return null;
|
|
68
|
+
return { panes, activities: held.record.panes.activities ?? [] };
|
|
69
|
+
}
|
|
48
70
|
export function fleetRecordsCurrent() { return Boolean(recordView.ready && recordView.stream && Date.now() - recordView.heardAt <= 45_000); }
|
|
49
71
|
const DEADLINE_MS = 10_000;
|
|
50
72
|
const within = (promise, ms) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error(`no answer within ${ms} ms`), { code: 'timeout' })), ms).unref?.())]);
|
|
51
73
|
// One machine's reading, from its held record: its panes, what a board launch needs of it, and the session under each
|
|
52
74
|
// live pane.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (held
|
|
56
|
-
if (
|
|
57
|
-
if (held.
|
|
75
|
+
/** Why a machine's held record is no current reading of its panes (`{ error, code }`), or null when it is one. */
|
|
76
|
+
function notCurrent(held) {
|
|
77
|
+
if (!held) return { error: 'this machine follows no record of it (not linked to the team, or not yet read)', code: 'unavailable' };
|
|
78
|
+
if (held.stream === 'unlinked') return { error: held.why ?? 'unlinked from the team', code: 'machine_offline' };
|
|
79
|
+
if (!held.record) return { error: `its record was never read: ${held.why ?? 'no reason given'}`, code: 'unavailable' };
|
|
80
|
+
if (held.stream !== 'open' && held.stream !== 'self') return { error: `its record may be out of date: its stream is ${held.stream} since ${held.since ? new Date(held.since).toISOString() : 'unknown'} (${held.why ?? 'no reason given'})`, code: 'stale' };
|
|
58
81
|
const record = held.record;
|
|
59
82
|
// panes not read, or read without the sessions under them (or while the session index is lost), are no reading of
|
|
60
83
|
// what runs there: the machine is unknown, never one whose panes or sessions are absent (refused is not absent). A
|
|
61
84
|
// part the index could not read (`sessions_index_parts`) is not: it keeps the rows its last read committed, and the
|
|
62
85
|
// sessions under the panes are read by their processes, not from it; the record names it.
|
|
63
|
-
const
|
|
64
|
-
|
|
86
|
+
const unread = record.unread?.panes ?? record.unread?.sessions_index ?? record.unread?.claude_registry_watch ?? record.unread?.pane_exits_watch ?? record.panes?.activities_omitted ?? record.panes?.sessions_unread ?? (record.panes ? null : 'its record holds no pane list');
|
|
87
|
+
return unread ? { error: `its panes are not current: ${unread}`, code: 'stale' } : null;
|
|
88
|
+
}
|
|
89
|
+
function readOne(name, held) {
|
|
90
|
+
const why = notCurrent(held);
|
|
91
|
+
if (why) return { machine: name, ...why };
|
|
92
|
+
const record = held.record;
|
|
65
93
|
const panes = record.panes?.panes ?? [];
|
|
66
94
|
const capabilities = record.capabilities ?? { guard: [], launches: false };
|
|
67
95
|
// Since when its record has been current without a break: this machine's own since it last woke (localWake), another's
|
|
@@ -80,6 +108,27 @@ export function localWake() {
|
|
|
80
108
|
} catch { return null; }
|
|
81
109
|
}
|
|
82
110
|
|
|
111
|
+
/** The parts of a record its session rows and their activity come from: one not read leaves no row of it held. */
|
|
112
|
+
const SESSION_SOURCES = ['sessions', 'sessions_index', 'sessions_index_parts', 'claude_registry_watch', 'sessions_activity'];
|
|
113
|
+
/**
|
|
114
|
+
* The sessions a machine's held record lists with their activity, grouped by address without losing distinct stores,
|
|
115
|
+
* as a discovery row of each (`locator`,
|
|
116
|
+
* `cwd`, `updated_at_ms`, `activity`: the machine's follow of its live sessions), where that record is current with
|
|
117
|
+
* every source of its session list read; none otherwise. A session the record lists without an activity (one past the
|
|
118
|
+
* sessions the machine follows, or a follower not granted it) is not held.
|
|
119
|
+
*/
|
|
120
|
+
function heldSessions(name, held, into, transcripts) {
|
|
121
|
+
if (!held?.record || (held.stream !== 'open' && held.stream !== 'self') || !Array.isArray(held.record.sessions)) return;
|
|
122
|
+
if (SESSION_SOURCES.some((source) => held.record.unread?.[source])) return;
|
|
123
|
+
for (const row of held.record.sessions) {
|
|
124
|
+
if (!row?.locator || typeof row.address !== 'string' || !row.address.startsWith(`sc:${name}:`)) continue;
|
|
125
|
+
// `transcripts`: every listed session's transcript locator, activity or not
|
|
126
|
+
transcripts.set(row.address, [...(transcripts.get(row.address) ?? []), { locator: row.locator }]);
|
|
127
|
+
if (!row.activity) continue;
|
|
128
|
+
into.set(row.address, [...(into.get(row.address) ?? []), { locator: row.locator, cwd: row.cwd ?? null, updated_at_ms: row.last_message_at_ms ?? null, activity: row.activity }]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
83
132
|
/** One reading of `machines` (normalized names) and this machine, `local`: every record, from this machine's daemon. */
|
|
84
133
|
export async function readMachineFleet(machines, local) {
|
|
85
134
|
const names = [...new Set([local, ...machines])];
|
|
@@ -96,11 +145,16 @@ export async function readMachineFleet(machines, local) {
|
|
|
96
145
|
}
|
|
97
146
|
catch (error) { heldError = String(error?.message ?? error).slice(0, 300); }
|
|
98
147
|
const rows = names.map((name) => heldError ? { machine: name, error: `this machine's daemon did not give the records it holds: ${heldError}`, code: 'unavailable' } : readOne(name, held[name]));
|
|
148
|
+
// `held`: each machine's live session rows with their activity, grouped by address (heldSessions);
|
|
149
|
+
// `transcripts`: each listed transcript locator, grouped by address, retaining distinct native stores
|
|
150
|
+
const heldRows = new Map(), transcripts = new Map();
|
|
151
|
+
if (!heldError) for (const name of names) heldSessions(name, held[name], heldRows, transcripts);
|
|
99
152
|
noteStep(`fleet reading (${names.length} machine${names.length === 1 ? '' : 's'}, held records)`, performance.now() - started);
|
|
100
153
|
// `panes`: each answering machine's live pane ids, whether or not a session was read in them
|
|
154
|
+
// `alive`: address-only presence for mail routing; retained conversations use the locators in `sessions`
|
|
101
155
|
// `ended`: the panes each machine saw end (its tmux's exit log, or a pane its host keeps dead), with how each ended
|
|
102
156
|
// `linkedSince`: each online machine's unbroken current reading began (epoch ms; this machine's last wake; null unknown)
|
|
103
|
-
const online = new Set(), offline = new Set(), unknown = new Set(), errors = new Map(), alive = new Set(),
|
|
157
|
+
const online = new Set(), offline = new Set(), unknown = new Set(), errors = new Map(), alive = new Set(), sessions = [], capabilities = new Map(), panes = new Map(), ended = new Map(), linkedSince = new Map();
|
|
104
158
|
let localError = null, localCode = null;
|
|
105
159
|
for (const row of rows) {
|
|
106
160
|
if (row.error) {
|
|
@@ -124,7 +178,6 @@ export async function readMachineFleet(machines, local) {
|
|
|
124
178
|
const pane = live.find((item) => item.pid === pid);
|
|
125
179
|
if (!pane || !activity?.session_id || !activity.harness) continue;
|
|
126
180
|
const address = `sc:${row.machine}:${activity.harness}:${activity.session_id}`, running = activity.presence === 'running';
|
|
127
|
-
native.set(address, { alive: running ? true : null, states: [running ? true : null] });
|
|
128
181
|
if (running) alive.add(address);
|
|
129
182
|
sessions.push({ address, machine: row.machine, harness: activity.harness, session_id: activity.session_id, locator: locator ?? null, pane: pane.pane, name: pane.title ?? pane.label ?? null,
|
|
130
183
|
starter_session: pane.starter_session ?? null,
|
|
@@ -142,5 +195,5 @@ export async function readMachineFleet(machines, local) {
|
|
|
142
195
|
// why, as any other is: the machines that answered are current beside it. Only no record of this machine at all
|
|
143
196
|
// leaves the whole reading untaken.
|
|
144
197
|
const ok = online.has(local) || (!heldError && localCode === 'stale');
|
|
145
|
-
return { ok, presenceKnown: ok, online, offline, unknown, errors, alive,
|
|
198
|
+
return { ok, presenceKnown: ok, online, offline, unknown, errors, alive, sessions, held: heldRows, transcripts, capabilities, panes, ended, linkedSince, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
|
|
146
199
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerWorkService } from '@volter/supercode-harness-sdk/work-ledger';
|
|
1
2
|
// The thread that serves the board owner's socket (owner-door.mjs). A listing is answered here, read on a connection of
|
|
2
3
|
// its own; every other request is handed to the owner's thread and its answer written back.
|
|
3
4
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
@@ -7,19 +8,18 @@ import { boardOwnerSocket } from '@volter/teams/board-owner';
|
|
|
7
8
|
import { parse } from './args.mjs';
|
|
8
9
|
import { answerOne } from './cli.mjs';
|
|
9
10
|
import { knowLocalMachine } from './dispatch.mjs';
|
|
10
|
-
import { holdOwnerHealth } from './owner-health.mjs';
|
|
11
11
|
import { ownHome } from './owner-files.mjs';
|
|
12
12
|
import { writeOwnerAnswer } from './reply.mjs';
|
|
13
13
|
import { holdWorkflow } from './workflow.mjs';
|
|
14
14
|
|
|
15
15
|
const { root, machine, readListings = true } = workerData;
|
|
16
|
+
registerWorkService({role:'board-door-worker',scope:{kind:'install',id:root},workerPurpose:'board-door'});
|
|
16
17
|
const waiting = new Map(); // request id → its answer's resolve
|
|
17
18
|
const MAX_REQUEST = 8 * 1024 * 1024, MAX_PENDING = 64 * 1024 * 1024;
|
|
18
19
|
let next = 0, pendingBytes = 0, inputBytes = 0, connections = 0;
|
|
19
20
|
const refused = message => ({ code: 1, stdout: '', stderr: `supercode workflow: ${message}; no operation was submitted\n` });
|
|
20
21
|
parentPort.on('message', (frame) => {
|
|
21
|
-
if (frame.type === '
|
|
22
|
-
else if (frame.type === 'workflow') holdWorkflow(frame.root, frame.entry);
|
|
22
|
+
if (frame.type === 'workflow') holdWorkflow(frame.root, frame.entry);
|
|
23
23
|
else if (frame.type === 'answer') { const held = waiting.get(frame.id); if (held) { pendingBytes -= held.bytes; waiting.delete(frame.id); held.resolve(frame.answer); } }
|
|
24
24
|
});
|
|
25
25
|
await knowLocalMachine(machine);
|
package/board/owner-door.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { observeWorker, stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
|
|
1
2
|
// The board owner's socket, served on a thread of its own (owner-door-worker.mjs), so a caller is heard while the
|
|
2
3
|
// owner's loop is held by a round. A listing (`list`, `ls`) is read on that thread, each on a connection of its own in
|
|
3
4
|
// one SQLite read transaction (store.mjs openBoard, readOnly): WAL serves it beside the owner's writes, and neither
|
|
4
5
|
// waits on the other. Every other request, and a listing that thread leaves to the owner (listing.mjs byOwner), comes
|
|
5
|
-
// here and is answered by the board's one writer exactly as before. The thread holds
|
|
6
|
-
//
|
|
6
|
+
// here and is answered by the board's one writer exactly as before. The thread holds the workflow it read
|
|
7
|
+
// (workflow.mjs), sent on each change.
|
|
7
8
|
import { Worker } from 'node:worker_threads';
|
|
8
|
-
import { watchOwnerHealth } from './owner-health.mjs';
|
|
9
9
|
import { watchWorkflows } from './workflow.mjs';
|
|
10
10
|
|
|
11
11
|
const failed = (error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` });
|
|
@@ -14,17 +14,24 @@ const failed = (error) => ({ code: 1, stdout: '', stderr: `supercode workflow: $
|
|
|
14
14
|
* Serve the owner's socket for the home at `root` (the caller holds its lock and has removed any old socket): resolves
|
|
15
15
|
* once the socket is bound. `answer(line)` answers a request here; `machine` is this machine's name as the owner learned
|
|
16
16
|
* it. A door that ends while the owner runs leaves the home with no door: the owner says so and exits, for its keeper to
|
|
17
|
-
* start it again. Answers the door's
|
|
17
|
+
* start it again. Answers an idempotent close that joins the door worker's exit.
|
|
18
18
|
*/
|
|
19
19
|
export async function openOwnerDoor(root, { machine, answer, log, readListings = true }) {
|
|
20
20
|
const worker = new Worker(new URL('./owner-door-worker.mjs', import.meta.url), { name: 'supercode board door', workerData: { root, machine, readListings } });
|
|
21
|
+
observeWorker(worker,'worker');
|
|
21
22
|
let open = false, closing = false;
|
|
22
23
|
const send = (frame) => { try { worker.postMessage(frame); } catch (error) { log(`board door: could not hand the door ${frame.type}: ${error.message}`); } };
|
|
23
24
|
const stops = [
|
|
24
|
-
watchOwnerHealth(({ boards, round }) => send({ type: 'health', boards, round })),
|
|
25
25
|
watchWorkflows((at, { workflow, params, profiles }) => send({ type: 'workflow', root: at, entry: { workflow, params, profiles } })),
|
|
26
26
|
];
|
|
27
|
-
|
|
27
|
+
let closingPromise;
|
|
28
|
+
const close = () => closingPromise ??= (async () => {
|
|
29
|
+
closing = true;
|
|
30
|
+
const errors = [];
|
|
31
|
+
for (const stop of stops) { try { stop(); } catch (error) { errors.push(error); } }
|
|
32
|
+
try { await stopWorkChild(worker, () => worker.terminate(), { exitCode: 1 }); } catch (error) { errors.push(error); }
|
|
33
|
+
if (errors.length) throw new AggregateError(errors, 'board door shutdown failed', { cause: errors[0] });
|
|
34
|
+
})();
|
|
28
35
|
worker.on('message', (frame) => {
|
|
29
36
|
if (frame?.type !== 'request') return;
|
|
30
37
|
// one answer per request, whatever it did: a request that fails is a failed answer, never the owner's end
|
|
@@ -34,7 +41,10 @@ export async function openOwnerDoor(root, { machine, answer, log, readListings =
|
|
|
34
41
|
await new Promise((resolve, reject) => {
|
|
35
42
|
const ended = (error) => {
|
|
36
43
|
if (closing) return;
|
|
37
|
-
if (!open) {
|
|
44
|
+
if (!open) {
|
|
45
|
+
close().then(() => reject(error), cleanup => reject(new AggregateError([error, cleanup], 'board door startup and cleanup failed', { cause: error })));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
38
48
|
log(`board door: ${error.message}; this home has no door now; stopping, for the keeper to start the owner again`);
|
|
39
49
|
process.exit(1);
|
|
40
50
|
};
|
package/board/owner-health.mjs
CHANGED
|
@@ -1,36 +1,33 @@
|
|
|
1
1
|
// What the board owner knows of its own dispatcher, held in its memory: each board's dispatching switch (loaded once
|
|
2
2
|
// when the owner starts, then set by the owner's own switch writes) and its last completed round (set as each round
|
|
3
|
-
// completes).
|
|
3
|
+
// completes). Explicit diagnostics read this; nothing is read per ordinary call. Outside the owner nothing is held.
|
|
4
4
|
const switches = new Map(); // board slug → { on, at, by, reason }
|
|
5
5
|
let round = null; // { at, errors }
|
|
6
6
|
let holding = false;
|
|
7
|
-
// the owner's read door holds the same (owner-door.mjs), told of each change
|
|
8
|
-
const watchers = new Set();
|
|
9
|
-
const told = () => { for (const watcher of watchers) watcher({ boards: [...switches], round }); };
|
|
10
|
-
/** `watcher` is told what the owner holds now and after each change; answers its stop. */
|
|
11
|
-
export function watchOwnerHealth(watcher) { watchers.add(watcher); watcher({ boards: [...switches], round }); return () => watchers.delete(watcher); }
|
|
12
|
-
|
|
13
7
|
/** The owner starts holding: each board's switch as stored, and the last round its predecessor recorded. */
|
|
14
8
|
export function holdOwnerHealth(boards, lastRound) {
|
|
15
9
|
holding = true;
|
|
16
10
|
for (const [slug, state] of boards) switches.set(slug, state);
|
|
17
11
|
round = lastRound;
|
|
18
|
-
told();
|
|
19
12
|
}
|
|
20
13
|
/** The owner wrote a board's switch. */
|
|
21
|
-
export function holdSwitch(slug, state) { if (holding)
|
|
14
|
+
export function holdSwitch(slug, state) { if (holding) switches.set(slug, state); }
|
|
22
15
|
/** The owner completed a round. */
|
|
23
|
-
export function holdRound(at, errors) { if (holding)
|
|
16
|
+
export function holdRound(at, errors) { if (holding) round = { at, errors }; }
|
|
24
17
|
|
|
25
|
-
/**
|
|
26
|
-
export function
|
|
18
|
+
/** Board diagnostics from held dispatching state and the last completed round, separate from a command's outcome. */
|
|
19
|
+
export function dispatcherDiagnostics(slug) {
|
|
27
20
|
if (!holding) return [];
|
|
28
21
|
const state = switches.get(slug);
|
|
29
22
|
if (!state) return [];
|
|
30
|
-
if (!state.on) return [
|
|
23
|
+
if (!state.on) return [{ kind: 'dispatching_off', severity: 'warning',
|
|
24
|
+
title: `dispatching is off on ${slug}${state.at ? ` since ${state.at}` : ''}${state.by ? ` by ${state.by}` : ''}${state.reason ? ` (${state.reason})` : ''}`,
|
|
25
|
+
data: state, actions: ['supercode workflow dispatching on'] }];
|
|
31
26
|
if (!round) return [];
|
|
32
27
|
return [
|
|
33
|
-
...(Date.now() - Date.parse(round.at) > 300_000 ? [
|
|
34
|
-
|
|
28
|
+
...(Date.now() - Date.parse(round.at) > 300_000 ? [{ kind: 'dispatcher_stalled', severity: 'error',
|
|
29
|
+
title: `the dispatcher has completed no round since ${round.at}`, data: { at: round.at }, actions: [] }] : []),
|
|
30
|
+
...(round.errors ?? []).map((error) => ({ kind: 'dispatcher_round_error', severity: 'error',
|
|
31
|
+
title: `dispatcher round at ${round.at}: ${error}`, data: { at: round.at, error }, actions: [] })),
|
|
35
32
|
];
|
|
36
33
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The board package's interface for the board owner's host (generation-owner.mjs, docs/adr/0250-layered-packages.md):
|
|
2
|
+
// the workflows a dispatcher generation reports, held by the owner for its door; this machine's name as the
|
|
3
|
+
// dispatcher knows it; and the command line's parse. The same bindings their modules export; nothing of its own.
|
|
4
|
+
export { holdWorkflow } from './workflow.mjs';
|
|
5
|
+
export { knowLocalMachine, localMachine } from './dispatch.mjs';
|
|
6
|
+
export { parse } from './args.mjs';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Retained publications are already serialized by the admitted writer. Observing them must not open a card
|
|
2
|
+
// writer, migrate its layout, import documents or choose a codec interpretation in the follower process.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { backingOf, backingPaths } from './store.mjs';
|
|
7
|
+
|
|
8
|
+
export function openPublicationReader(root, slug) {
|
|
9
|
+
const backing = backingOf(root, slug);
|
|
10
|
+
if (!backing) throw new Error(`no board ${slug} in ${root}`);
|
|
11
|
+
const paths = backingPaths(root, slug);
|
|
12
|
+
let db;
|
|
13
|
+
try {
|
|
14
|
+
if (backing === 'files') {
|
|
15
|
+
// The files writer replaces _board.json last. All three publication tables belong to this one atomic
|
|
16
|
+
// file, so their watermark and heads form one committed view without reading any card files.
|
|
17
|
+
const state = JSON.parse(readFileSync(join(paths.files, '_board.json'), 'utf8'));
|
|
18
|
+
if (state.format !== 'supercode-board-files' || state.format_version !== 1)
|
|
19
|
+
throw new Error(`board ${slug} has an unsupported files publication format`);
|
|
20
|
+
db = new DatabaseSync(':memory:');
|
|
21
|
+
const tables = {
|
|
22
|
+
supercode_publication_log: ['id', 'pruned_through'],
|
|
23
|
+
supercode_publications: ['sequence', 'id', 'kind', 'snapshot'],
|
|
24
|
+
supercode_publication_heads: ['resource_id', 'sequence'],
|
|
25
|
+
};
|
|
26
|
+
db.exec(`CREATE TABLE supercode_publication_log (id TEXT, pruned_through INTEGER);
|
|
27
|
+
CREATE TABLE supercode_publications (sequence INTEGER PRIMARY KEY, id TEXT, kind TEXT, snapshot TEXT);
|
|
28
|
+
CREATE TABLE supercode_publication_heads (resource_id TEXT PRIMARY KEY, sequence INTEGER);`);
|
|
29
|
+
for (const [name, columns] of Object.entries(tables)) {
|
|
30
|
+
const rows = state.tables?.[name];
|
|
31
|
+
if (!Array.isArray(rows)) throw new Error(`board ${slug} has no retained ${name}`);
|
|
32
|
+
const insert = db.prepare(`INSERT INTO ${name} (${columns.join(',')}) VALUES (${columns.map(() => '?').join(',')})`);
|
|
33
|
+
for (const row of rows) insert.run(...columns.map(column => row[column]));
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
const path = backing === 'ztrack' ? `${paths.ztrack}.board.db` : paths.sqlite;
|
|
37
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
38
|
+
db.exec('PRAGMA busy_timeout=8000');
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
db,
|
|
42
|
+
tx(work) {
|
|
43
|
+
db.exec('BEGIN');
|
|
44
|
+
try { return work(db); }
|
|
45
|
+
finally { db.exec('ROLLBACK'); }
|
|
46
|
+
},
|
|
47
|
+
close: () => db.close(),
|
|
48
|
+
};
|
|
49
|
+
} catch (error) { db?.close(); throw error; }
|
|
50
|
+
}
|