@voidlabs.gg/voidshift-agents 0.6.0 → 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/dist/control.d.ts +1 -0
- package/dist/control.js +16 -4
- package/dist/index.js +54 -1
- package/dist/notify.d.ts +30 -0
- package/dist/notify.js +233 -0
- package/dist/runner.d.ts +3 -0
- package/dist/runner.js +34 -10
- package/dist/secrets.d.ts +1 -0
- package/dist/secrets.js +12 -1
- package/package.json +1 -1
package/dist/control.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ 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;
|
package/dist/control.js
CHANGED
|
@@ -9,6 +9,7 @@ import { TRACKERS, trackersFor } from "./tracker.js";
|
|
|
9
9
|
import { parseFrom, resolveName, stepNames } from "./names.js";
|
|
10
10
|
import { isStatusLabel } from "./workflow.js";
|
|
11
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+$/;
|
|
@@ -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;
|
|
@@ -366,7 +374,7 @@ const pause = guard(async (args, ctx) => {
|
|
|
366
374
|
const p = parse('pause', args, {}, ctx);
|
|
367
375
|
if (typeof p === 'number')
|
|
368
376
|
return p;
|
|
369
|
-
|
|
377
|
+
await changePaused(ctx, true, 'by vsf agents pause');
|
|
370
378
|
ctx.log('Paused. The runner picks up no new cards; a card in progress carries on. vsf agents resume to continue.');
|
|
371
379
|
return 0;
|
|
372
380
|
});
|
|
@@ -374,7 +382,7 @@ const resume = guard(async (args, ctx) => {
|
|
|
374
382
|
const p = parse('resume', args, {}, ctx);
|
|
375
383
|
if (typeof p === 'number')
|
|
376
384
|
return p;
|
|
377
|
-
|
|
385
|
+
await changePaused(ctx, false, 'by vsf agents resume');
|
|
378
386
|
ctx.log('Resumed.');
|
|
379
387
|
return 0;
|
|
380
388
|
});
|
|
@@ -720,7 +728,11 @@ const tickCmd = async (args, ctx) => {
|
|
|
720
728
|
try {
|
|
721
729
|
const { values } = parseArgs({ args, options: { card: { type: 'string' }, workflow: { type: 'string' }, schedule: { type: 'string' } }, strict: true });
|
|
722
730
|
const config = readAgentsConfig(ctx.configDir);
|
|
723
|
-
const rc = {
|
|
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
|
+
};
|
|
724
736
|
if (values.card || values.workflow)
|
|
725
737
|
return await tick(rc, values.card?.toUpperCase(), { workflow: values.workflow, schedule: values.schedule, child: true });
|
|
726
738
|
return await dispatch(rc, { reload: () => readAgentsConfig(ctx.configDir) });
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ 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";
|
|
11
11
|
import { createVoidTrack } from "./voidtrack.js";
|
|
12
|
+
import { addWebhook, dropWebhook, looksLikeWebhook, moveWebhook, removeWebhook, testWebhook, webhookFor } from "./notify.js";
|
|
12
13
|
import { parseCron } from "./cron.js";
|
|
13
14
|
import { trackersInUse } from "./control.js";
|
|
14
15
|
import { runnerService } from "./service.js";
|
|
@@ -379,6 +380,7 @@ const USAGE = [
|
|
|
379
380
|
['link DIR [--claude | --codex]', 'Link the agents into a config folder'],
|
|
380
381
|
['unlink DIR', 'Remove the agents from a config folder'],
|
|
381
382
|
['key [--voidtrack]', 'Store or replace the Linear or VoidTrack API key (reads stdin without a terminal)'],
|
|
383
|
+
['discord [--workflow W] [--test] [--rm]', 'Store, test or remove the Discord webhook for run pings, global or for one workflow; it is stored only after a test message goes through (reads stdin without a terminal)'],
|
|
382
384
|
['profile [init]', `Your profile: rules (${RULES_FILE}) and Claude settings (${CLAUDE_SETTINGS}) every agent run carries; init creates it`],
|
|
383
385
|
['validate [DIR]', `Check an agent folder (DIR/${AGENT_FILE} and its prompts), or the config and every folder under agents/defs`],
|
|
384
386
|
['help [COMMAND]', 'Show this list, or one command'],
|
|
@@ -662,8 +664,14 @@ const workflowSubs = {
|
|
|
662
664
|
if (!teams)
|
|
663
665
|
return 1;
|
|
664
666
|
const r = await promptWorkflow(ctx.ui, config, teams, { name, workflow: current }, true);
|
|
665
|
-
if (r.name !== name)
|
|
667
|
+
if (r.name !== name) {
|
|
668
|
+
const refused = moveWebhook(ctx.configDir, name, r.name);
|
|
669
|
+
if (refused) {
|
|
670
|
+
ctx.log(refused);
|
|
671
|
+
return 1;
|
|
672
|
+
}
|
|
666
673
|
config.workflows = renameKey(config.workflows, name, r.name);
|
|
674
|
+
}
|
|
667
675
|
config.workflows[r.name] = r.workflow;
|
|
668
676
|
return saveWorkflow(config, r.name, r.workflow, ctx);
|
|
669
677
|
}
|
|
@@ -716,6 +724,11 @@ const workflowSubs = {
|
|
|
716
724
|
ctx.log(`A workflow named ${next} already exists.`);
|
|
717
725
|
return 1;
|
|
718
726
|
}
|
|
727
|
+
const refused = moveWebhook(ctx.configDir, old, next);
|
|
728
|
+
if (refused) {
|
|
729
|
+
ctx.log(refused);
|
|
730
|
+
return 1;
|
|
731
|
+
}
|
|
719
732
|
config.workflows = renameKey(config.workflows, old, next);
|
|
720
733
|
save(ctx, config);
|
|
721
734
|
ctx.log(`Workflow ${old} is now ${next}.`);
|
|
@@ -742,6 +755,7 @@ const workflowSubs = {
|
|
|
742
755
|
}
|
|
743
756
|
delete config.workflows[name];
|
|
744
757
|
save(ctx, config);
|
|
758
|
+
dropWebhook(ctx.configDir, name);
|
|
745
759
|
ctx.log(`Deleted workflow ${name}.`);
|
|
746
760
|
return 0;
|
|
747
761
|
},
|
|
@@ -1220,6 +1234,11 @@ const agents = {
|
|
|
1220
1234
|
if (!(await setupKey(ui, ctx.configDir, 'voidtrack')))
|
|
1221
1235
|
ctx.log('VoidTrack is not connected yet. Run vsf agents key --voidtrack.');
|
|
1222
1236
|
}
|
|
1237
|
+
if (!webhookFor(ctx.configDir) && (await ui.confirm('Send run pings to Discord?', false))) {
|
|
1238
|
+
const url = await ui.secret('Paste the Discord webhook URL', (v) => (looksLikeWebhook(v.trim()) ? undefined : 'Not a Discord webhook URL'));
|
|
1239
|
+
if (!(await addWebhook(ctx.configDir, url.trim(), undefined, ui.info)))
|
|
1240
|
+
ctx.log('Discord is not connected. Run vsf agents discord to retry.');
|
|
1241
|
+
}
|
|
1223
1242
|
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.'))) {
|
|
1224
1243
|
const ok = runnerService().start(ctx.home, [...ctx.selfCommand, 'agents', 'tick'], join(agentsDir(ctx.configDir), 'runner.log'));
|
|
1225
1244
|
ctx.log(ok ? 'Runner started.' : 'Could not start the runner. Try vsf agents start.');
|
|
@@ -1258,6 +1277,8 @@ const agents = {
|
|
|
1258
1277
|
checks.push({ name, status: 'fail', detail: err.message, fix: `vsf agents key${kind === 'linear' ? '' : ` --${kind}`}` });
|
|
1259
1278
|
}
|
|
1260
1279
|
}
|
|
1280
|
+
if (webhookFor(ctx.configDir))
|
|
1281
|
+
checks.push({ name: 'discord', status: 'ok', detail: 'webhook set' });
|
|
1261
1282
|
const workflows = Object.entries(saved.workflows);
|
|
1262
1283
|
const broken = workflows.filter(([, c]) => !saved.projects[c.project] || !isRepo(saved.projects[c.project].path)).map(([n]) => n);
|
|
1263
1284
|
checks.push(!workflows.length
|
|
@@ -1389,6 +1410,38 @@ const agents = {
|
|
|
1389
1410
|
}
|
|
1390
1411
|
return (await verifyAndStore(ctx.configDir, key, ctx.log, ctx.log, kind)) ? 0 : 1;
|
|
1391
1412
|
},
|
|
1413
|
+
async discord(args, ctx) {
|
|
1414
|
+
const p = begin('discord', args, { workflow: { type: 'string' }, test: { type: 'boolean' }, rm: { type: 'boolean' } }, ctx);
|
|
1415
|
+
if (typeof p === 'number')
|
|
1416
|
+
return p;
|
|
1417
|
+
if (p.positionals.length) {
|
|
1418
|
+
ctx.log('Webhooks are not accepted on the command line. Pipe the URL in instead.');
|
|
1419
|
+
return 2;
|
|
1420
|
+
}
|
|
1421
|
+
if (p.values.test && p.values.rm) {
|
|
1422
|
+
ctx.log('Give --test or --rm, not both.');
|
|
1423
|
+
return 2;
|
|
1424
|
+
}
|
|
1425
|
+
let workflow;
|
|
1426
|
+
if (p.values.workflow) {
|
|
1427
|
+
const name = pickWorkflow(readAgentsConfig(ctx.configDir), p.values.workflow, ctx);
|
|
1428
|
+
if (!name)
|
|
1429
|
+
return 1;
|
|
1430
|
+
workflow = name;
|
|
1431
|
+
}
|
|
1432
|
+
if (p.values.test)
|
|
1433
|
+
return (await testWebhook(ctx.configDir, workflow, ctx.log)) ? 0 : 1;
|
|
1434
|
+
if (p.values.rm)
|
|
1435
|
+
return removeWebhook(ctx.configDir, workflow, ctx.log) ? 0 : 1;
|
|
1436
|
+
const url = interactive()
|
|
1437
|
+
? await ctx.ui.secret('Paste the Discord webhook URL', (v) => (looksLikeWebhook(v.trim()) ? undefined : 'Not a Discord webhook URL'))
|
|
1438
|
+
: readFileSync(0, 'utf8');
|
|
1439
|
+
if (!url.trim()) {
|
|
1440
|
+
ctx.log('No webhook on stdin.');
|
|
1441
|
+
return 2;
|
|
1442
|
+
}
|
|
1443
|
+
return (await addWebhook(ctx.configDir, url.trim(), workflow, ctx.log)) ? 0 : 1;
|
|
1444
|
+
},
|
|
1392
1445
|
async help(args, ctx) {
|
|
1393
1446
|
const want = args.join(' ').trim();
|
|
1394
1447
|
if (!want) {
|
package/dist/notify.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const looksLikeWebhook: (url: string) => boolean;
|
|
2
|
+
export declare const slug: (name: string) => string;
|
|
3
|
+
export declare const webhookFor: (configDir: string, workflow?: string, platform?: NodeJS.Platform) => string | null;
|
|
4
|
+
export declare function postDiscord(url: string, content: string, fetcher?: typeof fetch, timeoutMs?: number): Promise<string | null>;
|
|
5
|
+
export type RunKind = 'ready' | 'passed' | 'parked' | 'failed';
|
|
6
|
+
export interface RunEvent {
|
|
7
|
+
kind: RunKind;
|
|
8
|
+
card: string;
|
|
9
|
+
title: string;
|
|
10
|
+
workflow: string;
|
|
11
|
+
url?: string;
|
|
12
|
+
prUrl?: string;
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function runMessage(e: RunEvent): string;
|
|
16
|
+
export declare function notifyRun(configDir: string, e: RunEvent, log: (line: string) => void, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<void>;
|
|
17
|
+
export declare function notifyRunner(configDir: string, paused: boolean, reason: string, log: (line: string) => void, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<void>;
|
|
18
|
+
export interface NotifyState {
|
|
19
|
+
summaryDay?: string;
|
|
20
|
+
summaryAt?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function readNotifyState(configDir: string): NotifyState;
|
|
23
|
+
export declare function writeNotifyState(configDir: string, state: NotifyState): void;
|
|
24
|
+
export declare function summaryMessage(configDir: string, since: Date, paused: boolean): string | null;
|
|
25
|
+
export declare function sendSummary(configDir: string, paused: boolean, log: (line: string) => void, now?: Date, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<void>;
|
|
26
|
+
export declare function addWebhook(configDir: string, url: string, workflow: string | undefined, log: (line: string) => void, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<boolean>;
|
|
27
|
+
export declare function testWebhook(configDir: string, workflow: string | undefined, log: (line: string) => void, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<boolean>;
|
|
28
|
+
export declare function removeWebhook(configDir: string, workflow: string | undefined, log: (line: string) => void, platform?: NodeJS.Platform): boolean;
|
|
29
|
+
export declare function moveWebhook(configDir: string, from: string, to: string, platform?: NodeJS.Platform): string | null;
|
|
30
|
+
export declare function dropWebhook(configDir: string, workflow: string, platform?: NodeJS.Platform): void;
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { agentsDir, readAgentsConfig } from "./config.js";
|
|
4
|
+
import { keyStore, readSecret, removeSecret, storeSecret } from "./secrets.js";
|
|
5
|
+
import { listRuns } from "./state.js";
|
|
6
|
+
// Run pings go to a Discord webhook: one global, and optionally one per
|
|
7
|
+
// workflow that wins over it. Both are kept as secrets.
|
|
8
|
+
const WEBHOOK = /^https:\/\/(discord\.com|discordapp\.com|ptb\.discord\.com|canary\.discord\.com)\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+$/;
|
|
9
|
+
const LIMIT = 2000;
|
|
10
|
+
const TIMEOUT_MS = 5000;
|
|
11
|
+
export const looksLikeWebhook = (url) => WEBHOOK.test(url);
|
|
12
|
+
// A workflow name as it can appear in a secret name.
|
|
13
|
+
export const slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
14
|
+
const secretName = (workflow) => (workflow ? `discord-${slug(workflow)}` : 'discord');
|
|
15
|
+
// Never throws: a secrets file that cannot be read means no ping.
|
|
16
|
+
function readWebhook(configDir, workflow, platform) {
|
|
17
|
+
if (workflow !== undefined && !slug(workflow))
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
return readSecret(configDir, secretName(workflow), platform);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// The workflow's own webhook, else the global one.
|
|
27
|
+
export const webhookFor = (configDir, workflow, platform = process.platform) => (workflow ? readWebhook(configDir, workflow, platform) : null) ?? readWebhook(configDir, undefined, platform);
|
|
28
|
+
// Posts one message. Never throws: returns why it failed, or null. A slow
|
|
29
|
+
// network gives up after the timeout, even when the fetch ignores its signal.
|
|
30
|
+
export async function postDiscord(url, content, fetcher = fetch, timeoutMs = TIMEOUT_MS) {
|
|
31
|
+
let timer;
|
|
32
|
+
const timeout = new Promise((resolve) => {
|
|
33
|
+
timer = setTimeout(() => resolve(`no answer from Discord in ${timeoutMs / 1000}s`), timeoutMs);
|
|
34
|
+
});
|
|
35
|
+
const send = (async () => {
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetcher(url, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
// No mentions are parsed, so card text can never ping anyone.
|
|
42
|
+
body: JSON.stringify({ content: content.slice(0, LIMIT), username: 'VoidShift', allowed_mentions: { parse: [] } }),
|
|
43
|
+
});
|
|
44
|
+
return res.ok ? null : `Discord answered ${res.status}`;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const cause = err.cause;
|
|
48
|
+
return `could not reach Discord (${cause?.code ?? cause?.message ?? err.message})`;
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
try {
|
|
52
|
+
return await Promise.race([send, timeout]);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const HEADINGS = { ready: 'PR ready', passed: 'Checks passed', parked: 'Parked', failed: 'Failed' };
|
|
59
|
+
export function runMessage(e) {
|
|
60
|
+
const link = e.prUrl || e.url;
|
|
61
|
+
const reason = (e.kind === 'parked' || e.kind === 'failed') && e.reason ? e.reason.slice(0, 300) : '';
|
|
62
|
+
return [`**${HEADINGS[e.kind]}** ${e.card} ${e.title}, workflow ${e.workflow}`, link ?? '', reason].filter(Boolean).join('\n');
|
|
63
|
+
}
|
|
64
|
+
// Pings the run's webhook, if one is set. Logs a failure and never throws.
|
|
65
|
+
export async function notifyRun(configDir, e, log, fetcher = fetch, platform = process.platform) {
|
|
66
|
+
const url = webhookFor(configDir, e.workflow, platform);
|
|
67
|
+
if (!url)
|
|
68
|
+
return;
|
|
69
|
+
const err = await postDiscord(url, runMessage(e), fetcher);
|
|
70
|
+
if (err)
|
|
71
|
+
log(`discord: ${err}`);
|
|
72
|
+
}
|
|
73
|
+
// The runner paused or resumed, with why; a budget pause can use it too.
|
|
74
|
+
export async function notifyRunner(configDir, paused, reason, log, fetcher = fetch, platform = process.platform) {
|
|
75
|
+
const url = webhookFor(configDir, undefined, platform);
|
|
76
|
+
if (!url)
|
|
77
|
+
return;
|
|
78
|
+
const err = await postDiscord(url, `**Runner ${paused ? 'paused' : 'resumed'}** ${reason}`, fetcher);
|
|
79
|
+
if (err)
|
|
80
|
+
log(`discord: ${err}`);
|
|
81
|
+
}
|
|
82
|
+
const notifyPath = (configDir) => join(agentsDir(configDir), 'notify.json');
|
|
83
|
+
export function readNotifyState(configDir) {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(readFileSync(notifyPath(configDir), 'utf8'));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function writeNotifyState(configDir, state) {
|
|
92
|
+
mkdirSync(agentsDir(configDir), { recursive: true });
|
|
93
|
+
writeFileSync(notifyPath(configDir), JSON.stringify(state, null, 2) + '\n');
|
|
94
|
+
}
|
|
95
|
+
const SUMMARY_HOUR = 9;
|
|
96
|
+
const DAY = 86_400_000;
|
|
97
|
+
const localDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
98
|
+
// Runs ended since the last summary (or the last day), counted by outcome,
|
|
99
|
+
// with the pull requests and parked cards.
|
|
100
|
+
export function summaryMessage(configDir, since, paused) {
|
|
101
|
+
const runs = listRuns(configDir)
|
|
102
|
+
.map((e) => e.record)
|
|
103
|
+
.filter((r) => r && r.outcome !== 'running' && r.endedAt && Date.parse(r.endedAt) > since.getTime());
|
|
104
|
+
if (!runs.length)
|
|
105
|
+
return null;
|
|
106
|
+
const counts = new Map();
|
|
107
|
+
for (const r of runs)
|
|
108
|
+
counts.set(r.outcome, (counts.get(r.outcome) ?? 0) + 1);
|
|
109
|
+
const prs = runs.filter((r) => r.outcome === 'verified' && r.prUrl).slice(0, 10);
|
|
110
|
+
const parked = runs.filter((r) => r.outcome === 'parked').slice(0, 10);
|
|
111
|
+
const lines = [`**Daily summary** ${[...counts].map(([o, n]) => `${n} ${o}`).join(', ')}`];
|
|
112
|
+
if (paused)
|
|
113
|
+
lines.push('Runner paused');
|
|
114
|
+
if (prs.length)
|
|
115
|
+
lines.push('PRs:', ...prs.map((r) => `${r.card} ${r.prUrl}`));
|
|
116
|
+
if (parked.length)
|
|
117
|
+
lines.push('Parked:', ...parked.map((r) => `${r.card} ${r.title}${r.reason ? `: ${r.reason.slice(0, 120)}` : ''}`));
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
}
|
|
120
|
+
// Sends the summary once a day, on the first call at or after 09:00 local.
|
|
121
|
+
// The day is recorded before posting, so overlapping calls send it once.
|
|
122
|
+
export async function sendSummary(configDir, paused, log, now = new Date(), fetcher = fetch, platform = process.platform) {
|
|
123
|
+
try {
|
|
124
|
+
if (now.getHours() < SUMMARY_HOUR)
|
|
125
|
+
return;
|
|
126
|
+
const state = readNotifyState(configDir);
|
|
127
|
+
const today = localDay(now);
|
|
128
|
+
if (state.summaryDay === today)
|
|
129
|
+
return;
|
|
130
|
+
const url = webhookFor(configDir, undefined, platform);
|
|
131
|
+
if (!url)
|
|
132
|
+
return;
|
|
133
|
+
const since = state.summaryAt ? new Date(state.summaryAt) : new Date(now.getTime() - DAY);
|
|
134
|
+
writeNotifyState(configDir, { summaryDay: today, summaryAt: now.toISOString() });
|
|
135
|
+
const message = summaryMessage(configDir, since, paused);
|
|
136
|
+
if (!message)
|
|
137
|
+
return;
|
|
138
|
+
const err = await postDiscord(url, message, fetcher);
|
|
139
|
+
if (err)
|
|
140
|
+
log(`discord: ${err}`);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
log(`discord: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Why an override cannot be kept for this workflow, or null.
|
|
147
|
+
function overrideProblem(configDir, workflow) {
|
|
148
|
+
const s = slug(workflow);
|
|
149
|
+
if (!s)
|
|
150
|
+
return `Workflow ${workflow} has no letters or digits, so it cannot have its own webhook.`;
|
|
151
|
+
const other = Object.keys(readAgentsConfig(configDir).workflows).find((n) => n !== workflow && slug(n) === s);
|
|
152
|
+
return other ? `Workflows ${workflow} and ${other} would share a webhook. Rename one first.` : null;
|
|
153
|
+
}
|
|
154
|
+
// Tests the webhook with one message and stores it only when that worked.
|
|
155
|
+
export async function addWebhook(configDir, url, workflow, log, fetcher = fetch, platform = process.platform) {
|
|
156
|
+
if (!looksLikeWebhook(url)) {
|
|
157
|
+
log('That is not a Discord webhook URL. It looks like https://discord.com/api/webhooks/123/abc.');
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
const problem = workflow ? overrideProblem(configDir, workflow) : null;
|
|
161
|
+
if (problem) {
|
|
162
|
+
log(problem);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const err = await postDiscord(url, `VoidShift is connected. Run pings${workflow ? ` for workflow ${workflow}` : ''} come here.`, fetcher);
|
|
166
|
+
if (err) {
|
|
167
|
+
log(`Discord did not take the test message: ${err}. Nothing was stored.`);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (!storeSecret(configDir, secretName(workflow), url, platform)) {
|
|
171
|
+
log(`Discord took the test message but the webhook could not be stored in ${keyStore(platform)}.`);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
log(`Webhook${workflow ? ` for workflow ${workflow}` : ''} stored in ${keyStore(platform)}. A test message was sent.`);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
// Sends a test message to the webhook a workflow's runs would use.
|
|
178
|
+
export async function testWebhook(configDir, workflow, log, fetcher = fetch, platform = process.platform) {
|
|
179
|
+
const url = webhookFor(configDir, workflow, platform);
|
|
180
|
+
if (!url) {
|
|
181
|
+
log('No webhook set. Run vsf agents discord to add one.');
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const err = await postDiscord(url, `Test message from VoidShift${workflow ? ` for workflow ${workflow}` : ''}.`, fetcher);
|
|
185
|
+
log(err ? `discord: ${err}` : 'Test message sent.');
|
|
186
|
+
return !err;
|
|
187
|
+
}
|
|
188
|
+
export function removeWebhook(configDir, workflow, log, platform = process.platform) {
|
|
189
|
+
if (!readWebhook(configDir, workflow, platform)) {
|
|
190
|
+
log('No webhook set.');
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
if (!removeSecret(configDir, secretName(workflow), platform)) {
|
|
194
|
+
log(`Could not remove the webhook from ${keyStore(platform)}.`);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
log(`Webhook${workflow ? ` for workflow ${workflow}` : ''} removed.`);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
// Carries a workflow's webhook across a rename. Returns why the rename must
|
|
201
|
+
// not go ahead, or null; nothing is changed when it refuses.
|
|
202
|
+
export function moveWebhook(configDir, from, to, platform = process.platform) {
|
|
203
|
+
if (slug(from) === slug(to))
|
|
204
|
+
return null;
|
|
205
|
+
const mine = readWebhook(configDir, from, platform);
|
|
206
|
+
const theirs = slug(to) ? readWebhook(configDir, to, platform) : null;
|
|
207
|
+
if (!mine && !theirs)
|
|
208
|
+
return null;
|
|
209
|
+
if (!slug(to))
|
|
210
|
+
return `${to} has no letters or digits, so workflow ${from} would lose its webhook. Pick another name.`;
|
|
211
|
+
const other = Object.keys(readAgentsConfig(configDir).workflows).find((n) => n !== from && slug(n) === slug(to));
|
|
212
|
+
if (other)
|
|
213
|
+
return `Workflow ${other} would share a webhook with ${to}. Pick another name.`;
|
|
214
|
+
// A webhook left behind by a deleted workflow is not inherited.
|
|
215
|
+
if (theirs)
|
|
216
|
+
removeSecret(configDir, secretName(to), platform);
|
|
217
|
+
if (mine) {
|
|
218
|
+
if (!storeSecret(configDir, secretName(to), mine, platform))
|
|
219
|
+
return `Could not move the webhook of workflow ${from}.`;
|
|
220
|
+
removeSecret(configDir, secretName(from), platform);
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
// Drops a deleted workflow's webhook. Never throws.
|
|
225
|
+
export function dropWebhook(configDir, workflow, platform = process.platform) {
|
|
226
|
+
try {
|
|
227
|
+
if (readWebhook(configDir, workflow, platform))
|
|
228
|
+
removeSecret(configDir, secretName(workflow), platform);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// nothing more to do
|
|
232
|
+
}
|
|
233
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { type AgentsConfig, type Workflow, type Project, type Trigger } from './config.ts';
|
|
2
2
|
import type { Card } from './linear.ts';
|
|
3
3
|
import { type Tracker, type Trackers } from './tracker.ts';
|
|
4
|
+
import type { RunEvent } from './notify.ts';
|
|
4
5
|
export interface RunContext {
|
|
5
6
|
configDir: string;
|
|
6
7
|
config: AgentsConfig;
|
|
7
8
|
trackers: Trackers;
|
|
8
9
|
log: (line: string) => void;
|
|
9
10
|
selfCommand: string[];
|
|
11
|
+
notify?: (event: RunEvent) => Promise<void>;
|
|
12
|
+
summary?: (paused: boolean) => Promise<void>;
|
|
10
13
|
}
|
|
11
14
|
export declare function summarize(output: string): string;
|
|
12
15
|
export declare function runCard(card: Card, trigger: Trigger, workflowName: string, workflow: Workflow, project: Project, rc: RunContext, opts?: {
|
package/dist/runner.js
CHANGED
|
@@ -111,7 +111,17 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
111
111
|
await linear.comment(card, `${PLAN_HEADING}\n\n${planToPost}`);
|
|
112
112
|
planToPost = null;
|
|
113
113
|
};
|
|
114
|
-
async
|
|
114
|
+
const ping = async (kind, reason) => {
|
|
115
|
+
if (!rc.notify)
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
await rc.notify({ kind, card: card.identifier, title: card.title, workflow: workflowName, url: card.url || undefined, prUrl: record.prUrl, reason });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
say(`discord: ${err.message}`);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
async function park(reason, kind = 'parked') {
|
|
115
125
|
say(`parked: ${reason}`);
|
|
116
126
|
const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${label}\` again to retry.`;
|
|
117
127
|
const add = labelsOn ? [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])] : [];
|
|
@@ -123,13 +133,15 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
123
133
|
await endLabels(LABELS.parked, loops || undefined);
|
|
124
134
|
}
|
|
125
135
|
catch (err) {
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
if (isCardTrigger(trigger)) {
|
|
137
|
+
say(`could not reach ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} to park it (${err.message}). Will retry next minute.`);
|
|
138
|
+
// An empty comment is skipped on retry, so it is not posted twice.
|
|
139
|
+
writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: labelsOn, source: trigger.source }]);
|
|
140
|
+
}
|
|
131
141
|
}
|
|
132
|
-
|
|
142
|
+
const outcome = done('parked', reason);
|
|
143
|
+
await ping(kind, reason);
|
|
144
|
+
return outcome;
|
|
133
145
|
}
|
|
134
146
|
try {
|
|
135
147
|
if (!existsSync(join(project.path, '.git')))
|
|
@@ -331,12 +343,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
331
343
|
await linear.comment(card, `**All checks passed.**${prUrl ? ` PR ready: ${prUrl}` : ''}${!moved ? `\n\n(No "${state}" state on this team, so the card was not moved.)` : ''}`);
|
|
332
344
|
}
|
|
333
345
|
say('verified');
|
|
334
|
-
|
|
346
|
+
done('verified');
|
|
335
347
|
}
|
|
336
348
|
catch (err) {
|
|
337
349
|
if (err instanceof Park)
|
|
338
350
|
return park(err.message);
|
|
339
|
-
return park(`Something went wrong: ${err.message}
|
|
351
|
+
return park(`Something went wrong: ${err.message}`, 'failed');
|
|
340
352
|
}
|
|
341
353
|
finally {
|
|
342
354
|
active = null;
|
|
@@ -345,6 +357,9 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
345
357
|
if (shared)
|
|
346
358
|
say(shared);
|
|
347
359
|
}
|
|
360
|
+
// Outside the try, so a ping that throws can never park a verified run.
|
|
361
|
+
await ping(record.prUrl ? 'ready' : 'passed');
|
|
362
|
+
return 'verified';
|
|
348
363
|
}
|
|
349
364
|
// The workspace whose queue a job waits in. A workflow whose project is
|
|
350
365
|
// missing still queues somewhere, so its run can say what is wrong.
|
|
@@ -660,7 +675,16 @@ export async function dispatch(rc, over = {}) {
|
|
|
660
675
|
if (deps.reload)
|
|
661
676
|
rc = { ...rc, config: deps.reload() };
|
|
662
677
|
await flushPending(rc);
|
|
663
|
-
|
|
678
|
+
const paused = isPaused(rc.configDir);
|
|
679
|
+
if (rc.summary) {
|
|
680
|
+
try {
|
|
681
|
+
await rc.summary(paused);
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
rc.log(`discord: ${err.message}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (!paused) {
|
|
664
688
|
const busy = new Set([...children.keys(), ...liveRuns(rc.configDir).map((c) => workspaceOf(c))]);
|
|
665
689
|
const skip = new Set([...started].filter(([, at]) => Date.now() - at < deps.waitMs).map(([id]) => id));
|
|
666
690
|
for (const job of await findJobs(rc, { busy, skip })) {
|
package/dist/secrets.d.ts
CHANGED
|
@@ -5,5 +5,6 @@ export declare function readSecretsFile(path: string): Record<string, string>;
|
|
|
5
5
|
export declare function writeSecretsFile(path: string, values: Record<string, string>): void;
|
|
6
6
|
export declare function readSecret(configDir: string, name: string, platform?: NodeJS.Platform): string | null;
|
|
7
7
|
export declare function storeSecret(configDir: string, name: string, value: string, platform?: NodeJS.Platform): boolean;
|
|
8
|
+
export declare function removeSecret(configDir: string, name: string, platform?: NodeJS.Platform): boolean;
|
|
8
9
|
export declare const readLinearKey: (configDir: string) => string | null;
|
|
9
10
|
export declare const storeLinearKey: (configDir: string, key: string) => boolean;
|
package/dist/secrets.js
CHANGED
|
@@ -40,7 +40,7 @@ export function readSecret(configDir, name, platform = process.platform) {
|
|
|
40
40
|
return readSecretsFile(secretsPath(configDir))[name] || null;
|
|
41
41
|
}
|
|
42
42
|
export function storeSecret(configDir, name, value, platform = process.platform) {
|
|
43
|
-
if (!SAFE_VALUE.test(value) || !/^[a-
|
|
43
|
+
if (!SAFE_VALUE.test(value) || !/^[a-z0-9-]+$/.test(name))
|
|
44
44
|
return false;
|
|
45
45
|
if (platform === 'darwin') {
|
|
46
46
|
// On stdin through `security -i`, so the value never appears in a process list.
|
|
@@ -51,5 +51,16 @@ export function storeSecret(configDir, name, value, platform = process.platform)
|
|
|
51
51
|
writeSecretsFile(path, { ...readSecretsFile(path), [name]: value });
|
|
52
52
|
return true;
|
|
53
53
|
}
|
|
54
|
+
export function removeSecret(configDir, name, platform = process.platform) {
|
|
55
|
+
if (platform === 'darwin') {
|
|
56
|
+
// 44 is the Keychain's "not found": already gone counts as removed.
|
|
57
|
+
const status = spawnSync('/usr/bin/security', ['delete-generic-password', '-s', service(name)], { encoding: 'utf8' }).status;
|
|
58
|
+
return status === 0 || status === 44;
|
|
59
|
+
}
|
|
60
|
+
const path = secretsPath(configDir);
|
|
61
|
+
const { [name]: _gone, ...rest } = readSecretsFile(path);
|
|
62
|
+
writeSecretsFile(path, rest);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
54
65
|
export const readLinearKey = (configDir) => readSecret(configDir, 'linear');
|
|
55
66
|
export const storeLinearKey = (configDir, key) => looksLikeLinearKey(key) && storeSecret(configDir, 'linear', key);
|
package/package.json
CHANGED