@volter/supercode-orchestrator 0.5.69 → 0.5.70
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 +250 -120
- package/board/client.mjs +87 -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 +45 -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,22 +47,31 @@ 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:
|
|
@@ -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);
|
|
@@ -481,6 +462,8 @@ export async function runWorkflow(argv) {
|
|
|
481
462
|
let workspace = args.workspace;
|
|
482
463
|
if ((workspace === 'dir' || workspace === 'worktree') && meta.default_workdir) workspace = `${workspace}:${meta.default_workdir}`;
|
|
483
464
|
// --goal / --no-goal are the create's words; without either, the workflow's initial decides
|
|
465
|
+
// a person filing for themselves sponsors it; an agent filing a person's request names them (proven) or records them unverified
|
|
466
|
+
const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
|
|
484
467
|
const created = withBoard((b) => engine.createCard(b, {
|
|
485
468
|
title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
|
|
486
469
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
@@ -490,8 +473,7 @@ export async function runWorkflow(argv) {
|
|
|
490
473
|
every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
|
|
491
474
|
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
475
|
tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])],
|
|
493
|
-
|
|
494
|
-
...(args.sponsor || args['requested-by'] ? requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {}),
|
|
476
|
+
...sponsorship,
|
|
495
477
|
}), true);
|
|
496
478
|
await withBoardAsync(b=>engine.runEffects(b,created.effects??[]));
|
|
497
479
|
let answer = { id: created.id, existing: created.existing };
|
|
@@ -566,8 +548,8 @@ export async function runWorkflow(argv) {
|
|
|
566
548
|
return 0;
|
|
567
549
|
}
|
|
568
550
|
const others = boardsOf(root, { includeArchived: false }).length - 1;
|
|
569
|
-
if (others > 0)
|
|
570
|
-
|
|
551
|
+
if (others > 0) current().out(`Board: ${slug} (${others} other board${others === 1 ? '' : 's'} — \`supercode workflow boards list\`)\n\n`);
|
|
552
|
+
current().out(cards.length ? `${cards.map(line).join('\n')}\n` : '(no matching tasks)\n');
|
|
571
553
|
return 0;
|
|
572
554
|
}
|
|
573
555
|
|
|
@@ -640,19 +622,19 @@ export async function runWorkflow(argv) {
|
|
|
640
622
|
}
|
|
641
623
|
}
|
|
642
624
|
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
|
-
|
|
625
|
+
current().out(`${out.join('\n')}\n`);
|
|
644
626
|
return 0;
|
|
645
627
|
}
|
|
646
628
|
|
|
647
629
|
// G3: usage from each run's own harness records, summed on agents and on their sponsors
|
|
648
630
|
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; } };
|
|
631
|
+
const byAgent = agentUsage(root), context = current().facts.SUPERCODE_CONTEXT;
|
|
632
|
+
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
633
|
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
634
|
// a harness that keeps no price records tokens only: its cost reads as unrecorded, never as zero
|
|
653
635
|
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
636
|
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)) }))
|
|
637
|
+
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
638
|
.filter((row) => !args.sponsor || row.sponsor === args.sponsor);
|
|
657
639
|
const total = sum(rows.flatMap((row) => byAgent.get(row.agent)));
|
|
658
640
|
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 +643,13 @@ export async function runWorkflow(argv) {
|
|
|
661
643
|
return 0;
|
|
662
644
|
}
|
|
663
645
|
|
|
664
|
-
case 'context': need(1, 'a card id');
|
|
646
|
+
case 'context': need(1, 'a card id'); current().out(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
|
|
665
647
|
|
|
666
648
|
case 'runs': {
|
|
667
649
|
need(1, 'a card id');
|
|
668
650
|
const got = withBoard((b) => engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null }));
|
|
669
651
|
if (args.json) { say(args, '', got); return 0; }
|
|
670
|
-
|
|
652
|
+
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
653
|
return 0;
|
|
672
654
|
}
|
|
673
655
|
|
|
@@ -685,14 +667,14 @@ export async function runWorkflow(argv) {
|
|
|
685
667
|
case 'assign': case 'reassign': if (args.agent) {
|
|
686
668
|
// C7: a card role's assignment moves to another agent; the sessions it ran keep their identity
|
|
687
669
|
need(1, 'a card id');
|
|
688
|
-
const context=
|
|
670
|
+
const context=current().facts.SUPERCODE_CONTEXT,scope=agentScope(context);
|
|
689
671
|
const task=withBoard(b=>engine.getTask(b.db,id));
|
|
690
672
|
if(!task)throw new Error(`no card ${id}`);
|
|
691
673
|
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
674
|
const role=args.role??'implementer';
|
|
693
675
|
const assignment=activeAgentAssignments(root).find(item=>item.org===scope.org&&item.kind==='card'&&item.target===id&&item.role===role);
|
|
694
676
|
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});
|
|
677
|
+
const changed=await reassignAgent(root,scope.org,assignment.id,args.agent,{context});
|
|
696
678
|
withBoard(b=>tx(b,db=>event(db,id,'agent_assignment_reassigned',{assignment:assignment.id,role,from:assignment.agent_id,to:changed.agent_id})));
|
|
697
679
|
say(args,`Reassigned ${id}/${role} to agent ${changed.agent_id}; sessions keep their identity`,{ok:true,assignment:changed});return 0;
|
|
698
680
|
} 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 +735,7 @@ export async function runWorkflow(argv) {
|
|
|
753
735
|
}
|
|
754
736
|
case 'attach': {
|
|
755
737
|
need(2, 'a card id and a file');
|
|
756
|
-
const source =
|
|
738
|
+
const source = callerPath(rest[1]);
|
|
757
739
|
if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
|
|
758
740
|
const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
|
|
759
741
|
const dir = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
|
|
@@ -768,7 +750,7 @@ export async function runWorkflow(argv) {
|
|
|
768
750
|
need(1, 'a card id');
|
|
769
751
|
const got = withBoard((b) => engine.attachments(b.db, id));
|
|
770
752
|
if (args.json) { say(args, '', got); return 0; }
|
|
771
|
-
|
|
753
|
+
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
754
|
return 0;
|
|
773
755
|
}
|
|
774
756
|
case 'attach-rm': {
|
|
@@ -892,7 +874,7 @@ export async function runWorkflow(argv) {
|
|
|
892
874
|
case 'diagnostics': case 'diag': {
|
|
893
875
|
const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
|
|
894
876
|
const fleet = observeFleet(root);
|
|
895
|
-
const publishing = boardPublishing(root);
|
|
877
|
+
const publishing = await boardPublishing(root);
|
|
896
878
|
const expiredMail = mailExpiries();
|
|
897
879
|
const waitingMail = mailWaiting();
|
|
898
880
|
const order = ['warning', 'error', 'critical'];
|
|
@@ -903,14 +885,14 @@ export async function runWorkflow(argv) {
|
|
|
903
885
|
.filter((d) => !args.severity || order.indexOf(d.severity) >= order.indexOf(args.severity));
|
|
904
886
|
});
|
|
905
887
|
if (args.json) { say(args, '', found); return 0; }
|
|
906
|
-
|
|
888
|
+
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
889
|
return 0;
|
|
908
890
|
}
|
|
909
891
|
case 'stats': {
|
|
910
892
|
const s = withBoard((b) => engine.stats(b.db));
|
|
911
893
|
if (args.json) { say(args, '', s); return 0; }
|
|
912
894
|
const fmt = (o) => Object.entries(o).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(none)';
|
|
913
|
-
|
|
895
|
+
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
896
|
return 0;
|
|
915
897
|
}
|
|
916
898
|
case 'assignees': {
|
|
@@ -918,7 +900,7 @@ export async function runWorkflow(argv) {
|
|
|
918
900
|
const c = withBoard((b) => engine.stats(b.db).by_assignee);
|
|
919
901
|
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
902
|
if (args.json) { say(args, '', data); return 0; }
|
|
921
|
-
|
|
903
|
+
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
904
|
return 0;
|
|
923
905
|
}
|
|
924
906
|
case 'log': {
|
|
@@ -927,12 +909,12 @@ export async function runWorkflow(argv) {
|
|
|
927
909
|
if (!existsSync(file)) {
|
|
928
910
|
// a pane session keeps no log file: its transcript is the harness's own, named by the run
|
|
929
911
|
const s = withBoard((b) => view(b, (db) => engine.lastSession(db, id)));
|
|
930
|
-
|
|
912
|
+
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
913
|
return 0;
|
|
932
914
|
}
|
|
933
915
|
const text = readFileSync(file, 'utf8');
|
|
934
916
|
const tail = args.tail ? Number(args.tail) : null;
|
|
935
|
-
|
|
917
|
+
current().out(tail ? text.slice(-tail) : text);
|
|
936
918
|
return 0;
|
|
937
919
|
}
|
|
938
920
|
case 'tail': case 'watch': {
|
|
@@ -951,7 +933,7 @@ export async function runWorkflow(argv) {
|
|
|
951
933
|
if (kinds && !kinds.has(e.kind)) continue;
|
|
952
934
|
if (args.assignee && e.assignee !== args.assignee) continue;
|
|
953
935
|
if (args.tenant && e.tenant !== args.tenant) continue;
|
|
954
|
-
|
|
936
|
+
current().out(`[${ts(e.created_at)}] ${verb === 'watch' ? `${e.task_id} ` : ''}${e.kind}${e.payload ? ` ${e.payload}` : ''}\n`);
|
|
955
937
|
}
|
|
956
938
|
await new Promise((done) => setTimeout(done, interval));
|
|
957
939
|
}
|
|
@@ -987,7 +969,7 @@ export async function runWorkflow(argv) {
|
|
|
987
969
|
case 'notify-list': {
|
|
988
970
|
const subs = withBoard((b) => engine.subscriptions(b.db, id ?? null));
|
|
989
971
|
if (args.json) { say(args, '', subs); return 0; }
|
|
990
|
-
|
|
972
|
+
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
973
|
return 0;
|
|
992
974
|
}
|
|
993
975
|
|
|
@@ -1005,7 +987,7 @@ export async function runWorkflow(argv) {
|
|
|
1005
987
|
}
|
|
1006
988
|
const reports = await tick({ root, lanes, settings, params });
|
|
1007
989
|
if (args.json) say(args, '', reports);
|
|
1008
|
-
else for (const r of reports)
|
|
990
|
+
else for (const r of reports) current().out(`${summary(r)}\n`);
|
|
1009
991
|
return reports.some((r) => r.error) ? 1 : 0;
|
|
1010
992
|
}
|
|
1011
993
|
case 'capacity': {
|
|
@@ -1024,7 +1006,7 @@ export async function runWorkflow(argv) {
|
|
|
1024
1006
|
}
|
|
1025
1007
|
lines.push(out.would.length ? `would start: ${out.would.join(' ')}` : 'would start: nothing');
|
|
1026
1008
|
for (const w of out.waiting) lines.push(`${w.id} ${w.why}`);
|
|
1027
|
-
|
|
1009
|
+
current().out(`${lines.join('\n')}\n`);
|
|
1028
1010
|
return 0;
|
|
1029
1011
|
}
|
|
1030
1012
|
case 'deliver': {
|
|
@@ -1032,7 +1014,7 @@ export async function runWorkflow(argv) {
|
|
|
1032
1014
|
// starts it after each round in a process of its own; never part of a round)
|
|
1033
1015
|
const reports = await deliver({ root, slugs: args.board ? [slug] : null });
|
|
1034
1016
|
if (args.json) say(args, '', reports);
|
|
1035
|
-
else for (const r of reports)
|
|
1017
|
+
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
1018
|
return reports.some((r) => r.error) ? 1 : 0;
|
|
1037
1019
|
}
|
|
1038
1020
|
case 'daemon': case 'serve': return serve(root, args);
|
|
@@ -1087,6 +1069,34 @@ export async function runWorkflow(argv) {
|
|
|
1087
1069
|
await withBoardAsync((b) => engine.act(b, id, 'cancel', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
|
|
1088
1070
|
say(args, `${id} cancelled`, { ok: true }); return 0;
|
|
1089
1071
|
}
|
|
1072
|
+
case 'run-session': {
|
|
1073
|
+
// A headless worker records its harness session on its run (kanban-worker.mjs), through the owner like every board
|
|
1074
|
+
// write. Only the run the owner launched for this worker: the launch's secret (in the worker's environment alone,
|
|
1075
|
+
// its hash kept on the run), its card and its run are checked at the write, against the card's current, unended run.
|
|
1076
|
+
need(2, 'a run id and a session id');
|
|
1077
|
+
// the secret is the worker's own environment (SUPERCODE_RUN_LAUNCH, sent as a caller fact), never an argument
|
|
1078
|
+
const runId = Number(id), harness = args.harness ?? null, launch = current().facts.SUPERCODE_RUN_LAUNCH ?? '', card = typeof args.task === 'string' ? args.task : '';
|
|
1079
|
+
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');
|
|
1080
|
+
const refused = withBoard((b) => tx(b, (db) => {
|
|
1081
|
+
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);
|
|
1082
|
+
const saved = json(row?.metadata, {})?.supercode ?? {};
|
|
1083
|
+
if (!row || row.task_id !== card || row.ended_at != null || Number(row.current_run_id) !== runId || saved.surface !== 'headless' || !saved.launch_secret
|
|
1084
|
+
|| 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`;
|
|
1085
|
+
engine.setRunSession(db, runId, { ...saved, session_id: rest[1], harness });
|
|
1086
|
+
return null;
|
|
1087
|
+
}));
|
|
1088
|
+
if (refused) throw new Error(refused);
|
|
1089
|
+
say(args, `run ${runId}: session ${rest[1]}`, { ok: true }); return 0;
|
|
1090
|
+
}
|
|
1091
|
+
case 'dispatching': {
|
|
1092
|
+
// the board's dispatching switch (its owner's controller): `dispatching` says it, `dispatching on|off` sets it
|
|
1093
|
+
if (id && !['on', 'off'].includes(id)) throw new Error('dispatching takes on or off');
|
|
1094
|
+
// switching it is the manager's: a caller outside any session, or a session the workflow's params.managers names
|
|
1095
|
+
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)`);
|
|
1096
|
+
const state = id ? setDispatching(root, slug, id === 'on', args.reason ?? rest.slice(1).join(' ')) : dispatchingOf(root, slug);
|
|
1097
|
+
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 });
|
|
1098
|
+
return 0;
|
|
1099
|
+
}
|
|
1090
1100
|
case 'pause': {
|
|
1091
1101
|
if (id) {
|
|
1092
1102
|
await withBoardAsync((b) => engine.act(b, id, 'pause', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
|
|
@@ -1157,10 +1167,10 @@ async function boardsVerb(root, rest, args) {
|
|
|
1157
1167
|
const [action = 'list', target] = rest;
|
|
1158
1168
|
switch (action) {
|
|
1159
1169
|
case 'list': case 'ls': {
|
|
1160
|
-
const
|
|
1161
|
-
const boards = boardsOf(root, { includeArchived: args.all }).map((b) => ({ ...b, is_current: b.slug ===
|
|
1170
|
+
const selected = boardOf(root, {});
|
|
1171
|
+
const boards = boardsOf(root, { includeArchived: args.all }).map((b) => ({ ...b, is_current: b.slug === selected, counts: counts(root, b.slug) }));
|
|
1162
1172
|
if (args.json) { say(args, '', boards); return 0; }
|
|
1163
|
-
|
|
1173
|
+
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
1174
|
return 0;
|
|
1165
1175
|
}
|
|
1166
1176
|
case 'create': case 'new': {
|
|
@@ -1215,7 +1225,7 @@ async function boardsVerb(root, rest, args) {
|
|
|
1215
1225
|
case 'export': {
|
|
1216
1226
|
const slug = normalSlug(target ?? boardOf(root, args));
|
|
1217
1227
|
if (!boardExists(root, slug)) throw new Error(`board '${slug}' has no database`);
|
|
1218
|
-
const out =
|
|
1228
|
+
const out = callerPath(args.output ?? `${slug}.tar.gz`);
|
|
1219
1229
|
const made = exportBoard(root, slug, out, { attachments: !args['no-attachments'], logs: Boolean(args['include-logs']) });
|
|
1220
1230
|
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
1231
|
return 0;
|
|
@@ -1242,6 +1252,8 @@ const TABLES = ['tasks', 'task_links', 'task_comments', 'task_events', 'task_run
|
|
|
1242
1252
|
*/
|
|
1243
1253
|
function scrub(db) {
|
|
1244
1254
|
db.exec('DELETE FROM kanban_notify_subs');
|
|
1255
|
+
// a fencing token is the home's runtime custody, never a board's portable data (fence.mjs)
|
|
1256
|
+
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
1257
|
db.exec(`UPDATE tasks SET claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, last_heartbeat_at = NULL,
|
|
1246
1258
|
session_id = NULL, project_id = NULL, consecutive_failures = 0, last_failure_error = NULL`);
|
|
1247
1259
|
db.exec("UPDATE tasks SET status = 'ready' WHERE status = 'running'");
|
|
@@ -1318,6 +1330,10 @@ function importBoard(root, archive, as) {
|
|
|
1318
1330
|
const name = staged.name || manifest.board_name || slug;
|
|
1319
1331
|
writeMeta(root, slug, { name, description: staged.description ?? '', icon: staged.icon ?? '', color: staged.color ?? '', archived: false });
|
|
1320
1332
|
const warnings = [];
|
|
1333
|
+
// the landed store keeps no other home's epoch: it is dropped before the board's first (fenced) transaction, which
|
|
1334
|
+
// stores this process's (an import holds the home's lock)
|
|
1335
|
+
const landed = new DatabaseSync(join(dest, 'kanban.db'));
|
|
1336
|
+
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
1337
|
const b = openBoard(root, slug);
|
|
1322
1338
|
let counts;
|
|
1323
1339
|
try {
|
|
@@ -1456,6 +1472,96 @@ export function summary(r) {
|
|
|
1456
1472
|
* re-read every tick, so a profile's change takes effect without a restart). Card claims
|
|
1457
1473
|
* are version-checked by the backing; the dispatcher takes no file lock.
|
|
1458
1474
|
*/
|
|
1475
|
+
/** Record the board's dispatching switch: on or off, by whom and why, as one row of its state. */
|
|
1476
|
+
function setDispatching(root, slug, on, reason) {
|
|
1477
|
+
const value = { on, by: actorOf() ?? principalOf() ?? null, at: new Date().toISOString(), reason: reason || null };
|
|
1478
|
+
const board = openBoard(root, slug);
|
|
1479
|
+
try {
|
|
1480
|
+
tx(board, (db) => {
|
|
1481
|
+
db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)');
|
|
1482
|
+
db.prepare("INSERT INTO supercode_board_state (key, value) VALUES ('dispatching', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(JSON.stringify(value));
|
|
1483
|
+
});
|
|
1484
|
+
} finally { board.close(); }
|
|
1485
|
+
return value;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* The owner's door: every card verb on this home's boards, from the CLI and the machine daemon's board door, answered
|
|
1490
|
+
* 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
|
|
1491
|
+
* its whole life by a holder process of its own (an exclusive SQLite lock on `<home>/board.lock`, released when the
|
|
1492
|
+
* owner ends and the holder's standard input closes): a second serve refuses, and only the lock's owner removes and
|
|
1493
|
+
* binds the socket.
|
|
1494
|
+
*/
|
|
1495
|
+
async function answerVerbs(root, log) {
|
|
1496
|
+
const home = boardHome(root);
|
|
1497
|
+
const lockPath = join(home, 'board.lock');
|
|
1498
|
+
// The lock is held by a process of its own (lock-holder.mjs), so nothing this process reads can drop it; a verb
|
|
1499
|
+
// naming the lock or socket as its input is refused (owner-files.mjs)
|
|
1500
|
+
let lock;
|
|
1501
|
+
try { lock = await holdLock(lockPath, { onLost: (error) => { log(`${error.message}: this home has no owner now; stopping`); process.exit(1); } }); }
|
|
1502
|
+
catch (error) { throw new Error(`the boards of ${root} already have their owner (${lockPath} is held: ${error.message}); one serve per home`); }
|
|
1503
|
+
ownHome(home);
|
|
1504
|
+
// the lock held, this owner takes its fencing epoch and fences every board, before it answers anything (fence.mjs)
|
|
1505
|
+
const epoch = takeEpoch(root, () => listBoards(root).map((slug) => openBoard(root, slug)));
|
|
1506
|
+
log(`owner epoch ${epoch}`);
|
|
1507
|
+
const path = boardOwnerSocket(root);
|
|
1508
|
+
try { unlinkSync(path); } catch { /* none left */ }
|
|
1509
|
+
// allowHalfOpen: a caller that ends its side after its request still gets the answer, written when it is ready
|
|
1510
|
+
const server = createServer({ allowHalfOpen: true }, (socket) => {
|
|
1511
|
+
let input = '';
|
|
1512
|
+
socket.setEncoding('utf8');
|
|
1513
|
+
socket.on('error', () => { /* the caller went away */ });
|
|
1514
|
+
socket.on('data', (chunk) => {
|
|
1515
|
+
if (input === null) return;
|
|
1516
|
+
input += chunk;
|
|
1517
|
+
const end = input.indexOf('\n');
|
|
1518
|
+
if (end < 0) return;
|
|
1519
|
+
const line = input.slice(0, end);
|
|
1520
|
+
input = null;
|
|
1521
|
+
// one answer per call, whatever the request did: a request that fails is a failed answer, never the owner's end
|
|
1522
|
+
answerOne(root, line)
|
|
1523
|
+
.catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
|
|
1524
|
+
.then((answer) => { if (!socket.destroyed) socket.end(`${JSON.stringify(answer)}\n`); });
|
|
1525
|
+
});
|
|
1526
|
+
});
|
|
1527
|
+
await new Promise((done, fail) => { server.once('error', fail); server.listen(path, () => { server.off('error', fail); done(); }); });
|
|
1528
|
+
try { chmodSync(path, 0o600); } catch { /* the home is the user's own */ }
|
|
1529
|
+
log(`answering the card verbs of ${root} at ${path}`);
|
|
1530
|
+
return () => { server.close(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/** One request to the owner, answered whole: the verb run in the caller's call, its output kept for the answer. */
|
|
1534
|
+
async function answerOne(root, line) {
|
|
1535
|
+
try {
|
|
1536
|
+
const request = JSON.parse(line);
|
|
1537
|
+
const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
|
|
1538
|
+
const argv = tool ? ['tool'] : Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') ? request.argv : null;
|
|
1539
|
+
if (request?.v !== 1 || !argv?.length) return { code: 1, stdout: '', stderr: 'supercode workflow: a request names a verb\n' };
|
|
1540
|
+
const verb = parse(argv)._[0];
|
|
1541
|
+
if (BOARD_OPERATION_VERBS.has(verb) && verb !== 'dispatching') 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` };
|
|
1542
|
+
const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
|
|
1543
|
+
const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
|
|
1544
|
+
const facts = Object.fromEntries(Object.entries(request.facts ?? {}).filter(([key, value]) => CALLER_FACTS.includes(key) && typeof value === 'string'));
|
|
1545
|
+
// a caller names its Teams context (the CLI and the daemon always do): one that names none is refused before anything
|
|
1546
|
+
// is done, never answered with the owner's
|
|
1547
|
+
if (!facts.SUPERCODE_CONTEXT) return { code: 1, stdout: '', stderr: 'supercode workflow: the request names no Teams context (facts.SUPERCODE_CONTEXT); nothing was done\n' };
|
|
1548
|
+
const cwd = typeof request.cwd === 'string' && request.cwd ? request.cwd : root;
|
|
1549
|
+
const stdin = typeof request.stdin === 'string' ? request.stdin : null;
|
|
1550
|
+
let stdout = '';
|
|
1551
|
+
let stderr = '';
|
|
1552
|
+
let code;
|
|
1553
|
+
await within({ caller, facts, cwd, stdin, out: (text) => { stdout += text; return true; }, err: (text) => { stderr += text; return true; } }, async () => {
|
|
1554
|
+
// a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
|
|
1555
|
+
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; }
|
|
1556
|
+
try { code = await runVerb([...argv, '--root', root, ...(typeof request.board === 'string' && request.board ? ['--board', request.board] : [])]); }
|
|
1557
|
+
catch (error) { stderr += `supercode workflow: ${error?.message ?? error}\n`; code = 1; }
|
|
1558
|
+
});
|
|
1559
|
+
return { code: code ?? 0, stdout, stderr };
|
|
1560
|
+
} catch (error) {
|
|
1561
|
+
return { code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` };
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1459
1565
|
async function serve(root, args = {}) {
|
|
1460
1566
|
// Hermes's daemon flags: --interval seconds, --max starts per tick, --failure-limit, --pidfile, --verbose
|
|
1461
1567
|
const override = Number(args.interval) > 0 ? { interval: Number(args.interval) } : {};
|
|
@@ -1471,7 +1577,7 @@ async function serve(root, args = {}) {
|
|
|
1471
1577
|
const kept = join(root, 'logs', 'workflow-serve.log');
|
|
1472
1578
|
const log = (text) => {
|
|
1473
1579
|
const line = `${new Date().toISOString()} ${text}\n`;
|
|
1474
|
-
|
|
1580
|
+
current().out(line);
|
|
1475
1581
|
try { mkdirSync(join(root, 'logs'), { recursive: true }); appendFileSync(kept, line); } catch { /* the pane still has it */ }
|
|
1476
1582
|
};
|
|
1477
1583
|
// the orchestrator this serve runs, as installed: a newer install ends the serve after its tick, so whatever keeps it
|
|
@@ -1501,7 +1607,7 @@ async function serve(root, args = {}) {
|
|
|
1501
1607
|
// the same kind as rounds that stopped, so it goes where every board verb's error line reads it. Only a refusal
|
|
1502
1608
|
// in hand counts: the team's 4xx answer, which the daemon records with the publication (from supercode-teams
|
|
1503
1609
|
// 0.3.44). An attempt still being retried, or a daemon that records no answer, says nothing here.
|
|
1504
|
-
const held = boardPublishing(root)?.failing;
|
|
1610
|
+
const held = (await boardPublishing(root))?.failing;
|
|
1505
1611
|
const refused = held && Number(held.status) >= 400 && Number(held.status) < 500 ? held : null;
|
|
1506
1612
|
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
1613
|
const temporary = `${heartbeat}.${process.pid}.tmp`;
|
|
@@ -1530,13 +1636,25 @@ async function serve(root, args = {}) {
|
|
|
1530
1636
|
}
|
|
1531
1637
|
};
|
|
1532
1638
|
const running = installed();
|
|
1533
|
-
|
|
1639
|
+
await knowLocalMachine();
|
|
1640
|
+
// The board's owner: it answers every card verb on this home's boards (answerVerbs) for as long as it runs, whether
|
|
1641
|
+
// or not it dispatches.
|
|
1642
|
+
// the held home is read again on its next use after a file it is decoded from changes (file events, no interval),
|
|
1643
|
+
// watched before anything reads it; a watch that cannot be kept stops the owner, for its keeper to start it again
|
|
1644
|
+
const unfollowHome = followHome(root, {
|
|
1645
|
+
onChange: (name) => log(`home changed (${name}): read again on its next use`),
|
|
1646
|
+
onLost: (error) => { log(`the home's files can no longer be watched (${error.message}); stopping, for the keeper to start the owner again`); stop(); },
|
|
1647
|
+
});
|
|
1648
|
+
let closeVerbs;
|
|
1649
|
+
try { closeVerbs = await answerVerbs(root, log); } catch (error) { unfollowHome(); throw error; }
|
|
1650
|
+
log(`owning the boards of ${root} on ${localMachine()} (orchestrator ${running ?? 'unknown'}); dispatching where a board's dispatching is on (\`supercode workflow dispatching\`)`);
|
|
1534
1651
|
events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
|
|
1535
1652
|
// The board's events are published by this machine's connector, not by the dispatcher (D137, D138 step 4): the home is
|
|
1536
1653
|
// named to the daemon once, and the connector follows its `workflow events` from then on, with or without this serve.
|
|
1537
|
-
{
|
|
1538
|
-
|
|
1539
|
-
|
|
1654
|
+
{ let named = null, refusal = null;
|
|
1655
|
+
try { named = await machineCall(null, 'harness.v1.boards.register', { root }, { timeoutMs: 30_000 }); } catch (error) { refusal = error.message; }
|
|
1656
|
+
const refused = named?.refused ?? null;
|
|
1657
|
+
log(refusal ? `board events: not published; the machine daemon did not take this home: ${refusal}`
|
|
1540
1658
|
: refused ? `board events: registered, but the team refused this machine's credential (${refused.status}); nothing is published until it is re-enrolled`
|
|
1541
1659
|
: `board events: this home is published by the machine's connector`); }
|
|
1542
1660
|
log('dispatch wakes on board writes, document edits and board mail; the interval sweep reads each machine\'s own door');
|
|
@@ -1559,14 +1677,23 @@ async function serve(root, args = {}) {
|
|
|
1559
1677
|
if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
|
|
1560
1678
|
wakeReason = null;
|
|
1561
1679
|
try {
|
|
1562
|
-
const state = await laneState(root);
|
|
1680
|
+
const state = await ownCall(() => laneState(root));
|
|
1563
1681
|
const settings = { ...state.settings, ...override };
|
|
1564
1682
|
interval = settings.interval;
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
if (
|
|
1569
|
-
|
|
1683
|
+
// Publishing is the owner's, dispatching or not: what a writer outside this process marked changed is published
|
|
1684
|
+
// on the wake its write caused, and the retained history kept bounded (store.mjs publishMarked).
|
|
1685
|
+
const published = await ownCall(async () => publishMarked(root));
|
|
1686
|
+
if (published && args.verbose) log(`published ${published} card(s) a writer outside the owner changed`);
|
|
1687
|
+
// Dispatching is a controller over the boards whose own state says it is on; the owner answers verbs either way.
|
|
1688
|
+
const dispatching = listBoards(root).filter((slug) => dispatchingOf(root, slug).on);
|
|
1689
|
+
const reports = dispatching.length ? await ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true })) : [];
|
|
1690
|
+
// with dispatching off on every board the owner only answers verbs: no round, no delivery, nothing told
|
|
1691
|
+
if (dispatching.length) {
|
|
1692
|
+
round += 1;
|
|
1693
|
+
// what the round recorded is delivered by a pass of its own, which this loop never waits for
|
|
1694
|
+
if (!stopped) startDelivery(root, { slugs: dispatching, log: (level, line) => { if (level === 'warn' || args.verbose) log(line); } });
|
|
1695
|
+
if (!stopped) await watchRounds(reports);
|
|
1696
|
+
}
|
|
1570
1697
|
for (const r of reports) {
|
|
1571
1698
|
nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
|
|
1572
1699
|
const said = summary(r);
|
|
@@ -1606,6 +1733,9 @@ async function serve(root, args = {}) {
|
|
|
1606
1733
|
if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
|
|
1607
1734
|
}
|
|
1608
1735
|
} finally {
|
|
1736
|
+
closeVerbs();
|
|
1737
|
+
unfollowHome();
|
|
1738
|
+
await closeDoors();
|
|
1609
1739
|
events?.close();
|
|
1610
1740
|
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.off(signal, stop);
|
|
1611
1741
|
if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }
|