@volter/supercode-orchestrator 0.5.69 → 0.5.71
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/agent-door.mjs +75 -0
- package/agents.mjs +42 -23
- package/bin/orchestrator.mjs +1 -2
- package/board/agent-declarations.mjs +3 -1
- package/board/args.mjs +36 -0
- package/board/call.mjs +45 -0
- package/board/caller-session.mjs +25 -0
- package/board/cli.mjs +258 -121
- package/board/client.mjs +85 -0
- package/board/dispatch.mjs +70 -76
- package/board/doors.mjs +60 -0
- package/board/engine.mjs +9 -5
- package/board/event-stream.mjs +7 -7
- package/board/fence.mjs +42 -0
- package/board/files.mjs +23 -3
- package/board/git-document.mjs +2 -1
- package/board/lock-holder.mjs +49 -0
- package/board/owner-files.mjs +19 -0
- package/board/published.mjs +106 -41
- package/board/store.mjs +38 -11
- package/board/workflow.mjs +109 -13
- package/board/ztrack.mjs +24 -14
- package/hand-off.mjs +2 -2
- package/index.d.ts +1 -1
- package/kanban-worker.mjs +11 -16
- package/loop.mjs +3 -2
- package/mcp/server.mjs +12 -9
- package/package.json +2 -2
- package/bin/agent-door.mjs +0 -53
package/board/cli.mjs
CHANGED
|
@@ -2,21 +2,30 @@
|
|
|
2
2
|
// format, and its dispatcher. Every verb of `hermes kanban` is here under its own name, with its own flags and its own
|
|
3
3
|
// output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`move`, `adopt`, `close`,
|
|
4
4
|
// `serve`, `--at`) are added beside them. The Rust CLI hands every verb but `load` to `bin/orchestrator.mjs workflow`,
|
|
5
|
-
// which lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME
|
|
5
|
+
// which lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`; the board is
|
|
6
6
|
// `--board <slug>`, else `HERMES_KANBAN_BOARD`, else the home's current board (`kanban/current`), else `default`.
|
|
7
|
+
import { machineCall, closeDoors } from './doors.mjs';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
10
|
+
import { homeOf, parse } from './args.mjs';
|
|
11
|
+
import { BOARD_OPERATION_VERBS, boardHome, boardOwnerSocket, CALLER_FACTS } from '@volter/teams/board-owner';
|
|
12
|
+
import { current, isCallersCall, ownCall, within } from './call.mjs';
|
|
7
13
|
import { taskStatusOf } from './published.mjs';
|
|
8
|
-
import {
|
|
14
|
+
import { createServer } from 'node:net';
|
|
15
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { notRuntime, ownHome } from './owner-files.mjs';
|
|
9
17
|
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { holdLock } from './lock-holder.mjs';
|
|
19
|
+
import { takeEpoch } from './fence.mjs';
|
|
20
|
+
import { boardTool } from './tools.mjs';
|
|
10
21
|
import { recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
|
|
11
22
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
12
23
|
import { fileURLToPath } from 'node:url';
|
|
13
24
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
14
|
-
import {
|
|
15
|
-
import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
|
|
25
|
+
import { actorOf, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
|
|
16
26
|
import { isLocalWorkspace } from './workspace.mjs';
|
|
17
27
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
18
28
|
import * as engine from './engine.mjs';
|
|
19
|
-
import { runtimeDoor } from './runtime-door.mjs';
|
|
20
29
|
|
|
21
30
|
/** The messages on this machine the mailbox still has to deliver (`supercode message waiting`), by message id. */
|
|
22
31
|
function mailWaiting() {
|
|
@@ -38,26 +47,35 @@ function mailExpiries() {
|
|
|
38
47
|
* refused its credential, the publication it is retrying and the ones it abandoned. Null when no daemon answers or the
|
|
39
48
|
* home is not registered: diagnostics then say nothing of publishing.
|
|
40
49
|
*/
|
|
41
|
-
function boardPublishing(root) {
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
async function boardPublishing(root) {
|
|
51
|
+
let listed;
|
|
52
|
+
try { listed = await machineCall(null, 'harness.v1.boards.list', {}, { timeoutMs: 15_000 }); } catch { return null; }
|
|
44
53
|
let here; try { here = realpathSync(root); } catch { here = resolve(root); }
|
|
45
|
-
|
|
54
|
+
const board = listed?.boards?.find((b) => b.root === here);
|
|
55
|
+
return board ? { ...board, refused: listed.refused ?? null } : null;
|
|
46
56
|
}
|
|
47
|
-
import { requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
57
|
+
import { dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
48
58
|
import { watchBoard } from './wake.mjs';
|
|
49
59
|
import { streamBoardPublications } from './event-stream.mjs';
|
|
50
|
-
import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
60
|
+
import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
51
61
|
import { evaluate } from './expr.mjs';
|
|
52
62
|
import { agentDoor,agentScope,activeAgentAssignments,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
|
|
53
63
|
|
|
54
64
|
// Text a verb stores, read from --file/--body-file PATH (`-` for stdin) when given: a shell never expands what it
|
|
55
65
|
// never sees, as it does `$(...)` and `$VAR` in an argument.
|
|
56
|
-
const textFrom = (file, inline) =>
|
|
66
|
+
const textFrom = (file, inline) => {
|
|
67
|
+
if (file === undefined) return inline;
|
|
68
|
+
if (file === '-') { const sent = current().stdin; if (sent === null) throw new Error('- names standard input, and the caller sent none'); return sent; }
|
|
69
|
+
return readFileSync(callerPath(String(file)), 'utf8');
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** A path a caller names, read against its working directory; never the owner's own runtime files (owner-files.mjs). */
|
|
73
|
+
const callerPath = (named) => notRuntime(resolve(current().cwd, named));
|
|
74
|
+
const SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
57
75
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
58
76
|
|
|
59
77
|
The board's verbs, with their flags:
|
|
60
|
-
init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--archive-max-lines N] [--archive-period month]
|
|
78
|
+
init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--dispatching on|off] [--archive-max-lines N] [--archive-period month]
|
|
61
79
|
migrate --backing ztrack --document <arcs.md> --status-map <json>
|
|
62
80
|
boards list|create|rm|switch|show|rename|set-default-workdir|export|import
|
|
63
81
|
create <title> [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
|
|
@@ -80,7 +98,7 @@ The board's verbs, with their flags:
|
|
|
80
98
|
diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
|
|
81
99
|
notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
|
|
82
100
|
capacity (each cap's count against its max, and why each card waits; docs/adr/0009-board-capacity.md)
|
|
83
|
-
dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
|
|
101
|
+
dispatching [on|off] [--reason R] dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
|
|
84
102
|
pause [reason] · resume (the board's emergency stop: new starts stop, running sessions go on)
|
|
85
103
|
|
|
86
104
|
And:
|
|
@@ -119,38 +137,7 @@ And:
|
|
|
119
137
|
workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
|
|
120
138
|
`;
|
|
121
139
|
|
|
122
|
-
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
|
|
123
|
-
const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
|
|
124
|
-
'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose', 'commit']);
|
|
125
|
-
// flags that take every following word up to the next flag (Hermes's nargs='+')
|
|
126
|
-
const GREEDY = new Set(['ids', 'rm']);
|
|
127
|
-
|
|
128
|
-
function parse(argv) {
|
|
129
|
-
const out = { _: [] };
|
|
130
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
131
|
-
const a = argv[i];
|
|
132
|
-
if (!a.startsWith('--') || a === '--') { out._.push(a); continue; }
|
|
133
|
-
const [key, inline] = a.slice(2).split(/=(.*)/s);
|
|
134
|
-
if (FLAGS.has(key)) { out[key] = true; continue; }
|
|
135
|
-
if (GREEDY.has(key)) {
|
|
136
|
-
const values = inline !== undefined ? [inline] : [];
|
|
137
|
-
while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) values.push(argv[++i]);
|
|
138
|
-
(out[key] ??= []).push(...values);
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
const value = inline ?? argv[++i];
|
|
142
|
-
if (value === undefined) throw new Error(`--${key} needs a value`);
|
|
143
|
-
if (MULTI.has(key)) (out[key] ??= []).push(value);
|
|
144
|
-
else out[key] = value;
|
|
145
|
-
}
|
|
146
|
-
return out;
|
|
147
|
-
}
|
|
148
140
|
|
|
149
|
-
function homeOf(args) {
|
|
150
|
-
const root = args.root ?? process.env.SUPERCODE_ORCHESTRATOR_HOME ?? process.env.HERMES_HOME;
|
|
151
|
-
if (!root) throw new Error('which home? --root <home> (or SUPERCODE_ORCHESTRATOR_HOME)');
|
|
152
|
-
return resolve(root);
|
|
153
|
-
}
|
|
154
141
|
|
|
155
142
|
const SLUG = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
156
143
|
|
|
@@ -164,7 +151,7 @@ const currentFile = (root) => join(root, 'kanban', 'current');
|
|
|
164
151
|
|
|
165
152
|
/** The board a verb acts on, as Hermes resolves it. */
|
|
166
153
|
function boardOf(root, args) {
|
|
167
|
-
for (const want of [args.board,
|
|
154
|
+
for (const want of [args.board, current().facts.HERMES_KANBAN_BOARD, existsSync(currentFile(root)) ? readFileSync(currentFile(root), 'utf8').trim() : null]) {
|
|
168
155
|
if (!want) continue;
|
|
169
156
|
const slug = normalSlug(want);
|
|
170
157
|
if (slug === DEFAULT_BOARD || boardExists(root, slug) || existsSync(metaPath(root, slug))) return slug;
|
|
@@ -224,9 +211,10 @@ function counts(root, slug) {
|
|
|
224
211
|
|
|
225
212
|
/** The home's lanes (profiles' workers) and board settings (the root config's `kanban:` block), as the IR reads them. */
|
|
226
213
|
async function laneState(root) {
|
|
227
|
-
|
|
214
|
+
// the home as the owner holds it (workflow.mjs readHome), read again only after a file it is decoded from changed
|
|
215
|
+
const orchestration = await readHome(root);
|
|
228
216
|
// the board's settings are its workflow's params (the instance's, under the home's own `kanban:` keys)
|
|
229
|
-
const entry = useWorkflow(root, workflowEntry(orchestration));
|
|
217
|
+
const entry = useWorkflow(root, Object.assign(workflowEntry(orchestration), { orchestration }));
|
|
230
218
|
const settings = boardSettings(entry.params);
|
|
231
219
|
const tick = Number(evaluate(entry.workflow.dispatch.tick, { params: entry.params }));
|
|
232
220
|
if (tick > 0) settings.interval = tick;
|
|
@@ -237,43 +225,43 @@ async function laneState(root) {
|
|
|
237
225
|
// The calling session's own address, as its actor is named: no fleet scan (a `message list` costs seconds).
|
|
238
226
|
// the address the board recorded for the calling session when it launched it (a verb mailed from another machine
|
|
239
227
|
// runs on the board's machine as its sender, whose address names the sender's machine)
|
|
240
|
-
let recordedCaller = null;
|
|
241
228
|
function callerAddress() {
|
|
242
229
|
const id = callerSession();
|
|
243
230
|
if (!id) return null;
|
|
244
|
-
return
|
|
231
|
+
return current().recorded ?? boardCaller()?.address ?? `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
|
|
245
232
|
}
|
|
246
233
|
|
|
247
|
-
const callerName = () =>
|
|
234
|
+
const callerName = () => current().facts.HERMES_PROFILE || current().facts.USER || 'user';
|
|
248
235
|
|
|
249
236
|
/**
|
|
250
237
|
* C4: who the caller is as a principal, as a card's `created_by` records it: its agent (a relaunch keeps it), the
|
|
251
238
|
* automation running it, its own plain session, or the person its Teams login names. Never the OS user; null when
|
|
252
239
|
* nothing names one.
|
|
253
240
|
*/
|
|
254
|
-
function callerPrincipal(root, sid, { teams = false } = {}) {
|
|
255
|
-
|
|
256
|
-
if (
|
|
241
|
+
async function callerPrincipal(root, sid, { teams = false } = {}) {
|
|
242
|
+
const facts = current().facts;
|
|
243
|
+
if (facts.SUPERCODE_AGENT_ID) return `agent:${facts.SUPERCODE_AGENT_ID}`;
|
|
244
|
+
if (facts.SUPERCODE_AUTOMATION) return `automation:${facts.SUPERCODE_AUTOMATION}`;
|
|
257
245
|
if (sid) {
|
|
258
246
|
const harness = boardCaller()?.harness ?? (sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code');
|
|
259
247
|
const owner = agentRunForSession(root, [`${harness}:${sid}`]);
|
|
260
248
|
if (owner) return `agent:${owner.agent_id}`;
|
|
261
249
|
// a session an agent declared elsewhere owns (a manager's main session) is that agent's, as Teams records it
|
|
262
250
|
let declared = null;
|
|
263
|
-
try { declared = agentDoor('session', [`${harness}:${sid}`], { context:
|
|
251
|
+
try { declared = (await agentDoor('session', [`${harness}:${sid}`], { context: current().facts.SUPERCODE_CONTEXT })).data?.agent?.id ?? null; } catch { /* no Teams: the session */ }
|
|
264
252
|
return declared ? `agent:${declared}` : `session:${callerAddress()}`;
|
|
265
253
|
}
|
|
266
254
|
if (!teams) return null;
|
|
267
|
-
const scope = agentScope(
|
|
255
|
+
const scope = agentScope(current().facts.SUPERCODE_CONTEXT);
|
|
268
256
|
return !scope.offline && scope.principal ? `person:${scope.principal}` : null;
|
|
269
257
|
}
|
|
270
258
|
|
|
271
259
|
/** C4: a requester named by an outside id; a proven link makes them the card's sponsor, anything less is recorded unverified. */
|
|
272
|
-
function requestedSponsor(args) {
|
|
260
|
+
async function requestedSponsor(args) {
|
|
273
261
|
if (args.sponsor) return { sponsor: args.sponsor, sponsorEvidence: args.request ? { request: args.request, by: principalOf() } : null };
|
|
274
262
|
if (!args['requested-by']) return {};
|
|
275
263
|
let resolved = null;
|
|
276
|
-
try { resolved = agentDoor('resolveIdentity', [args['requested-by']], { context:
|
|
264
|
+
try { resolved = (await agentDoor('resolveIdentity', [args['requested-by']], { context: current().facts.SUPERCODE_CONTEXT })).data; } catch { /* no Teams: unverified */ }
|
|
277
265
|
if (resolved?.classification === 'proven' && resolved.principal?.kind === 'user') {
|
|
278
266
|
const link = resolved.links?.find((l) => l.classification === 'proven');
|
|
279
267
|
return { sponsor: resolved.principal.id, sponsorEvidence: { observation: args['requested-by'], link: link?.id ?? null, proof: link?.proof?.kind ?? null, request: args.request ?? null, by: principalOf() } };
|
|
@@ -283,7 +271,7 @@ function requestedSponsor(args) {
|
|
|
283
271
|
|
|
284
272
|
const tagsOf = (card) => { const tags = Array.isArray(card.tags) ? card.tags : json(card.tags, null); return Array.isArray(tags) ? tags : card.tenant ? [card.tenant] : []; };
|
|
285
273
|
|
|
286
|
-
const say = (args, human, value) => {
|
|
274
|
+
const say = (args, human, value) => { current().out(args.json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`); };
|
|
287
275
|
|
|
288
276
|
// Hermes's own output lines, so a reader of `hermes kanban list` reads this one
|
|
289
277
|
const ICONS = { todo: '◻', ready: '▶', running: '●', scheduled: '⏱', blocked: '⊘', done: '✓', archived: '—' };
|
|
@@ -357,27 +345,14 @@ function releaseBehind() {
|
|
|
357
345
|
if (older < 0) return { key: `${running}>${latest}`, text: `orchestrator ${running} is behind the published ${latest}; action required: ${fix}` };
|
|
358
346
|
return null;
|
|
359
347
|
}
|
|
360
|
-
function sayReleaseBehind() {
|
|
361
|
-
const behind = releaseBehind();
|
|
362
|
-
if (behind) process.stderr.write(`board: error: ${behind.text}\n`);
|
|
363
|
-
}
|
|
364
348
|
|
|
365
|
-
/**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
* watch, a tail, an attach, the event stream). They are run on the board's machine. A verb the door started reads this
|
|
369
|
-
* one list and refuses before it does anything.
|
|
370
|
-
*/
|
|
371
|
-
export const NOT_FROM_ANOTHER_MACHINE = new Set(['serve', 'daemon', 'dispatch', 'init', 'adopt', 'boards', 'gc', 'repair', 'migrate', 'watch', 'tail', 'attach', 'events']);
|
|
372
|
-
|
|
373
|
-
export async function runWorkflow(argv) {
|
|
349
|
+
/** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. */
|
|
350
|
+
export async function runVerb(argv) {
|
|
351
|
+
await knowLocalMachine();
|
|
374
352
|
const args = parse(argv);
|
|
375
353
|
const [verb, ...rest] = args._;
|
|
376
|
-
if (!verb || verb === 'help' || args.help) {
|
|
354
|
+
if (!verb || verb === 'help' || args.help) { current().out(HELP); return 0; }
|
|
377
355
|
const root = homeOf(args);
|
|
378
|
-
if (process.env.SUPERCODE_BOARD_CALL === '1' && NOT_FROM_ANOTHER_MACHINE.has(verb)) throw new Error(`${verb} is run on the board's own machine, not called from another`);
|
|
379
|
-
// the dispatcher's own verbs run every round; its serve says it once when it starts
|
|
380
|
-
if (!['dispatch', 'events'].includes(verb)) sayReleaseBehind();
|
|
381
356
|
if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
|
|
382
357
|
// C4: one task store per Org. A named board without a store of its own is a saved view over it: its tag selects
|
|
383
358
|
// the cards it shows, and a card filed through it carries that tag.
|
|
@@ -396,11 +371,13 @@ export async function runWorkflow(argv) {
|
|
|
396
371
|
await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
397
372
|
const sid = listing && !args.mine ? null : callerSession();
|
|
398
373
|
// what this command records is the calling session's act (its address, as its subscriptions name it)
|
|
399
|
-
|
|
374
|
+
current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare("SELECT json_extract(metadata, '$.supercode.address') AS address FROM task_runs WHERE json_extract(metadata, '$.supercode.session_id') = ? ORDER BY id DESC LIMIT 1").get(sid)?.address) ?? null : null;
|
|
400
375
|
if (sid) setActor(callerAddress());
|
|
401
|
-
setPrincipal(callerPrincipal(root, sid, { teams: !sid && !READS.has(verb) }));
|
|
376
|
+
setPrincipal(await callerPrincipal(root, sid, { teams: !sid && !READS.has(verb) }));
|
|
402
377
|
if (sid && !READS.has(verb) && boardExists(root, slug)) {
|
|
403
|
-
|
|
378
|
+
// binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
|
|
379
|
+
// never writes)
|
|
380
|
+
withBoard((b) => { if (isCallersCall()) reconcileCallerLaunch(b, sid); const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
|
|
404
381
|
}
|
|
405
382
|
// the board's workflow (the IR's): what each verb does, and who may send it, is its
|
|
406
383
|
// every verb reads the board through its workflow (a list settles cards too), so every verb loads it
|
|
@@ -423,7 +400,11 @@ export async function runWorkflow(argv) {
|
|
|
423
400
|
const source = openBoard(root, slug);
|
|
424
401
|
try {
|
|
425
402
|
const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug });
|
|
426
|
-
|
|
403
|
+
// the board's own state goes with it (its dispatching switch, its hooks cursor); its epoch is this process's
|
|
404
|
+
// (the migration holds the home's lock), which the target's first transaction stores
|
|
405
|
+
const state = (() => { try { return source.db.prepare("SELECT key, value FROM supercode_board_state WHERE key != 'owner_epoch'").all(); } catch { return []; } })();
|
|
406
|
+
try { migrated.tx((db) => { db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)'); for (const row of state) db.prepare('INSERT INTO supercode_board_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(row.key, row.value); }); }
|
|
407
|
+
finally { migrated.close(); }
|
|
427
408
|
} finally { source.close(); }
|
|
428
409
|
renameSync(`${preparing}.board.db`, `${paths.ztrack}.board.db`);
|
|
429
410
|
renameSync(preparing, paths.ztrack);
|
|
@@ -433,6 +414,8 @@ export async function runWorkflow(argv) {
|
|
|
433
414
|
return 0;
|
|
434
415
|
}
|
|
435
416
|
case 'init': {
|
|
417
|
+
// a malformed switch is refused before anything is made
|
|
418
|
+
if (args.dispatching !== undefined && !['on', 'off'].includes(args.dispatching)) throw new Error('--dispatching takes on or off; nothing was made');
|
|
436
419
|
// A board on the ztrack backing is made with what its ztrack.json declares besides its document: `--commit` (the
|
|
437
420
|
// documents live in a git checkout, each write committed and pushed), its manager (`--manager`, else the first one
|
|
438
421
|
// the home's workflow names), whose own writes it does not commit, and the archive's rotation. A board already there
|
|
@@ -464,6 +447,11 @@ export async function runWorkflow(argv) {
|
|
|
464
447
|
const own = named(fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url)));
|
|
465
448
|
writeFileSync(record, `${JSON.stringify({ package: '@volter/supercode-orchestrator', version: own?.version ?? null, entry: own?.entry ?? null, supercode: process.env.SUPERCODE_BIN ? named(process.env.SUPERCODE_BIN) : null })}\n`);
|
|
466
449
|
}
|
|
450
|
+
// its dispatching switch, set before any owner runs (an install's first start: `init --dispatching off`); while an
|
|
451
|
+
// owner runs, `workflow dispatching` is its verb
|
|
452
|
+
if (args.dispatching !== undefined) {
|
|
453
|
+
setDispatching(root, slug, args.dispatching === 'on', 'set at init');
|
|
454
|
+
}
|
|
467
455
|
const backing = made.backing;
|
|
468
456
|
made.close();
|
|
469
457
|
const { profiles } = await laneState(root).catch(() => ({ profiles: [] }));
|
|
@@ -481,6 +469,8 @@ export async function runWorkflow(argv) {
|
|
|
481
469
|
let workspace = args.workspace;
|
|
482
470
|
if ((workspace === 'dir' || workspace === 'worktree') && meta.default_workdir) workspace = `${workspace}:${meta.default_workdir}`;
|
|
483
471
|
// --goal / --no-goal are the create's words; without either, the workflow's initial decides
|
|
472
|
+
// a person filing for themselves sponsors it; an agent filing a person's request names them (proven) or records them unverified
|
|
473
|
+
const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
|
|
484
474
|
const created = withBoard((b) => engine.createCard(b, {
|
|
485
475
|
title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
|
|
486
476
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
@@ -490,8 +480,7 @@ export async function runWorkflow(argv) {
|
|
|
490
480
|
every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
|
|
491
481
|
project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
|
|
492
482
|
tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])],
|
|
493
|
-
|
|
494
|
-
...(args.sponsor || args['requested-by'] ? requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {}),
|
|
483
|
+
...sponsorship,
|
|
495
484
|
}), true);
|
|
496
485
|
await withBoardAsync(b=>engine.runEffects(b,created.effects??[]));
|
|
497
486
|
let answer = { id: created.id, existing: created.existing };
|
|
@@ -566,8 +555,8 @@ export async function runWorkflow(argv) {
|
|
|
566
555
|
return 0;
|
|
567
556
|
}
|
|
568
557
|
const others = boardsOf(root, { includeArchived: false }).length - 1;
|
|
569
|
-
if (others > 0)
|
|
570
|
-
|
|
558
|
+
if (others > 0) current().out(`Board: ${slug} (${others} other board${others === 1 ? '' : 's'} — \`supercode workflow boards list\`)\n\n`);
|
|
559
|
+
current().out(cards.length ? `${cards.map(line).join('\n')}\n` : '(no matching tasks)\n');
|
|
571
560
|
return 0;
|
|
572
561
|
}
|
|
573
562
|
|
|
@@ -640,19 +629,19 @@ export async function runWorkflow(argv) {
|
|
|
640
629
|
}
|
|
641
630
|
}
|
|
642
631
|
if (card.subscriptions.length) { out.push('', 'Notices:'); for (const s of card.subscriptions) out.push(` → ${s.chat_id} (${s.platform === 'supercode' ? s.delivery_metadata?.role ?? 'creator' : s.platform})`); }
|
|
643
|
-
|
|
632
|
+
current().out(`${out.join('\n')}\n`);
|
|
644
633
|
return 0;
|
|
645
634
|
}
|
|
646
635
|
|
|
647
636
|
// G3: usage from each run's own harness records, summed on agents and on their sponsors
|
|
648
637
|
case 'usage': {
|
|
649
|
-
const byAgent = agentUsage(root), context =
|
|
650
|
-
const sponsorOf = (agent) => { try { return agentDoor('get', [agent], { context }).data.sponsor ?? null; } catch { for (const row of activeAgentAssignments(root).filter((a) => a.agent_id === agent)) { const local = localAgentRecord(root, row.org, agent); if (local) return local.identity.sponsor ?? null; } return null; } };
|
|
638
|
+
const byAgent = agentUsage(root), context = current().facts.SUPERCODE_CONTEXT;
|
|
639
|
+
const sponsorOf = async (agent) => { try { return (await agentDoor('get', [agent], { context })).data.sponsor ?? null; } catch { for (const row of activeAgentAssignments(root).filter((a) => a.agent_id === agent)) { const local = localAgentRecord(root, row.org, agent); if (local) return local.identity.sponsor ?? null; } return null; } };
|
|
651
640
|
const sum = (records) => { const out = {}; for (const u of records) { const m = out[u.model] ??= { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, cost: 0 }; m.input_tokens += u.input_tokens ?? 0; m.output_tokens += u.output_tokens ?? 0; m.cache_read_tokens += u.cache_read_tokens ?? 0; m.cache_write_tokens += u.cache_write_tokens ?? 0; m.cost += u.cost?.amount ?? 0; if (u.cost?.amount != null) { m.cost_recorded = true; m.currency = u.cost.currency ?? m.currency ?? null; } else m.cost_unrecorded = true; } return out; };
|
|
652
641
|
// a harness that keeps no price records tokens only: its cost reads as unrecorded, never as zero
|
|
653
642
|
const line = (byModel) => Object.entries(byModel).map(([m, t]) => `${m} in ${t.input_tokens} out ${t.output_tokens} cache read ${t.cache_read_tokens} write ${t.cache_write_tokens} cost ${t.cost_recorded ? `${t.cost.toFixed(4)} ${t.currency ?? ''}${t.cost_unrecorded ? ' (some sessions unrecorded)' : ''}`.trim() : 'unrecorded'}`).join('; ');
|
|
654
643
|
const agents = [...byAgent.keys()].filter((a) => !args.agent || a === args.agent);
|
|
655
|
-
const rows = agents.map((agent) => ({ agent, sponsor: args.sponsor || !args.agent ? sponsorOf(agent) : undefined, sessions: new Set(byAgent.get(agent).map((u) => u.session)).size, by_model: sum(byAgent.get(agent)) }))
|
|
644
|
+
const rows = (await Promise.all(agents.map(async (agent) => ({ agent, sponsor: args.sponsor || !args.agent ? await sponsorOf(agent) : undefined, sessions: new Set(byAgent.get(agent).map((u) => u.session)).size, by_model: sum(byAgent.get(agent)) }))))
|
|
656
645
|
.filter((row) => !args.sponsor || row.sponsor === args.sponsor);
|
|
657
646
|
const total = sum(rows.flatMap((row) => byAgent.get(row.agent)));
|
|
658
647
|
const sponsors = [...new Set(rows.map((row) => row.sponsor).filter(Boolean))].map((sponsor) => ({ sponsor, agents: rows.filter((row) => row.sponsor === sponsor).length, by_model: sum(rows.filter((row) => row.sponsor === sponsor).flatMap((row) => byAgent.get(row.agent))) }));
|
|
@@ -661,13 +650,13 @@ export async function runWorkflow(argv) {
|
|
|
661
650
|
return 0;
|
|
662
651
|
}
|
|
663
652
|
|
|
664
|
-
case 'context': need(1, 'a card id');
|
|
653
|
+
case 'context': need(1, 'a card id'); current().out(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
|
|
665
654
|
|
|
666
655
|
case 'runs': {
|
|
667
656
|
need(1, 'a card id');
|
|
668
657
|
const got = withBoard((b) => engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null }));
|
|
669
658
|
if (args.json) { say(args, '', got); return 0; }
|
|
670
|
-
|
|
659
|
+
current().out(got.length ? `${got.map((r) => `#${r.id} ${(r.outcome || r.status).padEnd(14)} @${r.profile || '-'} ${ts(r.started_at)}${r.ended_at ? ` → ${ts(r.ended_at)}` : ' (active)'}${r.metadata?.supercode?.address ? ` ${r.metadata.supercode.address}` : ''}${r.metadata?.supercode?.counted === false ? ` not counted: profile differs (${r.metadata.supercode.agent_profile_mismatch.differences.map((d) => d.field).join(', ')})` : ''}${r.summary ? `\n → ${r.summary.split('\n')[0].slice(0, 160)}` : ''}`).join('\n')}\n` : `(no runs for ${id})\n`);
|
|
671
660
|
return 0;
|
|
672
661
|
}
|
|
673
662
|
|
|
@@ -685,14 +674,14 @@ export async function runWorkflow(argv) {
|
|
|
685
674
|
case 'assign': case 'reassign': if (args.agent) {
|
|
686
675
|
// C7: a card role's assignment moves to another agent; the sessions it ran keep their identity
|
|
687
676
|
need(1, 'a card id');
|
|
688
|
-
const context=
|
|
677
|
+
const context=current().facts.SUPERCODE_CONTEXT,scope=agentScope(context);
|
|
689
678
|
const task=withBoard(b=>engine.getTask(b.db,id));
|
|
690
679
|
if(!task)throw new Error(`no card ${id}`);
|
|
691
680
|
withBoard(b=>{if(!rolesFor(b.db,id,sid,workflowFor(root).params).includes('manager'))throw new Error('Only the manager moves a card assignment between agents.');});
|
|
692
681
|
const role=args.role??'implementer';
|
|
693
682
|
const assignment=activeAgentAssignments(root).find(item=>item.org===scope.org&&item.kind==='card'&&item.target===id&&item.role===role);
|
|
694
683
|
if(!assignment)throw new Error('declare the card role before reassigning its assignment');
|
|
695
|
-
const changed=reassignAgent(root,scope.org,assignment.id,args.agent,{context});
|
|
684
|
+
const changed=await reassignAgent(root,scope.org,assignment.id,args.agent,{context});
|
|
696
685
|
withBoard(b=>tx(b,db=>event(db,id,'agent_assignment_reassigned',{assignment:assignment.id,role,from:assignment.agent_id,to:changed.agent_id})));
|
|
697
686
|
say(args,`Reassigned ${id}/${role} to agent ${changed.agent_id}; sessions keep their identity`,{ok:true,assignment:changed});return 0;
|
|
698
687
|
} need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.set(b, id, 'assignee', rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
|
|
@@ -753,7 +742,7 @@ export async function runWorkflow(argv) {
|
|
|
753
742
|
}
|
|
754
743
|
case 'attach': {
|
|
755
744
|
need(2, 'a card id and a file');
|
|
756
|
-
const source =
|
|
745
|
+
const source = callerPath(rest[1]);
|
|
757
746
|
if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
|
|
758
747
|
const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
|
|
759
748
|
const dir = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
|
|
@@ -768,7 +757,7 @@ export async function runWorkflow(argv) {
|
|
|
768
757
|
need(1, 'a card id');
|
|
769
758
|
const got = withBoard((b) => engine.attachments(b.db, id));
|
|
770
759
|
if (args.json) { say(args, '', got); return 0; }
|
|
771
|
-
|
|
760
|
+
current().out(got.length ? `${got.map((a) => `#${a.id} ${a.filename} ${a.size} bytes ${ts(a.created_at)} ${a.stored_path}`).join('\n')}\n` : `(no attachments on ${id})\n`);
|
|
772
761
|
return 0;
|
|
773
762
|
}
|
|
774
763
|
case 'attach-rm': {
|
|
@@ -892,7 +881,7 @@ export async function runWorkflow(argv) {
|
|
|
892
881
|
case 'diagnostics': case 'diag': {
|
|
893
882
|
const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
|
|
894
883
|
const fleet = observeFleet(root);
|
|
895
|
-
const publishing = boardPublishing(root);
|
|
884
|
+
const publishing = await boardPublishing(root);
|
|
896
885
|
const expiredMail = mailExpiries();
|
|
897
886
|
const waitingMail = mailWaiting();
|
|
898
887
|
const order = ['warning', 'error', 'critical'];
|
|
@@ -903,14 +892,14 @@ export async function runWorkflow(argv) {
|
|
|
903
892
|
.filter((d) => !args.severity || order.indexOf(d.severity) >= order.indexOf(args.severity));
|
|
904
893
|
});
|
|
905
894
|
if (args.json) { say(args, '', found); return 0; }
|
|
906
|
-
|
|
895
|
+
current().out(found.length ? `${found.map((d) => `${d.severity.padEnd(8)} ${d.task} ${d.kind}: ${d.title}${d.actions[0] ? `\n → ${d.actions[0]}` : ''}`).join('\n')}\n` : '(no active diagnostics)\n');
|
|
907
896
|
return 0;
|
|
908
897
|
}
|
|
909
898
|
case 'stats': {
|
|
910
899
|
const s = withBoard((b) => engine.stats(b.db));
|
|
911
900
|
if (args.json) { say(args, '', s); return 0; }
|
|
912
901
|
const fmt = (o) => Object.entries(o).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(none)';
|
|
913
|
-
|
|
902
|
+
current().out(`By status: ${fmt(s.by_status)}\nBy assignee:\n${Object.entries(s.by_assignee).map(([a, c]) => ` ${a.padEnd(20)} ${fmt(c)}`).join('\n') || ' (none)'}\nBy machine:\n${Object.entries(s.by_machine).map(([m, c]) => ` ${m.padEnd(20)} ${fmt(c)}`).join('\n') || ' (none)'}\nOldest ready: ${s.oldest_ready_age_seconds === null ? '-' : `${Math.floor(s.oldest_ready_age_seconds / 60)}m`}\n`);
|
|
914
903
|
return 0;
|
|
915
904
|
}
|
|
916
905
|
case 'assignees': {
|
|
@@ -918,7 +907,7 @@ export async function runWorkflow(argv) {
|
|
|
918
907
|
const c = withBoard((b) => engine.stats(b.db).by_assignee);
|
|
919
908
|
const data = [...new Set([...profiles, ...Object.keys(c)])].sort().map((name) => ({ name, on_disk: profiles.includes(name), surface: lanes[name]?.surface ?? null, counts: c[name] ?? {} }));
|
|
920
909
|
if (args.json) { say(args, '', data); return 0; }
|
|
921
|
-
|
|
910
|
+
current().out(`${'NAME'.padEnd(20)} ${'ON DISK'.padEnd(8)} ${'LANE'.padEnd(9)} COUNTS\n${data.map((e) => `${e.name.padEnd(20)} ${(e.on_disk ? 'yes' : 'no').padEnd(8)} ${(e.surface ?? '-').padEnd(9)} ${Object.entries(e.counts).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(idle)'}`).join('\n')}\n`);
|
|
922
911
|
return 0;
|
|
923
912
|
}
|
|
924
913
|
case 'log': {
|
|
@@ -927,12 +916,12 @@ export async function runWorkflow(argv) {
|
|
|
927
916
|
if (!existsSync(file)) {
|
|
928
917
|
// a pane session keeps no log file: its transcript is the harness's own, named by the run
|
|
929
918
|
const s = withBoard((b) => view(b, (db) => engine.lastSession(db, id)));
|
|
930
|
-
|
|
919
|
+
current().out(s ? `(${id} runs as session ${s.address}; its transcript is that session's: \`supercode sessions show ${s.session_id}\`)\n` : `(no log for ${id})\n`);
|
|
931
920
|
return 0;
|
|
932
921
|
}
|
|
933
922
|
const text = readFileSync(file, 'utf8');
|
|
934
923
|
const tail = args.tail ? Number(args.tail) : null;
|
|
935
|
-
|
|
924
|
+
current().out(tail ? text.slice(-tail) : text);
|
|
936
925
|
return 0;
|
|
937
926
|
}
|
|
938
927
|
case 'tail': case 'watch': {
|
|
@@ -951,7 +940,7 @@ export async function runWorkflow(argv) {
|
|
|
951
940
|
if (kinds && !kinds.has(e.kind)) continue;
|
|
952
941
|
if (args.assignee && e.assignee !== args.assignee) continue;
|
|
953
942
|
if (args.tenant && e.tenant !== args.tenant) continue;
|
|
954
|
-
|
|
943
|
+
current().out(`[${ts(e.created_at)}] ${verb === 'watch' ? `${e.task_id} ` : ''}${e.kind}${e.payload ? ` ${e.payload}` : ''}\n`);
|
|
955
944
|
}
|
|
956
945
|
await new Promise((done) => setTimeout(done, interval));
|
|
957
946
|
}
|
|
@@ -987,7 +976,7 @@ export async function runWorkflow(argv) {
|
|
|
987
976
|
case 'notify-list': {
|
|
988
977
|
const subs = withBoard((b) => engine.subscriptions(b.db, id ?? null));
|
|
989
978
|
if (args.json) { say(args, '', subs); return 0; }
|
|
990
|
-
|
|
979
|
+
current().out(subs.length ? `${subs.map((s) => `${s.task_id} ${s.platform}:${s.chat_id}${s.thread_id ? `#${s.thread_id}` : ''} ${s.platform === 'supercode' ? s.delivery_metadata?.role ?? 'creator' : s.delivery_mode} cursor=${s.last_event_id}`).join('\n')}\n` : '(no subscriptions)\n');
|
|
991
980
|
return 0;
|
|
992
981
|
}
|
|
993
982
|
|
|
@@ -1005,7 +994,7 @@ export async function runWorkflow(argv) {
|
|
|
1005
994
|
}
|
|
1006
995
|
const reports = await tick({ root, lanes, settings, params });
|
|
1007
996
|
if (args.json) say(args, '', reports);
|
|
1008
|
-
else for (const r of reports)
|
|
997
|
+
else for (const r of reports) current().out(`${summary(r)}\n`);
|
|
1009
998
|
return reports.some((r) => r.error) ? 1 : 0;
|
|
1010
999
|
}
|
|
1011
1000
|
case 'capacity': {
|
|
@@ -1024,7 +1013,7 @@ export async function runWorkflow(argv) {
|
|
|
1024
1013
|
}
|
|
1025
1014
|
lines.push(out.would.length ? `would start: ${out.would.join(' ')}` : 'would start: nothing');
|
|
1026
1015
|
for (const w of out.waiting) lines.push(`${w.id} ${w.why}`);
|
|
1027
|
-
|
|
1016
|
+
current().out(`${lines.join('\n')}\n`);
|
|
1028
1017
|
return 0;
|
|
1029
1018
|
}
|
|
1030
1019
|
case 'deliver': {
|
|
@@ -1032,7 +1021,7 @@ export async function runWorkflow(argv) {
|
|
|
1032
1021
|
// starts it after each round in a process of its own; never part of a round)
|
|
1033
1022
|
const reports = await deliver({ root, slugs: args.board ? [slug] : null });
|
|
1034
1023
|
if (args.json) say(args, '', reports);
|
|
1035
|
-
else for (const r of reports)
|
|
1024
|
+
else for (const r of reports) current().out(`${r.board}: ${r.error ? `error: ${r.error}` : `${r.effects_waiting ?? 0} effect(s) waiting, ${r.effects_failed ?? 0} failed for good, ${r.notified.length} notified`}\n`);
|
|
1036
1025
|
return reports.some((r) => r.error) ? 1 : 0;
|
|
1037
1026
|
}
|
|
1038
1027
|
case 'daemon': case 'serve': return serve(root, args);
|
|
@@ -1087,6 +1076,34 @@ export async function runWorkflow(argv) {
|
|
|
1087
1076
|
await withBoardAsync((b) => engine.act(b, id, 'cancel', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
|
|
1088
1077
|
say(args, `${id} cancelled`, { ok: true }); return 0;
|
|
1089
1078
|
}
|
|
1079
|
+
case 'run-session': {
|
|
1080
|
+
// A headless worker records its harness session on its run (kanban-worker.mjs), through the owner like every board
|
|
1081
|
+
// write. Only the run the owner launched for this worker: the launch's secret (in the worker's environment alone,
|
|
1082
|
+
// its hash kept on the run), its card and its run are checked at the write, against the card's current, unended run.
|
|
1083
|
+
need(2, 'a run id and a session id');
|
|
1084
|
+
// the secret is the worker's own environment (SUPERCODE_RUN_LAUNCH, sent as a caller fact), never an argument
|
|
1085
|
+
const runId = Number(id), harness = args.harness ?? null, launch = current().facts.SUPERCODE_RUN_LAUNCH ?? '', card = typeof args.task === 'string' ? args.task : '';
|
|
1086
|
+
if (!Number.isSafeInteger(runId) || !SAFE_SESSION.test(rest[1]) || (harness !== null && !SAFE_SESSION.test(harness)) || !launch || !card) throw new Error('run-session takes a run id, a session id, --harness and --task, from a worker the owner launched');
|
|
1087
|
+
const refused = withBoard((b) => tx(b, (db) => {
|
|
1088
|
+
const row = db.prepare('SELECT r.task_id, r.ended_at, r.metadata, t.current_run_id FROM task_runs r JOIN tasks t ON t.id = r.task_id WHERE r.id = ?').get(runId);
|
|
1089
|
+
const saved = json(row?.metadata, {})?.supercode ?? {};
|
|
1090
|
+
if (!row || row.task_id !== card || row.ended_at != null || Number(row.current_run_id) !== runId || saved.surface !== 'headless' || !saved.launch_secret
|
|
1091
|
+
|| saved.launch_secret !== createHash('sha256').update(launch).digest('hex') || (saved.session_id && saved.session_id !== rest[1])) return `run ${runId} is not a launch this worker holds; nothing was recorded`;
|
|
1092
|
+
engine.setRunSession(db, runId, { ...saved, session_id: rest[1], harness });
|
|
1093
|
+
return null;
|
|
1094
|
+
}));
|
|
1095
|
+
if (refused) throw new Error(refused);
|
|
1096
|
+
say(args, `run ${runId}: session ${rest[1]}`, { ok: true }); return 0;
|
|
1097
|
+
}
|
|
1098
|
+
case 'dispatching': {
|
|
1099
|
+
// the board's dispatching switch (its owner's controller): `dispatching` says it, `dispatching on|off` sets it
|
|
1100
|
+
if (id && !['on', 'off'].includes(id)) throw new Error('dispatching takes on or off');
|
|
1101
|
+
// switching it is the manager's: a caller outside any session, or a session the workflow's params.managers names
|
|
1102
|
+
if (id && sid && !withBoard((b) => rolesFor(b.db, '', sid, workflowFor(root).params)).includes('manager')) throw new Error(`switching dispatching is the manager's act; ${callerAddress()} is not a manager of this board (params.managers)`);
|
|
1103
|
+
const state = id ? setDispatching(root, slug, id === 'on', args.reason ?? rest.slice(1).join(' ')) : dispatchingOf(root, slug);
|
|
1104
|
+
say(args, `board ${slug}: dispatching ${state.on ? 'on' : 'off'}${state.at ? ` since ${state.at}` : ''}${state.by ? ` by ${state.by}` : ''}${state.reason ? ` (${state.reason})` : ''}`, { board: slug, ...state });
|
|
1105
|
+
return 0;
|
|
1106
|
+
}
|
|
1090
1107
|
case 'pause': {
|
|
1091
1108
|
if (id) {
|
|
1092
1109
|
await withBoardAsync((b) => engine.act(b, id, 'pause', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
|
|
@@ -1157,10 +1174,10 @@ async function boardsVerb(root, rest, args) {
|
|
|
1157
1174
|
const [action = 'list', target] = rest;
|
|
1158
1175
|
switch (action) {
|
|
1159
1176
|
case 'list': case 'ls': {
|
|
1160
|
-
const
|
|
1161
|
-
const boards = boardsOf(root, { includeArchived: args.all }).map((b) => ({ ...b, is_current: b.slug ===
|
|
1177
|
+
const selected = boardOf(root, {});
|
|
1178
|
+
const boards = boardsOf(root, { includeArchived: args.all }).map((b) => ({ ...b, is_current: b.slug === selected, counts: counts(root, b.slug) }));
|
|
1162
1179
|
if (args.json) { say(args, '', boards); return 0; }
|
|
1163
|
-
|
|
1180
|
+
current().out(`${''.padEnd(2)} ${'SLUG'.padEnd(24)} ${'NAME'.padEnd(28)} COUNTS\n${boards.map((b) => `${(b.is_current ? '●' : ' ').padEnd(2)} ${b.slug.padEnd(24)} ${`${b.name}${b.archived ? ' [archived]' : ''}`.padEnd(28)} ${Object.entries(b.counts).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(empty)'}`).join('\n')}\n\nCurrent board: ${selected}\n`);
|
|
1164
1181
|
return 0;
|
|
1165
1182
|
}
|
|
1166
1183
|
case 'create': case 'new': {
|
|
@@ -1215,7 +1232,7 @@ async function boardsVerb(root, rest, args) {
|
|
|
1215
1232
|
case 'export': {
|
|
1216
1233
|
const slug = normalSlug(target ?? boardOf(root, args));
|
|
1217
1234
|
if (!boardExists(root, slug)) throw new Error(`board '${slug}' has no database`);
|
|
1218
|
-
const out =
|
|
1235
|
+
const out = callerPath(args.output ?? `${slug}.tar.gz`);
|
|
1219
1236
|
const made = exportBoard(root, slug, out, { attachments: !args['no-attachments'], logs: Boolean(args['include-logs']) });
|
|
1220
1237
|
say(args, `Exported board '${slug}' → ${out}\n Tasks: ${made.counts.tasks}\n Comments: ${made.counts.task_comments}\n Attachments: ${made.counts.attachment_files}`, made);
|
|
1221
1238
|
return 0;
|
|
@@ -1242,6 +1259,8 @@ const TABLES = ['tasks', 'task_links', 'task_comments', 'task_events', 'task_run
|
|
|
1242
1259
|
*/
|
|
1243
1260
|
function scrub(db) {
|
|
1244
1261
|
db.exec('DELETE FROM kanban_notify_subs');
|
|
1262
|
+
// a fencing token is the home's runtime custody, never a board's portable data (fence.mjs)
|
|
1263
|
+
db.exec("CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT); DELETE FROM supercode_board_state WHERE key = 'owner_epoch'");
|
|
1245
1264
|
db.exec(`UPDATE tasks SET claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, last_heartbeat_at = NULL,
|
|
1246
1265
|
session_id = NULL, project_id = NULL, consecutive_failures = 0, last_failure_error = NULL`);
|
|
1247
1266
|
db.exec("UPDATE tasks SET status = 'ready' WHERE status = 'running'");
|
|
@@ -1318,6 +1337,10 @@ function importBoard(root, archive, as) {
|
|
|
1318
1337
|
const name = staged.name || manifest.board_name || slug;
|
|
1319
1338
|
writeMeta(root, slug, { name, description: staged.description ?? '', icon: staged.icon ?? '', color: staged.color ?? '', archived: false });
|
|
1320
1339
|
const warnings = [];
|
|
1340
|
+
// the landed store keeps no other home's epoch: it is dropped before the board's first (fenced) transaction, which
|
|
1341
|
+
// stores this process's (an import holds the home's lock)
|
|
1342
|
+
const landed = new DatabaseSync(join(dest, 'kanban.db'));
|
|
1343
|
+
try { landed.exec("CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT); DELETE FROM supercode_board_state WHERE key = 'owner_epoch'"); } finally { landed.close(); }
|
|
1321
1344
|
const b = openBoard(root, slug);
|
|
1322
1345
|
let counts;
|
|
1323
1346
|
try {
|
|
@@ -1456,6 +1479,96 @@ export function summary(r) {
|
|
|
1456
1479
|
* re-read every tick, so a profile's change takes effect without a restart). Card claims
|
|
1457
1480
|
* are version-checked by the backing; the dispatcher takes no file lock.
|
|
1458
1481
|
*/
|
|
1482
|
+
/** Record the board's dispatching switch: on or off, by whom and why, as one row of its state. */
|
|
1483
|
+
function setDispatching(root, slug, on, reason) {
|
|
1484
|
+
const value = { on, by: actorOf() ?? principalOf() ?? null, at: new Date().toISOString(), reason: reason || null };
|
|
1485
|
+
const board = openBoard(root, slug);
|
|
1486
|
+
try {
|
|
1487
|
+
tx(board, (db) => {
|
|
1488
|
+
db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)');
|
|
1489
|
+
db.prepare("INSERT INTO supercode_board_state (key, value) VALUES ('dispatching', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(JSON.stringify(value));
|
|
1490
|
+
});
|
|
1491
|
+
} finally { board.close(); }
|
|
1492
|
+
return value;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* The owner's door: every card verb on this home's boards, from the CLI and the machine daemon's board door, answered
|
|
1497
|
+
* here in a call of its own (call.mjs), with the home this process holds. One owner per home, by an OS lock held for
|
|
1498
|
+
* its whole life by a holder process of its own (an exclusive SQLite lock on `<home>/board.lock`, released when the
|
|
1499
|
+
* owner ends and the holder's standard input closes): a second serve refuses, and only the lock's owner removes and
|
|
1500
|
+
* binds the socket.
|
|
1501
|
+
*/
|
|
1502
|
+
async function answerVerbs(root, log) {
|
|
1503
|
+
const home = boardHome(root);
|
|
1504
|
+
const lockPath = join(home, 'board.lock');
|
|
1505
|
+
// The lock is held by a process of its own (lock-holder.mjs), so nothing this process reads can drop it; a verb
|
|
1506
|
+
// naming the lock or socket as its input is refused (owner-files.mjs)
|
|
1507
|
+
let lock;
|
|
1508
|
+
try { lock = await holdLock(lockPath, { onLost: (error) => { log(`${error.message}: this home has no owner now; stopping`); process.exit(1); } }); }
|
|
1509
|
+
catch (error) { throw new Error(`the boards of ${root} already have their owner (${lockPath} is held: ${error.message}); one serve per home`); }
|
|
1510
|
+
ownHome(home);
|
|
1511
|
+
// the lock held, this owner takes its fencing epoch and fences every board, before it answers anything (fence.mjs)
|
|
1512
|
+
const epoch = takeEpoch(root, () => listBoards(root).map((slug) => openBoard(root, slug)));
|
|
1513
|
+
log(`owner epoch ${epoch}`);
|
|
1514
|
+
const path = boardOwnerSocket(root);
|
|
1515
|
+
try { unlinkSync(path); } catch { /* none left */ }
|
|
1516
|
+
// allowHalfOpen: a caller that ends its side after its request still gets the answer, written when it is ready
|
|
1517
|
+
const server = createServer({ allowHalfOpen: true }, (socket) => {
|
|
1518
|
+
let input = '';
|
|
1519
|
+
socket.setEncoding('utf8');
|
|
1520
|
+
socket.on('error', () => { /* the caller went away */ });
|
|
1521
|
+
socket.on('data', (chunk) => {
|
|
1522
|
+
if (input === null) return;
|
|
1523
|
+
input += chunk;
|
|
1524
|
+
const end = input.indexOf('\n');
|
|
1525
|
+
if (end < 0) return;
|
|
1526
|
+
const line = input.slice(0, end);
|
|
1527
|
+
input = null;
|
|
1528
|
+
// one answer per call, whatever the request did: a request that fails is a failed answer, never the owner's end
|
|
1529
|
+
answerOne(root, line)
|
|
1530
|
+
.catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
|
|
1531
|
+
.then((answer) => { if (!socket.destroyed) socket.end(`${JSON.stringify(answer)}\n`); });
|
|
1532
|
+
});
|
|
1533
|
+
});
|
|
1534
|
+
await new Promise((done, fail) => { server.once('error', fail); server.listen(path, () => { server.off('error', fail); done(); }); });
|
|
1535
|
+
try { chmodSync(path, 0o600); } catch { /* the home is the user's own */ }
|
|
1536
|
+
log(`answering the card verbs of ${root} at ${path}`);
|
|
1537
|
+
return () => { server.close(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** One request to the owner, answered whole: the verb run in the caller's call, its output kept for the answer. */
|
|
1541
|
+
async function answerOne(root, line) {
|
|
1542
|
+
try {
|
|
1543
|
+
const request = JSON.parse(line);
|
|
1544
|
+
const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
|
|
1545
|
+
const argv = tool ? ['tool'] : Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') ? request.argv : null;
|
|
1546
|
+
if (request?.v !== 1 || !argv?.length) return { code: 1, stdout: '', stderr: 'supercode workflow: a request names a verb\n' };
|
|
1547
|
+
const verb = parse(argv)._[0];
|
|
1548
|
+
if (BOARD_OPERATION_VERBS.has(verb)) return { code: 1, stdout: '', stderr: `supercode workflow: ${verb} is run on the board's machine as its own act, not sent to the owner\n` };
|
|
1549
|
+
const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
|
|
1550
|
+
const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
|
|
1551
|
+
const facts = Object.fromEntries(Object.entries(request.facts ?? {}).filter(([key, value]) => CALLER_FACTS.includes(key) && typeof value === 'string'));
|
|
1552
|
+
// a caller names its Teams context (the CLI and the daemon always do): one that names none is refused before anything
|
|
1553
|
+
// is done, never answered with the owner's
|
|
1554
|
+
if (!facts.SUPERCODE_CONTEXT) return { code: 1, stdout: '', stderr: 'supercode workflow: the request names no Teams context (facts.SUPERCODE_CONTEXT); nothing was done\n' };
|
|
1555
|
+
const cwd = typeof request.cwd === 'string' && request.cwd ? request.cwd : root;
|
|
1556
|
+
const stdin = typeof request.stdin === 'string' ? request.stdin : null;
|
|
1557
|
+
let stdout = '';
|
|
1558
|
+
let stderr = '';
|
|
1559
|
+
let code;
|
|
1560
|
+
await within({ caller, facts, cwd, stdin, out: (text) => { stdout += text; return true; }, err: (text) => { stderr += text; return true; } }, async () => {
|
|
1561
|
+
// a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
|
|
1562
|
+
if (tool) { stdout = 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; }
|
|
1563
|
+
try { code = await runVerb([...argv, '--root', root, ...(typeof request.board === 'string' && request.board ? ['--board', request.board] : [])]); }
|
|
1564
|
+
catch (error) { stderr += `supercode workflow: ${error?.message ?? error}\n`; code = 1; }
|
|
1565
|
+
});
|
|
1566
|
+
return { code: code ?? 0, stdout, stderr };
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
return { code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` };
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1459
1572
|
async function serve(root, args = {}) {
|
|
1460
1573
|
// Hermes's daemon flags: --interval seconds, --max starts per tick, --failure-limit, --pidfile, --verbose
|
|
1461
1574
|
const override = Number(args.interval) > 0 ? { interval: Number(args.interval) } : {};
|
|
@@ -1471,7 +1584,7 @@ async function serve(root, args = {}) {
|
|
|
1471
1584
|
const kept = join(root, 'logs', 'workflow-serve.log');
|
|
1472
1585
|
const log = (text) => {
|
|
1473
1586
|
const line = `${new Date().toISOString()} ${text}\n`;
|
|
1474
|
-
|
|
1587
|
+
current().out(line);
|
|
1475
1588
|
try { mkdirSync(join(root, 'logs'), { recursive: true }); appendFileSync(kept, line); } catch { /* the pane still has it */ }
|
|
1476
1589
|
};
|
|
1477
1590
|
// the orchestrator this serve runs, as installed: a newer install ends the serve after its tick, so whatever keeps it
|
|
@@ -1501,7 +1614,7 @@ async function serve(root, args = {}) {
|
|
|
1501
1614
|
// the same kind as rounds that stopped, so it goes where every board verb's error line reads it. Only a refusal
|
|
1502
1615
|
// in hand counts: the team's 4xx answer, which the daemon records with the publication (from supercode-teams
|
|
1503
1616
|
// 0.3.44). An attempt still being retried, or a daemon that records no answer, says nothing here.
|
|
1504
|
-
const held = boardPublishing(root)?.failing;
|
|
1617
|
+
const held = (await boardPublishing(root))?.failing;
|
|
1505
1618
|
const refused = held && Number(held.status) >= 400 && Number(held.status) < 500 ? held : null;
|
|
1506
1619
|
const heldLine = refused ? [`publishing to the team is held: the team refused ${refused.card}'s ${refused.kind} (sequence ${refused.sequence}) with ${refused.status}${refused.code ? ` ${refused.code}` : ''}: ${String(refused.error ?? '').split('\n')[0].slice(0, 200)} (tried ${refused.attempts} time(s)); nothing of this home after it is published until the team takes it`] : [];
|
|
1507
1620
|
const temporary = `${heartbeat}.${process.pid}.tmp`;
|
|
@@ -1530,13 +1643,25 @@ async function serve(root, args = {}) {
|
|
|
1530
1643
|
}
|
|
1531
1644
|
};
|
|
1532
1645
|
const running = installed();
|
|
1533
|
-
|
|
1646
|
+
await knowLocalMachine();
|
|
1647
|
+
// The board's owner: it answers every card verb on this home's boards (answerVerbs) for as long as it runs, whether
|
|
1648
|
+
// or not it dispatches.
|
|
1649
|
+
// the held home is read again on its next use after a file it is decoded from changes (file events, no interval),
|
|
1650
|
+
// watched before anything reads it; a watch that cannot be kept stops the owner, for its keeper to start it again
|
|
1651
|
+
const unfollowHome = followHome(root, {
|
|
1652
|
+
onChange: (name) => log(`home changed (${name}): read again on its next use`),
|
|
1653
|
+
onLost: (error) => { log(`the home's files can no longer be watched (${error.message}); stopping, for the keeper to start the owner again`); stop(); },
|
|
1654
|
+
});
|
|
1655
|
+
let closeVerbs;
|
|
1656
|
+
try { closeVerbs = await answerVerbs(root, log); } catch (error) { unfollowHome(); throw error; }
|
|
1657
|
+
log(`owning the boards of ${root} on ${localMachine()} (orchestrator ${running ?? 'unknown'}); dispatching where a board's dispatching is on (\`supercode workflow dispatching\`)`);
|
|
1534
1658
|
events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
|
|
1535
1659
|
// The board's events are published by this machine's connector, not by the dispatcher (D137, D138 step 4): the home is
|
|
1536
1660
|
// named to the daemon once, and the connector follows its `workflow events` from then on, with or without this serve.
|
|
1537
|
-
{
|
|
1538
|
-
|
|
1539
|
-
|
|
1661
|
+
{ let named = null, refusal = null;
|
|
1662
|
+
try { named = await machineCall(null, 'harness.v1.boards.register', { root }, { timeoutMs: 30_000 }); } catch (error) { refusal = error.message; }
|
|
1663
|
+
const refused = named?.refused ?? null;
|
|
1664
|
+
log(refusal ? `board events: not published; the machine daemon did not take this home: ${refusal}`
|
|
1540
1665
|
: refused ? `board events: registered, but the team refused this machine's credential (${refused.status}); nothing is published until it is re-enrolled`
|
|
1541
1666
|
: `board events: this home is published by the machine's connector`); }
|
|
1542
1667
|
log('dispatch wakes on board writes, document edits and board mail; the interval sweep reads each machine\'s own door');
|
|
@@ -1559,14 +1684,23 @@ async function serve(root, args = {}) {
|
|
|
1559
1684
|
if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
|
|
1560
1685
|
wakeReason = null;
|
|
1561
1686
|
try {
|
|
1562
|
-
const state = await laneState(root);
|
|
1687
|
+
const state = await ownCall(() => laneState(root));
|
|
1563
1688
|
const settings = { ...state.settings, ...override };
|
|
1564
1689
|
interval = settings.interval;
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
if (
|
|
1569
|
-
|
|
1690
|
+
// Publishing is the owner's, dispatching or not: what a writer outside this process marked changed is published
|
|
1691
|
+
// on the wake its write caused, and the retained history kept bounded (store.mjs publishMarked).
|
|
1692
|
+
const published = await ownCall(async () => publishMarked(root));
|
|
1693
|
+
if (published && args.verbose) log(`published ${published} card(s) a writer outside the owner changed`);
|
|
1694
|
+
// Dispatching is a controller over the boards whose own state says it is on; the owner answers verbs either way.
|
|
1695
|
+
const dispatching = listBoards(root).filter((slug) => dispatchingOf(root, slug).on);
|
|
1696
|
+
const reports = dispatching.length ? await ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true })) : [];
|
|
1697
|
+
// with dispatching off on every board the owner only answers verbs: no round, no delivery, nothing told
|
|
1698
|
+
if (dispatching.length) {
|
|
1699
|
+
round += 1;
|
|
1700
|
+
// what the round recorded is delivered by a pass of its own, which this loop never waits for
|
|
1701
|
+
if (!stopped) startDelivery(root, { slugs: dispatching, log: (level, line) => { if (level === 'warn' || args.verbose) log(line); } });
|
|
1702
|
+
if (!stopped) await watchRounds(reports);
|
|
1703
|
+
}
|
|
1570
1704
|
for (const r of reports) {
|
|
1571
1705
|
nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
|
|
1572
1706
|
const said = summary(r);
|
|
@@ -1606,6 +1740,9 @@ async function serve(root, args = {}) {
|
|
|
1606
1740
|
if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
|
|
1607
1741
|
}
|
|
1608
1742
|
} finally {
|
|
1743
|
+
closeVerbs();
|
|
1744
|
+
unfollowHome();
|
|
1745
|
+
await closeDoors();
|
|
1609
1746
|
events?.close();
|
|
1610
1747
|
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.off(signal, stop);
|
|
1611
1748
|
if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }
|