@roberttlange/agentlens 0.2.2 → 0.3.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 (48) hide show
  1. package/dist/browser.js +154 -20
  2. package/dist/browser.js.map +1 -1
  3. package/dist/main.test.js +138 -1
  4. package/dist/main.test.js.map +1 -1
  5. package/node_modules/@agentlens/contracts/dist/index.d.ts +120 -0
  6. package/node_modules/@agentlens/core/dist/__tests__/config.test.js +67 -2
  7. package/node_modules/@agentlens/core/dist/__tests__/config.test.js.map +1 -1
  8. package/node_modules/@agentlens/core/dist/__tests__/index.test.js +590 -2
  9. package/node_modules/@agentlens/core/dist/__tests__/index.test.js.map +1 -1
  10. package/node_modules/@agentlens/core/dist/config.js +95 -5
  11. package/node_modules/@agentlens/core/dist/config.js.map +1 -1
  12. package/node_modules/@agentlens/core/dist/generatedPricing.d.ts +3 -0
  13. package/node_modules/@agentlens/core/dist/generatedPricing.js +131 -0
  14. package/node_modules/@agentlens/core/dist/generatedPricing.js.map +1 -0
  15. package/node_modules/@agentlens/core/dist/metrics.d.ts +13 -0
  16. package/node_modules/@agentlens/core/dist/metrics.js +227 -54
  17. package/node_modules/@agentlens/core/dist/metrics.js.map +1 -1
  18. package/node_modules/@agentlens/core/dist/pricing.d.ts +15 -0
  19. package/node_modules/@agentlens/core/dist/pricing.js +133 -0
  20. package/node_modules/@agentlens/core/dist/pricing.js.map +1 -0
  21. package/node_modules/@agentlens/core/dist/pricing.test.d.ts +1 -0
  22. package/node_modules/@agentlens/core/dist/pricing.test.js +109 -0
  23. package/node_modules/@agentlens/core/dist/pricing.test.js.map +1 -0
  24. package/node_modules/@agentlens/core/dist/sourceProfiles.js +7 -67
  25. package/node_modules/@agentlens/core/dist/sourceProfiles.js.map +1 -1
  26. package/node_modules/@agentlens/core/dist/traceIndex.d.ts +34 -1
  27. package/node_modules/@agentlens/core/dist/traceIndex.js +374 -15
  28. package/node_modules/@agentlens/core/dist/traceIndex.js.map +1 -1
  29. package/node_modules/@agentlens/server/dist/activity-cache.d.ts +32 -0
  30. package/node_modules/@agentlens/server/dist/activity-cache.js +63 -0
  31. package/node_modules/@agentlens/server/dist/activity-cache.js.map +1 -0
  32. package/node_modules/@agentlens/server/dist/activity-cache.test.d.ts +1 -0
  33. package/node_modules/@agentlens/server/dist/activity-cache.test.js +170 -0
  34. package/node_modules/@agentlens/server/dist/activity-cache.test.js.map +1 -0
  35. package/node_modules/@agentlens/server/dist/activity.d.ts +31 -1
  36. package/node_modules/@agentlens/server/dist/activity.js +532 -34
  37. package/node_modules/@agentlens/server/dist/activity.js.map +1 -1
  38. package/node_modules/@agentlens/server/dist/app.d.ts +4 -2
  39. package/node_modules/@agentlens/server/dist/app.js +248 -5
  40. package/node_modules/@agentlens/server/dist/app.js.map +1 -1
  41. package/node_modules/@agentlens/server/dist/app.test.js +670 -9
  42. package/node_modules/@agentlens/server/dist/app.test.js.map +1 -1
  43. package/node_modules/@agentlens/server/dist/web/assets/index-CTFOBaBt.css +1 -0
  44. package/node_modules/@agentlens/server/dist/web/assets/index-CVf00w06.js +52 -0
  45. package/node_modules/@agentlens/server/dist/web/index.html +2 -2
  46. package/package.json +1 -1
  47. package/node_modules/@agentlens/server/dist/web/assets/index-Ci8okH8M.js +0 -52
  48. package/node_modules/@agentlens/server/dist/web/assets/index-Cj3kmsFf.css +0 -1
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { describe, expect, it, vi } from "vitest";
5
5
  import { mergeConfig, saveConfig, TraceIndex } from "@agentlens/core";
6
- import { createServer, extractCursorSessionIdsFromOpenPaths, extractOpenCodeSessionIdsFromLogContent, geminiLogsContainSessionId, geminiProjectHashFromTracePath, geminiProjectHashesFromCwd, geminiProjectKeyFromTracePath, geminiProjectSlugsFromCwd, inferSessionCwd, matchesCurrentUser, parseTmuxClients, resolveDefaultWebDistPath, extractClaudeDebugProcessPid, selectAgentProcessPidsBySessionId, selectPreferredTmuxClient, selectOpenFileProcessPids, selectAgentProjectProcessPids, selectClaudeProjectProcessPids, selectCursorProjectProcessPids, selectGeminiProjectProcessPids, selectPidGroupByNearestTimestamp, } from "./app.js";
6
+ import { buildLiveBatchEnvelope, createServer, extractCursorSessionIdsFromOpenPaths, extractOpenCodeSessionIdsFromLogContent, geminiLogsContainSessionId, geminiProjectHashFromTracePath, geminiProjectHashesFromCwd, geminiProjectKeyFromTracePath, geminiProjectSlugsFromCwd, inferSessionCwd, matchesCurrentUser, parseTmuxClients, resolveDefaultWebDistPath, extractClaudeDebugProcessPid, selectAgentProcessPidsBySessionId, selectPreferredTmuxClient, selectOpenFileProcessPids, selectAgentProjectProcessPids, selectClaudeProjectProcessPids, selectCursorProjectProcessPids, selectGeminiProjectProcessPids, selectPidGroupByNearestTimestamp, runServer, } from "./app.js";
7
7
  function buildTraceLog(sessionId, sequence, withToolEvents) {
8
8
  const firstTs = new Date(Date.UTC(2026, 1, 11, 10, 0, sequence)).toISOString();
9
9
  const secondTs = new Date(Date.UTC(2026, 1, 11, 10, 0, sequence + 1)).toISOString();
@@ -198,6 +198,94 @@ async function buildFixtureWithCustomTrace(traceLog, sessionId) {
198
198
  return { configPath, index, sessionId };
199
199
  }
200
200
  describe("server api", () => {
201
+ it("coalesces bursty live deltas into one ordered batch envelope", async () => {
202
+ const original = buildTraceLog("server-session-burst", 1, true);
203
+ const updated = buildTraceLog("server-session-burst", 4, true);
204
+ const fixture = await buildFixtureWithCustomTrace(original, "server-session-burst");
205
+ const [initialSummary] = fixture.index.getSummaries();
206
+ if (!initialSummary)
207
+ throw new Error("missing initial summary");
208
+ await writeFile(path.join(path.dirname(initialSummary.path), path.basename(initialSummary.path)), `${original}\n${updated}`, "utf8");
209
+ await fixture.index.refresh();
210
+ const [updatedSummary] = fixture.index.getSummaries();
211
+ if (!updatedSummary)
212
+ throw new Error("missing updated summary");
213
+ const batch = buildLiveBatchEnvelope([
214
+ {
215
+ id: "1",
216
+ type: "trace_updated",
217
+ version: 1,
218
+ payload: { summary: { ...initialSummary, eventCount: 3, mtimeMs: initialSummary.mtimeMs + 1 } },
219
+ },
220
+ {
221
+ id: "2",
222
+ type: "trace_updated",
223
+ version: 2,
224
+ payload: { summary: updatedSummary },
225
+ },
226
+ {
227
+ id: "3",
228
+ type: "events_appended",
229
+ version: 3,
230
+ payload: {
231
+ id: updatedSummary.id,
232
+ appended: 1,
233
+ latestEvents: fixture.index.getTracePage(updatedSummary.id, { limit: 1 }).events,
234
+ },
235
+ },
236
+ {
237
+ id: "4",
238
+ type: "events_appended",
239
+ version: 4,
240
+ payload: {
241
+ id: updatedSummary.id,
242
+ appended: 2,
243
+ latestEvents: fixture.index.getTracePage(updatedSummary.id, { limit: 2 }).events,
244
+ },
245
+ },
246
+ {
247
+ id: "5",
248
+ type: "overview_updated",
249
+ version: 5,
250
+ payload: { overview: fixture.index.getOverview(), startup: fixture.index.getStartupStatus() },
251
+ },
252
+ {
253
+ id: "6",
254
+ type: "trace_removed",
255
+ version: 6,
256
+ payload: { id: updatedSummary.id },
257
+ },
258
+ {
259
+ id: "7",
260
+ type: "trace_updated",
261
+ version: 7,
262
+ payload: { summary: { ...updatedSummary, mtimeMs: updatedSummary.mtimeMs + 5 } },
263
+ },
264
+ ]);
265
+ expect(batch.type).toBe("batch");
266
+ expect(batch.payload.events.map((event) => event.type)).toEqual(["trace_updated", "events_appended", "overview_updated"]);
267
+ const finalTraceUpdate = batch.payload.events[0];
268
+ expect(finalTraceUpdate?.type).toBe("trace_updated");
269
+ if (finalTraceUpdate?.type !== "trace_updated") {
270
+ throw new Error("expected trace_updated envelope");
271
+ }
272
+ expect(finalTraceUpdate.payload.summary).toMatchObject({
273
+ id: updatedSummary.id,
274
+ eventCount: updatedSummary.eventCount,
275
+ mtimeMs: updatedSummary.mtimeMs + 5,
276
+ });
277
+ const appended = batch.payload.events[1];
278
+ expect(appended?.type).toBe("events_appended");
279
+ if (appended?.type !== "events_appended") {
280
+ throw new Error("expected events_appended envelope");
281
+ }
282
+ expect(appended.payload).toMatchObject({
283
+ id: updatedSummary.id,
284
+ appended: 3,
285
+ });
286
+ expect(appended.payload.latestEvents).toHaveLength(2);
287
+ expect(batch.payload.events[2]?.type).toBe("overview_updated");
288
+ });
201
289
  it("infers session cwd from both session_meta and session events", async () => {
202
290
  const codexDetail = {
203
291
  events: [{ rawType: "session_meta", raw: { payload: { cwd: " /tmp/codex " } } }],
@@ -868,8 +956,262 @@ describe("server api", () => {
868
956
  expect(payload.activity.days[0]?.bins[1]?.startMs).toBe(Date.UTC(2026, 1, 5, 6, 30, 0));
869
957
  await server.close();
870
958
  }, 20_000);
871
- it("supports year-to-date style activity windows with daily aggregation", async () => {
959
+ it("accepts metric and color overrides from activity query params", async () => {
960
+ const fixture = await buildFixtureWithTraceCount(1);
961
+ const server = await createServer({
962
+ traceIndex: fixture.index,
963
+ configPath: fixture.configPath,
964
+ enableStatic: false,
965
+ });
966
+ const weekResponse = await server.inject({
967
+ method: "GET",
968
+ url: "/api/activity/week?end_date=2026-02-11&tz_offset_min=0&day_count=7&slot_min=30&hour_start=6&hour_end=2&metric=output_tokens&color=%2316a34a",
969
+ });
970
+ expect(weekResponse.statusCode).toBe(200);
971
+ const weekPayload = weekResponse.json();
972
+ expect(weekPayload.activity.presentation).toMatchObject({
973
+ metric: "output_tokens",
974
+ color: "#16a34a",
975
+ });
976
+ const yearResponse = await server.inject({
977
+ method: "GET",
978
+ url: "/api/activity/year?end_date=2026-02-11&tz_offset_min=0&day_count=365&metric=bogus&color=red",
979
+ });
980
+ expect(yearResponse.statusCode).toBe(200);
981
+ const yearPayload = yearResponse.json();
982
+ expect(yearPayload.activity.presentation).toMatchObject({
983
+ metric: "sessions",
984
+ color: "#dc2626",
985
+ });
986
+ await server.close();
987
+ }, 20_000);
988
+ it("serves output-token heatmap presentation and summed weekly values from config", async () => {
989
+ const traceLog = [
990
+ JSON.stringify({
991
+ timestamp: "2026-02-11T06:00:00.000Z",
992
+ type: "session_meta",
993
+ payload: { id: "metric-trace", cwd: "/tmp/proj", cli_version: "0.1.0" },
994
+ }),
995
+ JSON.stringify({
996
+ timestamp: "2026-02-11T06:00:00.000Z",
997
+ type: "turn_context",
998
+ payload: { model: "gpt-5.3-codex" },
999
+ }),
1000
+ JSON.stringify({
1001
+ timestamp: "2026-02-11T06:00:00.000Z",
1002
+ type: "event_msg",
1003
+ payload: {
1004
+ type: "token_count",
1005
+ info: {
1006
+ total_token_usage: {
1007
+ input_tokens: 100,
1008
+ cached_input_tokens: 0,
1009
+ output_tokens: 60,
1010
+ reasoning_output_tokens: 0,
1011
+ total_tokens: 160,
1012
+ },
1013
+ last_token_usage: {
1014
+ input_tokens: 100,
1015
+ cached_input_tokens: 0,
1016
+ output_tokens: 60,
1017
+ reasoning_output_tokens: 0,
1018
+ total_tokens: 160,
1019
+ },
1020
+ },
1021
+ },
1022
+ }),
1023
+ JSON.stringify({
1024
+ timestamp: "2026-02-11T06:30:00.000Z",
1025
+ type: "response_item",
1026
+ payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "halfway" }] },
1027
+ }),
1028
+ JSON.stringify({
1029
+ timestamp: "2026-02-11T07:00:00.000Z",
1030
+ type: "response_item",
1031
+ payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "done" }] },
1032
+ }),
1033
+ ].join("\n");
1034
+ const fixture = await buildFixtureWithCustomTrace(traceLog, "metric-trace");
1035
+ const nextConfig = mergeConfig({
1036
+ ...fixture.index.getConfig(),
1037
+ activityHeatmap: {
1038
+ metric: "output_tokens",
1039
+ color: "#16a34a",
1040
+ },
1041
+ });
1042
+ fixture.index.setConfig(nextConfig);
1043
+ await saveConfig(nextConfig, fixture.configPath);
1044
+ const server = await createServer({
1045
+ traceIndex: fixture.index,
1046
+ configPath: fixture.configPath,
1047
+ enableStatic: false,
1048
+ });
1049
+ const response = await server.inject({
1050
+ method: "GET",
1051
+ url: "/api/activity/week?end_date=2026-02-11&tz_offset_min=0&day_count=1&slot_min=30&hour_start=6&hour_end=8",
1052
+ });
1053
+ expect(response.statusCode).toBe(200);
1054
+ const payload = response.json();
1055
+ const targetDay = payload.activity.days[0];
1056
+ expect(payload.activity.presentation).toMatchObject({
1057
+ metric: "output_tokens",
1058
+ color: "#16a34a",
1059
+ });
1060
+ expect(payload.activity.presentation.palette).toHaveLength(5);
1061
+ expect(targetDay?.bins[0]?.heatmapValue).toBeCloseTo(60, 6);
1062
+ expect(targetDay?.bins[1]?.heatmapValue).toBeCloseTo(0, 6);
1063
+ expect(targetDay?.bins[2]?.heatmapValue).toBeCloseTo(0, 6);
1064
+ await server.close();
1065
+ }, 20_000);
1066
+ it("builds weekly usage summaries from usage timestamps within the selected window", async () => {
1067
+ const traceLog = [
1068
+ JSON.stringify({
1069
+ timestamp: "2026-02-11T06:00:00.000Z",
1070
+ type: "session_meta",
1071
+ payload: { id: "weekly-usage-window", cwd: "/tmp/proj", cli_version: "0.1.0" },
1072
+ }),
1073
+ JSON.stringify({
1074
+ timestamp: "2026-02-11T06:00:00.000Z",
1075
+ type: "turn_context",
1076
+ payload: { model: "gpt-5.3-codex" },
1077
+ }),
1078
+ JSON.stringify({
1079
+ timestamp: "2026-02-11T06:05:00.000Z",
1080
+ type: "response_item",
1081
+ payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "within window" }] },
1082
+ }),
1083
+ JSON.stringify({
1084
+ timestamp: "2026-02-11T09:00:00.000Z",
1085
+ type: "event_msg",
1086
+ payload: {
1087
+ type: "token_count",
1088
+ info: {
1089
+ total_token_usage: {
1090
+ input_tokens: 120,
1091
+ cached_input_tokens: 30,
1092
+ output_tokens: 50,
1093
+ reasoning_output_tokens: 0,
1094
+ total_tokens: 200,
1095
+ },
1096
+ last_token_usage: {
1097
+ input_tokens: 120,
1098
+ cached_input_tokens: 30,
1099
+ output_tokens: 50,
1100
+ reasoning_output_tokens: 0,
1101
+ total_tokens: 200,
1102
+ },
1103
+ },
1104
+ },
1105
+ }),
1106
+ JSON.stringify({
1107
+ timestamp: "2026-02-12T06:00:00.000Z",
1108
+ type: "response_item",
1109
+ payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "next day" }] },
1110
+ }),
1111
+ ].join("\n");
1112
+ const fixture = await buildFixtureWithCustomTrace(traceLog, "weekly-usage-window");
1113
+ const server = await createServer({
1114
+ traceIndex: fixture.index,
1115
+ configPath: fixture.configPath,
1116
+ enableStatic: false,
1117
+ });
1118
+ const response = await server.inject({
1119
+ method: "GET",
1120
+ url: "/api/activity/week?end_date=2026-02-12&tz_offset_min=0&day_count=2&slot_min=30&hour_start=6&hour_end=8",
1121
+ });
1122
+ expect(response.statusCode).toBe(200);
1123
+ const payload = response.json();
1124
+ const codexRow = payload.activity.usageSummary.rows.find((row) => row.agent === "codex");
1125
+ expect(codexRow).toEqual(expect.objectContaining({
1126
+ inputTokens: 0,
1127
+ cacheTokens: 0,
1128
+ outputTokens: 0,
1129
+ }));
1130
+ await server.close();
1131
+ }, 20_000);
1132
+ it("serves compact yearly activity from the earliest active day in the selected year", async () => {
872
1133
  const fixture = await buildFixtureWithTraceCount(3);
1134
+ const detailSpy = vi.spyOn(fixture.index, "getSessionDetail");
1135
+ const server = await createServer({
1136
+ traceIndex: fixture.index,
1137
+ configPath: fixture.configPath,
1138
+ enableStatic: false,
1139
+ });
1140
+ const response = await server.inject({
1141
+ method: "GET",
1142
+ url: "/api/activity/year?end_date=2026-02-22&tz_offset_min=0&day_count=53",
1143
+ });
1144
+ expect(response.statusCode).toBe(200);
1145
+ const payload = response.json();
1146
+ expect(payload.activity.startDateLocal).toBe("2026-02-11");
1147
+ expect(payload.activity.endDateLocal).toBe("2026-02-22");
1148
+ expect(payload.activity.dayCount).toBe(12);
1149
+ expect(payload.activity.days).toHaveLength(12);
1150
+ expect(payload.activity.days[0]?.dateLocal).toBe("2026-02-11");
1151
+ expect(payload.activity.days[11]?.dateLocal).toBe("2026-02-22");
1152
+ expect(payload.activity.days.every((day) => day.bins === undefined)).toBe(true);
1153
+ expect(payload.activity.days.some((day) => day.totalEventCount > 0)).toBe(true);
1154
+ expect(payload.activity.usageSummary.totals.totalUniqueSessions).toBe(3);
1155
+ expect(detailSpy).not.toHaveBeenCalled();
1156
+ detailSpy.mockRestore();
1157
+ await server.close();
1158
+ }, 20_000);
1159
+ it("serves cost heatmap presentation and summed yearly values from config", async () => {
1160
+ const traceLog = [
1161
+ JSON.stringify({
1162
+ timestamp: "2026-02-11T07:00:00.000Z",
1163
+ type: "session_meta",
1164
+ payload: { id: "cost-trace", cwd: "/tmp/proj", cli_version: "0.1.0" },
1165
+ }),
1166
+ JSON.stringify({
1167
+ timestamp: "2026-02-11T07:00:00.000Z",
1168
+ type: "turn_context",
1169
+ payload: { model: "gpt-5.3-codex" },
1170
+ }),
1171
+ JSON.stringify({
1172
+ timestamp: "2026-02-11T07:00:00.000Z",
1173
+ type: "event_msg",
1174
+ payload: {
1175
+ type: "token_count",
1176
+ info: {
1177
+ total_token_usage: {
1178
+ input_tokens: 40,
1179
+ cached_input_tokens: 0,
1180
+ output_tokens: 60,
1181
+ reasoning_output_tokens: 0,
1182
+ total_tokens: 100,
1183
+ },
1184
+ last_token_usage: {
1185
+ input_tokens: 40,
1186
+ cached_input_tokens: 0,
1187
+ output_tokens: 60,
1188
+ reasoning_output_tokens: 0,
1189
+ total_tokens: 100,
1190
+ },
1191
+ },
1192
+ },
1193
+ }),
1194
+ JSON.stringify({
1195
+ timestamp: "2026-02-11T19:00:00.000Z",
1196
+ type: "response_item",
1197
+ payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "day one" }] },
1198
+ }),
1199
+ JSON.stringify({
1200
+ timestamp: "2026-02-12T19:00:00.000Z",
1201
+ type: "response_item",
1202
+ payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "day two" }] },
1203
+ }),
1204
+ ].join("\n");
1205
+ const fixture = await buildFixtureWithCustomTrace(traceLog, "cost-trace");
1206
+ const nextConfig = mergeConfig({
1207
+ ...fixture.index.getConfig(),
1208
+ activityHeatmap: {
1209
+ metric: "total_cost_usd",
1210
+ color: "#2563eb",
1211
+ },
1212
+ });
1213
+ fixture.index.setConfig(nextConfig);
1214
+ await saveConfig(nextConfig, fixture.configPath);
873
1215
  const server = await createServer({
874
1216
  traceIndex: fixture.index,
875
1217
  configPath: fixture.configPath,
@@ -877,17 +1219,103 @@ describe("server api", () => {
877
1219
  });
878
1220
  const response = await server.inject({
879
1221
  method: "GET",
880
- url: "/api/activity/week?end_date=2026-02-22&tz_offset_min=0&day_count=53&slot_min=30&hour_start=7&hour_end=7",
1222
+ url: "/api/activity/year?end_date=2026-02-12&tz_offset_min=0&day_count=365",
881
1223
  });
882
1224
  expect(response.statusCode).toBe(200);
883
1225
  const payload = response.json();
884
- expect(payload.activity.dayCount).toBe(53);
885
- expect(payload.activity.days).toHaveLength(53);
886
- expect(payload.activity.days[0]?.dateLocal).toBe("2026-01-01");
887
- expect(payload.activity.days[52]?.dateLocal).toBe("2026-02-22");
888
- expect(payload.activity.days.every((day) => day.bins.length === 48)).toBe(true);
1226
+ expect(payload.activity.presentation).toMatchObject({
1227
+ metric: "total_cost_usd",
1228
+ color: "#2563eb",
1229
+ });
1230
+ const firstDayCost = payload.activity.days[0]?.heatmapValue ?? 0;
1231
+ const secondDayCost = payload.activity.days[1]?.heatmapValue ?? 0;
1232
+ expect(firstDayCost).toBeCloseTo(0.0001, 6);
1233
+ expect(secondDayCost).toBeCloseTo(0, 6);
889
1234
  await server.close();
890
1235
  }, 20_000);
1236
+ it("builds yearly activity without reparsing cold traces", async () => {
1237
+ const root = await mkdtemp(path.join(os.tmpdir(), "agentlens-server-year-cold-"));
1238
+ const codexRoot = path.join(root, ".codex", "sessions", "2026", "02", "11");
1239
+ await mkdir(codexRoot, { recursive: true });
1240
+ for (const [index, filename] of ["rollout-a.jsonl", "rollout-b.jsonl", "rollout-c.jsonl"].entries()) {
1241
+ await writeFile(path.join(codexRoot, filename), [
1242
+ JSON.stringify({
1243
+ timestamp: `2026-02-11T10:00:0${index}.000Z`,
1244
+ type: "session_meta",
1245
+ payload: { id: `server-cold-${index}`, cwd: "/tmp/project", cli_version: "0.1.0" },
1246
+ }),
1247
+ JSON.stringify({
1248
+ timestamp: `2026-02-11T10:05:0${index}.000Z`,
1249
+ type: "response_item",
1250
+ payload: {
1251
+ type: "message",
1252
+ role: "assistant",
1253
+ content: [{ type: "output_text", text: `trace-${index}` }],
1254
+ },
1255
+ }),
1256
+ ].join("\n") + "\n", "utf8");
1257
+ }
1258
+ const config = mergeConfig({
1259
+ retention: {
1260
+ strategy: "aggressive_recency",
1261
+ hotTraceCount: 1,
1262
+ warmTraceCount: 0,
1263
+ maxResidentEventsPerHotTrace: 1000,
1264
+ maxResidentEventsPerWarmTrace: 50,
1265
+ detailLoadMode: "lazy_from_disk",
1266
+ },
1267
+ sessionLogDirectories: [],
1268
+ sources: {
1269
+ codex_home: {
1270
+ name: "codex_home",
1271
+ enabled: true,
1272
+ roots: [path.join(root, ".codex", "sessions")],
1273
+ includeGlobs: ["**/*.jsonl"],
1274
+ excludeGlobs: [],
1275
+ maxDepth: 8,
1276
+ agentHint: "codex",
1277
+ },
1278
+ claude_projects: {
1279
+ name: "claude_projects",
1280
+ enabled: false,
1281
+ roots: [],
1282
+ includeGlobs: ["**/*.jsonl"],
1283
+ excludeGlobs: [],
1284
+ maxDepth: 8,
1285
+ agentHint: "claude",
1286
+ },
1287
+ claude_history: {
1288
+ name: "claude_history",
1289
+ enabled: false,
1290
+ roots: [],
1291
+ includeGlobs: ["history.jsonl"],
1292
+ excludeGlobs: [],
1293
+ maxDepth: 8,
1294
+ agentHint: "claude",
1295
+ },
1296
+ },
1297
+ });
1298
+ const configPath = path.join(root, "config.toml");
1299
+ await saveConfig(config, configPath);
1300
+ const index = new TraceIndex(config);
1301
+ await index.start();
1302
+ expect(index.getSummaries()[2]?.residentTier).not.toBe("hot");
1303
+ const internal = index;
1304
+ const parseFileSyncSpy = vi.spyOn(internal.parserRegistry, "parseFileSync");
1305
+ const server = await createServer({
1306
+ traceIndex: index,
1307
+ configPath,
1308
+ enableStatic: false,
1309
+ });
1310
+ const response = await server.inject({
1311
+ method: "GET",
1312
+ url: "/api/activity/year?end_date=2026-02-11&tz_offset_min=0&day_count=365",
1313
+ });
1314
+ expect(response.statusCode).toBe(200);
1315
+ expect(parseFileSyncSpy).not.toHaveBeenCalled();
1316
+ await server.close();
1317
+ index.stop();
1318
+ }, 20_000);
891
1319
  it("returns validation errors for invalid activity day query params", async () => {
892
1320
  const fixture = await buildFixtureWithTraceCount(1);
893
1321
  const server = await createServer({
@@ -1002,6 +1430,239 @@ describe("server api", () => {
1002
1430
  expect(byStart.get(Date.UTC(2026, 1, 11, 13, 0, 0))).toBe(1);
1003
1431
  await server.close();
1004
1432
  }, 20_000);
1433
+ it("listens before trace index startup resolves", async () => {
1434
+ const fixture = await buildFixture();
1435
+ let releaseStart = () => { };
1436
+ const startDeferred = new Promise((resolve) => {
1437
+ releaseStart = resolve;
1438
+ });
1439
+ let startupState = {
1440
+ phase: "bootstrapping",
1441
+ inspectorReady: false,
1442
+ fullReady: false,
1443
+ isPartial: false,
1444
+ discoveredTraceCount: 0,
1445
+ hydratedTraceCount: 0,
1446
+ startupError: "",
1447
+ };
1448
+ const startSpy = vi.spyOn(fixture.index, "start").mockImplementation(async () => {
1449
+ await startDeferred;
1450
+ startupState = {
1451
+ phase: "ready",
1452
+ inspectorReady: true,
1453
+ fullReady: true,
1454
+ isPartial: false,
1455
+ discoveredTraceCount: 1,
1456
+ hydratedTraceCount: 1,
1457
+ startupError: "",
1458
+ };
1459
+ });
1460
+ const startupSpy = vi.spyOn(fixture.index, "getStartupStatus").mockImplementation(() => startupState);
1461
+ const server = await runServer({
1462
+ host: "127.0.0.1",
1463
+ port: 0,
1464
+ configPath: fixture.configPath,
1465
+ enableStatic: false,
1466
+ traceIndex: fixture.index,
1467
+ });
1468
+ try {
1469
+ expect(startSpy).toHaveBeenCalledOnce();
1470
+ const health = await server.inject({ method: "GET", url: "/api/healthz" });
1471
+ expect(health.statusCode).toBe(200);
1472
+ expect(health.json()).toEqual({ ok: true });
1473
+ const notReady = await server.inject({ method: "GET", url: "/api/readyz" });
1474
+ expect(notReady.statusCode).toBe(503);
1475
+ expect(notReady.json()).toEqual({ ok: false, ready: false, startup: startupState });
1476
+ releaseStart();
1477
+ await startDeferred;
1478
+ const ready = await server.inject({ method: "GET", url: "/api/readyz" });
1479
+ expect(ready.statusCode).toBe(200);
1480
+ expect(ready.json()).toEqual({ ok: true, ready: true, startup: startupState });
1481
+ }
1482
+ finally {
1483
+ fixture.index.stop();
1484
+ await server.close();
1485
+ startSpy.mockRestore();
1486
+ startupSpy.mockRestore();
1487
+ }
1488
+ });
1489
+ it("surfaces trace index startup failures through readiness endpoint", async () => {
1490
+ const fixture = await buildFixture();
1491
+ const startSpy = vi.spyOn(fixture.index, "start").mockRejectedValue(new Error("boom on startup"));
1492
+ const startupState = {
1493
+ phase: "failed",
1494
+ inspectorReady: false,
1495
+ fullReady: false,
1496
+ isPartial: false,
1497
+ discoveredTraceCount: 0,
1498
+ hydratedTraceCount: 0,
1499
+ startupError: "boom on startup",
1500
+ };
1501
+ const startupSpy = vi.spyOn(fixture.index, "getStartupStatus").mockReturnValue(startupState);
1502
+ const server = await runServer({
1503
+ host: "127.0.0.1",
1504
+ port: 0,
1505
+ configPath: fixture.configPath,
1506
+ enableStatic: false,
1507
+ traceIndex: fixture.index,
1508
+ });
1509
+ try {
1510
+ expect(startSpy).toHaveBeenCalledOnce();
1511
+ await new Promise((resolve) => setTimeout(resolve, 25));
1512
+ const ready = await server.inject({ method: "GET", url: "/api/readyz" });
1513
+ expect(ready.statusCode).toBe(503);
1514
+ expect(ready.json()).toEqual({
1515
+ ok: false,
1516
+ ready: false,
1517
+ startup: startupState,
1518
+ startupError: "boom on startup",
1519
+ });
1520
+ }
1521
+ finally {
1522
+ fixture.index.stop();
1523
+ await server.close();
1524
+ startSpy.mockRestore();
1525
+ startupSpy.mockRestore();
1526
+ }
1527
+ });
1528
+ it("serves partial overview and warming activity while background hydration continues", async () => {
1529
+ const fixture = await buildFixture();
1530
+ const startupState = {
1531
+ phase: "hydrating",
1532
+ inspectorReady: true,
1533
+ fullReady: false,
1534
+ isPartial: true,
1535
+ discoveredTraceCount: 240,
1536
+ hydratedTraceCount: 120,
1537
+ startupError: "",
1538
+ };
1539
+ const startupSpy = vi.spyOn(fixture.index, "getStartupStatus").mockReturnValue(startupState);
1540
+ const dayProgress = {
1541
+ ready: false,
1542
+ relevantDiscoveredCount: 240,
1543
+ relevantHydratedCount: 120,
1544
+ percent: 50,
1545
+ };
1546
+ const progressSpy = vi.spyOn(fixture.index, "getHydrationProgress").mockReturnValue(dayProgress);
1547
+ const server = await createServer({
1548
+ traceIndex: fixture.index,
1549
+ configPath: fixture.configPath,
1550
+ enableStatic: false,
1551
+ });
1552
+ try {
1553
+ const ready = await server.inject({ method: "GET", url: "/api/readyz" });
1554
+ expect(ready.statusCode).toBe(200);
1555
+ expect(ready.json()).toEqual({ ok: true, ready: true, startup: startupState });
1556
+ const overview = await server.inject({ method: "GET", url: "/api/overview" });
1557
+ expect(overview.statusCode).toBe(200);
1558
+ expect(overview.json()).toMatchObject({
1559
+ overview: expect.any(Object),
1560
+ startup: startupState,
1561
+ });
1562
+ const day = await server.inject({
1563
+ method: "GET",
1564
+ url: "/api/activity/day?date=2026-02-11&tz_offset_min=0&bin_min=5&break_min=10",
1565
+ });
1566
+ expect(day.statusCode).toBe(503);
1567
+ expect(day.json()).toEqual({
1568
+ warming: true,
1569
+ progress: dayProgress,
1570
+ });
1571
+ const year = await server.inject({
1572
+ method: "GET",
1573
+ url: "/api/activity/year?end_date=2026-02-11&tz_offset_min=0&day_count=365",
1574
+ });
1575
+ expect(year.statusCode).toBe(503);
1576
+ expect(year.json()).toEqual({
1577
+ warming: true,
1578
+ progress: dayProgress,
1579
+ });
1580
+ }
1581
+ finally {
1582
+ progressSpy.mockRestore();
1583
+ startupSpy.mockRestore();
1584
+ fixture.index.stop();
1585
+ await server.close();
1586
+ }
1587
+ });
1588
+ it("keeps activity warming until inspector bootstrap metadata is ready", async () => {
1589
+ const fixture = await buildFixture();
1590
+ const startupState = {
1591
+ phase: "bootstrapping",
1592
+ inspectorReady: false,
1593
+ fullReady: false,
1594
+ isPartial: false,
1595
+ discoveredTraceCount: 0,
1596
+ hydratedTraceCount: 0,
1597
+ startupError: "",
1598
+ };
1599
+ const startupSpy = vi.spyOn(fixture.index, "getStartupStatus").mockReturnValue(startupState);
1600
+ const progressSpy = vi.spyOn(fixture.index, "getHydrationProgress").mockReturnValue({
1601
+ ready: true,
1602
+ relevantDiscoveredCount: 12,
1603
+ relevantHydratedCount: 12,
1604
+ percent: 100,
1605
+ });
1606
+ const server = await createServer({
1607
+ traceIndex: fixture.index,
1608
+ configPath: fixture.configPath,
1609
+ enableStatic: false,
1610
+ });
1611
+ try {
1612
+ const day = await server.inject({
1613
+ method: "GET",
1614
+ url: "/api/activity/day?date=2026-02-11&tz_offset_min=0&bin_min=5&break_min=10",
1615
+ });
1616
+ expect(day.statusCode).toBe(503);
1617
+ expect(day.json()).toEqual({
1618
+ warming: true,
1619
+ progress: {
1620
+ ready: false,
1621
+ relevantDiscoveredCount: 0,
1622
+ relevantHydratedCount: 0,
1623
+ percent: 0,
1624
+ },
1625
+ });
1626
+ }
1627
+ finally {
1628
+ progressSpy.mockRestore();
1629
+ startupSpy.mockRestore();
1630
+ fixture.index.stop();
1631
+ await server.close();
1632
+ }
1633
+ });
1634
+ it("allows day activity to load before slower year hydration completes", async () => {
1635
+ const fixture = await buildFixture();
1636
+ const progressSpy = vi.spyOn(fixture.index, "getHydrationProgress").mockImplementation((windowStartMs) => windowStartMs >= Date.UTC(2026, 1, 11, 7, 0, 0)
1637
+ ? { ready: true, relevantDiscoveredCount: 12, relevantHydratedCount: 12, percent: 100 }
1638
+ : { ready: false, relevantDiscoveredCount: 240, relevantHydratedCount: 120, percent: 50 });
1639
+ const server = await createServer({
1640
+ traceIndex: fixture.index,
1641
+ configPath: fixture.configPath,
1642
+ enableStatic: false,
1643
+ });
1644
+ try {
1645
+ const day = await server.inject({
1646
+ method: "GET",
1647
+ url: "/api/activity/day?date=2026-02-11&tz_offset_min=0&bin_min=5&break_min=10",
1648
+ });
1649
+ expect(day.statusCode).toBe(200);
1650
+ const year = await server.inject({
1651
+ method: "GET",
1652
+ url: "/api/activity/year?end_date=2026-02-11&tz_offset_min=0&day_count=365",
1653
+ });
1654
+ expect(year.statusCode).toBe(503);
1655
+ expect(year.json()).toEqual({
1656
+ warming: true,
1657
+ progress: { ready: false, relevantDiscoveredCount: 240, relevantHydratedCount: 120, percent: 50 },
1658
+ });
1659
+ }
1660
+ finally {
1661
+ progressSpy.mockRestore();
1662
+ fixture.index.stop();
1663
+ await server.close();
1664
+ }
1665
+ });
1005
1666
  it("serves overview, trace listing, trace details, stop/open controls, and config updates", async () => {
1006
1667
  const fixture = await buildFixture();
1007
1668
  const stopTraceSession = vi.fn();
@@ -1259,7 +1920,7 @@ describe("server api", () => {
1259
1920
  const configPayload = configPatch.json();
1260
1921
  expect(configPayload.config.scan.intervalSeconds).toBe(3);
1261
1922
  expect(configPayload.config.cost.enabled).toBe(false);
1262
- expect(configPayload.config.cost.modelRates[0]?.model).toBe("gpt-5.3-codex");
1923
+ expect(configPayload.config.cost.modelRates.some((rate) => rate.model === "gpt-5.3-codex")).toBe(true);
1263
1924
  await server.close();
1264
1925
  }, 20_000);
1265
1926
  it("serves web index for trace-file deep-link routes when static assets exist", async () => {