@volter/supercode-orchestrator 0.5.73 → 0.5.74
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/agents.mjs +34 -24
- package/bin/orchestrator.mjs +2 -0
- package/board/agent-declarations.mjs +5 -5
- package/board/ci.mjs +98 -102
- package/board/cli.mjs +25 -55
- package/board/dispatch.mjs +314 -190
- package/board/engine.mjs +50 -28
- package/board/git-document.mjs +68 -20
- package/board/machine-fleet.mjs +27 -14
- package/board/runtime-door.mjs +17 -14
- package/board/tools.mjs +2 -2
- package/board/workflow.mjs +13 -12
- package/board/workspace.mjs +22 -15
- package/board/ztrack.mjs +14 -4
- package/hermes-cli.mjs +4 -12
- package/loop.mjs +7 -7
- package/package.json +2 -2
- package/bin/machine-fleet.mjs +0 -38
- package/bin/runtime-door.mjs +0 -23
package/board/engine.mjs
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
|
|
9
9
|
// observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
|
|
10
10
|
import { current } from './call.mjs';
|
|
11
|
-
import { spawn,
|
|
11
|
+
import { spawn, execFile } from 'node:child_process';
|
|
12
12
|
import { existsSync, rmSync } from 'node:fs';
|
|
13
13
|
import { join, resolve } from 'node:path';
|
|
14
|
-
import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace } from './workspace.mjs';
|
|
14
|
+
import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
|
|
15
15
|
import { DatabaseSync } from 'node:sqlite';
|
|
16
16
|
import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
17
17
|
import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
@@ -181,6 +181,25 @@ export function bodyTasks(body) {
|
|
|
181
181
|
return { body: rest || null, tasks: tasks.map((t) => ({ title: t.title, body: t.body.join('\n') || null })) };
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* A card's workspace declared through its machine's door when it names a path (a `dir`, a legacy checkout path), awaited
|
|
186
|
+
* before the card is filed: the filing (createCard) is one write that asks no machine. Answers the spec to file.
|
|
187
|
+
*/
|
|
188
|
+
export async function prepareCard(board, spec) {
|
|
189
|
+
const ws = parseWorkspace(spec.workspace ?? 'scratch');
|
|
190
|
+
const project = spec.project ? projectOf(board.root, spec.project) : null;
|
|
191
|
+
if (project) {
|
|
192
|
+
if (ws.kind === 'scratch') ws.kind = 'worktree';
|
|
193
|
+
if (ws.kind === 'worktree' && !ws.path) ws.path = project.primary_path;
|
|
194
|
+
}
|
|
195
|
+
if (plainWorkspace(ws.kind, ws.path, spec.branch)) return spec;
|
|
196
|
+
const machineHint = spec.machine ?? (spec.subtaskOf ? getTask(board.db, spec.subtaskOf)?.machine : null);
|
|
197
|
+
const declared = await declareWorkspace(ws.kind, ws.path, machineHint, spec.branch);
|
|
198
|
+
const workspaceTask = { workspace_kind: ws.kind, workspace_path: declared.path, workspace_anchor: declared.repository, branch_name: declared.branch, machine: machineHint };
|
|
199
|
+
// Logical repositories may be filed before a machine has registered them; launch refuses missing mappings.
|
|
200
|
+
const checked = ws.kind === 'dir' && declared.path ? await checkWorkspace(board, workspaceTask) : null;
|
|
201
|
+
return { ...spec, workspaceDeclared: { declared, checked } };
|
|
202
|
+
}
|
|
184
203
|
export function createCard(board, spec) {
|
|
185
204
|
const title = String(spec.title ?? '').trim();
|
|
186
205
|
if (!title) throw new Error('a card has a title');
|
|
@@ -196,10 +215,11 @@ export function createCard(board, spec) {
|
|
|
196
215
|
if (ws.kind === 'worktree' && !ws.path) ws.path = project.primary_path;
|
|
197
216
|
}
|
|
198
217
|
const machineHint = spec.machine ?? (spec.subtaskOf ? getTask(board.db, spec.subtaskOf)?.machine : null);
|
|
199
|
-
const declared =
|
|
218
|
+
const declared = spec.workspaceDeclared?.declared ?? plainWorkspace(ws.kind, ws.path, spec.branch);
|
|
219
|
+
if (!declared) throw new Error('a workspace path is declared through its machine before the card is filed (prepareCard)');
|
|
200
220
|
const workspaceTask = { workspace_kind: ws.kind, workspace_path: declared.path, workspace_anchor: declared.repository, branch_name: declared.branch, machine: machineHint };
|
|
201
221
|
// Logical repositories may be filed before a machine has registered them; launch refuses missing mappings.
|
|
202
|
-
const checked =
|
|
222
|
+
const checked = spec.workspaceDeclared?.checked ?? null;
|
|
203
223
|
ws.path = declared.path;
|
|
204
224
|
spec = { ...spec, branch: declared.branch ?? spec.branch };
|
|
205
225
|
const initial = spec.initialStatus ?? null;
|
|
@@ -367,12 +387,13 @@ export function extendClaim(db, id, { ttl, reason, extra = {} }) {
|
|
|
367
387
|
* A completed card's workspace goes, as Hermes cleans up on completion: a scratch directory under the board's own
|
|
368
388
|
* workspaces, or a worktree (its branch stays); never while a child still waits on it, never a `dir`.
|
|
369
389
|
*/
|
|
370
|
-
function cleanupWorkspace(board, id) {
|
|
390
|
+
async function cleanupWorkspace(board, id) {
|
|
371
391
|
const task = view(board, (db) => getTask(db, id));
|
|
372
392
|
if (!task?.workspace_path || !isLocalWorkspace(task) || !['scratch', 'worktree'].includes(task.workspace_kind)) return;
|
|
373
393
|
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));
|
|
374
394
|
if (waiting || !existsSync(task.workspace_path)) return;
|
|
375
|
-
|
|
395
|
+
// awaited, never a wait that holds the event loop
|
|
396
|
+
if (task.workspace_kind === 'worktree') { await new Promise((resolve) => execFile('git', ['-C', task.workspace_path, 'worktree', 'remove', '--force', task.workspace_path], { encoding: 'utf8' }, () => resolve())); return; }
|
|
376
397
|
const managed = resolve(board.slug === 'default' ? join(board.root, 'kanban', 'workspaces') : join(board.root, 'kanban', 'boards', board.slug, 'workspaces'));
|
|
377
398
|
if (resolve(task.workspace_path).startsWith(`${managed}/`)) rmSync(task.workspace_path, { recursive: true, force: true });
|
|
378
399
|
}
|
|
@@ -406,9 +427,9 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
406
427
|
const entry = await loadWorkflow(board.root);
|
|
407
428
|
if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
|
|
408
429
|
refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
|
|
409
|
-
refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
|
|
430
|
+
await refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
|
|
410
431
|
const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
|
|
411
|
-
if (evidencePhase) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
|
|
432
|
+
if (evidencePhase) payload = { ...payload, ci: await observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
|
|
412
433
|
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.');
|
|
413
434
|
const result = tx(board, (db) => {
|
|
414
435
|
roles ??= callerRoles(db, id);
|
|
@@ -423,7 +444,7 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
423
444
|
});
|
|
424
445
|
await runEffects(board, result.effects);
|
|
425
446
|
// A card this verb closed ends its agents' assignments; the verb hands the ends on itself (agent-declarations.mjs).
|
|
426
|
-
if (result.moved) { const { endFinishedAgents } = await import('./agent-declarations.mjs'); endFinishedAgents(board, [id]); }
|
|
447
|
+
if (result.moved) { const { endFinishedAgents } = await import('./agent-declarations.mjs'); await endFinishedAgents(board, [id]); }
|
|
427
448
|
return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}) };
|
|
428
449
|
}
|
|
429
450
|
|
|
@@ -431,16 +452,16 @@ export async function requestStateMany(board, ids, payload = {}) {
|
|
|
431
452
|
const targets = [...new Set(ids.map(String))];
|
|
432
453
|
if (!targets.length) throw new Error('A state request names at least one card.');
|
|
433
454
|
const entry = await loadWorkflow(board.root);
|
|
434
|
-
const prepared = new Map(targets.map(id => {
|
|
455
|
+
const prepared = new Map(await Promise.all(targets.map(async id => {
|
|
435
456
|
const roles = callerRoles(board.db, id);
|
|
436
457
|
const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
|
|
437
|
-
const evidence = inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
458
|
+
const evidence = await inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
438
459
|
// An ordinary request mails a decision to the manager, not a completion.
|
|
439
460
|
// Keep a failed source observation in that mail. A reviewer recording an
|
|
440
461
|
// actual verdict still has to verify the requested PR head.
|
|
441
462
|
if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
|
|
442
463
|
return [id, evidence];
|
|
443
|
-
}));
|
|
464
|
+
})));
|
|
444
465
|
const result = tx(board, db => {
|
|
445
466
|
const effects = [];
|
|
446
467
|
const results = targets.map(id => withCompletionEvidence(board, id, prepared.get(id), () => {
|
|
@@ -451,15 +472,15 @@ export async function requestStateMany(board, ids, payload = {}) {
|
|
|
451
472
|
return { results, effects };
|
|
452
473
|
});
|
|
453
474
|
await runEffects(board, result.effects);
|
|
454
|
-
{ const { endFinishedAgents } = await import('./agent-declarations.mjs'); endFinishedAgents(board, targets); }
|
|
475
|
+
{ const { endFinishedAgents } = await import('./agent-declarations.mjs'); await endFinishedAgents(board, targets); }
|
|
455
476
|
return result.results;
|
|
456
477
|
}
|
|
457
478
|
|
|
458
479
|
/** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
|
|
459
480
|
export async function goto(board, id, status, { roles = null, payload = {} } = {}) {
|
|
460
481
|
const entry = await loadWorkflow(board.root);
|
|
461
|
-
refreshCardInputs(board, id, { commits: true });
|
|
462
|
-
observeCompletionEvidence(board, id, entry, payload);
|
|
482
|
+
await refreshCardInputs(board, id, { commits: true });
|
|
483
|
+
await observeCompletionEvidence(board, id, entry, payload);
|
|
463
484
|
const { name, refusal, from } = (() => {
|
|
464
485
|
const db=board.db;
|
|
465
486
|
roles ??= callerRoles(db, id);
|
|
@@ -553,10 +574,10 @@ export async function runEffects(board, effects = [], { pending: due = false, el
|
|
|
553
574
|
/** One effect's I/O: true when it is done, false when it is not done yet (the caller counts the attempt). */
|
|
554
575
|
async function runEffect(board, e) {
|
|
555
576
|
if (e.kind === 'session_notice') { const { sessionNotice } = await import('./dispatch.mjs'); return await sessionNotice(board, e) !== false; }
|
|
556
|
-
if (e.kind === 'cleanup_workspace') { cleanupWorkspace(board, e.id); return true; }
|
|
557
|
-
if (e.kind === 'declare_card_agent') { const { declareCardAgentEffect } = await import('./agent-declarations.mjs'); return declareCardAgentEffect(board, e) !== false; }
|
|
577
|
+
if (e.kind === 'cleanup_workspace') { await cleanupWorkspace(board, e.id); return true; }
|
|
578
|
+
if (e.kind === 'declare_card_agent') { const { declareCardAgentEffect } = await import('./agent-declarations.mjs'); return (await declareCardAgentEffect(board, e)) !== false; }
|
|
558
579
|
// a verb's own ask files its card's mail at once (a local write; the mailbox delivers it)
|
|
559
|
-
if (e.kind === 'board_message') { const { fileBoardMail } = await import('./dispatch.mjs'); fileBoardMail(board, { undelivered: [] }, e.id); return true; }
|
|
580
|
+
if (e.kind === 'board_message') { const { fileBoardMail } = await import('./dispatch.mjs'); await fileBoardMail(board, { undelivered: [] }, e.id); return true; }
|
|
560
581
|
if (e.kind === 'merge_reviewed') { const { mergeReviewed } = await import('./dispatch.mjs'); return await mergeReviewed(board, e) !== false; }
|
|
561
582
|
if (e.kind === 'invalidate_descendants') { tx(board, (db) => invalidateDescendants(db, board, e.id)); return true; }
|
|
562
583
|
if (e.kind === 'stop_run') { stopWorker(board, e.runId); return true; }
|
|
@@ -624,14 +645,14 @@ function invalidateDescendants(db, board, id) {
|
|
|
624
645
|
|
|
625
646
|
// each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
|
|
626
647
|
|
|
627
|
-
export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null, commit = null, ci_run = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = {}) =>
|
|
628
|
-
act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run, branch, pr, pr_head, reviewed_sha } });
|
|
648
|
+
export const complete = async (board, id, { result = null, summary = null, metadata = null, roles, runId = null, commit = null, ci_run = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = {}) =>
|
|
649
|
+
await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run, branch, pr, pr_head, reviewed_sha } });
|
|
629
650
|
// a block names the one thing that ends it: a card it waits on (`on`), or the message whose answer it waits for (`waiting_on`)
|
|
630
|
-
export const block = (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
|
|
631
|
-
act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
|
|
651
|
+
export const block = async (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
|
|
652
|
+
await act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
|
|
632
653
|
export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
|
|
633
|
-
export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, ci_run = null, branch = null, pr = null, pr_head = null } = {}) =>
|
|
634
|
-
act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run, branch, pr, pr_head } });
|
|
654
|
+
export const requestReview = async (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, ci_run = null, branch = null, pr = null, pr_head = null } = {}) =>
|
|
655
|
+
await act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run, branch, pr, pr_head } });
|
|
635
656
|
export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
|
|
636
657
|
export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
|
|
637
658
|
export const reopenReview = (board, id, { roles, reason = null } = {}) => act(board, id, 'reopen_review', { roles, payload: { reason } });
|
|
@@ -651,10 +672,10 @@ export async function specify(board, id, { title = null, body = null, workspace
|
|
|
651
672
|
const ws = parseWorkspace(word);
|
|
652
673
|
if (task.current_run_id) throw new Error('end the current run before changing its workspace');
|
|
653
674
|
if (branch != null && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
|
|
654
|
-
declaration = { kind: ws.kind, ...declareWorkspace(ws.kind, ws.path, target, branch), ...(machine != null && target !== task.machine ? { machine: target } : {}) };
|
|
675
|
+
declaration = { kind: ws.kind, ...(await declareWorkspace(ws.kind, ws.path, target, branch)), ...(machine != null && target !== task.machine ? { machine: target } : {}) };
|
|
655
676
|
if (declaration.repository === task.workspace_anchor) declaration.branch ??= task.branch_name;
|
|
656
677
|
if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
|
|
657
|
-
if (declaration.kind === 'dir') declaration.path = checkWorkspace(board, { ...task, machine: target, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch }).path;
|
|
678
|
+
if (declaration.kind === 'dir') declaration.path = (await checkWorkspace(board, { ...task, machine: target, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch })).path;
|
|
658
679
|
}
|
|
659
680
|
// an arc's new task lines are filed as its subtasks once the specify itself is accepted
|
|
660
681
|
const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
|
|
@@ -670,10 +691,11 @@ export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) =
|
|
|
670
691
|
export async function decompose(board, id, children, { author = 'user', roles } = {}) {
|
|
671
692
|
if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
|
|
672
693
|
const task = view(board, (db) => must(db, id));
|
|
673
|
-
const ids =
|
|
694
|
+
const ids = [];
|
|
695
|
+
for (const c of children) ids.push(createCard(board, await prepareCard(board, {
|
|
674
696
|
title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
|
|
675
697
|
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,
|
|
676
|
-
}).id);
|
|
698
|
+
})).id);
|
|
677
699
|
await act(board, id, 'decompose', { roles, payload: { children: ids, author } });
|
|
678
700
|
return ids;
|
|
679
701
|
}
|
package/board/git-document.mjs
CHANGED
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
// the two texts of a board document merged by ztrack's own document merge, in which a hand edit wins its cards. Nothing
|
|
7
7
|
// either side wrote is dropped; a merge that cannot be made stops the push and says why, leaving both texts in the
|
|
8
8
|
// checkout's history.
|
|
9
|
+
//
|
|
10
|
+
// The remote is never waited on (D192): a fetch runs beside the process, and what it brings is merged in one local step
|
|
11
|
+
// on this process's loop, serialized with its writes, as a write's own commit is; a push runs beside the process too.
|
|
12
|
+
// What is owed is git's own state, never a queue in memory: board documents that differ from HEAD are a commit owed,
|
|
13
|
+
// and a HEAD ahead of its upstream is a push owed. A process that ends with either owed leaves it in the checkout, and
|
|
14
|
+
// the next one to open each board derives it again and does it (`derive`): after that board's own recovery has written
|
|
15
|
+
// what its store accepted, over every document the board has (the open one, its archive and each rotated archive).
|
|
9
16
|
import { current } from './call.mjs';
|
|
10
|
-
import { execFileSync, spawn } from 'node:child_process';
|
|
17
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
11
18
|
import { readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
19
|
import { basename, dirname, relative } from 'node:path';
|
|
13
20
|
|
|
@@ -25,6 +32,17 @@ const indexed = (cwd, args) => {
|
|
|
25
32
|
};
|
|
26
33
|
|
|
27
34
|
const FETCH_EVERY_MS = 15_000;
|
|
35
|
+
// A fetch beside the process, the network's part: its result is merged (locally, in one step) when it lands.
|
|
36
|
+
const fetchAside = (cwd, branch, timeout = 120_000) => new Promise((resolve) => execFile('git', ['fetch', '--quiet', 'origin', branch], { cwd, timeout }, (error) => resolve(!error)));
|
|
37
|
+
const fetching = new Map(); // checkout root → the fetch in flight (one at a time per checkout, in this process)
|
|
38
|
+
const derived = new Set(); // boards (by their open document) whose owed commit this process has derived from git's state
|
|
39
|
+
const pushDerived = new Set(); // checkouts whose owed push this process has derived
|
|
40
|
+
|
|
41
|
+
/** The fetches in flight, landed and merged, until none is left: a process that exits after a write awaits this first. */
|
|
42
|
+
export async function settleDocuments() {
|
|
43
|
+
// until none is left: a verb answered while one landed may have started another
|
|
44
|
+
while (fetching.size) await Promise.all([...fetching.values()]);
|
|
45
|
+
}
|
|
28
46
|
/** The background push: `git push`, then its error kept for `behind()` (or the last one cleared when it landed). */
|
|
29
47
|
const PUSH = `const {spawnSync}=require('node:child_process'),fs=require('node:fs');const [branch,failure,temporary]=process.argv.slice(1);
|
|
30
48
|
const r=spawnSync('git',['push','--quiet','origin','HEAD:'+branch],{encoding:'utf8',windowsHide:true});
|
|
@@ -40,6 +58,29 @@ export class GitDocument {
|
|
|
40
58
|
this.merge = merge; // (base, theirs, ours, path) => text | null, ztrack's mergeBoardDocument
|
|
41
59
|
}
|
|
42
60
|
|
|
61
|
+
/** Whether this board's owed commit is still to be derived in this process (its board calls `derive` after its first
|
|
62
|
+
* recovery, when `paths` is its whole inventory). */
|
|
63
|
+
get underived() { return this.usable && !derived.has(this.paths[0]); }
|
|
64
|
+
|
|
65
|
+
/** What an earlier process left owed, from git's own state: this board's documents that differ from HEAD are
|
|
66
|
+
* committed (a write, or a recovered one, that ended before its commit), and a HEAD ahead of its upstream is pushed.
|
|
67
|
+
* Once per board per process, by the board, after its recovery and with every document it has in `paths`. */
|
|
68
|
+
derive() {
|
|
69
|
+
if (!this.usable) return;
|
|
70
|
+
derived.add(this.paths[0]);
|
|
71
|
+
this.commitDirty('board: documents written before this process started');
|
|
72
|
+
if (pushDerived.has(this.root)) return;
|
|
73
|
+
pushDerived.add(this.root);
|
|
74
|
+
const branch = this.branch();
|
|
75
|
+
if (branch && this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** This board's documents that differ from HEAD, committed as the board (a recovery's writes are no doer's). */
|
|
79
|
+
commitDirty(message) {
|
|
80
|
+
if (!this.usable) return;
|
|
81
|
+
if (quiet(this.root, ['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))])?.trim()) this.commit(null, message);
|
|
82
|
+
}
|
|
83
|
+
|
|
43
84
|
get usable() { return this.root !== null; }
|
|
44
85
|
rel(path) { return relative(this.root, path); }
|
|
45
86
|
/** The checkout's branch, or null on no branch (a detached HEAD has nowhere to push and nothing to pull). */
|
|
@@ -66,13 +107,31 @@ export class GitDocument {
|
|
|
66
107
|
if (Date.now() - fetchedAt(this.root) < FETCH_EVERY_MS) return;
|
|
67
108
|
const branch = this.branch();
|
|
68
109
|
if (!branch) return this.detached();
|
|
69
|
-
|
|
70
|
-
|
|
110
|
+
this.fetchThenTake(branch);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The fetch beside the process; when it lands, what was pushed is taken (take) in one local step. One at a time. */
|
|
114
|
+
fetchThenTake(branch, timeout) {
|
|
115
|
+
if (fetching.has(this.root)) return fetching.get(this.root);
|
|
116
|
+
const root = this.root;
|
|
117
|
+
const landing = fetchAside(root, branch, timeout).then((fetched) => {
|
|
118
|
+
if (!fetched) return; // offline: a later write asks again
|
|
119
|
+
try { this.take(branch); } catch (error) { current().err(`board: ${error.message}\n`); }
|
|
120
|
+
}).finally(() => fetching.delete(root));
|
|
121
|
+
fetching.set(root, landing);
|
|
122
|
+
return landing;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** After a fetch: what was pushed merged into this checkout (local, serialized with this process's writes), then a
|
|
126
|
+
* push of what this checkout holds that the remote lacks, beside the process. */
|
|
127
|
+
take(branch) {
|
|
71
128
|
const behind = Number(git(this.root, ['rev-list', '--count', `HEAD..origin/${branch}`]).trim());
|
|
72
|
-
if (behind
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
129
|
+
if (behind > 0) {
|
|
130
|
+
const dirty = git(this.root, ['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))]).trim();
|
|
131
|
+
if (dirty) return; // a write's documents are not committed yet: its commit is owed, and the next fetch takes this
|
|
132
|
+
this.integrate(branch, 'board');
|
|
133
|
+
}
|
|
134
|
+
if (this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
|
|
76
135
|
}
|
|
77
136
|
|
|
78
137
|
// After a write: commit the board documents as their doer, then push, taking anything pushed meanwhile.
|
|
@@ -136,9 +195,8 @@ export class GitDocument {
|
|
|
136
195
|
if (!this.usable || this.pushing) return;
|
|
137
196
|
const branch = this.branch();
|
|
138
197
|
if (!branch || !this.unpushed(branch)) return;
|
|
139
|
-
// A remote that hangs costs
|
|
140
|
-
|
|
141
|
-
if (this.unpushed(branch)) this.publish(branch, 'board');
|
|
198
|
+
// A remote that hangs costs this fetch 15 s, beside the round, never the round.
|
|
199
|
+
this.fetchThenTake(branch, 15_000);
|
|
142
200
|
}
|
|
143
201
|
|
|
144
202
|
/** Whether this checkout holds board commits the remote does not have. */
|
|
@@ -146,16 +204,6 @@ export class GitDocument {
|
|
|
146
204
|
return Number(quiet(this.root, ['rev-list', '--count', `origin/${branch}..HEAD`])?.trim() || 0) > 0;
|
|
147
205
|
}
|
|
148
206
|
|
|
149
|
-
/** Push what this checkout holds, taking anything pushed meanwhile. */
|
|
150
|
-
publish(branch, who) {
|
|
151
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
152
|
-
if (quiet(this.root, ['push', '--quiet', 'origin', `HEAD:${branch}`]) !== null) { try { rmSync(this.failurePath(), { force: true }); } catch { /* none */ } return; }
|
|
153
|
-
if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) throw new Error('the board documents were committed here but could not be pushed: the remote did not answer');
|
|
154
|
-
this.integrate(branch, who);
|
|
155
|
-
}
|
|
156
|
-
throw new Error('the board documents were committed here but the push was refused three times');
|
|
157
|
-
}
|
|
158
|
-
|
|
159
207
|
// Take what was pushed with a merge: it works beside the other edits in this checkout (the manager's own files), which
|
|
160
208
|
// a rebase refuses. A board document both sides changed is merged by the board's own document merge; any other
|
|
161
209
|
// conflict leaves the checkout as it was and says so.
|
package/board/machine-fleet.mjs
CHANGED
|
@@ -4,32 +4,45 @@
|
|
|
4
4
|
// (`capabilities`: its board process guard's harnesses, and whether it has the keyed launch door). Teams' session feed
|
|
5
5
|
// is for viewers; dispatch never waits on it (D137, D138).
|
|
6
6
|
//
|
|
7
|
-
// Every machine is read at once, each within DEADLINE_MS (
|
|
7
|
+
// Every machine is read at once, each within DEADLINE_MS, in this process through its door (doors.mjs), so one hung machine or relay costs a
|
|
8
8
|
// reading that long, not the sum of them. A machine that answered is `online`. A remote machine that did not is
|
|
9
9
|
// `unknown` (its runs are kept, as an unread machine's always were), unless the relay positively answered that it is
|
|
10
10
|
// not linked (`machine_offline`): then it is offline. This machine's door not answering is no reading at all.
|
|
11
|
-
import { spawnSync } from 'node:child_process';
|
|
12
|
-
import { fileURLToPath } from 'node:url';
|
|
13
11
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
12
|
+
import { machineCall } from './doors.mjs';
|
|
14
13
|
|
|
15
|
-
const entry = fileURLToPath(new URL('../bin/machine-fleet.mjs', import.meta.url));
|
|
16
14
|
const DEADLINE_MS = 10_000;
|
|
15
|
+
const within = (promise, ms) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error(`no answer within ${ms} ms`), { code: 'timeout' })), ms).unref?.())]);
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// One machine's reading, through its door in this process (doors.mjs): its panes, what a board launch needs of it, and
|
|
18
|
+
// the session under each live pane.
|
|
19
|
+
async function readOne(name, at) {
|
|
20
|
+
const panes = await machineCall(at, 'harness.v1.panes.list', {});
|
|
21
|
+
// What a board launch needs of this machine: its board process guard (describe), and its keyed launch door, asked
|
|
22
|
+
// for a key no launch has (an answer of null is the door; an unknown method is its absence)
|
|
23
|
+
const description = await machineCall(at, 'harness.v1.machine.describe', {}).catch(() => null);
|
|
24
|
+
const keyed = await machineCall(at, 'harness.v1.launches.get', { key: 'board-capability-reading' }).then(() => null, (error) => String(error?.message ?? error).slice(0, 200));
|
|
25
|
+
const capabilities = { guard: description?.boardProcessPolicy?.version === 1 ? description.boardProcessPolicy.harnesses ?? [] : [], launches: keyed === null, launchesRefused: keyed };
|
|
26
|
+
const live = (Array.isArray(panes) ? panes : []).filter((pane) => !pane.dead && pane.pid);
|
|
27
|
+
// the session under each live pane: the harness stream, an operator's; a caller the machine admits only to its panes
|
|
28
|
+
// (a box another custodian enrolled, granting view and control) reads its panes and no sessions
|
|
29
|
+
let activities = [], sessionsUnread = null;
|
|
30
|
+
if (live.length) {
|
|
31
|
+
try { activities = (await machineCall(at, 'harness.v1.sessions.activity_under', { pids: live.map((pane) => pane.pid) }))?.activities ?? []; }
|
|
32
|
+
catch (error) { sessionsUnread = String(error?.message ?? error).slice(0, 200); }
|
|
33
|
+
}
|
|
34
|
+
return { machine: name, panes, activities, capabilities, sessionsUnread };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One reading of `machines` (normalized names) and this machine, `local`: every machine at once, each bounded. */
|
|
38
|
+
export async function readMachineFleet(machines, local) {
|
|
20
39
|
const names = [...new Set([local, ...machines])];
|
|
21
40
|
const started = performance.now();
|
|
22
|
-
const
|
|
23
|
-
|
|
41
|
+
const rows = await Promise.all(names.map((name) => within(readOne(name, name === local ? null : name), DEADLINE_MS)
|
|
42
|
+
.catch((error) => ({ machine: name, error: String(error?.message ?? error).slice(0, 300), code: error?.code ?? 'unavailable' }))));
|
|
24
43
|
noteStep(`fleet reading (${names.length} machine${names.length === 1 ? '' : 's'})`, performance.now() - started);
|
|
25
|
-
let rows = null;
|
|
26
|
-
try { rows = JSON.parse(String(reply.stdout).trim().split('\n').pop()); } catch { /* no reading */ }
|
|
27
44
|
// `panes`: each answering machine's live pane ids, whether or not a session was read in them
|
|
28
45
|
const online = new Set(), unknown = new Set(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map();
|
|
29
|
-
if (!Array.isArray(rows)) {
|
|
30
|
-
const error = String(reply.stderr || reply.error?.message || 'no answer').trim().slice(-300);
|
|
31
|
-
return { ok: false, presenceKnown: false, online, unknown: new Set(names), alive, native, sessions, capabilities, panes, error: `the fleet reading did not answer: ${error}` };
|
|
32
|
-
}
|
|
33
46
|
let localError = null;
|
|
34
47
|
for (const row of rows) {
|
|
35
48
|
if (row.error) {
|
package/board/runtime-door.mjs
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// A machine's public door (`harness.v1.*`), called in this process through the held channel to its daemon (doors.mjs):
|
|
2
|
+
// no helper process, nothing that waits on this process's event loop. Answered as the helper it replaces answered,
|
|
3
|
+
// `{code, stdout, stderr}`, so a caller reads a door's refusal as it always did.
|
|
4
|
+
import { machineCall } from './doors.mjs';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
/**
|
|
7
|
+
* One door call. `machine` null for this machine. `as`: the board this call acts for (`sc:<machine>:board:<name>`),
|
|
8
|
+
* named to the daemon as the caller, which it takes from the process that registered that board's home (machine.mjs).
|
|
9
|
+
*/
|
|
10
|
+
export async function runtimeDoor(machine, method, params = {}, { timeout = 60_000, as = null } = {}) {
|
|
11
|
+
try {
|
|
12
|
+
if (!method.startsWith('harness.v1.')) throw Object.assign(new Error('the runtime door accepts harness.v1 methods'), { code: 'invalid_request' });
|
|
13
|
+
const named = as && !method.startsWith('harness.v1.sessions.') ? { ...params, caller: as } : params;
|
|
14
|
+
const result = await machineCall(machine, method, named, { timeoutMs: timeout });
|
|
15
|
+
return { code: 0, stdout: `${JSON.stringify(result ?? null)}\n`, stderr: '' };
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return { code: 1, stdout: '', stderr: `${JSON.stringify({ code: error?.code ?? 'unavailable', message: String(error?.message ?? error) })}\n` };
|
|
18
|
+
}
|
|
16
19
|
}
|
package/board/tools.mjs
CHANGED
|
@@ -175,12 +175,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
175
175
|
return ok({ task_id: tid, attachments: tx(board, (db) => engine.attachments(db, tid)).map((a) => ({ id: a.id, filename: a.filename, size: a.size, content_type: a.content_type, path: a.stored_path })) });
|
|
176
176
|
case 'kanban_create': {
|
|
177
177
|
const workspace = args.workspace_kind ? `${args.workspace_kind}${args.workspace_path ? `:${args.workspace_path}` : ''}` : undefined;
|
|
178
|
-
const made = engine.createCard(board, {
|
|
178
|
+
const made = engine.createCard(board, await engine.prepareCard(board, {
|
|
179
179
|
title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
180
180
|
priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
|
|
181
181
|
maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
|
|
182
182
|
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
|
183
|
-
});
|
|
183
|
+
}));
|
|
184
184
|
await engine.runEffects(board,made.effects??[]);
|
|
185
185
|
return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
|
|
186
186
|
}
|
package/board/workflow.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// pane, sending a session a message, cleaning a workspace) is answered as effects the caller runs after commit.
|
|
7
7
|
import { current } from './call.mjs';
|
|
8
8
|
import { notRuntime } from './owner-files.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
10
|
import { promisify } from 'node:util';
|
|
11
11
|
import { existsSync, readdirSync, realpathSync, watch, readFileSync } from 'node:fs';
|
|
12
12
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
@@ -206,26 +206,27 @@ function pattern(p) {
|
|
|
206
206
|
// a change to its reviewed work. Merge commits are not the card's work: merging its reviewed PR, or main into its
|
|
207
207
|
// branch, is not a newer change to review.
|
|
208
208
|
const commitTimes = new Map();
|
|
209
|
-
function observeLastCommit(path, id, branches = []) {
|
|
209
|
+
async function observeLastCommit(path, id, branches = []) {
|
|
210
210
|
if (!path) return null;
|
|
211
211
|
const key = `${path}:${id}`;
|
|
212
212
|
const cached = commitTimes.get(key);
|
|
213
213
|
if (cached && now() - cached.at < 15) return cached.value;
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
214
|
+
// awaited, never a wait that holds the event loop: `status` 0 or the exit code, as a synchronous run answered
|
|
215
|
+
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 ?? '' }));
|
|
216
|
+
const readCommit = async (repo) => {
|
|
217
|
+
const listed = await git(repo, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
|
|
217
218
|
if (listed.status !== 0) return { repo: false, value: null };
|
|
218
|
-
const current = git(repo, ['branch', '--show-current']).stdout?.trim();
|
|
219
|
-
const fallback = git(repo, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']).stdout?.trim().replace(/^refs\/remotes\/origin\//, '');
|
|
219
|
+
const current = (await git(repo, ['branch', '--show-current'])).stdout?.trim();
|
|
220
|
+
const fallback = (await git(repo, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])).stdout?.trim().replace(/^refs\/remotes\/origin\//, '');
|
|
220
221
|
const names = new Set([...branches, current, fallback].filter(Boolean));
|
|
221
222
|
const short = (ref) => ref.startsWith('refs/heads/') ? ref.slice(11) : ref.split('/').slice(3).join('/');
|
|
222
223
|
const refs = listed.stdout.split('\n').filter((ref) => ref && !ref.endsWith('/HEAD') && (names.has(short(ref)) || namedWorkBranch(id, short(ref))));
|
|
223
224
|
if (!refs.length) return { repo: true, value: null };
|
|
224
|
-
const r = git(repo, ['log', ...refs, '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`, '--']);
|
|
225
|
+
const r = await git(repo, ['log', ...refs, '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`, '--']);
|
|
225
226
|
const value = Number(r.stdout?.trim());
|
|
226
227
|
return { repo: r.status === 0, value: Number.isFinite(value) && value > 0 ? value : null };
|
|
227
228
|
};
|
|
228
|
-
const own = readCommit(path);
|
|
229
|
+
const own = await readCommit(path);
|
|
229
230
|
let value = own.value;
|
|
230
231
|
if (!own.repo) {
|
|
231
232
|
// A fleet card may span sibling repositories under its declared workspace.
|
|
@@ -235,7 +236,7 @@ function observeLastCommit(path, id, branches = []) {
|
|
|
235
236
|
if (!child.isDirectory() && !child.isSymbolicLink()) continue;
|
|
236
237
|
const repo = resolve(path, child.name);
|
|
237
238
|
if (!existsSync(resolve(repo, '.git'))) continue;
|
|
238
|
-
const t = readCommit(repo).value;
|
|
239
|
+
const t = (await readCommit(repo)).value;
|
|
239
240
|
if (t != null) value = Math.max(value ?? 0, t);
|
|
240
241
|
}
|
|
241
242
|
}
|
|
@@ -245,7 +246,7 @@ function observeLastCommit(path, id, branches = []) {
|
|
|
245
246
|
|
|
246
247
|
function lastCommitAt(path, id) { return path ? commitTimes.get(`${path}:${id}`)?.value ?? null : null; }
|
|
247
248
|
/** Observe external facts before entering an optimistic board mutation. */
|
|
248
|
-
export function refreshCardInputs(board, id, { commits = false } = {}) {
|
|
249
|
+
export async function refreshCardInputs(board, id, { commits = false } = {}) {
|
|
249
250
|
const task=board.db.prepare('SELECT t.*,c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
|
|
250
251
|
if (!task)return;
|
|
251
252
|
if (commits && !task.subtask_of) {
|
|
@@ -253,7 +254,7 @@ export function refreshCardInputs(board, id, { commits = false } = {}) {
|
|
|
253
254
|
const parent=!own && task.subtask_of?board.db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(task.subtask_of)?.workspace_path:null;
|
|
254
255
|
const path=own??parent??null;
|
|
255
256
|
if(path)commitTimes.delete(`${path}:${id}`);
|
|
256
|
-
observeLastCommit(path,id,declaredWorkBranches(board.db,id));
|
|
257
|
+
await observeLastCommit(path,id,declaredWorkBranches(board.db,id));
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
|
package/board/workspace.mjs
CHANGED
|
@@ -18,7 +18,7 @@ export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '
|
|
|
18
18
|
// The dispatcher's latest presence reading (observeFleet): a machine it reads as offline is not asked.
|
|
19
19
|
let presence = null;
|
|
20
20
|
export const notePresence = (reading) => { presence = reading; };
|
|
21
|
-
function workspaceDoor(machine, verb, value, options = {}) {
|
|
21
|
+
async function workspaceDoor(machine, verb, value, options = {}) {
|
|
22
22
|
if (machine && presence?.known === true && normalMachine(machine) !== localMachine() && !presence.online.has(normalMachine(machine))) {
|
|
23
23
|
throw new Error(`workspace on ${machine}: machine ${normalMachine(machine)} is offline`);
|
|
24
24
|
}
|
|
@@ -27,39 +27,46 @@ function workspaceDoor(machine, verb, value, options = {}) {
|
|
|
27
27
|
// none on a volume macOS guards, where the session that named the path does, and a verdict waited on the daemon's
|
|
28
28
|
// EPERM. The daemon is asked when this process cannot read it.
|
|
29
29
|
let own = null;
|
|
30
|
-
if (local && verb === 'inspect') { try { return inspectCheckout(value); } catch (error) { own = error; } }
|
|
30
|
+
if (local && verb === 'inspect') { try { return await inspectCheckout(value); } catch (error) { own = error; } }
|
|
31
31
|
const params = verb === 'list' ? {} : verb === 'inspect' || verb === 'register' ? {path:value} : {repository:value,...options};
|
|
32
|
-
const r = runtimeDoor(local?null:machine,`harness.v1.workspaces.${verb}`,params,{env:commandEnv()});
|
|
32
|
+
const r = await runtimeDoor(local?null:machine,`harness.v1.workspaces.${verb}`,params,{env:commandEnv()});
|
|
33
33
|
if (r.code !== 0) throw new Error(`workspace on ${machine}: ${own ? `${own.message}; the machine daemon: ` : ''}${(r.stderr || r.stdout || r.error?.message).trim()}`);
|
|
34
34
|
return JSON.parse(r.stdout);
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
/** A workspace declaration that asks no machine (scratch, or a repository named by id), else null: a path is declared
|
|
37
|
+
* through its machine's door (declareWorkspace). */
|
|
38
|
+
export function plainWorkspace(kind, value, branch = null) {
|
|
37
39
|
if (kind === 'scratch') return { repository: null, branch: null, path: null };
|
|
38
40
|
if (!value) throw new Error('workspace names a repository, for example worktree:volter-ai/editor');
|
|
39
41
|
if (!isWorkspacePath(value)) return { repository: repositoryId(value), branch, path: null };
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
export async function declareWorkspace(kind, value, machine, branch = null) {
|
|
45
|
+
const plain = plainWorkspace(kind, value, branch);
|
|
46
|
+
if (plain) return plain;
|
|
40
47
|
// A legacy path supplied explicitly is checked and registered on its machine, never copied into the card.
|
|
41
|
-
const info = workspaceDoor(machine, 'inspect', value);
|
|
48
|
+
const info = await workspaceDoor(machine, 'inspect', value);
|
|
42
49
|
if (kind === 'worktree' && info.linked) throw new Error('worktree workspace already names a linked worktree; name its repository for a solo worktree');
|
|
43
50
|
if (kind === 'dir' && !info.branch && !branch) throw new Error('a detached checkout has no portable branch identity; name the repository and a solo worktree branch');
|
|
44
|
-
const row = workspaceDoor(machine, 'register', value);
|
|
51
|
+
const row = await workspaceDoor(machine, 'register', value);
|
|
45
52
|
return { repository: row.repository, branch: branch ?? (kind === 'dir' ? row.branch : null), path: kind === 'dir' ? row.path : null };
|
|
46
53
|
}
|
|
47
54
|
/** The checkouts this machine keeps of any repository on `branch`: where an arc changing several repositories
|
|
48
55
|
* keeps its work in each of them (its own worktree aside). Another machine's map is not read from here. */
|
|
49
|
-
export function branchCheckouts(machine, branch) {
|
|
56
|
+
export async function branchCheckouts(machine, branch) {
|
|
50
57
|
if (!branch || (machine && normalMachine(machine) !== localMachine())) return [];
|
|
51
|
-
const listed = workspaceDoor(null, 'list', null);
|
|
58
|
+
const listed = await workspaceDoor(null, 'list', null);
|
|
52
59
|
return (Array.isArray(listed) ? listed : listed.workspaces ?? listed.items ?? []).filter((row) => (branch === '*' || row.branch === branch) && existsSync(row.path));
|
|
53
60
|
}
|
|
54
61
|
/** The registered checkout of `repository` on `branch` on `machine`: where a card changing several repositories
|
|
55
62
|
* keeps its work in each of them. */
|
|
56
|
-
export function checkoutOf(machine, repository, branch) {
|
|
63
|
+
export async function checkoutOf(machine, repository, branch) {
|
|
57
64
|
return workspaceDoor(machine, 'resolve', repositoryId(repository), { branch });
|
|
58
65
|
}
|
|
59
|
-
export function resolveWorkspace(task, create = false) {
|
|
66
|
+
export async function resolveWorkspace(task, create = false) {
|
|
60
67
|
const declared = task.workspace_anchor ?? task.workspace_path;
|
|
61
68
|
if (isWorkspacePath(declared)) {
|
|
62
|
-
const info = workspaceDoor(task.machine, 'inspect', declared);
|
|
69
|
+
const info = await workspaceDoor(task.machine, 'inspect', declared);
|
|
63
70
|
if (task.workspace_kind === 'dir') return info;
|
|
64
71
|
if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
|
|
65
72
|
throw new Error('legacy worktree path needs registration and a repository declaration before launch');
|
|
@@ -70,7 +77,7 @@ export function resolveWorkspace(task, create = false) {
|
|
|
70
77
|
if (task.workspace_kind === 'worktree' && task.workspace_path &&
|
|
71
78
|
basename(task.workspace_path) === task.id && basename(dirname(task.workspace_path)) === '.worktrees' &&
|
|
72
79
|
(!isLocalWorkspace(task) || existsSync(task.workspace_path))) {
|
|
73
|
-
const cached = workspaceDoor(task.machine, 'inspect', task.workspace_path);
|
|
80
|
+
const cached = await workspaceDoor(task.machine, 'inspect', task.workspace_path);
|
|
74
81
|
if (cached.linked && basename(cached.path) === task.id && basename(dirname(cached.path)) === '.worktrees' &&
|
|
75
82
|
cached.repository === repositoryId(declared) && cached.branch === (task.branch_name ?? `wt/${task.id}`)) {
|
|
76
83
|
return { ...cached, root: dirname(dirname(cached.path)) };
|
|
@@ -79,14 +86,14 @@ export function resolveWorkspace(task, create = false) {
|
|
|
79
86
|
}
|
|
80
87
|
return workspaceDoor(task.machine, 'resolve', declared, { branch: task.branch_name, solo: task.workspace_kind === 'worktree', card: task.id, create });
|
|
81
88
|
}
|
|
82
|
-
export function checkWorkspace(board, task) {
|
|
89
|
+
export async function checkWorkspace(board, task) {
|
|
83
90
|
if (task.workspace_kind === 'scratch') return null;
|
|
84
|
-
const facts = resolveWorkspace(task), peers = [];
|
|
91
|
+
const facts = await resolveWorkspace(task), peers = [];
|
|
85
92
|
if (task.workspace_kind === 'dir' && !facts.linked) {
|
|
86
93
|
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()) {
|
|
87
94
|
if (row.id === task.id || normalMachine(row.machine ?? localMachine()) !== normalMachine(task.machine ?? localMachine())) continue;
|
|
88
95
|
let other;
|
|
89
|
-
try { other = resolveWorkspace({ ...row, workspace_kind: 'dir' }); } catch { continue; } // That card's own launch reports its invalid workspace.
|
|
96
|
+
try { other = await resolveWorkspace({ ...row, workspace_kind: 'dir' }); } catch { continue; } // That card's own launch reports its invalid workspace.
|
|
90
97
|
if (!other.linked && other.root === facts.root) peers.push(row.id);
|
|
91
98
|
}
|
|
92
99
|
}
|