clementine-agent 1.0.87 → 1.0.88
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/agent/webhook-actions.d.ts +116 -0
- package/dist/agent/webhook-actions.js +240 -0
- package/dist/cli/dashboard.js +90 -0
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Webhook → action dispatch.
|
|
3
|
+
*
|
|
4
|
+
* External services (Salesforce, GitHub, calendar, email) POST events
|
|
5
|
+
* to /webhook-action/:source. This module turns those events into
|
|
6
|
+
* agentic actions:
|
|
7
|
+
*
|
|
8
|
+
* - `wake_agent` → write wake sentinel; agent ticks within ~3s
|
|
9
|
+
* - `start_background_task` → create a pending task; cron-scheduler picks
|
|
10
|
+
* it up and runs unleashed
|
|
11
|
+
*
|
|
12
|
+
* Configuration: ~/.clementine/webhook-actions.json
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* "hooks": [
|
|
16
|
+
* {
|
|
17
|
+
* "source": "github",
|
|
18
|
+
* "secretEnv": "GITHUB_WEBHOOK_SECRET",
|
|
19
|
+
* "on": [
|
|
20
|
+
* {
|
|
21
|
+
* "match": { "action": "opened", "pull_request": "*" },
|
|
22
|
+
* "do": "wake_agent",
|
|
23
|
+
* "agent": "ross-the-sdr",
|
|
24
|
+
* "reason": "PR opened — review needed"
|
|
25
|
+
* }
|
|
26
|
+
* ]
|
|
27
|
+
* }
|
|
28
|
+
* ]
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* Match values: literal strings/numbers/booleans for exact match, or "*"
|
|
32
|
+
* to require the field be present (any value, non-null/undefined). Dot
|
|
33
|
+
* notation supported for nested fields ("payload.user.id"). All conditions
|
|
34
|
+
* in a `match` block must hold (AND).
|
|
35
|
+
*
|
|
36
|
+
* Templating: `prompt` and `reason` strings can interpolate payload
|
|
37
|
+
* fields with `{{ field.path }}`. Missing fields render as empty string.
|
|
38
|
+
*
|
|
39
|
+
* Every dispatched event is logged to ~/.clementine/webhook-actions/log.jsonl
|
|
40
|
+
* (rotated at 1MB / 1000 lines, 30-day retention).
|
|
41
|
+
*/
|
|
42
|
+
export type WebhookActionVerb = 'wake_agent' | 'start_background_task';
|
|
43
|
+
export interface WebhookActionRule {
|
|
44
|
+
/** Field/value conditions, all must match. Use "*" for "field present". */
|
|
45
|
+
match?: Record<string, string | number | boolean>;
|
|
46
|
+
do: WebhookActionVerb;
|
|
47
|
+
agent: string;
|
|
48
|
+
/** For wake_agent — short reason annotated on the wake sentinel. */
|
|
49
|
+
reason?: string;
|
|
50
|
+
/** For start_background_task — the prompt template. Supports {{ field.path }}. */
|
|
51
|
+
prompt?: string;
|
|
52
|
+
/** For start_background_task — wall-clock cap. Default 30. */
|
|
53
|
+
maxMinutes?: number;
|
|
54
|
+
}
|
|
55
|
+
export interface WebhookActionSource {
|
|
56
|
+
source: string;
|
|
57
|
+
/** Env var holding the HMAC secret. Required unless `secret` is set inline. */
|
|
58
|
+
secretEnv?: string;
|
|
59
|
+
/** Inline secret (for tests / local-only setups). Prefer secretEnv in prod. */
|
|
60
|
+
secret?: string;
|
|
61
|
+
on: WebhookActionRule[];
|
|
62
|
+
}
|
|
63
|
+
export interface WebhookActionConfig {
|
|
64
|
+
hooks: WebhookActionSource[];
|
|
65
|
+
}
|
|
66
|
+
export interface DispatchResult {
|
|
67
|
+
matched: number;
|
|
68
|
+
dispatched: number;
|
|
69
|
+
errors: string[];
|
|
70
|
+
log: Array<{
|
|
71
|
+
rule: WebhookActionRule;
|
|
72
|
+
ok: boolean;
|
|
73
|
+
message: string;
|
|
74
|
+
}>;
|
|
75
|
+
}
|
|
76
|
+
export declare function loadWebhookActionConfig(opts?: {
|
|
77
|
+
configPath?: string;
|
|
78
|
+
}): WebhookActionConfig;
|
|
79
|
+
export declare function getSourceConfig(source: string, opts?: {
|
|
80
|
+
configPath?: string;
|
|
81
|
+
}): WebhookActionSource | null;
|
|
82
|
+
/** All match conditions hold. "*" means "field is present (non-null/undefined)". */
|
|
83
|
+
export declare function ruleMatches(rule: WebhookActionRule, payload: unknown): boolean;
|
|
84
|
+
/** Replace {{ dot.path }} in a template with payload values. Missing → "". */
|
|
85
|
+
export declare function renderTemplate(template: string, payload: unknown): string;
|
|
86
|
+
/**
|
|
87
|
+
* Match the payload against every rule in the source config and dispatch
|
|
88
|
+
* all matches. Each rule is independent — multiple matches all fire.
|
|
89
|
+
*/
|
|
90
|
+
export declare function dispatchWebhookActions(source: string, payload: unknown, opts?: {
|
|
91
|
+
configPath?: string;
|
|
92
|
+
baseDir?: string;
|
|
93
|
+
}): DispatchResult;
|
|
94
|
+
export interface WebhookEventLogEntry {
|
|
95
|
+
timestamp: string;
|
|
96
|
+
source: string;
|
|
97
|
+
verified: boolean;
|
|
98
|
+
matched: number;
|
|
99
|
+
dispatched: number;
|
|
100
|
+
errors: string[];
|
|
101
|
+
payloadPreview: string;
|
|
102
|
+
}
|
|
103
|
+
export declare function logWebhookEvent(entry: WebhookEventLogEntry, opts?: {
|
|
104
|
+
logPath?: string;
|
|
105
|
+
logDir?: string;
|
|
106
|
+
}): void;
|
|
107
|
+
export declare function recentWebhookEvents(limit?: number, opts?: {
|
|
108
|
+
logPath?: string;
|
|
109
|
+
}): WebhookEventLogEntry[];
|
|
110
|
+
export declare const _internals: {
|
|
111
|
+
CONFIG_PATH: string;
|
|
112
|
+
LOG_PATH: string;
|
|
113
|
+
LOG_DIR: string;
|
|
114
|
+
WAKE_DIR: string;
|
|
115
|
+
};
|
|
116
|
+
//# sourceMappingURL=webhook-actions.d.ts.map
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Webhook → action dispatch.
|
|
3
|
+
*
|
|
4
|
+
* External services (Salesforce, GitHub, calendar, email) POST events
|
|
5
|
+
* to /webhook-action/:source. This module turns those events into
|
|
6
|
+
* agentic actions:
|
|
7
|
+
*
|
|
8
|
+
* - `wake_agent` → write wake sentinel; agent ticks within ~3s
|
|
9
|
+
* - `start_background_task` → create a pending task; cron-scheduler picks
|
|
10
|
+
* it up and runs unleashed
|
|
11
|
+
*
|
|
12
|
+
* Configuration: ~/.clementine/webhook-actions.json
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* "hooks": [
|
|
16
|
+
* {
|
|
17
|
+
* "source": "github",
|
|
18
|
+
* "secretEnv": "GITHUB_WEBHOOK_SECRET",
|
|
19
|
+
* "on": [
|
|
20
|
+
* {
|
|
21
|
+
* "match": { "action": "opened", "pull_request": "*" },
|
|
22
|
+
* "do": "wake_agent",
|
|
23
|
+
* "agent": "ross-the-sdr",
|
|
24
|
+
* "reason": "PR opened — review needed"
|
|
25
|
+
* }
|
|
26
|
+
* ]
|
|
27
|
+
* }
|
|
28
|
+
* ]
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* Match values: literal strings/numbers/booleans for exact match, or "*"
|
|
32
|
+
* to require the field be present (any value, non-null/undefined). Dot
|
|
33
|
+
* notation supported for nested fields ("payload.user.id"). All conditions
|
|
34
|
+
* in a `match` block must hold (AND).
|
|
35
|
+
*
|
|
36
|
+
* Templating: `prompt` and `reason` strings can interpolate payload
|
|
37
|
+
* fields with `{{ field.path }}`. Missing fields render as empty string.
|
|
38
|
+
*
|
|
39
|
+
* Every dispatched event is logged to ~/.clementine/webhook-actions/log.jsonl
|
|
40
|
+
* (rotated at 1MB / 1000 lines, 30-day retention).
|
|
41
|
+
*/
|
|
42
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { BASE_DIR } from '../config.js';
|
|
45
|
+
import { createBackgroundTask } from './background-tasks.js';
|
|
46
|
+
// ── Storage paths ────────────────────────────────────────────────────
|
|
47
|
+
const CONFIG_PATH = path.join(BASE_DIR, 'webhook-actions.json');
|
|
48
|
+
const LOG_DIR = path.join(BASE_DIR, 'webhook-actions');
|
|
49
|
+
const LOG_PATH = path.join(LOG_DIR, 'log.jsonl');
|
|
50
|
+
const WAKE_DIR = path.join(BASE_DIR, 'heartbeat', 'wake');
|
|
51
|
+
const LOG_MAX_BYTES = 1_000_000;
|
|
52
|
+
const LOG_MAX_LINES = 1000;
|
|
53
|
+
const LOG_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
54
|
+
// ── Config I/O ───────────────────────────────────────────────────────
|
|
55
|
+
export function loadWebhookActionConfig(opts) {
|
|
56
|
+
const file = opts?.configPath ?? CONFIG_PATH;
|
|
57
|
+
if (!existsSync(file))
|
|
58
|
+
return { hooks: [] };
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(readFileSync(file, 'utf-8'));
|
|
61
|
+
if (!Array.isArray(raw.hooks))
|
|
62
|
+
return { hooks: [] };
|
|
63
|
+
return { hooks: raw.hooks };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { hooks: [] };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function getSourceConfig(source, opts) {
|
|
70
|
+
return loadWebhookActionConfig(opts).hooks.find((h) => h.source === source) ?? null;
|
|
71
|
+
}
|
|
72
|
+
// ── Matcher ──────────────────────────────────────────────────────────
|
|
73
|
+
/** Read a dot-path from a JSON-ish object. Returns undefined if any segment is missing. */
|
|
74
|
+
function readPath(obj, dotPath) {
|
|
75
|
+
if (obj == null || typeof obj !== 'object')
|
|
76
|
+
return undefined;
|
|
77
|
+
let cursor = obj;
|
|
78
|
+
for (const segment of dotPath.split('.')) {
|
|
79
|
+
if (cursor == null || typeof cursor !== 'object')
|
|
80
|
+
return undefined;
|
|
81
|
+
cursor = cursor[segment];
|
|
82
|
+
}
|
|
83
|
+
return cursor;
|
|
84
|
+
}
|
|
85
|
+
/** All match conditions hold. "*" means "field is present (non-null/undefined)". */
|
|
86
|
+
export function ruleMatches(rule, payload) {
|
|
87
|
+
const conds = rule.match;
|
|
88
|
+
if (!conds || Object.keys(conds).length === 0)
|
|
89
|
+
return true; // empty match = match-all
|
|
90
|
+
for (const [pathSpec, expected] of Object.entries(conds)) {
|
|
91
|
+
const actual = readPath(payload, pathSpec);
|
|
92
|
+
if (expected === '*') {
|
|
93
|
+
if (actual === undefined || actual === null)
|
|
94
|
+
return false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Loose equality: 1 == "1", true == "true". Real users put strings in JSON; loose is friendlier.
|
|
98
|
+
// eslint-disable-next-line eqeqeq
|
|
99
|
+
if (actual == expected)
|
|
100
|
+
continue;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
/** Replace {{ dot.path }} in a template with payload values. Missing → "". */
|
|
106
|
+
export function renderTemplate(template, payload) {
|
|
107
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, dotPath) => {
|
|
108
|
+
const v = readPath(payload, dotPath);
|
|
109
|
+
if (v == null)
|
|
110
|
+
return '';
|
|
111
|
+
if (typeof v === 'object')
|
|
112
|
+
return JSON.stringify(v);
|
|
113
|
+
return String(v);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function wakeSentinelPath(slug, baseDir) {
|
|
117
|
+
return path.join(baseDir, 'heartbeat', 'wake', `${slug}.json`);
|
|
118
|
+
}
|
|
119
|
+
function dispatchOne(rule, source, payload, env) {
|
|
120
|
+
const baseDir = env.baseDir ?? BASE_DIR;
|
|
121
|
+
if (rule.do === 'wake_agent') {
|
|
122
|
+
try {
|
|
123
|
+
const wakeDir = path.join(baseDir, 'heartbeat', 'wake');
|
|
124
|
+
mkdirSync(wakeDir, { recursive: true });
|
|
125
|
+
const reason = rule.reason ? renderTemplate(rule.reason, payload) : `webhook:${source}`;
|
|
126
|
+
const sentinel = {
|
|
127
|
+
targetSlug: rule.agent,
|
|
128
|
+
fromSlug: `webhook:${source}`,
|
|
129
|
+
reason: reason.slice(0, 200),
|
|
130
|
+
requestedAt: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
writeFileSync(wakeSentinelPath(rule.agent, baseDir), JSON.stringify(sentinel, null, 2));
|
|
133
|
+
return { ok: true, message: `Woke ${rule.agent} (${reason})` };
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
return { ok: false, message: `wake_agent failed: ${String(err).slice(0, 200)}` };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (rule.do === 'start_background_task') {
|
|
140
|
+
if (!rule.prompt) {
|
|
141
|
+
return { ok: false, message: 'start_background_task: rule has no `prompt` template' };
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const prompt = renderTemplate(rule.prompt, payload);
|
|
145
|
+
const task = createBackgroundTask({
|
|
146
|
+
fromAgent: rule.agent,
|
|
147
|
+
prompt,
|
|
148
|
+
maxMinutes: rule.maxMinutes ?? 30,
|
|
149
|
+
}, env.baseDir ? { dir: path.join(env.baseDir, 'background-tasks') } : undefined);
|
|
150
|
+
return { ok: true, message: `Queued background task ${task.id} for ${rule.agent}` };
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
return { ok: false, message: `start_background_task failed: ${String(err).slice(0, 200)}` };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Exhaustiveness check — should never hit at runtime if types are honored.
|
|
157
|
+
return { ok: false, message: `Unknown action verb: ${rule.do}` };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Match the payload against every rule in the source config and dispatch
|
|
161
|
+
* all matches. Each rule is independent — multiple matches all fire.
|
|
162
|
+
*/
|
|
163
|
+
export function dispatchWebhookActions(source, payload, opts) {
|
|
164
|
+
const cfg = getSourceConfig(source, opts);
|
|
165
|
+
const result = { matched: 0, dispatched: 0, errors: [], log: [] };
|
|
166
|
+
if (!cfg) {
|
|
167
|
+
result.errors.push(`No webhook-action config for source "${source}"`);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
for (const rule of cfg.on) {
|
|
171
|
+
if (!ruleMatches(rule, payload))
|
|
172
|
+
continue;
|
|
173
|
+
result.matched++;
|
|
174
|
+
const r = dispatchOne(rule, source, payload, { baseDir: opts?.baseDir });
|
|
175
|
+
result.log.push({ rule, ok: r.ok, message: r.message });
|
|
176
|
+
if (r.ok)
|
|
177
|
+
result.dispatched++;
|
|
178
|
+
else
|
|
179
|
+
result.errors.push(r.message);
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
function rotateLogIfNeeded(opts) {
|
|
184
|
+
const file = opts?.logPath ?? LOG_PATH;
|
|
185
|
+
try {
|
|
186
|
+
if (!existsSync(file))
|
|
187
|
+
return;
|
|
188
|
+
const { size } = statSync(file);
|
|
189
|
+
if (size <= LOG_MAX_BYTES)
|
|
190
|
+
return;
|
|
191
|
+
const lines = readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean);
|
|
192
|
+
if (lines.length <= LOG_MAX_LINES)
|
|
193
|
+
return;
|
|
194
|
+
const cutoff = Date.now() - LOG_MAX_AGE_MS;
|
|
195
|
+
const kept = [];
|
|
196
|
+
for (const line of lines.slice(-LOG_MAX_LINES)) {
|
|
197
|
+
try {
|
|
198
|
+
const e = JSON.parse(line);
|
|
199
|
+
const ts = new Date(e.timestamp).getTime();
|
|
200
|
+
if (Number.isFinite(ts) && ts >= cutoff)
|
|
201
|
+
kept.push(line);
|
|
202
|
+
}
|
|
203
|
+
catch { /* drop malformed */ }
|
|
204
|
+
}
|
|
205
|
+
writeFileSync(file, kept.join('\n') + (kept.length ? '\n' : ''));
|
|
206
|
+
}
|
|
207
|
+
catch { /* non-fatal */ }
|
|
208
|
+
}
|
|
209
|
+
export function logWebhookEvent(entry, opts) {
|
|
210
|
+
try {
|
|
211
|
+
const dir = opts?.logDir ?? LOG_DIR;
|
|
212
|
+
const file = opts?.logPath ?? LOG_PATH;
|
|
213
|
+
mkdirSync(dir, { recursive: true });
|
|
214
|
+
appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
215
|
+
setImmediate(() => rotateLogIfNeeded(opts));
|
|
216
|
+
}
|
|
217
|
+
catch { /* non-fatal */ }
|
|
218
|
+
}
|
|
219
|
+
export function recentWebhookEvents(limit = 50, opts) {
|
|
220
|
+
try {
|
|
221
|
+
const file = opts?.logPath ?? LOG_PATH;
|
|
222
|
+
if (!existsSync(file))
|
|
223
|
+
return [];
|
|
224
|
+
const lines = readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean);
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const line of lines.slice(-limit).reverse()) {
|
|
227
|
+
try {
|
|
228
|
+
out.push(JSON.parse(line));
|
|
229
|
+
}
|
|
230
|
+
catch { /* skip */ }
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// ── Test-only ────────────────────────────────────────────────────────
|
|
239
|
+
export const _internals = { CONFIG_PATH, LOG_PATH, LOG_DIR, WAKE_DIR };
|
|
240
|
+
//# sourceMappingURL=webhook-actions.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -1364,6 +1364,66 @@ export async function cmdDashboard(opts) {
|
|
|
1364
1364
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1365
1365
|
return diff === 0;
|
|
1366
1366
|
}
|
|
1367
|
+
// ── Webhook → action triggers ─────────────────────────────────────
|
|
1368
|
+
// Sibling of /webhook/:slug. Where /webhook/:slug ingests data into
|
|
1369
|
+
// the brain, /webhook-action/:source dispatches agentic actions
|
|
1370
|
+
// (wake an agent, start a background task) based on a YAML-ish config
|
|
1371
|
+
// at ~/.clementine/webhook-actions.json. Same HMAC verification.
|
|
1372
|
+
app.post('/webhook-action/:source', rawBodyParser, async (req, res) => {
|
|
1373
|
+
const sourceParam = req.params.source;
|
|
1374
|
+
const { getSourceConfig, dispatchWebhookActions, logWebhookEvent, } = await import('../agent/webhook-actions.js');
|
|
1375
|
+
const cfg = getSourceConfig(sourceParam);
|
|
1376
|
+
if (!cfg) {
|
|
1377
|
+
res.status(404).json({ error: `No webhook-action config for source "${sourceParam}"` });
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
// Resolve the HMAC secret (env first, inline fallback for local dev).
|
|
1381
|
+
const secret = (cfg.secretEnv ? process.env[cfg.secretEnv] : undefined) ?? cfg.secret ?? '';
|
|
1382
|
+
if (!secret) {
|
|
1383
|
+
res.status(500).json({ error: `Webhook source "${sourceParam}" has no secret configured` });
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const sig = String(req.headers['x-signature'] ?? req.headers['x-hub-signature-256'] ?? '').trim();
|
|
1387
|
+
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body ?? ''));
|
|
1388
|
+
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
|
1389
|
+
const given = sig.startsWith('sha256=') ? sig.slice('sha256='.length) : sig;
|
|
1390
|
+
if (!given || !safeHexEquals(given, expected)) {
|
|
1391
|
+
logWebhookEvent({
|
|
1392
|
+
timestamp: new Date().toISOString(),
|
|
1393
|
+
source: sourceParam,
|
|
1394
|
+
verified: false,
|
|
1395
|
+
matched: 0,
|
|
1396
|
+
dispatched: 0,
|
|
1397
|
+
errors: ['HMAC signature mismatch'],
|
|
1398
|
+
payloadPreview: rawBody.toString('utf-8').slice(0, 200),
|
|
1399
|
+
});
|
|
1400
|
+
res.status(401).json({ error: 'Invalid or missing HMAC signature' });
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
let payload;
|
|
1404
|
+
try {
|
|
1405
|
+
payload = JSON.parse(rawBody.toString('utf-8'));
|
|
1406
|
+
}
|
|
1407
|
+
catch (err) {
|
|
1408
|
+
res.status(400).json({ error: `Body is not JSON: ${err instanceof Error ? err.message : String(err)}` });
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
const result = dispatchWebhookActions(sourceParam, payload);
|
|
1412
|
+
logWebhookEvent({
|
|
1413
|
+
timestamp: new Date().toISOString(),
|
|
1414
|
+
source: sourceParam,
|
|
1415
|
+
verified: true,
|
|
1416
|
+
matched: result.matched,
|
|
1417
|
+
dispatched: result.dispatched,
|
|
1418
|
+
errors: result.errors,
|
|
1419
|
+
payloadPreview: rawBody.toString('utf-8').slice(0, 200),
|
|
1420
|
+
});
|
|
1421
|
+
res.json({
|
|
1422
|
+
matched: result.matched,
|
|
1423
|
+
dispatched: result.dispatched,
|
|
1424
|
+
errors: result.errors,
|
|
1425
|
+
});
|
|
1426
|
+
});
|
|
1367
1427
|
// Only parse JSON bodies on POST/PUT/PATCH — GET requests don't need body parsing.
|
|
1368
1428
|
// Registered AFTER the webhook route so /webhook/* keeps its raw body.
|
|
1369
1429
|
app.use((req, res, next) => {
|
|
@@ -1978,6 +2038,36 @@ export async function cmdDashboard(opts) {
|
|
|
1978
2038
|
app.get('/api/agent-heartbeats', (_req, res) => {
|
|
1979
2039
|
res.json(getAgentHeartbeats());
|
|
1980
2040
|
});
|
|
2041
|
+
app.get('/api/webhook-actions', async (_req, res) => {
|
|
2042
|
+
try {
|
|
2043
|
+
const { loadWebhookActionConfig, recentWebhookEvents } = await import('../agent/webhook-actions.js');
|
|
2044
|
+
const cfg = loadWebhookActionConfig();
|
|
2045
|
+
// Don't leak secrets — strip secret/secretEnv from the config response
|
|
2046
|
+
const sanitized = {
|
|
2047
|
+
hooks: cfg.hooks.map((h) => ({
|
|
2048
|
+
source: h.source,
|
|
2049
|
+
hasSecret: Boolean(h.secret) || Boolean(h.secretEnv),
|
|
2050
|
+
secretEnv: h.secretEnv ?? null,
|
|
2051
|
+
rules: h.on.length,
|
|
2052
|
+
on: h.on.map((r) => ({
|
|
2053
|
+
do: r.do,
|
|
2054
|
+
agent: r.agent,
|
|
2055
|
+
match: r.match ?? {},
|
|
2056
|
+
reason: r.reason,
|
|
2057
|
+
promptHead: r.prompt ? r.prompt.slice(0, 120) : undefined,
|
|
2058
|
+
maxMinutes: r.maxMinutes,
|
|
2059
|
+
})),
|
|
2060
|
+
})),
|
|
2061
|
+
};
|
|
2062
|
+
res.json({
|
|
2063
|
+
config: sanitized,
|
|
2064
|
+
recent: recentWebhookEvents(50),
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
catch (err) {
|
|
2068
|
+
res.status(500).json({ error: String(err).slice(0, 200) });
|
|
2069
|
+
}
|
|
2070
|
+
});
|
|
1981
2071
|
app.get('/api/background-tasks', async (_req, res) => {
|
|
1982
2072
|
try {
|
|
1983
2073
|
const { listBackgroundTasks } = await import('../agent/background-tasks.js');
|