@phnx-labs/agents-cli 1.20.87 → 1.20.89
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/CHANGELOG.md +323 -0
- package/README.md +12 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/doctor.d.ts +0 -19
- package/dist/commands/doctor.js +219 -305
- package/dist/commands/exec.js +7 -19
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/inspect.js +3 -5
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/routines.js +25 -3
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.js +2 -0
- package/dist/commands/sessions.d.ts +7 -1
- package/dist/commands/sessions.js +40 -12
- package/dist/commands/ssh.js +3 -3
- package/dist/commands/usage.d.ts +3 -2
- package/dist/commands/usage.js +2 -9
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/agents.d.ts +31 -1
- package/dist/lib/agents.js +55 -0
- package/dist/lib/command-skills.d.ts +10 -0
- package/dist/lib/command-skills.js +14 -0
- package/dist/lib/commands.js +28 -2
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +75 -6
- package/dist/lib/devices/doctor-findings.d.ts +167 -0
- package/dist/lib/devices/doctor-findings.js +893 -0
- package/dist/lib/devices/fleet-divergence.d.ts +22 -0
- package/dist/lib/devices/fleet-divergence.js +34 -10
- package/dist/lib/devices/fleet-inventory.d.ts +17 -6
- package/dist/lib/devices/fleet-inventory.js +56 -8
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +14 -3
- package/dist/lib/exec.js +41 -8
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/project-resources.js +34 -20
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +16 -1
- package/dist/lib/runner.js +58 -16
- package/dist/lib/sandbox.d.ts +2 -0
- package/dist/lib/sandbox.js +38 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
- package/dist/lib/secrets/rc-hygiene.js +0 -24
- package/dist/lib/session/active.d.ts +6 -6
- package/dist/lib/session/active.js +6 -6
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +137 -1
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +76 -37
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/staleness/detectors/commands.js +14 -5
- package/dist/lib/staleness/types.d.ts +2 -0
- package/dist/lib/staleness/writers/commands.js +13 -7
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/dist/lib/usage.d.ts +72 -1
- package/dist/lib/usage.js +21 -27
- package/dist/lib/versions.js +30 -13
- package/package.json +1 -1
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook handler config layer.
|
|
3
|
+
*
|
|
4
|
+
* Handlers are one-off triggers stored in `~/.agents/webhooks/*.yml` (plus
|
|
5
|
+
* project/system layers). They complement routine triggers: a matching webhook
|
|
6
|
+
* can run an agent, workflow, shell command, or delegate to a routine.
|
|
7
|
+
*/
|
|
8
|
+
import { exec } from 'child_process';
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import * as yaml from 'yaml';
|
|
12
|
+
import { emit } from '../events.js';
|
|
13
|
+
import { machineId, normalizeHost } from '../machine-id.js';
|
|
14
|
+
import { safeJoin } from '../paths.js';
|
|
15
|
+
import { pickFleetDevice } from '../routines-placement.js';
|
|
16
|
+
import { assertShellSubstitutionSupported, readJob, substituteWebhookCommand, substituteWebhookPrompt, } from '../routines.js';
|
|
17
|
+
import { ensureAgentsDir, getProjectWebhooksDir, getSystemWebhooksDir, getWebhooksDir } from '../state.js';
|
|
18
|
+
const HANDLER_DEFAULTS = {
|
|
19
|
+
enabled: true,
|
|
20
|
+
};
|
|
21
|
+
/** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
|
|
22
|
+
function payloadRepo(payload) {
|
|
23
|
+
const repo = payload?.repository;
|
|
24
|
+
const fullName = repo?.full_name;
|
|
25
|
+
return typeof fullName === 'string' && fullName.length > 0 ? fullName : null;
|
|
26
|
+
}
|
|
27
|
+
/** Strip a `refs/heads/` (or `refs/tags/`) prefix to the short branch/tag name. */
|
|
28
|
+
function shortRef(ref) {
|
|
29
|
+
return ref.replace(/^refs\/(heads|tags)\//, '');
|
|
30
|
+
}
|
|
31
|
+
/** Extract candidate branches a webhook payload references. */
|
|
32
|
+
function payloadBranches(event, payload) {
|
|
33
|
+
const branches = new Set();
|
|
34
|
+
const add = (v) => {
|
|
35
|
+
if (typeof v === 'string' && v.length > 0)
|
|
36
|
+
branches.add(shortRef(v));
|
|
37
|
+
};
|
|
38
|
+
switch (event) {
|
|
39
|
+
case 'push':
|
|
40
|
+
add(payload.ref);
|
|
41
|
+
break;
|
|
42
|
+
case 'pull_request': {
|
|
43
|
+
const pr = payload.pull_request;
|
|
44
|
+
add(pr?.base?.ref);
|
|
45
|
+
add(pr?.head?.ref);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case 'workflow_run': {
|
|
49
|
+
const run = payload.workflow_run;
|
|
50
|
+
add(run?.head_branch);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
default:
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
return [...branches];
|
|
57
|
+
}
|
|
58
|
+
function linearAction(payload) {
|
|
59
|
+
return typeof payload.action === 'string' ? payload.action : null;
|
|
60
|
+
}
|
|
61
|
+
function linearTeamKey(payload) {
|
|
62
|
+
const data = payload.data;
|
|
63
|
+
const identifier = data?.identifier;
|
|
64
|
+
if (typeof identifier === 'string') {
|
|
65
|
+
const match = /^([A-Z][A-Z0-9]*)-\d+$/.exec(identifier);
|
|
66
|
+
if (match)
|
|
67
|
+
return match[1];
|
|
68
|
+
}
|
|
69
|
+
const team = data?.team;
|
|
70
|
+
return typeof team?.key === 'string' ? team.key : null;
|
|
71
|
+
}
|
|
72
|
+
function linearLabels(payload) {
|
|
73
|
+
const data = payload.data;
|
|
74
|
+
const labels = Array.isArray(data?.labels) ? data?.labels : [];
|
|
75
|
+
return labels
|
|
76
|
+
.map((n) => n.name)
|
|
77
|
+
.filter((n) => typeof n === 'string' && n.length > 0);
|
|
78
|
+
}
|
|
79
|
+
function githubAction(payload) {
|
|
80
|
+
return typeof payload.action === 'string' ? payload.action : null;
|
|
81
|
+
}
|
|
82
|
+
function githubLabels(payload) {
|
|
83
|
+
const names = new Set();
|
|
84
|
+
const add = (value) => {
|
|
85
|
+
if (typeof value === 'string' && value.length > 0)
|
|
86
|
+
names.add(value);
|
|
87
|
+
};
|
|
88
|
+
const deliveryLabel = payload.label;
|
|
89
|
+
add(deliveryLabel?.name);
|
|
90
|
+
const pr = payload.pull_request;
|
|
91
|
+
const prLabels = Array.isArray(pr?.labels) ? pr.labels : [];
|
|
92
|
+
for (const label of prLabels) {
|
|
93
|
+
add(label.name);
|
|
94
|
+
}
|
|
95
|
+
const issue = payload.issue;
|
|
96
|
+
const issueLabels = Array.isArray(issue?.labels) ? issue.labels : [];
|
|
97
|
+
for (const label of issueLabels) {
|
|
98
|
+
add(label.name);
|
|
99
|
+
}
|
|
100
|
+
return [...names];
|
|
101
|
+
}
|
|
102
|
+
function readHandlerFile(filePath) {
|
|
103
|
+
try {
|
|
104
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
105
|
+
const parsed = yaml.parse(content);
|
|
106
|
+
if (!parsed || typeof parsed !== 'object')
|
|
107
|
+
return null;
|
|
108
|
+
return {
|
|
109
|
+
...HANDLER_DEFAULTS,
|
|
110
|
+
...parsed,
|
|
111
|
+
name: parsed.name || path.basename(filePath).replace(/\.ya?ml$/, ''),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function handlerRunsOnThisDevice(handler) {
|
|
119
|
+
if (!handler.devices || handler.devices.length === 0)
|
|
120
|
+
return true;
|
|
121
|
+
const self = machineId();
|
|
122
|
+
return handler.devices.some((d) => normalizeHost(d) === self);
|
|
123
|
+
}
|
|
124
|
+
const HOST_PLATFORMS = ['linux', 'macos', 'windows'];
|
|
125
|
+
function parseHostPlatform(raw) {
|
|
126
|
+
const parts = raw
|
|
127
|
+
.split('/')
|
|
128
|
+
.map((s) => s.trim().toLowerCase())
|
|
129
|
+
.filter((s) => s.length > 0);
|
|
130
|
+
const platformIdx = parts.findIndex((p) => HOST_PLATFORMS.includes(p));
|
|
131
|
+
if (platformIdx === -1)
|
|
132
|
+
return { base: parts.join('/') };
|
|
133
|
+
const platform = parts[platformIdx];
|
|
134
|
+
const rest = parts.filter((_, i) => i !== platformIdx).join('/');
|
|
135
|
+
return { base: rest, platform };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Resolve a handler `host` expression to a concrete host or local execution.
|
|
139
|
+
*
|
|
140
|
+
* - Specific device name (e.g. `yosemite-s0`) → run there over SSH, or locally
|
|
141
|
+
* if it names this machine.
|
|
142
|
+
* - `fleet` → pick any online worker device.
|
|
143
|
+
* - `fleet/<platform>` or `<platform>/fleet` (e.g. `fleet/linux`, `linux/fleet`)
|
|
144
|
+
* → pick any online worker on that platform. `linux` alone is accepted as a
|
|
145
|
+
* shorthand for `fleet/linux`.
|
|
146
|
+
*
|
|
147
|
+
* Throws when a fleet expression matches no eligible device, rather than
|
|
148
|
+
* silently falling back to this machine — `fleet/linux` must never land on a
|
|
149
|
+
* macOS box.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveHandlerHost(host) {
|
|
152
|
+
if (!host || host.trim() === '')
|
|
153
|
+
return {};
|
|
154
|
+
const { base, platform } = parseHostPlatform(host);
|
|
155
|
+
const isFleet = base === '' || base === 'fleet';
|
|
156
|
+
if (isFleet) {
|
|
157
|
+
const picked = pickFleetDevice(undefined, platform);
|
|
158
|
+
if (!picked) {
|
|
159
|
+
throw new Error(`handler host '${host}': no eligible online fleet device`);
|
|
160
|
+
}
|
|
161
|
+
if (normalizeHost(picked) === machineId())
|
|
162
|
+
return {};
|
|
163
|
+
return { host: picked, hostStrategy: 'host' };
|
|
164
|
+
}
|
|
165
|
+
if (normalizeHost(base) === machineId())
|
|
166
|
+
return {};
|
|
167
|
+
return { host: base, hostStrategy: 'host' };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* List all webhook handlers, scanning project > user > system webhook dirs.
|
|
171
|
+
* Higher layers shadow lower ones of the same name (first-seen wins).
|
|
172
|
+
*/
|
|
173
|
+
export function listHandlers(cwd) {
|
|
174
|
+
ensureAgentsDir();
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
const handlers = [];
|
|
177
|
+
const dirs = [];
|
|
178
|
+
if (cwd) {
|
|
179
|
+
const projectDir = getProjectWebhooksDir(cwd);
|
|
180
|
+
if (projectDir)
|
|
181
|
+
dirs.push({ scope: 'project', path: projectDir });
|
|
182
|
+
}
|
|
183
|
+
dirs.push({ scope: 'user', path: getWebhooksDir() });
|
|
184
|
+
dirs.push({ scope: 'system', path: getSystemWebhooksDir() });
|
|
185
|
+
for (const { path: dir } of dirs) {
|
|
186
|
+
if (!fs.existsSync(dir))
|
|
187
|
+
continue;
|
|
188
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
const handler = readHandlerFile(safeJoin(dir, file));
|
|
191
|
+
if (!handler)
|
|
192
|
+
continue;
|
|
193
|
+
if (seen.has(handler.name))
|
|
194
|
+
continue;
|
|
195
|
+
seen.add(handler.name);
|
|
196
|
+
handlers.push(handler);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return handlers;
|
|
200
|
+
}
|
|
201
|
+
/** Pure matcher: does this handler match the incoming webhook? */
|
|
202
|
+
export function handlerMatchesWebhook(handler, webhook) {
|
|
203
|
+
if (handler.enabled === false)
|
|
204
|
+
return false;
|
|
205
|
+
if (handler.source !== webhook.source)
|
|
206
|
+
return false;
|
|
207
|
+
if (handler.event && handler.event !== webhook.event)
|
|
208
|
+
return false;
|
|
209
|
+
if (!handlerRunsOnThisDevice(handler))
|
|
210
|
+
return false;
|
|
211
|
+
if (handler.action) {
|
|
212
|
+
const action = webhook.source === 'github' ? githubAction(webhook.payload) : linearAction(webhook.payload);
|
|
213
|
+
if (action !== handler.action)
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
if (webhook.source === 'linear') {
|
|
217
|
+
if (handler.teamKey && linearTeamKey(webhook.payload) !== handler.teamKey)
|
|
218
|
+
return false;
|
|
219
|
+
if (handler.label) {
|
|
220
|
+
const expected = handler.label.toLowerCase();
|
|
221
|
+
if (!linearLabels(webhook.payload).some((name) => name.toLowerCase() === expected))
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
if (handler.stateTo) {
|
|
225
|
+
const data = webhook.payload.data;
|
|
226
|
+
const current = data?.state?.name;
|
|
227
|
+
if (current !== handler.stateTo)
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
if (handler.stateFrom) {
|
|
231
|
+
const updatedFrom = webhook.payload.updatedFrom;
|
|
232
|
+
const previous = updatedFrom?.state?.name;
|
|
233
|
+
if (previous !== handler.stateFrom)
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
if (handler.repo) {
|
|
239
|
+
const repo = payloadRepo(webhook.payload);
|
|
240
|
+
if (!repo || repo.toLowerCase() !== handler.repo.toLowerCase())
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
if (handler.branch) {
|
|
244
|
+
const branches = payloadBranches(webhook.event, webhook.payload);
|
|
245
|
+
if (!branches.some((b) => b === handler.branch))
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
if (handler.label) {
|
|
249
|
+
const expected = handler.label.toLowerCase();
|
|
250
|
+
if (!githubLabels(webhook.payload).some((name) => name.toLowerCase() === expected))
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Build the variable-substitution context for a webhook. Linear events expose
|
|
257
|
+
* `issue` and `updatedFrom`; GitHub events expose `repository`, `pull_request`,
|
|
258
|
+
* and `issue`.
|
|
259
|
+
*/
|
|
260
|
+
export function buildWebhookContext(webhook) {
|
|
261
|
+
const action = webhook.source === 'github'
|
|
262
|
+
? githubAction(webhook.payload) ?? undefined
|
|
263
|
+
: linearAction(webhook.payload) ?? undefined;
|
|
264
|
+
if (webhook.source === 'linear') {
|
|
265
|
+
return {
|
|
266
|
+
source: webhook.source,
|
|
267
|
+
event: webhook.event,
|
|
268
|
+
action,
|
|
269
|
+
issue: webhook.payload.data,
|
|
270
|
+
updatedFrom: webhook.payload.updatedFrom,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
source: webhook.source,
|
|
275
|
+
event: webhook.event,
|
|
276
|
+
action,
|
|
277
|
+
repository: webhook.payload.repository,
|
|
278
|
+
pull_request: webhook.payload.pull_request,
|
|
279
|
+
issue: webhook.payload.issue,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function defaultExecCommand(command) {
|
|
283
|
+
return new Promise((resolve) => {
|
|
284
|
+
exec(command, (error, stdout, stderr) => {
|
|
285
|
+
const output = stdout + stderr;
|
|
286
|
+
if (error) {
|
|
287
|
+
resolve({ exitCode: typeof error.code === 'number' ? error.code : 1, output });
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
resolve({ exitCode: 0, output });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function dispatchDefault(config) {
|
|
296
|
+
// Import lazily so the runner module (heavy) is only loaded when a handler
|
|
297
|
+
// actually needs to spawn. Tests inject dispatch functions, so this path is
|
|
298
|
+
// not exercised in unit tests.
|
|
299
|
+
return import('../runner.js').then((m) => m.executeJobDetached(config));
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Execute a handler's action. Runs the configured agent/workflow/command or
|
|
303
|
+
* delegates to a routine, substituting `{{...}}` placeholders from the webhook
|
|
304
|
+
* context.
|
|
305
|
+
*/
|
|
306
|
+
export async function executeHandler(handler, webhook, opts = {}) {
|
|
307
|
+
const context = buildWebhookContext(webhook);
|
|
308
|
+
const base = { handlerName: handler.name };
|
|
309
|
+
emit('webhook.handler.start', {
|
|
310
|
+
source: webhook.source,
|
|
311
|
+
event: webhook.event,
|
|
312
|
+
handlerName: handler.name,
|
|
313
|
+
});
|
|
314
|
+
try {
|
|
315
|
+
const result = await executeHandlerAction(handler, webhook, context, opts);
|
|
316
|
+
emit('webhook.handler.end', {
|
|
317
|
+
source: webhook.source,
|
|
318
|
+
event: webhook.event,
|
|
319
|
+
handlerName: handler.name,
|
|
320
|
+
status: 'success',
|
|
321
|
+
...result,
|
|
322
|
+
});
|
|
323
|
+
return { ...base, ...result };
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
const error = err.message;
|
|
327
|
+
emit('webhook.handler.end', {
|
|
328
|
+
source: webhook.source,
|
|
329
|
+
event: webhook.event,
|
|
330
|
+
handlerName: handler.name,
|
|
331
|
+
status: 'error',
|
|
332
|
+
error,
|
|
333
|
+
});
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async function executeHandlerAction(handler, webhook, context, opts) {
|
|
338
|
+
const substitutedPrompt = handler.run?.prompt ? substituteWebhookPrompt(handler.run.prompt, context) : '';
|
|
339
|
+
// Resolved once so a fleet pick can't differ between the two dispatch paths.
|
|
340
|
+
const hostFields = resolveHandlerHost(handler.host);
|
|
341
|
+
if (handler.run?.agent || handler.run?.workflow) {
|
|
342
|
+
const config = {
|
|
343
|
+
name: handler.name,
|
|
344
|
+
mode: 'auto',
|
|
345
|
+
effort: 'auto',
|
|
346
|
+
timeout: '10m',
|
|
347
|
+
enabled: true,
|
|
348
|
+
prompt: substitutedPrompt,
|
|
349
|
+
...(handler.run.agent ? { agent: handler.run.agent } : { workflow: handler.run.workflow }),
|
|
350
|
+
...(handler.devices ? { devices: handler.devices } : {}),
|
|
351
|
+
...(handler.run.env ? { env: handler.run.env } : {}),
|
|
352
|
+
...(hostFields.host ? { host: hostFields.host } : {}),
|
|
353
|
+
...(hostFields.hostStrategy ? { hostStrategy: hostFields.hostStrategy } : {}),
|
|
354
|
+
};
|
|
355
|
+
const dispatch = handler.run.agent
|
|
356
|
+
? (opts.dispatchAgent ?? dispatchDefault)
|
|
357
|
+
: (opts.dispatchWorkflow ?? dispatchDefault);
|
|
358
|
+
const meta = await dispatch(config);
|
|
359
|
+
return { runId: meta.runId };
|
|
360
|
+
}
|
|
361
|
+
if (handler.run?.command) {
|
|
362
|
+
assertShellSubstitutionSupported(handler.run.command);
|
|
363
|
+
const command = substituteWebhookCommand(handler.run.command, context);
|
|
364
|
+
const exec = opts.execCommand ?? defaultExecCommand;
|
|
365
|
+
const { exitCode, output } = await exec(command);
|
|
366
|
+
return { exitCode, output };
|
|
367
|
+
}
|
|
368
|
+
if (handler.routine) {
|
|
369
|
+
const routine = readJob(handler.routine);
|
|
370
|
+
if (!routine)
|
|
371
|
+
throw new Error(`routine '${handler.routine}' not found`);
|
|
372
|
+
const config = {
|
|
373
|
+
...routine,
|
|
374
|
+
prompt: substituteWebhookPrompt(routine.prompt, context),
|
|
375
|
+
...(handler.devices ? { devices: handler.devices } : {}),
|
|
376
|
+
...(hostFields.host ? { host: hostFields.host } : {}),
|
|
377
|
+
...(hostFields.hostStrategy ? { hostStrategy: hostFields.hostStrategy } : {}),
|
|
378
|
+
};
|
|
379
|
+
const dispatch = opts.dispatchRoutine ?? dispatchDefault;
|
|
380
|
+
const meta = await dispatch(config);
|
|
381
|
+
return { runId: meta.runId };
|
|
382
|
+
}
|
|
383
|
+
throw new Error(`handler '${handler.name}' has no run or routine action`);
|
|
384
|
+
}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as http from 'http';
|
|
15
15
|
import type { IncomingHttpHeaders } from 'http';
|
|
16
|
-
import type { JobConfig, RunMeta } from '../routines.js';
|
|
16
|
+
import type { JobConfig, RunMeta, WebhookContext } from '../routines.js';
|
|
17
|
+
import { type FiredHandler } from './handlers.js';
|
|
17
18
|
export type WebhookSource = 'github' | 'linear';
|
|
18
19
|
export interface IncomingWebhook {
|
|
19
20
|
/** Delivery source, derived from `/hooks/<source>` or one-shot command flags. */
|
|
@@ -41,6 +42,11 @@ export declare function webhookRepo(payload: Record<string, unknown>): string |
|
|
|
41
42
|
* - issue_comment: no branch (comments aren't branch-scoped)
|
|
42
43
|
*/
|
|
43
44
|
export declare function webhookBranches(event: string, payload: Record<string, unknown>): string[];
|
|
45
|
+
export declare function linearAction(payload: Record<string, unknown>): string | null;
|
|
46
|
+
export declare function linearTeamKey(payload: Record<string, unknown>): string | null;
|
|
47
|
+
export declare function linearLabels(payload: Record<string, unknown>): string[];
|
|
48
|
+
export declare function githubAction(payload: Record<string, unknown>): string | null;
|
|
49
|
+
export declare function githubLabels(payload: Record<string, unknown>): string[];
|
|
44
50
|
/** True when a single job's trigger matches the given webhook. Pure. */
|
|
45
51
|
export declare function jobMatchesWebhook(job: JobConfig, webhook: IncomingWebhook): boolean;
|
|
46
52
|
/**
|
|
@@ -63,6 +69,8 @@ export interface FireWebhookOptions {
|
|
|
63
69
|
skipJobNames?: ReadonlySet<string>;
|
|
64
70
|
/** Called immediately after a single matched job dispatch succeeds. */
|
|
65
71
|
onJobFired?: (job: JobConfig, fired: FiredJob) => void;
|
|
72
|
+
/** Optional webhook context used to expand `{{...}}` placeholders in prompts. */
|
|
73
|
+
context?: WebhookContext;
|
|
66
74
|
}
|
|
67
75
|
/** Result of firing one matched job. */
|
|
68
76
|
export interface FiredJob {
|
|
@@ -124,7 +132,7 @@ export interface WebhookServerOptions {
|
|
|
124
132
|
/** Override the fire options (mainly for tests). */
|
|
125
133
|
fire?: FireWebhookOptions;
|
|
126
134
|
/** Called after each delivery is handled (mainly for tests/observability). */
|
|
127
|
-
onDelivery?: (webhook: IncomingWebhook, fired: FiredJob[]) => void;
|
|
135
|
+
onDelivery?: (webhook: IncomingWebhook, fired: FiredJob[], handlers: FiredHandler[]) => void;
|
|
128
136
|
deliveryStore?: DeliveryStore;
|
|
129
137
|
rateLimiter?: RateLimiter;
|
|
130
138
|
rateLimitPerMinute?: number;
|
|
@@ -15,8 +15,10 @@ import * as crypto from 'crypto';
|
|
|
15
15
|
import * as fs from 'fs';
|
|
16
16
|
import * as http from 'http';
|
|
17
17
|
import * as path from 'path';
|
|
18
|
-
import { jobRunsOnThisDevice, listJobs } from '../routines.js';
|
|
18
|
+
import { jobRunsOnThisDevice, listJobs, substituteWebhookPrompt } from '../routines.js';
|
|
19
19
|
import { executeJobDetached } from '../runner.js';
|
|
20
|
+
import { emit } from '../events.js';
|
|
21
|
+
import { listHandlers, handlerMatchesWebhook, executeHandler, buildWebhookContext, } from './handlers.js';
|
|
20
22
|
/** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
|
|
21
23
|
export function webhookRepo(payload) {
|
|
22
24
|
const repo = payload?.repository;
|
|
@@ -62,10 +64,10 @@ export function webhookBranches(event, payload) {
|
|
|
62
64
|
}
|
|
63
65
|
return [...branches];
|
|
64
66
|
}
|
|
65
|
-
function linearAction(payload) {
|
|
67
|
+
export function linearAction(payload) {
|
|
66
68
|
return typeof payload.action === 'string' ? payload.action : null;
|
|
67
69
|
}
|
|
68
|
-
function linearTeamKey(payload) {
|
|
70
|
+
export function linearTeamKey(payload) {
|
|
69
71
|
const data = payload.data;
|
|
70
72
|
const identifier = data?.identifier;
|
|
71
73
|
if (typeof identifier === 'string') {
|
|
@@ -76,7 +78,7 @@ function linearTeamKey(payload) {
|
|
|
76
78
|
const team = data?.team;
|
|
77
79
|
return typeof team?.key === 'string' ? team.key : null;
|
|
78
80
|
}
|
|
79
|
-
function linearLabels(payload) {
|
|
81
|
+
export function linearLabels(payload) {
|
|
80
82
|
// Linear webhook bodies flatten list relations: an Issue event carries
|
|
81
83
|
// `data.labels` as a flat array of label objects (`[{ id, name, color }]`),
|
|
82
84
|
// NOT the `{ nodes: [...] }` connection shape returned by the GraphQL API.
|
|
@@ -87,10 +89,10 @@ function linearLabels(payload) {
|
|
|
87
89
|
.map((n) => n.name)
|
|
88
90
|
.filter((n) => typeof n === 'string' && n.length > 0);
|
|
89
91
|
}
|
|
90
|
-
function githubAction(payload) {
|
|
92
|
+
export function githubAction(payload) {
|
|
91
93
|
return typeof payload.action === 'string' ? payload.action : null;
|
|
92
94
|
}
|
|
93
|
-
function githubLabels(payload) {
|
|
95
|
+
export function githubLabels(payload) {
|
|
94
96
|
const names = new Set();
|
|
95
97
|
const add = (value) => {
|
|
96
98
|
if (typeof value === 'string' && value.length > 0)
|
|
@@ -148,6 +150,18 @@ function linearTriggerMatches(trigger, webhook) {
|
|
|
148
150
|
if (!linearLabels(webhook.payload).some((name) => name.toLowerCase() === expected))
|
|
149
151
|
return false;
|
|
150
152
|
}
|
|
153
|
+
if (trigger.stateTo) {
|
|
154
|
+
const data = webhook.payload.data;
|
|
155
|
+
const current = data?.state?.name;
|
|
156
|
+
if (current !== trigger.stateTo)
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (trigger.stateFrom) {
|
|
160
|
+
const updatedFrom = webhook.payload.updatedFrom;
|
|
161
|
+
const previous = updatedFrom?.state?.name;
|
|
162
|
+
if (previous !== trigger.stateFrom)
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
151
165
|
return true;
|
|
152
166
|
}
|
|
153
167
|
/** True when a single job's trigger matches the given webhook. Pure. */
|
|
@@ -193,8 +207,12 @@ export async function fireWebhookJobs(webhook, options = {}) {
|
|
|
193
207
|
for (const job of matched) {
|
|
194
208
|
if (skipJobNames.has(job.name))
|
|
195
209
|
continue;
|
|
210
|
+
emit('webhook.matched', { source: webhook.source, event: webhook.event, jobName: job.name });
|
|
196
211
|
try {
|
|
197
|
-
const
|
|
212
|
+
const jobToDispatch = options.context
|
|
213
|
+
? { ...job, prompt: substituteWebhookPrompt(job.prompt, options.context) }
|
|
214
|
+
: job;
|
|
215
|
+
const meta = await dispatch(jobToDispatch);
|
|
198
216
|
const firedJob = { jobName: job.name, runId: meta.runId };
|
|
199
217
|
fired.push(firedJob);
|
|
200
218
|
options.onJobFired?.(job, firedJob);
|
|
@@ -419,6 +437,7 @@ export function startWebhookServer(options) {
|
|
|
419
437
|
}
|
|
420
438
|
const secret = options.secrets[source];
|
|
421
439
|
if (!secret) {
|
|
440
|
+
emit('webhook.rejected', { source, reason: 'missing webhook secret' });
|
|
422
441
|
res.writeHead(503, { 'content-type': 'application/json' });
|
|
423
442
|
res.end(JSON.stringify({ ok: false, error: `missing ${source} webhook secret` }));
|
|
424
443
|
return;
|
|
@@ -430,12 +449,14 @@ export function startWebhookServer(options) {
|
|
|
430
449
|
// shed this load on its own.
|
|
431
450
|
const ip = req.socket.remoteAddress ?? 'unknown';
|
|
432
451
|
if (!ipRateLimiter.take(ip)) {
|
|
452
|
+
emit('webhook.rejected', { source, reason: 'ip rate limit exceeded' });
|
|
433
453
|
res.writeHead(429, { 'content-type': 'application/json' });
|
|
434
454
|
res.end(JSON.stringify({ ok: false, error: 'rate limit exceeded' }));
|
|
435
455
|
return;
|
|
436
456
|
}
|
|
437
457
|
const declaredLength = Number.parseInt(header(req.headers, 'content-length') ?? '', 10);
|
|
438
458
|
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
|
|
459
|
+
emit('webhook.rejected', { source, reason: 'payload too large' });
|
|
439
460
|
res.writeHead(413, { 'content-type': 'application/json' });
|
|
440
461
|
res.end(JSON.stringify({ ok: false, error: `payload exceeds ${maxBodyBytes} bytes` }));
|
|
441
462
|
return;
|
|
@@ -446,45 +467,78 @@ export function startWebhookServer(options) {
|
|
|
446
467
|
? verifyGithubSignature(req.headers, rawBody, secret)
|
|
447
468
|
: verifyLinearSignature(req.headers, rawBody, secret);
|
|
448
469
|
if (!valid) {
|
|
470
|
+
emit('webhook.rejected', { source, reason: 'invalid signature' });
|
|
449
471
|
res.writeHead(401, { 'content-type': 'application/json' });
|
|
450
472
|
res.end(JSON.stringify({ ok: false, error: 'invalid signature' }));
|
|
451
473
|
return;
|
|
452
474
|
}
|
|
453
475
|
const id = deliveryId(source, req.headers, rawBody);
|
|
476
|
+
const event = source === 'github' ? (header(req.headers, 'x-github-event') ?? '') : '';
|
|
477
|
+
emit('webhook.received', { source, event, deliveryId: id });
|
|
454
478
|
if (deliveryStore.seen(id)) {
|
|
455
479
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
456
480
|
res.end(JSON.stringify({ ok: true, duplicate: true, fired: [] }));
|
|
457
481
|
return;
|
|
458
482
|
}
|
|
459
483
|
const payload = rawBody.length > 0 ? JSON.parse(rawBody.toString('utf-8')) : {};
|
|
484
|
+
const webhookEvent = source === 'github' ? event : String(payload.type ?? '');
|
|
460
485
|
if (source === 'linear' && !verifyLinearTimestamp(payload)) {
|
|
486
|
+
emit('webhook.rejected', { source, event: webhookEvent, deliveryId: id, reason: 'stale linear webhook timestamp' });
|
|
461
487
|
res.writeHead(401, { 'content-type': 'application/json' });
|
|
462
488
|
res.end(JSON.stringify({ ok: false, error: 'stale linear webhook timestamp' }));
|
|
463
489
|
return;
|
|
464
490
|
}
|
|
465
491
|
if (!rateLimiter.take(source)) {
|
|
492
|
+
emit('webhook.rejected', { source, event: webhookEvent, deliveryId: id, reason: 'rate limit exceeded' });
|
|
466
493
|
res.writeHead(429, { 'content-type': 'application/json' });
|
|
467
494
|
res.end(JSON.stringify({ ok: false, error: 'rate limit exceeded' }));
|
|
468
495
|
return;
|
|
469
496
|
}
|
|
470
497
|
const webhook = {
|
|
471
498
|
source,
|
|
472
|
-
event:
|
|
499
|
+
event: webhookEvent,
|
|
473
500
|
payload,
|
|
474
501
|
};
|
|
502
|
+
emit('webhook.authorized', { source, event: webhookEvent, deliveryId: id });
|
|
503
|
+
const context = buildWebhookContext(webhook);
|
|
475
504
|
const fireOptions = options.fire ?? {};
|
|
476
|
-
const
|
|
505
|
+
const firedJobs = await fireWebhookJobs(webhook, {
|
|
477
506
|
...fireOptions,
|
|
507
|
+
context,
|
|
478
508
|
skipJobNames: deliveryStore.completedJobs(id),
|
|
479
509
|
onJobFired: (job, firedJob) => {
|
|
510
|
+
emit('webhook.fired', { source, event: webhookEvent, deliveryId: id, jobName: job.name, runId: firedJob.runId });
|
|
480
511
|
deliveryStore.markJob(id, job.name);
|
|
481
512
|
fireOptions.onJobFired?.(job, firedJob);
|
|
482
513
|
},
|
|
483
514
|
});
|
|
515
|
+
const handlers = listHandlers();
|
|
516
|
+
const matchedHandlers = handlers.filter((handler) => handlerMatchesWebhook(handler, webhook));
|
|
517
|
+
const firedHandlers = [];
|
|
518
|
+
const handlerErrors = [];
|
|
519
|
+
await Promise.allSettled(matchedHandlers.map(async (handler) => {
|
|
520
|
+
if (deliveryStore.completedJobs(id).has(handler.name))
|
|
521
|
+
return;
|
|
522
|
+
emit('webhook.matched', { source, event: webhookEvent, deliveryId: id, handlerName: handler.name });
|
|
523
|
+
try {
|
|
524
|
+
const result = await executeHandler(handler, webhook);
|
|
525
|
+
deliveryStore.markJob(id, handler.name);
|
|
526
|
+
firedHandlers.push(result);
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
handlerErrors.push({ handlerName: handler.name, error: err.message });
|
|
530
|
+
}
|
|
531
|
+
}));
|
|
484
532
|
deliveryStore.mark(id);
|
|
485
|
-
options.onDelivery?.(webhook,
|
|
533
|
+
options.onDelivery?.(webhook, firedJobs, firedHandlers);
|
|
486
534
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
487
|
-
res.end(JSON.stringify({
|
|
535
|
+
res.end(JSON.stringify({
|
|
536
|
+
ok: true,
|
|
537
|
+
fired: firedJobs.map((f) => f.jobName),
|
|
538
|
+
runs: firedJobs,
|
|
539
|
+
handlers: firedHandlers,
|
|
540
|
+
...(handlerErrors.length > 0 ? { handlerErrors } : {}),
|
|
541
|
+
}));
|
|
488
542
|
}
|
|
489
543
|
catch (err) {
|
|
490
544
|
res.writeHead(400, { 'content-type': 'application/json' });
|