@volter/supercode-orchestrator 0.4.148 → 0.4.150
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/ci.mjs +147 -56
- package/board/cli.mjs +68 -61
- package/board/dispatch.mjs +129 -58
- package/board/engine.mjs +69 -25
- package/board/store.mjs +6 -3
- package/board/tools.mjs +6 -3
- package/board/workflow.mjs +30 -45
- package/board/workspace.mjs +32 -0
- package/board/ztrack.mjs +802 -542
- package/cron.mjs +8 -2
- package/hermes-locks.mjs +1 -20
- package/loop.mjs +23 -2
- package/mcp/hermes-kanban-tools.json +47 -18
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
package/board/cli.mjs
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
9
|
import { basename, join, resolve } from 'node:path';
|
|
10
|
-
import { tmpdir } from 'node:os';
|
|
10
|
+
import { tmpdir, userInfo } from 'node:os';
|
|
11
11
|
import { read } from '../orchestration.mjs';
|
|
12
12
|
import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
13
13
|
import { isLocalWorkspace } from './workspace.mjs';
|
|
14
14
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
15
15
|
import * as engine from './engine.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { requestAdoption, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
|
|
17
17
|
import { watchBoard } from './wake.mjs';
|
|
18
18
|
import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
19
19
|
import { evaluate } from './expr.mjs';
|
|
@@ -29,14 +29,14 @@ Hermes Kanban's verbs, with their flags:
|
|
|
29
29
|
[--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
|
|
30
30
|
swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
31
31
|
list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> runs <id>
|
|
32
|
-
|
|
32
|
+
set <id> <assignee|machine|model|every> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
|
|
33
33
|
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
|
|
34
34
|
comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
35
35
|
complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
|
|
36
36
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
37
|
-
pause <id> --reason <why> resume <id> cancel <id>
|
|
37
|
+
pause <id> --reason <why> resume <id> cancel <id>
|
|
38
38
|
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
39
|
-
unblock <id>...
|
|
39
|
+
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
|
|
40
40
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
|
|
41
41
|
specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--all]
|
|
42
42
|
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
@@ -48,7 +48,7 @@ Hermes Kanban's verbs, with their flags:
|
|
|
48
48
|
supercode's own:
|
|
49
49
|
init --backing sqlite|files · boards create <slug> --backing sqlite|files
|
|
50
50
|
Where the board is kept: sqlite is Hermes's kanban.db (the default); files is one JSON file per card.
|
|
51
|
-
create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
|
|
51
|
+
create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
|
|
52
52
|
With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
|
|
53
53
|
waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
|
|
54
54
|
move <id> <machine|none> adopt <id> <session> [--assignee P] [--role ROLE] close <id> serve (= daemon) list --machine M
|
|
@@ -61,10 +61,9 @@ supercode's own:
|
|
|
61
61
|
create ... --adopt <session> hold dispatch until that live session is attached
|
|
62
62
|
goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
|
|
63
63
|
|
|
64
|
-
specify <id> --review-only|--no-review-only: a manager declares a standalone independent review job.
|
|
65
64
|
specify <id> --workspace dir:<repository> --branch NAME: the manager retargets a stopped card without replacing its history.
|
|
66
|
-
request-review <id> --pr URL --pr-head SHA
|
|
67
|
-
request-state <id> --state done --reviewed-sha SHA
|
|
65
|
+
request-review <id> (--pr URL --pr-head SHA)...: hand over every open PR the arc changed, each at its pushed head; one review covers them all.
|
|
66
|
+
request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
|
|
68
67
|
request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
|
|
69
68
|
complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
|
|
70
69
|
specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
|
|
@@ -72,9 +71,9 @@ supercode's own:
|
|
|
72
71
|
workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
|
|
73
72
|
`;
|
|
74
73
|
|
|
75
|
-
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
|
|
74
|
+
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
|
|
76
75
|
const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
|
|
77
|
-
'goal', 'no-goal', '
|
|
76
|
+
'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
|
|
78
77
|
// flags that take every following word up to the next flag (Hermes's nargs='+')
|
|
79
78
|
const GREEDY = new Set(['ids', 'rm']);
|
|
80
79
|
|
|
@@ -180,11 +179,11 @@ async function laneState(root) {
|
|
|
180
179
|
}
|
|
181
180
|
|
|
182
181
|
/** The caller's own supercode address, when it is a session supercode can reach (for its card's notices). */
|
|
182
|
+
// The calling session's own address, as its actor is named: no fleet scan (a `message list` costs seconds).
|
|
183
183
|
function callerAddress() {
|
|
184
184
|
const id = callerSession();
|
|
185
185
|
if (!id) return null;
|
|
186
|
-
|
|
187
|
-
return [...fleet.alive].find((a) => a.endsWith(`:${id}`)) ?? null;
|
|
186
|
+
return `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
|
|
188
187
|
}
|
|
189
188
|
|
|
190
189
|
const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
|
|
@@ -207,7 +206,7 @@ const cardDict = (t) => ({
|
|
|
207
206
|
created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
|
|
208
207
|
skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
|
|
209
208
|
session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
|
|
210
|
-
machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
|
|
209
|
+
machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
|
|
211
210
|
});
|
|
212
211
|
|
|
213
212
|
/** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
|
|
@@ -239,7 +238,6 @@ const metadataOf = (v) => {
|
|
|
239
238
|
export async function runWorkflow(argv) {
|
|
240
239
|
const args = parse(argv);
|
|
241
240
|
const [verb, ...rest] = args._;
|
|
242
|
-
if (args['review-only'] && args['no-review-only']) throw new Error('Choose review-only or no-review-only, not both.');
|
|
243
241
|
if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
|
|
244
242
|
const root = homeOf(args);
|
|
245
243
|
const slug = boardOf(root, args);
|
|
@@ -266,8 +264,8 @@ export async function runWorkflow(argv) {
|
|
|
266
264
|
if (verb === 'comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
|
|
267
265
|
return roles;
|
|
268
266
|
});
|
|
269
|
-
// a verb with no session behind it
|
|
270
|
-
if (!sid && verb !== 'help')
|
|
267
|
+
// a verb with no session behind it is recorded as the OS user's; it holds no role on any card
|
|
268
|
+
if (!sid && verb !== 'help') setActor(`user:${userInfo().username}`);
|
|
271
269
|
|
|
272
270
|
switch (verb) {
|
|
273
271
|
case 'migrate': {
|
|
@@ -282,7 +280,7 @@ export async function runWorkflow(argv) {
|
|
|
282
280
|
const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug, compatPath: paths.sqlite });
|
|
283
281
|
migrated.close();
|
|
284
282
|
} finally { source.close(); }
|
|
285
|
-
renameSync(`${preparing}.
|
|
283
|
+
renameSync(`${preparing}.board.db`, `${paths.ztrack}.board.db`);
|
|
286
284
|
renameSync(preparing, paths.ztrack);
|
|
287
285
|
say(args, `Board migrated to ${args.document}; kanban.db is now a Hermes export`, { ok: true, backing: 'ztrack', document: args.document });
|
|
288
286
|
return 0;
|
|
@@ -298,7 +296,6 @@ export async function runWorkflow(argv) {
|
|
|
298
296
|
|
|
299
297
|
case 'create': {
|
|
300
298
|
need(1, 'a title');
|
|
301
|
-
const { lanes, settings } = await laneState(root);
|
|
302
299
|
const maxRetries = args['max-retries'] !== undefined ? Number(args['max-retries']) : null;
|
|
303
300
|
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`);
|
|
304
301
|
const meta = boardMeta(root, slug);
|
|
@@ -310,29 +307,25 @@ export async function runWorkflow(argv) {
|
|
|
310
307
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
311
308
|
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
|
|
312
309
|
maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
|
|
313
|
-
noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
|
|
310
|
+
noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
|
|
314
311
|
every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
|
|
315
312
|
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,
|
|
316
313
|
}), true);
|
|
317
314
|
let answer = { id: created.id, existing: created.existing };
|
|
318
315
|
if (args.adopt) {
|
|
319
316
|
withBoard(b => { if (!rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|
|
320
|
-
|
|
321
|
-
answer = { ...answer,
|
|
322
|
-
} else if (!args['no-start'] && !created.existing && !args['subtask-of']) {
|
|
323
|
-
const [report] = await tick({ root, lanes, settings, only: created.id, slugs: [slug] });
|
|
324
|
-
const started = report.started.find(([t]) => t === created.id);
|
|
325
|
-
const queued = report.queued.find(([t]) => t === created.id);
|
|
326
|
-
const failed = report.failed.find(([t]) => t === created.id);
|
|
327
|
-
answer = { ...answer, ...(started ? { started: true, session: started[1], mode: started[2] } : {}), ...(queued ? { started: false, waits: queued[1] } : {}), ...(failed ? { started: false, failed: failed[1] } : {}) };
|
|
317
|
+
withBoard(b => requestAdoption(b, created.id, args.adopt));
|
|
318
|
+
answer = { ...answer, adoption_requested: args.adopt };
|
|
328
319
|
}
|
|
320
|
+
// Starting a card is the dispatcher's: this write's wake hint reaches it, and the card's
|
|
321
|
+
// thread records the start. A create never runs a dispatch tick inline.
|
|
329
322
|
// the creator hears what happens from here on; a start answered above is not told twice
|
|
330
323
|
const address = args['no-notify'] ? null : args.notify ?? callerAddress();
|
|
331
324
|
if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator'));
|
|
332
325
|
const task = withBoard((b) => view(b, (db) => engine.getTask(db, created.id)));
|
|
333
326
|
const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
|
|
334
327
|
if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
|
|
335
|
-
const said = answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
|
|
328
|
+
const said = answer.adoption_requested ? `; held for ${answer.adoption_requested}, which the dispatcher binds on its next tick` : answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
|
|
336
329
|
say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
|
|
337
330
|
return answer.failed ? 1 : 0;
|
|
338
331
|
}
|
|
@@ -472,17 +465,18 @@ export async function runWorkflow(argv) {
|
|
|
472
465
|
return 0;
|
|
473
466
|
}
|
|
474
467
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
});
|
|
483
|
-
say(args,
|
|
468
|
+
// One door sets a card's properties; the workflow's `set` rules say who may and what follows (a live run
|
|
469
|
+
// whose session must change is ended, and the dispatcher starts the next itself). `assign`/`reassign` are
|
|
470
|
+
// Hermes's names for setting the assignee, and `set-model` its name for setting the model.
|
|
471
|
+
case 'set': {
|
|
472
|
+
need(3, 'a card id, a property (assignee, machine, model or every) and its value (or none)');
|
|
473
|
+
const value = rest[2] === 'none' ? null : rest[2];
|
|
474
|
+
const cards = ids([id]);
|
|
475
|
+
for (const t of cards) await withBoardAsync((b) => engine.set(b, t, rest[1], value, { provider: args.provider ?? null }));
|
|
476
|
+
say(args, cards.map((t) => `${t}: ${rest[1]} ${value ?? '(none)'}`).join('\n'), { ok: true }); return 0;
|
|
484
477
|
}
|
|
485
|
-
case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.
|
|
478
|
+
case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.set(b, id, 'model', rest[1] ?? null, { provider: 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;
|
|
479
|
+
case 'assign': case 'reassign': 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;
|
|
486
480
|
case 'set-goal': {
|
|
487
481
|
// a card into goal mode (or out of it with --off) from its next idle turn: the heartbeat keeps its session going
|
|
488
482
|
need(1, 'a card id');
|
|
@@ -491,7 +485,6 @@ export async function runWorkflow(argv) {
|
|
|
491
485
|
for (const t of ids()) await withBoardAsync((b) => engine.setGoal(b, t, { on: !args.off, maxTurns }));
|
|
492
486
|
say(args, ids().map((t) => args.off ? `${t} is no longer a goal card` : `${t} is a goal card${maxTurns ? ` (${maxTurns} turns)` : ''}`).join('\n'), { ok: true }); return 0;
|
|
493
487
|
}
|
|
494
|
-
case 'move': need(2, 'a card id and a machine (or none)'); await withBoardAsync((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
|
|
495
488
|
|
|
496
489
|
case 'claim': {
|
|
497
490
|
need(1, 'a card id');
|
|
@@ -552,7 +545,7 @@ export async function runWorkflow(argv) {
|
|
|
552
545
|
case 'complete': {
|
|
553
546
|
need(1, 'a card id');
|
|
554
547
|
const metadata = metadataOf(args.metadata);
|
|
555
|
-
for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null,
|
|
548
|
+
for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, reviewed_sha: args['reviewed-sha'] ?? null }));
|
|
556
549
|
say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
|
|
557
550
|
}
|
|
558
551
|
case 'edit': {
|
|
@@ -577,13 +570,21 @@ export async function runWorkflow(argv) {
|
|
|
577
570
|
case 'unblock': {
|
|
578
571
|
need(1, 'a card id');
|
|
579
572
|
const results = [];
|
|
580
|
-
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? null }))).to]);
|
|
573
|
+
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? args['waiting-on'] ?? null }))).to]);
|
|
581
574
|
// Hermes records the reason as a comment on each card it unblocks
|
|
582
575
|
if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
|
|
583
576
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
584
577
|
}
|
|
585
578
|
case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
|
|
586
|
-
case 'request-changes':
|
|
579
|
+
case 'request-changes': {
|
|
580
|
+
need(2, 'a card id and the reason');
|
|
581
|
+
// each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
|
|
582
|
+
const findings = [].concat(args.finding ?? []).map((f) => String(f).trim()).filter(Boolean);
|
|
583
|
+
const filed = [];
|
|
584
|
+
for (const finding of findings) filed.push((await withBoardAsync((b) => engine.createCard(b, { title: finding, subtaskOf: id }))).id);
|
|
585
|
+
await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null }));
|
|
586
|
+
say(args, `${id} sent back for changes${filed.length ? `; findings filed as ${filed.join(', ')}` : ''}`, { ok: true, findings: filed }); return 0;
|
|
587
|
+
}
|
|
587
588
|
case 'reopen': {
|
|
588
589
|
// a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
|
|
589
590
|
need(1, 'a card id');
|
|
@@ -637,8 +638,8 @@ export async function runWorkflow(argv) {
|
|
|
637
638
|
if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
|
|
638
639
|
const results = [];
|
|
639
640
|
for (const t of targets) {
|
|
640
|
-
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined
|
|
641
|
-
const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null,
|
|
641
|
+
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined)) {
|
|
642
|
+
const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, author: args.author ?? callerAddress() ?? callerName() }));
|
|
642
643
|
results.push({ id: t, specified: true, status: moved.to });
|
|
643
644
|
} else if (verb === 'decompose' && args.child?.length) {
|
|
644
645
|
const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
|
|
@@ -811,11 +812,6 @@ export async function runWorkflow(argv) {
|
|
|
811
812
|
return report.status === 'corrupt' ? 1 : 0;
|
|
812
813
|
}
|
|
813
814
|
|
|
814
|
-
case 'set-every': {
|
|
815
|
-
need(1, 'a card id');
|
|
816
|
-
await withBoardAsync((b) => engine.act(b, id, 'set_every', { payload: { every: rest[1] === 'none' ? null : rest[1] } }));
|
|
817
|
-
say(args, `${id} recurrence updated`, { ok: true }); return 0;
|
|
818
|
-
}
|
|
819
815
|
case 'cancel': {
|
|
820
816
|
need(1, 'a card id');
|
|
821
817
|
await withBoardAsync((b) => engine.act(b, id, 'cancel', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
|
|
@@ -850,9 +846,8 @@ export async function runWorkflow(argv) {
|
|
|
850
846
|
case 'adopt': {
|
|
851
847
|
need(2, 'a card id and a session');
|
|
852
848
|
withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
|
|
849
|
+
withBoard((b) => requestAdoption(b, id, rest[1], { workspace: args.workspace ?? null, assignee: args.assignee ?? null, role: args.role ?? null }));
|
|
850
|
+
say(args, `${id} is held for ${rest[1]}; the dispatcher verifies and binds it on its next tick (session_adopted, or adoption_failed with the reason)`, { ok: true, requested: rest[1] });
|
|
856
851
|
return 0;
|
|
857
852
|
}
|
|
858
853
|
case 'goto': {
|
|
@@ -1099,12 +1094,12 @@ function ownCard(db, sessionId) {
|
|
|
1099
1094
|
const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
|
|
1100
1095
|
|
|
1101
1096
|
/**
|
|
1102
|
-
* Who a caller is on card `cardId`, in the workflow's roles: a
|
|
1103
|
-
*
|
|
1097
|
+
* Who a caller is on card `cardId`, in the workflow's roles: a caller with no session behind it has no role (the
|
|
1098
|
+
* manager acts from its own session, named in params.managers); the session of the card's current run has that run's role; the card's working session between runs
|
|
1104
1099
|
* is its `implementer`; the session working a subtask's arc is `arc`; its recorded creator also has the creator role; any other session is nobody on it.
|
|
1105
1100
|
*/
|
|
1106
1101
|
function rolesFor(db, cardId, sessionId, params = {}) {
|
|
1107
|
-
if (!sessionId) return [
|
|
1102
|
+
if (!sessionId) return [];
|
|
1108
1103
|
// a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
|
|
1109
1104
|
const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
|
|
1110
1105
|
if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
|
|
@@ -1133,7 +1128,7 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1133
1128
|
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
1134
1129
|
// verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
|
|
1135
1130
|
// (what a session may do to a card is its workflow's: each transition's `by`)
|
|
1136
|
-
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', '
|
|
1131
|
+
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'set', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
|
|
1137
1132
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1138
1133
|
if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
|
|
1139
1134
|
if (args['for-card'] !== own.id) refuse(`subscribes only its own arc (--for-card ${own.id})`);
|
|
@@ -1211,18 +1206,30 @@ async function serve(root, args = {}) {
|
|
|
1211
1206
|
// An installer owns this operational window, not a card pause. Finish the
|
|
1212
1207
|
// current tick, acknowledge the request, then leave every run untouched.
|
|
1213
1208
|
// A replacement serve sees the same request before its first tick.
|
|
1209
|
+
// A request whose installer is gone (its pane closed, or it was killed before clearing it) is void.
|
|
1214
1210
|
const installation = join(root, 'installation-window.json');
|
|
1215
|
-
|
|
1216
|
-
|
|
1211
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
1212
|
+
const request = existsSync(installation) ? readJson(installation) : null;
|
|
1213
|
+
const installerAlive = (pid) => { try { process.kill(Number(pid), 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
1214
|
+
if (request?.token && request.pid && !installerAlive(request.pid)) {
|
|
1215
|
+
log(`installation window ${request.token} of ${request.owner} is void: its installer ${request.pid} is gone`);
|
|
1216
|
+
for (const path of [installation, join(root, 'installation-window.ack.json')]) if (readJson(path)?.token === request.token) try { unlinkSync(path); } catch { /* gone */ }
|
|
1217
|
+
} else if (request?.token) {
|
|
1217
1218
|
const acknowledgment = join(root, 'installation-window.ack.json');
|
|
1218
|
-
const previous =
|
|
1219
|
+
const previous = readJson(acknowledgment);
|
|
1219
1220
|
if (previous?.token !== request.token || previous?.pid !== process.pid) {
|
|
1220
|
-
|
|
1221
|
+
const temporary = `${acknowledgment}.${process.pid}`;
|
|
1222
|
+
writeFileSync(temporary, JSON.stringify({ token: request.token, pid: process.pid, at: Date.now() }));
|
|
1223
|
+
renameSync(temporary, acknowledgment);
|
|
1221
1224
|
log(`installation window held by ${request.owner}; dispatch waits`);
|
|
1222
1225
|
}
|
|
1223
1226
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1224
1227
|
continue;
|
|
1225
1228
|
}
|
|
1229
|
+
// A serve never ticks on code older than the installed orchestrator, also right after an
|
|
1230
|
+
// installation window: its keeper starts the new one, whose board store this one does not keep.
|
|
1231
|
+
const replaced = (() => { const now = installed(); return running && now && now !== running ? now : null; })();
|
|
1232
|
+
if (replaced) { log(`orchestrator ${replaced} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
|
|
1226
1233
|
pausedSaid = Boolean(paused(root)) && pausedSaid;
|
|
1227
1234
|
let interval = 60;
|
|
1228
1235
|
let nextMailAt = Infinity;
|