@obsunified/collector 2.0.1 → 2.1.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.
Files changed (107) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/index.d.ts +322 -26
  6. package/dist/index.js +1478 -415
  7. package/dist/index.js.map +1 -1
  8. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  9. package/package.json +5 -5
  10. package/src/analyses/derive.ts +26 -25
  11. package/src/analyses/investigations.ts +8 -8
  12. package/src/analyses/tier0.ts +22 -22
  13. package/src/auth/dashboard-auth.test.ts +791 -0
  14. package/src/auth/dashboard-auth.ts +187 -38
  15. package/src/auth/login-rate-limit.test.ts +129 -0
  16. package/src/auth/login-rate-limit.ts +143 -0
  17. package/src/framework/body-limit.test.ts +165 -0
  18. package/src/framework/collector.ts +355 -115
  19. package/src/framework/env.ts +9 -0
  20. package/src/framework/limits.ts +31 -0
  21. package/src/framework/scheduled-handlers.test.ts +342 -0
  22. package/src/index.ts +16 -1
  23. package/src/lib/action-retention.test.ts +472 -0
  24. package/src/lib/action-retention.ts +420 -0
  25. package/src/lib/actions-store.ts +6 -12
  26. package/src/lib/ai-store/calls.ts +2 -2
  27. package/src/lib/ai-store/sessions.ts +3 -3
  28. package/src/lib/ai-store/spans.ts +1 -1
  29. package/src/lib/ai-store/types.ts +2 -1
  30. package/src/lib/alerts-store.test.ts +153 -0
  31. package/src/lib/alerts-store.ts +122 -21
  32. package/src/lib/analyses-runner.test.ts +75 -0
  33. package/src/lib/analyses-runner.ts +60 -3
  34. package/src/lib/analyses-store-latest.test.ts +61 -0
  35. package/src/lib/analyses-store.ts +17 -10
  36. package/src/lib/blob-store.ts +1 -1
  37. package/src/lib/housekeeping.test.ts +73 -0
  38. package/src/lib/housekeeping.ts +93 -0
  39. package/src/lib/identity-index/action-lookups.ts +6 -6
  40. package/src/lib/identity-index/key-lookups.ts +2 -2
  41. package/src/lib/identity-index/user-lookup.ts +8 -5
  42. package/src/lib/identity-index.test.ts +2 -3
  43. package/src/lib/metrics-store-series.test.ts +99 -0
  44. package/src/lib/metrics-store.test.ts +23 -0
  45. package/src/lib/metrics-store.ts +27 -3
  46. package/src/lib/projects-store.test.ts +25 -0
  47. package/src/lib/projects-store.ts +4 -3
  48. package/src/lib/sql-db-dialect.test.ts +12 -3
  49. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  50. package/src/lib/sql-db-postgres.test.ts +314 -0
  51. package/src/lib/sql-db-postgres.ts +392 -54
  52. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  53. package/src/lib/sql-db.ts +40 -6
  54. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  55. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  56. package/src/lib/webhook-url.test.ts +122 -0
  57. package/src/lib/webhook-url.ts +155 -0
  58. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  59. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  60. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  61. package/src/migrations/044_actions_started_at_index.sql +10 -0
  62. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  63. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  64. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  65. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  66. package/src/migrations/049_alert_state_notified.sql +17 -0
  67. package/src/migrations/alert-state-notified.test.ts +111 -0
  68. package/src/migrations/parity.test.ts +64 -0
  69. package/src/migrations/project-scoped-keys.test.ts +432 -0
  70. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  71. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  72. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  73. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  74. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  75. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  76. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  77. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  78. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  79. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  80. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  81. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  82. package/src/migrations-postgres/README.md +58 -5
  83. package/src/otlp/decode/body.test.ts +76 -0
  84. package/src/otlp/decode/body.ts +67 -6
  85. package/src/plugins/_json.ts +21 -0
  86. package/src/plugins/action-graph-processor.ts +6 -12
  87. package/src/plugins/ai-receiver.ts +5 -12
  88. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  89. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  90. package/src/plugins/alerts-evaluator.test.ts +464 -3
  91. package/src/plugins/alerts-evaluator.ts +165 -41
  92. package/src/plugins/alerts-routes.ts +10 -6
  93. package/src/plugins/ask-routes.ts +4 -5
  94. package/src/plugins/connected-routes.test.ts +106 -0
  95. package/src/plugins/connected-routes.ts +14 -5
  96. package/src/plugins/dashboard-routes.test.ts +120 -0
  97. package/src/plugins/dashboard-routes.ts +11 -2
  98. package/src/plugins/eval-cases-routes.ts +8 -17
  99. package/src/plugins/identity-receiver.ts +9 -11
  100. package/src/plugins/json-body.test.ts +91 -0
  101. package/src/plugins/onboarding-routes.test.ts +105 -0
  102. package/src/plugins/onboarding-routes.ts +26 -11
  103. package/src/plugins/projects-routes.ts +2 -1
  104. package/src/plugins/replay-isolation.test.ts +922 -0
  105. package/src/plugins/replay-receiver.ts +8 -8
  106. package/src/plugins/usage-receiver.ts +3 -6
  107. package/dist/chunk-UGWDOGGH.js.map +0 -1
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Action graph retention against real SQLite (`node:sqlite`) with the shipped
3
+ * `migrations/`. Postgres coverage for the same module lives in
4
+ * `sql-db-postgres.integration.test.ts`.
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+ import {
9
+ actionRetentionCutoff,
10
+ purgeExpiredActionGraph,
11
+ } from "./action-retention";
12
+ import { ActionStore } from "./actions-store";
13
+ import type { SqlDb, SqlStatement } from "./sql-db";
14
+ import {
15
+ actionRecord,
16
+ agentRunRecord,
17
+ countActionGraph,
18
+ EXPIRED_START,
19
+ FIXTURE_NOW_MS,
20
+ FIXTURE_RETENTION_HOURS,
21
+ FRESH_START,
22
+ hoursAgo,
23
+ seedEvalCase,
24
+ seedTree,
25
+ toolCallRecord,
26
+ } from "./test-utils/action-graph-fixtures";
27
+ import {
28
+ listSqliteMigrations,
29
+ NodeSqliteDb,
30
+ } from "./test-utils/node-sqlite-db";
31
+
32
+ type PurgeOverrides = {
33
+ maxActionsPerBatch?: number;
34
+ maxTreesPerBatch?: number;
35
+ maxBatches?: number;
36
+ };
37
+
38
+ const now = () => FIXTURE_NOW_MS;
39
+ const purge = (db: SqlDb, over: PurgeOverrides = {}) =>
40
+ purgeExpiredActionGraph(db, {
41
+ retentionHours: FIXTURE_RETENTION_HOURS,
42
+ now,
43
+ ...over,
44
+ });
45
+
46
+ const EMPTY_COUNTS = {
47
+ actions: 0,
48
+ agent_runs: 0,
49
+ tool_calls: 0,
50
+ retrieval_events: 0,
51
+ eval_results: 0,
52
+ artifacts: 0,
53
+ };
54
+
55
+ const NOTHING_PURGED = {
56
+ ...EMPTY_COUNTS,
57
+ trees: 0,
58
+ batches: 0,
59
+ truncated: false,
60
+ oversized_trees: 0,
61
+ oversized: [],
62
+ };
63
+
64
+ const indexNames = (db: NodeSqliteDb): string[] =>
65
+ (
66
+ db.raw
67
+ .prepare(
68
+ `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'actions'`,
69
+ )
70
+ .all() as Array<{ name: string }>
71
+ ).map((r) => r.name);
72
+
73
+ /** Root + `children` child actions, one `agent_runs` row, leaves on the first child. */
74
+ const seedWideTree = async (
75
+ db: SqlDb,
76
+ rootId: string,
77
+ children: number,
78
+ projectId = "alpha",
79
+ ) => {
80
+ const store = new ActionStore(db);
81
+ await store.upsertAction(
82
+ actionRecord({
83
+ id: rootId,
84
+ projectId,
85
+ rootActionId: rootId,
86
+ agentRunId: rootId,
87
+ }),
88
+ );
89
+ await store.upsertAgentRun(agentRunRecord({ id: rootId, projectId }));
90
+ for (let i = 0; i < children; i += 1) {
91
+ await store.upsertAction(
92
+ actionRecord({
93
+ id: `${rootId}-c${i}`,
94
+ projectId,
95
+ rootActionId: rootId,
96
+ causedByActionId: rootId,
97
+ agentRunId: rootId,
98
+ actionKind: "tool.call",
99
+ }),
100
+ );
101
+ }
102
+ await store.upsertToolCall(
103
+ toolCallRecord({ id: `${rootId}-tc`, actionId: `${rootId}-c0`, projectId }),
104
+ );
105
+ return { rootId, actions: children + 1, toolCallId: `${rootId}-tc` };
106
+ };
107
+
108
+ describe("actionRetentionCutoff", () => {
109
+ it("renders the cutoff as collector-style ISO text", () => {
110
+ expect(actionRetentionCutoff(72, FIXTURE_NOW_MS)).toBe(hoursAgo(72));
111
+ expect(actionRetentionCutoff(1, FIXTURE_NOW_MS)).toMatch(
112
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
113
+ );
114
+ });
115
+ });
116
+
117
+ describe("purgeExpiredActionGraph (SQLite)", () => {
118
+ it("fresh schema ships the started_at index", () => {
119
+ const db = NodeSqliteDb.migrated();
120
+ expect(indexNames(db)).toContain("idx_actions_started_at");
121
+ });
122
+
123
+ it("upgrade: 044 adds the index to a database migrated through 043 and the purge then works", async () => {
124
+ const db = NodeSqliteDb.migrated(
125
+ "043_metric_exemplars_text_timestamps.sql",
126
+ );
127
+ expect(indexNames(db)).not.toContain("idx_actions_started_at");
128
+
129
+ // 044 adds the index; 046 rebuilds the table on (project_id, id) and
130
+ // must carry it over. The store's upsert targets that composite key,
131
+ // so seeding happens after the upgrade tail is applied.
132
+ db.applyMigrations(
133
+ listSqliteMigrations().filter((f) => f >= "044_" && f < "049_"),
134
+ );
135
+ expect(indexNames(db)).toContain("idx_actions_started_at");
136
+ await seedTree(db, { rootId: "old" });
137
+
138
+ const summary = await purge(db);
139
+ expect(summary.actions).toBe(2);
140
+ expect(summary.trees).toBe(1);
141
+ expect(await countActionGraph(db, "alpha")).toEqual(EMPTY_COUNTS);
142
+ });
143
+
144
+ it("deletes expired trees with their leaves and keeps fresh trees, per project", async () => {
145
+ const db = NodeSqliteDb.migrated();
146
+ const expired = await seedTree(db, { rootId: "alpha-old" });
147
+ const fresh = await seedTree(db, {
148
+ rootId: "alpha-new",
149
+ rootStartedAt: FRESH_START,
150
+ });
151
+ // Keys are (project_id, id) since 046; beta gets its own fresh and
152
+ // expired trees so the per-project counts stay readable.
153
+ const otherFresh = await seedTree(db, {
154
+ projectId: "beta",
155
+ rootId: "beta-new",
156
+ rootStartedAt: FRESH_START,
157
+ });
158
+ await seedTree(db, { projectId: "beta", rootId: "beta-old" });
159
+
160
+ const summary = await purge(db);
161
+
162
+ expect(summary).toEqual({
163
+ actions: 4,
164
+ agent_runs: 2,
165
+ tool_calls: 2,
166
+ retrieval_events: 2,
167
+ eval_results: 2,
168
+ artifacts: 2,
169
+ trees: 2,
170
+ batches: 1,
171
+ truncated: false,
172
+ oversized_trees: 0,
173
+ oversized: [],
174
+ });
175
+ expect(db.count("actions", "root_action_id = ?", expired.rootId)).toBe(0);
176
+ expect(db.count("agent_runs", "id = ?", expired.rootId)).toBe(0);
177
+ expect(db.count("tool_calls", "id = ?", expired.toolCallId)).toBe(0);
178
+ expect(
179
+ db.count("retrieval_events", "id = ?", expired.retrievalEventId),
180
+ ).toBe(0);
181
+ expect(db.count("eval_results", "id = ?", expired.evalResultId)).toBe(0);
182
+ expect(db.count("artifacts", "id = ?", expired.artifactId)).toBe(0);
183
+ expect(db.count("actions", "root_action_id = ?", "beta-old")).toBe(0);
184
+
185
+ expect(db.count("actions", "root_action_id = ?", fresh.rootId)).toBe(2);
186
+ expect(db.count("tool_calls", "id = ?", fresh.toolCallId)).toBe(1);
187
+ expect(await countActionGraph(db, "beta")).toEqual({
188
+ actions: 2,
189
+ agent_runs: 1,
190
+ tool_calls: 1,
191
+ retrieval_events: 1,
192
+ eval_results: 1,
193
+ artifacts: 1,
194
+ });
195
+ expect(db.count("tool_calls", "id = ?", otherFresh.toolCallId)).toBe(1);
196
+ });
197
+
198
+ it("scopes every DELETE by project even though action ids are globally unique", async () => {
199
+ const db = NodeSqliteDb.migrated();
200
+ await seedTree(db, { rootId: "alpha-old" });
201
+ // A beta leaf row that (incorrectly) points at an alpha action id must
202
+ // not be swept up with alpha's tree.
203
+ await new ActionStore(db).upsertToolCall(
204
+ toolCallRecord({
205
+ id: "beta-stray",
206
+ actionId: "alpha-old-child",
207
+ projectId: "beta",
208
+ }),
209
+ );
210
+
211
+ const summary = await purge(db);
212
+
213
+ expect(summary.actions).toBe(2);
214
+ expect(summary.tool_calls).toBe(1);
215
+ expect(db.count("tool_calls", "id = ?", "beta-stray")).toBe(1);
216
+ });
217
+
218
+ it("keeps a whole tree while any action in it is fresh or still in progress", async () => {
219
+ const db = NodeSqliteDb.migrated();
220
+ // Expired root, fresh child → the root must survive too.
221
+ await seedTree(db, { rootId: "straddles", childStartedAt: FRESH_START });
222
+ // Expired root, expired child that never ended.
223
+ await seedTree(db, { rootId: "hung", childEndedAt: null });
224
+ // Fully expired control tree.
225
+ await seedTree(db, { rootId: "done" });
226
+
227
+ const summary = await purge(db);
228
+
229
+ expect(summary.actions).toBe(2);
230
+ expect(db.count("actions", "root_action_id = ?", "straddles")).toBe(2);
231
+ expect(db.count("actions", "root_action_id = ?", "hung")).toBe(2);
232
+ expect(db.count("tool_calls", "action_id = ?", "hung-child")).toBe(1);
233
+ expect(db.count("actions", "root_action_id = ?", "done")).toBe(0);
234
+ });
235
+
236
+ it("keeps trees whose agent run is still marked running", async () => {
237
+ const db = NodeSqliteDb.migrated();
238
+ await seedTree(db, { rootId: "running", agentRunStatus: "running" });
239
+ await seedTree(db, { rootId: "finished" });
240
+
241
+ const summary = await purge(db);
242
+
243
+ expect(summary.actions).toBe(2);
244
+ expect(db.count("actions", "root_action_id = ?", "running")).toBe(2);
245
+ expect(db.count("agent_runs", "id = ?", "running")).toBe(1);
246
+ expect(db.count("actions", "root_action_id = ?", "finished")).toBe(0);
247
+ });
248
+
249
+ it("keeps trees referenced by eval cases through action, agent run or tool call ids", async () => {
250
+ const db = NodeSqliteDb.migrated();
251
+ const byAction = await seedTree(db, { rootId: "pin-action" });
252
+ const byRun = await seedTree(db, { rootId: "pin-run" });
253
+ const byTool = await seedTree(db, { rootId: "pin-tool" });
254
+ await seedTree(db, { rootId: "unpinned" });
255
+ await seedTree(db, { rootId: "cross" });
256
+
257
+ await seedEvalCase(db, { id: "c1", sourceActionId: byAction.childId });
258
+ await seedEvalCase(db, { id: "c2", sourceAgentRunId: byRun.rootId });
259
+ await seedEvalCase(db, { id: "c3", sourceToolCallId: byTool.toolCallId });
260
+ // An eval case in another project naming an alpha action id does not
261
+ // pin alpha's tree: references are matched within the project.
262
+ await seedEvalCase(db, {
263
+ id: "c4",
264
+ projectId: "beta",
265
+ sourceActionId: "cross-child",
266
+ });
267
+
268
+ const summary = await purge(db);
269
+
270
+ expect(summary.actions).toBe(4);
271
+ for (const root of ["pin-action", "pin-run", "pin-tool"]) {
272
+ expect(db.count("actions", "root_action_id = ?", root)).toBe(2);
273
+ expect(db.count("agent_runs", "id = ?", root)).toBe(1);
274
+ }
275
+ expect(db.count("tool_calls", "id = ?", byTool.toolCallId)).toBe(1);
276
+ expect(db.count("actions", "root_action_id = ?", "unpinned")).toBe(0);
277
+ expect(db.count("actions", "root_action_id = ?", "cross")).toBe(0);
278
+ expect(db.count("eval_cases")).toBe(4);
279
+ });
280
+
281
+ it("is idempotent: a second sweep finds nothing and changes nothing", async () => {
282
+ const db = NodeSqliteDb.migrated();
283
+ await seedTree(db, { rootId: "run-1" });
284
+ await seedTree(db, { rootId: "run-2", rootStartedAt: FRESH_START });
285
+
286
+ const first = await purge(db);
287
+ const second = await purge(db);
288
+
289
+ expect(first.actions).toBe(2);
290
+ expect(second).toEqual(NOTHING_PURGED);
291
+ expect(await countActionGraph(db, "alpha")).toEqual({
292
+ actions: 2,
293
+ agent_runs: 1,
294
+ tool_calls: 1,
295
+ retrieval_events: 1,
296
+ eval_results: 1,
297
+ artifacts: 1,
298
+ });
299
+ });
300
+
301
+ it("never splits a tree: one eligible tree larger than the batch cap is retained intact and reported", async () => {
302
+ const db = NodeSqliteDb.migrated();
303
+ const big = await seedWideTree(db, "big", 6); // 7 actions
304
+ const small = await seedTree(db, { rootId: "small" }); // 2 actions
305
+
306
+ const summary = await purge(db, { maxActionsPerBatch: 4, maxBatches: 1 });
307
+
308
+ // The small tree goes whole; the big one is untouched, not trimmed.
309
+ expect(summary).toMatchObject({
310
+ actions: 2,
311
+ trees: 1,
312
+ batches: 1,
313
+ truncated: false,
314
+ oversized_trees: 1,
315
+ oversized: [{ project_id: "alpha", root_action_id: "big", actions: 7 }],
316
+ });
317
+ expect(db.count("actions", "root_action_id = ?", big.rootId)).toBe(
318
+ big.actions,
319
+ );
320
+ expect(db.count("agent_runs", "id = ?", big.rootId)).toBe(1);
321
+ expect(db.count("tool_calls", "id = ?", big.toolCallId)).toBe(1);
322
+ expect(db.count("actions", "root_action_id = ?", small.rootId)).toBe(0);
323
+
324
+ // Running again with the same cap changes nothing; raising the cap
325
+ // deletes the big tree in one piece.
326
+ expect(await purge(db, { maxActionsPerBatch: 4 })).toMatchObject({
327
+ actions: 0,
328
+ oversized_trees: 1,
329
+ });
330
+ const raised = await purge(db, { maxActionsPerBatch: 7 });
331
+ expect(raised).toMatchObject({
332
+ actions: 7,
333
+ trees: 1,
334
+ agent_runs: 1,
335
+ tool_calls: 1,
336
+ oversized_trees: 0,
337
+ });
338
+ expect(await countActionGraph(db, "alpha")).toEqual(EMPTY_COUNTS);
339
+ });
340
+
341
+ it("packs whole trees into a batch up to the action cap and leaves the rest for later batches", async () => {
342
+ const db = NodeSqliteDb.migrated();
343
+ for (let i = 0; i < 5; i += 1) {
344
+ await seedTree(db, { rootId: `run-${i}`, withLeaves: false });
345
+ }
346
+ expect(db.count("actions")).toBe(10);
347
+
348
+ // 5 actions fit two 2-action trees, not two and a half.
349
+ const capped = await purge(db, { maxActionsPerBatch: 5, maxBatches: 1 });
350
+ expect(capped).toMatchObject({
351
+ actions: 4,
352
+ trees: 2,
353
+ agent_runs: 2,
354
+ batches: 1,
355
+ truncated: true,
356
+ });
357
+ expect(db.count("actions")).toBe(6);
358
+ expect(db.count("agent_runs")).toBe(3);
359
+ // Every surviving tree is still complete.
360
+ const survivors = db.raw
361
+ .prepare(
362
+ `SELECT root_action_id AS root, COUNT(*) AS n FROM actions GROUP BY root_action_id`,
363
+ )
364
+ .all() as Array<{ root: string; n: number }>;
365
+ expect(survivors.map((s) => Number(s.n))).toEqual([2, 2, 2]);
366
+
367
+ const rest = await purge(db, { maxActionsPerBatch: 5 });
368
+ expect(rest).toMatchObject({
369
+ actions: 6,
370
+ trees: 3,
371
+ batches: 2,
372
+ truncated: false,
373
+ });
374
+ expect(db.count("actions")).toBe(0);
375
+ expect(db.count("agent_runs")).toBe(0);
376
+ });
377
+
378
+ it("bounds trees per batch and reports truncation when batches run out", async () => {
379
+ const db = NodeSqliteDb.migrated();
380
+ for (let i = 0; i < 4; i += 1) {
381
+ await seedTree(db, { rootId: `run-${i}`, withLeaves: false });
382
+ }
383
+
384
+ const summary = await purge(db, { maxTreesPerBatch: 1, maxBatches: 2 });
385
+
386
+ expect(summary).toMatchObject({
387
+ actions: 4,
388
+ trees: 2,
389
+ batches: 2,
390
+ truncated: true,
391
+ });
392
+ expect(db.count("actions")).toBe(4);
393
+ });
394
+
395
+ it("re-checks eligibility inside the deleting transaction", async () => {
396
+ // Simulate an ingest landing after selection but before the batch runs:
397
+ // a fresh child arrives for the already-selected tree.
398
+ const inner = NodeSqliteDb.migrated();
399
+ await seedTree(inner, { rootId: "late" });
400
+ await seedTree(inner, { rootId: "quiet" });
401
+ const store = new ActionStore(inner);
402
+ let raced = false;
403
+ const racing: SqlDb = {
404
+ prepare: (sql) => inner.prepare(sql),
405
+ batch: async (statements: SqlStatement[]) => {
406
+ if (!raced) {
407
+ raced = true;
408
+ await store.upsertAction(
409
+ actionRecord({
410
+ id: "late-straggler",
411
+ rootActionId: "late",
412
+ causedByActionId: "late",
413
+ agentRunId: "late",
414
+ startedAt: FRESH_START,
415
+ endedAt: FRESH_START,
416
+ }),
417
+ );
418
+ }
419
+ return inner.batch(statements);
420
+ },
421
+ };
422
+
423
+ const summary = await purge(racing);
424
+
425
+ // Only the undisturbed tree went; the raced tree kept spine AND leaves.
426
+ expect(summary.actions).toBe(2);
427
+ expect(summary.trees).toBe(1);
428
+ expect(inner.count("actions", "root_action_id = ?", "late")).toBe(3);
429
+ expect(inner.count("agent_runs", "id = ?", "late")).toBe(1);
430
+ expect(inner.count("tool_calls", "action_id = ?", "late-child")).toBe(1);
431
+ expect(inner.count("artifacts", "action_id = ?", "late-child")).toBe(1);
432
+ expect(inner.count("actions", "root_action_id = ?", "quiet")).toBe(0);
433
+ });
434
+
435
+ it("leaves agent_runs rows that have no action and leaves other actions' leaves alone", async () => {
436
+ const db = NodeSqliteDb.migrated();
437
+ const store = new ActionStore(db);
438
+ await store.upsertAgentRun(agentRunRecord({ id: "orphan-run" }));
439
+ const expired = await seedTree(db, { rootId: "run-1" });
440
+ const fresh = await seedTree(db, {
441
+ rootId: "run-2",
442
+ rootStartedAt: FRESH_START,
443
+ });
444
+
445
+ await purge(db);
446
+
447
+ expect(db.count("agent_runs", "id = ?", "orphan-run")).toBe(1);
448
+ expect(db.count("artifacts", "id = ?", expired.artifactId)).toBe(0);
449
+ expect(db.count("artifacts", "id = ?", fresh.artifactId)).toBe(1);
450
+ expect(db.count("tool_calls", "id = ?", fresh.toolCallId)).toBe(1);
451
+ });
452
+
453
+ it("treats started_at exactly at the cutoff as not expired", async () => {
454
+ const db = NodeSqliteDb.migrated();
455
+ const store = new ActionStore(db);
456
+ const cutoff = actionRetentionCutoff(
457
+ FIXTURE_RETENTION_HOURS,
458
+ FIXTURE_NOW_MS,
459
+ );
460
+ await store.upsertAction(
461
+ actionRecord({ id: "at-cutoff", startedAt: cutoff, endedAt: cutoff }),
462
+ );
463
+ await store.upsertAction(
464
+ actionRecord({ id: "just-before", startedAt: EXPIRED_START }),
465
+ );
466
+
467
+ const summary = await purge(db);
468
+
469
+ expect(summary.actions).toBe(1);
470
+ expect(db.count("actions", "id = ?", "at-cutoff")).toBe(1);
471
+ });
472
+ });