@voidlabs.gg/voidshift-agents 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/dist/config.d.ts +19 -0
- package/dist/config.js +43 -0
- package/dist/control.d.ts +6 -2
- package/dist/control.js +67 -35
- package/dist/index.js +132 -10
- package/dist/manage.d.ts +2 -1
- package/dist/manage.js +46 -1
- package/dist/runner.d.ts +25 -2
- package/dist/runner.js +190 -51
- package/dist/state.d.ts +11 -1
- package/dist/state.js +57 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,9 +74,9 @@ or `vs-parked`) and the round (`vs-v1`, `vs-v2`). On Linear it posts the plan an
|
|
|
74
74
|
one final comment, nothing in between.
|
|
75
75
|
|
|
76
76
|
```sh
|
|
77
|
-
vsf agents status # runner, the card
|
|
77
|
+
vsf agents status # runner, the card each workspace is on, parked cards
|
|
78
78
|
vsf agents run ENG-123 [--from qa] # run one card now, in this terminal
|
|
79
|
-
vsf agents start | stop | restart # the background runner (stop cancels
|
|
79
|
+
vsf agents start | stop | restart # the background runner (stop cancels its cards)
|
|
80
80
|
vsf agents pause | resume # pick up no new cards for a while
|
|
81
81
|
vsf agents cancel | retry ENG-123 # stop a card, or queue a parked one again
|
|
82
82
|
vsf agents runs | show ENG-123 # what happened, step by step
|
|
@@ -84,11 +84,25 @@ vsf agents logs [ENG-123] [-f] # the runner log, or a card's step logs
|
|
|
84
84
|
vsf agents workflows # workflows in the order they match
|
|
85
85
|
vsf agents workflow add|edit|show|rename|move|rm
|
|
86
86
|
vsf agents projects | project add|edit|show|rename|rm
|
|
87
|
+
vsf agents workspace add|list|rm # folders of repos, each with its own queue
|
|
87
88
|
vsf agents homes | link DIR # where the agents are installed
|
|
88
89
|
vsf agents clean # old worktrees and run logs
|
|
89
90
|
vsf agents validate [DIR] # check the config and agent folders
|
|
90
91
|
```
|
|
91
92
|
|
|
93
|
+
Each workspace has one queue: cards in different workspaces run side by side,
|
|
94
|
+
two cards in one workspace one after the other. Projects are in the `default`
|
|
95
|
+
workspace until you move them:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
vsf agents workspace add solvia --root ~/work/solvia # steps carry ~/work/solvia/CONTEXT.md
|
|
99
|
+
vsf agents project edit erp --workspace solvia --match DS06
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`--match` names a tracker project (or its first word, so `DS06` matches "DS06
|
|
103
|
+
Atleurope ERP"). A card of that project runs in the matching project, in any
|
|
104
|
+
workspace, before the workflow's own project is considered.
|
|
105
|
+
|
|
92
106
|
A workflow can also be a folder you write by hand,
|
|
93
107
|
`~/.config/voidshift/agents/defs/NAME/agent.json`, with its prompt files next to
|
|
94
108
|
it. Steps are the built-in ids above or your own:
|
package/dist/config.d.ts
CHANGED
|
@@ -52,7 +52,16 @@ export declare function describeTrigger(t: Trigger): string;
|
|
|
52
52
|
export interface Project {
|
|
53
53
|
path: string;
|
|
54
54
|
base?: string;
|
|
55
|
+
workspace?: string;
|
|
56
|
+
match?: string[];
|
|
55
57
|
}
|
|
58
|
+
export interface Workspace {
|
|
59
|
+
root: string;
|
|
60
|
+
context?: string;
|
|
61
|
+
}
|
|
62
|
+
export declare const DEFAULT_WORKSPACE = "default";
|
|
63
|
+
export declare const WORKSPACE_NAME: RegExp;
|
|
64
|
+
export declare const workspaceOf: (project: Pick<Project, "workspace"> | undefined) => string;
|
|
56
65
|
export interface WriteBack {
|
|
57
66
|
labels?: boolean;
|
|
58
67
|
state?: string | null;
|
|
@@ -72,6 +81,7 @@ export interface AgentsConfig {
|
|
|
72
81
|
review?: string;
|
|
73
82
|
};
|
|
74
83
|
projects: Record<string, Project>;
|
|
84
|
+
workspaces: Record<string, Workspace>;
|
|
75
85
|
workflows: Record<string, Workflow>;
|
|
76
86
|
reviewState: string;
|
|
77
87
|
timeoutMinutes: number;
|
|
@@ -104,6 +114,15 @@ export declare function agentFolders(configDir: string): string[];
|
|
|
104
114
|
export declare function readAgentFolder(dir: string): Workflow;
|
|
105
115
|
export declare function readAgentsConfig(configDir: string): AgentsConfig;
|
|
106
116
|
export declare function writeAgentsConfig(configDir: string, config: AgentsConfig): void;
|
|
117
|
+
export declare const workspacesInUse: (config: Pick<AgentsConfig, "projects" | "workspaces">) => string[];
|
|
118
|
+
export declare function matchesProject(entry: string, projectName: string): boolean;
|
|
119
|
+
export declare function resolveProject(config: Pick<AgentsConfig, 'projects'>, workflow: Pick<Workflow, 'project'>, card: {
|
|
120
|
+
projectName?: string;
|
|
121
|
+
}): {
|
|
122
|
+
name: string;
|
|
123
|
+
project: Project;
|
|
124
|
+
} | null;
|
|
125
|
+
export declare function workspaceContext(config: Pick<AgentsConfig, 'workspaces'>, name: string): string;
|
|
107
126
|
export interface ResolvedStep {
|
|
108
127
|
id: string;
|
|
109
128
|
label: string;
|
package/dist/config.js
CHANGED
|
@@ -43,6 +43,9 @@ export function describeTrigger(t) {
|
|
|
43
43
|
return `cron ${t.schedule}`;
|
|
44
44
|
return `${t.source === 'voidtrack' ? 'VoidTrack ' : ''}${t.team} + ${t.label}`;
|
|
45
45
|
}
|
|
46
|
+
export const DEFAULT_WORKSPACE = 'default';
|
|
47
|
+
export const WORKSPACE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
48
|
+
export const workspaceOf = (project) => project?.workspace || DEFAULT_WORKSPACE;
|
|
46
49
|
// Step lists to start a workflow from.
|
|
47
50
|
export const TEMPLATES = {
|
|
48
51
|
build: { steps: ['vs-dev', 'vs-qa'], maxRounds: 3 },
|
|
@@ -53,6 +56,7 @@ export const DEFAULTS = {
|
|
|
53
56
|
engine: 'claude',
|
|
54
57
|
models: { dev: 'opus', review: 'sonnet' },
|
|
55
58
|
projects: {},
|
|
59
|
+
workspaces: {},
|
|
56
60
|
workflows: {},
|
|
57
61
|
reviewState: 'In Review',
|
|
58
62
|
timeoutMinutes: 45,
|
|
@@ -157,6 +161,45 @@ export function writeAgentsConfig(configDir, config) {
|
|
|
157
161
|
const workflows = Object.fromEntries(Object.entries(config.workflows).filter(([name]) => !folders?.[name]));
|
|
158
162
|
writeFileSync(configPath(configDir), JSON.stringify({ ...rest, workflows }, null, 2) + '\n');
|
|
159
163
|
}
|
|
164
|
+
// Every workspace a config uses: the named ones, then any a project names,
|
|
165
|
+
// the default one included.
|
|
166
|
+
export const workspacesInUse = (config) => [
|
|
167
|
+
...new Set([...Object.keys(config.workspaces ?? {}), ...Object.values(config.projects).map(workspaceOf)]),
|
|
168
|
+
];
|
|
169
|
+
// Whether a match entry names a tracker project: the whole name, or its first
|
|
170
|
+
// word, so "DS06" matches "DS06 Atleurope ERP". Case does not matter.
|
|
171
|
+
export function matchesProject(entry, projectName) {
|
|
172
|
+
const want = entry.trim().toLowerCase();
|
|
173
|
+
const name = projectName.trim().toLowerCase();
|
|
174
|
+
return !!want && (want === name || want === name.split(/\s+/)[0]);
|
|
175
|
+
}
|
|
176
|
+
// The project a card runs in. Explicit matches on the card's tracker project
|
|
177
|
+
// come first, in every workspace, and only then the workflow's own project.
|
|
178
|
+
// A scheduled run has no tracker project, so it always takes the workflow's.
|
|
179
|
+
export function resolveProject(config, workflow, card) {
|
|
180
|
+
if (card.projectName) {
|
|
181
|
+
for (const [name, project] of Object.entries(config.projects)) {
|
|
182
|
+
if (project.match?.some((m) => matchesProject(m, card.projectName)))
|
|
183
|
+
return { name, project };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const project = config.projects[workflow.project];
|
|
187
|
+
return project ? { name: workflow.project, project } : null;
|
|
188
|
+
}
|
|
189
|
+
// The workspace's context text for the step prompts: its context file, or
|
|
190
|
+
// CONTEXT.md in its root. Empty when there is none.
|
|
191
|
+
export function workspaceContext(config, name) {
|
|
192
|
+
const ws = config.workspaces?.[name];
|
|
193
|
+
if (!ws)
|
|
194
|
+
return '';
|
|
195
|
+
const file = ws.context ? resolve(ws.root, ws.context) : join(ws.root, 'CONTEXT.md');
|
|
196
|
+
try {
|
|
197
|
+
return readFileSync(file, 'utf8').trim();
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
160
203
|
const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
|
|
161
204
|
const packSkill = (name) => join(packDir, name, 'SKILL.md');
|
|
162
205
|
// The file a custom step's prompt comes from: its prompt file, a pack skill,
|
package/dist/control.d.ts
CHANGED
|
@@ -10,8 +10,8 @@ export declare function stampDate(stamp: string): Date;
|
|
|
10
10
|
export declare function countRounds(steps: {
|
|
11
11
|
result?: string;
|
|
12
12
|
}[]): number;
|
|
13
|
-
export declare const isInterrupted: (e: RunEntry, live: Current
|
|
14
|
-
export declare function formatRunLine(e: RunEntry, now?: number, live?: Current
|
|
13
|
+
export declare const isInterrupted: (e: RunEntry, live: Current[]) => boolean;
|
|
14
|
+
export declare function formatRunLine(e: RunEntry, now?: number, live?: Current[]): string;
|
|
15
15
|
export declare function scanRunnerErrors(text: string, now: number): {
|
|
16
16
|
count: number;
|
|
17
17
|
last: string;
|
|
@@ -31,5 +31,9 @@ export declare function trackerWarnings(kinds: Kind[], trackers: Trackers): Prom
|
|
|
31
31
|
export declare function workingLine(current: (Current & {
|
|
32
32
|
elapsedMinutes: number;
|
|
33
33
|
}) | null): string;
|
|
34
|
+
export declare function workspaceLines(workspaces: string[], running: (Current & {
|
|
35
|
+
elapsedMinutes: number;
|
|
36
|
+
})[]): string[];
|
|
37
|
+
export declare function pickCancel(locks: Current[], dispatcher: Current | null, card?: string): Current | string;
|
|
34
38
|
export declare const controlCommands: Record<string, ToolCommand>;
|
|
35
39
|
export declare function controlChecks(ctx: ToolContext): Promise<Check[]>;
|
package/dist/control.js
CHANGED
|
@@ -2,13 +2,13 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { agentsDir, isCardTrigger, readAgentsConfig } from "./config.js";
|
|
6
|
-
import { isPaused, listRuns, ours,
|
|
5
|
+
import { agentsDir, isCardTrigger, readAgentsConfig, workspaceOf, workspacesInUse } from "./config.js";
|
|
6
|
+
import { isPaused, listRuns, ours, clearLock, liveCurrent, liveRuns, readCurrent, setPaused, workspaceLocks, writeRun } from "./state.js";
|
|
7
7
|
import { runnerService } from "./service.js";
|
|
8
8
|
import { TRACKERS, trackersFor } from "./tracker.js";
|
|
9
9
|
import { parseFrom, resolveName, stepNames } from "./names.js";
|
|
10
10
|
import { isStatusLabel } from "./workflow.js";
|
|
11
|
-
import { cancelCard, tick } from "./runner.js";
|
|
11
|
+
import { cancelCard, dispatch, tick } from "./runner.js";
|
|
12
12
|
const HOUR = 3_600_000;
|
|
13
13
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
14
14
|
const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
@@ -78,9 +78,9 @@ export function countRounds(steps) {
|
|
|
78
78
|
}
|
|
79
79
|
return rounds;
|
|
80
80
|
}
|
|
81
|
-
// A run that says 'running' but is
|
|
82
|
-
export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !(
|
|
83
|
-
export function formatRunLine(e, now = Date.now(), live =
|
|
81
|
+
// A run that says 'running' but is none of the live runs was cut off.
|
|
82
|
+
export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !live.some((l) => (l.runDir ? l.runDir === e.dir : l.card === e.card));
|
|
83
|
+
export function formatRunLine(e, now = Date.now(), live = []) {
|
|
84
84
|
const r = e.record;
|
|
85
85
|
if (!r)
|
|
86
86
|
return `${e.card} ${when(stampDate(e.stamp))} older run, logs only (${plural(e.logs.length, 'log')})`;
|
|
@@ -121,14 +121,14 @@ const readLog = (configDir) => {
|
|
|
121
121
|
}
|
|
122
122
|
};
|
|
123
123
|
const USAGE = {
|
|
124
|
-
status: 'status [--json] Runner,
|
|
124
|
+
status: 'status [--json] Runner, the card each workspace is on, parked cards and workflows',
|
|
125
125
|
start: 'start Start the background runner',
|
|
126
|
-
stop: 'stop Stop the runner and cancel the
|
|
126
|
+
stop: 'stop Stop the runner and cancel the cards it is working on',
|
|
127
127
|
restart: 'restart Stop and start the runner',
|
|
128
128
|
pause: 'pause Pick up no new cards; a card in progress carries on',
|
|
129
129
|
resume: 'resume Pick up new cards again',
|
|
130
130
|
run: 'run [CARD] [--workflow W] [--from STEP] Run now instead of waiting for the next minute',
|
|
131
|
-
cancel: 'cancel [CARD] Stop
|
|
131
|
+
cancel: 'cancel [CARD] Stop a card being worked on and clear its labels',
|
|
132
132
|
retry: 'retry CARD [--workflow W] Queue a parked or cancelled card again',
|
|
133
133
|
runs: 'runs [CARD] [--limit N] [--json] Past runs, newest first',
|
|
134
134
|
show: 'show CARD [--json] Steps, outcome and labels of the latest run',
|
|
@@ -215,7 +215,7 @@ export async function cancelCurrent(ctx, c, deps) {
|
|
|
215
215
|
}
|
|
216
216
|
else
|
|
217
217
|
ctx.log('Cleared a stale lock.');
|
|
218
|
-
|
|
218
|
+
clearLock(ctx.configDir, c);
|
|
219
219
|
}
|
|
220
220
|
const realDeps = (ctx) => ({
|
|
221
221
|
ours,
|
|
@@ -263,7 +263,9 @@ async function startCmd(ctx) {
|
|
|
263
263
|
}
|
|
264
264
|
async function stopCmd(ctx) {
|
|
265
265
|
runnerService().stop(ctx.home);
|
|
266
|
-
// Stopping the runner may have stopped
|
|
266
|
+
// Stopping the runner may have stopped its runs; finish or tidy up whatever the locks still name.
|
|
267
|
+
for (const c of workspaceLocks(ctx.configDir))
|
|
268
|
+
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
267
269
|
const c = readCurrent(ctx.configDir);
|
|
268
270
|
if (c)
|
|
269
271
|
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
@@ -278,6 +280,12 @@ export function workingLine(current) {
|
|
|
278
280
|
return ' Checking now.';
|
|
279
281
|
return ` Working on ${current.card}${current.title ? ` "${current.title}"` : ''} (${[current.workflow, current.step, current.round ? `round ${current.round}` : '', `${current.elapsedMinutes}m`].filter(Boolean).join(', ')}).`;
|
|
280
282
|
}
|
|
283
|
+
const elapsed = (c) => (c.startedAt ? Math.max(0, Math.round((Date.now() - Date.parse(c.startedAt)) / 60_000)) : 0);
|
|
284
|
+
// One line per workspace: the card it is on, or idle.
|
|
285
|
+
export function workspaceLines(workspaces, running) {
|
|
286
|
+
const names = [...new Set([...workspaces, ...running.map((r) => workspaceOf(r))])];
|
|
287
|
+
return names.map((ws) => ` ${ws}:${workingLine(running.find((r) => workspaceOf(r) === ws) ?? null) || ' idle.'}`);
|
|
288
|
+
}
|
|
281
289
|
const status = guard(async (args, ctx) => {
|
|
282
290
|
const p = parse('status', args, { json: { type: 'boolean' } }, ctx);
|
|
283
291
|
if (typeof p === 'number')
|
|
@@ -286,8 +294,11 @@ const status = guard(async (args, ctx) => {
|
|
|
286
294
|
const runner = runnerState(ctx);
|
|
287
295
|
const text = readLog(ctx.configDir);
|
|
288
296
|
const lastTick = lastTickOf(text);
|
|
289
|
-
const live =
|
|
290
|
-
const
|
|
297
|
+
const live = liveRuns(ctx.configDir);
|
|
298
|
+
const running = live.map((c) => ({ ...c, workspace: workspaceOf(c), elapsedMinutes: elapsed(c) }));
|
|
299
|
+
const dispatcher = liveCurrent(ctx.configDir);
|
|
300
|
+
// The first run, or the dispatcher checking for work, for dashboards that know one run only.
|
|
301
|
+
const current = running.length ? { ...running[0], checking: false } : dispatcher ? { ...dispatcher, checking: true, elapsedMinutes: elapsed(dispatcher) } : null;
|
|
291
302
|
const seen = new Set();
|
|
292
303
|
const parked = [];
|
|
293
304
|
const interrupted = [];
|
|
@@ -303,16 +314,22 @@ const status = guard(async (args, ctx) => {
|
|
|
303
314
|
const workflows = Object.keys(config.workflows).length;
|
|
304
315
|
const projects = Object.keys(config.projects).length;
|
|
305
316
|
if (p.values.json) {
|
|
306
|
-
ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, parked, interrupted, workflows, projects }, null, 2));
|
|
317
|
+
ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, running, parked, interrupted, workflows, projects }, null, 2));
|
|
307
318
|
return 0;
|
|
308
319
|
}
|
|
309
|
-
|
|
320
|
+
// One workspace keeps the single line; several get a line each.
|
|
321
|
+
const workspaces = workspacesInUse(config);
|
|
322
|
+
const perWorkspace = runner !== 'stopped' && (workspaces.length > 1 || running.some((r) => !workspaces.includes(r.workspace)));
|
|
323
|
+
const working = perWorkspace ? '' : workingLine(current);
|
|
310
324
|
if (runner === 'running')
|
|
311
325
|
ctx.log(`Runner: running.${working}`);
|
|
312
326
|
else if (runner === 'paused')
|
|
313
327
|
ctx.log(`Runner: paused, picks up no new cards. vsf agents resume to continue.${working}`);
|
|
314
328
|
else
|
|
315
329
|
ctx.log('Runner: stopped. vsf agents start to begin.');
|
|
330
|
+
if (perWorkspace)
|
|
331
|
+
for (const line of workspaceLines(workspaces, running))
|
|
332
|
+
ctx.log(line);
|
|
316
333
|
if (lastTick)
|
|
317
334
|
ctx.log(`Last check: ${clock(new Date(lastTick.at))}, ${lastTick.line.replace(/\.$/, '') || 'no details'}.`);
|
|
318
335
|
for (const card of interrupted)
|
|
@@ -399,6 +416,20 @@ const run = guard(async (args, ctx) => {
|
|
|
399
416
|
}
|
|
400
417
|
return code;
|
|
401
418
|
});
|
|
419
|
+
// The lock a cancel ends, or why there is none. Every run's lock counts, stale
|
|
420
|
+
// ones included, so a dead run can be tidied up; with none, the dispatcher's
|
|
421
|
+
// own lock, a check in progress. Without a card it must be the only one.
|
|
422
|
+
export function pickCancel(locks, dispatcher, card) {
|
|
423
|
+
const all = locks.length ? locks : dispatcher ? [dispatcher] : [];
|
|
424
|
+
if (!all.length)
|
|
425
|
+
return card ? `${card} is not running.` : 'Nothing is running.';
|
|
426
|
+
const name = (l) => `${l.card ?? 'a check'}${l.workspace ? ` in ${l.workspace}` : ''}`;
|
|
427
|
+
if (card)
|
|
428
|
+
return all.find((l) => l.card === card) ?? `${card} is not running. Running: ${all.map(name).join(', ')}.`;
|
|
429
|
+
if (all.length > 1)
|
|
430
|
+
return `More than one card is running: ${all.map(name).join(', ')}. Name one: vsf agents cancel CARD.`;
|
|
431
|
+
return all[0];
|
|
432
|
+
}
|
|
402
433
|
const cancel = guard(async (args, ctx) => {
|
|
403
434
|
const p = parse('cancel', args, {}, ctx);
|
|
404
435
|
if (typeof p === 'number')
|
|
@@ -406,16 +437,12 @@ const cancel = guard(async (args, ctx) => {
|
|
|
406
437
|
const card = p.positionals[0]?.toUpperCase();
|
|
407
438
|
if (p.positionals.length > 1 || (card && !CARD_ID.test(card)))
|
|
408
439
|
return usageError('cancel', ctx, 'Give one card id like ENG-12.');
|
|
409
|
-
const
|
|
410
|
-
if (
|
|
411
|
-
ctx.log(
|
|
412
|
-
return card ? 1 : 0;
|
|
413
|
-
}
|
|
414
|
-
if (card && c.card !== card) {
|
|
415
|
-
ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
|
|
416
|
-
return 1;
|
|
440
|
+
const found = pickCancel(workspaceLocks(ctx.configDir), readCurrent(ctx.configDir), card);
|
|
441
|
+
if (typeof found === 'string') {
|
|
442
|
+
ctx.log(found);
|
|
443
|
+
return found.startsWith('More than one') ? 2 : card ? 1 : 0;
|
|
417
444
|
}
|
|
418
|
-
await cancelCurrent(ctx,
|
|
445
|
+
await cancelCurrent(ctx, found, realDeps(ctx));
|
|
419
446
|
return 0;
|
|
420
447
|
});
|
|
421
448
|
const retry = guard(async (args, ctx) => {
|
|
@@ -425,7 +452,7 @@ const retry = guard(async (args, ctx) => {
|
|
|
425
452
|
const id = p.positionals[0]?.toUpperCase();
|
|
426
453
|
if (p.positionals.length !== 1 || !CARD_ID.test(id))
|
|
427
454
|
return usageError('retry', ctx, 'Give one card id like ENG-12.');
|
|
428
|
-
if (
|
|
455
|
+
if (liveRuns(ctx.configDir).some((c) => c.card === id)) {
|
|
429
456
|
ctx.log(`${id} is running now. vsf agents cancel ${id} first.`);
|
|
430
457
|
return 1;
|
|
431
458
|
}
|
|
@@ -495,7 +522,7 @@ const runs = guard(async (args, ctx) => {
|
|
|
495
522
|
}
|
|
496
523
|
if (!entries.length)
|
|
497
524
|
ctx.log('No runs yet.');
|
|
498
|
-
const live =
|
|
525
|
+
const live = liveRuns(ctx.configDir);
|
|
499
526
|
for (const e of entries)
|
|
500
527
|
ctx.log(formatRunLine(e, Date.now(), live));
|
|
501
528
|
return 0;
|
|
@@ -529,7 +556,7 @@ const show = guard(async (args, ctx) => {
|
|
|
529
556
|
return 0;
|
|
530
557
|
}
|
|
531
558
|
ctx.log(`${latest.card} ${latest.title}`);
|
|
532
|
-
ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0],
|
|
559
|
+
ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0], liveRuns(ctx.configDir)) ? 'interrupted' : latest.outcome}${latest.prUrl ? `, PR ${latest.prUrl}` : ''}`);
|
|
533
560
|
if (labels)
|
|
534
561
|
ctx.log(`Labels: ${labels.join(', ') || 'none'}`);
|
|
535
562
|
for (const s of latest.steps) {
|
|
@@ -645,10 +672,10 @@ const clean = guard(async (args, ctx) => {
|
|
|
645
672
|
if (ms === null)
|
|
646
673
|
return usageError('clean', ctx, `${age} is not an age. Use a number of days or hours, like 14d or 36h.`);
|
|
647
674
|
const cutoff = Date.now() - ms;
|
|
648
|
-
const live =
|
|
675
|
+
const live = new Set(liveRuns(ctx.configDir).map((c) => c.card));
|
|
649
676
|
const wtRoot = join(agentsDir(ctx.configDir), 'worktrees');
|
|
650
677
|
const worktrees = (existsSync(wtRoot) ? readdirSync(wtRoot) : [])
|
|
651
|
-
.filter((card) =>
|
|
678
|
+
.filter((card) => !live.has(card))
|
|
652
679
|
.filter((card) => {
|
|
653
680
|
const record = listRuns(ctx.configDir, card)[0]?.record;
|
|
654
681
|
const ended = record?.endedAt ? Date.parse(record.endedAt) : mtime(join(wtRoot, card));
|
|
@@ -656,7 +683,7 @@ const clean = guard(async (args, ctx) => {
|
|
|
656
683
|
})
|
|
657
684
|
.map((card) => join(wtRoot, card));
|
|
658
685
|
const old = listRuns(ctx.configDir).filter((e) => {
|
|
659
|
-
if (e.record?.outcome === 'running' && e.card
|
|
686
|
+
if (e.record?.outcome === 'running' && live.has(e.card))
|
|
660
687
|
return false;
|
|
661
688
|
return (e.record?.endedAt ? Date.parse(e.record.endedAt) : mtime(e.dir)) < cutoff;
|
|
662
689
|
});
|
|
@@ -686,12 +713,17 @@ const clean = guard(async (args, ctx) => {
|
|
|
686
713
|
ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
|
|
687
714
|
return 0;
|
|
688
715
|
});
|
|
689
|
-
// Internal: the runner (launchd or systemd) runs this every minute
|
|
690
|
-
|
|
716
|
+
// Internal: the runner (launchd or systemd) runs this every minute, and it
|
|
717
|
+
// starts itself with --card or --schedule for each run, one per workspace.
|
|
718
|
+
const tickCmd = async (args, ctx) => {
|
|
691
719
|
const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
|
|
692
720
|
try {
|
|
721
|
+
const { values } = parseArgs({ args, options: { card: { type: 'string' }, workflow: { type: 'string' }, schedule: { type: 'string' } }, strict: true });
|
|
693
722
|
const config = readAgentsConfig(ctx.configDir);
|
|
694
|
-
|
|
723
|
+
const rc = { configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand };
|
|
724
|
+
if (values.card || values.workflow)
|
|
725
|
+
return await tick(rc, values.card?.toUpperCase(), { workflow: values.workflow, schedule: values.schedule, child: true });
|
|
726
|
+
return await dispatch(rc, { reload: () => readAgentsConfig(ctx.configDir) });
|
|
695
727
|
}
|
|
696
728
|
catch (err) {
|
|
697
729
|
stamped(`Could not run the check: ${err.message}`);
|
|
@@ -734,8 +766,8 @@ export async function controlChecks(ctx) {
|
|
|
734
766
|
? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
|
|
735
767
|
: { name: 'runner program', status: 'ok', detail: 'found' });
|
|
736
768
|
}
|
|
737
|
-
const
|
|
738
|
-
if (c && !ours(c.pid))
|
|
769
|
+
const locks = [readCurrent(ctx.configDir), ...workspaceLocks(ctx.configDir)];
|
|
770
|
+
if (locks.some((c) => c && !ours(c.pid)))
|
|
739
771
|
checks.push({ name: 'stale lock', status: 'warn', detail: 'a stale lock from a run that died', fix: 'vsf agents cancel' });
|
|
740
772
|
if (existsSync(logPath(ctx.configDir))) {
|
|
741
773
|
const { count, last } = scanRunnerErrors(readLog(ctx.configDir), Date.now());
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
5
5
|
import { parseArgs } from 'node:util';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { AGENTS, AGENT_FILE, STEPS, TEMPLATES, isProducer, agentFolders, agentsDir, describeSteps, describeTrigger, describeTriggers, describeWorkflow, stepEnabled, stepId, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
|
|
7
|
+
import { AGENTS, AGENT_FILE, STEPS, TEMPLATES, isProducer, agentFolders, agentsDir, describeSteps, describeTrigger, describeTriggers, describeWorkflow, stepEnabled, stepId, visibleAgents, readAgentsConfig, writeAgentsConfig, workspaceOf, DEFAULTS, DEFAULT_WORKSPACE, WORKSPACE_NAME } from "./config.js";
|
|
8
8
|
import { controlChecks, controlCommands, controlUsage } from "./control.js";
|
|
9
9
|
import { keyStore, looksLikeLinearKey, readLinearKey, readSecret, storeLinearKey, storeSecret } from "./secrets.js";
|
|
10
10
|
import { TRACKERS } from "./tracker.js";
|
|
@@ -14,7 +14,7 @@ import { trackersInUse } from "./control.js";
|
|
|
14
14
|
import { runnerService } from "./service.js";
|
|
15
15
|
import { initProfile, profileChecks, profileDir, readRules, renderClaudeSettings, CLAUDE_SETTINGS, RULES_FILE } from "./profile.js";
|
|
16
16
|
import { createLinear } from "./linear.js";
|
|
17
|
-
import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateAgentFolder, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
|
|
17
|
+
import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, projectProblems, renameKey, sameTrigger, validateAgentFolder, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
|
|
18
18
|
import { parseStep, resolveName, stepNames } from "./names.js";
|
|
19
19
|
import { findRepos } from "./repos.js";
|
|
20
20
|
import { defaultBranch, sh } from "./git.js";
|
|
@@ -239,7 +239,8 @@ async function promptProject(ui, config, current = {}, edit = false, allowExisti
|
|
|
239
239
|
const describeProject = (config, name) => {
|
|
240
240
|
const p = config.projects[name];
|
|
241
241
|
const used = workflowsUsing(config, name);
|
|
242
|
-
|
|
242
|
+
const where = [p.workspace ? `workspace ${p.workspace}` : '', p.match?.length ? `matches ${p.match.join(', ')}` : ''].filter(Boolean);
|
|
243
|
+
return `${name} ${tilde(p.path)}, PRs into ${p.base ?? 'the default branch'}, ${where.map((w) => `${w}, `).join('')}${used.length ? `used by ${used.join(', ')}` : 'not used by any workflow'}`;
|
|
243
244
|
};
|
|
244
245
|
// Named local repos the workflows run in. Triggers point at them by name.
|
|
245
246
|
async function setupProjects(ui, config, log) {
|
|
@@ -366,11 +367,14 @@ const USAGE = [
|
|
|
366
367
|
['workflow rm NAME [--yes]', 'Delete a workflow'],
|
|
367
368
|
['workflow move NAME (--first | --last | --before OTHER | --after OTHER)', 'Change the match order'],
|
|
368
369
|
['projects [--json]', 'List projects'],
|
|
369
|
-
['project add [NAME] [--path P] [--base B]', 'Add a project; prompts for what is missing'],
|
|
370
|
+
['project add [NAME] [--path P] [--base B] [--workspace W] [--match NAME]...', 'Add a project; prompts for what is missing. --match routes cards of that tracker project here, whatever the workflow says'],
|
|
370
371
|
['project show NAME [--json]', 'Show one project'],
|
|
371
|
-
['project edit NAME [--path P] [--base B]', 'Change a project; no flags asks'],
|
|
372
|
+
['project edit NAME [--path P] [--base B] [--workspace W] [--match NAME]...', 'Change a project; no flags asks. An empty --workspace or --match clears it'],
|
|
372
373
|
['project rename OLD NEW', 'Rename a project and the workflows that use it'],
|
|
373
374
|
['project rm NAME [--yes]', 'Delete a project nothing uses'],
|
|
375
|
+
['workspace add NAME --root DIR [--context FILE]', 'Add a workspace: a folder of repos with its own run queue. Steps carry FILE, or DIR/CONTEXT.md'],
|
|
376
|
+
['workspace list [--json]', 'List workspaces and their projects'],
|
|
377
|
+
['workspace rm NAME', 'Delete a workspace no project uses'],
|
|
374
378
|
['homes [--json]', 'Show where the agents are linked'],
|
|
375
379
|
['link DIR [--claude | --codex]', 'Link the agents into a config folder'],
|
|
376
380
|
['unlink DIR', 'Remove the agents from a config folder'],
|
|
@@ -789,9 +793,31 @@ function pickProject(config, input, ctx) {
|
|
|
789
793
|
}
|
|
790
794
|
return r.name;
|
|
791
795
|
}
|
|
796
|
+
const PROJECT_FLAGS = { path: { type: 'string' }, base: { type: 'string' }, workspace: { type: 'string' }, match: { type: 'string', multiple: true } };
|
|
797
|
+
// Applies --workspace and --match to a project. An empty value clears either.
|
|
798
|
+
// Returns a complaint when the workspace or a match does not fit.
|
|
799
|
+
function applyWorkspaceFlags(config, name, project, v) {
|
|
800
|
+
if (v.workspace !== undefined) {
|
|
801
|
+
const ws = String(v.workspace).trim();
|
|
802
|
+
if (ws && ws !== DEFAULT_WORKSPACE && !config.workspaces[ws])
|
|
803
|
+
return `No workspace named ${ws}. vsf agents workspace add creates it.`;
|
|
804
|
+
if (ws && ws !== DEFAULT_WORKSPACE)
|
|
805
|
+
project.workspace = ws;
|
|
806
|
+
else
|
|
807
|
+
delete project.workspace;
|
|
808
|
+
}
|
|
809
|
+
if (v.match !== undefined) {
|
|
810
|
+
const match = v.match.map((m) => m.trim()).filter(Boolean);
|
|
811
|
+
if (match.length)
|
|
812
|
+
project.match = match;
|
|
813
|
+
else
|
|
814
|
+
delete project.match;
|
|
815
|
+
}
|
|
816
|
+
return projectProblems({ ...config.projects, [name]: project }, config.workspaces)[0] ?? null;
|
|
817
|
+
}
|
|
792
818
|
const projectSubs = {
|
|
793
819
|
async add(args, ctx) {
|
|
794
|
-
const p = begin('project add', args,
|
|
820
|
+
const p = begin('project add', args, PROJECT_FLAGS, ctx);
|
|
795
821
|
if (typeof p === 'number')
|
|
796
822
|
return p;
|
|
797
823
|
if (p.positionals.length > 1) {
|
|
@@ -838,6 +864,11 @@ const projectSubs = {
|
|
|
838
864
|
name = r.name;
|
|
839
865
|
project = r.project;
|
|
840
866
|
}
|
|
867
|
+
const bad = applyWorkspaceFlags(config, name, project, p.values);
|
|
868
|
+
if (bad) {
|
|
869
|
+
ctx.log(bad);
|
|
870
|
+
return 1;
|
|
871
|
+
}
|
|
841
872
|
config.projects[name] = project;
|
|
842
873
|
save(ctx, config);
|
|
843
874
|
ctx.log(`Project ${name}: ${tilde(project.path)}${project.base ? `, PRs into ${project.base}` : ''}`);
|
|
@@ -861,12 +892,15 @@ const projectSubs = {
|
|
|
861
892
|
ctx.log(`Project ${name}`);
|
|
862
893
|
ctx.log(`Path: ${tilde(proj.path)}`);
|
|
863
894
|
ctx.log(`PRs into: ${proj.base ?? 'the default branch'}`);
|
|
895
|
+
ctx.log(`Workspace: ${workspaceOf(proj)}`);
|
|
896
|
+
if (proj.match?.length)
|
|
897
|
+
ctx.log(`Matches: ${proj.match.join(', ')}`);
|
|
864
898
|
ctx.log(`Used by: ${used.length ? used.join(', ') : 'no workflows'}`);
|
|
865
899
|
}
|
|
866
900
|
return 0;
|
|
867
901
|
},
|
|
868
902
|
async edit(args, ctx) {
|
|
869
|
-
const p = begin('project edit', args,
|
|
903
|
+
const p = begin('project edit', args, PROJECT_FLAGS, ctx);
|
|
870
904
|
if (typeof p === 'number')
|
|
871
905
|
return p;
|
|
872
906
|
if (!needArgs(p, 1, 'project edit', ctx))
|
|
@@ -877,9 +911,9 @@ const projectSubs = {
|
|
|
877
911
|
return 1;
|
|
878
912
|
const current = config.projects[name];
|
|
879
913
|
const v = p.values;
|
|
880
|
-
if (v.path === undefined && v.base === undefined) {
|
|
914
|
+
if (v.path === undefined && v.base === undefined && v.workspace === undefined && v.match === undefined) {
|
|
881
915
|
if (!interactive()) {
|
|
882
|
-
ctx.log('Nothing to change. Give --path or --
|
|
916
|
+
ctx.log('Nothing to change. Give --path, --base, --workspace or --match, or run this in a terminal to be asked.');
|
|
883
917
|
ctx.log(usageOf('project edit'));
|
|
884
918
|
return 2;
|
|
885
919
|
}
|
|
@@ -887,7 +921,8 @@ const projectSubs = {
|
|
|
887
921
|
}
|
|
888
922
|
else {
|
|
889
923
|
const path = v.path !== undefined ? resolve(expandHome(v.path)) : current.path;
|
|
890
|
-
|
|
924
|
+
// Only a new path needs checking; a workspace or match change keeps the repo.
|
|
925
|
+
if (v.path !== undefined && !isRepo(path)) {
|
|
891
926
|
ctx.log(`${tilde(path)} is not a git repository.`);
|
|
892
927
|
return 1;
|
|
893
928
|
}
|
|
@@ -899,6 +934,11 @@ const projectSubs = {
|
|
|
899
934
|
else
|
|
900
935
|
delete next.base;
|
|
901
936
|
}
|
|
937
|
+
const bad = applyWorkspaceFlags(config, name, next, v);
|
|
938
|
+
if (bad) {
|
|
939
|
+
ctx.log(bad);
|
|
940
|
+
return 1;
|
|
941
|
+
}
|
|
902
942
|
config.projects[name] = next;
|
|
903
943
|
}
|
|
904
944
|
save(ctx, config);
|
|
@@ -960,6 +1000,87 @@ const projectSubs = {
|
|
|
960
1000
|
return 0;
|
|
961
1001
|
},
|
|
962
1002
|
};
|
|
1003
|
+
// ---- workspaces ----
|
|
1004
|
+
const projectsIn = (config, ws) => Object.entries(config.projects).filter(([, p]) => workspaceOf(p) === ws).map(([n]) => n);
|
|
1005
|
+
const workspaceSubs = {
|
|
1006
|
+
async add(args, ctx) {
|
|
1007
|
+
const p = begin('workspace add', args, { root: { type: 'string' }, context: { type: 'string' } }, ctx);
|
|
1008
|
+
if (typeof p === 'number')
|
|
1009
|
+
return p;
|
|
1010
|
+
if (!needArgs(p, 1, 'workspace add', ctx))
|
|
1011
|
+
return 2;
|
|
1012
|
+
const name = p.positionals[0].trim();
|
|
1013
|
+
if (!WORKSPACE_NAME.test(name)) {
|
|
1014
|
+
ctx.log('A workspace name is letters, digits, dashes and underscores.');
|
|
1015
|
+
return 2;
|
|
1016
|
+
}
|
|
1017
|
+
if (!p.values.root) {
|
|
1018
|
+
ctx.log('Missing --root.');
|
|
1019
|
+
ctx.log(usageOf('workspace add'));
|
|
1020
|
+
return 2;
|
|
1021
|
+
}
|
|
1022
|
+
const config = readAgentsConfig(ctx.configDir);
|
|
1023
|
+
if (config.workspaces[name]) {
|
|
1024
|
+
ctx.log(`A workspace named ${name} already exists.`);
|
|
1025
|
+
return 1;
|
|
1026
|
+
}
|
|
1027
|
+
const root = resolve(expandHome(p.values.root));
|
|
1028
|
+
if (!existsSync(root)) {
|
|
1029
|
+
ctx.log(`${tilde(root)} does not exist.`);
|
|
1030
|
+
return 1;
|
|
1031
|
+
}
|
|
1032
|
+
const context = p.values.context ? resolve(root, expandHome(p.values.context)) : undefined;
|
|
1033
|
+
if (context && !existsSync(context)) {
|
|
1034
|
+
ctx.log(`${tilde(context)} does not exist.`);
|
|
1035
|
+
return 1;
|
|
1036
|
+
}
|
|
1037
|
+
config.workspaces[name] = { root, ...(context ? { context } : {}) };
|
|
1038
|
+
save(ctx, config);
|
|
1039
|
+
ctx.log(`Workspace ${name}: ${tilde(root)}. vsf agents project edit P --workspace ${name} puts a project in it.`);
|
|
1040
|
+
return 0;
|
|
1041
|
+
},
|
|
1042
|
+
async list(args, ctx) {
|
|
1043
|
+
const p = begin('workspace list', args, { json: { type: 'boolean' } }, ctx);
|
|
1044
|
+
if (typeof p === 'number')
|
|
1045
|
+
return p;
|
|
1046
|
+
const config = readAgentsConfig(ctx.configDir);
|
|
1047
|
+
const names = [...new Set([...Object.keys(config.workspaces), ...Object.values(config.projects).map(workspaceOf)])];
|
|
1048
|
+
if (p.values.json) {
|
|
1049
|
+
out(ctx, names.map((name) => ({ name, ...config.workspaces[name], projects: projectsIn(config, name) })));
|
|
1050
|
+
return 0;
|
|
1051
|
+
}
|
|
1052
|
+
if (!names.length)
|
|
1053
|
+
ctx.log('No workspaces yet. Every project runs in the default one; vsf agents workspace add creates another.');
|
|
1054
|
+
for (const name of names) {
|
|
1055
|
+
const w = config.workspaces[name];
|
|
1056
|
+
const projects = projectsIn(config, name);
|
|
1057
|
+
ctx.log(`${name} ${w ? tilde(w.root) : 'no root'}${w?.context ? `, context ${tilde(w.context)}` : ''}, ${projects.length ? projects.join(', ') : 'no projects'}`);
|
|
1058
|
+
}
|
|
1059
|
+
return 0;
|
|
1060
|
+
},
|
|
1061
|
+
async rm(args, ctx) {
|
|
1062
|
+
const p = begin('workspace rm', args, {}, ctx);
|
|
1063
|
+
if (typeof p === 'number')
|
|
1064
|
+
return p;
|
|
1065
|
+
if (!needArgs(p, 1, 'workspace rm', ctx))
|
|
1066
|
+
return 2;
|
|
1067
|
+
const config = readAgentsConfig(ctx.configDir);
|
|
1068
|
+
const name = p.positionals[0].trim();
|
|
1069
|
+
if (!config.workspaces[name]) {
|
|
1070
|
+
ctx.log(`No workspace named ${name}.`);
|
|
1071
|
+
return 1;
|
|
1072
|
+
}
|
|
1073
|
+
const used = projectsIn(config, name);
|
|
1074
|
+
if (used.length) {
|
|
1075
|
+
ctx.log(`Used by ${used.join(', ')}. Move those projects first: vsf agents project edit P --workspace OTHER.`);
|
|
1076
|
+
return 1;
|
|
1077
|
+
}
|
|
1078
|
+
delete config.workspaces[name];
|
|
1079
|
+
save(ctx, config);
|
|
1080
|
+
ctx.log(`Deleted workspace ${name}.`);
|
|
1081
|
+
return 0;
|
|
1082
|
+
},
|
|
1083
|
+
};
|
|
963
1084
|
// ---- homes ----
|
|
964
1085
|
// Per enabled agent: ours, taken by something else, or missing.
|
|
965
1086
|
function homeReport(config, h) {
|
|
@@ -1242,6 +1363,7 @@ const agents = {
|
|
|
1242
1363
|
return 0;
|
|
1243
1364
|
},
|
|
1244
1365
|
project: (args, ctx) => group('project', projectSubs, args, ctx),
|
|
1366
|
+
workspace: (args, ctx) => group('workspace', workspaceSubs, args, ctx),
|
|
1245
1367
|
homes: homeCommands.homes,
|
|
1246
1368
|
link: homeCommands.link,
|
|
1247
1369
|
unlink: homeCommands.unlink,
|
package/dist/manage.d.ts
CHANGED
|
@@ -18,4 +18,5 @@ export type Place = {
|
|
|
18
18
|
};
|
|
19
19
|
export declare function moveKey<T>(record: Record<string, T>, name: string, place: Place): Record<string, T>;
|
|
20
20
|
export declare const workflowsUsing: (config: Pick<AgentsConfig, "workflows">, project: string) => string[];
|
|
21
|
-
export declare function
|
|
21
|
+
export declare function projectProblems(projects: AgentsConfig['projects'], workspaces: AgentsConfig['workspaces']): string[];
|
|
22
|
+
export declare function validateConfig(value: unknown, saved?: Pick<AgentsConfig, 'projects' | 'workflows'> & Partial<Pick<AgentsConfig, 'workspaces'>>): string[];
|
package/dist/manage.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { basename, join, resolve } from 'node:path';
|
|
3
|
-
import { AGENTS, AGENT_FILE, OUTPUTS, STEP_ID, isBuiltin, readAgentFolder, stepFile, describeTrigger, triggerSpec } from "./config.js";
|
|
3
|
+
import { AGENTS, AGENT_FILE, DEFAULT_WORKSPACE, OUTPUTS, STEP_ID, WORKSPACE_NAME, isBuiltin, readAgentFolder, stepFile, describeTrigger, triggerSpec } from "./config.js";
|
|
4
4
|
import { parseCron } from "./cron.js";
|
|
5
5
|
const isInt = (v, min, max) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
|
|
6
6
|
// The line a step's prompt must ask for, so the runner can read the result.
|
|
@@ -185,6 +185,34 @@ export function moveKey(record, name, place) {
|
|
|
185
185
|
}
|
|
186
186
|
export const workflowsUsing = (config, project) => Object.entries(config.workflows).filter(([, w]) => w.project === project).map(([n]) => n);
|
|
187
187
|
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
188
|
+
// What is wrong with the projects' workspaces and matches: a workspace that
|
|
189
|
+
// does not exist, or a tracker project name two projects both claim.
|
|
190
|
+
export function projectProblems(projects, workspaces) {
|
|
191
|
+
const problems = [];
|
|
192
|
+
const claimed = new Map();
|
|
193
|
+
for (const [name, p] of Object.entries(projects)) {
|
|
194
|
+
if (!isObj(p))
|
|
195
|
+
continue;
|
|
196
|
+
if (p.workspace !== undefined && (typeof p.workspace !== 'string' || (p.workspace !== DEFAULT_WORKSPACE && !Object.hasOwn(workspaces, p.workspace)))) {
|
|
197
|
+
problems.push(`Project ${name}: workspace ${String(p.workspace)} does not exist. vsf agents workspace add creates it.`);
|
|
198
|
+
}
|
|
199
|
+
if (p.match === undefined)
|
|
200
|
+
continue;
|
|
201
|
+
if (!Array.isArray(p.match) || p.match.some((m) => typeof m !== 'string' || !m.trim())) {
|
|
202
|
+
problems.push(`Project ${name}: match must list tracker project names.`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
for (const m of p.match) {
|
|
206
|
+
const key = m.trim().toLowerCase();
|
|
207
|
+
const other = claimed.get(key);
|
|
208
|
+
if (other && other !== name)
|
|
209
|
+
problems.push(`Projects ${other} and ${name} both match ${m}.`);
|
|
210
|
+
else
|
|
211
|
+
claimed.set(key, name);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return problems;
|
|
215
|
+
}
|
|
188
216
|
// Everything wrong with a whole agents config, e.g. one edited by hand.
|
|
189
217
|
// `saved` is the config on disk: only what the write changes is checked, so a
|
|
190
218
|
// moved folder or a retired agent elsewhere does not block an unrelated edit.
|
|
@@ -209,6 +237,23 @@ export function validateConfig(value, saved) {
|
|
|
209
237
|
else if (saved?.projects?.[name]?.path !== p.path && !existsSync(join(p.path, '.git')))
|
|
210
238
|
problems.push(`Project ${name}: ${p.path} is not a git repository.`);
|
|
211
239
|
}
|
|
240
|
+
// Older configs have no workspaces: every project is in the default one.
|
|
241
|
+
const workspaces = value.workspaces === undefined ? {} : isObj(value.workspaces) ? value.workspaces : null;
|
|
242
|
+
if (!workspaces)
|
|
243
|
+
problems.push('workspaces must be an object.');
|
|
244
|
+
for (const [name, w] of Object.entries(workspaces ?? {})) {
|
|
245
|
+
if (!WORKSPACE_NAME.test(name))
|
|
246
|
+
problems.push(`Workspace ${name} must be letters, digits, dashes and underscores.`);
|
|
247
|
+
if (!isObj(w) || typeof w.root !== 'string' || !w.root)
|
|
248
|
+
problems.push(`Workspace ${name} needs a root folder.`);
|
|
249
|
+
else {
|
|
250
|
+
if (saved?.workspaces?.[name]?.root !== w.root && !existsSync(w.root))
|
|
251
|
+
problems.push(`Workspace ${name}: ${w.root} does not exist.`);
|
|
252
|
+
if (w.context !== undefined && typeof w.context !== 'string')
|
|
253
|
+
problems.push(`Workspace ${name} context must be a file name.`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
problems.push(...projectProblems((projects ?? {}), (workspaces ?? {})));
|
|
212
257
|
if (!Array.isArray(value.enabled) || value.enabled.some((a) => typeof a !== 'string' || !(a in AGENTS)))
|
|
213
258
|
problems.push(`enabled must list agents from: ${Object.keys(AGENTS).join(', ')}.`);
|
|
214
259
|
if (value.commits !== undefined && (!isObj(value.commits) || Object.entries(value.commits).some(([k, v]) => !['signOff', 'dropAiTrailers'].includes(k) || typeof v !== 'boolean')))
|
package/dist/runner.d.ts
CHANGED
|
@@ -16,15 +16,38 @@ type Job = {
|
|
|
16
16
|
card: Card;
|
|
17
17
|
trigger: Trigger;
|
|
18
18
|
workflow: string;
|
|
19
|
+
workspace: string;
|
|
19
20
|
};
|
|
20
21
|
export declare function cronCard(workflow: string, task: string, at: Date): Card;
|
|
21
|
-
export
|
|
22
|
+
export interface JobQuery {
|
|
23
|
+
only?: string;
|
|
24
|
+
workflow?: string;
|
|
25
|
+
schedule?: string;
|
|
22
26
|
force?: boolean;
|
|
27
|
+
busy?: Set<string>;
|
|
28
|
+
skip?: Set<string>;
|
|
29
|
+
limit?: number;
|
|
23
30
|
now?: Date;
|
|
24
|
-
}
|
|
31
|
+
}
|
|
32
|
+
export declare function findJobs(rc: RunContext, q?: JobQuery): Promise<Job[]>;
|
|
33
|
+
export declare function nextJob(rc: RunContext, only?: string, workflowName?: string, opts?: Omit<JobQuery, 'only' | 'workflow' | 'limit'>): Promise<Job | null>;
|
|
25
34
|
export declare function cancelCard(tracker: Tracker, configDir: string, cardId: string, label: string, source?: 'linear' | 'voidtrack'): Promise<boolean>;
|
|
26
35
|
export declare function tick(rc: RunContext, only?: string, opts?: {
|
|
27
36
|
workflow?: string;
|
|
28
37
|
from?: string;
|
|
38
|
+
schedule?: string;
|
|
39
|
+
child?: boolean;
|
|
29
40
|
}): Promise<number>;
|
|
41
|
+
export interface ChildRun {
|
|
42
|
+
kill(signal: NodeJS.Signals): unknown;
|
|
43
|
+
once(event: 'exit' | 'error', listener: (err?: Error) => void): unknown;
|
|
44
|
+
exitCode: number | null;
|
|
45
|
+
}
|
|
46
|
+
export interface DispatchDeps {
|
|
47
|
+
start: (args: string[]) => ChildRun;
|
|
48
|
+
waitMs: number;
|
|
49
|
+
reload?: () => AgentsConfig;
|
|
50
|
+
}
|
|
51
|
+
export declare const jobArgs: (job: Job) => string[];
|
|
52
|
+
export declare function dispatch(rc: RunContext, over?: Partial<DispatchDeps>): Promise<number>;
|
|
30
53
|
export {};
|
package/dist/runner.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
1
2
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
3
|
import { basename, join } from 'node:path';
|
|
3
4
|
import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget, isStatusLabel, latestPlan, needsPlan, parseDevResult, parseVerdict, planBody, qaPassed, stageLabels, verdictBlock, } from "./workflow.js";
|
|
4
|
-
import { agentsDir, hasWork, isCardTrigger, resolveStep, stepId, stepIdLabel } from "./config.js";
|
|
5
|
+
import { agentsDir, hasWork, isCardTrigger, resolveProject, resolveStep, stepId, stepIdLabel, workspaceContext, workspaceOf } from "./config.js";
|
|
5
6
|
import { lastDue, parseCron } from "./cron.js";
|
|
6
7
|
import { runAgent, stopAgents } from "./engine.js";
|
|
7
8
|
import { commitsAhead, enforceCommitPolicy, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
|
|
8
9
|
import { noTracker } from "./tracker.js";
|
|
9
10
|
import { recall, syncMemory, vaultPath } from "./memory.js";
|
|
10
11
|
import { claudeSettingsFile, readRules, rulesSection } from "./profile.js";
|
|
11
|
-
import {
|
|
12
|
+
import { acquireWorkspace, clearCurrent, heldByOther, isPaused, liveRuns, readCurrent, readPending, releaseWorkspace, writeCurrent, writePending, writeRun, writeWorkspace, readCronState, writeCronState } from "./state.js";
|
|
12
13
|
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
|
|
13
14
|
class Park extends Error {
|
|
14
15
|
}
|
|
@@ -55,13 +56,14 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
55
56
|
const writeBack = isCardTrigger(trigger) ? (workflow.writeBack ?? {}) : {};
|
|
56
57
|
const labelsOn = writeBack.labels !== false;
|
|
57
58
|
const startedAt = new Date().toISOString();
|
|
58
|
-
const
|
|
59
|
+
const workspace = workspaceOf(project);
|
|
60
|
+
const record = { card: card.identifier, title: card.title, url: card.url || undefined, workflow: workflowName, trigger: label || (trigger.source === 'cron' ? trigger.schedule : ''), source: trigger.source, steps: [], outcome: 'running', workspace, startedAt };
|
|
59
61
|
active = { record, runDir };
|
|
60
62
|
const save = () => writeRun(runDir, record);
|
|
61
63
|
save();
|
|
62
64
|
let loops = 0;
|
|
63
|
-
// The lock tells status and cancel what is running.
|
|
64
|
-
const mark = (step) =>
|
|
65
|
+
// The workspace lock tells status and cancel what is running.
|
|
66
|
+
const mark = (step) => writeWorkspace(rc.configDir, workspace, { pid: process.pid, card: card.identifier, title: card.title, workflow: workflowName, step, round: loops, startedAt, runDir, source: trigger.source });
|
|
65
67
|
// Written before the first tracker call, so a cancel always knows the card.
|
|
66
68
|
mark('Starting');
|
|
67
69
|
const begin = (def) => {
|
|
@@ -157,7 +159,8 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
157
159
|
const pulled = syncMemory(rc.selfCommand, rc.configDir);
|
|
158
160
|
if (pulled)
|
|
159
161
|
say(pulled);
|
|
160
|
-
const
|
|
162
|
+
const context = workspaceContext(config, workspace);
|
|
163
|
+
const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), context ? `# Workspace\n\n${context}` : '', vault ? memoryNote(vault) : '', rules].filter(Boolean).join('\n\n');
|
|
161
164
|
const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
|
|
162
165
|
ensureWorktree(project.path, worktree, card.branchName, base);
|
|
163
166
|
say(`working in ${worktree} on ${card.branchName}`);
|
|
@@ -343,7 +346,9 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
343
346
|
say(shared);
|
|
344
347
|
}
|
|
345
348
|
}
|
|
346
|
-
//
|
|
349
|
+
// The workspace whose queue a job waits in. A workflow whose project is
|
|
350
|
+
// missing still queues somewhere, so its run can say what is wrong.
|
|
351
|
+
const jobWorkspace = (config, workflow, card) => workspaceOf(resolveProject(config, workflow, card)?.project);
|
|
347
352
|
export function cronCard(workflow, task, at) {
|
|
348
353
|
const stamp = at.toISOString().slice(0, 16).replace(/[-:T]/g, '');
|
|
349
354
|
const slug = workflow.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cron';
|
|
@@ -360,21 +365,24 @@ export function cronCard(workflow, task, at) {
|
|
|
360
365
|
};
|
|
361
366
|
}
|
|
362
367
|
const cronKey = (workflow, schedule) => `${workflow} ${schedule}`;
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
export async function
|
|
369
|
-
const now =
|
|
370
|
-
const
|
|
368
|
+
// Jobs waiting on the workflows' triggers, in workflow order, at most one per
|
|
369
|
+
// workspace and none in a busy one: a card carrying a trigger label, or a
|
|
370
|
+
// schedule that came due since it last fired. A schedule seen for the first
|
|
371
|
+
// time starts counting from now, and one whose workspace is busy keeps waiting
|
|
372
|
+
// without being marked as fired.
|
|
373
|
+
export async function findJobs(rc, q = {}) {
|
|
374
|
+
const now = q.now ?? new Date();
|
|
375
|
+
const limit = q.limit ?? Infinity;
|
|
376
|
+
const taken = new Set(q.busy ?? []);
|
|
377
|
+
const out = [];
|
|
378
|
+
const workflows = Object.entries(rc.config.workflows).filter(([name]) => !q.workflow || name === q.workflow);
|
|
371
379
|
let pinned = null;
|
|
372
|
-
if (only) {
|
|
373
|
-
const team = only.split('-')[0].toUpperCase();
|
|
380
|
+
if (q.only) {
|
|
381
|
+
const team = q.only.split('-')[0].toUpperCase();
|
|
374
382
|
const sources = [...new Set(workflows.flatMap(([, w]) => w.triggers.filter(isCardTrigger).filter((t) => t.team === team).map((t) => t.source)))];
|
|
375
383
|
for (const source of sources) {
|
|
376
384
|
try {
|
|
377
|
-
pinned = { card: await rc.trackers(source).card(only), source };
|
|
385
|
+
pinned = { card: await rc.trackers(source).card(q.only), source };
|
|
378
386
|
break;
|
|
379
387
|
}
|
|
380
388
|
catch (err) {
|
|
@@ -383,32 +391,50 @@ export async function nextJob(rc, only, workflowName, opts = {}) {
|
|
|
383
391
|
}
|
|
384
392
|
}
|
|
385
393
|
if (!pinned)
|
|
386
|
-
return
|
|
394
|
+
return [];
|
|
387
395
|
}
|
|
388
396
|
const cron = readCronState(rc.configDir);
|
|
389
397
|
let cronChanged = false;
|
|
398
|
+
const take = (job, workspace) => {
|
|
399
|
+
taken.add(workspace);
|
|
400
|
+
out.push({ ...job, workspace });
|
|
401
|
+
return out.length >= limit;
|
|
402
|
+
};
|
|
390
403
|
try {
|
|
391
404
|
for (const [name, workflow] of workflows) {
|
|
392
405
|
for (const trigger of workflow.triggers) {
|
|
393
406
|
if (!isCardTrigger(trigger)) {
|
|
394
|
-
const schedule = only ? null : parseCron(trigger.schedule);
|
|
407
|
+
const schedule = q.only || (q.schedule && q.schedule !== trigger.schedule) ? null : parseCron(trigger.schedule);
|
|
395
408
|
if (!schedule)
|
|
396
409
|
continue;
|
|
397
410
|
const key = cronKey(name, trigger.schedule);
|
|
398
411
|
const last = cron[key];
|
|
399
|
-
|
|
400
|
-
if (!last || due) {
|
|
412
|
+
if (!last) {
|
|
401
413
|
cron[key] = now.toISOString();
|
|
402
414
|
cronChanged = true;
|
|
403
415
|
}
|
|
404
|
-
|
|
405
|
-
|
|
416
|
+
const due = q.force ? now : last ? lastDue(schedule, new Date(last), now) : null;
|
|
417
|
+
if (!due)
|
|
418
|
+
continue;
|
|
419
|
+
const card = cronCard(name, trigger.task, due);
|
|
420
|
+
const workspace = jobWorkspace(rc.config, workflow, card);
|
|
421
|
+
if (taken.has(workspace))
|
|
422
|
+
continue;
|
|
423
|
+
cron[key] = now.toISOString();
|
|
424
|
+
cronChanged = true;
|
|
425
|
+
if (take({ card, trigger, workflow: name }, workspace))
|
|
426
|
+
return out;
|
|
406
427
|
continue;
|
|
407
428
|
}
|
|
429
|
+
// A run for one schedule takes no labelled cards.
|
|
430
|
+
if (q.schedule)
|
|
431
|
+
continue;
|
|
408
432
|
// A pinned card may have lost its label (a parked card re-run by hand), so match by team.
|
|
409
433
|
if (pinned) {
|
|
410
|
-
if (pinned.source === trigger.source && pinned.card.teamKey === trigger.team)
|
|
411
|
-
|
|
434
|
+
if (pinned.source === trigger.source && pinned.card.teamKey === trigger.team) {
|
|
435
|
+
take({ card: pinned.card, trigger, workflow: name }, jobWorkspace(rc.config, workflow, pinned.card));
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
412
438
|
continue;
|
|
413
439
|
}
|
|
414
440
|
let cards;
|
|
@@ -420,18 +446,33 @@ export async function nextJob(rc, only, workflowName, opts = {}) {
|
|
|
420
446
|
rc.log(`Could not check ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} for ${trigger.team} + ${trigger.label} (${err.message}). Trying again next minute.`);
|
|
421
447
|
continue;
|
|
422
448
|
}
|
|
423
|
-
const card
|
|
424
|
-
|
|
425
|
-
|
|
449
|
+
for (const card of cards) {
|
|
450
|
+
if (card.teamKey !== trigger.team || !card.labels.includes(trigger.label))
|
|
451
|
+
continue;
|
|
452
|
+
// A card already taken this pass, by an earlier trigger, is not run twice.
|
|
453
|
+
if (q.skip?.has(card.identifier) || out.some((j) => j.card.identifier === card.identifier))
|
|
454
|
+
continue;
|
|
455
|
+
const workspace = jobWorkspace(rc.config, workflow, card);
|
|
456
|
+
if (taken.has(workspace))
|
|
457
|
+
continue;
|
|
458
|
+
if (take({ card, trigger, workflow: name }, workspace))
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
426
461
|
}
|
|
427
462
|
}
|
|
428
|
-
return
|
|
463
|
+
return out;
|
|
429
464
|
}
|
|
430
465
|
finally {
|
|
431
466
|
if (cronChanged)
|
|
432
467
|
writeCronState(rc.configDir, cron);
|
|
433
468
|
}
|
|
434
469
|
}
|
|
470
|
+
// The first job waiting, as findJobs finds it. With a card, only that card is
|
|
471
|
+
// considered; with a workflow name, only that workflow, and `force` fires its
|
|
472
|
+
// schedule now.
|
|
473
|
+
export async function nextJob(rc, only, workflowName, opts = {}) {
|
|
474
|
+
return (await findJobs(rc, { ...opts, only, workflow: workflowName, limit: 1 }))[0] ?? null;
|
|
475
|
+
}
|
|
435
476
|
// Retries tracker writes that failed while parking. Never throws.
|
|
436
477
|
async function flushPending(rc) {
|
|
437
478
|
const left = [];
|
|
@@ -482,21 +523,20 @@ function queueCancel(configDir, cardId, comment, source = 'linear') {
|
|
|
482
523
|
// nothing more to do
|
|
483
524
|
}
|
|
484
525
|
}
|
|
485
|
-
//
|
|
526
|
+
// Runs one job in its workspace's queue: a pinned card, a workflow's schedule,
|
|
527
|
+
// or the first job waiting in a free workspace. The workspace lock keeps two
|
|
528
|
+
// runs from working in one workspace at once. `child` is a run the dispatcher
|
|
529
|
+
// started, which leaves the pending writes to it.
|
|
486
530
|
export async function tick(rc, only, opts = {}) {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (only)
|
|
493
|
-
rc.log(`The runner is busy with ${held.card ?? 'a check'}. Try again when it finishes.`);
|
|
494
|
-
return only ? 1 : 0;
|
|
495
|
-
}
|
|
496
|
-
writeCurrent(rc.configDir, { pid: process.pid, startedAt: new Date().toISOString() });
|
|
497
|
-
// A stop (Ctrl+C, launchd, shutdown) ends the agents, marks the run, and
|
|
498
|
-
// takes the working label off the card so it is never left stuck.
|
|
531
|
+
let held = null;
|
|
532
|
+
// A stop (Ctrl+C, the dispatcher, launchd, shutdown) ends the agents, marks
|
|
533
|
+
// the run, and takes the working label off the card so it is never left stuck.
|
|
534
|
+
// The dispatcher and the service can both signal, so a second one is ignored.
|
|
535
|
+
let stopping = false;
|
|
499
536
|
const onSignal = async () => {
|
|
537
|
+
if (stopping)
|
|
538
|
+
return;
|
|
539
|
+
stopping = true;
|
|
500
540
|
await stopAgents();
|
|
501
541
|
const running = active && active.record.outcome === 'running' ? active : null;
|
|
502
542
|
if (active) {
|
|
@@ -519,18 +559,21 @@ export async function tick(rc, only, opts = {}) {
|
|
|
519
559
|
if (!finished)
|
|
520
560
|
queueCancel(rc.configDir, card, `**Cancelled.** Add \`${trigger}\` again to restart.`, source);
|
|
521
561
|
}
|
|
522
|
-
|
|
562
|
+
if (held)
|
|
563
|
+
releaseWorkspace(rc.configDir, held);
|
|
523
564
|
process.exit(143);
|
|
524
565
|
};
|
|
525
|
-
process.
|
|
526
|
-
process.
|
|
566
|
+
process.on('SIGTERM', onSignal);
|
|
567
|
+
process.on('SIGINT', onSignal);
|
|
527
568
|
try {
|
|
528
|
-
|
|
569
|
+
if (!opts.child)
|
|
570
|
+
await flushPending(rc);
|
|
529
571
|
if (!only && isPaused(rc.configDir))
|
|
530
572
|
return 0;
|
|
531
573
|
let job;
|
|
532
574
|
try {
|
|
533
|
-
|
|
575
|
+
const busy = only ? undefined : new Set(liveRuns(rc.configDir).filter((c) => c.pid !== process.pid).map((c) => workspaceOf(c)));
|
|
576
|
+
job = await nextJob(rc, only, opts.workflow, { force: !!opts.workflow && !only, schedule: opts.schedule, busy });
|
|
534
577
|
}
|
|
535
578
|
catch (err) {
|
|
536
579
|
rc.log(`Could not read ${only} (${err.message}).`);
|
|
@@ -542,8 +585,8 @@ export async function tick(rc, only, opts = {}) {
|
|
|
542
585
|
return only ? 1 : 0;
|
|
543
586
|
}
|
|
544
587
|
const workflow = rc.config.workflows[job.workflow];
|
|
545
|
-
const
|
|
546
|
-
if (!
|
|
588
|
+
const resolved = resolveProject(rc.config, workflow, job.card);
|
|
589
|
+
if (!resolved) {
|
|
547
590
|
rc.log(`Workflow ${job.workflow} points at a missing project ${workflow.project}. Run vsf agents workflow edit to fix it.`);
|
|
548
591
|
return 1;
|
|
549
592
|
}
|
|
@@ -551,13 +594,109 @@ export async function tick(rc, only, opts = {}) {
|
|
|
551
594
|
rc.log(`Workflow ${job.workflow} has no ${stepIdLabel(opts.from)} step.`);
|
|
552
595
|
return 2;
|
|
553
596
|
}
|
|
554
|
-
const
|
|
597
|
+
const busy = acquireWorkspace(rc.configDir, job.workspace, { pid: process.pid, card: job.card.identifier, startedAt: new Date().toISOString() });
|
|
598
|
+
if (busy) {
|
|
599
|
+
rc.log(`Workspace ${job.workspace} is busy with ${busy.card ?? 'a check'}. Try again when it finishes.`);
|
|
600
|
+
return 1;
|
|
601
|
+
}
|
|
602
|
+
held = job.workspace;
|
|
603
|
+
const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, resolved.project, rc, { from: opts.from });
|
|
555
604
|
return outcome === 'verified' ? 0 : 1;
|
|
556
605
|
}
|
|
557
606
|
catch (err) {
|
|
558
607
|
rc.log(`Error: ${err.message}`);
|
|
559
608
|
return 1;
|
|
560
609
|
}
|
|
610
|
+
finally {
|
|
611
|
+
process.off('SIGTERM', onSignal);
|
|
612
|
+
process.off('SIGINT', onSignal);
|
|
613
|
+
if (held)
|
|
614
|
+
releaseWorkspace(rc.configDir, held);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// The arguments that make a child tick run exactly this job.
|
|
618
|
+
export const jobArgs = (job) => isCardTrigger(job.trigger) ? ['--card', job.card.identifier, '--workflow', job.workflow] : ['--workflow', job.workflow, '--schedule', job.trigger.schedule];
|
|
619
|
+
const realDeps = (rc) => ({
|
|
620
|
+
start: (args) => {
|
|
621
|
+
const [bin, ...rest] = rc.selfCommand;
|
|
622
|
+
return spawn(bin, [...rest, 'agents', 'tick', ...args], { stdio: 'inherit' });
|
|
623
|
+
},
|
|
624
|
+
waitMs: 60_000,
|
|
625
|
+
});
|
|
626
|
+
// The background runner. The service starts a new pass only once the last one
|
|
627
|
+
// ended, so this pass stays up while cards run: it starts one child process
|
|
628
|
+
// per workspace with work waiting, looks again whenever one ends or a minute
|
|
629
|
+
// passes, and returns once nothing runs and nothing waits. runner.lock keeps
|
|
630
|
+
// two dispatchers from working at once.
|
|
631
|
+
export async function dispatch(rc, over = {}) {
|
|
632
|
+
const deps = { ...realDeps(rc), ...over };
|
|
633
|
+
const held = readCurrent(rc.configDir);
|
|
634
|
+
if (held && heldByOther(held))
|
|
635
|
+
return 0;
|
|
636
|
+
writeCurrent(rc.configDir, { pid: process.pid, startedAt: new Date().toISOString() });
|
|
637
|
+
const children = new Map();
|
|
638
|
+
// When each card was started, so one whose run failed before it took the
|
|
639
|
+
// trigger label off waits for the next pass instead of starting again at once.
|
|
640
|
+
const started = new Map();
|
|
641
|
+
// Ends the wait early when a child exits.
|
|
642
|
+
let wake = () => { };
|
|
643
|
+
// A stop ends every run: each child cancels its own card.
|
|
644
|
+
let stopping = false;
|
|
645
|
+
const onSignal = async () => {
|
|
646
|
+
if (stopping)
|
|
647
|
+
return;
|
|
648
|
+
stopping = true;
|
|
649
|
+
const exits = [...children.values()].filter((c) => c.exitCode === null).map((c) => new Promise((resolve) => c.once('exit', () => resolve())));
|
|
650
|
+
for (const c of children.values())
|
|
651
|
+
c.kill('SIGTERM');
|
|
652
|
+
await Promise.race([Promise.all(exits), new Promise((resolve) => setTimeout(resolve, 10_000).unref())]);
|
|
653
|
+
clearCurrent(rc.configDir);
|
|
654
|
+
process.exit(143);
|
|
655
|
+
};
|
|
656
|
+
process.on('SIGTERM', onSignal);
|
|
657
|
+
process.on('SIGINT', onSignal);
|
|
658
|
+
try {
|
|
659
|
+
for (;;) {
|
|
660
|
+
if (deps.reload)
|
|
661
|
+
rc = { ...rc, config: deps.reload() };
|
|
662
|
+
await flushPending(rc);
|
|
663
|
+
if (!isPaused(rc.configDir)) {
|
|
664
|
+
const busy = new Set([...children.keys(), ...liveRuns(rc.configDir).map((c) => workspaceOf(c))]);
|
|
665
|
+
const skip = new Set([...started].filter(([, at]) => Date.now() - at < deps.waitMs).map(([id]) => id));
|
|
666
|
+
for (const job of await findJobs(rc, { busy, skip })) {
|
|
667
|
+
started.set(job.card.identifier, Date.now());
|
|
668
|
+
rc.log(`${job.card.identifier} starts ${job.workflow} in workspace ${job.workspace}.`);
|
|
669
|
+
const child = deps.start(jobArgs(job));
|
|
670
|
+
children.set(job.workspace, child);
|
|
671
|
+
const ended = () => {
|
|
672
|
+
if (children.get(job.workspace) === child)
|
|
673
|
+
children.delete(job.workspace);
|
|
674
|
+
wake();
|
|
675
|
+
};
|
|
676
|
+
child.once('exit', ended);
|
|
677
|
+
// A program that could not start never exits.
|
|
678
|
+
child.once('error', (err) => {
|
|
679
|
+
rc.log(`Could not start the run for ${job.card.identifier} (${err?.message ?? 'unknown error'}).`);
|
|
680
|
+
ended();
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (!children.size)
|
|
685
|
+
return 0;
|
|
686
|
+
await new Promise((resolve) => {
|
|
687
|
+
const timer = setTimeout(resolve, deps.waitMs);
|
|
688
|
+
wake = () => {
|
|
689
|
+
clearTimeout(timer);
|
|
690
|
+
resolve();
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
wake = () => { };
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
rc.log(`Error: ${err.message}`);
|
|
698
|
+
return 1;
|
|
699
|
+
}
|
|
561
700
|
finally {
|
|
562
701
|
process.off('SIGTERM', onSignal);
|
|
563
702
|
process.off('SIGINT', onSignal);
|
package/dist/state.d.ts
CHANGED
|
@@ -9,15 +9,24 @@ export interface Current {
|
|
|
9
9
|
startedAt?: string;
|
|
10
10
|
runDir?: string;
|
|
11
11
|
source?: Trigger['source'];
|
|
12
|
+
workspace?: string;
|
|
12
13
|
}
|
|
13
14
|
export declare const lockPath: (configDir: string) => string;
|
|
14
|
-
export declare
|
|
15
|
+
export declare const readCurrent: (configDir: string) => Current | null;
|
|
15
16
|
export declare function writeCurrent(configDir: string, current: Current): void;
|
|
16
17
|
export declare const clearCurrent: (configDir: string) => void;
|
|
17
18
|
export declare function alive(pid: number): boolean;
|
|
18
19
|
export declare function ours(pid: number): boolean;
|
|
19
20
|
export declare function bootTime(): Date | null;
|
|
20
21
|
export declare function liveCurrent(configDir: string): Current | null;
|
|
22
|
+
export declare function heldByOther(c: Current): boolean;
|
|
23
|
+
export declare const workspaceLockPath: (configDir: string, workspace: string) => string;
|
|
24
|
+
export declare function acquireWorkspace(configDir: string, workspace: string, current: Omit<Current, 'workspace'>): Current | null;
|
|
25
|
+
export declare function writeWorkspace(configDir: string, workspace: string, current: Omit<Current, 'workspace'>): void;
|
|
26
|
+
export declare const releaseWorkspace: (configDir: string, workspace: string) => void;
|
|
27
|
+
export declare function workspaceLocks(configDir: string): Current[];
|
|
28
|
+
export declare const liveRuns: (configDir: string) => Current[];
|
|
29
|
+
export declare const clearLock: (configDir: string, c: Current) => void;
|
|
21
30
|
export declare const isPaused: (configDir: string) => boolean;
|
|
22
31
|
export declare function setPaused(configDir: string, paused: boolean): void;
|
|
23
32
|
export interface Pending {
|
|
@@ -51,6 +60,7 @@ export interface RunRecord {
|
|
|
51
60
|
outcome: Outcome;
|
|
52
61
|
reason?: string;
|
|
53
62
|
prUrl?: string;
|
|
63
|
+
workspace?: string;
|
|
54
64
|
startedAt: string;
|
|
55
65
|
endedAt?: string;
|
|
56
66
|
}
|
package/dist/state.js
CHANGED
|
@@ -3,8 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { agentsDir } from "./config.js";
|
|
5
5
|
export const lockPath = (configDir) => join(agentsDir(configDir), 'runner.lock');
|
|
6
|
-
|
|
7
|
-
const path = lockPath(configDir);
|
|
6
|
+
function readLock(path) {
|
|
8
7
|
if (!existsSync(path))
|
|
9
8
|
return null;
|
|
10
9
|
const text = readFileSync(path, 'utf8').trim();
|
|
@@ -16,6 +15,7 @@ export function readCurrent(configDir) {
|
|
|
16
15
|
return null;
|
|
17
16
|
}
|
|
18
17
|
}
|
|
18
|
+
export const readCurrent = (configDir) => readLock(lockPath(configDir));
|
|
19
19
|
export function writeCurrent(configDir, current) {
|
|
20
20
|
mkdirSync(agentsDir(configDir), { recursive: true });
|
|
21
21
|
writeFileSync(lockPath(configDir), JSON.stringify(current) + '\n');
|
|
@@ -44,11 +44,65 @@ export function bootTime() {
|
|
|
44
44
|
const sec = /sec = (\d+)/.exec(r.stdout ?? '')?.[1];
|
|
45
45
|
return sec ? new Date(Number(sec) * 1000) : null;
|
|
46
46
|
}
|
|
47
|
-
// The live
|
|
47
|
+
// The live dispatcher, or null when none runs or the lock is stale.
|
|
48
48
|
export function liveCurrent(configDir) {
|
|
49
49
|
const c = readCurrent(configDir);
|
|
50
50
|
return c && ours(c.pid) ? c : null;
|
|
51
51
|
}
|
|
52
|
+
// Whether a lock belongs to a live runner other than this process. A lock from
|
|
53
|
+
// before the last boot cannot be a live run, whatever its pid is now.
|
|
54
|
+
export function heldByOther(c) {
|
|
55
|
+
const booted = bootTime();
|
|
56
|
+
const fromBeforeBoot = !!(c.startedAt && booted && new Date(c.startedAt) < booted);
|
|
57
|
+
return c.pid !== process.pid && !fromBeforeBoot && ours(c.pid);
|
|
58
|
+
}
|
|
59
|
+
// One lock per workspace: the run holding its queue.
|
|
60
|
+
const locksDir = (configDir) => join(agentsDir(configDir), 'locks');
|
|
61
|
+
export const workspaceLockPath = (configDir, workspace) => join(locksDir(configDir), `${encodeURIComponent(workspace)}.json`);
|
|
62
|
+
// Takes a workspace's queue for this process. Returns null once taken, or the
|
|
63
|
+
// live run that holds it. A stale lock is removed and the take tried again.
|
|
64
|
+
// The file is created exclusively, so two runners never both take it.
|
|
65
|
+
export function acquireWorkspace(configDir, workspace, current) {
|
|
66
|
+
mkdirSync(locksDir(configDir), { recursive: true });
|
|
67
|
+
const path = workspaceLockPath(configDir, workspace);
|
|
68
|
+
for (let attempt = 0;; attempt++) {
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(path, JSON.stringify({ ...current, workspace }) + '\n', { flag: 'wx' });
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err.code !== 'EEXIST')
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
const held = readLock(path);
|
|
78
|
+
if (held && heldByOther(held))
|
|
79
|
+
return held;
|
|
80
|
+
if (attempt)
|
|
81
|
+
return held ?? { pid: 0, workspace };
|
|
82
|
+
rmSync(path, { force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Updates the lock of a queue this process holds.
|
|
86
|
+
export function writeWorkspace(configDir, workspace, current) {
|
|
87
|
+
mkdirSync(locksDir(configDir), { recursive: true });
|
|
88
|
+
writeFileSync(workspaceLockPath(configDir, workspace), JSON.stringify({ ...current, workspace }) + '\n');
|
|
89
|
+
}
|
|
90
|
+
export const releaseWorkspace = (configDir, workspace) => rmSync(workspaceLockPath(configDir, workspace), { force: true });
|
|
91
|
+
// Every workspace lock on disk, stale ones included.
|
|
92
|
+
export function workspaceLocks(configDir) {
|
|
93
|
+
let names;
|
|
94
|
+
try {
|
|
95
|
+
names = readdirSync(locksDir(configDir)).filter((n) => n.endsWith('.json')).sort();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
return names.map((n) => readLock(join(locksDir(configDir), n))).filter((c) => !!c);
|
|
101
|
+
}
|
|
102
|
+
// The card runs going on now, one per busy workspace.
|
|
103
|
+
export const liveRuns = (configDir) => workspaceLocks(configDir).filter((c) => ours(c.pid));
|
|
104
|
+
// Releases the lock a run left behind, wherever it lives.
|
|
105
|
+
export const clearLock = (configDir, c) => (c.workspace ? releaseWorkspace(configDir, c.workspace) : clearCurrent(configDir));
|
|
52
106
|
// Paused: the runner stays loaded but picks up no new cards.
|
|
53
107
|
const pausePath = (configDir) => join(agentsDir(configDir), 'paused');
|
|
54
108
|
export const isPaused = (configDir) => existsSync(pausePath(configDir));
|
package/package.json
CHANGED