@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.
Files changed (62) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +994 -62
  3. package/package.json +2 -1
  4. package/src/be/budget-admission.ts +121 -0
  5. package/src/be/budget-refusal-notify.ts +145 -0
  6. package/src/be/db.ts +488 -5
  7. package/src/be/migrations/044_provider_meta.sql +2 -0
  8. package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
  9. package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
  10. package/src/cli.tsx +22 -1
  11. package/src/commands/claude-managed-setup.ts +687 -0
  12. package/src/commands/codex-login.ts +1 -1
  13. package/src/commands/runner.ts +175 -28
  14. package/src/commands/templates.ts +10 -6
  15. package/src/http/budgets.ts +219 -0
  16. package/src/http/index.ts +6 -0
  17. package/src/http/integrations.ts +134 -0
  18. package/src/http/poll.ts +161 -3
  19. package/src/http/pricing.ts +245 -0
  20. package/src/http/session-data.ts +54 -6
  21. package/src/http/tasks.ts +23 -2
  22. package/src/prompts/base-prompt.ts +103 -73
  23. package/src/prompts/session-templates.ts +43 -0
  24. package/src/providers/claude-adapter.ts +3 -1
  25. package/src/providers/claude-managed-adapter.ts +871 -0
  26. package/src/providers/claude-managed-models.ts +117 -0
  27. package/src/providers/claude-managed-swarm-events.ts +77 -0
  28. package/src/providers/codex-adapter.ts +3 -1
  29. package/src/providers/codex-skill-resolver.ts +10 -0
  30. package/src/providers/codex-swarm-events.ts +20 -161
  31. package/src/providers/devin-adapter.ts +894 -0
  32. package/src/providers/devin-api.ts +207 -0
  33. package/src/providers/devin-playbooks.ts +91 -0
  34. package/src/providers/devin-skill-resolver.ts +113 -0
  35. package/src/providers/index.ts +10 -1
  36. package/src/providers/pi-mono-adapter.ts +3 -1
  37. package/src/providers/swarm-events-shared.ts +262 -0
  38. package/src/providers/types.ts +26 -1
  39. package/src/tests/base-prompt.test.ts +199 -0
  40. package/src/tests/budget-admission.test.ts +339 -0
  41. package/src/tests/budget-claim-gate.test.ts +288 -0
  42. package/src/tests/budget-refusal-notification.test.ts +324 -0
  43. package/src/tests/budgets-routes.test.ts +331 -0
  44. package/src/tests/claude-managed-adapter.test.ts +1301 -0
  45. package/src/tests/claude-managed-setup.test.ts +325 -0
  46. package/src/tests/devin-adapter.test.ts +677 -0
  47. package/src/tests/devin-api.test.ts +339 -0
  48. package/src/tests/integrations-http.test.ts +211 -0
  49. package/src/tests/migration-046-budgets.test.ts +327 -0
  50. package/src/tests/pricing-routes.test.ts +315 -0
  51. package/src/tests/prompt-template-remaining.test.ts +4 -0
  52. package/src/tests/prompt-template-session.test.ts +2 -2
  53. package/src/tests/provider-adapter.test.ts +1 -1
  54. package/src/tests/runner-budget-refused.test.ts +271 -0
  55. package/src/tests/session-costs-codex-recompute.test.ts +386 -0
  56. package/src/tools/poll-task.ts +13 -2
  57. package/src/tools/task-action.ts +92 -2
  58. package/src/tools/templates.ts +29 -0
  59. package/src/types.ts +116 -0
  60. package/src/utils/budget-backoff.ts +34 -0
  61. package/src/utils/credentials.ts +4 -0
  62. package/src/utils/provider-metadata.ts +9 -0
package/src/be/db.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { parseProviderMeta } from "@/utils/provider-metadata.ts";
2
3
  import pkg from "../../package.json";
3
4
  import { addEyesReactionOnTaskStart } from "../github/task-reactions";
4
5
  import { configureDbResolver } from "../prompts/resolver";
@@ -14,6 +15,10 @@ import type {
14
15
  AgentTaskSource,
15
16
  AgentTaskStatus,
16
17
  AgentWithTasks,
18
+ Budget,
19
+ BudgetRefusalCause,
20
+ BudgetRefusalNotification,
21
+ BudgetScope,
17
22
  ChangeSource,
18
23
  Channel,
19
24
  ChannelMessage,
@@ -22,6 +27,7 @@ import type {
22
27
  ContextSnapshotEventType,
23
28
  ContextVersion,
24
29
  CooldownConfig,
30
+ DevinProviderMeta,
25
31
  InboxMessage,
26
32
  InboxMessageStatus,
27
33
  InputValue,
@@ -29,13 +35,18 @@ import type {
29
35
  McpServerScope,
30
36
  McpServerTransport,
31
37
  McpServerWithInstallInfo,
38
+ PricingProvider,
39
+ PricingRow,
40
+ PricingTokenClass,
32
41
  PromptTemplate,
33
42
  PromptTemplateHistory,
43
+ ProviderName,
34
44
  RepoGuidelines,
35
45
  ScheduledTask,
36
46
  Service,
37
47
  ServiceStatus,
38
48
  SessionCost,
49
+ SessionCostSource,
39
50
  SessionLog,
40
51
  Skill,
41
52
  SkillScope,
@@ -819,6 +830,8 @@ type AgentTaskRow = {
819
830
  credentialKeyType: string | null;
820
831
  requestedByUserId: string | null;
821
832
  swarmVersion: string | null;
833
+ provider: string | null;
834
+ providerMeta: string | null;
822
835
  };
823
836
 
824
837
  function rowToAgentTask(row: AgentTaskRow): AgentTask {
@@ -880,6 +893,8 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
880
893
  credentialKeyType: row.credentialKeyType ?? undefined,
881
894
  requestedByUserId: row.requestedByUserId ?? undefined,
882
895
  swarmVersion: row.swarmVersion ?? undefined,
896
+ provider: (row.provider as ProviderName | null) ?? undefined,
897
+ providerMeta: parseProviderMeta(row.provider as ProviderName | null, row.providerMeta),
883
898
  };
884
899
  }
885
900
 
@@ -1061,12 +1076,28 @@ export function getChildTasks(parentTaskId: string): AgentTask[] {
1061
1076
  export function updateTaskClaudeSessionId(
1062
1077
  taskId: string,
1063
1078
  claudeSessionId: string,
1079
+ provider?: ProviderName,
1080
+ providerMeta?: Record<string, unknown>,
1064
1081
  ): AgentTask | null {
1082
+ const setClauses = ["claudeSessionId = ?", "lastUpdatedAt = ?"];
1083
+ const params: (string | null)[] = [claudeSessionId, new Date().toISOString()];
1084
+
1085
+ if (provider !== undefined) {
1086
+ setClauses.push("provider = ?");
1087
+ params.push(provider);
1088
+ }
1089
+ if (providerMeta !== undefined) {
1090
+ setClauses.push("providerMeta = ?");
1091
+ params.push(JSON.stringify(providerMeta));
1092
+ }
1093
+
1094
+ params.push(taskId);
1095
+
1065
1096
  const row = getDb()
1066
- .prepare<AgentTaskRow, [string, string, string]>(
1067
- `UPDATE agent_tasks SET claudeSessionId = ?, lastUpdatedAt = ? WHERE id = ? RETURNING *`,
1097
+ .prepare<AgentTaskRow, (string | null)[]>(
1098
+ `UPDATE agent_tasks SET ${setClauses.join(", ")} WHERE id = ? RETURNING *`,
1068
1099
  )
1069
- .get(claudeSessionId, new Date().toISOString(), taskId);
1100
+ .get(...params);
1070
1101
  return row ? rowToAgentTask(row) : null;
1071
1102
  }
1072
1103
 
@@ -1909,6 +1940,14 @@ export function getLogsByTaskIdChronological(taskId: string): AgentLog[] {
1909
1940
  .map(rowToAgentLog);
1910
1941
  }
1911
1942
 
1943
+ /**
1944
+ * Phase 6: list all log rows of a given eventType, newest first. Used by the
1945
+ * REST audit-log tests to assert mutation rows landed.
1946
+ */
1947
+ export function getLogsByEventType(eventType: AgentLogEventType): AgentLog[] {
1948
+ return logQueries.getByEventType().all(eventType).map(rowToAgentLog);
1949
+ }
1950
+
1912
1951
  export function getAllLogs(limit?: number): AgentLog[] {
1913
1952
  if (limit) {
1914
1953
  return getDb()
@@ -3478,6 +3517,7 @@ type SessionCostRow = {
3478
3517
  numTurns: number;
3479
3518
  model: string;
3480
3519
  isError: number;
3520
+ costSource: string;
3481
3521
  createdAt: string;
3482
3522
  };
3483
3523
 
@@ -3496,6 +3536,7 @@ function rowToSessionCost(row: SessionCostRow): SessionCost {
3496
3536
  numTurns: row.numTurns,
3497
3537
  model: row.model,
3498
3538
  isError: row.isError === 1,
3539
+ costSource: (row.costSource as SessionCostSource) ?? "harness",
3499
3540
  createdAt: row.createdAt,
3500
3541
  };
3501
3542
  }
@@ -3518,10 +3559,11 @@ const sessionCostQueries = {
3518
3559
  number,
3519
3560
  string,
3520
3561
  number,
3562
+ string,
3521
3563
  ]
3522
3564
  >(
3523
- `INSERT INTO session_costs (id, sessionId, taskId, agentId, totalCostUsd, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, durationMs, numTurns, model, isError, createdAt)
3524
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
3565
+ `INSERT INTO session_costs (id, sessionId, taskId, agentId, totalCostUsd, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, durationMs, numTurns, model, isError, costSource, createdAt)
3566
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
3525
3567
  ),
3526
3568
 
3527
3569
  getByTaskId: () =>
@@ -3553,10 +3595,19 @@ export interface CreateSessionCostInput {
3553
3595
  numTurns: number;
3554
3596
  model: string;
3555
3597
  isError?: boolean;
3598
+ /**
3599
+ * Phase 6: where the recorded `totalCostUsd` came from.
3600
+ * - 'harness' — value reported by the harness as-is (default).
3601
+ * - 'pricing-table' — value recomputed by the API from `pricing` rows
3602
+ * (Codex when DB pricing rows exist for all three
3603
+ * token classes).
3604
+ */
3605
+ costSource?: SessionCostSource;
3556
3606
  }
3557
3607
 
3558
3608
  export function createSessionCost(input: CreateSessionCostInput): SessionCost {
3559
3609
  const id = crypto.randomUUID();
3610
+ const costSource: SessionCostSource = input.costSource ?? "harness";
3560
3611
  sessionCostQueries
3561
3612
  .insert()
3562
3613
  .run(
@@ -3573,6 +3624,7 @@ export function createSessionCost(input: CreateSessionCostInput): SessionCost {
3573
3624
  input.numTurns,
3574
3625
  input.model,
3575
3626
  input.isError ? 1 : 0,
3627
+ costSource,
3576
3628
  );
3577
3629
 
3578
3630
  return {
@@ -3589,6 +3641,7 @@ export function createSessionCost(input: CreateSessionCostInput): SessionCost {
3589
3641
  numTurns: input.numTurns,
3590
3642
  model: input.model,
3591
3643
  isError: input.isError ?? false,
3644
+ costSource,
3592
3645
  createdAt: new Date().toISOString(),
3593
3646
  };
3594
3647
  }
@@ -8070,3 +8123,433 @@ export function deleteUser(id: string): boolean {
8070
8123
  const result = getDb().prepare("DELETE FROM users WHERE id = ?").run(id);
8071
8124
  return result.changes > 0;
8072
8125
  }
8126
+
8127
+ // ============================================================================
8128
+ // Budgets, daily-spend aggregation, and budget-refusal notifications (Phase 2)
8129
+ // ----------------------------------------------------------------------------
8130
+ // `budgets` and `budget_refusal_notifications` use INTEGER epoch-ms for their
8131
+ // `createdAt` / `lastUpdatedAt` columns (deliberate divergence — see migration
8132
+ // 044). All inserts here use `Date.now()` accordingly.
8133
+ // ============================================================================
8134
+
8135
+ interface BudgetRow {
8136
+ scope: string;
8137
+ scope_id: string;
8138
+ daily_budget_usd: number;
8139
+ createdAt: number;
8140
+ lastUpdatedAt: number;
8141
+ }
8142
+
8143
+ interface BudgetRefusalNotificationRow {
8144
+ task_id: string;
8145
+ date: string;
8146
+ agent_id: string;
8147
+ cause: string;
8148
+ agent_spend_usd: number | null;
8149
+ agent_budget_usd: number | null;
8150
+ global_spend_usd: number | null;
8151
+ global_budget_usd: number | null;
8152
+ follow_up_task_id: string | null;
8153
+ createdAt: number;
8154
+ }
8155
+
8156
+ interface CoalesceSumRow {
8157
+ total: number;
8158
+ }
8159
+
8160
+ function rowToBudget(row: BudgetRow): Budget {
8161
+ return {
8162
+ scope: row.scope as BudgetScope,
8163
+ scopeId: row.scope_id,
8164
+ dailyBudgetUsd: row.daily_budget_usd,
8165
+ createdAt: row.createdAt,
8166
+ lastUpdatedAt: row.lastUpdatedAt,
8167
+ };
8168
+ }
8169
+
8170
+ function rowToBudgetRefusalNotification(
8171
+ row: BudgetRefusalNotificationRow,
8172
+ ): BudgetRefusalNotification {
8173
+ return {
8174
+ taskId: row.task_id,
8175
+ date: row.date,
8176
+ agentId: row.agent_id,
8177
+ cause: row.cause as BudgetRefusalCause,
8178
+ agentSpendUsd: row.agent_spend_usd ?? undefined,
8179
+ agentBudgetUsd: row.agent_budget_usd ?? undefined,
8180
+ globalSpendUsd: row.global_spend_usd ?? undefined,
8181
+ globalBudgetUsd: row.global_budget_usd ?? undefined,
8182
+ followUpTaskId: row.follow_up_task_id ?? undefined,
8183
+ createdAt: row.createdAt,
8184
+ };
8185
+ }
8186
+
8187
+ /**
8188
+ * Look up a single budget row by (scope, scopeId). Returns `null` when no row
8189
+ * exists — callers treat that as "unlimited / no budget configured".
8190
+ */
8191
+ export function getBudget(scope: BudgetScope, scopeId: string): Budget | null {
8192
+ const row = getDb()
8193
+ .prepare<BudgetRow, [string, string]>(
8194
+ "SELECT scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt FROM budgets WHERE scope = ? AND scope_id = ?",
8195
+ )
8196
+ .get(scope, scopeId);
8197
+ return row ? rowToBudget(row) : null;
8198
+ }
8199
+
8200
+ /**
8201
+ * Phase 6: list every budget row in the system. Used by `GET /api/budgets`.
8202
+ * Order is `(scope, scope_id)` for stable output across calls.
8203
+ */
8204
+ export function getBudgets(): Budget[] {
8205
+ return getDb()
8206
+ .prepare<BudgetRow, []>(
8207
+ "SELECT scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt FROM budgets ORDER BY scope, scope_id",
8208
+ )
8209
+ .all()
8210
+ .map(rowToBudget);
8211
+ }
8212
+
8213
+ /**
8214
+ * Phase 6: upsert a budget row. Creates the row if `(scope, scopeId)` does not
8215
+ * exist, otherwise updates `daily_budget_usd` and `lastUpdatedAt`. Returns the
8216
+ * resulting row in both cases.
8217
+ */
8218
+ export function upsertBudget(scope: BudgetScope, scopeId: string, dailyBudgetUsd: number): Budget {
8219
+ const now = Date.now();
8220
+ getDb()
8221
+ .prepare(
8222
+ `INSERT INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt)
8223
+ VALUES (?, ?, ?, ?, ?)
8224
+ ON CONFLICT(scope, scope_id) DO UPDATE SET
8225
+ daily_budget_usd = excluded.daily_budget_usd,
8226
+ lastUpdatedAt = excluded.lastUpdatedAt`,
8227
+ )
8228
+ .run(scope, scopeId, dailyBudgetUsd, now, now);
8229
+
8230
+ const updated = getBudget(scope, scopeId);
8231
+ if (!updated) {
8232
+ throw new Error(
8233
+ `upsertBudget: row missing after insert for (scope=${scope}, scopeId=${scopeId})`,
8234
+ );
8235
+ }
8236
+ return updated;
8237
+ }
8238
+
8239
+ /**
8240
+ * Phase 6: delete a budget row. Returns `true` if a row was deleted, `false`
8241
+ * if `(scope, scopeId)` did not exist.
8242
+ */
8243
+ export function deleteBudget(scope: BudgetScope, scopeId: string): boolean {
8244
+ const result = getDb()
8245
+ .prepare("DELETE FROM budgets WHERE scope = ? AND scope_id = ?")
8246
+ .run(scope, scopeId);
8247
+ return result.changes > 0;
8248
+ }
8249
+
8250
+ // ============================================================================
8251
+ // Pricing rows (Phase 6 — append-only price book)
8252
+ // ----------------------------------------------------------------------------
8253
+ // `pricing` uses INTEGER epoch-ms for `effective_from`, `createdAt`,
8254
+ // `lastUpdatedAt` (see migration 044). Append-only by design: operators add a
8255
+ // new row with a later `effective_from` rather than mutating an existing row.
8256
+ // `getActivePricingRow` resolves the row with the largest
8257
+ // `effective_from <= atEpochMs`, which is the correct "what price was in
8258
+ // effect at time T" semantics regardless of insertion order.
8259
+ // ============================================================================
8260
+
8261
+ interface PricingRowDb {
8262
+ provider: string;
8263
+ model: string;
8264
+ token_class: string;
8265
+ effective_from: number;
8266
+ price_per_million_usd: number;
8267
+ createdAt: number;
8268
+ lastUpdatedAt: number;
8269
+ }
8270
+
8271
+ function rowToPricingRow(row: PricingRowDb): PricingRow {
8272
+ return {
8273
+ provider: row.provider as PricingProvider,
8274
+ model: row.model,
8275
+ tokenClass: row.token_class as PricingTokenClass,
8276
+ effectiveFrom: row.effective_from,
8277
+ pricePerMillionUsd: row.price_per_million_usd,
8278
+ createdAt: row.createdAt,
8279
+ lastUpdatedAt: row.lastUpdatedAt,
8280
+ };
8281
+ }
8282
+
8283
+ /** Phase 6: list every pricing row, latest-effective first. */
8284
+ export function getAllPricingRows(): PricingRow[] {
8285
+ return getDb()
8286
+ .prepare<PricingRowDb, []>(
8287
+ "SELECT provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt FROM pricing ORDER BY provider, model, token_class, effective_from DESC",
8288
+ )
8289
+ .all()
8290
+ .map(rowToPricingRow);
8291
+ }
8292
+
8293
+ /**
8294
+ * Phase 6: list every pricing row for a given (provider, model, tokenClass)
8295
+ * triple. Order is `effective_from DESC` so newest is first.
8296
+ */
8297
+ export function getPricingRows(
8298
+ provider: PricingProvider,
8299
+ model: string,
8300
+ tokenClass: PricingTokenClass,
8301
+ ): PricingRow[] {
8302
+ return getDb()
8303
+ .prepare<PricingRowDb, [string, string, string]>(
8304
+ "SELECT provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt FROM pricing WHERE provider = ? AND model = ? AND token_class = ? ORDER BY effective_from DESC",
8305
+ )
8306
+ .all(provider, model, tokenClass)
8307
+ .map(rowToPricingRow);
8308
+ }
8309
+
8310
+ /**
8311
+ * Phase 6: resolve "what price was in effect at time `atEpochMs`" — the row
8312
+ * with the largest `effective_from <= atEpochMs`. Returns null when no row
8313
+ * matches (model unseeded for that triple at that time). Backed by the
8314
+ * `idx_pricing_lookup` index from migration 044.
8315
+ */
8316
+ export function getActivePricingRow(
8317
+ provider: PricingProvider,
8318
+ model: string,
8319
+ tokenClass: PricingTokenClass,
8320
+ atEpochMs: number,
8321
+ ): PricingRow | null {
8322
+ const row = getDb()
8323
+ .prepare<PricingRowDb, [string, string, string, number]>(
8324
+ "SELECT provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt FROM pricing WHERE provider = ? AND model = ? AND token_class = ? AND effective_from <= ? ORDER BY effective_from DESC LIMIT 1",
8325
+ )
8326
+ .get(provider, model, tokenClass, atEpochMs);
8327
+ return row ? rowToPricingRow(row) : null;
8328
+ }
8329
+
8330
+ export interface InsertPricingRowInput {
8331
+ provider: PricingProvider;
8332
+ model: string;
8333
+ tokenClass: PricingTokenClass;
8334
+ effectiveFrom: number;
8335
+ pricePerMillionUsd: number;
8336
+ }
8337
+
8338
+ /**
8339
+ * Phase 6: insert a new pricing row. Throws on PK collision
8340
+ * `(provider, model, token_class, effective_from)` — caller (the HTTP route)
8341
+ * translates that into a 409.
8342
+ */
8343
+ export function insertPricingRow(input: InsertPricingRowInput): PricingRow {
8344
+ const now = Date.now();
8345
+ getDb()
8346
+ .prepare(
8347
+ `INSERT INTO pricing (provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt)
8348
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
8349
+ )
8350
+ .run(
8351
+ input.provider,
8352
+ input.model,
8353
+ input.tokenClass,
8354
+ input.effectiveFrom,
8355
+ input.pricePerMillionUsd,
8356
+ now,
8357
+ now,
8358
+ );
8359
+ return {
8360
+ provider: input.provider,
8361
+ model: input.model,
8362
+ tokenClass: input.tokenClass,
8363
+ effectiveFrom: input.effectiveFrom,
8364
+ pricePerMillionUsd: input.pricePerMillionUsd,
8365
+ createdAt: now,
8366
+ lastUpdatedAt: now,
8367
+ };
8368
+ }
8369
+
8370
+ /**
8371
+ * Phase 6: delete a pricing row. Returns true if a row was deleted, false if
8372
+ * the row did not exist. Discouraged operationally — historical session_costs
8373
+ * are not retroactively recomputed — but allowed for typo correction.
8374
+ */
8375
+ export function deletePricingRow(
8376
+ provider: PricingProvider,
8377
+ model: string,
8378
+ tokenClass: PricingTokenClass,
8379
+ effectiveFrom: number,
8380
+ ): boolean {
8381
+ const result = getDb()
8382
+ .prepare(
8383
+ "DELETE FROM pricing WHERE provider = ? AND model = ? AND token_class = ? AND effective_from = ?",
8384
+ )
8385
+ .run(provider, model, tokenClass, effectiveFrom);
8386
+ return result.changes > 0;
8387
+ }
8388
+
8389
+ /**
8390
+ * Sum of `totalCostUsd` across all `session_costs` rows for a given agent on a
8391
+ * given UTC calendar day. `dateUtc` MUST be `'YYYY-MM-DD'` (UTC). Returns 0
8392
+ * when no rows exist.
8393
+ *
8394
+ * Implementation note: we filter on `substr(createdAt, 1, 10) = ?` rather than
8395
+ * `date(createdAt / 1000, 'unixepoch') = ?` because `session_costs.createdAt`
8396
+ * is TEXT in ISO 8601 format (`'YYYY-MM-DDTHH:MM:SS.SSSZ'`), populated via
8397
+ * `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`. The left-anchored `substr` prefix
8398
+ * also lets the SQLite optimizer use the existing
8399
+ * `idx_session_costs_agent_createdAt` index (verified via EXPLAIN QUERY PLAN
8400
+ * in the test suite).
8401
+ */
8402
+ export function getDailySpendForAgent(agentId: string, dateUtc: string): number {
8403
+ const row = getDb()
8404
+ .prepare<CoalesceSumRow, [string, string]>(
8405
+ "SELECT COALESCE(SUM(totalCostUsd), 0) as total FROM session_costs WHERE agentId = ? AND substr(createdAt, 1, 10) = ?",
8406
+ )
8407
+ .get(agentId, dateUtc);
8408
+ return row?.total ?? 0;
8409
+ }
8410
+
8411
+ /**
8412
+ * Sum of `totalCostUsd` across all `session_costs` rows for a given UTC
8413
+ * calendar day, regardless of agent. `dateUtc` MUST be `'YYYY-MM-DD'` (UTC).
8414
+ *
8415
+ * NOTE: this query has no `agentId` prefix and therefore does not naturally
8416
+ * match the `(agentId, createdAt)` composite index. SQLite's optimizer may
8417
+ * pick `idx_session_costs_createdAt` (single-column on `createdAt`) — but
8418
+ * because the predicate is `substr(createdAt, 1, 10) = ?` rather than a range
8419
+ * scan, the planner often falls back to a full table scan. That is acceptable
8420
+ * for V1 daily-spend volumes; if it ever becomes a hotspot, a covering
8421
+ * functional index on `substr(createdAt, 1, 10)` would be the fix.
8422
+ */
8423
+ export function getDailySpendGlobal(dateUtc: string): number {
8424
+ const row = getDb()
8425
+ .prepare<CoalesceSumRow, [string]>(
8426
+ "SELECT COALESCE(SUM(totalCostUsd), 0) as total FROM session_costs WHERE substr(createdAt, 1, 10) = ?",
8427
+ )
8428
+ .get(dateUtc);
8429
+ return row?.total ?? 0;
8430
+ }
8431
+
8432
+ export interface RecordBudgetRefusalNotificationInput {
8433
+ taskId: string;
8434
+ date: string;
8435
+ agentId: string;
8436
+ cause: BudgetRefusalCause;
8437
+ agentSpendUsd?: number;
8438
+ agentBudgetUsd?: number;
8439
+ globalSpendUsd?: number;
8440
+ globalBudgetUsd?: number;
8441
+ }
8442
+
8443
+ /**
8444
+ * Idempotent insert of a budget-refusal notification keyed by
8445
+ * `(task_id, date)`. Returns `{ inserted: true, row }` on first call for that
8446
+ * key, or `{ inserted: false, row }` (with the original row) on subsequent
8447
+ * calls — used by the notification path to dedup "the agent told me about
8448
+ * this task already" across retries within the same UTC day.
8449
+ */
8450
+ export function recordBudgetRefusalNotification(input: RecordBudgetRefusalNotificationInput): {
8451
+ inserted: boolean;
8452
+ row: BudgetRefusalNotification;
8453
+ } {
8454
+ const db = getDb();
8455
+ const now = Date.now();
8456
+ const result = db
8457
+ .prepare(
8458
+ `INSERT OR IGNORE INTO budget_refusal_notifications
8459
+ (task_id, date, agent_id, cause, agent_spend_usd, agent_budget_usd, global_spend_usd, global_budget_usd, follow_up_task_id, createdAt)
8460
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
8461
+ )
8462
+ .run(
8463
+ input.taskId,
8464
+ input.date,
8465
+ input.agentId,
8466
+ input.cause,
8467
+ input.agentSpendUsd ?? null,
8468
+ input.agentBudgetUsd ?? null,
8469
+ input.globalSpendUsd ?? null,
8470
+ input.globalBudgetUsd ?? null,
8471
+ now,
8472
+ );
8473
+
8474
+ const existing = db
8475
+ .prepare<BudgetRefusalNotificationRow, [string, string]>(
8476
+ "SELECT * FROM budget_refusal_notifications WHERE task_id = ? AND date = ?",
8477
+ )
8478
+ .get(input.taskId, input.date);
8479
+
8480
+ if (!existing) {
8481
+ // Should be unreachable: INSERT OR IGNORE either inserts or leaves an
8482
+ // existing row. If we hit this it's a hard schema/runtime invariant break.
8483
+ throw new Error(
8484
+ `recordBudgetRefusalNotification: row missing after insert for (taskId=${input.taskId}, date=${input.date})`,
8485
+ );
8486
+ }
8487
+
8488
+ return {
8489
+ inserted: result.changes > 0,
8490
+ row: rowToBudgetRefusalNotification(existing),
8491
+ };
8492
+ }
8493
+
8494
+ /**
8495
+ * Lookup helper used by tests and by the Phase 5 follow-up-task write-back.
8496
+ */
8497
+ export function getBudgetRefusalNotification(
8498
+ taskId: string,
8499
+ date: string,
8500
+ ): BudgetRefusalNotification | null {
8501
+ const row = getDb()
8502
+ .prepare<BudgetRefusalNotificationRow, [string, string]>(
8503
+ "SELECT * FROM budget_refusal_notifications WHERE task_id = ? AND date = ?",
8504
+ )
8505
+ .get(taskId, date);
8506
+ return row ? rowToBudgetRefusalNotification(row) : null;
8507
+ }
8508
+
8509
+ /**
8510
+ * List recent budget refusal notifications across all tasks/dates, newest
8511
+ * first. Used by the operator dashboard to surface refusals as an
8512
+ * actionable feed (parent task → follow-up task link).
8513
+ */
8514
+ export function getRecentBudgetRefusalNotifications(limit = 50): BudgetRefusalNotification[] {
8515
+ const rows = getDb()
8516
+ .prepare<BudgetRefusalNotificationRow, [number]>(
8517
+ "SELECT * FROM budget_refusal_notifications ORDER BY createdAt DESC LIMIT ?",
8518
+ )
8519
+ .all(limit);
8520
+ return rows.map(rowToBudgetRefusalNotification);
8521
+ }
8522
+
8523
+ /**
8524
+ * Boolean observability helper — returns true iff a refusal notification has
8525
+ * already been recorded for `(taskId, date)`.
8526
+ */
8527
+ export function hasBudgetRefusalNotificationToday(taskId: string, date: string): boolean {
8528
+ const row = getDb()
8529
+ .prepare<{ one: number }, [string, string]>(
8530
+ "SELECT 1 as one FROM budget_refusal_notifications WHERE task_id = ? AND date = ? LIMIT 1",
8531
+ )
8532
+ .get(taskId, date);
8533
+ return row !== null;
8534
+ }
8535
+
8536
+ /**
8537
+ * Phase 5 write-back: link the freshly-created lead-facing follow-up task
8538
+ * back to its dedup row so operators can audit "find the lead-facing
8539
+ * follow-up that was created when this task was first refused".
8540
+ *
8541
+ * Idempotent — safe to call multiple times with the same `(taskId, date)`,
8542
+ * but only the first refusal per day creates a follow-up task in the first
8543
+ * place (see `recordBudgetRefusalNotification` for the dedup invariant).
8544
+ */
8545
+ export function setBudgetRefusalFollowUpTaskId(
8546
+ taskId: string,
8547
+ date: string,
8548
+ followUpTaskId: string,
8549
+ ): void {
8550
+ getDb()
8551
+ .prepare(
8552
+ "UPDATE budget_refusal_notifications SET follow_up_task_id = ? WHERE task_id = ? AND date = ?",
8553
+ )
8554
+ .run(followUpTaskId, taskId, date);
8555
+ }
@@ -0,0 +1,2 @@
1
+ ALTER TABLE agent_tasks ADD COLUMN provider TEXT;
2
+ ALTER TABLE agent_tasks ADD COLUMN providerMeta TEXT;
@@ -0,0 +1,87 @@
1
+ -- 046_budgets_and_pricing.sql
2
+ -- Per-agent daily cost budget (V1) — schema + price-book DB-ification.
3
+ --
4
+ -- Tables added:
5
+ -- * budgets — daily USD budgets per scope (global/agent).
6
+ -- * pricing — append-only price book per (provider, model, token_class, effective_from).
7
+ -- * budget_refusal_notifications — per (task_id, day) dedup of refusal notifications.
8
+ --
9
+ -- Index added:
10
+ -- * idx_pricing_lookup — supports "latest active price" queries.
11
+ --
12
+ -- Seed:
13
+ -- * pricing rows derived from `CODEX_MODEL_PRICING` (src/providers/codex-models.ts:97-119),
14
+ -- 12 rows total (4 models × 3 token_classes), all with effective_from = 0 (epoch).
15
+ -- INSERT OR IGNORE keeps the migration idempotent on re-apply.
16
+ --
17
+ -- Timestamp convention (deliberate divergence from existing tables):
18
+ -- The columns `createdAt`, `lastUpdatedAt`, and `effective_from` in this migration are
19
+ -- INTEGER epoch milliseconds — NOT TEXT ISO 8601 like the rest of the schema. This is
20
+ -- intentional: integer math keeps the price-book "largest effective_from <= now" lookup
21
+ -- simple and lets the three timestamp fields share the same numeric domain. Seed rows
22
+ -- use literal 0 so re-runs of the seed are idempotent under INSERT OR IGNORE.
23
+ --
24
+ -- Index that was deliberately NOT added here:
25
+ -- `(agentId, createdAt)` on session_costs already exists as `idx_session_costs_agent_createdAt`
26
+ -- in `001_initial.sql:363`. Adding it again under a new name (e.g. idx_session_costs_agent_created)
27
+ -- would be a redundant duplicate. Phase 2 query plans should reference the canonical
28
+ -- index name from 001.
29
+
30
+ CREATE TABLE IF NOT EXISTS budgets (
31
+ scope TEXT NOT NULL,
32
+ scope_id TEXT NOT NULL,
33
+ daily_budget_usd REAL NOT NULL,
34
+ createdAt INTEGER NOT NULL,
35
+ lastUpdatedAt INTEGER NOT NULL,
36
+ PRIMARY KEY (scope, scope_id),
37
+ CHECK (scope IN ('global', 'agent')),
38
+ CHECK (daily_budget_usd >= 0)
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS pricing (
42
+ provider TEXT NOT NULL,
43
+ model TEXT NOT NULL,
44
+ token_class TEXT NOT NULL,
45
+ effective_from INTEGER NOT NULL,
46
+ price_per_million_usd REAL NOT NULL,
47
+ createdAt INTEGER NOT NULL,
48
+ lastUpdatedAt INTEGER NOT NULL,
49
+ PRIMARY KEY (provider, model, token_class, effective_from),
50
+ CHECK (provider IN ('claude', 'codex', 'pi')),
51
+ CHECK (token_class IN ('input', 'cached_input', 'output'))
52
+ );
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_pricing_lookup
55
+ ON pricing (provider, model, token_class, effective_from DESC);
56
+
57
+ CREATE TABLE IF NOT EXISTS budget_refusal_notifications (
58
+ task_id TEXT NOT NULL,
59
+ date TEXT NOT NULL,
60
+ agent_id TEXT NOT NULL,
61
+ cause TEXT NOT NULL,
62
+ agent_spend_usd REAL,
63
+ agent_budget_usd REAL,
64
+ global_spend_usd REAL,
65
+ global_budget_usd REAL,
66
+ follow_up_task_id TEXT,
67
+ createdAt INTEGER NOT NULL,
68
+ PRIMARY KEY (task_id, date),
69
+ CHECK (cause IN ('agent', 'global'))
70
+ );
71
+
72
+ -- Seed Codex price book. Mirrors CODEX_MODEL_PRICING (src/providers/codex-models.ts).
73
+ -- Use literal 0 for effective_from / createdAt / lastUpdatedAt so re-applying the
74
+ -- seed under INSERT OR IGNORE is a true no-op (no clock drift between runs).
75
+ INSERT OR IGNORE INTO pricing (provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt) VALUES
76
+ ('codex', 'gpt-5.4', 'input', 0, 2.5, 0, 0),
77
+ ('codex', 'gpt-5.4', 'cached_input', 0, 0.25, 0, 0),
78
+ ('codex', 'gpt-5.4', 'output', 0, 15.0, 0, 0),
79
+ ('codex', 'gpt-5.4-mini', 'input', 0, 0.75, 0, 0),
80
+ ('codex', 'gpt-5.4-mini', 'cached_input', 0, 0.075, 0, 0),
81
+ ('codex', 'gpt-5.4-mini', 'output', 0, 4.5, 0, 0),
82
+ ('codex', 'gpt-5.3-codex', 'input', 0, 1.75, 0, 0),
83
+ ('codex', 'gpt-5.3-codex', 'cached_input', 0, 0.175, 0, 0),
84
+ ('codex', 'gpt-5.3-codex', 'output', 0, 14.0, 0, 0),
85
+ ('codex', 'gpt-5.2-codex', 'input', 0, 1.75, 0, 0),
86
+ ('codex', 'gpt-5.2-codex', 'cached_input', 0, 0.175, 0, 0),
87
+ ('codex', 'gpt-5.2-codex', 'output', 0, 14.0, 0, 0);