@voidlabs.gg/voidshift-agents 0.4.2 → 0.4.4
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 +18 -6
- package/dist/config.d.ts +12 -2
- package/dist/config.js +14 -1
- package/dist/control.d.ts +4 -2
- package/dist/control.js +79 -52
- package/dist/cron.d.ts +9 -0
- package/dist/cron.js +66 -0
- package/dist/index.js +77 -44
- package/dist/launchd.d.ts +1 -0
- package/dist/launchd.js +6 -0
- package/dist/linear.d.ts +1 -1
- package/dist/linear.js +3 -3
- package/dist/manage.d.ts +1 -0
- package/dist/manage.js +35 -13
- package/dist/runner.d.ts +12 -5
- package/dist/runner.js +130 -47
- package/dist/secrets.d.ts +9 -0
- package/dist/secrets.js +55 -0
- package/dist/service.d.ts +9 -0
- package/dist/service.js +18 -0
- package/dist/state.d.ts +6 -1
- package/dist/state.js +15 -0
- package/dist/systemd.d.ts +14 -0
- package/dist/systemd.js +85 -0
- package/dist/tracker.d.ts +24 -0
- package/dist/tracker.js +36 -0
- package/dist/voidtrack.d.ts +18 -0
- package/dist/voidtrack.js +89 -0
- package/package.json +3 -5
- package/dist/keychain.d.ts +0 -3
- package/dist/keychain.js +0 -19
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ One CLI for the Void dev tools. Install it once, run `vsf` (short for
|
|
|
5
5
|
defaults you can change. Everything setup does can be seen and changed later
|
|
6
6
|
from the command line.
|
|
7
7
|
|
|
8
|
-
> Early release. macOS
|
|
8
|
+
> Early release. macOS and Linux (awake is macOS only); Windows is next.
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
11
|
npm i -g @voidlabs.gg/voidshift
|
|
@@ -50,15 +50,24 @@ Five agents, installed into Claude Code (and Codex if you use it):
|
|
|
50
50
|
| QA | CI, then code review, then a usability check in a headless browser |
|
|
51
51
|
|
|
52
52
|
Use them yourself (`/vs-plan ENG-123` in Claude Code), or let a **workflow**
|
|
53
|
-
run them. A workflow starts when one of your Linear cards gets a
|
|
54
|
-
agents in order in a project (a repo on your
|
|
55
|
-
step that made it when a check fails:
|
|
53
|
+
run them. A workflow starts when one of your Linear or VoidTrack cards gets a
|
|
54
|
+
label, or on a schedule, runs agents in order in a project (a repo on your
|
|
55
|
+
machine), and sends work back to the step that made it when a check fails:
|
|
56
56
|
|
|
57
57
|
```
|
|
58
58
|
when ENG + vs-plan: Dev → QA in tether-relay
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
Triggers, as `--trigger` takes them:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
ENG:vs-plan # a Linear card in ENG assigned to you gets vs-plan
|
|
65
|
+
voidtrack:ORG:vs-start # the same for VoidTrack (vsf agents key --voidtrack)
|
|
66
|
+
"cron:0 5 * * 1:Update dependencies" # every Monday 05:00, no card, on its own branch
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A schedule fires once when due (a missed one is made up within a day) and
|
|
70
|
+
`vsf agents run --workflow NAME` fires it now. The runner checks every minute, works in its own copy of the repo so
|
|
62
71
|
your checkout is never touched, and keeps two labels on the card as it goes: the
|
|
63
72
|
stage (`vs-planning`, `vs-plan-review`, `vs-dev`, `vs-qa`, then `vs-qa-verified`
|
|
64
73
|
or `vs-parked`) and the round (`vs-v1`, `vs-v2`). On Linear it posts the plan and
|
|
@@ -104,10 +113,13 @@ to trust the recall hook once: run `/hooks` in Codex.
|
|
|
104
113
|
- **Your own logins.** Agents run the official `claude` and `codex` CLIs you
|
|
105
114
|
already use, signed in with your own account. VoidShift never reads or
|
|
106
115
|
stores those credentials. The Linear API key for the runner lives in your
|
|
107
|
-
macOS Keychain.
|
|
116
|
+
macOS Keychain, or on Linux in `~/.config/voidshift/agents/secrets.json`
|
|
117
|
+
(mode 0600).
|
|
108
118
|
- **Trust.** The Dev agent can run shell commands inside its copy of the repo
|
|
109
119
|
so it can install and test. Review its pull requests like any other.
|
|
110
120
|
- **Your config** lives in `~/.config/voidshift`.
|
|
121
|
+
- **Linux.** The runner is a systemd user timer. Run
|
|
122
|
+
`sudo loginctl enable-linger $USER` so it keeps going after you log out.
|
|
111
123
|
|
|
112
124
|
```sh
|
|
113
125
|
vsf doctor # check everything is set up, and whether an update is out
|
package/dist/config.d.ts
CHANGED
|
@@ -13,11 +13,21 @@ export declare const AGENTS: Record<string, {
|
|
|
13
13
|
}>;
|
|
14
14
|
export declare const visibleAgents: () => string[];
|
|
15
15
|
export declare const stepEnabled: (step: StepId, enabled: string[]) => boolean;
|
|
16
|
-
export
|
|
17
|
-
|
|
16
|
+
export type Tracker = 'linear' | 'voidtrack';
|
|
17
|
+
export interface CardTrigger {
|
|
18
|
+
source: Tracker;
|
|
18
19
|
team: string;
|
|
19
20
|
label: string;
|
|
20
21
|
}
|
|
22
|
+
export interface CronTrigger {
|
|
23
|
+
source: 'cron';
|
|
24
|
+
schedule: string;
|
|
25
|
+
task: string;
|
|
26
|
+
}
|
|
27
|
+
export type Trigger = CardTrigger | CronTrigger;
|
|
28
|
+
export declare const isCardTrigger: (t: Trigger) => t is CardTrigger;
|
|
29
|
+
export declare function triggerSpec(t: Trigger): string;
|
|
30
|
+
export declare function describeTrigger(t: Trigger): string;
|
|
21
31
|
export interface Project {
|
|
22
32
|
path: string;
|
|
23
33
|
base?: string;
|
package/dist/config.js
CHANGED
|
@@ -19,6 +19,19 @@ export const AGENTS = {
|
|
|
19
19
|
};
|
|
20
20
|
export const visibleAgents = () => Object.keys(AGENTS);
|
|
21
21
|
export const stepEnabled = (step, enabled) => enabled.includes(step);
|
|
22
|
+
export const isCardTrigger = (t) => t.source !== 'cron';
|
|
23
|
+
// The form `--trigger` takes: "ENG:vs-plan" (Linear), "voidtrack:ORG:vs-start",
|
|
24
|
+
// or "cron:0 5 * * 1:Update the dependencies".
|
|
25
|
+
export function triggerSpec(t) {
|
|
26
|
+
if (t.source === 'cron')
|
|
27
|
+
return `cron:${t.schedule}:${t.task}`;
|
|
28
|
+
return t.source === 'voidtrack' ? `voidtrack:${t.team}:${t.label}` : `${t.team}:${t.label}`;
|
|
29
|
+
}
|
|
30
|
+
export function describeTrigger(t) {
|
|
31
|
+
if (t.source === 'cron')
|
|
32
|
+
return `cron ${t.schedule}`;
|
|
33
|
+
return `${t.source === 'voidtrack' ? 'VoidTrack ' : ''}${t.team} + ${t.label}`;
|
|
34
|
+
}
|
|
22
35
|
// Step lists to start a workflow from.
|
|
23
36
|
export const TEMPLATES = {
|
|
24
37
|
build: { steps: ['vs-dev', 'vs-qa'], maxRounds: 3 },
|
|
@@ -40,7 +53,7 @@ const configPath = (configDir) => join(agentsDir(configDir), 'config.json');
|
|
|
40
53
|
export const hasWork = (workflow) => workflow.steps.some((s) => STEPS[s].output === 'commits');
|
|
41
54
|
export const hasProducer = (workflow) => workflow.steps.some(isProducer);
|
|
42
55
|
export const describeSteps = (steps) => steps.map((s) => STEPS[s].label).join(' → ');
|
|
43
|
-
export const describeTriggers = (triggers) => triggers.map(
|
|
56
|
+
export const describeTriggers = (triggers) => triggers.map(describeTrigger).join(', ');
|
|
44
57
|
export const describeWorkflow = (c) => `when ${describeTriggers(c.triggers)}: ${describeSteps(c.steps)} in ${c.project}`;
|
|
45
58
|
// Step ids that were folded into QA.
|
|
46
59
|
const RENAMED = { 'vs-qa-review': 'vs-qa', ci: null, 'vs-qa-ui': null };
|
package/dist/control.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { type AgentsConfig, type Tracker as Kind } from './config.ts';
|
|
1
2
|
import { type Current, type RunEntry } from './state.ts';
|
|
2
3
|
import type { Check, ToolCommand, ToolContext } from './types.ts';
|
|
4
|
+
export { parsePlistArgs } from './launchd.ts';
|
|
5
|
+
export declare const trackersInUse: (config: Pick<AgentsConfig, "workflows">) => Kind[];
|
|
3
6
|
export declare function parseAge(input: string): number | null;
|
|
4
7
|
export declare function formatDuration(ms: number): string;
|
|
5
8
|
export declare function stampDate(stamp: string): Date;
|
|
@@ -12,13 +15,12 @@ export declare function scanRunnerErrors(text: string, now: number): {
|
|
|
12
15
|
count: number;
|
|
13
16
|
last: string;
|
|
14
17
|
};
|
|
15
|
-
export declare function parsePlistArgs(plist: string): string[];
|
|
16
18
|
export declare const controlUsage: Record<string, string>;
|
|
17
19
|
export interface CancelDeps {
|
|
18
20
|
ours: (pid: number) => boolean;
|
|
19
21
|
kill: (pid: number, signal: 'SIGTERM' | 'SIGKILL') => void;
|
|
20
22
|
sleep: (ms: number) => Promise<void>;
|
|
21
|
-
cancelCard: (card: string, label: string) => Promise<boolean>;
|
|
23
|
+
cancelCard: (card: string, label: string, source: Kind) => Promise<boolean>;
|
|
22
24
|
}
|
|
23
25
|
export declare function cancelCurrent(ctx: ToolContext, c: Current, deps: CancelDeps): Promise<void>;
|
|
24
26
|
export declare const controlCommands: Record<string, ToolCommand>;
|
package/dist/control.js
CHANGED
|
@@ -2,11 +2,10 @@ 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, readAgentsConfig } from "./config.js";
|
|
6
|
-
import { readLinearKey } from "./keychain.js";
|
|
5
|
+
import { agentsDir, isCardTrigger, readAgentsConfig } from "./config.js";
|
|
7
6
|
import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPaused, writeRun } from "./state.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { runnerService } from "./service.js";
|
|
8
|
+
import { TRACKERS, trackersFor } from "./tracker.js";
|
|
10
9
|
import { parseStep, resolveName, stepNames } from "./names.js";
|
|
11
10
|
import { isStatusLabel } from "./workflow.js";
|
|
12
11
|
import { cancelCard, tick } from "./runner.js";
|
|
@@ -14,11 +13,28 @@ const HOUR = 3_600_000;
|
|
|
14
13
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
15
14
|
const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
16
15
|
const ISO_LINE = /^(\d{4}-\d\d-\d\dT[\d:.]+Z)\s*(.*)$/;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
export { parsePlistArgs } from "./launchd.js";
|
|
17
|
+
// Trackers the workflows' card triggers use.
|
|
18
|
+
export const trackersInUse = (config) => [
|
|
19
|
+
...new Set(Object.values(config.workflows).flatMap((w) => w.triggers.filter(isCardTrigger).map((t) => t.source))),
|
|
20
|
+
];
|
|
21
|
+
// A card by id, from the tracker of a workflow that starts on its team.
|
|
22
|
+
async function findCard(config, trackers, id) {
|
|
23
|
+
const team = id.split('-')[0].toUpperCase();
|
|
24
|
+
const sources = [...new Set(Object.values(config.workflows).flatMap((w) => w.triggers.filter(isCardTrigger).filter((t) => t.team === team).map((t) => t.source)))];
|
|
25
|
+
if (!sources.length)
|
|
26
|
+
throw new Error(`No workflow starts on team ${team}.`);
|
|
27
|
+
let last = null;
|
|
28
|
+
for (const source of sources) {
|
|
29
|
+
try {
|
|
30
|
+
const tracker = trackers(source);
|
|
31
|
+
return { card: await tracker.card(id), tracker, source };
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
last = err;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw last;
|
|
22
38
|
}
|
|
23
39
|
const logPath = (configDir) => join(agentsDir(configDir), 'runner.log');
|
|
24
40
|
const program = (ctx) => [...ctx.selfCommand, 'agents', 'tick'];
|
|
@@ -88,12 +104,6 @@ export function scanRunnerErrors(text, now) {
|
|
|
88
104
|
}
|
|
89
105
|
return { count: bad.length, last: bad.at(-1) ?? '' };
|
|
90
106
|
}
|
|
91
|
-
// The <string> values of a launchd plist's ProgramArguments.
|
|
92
|
-
export function parsePlistArgs(plist) {
|
|
93
|
-
const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist)?.[1] ?? '';
|
|
94
|
-
const unxml = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
95
|
-
return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1]));
|
|
96
|
-
}
|
|
97
107
|
function lastTickOf(text) {
|
|
98
108
|
for (const line of text.split('\n').reverse()) {
|
|
99
109
|
const m = ISO_LINE.exec(line);
|
|
@@ -160,7 +170,7 @@ const guard = (fn) => async (args, ctx) => {
|
|
|
160
170
|
};
|
|
161
171
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
162
172
|
function runnerState(ctx) {
|
|
163
|
-
if (!
|
|
173
|
+
if (!runnerService().loaded())
|
|
164
174
|
return 'stopped';
|
|
165
175
|
return isPaused(ctx.configDir) ? 'paused' : 'running';
|
|
166
176
|
}
|
|
@@ -184,7 +194,7 @@ export async function cancelCurrent(ctx, c, deps) {
|
|
|
184
194
|
// gone just now
|
|
185
195
|
}
|
|
186
196
|
}
|
|
187
|
-
ctx.log(c.card ? `Cancelled ${c.card}.` : 'Stopped a check
|
|
197
|
+
ctx.log(c.card ? `Cancelled ${c.card}.` : 'Stopped a check.');
|
|
188
198
|
return;
|
|
189
199
|
}
|
|
190
200
|
// Dead or a reused pid: no signal, just tidy up what it left behind.
|
|
@@ -198,47 +208,58 @@ export async function cancelCurrent(ctx, c, deps) {
|
|
|
198
208
|
// the record is only a nicety
|
|
199
209
|
}
|
|
200
210
|
}
|
|
201
|
-
if (c.card) {
|
|
202
|
-
const
|
|
203
|
-
await deps.cancelCard(c.card, label);
|
|
211
|
+
if (c.card && c.source !== 'cron') {
|
|
212
|
+
const first = readAgentsConfig(ctx.configDir).workflows[c.workflow ?? '']?.triggers.find(isCardTrigger);
|
|
213
|
+
await deps.cancelCard(c.card, first?.label ?? "the workflow's label", c.source ?? 'linear');
|
|
204
214
|
ctx.log(`Cleared a stale lock and cancelled ${c.card}.`);
|
|
205
215
|
}
|
|
206
216
|
else
|
|
207
217
|
ctx.log('Cleared a stale lock.');
|
|
208
218
|
clearCurrent(ctx.configDir);
|
|
209
219
|
}
|
|
210
|
-
const realDeps = (ctx
|
|
220
|
+
const realDeps = (ctx) => ({
|
|
211
221
|
ours,
|
|
212
222
|
kill: (pid, signal) => process.kill(pid, signal),
|
|
213
223
|
sleep,
|
|
214
|
-
cancelCard: (card, label) =>
|
|
224
|
+
cancelCard: async (card, label, source) => {
|
|
225
|
+
try {
|
|
226
|
+
return await cancelCard(trackersFor(ctx.configDir)(source), ctx.configDir, card, label, source);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
},
|
|
215
232
|
});
|
|
216
233
|
async function startCmd(ctx) {
|
|
217
234
|
if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
|
|
218
235
|
ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
|
|
219
236
|
return 1;
|
|
220
237
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
238
|
+
const trackers = trackersFor(ctx.configDir);
|
|
239
|
+
for (const kind of trackersInUse(readAgentsConfig(ctx.configDir))) {
|
|
240
|
+
try {
|
|
241
|
+
await trackers(kind).viewer();
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
const msg = err.message;
|
|
245
|
+
const flag = kind === 'linear' ? '' : ` --${kind}`;
|
|
246
|
+
ctx.log(/^No .* API key/.test(msg) ? msg : `${TRACKERS[kind].name} did not accept the key: ${msg}. Run vsf agents key${flag}.`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
228
249
|
}
|
|
229
250
|
setPaused(ctx.configDir, false);
|
|
230
|
-
if (!
|
|
251
|
+
if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
|
|
231
252
|
ctx.log('Could not start the runner. Check vsf agents logs.');
|
|
232
253
|
return 1;
|
|
233
254
|
}
|
|
234
255
|
return 0;
|
|
235
256
|
}
|
|
236
257
|
async function stopCmd(ctx) {
|
|
237
|
-
|
|
238
|
-
//
|
|
258
|
+
runnerService().stop(ctx.home);
|
|
259
|
+
// Stopping the runner may have stopped the pass; finish or tidy up whatever the lock still names.
|
|
239
260
|
const c = readCurrent(ctx.configDir);
|
|
240
261
|
if (c)
|
|
241
|
-
await cancelCurrent(ctx, c, realDeps(ctx
|
|
262
|
+
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
242
263
|
ctx.log('Runner stopped.');
|
|
243
264
|
return 0;
|
|
244
265
|
}
|
|
@@ -271,7 +292,7 @@ const status = guard(async (args, ctx) => {
|
|
|
271
292
|
return 0;
|
|
272
293
|
}
|
|
273
294
|
const working = current && !current.card
|
|
274
|
-
? ' Checking
|
|
295
|
+
? ' Checking now.'
|
|
275
296
|
: current
|
|
276
297
|
? ` Working on ${current.card ?? 'a card'}${current.title ? ` "${current.title}"` : ''} (${[current.workflow, current.step, current.round !== undefined ? `round ${current.round + 1}` : '', `${current.elapsedMinutes}m`].filter(Boolean).join(', ')}).`
|
|
277
298
|
: '';
|
|
@@ -296,7 +317,7 @@ const start = guard(async (args, ctx) => {
|
|
|
296
317
|
return p;
|
|
297
318
|
const code = await startCmd(ctx);
|
|
298
319
|
if (!code)
|
|
299
|
-
ctx.log('Runner started.
|
|
320
|
+
ctx.log('Runner started. Every minute it checks for cards and schedules that start your workflows.');
|
|
300
321
|
return code;
|
|
301
322
|
});
|
|
302
323
|
const stop = guard(async (args, ctx) => {
|
|
@@ -358,7 +379,7 @@ const run = guard(async (args, ctx) => {
|
|
|
358
379
|
return 2;
|
|
359
380
|
}
|
|
360
381
|
}
|
|
361
|
-
const code = await tick({ configDir: ctx.configDir, config,
|
|
382
|
+
const code = await tick({ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: ctx.log, selfCommand: ctx.selfCommand }, card, { workflow, from });
|
|
362
383
|
if (!card && code === 0) {
|
|
363
384
|
if (isPaused(ctx.configDir))
|
|
364
385
|
ctx.log('The runner is paused, so nothing was picked up. vsf agents resume, or name a card.');
|
|
@@ -383,7 +404,7 @@ const cancel = guard(async (args, ctx) => {
|
|
|
383
404
|
ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
|
|
384
405
|
return 1;
|
|
385
406
|
}
|
|
386
|
-
await cancelCurrent(ctx, c, realDeps(ctx
|
|
407
|
+
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
387
408
|
return 0;
|
|
388
409
|
});
|
|
389
410
|
const retry = guard(async (args, ctx) => {
|
|
@@ -399,9 +420,17 @@ const retry = guard(async (args, ctx) => {
|
|
|
399
420
|
}
|
|
400
421
|
const config = readAgentsConfig(ctx.configDir);
|
|
401
422
|
const names = Object.keys(config.workflows);
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
423
|
+
let found;
|
|
424
|
+
try {
|
|
425
|
+
found = await findCard(config, trackersFor(ctx.configDir), id);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
ctx.log(err.message);
|
|
429
|
+
return 1;
|
|
430
|
+
}
|
|
431
|
+
const { card, source } = found;
|
|
432
|
+
const l = found.tracker;
|
|
433
|
+
const startsOn = (n) => config.workflows[n].triggers.some((t) => isCardTrigger(t) && t.source === source && t.team === card.teamKey);
|
|
405
434
|
let chosen;
|
|
406
435
|
if (p.values.workflow) {
|
|
407
436
|
const r = resolveName(names, p.values.workflow, 'workflow');
|
|
@@ -428,7 +457,7 @@ const retry = guard(async (args, ctx) => {
|
|
|
428
457
|
}
|
|
429
458
|
chosen = candidates[0];
|
|
430
459
|
}
|
|
431
|
-
const label = config.workflows[chosen].triggers.find((t) => t.team === card.teamKey).label;
|
|
460
|
+
const label = config.workflows[chosen].triggers.filter(isCardTrigger).find((t) => t.source === source && t.team === card.teamKey).label;
|
|
432
461
|
const next = card.labels.filter((n) => !isStatusLabel(n));
|
|
433
462
|
if (!next.includes(label))
|
|
434
463
|
next.push(label);
|
|
@@ -479,7 +508,7 @@ const show = guard(async (args, ctx) => {
|
|
|
479
508
|
}
|
|
480
509
|
let labels = null;
|
|
481
510
|
try {
|
|
482
|
-
labels = (await
|
|
511
|
+
labels = (await findCard(readAgentsConfig(ctx.configDir), trackersFor(ctx.configDir), id)).card.labels;
|
|
483
512
|
}
|
|
484
513
|
catch {
|
|
485
514
|
// live labels are optional
|
|
@@ -646,16 +675,12 @@ const clean = guard(async (args, ctx) => {
|
|
|
646
675
|
ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
|
|
647
676
|
return 0;
|
|
648
677
|
});
|
|
649
|
-
// Internal: launchd runs this every minute.
|
|
678
|
+
// Internal: the runner (launchd or systemd) runs this every minute.
|
|
650
679
|
const tickCmd = async (_args, ctx) => {
|
|
651
680
|
const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
|
|
652
681
|
try {
|
|
653
682
|
const config = readAgentsConfig(ctx.configDir);
|
|
654
|
-
|
|
655
|
-
stamped('No Linear API key. Run vsf agents key.');
|
|
656
|
-
return 1;
|
|
657
|
-
}
|
|
658
|
-
return await tick({ configDir: ctx.configDir, config, linear: linear(), log: stamped, selfCommand: ctx.selfCommand });
|
|
683
|
+
return await tick({ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand });
|
|
659
684
|
}
|
|
660
685
|
catch (err) {
|
|
661
686
|
stamped(`Could not run the check: ${err.message}`);
|
|
@@ -686,13 +711,14 @@ export async function controlChecks(ctx) {
|
|
|
686
711
|
const checks = [];
|
|
687
712
|
const state = runnerState(ctx);
|
|
688
713
|
checks.push(state === 'running'
|
|
689
|
-
? { name: 'agent runner', status: 'ok', detail: '
|
|
714
|
+
? { name: 'agent runner', status: 'ok', detail: 'checking every minute' }
|
|
690
715
|
: state === 'paused'
|
|
691
716
|
? { name: 'agent runner', status: 'warn', detail: 'paused', fix: 'vsf agents resume' }
|
|
692
717
|
: { name: 'agent runner', status: 'warn', detail: 'not running', fix: 'vsf agents start' });
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
718
|
+
const service = runnerService();
|
|
719
|
+
const installed = service.programArgs(ctx.home);
|
|
720
|
+
if (installed) {
|
|
721
|
+
const missing = installed.filter((a) => a.startsWith('/') && !existsSync(a));
|
|
696
722
|
checks.push(missing.length
|
|
697
723
|
? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
|
|
698
724
|
: { name: 'runner program', status: 'ok', detail: 'found' });
|
|
@@ -706,6 +732,7 @@ export async function controlChecks(ctx) {
|
|
|
706
732
|
? { name: 'runner errors', status: 'warn', detail: `${plural(count, 'error')} in the last hour, last: ${last.slice(0, 120)}`, fix: 'vsf agents logs' }
|
|
707
733
|
: { name: 'runner errors', status: 'ok', detail: 'no errors in the last hour' });
|
|
708
734
|
}
|
|
735
|
+
checks.push(...service.checks());
|
|
709
736
|
checks.push(engineLogin(readAgentsConfig(ctx.configDir).engine));
|
|
710
737
|
return checks;
|
|
711
738
|
}
|
package/dist/cron.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface Schedule {
|
|
2
|
+
sets: Set<number>[];
|
|
3
|
+
anyDom: boolean;
|
|
4
|
+
anyDow: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function parseCron(schedule: string): Schedule | null;
|
|
7
|
+
export declare function matches(s: Schedule, d: Date): boolean;
|
|
8
|
+
export declare const CATCH_UP_MS: number;
|
|
9
|
+
export declare function lastDue(s: Schedule, since: Date, now: Date): Date | null;
|
package/dist/cron.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Five field cron schedules: minute hour day-of-month month day-of-week, each
|
|
2
|
+
// a list of *, numbers, ranges and steps (*/15, 1-5, 0,30). Sunday is 0 or 7.
|
|
3
|
+
// When both day fields are restricted, either may match, as in classic cron.
|
|
4
|
+
const FIELDS = [
|
|
5
|
+
[0, 59],
|
|
6
|
+
[0, 23],
|
|
7
|
+
[1, 31],
|
|
8
|
+
[1, 12],
|
|
9
|
+
[0, 7],
|
|
10
|
+
];
|
|
11
|
+
function field(text, [min, max]) {
|
|
12
|
+
const out = new Set();
|
|
13
|
+
for (const part of text.split(',')) {
|
|
14
|
+
const m = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part);
|
|
15
|
+
if (!m)
|
|
16
|
+
return null;
|
|
17
|
+
const step = m[4] ? Number(m[4]) : 1;
|
|
18
|
+
const lo = m[1] === '*' ? min : Number(m[2]);
|
|
19
|
+
const hi = m[1] === '*' ? max : m[3] ? Number(m[3]) : m[4] ? max : lo;
|
|
20
|
+
if (step < 1 || lo < min || hi > max || lo > hi)
|
|
21
|
+
return null;
|
|
22
|
+
for (let v = lo; v <= hi; v += step)
|
|
23
|
+
out.add(v);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
export function parseCron(schedule) {
|
|
28
|
+
const parts = schedule.trim().split(/\s+/);
|
|
29
|
+
if (parts.length !== 5)
|
|
30
|
+
return null;
|
|
31
|
+
const sets = [];
|
|
32
|
+
for (let i = 0; i < 5; i++) {
|
|
33
|
+
const s = field(parts[i], FIELDS[i]);
|
|
34
|
+
if (!s)
|
|
35
|
+
return null;
|
|
36
|
+
sets.push(s);
|
|
37
|
+
}
|
|
38
|
+
if (sets[4].has(7))
|
|
39
|
+
sets[4].add(0);
|
|
40
|
+
return { sets, anyDom: parts[2] === '*', anyDow: parts[4] === '*' };
|
|
41
|
+
}
|
|
42
|
+
export function matches(s, d) {
|
|
43
|
+
const [min, hour, dom, month, dow] = s.sets;
|
|
44
|
+
if (!min.has(d.getMinutes()) || !hour.has(d.getHours()) || !month.has(d.getMonth() + 1))
|
|
45
|
+
return false;
|
|
46
|
+
const domOk = dom.has(d.getDate());
|
|
47
|
+
const dowOk = dow.has(d.getDay());
|
|
48
|
+
if (s.anyDom || s.anyDow)
|
|
49
|
+
return domOk && dowOk;
|
|
50
|
+
return domOk || dowOk;
|
|
51
|
+
}
|
|
52
|
+
const MINUTE = 60_000;
|
|
53
|
+
// How far back a missed schedule is still made up for, e.g. after the runner
|
|
54
|
+
// was busy or the machine was off. Older misses are skipped, not queued.
|
|
55
|
+
export const CATCH_UP_MS = 24 * 60 * MINUTE;
|
|
56
|
+
// The latest minute after `since` and up to `now` that the schedule matches,
|
|
57
|
+
// or null. Only the latest counts: several missed runs fire once.
|
|
58
|
+
export function lastDue(s, since, now) {
|
|
59
|
+
const floor = Math.max(since.getTime(), now.getTime() - CATCH_UP_MS);
|
|
60
|
+
const t = new Date(now);
|
|
61
|
+
t.setSeconds(0, 0);
|
|
62
|
+
for (; t.getTime() > floor; t.setTime(t.getTime() - MINUTE))
|
|
63
|
+
if (matches(s, t))
|
|
64
|
+
return new Date(t);
|
|
65
|
+
return null;
|
|
66
|
+
}
|