@obsunified/collector 2.0.1 → 2.2.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 (126) 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/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Retention for the RFC 0010 action graph (`actions` spine plus the
3
+ * `agent_runs`, `tool_calls`, `retrieval_events`, `eval_results` and
4
+ * `artifacts` leaves).
5
+ *
6
+ * Unlike the telemetry tables these rows carry no `expires_at`; the sweep
7
+ * derives expiry from `actions.started_at` and the configured
8
+ * `RETENTION_HOURS`. The unit of deletion is the causal tree
9
+ * (`project_id`, `root_action_id`): a tree is selected, rechecked and
10
+ * removed as a whole inside one `SqlDb.batch` (a D1 batch or a Postgres
11
+ * transaction), never action by action. A tree is kept whole while any of
12
+ * its actions:
13
+ *
14
+ * - started inside the retention window,
15
+ * - is still in progress (`ended_at IS NULL`),
16
+ * - belongs to an `agent_runs` row whose `status` is `running`,
17
+ * - is referenced by an `eval_cases` row in the same project via
18
+ * `source_action_id`, `source_agent_run_id`, or (through a `tool_calls`
19
+ * row) `source_tool_call_id`.
20
+ *
21
+ * Work is bounded per invocation: each batch packs whole trees up to
22
+ * `maxActionsPerBatch` actions and `maxTreesPerBatch` trees, and the sweep
23
+ * stops after `maxBatches`. A tree with more than `maxActionsPerBatch`
24
+ * actions can never be deleted atomically within those bounds, so it is
25
+ * retained intact and reported as `oversized_trees` (and logged by the
26
+ * handler) instead of being deleted in pieces. Raise `maxActionsPerBatch`
27
+ * to reclaim such trees.
28
+ *
29
+ * Concurrency: every DELETE repeats the eligibility predicate, so a tree
30
+ * that gained a fresh or unfinished action, a `running` run, or an eval
31
+ * case between selection and deletion is left alone. On D1 the batch is a
32
+ * single serialized SQLite transaction, so that recheck is airtight. On
33
+ * Postgres (READ COMMITTED) each statement sees writes committed before it
34
+ * started, so a commit landing between the leaf deletes and the final spine
35
+ * delete of the same tree could leave that tree's spine without its leaves.
36
+ * That needs an ingest for a tree whose newest action is already older than
37
+ * the retention window, inside a sub-second window; it is documented rather
38
+ * than locked against because `SqlDb` has no row-locking primitive.
39
+ *
40
+ * Intentionally **not** deleted here:
41
+ *
42
+ * - `artifacts.storage_ref` blobs — only the metadata row goes; the blob
43
+ * store is not consulted (the collector never wrote those blobs).
44
+ * - `agent_runs` rows whose `id` is not an action in the tree — nothing
45
+ * proves when they started.
46
+ * - `ai_span_payloads.action_id` and `evidence_retrieval_refs` anchors —
47
+ * those tables have their own `expires_at` retention.
48
+ * - `eval_cases`, `eval_runs`, `eval_case_results` — curated fixtures,
49
+ * never retention-bound.
50
+ */
51
+
52
+ import type { SqlDb, SqlStatement } from "./sql-db";
53
+
54
+ export interface ActionGraphRetentionOptions {
55
+ /** Retention window; trees whose newest action is older than this expire. */
56
+ retentionHours: number;
57
+ /** Clock override for tests. Defaults to `Date.now`. */
58
+ now?: () => number;
59
+ /**
60
+ * Upper bound on actions deleted per batch/transaction. Also the largest
61
+ * tree the sweep will delete; bigger trees are retained and reported.
62
+ * Defaults to 500.
63
+ */
64
+ maxActionsPerBatch?: number;
65
+ /** Upper bound on trees per batch (bounds statements per transaction). Defaults to 25. */
66
+ maxTreesPerBatch?: number;
67
+ /** Upper bound on batches per invocation. Defaults to 10. */
68
+ maxBatches?: number;
69
+ }
70
+
71
+ /** Per-table delete counts for one `purgeExpiredActionGraph` call. */
72
+ export interface ActionGraphPurgeSummary {
73
+ actions: number;
74
+ agent_runs: number;
75
+ tool_calls: number;
76
+ retrieval_events: number;
77
+ eval_results: number;
78
+ artifacts: number;
79
+ /** Whole trees deleted. */
80
+ trees: number;
81
+ /** Batches (transactions) executed. */
82
+ batches: number;
83
+ /** `true` when `maxBatches` was hit while deletable trees remained. */
84
+ truncated: boolean;
85
+ /**
86
+ * Expired, otherwise eligible trees retained because they hold more than
87
+ * `maxActionsPerBatch` actions. Capped at `OVERSIZED_REPORT_LIMIT`.
88
+ */
89
+ oversized_trees: number;
90
+ /** Identities of the reported oversized trees, for operators. */
91
+ oversized: Array<{
92
+ project_id: string;
93
+ root_action_id: string;
94
+ actions: number;
95
+ }>;
96
+ }
97
+
98
+ const DEFAULT_MAX_ACTIONS_PER_BATCH = 500;
99
+ const DEFAULT_MAX_TREES_PER_BATCH = 25;
100
+ const DEFAULT_MAX_BATCHES = 10;
101
+ /** How many oversized trees one invocation lists; keeps the log line bounded. */
102
+ export const OVERSIZED_REPORT_LIMIT = 20;
103
+
104
+ /**
105
+ * Tree eligibility for the tree identified by the SQL expressions
106
+ * `project` / `root`. The DELETEs pass `?` placeholders (bound through
107
+ * `treeEligibleBinds`); the candidate query passes the grouped columns.
108
+ * Using the same text in both places means the decision is re-made,
109
+ * unchanged, inside the deleting transaction.
110
+ */
111
+ const treeEligibleSql = (project: string, root: string, cutoff: string) => `
112
+ NOT EXISTS (
113
+ SELECT 1 FROM actions t
114
+ WHERE t.project_id = ${project} AND t.root_action_id = ${root}
115
+ AND (t.started_at >= ${cutoff} OR t.ended_at IS NULL)
116
+ )
117
+ AND NOT EXISTS (
118
+ SELECT 1 FROM actions t
119
+ JOIN agent_runs r ON r.project_id = t.project_id AND r.id = t.agent_run_id
120
+ WHERE t.project_id = ${project} AND t.root_action_id = ${root}
121
+ AND r.status = 'running'
122
+ )
123
+ AND NOT EXISTS (
124
+ SELECT 1 FROM actions t
125
+ JOIN eval_cases c
126
+ ON c.project_id = t.project_id
127
+ AND (c.source_action_id = t.id OR c.source_agent_run_id = t.agent_run_id)
128
+ WHERE t.project_id = ${project} AND t.root_action_id = ${root}
129
+ )
130
+ AND NOT EXISTS (
131
+ SELECT 1 FROM actions t
132
+ JOIN tool_calls tc ON tc.project_id = t.project_id AND tc.action_id = t.id
133
+ JOIN eval_cases c ON c.project_id = tc.project_id AND c.source_tool_call_id = tc.id
134
+ WHERE t.project_id = ${project} AND t.root_action_id = ${root}
135
+ )`;
136
+
137
+ /** Eligibility with every tree reference bound; pair with `treeEligibleBinds`. */
138
+ const TREE_ELIGIBLE_SQL = treeEligibleSql("?", "?", "?");
139
+
140
+ /** Binds for `TREE_ELIGIBLE_SQL`, in placeholder order. */
141
+ const treeEligibleBinds = (
142
+ projectId: string,
143
+ rootActionId: string,
144
+ cutoff: string,
145
+ ): unknown[] => [
146
+ projectId,
147
+ rootActionId,
148
+ cutoff,
149
+ projectId,
150
+ rootActionId,
151
+ projectId,
152
+ rootActionId,
153
+ projectId,
154
+ rootActionId,
155
+ ];
156
+
157
+ /**
158
+ * Candidate trees, oldest first. The WHERE keeps the scan to expired rows
159
+ * (served by `idx_actions_started_at`); eligibility is decided per tree in
160
+ * HAVING over the whole tree, which — because eligible trees contain no
161
+ * fresh action — also makes `COUNT(*)` the full tree size.
162
+ *
163
+ * `sizeOp` is `<=` for deletable trees and `>` for oversized reporting.
164
+ * Binds: cutoff, size cap, cutoff (inside the eligibility test), limit.
165
+ */
166
+ const candidateTreesSql = (sizeOp: "<=" | ">") => `
167
+ SELECT a.project_id, a.root_action_id, COUNT(*) AS action_count,
168
+ MIN(a.started_at) AS oldest_started_at
169
+ FROM actions a
170
+ WHERE a.started_at < ?
171
+ GROUP BY a.project_id, a.root_action_id
172
+ HAVING COUNT(*) ${sizeOp} ?
173
+ AND ${treeEligibleSql("a.project_id", "a.root_action_id", "?")}
174
+ ORDER BY oldest_started_at ASC
175
+ LIMIT ?
176
+ `;
177
+
178
+ const SELECT_DELETABLE_TREES_SQL = candidateTreesSql("<=");
179
+ const SELECT_OVERSIZED_TREES_SQL = candidateTreesSql(">");
180
+
181
+ interface CandidateTreeRow {
182
+ project_id: string;
183
+ root_action_id: string;
184
+ /** D1 returns numbers; pg returns BIGINT as string unless type parsers are configured. */
185
+ action_count: number | string;
186
+ }
187
+
188
+ interface CandidateTree {
189
+ projectId: string;
190
+ rootActionId: string;
191
+ actions: number;
192
+ }
193
+
194
+ /** Tables keyed by `action_id`, deleted before the spine. */
195
+ const LEAF_TABLES = [
196
+ "tool_calls",
197
+ "retrieval_events",
198
+ "eval_results",
199
+ "artifacts",
200
+ ] as const;
201
+
202
+ type LeafTable = (typeof LEAF_TABLES)[number];
203
+ type CountedTable = LeafTable | "agent_runs" | "actions";
204
+
205
+ const emptySummary = (): ActionGraphPurgeSummary => ({
206
+ actions: 0,
207
+ agent_runs: 0,
208
+ tool_calls: 0,
209
+ retrieval_events: 0,
210
+ eval_results: 0,
211
+ artifacts: 0,
212
+ trees: 0,
213
+ batches: 0,
214
+ truncated: false,
215
+ oversized_trees: 0,
216
+ oversized: [],
217
+ });
218
+
219
+ /** Cutoff as the same ISO text shape the collector writes to `started_at`. */
220
+ export const actionRetentionCutoff = (
221
+ retentionHours: number,
222
+ nowMs: number = Date.now(),
223
+ ): string => new Date(nowMs - retentionHours * 60 * 60 * 1000).toISOString();
224
+
225
+ const selectTrees = async (
226
+ db: SqlDb,
227
+ sql: string,
228
+ cutoff: string,
229
+ sizeCap: number,
230
+ limit: number,
231
+ ): Promise<CandidateTree[]> => {
232
+ const { results } = await db
233
+ .prepare(sql)
234
+ .bind(cutoff, sizeCap, cutoff, limit)
235
+ .all<CandidateTreeRow>();
236
+ return results.map((row) => ({
237
+ projectId: row.project_id,
238
+ rootActionId: row.root_action_id,
239
+ actions: Number(row.action_count),
240
+ }));
241
+ };
242
+
243
+ /**
244
+ * Greedy pack of whole trees (already ordered oldest first) into one batch
245
+ * without exceeding `maxActions`. Every candidate fits on its own because
246
+ * the SELECT capped tree size at `maxActions`.
247
+ */
248
+ const packBatch = (
249
+ candidates: readonly CandidateTree[],
250
+ maxActions: number,
251
+ ): CandidateTree[] => {
252
+ const batch: CandidateTree[] = [];
253
+ let used = 0;
254
+ for (const tree of candidates) {
255
+ if (batch.length > 0 && used + tree.actions > maxActions) break;
256
+ batch.push(tree);
257
+ used += tree.actions;
258
+ }
259
+ return batch;
260
+ };
261
+
262
+ /**
263
+ * Purge expired action trees. Safe to call repeatedly: a second call right
264
+ * after the first finds no deletable trees and changes nothing.
265
+ */
266
+ export async function purgeExpiredActionGraph(
267
+ db: SqlDb,
268
+ options: ActionGraphRetentionOptions,
269
+ ): Promise<ActionGraphPurgeSummary> {
270
+ const maxActions = Math.max(
271
+ 1,
272
+ options.maxActionsPerBatch ?? DEFAULT_MAX_ACTIONS_PER_BATCH,
273
+ );
274
+ const maxTrees = Math.max(
275
+ 1,
276
+ options.maxTreesPerBatch ?? DEFAULT_MAX_TREES_PER_BATCH,
277
+ );
278
+ const maxBatches = Math.max(1, options.maxBatches ?? DEFAULT_MAX_BATCHES);
279
+ const nowMs = (options.now ?? Date.now)();
280
+ const cutoff = actionRetentionCutoff(options.retentionHours, nowMs);
281
+
282
+ const summary = emptySummary();
283
+ let exhausted = false;
284
+
285
+ for (let batch = 0; batch < maxBatches && !exhausted; batch += 1) {
286
+ const candidates = await selectTrees(
287
+ db,
288
+ SELECT_DELETABLE_TREES_SQL,
289
+ cutoff,
290
+ maxActions,
291
+ maxTrees,
292
+ );
293
+ if (candidates.length === 0) {
294
+ exhausted = true;
295
+ break;
296
+ }
297
+ const trees = packBatch(candidates, maxActions);
298
+ summary.batches += 1;
299
+ const deleted = await deleteTrees(db, trees, cutoff);
300
+ for (const table of LEAF_TABLES) summary[table] += deleted[table];
301
+ summary.agent_runs += deleted.agent_runs;
302
+ summary.actions += deleted.actions;
303
+ summary.trees += deleted.trees;
304
+ // Fewer candidates than asked for and all of them packed: nothing left.
305
+ exhausted =
306
+ candidates.length < maxTrees && trees.length === candidates.length;
307
+ }
308
+
309
+ if (!exhausted) {
310
+ const remaining = await selectTrees(
311
+ db,
312
+ SELECT_DELETABLE_TREES_SQL,
313
+ cutoff,
314
+ maxActions,
315
+ 1,
316
+ );
317
+ summary.truncated = remaining.length > 0;
318
+ }
319
+
320
+ const oversized = await selectTrees(
321
+ db,
322
+ SELECT_OVERSIZED_TREES_SQL,
323
+ cutoff,
324
+ maxActions,
325
+ OVERSIZED_REPORT_LIMIT,
326
+ );
327
+ summary.oversized_trees = oversized.length;
328
+ summary.oversized = oversized.map((tree) => ({
329
+ project_id: tree.projectId,
330
+ root_action_id: tree.rootActionId,
331
+ actions: tree.actions,
332
+ }));
333
+
334
+ return summary;
335
+ }
336
+
337
+ type BatchCounts = Record<CountedTable, number> & { trees: number };
338
+
339
+ /**
340
+ * Delete the given trees in one `SqlDb.batch`: for each tree, its four leaf
341
+ * tables, then `agent_runs`, then the spine, every statement scoped by
342
+ * `project_id` and the tree and guarded by the eligibility recheck.
343
+ */
344
+ async function deleteTrees(
345
+ db: SqlDb,
346
+ trees: readonly CandidateTree[],
347
+ cutoff: string,
348
+ ): Promise<BatchCounts> {
349
+ const statements: SqlStatement[] = [];
350
+ const owners: Array<{ table: CountedTable; tree: number }> = [];
351
+
352
+ trees.forEach(({ projectId, rootActionId }, treeIndex) => {
353
+ const guard = treeEligibleBinds(projectId, rootActionId, cutoff);
354
+ const push = (table: CountedTable, stmt: SqlStatement) => {
355
+ statements.push(stmt);
356
+ owners.push({ table, tree: treeIndex });
357
+ };
358
+ for (const table of LEAF_TABLES) {
359
+ push(
360
+ table,
361
+ db
362
+ .prepare(
363
+ `DELETE FROM ${table}
364
+ WHERE project_id = ?
365
+ AND action_id IN (
366
+ SELECT id FROM actions WHERE project_id = ? AND root_action_id = ?
367
+ )
368
+ AND ${TREE_ELIGIBLE_SQL}`,
369
+ )
370
+ .bind(projectId, projectId, rootActionId, ...guard),
371
+ );
372
+ }
373
+ // `agent_runs.id` matches the run's root `actions.id` (sub-agent runs
374
+ // match a nested action), so only runs anchored in this tree go.
375
+ push(
376
+ "agent_runs",
377
+ db
378
+ .prepare(
379
+ `DELETE FROM agent_runs
380
+ WHERE project_id = ?
381
+ AND id IN (
382
+ SELECT id FROM actions WHERE project_id = ? AND root_action_id = ?
383
+ )
384
+ AND ${TREE_ELIGIBLE_SQL}`,
385
+ )
386
+ .bind(projectId, projectId, rootActionId, ...guard),
387
+ );
388
+ push(
389
+ "actions",
390
+ db
391
+ .prepare(
392
+ `DELETE FROM actions
393
+ WHERE project_id = ? AND root_action_id = ?
394
+ AND ${TREE_ELIGIBLE_SQL}`,
395
+ )
396
+ .bind(projectId, rootActionId, ...guard),
397
+ );
398
+ });
399
+
400
+ const results = await db.batch(statements);
401
+ const counts: BatchCounts = {
402
+ tool_calls: 0,
403
+ retrieval_events: 0,
404
+ eval_results: 0,
405
+ artifacts: 0,
406
+ agent_runs: 0,
407
+ actions: 0,
408
+ trees: 0,
409
+ };
410
+ const spineDeleted = new Set<number>();
411
+ results.forEach((result, index) => {
412
+ const owner = owners[index];
413
+ if (!owner) return;
414
+ const changes = result?.meta?.changes ?? 0;
415
+ counts[owner.table] += changes;
416
+ if (owner.table === "actions" && changes > 0) spineDeleted.add(owner.tree);
417
+ });
418
+ counts.trees = spineDeleted.size;
419
+ return counts;
420
+ }
@@ -55,8 +55,7 @@ export class ActionStore {
55
55
  tool_call_id, prompt_version, model_name, provider, total_cost_usd,
56
56
  attrs_json
57
57
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
58
- ON CONFLICT(id) DO UPDATE SET
59
- project_id = excluded.project_id,
58
+ ON CONFLICT(project_id, id) DO UPDATE SET
60
59
  root_action_id = excluded.root_action_id,
61
60
  caused_by_action_id = excluded.caused_by_action_id,
62
61
  actor_type = excluded.actor_type,
@@ -119,8 +118,7 @@ export class ActionStore {
119
118
  autonomy_level, status, error_message, total_cost_usd,
120
119
  total_duration_ms, metadata_json
121
120
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
122
- ON CONFLICT(id) DO UPDATE SET
123
- project_id = excluded.project_id,
121
+ ON CONFLICT(project_id, id) DO UPDATE SET
124
122
  agent_id = excluded.agent_id,
125
123
  agent_name = excluded.agent_name,
126
124
  agent_version = excluded.agent_version,
@@ -160,9 +158,8 @@ export class ActionStore {
160
158
  mcp_audit_json, mutation_before_json, mutation_after_json,
161
159
  mutation_diff_json, mutation_artifact_id
162
160
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
163
- ON CONFLICT(id) DO UPDATE SET
161
+ ON CONFLICT(project_id, id) DO UPDATE SET
164
162
  action_id = excluded.action_id,
165
- project_id = excluded.project_id,
166
163
  tool_name = excluded.tool_name,
167
164
  args_hash = excluded.args_hash,
168
165
  result_hash = excluded.result_hash,
@@ -205,9 +202,8 @@ export class ActionStore {
205
202
  id, action_id, project_id, retriever_name, query_hash, documents_json,
206
203
  total_results, max_relevance_score, duration_ms
207
204
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
208
- ON CONFLICT(id) DO UPDATE SET
205
+ ON CONFLICT(project_id, id) DO UPDATE SET
209
206
  action_id = excluded.action_id,
210
- project_id = excluded.project_id,
211
207
  retriever_name = excluded.retriever_name,
212
208
  query_hash = excluded.query_hash,
213
209
  documents_json = excluded.documents_json,
@@ -236,9 +232,8 @@ export class ActionStore {
236
232
  id, action_id, project_id, evaluator_name, evaluator_version, score,
237
233
  passed, reasoning, rubric_json
238
234
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
239
- ON CONFLICT(id) DO UPDATE SET
235
+ ON CONFLICT(project_id, id) DO UPDATE SET
240
236
  action_id = excluded.action_id,
241
- project_id = excluded.project_id,
242
237
  evaluator_name = excluded.evaluator_name,
243
238
  evaluator_version = excluded.evaluator_version,
244
239
  score = excluded.score,
@@ -267,9 +262,8 @@ export class ActionStore {
267
262
  id, action_id, project_id, artifact_name, artifact_type, storage_ref,
268
263
  size_bytes, sha256_hash, content_preview
269
264
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
270
- ON CONFLICT(id) DO UPDATE SET
265
+ ON CONFLICT(project_id, id) DO UPDATE SET
271
266
  action_id = excluded.action_id,
272
- project_id = excluded.project_id,
273
267
  artifact_name = excluded.artifact_name,
274
268
  artifact_type = excluded.artifact_type,
275
269
  storage_ref = excluded.storage_ref,
@@ -97,7 +97,7 @@ export async function getAICallsOverview(
97
97
  SUM(total_cost_usd) as totalCostUsd,
98
98
  SUM(prompt_tokens) as totalPromptTokens,
99
99
  SUM(completion_tokens) as totalCompletionTokens,
100
- SUM(is_error) as errorCalls
100
+ SUM(CASE WHEN is_error THEN 1 ELSE 0 END) as errorCalls
101
101
  FROM ai_calls WHERE project_id = ? AND received_at >= ${dialect.sinceHours("?")}
102
102
  ${options.service ? "AND service_name = ?" : ""}
103
103
  `;
@@ -124,7 +124,7 @@ export async function getAICallsOverview(
124
124
  completionTokens: row.completion_tokens,
125
125
  totalCostUsd: row.total_cost_usd,
126
126
  latencyMs: row.latency_ms,
127
- isError: row.is_error === 1,
127
+ isError: row.is_error === 1 || row.is_error === true,
128
128
  errorMessage: row.error_message,
129
129
  occurredAt: row.occurred_at,
130
130
  receivedAt: row.received_at,
@@ -43,7 +43,7 @@ export async function listAISessions(
43
43
  COUNT(DISTINCT s.trace_id) AS trace_count
44
44
  FROM ai_span_payloads p
45
45
  INNER JOIN telemetry_spans s
46
- ON s.trace_id = p.trace_id AND s.span_id = p.span_id
46
+ ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
47
47
  WHERE p.project_id = ?
48
48
  AND p.session_id IS NOT NULL
49
49
  AND p.received_at >= ${dialect.sinceHours("?")}
@@ -118,7 +118,7 @@ export async function getAISessionDetail(
118
118
  p.user_id AS user_id
119
119
  FROM ai_span_payloads p
120
120
  INNER JOIN telemetry_spans s
121
- ON s.trace_id = p.trace_id AND s.span_id = p.span_id
121
+ ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
122
122
  WHERE p.project_id = ? AND p.session_id = ?
123
123
  ORDER BY s.start_time ASC
124
124
  LIMIT 1000
@@ -177,7 +177,7 @@ async function loadSessionPreviews(
177
177
  SELECT p.session_id, p.input_json, s.start_time
178
178
  FROM ai_span_payloads p
179
179
  INNER JOIN telemetry_spans s
180
- ON s.trace_id = p.trace_id AND s.span_id = p.span_id
180
+ ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
181
181
  WHERE p.project_id = ?
182
182
  AND p.session_id IS NOT NULL
183
183
  AND p.session_id IN (${sessionIds.map(() => "?").join(", ")})
@@ -38,7 +38,7 @@ export async function getAISpansOverview(
38
38
  p.output_json AS output_json
39
39
  FROM ai_span_payloads p
40
40
  INNER JOIN telemetry_spans s
41
- ON s.trace_id = p.trace_id AND s.span_id = p.span_id
41
+ ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
42
42
  WHERE p.project_id = ?
43
43
  AND p.received_at >= ${dialect.sinceHours("?")}
44
44
  `;
@@ -42,7 +42,8 @@ export interface AICallRow {
42
42
  completion_tokens: number | null;
43
43
  total_cost_usd: number | null;
44
44
  latency_ms: number | null;
45
- is_error: number;
45
+ /** INTEGER 0/1 on D1; BOOLEAN on Postgres unless the host installs the D1-shaped type parsers. */
46
+ is_error: number | boolean;
46
47
  error_message: string | null;
47
48
  occurred_at: string;
48
49
  received_at: string;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Project scoping for alert rule deletion and webhook target validation,
3
+ * executed against the real migrated SQLite schema.
4
+ */
5
+
6
+ import type { AlertRuleInput } from "@obsunified/types";
7
+ import { describe, expect, it } from "vitest";
8
+ import { AlertsStore } from "./alerts-store";
9
+ import { ProjectsStore } from "./projects-store";
10
+ import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
11
+
12
+ const input = (over: Partial<AlertRuleInput> = {}): AlertRuleInput => ({
13
+ name: "errors",
14
+ signal: "spans",
15
+ query: {},
16
+ threshold: 1,
17
+ windowMins: 5,
18
+ comparison: ">",
19
+ channels: [{ type: "webhook", url: "https://hooks.example.com/alert/abc" }],
20
+ enabled: true,
21
+ ...over,
22
+ });
23
+
24
+ describe("AlertsStore.deleteRule", () => {
25
+ it("deletes state and evaluations only for a rule the project owns", async () => {
26
+ const db = createMigratedSqliteDb();
27
+ // alert_rules.project_id references projects(id) (migration 012).
28
+ const projects = new ProjectsStore(db);
29
+ const alphaProject = await projects.createProject({ name: "A", slug: "a" });
30
+ const betaProject = await projects.createProject({ name: "B", slug: "b" });
31
+ const store = new AlertsStore(db);
32
+ const alpha = await store.createRule(
33
+ alphaProject.id,
34
+ input({ name: "alpha" }),
35
+ );
36
+ const beta = await store.createRule(
37
+ betaProject.id,
38
+ input({ name: "beta" }),
39
+ );
40
+ for (const rule of [alpha, beta]) {
41
+ await store.transitionState(rule.id, rule.projectId, "firing", "t0");
42
+ await store.recordEvaluation(rule.id, rule.projectId, 5, "firing", true);
43
+ }
44
+ const count = async (table: string, ruleId: string) =>
45
+ (
46
+ await db
47
+ .prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE rule_id = ?`)
48
+ .bind(ruleId)
49
+ .first<{ n: number }>()
50
+ )?.n;
51
+
52
+ // Wrong project: nothing is deleted, not even the child rows.
53
+ expect(await store.deleteRule(beta.id, alphaProject.id)).toBe(false);
54
+ expect(await store.getRule(beta.id, betaProject.id)).not.toBeNull();
55
+ expect(await count("alert_state", beta.id)).toBe(1);
56
+ expect(await count("alert_evaluations", beta.id)).toBe(1);
57
+
58
+ // Right project: the rule and its children go, beta's stay.
59
+ expect(await store.deleteRule(alpha.id, alphaProject.id)).toBe(true);
60
+ expect(await store.getRule(alpha.id, alphaProject.id)).toBeNull();
61
+ expect(await count("alert_state", alpha.id)).toBe(0);
62
+ expect(await count("alert_evaluations", alpha.id)).toBe(0);
63
+ expect(await count("alert_state", beta.id)).toBe(1);
64
+ expect(await count("alert_evaluations", beta.id)).toBe(1);
65
+ db.close();
66
+ });
67
+ });
68
+
69
+ describe("AlertsStore.validateInput webhook targets", () => {
70
+ const store = new AlertsStore(createMigratedSqliteDb());
71
+
72
+ it("accepts public https hooks and rejects internal or credentialed ones", () => {
73
+ expect(() => store.validateInput(input())).not.toThrow();
74
+ for (const url of [
75
+ "http://127.0.0.1:8080/hook",
76
+ "http://169.254.169.254/latest/meta-data/",
77
+ "http://localhost/hook",
78
+ "https://user:pw@hooks.example.com/alert",
79
+ "ftp://hooks.example.com/alert",
80
+ ]) {
81
+ expect(
82
+ () =>
83
+ store.validateInput(input({ channels: [{ type: "webhook", url }] })),
84
+ url,
85
+ ).toThrow(/channel\.url/);
86
+ }
87
+ });
88
+
89
+ it("permits private receivers when the store is told to", () => {
90
+ const permissive = new AlertsStore(createMigratedSqliteDb(), {
91
+ allowPrivateWebhookTargets: true,
92
+ });
93
+ const internal = input({
94
+ channels: [{ type: "webhook", url: "http://10.0.0.5:9093/hook" }],
95
+ });
96
+ expect(() => permissive.validateInput(internal)).not.toThrow();
97
+ expect(() => store.validateInput(internal)).toThrow(/not a public/);
98
+ });
99
+ });
100
+
101
+ describe("AlertsStore alert_state.notified (migration 049)", () => {
102
+ it("records a transition as undelivered, marks exactly that transition delivered, and scopes by project", async () => {
103
+ const db = createMigratedSqliteDb();
104
+ const projects = new ProjectsStore(db);
105
+ const alpha = await projects.createProject({ name: "A", slug: "a" });
106
+ const beta = await projects.createProject({ name: "B", slug: "b" });
107
+ const store = new AlertsStore(db);
108
+ const rule = await store.createRule(alpha.id, input());
109
+
110
+ const t1 = "2026-05-31T00:00:00.000Z";
111
+ await store.transitionState(rule.id, alpha.id, "firing", t1);
112
+ expect(await store.getState(rule.id, alpha.id)).toMatchObject({
113
+ current_state: "firing",
114
+ last_state_change: t1,
115
+ notified: 0,
116
+ });
117
+ // Another project cannot read or acknowledge this rule's state.
118
+ expect(await store.getState(rule.id, beta.id)).toBeNull();
119
+ expect(await store.markNotified(rule.id, beta.id, "firing", t1)).toBe(
120
+ false,
121
+ );
122
+
123
+ // A stale acknowledgement (different state or timestamp) is ignored.
124
+ expect(await store.markNotified(rule.id, alpha.id, "ok", t1)).toBe(false);
125
+ expect(
126
+ await store.markNotified(
127
+ rule.id,
128
+ alpha.id,
129
+ "firing",
130
+ "2026-01-01T00:00:00.000Z",
131
+ ),
132
+ ).toBe(false);
133
+ expect((await store.getState(rule.id, alpha.id))?.notified).toBe(0);
134
+
135
+ expect(await store.markNotified(rule.id, alpha.id, "firing", t1)).toBe(
136
+ true,
137
+ );
138
+ expect((await store.getState(rule.id, alpha.id))?.notified).toBe(1);
139
+ // Idempotent: already delivered → no row changed.
140
+ expect(await store.markNotified(rule.id, alpha.id, "firing", t1)).toBe(
141
+ false,
142
+ );
143
+
144
+ // The next transition starts a fresh outstanding notification.
145
+ const t2 = "2026-05-31T01:00:00.000Z";
146
+ await store.transitionState(rule.id, alpha.id, "ok", t2);
147
+ expect(await store.getState(rule.id, alpha.id)).toMatchObject({
148
+ current_state: "ok",
149
+ last_state_change: t2,
150
+ notified: 0,
151
+ });
152
+ });
153
+ });