@voidlabs.gg/voidshift-agents 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/dist/config.d.ts +19 -0
- package/dist/config.js +43 -0
- package/dist/control.d.ts +7 -2
- package/dist/control.js +82 -38
- package/dist/index.js +186 -11
- package/dist/manage.d.ts +2 -1
- package/dist/manage.js +46 -1
- package/dist/notify.d.ts +30 -0
- package/dist/notify.js +233 -0
- package/dist/runner.d.ts +28 -2
- package/dist/runner.js +223 -60
- package/dist/secrets.d.ts +1 -0
- package/dist/secrets.js +12 -1
- package/dist/state.d.ts +11 -1
- package/dist/state.js +57 -3
- package/package.json +1 -1
package/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?: {
|
|
@@ -16,15 +19,38 @@ type Job = {
|
|
|
16
19
|
card: Card;
|
|
17
20
|
trigger: Trigger;
|
|
18
21
|
workflow: string;
|
|
22
|
+
workspace: string;
|
|
19
23
|
};
|
|
20
24
|
export declare function cronCard(workflow: string, task: string, at: Date): Card;
|
|
21
|
-
export
|
|
25
|
+
export interface JobQuery {
|
|
26
|
+
only?: string;
|
|
27
|
+
workflow?: string;
|
|
28
|
+
schedule?: string;
|
|
22
29
|
force?: boolean;
|
|
30
|
+
busy?: Set<string>;
|
|
31
|
+
skip?: Set<string>;
|
|
32
|
+
limit?: number;
|
|
23
33
|
now?: Date;
|
|
24
|
-
}
|
|
34
|
+
}
|
|
35
|
+
export declare function findJobs(rc: RunContext, q?: JobQuery): Promise<Job[]>;
|
|
36
|
+
export declare function nextJob(rc: RunContext, only?: string, workflowName?: string, opts?: Omit<JobQuery, 'only' | 'workflow' | 'limit'>): Promise<Job | null>;
|
|
25
37
|
export declare function cancelCard(tracker: Tracker, configDir: string, cardId: string, label: string, source?: 'linear' | 'voidtrack'): Promise<boolean>;
|
|
26
38
|
export declare function tick(rc: RunContext, only?: string, opts?: {
|
|
27
39
|
workflow?: string;
|
|
28
40
|
from?: string;
|
|
41
|
+
schedule?: string;
|
|
42
|
+
child?: boolean;
|
|
29
43
|
}): Promise<number>;
|
|
44
|
+
export interface ChildRun {
|
|
45
|
+
kill(signal: NodeJS.Signals): unknown;
|
|
46
|
+
once(event: 'exit' | 'error', listener: (err?: Error) => void): unknown;
|
|
47
|
+
exitCode: number | null;
|
|
48
|
+
}
|
|
49
|
+
export interface DispatchDeps {
|
|
50
|
+
start: (args: string[]) => ChildRun;
|
|
51
|
+
waitMs: number;
|
|
52
|
+
reload?: () => AgentsConfig;
|
|
53
|
+
}
|
|
54
|
+
export declare const jobArgs: (job: Job) => string[];
|
|
55
|
+
export declare function dispatch(rc: RunContext, over?: Partial<DispatchDeps>): Promise<number>;
|
|
30
56
|
export {};
|