@volter/supercode-orchestrator 0.4.111 → 0.4.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/cli.mjs +9 -6
- package/board/dispatch.mjs +28 -24
- package/board/engine.mjs +38 -16
- package/board/rows.mjs +5 -2
- package/board/store.mjs +1 -1
- package/board/workflow.mjs +10 -2
- package/board/workspace.mjs +62 -0
- package/board/ztrack.mjs +11 -7
- package/package.json +3 -2
package/board/cli.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { basename, join, resolve } from 'node:path';
|
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { read } from '../orchestration.mjs';
|
|
12
12
|
import { view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
13
|
+
import { isLocalWorkspace } from './workspace.mjs';
|
|
13
14
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
14
15
|
import * as engine from './engine.mjs';
|
|
15
16
|
import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
|
|
@@ -22,7 +23,7 @@ Hermes Kanban's verbs, with their flags:
|
|
|
22
23
|
init [--backing sqlite|files|ztrack] [--document <arcs.md>]
|
|
23
24
|
migrate --backing ztrack --document <arcs.md> --status-map <json>
|
|
24
25
|
boards list|create|rm|switch|show|rename|set-default-workdir|export|import
|
|
25
|
-
create <title> [--body] [--assignee] [--parent]... [--workspace scratch|worktree
|
|
26
|
+
create <title> [--body] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
|
|
26
27
|
[--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
|
|
27
28
|
[--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
|
|
28
29
|
swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
@@ -189,7 +190,7 @@ const ts = (t) => {
|
|
|
189
190
|
const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
|
|
190
191
|
const cardDict = (t) => ({
|
|
191
192
|
id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
|
|
192
|
-
workspace_kind: t.workspace_kind, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
|
|
193
|
+
workspace_kind: t.workspace_kind, repository: t.workspace_anchor ?? null, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
|
|
193
194
|
created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
|
|
194
195
|
skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
|
|
195
196
|
session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
|
|
@@ -392,7 +393,8 @@ export async function runWorkflow(argv) {
|
|
|
392
393
|
out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
|
|
393
394
|
if (t.subtask_of) out.push(` subtask of: ${t.subtask_of}`);
|
|
394
395
|
if (t.tenant) out.push(` tenant: ${t.tenant}`);
|
|
395
|
-
out.push(` workspace: ${t.workspace_kind}${t.
|
|
396
|
+
out.push(` workspace: ${t.workspace_kind}${t.workspace_anchor ? `:${t.workspace_anchor}` : ''}`);
|
|
397
|
+
if (t.workspace_path) out.push(` resolved: ${t.workspace_path}`);
|
|
396
398
|
if (t.branch_name) out.push(` branch: ${t.branch_name}`);
|
|
397
399
|
if (t.skills?.length) out.push(` skills: ${t.skills.join(', ')}`);
|
|
398
400
|
if (t.model_override) out.push(` model: ${t.model_override}${t.provider_override ? ` (provider: ${t.provider_override})` : ''}`);
|
|
@@ -611,8 +613,8 @@ export async function runWorkflow(argv) {
|
|
|
611
613
|
if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
|
|
612
614
|
const results = [];
|
|
613
615
|
for (const t of targets) {
|
|
614
|
-
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined)) {
|
|
615
|
-
const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, author: args.author ?? callerAddress() ?? callerName() }));
|
|
616
|
+
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined)) {
|
|
617
|
+
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() }));
|
|
616
618
|
results.push({ id: t, specified: true, status: moved.to });
|
|
617
619
|
} else if (verb === 'decompose' && args.child?.length) {
|
|
618
620
|
const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
|
|
@@ -739,8 +741,9 @@ export async function runWorkflow(argv) {
|
|
|
739
741
|
const logDays = Number(args['log-retention-days'] ?? 30);
|
|
740
742
|
let removedWs = 0;
|
|
741
743
|
const scratchRoot = slug === DEFAULT_BOARD ? join(root, 'kanban', 'workspaces') : join(boardDir(root, slug), 'workspaces');
|
|
742
|
-
const archived = withBoard((b) => b.db.prepare("SELECT id,
|
|
744
|
+
const archived = withBoard((b) => b.db.prepare("SELECT t.id,t.workspace_kind,t.workspace_path,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.status = 'archived'").all());
|
|
743
745
|
for (const t of archived) {
|
|
746
|
+
if (!isLocalWorkspace(t)) continue;
|
|
744
747
|
if (t.workspace_kind === 'worktree' && t.workspace_path && existsSync(t.workspace_path)) {
|
|
745
748
|
// an archived card's worktree goes, as Hermes's gc removes it (the branch stays)
|
|
746
749
|
const r = spawnSync('git', ['-C', t.workspace_path, 'worktree', 'remove', '--force', t.workspace_path], { encoding: 'utf8' });
|
package/board/dispatch.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
24
24
|
import { fileURLToPath } from 'node:url';
|
|
25
25
|
import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
26
26
|
import { doors, fire, loadWorkflow, resumeOf, runSpec, requestSessionClose, retireSupersededRuns, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
|
|
27
|
+
import { checkWorkspace, assertWorkspaceOwner, resolveWorkspace } from './workspace.mjs';
|
|
27
28
|
import { evaluate, holds, render } from './expr.mjs';
|
|
28
29
|
import {
|
|
29
30
|
cardContext, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
|
|
@@ -58,7 +59,7 @@ export const supercodeBin = () => process.env.SUPERCODE_BIN || 'supercode';
|
|
|
58
59
|
// Installed board workers use their sibling Teams package, not a CLI build's retained checkout.
|
|
59
60
|
// An explicit operator override remains authoritative for source development.
|
|
60
61
|
let teamsEntry;
|
|
61
|
-
function commandEnv(as = null) {
|
|
62
|
+
export function commandEnv(as = null) {
|
|
62
63
|
if (teamsEntry === undefined) {
|
|
63
64
|
try { teamsEntry = createRequire(import.meta.url).resolve('@volter/supercode-teams/bin'); }
|
|
64
65
|
catch { teamsEntry = null; }
|
|
@@ -439,9 +440,12 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
439
440
|
for (const l of limits) if (holds(l.counts, scope)) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
|
|
440
441
|
continue;
|
|
441
442
|
}
|
|
442
|
-
let moved;
|
|
443
|
+
let moved, checked;
|
|
443
444
|
try {
|
|
445
|
+
checked = checkWorkspace(board, task);
|
|
444
446
|
moved = tx(board, (db) => {
|
|
447
|
+
assertWorkspaceOwner(db, task, checked);
|
|
448
|
+
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, task.id);
|
|
445
449
|
if (!task.assignee && plan.profile) {
|
|
446
450
|
db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(plan.profile, task.id);
|
|
447
451
|
event(db, task.id, 'assigned', { assignee: plan.profile, source: 'workflow role' });
|
|
@@ -473,7 +477,12 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
473
477
|
|
|
474
478
|
/** Launch what the card's status runs: its role's lane, in the run's session slot, told the run's prompt and doors. */
|
|
475
479
|
export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
476
|
-
|
|
480
|
+
let task = getTaskNow(board, id);
|
|
481
|
+
const checked = checkWorkspace(board, task);
|
|
482
|
+
if (checked) tx(board, (db) => {
|
|
483
|
+
assertWorkspaceOwner(db, task, checked);
|
|
484
|
+
db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
|
|
485
|
+
});
|
|
477
486
|
const status = view(board, (db) => statusOf(db, task));
|
|
478
487
|
const spec = runSpec(entry, status, task);
|
|
479
488
|
if (!spec) return null;
|
|
@@ -502,7 +511,12 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
|
502
511
|
export async function startCard(board, id, { roles = ['manager'], lanes, settings } = {}) {
|
|
503
512
|
const entry = await loadWorkflow(board.root);
|
|
504
513
|
const fleet = observeFleet();
|
|
505
|
-
const
|
|
514
|
+
const before = getTaskNow(board, id), checked = checkWorkspace(board, before);
|
|
515
|
+
const moved = tx(board, (db) => {
|
|
516
|
+
assertWorkspaceOwner(db, before, checked);
|
|
517
|
+
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
|
|
518
|
+
return fire(db, board, entry, id, entry.workflow.dispatch.start, { roles, payload: { lock: claimer() } });
|
|
519
|
+
});
|
|
506
520
|
const task = getTaskNow(board, id);
|
|
507
521
|
const spec = runSpec(entry, moved.to, task);
|
|
508
522
|
const profile = spec ? view(board, (db) => evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scopeOf(db, board, task, { params: entry.params }))) : null;
|
|
@@ -696,7 +710,7 @@ function launcherCheck(db, task, lane, fleet) {
|
|
|
696
710
|
if (!fleet.online.has(machine)) return `machine ${machine} is offline; the card starts when it is back`;
|
|
697
711
|
// a card on this machine gets its workspace made here (scratch, dir or worktree, as Hermes makes it); another
|
|
698
712
|
// machine's card names its directory there
|
|
699
|
-
if (machine !== localMachine() &&
|
|
713
|
+
if (machine !== localMachine() && task.workspace_kind === 'scratch') return `a remote card names its repository with worktree:<repository> or dir:<repository>`;
|
|
700
714
|
if (!HARNESSES[lane.harness]) return `the board opens pane sessions of ${Object.keys(HARNESSES).join(' and ')}; the lane's worker is ${lane.harness}`;
|
|
701
715
|
return null;
|
|
702
716
|
}
|
|
@@ -828,9 +842,11 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings) {
|
|
|
828
842
|
|
|
829
843
|
function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
|
|
830
844
|
const machine = normalMachine(task.machine);
|
|
831
|
-
|
|
845
|
+
{
|
|
832
846
|
const made = resolveLocalWorkspace(board, task);
|
|
833
|
-
tx(board, (db) =>
|
|
847
|
+
tx(board, (db) => {
|
|
848
|
+
db.prepare('UPDATE tasks SET workspace_path=?, branch_name=COALESCE(branch_name,?) WHERE id=?').run(made, task.workspace_kind === 'worktree' ? `wt/${task.id}` : null, task.id);
|
|
849
|
+
});
|
|
834
850
|
task = { ...task, workspace_path: made };
|
|
835
851
|
}
|
|
836
852
|
const model = task.model_override ?? lane.model;
|
|
@@ -994,6 +1010,7 @@ function paneSurvives(machine, pane) {
|
|
|
994
1010
|
/** Hermes's own worker argv and task environment, run on this machine; watched by its pid. */
|
|
995
1011
|
function launchHeadless(board, task, runId, lane, settings, { prompt = null, skills: runSkills = [], meta = {} } = {}) {
|
|
996
1012
|
const workspace = resolveLocalWorkspace(board, task);
|
|
1013
|
+
tx(board, (db) => db.prepare('UPDATE tasks SET workspace_path=?,branch_name=COALESCE(branch_name,?) WHERE id=?').run(workspace, task.workspace_kind === 'worktree' ? `wt/${task.id}` : null, task.id));
|
|
997
1014
|
// the card's own skills and the ones its status's run names (a reviewer's, for one)
|
|
998
1015
|
const skills = [...new Set([...task.skills, ...runSkills])];
|
|
999
1016
|
const args = ['-p', lane.name, '--cli', '--accept-hooks', ...skills.flatMap((s) => ['--skills', s]),
|
|
@@ -1041,20 +1058,7 @@ function rotateLog(file, max, backups) {
|
|
|
1041
1058
|
/** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
|
|
1042
1059
|
function resolveLocalWorkspace(board, task) {
|
|
1043
1060
|
const root = board.root;
|
|
1044
|
-
if (task.workspace_kind
|
|
1045
|
-
if (task.workspace_kind === 'worktree') {
|
|
1046
|
-
const anchor = task.workspace_path;
|
|
1047
|
-
if (!anchor) throw new Error(`card ${task.id} is a worktree card with no repository (worktree:<path>)`);
|
|
1048
|
-
const top = spawnSync('git', ['-C', anchor, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
1049
|
-
const repo = top.status === 0 ? top.stdout.trim() : null;
|
|
1050
|
-
const target = repo && repo === anchor ? join(repo, '.worktrees', task.id) : anchor;
|
|
1051
|
-
if (!existsSync(target)) {
|
|
1052
|
-
const branch = task.branch_name || `wt/${task.id}`;
|
|
1053
|
-
const made = spawnSync('git', ['-C', repo ?? dirname(anchor), 'worktree', 'add', '-B', branch, target], { encoding: 'utf8' });
|
|
1054
|
-
if (made.status !== 0) throw new Error(`git worktree add ${target}: ${made.stderr.trim().slice(-300)}`);
|
|
1055
|
-
}
|
|
1056
|
-
return target;
|
|
1057
|
-
}
|
|
1061
|
+
if (task.workspace_kind !== 'scratch') return resolveWorkspace(task, true).path;
|
|
1058
1062
|
const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
|
|
1059
1063
|
mkdirSync(dir, { recursive: true });
|
|
1060
1064
|
return dir;
|
|
@@ -1102,7 +1106,7 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
|
|
|
1102
1106
|
if (!task.machine) db.prepare('INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine').run(id, host);
|
|
1103
1107
|
// the lane that resumes it if it is lost, set in the same transaction (assigning first would let a tick start another)
|
|
1104
1108
|
if (assignee && assignee !== task.assignee) { db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, id); event(db, id, 'assigned', { assignee }); }
|
|
1105
|
-
if (dir && !(task.workspace_kind === 'dir' && task.workspace_path)) {
|
|
1109
|
+
if (dir && !task.workspace_anchor && !(task.workspace_kind === 'dir' && task.workspace_path)) {
|
|
1106
1110
|
db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
|
|
1107
1111
|
}
|
|
1108
1112
|
// the workflow's adopt event: the card is claimed for its implementer, whose session this now is
|
|
@@ -1124,7 +1128,7 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
|
|
|
1124
1128
|
|
|
1125
1129
|
// a notice sent within a transaction's caller that cannot await (the launcher): the same door, synchronously
|
|
1126
1130
|
function deliverNow(board, to, body, key) {
|
|
1127
|
-
const request = { op: 'send', from: boardAddress(board.label), from_name: `${board.label} board`, to, queue: false, id: key,
|
|
1131
|
+
const request = { op: 'send', from: boardAddress(board.label), from_name: `${board.label} board`, to, queue: false, id: key, subject: String(body).split('\n')[0].slice(0, 160),
|
|
1128
1132
|
body: `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)` };
|
|
1129
1133
|
const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
|
|
1130
1134
|
const answer = lastJson(r.stdout);
|
|
@@ -1133,7 +1137,7 @@ function deliverNow(board, to, body, key) {
|
|
|
1133
1137
|
|
|
1134
1138
|
async function send(from, label, to, body, key, { queue = false, ask = false } = {}) {
|
|
1135
1139
|
const request = {
|
|
1136
|
-
op: 'send', from, from_name: `${label} board`, to, queue, id: key,
|
|
1140
|
+
op: 'send', from, from_name: `${label} board`, to, queue, id: key, subject: String(body).split('\n')[0].slice(0, 160),
|
|
1137
1141
|
// a question the board waits on is answered by a reply; a notice is not
|
|
1138
1142
|
body: ask ? `[A question from the ${label} board] ${body}` : `[Board notice from the ${label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
|
|
1139
1143
|
};
|
package/board/engine.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { spawn, spawnSync } from 'node:child_process';
|
|
11
11
|
import { existsSync, rmSync } from 'node:fs';
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
|
+
import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
|
|
13
14
|
import { DatabaseSync } from 'node:sqlite';
|
|
14
15
|
import { view, claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
15
16
|
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
@@ -35,7 +36,7 @@ export function getTask(db, id) {
|
|
|
35
36
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
36
37
|
if (!row) return null;
|
|
37
38
|
const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
|
|
38
|
-
return { ...row, 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 };
|
|
39
|
+
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 };
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
function must(db, id) {
|
|
@@ -48,13 +49,12 @@ const parentsOf = (db, id) => all(db, 'SELECT t.id, t.status FROM tasks t JOIN t
|
|
|
48
49
|
const childrenOf = (db, id) => all(db, 'SELECT child_id AS id FROM task_links WHERE parent_id = ?', id).map((r) => r.id);
|
|
49
50
|
const parentsDone = (db, id) => parentsOf(db, id).every((p) => p.status === 'done' || p.status === 'archived');
|
|
50
51
|
|
|
51
|
-
/** The workspace word Hermes's CLI takes: `scratch`, `worktree
|
|
52
|
+
/** The workspace word Hermes's CLI takes: `scratch`, `worktree:<repository>`, `dir:<repository>`. */
|
|
52
53
|
export function parseWorkspace(word = 'scratch') {
|
|
53
54
|
const [kind, ...rest] = String(word).split(':');
|
|
54
55
|
const path = rest.join(':') || null;
|
|
55
|
-
if (!WORKSPACE_KINDS.includes(kind)) throw new Error(`workspace is scratch | worktree
|
|
56
|
-
if (kind === 'dir' && !path) throw new Error('a dir workspace names its
|
|
57
|
-
if (path && !/^(\/|~|[A-Za-z]:[\\/])/.test(path)) throw new Error(`workspace path ${path} is not absolute`);
|
|
56
|
+
if (!WORKSPACE_KINDS.includes(kind)) throw new Error(`workspace is scratch | worktree:<repository> | dir:<repository>, not ${word}`);
|
|
57
|
+
if (kind === 'dir' && !path) throw new Error('a dir workspace names its repository (dir:volter-ai/editor)');
|
|
58
58
|
return { kind, path };
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -155,8 +155,20 @@ export function createCard(board, spec) {
|
|
|
155
155
|
const title = String(spec.title ?? '').trim();
|
|
156
156
|
if (!title) throw new Error('a card has a title');
|
|
157
157
|
const ws = parseWorkspace(spec.workspace ?? 'scratch');
|
|
158
|
-
if (spec.branch && ws.kind
|
|
158
|
+
if (spec.branch && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
|
|
159
159
|
const parents = [...new Set((spec.parents ?? []).filter(Boolean))];
|
|
160
|
+
const project = spec.project ? projectOf(board.root, spec.project) : null;
|
|
161
|
+
if (project) {
|
|
162
|
+
if (ws.kind === 'scratch') ws.kind = 'worktree';
|
|
163
|
+
if (ws.kind === 'worktree' && !ws.path) ws.path = project.primary_path;
|
|
164
|
+
}
|
|
165
|
+
const machineHint = spec.machine ?? (spec.subtaskOf ? getTask(board.db, spec.subtaskOf)?.machine : null);
|
|
166
|
+
const declared = declareWorkspace(ws.kind, ws.path, machineHint, spec.branch);
|
|
167
|
+
const workspaceTask = { workspace_kind: ws.kind, workspace_path: declared.path, workspace_anchor: declared.repository, branch_name: declared.branch, machine: machineHint };
|
|
168
|
+
// Logical repositories may be filed before a machine has registered them; launch refuses missing mappings.
|
|
169
|
+
const checked = ws.kind === 'dir' && declared.path ? checkWorkspace(board, workspaceTask) : null;
|
|
170
|
+
ws.path = declared.path;
|
|
171
|
+
spec = { ...spec, branch: declared.branch ?? spec.branch };
|
|
160
172
|
const initial = spec.initialStatus ?? null;
|
|
161
173
|
if (initial && !['blocked', 'running'].includes(initial)) throw new Error('initial status is blocked or running');
|
|
162
174
|
if (spec.provider && !spec.model) throw new Error('a provider needs a model');
|
|
@@ -172,17 +184,16 @@ export function createCard(board, spec) {
|
|
|
172
184
|
if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
|
|
173
185
|
let id = newTaskId();
|
|
174
186
|
while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
|
|
175
|
-
|
|
176
|
-
|
|
187
|
+
assertWorkspaceOwner(db, workspaceTask, checked);
|
|
188
|
+
// The repository declaration stays separate from the resolved worktree path.
|
|
177
189
|
if (project) {
|
|
178
190
|
if (ws.kind === 'scratch') ws.kind = 'worktree';
|
|
179
|
-
if (ws.kind === 'worktree' && !ws.path && project.primary_path) ws.path = join(project.primary_path, '.worktrees', id);
|
|
180
191
|
if (ws.kind === 'worktree' && !spec.branch) {
|
|
181
192
|
const t = title.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
|
|
182
193
|
spec = { ...spec, branch: `${project.slug}/${id}${t ? `-${t}` : ''}` };
|
|
183
194
|
}
|
|
184
195
|
}
|
|
185
|
-
spec = { ...spec, project: project?.id ?? null };
|
|
196
|
+
spec = { ...spec, project: project?.id ?? null, branch: spec.branch ?? (ws.kind === 'worktree' ? `wt/${id}` : null) };
|
|
186
197
|
const t = now();
|
|
187
198
|
run(db, `INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, branch_name,
|
|
188
199
|
tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id,
|
|
@@ -193,7 +204,7 @@ export function createCard(board, spec) {
|
|
|
193
204
|
spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
|
|
194
205
|
spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
|
|
195
206
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
196
|
-
|
|
207
|
+
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);
|
|
197
208
|
const entry = workflowFor(board.root);
|
|
198
209
|
for (const p of parents) addDependency(db, entry, id, p);
|
|
199
210
|
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
@@ -236,7 +247,9 @@ function wouldCycle(db, parent, child) {
|
|
|
236
247
|
export function setMachine(board, id, machine) {
|
|
237
248
|
return tx(board, (db) => {
|
|
238
249
|
const task = must(db, id);
|
|
239
|
-
if (task.
|
|
250
|
+
if (task.current_run_id) throw new Error(`${id} has an open run on ${task.machine}; end it before moving it`);
|
|
251
|
+
if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw new Error('declare the repository and branch with workflow specify --workspace before moving a legacy path card');
|
|
252
|
+
if (machine !== task.machine) run(db, 'UPDATE tasks SET workspace_path=NULL WHERE id=?', id);
|
|
240
253
|
run(db, 'INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine', id, machine || null);
|
|
241
254
|
event(db, id, 'machine_set', { machine: machine || null, was: task.machine });
|
|
242
255
|
});
|
|
@@ -292,7 +305,7 @@ export function extendClaim(db, id, { ttl, reason, extra = {} }) {
|
|
|
292
305
|
*/
|
|
293
306
|
function cleanupWorkspace(board, id) {
|
|
294
307
|
const task = view(board, (db) => getTask(db, id));
|
|
295
|
-
if (!task?.workspace_path || !['scratch', 'worktree'].includes(task.workspace_kind)) return;
|
|
308
|
+
if (!task?.workspace_path || !isLocalWorkspace(task) || !['scratch', 'worktree'].includes(task.workspace_kind)) return;
|
|
296
309
|
const waiting = view(board, (db) => one(db, "SELECT 1 FROM task_links l JOIN tasks t ON t.id = l.child_id WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived') LIMIT 1", id));
|
|
297
310
|
if (waiting || !existsSync(task.workspace_path)) return;
|
|
298
311
|
if (task.workspace_kind === 'worktree') { spawnSync('git', ['-C', task.workspace_path, 'worktree', 'remove', '--force', task.workspace_path], { encoding: 'utf8' }); return; }
|
|
@@ -446,7 +459,16 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
|
|
|
446
459
|
export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
|
|
447
460
|
export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
|
|
448
461
|
export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
|
|
449
|
-
export
|
|
462
|
+
export function specify(board, id, { title = null, body = null, workspace = null, branch = null, author = 'user', roles } = {}) {
|
|
463
|
+
let declaration = null;
|
|
464
|
+
if (workspace != null) {
|
|
465
|
+
const task = getTask(board.db, id), ws = parseWorkspace(workspace);
|
|
466
|
+
if (task.current_run_id) throw new Error('end the current run before changing its workspace');
|
|
467
|
+
declaration = { kind: ws.kind, ...declareWorkspace(ws.kind, ws.path, task.machine, branch) };
|
|
468
|
+
if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
|
|
469
|
+
}
|
|
470
|
+
return act(board, id, 'specify', { roles, payload: { title, body, author, ...(declaration ? { workspace: declaration } : {}) } });
|
|
471
|
+
}
|
|
450
472
|
export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
|
|
451
473
|
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
|
|
452
474
|
export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
|
|
@@ -462,7 +484,7 @@ export async function decompose(board, id, children, { author = 'user', roles }
|
|
|
462
484
|
const task = view(board, (db) => must(db, id));
|
|
463
485
|
const ids = children.map((c) => createCard(board, {
|
|
464
486
|
title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
|
|
465
|
-
workspace: task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
|
|
487
|
+
workspace: task.workspace_anchor ? `${task.workspace_kind}:${task.workspace_anchor}` : task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
|
|
466
488
|
}).id);
|
|
467
489
|
await act(board, id, 'decompose', { roles, payload: { children: ids, author } });
|
|
468
490
|
return ids;
|
|
@@ -564,7 +586,7 @@ const cap = (text, n) => (text && text.length > n ? `${text.slice(0, n)}… (${t
|
|
|
564
586
|
export function cardContext(db, id) {
|
|
565
587
|
const task = must(db, id);
|
|
566
588
|
const out = [`# Card ${task.id}: ${task.title}`, ''];
|
|
567
|
-
out.push(`Status: ${task.status}. Machine: ${task.machine ?? 'unset'}. Workspace: ${task.workspace_kind}${task.workspace_path ? ` ${task.workspace_path}` : ''}.${task.subtask_of ? ` A subtask of ${task.subtask_of}.` : ''}`);
|
|
589
|
+
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}.` : ''}`);
|
|
568
590
|
if (task.body) out.push('', cap(task.body, 8 * 1024));
|
|
569
591
|
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);
|
|
570
592
|
if (parents.length) {
|
package/board/rows.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export class RuntimeRows {
|
|
|
13
13
|
const columns=mem.prepare(`PRAGMA table_info(${quote(name)})`).all();
|
|
14
14
|
const keys=columns.filter(c=>c.pk).sort((a,b)=>a.pk-b.pk).map(c=>c.name);
|
|
15
15
|
if (!keys.length) keys.push('rowid');
|
|
16
|
-
this.tables.set(name,{name,sql,keys});
|
|
16
|
+
this.tables.set(name,{name,sql,keys,columns});
|
|
17
17
|
const key=prefix=>`json_array(${keys.map(k=>`${prefix}.${quote(k)}`).join(',')})`;
|
|
18
18
|
for(const [suffix,verb,prefix] of [['i','INSERT','NEW'],['u','UPDATE','NEW'],['d','DELETE','OLD']])
|
|
19
19
|
mem.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(`_runtime_${name}_${suffix}`)} AFTER ${verb} ON main.${quote(name)} BEGIN INSERT OR IGNORE INTO _runtime_dirty VALUES ('${name}',${key(prefix)}); END`);
|
|
@@ -53,7 +53,7 @@ export class RuntimeRows {
|
|
|
53
53
|
const table=this.tables.get(name);
|
|
54
54
|
const row=mem.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key))??null;
|
|
55
55
|
if(name==='tasks')return {name:'@runtime',key:JSON.parse(key)[0],value:row&&Object.fromEntries(Object.entries(row).filter(([k])=>RUNTIME.has(k))),source:{name,key,row}};
|
|
56
|
-
if(name==='supercode_cards')return {name:'@clock',key:JSON.parse(key)[0],value:row&&{since:row.since,after_fired:row.after_fired,next_run_at:row.next_run_at},source:{name,key,row}};
|
|
56
|
+
if(name==='supercode_cards')return {name:'@clock',key:JSON.parse(key)[0],value:row&&{workspace_root:row.workspace_root,since:row.since,after_fired:row.after_fired,next_run_at:row.next_run_at},source:{name,key,row}};
|
|
57
57
|
return {name,key,value:row,source:{name,key,row}};
|
|
58
58
|
}).filter(change=>{
|
|
59
59
|
if(all)return true;
|
|
@@ -78,6 +78,9 @@ export class RuntimeRows {
|
|
|
78
78
|
for(const name of ['tasks','supercode_cards']) {
|
|
79
79
|
const table=this.tables.get(name); if(!table)return;
|
|
80
80
|
this.db.exec(table.sql.replace(/^CREATE TABLE (?:IF NOT EXISTS )?(?:"[^"]+"|\w+)/,`CREATE TABLE IF NOT EXISTS ${quote('read_'+name)}`));
|
|
81
|
+
const columns=this.db.prepare(`PRAGMA table_info(${quote('read_'+name)})`).all();
|
|
82
|
+
for(const c of table.columns) if(!columns.some(old=>old.name===c.name))this.db.exec(`ALTER TABLE ${quote('read_'+name)} ADD COLUMN ${quote(c.name)} ${c.type}${c.dflt_value==null?'':` DEFAULT ${c.dflt_value}`}`);
|
|
83
|
+
|
|
81
84
|
}
|
|
82
85
|
const rows=previous===revision-1?changes.filter(c=>['tasks','supercode_cards'].includes(c.source.name)).map(c=>c.source):this.db.prepare("SELECT name,key,value FROM runtime_rows WHERE name IN ('@task_cache','@card_cache')").all().map(r=>({name:r.name==='@task_cache'?'tasks':'supercode_cards',key:JSON.stringify([r.key]),row:json(r.value,null)}));
|
|
83
86
|
for(const {name,key,row} of rows) {
|
package/board/store.mjs
CHANGED
|
@@ -72,7 +72,7 @@ const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY K
|
|
|
72
72
|
CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
|
|
73
73
|
// `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
|
|
74
74
|
// `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
|
|
75
|
-
const OURS_COLUMNS = { 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' };
|
|
75
|
+
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' };
|
|
76
76
|
|
|
77
77
|
// ------------------------------------------------------------------ backings
|
|
78
78
|
//
|
package/board/workflow.mjs
CHANGED
|
@@ -344,7 +344,7 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
344
344
|
// `sender`: the roles the event was sent with (a validator can ask who is asking)
|
|
345
345
|
const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), ...extra, sender: roles } });
|
|
346
346
|
const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
|
|
347
|
-
if (!allowed.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}`);
|
|
347
|
+
if (!allowed.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map((t) => t.description).filter(Boolean).join(' ')}`);
|
|
348
348
|
const taken = allowed.find((t) => holds(t.guard ?? null, scope));
|
|
349
349
|
if (!taken) { const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply to it now`); none.noTransition = true; throw none; }
|
|
350
350
|
for (const v of taken.validate ?? []) {
|
|
@@ -557,7 +557,8 @@ const ACTIONS = {
|
|
|
557
557
|
if (!['paused', 'archived'].includes(name)) throw new Error(`unknown flag ${name}`);
|
|
558
558
|
if (name === 'paused') {
|
|
559
559
|
const was = json(one(ctx.db, 'SELECT paused FROM supercode_cards WHERE task_id = ?', ctx.id)?.paused, null);
|
|
560
|
-
|
|
560
|
+
const clearBy = ctx.entry.workflow.flags?.paused?.clear_by ?? ['pauser', 'owner'];
|
|
561
|
+
if (!value && was && !clearBy.some((role) => role === 'pauser' ? was.by === actorOf() : ctx.scope.sender?.includes(role))) throw new Error(`only ${clearBy.join(' or ')} may resume this card; ask the manager with your reason`);
|
|
561
562
|
if (value && !String(reason ?? '').trim()) throw new Error('pause needs a reason');
|
|
562
563
|
cardField(ctx, name, value ? JSON.stringify({ by: actorOf(), reason }) : null);
|
|
563
564
|
} else cardField(ctx, name, Number(Boolean(value)));
|
|
@@ -682,6 +683,13 @@ const ACTIONS = {
|
|
|
682
683
|
run(ctx.db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, ctx.id);
|
|
683
684
|
},
|
|
684
685
|
specify(ctx, said) {
|
|
686
|
+
if (said?.workspace) {
|
|
687
|
+
const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
|
|
688
|
+
if (task.current_run_id) throw new Error('end the current run before changing its workspace');
|
|
689
|
+
run(ctx.db, 'UPDATE tasks SET workspace_kind=?,workspace_path=?,branch_name=? WHERE id=?', ws.kind, ws.path ?? null, ws.branch ?? null, ctx.id);
|
|
690
|
+
cardField(ctx, 'workspace_anchor', ws.repository ?? null);
|
|
691
|
+
cardField(ctx, 'workspace_root', null);
|
|
692
|
+
}
|
|
685
693
|
if (said?.title && String(said.title).trim()) run(ctx.db, 'UPDATE tasks SET title = ? WHERE id = ?', String(said.title).trim(), ctx.id);
|
|
686
694
|
if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
|
|
687
695
|
},
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Filesystem/RPC work happens before a board transaction; the ownership check is pure.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { Workspaces, inspectCheckout, repositoryId, teamsHome } from '@volter/supercode-teams/workspaces';
|
|
4
|
+
import { localMachine, normalMachine, supercodeBin, commandEnv } from './dispatch.mjs';
|
|
5
|
+
export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '');
|
|
6
|
+
function workspaceDoor(machine, verb, value, options = {}) {
|
|
7
|
+
if (!machine || normalMachine(machine) === localMachine()) {
|
|
8
|
+
const map = new Workspaces(teamsHome());
|
|
9
|
+
if (verb === 'inspect') return inspectCheckout(value);
|
|
10
|
+
if (verb === 'register') return map.register(value);
|
|
11
|
+
return map.resolve(value, options.branch, options);
|
|
12
|
+
}
|
|
13
|
+
const args = ['teams', 'workspace', verb, value, '--machine', machine];
|
|
14
|
+
for (const key of ['branch', 'card']) if (options[key]) args.push(`--${key}`, options[key]);
|
|
15
|
+
for (const key of ['solo', 'create']) if (options[key]) args.push(`--${key}`);
|
|
16
|
+
const r = spawnSync(supercodeBin(), args, { env: commandEnv(), encoding: 'utf8', timeout: 60_000 });
|
|
17
|
+
if (r.status !== 0) throw new Error(`workspace on ${machine}: ${(r.stderr || r.stdout || r.error?.message).trim()}`);
|
|
18
|
+
return JSON.parse(r.stdout);
|
|
19
|
+
}
|
|
20
|
+
export function declareWorkspace(kind, value, machine, branch = null) {
|
|
21
|
+
if (kind === 'scratch') return { repository: null, branch: null, path: null };
|
|
22
|
+
if (!value) throw new Error('workspace names a repository, for example worktree:volter-ai/editor');
|
|
23
|
+
if (!isWorkspacePath(value)) return { repository: repositoryId(value), branch, path: null };
|
|
24
|
+
// A legacy path supplied explicitly is checked and registered on its machine, never copied into the card.
|
|
25
|
+
const info = workspaceDoor(machine, 'inspect', value);
|
|
26
|
+
if (kind === 'worktree' && info.linked) throw new Error('worktree workspace already names a linked worktree; name its repository for a solo worktree');
|
|
27
|
+
const row = workspaceDoor(machine, 'register', value);
|
|
28
|
+
return { repository: row.repository, branch: branch ?? (kind === 'dir' ? row.branch : null), path: kind === 'dir' ? row.path : null };
|
|
29
|
+
}
|
|
30
|
+
export function resolveWorkspace(task, create = false) {
|
|
31
|
+
const declared = task.workspace_anchor ?? task.workspace_path;
|
|
32
|
+
if (isWorkspacePath(declared)) {
|
|
33
|
+
const info = workspaceDoor(task.machine, 'inspect', declared);
|
|
34
|
+
if (task.workspace_kind === 'dir') return info;
|
|
35
|
+
if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
|
|
36
|
+
throw new Error('legacy worktree path needs registration and a repository declaration before launch');
|
|
37
|
+
}
|
|
38
|
+
return workspaceDoor(task.machine, 'resolve', declared, { branch: task.branch_name, solo: task.workspace_kind === 'worktree', card: task.id, create });
|
|
39
|
+
}
|
|
40
|
+
export function checkWorkspace(board, task) {
|
|
41
|
+
if (task.workspace_kind === 'scratch') return null;
|
|
42
|
+
const facts = resolveWorkspace(task), peers = [];
|
|
43
|
+
if (task.workspace_kind === 'dir' && !facts.linked) {
|
|
44
|
+
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()) {
|
|
45
|
+
if (row.id === task.id || normalMachine(row.machine ?? localMachine()) !== normalMachine(task.machine ?? localMachine())) continue;
|
|
46
|
+
let other;
|
|
47
|
+
try { other = resolveWorkspace({ ...row, workspace_kind: 'dir' }); } catch { continue; } // That card's own launch reports its invalid workspace.
|
|
48
|
+
if (!other.linked && other.root === facts.root) peers.push(row.id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const checked = { ...facts, mainRoot: task.workspace_kind === 'dir' && !facts.linked ? facts.root : null, peers };
|
|
52
|
+
assertWorkspaceOwner(board.db, task, checked);
|
|
53
|
+
return checked;
|
|
54
|
+
}
|
|
55
|
+
export function assertWorkspaceOwner(db, task, checked) {
|
|
56
|
+
if (!checked?.mainRoot) return;
|
|
57
|
+
const conflict = db.prepare(`SELECT t.id,c.machine,c.workspace_root 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()
|
|
58
|
+
.find(row => row.id !== task.id && normalMachine(row.machine ?? localMachine()) === normalMachine(task.machine ?? localMachine()) && (row.workspace_root === checked.mainRoot || checked.peers?.includes(row.id)));
|
|
59
|
+
if (conflict) throw new Error(`main checkout is already used by running card ${conflict.id}; declare worktree:${checked.repository} for a solo worktree`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const isLocalWorkspace = task => !task.machine || normalMachine(task.machine) === localMachine();
|
package/board/ztrack.mjs
CHANGED
|
@@ -130,10 +130,10 @@ class ZtrackBoard {
|
|
|
130
130
|
const documentChanges = changes.filter(({source})=>{
|
|
131
131
|
if(source.name==='tasks') {
|
|
132
132
|
const before=this.rows.cachedTasks[JSON.parse(source.key)[0]];
|
|
133
|
-
if(source.row?.workspace_kind
|
|
133
|
+
if(source.row?.workspace_kind==='dir' && !this.rows.cachedCards[JSON.parse(source.key)[0]]?.workspace_anchor && source.row?.workspace_path!==before?.workspace_path)return true;
|
|
134
134
|
return JSON.stringify(canonical(source.row,new Set([...RUNTIME,'status'])))!==JSON.stringify(canonical(before,new Set([...RUNTIME,'status'])));
|
|
135
135
|
}
|
|
136
|
-
if(source.name==='supercode_cards')return JSON.stringify(canonical(source.row,new Set(['since','after_fired','next_run_at'])))!==JSON.stringify(canonical(this.rows.cachedCards[JSON.parse(source.key)[0]],new Set(['since','after_fired','next_run_at'])));
|
|
136
|
+
if(source.name==='supercode_cards')return JSON.stringify(canonical(source.row,new Set(['since','after_fired','next_run_at','workspace_root'])))!==JSON.stringify(canonical(this.rows.cachedCards[JSON.parse(source.key)[0]],new Set(['since','after_fired','next_run_at','workspace_root'])));
|
|
137
137
|
return false;
|
|
138
138
|
});
|
|
139
139
|
this.publishedFingerprints = {};
|
|
@@ -242,7 +242,11 @@ class ZtrackBoard {
|
|
|
242
242
|
this.mem.exec(`CREATE TEMP VIEW tasks AS SELECT ${fields.join(',')} FROM source.read_tasks t LEFT JOIN source.read_supercode_cards c ON c.task_id=t.id`);
|
|
243
243
|
continue;
|
|
244
244
|
}
|
|
245
|
-
if(native && name==='supercode_cards') {
|
|
245
|
+
if(native && name==='supercode_cards') {
|
|
246
|
+
const have=new Set(this.mem.prepare('PRAGMA source.table_info(read_supercode_cards)').all().map(c=>c.name));
|
|
247
|
+
const fields=this.mem.prepare('PRAGMA main.table_info(supercode_cards)').all().map(c=>have.has(c.name)?quote(c.name):`${c.dflt_value??'NULL'} AS ${quote(c.name)}`);
|
|
248
|
+
this.mem.exec(`CREATE TEMP VIEW supercode_cards AS SELECT ${fields.join(',')} FROM source.read_supercode_cards`);continue;
|
|
249
|
+
}
|
|
246
250
|
if(native && name==='task_links') {this.mem.exec("CREATE TEMP VIEW task_links AS SELECT json_extract(b.value,'$.id') parent_id,r.task_id child_id FROM source.read_supercode_cards r,json_each(r.blocked_by) b WHERE json_extract(b.value,'$.kind')='card'");continue;}
|
|
247
251
|
if(name==='task_links') {
|
|
248
252
|
this.mem.exec(`CREATE TEMP VIEW task_links AS SELECT json_extract(b.value,'$.id') parent_id,r.key child_id FROM source.runtime_rows r,json_each(json_extract(r.value,'$.blocked_by')) b WHERE r.name='@card_cache' AND json_extract(b.value,'$.kind')='card'`);
|
|
@@ -298,7 +302,7 @@ class ZtrackBoard {
|
|
|
298
302
|
const keys = Object.keys(row);
|
|
299
303
|
if (keys.length) this.mem.prepare(`UPDATE tasks SET ${keys.map(k => `${quote(k)}=?`).join(',')} WHERE id=?`).run(...keys.map(k => row[k]), id);
|
|
300
304
|
}
|
|
301
|
-
for (const [id, row] of Object.entries(this.saved.clocks ?? {})) this.mem.prepare('UPDATE supercode_cards SET since=?,after_fired=?,next_run_at=? WHERE task_id=?').run(row.since??null,row.after_fired??null,row.next_run_at??null,id);
|
|
305
|
+
for (const [id, row] of Object.entries(this.saved.clocks ?? {})) this.mem.prepare('UPDATE supercode_cards SET since=?,after_fired=?,next_run_at=?,workspace_root=? WHERE task_id=?').run(row.since??null,row.after_fired??null,row.next_run_at??null,row.workspace_root??null,id);
|
|
302
306
|
this.rows.watch(this.mem);
|
|
303
307
|
if (observe) this.documentEvents();
|
|
304
308
|
return;
|
|
@@ -381,8 +385,8 @@ class ZtrackBoard {
|
|
|
381
385
|
const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
|
|
382
386
|
const task = { ...meta, ...this.saved.runtime?.[id], id, title: card.title, body: stored.body,
|
|
383
387
|
status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? now(), priority: card.priority ?? 0,
|
|
384
|
-
workspace_kind: workspace?.[1] ?? 'scratch', workspace_path:
|
|
385
|
-
const state = { ...this.saved.clocks?.[id], task_id: id, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
|
|
388
|
+
workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: this.saved.runtime?.[id]?.workspace_path ?? (/^(\/|~|[A-Za-z]:[\\/])/.test(workspace?.[2] ?? '') ? workspace[2] : null), branch_name: card.branch ?? null };
|
|
389
|
+
const state = { ...this.saved.clocks?.[id], task_id: id, workspace_anchor: workspace?.[2] ?? null, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
|
|
386
390
|
blocked_by: JSON.stringify((card.relations ?? []).map((r) => ({ ...(meta.blocked_by ?? []).find((b) => b.id === nativeId(r.issueId)), id: nativeId(r.issueId), kind: /^[ma]-/.test(r.issueId) ? 'message' : 'card' }))),
|
|
387
391
|
paused: card.paused ? JSON.stringify(card.paused) : null, archived: meta.archived ? 1 : 0, every: card.every ?? null };
|
|
388
392
|
return { task, state };
|
|
@@ -438,7 +442,7 @@ class ZtrackBoard {
|
|
|
438
442
|
const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
|
|
439
443
|
if (c.archived) metadata.archived = true;
|
|
440
444
|
if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
|
|
441
|
-
cards.set(row.id, { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_kind !== 'scratch' && row.workspace_path ? `:${row.workspace_path}` : ''),
|
|
445
|
+
cards.set(row.id, { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_kind !== 'scratch' && (c.workspace_anchor ?? row.workspace_path) ? `:${c.workspace_anchor ?? row.workspace_path}` : ''),
|
|
442
446
|
branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
|
|
443
447
|
relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: this.fileIds.get(b.id) ?? b.id })), acceptanceCriteria: [] });
|
|
444
448
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.113",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@volter/supercode-harness-sdk": "^0.3.41",
|
|
28
28
|
"@volter/ztrack": "^2.4.16",
|
|
29
|
-
"yaml": "^2.9.0"
|
|
29
|
+
"yaml": "^2.9.0",
|
|
30
|
+
"@volter/supercode-teams": "^0.2.120"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@chat-adapter/state-memory": "4.39.0",
|