jules-orchestrator-kit 0.33.0 → 0.35.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 +9 -5
- package/bin/agentctl.mjs +256 -7
- package/index.mjs +30 -2
- package/package.json +1 -1
- package/src/budget.mjs +105 -78
- package/src/config.mjs +48 -4
- package/src/flaky-ledger.mjs +205 -2
- package/src/ops/command-registry.mjs +54 -0
- package/src/ops/doctor-registry.mjs +37 -0
- package/src/state.mjs +146 -52
- package/src/webhook.mjs +415 -119
- package/src/wizard-init.mjs +6 -1
package/src/webhook.mjs
CHANGED
|
@@ -1,5 +1,420 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getStateDir } from "./state.mjs";
|
|
6
|
+
import { resolveRoot } from "./config.mjs";
|
|
7
|
+
import { redactSecrets } from "./security.mjs";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CRITICAL_REASONS = [
|
|
10
|
+
"R3_GATE_VIOLATION",
|
|
11
|
+
"AWAITING_USER_FEEDBACK",
|
|
12
|
+
"OODA_REPAIR_EXHAUSTED",
|
|
13
|
+
"SECRET_LEAK_DETECTED",
|
|
14
|
+
"CRITICAL_FAILURE",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function getDigestFilePath(root = resolveRoot()) {
|
|
18
|
+
return join(getStateDir(root), "escalation-digest.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getInterruptionLedgerPath(root = resolveRoot()) {
|
|
22
|
+
return join(getStateDir(root), "interruption-ledger.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function loadEscalationDigest(root = resolveRoot()) {
|
|
26
|
+
const file = getDigestFilePath(root);
|
|
27
|
+
if (!existsSync(file)) return { incidents: [], createdAt: null, updatedAt: null };
|
|
28
|
+
try {
|
|
29
|
+
const raw = readFileSync(file, "utf-8");
|
|
30
|
+
const data = JSON.parse(raw);
|
|
31
|
+
return {
|
|
32
|
+
incidents: Array.isArray(data.incidents) ? data.incidents : [],
|
|
33
|
+
createdAt: data.createdAt || null,
|
|
34
|
+
updatedAt: data.updatedAt || null,
|
|
35
|
+
};
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return { incidents: [], createdAt: null, updatedAt: null };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function saveEscalationDigest(root = resolveRoot(), digestData = {}) {
|
|
42
|
+
const file = getDigestFilePath(root);
|
|
43
|
+
const data = {
|
|
44
|
+
incidents: digestData.incidents || [],
|
|
45
|
+
createdAt: digestData.createdAt || new Date().toISOString(),
|
|
46
|
+
updatedAt: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
writeFileSync(file, JSON.stringify(data, null, 2), "utf-8");
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clearEscalationDigest(root = resolveRoot()) {
|
|
53
|
+
const file = getDigestFilePath(root);
|
|
54
|
+
if (existsSync(file)) {
|
|
55
|
+
try {
|
|
56
|
+
unlinkSync(file);
|
|
57
|
+
} catch (_) {
|
|
58
|
+
writeFileSync(file, JSON.stringify({ incidents: [], createdAt: null, updatedAt: null }), "utf-8");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, cleared: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function bufferEscalationIncident(incident = {}, root = resolveRoot()) {
|
|
65
|
+
const current = loadEscalationDigest(root);
|
|
66
|
+
const rawLogs = incident.logs || incident.error || "";
|
|
67
|
+
const scrubbedLogs = rawLogs ? redactSecrets(String(rawLogs)) : "";
|
|
68
|
+
const entry = {
|
|
69
|
+
id: incident.sessionId || `incident-${Date.now()}`,
|
|
70
|
+
sessionId: incident.sessionId || "unknown-session",
|
|
71
|
+
taskId: incident.taskId || "agent-task",
|
|
72
|
+
branch: incident.branch || "main",
|
|
73
|
+
reason: incident.reason || "NON_CRITICAL_EVENT",
|
|
74
|
+
logs: scrubbedLogs.split("\n").slice(-10).join("\n"),
|
|
75
|
+
timestamp: incident.timestamp || new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
const updatedIncidents = [...current.incidents, entry];
|
|
78
|
+
saveEscalationDigest(root, {
|
|
79
|
+
incidents: updatedIncidents,
|
|
80
|
+
createdAt: current.createdAt || new Date().toISOString(),
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
buffered: true,
|
|
84
|
+
count: updatedIncidents.length,
|
|
85
|
+
incident: entry,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function loadInterruptionLedger(root = resolveRoot()) {
|
|
90
|
+
const file = getInterruptionLedgerPath(root);
|
|
91
|
+
if (!existsSync(file)) return [];
|
|
92
|
+
try {
|
|
93
|
+
const raw = readFileSync(file, "utf-8");
|
|
94
|
+
const arr = JSON.parse(raw);
|
|
95
|
+
return Array.isArray(arr) ? arr : [];
|
|
96
|
+
} catch (_) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function recordInterruption(root = resolveRoot()) {
|
|
102
|
+
const file = getInterruptionLedgerPath(root);
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const entries = loadInterruptionLedger(root).filter((ts) => now - ts < 3600000); // 1 hour sliding window
|
|
105
|
+
entries.push(now);
|
|
106
|
+
writeFileSync(file, JSON.stringify(entries), "utf-8");
|
|
107
|
+
return entries.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function countRecentInterruptions(root = resolveRoot(), windowMs = 3600000) {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const entries = loadInterruptionLedger(root);
|
|
113
|
+
return entries.filter((ts) => now - ts < windowMs).length;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getEscalationDigestStatus(root = resolveRoot(), config = {}) {
|
|
117
|
+
const digest = loadEscalationDigest(root);
|
|
118
|
+
const notifications = config.notifications || {};
|
|
119
|
+
const budgetPerHour = notifications.budgetPerHour ?? 3;
|
|
120
|
+
const recentInterruptions = countRecentInterruptions(root);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
pendingCount: digest.incidents.length,
|
|
124
|
+
incidents: digest.incidents,
|
|
125
|
+
createdAt: digest.createdAt,
|
|
126
|
+
updatedAt: digest.updatedAt,
|
|
127
|
+
budgetPerHour,
|
|
128
|
+
recentInterruptions,
|
|
129
|
+
budgetAvailable: Math.max(0, budgetPerHour - recentInterruptions),
|
|
130
|
+
mode: notifications.mode || "immediate",
|
|
131
|
+
threshold: notifications.threshold || 5,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function flushEscalationDigest(config = {}, options = {}) {
|
|
136
|
+
const root = options.root || resolveRoot();
|
|
137
|
+
const digest = loadEscalationDigest(root);
|
|
138
|
+
if (digest.incidents.length === 0) {
|
|
139
|
+
return { flushed: false, count: 0, reason: "No pending incidents in digest" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const notifications = config.notifications || {};
|
|
143
|
+
const slackUrl = process.env.SLACK_WEBHOOK_URL || notifications.slackWebhookUrl || config.slackWebhookUrl || "";
|
|
144
|
+
const discordUrl = process.env.DISCORD_WEBHOOK_URL || notifications.discordWebhookUrl || config.discordWebhookUrl || "";
|
|
145
|
+
const dryRun = options.dryRun || config.dryRun || false;
|
|
146
|
+
|
|
147
|
+
const count = digest.incidents.length;
|
|
148
|
+
const results = { flushed: true, count, slack: false, discord: false, dryRun };
|
|
149
|
+
|
|
150
|
+
if (dryRun) {
|
|
151
|
+
if (!options.preserve) clearEscalationDigest(root);
|
|
152
|
+
return {
|
|
153
|
+
...results,
|
|
154
|
+
payload: {
|
|
155
|
+
count,
|
|
156
|
+
incidents: digest.incidents,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!slackUrl && !discordUrl) {
|
|
162
|
+
return { flushed: false, count, slack: false, discord: false, reason: "No webhook URL configured" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Format Slack digest
|
|
166
|
+
if (slackUrl) {
|
|
167
|
+
try {
|
|
168
|
+
const summaryText = digest.incidents
|
|
169
|
+
.map((inc) => `• *\`${inc.sessionId}\`* on \`${inc.branch}\` [${inc.reason}]: \`agentctl resume ${inc.sessionId}\``)
|
|
170
|
+
.join("\n");
|
|
171
|
+
|
|
172
|
+
const slackBody = {
|
|
173
|
+
text: `📋 *Jules Escalation Digest* (${count} batched incident${count > 1 ? "s" : ""})`,
|
|
174
|
+
blocks: [
|
|
175
|
+
{
|
|
176
|
+
type: "header",
|
|
177
|
+
text: { type: "plain_text", text: `📋 Jules Escalation Digest (${count} incidents)` },
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: "section",
|
|
181
|
+
text: { type: "mrkdwn", text: summaryText.slice(0, 2500) },
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
type: "context",
|
|
185
|
+
elements: [
|
|
186
|
+
{ type: "mrkdwn", text: `Aggregated by Type III Silence Governor · Oldest: ${digest.createdAt || "N/A"}` },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const res = await fetch(slackUrl, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "Content-Type": "application/json" },
|
|
195
|
+
body: JSON.stringify(slackBody),
|
|
196
|
+
});
|
|
197
|
+
results.slack = res.ok;
|
|
198
|
+
} catch (_) {
|
|
199
|
+
results.slack = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Format Discord digest
|
|
204
|
+
if (discordUrl) {
|
|
205
|
+
try {
|
|
206
|
+
const fields = digest.incidents.slice(0, 10).map((inc) => ({
|
|
207
|
+
name: `Session \`${inc.sessionId}\` [${inc.reason}]`,
|
|
208
|
+
value: `Branch: \`${inc.branch}\`\n\`agentctl resume ${inc.sessionId} --response "<answer>"\``,
|
|
209
|
+
inline: false,
|
|
210
|
+
}));
|
|
211
|
+
|
|
212
|
+
const discordBody = {
|
|
213
|
+
content: `📋 **Jules Escalation Digest** (${count} batched incident${count > 1 ? "s" : ""})`,
|
|
214
|
+
embeds: [
|
|
215
|
+
{
|
|
216
|
+
title: `Escalation Digest (${count} incidents)`,
|
|
217
|
+
color: 3447003,
|
|
218
|
+
fields,
|
|
219
|
+
footer: { text: `Aggregated by Type III Silence Governor · Total: ${count}` },
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const res = await fetch(discordUrl, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: { "Content-Type": "application/json" },
|
|
227
|
+
body: JSON.stringify(discordBody),
|
|
228
|
+
});
|
|
229
|
+
results.discord = res.ok;
|
|
230
|
+
} catch (_) {
|
|
231
|
+
results.discord = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (results.slack || results.discord) {
|
|
236
|
+
if (!options.preserve) {
|
|
237
|
+
clearEscalationDigest(root);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return results;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Dispatches an asynchronous escalation incident payload to configured Slack and/or Discord webhooks,
|
|
246
|
+
* governed by the Type III Silence Governor and interruption budget.
|
|
247
|
+
* @param {object} incident - Incident details
|
|
248
|
+
* @param {string} incident.sessionId
|
|
249
|
+
* @param {string} [incident.taskId]
|
|
250
|
+
* @param {string} [incident.branch]
|
|
251
|
+
* @param {string} [incident.reason] - AWAITING_USER_FEEDBACK | OODA_REPAIR_EXHAUSTED | R3_GATE_VIOLATION
|
|
252
|
+
* @param {string} [incident.logs] - Last 20 lines of compiler/test output
|
|
253
|
+
* @param {boolean} [incident.critical] - Force immediate alert
|
|
254
|
+
* @param {object} [config]
|
|
255
|
+
* @returns {Promise<object>} Dispatch status
|
|
256
|
+
*/
|
|
257
|
+
export async function dispatchEscalation(incident = {}, config = {}) {
|
|
258
|
+
const root = config.root || resolveRoot();
|
|
259
|
+
const notifications = config.notifications || {};
|
|
260
|
+
const mode = (notifications.mode || config.notificationMode || "immediate").toLowerCase();
|
|
261
|
+
const threshold = notifications.threshold || 5;
|
|
262
|
+
const budgetPerHour = notifications.budgetPerHour ?? 3;
|
|
263
|
+
const criticalReasons = notifications.criticalReasons || DEFAULT_CRITICAL_REASONS;
|
|
264
|
+
|
|
265
|
+
const reason = incident.reason || "AWAITING_USER_FEEDBACK";
|
|
266
|
+
const isCritical = incident.critical === true || criticalReasons.includes(reason) || config.forceImmediate === true;
|
|
267
|
+
|
|
268
|
+
// Non-critical governance
|
|
269
|
+
if (!isCritical) {
|
|
270
|
+
if (mode === "silent") {
|
|
271
|
+
bufferEscalationIncident(incident, root);
|
|
272
|
+
return {
|
|
273
|
+
dispatched: false,
|
|
274
|
+
buffered: true,
|
|
275
|
+
silent: true,
|
|
276
|
+
reason: "SILENT_MODE",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (mode === "digest" || mode === "threshold") {
|
|
281
|
+
const bufferRes = bufferEscalationIncident(incident, root);
|
|
282
|
+
if (bufferRes.count >= threshold) {
|
|
283
|
+
return await flushEscalationDigest(config, { root, dryRun: config.dryRun });
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
dispatched: false,
|
|
287
|
+
buffered: true,
|
|
288
|
+
digestCount: bufferRes.count,
|
|
289
|
+
reason: "BUFFERED_IN_DIGEST",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// mode === "immediate" with interruption budget
|
|
294
|
+
const recentCount = countRecentInterruptions(root);
|
|
295
|
+
if (recentCount >= budgetPerHour) {
|
|
296
|
+
const bufferRes = bufferEscalationIncident(incident, root);
|
|
297
|
+
return {
|
|
298
|
+
dispatched: false,
|
|
299
|
+
buffered: true,
|
|
300
|
+
digestCount: bufferRes.count,
|
|
301
|
+
reason: "INTERRUPTION_BUDGET_EXCEEDED",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// If immediate/critical dispatch:
|
|
307
|
+
if (!isCritical) {
|
|
308
|
+
recordInterruption(root);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const slackUrl = process.env.SLACK_WEBHOOK_URL || notifications.slackWebhookUrl || config.slackWebhookUrl || "";
|
|
312
|
+
const discordUrl = process.env.DISCORD_WEBHOOK_URL || notifications.discordWebhookUrl || config.discordWebhookUrl || "";
|
|
313
|
+
|
|
314
|
+
if (!slackUrl && !discordUrl && !config.dryRun) {
|
|
315
|
+
return { dispatched: false, slack: false, discord: false, reason: "No webhook URL configured" };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const sessionId = incident.sessionId || "unknown-session";
|
|
319
|
+
const taskId = incident.taskId || "agent-task";
|
|
320
|
+
const branch = incident.branch || "main";
|
|
321
|
+
const rawLogs = incident.logs || incident.error || "No error log attached.";
|
|
322
|
+
|
|
323
|
+
// Normalize last 20 lines of logs with secret scrubbing
|
|
324
|
+
const scrubbedLogs = redactSecrets(String(rawLogs));
|
|
325
|
+
const logLines = scrubbedLogs.split("\n").slice(-20).join("\n");
|
|
326
|
+
const resumeCmd = `agentctl resume ${sessionId} --response "<your-answer>"`;
|
|
327
|
+
|
|
328
|
+
const results = { dispatched: true, slack: false, discord: false };
|
|
329
|
+
|
|
330
|
+
if (config.dryRun) {
|
|
331
|
+
return {
|
|
332
|
+
...results,
|
|
333
|
+
dryRun: true,
|
|
334
|
+
payload: {
|
|
335
|
+
sessionId,
|
|
336
|
+
taskId,
|
|
337
|
+
reason,
|
|
338
|
+
resumeCmd,
|
|
339
|
+
logLines,
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Dispatch to Slack
|
|
345
|
+
if (slackUrl) {
|
|
346
|
+
try {
|
|
347
|
+
const slackBody = {
|
|
348
|
+
text: `🚨 *Jules Agent Escalation* [${reason}]: Session ${sessionId}`,
|
|
349
|
+
blocks: [
|
|
350
|
+
{
|
|
351
|
+
type: "header",
|
|
352
|
+
text: { type: "plain_text", text: `🚨 Jules Escalation: ${reason}` },
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
type: "section",
|
|
356
|
+
fields: [
|
|
357
|
+
{ type: "mrkdwn", text: `*Session ID:*\n\`${sessionId}\`` },
|
|
358
|
+
{ type: "mrkdwn", text: `*Branch:*\n\`${branch}\`` },
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
type: "section",
|
|
363
|
+
text: { type: "mrkdwn", text: `*Recent Log Output:*\n\`\`\`${logLines.slice(0, 1000)}\`\`\`` },
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
type: "context",
|
|
367
|
+
elements: [
|
|
368
|
+
{ type: "mrkdwn", text: `👉 *To resume session run:* \`${resumeCmd}\`` },
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const res = await fetch(slackUrl, {
|
|
375
|
+
method: "POST",
|
|
376
|
+
headers: { "Content-Type": "application/json" },
|
|
377
|
+
body: JSON.stringify(slackBody),
|
|
378
|
+
});
|
|
379
|
+
results.slack = res.ok;
|
|
380
|
+
} catch (_) {
|
|
381
|
+
results.slack = false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Dispatch to Discord
|
|
386
|
+
if (discordUrl) {
|
|
387
|
+
try {
|
|
388
|
+
const discordBody = {
|
|
389
|
+
content: `🚨 **Jules Agent Escalation** [${reason}] for session \`${sessionId}\``,
|
|
390
|
+
embeds: [
|
|
391
|
+
{
|
|
392
|
+
title: `Escalation Reason: ${reason}`,
|
|
393
|
+
color: 15158332, // Red
|
|
394
|
+
fields: [
|
|
395
|
+
{ name: "Session ID", value: `\`${sessionId}\``, inline: true },
|
|
396
|
+
{ name: "Branch", value: `\`${branch}\``, inline: true },
|
|
397
|
+
{ name: "Resume Command", value: `\`${resumeCmd}\`` },
|
|
398
|
+
],
|
|
399
|
+
description: `\`\`\`\n${logLines.slice(0, 1000)}\n\`\`\``,
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const res = await fetch(discordUrl, {
|
|
405
|
+
method: "POST",
|
|
406
|
+
headers: { "Content-Type": "application/json" },
|
|
407
|
+
body: JSON.stringify(discordBody),
|
|
408
|
+
});
|
|
409
|
+
results.discord = res.ok;
|
|
410
|
+
} catch (_) {
|
|
411
|
+
results.discord = false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return results;
|
|
416
|
+
}
|
|
417
|
+
|
|
3
418
|
|
|
4
419
|
/**
|
|
5
420
|
* Verify GitHub webhook HMAC SHA-256 signature against request payload.
|
|
@@ -186,122 +601,3 @@ export function createWebhookServer({ _port = 8787, secret = process.env.JULES_W
|
|
|
186
601
|
return server;
|
|
187
602
|
}
|
|
188
603
|
|
|
189
|
-
/**
|
|
190
|
-
* Dispatches an asynchronous escalation incident payload to configured Slack and/or Discord webhooks.
|
|
191
|
-
* @param {object} incident - Incident details
|
|
192
|
-
* @param {string} incident.sessionId
|
|
193
|
-
* @param {string} [incident.taskId]
|
|
194
|
-
* @param {string} [incident.branch]
|
|
195
|
-
* @param {string} [incident.reason] - AWAITING_USER_FEEDBACK | OODA_REPAIR_EXHAUSTED | R3_GATE_VIOLATION
|
|
196
|
-
* @param {string} [incident.logs] - Last 20 lines of compiler/test output
|
|
197
|
-
* @param {object} [config]
|
|
198
|
-
* @returns {Promise<object>} Dispatch status { dispatched: boolean, slack: boolean, discord: boolean }
|
|
199
|
-
*/
|
|
200
|
-
export async function dispatchEscalation(incident = {}, config = {}) {
|
|
201
|
-
const slackUrl = process.env.SLACK_WEBHOOK_URL || config.slackWebhookUrl || "";
|
|
202
|
-
const discordUrl = process.env.DISCORD_WEBHOOK_URL || config.discordWebhookUrl || "";
|
|
203
|
-
|
|
204
|
-
if (!slackUrl && !discordUrl && !config.dryRun) {
|
|
205
|
-
return { dispatched: false, slack: false, discord: false, reason: "No webhook URL configured" };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const sessionId = incident.sessionId || "unknown-session";
|
|
209
|
-
const taskId = incident.taskId || "agent-task";
|
|
210
|
-
const branch = incident.branch || "main";
|
|
211
|
-
const reason = incident.reason || "AWAITING_USER_FEEDBACK";
|
|
212
|
-
const rawLogs = incident.logs || incident.error || "No error log attached.";
|
|
213
|
-
|
|
214
|
-
// Normalize last 20 lines of logs
|
|
215
|
-
const logLines = String(rawLogs).split("\n").slice(-20).join("\n");
|
|
216
|
-
const resumeCmd = `agentctl resume ${sessionId} --response "<your-answer>"`;
|
|
217
|
-
|
|
218
|
-
const results = { dispatched: true, slack: false, discord: false };
|
|
219
|
-
|
|
220
|
-
if (config.dryRun) {
|
|
221
|
-
return {
|
|
222
|
-
...results,
|
|
223
|
-
dryRun: true,
|
|
224
|
-
payload: {
|
|
225
|
-
sessionId,
|
|
226
|
-
taskId,
|
|
227
|
-
reason,
|
|
228
|
-
resumeCmd,
|
|
229
|
-
logLines,
|
|
230
|
-
},
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Dispatch to Slack
|
|
235
|
-
if (slackUrl) {
|
|
236
|
-
try {
|
|
237
|
-
const slackBody = {
|
|
238
|
-
text: `🚨 *Jules Agent Escalation* [${reason}]: Session ${sessionId}`,
|
|
239
|
-
blocks: [
|
|
240
|
-
{
|
|
241
|
-
type: "header",
|
|
242
|
-
text: { type: "plain_text", text: `🚨 Jules Escalation: ${reason}` },
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
type: "section",
|
|
246
|
-
fields: [
|
|
247
|
-
{ type: "mrkdwn", text: `*Session ID:*\n\`${sessionId}\`` },
|
|
248
|
-
{ type: "mrkdwn", text: `*Branch:*\n\`${branch}\`` },
|
|
249
|
-
],
|
|
250
|
-
},
|
|
251
|
-
{
|
|
252
|
-
type: "section",
|
|
253
|
-
text: { type: "mrkdwn", text: `*Recent Log Output:*\n\`\`\`${logLines.slice(0, 1000)}\`\`\`` },
|
|
254
|
-
},
|
|
255
|
-
{
|
|
256
|
-
type: "context",
|
|
257
|
-
elements: [
|
|
258
|
-
{ type: "mrkdwn", text: `👉 *To resume session run:* \`${resumeCmd}\`` },
|
|
259
|
-
],
|
|
260
|
-
},
|
|
261
|
-
],
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
const res = await fetch(slackUrl, {
|
|
265
|
-
method: "POST",
|
|
266
|
-
headers: { "Content-Type": "application/json" },
|
|
267
|
-
body: JSON.stringify(slackBody),
|
|
268
|
-
});
|
|
269
|
-
results.slack = res.ok;
|
|
270
|
-
} catch (_) {
|
|
271
|
-
results.slack = false;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Dispatch to Discord
|
|
276
|
-
if (discordUrl) {
|
|
277
|
-
try {
|
|
278
|
-
const discordBody = {
|
|
279
|
-
content: `🚨 **Jules Agent Escalation** [${reason}] for session \`${sessionId}\``,
|
|
280
|
-
embeds: [
|
|
281
|
-
{
|
|
282
|
-
title: `Escalation Reason: ${reason}`,
|
|
283
|
-
color: 15158332, // Red
|
|
284
|
-
fields: [
|
|
285
|
-
{ name: "Session ID", value: `\`${sessionId}\``, inline: true },
|
|
286
|
-
{ name: "Branch", value: `\`${branch}\``, inline: true },
|
|
287
|
-
{ name: "Resume Command", value: `\`${resumeCmd}\`` },
|
|
288
|
-
],
|
|
289
|
-
description: `\`\`\`\n${logLines.slice(0, 1000)}\n\`\`\``,
|
|
290
|
-
},
|
|
291
|
-
],
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const res = await fetch(discordUrl, {
|
|
295
|
-
method: "POST",
|
|
296
|
-
headers: { "Content-Type": "application/json" },
|
|
297
|
-
body: JSON.stringify(discordBody),
|
|
298
|
-
});
|
|
299
|
-
results.discord = res.ok;
|
|
300
|
-
} catch (_) {
|
|
301
|
-
results.discord = false;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return results;
|
|
306
|
-
}
|
|
307
|
-
|
package/src/wizard-init.mjs
CHANGED
|
@@ -60,10 +60,15 @@ export function tierOptions() {
|
|
|
60
60
|
return order.map((name) => {
|
|
61
61
|
const p = TIER_PRESETS[name];
|
|
62
62
|
const worker = p.concurrency === 1 ? "worker" : "workers";
|
|
63
|
+
// The ceiling is shown next to the default so the number the wizard writes
|
|
64
|
+
// reads as a starting point rather than as the plan's limit.
|
|
65
|
+
const slots = p.maxConcurrency > p.concurrency
|
|
66
|
+
? `${p.concurrency} ${worker} (plan allows ${p.maxConcurrency})`
|
|
67
|
+
: `${p.concurrency} ${worker}`;
|
|
63
68
|
return {
|
|
64
69
|
label: TIER_LABELS[name] || name,
|
|
65
70
|
value: name,
|
|
66
|
-
description: `${
|
|
71
|
+
description: `${slots}, ~${p.dailyTasks} daily tasks, ${p.diffKb} KB diff limit`,
|
|
67
72
|
};
|
|
68
73
|
});
|
|
69
74
|
}
|