@volter/supercode-orchestrator 0.5.207 → 0.5.209
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/README.md +30 -2
- package/agents.mjs +27 -12
- package/bin/connection-runner.mjs +12 -21
- package/bin/orchestrator.mjs +16 -3
- package/board/agent-declarations.mjs +3 -3
- package/board/args.mjs +2 -2
- package/board/card-state.mjs +29 -0
- package/board/ci.mjs +87 -29
- package/board/cli.mjs +125 -91
- package/board/codex-pane-inputs.mjs +44 -0
- package/board/dispatch.mjs +354 -84
- package/board/engine.mjs +77 -50
- package/board/event-stream.mjs +6 -1
- package/board/files.mjs +3 -1
- package/board/listing.mjs +13 -11
- package/board/machine-fleet.mjs +7 -5
- package/board/notices.mjs +2 -2
- package/board/published.mjs +124 -33
- package/board/readable-id.mjs +34 -0
- package/board/record-view.mjs +10 -1
- package/board/request-mail.mjs +9 -3
- package/board/store.mjs +95 -14
- package/board/tools.mjs +10 -8
- package/board/visibility.mjs +11 -0
- package/board/workflow.mjs +58 -16
- package/board/workspace.mjs +21 -9
- package/board/ztrack.mjs +119 -48
- package/codex-pane-resume.mjs +1 -0
- package/connection.mjs +108 -30
- package/index.d.ts +6 -2
- package/loop.mjs +51 -3
- package/mcp/bridge.mjs +4 -1
- package/mcp/hermes-kanban-tools.json +5 -1
- package/ontology.d.ts +7 -24
- package/orchestration.mjs +6 -3
- package/package.json +2 -2
- package/source-lock.mjs +7 -2
- package/worker-route.mjs +31 -3
package/board/tools.mjs
CHANGED
|
@@ -43,7 +43,9 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
43
43
|
const slug = args.board || env.HERMES_KANBAN_BOARD || DEFAULT_BOARD;
|
|
44
44
|
// the board's workflow decides what each tool's event does; the worker is its run's role on its own task, and
|
|
45
45
|
// nobody on another (Hermes's worker scoping)
|
|
46
|
-
|
|
46
|
+
let entry;
|
|
47
|
+
try { entry = await loadWorkflow(root); } catch (e) { return error(e.message); }
|
|
48
|
+
const stateOptions = { nativeHolds: Boolean(entry.workflow.flags?.paused) };
|
|
47
49
|
engine.setCaller((db, cardId) => {
|
|
48
50
|
if (!own || !runId) return own ? [] : ['manager'];
|
|
49
51
|
const meta = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId)?.metadata, {})?.supercode ?? {};
|
|
@@ -78,13 +80,13 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
78
80
|
if (name !== 'kanban_show' && name !== 'kanban_list' && !(name === 'kanban_comment' && isLatestReviewer(board.db, tid, actorOf()))) { const stale = ownRun(); if (stale) return error(stale); }
|
|
79
81
|
switch (name) {
|
|
80
82
|
case 'kanban_show': {
|
|
81
|
-
const card = tx(board, (db) => { const c = engine.showCard(db, tid); c.context = engine.cardContext(db, tid); return c; });
|
|
83
|
+
const card = tx(board, (db) => { const c = engine.showCard(db, tid, stateOptions); c.context = engine.cardContext(db, tid, stateOptions); return c; });
|
|
82
84
|
const t = card.task;
|
|
83
85
|
return JSON.stringify({
|
|
84
|
-
task: { id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, tenant: t.tenant, priority: t.priority, workspace_kind: t.workspace_kind,
|
|
86
|
+
task: { id: t.id, readable_id: t.readable_id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, tenant: t.tenant, priority: t.priority, workspace_kind: t.workspace_kind,
|
|
85
87
|
workspace_path: t.workspace_path, created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
|
|
86
88
|
current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override },
|
|
87
|
-
parents: card.parents.map((p) => p.id), children: card.children,
|
|
89
|
+
parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks,
|
|
88
90
|
comments: card.comments, events: card.events.slice(-50), runs: card.runs.map((r) => ({ id: r.id, profile: r.profile, status: r.status, outcome: r.outcome, summary: r.summary, error: r.error, metadata: r.metadata, started_at: r.started_at, ended_at: r.ended_at })),
|
|
89
91
|
worker_context: card.context,
|
|
90
92
|
});
|
|
@@ -92,7 +94,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
92
94
|
case 'kanban_list': {
|
|
93
95
|
const limit = args.limit === undefined ? 100 : Number(args.limit);
|
|
94
96
|
if (!Number.isInteger(limit) || limit < 1) return error('limit must be an integer >= 1');
|
|
95
|
-
const cards = tx(board, (db) => engine.listCards(db, { status: args.status ?? null, assignee: args.assignee ?? null, includeArchived: Boolean(args.include_archived) }))
|
|
97
|
+
const cards = tx(board, (db) => engine.listCards(db, { ...stateOptions, status: args.status ?? null, assignee: args.assignee ?? null, includeArchived: Boolean(args.include_archived), includeSubtasks: Boolean(args.include_subtasks) }))
|
|
96
98
|
.filter((t) => !args.tenant || t.tenant === args.tenant).slice(0, limit);
|
|
97
99
|
return ok({ tasks: cards.map((t) => ({ id: t.id, title: t.title, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant, machine: t.machine })), count: cards.length });
|
|
98
100
|
}
|
|
@@ -120,7 +122,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
120
122
|
}
|
|
121
123
|
case 'kanban_block': {
|
|
122
124
|
const stale = ownRun(); if (stale) return error(stale);
|
|
123
|
-
const { to: status } = await engine.block(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, on: args.on ?? null, waitingOn: args.waiting_on ?? null });
|
|
125
|
+
const { to: status } = await engine.block(board, tid, { kind: args.kind ?? 'needs_input', reason: args.reason ? redact(String(args.reason)) : null, on: args.on ?? null, waitingOn: args.waiting_on ?? null });
|
|
124
126
|
return ok({ task_id: tid, status });
|
|
125
127
|
}
|
|
126
128
|
case 'kanban_request_state': {
|
|
@@ -135,7 +137,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
135
137
|
}
|
|
136
138
|
case 'kanban_request_changes': {
|
|
137
139
|
const stale = ownRun(); if (stale) return error(stale);
|
|
138
|
-
// each finding is filed as a task on the arc first
|
|
140
|
+
// each finding is filed as a task on the arc first: the arc's review is due again once every one is closed (ci.mjs answeredChanges)
|
|
139
141
|
const findings = [].concat(args.findings ?? []).map((f) => redact(String(f)).trim()).filter(Boolean);
|
|
140
142
|
const filed = findings.map((finding) => engine.createCard(board, { title: finding, subtaskOf: tid }).id);
|
|
141
143
|
await engine.requestChanges(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, reviewer: author });
|
|
@@ -183,7 +185,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
183
185
|
case 'kanban_create': {
|
|
184
186
|
const workspace = args.workspace_kind ? `${args.workspace_kind}${args.workspace_path ? `:${args.workspace_path}` : ''}` : undefined;
|
|
185
187
|
const made = engine.createCard(board, await engine.prepareCard(board, {
|
|
186
|
-
title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, reviewer: args.reviewer ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
188
|
+
title: args.title, readableId: args.readable_id, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, reviewer: args.reviewer ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
187
189
|
priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
|
|
188
190
|
maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
|
|
189
191
|
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Shared by the bounded CLI reader and the engine's record listing. t and c are
|
|
2
|
+
// the task and its supercode state; the two parameters opt into subtasks/history.
|
|
3
|
+
export const CARD_VISIBILITY_SQL = `(? OR c.subtask_of IS NULL) AND (? OR (
|
|
4
|
+
t.status NOT IN ('cancelled', 'archived') AND COALESCE(c.stage, '') NOT IN ('cancelled', 'archived')
|
|
5
|
+
AND COALESCE(c.archived, 0) = 0
|
|
6
|
+
AND NOT EXISTS (
|
|
7
|
+
SELECT 1 FROM tasks arc LEFT JOIN supercode_cards state ON state.task_id = arc.id
|
|
8
|
+
WHERE arc.id = c.subtask_of AND (arc.status IN ('cancelled', 'archived')
|
|
9
|
+
OR state.stage IN ('cancelled', 'archived') OR state.archived = 1)
|
|
10
|
+
)
|
|
11
|
+
))`;
|
package/board/workflow.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { allocateReadableId } from './readable-id.mjs';
|
|
1
2
|
// The board's workflow, interpreted (docs/architecture/orchestrator.md §2.9). The IR's `Orchestration.workflow`
|
|
2
3
|
// (crates/interchange/src/orchestration/workflow.rs; Hermes's dispatcher, hermes.workflow.yaml, when a home declares
|
|
3
4
|
// none) says every status a card can be in, what runs there, which events move a card and who may send them, what
|
|
@@ -13,7 +14,7 @@ import { promisify } from 'node:util';
|
|
|
13
14
|
import { existsSync, readdirSync, realpathSync, watch, readFileSync } from 'node:fs';
|
|
14
15
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
15
16
|
import { randomBytes } from 'node:crypto';
|
|
16
|
-
import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
|
|
17
|
+
import { actorOf, appendRunObservation, boardKey, cardHandleOf, event as record, json, now } from './store.mjs';
|
|
17
18
|
import { releaseAllSideSessions } from './side-sessions.mjs';
|
|
18
19
|
import { evaluate, holds, render, needsSources } from './expr.mjs';
|
|
19
20
|
import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
|
|
@@ -150,9 +151,9 @@ export function workflowEntry(orchestration) {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
/** The workflow loaded for `root` (loadWorkflow first): the running call's basis, else the latest read. */
|
|
153
|
-
export function workflowFor(root) {
|
|
154
|
+
export function workflowFor(root, { optional = false } = {}) {
|
|
154
155
|
const entry = current().workflows.get(root) ?? loaded.get(root);
|
|
155
|
-
if (!entry) throw new Error(`the board workflow of ${root} is not loaded`);
|
|
156
|
+
if (!entry && !optional) throw new Error(`the board workflow of ${root} is not loaded`);
|
|
156
157
|
return entry;
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -235,7 +236,8 @@ function pattern(p) {
|
|
|
235
236
|
// ran: volter-org t_9a9c2a9c). A folder that is not there, a git that did not answer, a machine that could not be asked:
|
|
236
237
|
// unknown, with why, and never "no commits".
|
|
237
238
|
const commitTimes = new Map(); // `${path}:${id}` → { at, known, value, error }
|
|
238
|
-
|
|
239
|
+
// A commit names the card by its id or by its keyed number (`VO-856`); a branch is the card's by its handle too.
|
|
240
|
+
async function readCardCommit(path, id, branches, handle = null) {
|
|
239
241
|
if (!existsSync(path)) return { known: false, value: null, error: `no folder ${path} on this machine` };
|
|
240
242
|
// awaited, never a wait that holds the event loop: `status` 0 or the exit code, as a synchronous run answered
|
|
241
243
|
const git = (repo, args) => execFileAsync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout: 10_000 }).then(({ stdout }) => ({ status: 0, stdout }), (error) => ({ status: error.code ?? 1, stdout: error.stdout ?? '', timedOut: Boolean(error.killed) }));
|
|
@@ -251,7 +253,13 @@ async function readCardCommit(path, id, branches) {
|
|
|
251
253
|
if (!refs.length) return { repo: true, known: true, value: null };
|
|
252
254
|
const r = await git(repo, ['log', ...refs, '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`, '--']);
|
|
253
255
|
if (r.status !== 0) return { repo: true, known: false, value: null, error: `git log ${r.timedOut ? 'did not answer within 10s' : 'failed'} in ${repo}` };
|
|
254
|
-
|
|
256
|
+
let value = Number(r.stdout?.trim());
|
|
257
|
+
const recorded = refs.filter(ref => branches.includes(short(ref)) && !['main', 'master', fallback].includes(short(ref)));
|
|
258
|
+
if (handle && recorded.length) {
|
|
259
|
+
const keyed = await git(repo, ['log', ...recorded, '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${handle.toUpperCase()}`, '--']);
|
|
260
|
+
if (keyed.status !== 0) return { repo: true, known: false, value: null, error: `git log failed in ${repo}` };
|
|
261
|
+
value = Math.max(value || 0, Number(keyed.stdout?.trim()) || 0);
|
|
262
|
+
}
|
|
255
263
|
return { repo: true, known: true, value: Number.isFinite(value) && value > 0 ? value : null };
|
|
256
264
|
};
|
|
257
265
|
const own = await readCommit(path);
|
|
@@ -271,15 +279,15 @@ async function readCardCommit(path, id, branches) {
|
|
|
271
279
|
// a commit that was read stands; with none read, a child that could not be read leaves the whole reading unknown
|
|
272
280
|
return value == null && unread ? { known: false, value: null, error: unread } : { known: true, value };
|
|
273
281
|
}
|
|
274
|
-
async function observeLastCommit(path, id, branches = [], machine = null) {
|
|
282
|
+
async function observeLastCommit(path, id, branches = [], machine = null, handle = null) {
|
|
275
283
|
if (!path) return null;
|
|
276
284
|
const key = `${path}:${id}`;
|
|
277
285
|
const cached = commitTimes.get(key);
|
|
278
286
|
if (cached && now() - cached.at < 15) return cached.value;
|
|
279
287
|
let reading;
|
|
280
288
|
try {
|
|
281
|
-
if (isLocalWorkspace({ machine })) reading = await readCardCommit(path, id, branches);
|
|
282
|
-
else reading = { known: true, value: (await cardCommitsOn(machine, path, id, branches)).at };
|
|
289
|
+
if (isLocalWorkspace({ machine })) reading = await readCardCommit(path, id, branches, handle);
|
|
290
|
+
else reading = { known: true, value: (await cardCommitsOn(machine, path, id, branches, handle?.toUpperCase() ?? null)).at };
|
|
283
291
|
} catch (error) { reading = { known: false, value: null, error: String(error?.message ?? error).slice(0, 300) }; }
|
|
284
292
|
commitTimes.set(key, { at: now(), ...reading });
|
|
285
293
|
return reading.value;
|
|
@@ -313,7 +321,7 @@ export async function refreshCardInputs(board, id, { commits = false } = {}) {
|
|
|
313
321
|
if (commits && !task.subtask_of) {
|
|
314
322
|
const { path, machine } = commitSource(board.db, task);
|
|
315
323
|
if(path)commitTimes.delete(`${path}:${id}`);
|
|
316
|
-
await observeLastCommit(path,id,declaredWorkBranches(board.db,id),machine);
|
|
324
|
+
await observeLastCommit(path,id,declaredWorkBranches(board.db,id),machine,cardHandleOf(board,id));
|
|
317
325
|
}
|
|
318
326
|
}
|
|
319
327
|
|
|
@@ -333,6 +341,12 @@ export async function refreshRelationAnswers(board) {
|
|
|
333
341
|
for (const row of board.db.prepare("SELECT e.payload FROM task_events e JOIN (SELECT task_id, MAX(id) id FROM task_events WHERE kind IN ('blocked','unblocked') GROUP BY task_id) last ON e.id=last.id JOIN tasks t ON t.id=e.task_id WHERE e.kind='blocked' AND t.status='blocked'").all()) {
|
|
334
342
|
const id = json(row.payload, {})?.waiting_on; if (id) ids.add(id);
|
|
335
343
|
}
|
|
344
|
+
// State requests stay live while the card runs, independently of a blocker.
|
|
345
|
+
// Read their exact question ids through the same mailbox receipt door.
|
|
346
|
+
for (const row of board.db.prepare("SELECT id FROM tasks WHERE current_run_id IS NOT NULL AND status NOT IN ('done','cancelled','archived')").all()) {
|
|
347
|
+
const request = stateRequestOf(board.db, row.id);
|
|
348
|
+
if (request?.message_id) ids.add(request.message_id);
|
|
349
|
+
}
|
|
336
350
|
const list = [...ids];
|
|
337
351
|
if (!list.length) return;
|
|
338
352
|
// one read of this machine's daemon for every id (its header index answers, no process per message)
|
|
@@ -341,7 +355,9 @@ export async function refreshRelationAnswers(board) {
|
|
|
341
355
|
if (!Array.isArray(rows)) throw new Error('the mail door returned no message list');
|
|
342
356
|
for (const id of list) {
|
|
343
357
|
const row = rows.find((item) => item?.id === id);
|
|
344
|
-
mailboxAnswers.set(id,
|
|
358
|
+
mailboxAnswers.set(id, row?.found === true
|
|
359
|
+
? { answer: row.replies?.[0]?.id ?? null, askedOf: row.session ?? null }
|
|
360
|
+
: { error: `the mailbox did not find question ${id}; its answer is unknown` });
|
|
345
361
|
}
|
|
346
362
|
} catch (error) { for (const id of list) mailboxAnswers.set(id, { error: `the answer to ${id} could not be read: ${String(error.message).slice(0, 200)}` }); }
|
|
347
363
|
}
|
|
@@ -351,6 +367,18 @@ function answerTo(id) {
|
|
|
351
367
|
return observed?.answer ?? null;
|
|
352
368
|
}
|
|
353
369
|
|
|
370
|
+
// A manager's state act settles the request; a mailbox reply answers it without
|
|
371
|
+
// changing the card. Unknown mailbox reads never assert an unanswered question.
|
|
372
|
+
function stateRequestOf(db, id) {
|
|
373
|
+
const row = one(db, "SELECT id,payload,created_at FROM task_events WHERE task_id=? AND kind='state_requested' ORDER BY id DESC LIMIT 1", id);
|
|
374
|
+
if (!row || one(db, "SELECT 1 FROM task_events WHERE task_id=? AND id>? AND kind IN ('paused','resumed','blocked','unblocked','completed','cancelled','review_pass','reopened') LIMIT 1", id, row.id)) return null;
|
|
375
|
+
const payload = json(row.payload, {}) ?? {};
|
|
376
|
+
const question = payload.message_id ?? json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND id<? AND kind='board_message' AND json_extract(payload,'$.batch') IS NOT NULL ORDER BY id DESC LIMIT 1", id, row.id)?.payload, {})?.message_id;
|
|
377
|
+
const receipt = question ? mailboxAnswers.get(question) : null;
|
|
378
|
+
return { id: row.id, created_at: row.created_at, state: payload.state, reason: payload.reason, message_id: question ?? null,
|
|
379
|
+
answer_known: Boolean(receipt && !receipt.error), answer: receipt?.answer ?? null, error: receipt?.error ?? null };
|
|
380
|
+
}
|
|
381
|
+
|
|
354
382
|
const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
|
|
355
383
|
const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
|
|
356
384
|
/** Whether `body` is `was` with only outcomes ticked: each changed line is under the Acceptance heading, was `- [ ] X`
|
|
@@ -484,6 +512,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
484
512
|
task.id, Number(one(db, 'SELECT since FROM supercode_cards WHERE task_id = ?', task.id)?.since ?? 0))?.payload, {})?.reason ?? null; },
|
|
485
513
|
// the message whose answer the standing block waits for (`block --waiting-on`), and that answer's id once it has landed
|
|
486
514
|
get waiting_on() { return waitingOn(); },
|
|
515
|
+
get state_request() { return stateRequestOf(db, task.id); },
|
|
487
516
|
answer: () => answerTo(waitingOn()),
|
|
488
517
|
// the seconds its runs spent, by the role each run was for (implementer: coding, reviewer: reviewing, …), an open
|
|
489
518
|
// run counted to now; `waiting` is the rest of its life since it was filed
|
|
@@ -593,7 +622,7 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
|
|
|
593
622
|
const defaultNotice = noticeSession
|
|
594
623
|
? '{{card.id}}: verified adoption started run {{run.id}} ({{event.from}} → {{event.to}}). Follow the board’s current state before continuing.'
|
|
595
624
|
: '{{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 ?? ""}}';
|
|
596
|
-
const body = numbered(cardNumber(db, task.id), render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope));
|
|
625
|
+
const body = numbered(boardKey(board.root, board.slug), cardNumber(db, task.id), render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope));
|
|
597
626
|
const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
|
|
598
627
|
const text = identifyNotice({ id: source, kind: name, run_id: targetRun.id, created_at: now() }, body);
|
|
599
628
|
const effect = { kind: 'session_notice', id: task.id, runId: targetRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
|
|
@@ -889,7 +918,7 @@ const FORMAT = {
|
|
|
889
918
|
model_override_set: ({ e }) => ({ model: (e.field === 'model' ? e.value : e.model) || null, provider: e.provider || null }),
|
|
890
919
|
goal_set: ({ after }) => ({ goal_mode: Boolean(after.goal_mode), goal_max_turns: after.goal_mode ? after.goal_max_turns ?? null : null }),
|
|
891
920
|
heartbeat: ({ e }) => (e.note || e.until ? { ...(e.note ? { note: e.note } : {}), ...(e.until ? { until: Number(e.until) } : {}) } : null),
|
|
892
|
-
commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length, ...(e.sources ? { sources: e.sources } : {}) }),
|
|
921
|
+
commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length, ...(e.sources ? { sources: e.sources } : {}), ...(e.branches ? { branches: e.branches } : {}) }),
|
|
893
922
|
archived: () => null,
|
|
894
923
|
status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
|
|
895
924
|
machine_set: ({ e, before }) => ({ machine: e.field === 'machine' ? e.value ?? null : e.machine ?? null, was: before.machine }),
|
|
@@ -1107,6 +1136,11 @@ const ACTIONS = {
|
|
|
1107
1136
|
cardField(ctx, 'workspace_root', null);
|
|
1108
1137
|
if (moves) cardField(ctx, 'machine', ws.machine ?? null);
|
|
1109
1138
|
}
|
|
1139
|
+
if (said?.readable_id != null) {
|
|
1140
|
+
const task = cardOf(ctx.db, ctx.id);
|
|
1141
|
+
const readable = allocateReadableId(ctx.db, task.title, said.readable_id, ctx.id);
|
|
1142
|
+
run(ctx.db, 'UPDATE tasks SET readable_id=? WHERE id=?', readable, ctx.id);
|
|
1143
|
+
}
|
|
1110
1144
|
if (said?.title && String(said.title).trim()) run(ctx.db, 'UPDATE tasks SET title = ? WHERE id = ?', String(said.title).trim(), ctx.id);
|
|
1111
1145
|
if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
|
|
1112
1146
|
},
|
|
@@ -1188,7 +1222,7 @@ const ACTIONS = {
|
|
|
1188
1222
|
send_to_actor(ctx, text) {
|
|
1189
1223
|
const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
|
|
1190
1224
|
const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
1191
|
-
effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, numbered(cardNumber(ctx.db, ctx.id), text));
|
|
1225
|
+
effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, numbered(boardKey(ctx.board.root, ctx.board.slug), cardNumber(ctx.db, ctx.id), text));
|
|
1192
1226
|
run(ctx.db, 'UPDATE supercode_effects SET effect=? WHERE id=?', JSON.stringify(effect), Number(inserted.lastInsertRowid));
|
|
1193
1227
|
ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
1194
1228
|
},
|
|
@@ -1371,14 +1405,22 @@ function settleRelations(db, board, entry, id, effects) {
|
|
|
1371
1405
|
export function storedStatus(db, board, entry, id) {
|
|
1372
1406
|
const task = cardOf(db, id);
|
|
1373
1407
|
const status = statusOf(db, task);
|
|
1374
|
-
|
|
1408
|
+
// Native holds are stored honestly. Hermes lowering is an export concern;
|
|
1409
|
+
// older homes may still declare a lossy paused -> blocked storage rule.
|
|
1410
|
+
const nativeHolds = Boolean(entry.workflow.flags?.paused);
|
|
1411
|
+
if (nativeHolds && !task.archived) {
|
|
1412
|
+
if (task.paused) return { status, stored: 'paused' };
|
|
1413
|
+
if (task.blocked_by.length) return { status, stored: 'blocked' };
|
|
1414
|
+
}
|
|
1415
|
+
if (!entry.workflow.stored_as?.length) return { status, stored: nativeHolds ? storedAs(entry.workflow, status) : task.status };
|
|
1375
1416
|
const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
|
|
1376
1417
|
const rule = entry.workflow.stored_as.find((r) => holds(r.guard, scope));
|
|
1377
|
-
|
|
1418
|
+
const stored = rule?.status ?? storedAs(entry.workflow, status);
|
|
1419
|
+
return { status, stored: nativeHolds && ['blocked', 'paused'].includes(stored) ? storedAs(entry.workflow, status) : stored };
|
|
1378
1420
|
}
|
|
1379
1421
|
|
|
1380
1422
|
export function projectStatus(db, board, entry, id) {
|
|
1381
|
-
if (!entry.workflow.stored_as?.length) return;
|
|
1423
|
+
if (!entry.workflow.stored_as?.length && !entry.workflow.flags?.paused) return;
|
|
1382
1424
|
const { status, stored } = storedStatus(db, board, entry, id);
|
|
1383
1425
|
// Only a change is written: an unchanged write still commits, and a commit wakes the dispatcher that called this.
|
|
1384
1426
|
run(db, 'UPDATE tasks SET status = ? WHERE id = ? AND status IS NOT ?', stored, id, stored);
|
package/board/workspace.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { basename, dirname } from 'node:path';
|
|
|
4
4
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
5
5
|
import { inspectCheckout } from '@volter/teams/checkout';
|
|
6
6
|
import { localMachine, normalMachine, commandEnv } from './dispatch.mjs';
|
|
7
|
+
import { cardHandleOf } from './store.mjs';
|
|
7
8
|
|
|
8
9
|
// Repository identities are portable input values; the daemon owns their checkout resolution.
|
|
9
10
|
function repositoryId(value) {
|
|
@@ -19,7 +20,7 @@ export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '
|
|
|
19
20
|
let presence = null;
|
|
20
21
|
export const notePresence = (reading) => { presence = reading; };
|
|
21
22
|
async function workspaceDoor(machine, verb, value, options = {}) {
|
|
22
|
-
if (machine && presence?.known === true && normalMachine(machine) !== localMachine() &&
|
|
23
|
+
if (machine && presence?.known === true && normalMachine(machine) !== localMachine() && presence.offline?.has(normalMachine(machine))) {
|
|
23
24
|
throw new Error(`workspace on ${machine}: machine ${normalMachine(machine)} is offline`);
|
|
24
25
|
}
|
|
25
26
|
const local = !machine || normalMachine(machine) === localMachine();
|
|
@@ -42,12 +43,12 @@ const commitDoorHeld = new Map(); // machine → { until (ms), why }
|
|
|
42
43
|
* seconds here): `{ at }`, seconds or null for none. Throws when it could not be read (the machine offline or silent,
|
|
43
44
|
* its daemon without the door, no such folder there): an unread checkout is unknown, never a card with no commits.
|
|
44
45
|
*/
|
|
45
|
-
export async function cardCommitsOn(machine, path, card, branches = []) {
|
|
46
|
+
export async function cardCommitsOn(machine, path, card, branches = [], ref = null) {
|
|
46
47
|
const name = normalMachine(machine);
|
|
47
|
-
if (presence?.known === true &&
|
|
48
|
+
if (presence?.known === true && presence.offline?.has(name)) throw new Error(`machine ${name} is offline`);
|
|
48
49
|
const held = commitDoorHeld.get(name);
|
|
49
50
|
if (held && Date.now() < held.until) throw new Error(held.why);
|
|
50
|
-
const r = await runtimeDoor(machine, 'harness.v1.workspaces.commits', { path, card, branches }, { timeout: 10_000 });
|
|
51
|
+
const r = await runtimeDoor(machine, 'harness.v1.workspaces.commits', { path, card, branches, ...(ref ? { ref } : {}) }, { timeout: 10_000 });
|
|
51
52
|
if (r.code !== 0) {
|
|
52
53
|
let refusal = null; try { refusal = JSON.parse(String(r.stderr).trim().split('\n').pop()); } catch { /* said in its own words */ }
|
|
53
54
|
const missing = refusal?.code === -32601 || /unknown method/.test(refusal?.message ?? '');
|
|
@@ -92,6 +93,15 @@ export async function branchCheckouts(machine, branch) {
|
|
|
92
93
|
export async function checkoutOf(machine, repository, branch) {
|
|
93
94
|
return workspaceDoor(machine, 'resolve', repositoryId(repository), { branch });
|
|
94
95
|
}
|
|
96
|
+
// A card's solo worktree folder under the repository's `.worktrees`: the one it was made in, while its path is recorded
|
|
97
|
+
// (a card running keeps its folder), else its handle (`vo-856`, store.mjs cardHandle; `task.handle`), as the board names
|
|
98
|
+
// a card's worktree, else its id. Its branch is its own, else `wt/<id>`, as the folder named for its id had.
|
|
99
|
+
const worktreeFolder = (task) => {
|
|
100
|
+
if (task.workspace_path && basename(dirname(task.workspace_path)) === '.worktrees') return basename(task.workspace_path);
|
|
101
|
+
const branch = task.branch_name?.split('/').at(-1);
|
|
102
|
+
return task.handle && (branch === task.handle || branch?.startsWith(`${task.handle}-`)) ? task.handle : task.id;
|
|
103
|
+
};
|
|
104
|
+
const worktreeBranch = (task) => task.branch_name ?? `wt/${task.id}`;
|
|
95
105
|
export async function resolveWorkspace(task, create = false) {
|
|
96
106
|
const declared = task.workspace_anchor ?? task.workspace_path;
|
|
97
107
|
if (isWorkspacePath(declared)) {
|
|
@@ -104,19 +114,21 @@ export async function resolveWorkspace(task, create = false) {
|
|
|
104
114
|
// not move an already allocated card worktree. Machine moves and workspace
|
|
105
115
|
// retargets clear this runtime receipt; never borrow an arbitrary checkout.
|
|
106
116
|
if (task.workspace_kind === 'worktree' && task.workspace_path &&
|
|
107
|
-
basename(
|
|
117
|
+
basename(dirname(task.workspace_path)) === '.worktrees' &&
|
|
108
118
|
(!isLocalWorkspace(task) || existsSync(task.workspace_path))) {
|
|
109
119
|
const cached = await workspaceDoor(task.machine, 'inspect', task.workspace_path);
|
|
110
|
-
if (cached.linked && basename(cached.path) === task
|
|
111
|
-
cached.repository === repositoryId(declared) && cached.branch === (task
|
|
120
|
+
if (cached.linked && basename(cached.path) === worktreeFolder(task) && basename(dirname(cached.path)) === '.worktrees' &&
|
|
121
|
+
cached.repository === repositoryId(declared) && cached.branch === worktreeBranch(task)) {
|
|
112
122
|
return { ...cached, root: dirname(dirname(cached.path)) };
|
|
113
123
|
}
|
|
114
|
-
throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${task
|
|
124
|
+
throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${worktreeBranch(task)}; retarget its workspace explicitly`);
|
|
115
125
|
}
|
|
116
|
-
|
|
126
|
+
const solo = task.workspace_kind === 'worktree';
|
|
127
|
+
return workspaceDoor(task.machine, 'resolve', declared, { branch: solo ? worktreeBranch(task) : task.branch_name, solo, card: solo ? worktreeFolder(task) : task.id, create });
|
|
117
128
|
}
|
|
118
129
|
export async function checkWorkspace(board, task) {
|
|
119
130
|
if (task.workspace_kind === 'scratch') return null;
|
|
131
|
+
task = { ...task, handle: task.handle ?? cardHandleOf(board, task.id) };
|
|
120
132
|
const facts = await resolveWorkspace(task), peers = [];
|
|
121
133
|
if (task.workspace_kind === 'dir' && !facts.linked) {
|
|
122
134
|
for (const row of board.db.prepare(`SELECT t.id,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind='dir'`).all()) {
|