@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 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
- setPaused(ctx.configDir, false);
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
- setPaused(ctx.configDir, true);
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
- setPaused(ctx.configDir, false);
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 = { configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand };
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) {
@@ -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 function park(reason) {
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 (!isCardTrigger(trigger))
127
- return done('parked', reason);
128
- say(`could not reach ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} to park it (${err.message}). Will retry next minute.`);
129
- // An empty comment is skipped on retry, so it is not posted twice.
130
- writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: labelsOn, source: trigger.source }]);
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
- return done('parked', reason);
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
- return done('verified');
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
- if (!isPaused(rc.configDir)) {
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-z-]+$/.test(name))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidlabs.gg/voidshift-agents",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Agents for Claude Code and Codex that plan, build and QA Linear cards, chained and run in the background.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {