@desplega.ai/agent-swarm 1.56.6 → 1.57.1

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/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.56.5",
5
+ "version": "1.57.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -1646,6 +1646,37 @@
1646
1646
  }
1647
1647
  }
1648
1648
  },
1649
+ "/api/keys/costs": {
1650
+ "get": {
1651
+ "summary": "Get aggregated cost data per API key",
1652
+ "tags": [
1653
+ "API Keys"
1654
+ ],
1655
+ "security": [
1656
+ {
1657
+ "bearerAuth": []
1658
+ }
1659
+ ],
1660
+ "parameters": [
1661
+ {
1662
+ "schema": {
1663
+ "type": "string"
1664
+ },
1665
+ "required": false,
1666
+ "name": "keyType",
1667
+ "in": "query"
1668
+ }
1669
+ ],
1670
+ "responses": {
1671
+ "200": {
1672
+ "description": "Per-key cost aggregation"
1673
+ },
1674
+ "401": {
1675
+ "description": "Unauthorized"
1676
+ }
1677
+ }
1678
+ }
1679
+ },
1649
1680
  "/api/events": {
1650
1681
  "post": {
1651
1682
  "summary": "Store a single event",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.56.6",
3
+ "version": "1.57.1",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Normalizes date strings from SQLite to ISO 8601 UTC format.
3
+ *
4
+ * SQLite's `datetime('now')` and `CURRENT_TIMESTAMP` produce bare format
5
+ * `YYYY-MM-DD HH:MM:SS` which browsers parse as local time. This utility
6
+ * converts them to `YYYY-MM-DDTHH:MM:SS.000Z` so they're unambiguously UTC.
7
+ */
8
+
9
+ const BARE_DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
10
+
11
+ export function normalizeDate(date: string | null | undefined): string | null {
12
+ if (date == null) return null;
13
+ if (BARE_DATETIME_RE.test(date)) {
14
+ return `${date.replace(" ", "T")}.000Z`;
15
+ }
16
+ return date;
17
+ }
18
+
19
+ /**
20
+ * Non-null variant for required date fields.
21
+ * Returns the input unchanged if already ISO 8601, or converts bare format.
22
+ */
23
+ export function normalizeDateRequired(date: string): string {
24
+ if (BARE_DATETIME_RE.test(date)) {
25
+ return `${date.replace(" ", "T")}.000Z`;
26
+ }
27
+ return date;
28
+ }
@@ -1,12 +1,30 @@
1
1
  import type { OAuthApp, OAuthTokens } from "../../tracker/types";
2
+ import { normalizeDateRequired } from "../date-utils";
2
3
  import { getDb } from "../db";
3
4
 
4
5
  // ── OAuth Apps ──
5
6
 
7
+ function normalizeOAuthApp(row: OAuthApp): OAuthApp {
8
+ return {
9
+ ...row,
10
+ createdAt: normalizeDateRequired(row.createdAt),
11
+ updatedAt: normalizeDateRequired(row.updatedAt),
12
+ };
13
+ }
14
+
15
+ function normalizeOAuthTokens(row: OAuthTokens): OAuthTokens {
16
+ return {
17
+ ...row,
18
+ createdAt: normalizeDateRequired(row.createdAt),
19
+ updatedAt: normalizeDateRequired(row.updatedAt),
20
+ };
21
+ }
22
+
6
23
  export function getOAuthApp(provider: string): OAuthApp | null {
7
- return getDb()
24
+ const row = getDb()
8
25
  .query("SELECT * FROM oauth_apps WHERE provider = ?")
9
26
  .get(provider) as OAuthApp | null;
27
+ return row ? normalizeOAuthApp(row) : null;
10
28
  }
11
29
 
12
30
  export function upsertOAuthApp(
@@ -33,7 +51,7 @@ export function upsertOAuthApp(
33
51
  redirectUri = excluded.redirectUri,
34
52
  scopes = excluded.scopes,
35
53
  metadata = excluded.metadata,
36
- updatedAt = datetime('now')`,
54
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
37
55
  )
38
56
  .run(
39
57
  provider,
@@ -50,9 +68,10 @@ export function upsertOAuthApp(
50
68
  // ── OAuth Tokens ──
51
69
 
52
70
  export function getOAuthTokens(provider: string): OAuthTokens | null {
53
- return getDb()
71
+ const row = getDb()
54
72
  .query("SELECT * FROM oauth_tokens WHERE provider = ?")
55
73
  .get(provider) as OAuthTokens | null;
74
+ return row ? normalizeOAuthTokens(row) : null;
56
75
  }
57
76
 
58
77
  export function storeOAuthTokens(
@@ -73,7 +92,7 @@ export function storeOAuthTokens(
73
92
  refreshToken = COALESCE(excluded.refreshToken, oauth_tokens.refreshToken),
74
93
  expiresAt = excluded.expiresAt,
75
94
  scope = COALESCE(excluded.scope, oauth_tokens.scope),
76
- updatedAt = datetime('now')`,
95
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
77
96
  )
78
97
  .run(provider, data.accessToken, data.refreshToken ?? null, data.expiresAt, data.scope ?? null);
79
98
  }
@@ -1,6 +1,22 @@
1
1
  import type { TrackerAgentMapping, TrackerSync } from "../../tracker/types";
2
+ import { normalizeDateRequired } from "../date-utils";
2
3
  import { getDb } from "../db";
3
4
 
5
+ function normalizeTrackerSync(row: TrackerSync): TrackerSync {
6
+ return {
7
+ ...row,
8
+ lastSyncedAt: normalizeDateRequired(row.lastSyncedAt),
9
+ createdAt: normalizeDateRequired(row.createdAt),
10
+ };
11
+ }
12
+
13
+ function normalizeTrackerAgentMapping(row: TrackerAgentMapping): TrackerAgentMapping {
14
+ return {
15
+ ...row,
16
+ createdAt: normalizeDateRequired(row.createdAt),
17
+ };
18
+ }
19
+
4
20
  // ── Tracker Sync ──
5
21
 
6
22
  export function getTrackerSync(
@@ -8,9 +24,10 @@ export function getTrackerSync(
8
24
  entityType: "task",
9
25
  swarmId: string,
10
26
  ): TrackerSync | null {
11
- return getDb()
27
+ const row = getDb()
12
28
  .query("SELECT * FROM tracker_sync WHERE provider = ? AND entityType = ? AND swarmId = ?")
13
29
  .get(provider, entityType, swarmId) as TrackerSync | null;
30
+ return row ? normalizeTrackerSync(row) : null;
14
31
  }
15
32
 
16
33
  export function getTrackerSyncByExternalId(
@@ -18,9 +35,10 @@ export function getTrackerSyncByExternalId(
18
35
  entityType: "task",
19
36
  externalId: string,
20
37
  ): TrackerSync | null {
21
- return getDb()
38
+ const row = getDb()
22
39
  .query("SELECT * FROM tracker_sync WHERE provider = ? AND entityType = ? AND externalId = ?")
23
40
  .get(provider, entityType, externalId) as TrackerSync | null;
41
+ return row ? normalizeTrackerSync(row) : null;
24
42
  }
25
43
 
26
44
  export function createTrackerSync(data: {
@@ -53,7 +71,7 @@ export function createTrackerSync(data: {
53
71
  data.lastDeliveryId ?? null,
54
72
  data.syncDirection ?? "inbound",
55
73
  ) as TrackerSync;
56
- return result;
74
+ return normalizeTrackerSync(result);
57
75
  }
58
76
 
59
77
  export function updateTrackerSync(
@@ -124,9 +142,11 @@ export function getAllTrackerSyncs(provider?: string, entityType?: "task"): Trac
124
142
  }
125
143
 
126
144
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
127
- return getDb()
128
- .query(`SELECT * FROM tracker_sync ${where} ORDER BY createdAt DESC`)
129
- .all(...values) as TrackerSync[];
145
+ return (
146
+ getDb()
147
+ .query(`SELECT * FROM tracker_sync ${where} ORDER BY createdAt DESC`)
148
+ .all(...values) as TrackerSync[]
149
+ ).map(normalizeTrackerSync);
130
150
  }
131
151
 
132
152
  // ── Tracker Agent Mapping ──
@@ -135,18 +155,20 @@ export function getTrackerAgentMapping(
135
155
  provider: string,
136
156
  agentId: string,
137
157
  ): TrackerAgentMapping | null {
138
- return getDb()
158
+ const row = getDb()
139
159
  .query("SELECT * FROM tracker_agent_mapping WHERE provider = ? AND agentId = ?")
140
160
  .get(provider, agentId) as TrackerAgentMapping | null;
161
+ return row ? normalizeTrackerAgentMapping(row) : null;
141
162
  }
142
163
 
143
164
  export function getTrackerAgentMappingByExternalUser(
144
165
  provider: string,
145
166
  externalUserId: string,
146
167
  ): TrackerAgentMapping | null {
147
- return getDb()
168
+ const row = getDb()
148
169
  .query("SELECT * FROM tracker_agent_mapping WHERE provider = ? AND externalUserId = ?")
149
170
  .get(provider, externalUserId) as TrackerAgentMapping | null;
171
+ return row ? normalizeTrackerAgentMapping(row) : null;
150
172
  }
151
173
 
152
174
  export function createTrackerAgentMapping(data: {
@@ -155,13 +177,14 @@ export function createTrackerAgentMapping(data: {
155
177
  externalUserId: string;
156
178
  agentName: string;
157
179
  }): TrackerAgentMapping {
158
- return getDb()
180
+ const result = getDb()
159
181
  .query(
160
182
  `INSERT INTO tracker_agent_mapping (provider, agentId, externalUserId, agentName)
161
183
  VALUES (?, ?, ?, ?)
162
184
  RETURNING *`,
163
185
  )
164
186
  .get(data.provider, data.agentId, data.externalUserId, data.agentName) as TrackerAgentMapping;
187
+ return normalizeTrackerAgentMapping(result);
165
188
  }
166
189
 
167
190
  export function deleteTrackerAgentMapping(provider: string, agentId: string): void {
@@ -172,11 +195,15 @@ export function deleteTrackerAgentMapping(provider: string, agentId: string): vo
172
195
 
173
196
  export function getAllTrackerAgentMappings(provider?: string): TrackerAgentMapping[] {
174
197
  if (provider) {
175
- return getDb()
176
- .query("SELECT * FROM tracker_agent_mapping WHERE provider = ? ORDER BY createdAt DESC")
177
- .all(provider) as TrackerAgentMapping[];
198
+ return (
199
+ getDb()
200
+ .query("SELECT * FROM tracker_agent_mapping WHERE provider = ? ORDER BY createdAt DESC")
201
+ .all(provider) as TrackerAgentMapping[]
202
+ ).map(normalizeTrackerAgentMapping);
178
203
  }
179
- return getDb()
180
- .query("SELECT * FROM tracker_agent_mapping ORDER BY createdAt DESC")
181
- .all() as TrackerAgentMapping[];
204
+ return (
205
+ getDb()
206
+ .query("SELECT * FROM tracker_agent_mapping ORDER BY createdAt DESC")
207
+ .all() as TrackerAgentMapping[]
208
+ ).map(normalizeTrackerAgentMapping);
182
209
  }
package/src/be/db.ts CHANGED
@@ -55,6 +55,7 @@ import type {
55
55
  WorkflowSnapshot,
56
56
  WorkflowVersion,
57
57
  } from "../types";
58
+ import { normalizeDate, normalizeDateRequired } from "./date-utils";
58
59
  import { runMigrations } from "./migrations/runner";
59
60
  import { seedDefaultTemplates } from "./seed";
60
61
 
@@ -727,6 +728,8 @@ type AgentTaskRow = {
727
728
  totalContextTokensUsed: number | null;
728
729
  contextWindowSize: number | null;
729
730
  was_paused: number;
731
+ credentialKeySuffix: string | null;
732
+ credentialKeyType: string | null;
730
733
  };
731
734
 
732
735
  function rowToAgentTask(row: AgentTaskRow): AgentTask {
@@ -780,6 +783,8 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
780
783
  output: row.output ?? undefined,
781
784
  progress: row.progress ?? undefined,
782
785
  wasPaused: !!row.was_paused,
786
+ credentialKeySuffix: row.credentialKeySuffix ?? undefined,
787
+ credentialKeyType: row.credentialKeyType ?? undefined,
783
788
  };
784
789
  }
785
790
 
@@ -3956,17 +3961,17 @@ function rowToScheduledTask(row: ScheduledTaskRow): ScheduledTask {
3956
3961
  priority: row.priority,
3957
3962
  targetAgentId: row.targetAgentId ?? undefined,
3958
3963
  enabled: row.enabled === 1,
3959
- lastRunAt: row.lastRunAt ?? undefined,
3960
- nextRunAt: row.nextRunAt ?? undefined,
3964
+ lastRunAt: normalizeDate(row.lastRunAt) ?? undefined,
3965
+ nextRunAt: normalizeDate(row.nextRunAt) ?? undefined,
3961
3966
  createdByAgentId: row.createdByAgentId ?? undefined,
3962
3967
  timezone: row.timezone,
3963
3968
  consecutiveErrors: row.consecutiveErrors ?? 0,
3964
- lastErrorAt: row.lastErrorAt ?? undefined,
3969
+ lastErrorAt: normalizeDate(row.lastErrorAt) ?? undefined,
3965
3970
  lastErrorMessage: row.lastErrorMessage ?? undefined,
3966
3971
  model: (row.model as "haiku" | "sonnet" | "opus" | null) ?? undefined,
3967
3972
  scheduleType: row.scheduleType as "recurring" | "one_time",
3968
- createdAt: row.createdAt,
3969
- lastUpdatedAt: row.lastUpdatedAt,
3973
+ createdAt: normalizeDateRequired(row.createdAt),
3974
+ lastUpdatedAt: normalizeDateRequired(row.lastUpdatedAt),
3970
3975
  };
3971
3976
  }
3972
3977
 
@@ -5237,8 +5242,8 @@ function rowToWorkflow(row: WorkflowRow): Workflow {
5237
5242
  dir: row.dir ?? undefined,
5238
5243
  vcsRepo: row.vcs_repo ?? undefined,
5239
5244
  createdByAgentId: row.createdByAgentId ?? undefined,
5240
- createdAt: row.createdAt,
5241
- lastUpdatedAt: row.lastUpdatedAt,
5245
+ createdAt: normalizeDateRequired(row.createdAt),
5246
+ lastUpdatedAt: normalizeDateRequired(row.lastUpdatedAt),
5242
5247
  };
5243
5248
  }
5244
5249
 
@@ -5442,9 +5447,9 @@ function rowToWorkflowRun(row: WorkflowRunRow): WorkflowRun {
5442
5447
  triggerData: row.triggerData ? JSON.parse(row.triggerData) : undefined,
5443
5448
  context: row.context ? (JSON.parse(row.context) as Record<string, unknown>) : undefined,
5444
5449
  error: row.error ?? undefined,
5445
- startedAt: row.startedAt,
5446
- lastUpdatedAt: row.lastUpdatedAt,
5447
- finishedAt: row.finishedAt ?? undefined,
5450
+ startedAt: normalizeDateRequired(row.startedAt),
5451
+ lastUpdatedAt: normalizeDateRequired(row.lastUpdatedAt),
5452
+ finishedAt: normalizeDate(row.finishedAt) ?? undefined,
5448
5453
  };
5449
5454
  }
5450
5455
 
@@ -5551,11 +5556,11 @@ function rowToWorkflowRunStep(row: WorkflowRunStepRow): WorkflowRunStep {
5551
5556
  input: row.input ? JSON.parse(row.input) : undefined,
5552
5557
  output: row.output ? JSON.parse(row.output) : undefined,
5553
5558
  error: row.error ?? undefined,
5554
- startedAt: row.startedAt,
5555
- finishedAt: row.finishedAt ?? undefined,
5559
+ startedAt: normalizeDateRequired(row.startedAt),
5560
+ finishedAt: normalizeDate(row.finishedAt) ?? undefined,
5556
5561
  retryCount: row.retryCount,
5557
5562
  maxRetries: row.maxRetries,
5558
- nextRetryAt: row.nextRetryAt ?? undefined,
5563
+ nextRetryAt: normalizeDate(row.nextRetryAt) ?? undefined,
5559
5564
  idempotencyKey: row.idempotencyKey ?? undefined,
5560
5565
  diagnostics: row.diagnostics ?? undefined,
5561
5566
  nextPort: row.nextPort ?? undefined,
@@ -5802,7 +5807,7 @@ function rowToWorkflowVersion(row: WorkflowVersionRow): WorkflowVersion {
5802
5807
  version: row.version,
5803
5808
  snapshot: JSON.parse(row.snapshot) as WorkflowSnapshot,
5804
5809
  changedByAgentId: row.changedByAgentId ?? undefined,
5805
- createdAt: row.createdAt,
5810
+ createdAt: normalizeDateRequired(row.createdAt),
5806
5811
  };
5807
5812
  }
5808
5813
 
@@ -6311,7 +6316,7 @@ function rowToChannelActivityCursor(row: ChannelActivityCursorRow): ChannelActiv
6311
6316
  return {
6312
6317
  channelId: row.channelId,
6313
6318
  lastSeenTs: row.lastSeenTs,
6314
- updatedAt: row.updatedAt,
6319
+ updatedAt: normalizeDateRequired(row.updatedAt),
6315
6320
  };
6316
6321
  }
6317
6322
 
@@ -6335,7 +6340,7 @@ export function upsertChannelActivityCursor(channelId: string, lastSeenTs: strin
6335
6340
  getDb()
6336
6341
  .prepare(
6337
6342
  `INSERT INTO channel_activity_cursors (channelId, lastSeenTs, updatedAt)
6338
- VALUES (?, ?, datetime('now'))
6343
+ VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
6339
6344
  ON CONFLICT(channelId) DO UPDATE SET lastSeenTs = excluded.lastSeenTs, updatedAt = excluded.updatedAt`,
6340
6345
  )
6341
6346
  .run(channelId, lastSeenTs);
@@ -6395,12 +6400,12 @@ function rowToApprovalRequest(row: ApprovalRequestRow): ApprovalRequest {
6395
6400
  status: row.status as ApprovalRequest["status"],
6396
6401
  responses: row.responses ? JSON.parse(row.responses) : null,
6397
6402
  resolvedBy: row.resolvedBy,
6398
- resolvedAt: row.resolvedAt,
6403
+ resolvedAt: normalizeDate(row.resolvedAt),
6399
6404
  timeoutSeconds: row.timeoutSeconds,
6400
- expiresAt: row.expiresAt,
6405
+ expiresAt: normalizeDate(row.expiresAt),
6401
6406
  notificationChannels: row.notificationChannels ? JSON.parse(row.notificationChannels) : null,
6402
- createdAt: row.createdAt,
6403
- updatedAt: row.updatedAt,
6407
+ createdAt: normalizeDateRequired(row.createdAt),
6408
+ updatedAt: normalizeDateRequired(row.updatedAt),
6404
6409
  };
6405
6410
  }
6406
6411
 
@@ -6561,7 +6566,7 @@ export function getStuckApprovalRuns(): StuckApprovalRun[] {
6561
6566
  JOIN approval_requests ar ON ar.workflowRunStepId = wrs.id
6562
6567
  WHERE wr.status = 'waiting'
6563
6568
  AND (ar.status IN ('approved', 'rejected', 'timeout')
6564
- OR (ar.status = 'pending' AND ar.expiresAt IS NOT NULL AND ar.expiresAt < datetime('now')))`,
6569
+ OR (ar.status = 'pending' AND ar.expiresAt IS NOT NULL AND ar.expiresAt < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))`,
6565
6570
  )
6566
6571
  .all();
6567
6572
  }
@@ -6582,7 +6587,7 @@ export function getExpiredPendingApprovals(): ApprovalRequest[] {
6582
6587
  `SELECT * FROM approval_requests
6583
6588
  WHERE status = 'pending'
6584
6589
  AND expiresAt IS NOT NULL
6585
- AND expiresAt < datetime('now')`,
6590
+ AND expiresAt < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
6586
6591
  )
6587
6592
  .all();
6588
6593
  return rows.map(rowToApprovalRequest);
@@ -7575,10 +7580,9 @@ export function recordKeyUsage(
7575
7580
 
7576
7581
  // Record which key was used on the task
7577
7582
  if (taskId) {
7578
- db.prepare("UPDATE agent_tasks SET credentialKeySuffix = ? WHERE id = ?").run(
7579
- keySuffix,
7580
- taskId,
7581
- );
7583
+ db.prepare(
7584
+ "UPDATE agent_tasks SET credentialKeySuffix = ?, credentialKeyType = ? WHERE id = ?",
7585
+ ).run(keySuffix, keyType, taskId);
7582
7586
  }
7583
7587
  }
7584
7588
 
@@ -7641,3 +7645,43 @@ export function getKeyStatuses(
7641
7645
  .prepare<ApiKeyStatus, string[]>(`SELECT * FROM api_key_status ${where} ORDER BY keyIndex`)
7642
7646
  .all(...params);
7643
7647
  }
7648
+
7649
+ export interface KeyCostSummary {
7650
+ keyType: string;
7651
+ keySuffix: string;
7652
+ totalCost: number;
7653
+ totalInputTokens: number;
7654
+ totalOutputTokens: number;
7655
+ taskCount: number;
7656
+ }
7657
+
7658
+ /**
7659
+ * Aggregate cost data per API key by joining session_costs through agent_tasks.
7660
+ */
7661
+ export function getKeyCostSummary(keyType?: string): KeyCostSummary[] {
7662
+ const db = getDb();
7663
+ const conditions = ["t.credentialKeySuffix IS NOT NULL"];
7664
+ const params: string[] = [];
7665
+
7666
+ if (keyType) {
7667
+ conditions.push("t.credentialKeyType = ?");
7668
+ params.push(keyType);
7669
+ }
7670
+
7671
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
7672
+ return db
7673
+ .prepare<KeyCostSummary, string[]>(
7674
+ `SELECT
7675
+ t.credentialKeyType as keyType,
7676
+ t.credentialKeySuffix as keySuffix,
7677
+ COALESCE(SUM(sc.totalCostUsd), 0) as totalCost,
7678
+ COALESCE(SUM(sc.inputTokens), 0) as totalInputTokens,
7679
+ COALESCE(SUM(sc.outputTokens), 0) as totalOutputTokens,
7680
+ COUNT(DISTINCT sc.taskId) as taskCount
7681
+ FROM session_costs sc
7682
+ JOIN agent_tasks t ON sc.taskId = t.id
7683
+ ${where}
7684
+ GROUP BY t.credentialKeyType, t.credentialKeySuffix`,
7685
+ )
7686
+ .all(...params);
7687
+ }
@@ -0,0 +1,2 @@
1
+ -- Store which credential type (env var name) was used per task
2
+ ALTER TABLE agent_tasks ADD COLUMN credentialKeyType TEXT;
@@ -0,0 +1,108 @@
1
+ -- Fix date storage format inconsistency: convert bare datetime format
2
+ -- (YYYY-MM-DD HH:MM:SS) to ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.000Z).
3
+ --
4
+ -- Affected tables use datetime('now') or CURRENT_TIMESTAMP defaults which
5
+ -- produce bare format. Browsers parse these as local time instead of UTC.
6
+
7
+ -- Update existing bare-format dates to ISO 8601 in all affected tables.
8
+ -- The pattern: replace space with T and append .000Z for dates matching bare format.
9
+
10
+ -- workflows
11
+ UPDATE workflows SET
12
+ createdAt = replace(createdAt, ' ', 'T') || '.000Z'
13
+ WHERE createdAt LIKE '____-__-__ __:__:__' AND createdAt NOT LIKE '%T%';
14
+ UPDATE workflows SET
15
+ lastUpdatedAt = replace(lastUpdatedAt, ' ', 'T') || '.000Z'
16
+ WHERE lastUpdatedAt LIKE '____-__-__ __:__:__' AND lastUpdatedAt NOT LIKE '%T%';
17
+
18
+ -- workflow_runs
19
+ UPDATE workflow_runs SET
20
+ startedAt = replace(startedAt, ' ', 'T') || '.000Z'
21
+ WHERE startedAt LIKE '____-__-__ __:__:__' AND startedAt NOT LIKE '%T%';
22
+ UPDATE workflow_runs SET
23
+ lastUpdatedAt = replace(lastUpdatedAt, ' ', 'T') || '.000Z'
24
+ WHERE lastUpdatedAt LIKE '____-__-__ __:__:__' AND lastUpdatedAt NOT LIKE '%T%';
25
+ UPDATE workflow_runs SET
26
+ finishedAt = replace(finishedAt, ' ', 'T') || '.000Z'
27
+ WHERE finishedAt IS NOT NULL AND finishedAt LIKE '____-__-__ __:__:__' AND finishedAt NOT LIKE '%T%';
28
+
29
+ -- workflow_run_steps
30
+ UPDATE workflow_run_steps SET
31
+ startedAt = replace(startedAt, ' ', 'T') || '.000Z'
32
+ WHERE startedAt LIKE '____-__-__ __:__:__' AND startedAt NOT LIKE '%T%';
33
+ UPDATE workflow_run_steps SET
34
+ finishedAt = replace(finishedAt, ' ', 'T') || '.000Z'
35
+ WHERE finishedAt IS NOT NULL AND finishedAt LIKE '____-__-__ __:__:__' AND finishedAt NOT LIKE '%T%';
36
+ UPDATE workflow_run_steps SET
37
+ nextRetryAt = replace(nextRetryAt, ' ', 'T') || '.000Z'
38
+ WHERE nextRetryAt IS NOT NULL AND nextRetryAt LIKE '____-__-__ __:__:__' AND nextRetryAt NOT LIKE '%T%';
39
+
40
+ -- tracker_sync
41
+ UPDATE tracker_sync SET
42
+ lastSyncedAt = replace(lastSyncedAt, ' ', 'T') || '.000Z'
43
+ WHERE lastSyncedAt LIKE '____-__-__ __:__:__' AND lastSyncedAt NOT LIKE '%T%';
44
+ UPDATE tracker_sync SET
45
+ createdAt = replace(createdAt, ' ', 'T') || '.000Z'
46
+ WHERE createdAt LIKE '____-__-__ __:__:__' AND createdAt NOT LIKE '%T%';
47
+
48
+ -- tracker_agent_mapping
49
+ UPDATE tracker_agent_mapping SET
50
+ createdAt = replace(createdAt, ' ', 'T') || '.000Z'
51
+ WHERE createdAt LIKE '____-__-__ __:__:__' AND createdAt NOT LIKE '%T%';
52
+
53
+ -- approval_requests
54
+ UPDATE approval_requests SET
55
+ createdAt = replace(createdAt, ' ', 'T') || '.000Z'
56
+ WHERE createdAt LIKE '____-__-__ __:__:__' AND createdAt NOT LIKE '%T%';
57
+ UPDATE approval_requests SET
58
+ updatedAt = replace(updatedAt, ' ', 'T') || '.000Z'
59
+ WHERE updatedAt LIKE '____-__-__ __:__:__' AND updatedAt NOT LIKE '%T%';
60
+ UPDATE approval_requests SET
61
+ resolvedAt = replace(resolvedAt, ' ', 'T') || '.000Z'
62
+ WHERE resolvedAt IS NOT NULL AND resolvedAt LIKE '____-__-__ __:__:__' AND resolvedAt NOT LIKE '%T%';
63
+ UPDATE approval_requests SET
64
+ expiresAt = replace(expiresAt, ' ', 'T') || '.000Z'
65
+ WHERE expiresAt IS NOT NULL AND expiresAt LIKE '____-__-__ __:__:__' AND expiresAt NOT LIKE '%T%';
66
+
67
+ -- channel_activity_cursors
68
+ UPDATE channel_activity_cursors SET
69
+ updatedAt = replace(updatedAt, ' ', 'T') || '.000Z'
70
+ WHERE updatedAt LIKE '____-__-__ __:__:__' AND updatedAt NOT LIKE '%T%';
71
+
72
+ -- oauth_apps
73
+ UPDATE oauth_apps SET
74
+ createdAt = replace(createdAt, ' ', 'T') || '.000Z'
75
+ WHERE createdAt LIKE '____-__-__ __:__:__' AND createdAt NOT LIKE '%T%';
76
+ UPDATE oauth_apps SET
77
+ updatedAt = replace(updatedAt, ' ', 'T') || '.000Z'
78
+ WHERE updatedAt LIKE '____-__-__ __:__:__' AND updatedAt NOT LIKE '%T%';
79
+
80
+ -- oauth_tokens
81
+ UPDATE oauth_tokens SET
82
+ createdAt = replace(createdAt, ' ', 'T') || '.000Z'
83
+ WHERE createdAt LIKE '____-__-__ __:__:__' AND createdAt NOT LIKE '%T%';
84
+ UPDATE oauth_tokens SET
85
+ updatedAt = replace(updatedAt, ' ', 'T') || '.000Z'
86
+ WHERE updatedAt LIKE '____-__-__ __:__:__' AND updatedAt NOT LIKE '%T%';
87
+
88
+ -- workflow_versions
89
+ UPDATE workflow_versions SET
90
+ createdAt = replace(createdAt, ' ', 'T') || '.000Z'
91
+ WHERE createdAt LIKE '____-__-__ __:__:__' AND createdAt NOT LIKE '%T%';
92
+
93
+ -- scheduled_tasks (defense-in-depth — dates are written from JS toISOString() but normalize any legacy data)
94
+ UPDATE scheduled_tasks SET
95
+ createdAt = replace(createdAt, ' ', 'T') || '.000Z'
96
+ WHERE createdAt LIKE '____-__-__ __:__:__' AND createdAt NOT LIKE '%T%';
97
+ UPDATE scheduled_tasks SET
98
+ lastUpdatedAt = replace(lastUpdatedAt, ' ', 'T') || '.000Z'
99
+ WHERE lastUpdatedAt LIKE '____-__-__ __:__:__' AND lastUpdatedAt NOT LIKE '%T%';
100
+ UPDATE scheduled_tasks SET
101
+ lastRunAt = replace(lastRunAt, ' ', 'T') || '.000Z'
102
+ WHERE lastRunAt IS NOT NULL AND lastRunAt LIKE '____-__-__ __:__:__' AND lastRunAt NOT LIKE '%T%';
103
+ UPDATE scheduled_tasks SET
104
+ nextRunAt = replace(nextRunAt, ' ', 'T') || '.000Z'
105
+ WHERE nextRunAt IS NOT NULL AND nextRunAt LIKE '____-__-__ __:__:__' AND nextRunAt NOT LIKE '%T%';
106
+ UPDATE scheduled_tasks SET
107
+ lastErrorAt = replace(lastErrorAt, ' ', 'T') || '.000Z'
108
+ WHERE lastErrorAt IS NOT NULL AND lastErrorAt LIKE '____-__-__ __:__:__' AND lastErrorAt NOT LIKE '%T%';
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import {
4
4
  getAvailableKeyIndices,
5
+ getKeyCostSummary,
5
6
  getKeyStatuses,
6
7
  markKeyRateLimited,
7
8
  recordKeyUsage,
@@ -93,6 +94,22 @@ const listStatuses = route({
93
94
  auth: { apiKey: true },
94
95
  });
95
96
 
97
+ const getCosts = route({
98
+ method: "get",
99
+ path: "/api/keys/costs",
100
+ pattern: ["api", "keys", "costs"],
101
+ summary: "Get aggregated cost data per API key",
102
+ tags: ["API Keys"],
103
+ query: z.object({
104
+ keyType: z.string().optional(),
105
+ }),
106
+ responses: {
107
+ 200: { description: "Per-key cost aggregation" },
108
+ 401: { description: "Unauthorized" },
109
+ },
110
+ auth: { apiKey: true },
111
+ });
112
+
96
113
  // ─── Handler ─────────────────────────────────────────────────────────────────
97
114
 
98
115
  export async function handleApiKeys(
@@ -149,6 +166,21 @@ export async function handleApiKeys(
149
166
  return true;
150
167
  }
151
168
 
169
+ // GET /api/keys/costs
170
+ if (getCosts.match(req.method, pathSegments)) {
171
+ const parsed = await getCosts.parse(req, res, pathSegments, queryParams);
172
+ if (!parsed) return true;
173
+
174
+ const { keyType } = parsed.query;
175
+ try {
176
+ const costs = getKeyCostSummary(keyType);
177
+ json(res, { success: true, costs });
178
+ } catch (err) {
179
+ jsonError(res, err instanceof Error ? err.message : "Failed to get key costs", 500);
180
+ }
181
+ return true;
182
+ }
183
+
152
184
  // GET /api/keys/status
153
185
  if (listStatuses.match(req.method, pathSegments)) {
154
186
  const parsed = await listStatuses.parse(req, res, pathSegments, queryParams);
@@ -103,12 +103,16 @@ describe("resolveCredentialPools", () => {
103
103
  expect(env.ANTHROPIC_API_KEY).toBe("key-ccc33");
104
104
  });
105
105
 
106
- test("non-pool vars are unchanged", async () => {
106
+ test("single keys are tracked with index 0", async () => {
107
107
  const env: Record<string, string | undefined> = {
108
108
  ANTHROPIC_API_KEY: "single-key",
109
109
  };
110
110
  const selections = await resolveCredentialPools(env);
111
- expect(selections.length).toBe(0);
111
+ expect(selections.length).toBe(1);
112
+ expect(selections[0]!.index).toBe(0);
113
+ expect(selections[0]!.total).toBe(1);
114
+ expect(selections[0]!.keySuffix).toBe("e-key");
115
+ expect(selections[0]!.keyType).toBe("ANTHROPIC_API_KEY");
112
116
  expect(env.ANTHROPIC_API_KEY).toBe("single-key");
113
117
  });
114
118
  });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { normalizeDate, normalizeDateRequired } from "../be/date-utils";
3
+
4
+ describe("normalizeDate", () => {
5
+ it("returns null for null input", () => {
6
+ expect(normalizeDate(null)).toBe(null);
7
+ });
8
+
9
+ it("returns null for undefined input", () => {
10
+ expect(normalizeDate(undefined)).toBe(null);
11
+ });
12
+
13
+ it("converts bare datetime format to ISO 8601", () => {
14
+ expect(normalizeDate("2026-03-31 14:30:00")).toBe("2026-03-31T14:30:00.000Z");
15
+ });
16
+
17
+ it("converts midnight bare datetime", () => {
18
+ expect(normalizeDate("2026-01-01 00:00:00")).toBe("2026-01-01T00:00:00.000Z");
19
+ });
20
+
21
+ it("passes through ISO 8601 with Z suffix unchanged", () => {
22
+ expect(normalizeDate("2026-03-31T14:30:00.000Z")).toBe("2026-03-31T14:30:00.000Z");
23
+ });
24
+
25
+ it("passes through ISO 8601 with milliseconds and Z", () => {
26
+ expect(normalizeDate("2026-03-31T14:30:00.123Z")).toBe("2026-03-31T14:30:00.123Z");
27
+ });
28
+
29
+ it("passes through toISOString() output unchanged", () => {
30
+ const iso = new Date().toISOString();
31
+ expect(normalizeDate(iso)).toBe(iso);
32
+ });
33
+
34
+ it("passes through strftime output unchanged", () => {
35
+ // strftime('%Y-%m-%dT%H:%M:%fZ', 'now') produces this format
36
+ expect(normalizeDate("2026-03-31T14:30:00.000Z")).toBe("2026-03-31T14:30:00.000Z");
37
+ });
38
+ });
39
+
40
+ describe("normalizeDateRequired", () => {
41
+ it("converts bare datetime format to ISO 8601", () => {
42
+ expect(normalizeDateRequired("2026-03-31 14:30:00")).toBe("2026-03-31T14:30:00.000Z");
43
+ });
44
+
45
+ it("passes through ISO 8601 unchanged", () => {
46
+ expect(normalizeDateRequired("2026-03-31T14:30:00.123Z")).toBe("2026-03-31T14:30:00.123Z");
47
+ });
48
+ });
@@ -68,7 +68,7 @@ describe("Heartbeat Triage", () => {
68
68
  createTaskExtended("Completed task", { agentId: agent.id });
69
69
  // Manually mark as completed
70
70
  getDb().run(
71
- "UPDATE agent_tasks SET status = 'completed', finishedAt = datetime('now') WHERE agentId = ?",
71
+ "UPDATE agent_tasks SET status = 'completed', finishedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE agentId = ?",
72
72
  [agent.id],
73
73
  );
74
74
 
package/src/types.ts CHANGED
@@ -149,6 +149,10 @@ export const AgentTaskSchema = z.object({
149
149
  peakContextPercent: z.number().min(0).max(100).optional(),
150
150
  totalContextTokensUsed: z.number().int().min(0).optional(),
151
151
  contextWindowSize: z.number().int().min(0).optional(),
152
+
153
+ // Credential tracking
154
+ credentialKeySuffix: z.string().optional(),
155
+ credentialKeyType: z.string().optional(),
152
156
  });
153
157
 
154
158
  export const AgentStatusSchema = z.enum(["idle", "busy", "offline"]);
@@ -91,8 +91,8 @@ async function fetchAvailableIndices(
91
91
  const availableIndicesMap: Record<string, number[]> = {};
92
92
  for (const envVar of CREDENTIAL_POOL_VARS) {
93
93
  const val = env[envVar];
94
- if (val?.includes(",")) {
95
- const totalKeys = val.split(",").filter((s) => s.trim()).length;
94
+ if (val) {
95
+ const totalKeys = val.includes(",") ? val.split(",").filter((s) => s.trim()).length : 1;
96
96
  try {
97
97
  const resp = await fetch(
98
98
  `${apiUrl}/api/keys/available?keyType=${encodeURIComponent(envVar)}&totalKeys=${totalKeys}`,
@@ -134,7 +134,7 @@ export async function resolveCredentialPools(
134
134
  const selections: CredentialSelection[] = [];
135
135
  for (const envVar of CREDENTIAL_POOL_VARS) {
136
136
  const val = env[envVar];
137
- if (val?.includes(",")) {
137
+ if (val) {
138
138
  const available = availableIndicesMap?.[envVar];
139
139
  const result = selectCredential(val, available, envVar);
140
140
  env[envVar] = result.selected;