@volter/supercode-orchestrator 0.4.148 → 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 +129 -56
- package/board/cli.mjs +21 -23
- package/board/dispatch.mjs +39 -24
- package/board/engine.mjs +50 -18
- package/board/store.mjs +6 -3
- package/board/tools.mjs +2 -2
- package/board/workflow.mjs +20 -27
- package/board/workspace.mjs +26 -0
- package/board/ztrack.mjs +779 -542
- package/hermes-locks.mjs +1 -20
- package/mcp/hermes-kanban-tools.json +40 -18
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
package/board/engine.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function getTask(db, id) {
|
|
|
37
37
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
38
38
|
if (!row) return null;
|
|
39
39
|
const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
|
|
40
|
-
return { ...row, workspace_anchor: ours?.workspace_anchor ?? null,
|
|
40
|
+
return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function must(db, id) {
|
|
@@ -152,10 +152,36 @@ function projectOf(root, idOrSlug) {
|
|
|
152
152
|
} catch { return null; } finally { db.close(); }
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A card's Tasks are its subtasks (the document renders them as its Tasks section). Task lines written in an arc's
|
|
157
|
+
* body (`## Tasks` then `- [ ] step`, its indented lines the step's own text) are answered as the steps to file as
|
|
158
|
+
* its subtasks, and leave the body. A Tasks section with no task line is the body's own text.
|
|
159
|
+
*/
|
|
160
|
+
export function bodyTasks(body) {
|
|
161
|
+
const lines = String(body ?? '').split('\n');
|
|
162
|
+
const at = lines.findIndex((line) => /^#{2,3}\s+Tasks\s*$/i.test(line));
|
|
163
|
+
if (at < 0) return { body, tasks: [] };
|
|
164
|
+
let end = lines.findIndex((line, i) => i > at && /^#{1,3}\s/.test(line));
|
|
165
|
+
if (end < 0) end = lines.length;
|
|
166
|
+
const section = lines.slice(at + 1, end);
|
|
167
|
+
if (!section.some((line) => /^[-*] \[[ xX]\]/.test(line))) return { body, tasks: [] };
|
|
168
|
+
const tasks = [];
|
|
169
|
+
for (const line of section) {
|
|
170
|
+
if (!line.trim()) continue;
|
|
171
|
+
const step = /^[-*] \[ \]\s+(\S.*)$/.exec(line);
|
|
172
|
+
if (step) tasks.push({ title: step[1].trim(), body: [] });
|
|
173
|
+
else if (/^\s+\S/.test(line) && tasks.length) tasks.at(-1).body.push(line.replace(/^\s{1,4}/, ''));
|
|
174
|
+
else throw new Error("A card's Tasks section holds open task lines ('- [ ] <step>', each filed as a subtask, with its own indented lines); record finished work as a comment and move other text out of the section");
|
|
175
|
+
}
|
|
176
|
+
const rest = [...lines.slice(0, at), ...lines.slice(end)].join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
177
|
+
return { body: rest || null, tasks: tasks.map((t) => ({ title: t.title, body: t.body.join('\n') || null })) };
|
|
178
|
+
}
|
|
179
|
+
|
|
155
180
|
export function createCard(board, spec) {
|
|
156
|
-
if (spec.reviewOnly !== undefined && typeof spec.reviewOnly !== 'boolean') throw new Error('reviewOnly must be a boolean');
|
|
157
181
|
const title = String(spec.title ?? '').trim();
|
|
158
182
|
if (!title) throw new Error('a card has a title');
|
|
183
|
+
const steps = spec.subtaskOf ? [] : bodyTasks(spec.body).tasks;
|
|
184
|
+
if (steps.length) spec = { ...spec, body: bodyTasks(spec.body).body };
|
|
159
185
|
const ws = parseWorkspace(spec.workspace ?? 'scratch');
|
|
160
186
|
if (spec.branch && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
|
|
161
187
|
const parents = [...new Set((spec.parents ?? []).filter(Boolean))];
|
|
@@ -184,10 +210,6 @@ export function createCard(board, spec) {
|
|
|
184
210
|
if (missing.length) throw new Error(`unknown parent card(s): ${missing.join(', ')}`);
|
|
185
211
|
const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
|
|
186
212
|
const entry = workflowFor(board.root);
|
|
187
|
-
if (spec.reviewOnly) {
|
|
188
|
-
const permitted = entry.params.review_only_by;
|
|
189
|
-
if (arc || spec.every || initial || !Array.isArray(permitted) || !callerRoles(db, arc?.id ?? '').some(role => permitted.includes(role))) throw new Error('Only a permitted manager creates a review-only arc, without recurrence, subtasks or an initial-status override.');
|
|
190
|
-
}
|
|
191
213
|
if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
|
|
192
214
|
if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
|
|
193
215
|
let id = newTaskId();
|
|
@@ -216,13 +238,13 @@ export function createCard(board, spec) {
|
|
|
216
238
|
spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
|
|
217
239
|
spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
|
|
218
240
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
219
|
-
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root
|
|
241
|
+
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root) VALUES (?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null);
|
|
220
242
|
for (const p of parents) addDependency(db, entry, id, p);
|
|
221
243
|
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
222
244
|
// the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
|
|
223
245
|
const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
|
|
224
|
-
|
|
225
|
-
event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status,
|
|
246
|
+
every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
|
|
247
|
+
event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
226
248
|
if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
|
|
227
249
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
228
250
|
if (arc) {
|
|
@@ -232,7 +254,9 @@ export function createCard(board, spec) {
|
|
|
232
254
|
}
|
|
233
255
|
if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
|
|
234
256
|
else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
|
|
235
|
-
|
|
257
|
+
// the body's task lines, as subtasks the creator filed, in this same write
|
|
258
|
+
const subtasks = steps.map((step) => createCard(board, { ...step, subtaskOf: id, assignee: spec.assignee || null }).id);
|
|
259
|
+
return { id, existing: false, ...(subtasks.length ? { subtasks } : {}) };
|
|
236
260
|
});
|
|
237
261
|
}
|
|
238
262
|
|
|
@@ -345,8 +369,9 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
345
369
|
const entry = await loadWorkflow(board.root);
|
|
346
370
|
if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
|
|
347
371
|
refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
|
|
348
|
-
|
|
349
|
-
if (
|
|
372
|
+
const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
|
|
373
|
+
if (evidencePhase) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
|
|
374
|
+
if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
|
|
350
375
|
const result = tx(board, (db) => {
|
|
351
376
|
roles ??= callerRoles(db, id);
|
|
352
377
|
const r = fire(db, board, entry, id, name, { roles, payload, extra });
|
|
@@ -368,9 +393,14 @@ export async function requestStateMany(board, ids, payload = {}) {
|
|
|
368
393
|
const entry = await loadWorkflow(board.root);
|
|
369
394
|
const prepared = new Map(targets.map(id => {
|
|
370
395
|
const roles = callerRoles(board.db, id);
|
|
371
|
-
|
|
396
|
+
const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
|
|
397
|
+
const evidence = inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
398
|
+
// An ordinary request mails a decision to the manager, not a completion.
|
|
399
|
+
// Keep a failed source observation in that mail. A reviewer recording an
|
|
400
|
+
// actual verdict still has to verify the requested PR head.
|
|
401
|
+
if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
|
|
402
|
+
return [id, evidence];
|
|
372
403
|
}));
|
|
373
|
-
for (const evidence of prepared.values()) if (evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
|
|
374
404
|
const result = tx(board, db => {
|
|
375
405
|
const effects = [];
|
|
376
406
|
const results = targets.map(id => withCompletionEvidence(board, id, prepared.get(id), () => {
|
|
@@ -494,7 +524,7 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
|
|
|
494
524
|
export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
|
|
495
525
|
export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
|
|
496
526
|
export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
|
|
497
|
-
export function specify(board, id, { title = null, body = null, workspace = null, branch = null,
|
|
527
|
+
export async function specify(board, id, { title = null, body = null, workspace = null, branch = null, author = 'user', roles } = {}) {
|
|
498
528
|
let declaration = null;
|
|
499
529
|
if (workspace != null || branch != null) {
|
|
500
530
|
const task = getTask(board.db, id);
|
|
@@ -507,8 +537,11 @@ export function specify(board, id, { title = null, body = null, workspace = null
|
|
|
507
537
|
if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
|
|
508
538
|
if (declaration.kind === 'dir') declaration.path = checkWorkspace(board, { ...task, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch }).path;
|
|
509
539
|
}
|
|
510
|
-
|
|
511
|
-
|
|
540
|
+
// an arc's new task lines are filed as its subtasks once the specify itself is accepted
|
|
541
|
+
const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
|
|
542
|
+
const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, author, ...(declaration ? { workspace: declaration } : {}) } });
|
|
543
|
+
for (const step of steps.tasks) createCard(board, { ...step, subtaskOf: id, assignee: getTask(board.db, id)?.assignee || null });
|
|
544
|
+
return done;
|
|
512
545
|
}
|
|
513
546
|
export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
|
|
514
547
|
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
|
|
@@ -665,7 +698,6 @@ export function cardContext(db, id) {
|
|
|
665
698
|
const task = must(db, id);
|
|
666
699
|
const out = [`# Card ${task.id}: ${task.title}`, ''];
|
|
667
700
|
out.push(`Status: ${task.status}. Machine: ${task.machine ?? 'unset'}. Workspace: ${task.workspace_kind}${task.workspace_anchor ? ` ${task.workspace_anchor}` : task.workspace_path ? ` ${task.workspace_path}` : ''}${task.branch_name ? ` (branch ${task.branch_name})` : ''}.${task.subtask_of ? ` A subtask of ${task.subtask_of}.` : ''}`);
|
|
668
|
-
if (task.review_only) out.push('Purpose: review-only. Record the independent verdict with request-state --state done --reason <verdict> --evidence <artifact>; its creator or manager accepts this review job.');
|
|
669
701
|
if (task.body) out.push('', cap(task.body, 8 * 1024));
|
|
670
702
|
const parents = all(db, 'SELECT t.id, t.title, t.result FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?', id);
|
|
671
703
|
if (parents.length) {
|
package/board/store.mjs
CHANGED
|
@@ -73,7 +73,7 @@ const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY K
|
|
|
73
73
|
CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
|
|
74
74
|
// `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
|
|
75
75
|
// `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
|
|
76
|
-
const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT',
|
|
76
|
+
const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER' };
|
|
77
77
|
|
|
78
78
|
// ------------------------------------------------------------------ backings
|
|
79
79
|
//
|
|
@@ -172,18 +172,21 @@ export const tx = (board, fn) => {
|
|
|
172
172
|
export const view = (board, fn) => fn(board.db);
|
|
173
173
|
|
|
174
174
|
// A deferred native transaction, with the whole operation retried on an optimistic conflict.
|
|
175
|
-
// There is no separate application writer lock or contention timeout.
|
|
175
|
+
// There is no separate application writer lock or contention timeout. A write inside one is part of it.
|
|
176
|
+
const inTx = new WeakSet();
|
|
176
177
|
function sqliteTx(db, fn) {
|
|
178
|
+
if (inTx.has(db)) return fn(db);
|
|
177
179
|
for (;;) {
|
|
178
180
|
try {
|
|
179
181
|
db.exec('BEGIN');
|
|
182
|
+
inTx.add(db);
|
|
180
183
|
const out=fn(db);
|
|
181
184
|
db.exec('COMMIT');
|
|
182
185
|
return out;
|
|
183
186
|
} catch(error) {
|
|
184
187
|
try {db.exec('ROLLBACK');} catch {}
|
|
185
188
|
if (!/locked|busy/i.test(error.message))throw error;
|
|
186
|
-
}
|
|
189
|
+
} finally { inTx.delete(db); }
|
|
187
190
|
}
|
|
188
191
|
}
|
|
189
192
|
|
package/board/tools.mjs
CHANGED
|
@@ -80,7 +80,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
80
80
|
return JSON.stringify({
|
|
81
81
|
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,
|
|
82
82
|
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,
|
|
83
|
-
current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override
|
|
83
|
+
current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override },
|
|
84
84
|
parents: card.parents.map((p) => p.id), children: card.children,
|
|
85
85
|
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 })),
|
|
86
86
|
worker_context: card.context,
|
|
@@ -176,7 +176,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
176
176
|
title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
177
177
|
priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
|
|
178
178
|
maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
|
|
179
|
-
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
|
179
|
+
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
|
180
180
|
});
|
|
181
181
|
return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
|
|
182
182
|
}
|
package/board/workflow.mjs
CHANGED
|
@@ -104,7 +104,8 @@ function pattern(p) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// the time of the latest commit that names `id` in its message, on any ref of the repository at `path` (a worktree's
|
|
107
|
-
// 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.
|
|
108
109
|
const commitTimes = new Map();
|
|
109
110
|
function observeLastCommit(path, id) {
|
|
110
111
|
if (!path) return null;
|
|
@@ -112,7 +113,7 @@ function observeLastCommit(path, id) {
|
|
|
112
113
|
const cached = commitTimes.get(key);
|
|
113
114
|
if (cached && now() - cached.at < 15) return cached.value;
|
|
114
115
|
const readCommit = (repo) => {
|
|
115
|
-
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 });
|
|
116
117
|
const value = Number(r.stdout?.trim());
|
|
117
118
|
return { repo: r.status === 0, value: Number.isFinite(value) && value > 0 ? value : null };
|
|
118
119
|
};
|
|
@@ -201,11 +202,8 @@ function onlyTicks(was, body) {
|
|
|
201
202
|
|
|
202
203
|
/** Seconds a card's runs spent by role, and the rest of its life as `waiting`. */
|
|
203
204
|
export function runWorkingSeconds(row, at = now()) {
|
|
204
|
-
const metadata = typeof row.metadata === 'string' ? json(row.metadata, {}) : row.metadata;
|
|
205
|
-
const session = metadata?.supercode ?? {};
|
|
206
205
|
const end = Number(row.ended_at ?? at);
|
|
207
|
-
|
|
208
|
-
return Math.max(0, end - Number(row.started_at ?? end) - waiting);
|
|
206
|
+
return Math.max(0, end - Number(row.started_at ?? end));
|
|
209
207
|
}
|
|
210
208
|
|
|
211
209
|
function timeOf(db, task) {
|
|
@@ -250,7 +248,6 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
|
|
|
250
248
|
...task,
|
|
251
249
|
creator_address: creatorOf(db, task.id),
|
|
252
250
|
completion_owner_address: completionOwnerOf(db, task.id),
|
|
253
|
-
review_only: Boolean(state.review_only),
|
|
254
251
|
latest_review: latestReview(db, task.id) ?? {},
|
|
255
252
|
review_candidate: reviewCandidate(board, task.id) ?? {},
|
|
256
253
|
ci: completionEvidence(board, task.id),
|
|
@@ -363,6 +360,11 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
|
|
|
363
360
|
cancellation?.outcome === 'not_issued' && cancellation?.live === false && cancellation?.pane === null) return;
|
|
364
361
|
const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
|
|
365
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;
|
|
366
368
|
const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
|
|
367
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);
|
|
368
370
|
const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
|
|
@@ -642,17 +644,6 @@ const ACTIONS = {
|
|
|
642
644
|
const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [].concat(as), payload, extra: { lanes: lanesIn(ctx.entry) } });
|
|
643
645
|
ctx.effects.push(...result.effects);
|
|
644
646
|
},
|
|
645
|
-
wait_run(ctx, waiting) {
|
|
646
|
-
if (typeof waiting !== 'boolean') throw new Error('wait_run requires a boolean');
|
|
647
|
-
const at = now();
|
|
648
|
-
runSession(ctx, session => {
|
|
649
|
-
if (waiting) return { ...session, wait_started_at: session.wait_started_at ?? at };
|
|
650
|
-
if (session.wait_started_at == null) return session;
|
|
651
|
-
return { ...session, wait_started_at: null,
|
|
652
|
-
wait_seconds: Number(session.wait_seconds ?? 0) + Math.max(0, at - Number(session.wait_started_at)),
|
|
653
|
-
goal_nudged_at: at };
|
|
654
|
-
});
|
|
655
|
-
},
|
|
656
647
|
record(ctx, args, scope) {
|
|
657
648
|
const kind = typeof args === 'string' ? args : args?.kind;
|
|
658
649
|
const given = typeof args === 'object' && args?.payload ? args.payload : null;
|
|
@@ -772,12 +763,6 @@ const ACTIONS = {
|
|
|
772
763
|
run(ctx.db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, ctx.id);
|
|
773
764
|
},
|
|
774
765
|
specify(ctx, said) {
|
|
775
|
-
if (said?.review_only != null) {
|
|
776
|
-
const task = cardOf(ctx.db, ctx.id);
|
|
777
|
-
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.');
|
|
778
|
-
cardField(ctx, 'review_only', said.review_only ? 1 : 0);
|
|
779
|
-
record(ctx.db, ctx.id, 'review_mode_changed', { review_only: said.review_only }, null);
|
|
780
|
-
}
|
|
781
766
|
if (said?.workspace) {
|
|
782
767
|
const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
|
|
783
768
|
if (task.current_run_id) throw new Error('end the current run before changing its workspace');
|
|
@@ -867,6 +852,8 @@ const ACTIONS = {
|
|
|
867
852
|
requester: actorOf() ?? ctx.scope.requester,
|
|
868
853
|
window_seconds: Math.max(1, Math.min(60, Number(ctx.entry.params.request_batch_seconds) || 10)),
|
|
869
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.`;
|
|
870
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);
|
|
871
858
|
ctx.effects.push({ kind: 'board_message', id: ctx.id });
|
|
872
859
|
},
|
|
@@ -1041,13 +1028,19 @@ function settleRelations(db, board, entry, id, effects) {
|
|
|
1041
1028
|
}
|
|
1042
1029
|
|
|
1043
1030
|
|
|
1044
|
-
|
|
1045
|
-
|
|
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) {
|
|
1046
1033
|
const task = cardOf(db, id);
|
|
1047
1034
|
const status = statusOf(db, task);
|
|
1035
|
+
if (!entry.workflow.stored_as?.length) return { status, stored: task.status };
|
|
1048
1036
|
const scope = scopeOf(db, board, task, { params: entry.params });
|
|
1049
1037
|
const rule = entry.workflow.stored_as.find((r) => holds(r.guard, scope));
|
|
1050
|
-
|
|
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);
|
|
1051
1044
|
run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
|
|
1052
1045
|
run(db, 'INSERT INTO supercode_cards (task_id, stage) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage', id, status);
|
|
1053
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) {
|