@voidlabs.gg/voidshift-agents 0.5.1 → 0.7.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 +7 -2
- package/dist/control.js +82 -38
- package/dist/index.js +186 -11
- package/dist/manage.d.ts +2 -1
- package/dist/manage.js +46 -1
- package/dist/notify.d.ts +30 -0
- package/dist/notify.js +233 -0
- package/dist/runner.d.ts +28 -2
- package/dist/runner.js +223 -60
- package/dist/secrets.d.ts +1 -0
- package/dist/secrets.js +12 -1
- 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;
|
|
@@ -28,8 +28,13 @@ export declare function trackerWarnings(kinds: Kind[], trackers: Trackers): Prom
|
|
|
28
28
|
kind: Kind;
|
|
29
29
|
message: string;
|
|
30
30
|
}[]>;
|
|
31
|
+
export declare function changePaused(ctx: Pick<ToolContext, 'configDir' | 'log'>, paused: boolean, reason: string, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<void>;
|
|
31
32
|
export declare function workingLine(current: (Current & {
|
|
32
33
|
elapsedMinutes: number;
|
|
33
34
|
}) | null): string;
|
|
35
|
+
export declare function workspaceLines(workspaces: string[], running: (Current & {
|
|
36
|
+
elapsedMinutes: number;
|
|
37
|
+
})[]): string[];
|
|
38
|
+
export declare function pickCancel(locks: Current[], dispatcher: Current | null, card?: string): Current | string;
|
|
34
39
|
export declare const controlCommands: Record<string, ToolCommand>;
|
|
35
40
|
export declare function controlChecks(ctx: ToolContext): Promise<Check[]>;
|
package/dist/control.js
CHANGED
|
@@ -2,13 +2,14 @@ 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
|
+
import { notifyRun, notifyRunner, sendSummary } from "./notify.js";
|
|
12
13
|
const HOUR = 3_600_000;
|
|
13
14
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
14
15
|
const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
@@ -78,9 +79,9 @@ export function countRounds(steps) {
|
|
|
78
79
|
}
|
|
79
80
|
return rounds;
|
|
80
81
|
}
|
|
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 =
|
|
82
|
+
// A run that says 'running' but is none of the live runs was cut off.
|
|
83
|
+
export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !live.some((l) => (l.runDir ? l.runDir === e.dir : l.card === e.card));
|
|
84
|
+
export function formatRunLine(e, now = Date.now(), live = []) {
|
|
84
85
|
const r = e.record;
|
|
85
86
|
if (!r)
|
|
86
87
|
return `${e.card} ${when(stampDate(e.stamp))} older run, logs only (${plural(e.logs.length, 'log')})`;
|
|
@@ -121,14 +122,14 @@ const readLog = (configDir) => {
|
|
|
121
122
|
}
|
|
122
123
|
};
|
|
123
124
|
const USAGE = {
|
|
124
|
-
status: 'status [--json] Runner,
|
|
125
|
+
status: 'status [--json] Runner, the card each workspace is on, parked cards and workflows',
|
|
125
126
|
start: 'start Start the background runner',
|
|
126
|
-
stop: 'stop Stop the runner and cancel the
|
|
127
|
+
stop: 'stop Stop the runner and cancel the cards it is working on',
|
|
127
128
|
restart: 'restart Stop and start the runner',
|
|
128
129
|
pause: 'pause Pick up no new cards; a card in progress carries on',
|
|
129
130
|
resume: 'resume Pick up new cards again',
|
|
130
131
|
run: 'run [CARD] [--workflow W] [--from STEP] Run now instead of waiting for the next minute',
|
|
131
|
-
cancel: 'cancel [CARD] Stop
|
|
132
|
+
cancel: 'cancel [CARD] Stop a card being worked on and clear its labels',
|
|
132
133
|
retry: 'retry CARD [--workflow W] Queue a parked or cancelled card again',
|
|
133
134
|
runs: 'runs [CARD] [--limit N] [--json] Past runs, newest first',
|
|
134
135
|
show: 'show CARD [--json] Steps, outcome and labels of the latest run',
|
|
@@ -215,7 +216,7 @@ export async function cancelCurrent(ctx, c, deps) {
|
|
|
215
216
|
}
|
|
216
217
|
else
|
|
217
218
|
ctx.log('Cleared a stale lock.');
|
|
218
|
-
|
|
219
|
+
clearLock(ctx.configDir, c);
|
|
219
220
|
}
|
|
220
221
|
const realDeps = (ctx) => ({
|
|
221
222
|
ours,
|
|
@@ -246,6 +247,13 @@ export async function trackerWarnings(kinds, trackers) {
|
|
|
246
247
|
}
|
|
247
248
|
return out;
|
|
248
249
|
}
|
|
250
|
+
// Pauses or resumes the runner, and pings Discord only when that changed something.
|
|
251
|
+
export async function changePaused(ctx, paused, reason, fetcher = fetch, platform = process.platform) {
|
|
252
|
+
const was = isPaused(ctx.configDir);
|
|
253
|
+
setPaused(ctx.configDir, paused);
|
|
254
|
+
if (was !== paused)
|
|
255
|
+
await notifyRunner(ctx.configDir, paused, reason, ctx.log, fetcher, platform);
|
|
256
|
+
}
|
|
249
257
|
async function startCmd(ctx) {
|
|
250
258
|
if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
|
|
251
259
|
ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
|
|
@@ -254,7 +262,7 @@ async function startCmd(ctx) {
|
|
|
254
262
|
for (const w of await trackerWarnings(trackersInUse(readAgentsConfig(ctx.configDir)), trackersFor(ctx.configDir))) {
|
|
255
263
|
ctx.log(`Warning: ${w.message.replace(/\.?$/, '.')} Workflows on ${TRACKERS[w.kind].name} are skipped until then.`);
|
|
256
264
|
}
|
|
257
|
-
|
|
265
|
+
await changePaused(ctx, false, 'by vsf agents start');
|
|
258
266
|
if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
|
|
259
267
|
ctx.log('Could not start the runner. Check vsf agents logs.');
|
|
260
268
|
return 1;
|
|
@@ -263,7 +271,9 @@ async function startCmd(ctx) {
|
|
|
263
271
|
}
|
|
264
272
|
async function stopCmd(ctx) {
|
|
265
273
|
runnerService().stop(ctx.home);
|
|
266
|
-
// Stopping the runner may have stopped
|
|
274
|
+
// Stopping the runner may have stopped its runs; finish or tidy up whatever the locks still name.
|
|
275
|
+
for (const c of workspaceLocks(ctx.configDir))
|
|
276
|
+
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
267
277
|
const c = readCurrent(ctx.configDir);
|
|
268
278
|
if (c)
|
|
269
279
|
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
@@ -278,6 +288,12 @@ export function workingLine(current) {
|
|
|
278
288
|
return ' Checking now.';
|
|
279
289
|
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
290
|
}
|
|
291
|
+
const elapsed = (c) => (c.startedAt ? Math.max(0, Math.round((Date.now() - Date.parse(c.startedAt)) / 60_000)) : 0);
|
|
292
|
+
// One line per workspace: the card it is on, or idle.
|
|
293
|
+
export function workspaceLines(workspaces, running) {
|
|
294
|
+
const names = [...new Set([...workspaces, ...running.map((r) => workspaceOf(r))])];
|
|
295
|
+
return names.map((ws) => ` ${ws}:${workingLine(running.find((r) => workspaceOf(r) === ws) ?? null) || ' idle.'}`);
|
|
296
|
+
}
|
|
281
297
|
const status = guard(async (args, ctx) => {
|
|
282
298
|
const p = parse('status', args, { json: { type: 'boolean' } }, ctx);
|
|
283
299
|
if (typeof p === 'number')
|
|
@@ -286,8 +302,11 @@ const status = guard(async (args, ctx) => {
|
|
|
286
302
|
const runner = runnerState(ctx);
|
|
287
303
|
const text = readLog(ctx.configDir);
|
|
288
304
|
const lastTick = lastTickOf(text);
|
|
289
|
-
const live =
|
|
290
|
-
const
|
|
305
|
+
const live = liveRuns(ctx.configDir);
|
|
306
|
+
const running = live.map((c) => ({ ...c, workspace: workspaceOf(c), elapsedMinutes: elapsed(c) }));
|
|
307
|
+
const dispatcher = liveCurrent(ctx.configDir);
|
|
308
|
+
// The first run, or the dispatcher checking for work, for dashboards that know one run only.
|
|
309
|
+
const current = running.length ? { ...running[0], checking: false } : dispatcher ? { ...dispatcher, checking: true, elapsedMinutes: elapsed(dispatcher) } : null;
|
|
291
310
|
const seen = new Set();
|
|
292
311
|
const parked = [];
|
|
293
312
|
const interrupted = [];
|
|
@@ -303,16 +322,22 @@ const status = guard(async (args, ctx) => {
|
|
|
303
322
|
const workflows = Object.keys(config.workflows).length;
|
|
304
323
|
const projects = Object.keys(config.projects).length;
|
|
305
324
|
if (p.values.json) {
|
|
306
|
-
ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, parked, interrupted, workflows, projects }, null, 2));
|
|
325
|
+
ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, running, parked, interrupted, workflows, projects }, null, 2));
|
|
307
326
|
return 0;
|
|
308
327
|
}
|
|
309
|
-
|
|
328
|
+
// One workspace keeps the single line; several get a line each.
|
|
329
|
+
const workspaces = workspacesInUse(config);
|
|
330
|
+
const perWorkspace = runner !== 'stopped' && (workspaces.length > 1 || running.some((r) => !workspaces.includes(r.workspace)));
|
|
331
|
+
const working = perWorkspace ? '' : workingLine(current);
|
|
310
332
|
if (runner === 'running')
|
|
311
333
|
ctx.log(`Runner: running.${working}`);
|
|
312
334
|
else if (runner === 'paused')
|
|
313
335
|
ctx.log(`Runner: paused, picks up no new cards. vsf agents resume to continue.${working}`);
|
|
314
336
|
else
|
|
315
337
|
ctx.log('Runner: stopped. vsf agents start to begin.');
|
|
338
|
+
if (perWorkspace)
|
|
339
|
+
for (const line of workspaceLines(workspaces, running))
|
|
340
|
+
ctx.log(line);
|
|
316
341
|
if (lastTick)
|
|
317
342
|
ctx.log(`Last check: ${clock(new Date(lastTick.at))}, ${lastTick.line.replace(/\.$/, '') || 'no details'}.`);
|
|
318
343
|
for (const card of interrupted)
|
|
@@ -349,7 +374,7 @@ const pause = guard(async (args, ctx) => {
|
|
|
349
374
|
const p = parse('pause', args, {}, ctx);
|
|
350
375
|
if (typeof p === 'number')
|
|
351
376
|
return p;
|
|
352
|
-
|
|
377
|
+
await changePaused(ctx, true, 'by vsf agents pause');
|
|
353
378
|
ctx.log('Paused. The runner picks up no new cards; a card in progress carries on. vsf agents resume to continue.');
|
|
354
379
|
return 0;
|
|
355
380
|
});
|
|
@@ -357,7 +382,7 @@ const resume = guard(async (args, ctx) => {
|
|
|
357
382
|
const p = parse('resume', args, {}, ctx);
|
|
358
383
|
if (typeof p === 'number')
|
|
359
384
|
return p;
|
|
360
|
-
|
|
385
|
+
await changePaused(ctx, false, 'by vsf agents resume');
|
|
361
386
|
ctx.log('Resumed.');
|
|
362
387
|
return 0;
|
|
363
388
|
});
|
|
@@ -399,6 +424,20 @@ const run = guard(async (args, ctx) => {
|
|
|
399
424
|
}
|
|
400
425
|
return code;
|
|
401
426
|
});
|
|
427
|
+
// The lock a cancel ends, or why there is none. Every run's lock counts, stale
|
|
428
|
+
// ones included, so a dead run can be tidied up; with none, the dispatcher's
|
|
429
|
+
// own lock, a check in progress. Without a card it must be the only one.
|
|
430
|
+
export function pickCancel(locks, dispatcher, card) {
|
|
431
|
+
const all = locks.length ? locks : dispatcher ? [dispatcher] : [];
|
|
432
|
+
if (!all.length)
|
|
433
|
+
return card ? `${card} is not running.` : 'Nothing is running.';
|
|
434
|
+
const name = (l) => `${l.card ?? 'a check'}${l.workspace ? ` in ${l.workspace}` : ''}`;
|
|
435
|
+
if (card)
|
|
436
|
+
return all.find((l) => l.card === card) ?? `${card} is not running. Running: ${all.map(name).join(', ')}.`;
|
|
437
|
+
if (all.length > 1)
|
|
438
|
+
return `More than one card is running: ${all.map(name).join(', ')}. Name one: vsf agents cancel CARD.`;
|
|
439
|
+
return all[0];
|
|
440
|
+
}
|
|
402
441
|
const cancel = guard(async (args, ctx) => {
|
|
403
442
|
const p = parse('cancel', args, {}, ctx);
|
|
404
443
|
if (typeof p === 'number')
|
|
@@ -406,16 +445,12 @@ const cancel = guard(async (args, ctx) => {
|
|
|
406
445
|
const card = p.positionals[0]?.toUpperCase();
|
|
407
446
|
if (p.positionals.length > 1 || (card && !CARD_ID.test(card)))
|
|
408
447
|
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;
|
|
448
|
+
const found = pickCancel(workspaceLocks(ctx.configDir), readCurrent(ctx.configDir), card);
|
|
449
|
+
if (typeof found === 'string') {
|
|
450
|
+
ctx.log(found);
|
|
451
|
+
return found.startsWith('More than one') ? 2 : card ? 1 : 0;
|
|
417
452
|
}
|
|
418
|
-
await cancelCurrent(ctx,
|
|
453
|
+
await cancelCurrent(ctx, found, realDeps(ctx));
|
|
419
454
|
return 0;
|
|
420
455
|
});
|
|
421
456
|
const retry = guard(async (args, ctx) => {
|
|
@@ -425,7 +460,7 @@ const retry = guard(async (args, ctx) => {
|
|
|
425
460
|
const id = p.positionals[0]?.toUpperCase();
|
|
426
461
|
if (p.positionals.length !== 1 || !CARD_ID.test(id))
|
|
427
462
|
return usageError('retry', ctx, 'Give one card id like ENG-12.');
|
|
428
|
-
if (
|
|
463
|
+
if (liveRuns(ctx.configDir).some((c) => c.card === id)) {
|
|
429
464
|
ctx.log(`${id} is running now. vsf agents cancel ${id} first.`);
|
|
430
465
|
return 1;
|
|
431
466
|
}
|
|
@@ -495,7 +530,7 @@ const runs = guard(async (args, ctx) => {
|
|
|
495
530
|
}
|
|
496
531
|
if (!entries.length)
|
|
497
532
|
ctx.log('No runs yet.');
|
|
498
|
-
const live =
|
|
533
|
+
const live = liveRuns(ctx.configDir);
|
|
499
534
|
for (const e of entries)
|
|
500
535
|
ctx.log(formatRunLine(e, Date.now(), live));
|
|
501
536
|
return 0;
|
|
@@ -529,7 +564,7 @@ const show = guard(async (args, ctx) => {
|
|
|
529
564
|
return 0;
|
|
530
565
|
}
|
|
531
566
|
ctx.log(`${latest.card} ${latest.title}`);
|
|
532
|
-
ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0],
|
|
567
|
+
ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0], liveRuns(ctx.configDir)) ? 'interrupted' : latest.outcome}${latest.prUrl ? `, PR ${latest.prUrl}` : ''}`);
|
|
533
568
|
if (labels)
|
|
534
569
|
ctx.log(`Labels: ${labels.join(', ') || 'none'}`);
|
|
535
570
|
for (const s of latest.steps) {
|
|
@@ -645,10 +680,10 @@ const clean = guard(async (args, ctx) => {
|
|
|
645
680
|
if (ms === null)
|
|
646
681
|
return usageError('clean', ctx, `${age} is not an age. Use a number of days or hours, like 14d or 36h.`);
|
|
647
682
|
const cutoff = Date.now() - ms;
|
|
648
|
-
const live =
|
|
683
|
+
const live = new Set(liveRuns(ctx.configDir).map((c) => c.card));
|
|
649
684
|
const wtRoot = join(agentsDir(ctx.configDir), 'worktrees');
|
|
650
685
|
const worktrees = (existsSync(wtRoot) ? readdirSync(wtRoot) : [])
|
|
651
|
-
.filter((card) =>
|
|
686
|
+
.filter((card) => !live.has(card))
|
|
652
687
|
.filter((card) => {
|
|
653
688
|
const record = listRuns(ctx.configDir, card)[0]?.record;
|
|
654
689
|
const ended = record?.endedAt ? Date.parse(record.endedAt) : mtime(join(wtRoot, card));
|
|
@@ -656,7 +691,7 @@ const clean = guard(async (args, ctx) => {
|
|
|
656
691
|
})
|
|
657
692
|
.map((card) => join(wtRoot, card));
|
|
658
693
|
const old = listRuns(ctx.configDir).filter((e) => {
|
|
659
|
-
if (e.record?.outcome === 'running' && e.card
|
|
694
|
+
if (e.record?.outcome === 'running' && live.has(e.card))
|
|
660
695
|
return false;
|
|
661
696
|
return (e.record?.endedAt ? Date.parse(e.record.endedAt) : mtime(e.dir)) < cutoff;
|
|
662
697
|
});
|
|
@@ -686,12 +721,21 @@ const clean = guard(async (args, ctx) => {
|
|
|
686
721
|
ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
|
|
687
722
|
return 0;
|
|
688
723
|
});
|
|
689
|
-
// Internal: the runner (launchd or systemd) runs this every minute
|
|
690
|
-
|
|
724
|
+
// Internal: the runner (launchd or systemd) runs this every minute, and it
|
|
725
|
+
// starts itself with --card or --schedule for each run, one per workspace.
|
|
726
|
+
const tickCmd = async (args, ctx) => {
|
|
691
727
|
const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
|
|
692
728
|
try {
|
|
729
|
+
const { values } = parseArgs({ args, options: { card: { type: 'string' }, workflow: { type: 'string' }, schedule: { type: 'string' } }, strict: true });
|
|
693
730
|
const config = readAgentsConfig(ctx.configDir);
|
|
694
|
-
|
|
731
|
+
const rc = {
|
|
732
|
+
configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand,
|
|
733
|
+
notify: (e) => notifyRun(ctx.configDir, e, stamped),
|
|
734
|
+
summary: (paused) => sendSummary(ctx.configDir, paused, stamped),
|
|
735
|
+
};
|
|
736
|
+
if (values.card || values.workflow)
|
|
737
|
+
return await tick(rc, values.card?.toUpperCase(), { workflow: values.workflow, schedule: values.schedule, child: true });
|
|
738
|
+
return await dispatch(rc, { reload: () => readAgentsConfig(ctx.configDir) });
|
|
695
739
|
}
|
|
696
740
|
catch (err) {
|
|
697
741
|
stamped(`Could not run the check: ${err.message}`);
|
|
@@ -734,8 +778,8 @@ export async function controlChecks(ctx) {
|
|
|
734
778
|
? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
|
|
735
779
|
: { name: 'runner program', status: 'ok', detail: 'found' });
|
|
736
780
|
}
|
|
737
|
-
const
|
|
738
|
-
if (c && !ours(c.pid))
|
|
781
|
+
const locks = [readCurrent(ctx.configDir), ...workspaceLocks(ctx.configDir)];
|
|
782
|
+
if (locks.some((c) => c && !ours(c.pid)))
|
|
739
783
|
checks.push({ name: 'stale lock', status: 'warn', detail: 'a stale lock from a run that died', fix: 'vsf agents cancel' });
|
|
740
784
|
if (existsSync(logPath(ctx.configDir))) {
|
|
741
785
|
const { count, last } = scanRunnerErrors(readLog(ctx.configDir), Date.now());
|