@volter/supercode-orchestrator 0.5.205 → 0.5.207
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +278 -97
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +305 -80
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +92 -27
- package/board/files.mjs +17 -3
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +8 -2
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/notices.mjs +5 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +84 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +96 -31
- package/board/ztrack.mjs +207 -32
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- package/reducer.mjs +74 -5
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The thread that serves the board owner's socket (owner-door.mjs). A listing is answered here, read on a connection of
|
|
2
|
+
// its own; every other request is handed to the owner's thread and its answer written back.
|
|
3
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
4
|
+
import { createServer } from 'node:net';
|
|
5
|
+
import { chmodSync } from 'node:fs';
|
|
6
|
+
import { boardOwnerSocket } from '@volter/teams/board-owner';
|
|
7
|
+
import { parse } from './args.mjs';
|
|
8
|
+
import { answerOne } from './cli.mjs';
|
|
9
|
+
import { knowLocalMachine } from './dispatch.mjs';
|
|
10
|
+
import { holdOwnerHealth } from './owner-health.mjs';
|
|
11
|
+
import { ownHome } from './owner-files.mjs';
|
|
12
|
+
import { writeOwnerAnswer } from './reply.mjs';
|
|
13
|
+
import { holdWorkflow } from './workflow.mjs';
|
|
14
|
+
|
|
15
|
+
const { root, machine } = workerData;
|
|
16
|
+
const waiting = new Map(); // request id → its answer's resolve
|
|
17
|
+
let next = 0;
|
|
18
|
+
parentPort.on('message', (frame) => {
|
|
19
|
+
if (frame.type === 'health') holdOwnerHealth(frame.boards, frame.round);
|
|
20
|
+
else if (frame.type === 'workflow') holdWorkflow(frame.root, frame.entry);
|
|
21
|
+
else if (frame.type === 'answer') { waiting.get(frame.id)?.(frame.answer); waiting.delete(frame.id); }
|
|
22
|
+
});
|
|
23
|
+
await knowLocalMachine(machine);
|
|
24
|
+
ownHome(root);
|
|
25
|
+
|
|
26
|
+
const byOwner = (line) => new Promise((resolve) => { const id = ++next; waiting.set(id, resolve); parentPort.postMessage({ type: 'request', id, line }); });
|
|
27
|
+
const isListing = (line) => {
|
|
28
|
+
try {
|
|
29
|
+
const request = JSON.parse(line);
|
|
30
|
+
return !request?.tool && Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') && ['list', 'ls'].includes(parse(request.argv)._[0]);
|
|
31
|
+
} catch { return false; } // the owner answers what it cannot read
|
|
32
|
+
};
|
|
33
|
+
const answer = async (line) => {
|
|
34
|
+
if (!isListing(line)) return byOwner(line);
|
|
35
|
+
const read = await answerOne(root, line, { readOnly: true });
|
|
36
|
+
return read.byOwner ? byOwner(line) : read;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// allowHalfOpen: a caller that ends its side after its request still gets the answer, written when it is ready
|
|
40
|
+
const server = createServer({ allowHalfOpen: true }, (socket) => {
|
|
41
|
+
let input = '';
|
|
42
|
+
socket.setEncoding('utf8');
|
|
43
|
+
socket.on('error', () => { /* the caller went away */ });
|
|
44
|
+
socket.on('data', (chunk) => {
|
|
45
|
+
if (input === null) return;
|
|
46
|
+
input += chunk;
|
|
47
|
+
const end = input.indexOf('\n');
|
|
48
|
+
if (end < 0) return;
|
|
49
|
+
const line = input.slice(0, end);
|
|
50
|
+
input = null;
|
|
51
|
+
// one answer per call, whatever the request did: a request that fails is a failed answer, never the door's end
|
|
52
|
+
answer(line)
|
|
53
|
+
.catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
|
|
54
|
+
.then((reply) => writeOwnerAnswer(socket, reply))
|
|
55
|
+
.catch(() => { socket.destroy(); }); // a disconnected caller leaves no queued reply
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
const path = boardOwnerSocket(root);
|
|
59
|
+
await new Promise((done, fail) => { server.once('error', fail); server.listen(path, () => { server.off('error', fail); done(); }); });
|
|
60
|
+
try { chmodSync(path, 0o600); } catch { /* the home is the user's own */ }
|
|
61
|
+
parentPort.postMessage({ type: 'ready' });
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The board owner's socket, served on a thread of its own (owner-door-worker.mjs), so a caller is heard while the
|
|
2
|
+
// 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
|
+
// one SQLite read transaction (store.mjs openBoard, readOnly): WAL serves it beside the owner's writes, and neither
|
|
4
|
+
// 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 what a listing says of the owner:
|
|
6
|
+
// its dispatcher's health (owner-health.mjs) and the workflow it read (workflow.mjs), sent on each change.
|
|
7
|
+
import { Worker } from 'node:worker_threads';
|
|
8
|
+
import { watchOwnerHealth } from './owner-health.mjs';
|
|
9
|
+
import { watchWorkflows } from './workflow.mjs';
|
|
10
|
+
|
|
11
|
+
const failed = (error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` });
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Serve the owner's socket for the home at `root` (the caller holds its lock and has removed any old socket): resolves
|
|
15
|
+
* once the socket is bound. `answer(line)` answers a request here; `machine` is this machine's name as the owner learned
|
|
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 close.
|
|
18
|
+
*/
|
|
19
|
+
export async function openOwnerDoor(root, { machine, answer, log }) {
|
|
20
|
+
const worker = new Worker(new URL('./owner-door-worker.mjs', import.meta.url), { name: 'supercode board door', workerData: { root, machine } });
|
|
21
|
+
let open = false, closing = false;
|
|
22
|
+
const send = (frame) => { try { worker.postMessage(frame); } catch (error) { log(`board door: could not hand the door ${frame.type}: ${error.message}`); } };
|
|
23
|
+
const stops = [
|
|
24
|
+
watchOwnerHealth(({ boards, round }) => send({ type: 'health', boards, round })),
|
|
25
|
+
watchWorkflows((at, { workflow, params, profiles }) => send({ type: 'workflow', root: at, entry: { workflow, params, profiles } })),
|
|
26
|
+
];
|
|
27
|
+
const close = () => { closing = true; for (const stop of stops) stop(); worker.terminate().catch(() => { /* ended */ }); };
|
|
28
|
+
worker.on('message', (frame) => {
|
|
29
|
+
if (frame?.type !== 'request') return;
|
|
30
|
+
// one answer per request, whatever it did: a request that fails is a failed answer, never the owner's end
|
|
31
|
+
Promise.resolve().then(() => answer(frame.line)).catch(failed)
|
|
32
|
+
.then((result) => { if (!closing) send({ type: 'answer', id: frame.id, answer: result }); });
|
|
33
|
+
});
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
const ended = (error) => {
|
|
36
|
+
if (closing) return;
|
|
37
|
+
if (!open) { close(); reject(error); return; }
|
|
38
|
+
log(`board door: ${error.message}; this home has no door now; stopping, for the keeper to start the owner again`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
};
|
|
41
|
+
worker.on('message', (frame) => { if (frame?.type === 'ready') { open = true; resolve(); } });
|
|
42
|
+
worker.on('error', ended);
|
|
43
|
+
worker.on('exit', (code) => ended(new Error(`the door's thread ended (${code})`)));
|
|
44
|
+
});
|
|
45
|
+
return close;
|
|
46
|
+
}
|
package/board/owner-health.mjs
CHANGED
|
@@ -4,17 +4,23 @@
|
|
|
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); }
|
|
7
12
|
|
|
8
13
|
/** The owner starts holding: each board's switch as stored, and the last round its predecessor recorded. */
|
|
9
14
|
export function holdOwnerHealth(boards, lastRound) {
|
|
10
15
|
holding = true;
|
|
11
16
|
for (const [slug, state] of boards) switches.set(slug, state);
|
|
12
17
|
round = lastRound;
|
|
18
|
+
told();
|
|
13
19
|
}
|
|
14
20
|
/** The owner wrote a board's switch. */
|
|
15
|
-
export function holdSwitch(slug, state) { if (holding) switches.set(slug, state); }
|
|
21
|
+
export function holdSwitch(slug, state) { if (holding) { switches.set(slug, state); told(); } }
|
|
16
22
|
/** The owner completed a round. */
|
|
17
|
-
export function holdRound(at, errors) { if (holding) round = { at, errors }; }
|
|
23
|
+
export function holdRound(at, errors) { if (holding) { round = { at, errors }; told(); } }
|
|
18
24
|
|
|
19
25
|
/** The notice for a verb on `slug`: dispatching off (a state), or a stalled round and its standing errors (failures). */
|
|
20
26
|
export function dispatcherNotice(slug) {
|
package/board/published.mjs
CHANGED
|
@@ -177,7 +177,7 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
|
|
|
177
177
|
const runs=rows.attempts(row.id);
|
|
178
178
|
const attempts=runs.map(run=>{
|
|
179
179
|
const native=parse(run.metadata,{})?.supercode??null;
|
|
180
|
-
const session=native?{machine:native.machine,harness:native.harness,session_id:native.session_id,address:native.address,pane:native.pane,surface:native.surface,state:native.state,closed_at:native.closed_at,agent:native.agent_id??null,counted:native.counted??null,profile_differences:native.agent_profile_mismatch?.differences??null}:null;
|
|
180
|
+
const session=native?{machine:native.machine,harness:native.harness,session_id:native.session_id,address:native.address,pane:native.pane,surface:native.surface,state:native.state,closed_at:native.closed_at,detached_at:native.detached_at??null,detached_to:native.detached_to??null,agent:native.agent_id??null,counted:native.counted??null,profile_differences:native.agent_profile_mismatch?.differences??null}:null;
|
|
181
181
|
const metadata=parse(run.metadata,{});delete metadata.supercode;
|
|
182
182
|
return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,execution:executionOf(run),session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
|
|
183
183
|
});
|
|
@@ -186,7 +186,7 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
|
|
|
186
186
|
execution:executionOf(runs.find(run=>run.id===row.current_run_id)),
|
|
187
187
|
priority:row.priority,created_by:row.created_by,created_at:at(row.created_at),started_at:at(row.started_at),completed_at:at(row.completed_at),result:row.result,skills:parse(row.skills,[]),model:row.model_override,provider:row.provider_override,
|
|
188
188
|
tags:parse(own.tags,null)??(row.tenant?[row.tenant]:[]),sponsor:own.sponsor??null,sponsor_evidence:parse(own.sponsor_evidence),requested_by_unverified:parse(own.requested_by_unverified),machine:own.machine??null,subtask_of:own.subtask_of??null,archived:!!own.archived,
|
|
189
|
-
blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),every:own.every??null,
|
|
189
|
+
blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),detached:parse(own.detached),every:own.every??null,
|
|
190
190
|
workspace:{kind:row.workspace_kind,path:row.workspace_path,branch:row.branch_name,root:own.workspace_root,anchor:own.workspace_anchor},
|
|
191
191
|
parents:rows.parents(row.id),
|
|
192
192
|
agents:rows.agents(row.id),reviews,children:rows.children(row.id),comments,attempts};
|
package/board/record-view.mjs
CHANGED
|
@@ -62,9 +62,11 @@ export class RecordView {
|
|
|
62
62
|
this.progressTimer.unref?.();
|
|
63
63
|
}
|
|
64
64
|
fail(error) {
|
|
65
|
+
this.ready = false;
|
|
65
66
|
this.failure = error;
|
|
66
67
|
for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(error); }
|
|
67
68
|
this.waiters.clear();
|
|
69
|
+
try { this.onUnavailable?.(error); } catch { /* unavailable never becomes a fresh observation */ }
|
|
68
70
|
}
|
|
69
71
|
own(key) { return key.role === 'producer' ? key.machine === this.self : key.role === 'holder' && key.machine !== this.self; }
|
|
70
72
|
apply(frame) {
|
package/board/store.mjs
CHANGED
|
@@ -19,7 +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
|
+
import { byOwner, LIST_CONNECTION, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
23
23
|
import { isFilesBoard, openFilesBoard } from './files.mjs';
|
|
24
24
|
import { signalBoardWrite } from './wake.mjs';
|
|
25
25
|
import { PUBLICATION_SCHEMA, pruneDue, prunePublications, publicationTriggers, recordCardPublications } from './published.mjs';
|
|
@@ -76,14 +76,22 @@ CREATE INDEX IF NOT EXISTS idx_events_run ON task_events(run_id, id);
|
|
|
76
76
|
// supercode's part of a card, beside Hermes's row: where its session runs, and the arc a subtask belongs to (a
|
|
77
77
|
// subtask is worked by its arc's session and never started on its own).
|
|
78
78
|
const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY KEY, machine TEXT, subtask_of TEXT, stage TEXT);
|
|
79
|
-
CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL)
|
|
79
|
+
CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS supercode_session_custody (
|
|
81
|
+
address TEXT PRIMARY KEY, task_id TEXT NOT NULL, person TEXT NOT NULL,
|
|
82
|
+
detached_at INTEGER NOT NULL, detached_by TEXT NOT NULL, run_id INTEGER NOT NULL
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS supercode_session_operations (
|
|
85
|
+
token TEXT PRIMARY KEY, card_ref TEXT, address TEXT, kind TEXT NOT NULL,
|
|
86
|
+
owner TEXT NOT NULL, started_at INTEGER NOT NULL
|
|
87
|
+
);`;
|
|
80
88
|
// `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
|
|
81
89
|
// `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
|
|
82
90
|
// `tags`: the card's ordered tags (Hermes's `tenant` holds the first); `sponsor`, `sponsor_evidence`, `requested_by_unverified`: C4
|
|
83
91
|
// An effect's attempts: how many were made, when the next may be, and, once its attempts are spent, when it failed and
|
|
84
92
|
// why (engine.mjs runEffects). A failed effect is kept as the record of its terminal state and never tried again.
|
|
85
93
|
const EFFECT_COLUMNS = { attempts: 'INTEGER NOT NULL DEFAULT 0', next_at: 'INTEGER NOT NULL DEFAULT 0', failed_at: 'INTEGER', last_error: 'TEXT' };
|
|
86
|
-
const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER', tags: 'TEXT', sponsor: 'TEXT', sponsor_evidence: 'TEXT', requested_by_unverified: 'TEXT' };
|
|
94
|
+
const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', detached: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER', tags: 'TEXT', sponsor: 'TEXT', sponsor_evidence: 'TEXT', requested_by_unverified: 'TEXT' };
|
|
87
95
|
|
|
88
96
|
// ------------------------------------------------------------------ backings
|
|
89
97
|
//
|
|
@@ -151,6 +159,18 @@ const RUN_SESSION = "json_extract(metadata, '$.supercode.session_id')";
|
|
|
151
159
|
const RUN_ADDRESS = "json_extract(metadata, '$.supercode.address')";
|
|
152
160
|
const RUN_LAUNCH_PENDING = "json_extract(metadata, '$.supercode.launch_pending')";
|
|
153
161
|
export const CALLER_RUN_ADDRESS = `SELECT ${RUN_ADDRESS} AS address FROM task_runs WHERE ${RUN_SESSION} = ? ORDER BY id DESC LIMIT 1`;
|
|
162
|
+
// Launch/adoption commits the native address and its immutable role-agent together.
|
|
163
|
+
// Pending/unissued launches are not an ownership receipt. Read only that identity,
|
|
164
|
+
// never the current assignee, a profile hint, or mutable manager/admission state.
|
|
165
|
+
export const CALLER_RUN_AGENT = `SELECT DISTINCT
|
|
166
|
+
json_extract(metadata, '$.supercode.agent_id') AS agent_id,
|
|
167
|
+
json_extract(metadata, '$.supercode.agent_org') AS org
|
|
168
|
+
FROM task_runs WHERE ${RUN_SESSION} = ? AND ${RUN_ADDRESS} = ?
|
|
169
|
+
AND COALESCE(${RUN_LAUNCH_PENDING}, 0) = 0
|
|
170
|
+
AND COALESCE(json_extract(metadata, '$.supercode.launch_deferred'), 0) = 0
|
|
171
|
+
AND json_extract(metadata, '$.supercode.mode') IN ('fresh', 'resume', 'adopt')
|
|
172
|
+
AND json_extract(metadata, '$.supercode.agent_id') IS NOT NULL
|
|
173
|
+
AND json_extract(metadata, '$.supercode.agent_org') IS NOT NULL`;
|
|
154
174
|
const RUN_SESSION_INDEX = 'idx_runs_supercode_session';
|
|
155
175
|
const RUN_LAUNCH_PENDING_INDEX = 'idx_runs_supercode_launch_pending';
|
|
156
176
|
// The indexes on a run's metadata. The second holds only the runs whose launch is pending (7 of 1,387 on the store
|
|
@@ -256,6 +276,34 @@ function layOutHistoryIndexes(db) {
|
|
|
256
276
|
}
|
|
257
277
|
}
|
|
258
278
|
|
|
279
|
+
// A card's number (#42): short and sequential, given once when the card is made, never changed and never given to
|
|
280
|
+
// another card, as an issue tracker numbers its issues. The store gives it, whoever makes the card (a verb, a document
|
|
281
|
+
// import, Hermes's own writer): a trigger on `tasks` numbers each new card id. AUTOINCREMENT never hands a number out
|
|
282
|
+
// twice, and a number stays with its card id while the card's row is replaced or removed. The trigger inserts only
|
|
283
|
+
// where the id has no number yet, so no conflict arises in it (a writing statement's own conflict clause would govern
|
|
284
|
+
// the trigger's insert: published.mjs). Its first installation numbers the cards already there, in the order they were
|
|
285
|
+
// made (created_at, then id), in the same savepoint.
|
|
286
|
+
export const CARD_NUMBERS = 'supercode_card_numbers';
|
|
287
|
+
function layOutNumbers(db) {
|
|
288
|
+
if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'supercode_number_cards'").get()) return;
|
|
289
|
+
db.exec('SAVEPOINT supercode_card_numbering');
|
|
290
|
+
try {
|
|
291
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${CARD_NUMBERS} (number INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL UNIQUE);
|
|
292
|
+
INSERT INTO ${CARD_NUMBERS} (task_id) SELECT id FROM tasks WHERE id NOT IN (SELECT task_id FROM ${CARD_NUMBERS}) ORDER BY created_at, id;
|
|
293
|
+
CREATE TRIGGER IF NOT EXISTS supercode_number_cards AFTER INSERT ON tasks BEGIN
|
|
294
|
+
INSERT INTO ${CARD_NUMBERS} (task_id) SELECT NEW.id WHERE NOT EXISTS (SELECT 1 FROM ${CARD_NUMBERS} WHERE task_id = NEW.id);
|
|
295
|
+
END;`);
|
|
296
|
+
db.exec('RELEASE supercode_card_numbering');
|
|
297
|
+
} catch (error) { db.exec('ROLLBACK TO supercode_card_numbering; RELEASE supercode_card_numbering'); throw error; }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Raise the numbers' high-water mark to the highest number held, and to `floor`: AUTOINCREMENT hands out only numbers
|
|
301
|
+
* above it. For a store whose numbers were given or copied explicitly. */
|
|
302
|
+
export function raiseCardNumbers(db, floor = 0) {
|
|
303
|
+
db.prepare(`INSERT INTO sqlite_sequence (name, seq) SELECT ?, 0 WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = ?)`).run(CARD_NUMBERS, CARD_NUMBERS);
|
|
304
|
+
db.prepare(`UPDATE sqlite_sequence SET seq = MAX(seq, ?, (SELECT COALESCE(MAX(number), 0) FROM ${CARD_NUMBERS})) WHERE name = ?`).run(Number(floor) || 0, CARD_NUMBERS);
|
|
305
|
+
}
|
|
306
|
+
|
|
259
307
|
/** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
|
|
260
308
|
export function layOut(db, { fresh }) {
|
|
261
309
|
if (fresh) db.exec(SCHEMA);
|
|
@@ -267,6 +315,7 @@ export function layOut(db, { fresh }) {
|
|
|
267
315
|
// each read walked every card's row. Laid out here, after the column it names exists on a store of any age and before
|
|
268
316
|
// any statement is prepared; no statement names it, so a store without it is only slower.
|
|
269
317
|
db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
|
|
318
|
+
layOutNumbers(db);
|
|
270
319
|
layOutEventIndex(db);
|
|
271
320
|
layOutHistoryIndexes(db);
|
|
272
321
|
// A covering summary index keeps human listings out of task records' body overflow pages.
|
|
@@ -371,6 +420,8 @@ export function heldConfigWatched(slug, source, watched) {
|
|
|
371
420
|
}
|
|
372
421
|
|
|
373
422
|
export function openBoard(root, slug = DEFAULT_BOARD, options = {}) {
|
|
423
|
+
// a listing on the owner's read door reads on a connection of its own, never the held board's (openReadOnly)
|
|
424
|
+
if (current().readOnly) return openReadOnly(root, slug);
|
|
374
425
|
if (holding !== null && !options.create && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
|
|
375
426
|
let entry = heldBoards.get(slug);
|
|
376
427
|
if (!entry) {
|
|
@@ -400,6 +451,33 @@ function heldHandle(entry, slug) {
|
|
|
400
451
|
close() { if (closed) return; closed = true; entry.uses -= 1; if (entry.retired && !entry.uses) try { entry.board.close(); } catch { /* closed */ } } };
|
|
401
452
|
}
|
|
402
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Board `slug` as one committed state, for a listing on the owner's read door: a connection of its own in one SQLite
|
|
456
|
+
* read transaction (WAL's snapshot, beside any writer, with no busy timeout), kept until `close`. It makes, lays out,
|
|
457
|
+
* recovers and writes nothing; a board it cannot read so (a `files` board, whose card files have no commit point a
|
|
458
|
+
* reader can take; a store not in WAL or not laid out; one it cannot begin reading, `unreadable`) is left to the owner
|
|
459
|
+
* (listing.mjs byOwner).
|
|
460
|
+
*/
|
|
461
|
+
function openReadOnly(root, slug) {
|
|
462
|
+
const use = backingOf(root, slug);
|
|
463
|
+
if (!use) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
|
|
464
|
+
const path = backingPaths(root, slug)[use];
|
|
465
|
+
if (use === 'ztrack') return { ...openZtrackBoard(path, { init: layOut, root, slug, readOnly: true }), root, slug, path, backing: use };
|
|
466
|
+
if (use === 'files') throw byOwner(`board ${slug} is kept in files`);
|
|
467
|
+
let db;
|
|
468
|
+
try {
|
|
469
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
470
|
+
if (String(db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() !== 'wal') throw byOwner(`${path} is not in WAL mode`);
|
|
471
|
+
db.exec('BEGIN');
|
|
472
|
+
// the transaction's first read begins its snapshot
|
|
473
|
+
if (!indexed(db, TASK_SUMMARY_INDEX)) throw byOwner(`${path} is not laid out yet`);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
db?.close();
|
|
476
|
+
throw unreadable(error) ? byOwner(error.message) : error;
|
|
477
|
+
}
|
|
478
|
+
return { db, root, slug, path, backing: use, tx: () => { throw new Error('a listing reads the board and writes nothing'); }, close: () => db.close() };
|
|
479
|
+
}
|
|
480
|
+
|
|
403
481
|
function openBoardNow(root, slug, { create = false, backing = null, document = null, declared = null, held = false } = {}) {
|
|
404
482
|
noteOrigin(root);
|
|
405
483
|
const kept = backingOf(root, slug);
|
|
@@ -477,6 +555,9 @@ export function publishMarked(root) {
|
|
|
477
555
|
export const contention = (error) => [5, 6].includes(Number(error?.errcode) & 255)
|
|
478
556
|
|| ['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(error?.code)
|
|
479
557
|
|| /^database (?:table |schema )?is locked$/i.test(error?.message ?? '');
|
|
558
|
+
// What keeps a read-only connection from beginning its read where the owner's own would not: contention, or a store it
|
|
559
|
+
// may not open or read as it stands (SQLite CANTOPEN, READONLY). The listing is then the owner's (openReadOnly).
|
|
560
|
+
export const unreadable = (error) => contention(error) || [8, 14].includes(Number(error?.errcode) & 255);
|
|
480
561
|
// The store's volume refusing a write (SQLite IOERR or FULL and their extended codes; ENOSPC or EIO from a file write):
|
|
481
562
|
// a fault of the machine's storage at that minute, never of the card or the operation that met it. A transaction it
|
|
482
563
|
// rolled back is tried again a bounded number of times (STORAGE_TRIES); one still refused fails cleanly, and its caller
|
package/board/tools.mjs
CHANGED
|
@@ -7,7 +7,9 @@ import { mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
|
7
7
|
import { basename, join } from 'node:path';
|
|
8
8
|
import { DEFAULT_BOARD, actorOf, event, json, openBoard, setActor, tx } from './store.mjs';
|
|
9
9
|
import * as engine from './engine.mjs';
|
|
10
|
+
import { assertSourceReceipt } from './ci.mjs';
|
|
10
11
|
import { completionOwnerOf, isLatestReviewer, loadWorkflow } from './workflow.mjs';
|
|
12
|
+
import { attachmentRecord, readAttachment } from './attachment-read.mjs';
|
|
11
13
|
|
|
12
14
|
const ok = (fields = {}) => JSON.stringify({ ok: true, ...fields });
|
|
13
15
|
const error = (message) => JSON.stringify({ error: message });
|
|
@@ -30,6 +32,7 @@ const ORCHESTRATOR_ONLY = new Set(['kanban_list', 'kanban_unblock']);
|
|
|
30
32
|
|
|
31
33
|
/** One board tool call. `root` is the home; `env` the worker's task environment (`HERMES_KANBAN_*`). */
|
|
32
34
|
export async function boardTool(root, name, args = {}, env = {}) {
|
|
35
|
+
try { assertSourceReceipt(args); } catch (e) { return error(e.message); }
|
|
33
36
|
const own = env.HERMES_KANBAN_TASK || null;
|
|
34
37
|
const runId = env.HERMES_KANBAN_RUN_ID ? Number(env.HERMES_KANBAN_RUN_ID) : null;
|
|
35
38
|
const author = env.HERMES_PROFILE || 'worker';
|
|
@@ -112,7 +115,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
if (env.HERMES_SESSION_ID && own === tid) metadata = { ...(metadata ?? {}), worker_session_id: env.HERMES_SESSION_ID };
|
|
115
|
-
await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null,
|
|
118
|
+
await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null });
|
|
116
119
|
return ok({ task_id: tid, status: 'done' });
|
|
117
120
|
}
|
|
118
121
|
case 'kanban_block': {
|
|
@@ -122,12 +125,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
122
125
|
}
|
|
123
126
|
case 'kanban_request_state': {
|
|
124
127
|
const stale = ownRun(); if (stale) return error(stale);
|
|
125
|
-
await engine.requestStateMany(board, targets, { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null, commit: args.commit ?? null,
|
|
128
|
+
await engine.requestStateMany(board, targets, { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null });
|
|
126
129
|
return ok({ task_id: tid, task_ids: targets, requested: args.state, changed: false });
|
|
127
130
|
}
|
|
128
131
|
case 'kanban_request_review': {
|
|
129
132
|
const stale = ownRun(); if (stale) return error(stale);
|
|
130
|
-
await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null,
|
|
133
|
+
await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
|
|
131
134
|
return ok({ task_id: tid, status: 'review' });
|
|
132
135
|
}
|
|
133
136
|
case 'kanban_request_changes': {
|
|
@@ -172,11 +175,15 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
172
175
|
return ok({ task_id: tid, attachment_id: id, path });
|
|
173
176
|
}
|
|
174
177
|
case 'kanban_attachments':
|
|
178
|
+
if (args.attachment_id !== undefined) {
|
|
179
|
+
const row = tx(board, (db) => attachmentRecord(db, tid, args.attachment_id));
|
|
180
|
+
return ok(await readAttachment(root, slug, row, { offset: args.offset, length: args.length, version: args.version }));
|
|
181
|
+
}
|
|
175
182
|
return ok({ task_id: tid, attachments: tx(board, (db) => engine.attachments(db, tid)).map((a) => ({ id: a.id, filename: a.filename, size: a.size, content_type: a.content_type, path: a.stored_path })) });
|
|
176
183
|
case 'kanban_create': {
|
|
177
184
|
const workspace = args.workspace_kind ? `${args.workspace_kind}${args.workspace_path ? `:${args.workspace_path}` : ''}` : undefined;
|
|
178
185
|
const made = engine.createCard(board, await engine.prepareCard(board, {
|
|
179
|
-
title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
186
|
+
title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, reviewer: args.reviewer ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
180
187
|
priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
|
|
181
188
|
maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
|
|
182
189
|
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|