@volter/supercode-orchestrator 0.4.147 → 0.4.149
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 +177 -108
- package/board/cli.mjs +58 -35
- package/board/dispatch.mjs +169 -43
- package/board/engine.mjs +130 -30
- package/board/notices.mjs +5 -0
- package/board/request-mail.mjs +59 -0
- package/board/store.mjs +6 -3
- package/board/tools.mjs +15 -9
- package/board/workflow.mjs +40 -32
- package/board/workspace.mjs +26 -0
- package/board/ztrack.mjs +781 -544
- package/hermes-locks.mjs +1 -20
- package/mcp/hermes-kanban-tools.json +76 -14
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
package/board/workflow.mjs
CHANGED
|
@@ -11,8 +11,9 @@ import { resolve, relative, isAbsolute } from 'node:path';
|
|
|
11
11
|
import { randomBytes } from 'node:crypto';
|
|
12
12
|
import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
|
|
13
13
|
import { evaluate, holds, render } from './expr.mjs';
|
|
14
|
-
import { completionEvidence } from './ci.mjs';
|
|
14
|
+
import { completionEvidence, reviewCandidate } from './ci.mjs';
|
|
15
15
|
import { isWorkspacePath } from './workspace.mjs';
|
|
16
|
+
import { identifyNotice } from './notices.mjs';
|
|
16
17
|
|
|
17
18
|
const loaded = new Map();
|
|
18
19
|
|
|
@@ -103,7 +104,8 @@ function pattern(p) {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
// the time of the latest commit that names `id` in its message, on any ref of the repository at `path` (a worktree's
|
|
106
|
-
// pushes land on the shared remote-tracking refs), or null (asked only when an expression does)
|
|
107
|
+
// pushes land on the shared remote-tracking refs), or null (asked only when an expression does). Merge commits are
|
|
108
|
+
// not the card's work: merging its reviewed PR, or main into its branch, is not a newer change to review.
|
|
107
109
|
const commitTimes = new Map();
|
|
108
110
|
function observeLastCommit(path, id) {
|
|
109
111
|
if (!path) return null;
|
|
@@ -111,7 +113,7 @@ function observeLastCommit(path, id) {
|
|
|
111
113
|
const cached = commitTimes.get(key);
|
|
112
114
|
if (cached && now() - cached.at < 15) return cached.value;
|
|
113
115
|
const readCommit = (repo) => {
|
|
114
|
-
const r = spawnSync('git', ['-C', repo, 'log', '--all', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
|
|
116
|
+
const r = spawnSync('git', ['-C', repo, 'log', '--all', '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
|
|
115
117
|
const value = Number(r.stdout?.trim());
|
|
116
118
|
return { repo: r.status === 0, value: Number.isFinite(value) && value > 0 ? value : null };
|
|
117
119
|
};
|
|
@@ -200,11 +202,8 @@ function onlyTicks(was, body) {
|
|
|
200
202
|
|
|
201
203
|
/** Seconds a card's runs spent by role, and the rest of its life as `waiting`. */
|
|
202
204
|
export function runWorkingSeconds(row, at = now()) {
|
|
203
|
-
const metadata = typeof row.metadata === 'string' ? json(row.metadata, {}) : row.metadata;
|
|
204
|
-
const session = metadata?.supercode ?? {};
|
|
205
205
|
const end = Number(row.ended_at ?? at);
|
|
206
|
-
|
|
207
|
-
return Math.max(0, end - Number(row.started_at ?? end) - waiting);
|
|
206
|
+
return Math.max(0, end - Number(row.started_at ?? end));
|
|
208
207
|
}
|
|
209
208
|
|
|
210
209
|
function timeOf(db, task) {
|
|
@@ -249,8 +248,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
|
|
|
249
248
|
...task,
|
|
250
249
|
creator_address: creatorOf(db, task.id),
|
|
251
250
|
completion_owner_address: completionOwnerOf(db, task.id),
|
|
252
|
-
review_only: Boolean(state.review_only),
|
|
253
251
|
latest_review: latestReview(db, task.id) ?? {},
|
|
252
|
+
review_candidate: reviewCandidate(board, task.id) ?? {},
|
|
254
253
|
ci: completionEvidence(board, task.id),
|
|
255
254
|
dispatch_hold: one(db, "SELECT kind FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','session_adopted') ORDER BY id DESC LIMIT 1", task.id)?.kind === 'dispatch_held',
|
|
256
255
|
status,
|
|
@@ -356,11 +355,20 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
|
|
|
356
355
|
const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
|
|
357
356
|
if (!beforeRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === beforeRun.id)) return;
|
|
358
357
|
const session = json(beforeRun.metadata, {})?.supercode;
|
|
358
|
+
const cancellation = session?.launch_cancellation;
|
|
359
|
+
if (cancellation?.key === session?.launch_key && cancellation?.status === 'cancelled' && cancellation?.cancelled === true &&
|
|
360
|
+
cancellation?.outcome === 'not_issued' && cancellation?.live === false && cancellation?.pane === null) return;
|
|
359
361
|
const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
|
|
360
362
|
if (!address && !session?.launch_key && !session?.pane) return;
|
|
363
|
+
// The session that made this change learns it from its own command's answer; a notice about
|
|
364
|
+
// its own act would only hold its close until the notice is confirmed. A session whose close is
|
|
365
|
+
// confirmed is not told either: its next run resumes it with the card's state in its prompt.
|
|
366
|
+
if (address && address === actorOf()) return;
|
|
367
|
+
if (session?.close_confirmed_at) return;
|
|
361
368
|
const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
|
|
362
|
-
const
|
|
369
|
+
const body = render(entry.workflow.notify?.worker?.run_changed ?? '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}', scope);
|
|
363
370
|
const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
|
|
371
|
+
const text = identifyNotice({ id: source, kind: name, run_id: beforeRun.id, created_at: now() }, body);
|
|
364
372
|
const effect = { kind: 'session_notice', id: task.id, runId: beforeRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
|
|
365
373
|
const inserted = run(db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
366
374
|
effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
@@ -385,7 +393,7 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
385
393
|
const candidates = transitionsFor(workflow, from, name);
|
|
386
394
|
if (!candidates.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there`);
|
|
387
395
|
// `sender`: the roles the event was sent with (a validator can ask who is asking)
|
|
388
|
-
const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), ...extra, sender: roles } });
|
|
396
|
+
const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
|
|
389
397
|
const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
|
|
390
398
|
if (!allowed.length) {
|
|
391
399
|
const state = name === 'complete' ? 'done' : name === 'cancel' ? 'cancelled' : null;
|
|
@@ -555,7 +563,7 @@ const FORMAT = {
|
|
|
555
563
|
blocked: ({ e, after, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, waiting_on: e.waiting_on ?? null, recurrences: Number(after.block_recurrences || 0), source_status: source }),
|
|
556
564
|
block_loop_detected: ({ e, after, source, params }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), limit: params.block_recurrence_limit, source_status: source }),
|
|
557
565
|
dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, source_status: source }),
|
|
558
|
-
review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
|
|
566
|
+
review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, pr: e.ci?.pr ?? null, commit: e.ci?.commit ?? null, implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
|
|
559
567
|
// the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
|
|
560
568
|
changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
|
|
561
569
|
review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
|
|
@@ -636,17 +644,6 @@ const ACTIONS = {
|
|
|
636
644
|
const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [].concat(as), payload, extra: { lanes: lanesIn(ctx.entry) } });
|
|
637
645
|
ctx.effects.push(...result.effects);
|
|
638
646
|
},
|
|
639
|
-
wait_run(ctx, waiting) {
|
|
640
|
-
if (typeof waiting !== 'boolean') throw new Error('wait_run requires a boolean');
|
|
641
|
-
const at = now();
|
|
642
|
-
runSession(ctx, session => {
|
|
643
|
-
if (waiting) return { ...session, wait_started_at: session.wait_started_at ?? at };
|
|
644
|
-
if (session.wait_started_at == null) return session;
|
|
645
|
-
return { ...session, wait_started_at: null,
|
|
646
|
-
wait_seconds: Number(session.wait_seconds ?? 0) + Math.max(0, at - Number(session.wait_started_at)),
|
|
647
|
-
goal_nudged_at: at };
|
|
648
|
-
});
|
|
649
|
-
},
|
|
650
647
|
record(ctx, args, scope) {
|
|
651
648
|
const kind = typeof args === 'string' ? args : args?.kind;
|
|
652
649
|
const given = typeof args === 'object' && args?.payload ? args.payload : null;
|
|
@@ -766,15 +763,12 @@ const ACTIONS = {
|
|
|
766
763
|
run(ctx.db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, ctx.id);
|
|
767
764
|
},
|
|
768
765
|
specify(ctx, said) {
|
|
769
|
-
if (said?.review_only != null) {
|
|
770
|
-
const task = cardOf(ctx.db, ctx.id);
|
|
771
|
-
if (typeof said.review_only !== 'boolean' || task.current_run_id || task.subtask_of || ctx.scope.card.every) throw new Error('A review-only declaration changes only an arc with no current run or recurrence.');
|
|
772
|
-
cardField(ctx, 'review_only', said.review_only ? 1 : 0);
|
|
773
|
-
record(ctx.db, ctx.id, 'review_mode_changed', { review_only: said.review_only }, null);
|
|
774
|
-
}
|
|
775
766
|
if (said?.workspace) {
|
|
776
767
|
const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
|
|
777
768
|
if (task.current_run_id) throw new Error('end the current run before changing its workspace');
|
|
769
|
+
ACTIONS.forget_session(ctx, '*');
|
|
770
|
+
const prior = one(ctx.db, 'SELECT workspace_anchor FROM supercode_cards WHERE task_id=?', ctx.id);
|
|
771
|
+
record(ctx.db, ctx.id, 'workspace_changed', { from: { kind: task.workspace_kind, repository: prior?.workspace_anchor, branch: task.branch_name }, to: { kind: ws.kind, repository: ws.repository, branch: ws.branch } }, null);
|
|
778
772
|
run(ctx.db, 'UPDATE tasks SET workspace_kind=?,workspace_path=?,branch_name=? WHERE id=?', ws.kind, ws.path ?? null, ws.branch ?? null, ctx.id);
|
|
779
773
|
cardField(ctx, 'workspace_anchor', ws.repository ?? null);
|
|
780
774
|
cardField(ctx, 'workspace_root', null);
|
|
@@ -854,12 +848,20 @@ const ACTIONS = {
|
|
|
854
848
|
if (!to) throw new Error(`the workflow asks nobody about ${ctx.id} (ask.to is empty)`);
|
|
855
849
|
const id = `m-${randomBytes(12).toString('hex')}`;
|
|
856
850
|
ctx.scope.asked = id;
|
|
857
|
-
|
|
851
|
+
const batch = ctx.scope.verb === 'request_state' ? {
|
|
852
|
+
requester: actorOf() ?? ctx.scope.requester,
|
|
853
|
+
window_seconds: Math.max(1, Math.min(60, Number(ctx.entry.params.request_batch_seconds) || 10)),
|
|
854
|
+
} : null;
|
|
855
|
+
const observation = ctx.scope.event?.ci;
|
|
856
|
+
if (batch && observation?.required && !observation.ready) text = `${String(text ?? '')}\nSource evidence could not be verified: ${observation.reason ?? 'unavailable'}. The decision request is retained.`;
|
|
857
|
+
record(ctx.db, ctx.id, 'board_message', { to: String(to), text: batch ? String(text ?? '') : `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id, ...(batch ? { batch } : {}) }, null);
|
|
858
858
|
ctx.effects.push({ kind: 'board_message', id: ctx.id });
|
|
859
859
|
},
|
|
860
860
|
send_to_actor(ctx, text) {
|
|
861
861
|
const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
|
|
862
862
|
const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
863
|
+
effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, text);
|
|
864
|
+
run(ctx.db, 'UPDATE supercode_effects SET effect=? WHERE id=?', JSON.stringify(effect), Number(inserted.lastInsertRowid));
|
|
863
865
|
ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
864
866
|
},
|
|
865
867
|
};
|
|
@@ -1026,13 +1028,19 @@ function settleRelations(db, board, entry, id, effects) {
|
|
|
1026
1028
|
}
|
|
1027
1029
|
|
|
1028
1030
|
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
+
/** A card's workflow status and the status Hermes's `tasks.status` stores it as, read without writing. */
|
|
1032
|
+
export function storedStatus(db, board, entry, id) {
|
|
1031
1033
|
const task = cardOf(db, id);
|
|
1032
1034
|
const status = statusOf(db, task);
|
|
1035
|
+
if (!entry.workflow.stored_as?.length) return { status, stored: task.status };
|
|
1033
1036
|
const scope = scopeOf(db, board, task, { params: entry.params });
|
|
1034
1037
|
const rule = entry.workflow.stored_as.find((r) => holds(r.guard, scope));
|
|
1035
|
-
|
|
1038
|
+
return { status, stored: rule?.status ?? storedAs(entry.workflow, status) };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
export function projectStatus(db, board, entry, id) {
|
|
1042
|
+
if (!entry.workflow.stored_as?.length) return;
|
|
1043
|
+
const { status, stored } = storedStatus(db, board, entry, id);
|
|
1036
1044
|
run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
|
|
1037
1045
|
run(db, 'INSERT INTO supercode_cards (task_id, stage) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage', id, status);
|
|
1038
1046
|
}
|
package/board/workspace.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Filesystem/RPC work happens before a board transaction; the ownership check is pure.
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname } from 'node:path';
|
|
3
5
|
import { Workspaces, inspectCheckout, repositoryId, teamsHome } from '@volter/supercode-teams/workspaces';
|
|
4
6
|
import { localMachine, normalMachine, supercodeBin, commandEnv } from './dispatch.mjs';
|
|
5
7
|
export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '');
|
|
@@ -28,6 +30,17 @@ export function declareWorkspace(kind, value, machine, branch = null) {
|
|
|
28
30
|
const row = workspaceDoor(machine, 'register', value);
|
|
29
31
|
return { repository: row.repository, branch: branch ?? (kind === 'dir' ? row.branch : null), path: kind === 'dir' ? row.path : null };
|
|
30
32
|
}
|
|
33
|
+
/** The checkouts this machine keeps of any repository on `branch`: where an arc changing several repositories
|
|
34
|
+
* keeps its work in each of them (its own worktree aside). Another machine's map is not read from here. */
|
|
35
|
+
export function branchCheckouts(machine, branch) {
|
|
36
|
+
if (!branch || (machine && normalMachine(machine) !== localMachine())) return [];
|
|
37
|
+
return new Workspaces(teamsHome()).list().filter((row) => (branch === '*' || row.branch === branch) && existsSync(row.path));
|
|
38
|
+
}
|
|
39
|
+
/** The registered checkout of `repository` on `branch` on `machine`: where a card changing several repositories
|
|
40
|
+
* keeps its work in each of them. */
|
|
41
|
+
export function checkoutOf(machine, repository, branch) {
|
|
42
|
+
return workspaceDoor(machine, 'resolve', repositoryId(repository), { branch });
|
|
43
|
+
}
|
|
31
44
|
export function resolveWorkspace(task, create = false) {
|
|
32
45
|
const declared = task.workspace_anchor ?? task.workspace_path;
|
|
33
46
|
if (isWorkspacePath(declared)) {
|
|
@@ -36,6 +49,19 @@ export function resolveWorkspace(task, create = false) {
|
|
|
36
49
|
if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
|
|
37
50
|
throw new Error('legacy worktree path needs registration and a repository declaration before launch');
|
|
38
51
|
}
|
|
52
|
+
// A primary checkout can change when another clone is registered. That does
|
|
53
|
+
// not move an already allocated card worktree. Machine moves and workspace
|
|
54
|
+
// retargets clear this runtime receipt; never borrow an arbitrary checkout.
|
|
55
|
+
if (task.workspace_kind === 'worktree' && task.workspace_path &&
|
|
56
|
+
basename(task.workspace_path) === task.id && basename(dirname(task.workspace_path)) === '.worktrees' &&
|
|
57
|
+
(!isLocalWorkspace(task) || existsSync(task.workspace_path))) {
|
|
58
|
+
const cached = workspaceDoor(task.machine, 'inspect', task.workspace_path);
|
|
59
|
+
if (cached.linked && basename(cached.path) === task.id && basename(dirname(cached.path)) === '.worktrees' &&
|
|
60
|
+
cached.repository === repositoryId(declared) && cached.branch === (task.branch_name ?? `wt/${task.id}`)) {
|
|
61
|
+
return { ...cached, root: dirname(dirname(cached.path)) };
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${task.branch_name ?? `wt/${task.id}`}; retarget its workspace explicitly`);
|
|
64
|
+
}
|
|
39
65
|
return workspaceDoor(task.machine, 'resolve', declared, { branch: task.branch_name, solo: task.workspace_kind === 'worktree', card: task.id, create });
|
|
40
66
|
}
|
|
41
67
|
export function checkWorkspace(board, task) {
|