@volter/supercode-orchestrator 0.4.16 → 0.4.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/cli.mjs +792 -121
- package/board/dispatch.mjs +229 -16
- package/board/engine.mjs +225 -7
- package/board.mjs +0 -1
- package/package.json +1 -1
package/board/cli.mjs
CHANGED
|
@@ -1,55 +1,72 @@
|
|
|
1
1
|
// `supercode workflow <verb>` (docs/architecture/orchestrator.md §2.9): supercode's own board, in Hermes Kanban's
|
|
2
|
-
// format, and its dispatcher.
|
|
3
|
-
//
|
|
4
|
-
// `--
|
|
5
|
-
|
|
2
|
+
// format, and its dispatcher. Every verb of `hermes kanban` is here under its own name, with its own flags and its own
|
|
3
|
+
// output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`move`, `adopt`, `close`,
|
|
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`, else `HERMES_HOME`; the board is
|
|
6
|
+
// `--board <slug>`, else `HERMES_KANBAN_BOARD`, else the home's current board (`kanban/current`), else `default`.
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
8
|
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { basename, join, resolve } from 'node:path';
|
|
7
10
|
import { tmpdir } from 'node:os';
|
|
8
|
-
import { resolve } from 'node:path';
|
|
9
11
|
import { read } from '../orchestration.mjs';
|
|
10
12
|
import { holdDispatcherLock } from '../hermes-locks.mjs';
|
|
11
|
-
import { boardPath, DEFAULT_BOARD, event, listBoards, openBoard, tx } from './store.mjs';
|
|
13
|
+
import { boardPath, DEFAULT_BOARD, event, json, listBoards, now, openBoard, tx } from './store.mjs';
|
|
12
14
|
import * as engine from './engine.mjs';
|
|
13
|
-
import {
|
|
15
|
+
import { adoptSession, boardLabel, boardSettings, lanesOf, localMachine, observeFleet, startTriageSession, tick } from './dispatch.mjs';
|
|
14
16
|
|
|
15
17
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
[--
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
19
|
+
Hermes Kanban's verbs, with their flags:
|
|
20
|
+
init · boards list|create|rm|switch|show|rename|set-default-workdir|export|import
|
|
21
|
+
create <title> [--body] [--assignee] [--parent]... [--workspace scratch|worktree[:<path>]|dir:<path>] [--branch] [--project]
|
|
22
|
+
[--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
|
|
23
|
+
[--model] [--provider] [--goal] [--goal-max-turns] [--initial-status blocked|running]
|
|
24
|
+
swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
25
|
+
list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> runs <id>
|
|
26
|
+
assign <id> <profile|none> reassign <id> <profile> [--reclaim] [--reason] set-model <id> [model] [--provider]
|
|
27
|
+
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] link|unlink <parent> <child>
|
|
28
|
+
comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
29
|
+
complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
|
|
30
|
+
block <id> [reason] [--kind dependency|needs_input|capability|transient] [--ids ...] schedule <id> [reason] [--ids ...]
|
|
31
|
+
unblock <id>... request-review <id> [--summary] [--reviewer] request-changes <id> <reason> reopen-review <id>...
|
|
32
|
+
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
|
|
33
|
+
specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
34
|
+
diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
|
|
35
|
+
notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
|
|
36
|
+
dispatch [--dry-run] [--max N] [--failure-limit N] daemon gc [--event-retention-days] [--log-retention-days] repair
|
|
37
|
+
|
|
38
|
+
supercode's own:
|
|
39
|
+
create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
|
|
40
|
+
With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
|
|
41
|
+
waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
|
|
42
|
+
move <id> <machine|none> adopt <id> <session> close <id> serve (= daemon) list --machine M
|
|
43
|
+
notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
|
|
44
|
+
Any verb … --at sc:<machine>:board:<home>: sent as mail to a board kept on another machine.
|
|
45
|
+
|
|
46
|
+
specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
|
|
47
|
+
block (with --kind), heartbeat and request review of its card, specify or decompose it in triage, and file, complete,
|
|
48
|
+
block and comment on its subtasks; its arc is marked done by its reviewers, never by itself.
|
|
41
49
|
`;
|
|
42
50
|
|
|
43
|
-
const MULTI = new Set(['parent', 'skill']);
|
|
44
|
-
const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help'
|
|
51
|
+
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
|
|
52
|
+
const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
|
|
53
|
+
'goal', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
|
|
54
|
+
// flags that take every following word up to the next flag (Hermes's nargs='+')
|
|
55
|
+
const GREEDY = new Set(['ids', 'rm']);
|
|
45
56
|
|
|
46
57
|
function parse(argv) {
|
|
47
58
|
const out = { _: [] };
|
|
48
59
|
for (let i = 0; i < argv.length; i += 1) {
|
|
49
60
|
const a = argv[i];
|
|
50
|
-
if (!a.startsWith('--')) { out._.push(a); continue; }
|
|
61
|
+
if (!a.startsWith('--') || a === '--') { out._.push(a); continue; }
|
|
51
62
|
const [key, inline] = a.slice(2).split(/=(.*)/s);
|
|
52
63
|
if (FLAGS.has(key)) { out[key] = true; continue; }
|
|
64
|
+
if (GREEDY.has(key)) {
|
|
65
|
+
const values = inline !== undefined ? [inline] : [];
|
|
66
|
+
while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) values.push(argv[++i]);
|
|
67
|
+
(out[key] ??= []).push(...values);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
53
70
|
const value = inline ?? argv[++i];
|
|
54
71
|
if (value === undefined) throw new Error(`--${key} needs a value`);
|
|
55
72
|
if (MULTI.has(key)) (out[key] ??= []).push(value);
|
|
@@ -64,11 +81,74 @@ function homeOf(args) {
|
|
|
64
81
|
return resolve(root);
|
|
65
82
|
}
|
|
66
83
|
|
|
84
|
+
const SLUG = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
85
|
+
|
|
86
|
+
function normalSlug(slug) {
|
|
87
|
+
const s = String(slug ?? '').trim().toLowerCase();
|
|
88
|
+
if (!SLUG.test(s)) throw new Error(`invalid board slug ${slug}: 1-64 lowercase letters, digits, - or _, not starting with - or _`);
|
|
89
|
+
return s;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const currentFile = (root) => join(root, 'kanban', 'current');
|
|
93
|
+
|
|
94
|
+
/** The board a verb acts on, as Hermes resolves it. */
|
|
95
|
+
function boardOf(root, args) {
|
|
96
|
+
for (const want of [args.board, process.env.HERMES_KANBAN_BOARD, existsSync(currentFile(root)) ? readFileSync(currentFile(root), 'utf8').trim() : null]) {
|
|
97
|
+
if (!want) continue;
|
|
98
|
+
const slug = normalSlug(want);
|
|
99
|
+
if (slug === DEFAULT_BOARD || existsSync(boardPath(root, slug))) return slug;
|
|
100
|
+
if (want === args.board) return slug;
|
|
101
|
+
}
|
|
102
|
+
return DEFAULT_BOARD;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const boardDir = (root, slug) => join(root, 'kanban', 'boards', slug);
|
|
106
|
+
const metaPath = (root, slug) => join(boardDir(root, slug), 'board.json');
|
|
107
|
+
|
|
108
|
+
function boardMeta(root, slug) {
|
|
109
|
+
const meta = { slug, name: slug === DEFAULT_BOARD ? 'Default' : slug, description: '', icon: '', color: '', default_workdir: null, project_id: null, created_at: null, archived: false };
|
|
110
|
+
try { Object.assign(meta, JSON.parse(readFileSync(metaPath(root, slug), 'utf8')), { slug }); } catch { /* no metadata yet */ }
|
|
111
|
+
return { ...meta, db_path: boardPath(root, slug) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function writeMeta(root, slug, patch) {
|
|
115
|
+
const { db_path: _, ...meta } = boardMeta(root, slug);
|
|
116
|
+
for (const [k, v] of Object.entries(patch)) if (v !== undefined) meta[k] = v;
|
|
117
|
+
meta.created_at ??= now();
|
|
118
|
+
mkdirSync(boardDir(root, slug), { recursive: true });
|
|
119
|
+
writeFileSync(metaPath(root, slug), `${JSON.stringify(meta, null, 2)}\n`);
|
|
120
|
+
return { ...meta, db_path: boardPath(root, slug) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Hermes's list: the default board and every named one with a database or metadata. */
|
|
124
|
+
function boardsOf(root, { includeArchived = true } = {}) {
|
|
125
|
+
const out = [boardMeta(root, DEFAULT_BOARD)];
|
|
126
|
+
const dir = join(root, 'kanban', 'boards');
|
|
127
|
+
if (existsSync(dir)) {
|
|
128
|
+
for (const slug of readdirSync(dir).sort()) {
|
|
129
|
+
if (slug === DEFAULT_BOARD || !SLUG.test(slug)) continue;
|
|
130
|
+
if (!existsSync(boardPath(root, slug)) && !existsSync(metaPath(root, slug))) continue;
|
|
131
|
+
const meta = boardMeta(root, slug);
|
|
132
|
+
if (meta.archived && !includeArchived) continue;
|
|
133
|
+
out.push(meta);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function counts(root, slug) {
|
|
140
|
+
if (!existsSync(boardPath(root, slug))) return {};
|
|
141
|
+
const b = openBoard(root, slug);
|
|
142
|
+
try {
|
|
143
|
+
return Object.fromEntries(b.db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status').all().map((r) => [r.status, Number(r.n)]));
|
|
144
|
+
} finally { b.close(); }
|
|
145
|
+
}
|
|
146
|
+
|
|
67
147
|
/** The home's lanes (profiles' workers) and board settings (the root config's `kanban:` block), as the IR reads them. */
|
|
68
148
|
async function laneState(root) {
|
|
69
149
|
const orchestration = await read(root);
|
|
70
150
|
const kanban = orchestration.profiles?.default?.residue?.config?.kanban ?? {};
|
|
71
|
-
return { lanes: lanesOf(orchestration.profiles), settings: boardSettings(kanban), kanban };
|
|
151
|
+
return { lanes: lanesOf(orchestration.profiles), settings: boardSettings(kanban), kanban, profiles: Object.keys(orchestration.profiles ?? {}) };
|
|
72
152
|
}
|
|
73
153
|
|
|
74
154
|
/** The caller's own supercode address, when it is a session supercode can reach (for its card's notices). */
|
|
@@ -79,22 +159,56 @@ function callerAddress() {
|
|
|
79
159
|
return [...fleet.alive].find((a) => a.endsWith(`:${id}`)) ?? null;
|
|
80
160
|
}
|
|
81
161
|
|
|
82
|
-
const
|
|
162
|
+
const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
|
|
83
163
|
|
|
84
|
-
|
|
85
|
-
|
|
164
|
+
const say = (args, human, value) => { process.stdout.write(args.json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`); };
|
|
165
|
+
|
|
166
|
+
// Hermes's own output lines, so a reader of `hermes kanban list` reads this one
|
|
167
|
+
const ICONS = { todo: '◻', ready: '▶', running: '●', scheduled: '⏱', blocked: '⊘', done: '✓', archived: '—' };
|
|
168
|
+
const ts = (t) => {
|
|
169
|
+
if (!t) return '';
|
|
170
|
+
const d = new Date(Number(t) * 1000);
|
|
171
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
172
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
173
|
+
};
|
|
174
|
+
const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${t.status.padEnd(8)} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
|
|
175
|
+
const cardDict = (t) => ({
|
|
176
|
+
id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
|
|
177
|
+
workspace_kind: t.workspace_kind, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
|
|
178
|
+
created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
|
|
179
|
+
skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
|
|
180
|
+
session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
|
|
181
|
+
machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
|
|
185
|
+
function duration(v) {
|
|
186
|
+
if (v === undefined || v === null || v === '') return null;
|
|
187
|
+
const s = String(v).trim().toLowerCase();
|
|
188
|
+
if (/^\d+$/.test(s)) return Number(s);
|
|
189
|
+
const m = /^(\d+(?:\.\d+)?)([smhd])$/.exec(s);
|
|
190
|
+
if (!m) throw new Error(`malformed duration ${v} (expected 30s, 5m, 2h, 1d, or a number)`);
|
|
191
|
+
return Math.floor(Number(m[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[m[2]]);
|
|
86
192
|
}
|
|
87
193
|
|
|
194
|
+
const metadataOf = (v) => {
|
|
195
|
+
if (v === undefined) return undefined;
|
|
196
|
+
const got = json(v, undefined);
|
|
197
|
+
if (got === undefined || typeof got !== 'object') throw new Error('--metadata is a JSON object');
|
|
198
|
+
return got;
|
|
199
|
+
};
|
|
200
|
+
|
|
88
201
|
export async function runWorkflow(argv) {
|
|
89
202
|
const args = parse(argv);
|
|
90
203
|
const [verb, ...rest] = args._;
|
|
91
204
|
if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
|
|
92
205
|
const root = homeOf(args);
|
|
93
|
-
const slug = args
|
|
206
|
+
const slug = boardOf(root, args);
|
|
94
207
|
const open = (create = false) => { const b = openBoard(root, slug, { create }); b.label = boardLabel(root, slug); return b; };
|
|
95
208
|
const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
|
|
96
209
|
const id = rest[0];
|
|
97
210
|
const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
|
|
211
|
+
const ids = (first = rest) => [...first, ...(args.ids ?? [])];
|
|
98
212
|
|
|
99
213
|
// a caller that is a card's own session (its harness names it; the board recorded which session it launched)
|
|
100
214
|
const sid = process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID;
|
|
@@ -103,30 +217,30 @@ export async function runWorkflow(argv) {
|
|
|
103
217
|
}
|
|
104
218
|
|
|
105
219
|
switch (verb) {
|
|
106
|
-
case '
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
say(args,
|
|
110
|
-
return 0;
|
|
111
|
-
}
|
|
112
|
-
case 'attach': {
|
|
113
|
-
need(2, 'a card id and a session');
|
|
114
|
-
const { settings } = await laneState(root);
|
|
115
|
-
const got = withBoard((b) => attachSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl }));
|
|
116
|
-
say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
|
|
220
|
+
case 'init': {
|
|
221
|
+
withBoard(() => {}, true);
|
|
222
|
+
const { profiles } = await laneState(root).catch(() => ({ profiles: [] }));
|
|
223
|
+
say(args, `Kanban DB initialized at ${boardPath(root, slug)}\n\n${profiles.length ? `Profiles of this home (any can be an --assignee): ${profiles.join(', ')}` : 'No profiles in this home.'}\nThe dispatcher: \`supercode workflow serve\`, or the orchestrator while it serves the home.`, { ok: true, board: slug, path: boardPath(root, slug), profiles });
|
|
117
224
|
return 0;
|
|
118
225
|
}
|
|
119
|
-
|
|
226
|
+
|
|
227
|
+
case 'boards': return boardsVerb(root, rest, args);
|
|
228
|
+
|
|
120
229
|
case 'create': {
|
|
121
230
|
need(1, 'a title');
|
|
122
231
|
const { lanes, settings } = await laneState(root);
|
|
232
|
+
const maxRetries = args['max-retries'] !== undefined ? Number(args['max-retries']) : null;
|
|
233
|
+
if (maxRetries !== null && !(maxRetries >= 1)) throw new Error(`--max-retries must be >= 1 (got ${args['max-retries']}); use 1 to trip on the first failure`);
|
|
234
|
+
const meta = boardMeta(root, slug);
|
|
235
|
+
let workspace = args.workspace;
|
|
236
|
+
if ((workspace === 'dir' || workspace === 'worktree') && meta.default_workdir) workspace = `${workspace}:${meta.default_workdir}`;
|
|
123
237
|
const created = withBoard((b) => engine.createCard(b, {
|
|
124
238
|
title: rest.join(' '), body: args.body, assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
|
|
125
|
-
workspace
|
|
126
|
-
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime:
|
|
127
|
-
maxRetries: args
|
|
128
|
-
|
|
129
|
-
|
|
239
|
+
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
240
|
+
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
|
|
241
|
+
maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
|
|
242
|
+
sessionId: process.env.CLAUDE_CODE_SESSION_ID ?? null, subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
|
|
243
|
+
project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : null,
|
|
130
244
|
}), true);
|
|
131
245
|
let answer = { id: created.id, existing: created.existing };
|
|
132
246
|
if (!args['no-start'] && !created.existing && !args['subtask-of']) {
|
|
@@ -139,115 +253,676 @@ export async function runWorkflow(argv) {
|
|
|
139
253
|
// the creator hears what happens from here on; a start answered above is not told twice
|
|
140
254
|
const address = args['no-notify'] ? null : args.notify ?? callerAddress();
|
|
141
255
|
if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator'));
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
: answer.waits ? `${created.id} waits: ${answer.waits}`
|
|
147
|
-
: `${created.existing ? 'existing' : 'created'} ${created.id} (${status.status})`;
|
|
148
|
-
say(args, `${line}${address ? `; notices go to ${address}` : ''}`, answer);
|
|
256
|
+
const task = withBoard((b) => tx(b, (db) => engine.getTask(db, created.id)));
|
|
257
|
+
if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null }); return answer.failed ? 1 : 0; }
|
|
258
|
+
const said = answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
|
|
259
|
+
say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${address ? `; notices go to ${address}` : ''}`, null);
|
|
149
260
|
return answer.failed ? 1 : 0;
|
|
150
261
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
262
|
+
|
|
263
|
+
case 'swarm': {
|
|
264
|
+
need(1, 'a goal');
|
|
265
|
+
const workers = (args.worker ?? []).map((w) => {
|
|
266
|
+
const [profile, title, skills] = w.split(':');
|
|
267
|
+
if (!profile || !title) throw new Error(`--worker is PROFILE:TITLE[:SKILL,SKILL], not ${w}`);
|
|
268
|
+
return { profile, title, skills: skills ? skills.split(',').map((x) => x.trim()).filter(Boolean) : [] };
|
|
269
|
+
});
|
|
270
|
+
if (!args.verifier || !args.synthesizer) throw new Error('a swarm names its --verifier and --synthesizer');
|
|
271
|
+
const made = withBoard((b) => engine.swarm(b, { goal: rest.join(' '), workers, verifier: args.verifier, synthesizer: args.synthesizer, tenant: args.tenant ?? null, createdBy: args['created-by'] ?? callerName(), priority: Number(args.priority) || 0, idempotencyKey: args['idempotency-key'] ?? null }), true);
|
|
272
|
+
say(args, `Swarm root: ${made.root_id}\nWorkers: ${(made.worker_ids ?? []).join(', ')}\nVerifier: ${made.verifier_id}\nSynthesizer: ${made.synthesizer_id}`, made);
|
|
154
273
|
return 0;
|
|
155
274
|
}
|
|
275
|
+
|
|
276
|
+
case 'list': case 'ls': {
|
|
277
|
+
const assignee = args.assignee ?? (args.mine ? callerName() : null);
|
|
278
|
+
const cards = withBoard((b) => {
|
|
279
|
+
engine.recomputeReady(b);
|
|
280
|
+
let rows = engine.listCards(b.db, { status: args.status, assignee, machine: args.machine, includeArchived: args.archived || args.all });
|
|
281
|
+
if (args.tenant) rows = rows.filter((t) => t.tenant === args.tenant);
|
|
282
|
+
if (args.session) rows = rows.filter((t) => t.session_id === args.session);
|
|
283
|
+
if (args['workflow-template-id']) rows = rows.filter((t) => t.workflow_template_id === args['workflow-template-id']);
|
|
284
|
+
if (args['step-key']) rows = rows.filter((t) => t.current_step_key === args['step-key']);
|
|
285
|
+
return rows;
|
|
286
|
+
});
|
|
287
|
+
const sorters = {
|
|
288
|
+
assignee: (a, b) => String(a.assignee ?? '').localeCompare(String(b.assignee ?? '')), created: (a, b) => a.created_at - b.created_at,
|
|
289
|
+
'created-desc': (a, b) => b.created_at - a.created_at, priority: (a, b) => a.priority - b.priority, 'priority-desc': (a, b) => b.priority - a.priority,
|
|
290
|
+
status: (a, b) => a.status.localeCompare(b.status), title: (a, b) => a.title.localeCompare(b.title),
|
|
291
|
+
updated: (a, b) => (b.completed_at ?? b.started_at ?? b.created_at) - (a.completed_at ?? a.started_at ?? a.created_at),
|
|
292
|
+
};
|
|
293
|
+
if (args.sort) { if (!sorters[args.sort]) throw new Error(`--sort is one of ${Object.keys(sorters).join(', ')}`); cards.sort(sorters[args.sort]); }
|
|
294
|
+
if (args.json) { say(args, '', cards.map(cardDict)); return 0; }
|
|
295
|
+
const others = boardsOf(root, { includeArchived: false }).length - 1;
|
|
296
|
+
if (others > 0) process.stdout.write(`Board: ${slug} (${others} other board${others === 1 ? '' : 's'} — \`supercode workflow boards list\`)\n\n`);
|
|
297
|
+
process.stdout.write(cards.length ? `${cards.map(line).join('\n')}\n` : '(no matching tasks)\n');
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
156
301
|
case 'show': {
|
|
157
302
|
need(1, 'a card id');
|
|
158
|
-
const
|
|
159
|
-
|
|
303
|
+
const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
|
|
304
|
+
const card = withBoard((b) => {
|
|
305
|
+
const got = engine.showCard(b.db, id);
|
|
306
|
+
got.runs = engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null });
|
|
307
|
+
got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit });
|
|
308
|
+
return got;
|
|
309
|
+
});
|
|
160
310
|
const t = card.task;
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
311
|
+
const latest = [...card.runs].reverse().find((r) => r.summary)?.summary ?? null;
|
|
312
|
+
if (args.json) {
|
|
313
|
+
say(args, '', { task: cardDict(t), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
const out = [`Task ${t.id}: ${t.title}`, ` status: ${t.status}`, ` assignee: ${t.assignee || '-'}`];
|
|
317
|
+
if (t.machine) out.push(` machine: ${t.machine}`);
|
|
318
|
+
if (t.subtask_of) out.push(` subtask of: ${t.subtask_of}`);
|
|
319
|
+
if (t.tenant) out.push(` tenant: ${t.tenant}`);
|
|
320
|
+
out.push(` workspace: ${t.workspace_kind}${t.workspace_path ? ` @ ${t.workspace_path}` : ''}`);
|
|
321
|
+
if (t.branch_name) out.push(` branch: ${t.branch_name}`);
|
|
322
|
+
if (t.skills?.length) out.push(` skills: ${t.skills.join(', ')}`);
|
|
323
|
+
if (t.model_override) out.push(` model: ${t.model_override}${t.provider_override ? ` (provider: ${t.provider_override})` : ''}`);
|
|
324
|
+
out.push(` max-retries: ${t.max_retries ?? settings.failureLimit} (${t.max_retries !== null ? 'task' : 'board'})`);
|
|
325
|
+
out.push(` created: ${ts(t.created_at)} by ${t.created_by || '-'}`);
|
|
326
|
+
if (card.diagnostics.length) {
|
|
327
|
+
out.push('', ` Diagnostics (${card.diagnostics.length}):`);
|
|
328
|
+
for (const d of card.diagnostics) { out.push(` ${{ warning: '⚠', error: '!!', critical: '!!!' }[d.severity]} [${d.severity}] ${d.title}`); for (const a of d.actions.slice(0, 1)) out.push(` → ${a}`); }
|
|
329
|
+
}
|
|
330
|
+
if (t.started_at) out.push(` started: ${ts(t.started_at)}`);
|
|
331
|
+
if (t.completed_at) out.push(` completed: ${ts(t.completed_at)}`);
|
|
332
|
+
if (card.parents.length) out.push(` parents: ${card.parents.map((p) => p.id).join(', ')}`);
|
|
333
|
+
if (card.children.length) out.push(` children: ${card.children.join(', ')}`);
|
|
334
|
+
if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${s.id} (${s.status})`).join(', ')}`);
|
|
335
|
+
if (t.body) out.push('', 'Body:', t.body);
|
|
336
|
+
if (t.result) out.push('', 'Result:', t.result);
|
|
337
|
+
else if (latest) out.push('', 'Latest summary:', latest);
|
|
338
|
+
if (card.comments.length) { out.push('', `Comments (${card.comments.length}):`); for (const c of card.comments) out.push(` [${ts(c.created_at)}] ${c.author}: ${c.body}`); }
|
|
339
|
+
if (card.events.length) {
|
|
340
|
+
out.push('', `Events (${card.events.length}):`);
|
|
341
|
+
for (const e of card.events.slice(-20)) out.push(` [${ts(e.created_at)}]${e.run_id ? ` [run ${e.run_id}]` : ''} ${e.kind}${e.payload ? ` ${JSON.stringify(e.payload)}` : ''}`);
|
|
170
342
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
343
|
+
if (card.runs.length) {
|
|
344
|
+
out.push('', `Runs (${card.runs.length}):`);
|
|
345
|
+
for (const r of card.runs) {
|
|
346
|
+
const s = r.metadata?.supercode;
|
|
347
|
+
out.push(` #${String(r.id).padEnd(3)} ${(r.outcome || r.status || 'active').padEnd(12)} @${r.profile || '-'} ${r.ended_at ? `${Math.max(0, r.ended_at - r.started_at)}s` : 'active'} ${ts(r.started_at)}`);
|
|
348
|
+
if (s) out.push(` ⇢ ${s.session_id ? `session ${s.session_id}${s.pane ? ` in pane ${s.pane}` : ''}` : `worker pid ${s.pid}`} on ${s.machine} (${s.mode})`);
|
|
349
|
+
if (r.summary) out.push(` → ${r.summary.split('\n')[0].slice(0, 160)}`);
|
|
350
|
+
if (r.error) out.push(` ! ${r.error.split('\n')[0].slice(0, 160)}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
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})`); }
|
|
354
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
174
355
|
return 0;
|
|
175
356
|
}
|
|
357
|
+
|
|
176
358
|
case 'context': need(1, 'a card id'); process.stdout.write(`${withBoard((b) => tx(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
|
|
359
|
+
|
|
360
|
+
case 'runs': {
|
|
361
|
+
need(1, 'a card id');
|
|
362
|
+
const got = withBoard((b) => engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null }));
|
|
363
|
+
if (args.json) { say(args, '', got); return 0; }
|
|
364
|
+
process.stdout.write(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.summary ? `\n → ${r.summary.split('\n')[0].slice(0, 160)}` : ''}`).join('\n')}\n` : `(no runs for ${id})\n`);
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case 'assign': need(2, 'a card id and a profile (or none)'); withBoard((b) => engine.assign(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
|
|
369
|
+
case 'reassign': {
|
|
370
|
+
need(2, 'a card id and a profile');
|
|
371
|
+
withBoard((b) => {
|
|
372
|
+
const task = tx(b, (db) => engine.getTask(db, id));
|
|
373
|
+
if (task?.status === 'running' && args.reclaim) engine.reclaim(b, id, args.reason ?? 'reassign');
|
|
374
|
+
engine.assign(b, id, rest[1] === 'none' ? null : rest[1]);
|
|
375
|
+
});
|
|
376
|
+
say(args, `Reassigned ${id} to ${rest[1]}`, { ok: true }); return 0;
|
|
377
|
+
}
|
|
378
|
+
case 'set-model': need(1, 'a card id'); withBoard((b) => engine.setModel(b, id, rest[1] ?? null, args.provider ?? null)); say(args, rest[1] ? `${id} runs on model ${rest[1]}${args.provider ? ` (${args.provider})` : ''} from its next start` : `${id} uses its profile's model`, { ok: true }); return 0;
|
|
379
|
+
case 'move': need(2, 'a card id and a machine (or none)'); withBoard((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
|
|
380
|
+
|
|
381
|
+
case 'claim': {
|
|
382
|
+
need(1, 'a card id');
|
|
383
|
+
const ttl = args.ttl ? duration(args.ttl) : engine.claimTtl();
|
|
384
|
+
const runId = withBoard((b) => engine.claim(b, id, { ttl }));
|
|
385
|
+
if (!runId) throw new Error(`cannot claim ${id} (not ready, already claimed, or waiting on parents)`);
|
|
386
|
+
const task = withBoard((b) => tx(b, (db) => engine.getTask(db, id)));
|
|
387
|
+
// Hermes's claim resolves the workspace and prints it; a scratch one is made under the board's workspaces
|
|
388
|
+
let workspace = task.workspace_path;
|
|
389
|
+
if (!workspace && task.workspace_kind === 'scratch') {
|
|
390
|
+
workspace = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'workspaces') : join(boardDir(root, slug), 'workspaces'), id);
|
|
391
|
+
mkdirSync(workspace, { recursive: true });
|
|
392
|
+
withBoard((b) => tx(b, (db) => db.prepare('UPDATE tasks SET workspace_path = ? WHERE id = ?').run(workspace, id)));
|
|
393
|
+
}
|
|
394
|
+
say(args, workspace ?? `(claimed ${id}; its ${task.workspace_kind} workspace is made where it starts)`, { ok: true, run_id: runId, workspace });
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
case 'reclaim': need(1, 'a card id'); withBoard((b) => engine.reclaim(b, id, args.reason ?? (rest.slice(1).join(' ') || null))); say(args, `Reclaimed ${id}`, { ok: true }); return 0;
|
|
398
|
+
case 'heartbeat': need(1, 'a card id'); withBoard((b) => engine.heartbeat(b, id, args.note ?? null)); say(args, `Heartbeat recorded for ${id}`, { ok: true }); return 0;
|
|
399
|
+
|
|
400
|
+
case 'link': need(2, 'a parent and a child'); withBoard((b) => engine.link(b, rest[0], rest[1])); say(args, `Linked ${rest[0]} -> ${rest[1]}`, { ok: true }); return 0;
|
|
401
|
+
case 'unlink': { need(2, 'a parent and a child'); const gone = withBoard((b) => engine.unlink(b, rest[0], rest[1])); say(args, gone ? `Unlinked ${rest[0]} -> ${rest[1]}` : `no link ${rest[0]} -> ${rest[1]}`, { ok: gone }); return gone ? 0 : 1; }
|
|
402
|
+
|
|
177
403
|
case 'comment': {
|
|
178
404
|
need(2, 'a card id and the text');
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
case '
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
405
|
+
let text = rest.slice(1).join(' ');
|
|
406
|
+
if (args['max-len']) text = text.slice(0, Number(args['max-len']));
|
|
407
|
+
const n = withBoard((b) => engine.comment(b, id, args.author ?? callerAddress() ?? callerName(), text));
|
|
408
|
+
say(args, `Comment added to ${id}`, { ok: true, comment: n }); return 0;
|
|
409
|
+
}
|
|
410
|
+
case 'attach': {
|
|
411
|
+
need(2, 'a card id and a file');
|
|
412
|
+
const source = resolve(rest[1]);
|
|
413
|
+
if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
|
|
414
|
+
const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
|
|
415
|
+
const dir = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
|
|
416
|
+
mkdirSync(dir, { recursive: true });
|
|
417
|
+
let target = join(dir, name);
|
|
418
|
+
for (let n = 1; existsSync(target); n += 1) target = join(dir, `${n}-${name}`);
|
|
419
|
+
copyFileSync(source, target);
|
|
420
|
+
const n = withBoard((b) => engine.addAttachment(b, id, { file: { path: target, size: statSync(target).size }, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() }));
|
|
421
|
+
say(args, `Attached ${name} to ${id} (#${n})`, { ok: true, id: n, path: target }); return 0;
|
|
422
|
+
}
|
|
423
|
+
case 'attachments': {
|
|
424
|
+
need(1, 'a card id');
|
|
425
|
+
const got = withBoard((b) => engine.attachments(b.db, id));
|
|
426
|
+
if (args.json) { say(args, '', got); return 0; }
|
|
427
|
+
process.stdout.write(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`);
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
case 'attach-rm': {
|
|
431
|
+
need(1, 'an attachment id');
|
|
432
|
+
const row = withBoard((b) => engine.removeAttachment(b, Number(id)));
|
|
433
|
+
try { unlinkSync(row.stored_path); } catch { /* already gone */ }
|
|
434
|
+
say(args, `Removed attachment #${id} (${row.filename})`, { ok: true }); return 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
case 'complete': {
|
|
438
|
+
need(1, 'a card id');
|
|
439
|
+
const metadata = metadataOf(args.metadata);
|
|
440
|
+
for (const t of rest) withBoard((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null }));
|
|
441
|
+
say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
|
|
442
|
+
}
|
|
443
|
+
case 'edit': {
|
|
444
|
+
need(1, 'a card id');
|
|
445
|
+
if (args.result === undefined) throw new Error('edit needs --result');
|
|
446
|
+
withBoard((b) => engine.editResult(b, id, { result: args.result, summary: args.summary ?? null, metadata: metadataOf(args.metadata) }));
|
|
447
|
+
say(args, `Edited ${id}`, { ok: true }); return 0;
|
|
448
|
+
}
|
|
449
|
+
case 'block': {
|
|
450
|
+
need(1, 'a card id');
|
|
451
|
+
const reason = rest.slice(1).join(' ') || null;
|
|
452
|
+
const results = [id, ...(args.ids ?? [])].map((t) => [t, withBoard((b) => engine.block(b, t, { reason, kind: args.kind ?? null }))]);
|
|
453
|
+
say(args, results.map(([t, s]) => `${s === 'todo' ? 'Waiting on dependencies' : s === 'triage' ? 'Routed to triage' : 'Blocked'} ${t}`).join('\n'), { ok: true, results }); return 0;
|
|
454
|
+
}
|
|
455
|
+
case 'schedule': {
|
|
456
|
+
need(1, 'a card id');
|
|
457
|
+
const reason = rest.slice(1).join(' ') || null;
|
|
458
|
+
for (const t of [id, ...(args.ids ?? [])]) withBoard((b) => engine.schedule(b, t, reason));
|
|
459
|
+
say(args, [id, ...(args.ids ?? [])].map((t) => `Scheduled ${t}`).join('\n'), { ok: true }); return 0;
|
|
460
|
+
}
|
|
461
|
+
case 'unblock': {
|
|
462
|
+
need(1, 'a card id');
|
|
463
|
+
const results = ids().map((t) => [t, withBoard((b) => engine.unblock(b, t))]);
|
|
464
|
+
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
465
|
+
}
|
|
466
|
+
case 'request-review': need(1, 'a card id'); withBoard((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null })); say(args, `${id} → review`, { ok: true }); return 0;
|
|
467
|
+
case 'request-changes': need(2, 'a card id and the reason'); withBoard((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? callerName() })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
|
|
468
|
+
case 'reopen-review': {
|
|
196
469
|
need(1, 'a card id');
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
withBoard((b) => engine.subscribe(b, id, address, args.role ?? 'creator'));
|
|
200
|
-
say(args, `${address} hears ${id}`, { ok: true, address }); return 0;
|
|
470
|
+
const results = ids().map((t) => [t, withBoard((b) => engine.reopenReview(b, t))]);
|
|
471
|
+
say(args, results.map(([t, s]) => `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
201
472
|
}
|
|
202
|
-
case '
|
|
473
|
+
case 'promote': {
|
|
203
474
|
need(1, 'a card id');
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
475
|
+
const reason = rest.slice(1).join(' ') || null;
|
|
476
|
+
const targets = [id, ...(args.ids ?? [])];
|
|
477
|
+
if (args['dry-run']) {
|
|
478
|
+
const verdicts = withBoard((b) => targets.map((t) => {
|
|
479
|
+
const card = engine.showCard(b.db, t);
|
|
480
|
+
const open = card.parents.filter((p) => !['done', 'archived'].includes(p.status)).map((p) => p.id);
|
|
481
|
+
const ok = ['todo', 'blocked'].includes(card.task.status) && (!open.length || args.force);
|
|
482
|
+
return { id: t, ok, reason: ok ? null : !['todo', 'blocked'].includes(card.task.status) ? `${t} is ${card.task.status}` : `parents not done: ${open.join(', ')}` };
|
|
483
|
+
}));
|
|
484
|
+
say(args, verdicts.map((v) => `${v.ok ? 'would promote' : 'would refuse'} ${v.id}${v.reason ? `: ${v.reason}` : ''}`).join('\n'), verdicts);
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
for (const t of targets) withBoard((b) => engine.promote(b, t, { actor: callerName(), reason, force: args.force }));
|
|
488
|
+
say(args, targets.map((t) => `Promoted ${t} → ready`).join('\n'), { ok: true }); return 0;
|
|
489
|
+
}
|
|
490
|
+
case 'archive': {
|
|
491
|
+
for (const t of rest) withBoard((b) => engine.archive(b, t));
|
|
492
|
+
const purged = [];
|
|
493
|
+
for (const t of args.rm ?? []) {
|
|
494
|
+
withBoard((b) => tx(b, (db) => {
|
|
495
|
+
const task = engine.getTask(db, t);
|
|
496
|
+
if (!task) throw new Error(`no card ${t}`);
|
|
497
|
+
if (task.status !== 'archived') throw new Error(`${t} is ${task.status}; only an archived card is deleted`);
|
|
498
|
+
for (const table of ['task_links']) db.prepare(`DELETE FROM ${table} WHERE parent_id = ? OR child_id = ?`).run(t, t);
|
|
499
|
+
for (const table of ['task_comments', 'task_events', 'task_runs', 'task_attachments', 'kanban_notify_subs', 'supercode_cards']) db.prepare(`DELETE FROM ${table} WHERE task_id = ?`).run(t);
|
|
500
|
+
db.prepare('DELETE FROM tasks WHERE id = ?').run(t);
|
|
501
|
+
}));
|
|
502
|
+
purged.push(t);
|
|
503
|
+
}
|
|
504
|
+
say(args, [...rest.map((t) => `Archived ${t}`), ...purged.map((t) => `Deleted ${t}`)].join('\n'), { ok: true, archived: rest, deleted: purged }); return 0;
|
|
207
505
|
}
|
|
506
|
+
|
|
507
|
+
case 'specify': case 'decompose': {
|
|
508
|
+
const { lanes, settings } = await laneState(root);
|
|
509
|
+
const targets = args.all ? withBoard((b) => engine.listCards(b.db, { status: 'triage' }).filter((t) => !args.tenant || t.tenant === args.tenant).map((t) => t.id)) : [id];
|
|
510
|
+
if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
|
|
511
|
+
const results = [];
|
|
512
|
+
for (const t of targets) {
|
|
513
|
+
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined)) {
|
|
514
|
+
withBoard((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, author: args.author ?? callerName() }));
|
|
515
|
+
results.push({ id: t, specified: true });
|
|
516
|
+
} else if (verb === 'decompose' && args.child?.length) {
|
|
517
|
+
const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
|
|
518
|
+
results.push({ id: t, children: withBoard((b) => engine.decompose(b, t, children, { author: args.author ?? callerName() })) });
|
|
519
|
+
} else {
|
|
520
|
+
const session = withBoard((b) => startTriageSession(b, t, lanes, settings, verb));
|
|
521
|
+
results.push({ id: t, session: session.address, mode: session.mode });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
say(args, results.map((r) => (r.specified ? `Specified ${r.id} → todo` : r.children ? `Decomposed ${r.id} into ${r.children.join(', ')}` : `${r.id}: session ${r.session} (${r.mode}) is writing its ${verb === 'specify' ? 'spec' : 'breakdown'}`)).join('\n'), results);
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
case 'diagnostics': case 'diag': {
|
|
529
|
+
const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
|
|
530
|
+
const fleet = observeFleet();
|
|
531
|
+
const order = ['warning', 'error', 'critical'];
|
|
532
|
+
const found = withBoard((b) => {
|
|
533
|
+
const cards = args.task ? [engine.getTask(b.db, args.task)].filter(Boolean) : engine.listCards(b.db, {});
|
|
534
|
+
return cards.flatMap((t) => engine.diagnose(b.db, t, { failureLimit: settings.failureLimit, fleet }).map((d) => ({ task: t.id, title_of_task: t.title, ...d })))
|
|
535
|
+
.filter((d) => !args.severity || order.indexOf(d.severity) >= order.indexOf(args.severity));
|
|
536
|
+
});
|
|
537
|
+
if (args.json) { say(args, '', found); return 0; }
|
|
538
|
+
process.stdout.write(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');
|
|
539
|
+
return 0;
|
|
540
|
+
}
|
|
541
|
+
case 'stats': {
|
|
542
|
+
const s = withBoard((b) => engine.stats(b.db));
|
|
543
|
+
if (args.json) { say(args, '', s); return 0; }
|
|
544
|
+
const fmt = (o) => Object.entries(o).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(none)';
|
|
545
|
+
process.stdout.write(`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`);
|
|
546
|
+
return 0;
|
|
547
|
+
}
|
|
548
|
+
case 'assignees': {
|
|
549
|
+
const { profiles, lanes } = await laneState(root);
|
|
550
|
+
const c = withBoard((b) => engine.stats(b.db).by_assignee);
|
|
551
|
+
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] ?? {} }));
|
|
552
|
+
if (args.json) { say(args, '', data); return 0; }
|
|
553
|
+
process.stdout.write(`${'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`);
|
|
554
|
+
return 0;
|
|
555
|
+
}
|
|
556
|
+
case 'log': {
|
|
557
|
+
need(1, 'a card id');
|
|
558
|
+
const file = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs'), `${id}.log`);
|
|
559
|
+
if (!existsSync(file)) {
|
|
560
|
+
// a pane session keeps no log file: its transcript is the harness's own, named by the run
|
|
561
|
+
const s = withBoard((b) => tx(b, (db) => engine.lastSession(db, id)));
|
|
562
|
+
process.stdout.write(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`);
|
|
563
|
+
return 0;
|
|
564
|
+
}
|
|
565
|
+
const text = readFileSync(file, 'utf8');
|
|
566
|
+
const tail = args.tail ? Number(args.tail) : null;
|
|
567
|
+
process.stdout.write(tail ? text.slice(-tail) : text);
|
|
568
|
+
return 0;
|
|
569
|
+
}
|
|
570
|
+
case 'tail': case 'watch': {
|
|
571
|
+
const interval = Math.max(Number(args.interval) || (verb === 'tail' ? 1 : 0.5), 0.2) * 1000;
|
|
572
|
+
const kinds = args.kinds ? new Set(String(args.kinds).split(',').map((k) => k.trim())) : null;
|
|
573
|
+
if (verb === 'tail') need(1, 'a card id');
|
|
574
|
+
let cursor = withBoard((b) => Number(b.db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events').get().c));
|
|
575
|
+
if (verb === 'tail') cursor = 0;
|
|
576
|
+
let stop = false;
|
|
577
|
+
process.on('SIGINT', () => { stop = true; });
|
|
578
|
+
process.on('SIGTERM', () => { stop = true; });
|
|
579
|
+
while (!stop) {
|
|
580
|
+
const rows = withBoard((b) => b.db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at, t.assignee, t.tenant FROM task_events e LEFT JOIN tasks t ON t.id = e.task_id WHERE e.id > ?${verb === 'tail' ? ' AND e.task_id = ?' : ''} ORDER BY e.id`).all(...(verb === 'tail' ? [cursor, id] : [cursor])));
|
|
581
|
+
for (const e of rows) {
|
|
582
|
+
cursor = e.id;
|
|
583
|
+
if (kinds && !kinds.has(e.kind)) continue;
|
|
584
|
+
if (args.assignee && e.assignee !== args.assignee) continue;
|
|
585
|
+
if (args.tenant && e.tenant !== args.tenant) continue;
|
|
586
|
+
process.stdout.write(`[${ts(e.created_at)}] ${verb === 'watch' ? `${e.task_id} ` : ''}${e.kind}${e.payload ? ` ${e.payload}` : ''}\n`);
|
|
587
|
+
}
|
|
588
|
+
await new Promise((done) => setTimeout(done, interval));
|
|
589
|
+
}
|
|
590
|
+
return 0;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
case 'notify-subscribe': case 'notify-unsubscribe': {
|
|
594
|
+
need(1, 'a card id');
|
|
595
|
+
const platform = args.platform ?? 'supercode';
|
|
596
|
+
const chat = args['chat-id'] ?? args.address ?? (platform === 'supercode' ? callerAddress() : null);
|
|
597
|
+
if (!chat) throw new Error(`${verb} names the subscriber: --platform P --chat-id C (a supercode session's address with --platform supercode)`);
|
|
598
|
+
if (verb === 'notify-unsubscribe') {
|
|
599
|
+
const gone = withBoard((b) => tx(b, (db) => db.prepare('DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?').run(id, platform, chat, args['thread-id'] ?? '').changes > 0));
|
|
600
|
+
say(args, gone ? `Unsubscribed ${platform}:${chat} from ${id}` : `${platform}:${chat} was not subscribed to ${id}`, { ok: gone }); return gone ? 0 : 1;
|
|
601
|
+
}
|
|
602
|
+
withBoard((b) => tx(b, (db) => {
|
|
603
|
+
if (!engine.getTask(db, id)) throw new Error(`no card ${id}`);
|
|
604
|
+
if (platform === 'supercode') return engine.subscribeIn(db, id, chat, args.role ?? 'creator');
|
|
605
|
+
const cursor = db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(id).c;
|
|
606
|
+
db.prepare(`INSERT INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, user_id, user_id_alt, chat_type, notifier_profile, delivery_mode, created_at, last_event_id)
|
|
607
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(task_id, platform, chat_id, thread_id) DO UPDATE SET chat_type = COALESCE(excluded.chat_type, chat_type), delivery_mode = excluded.delivery_mode`)
|
|
608
|
+
.run(id, platform, chat, args['thread-id'] ?? '', args['user-id'] ?? null, args['user-id-alt'] ?? null, args['chat-type'] ?? 'dm', args['notifier-profile'] ?? null, args['delivery-mode'] ?? 'notify', now(), cursor);
|
|
609
|
+
return null;
|
|
610
|
+
}));
|
|
611
|
+
say(args, `Subscribed ${platform}:${chat} to ${id}`, { ok: true }); return 0;
|
|
612
|
+
}
|
|
613
|
+
case 'notify-list': {
|
|
614
|
+
const subs = withBoard((b) => engine.subscriptions(b.db, id ?? null));
|
|
615
|
+
if (args.json) { say(args, '', subs); return 0; }
|
|
616
|
+
process.stdout.write(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');
|
|
617
|
+
return 0;
|
|
618
|
+
}
|
|
619
|
+
|
|
208
620
|
case 'dispatch': {
|
|
209
621
|
const { lanes, settings } = await laneState(root);
|
|
622
|
+
if (args['failure-limit']) settings.failureLimit = Number(args['failure-limit']);
|
|
623
|
+
if (args.max) settings.maxInProgress = Number(args.max);
|
|
624
|
+
if (args['dry-run']) {
|
|
625
|
+
const would = withBoard((b) => engine.listCards(b.db, { status: 'ready' }).filter((t) => t.assignee && lanes[t.assignee] && !t.subtask_of).map((t) => ({ id: t.id, assignee: t.assignee, machine: t.machine, surface: lanes[t.assignee].surface })));
|
|
626
|
+
say(args, would.length ? would.map((w) => `would start ${w.id} as ${w.surface} for ${w.assignee}${w.machine ? ` on ${w.machine}` : ''}`).join('\n') : 'nothing would start', would);
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
210
629
|
const reports = tick({ root, lanes, settings });
|
|
211
630
|
if (args.json) say(args, '', reports);
|
|
212
631
|
else for (const r of reports) process.stdout.write(`${summary(r)}\n`);
|
|
213
632
|
return reports.some((r) => r.error) ? 1 : 0;
|
|
214
633
|
}
|
|
215
|
-
case 'serve': return serve(root);
|
|
634
|
+
case 'daemon': case 'serve': return serve(root);
|
|
635
|
+
|
|
636
|
+
case 'gc': {
|
|
637
|
+
const eventDays = Number(args['event-retention-days'] ?? 30);
|
|
638
|
+
const logDays = Number(args['log-retention-days'] ?? 30);
|
|
639
|
+
let removedWs = 0;
|
|
640
|
+
const scratchRoot = slug === DEFAULT_BOARD ? join(root, 'kanban', 'workspaces') : join(boardDir(root, slug), 'workspaces');
|
|
641
|
+
const archived = withBoard((b) => b.db.prepare("SELECT id, workspace_kind, workspace_path FROM tasks WHERE status = 'archived'").all());
|
|
642
|
+
for (const t of archived) {
|
|
643
|
+
if (t.workspace_kind === 'worktree' && t.workspace_path && existsSync(t.workspace_path)) {
|
|
644
|
+
// an archived card's worktree goes, as Hermes's gc removes it (the branch stays)
|
|
645
|
+
const r = spawnSync('git', ['-C', t.workspace_path, 'worktree', 'remove', '--force', t.workspace_path], { encoding: 'utf8' });
|
|
646
|
+
if (r.status === 0) removedWs += 1;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (t.workspace_kind !== 'scratch') continue;
|
|
650
|
+
const dir = resolve(t.workspace_path || join(scratchRoot, t.id));
|
|
651
|
+
if (!dir.startsWith(`${resolve(scratchRoot)}/`) || !existsSync(dir)) continue;
|
|
652
|
+
rmSync(dir, { recursive: true, force: true });
|
|
653
|
+
removedWs += 1;
|
|
654
|
+
}
|
|
655
|
+
const events = withBoard((b) => engine.gcEvents(b, eventDays * 86400));
|
|
656
|
+
let logs = 0;
|
|
657
|
+
const logDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs');
|
|
658
|
+
if (existsSync(logDir)) for (const f of readdirSync(logDir)) { const p = join(logDir, f); if (statSync(p).isFile() && statSync(p).mtimeMs < Date.now() - logDays * 86400_000) { unlinkSync(p); logs += 1; } }
|
|
659
|
+
say(args, `GC complete: ${removedWs} workspace(s), ${events} event row(s), ${logs} log file(s) removed`, { workspaces: removedWs, events, logs });
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
662
|
+
case 'repair': {
|
|
663
|
+
const path = boardPath(root, slug);
|
|
664
|
+
if (!existsSync(path)) { say(args, `No kanban DB at ${path} — nothing to repair.`, { status: 'missing', db_path: path }); return 0; }
|
|
665
|
+
const report = withBoard((b) => {
|
|
666
|
+
const check = () => b.db.prepare('PRAGMA integrity_check').all().map((r) => Object.values(r)[0]);
|
|
667
|
+
const first = check();
|
|
668
|
+
if (first.length === 1 && first[0] === 'ok') return { status: 'ok', messages: first };
|
|
669
|
+
// a copy of the damaged file first, as Hermes keeps one, then the index rebuild
|
|
670
|
+
const backup = `${path}.corrupt-${now()}`;
|
|
671
|
+
b.db.prepare('VACUUM INTO ?').run(backup);
|
|
672
|
+
b.db.exec('REINDEX');
|
|
673
|
+
const after = check();
|
|
674
|
+
return { status: after.length === 1 && after[0] === 'ok' ? 'repaired' : 'corrupt', messages: first, post_repair_messages: after, reindexed: true, backup_path: backup };
|
|
675
|
+
});
|
|
676
|
+
say(args, `${report.status}: ${path}${report.status !== 'ok' ? `\n${report.messages.join('\n')}` : ''}`, { ...report, db_path: path });
|
|
677
|
+
return report.status === 'corrupt' ? 1 : 0;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
case 'adopt': {
|
|
681
|
+
need(2, 'a card id and a session');
|
|
682
|
+
const { settings } = await laneState(root);
|
|
683
|
+
const got = withBoard((b) => adoptSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl }));
|
|
684
|
+
say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
|
|
685
|
+
return 0;
|
|
686
|
+
}
|
|
687
|
+
case 'close': {
|
|
688
|
+
need(1, 'a card id');
|
|
689
|
+
const closed = withBoard((b) => closeSession(b, id));
|
|
690
|
+
say(args, closed ? `closed ${closed.session_id} (pane ${closed.pane} on ${closed.machine})` : `${id} has no live session`, { ok: true, closed });
|
|
691
|
+
return 0;
|
|
692
|
+
}
|
|
693
|
+
|
|
216
694
|
default: throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
|
|
217
695
|
}
|
|
218
696
|
}
|
|
219
697
|
|
|
698
|
+
// ------------------------------------------------------------------ boards
|
|
699
|
+
|
|
700
|
+
async function boardsVerb(root, rest, args) {
|
|
701
|
+
const [action = 'list', target] = rest;
|
|
702
|
+
switch (action) {
|
|
703
|
+
case 'list': case 'ls': {
|
|
704
|
+
const current = boardOf(root, {});
|
|
705
|
+
const boards = boardsOf(root, { includeArchived: args.all }).map((b) => ({ ...b, is_current: b.slug === current, counts: counts(root, b.slug) }));
|
|
706
|
+
if (args.json) { say(args, '', boards); return 0; }
|
|
707
|
+
process.stdout.write(`${''.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: ${current}\n`);
|
|
708
|
+
return 0;
|
|
709
|
+
}
|
|
710
|
+
case 'create': case 'new': {
|
|
711
|
+
const slug = normalSlug(target);
|
|
712
|
+
const meta = writeMeta(root, slug, { name: args.name, description: args.description, icon: args.icon, color: args.color, default_workdir: args['default-workdir'] });
|
|
713
|
+
openBoard(root, slug, { create: true }).close();
|
|
714
|
+
if (args.switch) { mkdirSync(join(root, 'kanban'), { recursive: true }); writeFileSync(currentFile(root), `${slug}\n`); }
|
|
715
|
+
say(args, `Board '${slug}' ready.\n Display name: ${meta.name}\n DB path: ${meta.db_path}${args.switch ? `\n Switched to '${slug}'.` : ''}`, meta);
|
|
716
|
+
return 0;
|
|
717
|
+
}
|
|
718
|
+
case 'rm': case 'remove': case 'delete': {
|
|
719
|
+
const slug = normalSlug(target);
|
|
720
|
+
if (slug === DEFAULT_BOARD) throw new Error("the 'default' board cannot be removed");
|
|
721
|
+
if (!existsSync(boardDir(root, slug))) throw new Error(`board '${slug}' does not exist`);
|
|
722
|
+
if (boardOf(root, {}) === slug) rmSync(currentFile(root), { force: true });
|
|
723
|
+
if (args.delete || action === 'delete') { rmSync(boardDir(root, slug), { recursive: true, force: true }); say(args, `Board '${slug}' deleted.`, { slug, action: 'deleted' }); return 0; }
|
|
724
|
+
const archive = join(root, 'kanban', 'boards', '_archived');
|
|
725
|
+
mkdirSync(archive, { recursive: true });
|
|
726
|
+
let dest = join(archive, `${slug}-${now()}`);
|
|
727
|
+
for (let n = 1; existsSync(dest); n += 1) dest = join(archive, `${slug}-${now()}-${n}`);
|
|
728
|
+
renameSync(boardDir(root, slug), dest);
|
|
729
|
+
say(args, `Board '${slug}' archived → ${dest}`, { slug, action: 'archived', new_path: dest });
|
|
730
|
+
return 0;
|
|
731
|
+
}
|
|
732
|
+
case 'switch': case 'use': {
|
|
733
|
+
const slug = normalSlug(target);
|
|
734
|
+
if (slug !== DEFAULT_BOARD && !existsSync(boardPath(root, slug))) throw new Error(`board '${slug}' does not exist; \`supercode workflow boards create ${slug}\``);
|
|
735
|
+
mkdirSync(join(root, 'kanban'), { recursive: true });
|
|
736
|
+
writeFileSync(currentFile(root), `${slug}\n`);
|
|
737
|
+
say(args, `Active board is now '${slug}'.`, { ok: true, board: slug });
|
|
738
|
+
return 0;
|
|
739
|
+
}
|
|
740
|
+
case 'show': case 'current': {
|
|
741
|
+
const slug = boardOf(root, args);
|
|
742
|
+
const meta = boardMeta(root, slug);
|
|
743
|
+
const c = counts(root, slug);
|
|
744
|
+
say(args, `Current board: ${slug}\n Display name: ${meta.name}${meta.description ? `\n Description: ${meta.description}` : ''}\n DB path: ${meta.db_path}\n Tasks: ${Object.values(c).reduce((a, b) => a + b, 0)} total${Object.keys(c).length ? ` (${Object.entries(c).sort().map(([k, v]) => `${k}=${v}`).join(', ')})` : ''}`, { ...meta, counts: c });
|
|
745
|
+
return 0;
|
|
746
|
+
}
|
|
747
|
+
case 'rename': {
|
|
748
|
+
const slug = normalSlug(target);
|
|
749
|
+
const meta = writeMeta(root, slug, { name: rest.slice(2).join(' ') || args.name });
|
|
750
|
+
say(args, `Board '${slug}' renamed to '${meta.name}'.`, meta); return 0;
|
|
751
|
+
}
|
|
752
|
+
case 'set-default-workdir': {
|
|
753
|
+
const slug = normalSlug(target);
|
|
754
|
+
const meta = writeMeta(root, slug, { default_workdir: rest[2] || null });
|
|
755
|
+
say(args, meta.default_workdir ? `Board '${slug}' default workdir set to '${meta.default_workdir}'.` : `Board '${slug}' default workdir cleared.`, meta); return 0;
|
|
756
|
+
}
|
|
757
|
+
case 'export': {
|
|
758
|
+
const slug = normalSlug(target ?? boardOf(root, args));
|
|
759
|
+
if (!existsSync(boardPath(root, slug))) throw new Error(`board '${slug}' has no database`);
|
|
760
|
+
const out = resolve(args.output ?? `${slug}.tar.gz`);
|
|
761
|
+
const made = exportBoard(root, slug, out, { attachments: !args['no-attachments'], logs: Boolean(args['include-logs']) });
|
|
762
|
+
say(args, `Exported board '${slug}' → ${out}\n Tasks: ${made.counts.tasks}\n Comments: ${made.counts.task_comments}\n Attachments: ${made.counts.attachment_files}`, made);
|
|
763
|
+
return 0;
|
|
764
|
+
}
|
|
765
|
+
case 'import': {
|
|
766
|
+
const made = importBoard(root, resolve(target ?? ''), args.as ?? null);
|
|
767
|
+
if (args.switch) writeFileSync(currentFile(root), `${made.board}\n`);
|
|
768
|
+
say(args, `Imported board '${made.board}' (${made.name}).${made.renamed ? `\n Renamed from '${made.requested_board}' — that slug was taken.` : ''}\n Path: ${made.path}\n Tasks: ${made.counts.tasks}${made.warnings.map((w) => `\n Note: ${w}`).join('')}`, made);
|
|
769
|
+
return 0;
|
|
770
|
+
}
|
|
771
|
+
default: throw new Error(`boards ${action}: list | create | rm | switch | show | rename | set-default-workdir | export | import`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ------------------------------------------------------------------ board archives, in Hermes's format
|
|
776
|
+
|
|
777
|
+
const ARCHIVE_FORMAT = 'hermes-kanban-board';
|
|
778
|
+
const TABLES = ['tasks', 'task_links', 'task_comments', 'task_events', 'task_runs', 'task_attachments'];
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Strip what only means something on the machine that wrote it, as Hermes's transfer does: subscriptions, claims,
|
|
782
|
+
* pids, heartbeats, sessions, failure counters; a running card goes back to ready, its run is released. supercode's
|
|
783
|
+
* record of a run's session is such state too.
|
|
784
|
+
*/
|
|
785
|
+
function scrub(db) {
|
|
786
|
+
db.exec('DELETE FROM kanban_notify_subs');
|
|
787
|
+
db.exec(`UPDATE tasks SET claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, last_heartbeat_at = NULL,
|
|
788
|
+
session_id = NULL, project_id = NULL, consecutive_failures = 0, last_failure_error = NULL`);
|
|
789
|
+
db.exec("UPDATE tasks SET status = 'ready' WHERE status = 'running'");
|
|
790
|
+
db.prepare("UPDATE task_runs SET status = 'released', outcome = COALESCE(outcome, 'reclaimed'), ended_at = COALESCE(ended_at, ?), last_heartbeat_at = NULL WHERE status = 'running'").run(now());
|
|
791
|
+
db.exec('UPDATE task_runs SET claim_lock = NULL, worker_pid = NULL');
|
|
792
|
+
for (const r of db.prepare("SELECT id, metadata FROM task_runs WHERE metadata LIKE '%\"supercode\"%'").all()) {
|
|
793
|
+
const m = json(r.metadata, {}) ?? {};
|
|
794
|
+
delete m.supercode;
|
|
795
|
+
db.prepare('UPDATE task_runs SET metadata = ? WHERE id = ?').run(Object.keys(m).length ? JSON.stringify(m) : null, r.id);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const rowCounts = (db) => Object.fromEntries(TABLES.map((t) => [t, Number(db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n)]));
|
|
800
|
+
|
|
801
|
+
function copyTree(from, to) {
|
|
802
|
+
if (!existsSync(from)) return 0;
|
|
803
|
+
let n = 0;
|
|
804
|
+
for (const name of readdirSync(from)) {
|
|
805
|
+
const src = join(from, name);
|
|
806
|
+
if (statSync(src).isDirectory()) n += copyTree(src, join(to, name));
|
|
807
|
+
else { mkdirSync(to, { recursive: true }); copyFileSync(src, join(to, name)); n += 1; }
|
|
808
|
+
}
|
|
809
|
+
return n;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function exportBoard(root, slug, out, { attachments, logs }) {
|
|
813
|
+
const staging = join(tmpdir(), `board-export-${process.pid}-${now()}`);
|
|
814
|
+
const base = join(staging, slug);
|
|
815
|
+
mkdirSync(base, { recursive: true });
|
|
816
|
+
try {
|
|
817
|
+
const b = openBoard(root, slug);
|
|
818
|
+
try { b.db.prepare('VACUUM INTO ?').run(join(base, 'kanban.db')); } finally { b.close(); }
|
|
819
|
+
const snap = openBoard(base, DEFAULT_BOARD);
|
|
820
|
+
let counts;
|
|
821
|
+
try { tx(snap, (db) => scrub(db)); counts = rowCounts(snap.db); } finally { snap.close(); }
|
|
822
|
+
const { db_path: _, default_workdir: __, project_id: ___, ...meta } = boardMeta(root, slug);
|
|
823
|
+
writeFileSync(join(base, 'board.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
824
|
+
const attachmentsDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments');
|
|
825
|
+
const logsDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs');
|
|
826
|
+
const files = attachments ? copyTree(attachmentsDir, join(base, 'attachments')) : 0;
|
|
827
|
+
const logFiles = logs ? copyTree(logsDir, join(base, 'logs')) : 0;
|
|
828
|
+
const manifest = { format: ARCHIVE_FORMAT, format_version: 1, board: slug, board_name: meta.name || slug, exported_at: now(), exported_by: 'supercode',
|
|
829
|
+
includes: { attachments, logs }, counts: { ...counts, attachment_files: files, log_files: logFiles } };
|
|
830
|
+
writeFileSync(join(base, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
831
|
+
const r = spawnSync('tar', ['-czf', out, '-C', staging, slug], { encoding: 'utf8' });
|
|
832
|
+
if (r.status !== 0) throw new Error(`tar: ${r.stderr.trim()}`);
|
|
833
|
+
return { board: slug, archive: out, size: statSync(out).size, counts: manifest.counts };
|
|
834
|
+
} finally {
|
|
835
|
+
rmSync(staging, { recursive: true, force: true });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function importBoard(root, archive, as) {
|
|
840
|
+
if (!existsSync(archive)) throw new Error(`archive not found: ${archive}`);
|
|
841
|
+
const staging = join(tmpdir(), `board-import-${process.pid}-${now()}`);
|
|
842
|
+
mkdirSync(staging, { recursive: true });
|
|
843
|
+
try {
|
|
844
|
+
const r = spawnSync('tar', ['-xzf', archive, '-C', staging], { encoding: 'utf8' });
|
|
845
|
+
if (r.status !== 0) throw new Error(`tar: ${r.stderr.trim()}`);
|
|
846
|
+
const tops = readdirSync(staging).filter((n) => statSync(join(staging, n)).isDirectory());
|
|
847
|
+
if (tops.length !== 1) throw new Error('a board archive holds exactly one top-level directory');
|
|
848
|
+
const extracted = join(staging, tops[0]);
|
|
849
|
+
const manifest = json(existsSync(join(extracted, 'manifest.json')) ? readFileSync(join(extracted, 'manifest.json'), 'utf8') : null, null);
|
|
850
|
+
if (manifest?.format !== ARCHIVE_FORMAT) throw new Error('the archive is not a Kanban board export (no manifest.json of format hermes-kanban-board)');
|
|
851
|
+
if (!existsSync(join(extracted, 'kanban.db'))) throw new Error('the archive is missing kanban.db');
|
|
852
|
+
const requested = normalSlug(as ?? manifest.board ?? tops[0]);
|
|
853
|
+
let slug = requested === DEFAULT_BOARD ? `${requested}-imported` : requested;
|
|
854
|
+
for (let n = 2; existsSync(boardDir(root, slug)); n += 1) slug = `${requested}-${n}`;
|
|
855
|
+
const dest = boardDir(root, slug);
|
|
856
|
+
mkdirSync(dest, { recursive: true });
|
|
857
|
+
renameSync(join(extracted, 'kanban.db'), join(dest, 'kanban.db'));
|
|
858
|
+
for (const tree of ['attachments', 'logs']) if (existsSync(join(extracted, tree))) renameSync(join(extracted, tree), join(dest, tree));
|
|
859
|
+
const staged = json(existsSync(join(extracted, 'board.json')) ? readFileSync(join(extracted, 'board.json'), 'utf8') : null, {}) ?? {};
|
|
860
|
+
const name = staged.name || manifest.board_name || slug;
|
|
861
|
+
writeMeta(root, slug, { name, description: staged.description ?? '', icon: staged.icon ?? '', color: staged.color ?? '', archived: false });
|
|
862
|
+
const warnings = [];
|
|
863
|
+
const b = openBoard(root, slug);
|
|
864
|
+
let counts;
|
|
865
|
+
try {
|
|
866
|
+
tx(b, (db) => {
|
|
867
|
+
scrub(db);
|
|
868
|
+
let dropped = 0;
|
|
869
|
+
for (const row of db.prepare('SELECT id, task_id, stored_path FROM task_attachments').all()) {
|
|
870
|
+
const landed = join(dest, 'attachments', row.task_id, basename(row.stored_path));
|
|
871
|
+
if (existsSync(landed)) db.prepare('UPDATE task_attachments SET stored_path = ? WHERE id = ?').run(landed, row.id);
|
|
872
|
+
else { db.prepare('DELETE FROM task_attachments WHERE id = ?').run(row.id); dropped += 1; }
|
|
873
|
+
}
|
|
874
|
+
if (dropped) warnings.push(`${dropped} attachment record(s) dropped — the files were not in the archive`);
|
|
875
|
+
const parked = db.prepare("SELECT id FROM tasks WHERE workspace_kind IN ('dir', 'worktree') AND status IN ('ready', 'running', 'todo', 'scheduled')").all().map((t) => t.id);
|
|
876
|
+
db.exec('UPDATE tasks SET workspace_path = NULL, branch_name = NULL');
|
|
877
|
+
for (const t of parked) { db.prepare("UPDATE tasks SET status = 'triage' WHERE id = ?").run(t); event(db, t, 'status', { status: 'triage', reason: 'imported: its directory means nothing on this machine' }); }
|
|
878
|
+
if (parked.length) warnings.push(`${parked.length} card(s) with a directory workspace parked in triage: the path means nothing here`);
|
|
879
|
+
});
|
|
880
|
+
counts = rowCounts(b.db);
|
|
881
|
+
} finally { b.close(); }
|
|
882
|
+
return { board: slug, requested_board: requested, renamed: slug !== requested, name, path: dest, db_path: boardPath(root, slug), counts, warnings };
|
|
883
|
+
} finally {
|
|
884
|
+
rmSync(staging, { recursive: true, force: true });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// ------------------------------------------------------------------ a card's own session
|
|
889
|
+
|
|
220
890
|
/** The card whose latest session is `sessionId` (the caller works it), or null. */
|
|
221
891
|
function ownCard(db, sessionId) {
|
|
222
|
-
const hit = db.prepare(
|
|
892
|
+
const hit = db.prepare('SELECT task_id FROM task_runs WHERE metadata LIKE ? ORDER BY id DESC LIMIT 1').get(`%"session_id":"${sessionId}"%`);
|
|
223
893
|
if (!hit) return null;
|
|
224
894
|
return engine.lastSession(db, hit.task_id)?.session_id === sessionId ? engine.getTask(db, hit.task_id) : null;
|
|
225
895
|
}
|
|
226
896
|
|
|
227
|
-
const READS = new Set(['list', 'show', 'context', '
|
|
897
|
+
const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
|
|
228
898
|
|
|
229
899
|
/**
|
|
230
|
-
* What a card's own session may write (the owner's rule): on its card, comment, block with a typed reason,
|
|
231
|
-
* it
|
|
232
|
-
* another arc's card: anything across arcs goes through the
|
|
900
|
+
* What a card's own session may write (the owner's rule): on its card, comment, block with a typed reason, heartbeat,
|
|
901
|
+
* request review, attach a file, specify or decompose it in triage; on its subtasks, file, complete, block, unblock,
|
|
902
|
+
* comment and attach. Never its arc's completion, and never another arc's card: anything across arcs goes through the
|
|
903
|
+
* fleet's manager.
|
|
233
904
|
*/
|
|
234
905
|
function authorize(db, verb, rest, args, own) {
|
|
235
|
-
if (READS.has(verb)) return;
|
|
236
906
|
const refuse = (why) => { throw new Error(`the session working ${own.id} ${why}`); };
|
|
237
907
|
if (verb === 'create') {
|
|
238
908
|
if (args['subtask-of'] !== own.id) refuse(`files subtasks of its own card only (--subtask-of ${own.id})`);
|
|
239
909
|
return;
|
|
240
910
|
}
|
|
911
|
+
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
241
912
|
const card = engine.getTask(db, rest[0]);
|
|
242
913
|
if (!card) return; // the verb itself answers "no card"
|
|
243
914
|
const mine = card.id === own.id;
|
|
244
915
|
const sub = card.subtask_of === own.id;
|
|
916
|
+
// a session started from the review lane is the card's reviewer: its verdict is its to give
|
|
917
|
+
const reviewing = mine && json(db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind = 'claimed' ORDER BY id DESC LIMIT 1").get(card.id)?.payload, {})?.source_status === 'review';
|
|
918
|
+
if (reviewing && ['complete', 'request-changes'].includes(verb)) return;
|
|
245
919
|
const allowed = {
|
|
246
|
-
comment: mine || sub, block: mine || sub, review: mine || sub,
|
|
247
|
-
subscribe: mine || sub, unsubscribe: mine || sub,
|
|
920
|
+
comment: mine || sub, block: mine || sub, heartbeat: mine, 'request-review': mine || sub, attach: mine || sub,
|
|
921
|
+
specify: mine, decompose: mine, complete: sub, unblock: sub, 'notify-subscribe': mine || sub, 'notify-unsubscribe': mine || sub,
|
|
248
922
|
}[verb];
|
|
249
923
|
if (!allowed) refuse(mine ? `cannot ${verb} its own card; its reviewers and the fleet's manager do` : `writes only its own card and its subtasks, not ${card.id}`);
|
|
250
924
|
if (verb === 'block' && mine && !args.kind) refuse('blocks its card with a typed reason (--kind needs_input|capability|transient|dependency)');
|
|
925
|
+
for (const other of args.ids ?? []) if (other !== own.id && engine.getTask(db, other)?.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${other}`);
|
|
251
926
|
}
|
|
252
927
|
|
|
253
928
|
/** Close the live session of a card that is not running, freeing its machine's place. */
|
|
@@ -262,14 +937,10 @@ function closeSession(board, id) {
|
|
|
262
937
|
return session;
|
|
263
938
|
}
|
|
264
939
|
|
|
265
|
-
function callerName() {
|
|
266
|
-
return process.env.HERMES_PROFILE || process.env.USER || 'user';
|
|
267
|
-
}
|
|
268
|
-
|
|
269
940
|
export function summary(r) {
|
|
270
941
|
const parts = [`board ${r.board}:`];
|
|
271
942
|
if (r.error) parts.push(`error ${r.error}`);
|
|
272
|
-
for (const [k, label] of [['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['auto_blocked', 'gave up'], ['notified', 'notices']]) {
|
|
943
|
+
for (const [k, label] of [['mailed', 'mailed updates'], ['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['auto_blocked', 'gave up'], ['notified', 'notices']]) {
|
|
273
944
|
if (r[k]?.length) parts.push(`${label} ${r[k].length}`);
|
|
274
945
|
}
|
|
275
946
|
if (r.promoted) parts.push(`promoted ${r.promoted}`);
|
|
@@ -288,8 +959,8 @@ async function serve(root) {
|
|
|
288
959
|
const stop = () => { stopped = true; };
|
|
289
960
|
process.on('SIGTERM', stop);
|
|
290
961
|
process.on('SIGINT', stop);
|
|
291
|
-
const log = (
|
|
292
|
-
log(`dispatching the boards of ${root}`);
|
|
962
|
+
const log = (text) => process.stdout.write(`${new Date().toISOString()} ${text}\n`);
|
|
963
|
+
log(`dispatching the boards of ${root} from ${localMachine()}`);
|
|
293
964
|
try {
|
|
294
965
|
while (!stopped) {
|
|
295
966
|
let interval = 60;
|