@desplega.ai/agent-swarm 1.71.2 → 1.72.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 +3 -2
- package/openapi.json +994 -62
- package/package.json +2 -1
- package/src/be/budget-admission.ts +121 -0
- package/src/be/budget-refusal-notify.ts +145 -0
- package/src/be/db.ts +488 -5
- package/src/be/migrations/044_provider_meta.sql +2 -0
- package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
- package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
- package/src/cli.tsx +22 -1
- package/src/commands/claude-managed-setup.ts +687 -0
- package/src/commands/codex-login.ts +1 -1
- package/src/commands/runner.ts +175 -28
- package/src/commands/templates.ts +10 -6
- package/src/http/budgets.ts +219 -0
- package/src/http/index.ts +6 -0
- package/src/http/integrations.ts +134 -0
- package/src/http/poll.ts +161 -3
- package/src/http/pricing.ts +245 -0
- package/src/http/session-data.ts +54 -6
- package/src/http/tasks.ts +23 -2
- package/src/prompts/base-prompt.ts +103 -73
- package/src/prompts/session-templates.ts +43 -0
- package/src/providers/claude-adapter.ts +3 -1
- package/src/providers/claude-managed-adapter.ts +871 -0
- package/src/providers/claude-managed-models.ts +117 -0
- package/src/providers/claude-managed-swarm-events.ts +77 -0
- package/src/providers/codex-adapter.ts +3 -1
- package/src/providers/codex-skill-resolver.ts +10 -0
- package/src/providers/codex-swarm-events.ts +20 -161
- package/src/providers/devin-adapter.ts +894 -0
- package/src/providers/devin-api.ts +207 -0
- package/src/providers/devin-playbooks.ts +91 -0
- package/src/providers/devin-skill-resolver.ts +113 -0
- package/src/providers/index.ts +10 -1
- package/src/providers/pi-mono-adapter.ts +3 -1
- package/src/providers/swarm-events-shared.ts +262 -0
- package/src/providers/types.ts +26 -1
- package/src/tests/base-prompt.test.ts +199 -0
- package/src/tests/budget-admission.test.ts +339 -0
- package/src/tests/budget-claim-gate.test.ts +288 -0
- package/src/tests/budget-refusal-notification.test.ts +324 -0
- package/src/tests/budgets-routes.test.ts +331 -0
- package/src/tests/claude-managed-adapter.test.ts +1301 -0
- package/src/tests/claude-managed-setup.test.ts +325 -0
- package/src/tests/devin-adapter.test.ts +677 -0
- package/src/tests/devin-api.test.ts +339 -0
- package/src/tests/integrations-http.test.ts +211 -0
- package/src/tests/migration-046-budgets.test.ts +327 -0
- package/src/tests/pricing-routes.test.ts +315 -0
- package/src/tests/prompt-template-remaining.test.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +2 -2
- package/src/tests/provider-adapter.test.ts +1 -1
- package/src/tests/runner-budget-refused.test.ts +271 -0
- package/src/tests/session-costs-codex-recompute.test.ts +386 -0
- package/src/tools/poll-task.ts +13 -2
- package/src/tools/task-action.ts +92 -2
- package/src/tools/templates.ts +29 -0
- package/src/types.ts +116 -0
- package/src/utils/budget-backoff.ts +34 -0
- package/src/utils/credentials.ts +4 -0
- package/src/utils/provider-metadata.ts +9 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Phase 5: lead-facing budget refusal notification tests.
|
|
2
|
+
//
|
|
3
|
+
// Exercises `handlePoll` end-to-end with a tripped budget gate to assert:
|
|
4
|
+
// - The first refusal of (task, day) creates exactly one follow-up task
|
|
5
|
+
// owned by the lead, with Slack context inherited from the refused task.
|
|
6
|
+
// - Same-day repeat refusals create ZERO additional follow-ups (dedup).
|
|
7
|
+
// - The dedup row's `follow_up_task_id` is populated with the new task id.
|
|
8
|
+
// - A subsequent refusal on a different UTC day creates a new follow-up
|
|
9
|
+
// (the `(task_id, date)` PK rolls over).
|
|
10
|
+
// - Each refusal — first or repeat — reaches the workflow event bus.
|
|
11
|
+
//
|
|
12
|
+
// Tests directly invoke `handlePoll` with mocked req/res, identical to the
|
|
13
|
+
// pattern used by `budget-claim-gate.test.ts`.
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
+
import { unlink } from "node:fs/promises";
|
|
17
|
+
import {
|
|
18
|
+
closeDb,
|
|
19
|
+
createAgent,
|
|
20
|
+
createSessionCost,
|
|
21
|
+
createTaskExtended,
|
|
22
|
+
getBudgetRefusalNotification,
|
|
23
|
+
getDb,
|
|
24
|
+
initDb,
|
|
25
|
+
} from "../be/db";
|
|
26
|
+
import { handlePoll } from "../http/poll";
|
|
27
|
+
import { workflowEventBus } from "../workflows/event-bus";
|
|
28
|
+
|
|
29
|
+
const TEST_DB_PATH = "./test-budget-refusal-notification.sqlite";
|
|
30
|
+
|
|
31
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
32
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
33
|
+
try {
|
|
34
|
+
await unlink(path + suffix);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
initDb(TEST_DB_PATH);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
closeDb();
|
|
47
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
const db = getDb();
|
|
52
|
+
db.prepare("DELETE FROM session_costs").run();
|
|
53
|
+
db.prepare("DELETE FROM budget_refusal_notifications").run();
|
|
54
|
+
db.prepare("DELETE FROM budgets").run();
|
|
55
|
+
db.prepare("DELETE FROM agent_tasks").run();
|
|
56
|
+
db.prepare("DELETE FROM agents").run();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
interface PollResponse {
|
|
62
|
+
status: number;
|
|
63
|
+
body: { trigger: { type: string; [key: string]: unknown } | null } | { error: string };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function callPoll(agentId: string | undefined): Promise<PollResponse> {
|
|
67
|
+
let status = 200;
|
|
68
|
+
let bodyStr = "";
|
|
69
|
+
const headers: Record<string, string> = {};
|
|
70
|
+
|
|
71
|
+
const req = {
|
|
72
|
+
method: "GET",
|
|
73
|
+
url: "/api/poll",
|
|
74
|
+
headers: agentId ? { "x-agent-id": agentId } : {},
|
|
75
|
+
} as unknown as Parameters<typeof handlePoll>[0];
|
|
76
|
+
|
|
77
|
+
const res = {
|
|
78
|
+
setHeader(name: string, value: string) {
|
|
79
|
+
headers[name.toLowerCase()] = value;
|
|
80
|
+
},
|
|
81
|
+
writeHead(code: number, h?: Record<string, string>) {
|
|
82
|
+
status = code;
|
|
83
|
+
if (h) {
|
|
84
|
+
for (const [k, v] of Object.entries(h)) headers[k.toLowerCase()] = v;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
end(body?: string) {
|
|
88
|
+
bodyStr = body ?? "";
|
|
89
|
+
},
|
|
90
|
+
} as unknown as Parameters<typeof handlePoll>[1];
|
|
91
|
+
|
|
92
|
+
const handled = await handlePoll(req, res, ["api", "poll"], new URLSearchParams(), agentId);
|
|
93
|
+
if (!handled) throw new Error("handlePoll did not handle the request");
|
|
94
|
+
return { status, body: bodyStr ? JSON.parse(bodyStr) : null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function insertBudget(scope: "global" | "agent", scopeId: string, dailyBudgetUsd: number): void {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
getDb()
|
|
100
|
+
.prepare(
|
|
101
|
+
"INSERT INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?)",
|
|
102
|
+
)
|
|
103
|
+
.run(scope, scopeId, dailyBudgetUsd, now, now);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function insertSpend(agentId: string, totalCostUsd: number): void {
|
|
107
|
+
createSessionCost({
|
|
108
|
+
sessionId: `sess-${crypto.randomUUID()}`,
|
|
109
|
+
agentId,
|
|
110
|
+
totalCostUsd,
|
|
111
|
+
durationMs: 1_000,
|
|
112
|
+
numTurns: 1,
|
|
113
|
+
model: "test-model",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function todayUtc(): string {
|
|
118
|
+
return new Date().toISOString().slice(0, 10);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface FollowUpRow {
|
|
122
|
+
id: string;
|
|
123
|
+
agentId: string | null;
|
|
124
|
+
parentTaskId: string | null;
|
|
125
|
+
taskType: string | null;
|
|
126
|
+
task: string;
|
|
127
|
+
slackChannelId: string | null;
|
|
128
|
+
slackThreadTs: string | null;
|
|
129
|
+
slackUserId: string | null;
|
|
130
|
+
source: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function listFollowUpTasks(parentTaskId: string): FollowUpRow[] {
|
|
134
|
+
return getDb()
|
|
135
|
+
.prepare<FollowUpRow, [string]>(
|
|
136
|
+
`SELECT id, agentId, parentTaskId, taskType, task, slackChannelId, slackThreadTs, slackUserId, source
|
|
137
|
+
FROM agent_tasks
|
|
138
|
+
WHERE parentTaskId = ? AND taskType = 'follow-up'
|
|
139
|
+
ORDER BY createdAt ASC`,
|
|
140
|
+
)
|
|
141
|
+
.all(parentTaskId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Brief microtask-pump helper. The workflow bus emit goes through a dynamic
|
|
145
|
+
// `import().then(...)` in budget-refusal-notify.ts, so we wait one tick for
|
|
146
|
+
// the listener to fire before asserting.
|
|
147
|
+
async function waitForBusEmit(): Promise<void> {
|
|
148
|
+
// Two macrotask ticks plus a couple of microtask flushes is overkill but
|
|
149
|
+
// makes the test deterministic regardless of import-cache state.
|
|
150
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
describe("Phase 5 — budget refusal lead notification + dedup", () => {
|
|
156
|
+
test("first refusal creates exactly one lead-owned follow-up task with Slack context", async () => {
|
|
157
|
+
const lead = createAgent({ name: "lead-1", isLead: true, status: "idle", maxTasks: 5 });
|
|
158
|
+
const worker = createAgent({ name: "worker-1", isLead: false, status: "idle", maxTasks: 1 });
|
|
159
|
+
insertBudget("agent", worker.id, 0.01);
|
|
160
|
+
insertSpend(worker.id, 0.05);
|
|
161
|
+
|
|
162
|
+
const parentTask = createTaskExtended("over-budget task", {
|
|
163
|
+
agentId: worker.id,
|
|
164
|
+
slackChannelId: "C_TEST_1",
|
|
165
|
+
slackThreadTs: "1700000000.000001",
|
|
166
|
+
slackUserId: "U_REPORTER_1",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const { body } = await callPoll(worker.id);
|
|
170
|
+
if ("error" in body) throw new Error("unexpected error response");
|
|
171
|
+
expect(body.trigger?.type).toBe("budget_refused");
|
|
172
|
+
|
|
173
|
+
const followUps = listFollowUpTasks(parentTask.id);
|
|
174
|
+
expect(followUps).toHaveLength(1);
|
|
175
|
+
const followUp = followUps[0]!;
|
|
176
|
+
expect(followUp.agentId).toBe(lead.id);
|
|
177
|
+
expect(followUp.taskType).toBe("follow-up");
|
|
178
|
+
expect(followUp.parentTaskId).toBe(parentTask.id);
|
|
179
|
+
expect(followUp.source).toBe("system");
|
|
180
|
+
// Slack context inherited from parent.
|
|
181
|
+
expect(followUp.slackChannelId).toBe("C_TEST_1");
|
|
182
|
+
expect(followUp.slackThreadTs).toBe("1700000000.000001");
|
|
183
|
+
expect(followUp.slackUserId).toBe("U_REPORTER_1");
|
|
184
|
+
// Body contains the rendered template variables.
|
|
185
|
+
expect(followUp.task).toContain("Cause: agent");
|
|
186
|
+
expect(followUp.task).toContain("worker-1");
|
|
187
|
+
expect(followUp.task).toContain("over-budget task");
|
|
188
|
+
expect(followUp.task).toContain("$0.05 / $0.01");
|
|
189
|
+
expect(followUp.task).toContain(parentTask.id);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("second same-day refusal creates ZERO additional follow-ups (dedup honored)", async () => {
|
|
193
|
+
createAgent({ name: "lead-2", isLead: true, status: "idle", maxTasks: 5 });
|
|
194
|
+
const worker = createAgent({ name: "worker-2", isLead: false, status: "idle", maxTasks: 1 });
|
|
195
|
+
insertBudget("agent", worker.id, 0.01);
|
|
196
|
+
insertSpend(worker.id, 0.5);
|
|
197
|
+
|
|
198
|
+
const parentTask = createTaskExtended("dedup target", { agentId: worker.id });
|
|
199
|
+
|
|
200
|
+
const r1 = await callPoll(worker.id);
|
|
201
|
+
if ("error" in r1.body) throw new Error("unexpected error response");
|
|
202
|
+
expect(r1.body.trigger?.type).toBe("budget_refused");
|
|
203
|
+
expect(listFollowUpTasks(parentTask.id)).toHaveLength(1);
|
|
204
|
+
|
|
205
|
+
// Second poll on the same UTC day — refusal repeats, but no new follow-up.
|
|
206
|
+
const r2 = await callPoll(worker.id);
|
|
207
|
+
if ("error" in r2.body) throw new Error("unexpected error response");
|
|
208
|
+
expect(r2.body.trigger?.type).toBe("budget_refused");
|
|
209
|
+
expect(listFollowUpTasks(parentTask.id)).toHaveLength(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("dedup row's follow_up_task_id is written back to the new follow-up's id", async () => {
|
|
213
|
+
createAgent({ name: "lead-3", isLead: true, status: "idle", maxTasks: 5 });
|
|
214
|
+
const worker = createAgent({ name: "worker-3", isLead: false, status: "idle", maxTasks: 1 });
|
|
215
|
+
insertBudget("agent", worker.id, 0.01);
|
|
216
|
+
insertSpend(worker.id, 0.05);
|
|
217
|
+
|
|
218
|
+
const parentTask = createTaskExtended("audit-trail task", { agentId: worker.id });
|
|
219
|
+
|
|
220
|
+
await callPoll(worker.id);
|
|
221
|
+
|
|
222
|
+
const followUps = listFollowUpTasks(parentTask.id);
|
|
223
|
+
expect(followUps).toHaveLength(1);
|
|
224
|
+
const followUpId = followUps[0]!.id;
|
|
225
|
+
|
|
226
|
+
const dedupRow = getBudgetRefusalNotification(parentTask.id, todayUtc());
|
|
227
|
+
expect(dedupRow).not.toBeNull();
|
|
228
|
+
expect(dedupRow?.followUpTaskId).toBe(followUpId);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("refusal on a NEW UTC day creates a new follow-up (PK rolls over)", async () => {
|
|
232
|
+
createAgent({ name: "lead-4", isLead: true, status: "idle", maxTasks: 5 });
|
|
233
|
+
const worker = createAgent({ name: "worker-4", isLead: false, status: "idle", maxTasks: 1 });
|
|
234
|
+
insertBudget("agent", worker.id, 0.01);
|
|
235
|
+
insertSpend(worker.id, 0.05);
|
|
236
|
+
|
|
237
|
+
const parentTask = createTaskExtended("rollover task", { agentId: worker.id });
|
|
238
|
+
|
|
239
|
+
// First refusal — first follow-up.
|
|
240
|
+
await callPoll(worker.id);
|
|
241
|
+
expect(listFollowUpTasks(parentTask.id)).toHaveLength(1);
|
|
242
|
+
|
|
243
|
+
// Simulate "yesterday already had a refusal" by manually inserting a row
|
|
244
|
+
// for a different `(task, date)` PK is the wrong approach — we instead
|
|
245
|
+
// simulate "today rolled over to tomorrow" by overwriting today's dedup
|
|
246
|
+
// row's `date` field to a yesterday placeholder, leaving the test poll
|
|
247
|
+
// to insert a fresh row for the actual current UTC date.
|
|
248
|
+
const yesterday = "1999-01-01"; // arbitrary past date guaranteed not to collide
|
|
249
|
+
getDb()
|
|
250
|
+
.prepare("UPDATE budget_refusal_notifications SET date = ? WHERE task_id = ? AND date = ?")
|
|
251
|
+
.run(yesterday, parentTask.id, todayUtc());
|
|
252
|
+
|
|
253
|
+
// Verify we moved the row.
|
|
254
|
+
expect(getBudgetRefusalNotification(parentTask.id, todayUtc())).toBeNull();
|
|
255
|
+
expect(getBudgetRefusalNotification(parentTask.id, yesterday)).not.toBeNull();
|
|
256
|
+
|
|
257
|
+
// Second refusal — fresh PK, fresh follow-up.
|
|
258
|
+
await callPoll(worker.id);
|
|
259
|
+
const followUps = listFollowUpTasks(parentTask.id);
|
|
260
|
+
expect(followUps).toHaveLength(2);
|
|
261
|
+
// The newer dedup row exists for today.
|
|
262
|
+
expect(getBudgetRefusalNotification(parentTask.id, todayUtc())).not.toBeNull();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("workflow event bus receives task.budget_refused on every refusal (not just first)", async () => {
|
|
266
|
+
createAgent({ name: "lead-5", isLead: true, status: "idle", maxTasks: 5 });
|
|
267
|
+
const worker = createAgent({ name: "worker-5", isLead: false, status: "idle", maxTasks: 1 });
|
|
268
|
+
insertBudget("agent", worker.id, 0.01);
|
|
269
|
+
insertSpend(worker.id, 0.05);
|
|
270
|
+
|
|
271
|
+
const parentTask = createTaskExtended("event-bus task", { agentId: worker.id });
|
|
272
|
+
|
|
273
|
+
const events: Array<{ taskId: string; agentId: string; cause: string }> = [];
|
|
274
|
+
const handler = (data: unknown) => {
|
|
275
|
+
events.push(data as { taskId: string; agentId: string; cause: string });
|
|
276
|
+
};
|
|
277
|
+
workflowEventBus.on("task.budget_refused", handler);
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
await callPoll(worker.id);
|
|
281
|
+
await waitForBusEmit();
|
|
282
|
+
await callPoll(worker.id);
|
|
283
|
+
await waitForBusEmit();
|
|
284
|
+
|
|
285
|
+
// Both refusals must reach the bus, even though only the first creates
|
|
286
|
+
// a follow-up task.
|
|
287
|
+
expect(events.length).toBeGreaterThanOrEqual(2);
|
|
288
|
+
for (const ev of events) {
|
|
289
|
+
expect(ev.taskId).toBe(parentTask.id);
|
|
290
|
+
expect(ev.agentId).toBe(worker.id);
|
|
291
|
+
expect(ev.cause).toBe("agent");
|
|
292
|
+
}
|
|
293
|
+
} finally {
|
|
294
|
+
workflowEventBus.off("task.budget_refused", handler);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("no follow-up created when there's no lead agent (refusal still emits + dedup row stays)", async () => {
|
|
299
|
+
// Workers only — no lead.
|
|
300
|
+
const worker = createAgent({
|
|
301
|
+
name: "worker-no-lead",
|
|
302
|
+
isLead: false,
|
|
303
|
+
status: "idle",
|
|
304
|
+
maxTasks: 1,
|
|
305
|
+
});
|
|
306
|
+
insertBudget("agent", worker.id, 0.01);
|
|
307
|
+
insertSpend(worker.id, 0.05);
|
|
308
|
+
|
|
309
|
+
const parentTask = createTaskExtended("no-lead task", { agentId: worker.id });
|
|
310
|
+
|
|
311
|
+
const { body } = await callPoll(worker.id);
|
|
312
|
+
if ("error" in body) throw new Error("unexpected error response");
|
|
313
|
+
expect(body.trigger?.type).toBe("budget_refused");
|
|
314
|
+
|
|
315
|
+
expect(listFollowUpTasks(parentTask.id)).toHaveLength(0);
|
|
316
|
+
// The dedup row is still recorded — write-back is a best-effort step that
|
|
317
|
+
// won't run when there's no follow-up to link, but the row's existence
|
|
318
|
+
// is what serves as the operator's "the lead was already notified
|
|
319
|
+
// (theoretically)" audit signal.
|
|
320
|
+
const dedupRow = getBudgetRefusalNotification(parentTask.id, todayUtc());
|
|
321
|
+
expect(dedupRow).not.toBeNull();
|
|
322
|
+
expect(dedupRow?.followUpTaskId).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Phase 6: REST CRUD + audit-log + auth tests for /api/budgets/*.
|
|
2
|
+
//
|
|
3
|
+
// Spins up a real HTTP server using `handleCore` (auth gate) → `handleBudgets`
|
|
4
|
+
// so we exercise the full request lifecycle including the API-key bearer
|
|
5
|
+
// check. Direct invocation of `handleBudgets` would bypass auth.
|
|
6
|
+
|
|
7
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { unlink } from "node:fs/promises";
|
|
9
|
+
import {
|
|
10
|
+
createServer as createHttpServer,
|
|
11
|
+
type IncomingMessage,
|
|
12
|
+
type Server,
|
|
13
|
+
type ServerResponse,
|
|
14
|
+
} from "node:http";
|
|
15
|
+
import {
|
|
16
|
+
closeDb,
|
|
17
|
+
getDb,
|
|
18
|
+
getLogsByEventType,
|
|
19
|
+
initDb,
|
|
20
|
+
recordBudgetRefusalNotification,
|
|
21
|
+
} from "../be/db";
|
|
22
|
+
import { handleBudgets } from "../http/budgets";
|
|
23
|
+
import { handleCore } from "../http/core";
|
|
24
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
25
|
+
|
|
26
|
+
const TEST_DB_PATH = "./test-budgets-routes.sqlite";
|
|
27
|
+
const API_KEY = "test-budget-secret-key";
|
|
28
|
+
|
|
29
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
30
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
31
|
+
try {
|
|
32
|
+
await unlink(path + suffix);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function listen(server: Server): Promise<number> {
|
|
40
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
41
|
+
const addr = server.address();
|
|
42
|
+
if (!addr || typeof addr === "string") throw new Error("no port");
|
|
43
|
+
return addr.port;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createTestServer(apiKey: string): Server {
|
|
47
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
48
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
49
|
+
const handled = await handleCore(req, res, myAgentId, apiKey);
|
|
50
|
+
if (handled) return;
|
|
51
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
52
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
53
|
+
const ok = await handleBudgets(req, res, pathSegments, queryParams, myAgentId);
|
|
54
|
+
if (!ok) {
|
|
55
|
+
res.writeHead(404);
|
|
56
|
+
res.end("Not Found");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let server: Server;
|
|
62
|
+
let port: number;
|
|
63
|
+
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
66
|
+
initDb(TEST_DB_PATH);
|
|
67
|
+
server = createTestServer(API_KEY);
|
|
68
|
+
port = await listen(server);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
73
|
+
closeDb();
|
|
74
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
const db = getDb();
|
|
79
|
+
db.prepare("DELETE FROM budgets").run();
|
|
80
|
+
db.prepare("DELETE FROM agent_log WHERE eventType LIKE 'budget.%'").run();
|
|
81
|
+
db.prepare("DELETE FROM budget_refusal_notifications").run();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
85
|
+
return fetch(`http://localhost:${port}${path}`, {
|
|
86
|
+
...init,
|
|
87
|
+
headers: {
|
|
88
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
...(init.headers ?? {}),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("Phase 6 — /api/budgets REST surface", () => {
|
|
96
|
+
describe("auth", () => {
|
|
97
|
+
test("401 when Authorization header is missing", async () => {
|
|
98
|
+
const res = await fetch(`http://localhost:${port}/api/budgets`);
|
|
99
|
+
expect(res.status).toBe(401);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("401 when bearer is wrong", async () => {
|
|
103
|
+
const res = await fetch(`http://localhost:${port}/api/budgets`, {
|
|
104
|
+
headers: { Authorization: "Bearer WRONG" },
|
|
105
|
+
});
|
|
106
|
+
expect(res.status).toBe(401);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("CRUD round-trip", () => {
|
|
111
|
+
test("PUT creates → GET returns 200 with the row → list includes it", async () => {
|
|
112
|
+
const agentId = "agent-uuid-1";
|
|
113
|
+
const putRes = await authedFetch(`/api/budgets/agent/${agentId}`, {
|
|
114
|
+
method: "PUT",
|
|
115
|
+
body: JSON.stringify({ dailyBudgetUsd: 5 }),
|
|
116
|
+
});
|
|
117
|
+
expect(putRes.status).toBe(200);
|
|
118
|
+
const created = await putRes.json();
|
|
119
|
+
expect(created.scope).toBe("agent");
|
|
120
|
+
expect(created.scopeId).toBe(agentId);
|
|
121
|
+
expect(created.dailyBudgetUsd).toBe(5);
|
|
122
|
+
|
|
123
|
+
const getRes = await authedFetch(`/api/budgets/agent/${agentId}`);
|
|
124
|
+
expect(getRes.status).toBe(200);
|
|
125
|
+
const fetched = await getRes.json();
|
|
126
|
+
expect(fetched.dailyBudgetUsd).toBe(5);
|
|
127
|
+
|
|
128
|
+
const listRes = await authedFetch(`/api/budgets`);
|
|
129
|
+
expect(listRes.status).toBe(200);
|
|
130
|
+
const listBody = await listRes.json();
|
|
131
|
+
expect(listBody.budgets).toBeInstanceOf(Array);
|
|
132
|
+
expect(listBody.budgets.length).toBe(1);
|
|
133
|
+
expect(listBody.budgets[0].scopeId).toBe(agentId);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("PUT upserts an existing row", async () => {
|
|
137
|
+
const agentId = "agent-uuid-2";
|
|
138
|
+
await authedFetch(`/api/budgets/agent/${agentId}`, {
|
|
139
|
+
method: "PUT",
|
|
140
|
+
body: JSON.stringify({ dailyBudgetUsd: 1 }),
|
|
141
|
+
});
|
|
142
|
+
const putRes = await authedFetch(`/api/budgets/agent/${agentId}`, {
|
|
143
|
+
method: "PUT",
|
|
144
|
+
body: JSON.stringify({ dailyBudgetUsd: 9 }),
|
|
145
|
+
});
|
|
146
|
+
expect(putRes.status).toBe(200);
|
|
147
|
+
const updated = await putRes.json();
|
|
148
|
+
expect(updated.dailyBudgetUsd).toBe(9);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("PUT for the global scope uses '_global' wire placeholder", async () => {
|
|
152
|
+
const putRes = await authedFetch(`/api/budgets/global/_global`, {
|
|
153
|
+
method: "PUT",
|
|
154
|
+
body: JSON.stringify({ dailyBudgetUsd: 100 }),
|
|
155
|
+
});
|
|
156
|
+
expect(putRes.status).toBe(200);
|
|
157
|
+
const created = await putRes.json();
|
|
158
|
+
expect(created.scope).toBe("global");
|
|
159
|
+
expect(created.scopeId).toBe("");
|
|
160
|
+
|
|
161
|
+
const getRes = await authedFetch(`/api/budgets/global/_global`);
|
|
162
|
+
expect(getRes.status).toBe(200);
|
|
163
|
+
const fetched = await getRes.json();
|
|
164
|
+
expect(fetched.scopeId).toBe("");
|
|
165
|
+
expect(fetched.dailyBudgetUsd).toBe(100);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("GET returns 404 for missing budget", async () => {
|
|
169
|
+
const getRes = await authedFetch(`/api/budgets/agent/does-not-exist`);
|
|
170
|
+
expect(getRes.status).toBe(404);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("DELETE returns 204 then 404 on subsequent GET", async () => {
|
|
174
|
+
const agentId = "agent-uuid-3";
|
|
175
|
+
await authedFetch(`/api/budgets/agent/${agentId}`, {
|
|
176
|
+
method: "PUT",
|
|
177
|
+
body: JSON.stringify({ dailyBudgetUsd: 5 }),
|
|
178
|
+
});
|
|
179
|
+
const delRes = await authedFetch(`/api/budgets/agent/${agentId}`, { method: "DELETE" });
|
|
180
|
+
expect(delRes.status).toBe(204);
|
|
181
|
+
const getRes = await authedFetch(`/api/budgets/agent/${agentId}`);
|
|
182
|
+
expect(getRes.status).toBe(404);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("DELETE on missing row returns 404", async () => {
|
|
186
|
+
const delRes = await authedFetch(`/api/budgets/agent/never-existed`, { method: "DELETE" });
|
|
187
|
+
expect(delRes.status).toBe(404);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("PUT 400 when dailyBudgetUsd is missing", async () => {
|
|
191
|
+
const res = await authedFetch(`/api/budgets/agent/x`, {
|
|
192
|
+
method: "PUT",
|
|
193
|
+
body: JSON.stringify({}),
|
|
194
|
+
});
|
|
195
|
+
expect(res.status).toBe(400);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("PUT 400 when dailyBudgetUsd is negative", async () => {
|
|
199
|
+
const res = await authedFetch(`/api/budgets/agent/x`, {
|
|
200
|
+
method: "PUT",
|
|
201
|
+
body: JSON.stringify({ dailyBudgetUsd: -1 }),
|
|
202
|
+
});
|
|
203
|
+
expect(res.status).toBe(400);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("PUT 400 on invalid scope", async () => {
|
|
207
|
+
const res = await authedFetch(`/api/budgets/team/x`, {
|
|
208
|
+
method: "PUT",
|
|
209
|
+
body: JSON.stringify({ dailyBudgetUsd: 1 }),
|
|
210
|
+
});
|
|
211
|
+
expect(res.status).toBe(400);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("audit logging", () => {
|
|
216
|
+
test("PUT writes a budget.upserted log row with key fingerprint and before/after", async () => {
|
|
217
|
+
const agentId = "audit-agent-1";
|
|
218
|
+
await authedFetch(`/api/budgets/agent/${agentId}`, {
|
|
219
|
+
method: "PUT",
|
|
220
|
+
body: JSON.stringify({ dailyBudgetUsd: 3 }),
|
|
221
|
+
});
|
|
222
|
+
// Update — should produce a SECOND audit row with before set.
|
|
223
|
+
await authedFetch(`/api/budgets/agent/${agentId}`, {
|
|
224
|
+
method: "PUT",
|
|
225
|
+
body: JSON.stringify({ dailyBudgetUsd: 7 }),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const logs = getLogsByEventType("budget.upserted");
|
|
229
|
+
expect(logs.length).toBe(2);
|
|
230
|
+
|
|
231
|
+
// Logs land within the same millisecond so the DESC-by-createdAt order
|
|
232
|
+
// is non-deterministic between the two. Identify them by payload shape:
|
|
233
|
+
// the insert log has `before === null`; the update log has the previous
|
|
234
|
+
// dailyBudgetUsd in `before`.
|
|
235
|
+
const metas = logs.map((l) => JSON.parse(l.metadata!));
|
|
236
|
+
const insertMeta = metas.find((m) => m.before === null)!;
|
|
237
|
+
const updateMeta = metas.find((m) => m.before !== null)!;
|
|
238
|
+
|
|
239
|
+
expect(insertMeta.scope).toBe("agent");
|
|
240
|
+
expect(insertMeta.scopeId).toBe(agentId);
|
|
241
|
+
expect(insertMeta.before).toBeNull();
|
|
242
|
+
expect(insertMeta.after.dailyBudgetUsd).toBe(3);
|
|
243
|
+
expect(insertMeta.apiKeyFingerprint).toMatch(/^[a-f0-9]{8}$/);
|
|
244
|
+
// Raw key MUST NEVER appear in the metadata payload.
|
|
245
|
+
for (const log of logs) {
|
|
246
|
+
expect(log.metadata).not.toContain(API_KEY);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
expect(updateMeta.before.dailyBudgetUsd).toBe(3);
|
|
250
|
+
expect(updateMeta.after.dailyBudgetUsd).toBe(7);
|
|
251
|
+
expect(updateMeta.apiKeyFingerprint).toMatch(/^[a-f0-9]{8}$/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("GET /api/budgets/refusals returns recent refusals newest first", async () => {
|
|
255
|
+
// Seed three refusals across two days/agents.
|
|
256
|
+
recordBudgetRefusalNotification({
|
|
257
|
+
taskId: "task-old",
|
|
258
|
+
date: "2026-04-26",
|
|
259
|
+
agentId: "agent-A",
|
|
260
|
+
cause: "agent",
|
|
261
|
+
agentSpendUsd: 1.5,
|
|
262
|
+
agentBudgetUsd: 1.0,
|
|
263
|
+
});
|
|
264
|
+
// Force a small gap so createdAt ordering is deterministic.
|
|
265
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
266
|
+
recordBudgetRefusalNotification({
|
|
267
|
+
taskId: "task-mid",
|
|
268
|
+
date: "2026-04-27",
|
|
269
|
+
agentId: "agent-B",
|
|
270
|
+
cause: "global",
|
|
271
|
+
globalSpendUsd: 50,
|
|
272
|
+
globalBudgetUsd: 40,
|
|
273
|
+
});
|
|
274
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
275
|
+
recordBudgetRefusalNotification({
|
|
276
|
+
taskId: "task-new",
|
|
277
|
+
date: "2026-04-28",
|
|
278
|
+
agentId: "agent-A",
|
|
279
|
+
cause: "agent",
|
|
280
|
+
agentSpendUsd: 2.0,
|
|
281
|
+
agentBudgetUsd: 1.5,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const res = await authedFetch(`/api/budgets/refusals`);
|
|
285
|
+
expect(res.status).toBe(200);
|
|
286
|
+
const body = await res.json();
|
|
287
|
+
expect(body.refusals).toBeInstanceOf(Array);
|
|
288
|
+
expect(body.refusals.length).toBe(3);
|
|
289
|
+
// Newest first by createdAt.
|
|
290
|
+
expect(body.refusals[0].taskId).toBe("task-new");
|
|
291
|
+
expect(body.refusals[1].taskId).toBe("task-mid");
|
|
292
|
+
expect(body.refusals[2].taskId).toBe("task-old");
|
|
293
|
+
expect(body.refusals[0].cause).toBe("agent");
|
|
294
|
+
expect(body.refusals[1].cause).toBe("global");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("GET /api/budgets/refusals respects limit query param", async () => {
|
|
298
|
+
for (let i = 0; i < 5; i++) {
|
|
299
|
+
recordBudgetRefusalNotification({
|
|
300
|
+
taskId: `task-${i}`,
|
|
301
|
+
date: "2026-04-28",
|
|
302
|
+
agentId: "agent-A",
|
|
303
|
+
cause: "agent",
|
|
304
|
+
});
|
|
305
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
306
|
+
}
|
|
307
|
+
const res = await authedFetch(`/api/budgets/refusals?limit=2`);
|
|
308
|
+
expect(res.status).toBe(200);
|
|
309
|
+
const body = await res.json();
|
|
310
|
+
expect(body.refusals.length).toBe(2);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("DELETE writes a budget.deleted log row with key fingerprint", async () => {
|
|
314
|
+
const agentId = "audit-agent-2";
|
|
315
|
+
await authedFetch(`/api/budgets/agent/${agentId}`, {
|
|
316
|
+
method: "PUT",
|
|
317
|
+
body: JSON.stringify({ dailyBudgetUsd: 4 }),
|
|
318
|
+
});
|
|
319
|
+
await authedFetch(`/api/budgets/agent/${agentId}`, { method: "DELETE" });
|
|
320
|
+
|
|
321
|
+
const logs = getLogsByEventType("budget.deleted");
|
|
322
|
+
expect(logs.length).toBe(1);
|
|
323
|
+
const meta = JSON.parse(logs[0].metadata!);
|
|
324
|
+
expect(meta.scope).toBe("agent");
|
|
325
|
+
expect(meta.scopeId).toBe(agentId);
|
|
326
|
+
expect(meta.before.dailyBudgetUsd).toBe(4);
|
|
327
|
+
expect(meta.apiKeyFingerprint).toMatch(/^[a-f0-9]{8}$/);
|
|
328
|
+
expect(logs[0].metadata).not.toContain(API_KEY);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
});
|