@voidlabs.gg/voidshift-agents 0.4.3 → 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 +13 -4
- package/dist/config.d.ts +12 -2
- package/dist/config.js +14 -1
- package/dist/control.d.ts +3 -1
- package/dist/control.js +67 -37
- package/dist/cron.d.ts +9 -0
- package/dist/cron.js +66 -0
- package/dist/index.js +66 -33
- 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/state.d.ts +6 -1
- package/dist/state.js +15 -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 +1 -1
package/README.md
CHANGED
|
@@ -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
|
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,6 +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';
|
|
3
4
|
export { parsePlistArgs } from './launchd.ts';
|
|
5
|
+
export declare const trackersInUse: (config: Pick<AgentsConfig, "workflows">) => Kind[];
|
|
4
6
|
export declare function parseAge(input: string): number | null;
|
|
5
7
|
export declare function formatDuration(ms: number): string;
|
|
6
8
|
export declare function stampDate(stamp: string): Date;
|
|
@@ -18,7 +20,7 @@ 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 "./secrets.js";
|
|
5
|
+
import { agentsDir, isCardTrigger, readAgentsConfig } from "./config.js";
|
|
7
6
|
import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPaused, writeRun } from "./state.js";
|
|
8
7
|
import { runnerService } from "./service.js";
|
|
9
|
-
import {
|
|
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";
|
|
@@ -15,11 +14,27 @@ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', '
|
|
|
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
16
|
export { parsePlistArgs } from "./launchd.js";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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;
|
|
23
38
|
}
|
|
24
39
|
const logPath = (configDir) => join(agentsDir(configDir), 'runner.log');
|
|
25
40
|
const program = (ctx) => [...ctx.selfCommand, 'agents', 'tick'];
|
|
@@ -179,7 +194,7 @@ export async function cancelCurrent(ctx, c, deps) {
|
|
|
179
194
|
// gone just now
|
|
180
195
|
}
|
|
181
196
|
}
|
|
182
|
-
ctx.log(c.card ? `Cancelled ${c.card}.` : 'Stopped a check
|
|
197
|
+
ctx.log(c.card ? `Cancelled ${c.card}.` : 'Stopped a check.');
|
|
183
198
|
return;
|
|
184
199
|
}
|
|
185
200
|
// Dead or a reused pid: no signal, just tidy up what it left behind.
|
|
@@ -193,33 +208,44 @@ export async function cancelCurrent(ctx, c, deps) {
|
|
|
193
208
|
// the record is only a nicety
|
|
194
209
|
}
|
|
195
210
|
}
|
|
196
|
-
if (c.card) {
|
|
197
|
-
const
|
|
198
|
-
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');
|
|
199
214
|
ctx.log(`Cleared a stale lock and cancelled ${c.card}.`);
|
|
200
215
|
}
|
|
201
216
|
else
|
|
202
217
|
ctx.log('Cleared a stale lock.');
|
|
203
218
|
clearCurrent(ctx.configDir);
|
|
204
219
|
}
|
|
205
|
-
const realDeps = (ctx
|
|
220
|
+
const realDeps = (ctx) => ({
|
|
206
221
|
ours,
|
|
207
222
|
kill: (pid, signal) => process.kill(pid, signal),
|
|
208
223
|
sleep,
|
|
209
|
-
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
|
+
},
|
|
210
232
|
});
|
|
211
233
|
async function startCmd(ctx) {
|
|
212
234
|
if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
|
|
213
235
|
ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
|
|
214
236
|
return 1;
|
|
215
237
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
}
|
|
223
249
|
}
|
|
224
250
|
setPaused(ctx.configDir, false);
|
|
225
251
|
if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
|
|
@@ -233,7 +259,7 @@ async function stopCmd(ctx) {
|
|
|
233
259
|
// Stopping the runner may have stopped the pass; finish or tidy up whatever the lock still names.
|
|
234
260
|
const c = readCurrent(ctx.configDir);
|
|
235
261
|
if (c)
|
|
236
|
-
await cancelCurrent(ctx, c, realDeps(ctx
|
|
262
|
+
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
237
263
|
ctx.log('Runner stopped.');
|
|
238
264
|
return 0;
|
|
239
265
|
}
|
|
@@ -266,7 +292,7 @@ const status = guard(async (args, ctx) => {
|
|
|
266
292
|
return 0;
|
|
267
293
|
}
|
|
268
294
|
const working = current && !current.card
|
|
269
|
-
? ' Checking
|
|
295
|
+
? ' Checking now.'
|
|
270
296
|
: current
|
|
271
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(', ')}).`
|
|
272
298
|
: '';
|
|
@@ -291,7 +317,7 @@ const start = guard(async (args, ctx) => {
|
|
|
291
317
|
return p;
|
|
292
318
|
const code = await startCmd(ctx);
|
|
293
319
|
if (!code)
|
|
294
|
-
ctx.log('Runner started.
|
|
320
|
+
ctx.log('Runner started. Every minute it checks for cards and schedules that start your workflows.');
|
|
295
321
|
return code;
|
|
296
322
|
});
|
|
297
323
|
const stop = guard(async (args, ctx) => {
|
|
@@ -353,7 +379,7 @@ const run = guard(async (args, ctx) => {
|
|
|
353
379
|
return 2;
|
|
354
380
|
}
|
|
355
381
|
}
|
|
356
|
-
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 });
|
|
357
383
|
if (!card && code === 0) {
|
|
358
384
|
if (isPaused(ctx.configDir))
|
|
359
385
|
ctx.log('The runner is paused, so nothing was picked up. vsf agents resume, or name a card.');
|
|
@@ -378,7 +404,7 @@ const cancel = guard(async (args, ctx) => {
|
|
|
378
404
|
ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
|
|
379
405
|
return 1;
|
|
380
406
|
}
|
|
381
|
-
await cancelCurrent(ctx, c, realDeps(ctx
|
|
407
|
+
await cancelCurrent(ctx, c, realDeps(ctx));
|
|
382
408
|
return 0;
|
|
383
409
|
});
|
|
384
410
|
const retry = guard(async (args, ctx) => {
|
|
@@ -394,9 +420,17 @@ const retry = guard(async (args, ctx) => {
|
|
|
394
420
|
}
|
|
395
421
|
const config = readAgentsConfig(ctx.configDir);
|
|
396
422
|
const names = Object.keys(config.workflows);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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);
|
|
400
434
|
let chosen;
|
|
401
435
|
if (p.values.workflow) {
|
|
402
436
|
const r = resolveName(names, p.values.workflow, 'workflow');
|
|
@@ -423,7 +457,7 @@ const retry = guard(async (args, ctx) => {
|
|
|
423
457
|
}
|
|
424
458
|
chosen = candidates[0];
|
|
425
459
|
}
|
|
426
|
-
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;
|
|
427
461
|
const next = card.labels.filter((n) => !isStatusLabel(n));
|
|
428
462
|
if (!next.includes(label))
|
|
429
463
|
next.push(label);
|
|
@@ -474,7 +508,7 @@ const show = guard(async (args, ctx) => {
|
|
|
474
508
|
}
|
|
475
509
|
let labels = null;
|
|
476
510
|
try {
|
|
477
|
-
labels = (await
|
|
511
|
+
labels = (await findCard(readAgentsConfig(ctx.configDir), trackersFor(ctx.configDir), id)).card.labels;
|
|
478
512
|
}
|
|
479
513
|
catch {
|
|
480
514
|
// live labels are optional
|
|
@@ -646,11 +680,7 @@ const tickCmd = async (_args, ctx) => {
|
|
|
646
680
|
const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
|
|
647
681
|
try {
|
|
648
682
|
const config = readAgentsConfig(ctx.configDir);
|
|
649
|
-
|
|
650
|
-
stamped('No Linear API key. Run vsf agents key.');
|
|
651
|
-
return 1;
|
|
652
|
-
}
|
|
653
|
-
return await tick({ configDir: ctx.configDir, config, linear: linear(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand });
|
|
683
|
+
return await tick({ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand });
|
|
654
684
|
}
|
|
655
685
|
catch (err) {
|
|
656
686
|
stamped(`Could not run the check: ${err.message}`);
|
|
@@ -681,7 +711,7 @@ export async function controlChecks(ctx) {
|
|
|
681
711
|
const checks = [];
|
|
682
712
|
const state = runnerState(ctx);
|
|
683
713
|
checks.push(state === 'running'
|
|
684
|
-
? { name: 'agent runner', status: 'ok', detail: '
|
|
714
|
+
? { name: 'agent runner', status: 'ok', detail: 'checking every minute' }
|
|
685
715
|
: state === 'paused'
|
|
686
716
|
? { name: 'agent runner', status: 'warn', detail: 'paused', fix: 'vsf agents resume' }
|
|
687
717
|
: { name: 'agent runner', status: 'warn', detail: 'not running', fix: 'vsf agents start' });
|
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
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,12 +4,16 @@ 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, STEPS, TEMPLATES, isProducer, agentsDir, describeSteps, describeTriggers, describeWorkflow, stepEnabled, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
|
|
7
|
+
import { AGENTS, STEPS, TEMPLATES, isProducer, agentsDir, describeSteps, describeTrigger, describeTriggers, describeWorkflow, stepEnabled, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
|
|
8
8
|
import { controlChecks, controlCommands, controlUsage } from "./control.js";
|
|
9
|
-
import { keyStore, looksLikeLinearKey, readLinearKey, storeLinearKey } from "./secrets.js";
|
|
9
|
+
import { keyStore, looksLikeLinearKey, readLinearKey, readSecret, storeLinearKey, storeSecret } from "./secrets.js";
|
|
10
|
+
import { TRACKERS } from "./tracker.js";
|
|
11
|
+
import { createVoidTrack } from "./voidtrack.js";
|
|
12
|
+
import { parseCron } from "./cron.js";
|
|
13
|
+
import { trackersInUse } from "./control.js";
|
|
10
14
|
import { runnerService } from "./service.js";
|
|
11
15
|
import { createLinear } from "./linear.js";
|
|
12
|
-
import { duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
|
|
16
|
+
import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
|
|
13
17
|
import { parseStep, resolveName, stepNames } from "./names.js";
|
|
14
18
|
import { findRepos } from "./repos.js";
|
|
15
19
|
import { defaultBranch, sh } from "./git.js";
|
|
@@ -81,26 +85,28 @@ async function chooseHomes(ui, configDir, saved) {
|
|
|
81
85
|
return homes;
|
|
82
86
|
}
|
|
83
87
|
// Checks the key against Linear, and only then stores it.
|
|
84
|
-
async function verifyAndStore(configDir, key, info, warn) {
|
|
88
|
+
async function verifyAndStore(configDir, key, info, warn, kind = 'linear') {
|
|
89
|
+
const name = TRACKERS[kind].name;
|
|
85
90
|
try {
|
|
86
|
-
const me = await createLinear(key).viewer();
|
|
87
|
-
if (!storeLinearKey(configDir, key)) {
|
|
88
|
-
warn(
|
|
91
|
+
const me = await (kind === 'voidtrack' ? createVoidTrack(key) : createLinear(key)).viewer();
|
|
92
|
+
if (!(kind === 'voidtrack' ? storeSecret(configDir, 'voidtrack', key) : storeLinearKey(configDir, key))) {
|
|
93
|
+
warn(`${name} accepted the key but it could not be stored in ${keyStore()}.`);
|
|
89
94
|
return null;
|
|
90
95
|
}
|
|
91
|
-
info(
|
|
96
|
+
info(`${name} connected as ${me.name}. Key stored in ${keyStore()}.`);
|
|
92
97
|
return me.name;
|
|
93
98
|
}
|
|
94
99
|
catch (err) {
|
|
95
|
-
warn(
|
|
100
|
+
warn(`${name} did not accept that key: ${err.message}. Nothing was stored.`);
|
|
96
101
|
return null;
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
// Asks for the key, checks it against Linear, and only then stores it.
|
|
100
|
-
async function setupKey(ui, configDir) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
async function setupKey(ui, configDir, kind = 'linear') {
|
|
106
|
+
const { name, keyHint } = TRACKERS[kind];
|
|
107
|
+
ui.info(`Agents need a ${name} API key (${keyHint}).`);
|
|
108
|
+
const key = await ui.secret(`Paste your ${name} API key`, (v) => kind === 'linear' ? (looksLikeLinearKey(v.trim()) ? undefined : 'Linear keys start with lin_api_') : v.trim() ? undefined : 'Required');
|
|
109
|
+
return verifyAndStore(configDir, key.trim(), ui.info, ui.warn, kind);
|
|
104
110
|
}
|
|
105
111
|
async function currentLinearUser(configDir) {
|
|
106
112
|
try {
|
|
@@ -277,11 +283,22 @@ async function promptWorkflow(ui, config, teams, current = {}, edit = false) {
|
|
|
277
283
|
if (!triggers) {
|
|
278
284
|
triggers = [];
|
|
279
285
|
do {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
:
|
|
286
|
+
const source = await ui.select(triggers.length ? 'Also start it when...' : 'Starts when...', [
|
|
287
|
+
{ value: 'linear', label: 'A Linear card gets a label' },
|
|
288
|
+
{ value: 'voidtrack', label: 'A VoidTrack card gets a label' },
|
|
289
|
+
{ value: 'cron', label: 'On a schedule', hint: 'runs on a standing task, no card' },
|
|
290
|
+
], 'linear');
|
|
291
|
+
if (source === 'cron') {
|
|
292
|
+
const schedule = (await ui.text('Cron schedule (minute hour day month weekday)', { initial: '0 5 * * 1', validate: (v) => (parseCron(v) ? undefined : 'Five fields, like 0 5 * * 1') })).trim();
|
|
293
|
+
const task = (await ui.text('What should each run work on?', { placeholder: 'Update the dependencies and fix what breaks', validate: (v) => (v.trim() ? undefined : 'Required') })).trim();
|
|
294
|
+
triggers.push({ source: 'cron', schedule, task });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const team = source === 'linear' && teams.length
|
|
298
|
+
? await ui.select('A card in which Linear team...', teams.map((t) => ({ value: t.key, label: `${t.key} ${t.name}`, hint: t.mine ? 'your team' : undefined })), myTeams.length === 1 ? myTeams[0].key : undefined)
|
|
299
|
+
: (await ui.text(`A card in which ${TRACKERS[source].name} team key...`, { validate: (v) => (v.trim() ? undefined : 'Required') })).trim().toUpperCase();
|
|
283
300
|
const label = (await ui.text('...gets which label?', { initial: 'vs-plan', validate: (v) => (v.trim() ? undefined : 'Required') })).trim();
|
|
284
|
-
triggers.push({ source
|
|
301
|
+
triggers.push({ source, team, label });
|
|
285
302
|
} while (await ui.confirm('Add another way to start this workflow?', false));
|
|
286
303
|
}
|
|
287
304
|
let steps = w.steps?.length && (await keep('steps', describeSteps(w.steps))) ? w.steps : undefined;
|
|
@@ -341,7 +358,7 @@ async function setupWorkflows(ui, config, teams, log) {
|
|
|
341
358
|
// One entry per command, in the order help lists them.
|
|
342
359
|
const USAGE = [
|
|
343
360
|
['workflows [--json]', 'List workflows in the order they match'],
|
|
344
|
-
['workflow add [NAME] [--trigger TEAM:LABEL]... [--steps dev,qa | --template build] [--project P] [--rounds N]', 'Create a workflow; prompts for what is missing'],
|
|
361
|
+
['workflow add [NAME] [--trigger TEAM:LABEL]... [--steps dev,qa | --template build] [--project P] [--rounds N]', 'Create a workflow; prompts for what is missing. A trigger is TEAM:LABEL (Linear), voidtrack:TEAM:LABEL or "cron:SCHEDULE:TASK"'],
|
|
345
362
|
['workflow show NAME [--json]', 'Show one workflow'],
|
|
346
363
|
['workflow edit NAME [--trigger T:L]... [--add-trigger T:L] [--remove-trigger T:L] [--steps S | --template T] [--project P] [--rounds N]', 'Change a workflow; no flags asks'],
|
|
347
364
|
['workflow rename OLD NEW', 'Rename a workflow, keeping its place'],
|
|
@@ -356,7 +373,7 @@ const USAGE = [
|
|
|
356
373
|
['homes [--json]', 'Show where the agents are linked'],
|
|
357
374
|
['link DIR [--claude | --codex]', 'Link the agents into a config folder'],
|
|
358
375
|
['unlink DIR', 'Remove the agents from a config folder'],
|
|
359
|
-
['key', 'Store or replace the Linear API key (reads stdin without a terminal)'],
|
|
376
|
+
['key [--voidtrack]', 'Store or replace the Linear or VoidTrack API key (reads stdin without a terminal)'],
|
|
360
377
|
['help [COMMAND]', 'Show this list, or one command'],
|
|
361
378
|
];
|
|
362
379
|
const usageOf = (name) => USAGE.filter(([c]) => c === name || c.startsWith(`${name} `))
|
|
@@ -441,7 +458,7 @@ function readWorkflowFlags(v, config) {
|
|
|
441
458
|
for (const t of v.trigger) {
|
|
442
459
|
const parsed = parseTrigger(t);
|
|
443
460
|
if (!parsed)
|
|
444
|
-
return { error: `Bad trigger ${t}. Use
|
|
461
|
+
return { error: `Bad trigger ${t}. Use ${TRIGGER_FORMS}.`, code: 2 };
|
|
445
462
|
flags.triggers.push(parsed);
|
|
446
463
|
}
|
|
447
464
|
}
|
|
@@ -590,7 +607,7 @@ const workflowSubs = {
|
|
|
590
607
|
else {
|
|
591
608
|
ctx.log(`Workflow ${name}: ${describeWorkflow(w)}`);
|
|
592
609
|
for (const t of w.triggers)
|
|
593
|
-
ctx.log(`Trigger: ${t.
|
|
610
|
+
ctx.log(`Trigger: ${describeTrigger(t)}${t.source === 'cron' ? `, task: ${t.task}` : ''}`);
|
|
594
611
|
ctx.log(`Steps: ${describeSteps(w.steps)}`);
|
|
595
612
|
ctx.log(`Project: ${w.project}`);
|
|
596
613
|
ctx.log(`Rounds: ${w.maxRounds}`);
|
|
@@ -637,7 +654,7 @@ const workflowSubs = {
|
|
|
637
654
|
for (const t of (v['add-trigger'] ?? [])) {
|
|
638
655
|
const parsed = parseTrigger(t);
|
|
639
656
|
if (!parsed) {
|
|
640
|
-
ctx.log(`Bad trigger ${t}. Use
|
|
657
|
+
ctx.log(`Bad trigger ${t}. Use ${TRIGGER_FORMS}.`);
|
|
641
658
|
return 2;
|
|
642
659
|
}
|
|
643
660
|
if (!next.triggers.some((x) => sameTrigger(x, parsed)))
|
|
@@ -1053,7 +1070,11 @@ const agents = {
|
|
|
1053
1070
|
await setupProjects(ui, config, ctx.log);
|
|
1054
1071
|
await setupWorkflows(ui, config, await linear(ctx.configDir).teams(), ctx.log);
|
|
1055
1072
|
writeAgentsConfig(ctx.configDir, config);
|
|
1056
|
-
if (
|
|
1073
|
+
if (trackersInUse(config).includes('voidtrack') && !readSecret(ctx.configDir, 'voidtrack')) {
|
|
1074
|
+
if (!(await setupKey(ui, ctx.configDir, 'voidtrack')))
|
|
1075
|
+
ctx.log('VoidTrack is not connected yet. Run vsf agents key --voidtrack.');
|
|
1076
|
+
}
|
|
1077
|
+
if (Object.keys(config.workflows).length && (await ui.confirm('Start the background runner now? Every minute it checks for cards and schedules that start your workflows.'))) {
|
|
1057
1078
|
const ok = runnerService().start(ctx.home, [...ctx.selfCommand, 'agents', 'tick'], join(agentsDir(ctx.configDir), 'runner.log'));
|
|
1058
1079
|
ctx.log(ok ? 'Runner started.' : 'Could not start the runner. Try vsf agents start.');
|
|
1059
1080
|
}
|
|
@@ -1077,12 +1098,19 @@ const agents = {
|
|
|
1077
1098
|
? { name: `agents in ${homeLabel(h)}`, status: 'ok', detail: `${wanted.length} linked` }
|
|
1078
1099
|
: { name: `agents in ${homeLabel(h)}`, status: 'warn', detail: `missing ${missing.join(', ')}`, fix: `vsf agents link ${tilde(h.dir)}` });
|
|
1079
1100
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1101
|
+
const inUse = trackersInUse(saved);
|
|
1102
|
+
for (const kind of inUse.length ? inUse : ['linear']) {
|
|
1103
|
+
const { name } = TRACKERS[kind];
|
|
1104
|
+
try {
|
|
1105
|
+
const key = readSecret(ctx.configDir, kind);
|
|
1106
|
+
if (!key)
|
|
1107
|
+
throw new Error(`No ${name} API key`);
|
|
1108
|
+
const me = await (kind === 'voidtrack' ? createVoidTrack(key) : createLinear(key)).viewer();
|
|
1109
|
+
checks.push({ name, status: 'ok', detail: `connected as ${me.name}` });
|
|
1110
|
+
}
|
|
1111
|
+
catch (err) {
|
|
1112
|
+
checks.push({ name, status: 'fail', detail: err.message, fix: `vsf agents key${kind === 'linear' ? '' : ` --${kind}`}` });
|
|
1113
|
+
}
|
|
1086
1114
|
}
|
|
1087
1115
|
const workflows = Object.entries(saved.workflows);
|
|
1088
1116
|
const broken = workflows.filter(([, c]) => !saved.projects[c.project] || !isRepo(saved.projects[c.project].path)).map(([n]) => n);
|
|
@@ -1134,21 +1162,26 @@ const agents = {
|
|
|
1134
1162
|
link: homeCommands.link,
|
|
1135
1163
|
unlink: homeCommands.unlink,
|
|
1136
1164
|
async key(args, ctx) {
|
|
1137
|
-
const p = begin('key', args, {}, ctx);
|
|
1165
|
+
const p = begin('key', args, { voidtrack: { type: 'boolean' } }, ctx);
|
|
1138
1166
|
if (typeof p === 'number')
|
|
1139
1167
|
return p;
|
|
1140
1168
|
if (p.positionals.length) {
|
|
1141
1169
|
ctx.log('Keys are not accepted on the command line. Pipe the key in instead.');
|
|
1142
1170
|
return 2;
|
|
1143
1171
|
}
|
|
1172
|
+
const kind = p.values.voidtrack ? 'voidtrack' : 'linear';
|
|
1144
1173
|
if (interactive())
|
|
1145
|
-
return (await setupKey(ctx.ui, ctx.configDir)) ? 0 : 1;
|
|
1174
|
+
return (await setupKey(ctx.ui, ctx.configDir, kind)) ? 0 : 1;
|
|
1146
1175
|
const key = readFileSync(0, 'utf8').trim();
|
|
1147
|
-
if (!looksLikeLinearKey(key)) {
|
|
1176
|
+
if (kind === 'linear' && !looksLikeLinearKey(key)) {
|
|
1148
1177
|
ctx.log('Linear keys start with lin_api_');
|
|
1149
1178
|
return 2;
|
|
1150
1179
|
}
|
|
1151
|
-
|
|
1180
|
+
if (!key) {
|
|
1181
|
+
ctx.log('No key on stdin.');
|
|
1182
|
+
return 2;
|
|
1183
|
+
}
|
|
1184
|
+
return (await verifyAndStore(ctx.configDir, key, ctx.log, ctx.log, kind)) ? 0 : 1;
|
|
1152
1185
|
},
|
|
1153
1186
|
async help(args, ctx) {
|
|
1154
1187
|
const want = args.join(' ').trim();
|
package/dist/linear.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export declare function createLinear(apiKey: string): {
|
|
|
20
20
|
name: string;
|
|
21
21
|
mine: boolean;
|
|
22
22
|
}[]>;
|
|
23
|
-
myCardsWithLabel(label: string): Promise<Card[]>;
|
|
23
|
+
myCardsWithLabel(label: string, team: string): Promise<Card[]>;
|
|
24
24
|
card(identifier: string): Promise<Card>;
|
|
25
25
|
comments(identifier: string): Promise<{
|
|
26
26
|
body: string;
|
package/dist/linear.js
CHANGED
|
@@ -79,14 +79,14 @@ export function createLinear(apiKey) {
|
|
|
79
79
|
const all = d?.teams?.nodes ?? [];
|
|
80
80
|
return all.map((t) => ({ ...t, mine: mine.has(t.key) })).sort((a, b) => Number(b.mine) - Number(a.mine));
|
|
81
81
|
},
|
|
82
|
-
// My cards carrying the trigger label.
|
|
83
|
-
async myCardsWithLabel(label) {
|
|
82
|
+
// My cards in the team carrying the trigger label.
|
|
83
|
+
async myCardsWithLabel(label, team) {
|
|
84
84
|
const d = await gql('myCards', `query MyCards($label: String!) {
|
|
85
85
|
issues(first: 20, orderBy: createdAt, filter: { assignee: { isMe: { eq: true } }, labels: { name: { eq: $label } } }) {
|
|
86
86
|
nodes { ${CARD_FIELDS} }
|
|
87
87
|
}
|
|
88
88
|
}`, { label });
|
|
89
|
-
return (d?.issues?.nodes ?? []).map(mapCard);
|
|
89
|
+
return (d?.issues?.nodes ?? []).map(mapCard).filter((c) => c.teamKey === team);
|
|
90
90
|
},
|
|
91
91
|
async card(identifier) {
|
|
92
92
|
const d = await gql('card', `query Card($id: String!) { issue(id: $id) { ${CARD_FIELDS} } }`, { id: identifier });
|
package/dist/manage.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type AgentsConfig, type Trigger, type Workflow } from './config.ts';
|
|
2
2
|
export declare function validateWorkflow(config: Pick<AgentsConfig, 'enabled' | 'projects'>, workflow: Workflow): string[];
|
|
3
|
+
export declare const TRIGGER_FORMS = "TEAM:LABEL (Linear), voidtrack:TEAM:LABEL, or cron:SCHEDULE:TASK";
|
|
3
4
|
export declare function parseTrigger(input: string): Trigger | null;
|
|
4
5
|
export declare const sameTrigger: (a: Trigger, b: Trigger) => boolean;
|
|
5
6
|
export declare function duplicateTriggerWarnings(workflows: Record<string, Workflow>, name: string, workflow: Workflow): string[];
|