@obsunified/collector 2.0.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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/dist/auth.d.ts +2 -0
  4. package/dist/auth.js +9 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/chunk-UGWDOGGH.js +319 -0
  7. package/dist/chunk-UGWDOGGH.js.map +1 -0
  8. package/dist/chunk-XKQTVIRE.js +671 -0
  9. package/dist/chunk-XKQTVIRE.js.map +1 -0
  10. package/dist/index.d.ts +1197 -0
  11. package/dist/index.js +17707 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/ingest-auth-HFVO1zIp.d.ts +165 -0
  14. package/dist/openai-FJYOQ7WO.js +384 -0
  15. package/dist/openai-FJYOQ7WO.js.map +1 -0
  16. package/package.json +73 -0
  17. package/src/ai-parity/ai-parity.acceptance.test.ts +449 -0
  18. package/src/analyses/derive.ts +642 -0
  19. package/src/analyses/index.ts +50 -0
  20. package/src/analyses/investigations.ts +277 -0
  21. package/src/analyses/tier0.ts +568 -0
  22. package/src/auth/dashboard-auth.ts +269 -0
  23. package/src/auth/ingest-auth.ts +147 -0
  24. package/src/durable-objects/tail-hub.test.ts +32 -0
  25. package/src/durable-objects/tail-hub.ts +158 -0
  26. package/src/framework/collector.ts +467 -0
  27. package/src/framework/env.ts +90 -0
  28. package/src/framework/logger.ts +81 -0
  29. package/src/index.ts +211 -0
  30. package/src/lib/action-aggregates.ts +1031 -0
  31. package/src/lib/action-identity.ts +106 -0
  32. package/src/lib/actions-store.ts +397 -0
  33. package/src/lib/ai-pricing.ts +80 -0
  34. package/src/lib/ai-store/calls.ts +148 -0
  35. package/src/lib/ai-store/evaluations.test.ts +47 -0
  36. package/src/lib/ai-store/evaluations.ts +125 -0
  37. package/src/lib/ai-store/helpers.ts +37 -0
  38. package/src/lib/ai-store/retention.ts +17 -0
  39. package/src/lib/ai-store/sessions.ts +277 -0
  40. package/src/lib/ai-store/spans.ts +109 -0
  41. package/src/lib/ai-store/types.ts +112 -0
  42. package/src/lib/ai-store.ts +76 -0
  43. package/src/lib/alerts-store.ts +458 -0
  44. package/src/lib/analyses-runner.ts +439 -0
  45. package/src/lib/analyses-store.test.ts +243 -0
  46. package/src/lib/analyses-store.ts +454 -0
  47. package/src/lib/ask.test.ts +302 -0
  48. package/src/lib/ask.ts +390 -0
  49. package/src/lib/blob-store-s3.ts +213 -0
  50. package/src/lib/blob-store.ts +366 -0
  51. package/src/lib/eval-cases-store.test.ts +171 -0
  52. package/src/lib/eval-cases-store.ts +1033 -0
  53. package/src/lib/evidence-references.test.ts +10 -0
  54. package/src/lib/evidence-references.ts +684 -0
  55. package/src/lib/hash.ts +26 -0
  56. package/src/lib/identity-index/action-lookups.ts +805 -0
  57. package/src/lib/identity-index/constants.ts +1 -0
  58. package/src/lib/identity-index/key-lookups.ts +199 -0
  59. package/src/lib/identity-index/mappers.ts +349 -0
  60. package/src/lib/identity-index/types.ts +196 -0
  61. package/src/lib/identity-index/user-lookup.ts +137 -0
  62. package/src/lib/identity-index.test.ts +624 -0
  63. package/src/lib/identity-index.ts +85 -0
  64. package/src/lib/json.ts +39 -0
  65. package/src/lib/llm.ts +263 -0
  66. package/src/lib/logs-store.ts +188 -0
  67. package/src/lib/metrics-store.test.ts +189 -0
  68. package/src/lib/metrics-store.ts +312 -0
  69. package/src/lib/narrate-gate.test.ts +260 -0
  70. package/src/lib/narrate-gate.ts +253 -0
  71. package/src/lib/openai.test.ts +300 -0
  72. package/src/lib/openai.ts +512 -0
  73. package/src/lib/otlp.ts +212 -0
  74. package/src/lib/parse-pprof.test.ts +64 -0
  75. package/src/lib/parse-pprof.ts +184 -0
  76. package/src/lib/payload-capture.ts +22 -0
  77. package/src/lib/projects-store.ts +207 -0
  78. package/src/lib/propagation-metric.test.ts +196 -0
  79. package/src/lib/propagation-metric.ts +175 -0
  80. package/src/lib/sql-db-dialect.test.ts +22 -0
  81. package/src/lib/sql-db-postgres.ts +229 -0
  82. package/src/lib/sql-db.ts +166 -0
  83. package/src/lib/stage6.test.ts +216 -0
  84. package/src/lib/store/export.ts +65 -0
  85. package/src/lib/store/helpers.ts +367 -0
  86. package/src/lib/store/ingest.ts +68 -0
  87. package/src/lib/store/issues.ts +158 -0
  88. package/src/lib/store/overview.ts +127 -0
  89. package/src/lib/store/retention.ts +10 -0
  90. package/src/lib/store/service-map.ts +384 -0
  91. package/src/lib/store/trace-detail.bench.ts +76 -0
  92. package/src/lib/store/trace-detail.test.ts +410 -0
  93. package/src/lib/store/trace-detail.ts +427 -0
  94. package/src/lib/store/trace-queries.test.ts +29 -0
  95. package/src/lib/store/trace-queries.ts +89 -0
  96. package/src/lib/store-overview.test.ts +77 -0
  97. package/src/lib/store.ts +96 -0
  98. package/src/lib/tail-publisher.ts +64 -0
  99. package/src/lib/test-utils/mem-sql-db.test.ts +93 -0
  100. package/src/lib/test-utils/mem-sql-db.ts +86 -0
  101. package/src/lib/ua-parser.ts +61 -0
  102. package/src/lib/usage-store.ts +567 -0
  103. package/src/migrations/001_telemetry_spans.sql +28 -0
  104. package/src/migrations/002_usage_events.sql +24 -0
  105. package/src/migrations/003_usage_analytics_columns.sql +13 -0
  106. package/src/migrations/004_logs.sql +19 -0
  107. package/src/migrations/005_ai_calls.sql +25 -0
  108. package/src/migrations/006_user_profiles.sql +11 -0
  109. package/src/migrations/007_session_replay_metadata.sql +10 -0
  110. package/src/migrations/008_session_replay_storage_bytes.sql +1 -0
  111. package/src/migrations/009_projects_and_keys.sql +22 -0
  112. package/src/migrations/010_signal_project_id.sql +15 -0
  113. package/src/migrations/011_default_project_seed.sql +3 -0
  114. package/src/migrations/012_alert_rules.sql +17 -0
  115. package/src/migrations/013_alert_evaluations.sql +13 -0
  116. package/src/migrations/014_alert_state.sql +9 -0
  117. package/src/migrations/015_metrics.sql +58 -0
  118. package/src/migrations/016_metrics_exp_histogram_summary.sql +5 -0
  119. package/src/migrations/017_span_dropped_counts.sql +11 -0
  120. package/src/migrations/018_log_flags.sql +8 -0
  121. package/src/migrations/019_ai_span_payloads.sql +30 -0
  122. package/src/migrations/020_ai_span_evaluations.sql +33 -0
  123. package/src/migrations/021_ai_span_session.sql +13 -0
  124. package/src/migrations/022_span_log_session_id.sql +13 -0
  125. package/src/migrations/023_service_map_indexes.sql +32 -0
  126. package/src/migrations/024_analysis_results.sql +49 -0
  127. package/src/migrations/025_stage6_autopinning_alerts.sql +38 -0
  128. package/src/migrations/026_obs_dashboard_project_seed.sql +18 -0
  129. package/src/migrations/027_identity_propagation.sql +50 -0
  130. package/src/migrations/028_profile_blobs.sql +57 -0
  131. package/src/migrations/029_telemetry_sdk_name.sql +20 -0
  132. package/src/migrations/030_analysis_claim.sql +18 -0
  133. package/src/migrations/031_agent_action_graph.sql +154 -0
  134. package/src/migrations/032_payload_capture_flags.sql +8 -0
  135. package/src/migrations/033_eval_cases.sql +52 -0
  136. package/src/migrations/034_eval_case_results.sql +23 -0
  137. package/src/migrations/035_metric_exemplars.sql +33 -0
  138. package/src/migrations/036_trace_instrumentation_gaps.sql +19 -0
  139. package/src/migrations/037_eval_runs.sql +37 -0
  140. package/src/migrations/038_tool_call_evidence.sql +11 -0
  141. package/src/migrations/039_drop_trace_instrumentation_gaps.sql +11 -0
  142. package/src/migrations/040_evidence_retrieval_refs.sql +50 -0
  143. package/src/migrations-postgres/001_telemetry_spans.sql +33 -0
  144. package/src/migrations-postgres/002_usage_events.sql +24 -0
  145. package/src/migrations-postgres/003_usage_analytics_columns.sql +13 -0
  146. package/src/migrations-postgres/004_logs.sql +19 -0
  147. package/src/migrations-postgres/005_ai_calls.sql +25 -0
  148. package/src/migrations-postgres/006_user_profiles.sql +11 -0
  149. package/src/migrations-postgres/007_session_replay_metadata.sql +10 -0
  150. package/src/migrations-postgres/008_session_replay_storage_bytes.sql +2 -0
  151. package/src/migrations-postgres/009_projects_and_keys.sql +25 -0
  152. package/src/migrations-postgres/010_signal_project_id.sql +18 -0
  153. package/src/migrations-postgres/011_default_project_seed.sql +5 -0
  154. package/src/migrations-postgres/012_alert_rules.sql +17 -0
  155. package/src/migrations-postgres/013_alert_evaluations.sql +13 -0
  156. package/src/migrations-postgres/014_alert_state.sql +9 -0
  157. package/src/migrations-postgres/015_metrics.sql +47 -0
  158. package/src/migrations-postgres/016_metrics_exp_histogram_summary.sql +1 -0
  159. package/src/migrations-postgres/017_span_dropped_counts.sql +4 -0
  160. package/src/migrations-postgres/018_log_flags.sql +2 -0
  161. package/src/migrations-postgres/019_ai_span_payloads.sql +18 -0
  162. package/src/migrations-postgres/020_ai_span_evaluations.sql +21 -0
  163. package/src/migrations-postgres/021_ai_span_session.sql +7 -0
  164. package/src/migrations-postgres/022_span_log_session_id.sql +7 -0
  165. package/src/migrations-postgres/023_service_map_indexes.sql +9 -0
  166. package/src/migrations-postgres/024_analysis_results.sql +41 -0
  167. package/src/migrations-postgres/025_stage6_autopinning_alerts.sql +17 -0
  168. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +14 -0
  169. package/src/migrations-postgres/027_identity_propagation.sql +27 -0
  170. package/src/migrations-postgres/028_profile_blobs.sql +48 -0
  171. package/src/migrations-postgres/029_telemetry_sdk_name.sql +5 -0
  172. package/src/migrations-postgres/030_analysis_claim.sql +5 -0
  173. package/src/migrations-postgres/031_agent_action_graph.sql +190 -0
  174. package/src/migrations-postgres/032_payload_capture_flags.sql +8 -0
  175. package/src/migrations-postgres/033_eval_cases.sql +55 -0
  176. package/src/migrations-postgres/034_eval_case_results.sql +26 -0
  177. package/src/migrations-postgres/035_metric_exemplars.sql +31 -0
  178. package/src/migrations-postgres/036_trace_instrumentation_gaps.sql +19 -0
  179. package/src/migrations-postgres/037_eval_runs.sql +40 -0
  180. package/src/migrations-postgres/038_tool_call_evidence.sql +11 -0
  181. package/src/migrations-postgres/039_drop_trace_instrumentation_gaps.sql +11 -0
  182. package/src/migrations-postgres/040_evidence_retrieval_refs.sql +47 -0
  183. package/src/migrations-postgres/README.md +40 -0
  184. package/src/otlp/acceptance.test.ts +373 -0
  185. package/src/otlp/decode/body.ts +127 -0
  186. package/src/otlp/decode/logs.ts +96 -0
  187. package/src/otlp/decode/metrics.ts +402 -0
  188. package/src/otlp/decode/traces.ts +92 -0
  189. package/src/otlp/decode/values.ts +150 -0
  190. package/src/otlp/decode.test.ts +800 -0
  191. package/src/otlp/decode.ts +11 -0
  192. package/src/otlp/gen/opentelemetry/proto/collector/logs/v1/logs_service_pb.ts +132 -0
  193. package/src/otlp/gen/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.ts +158 -0
  194. package/src/otlp/gen/opentelemetry/proto/collector/trace/v1/trace_service_pb.ts +158 -0
  195. package/src/otlp/gen/opentelemetry/proto/common/v1/common_pb.ts +289 -0
  196. package/src/otlp/gen/opentelemetry/proto/logs/v1/logs_pb.ts +397 -0
  197. package/src/otlp/gen/opentelemetry/proto/metrics/v1/metrics_pb.ts +1329 -0
  198. package/src/otlp/gen/opentelemetry/proto/resource/v1/resource_pb.ts +86 -0
  199. package/src/otlp/gen/opentelemetry/proto/trace/v1/trace_pb.ts +710 -0
  200. package/src/otlp/response.test.ts +158 -0
  201. package/src/otlp/response.ts +129 -0
  202. package/src/plugins/_context.ts +15 -0
  203. package/src/plugins/action-graph-processor/enrichers.ts +45 -0
  204. package/src/plugins/action-graph-processor/redaction.ts +84 -0
  205. package/src/plugins/action-graph-processor.test.ts +1023 -0
  206. package/src/plugins/action-graph-processor.ts +1172 -0
  207. package/src/plugins/action-routes.test.ts +716 -0
  208. package/src/plugins/action-routes.ts +343 -0
  209. package/src/plugins/ai-receiver.ts +254 -0
  210. package/src/plugins/ai-span-payloads-processor.test.ts +90 -0
  211. package/src/plugins/ai-span-payloads-processor.ts +180 -0
  212. package/src/plugins/alerts-evaluator.test.ts +91 -0
  213. package/src/plugins/alerts-evaluator.ts +328 -0
  214. package/src/plugins/alerts-routes.ts +101 -0
  215. package/src/plugins/analyses-routes.ts +138 -0
  216. package/src/plugins/ask-routes.ts +182 -0
  217. package/src/plugins/bot-filter.ts +19 -0
  218. package/src/plugins/connected-routes/manifest.ts +531 -0
  219. package/src/plugins/connected-routes.test.ts +1791 -0
  220. package/src/plugins/connected-routes.ts +1103 -0
  221. package/src/plugins/dashboard-routes.ts +41 -0
  222. package/src/plugins/default-span-enrichment.ts +92 -0
  223. package/src/plugins/eval-cases-routes.test.ts +391 -0
  224. package/src/plugins/eval-cases-routes.ts +529 -0
  225. package/src/plugins/evidence-retrieval-routes.benchmark.test.ts +245 -0
  226. package/src/plugins/evidence-retrieval-routes.test.ts +932 -0
  227. package/src/plugins/evidence-retrieval-routes.ts +2179 -0
  228. package/src/plugins/gen-ai-normalizer.test.ts +680 -0
  229. package/src/plugins/gen-ai-normalizer.ts +302 -0
  230. package/src/plugins/identity-receiver.ts +96 -0
  231. package/src/plugins/issue-enrichment.ts +132 -0
  232. package/src/plugins/issue-insights.ts +81 -0
  233. package/src/plugins/logs-receiver.ts +160 -0
  234. package/src/plugins/metrics-receiver.ts +91 -0
  235. package/src/plugins/onboarding-routes.ts +54 -0
  236. package/src/plugins/otlp-receiver.ts +124 -0
  237. package/src/plugins/platform-routes.ts +165 -0
  238. package/src/plugins/profile-routes.ts +468 -0
  239. package/src/plugins/projects-routes.ts +92 -0
  240. package/src/plugins/query-routes.ts +220 -0
  241. package/src/plugins/redaction-processor.ts +132 -0
  242. package/src/plugins/replay-query-routes.test.ts +38 -0
  243. package/src/plugins/replay-query-routes.ts +199 -0
  244. package/src/plugins/replay-receiver.ts +81 -0
  245. package/src/plugins/tail-routes.test.ts +47 -0
  246. package/src/plugins/tail-routes.ts +42 -0
  247. package/src/plugins/timeline-routes.ts +244 -0
  248. package/src/plugins/ua-enrichment.ts +27 -0
  249. package/src/plugins/usage-normalization.ts +46 -0
  250. package/src/plugins/usage-privacy.ts +55 -0
  251. package/src/plugins/usage-query-routes.ts +102 -0
  252. package/src/plugins/usage-receiver.ts +129 -0
  253. package/src/plugins/users-query-routes.ts +78 -0
  254. package/src/plugins/utm-enrichment.ts +36 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * AnalysesStore — RFC 0002 Stage 1 persistence for application-aware Analyses.
3
+ *
4
+ * Two tables:
5
+ * - analysis_definitions: durable registry of (id, title, group, sql, ...).
6
+ * Refreshed on every scheduled tick by the runner from the in-process
7
+ * analysis registry. Mirrored here so the dashboard can list available
8
+ * analyses without coupling to deploy artifacts.
9
+ * - analysis_results: append-only result rows. Reads are "latest per
10
+ * analysis_id" (covered by idx_analysis_results_latest); retention is
11
+ * handled by the existing scheduled cron via purgeExpired().
12
+ */
13
+
14
+ import type {
15
+ AnalysisDefinition,
16
+ AnalysisGroup,
17
+ AnalysisResult,
18
+ AnalysisSource,
19
+ AnalysisStatus,
20
+ AnalysisView,
21
+ } from "@obsunified/types";
22
+ import { EVIDENCE_REFERENCE_CONTRACT } from "@obsunified/types";
23
+ import { analysisResultEvidenceReferences } from "./evidence-references";
24
+ import { parseJsonRecord } from "./json";
25
+ import type { SqlDb } from "./sql-db";
26
+
27
+ interface DefinitionRow {
28
+ project_id: string;
29
+ id: string;
30
+ title: string;
31
+ group: string;
32
+ source: string;
33
+ view: string;
34
+ refresh_seconds: number | null;
35
+ sql: string | null;
36
+ scope_json: string | null;
37
+ created_at: string;
38
+ updated_at: string;
39
+ last_run_at: string | null;
40
+ }
41
+
42
+ interface ResultRow {
43
+ id: number;
44
+ project_id: string;
45
+ analysis_id: string;
46
+ generated_at: number;
47
+ params_hash: string | null;
48
+ status: string;
49
+ primary_value: number | null;
50
+ baseline_value: number | null;
51
+ delta_pct: number | null;
52
+ payload_json: string;
53
+ narrative: string | null;
54
+ narrative_signature: string | null;
55
+ duration_ms: number;
56
+ expires_at: number;
57
+ }
58
+
59
+ const rowToDefinition = (row: DefinitionRow): AnalysisDefinition => {
60
+ const def: AnalysisDefinition = {
61
+ id: row.id,
62
+ title: row.title,
63
+ group: row.group as AnalysisGroup,
64
+ source: row.source as AnalysisSource,
65
+ view: row.view as AnalysisView,
66
+ };
67
+ if (row.refresh_seconds !== null && row.refresh_seconds !== undefined) {
68
+ def.refreshSeconds = row.refresh_seconds;
69
+ }
70
+ if (row.sql) {
71
+ def.sql = row.sql;
72
+ }
73
+ if (row.scope_json) {
74
+ def.scope = parseJsonRecord(row.scope_json);
75
+ }
76
+ return def;
77
+ };
78
+
79
+ const rowToResult = (row: ResultRow): AnalysisResult => {
80
+ const result: AnalysisResult = {
81
+ analysisId: row.analysis_id,
82
+ projectId: row.project_id,
83
+ generatedAt: new Date(row.generated_at).toISOString(),
84
+ paramsHash: row.params_hash,
85
+ status: row.status as AnalysisStatus,
86
+ primaryValue: row.primary_value,
87
+ baselineValue: row.baseline_value,
88
+ deltaPct: row.delta_pct,
89
+ payload: row.payload_json ? parseJsonRecord(row.payload_json) : {},
90
+ narrative: row.narrative,
91
+ narrativeSignature: row.narrative_signature,
92
+ durationMs: row.duration_ms,
93
+ };
94
+ const evidenceReferences = analysisResultEvidenceReferences(result);
95
+ if (evidenceReferences.length > 0) {
96
+ result.evidenceReferences = evidenceReferences;
97
+ result.evidenceContract = EVIDENCE_REFERENCE_CONTRACT;
98
+ }
99
+ return result;
100
+ };
101
+
102
+ export class AnalysesStore {
103
+ constructor(private readonly db: SqlDb) {}
104
+
105
+ async upsertDefinition(
106
+ projectId: string,
107
+ def: AnalysisDefinition,
108
+ ): Promise<void> {
109
+ if (!projectId)
110
+ throw new Error("AnalysesStore.upsertDefinition: projectId is required");
111
+ if (!def.id)
112
+ throw new Error("AnalysesStore.upsertDefinition: def.id is required");
113
+
114
+ const now = new Date().toISOString();
115
+ const scopeJson = def.scope ? JSON.stringify(def.scope) : null;
116
+ const refreshSeconds =
117
+ def.refreshSeconds === undefined || def.refreshSeconds === null
118
+ ? null
119
+ : def.refreshSeconds;
120
+
121
+ await this.db
122
+ .prepare(
123
+ `INSERT INTO analysis_definitions (
124
+ project_id, id, title, "group", source, view,
125
+ refresh_seconds, sql, scope_json,
126
+ created_at, updated_at, last_run_at
127
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
128
+ ON CONFLICT(project_id, id) DO UPDATE SET
129
+ title = excluded.title,
130
+ "group" = excluded."group",
131
+ source = excluded.source,
132
+ view = excluded.view,
133
+ refresh_seconds = excluded.refresh_seconds,
134
+ sql = excluded.sql,
135
+ scope_json = excluded.scope_json,
136
+ updated_at = excluded.updated_at`,
137
+ )
138
+ .bind(
139
+ projectId,
140
+ def.id,
141
+ def.title,
142
+ def.group,
143
+ def.source,
144
+ def.view,
145
+ refreshSeconds,
146
+ def.sql ?? null,
147
+ scopeJson,
148
+ now,
149
+ now,
150
+ )
151
+ .run();
152
+ }
153
+
154
+ async listDefinitions(projectId: string): Promise<AnalysisDefinition[]> {
155
+ if (!projectId)
156
+ throw new Error("AnalysesStore.listDefinitions: projectId is required");
157
+ const result = await this.db
158
+ .prepare(
159
+ `SELECT project_id, id, title, "group" AS "group", source, view,
160
+ refresh_seconds, sql, scope_json,
161
+ created_at, updated_at, last_run_at
162
+ FROM analysis_definitions
163
+ WHERE project_id = ?
164
+ ORDER BY "group", id`,
165
+ )
166
+ .bind(projectId)
167
+ .all<DefinitionRow>();
168
+ return (result.results ?? []).map(rowToDefinition);
169
+ }
170
+
171
+ async getDueAnalyses(
172
+ projectId: string,
173
+ now: number,
174
+ ): Promise<AnalysisDefinition[]> {
175
+ if (!projectId)
176
+ throw new Error("AnalysesStore.getDueAnalyses: projectId is required");
177
+ // last_run_at is stored as ISO string; we compute due-ness in JS so the
178
+ // comparison is unambiguous regardless of D1's date function support.
179
+ const result = await this.db
180
+ .prepare(
181
+ `SELECT project_id, id, title, "group" AS "group", source, view,
182
+ refresh_seconds, sql, scope_json,
183
+ created_at, updated_at, last_run_at
184
+ FROM analysis_definitions
185
+ WHERE project_id = ? AND refresh_seconds IS NOT NULL`,
186
+ )
187
+ .bind(projectId)
188
+ .all<DefinitionRow>();
189
+
190
+ const due: AnalysisDefinition[] = [];
191
+ for (const row of result.results ?? []) {
192
+ const refreshSeconds = row.refresh_seconds;
193
+ if (refreshSeconds === null || refreshSeconds === undefined) continue;
194
+ const lastRunMs = row.last_run_at ? Date.parse(row.last_run_at) : 0;
195
+ if (lastRunMs + refreshSeconds * 1000 < now) {
196
+ due.push(rowToDefinition(row));
197
+ }
198
+ }
199
+ return due;
200
+ }
201
+
202
+ async markRan(
203
+ projectId: string,
204
+ analysisId: string,
205
+ ranAt: string,
206
+ ): Promise<void> {
207
+ // last_started_at = NULL releases the lease so the next tick is free
208
+ // to re-evaluate refresh_seconds against last_run_at.
209
+ await this.db
210
+ .prepare(
211
+ `UPDATE analysis_definitions
212
+ SET last_run_at = ?, last_started_at = NULL
213
+ WHERE project_id = ? AND id = ?`,
214
+ )
215
+ .bind(ranAt, projectId, analysisId)
216
+ .run();
217
+ }
218
+
219
+ /**
220
+ * Attempt to claim an analysis for the current tick. Returns true if
221
+ * the row was successfully claimed (i.e., this caller is now responsible
222
+ * for running it), false if another tick already holds an active lease.
223
+ *
224
+ * The atomic operation is a single UPDATE … WHERE that only matches when
225
+ * the existing claim is NULL or older than the lease window. We use
226
+ * meta.changes to detect whether the row actually moved; D1 returns 0
227
+ * when the WHERE clause excluded the row.
228
+ */
229
+ async claimAnalysis(
230
+ projectId: string,
231
+ analysisId: string,
232
+ now: number,
233
+ leaseMs: number,
234
+ ): Promise<boolean> {
235
+ const nowIso = new Date(now).toISOString();
236
+ const leaseFloorIso = new Date(now - leaseMs).toISOString();
237
+ const result = await this.db
238
+ .prepare(
239
+ `UPDATE analysis_definitions
240
+ SET last_started_at = ?
241
+ WHERE project_id = ? AND id = ?
242
+ AND (last_started_at IS NULL OR last_started_at < ?)`,
243
+ )
244
+ .bind(nowIso, projectId, analysisId, leaseFloorIso)
245
+ .run();
246
+ return (result.meta?.changes ?? 0) > 0;
247
+ }
248
+
249
+ /**
250
+ * Release a lease without writing to last_run_at — used when the run
251
+ * failed and we want the next tick to retry rather than wait the full
252
+ * refresh_seconds window.
253
+ */
254
+ async releaseClaim(projectId: string, analysisId: string): Promise<void> {
255
+ await this.db
256
+ .prepare(
257
+ `UPDATE analysis_definitions
258
+ SET last_started_at = NULL
259
+ WHERE project_id = ? AND id = ?`,
260
+ )
261
+ .bind(projectId, analysisId)
262
+ .run();
263
+ }
264
+
265
+ async getLatestResult(
266
+ projectId: string,
267
+ analysisId: string,
268
+ ): Promise<AnalysisResult | null> {
269
+ if (!projectId)
270
+ throw new Error("AnalysesStore.getLatestResult: projectId is required");
271
+ const row = await this.db
272
+ .prepare(
273
+ `SELECT id, project_id, analysis_id, generated_at, params_hash,
274
+ status, primary_value, baseline_value, delta_pct, payload_json,
275
+ narrative, narrative_signature, duration_ms, expires_at
276
+ FROM analysis_results
277
+ WHERE project_id = ? AND analysis_id = ?
278
+ ORDER BY generated_at DESC
279
+ LIMIT 1`,
280
+ )
281
+ .bind(projectId, analysisId)
282
+ .first<ResultRow>();
283
+ return row ? rowToResult(row) : null;
284
+ }
285
+
286
+ async getLatestResultsBulk(
287
+ projectId: string,
288
+ analysisIds: string[],
289
+ ): Promise<Map<string, AnalysisResult>> {
290
+ if (!projectId)
291
+ throw new Error(
292
+ "AnalysesStore.getLatestResultsBulk: projectId is required",
293
+ );
294
+ if (analysisIds.length === 0) return new Map();
295
+
296
+ // Pull a recent window of rows for the requested analyses then group by
297
+ // analysis_id, taking the row with the largest generated_at per group.
298
+ // One query, no per-analysis round-trips.
299
+ const placeholders = analysisIds.map(() => "?").join(", ");
300
+ const result = await this.db
301
+ .prepare(
302
+ `SELECT id, project_id, analysis_id, generated_at, params_hash,
303
+ status, primary_value, baseline_value, delta_pct, payload_json,
304
+ narrative, narrative_signature, duration_ms, expires_at
305
+ FROM analysis_results
306
+ WHERE project_id = ? AND analysis_id IN (${placeholders})
307
+ ORDER BY generated_at DESC`,
308
+ )
309
+ .bind(projectId, ...analysisIds)
310
+ .all<ResultRow>();
311
+
312
+ const latest = new Map<string, AnalysisResult>();
313
+ for (const row of result.results ?? []) {
314
+ if (!latest.has(row.analysis_id)) {
315
+ latest.set(row.analysis_id, rowToResult(row));
316
+ }
317
+ }
318
+ return latest;
319
+ }
320
+
321
+ async insertResult(result: AnalysisResult, expiresAt: number): Promise<void> {
322
+ if (!result.projectId)
323
+ throw new Error("AnalysesStore.insertResult: projectId is required");
324
+ if (!result.analysisId)
325
+ throw new Error("AnalysesStore.insertResult: analysisId is required");
326
+
327
+ const generatedAtMs = Date.parse(result.generatedAt);
328
+ await this.db
329
+ .prepare(
330
+ `INSERT INTO analysis_results (
331
+ project_id, analysis_id, generated_at, params_hash,
332
+ status, primary_value, baseline_value, delta_pct, payload_json,
333
+ narrative, narrative_signature, duration_ms, expires_at
334
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
335
+ )
336
+ .bind(
337
+ result.projectId,
338
+ result.analysisId,
339
+ Number.isFinite(generatedAtMs) ? generatedAtMs : Date.now(),
340
+ result.paramsHash,
341
+ result.status,
342
+ result.primaryValue,
343
+ result.baselineValue,
344
+ result.deltaPct,
345
+ JSON.stringify(result.payload ?? {}),
346
+ result.narrative,
347
+ result.narrativeSignature,
348
+ result.durationMs,
349
+ expiresAt,
350
+ )
351
+ .run();
352
+ }
353
+
354
+ /**
355
+ * Count how many narrative-bearing results we've written for this project
356
+ * in the trailing `windowMinutes`. Used as a per-project safety rail
357
+ * (RFC 0002 Stage 3 narrative budget) so a misconfigured `only_when` or
358
+ * a flapping panel can't run up an LLM bill.
359
+ *
360
+ * Counts `narrative IS NOT NULL`, including reuse-cached narratives,
361
+ * because what we're protecting against is *write rate* — the budget
362
+ * caps generation, not display. Reused narratives don't actually call
363
+ * the LLM, so the more accurate "calls in last hour" reading is just
364
+ * a proxy. For Stage 3 this is good enough; if it bites we'll add a
365
+ * dedicated `narrative_calls` table.
366
+ */
367
+ async countNarrativesInWindow(
368
+ projectId: string,
369
+ windowMinutes: number,
370
+ ): Promise<number> {
371
+ if (!projectId)
372
+ throw new Error(
373
+ "AnalysesStore.countNarrativesInWindow: projectId is required",
374
+ );
375
+ const since = Date.now() - windowMinutes * 60 * 1000;
376
+ const row = await this.db
377
+ .prepare(
378
+ `SELECT COUNT(*) AS n
379
+ FROM analysis_results
380
+ WHERE project_id = ?
381
+ AND narrative IS NOT NULL
382
+ AND generated_at >= ?`,
383
+ )
384
+ .bind(projectId, since)
385
+ .first<{ n: number }>();
386
+ return row?.n ?? 0;
387
+ }
388
+
389
+ /**
390
+ * RFC 0002 Stage 6 — Ask-box auto-pinning input.
391
+ *
392
+ * Records that an analysis was cited in evidence on a successful Ask
393
+ * answer. The Health tab's Pinned group derives from `getTopAskedAnalyses`
394
+ * which reads these rows. We deliberately keep this table append-only
395
+ * and keyed only by `(project_id, analysis_id, asked_at)` — pinning is
396
+ * inherently observational; explicit user pins live elsewhere.
397
+ */
398
+ async recordAskEvidence(
399
+ projectId: string,
400
+ analysisIds: readonly string[],
401
+ ): Promise<void> {
402
+ if (!projectId)
403
+ throw new Error("AnalysesStore.recordAskEvidence: projectId required");
404
+ const unique = Array.from(new Set(analysisIds.filter(Boolean)));
405
+ if (unique.length === 0) return;
406
+ const askedAt = Date.now();
407
+ const stmt = this.db.prepare(
408
+ `INSERT INTO ask_evidence_events (project_id, analysis_id, asked_at)
409
+ VALUES (?, ?, ?)`,
410
+ );
411
+ for (const id of unique) {
412
+ await stmt.bind(projectId, id, askedAt).run();
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Top N analyses cited by the Ask box in the trailing window. Used as
418
+ * the auto-pin signal — no manual UX, just "what are people actually
419
+ * asking about right now?"
420
+ */
421
+ async getTopAskedAnalyses(
422
+ projectId: string,
423
+ windowDays = 7,
424
+ limit = 6,
425
+ ): Promise<Array<{ analysisId: string; citations: number }>> {
426
+ if (!projectId)
427
+ throw new Error("AnalysesStore.getTopAskedAnalyses: projectId required");
428
+ const since = Date.now() - windowDays * 24 * 3600 * 1000;
429
+ const result = await this.db
430
+ .prepare(
431
+ `SELECT analysis_id, COUNT(*) AS citations
432
+ FROM ask_evidence_events
433
+ WHERE project_id = ? AND asked_at >= ?
434
+ GROUP BY analysis_id
435
+ ORDER BY citations DESC, MAX(asked_at) DESC
436
+ LIMIT ?`,
437
+ )
438
+ .bind(projectId, since, limit)
439
+ .all<{ analysis_id: string; citations: number }>();
440
+ return (result.results ?? []).map((r) => ({
441
+ analysisId: r.analysis_id,
442
+ citations: r.citations,
443
+ }));
444
+ }
445
+
446
+ async purgeExpired(): Promise<number> {
447
+ const now = Date.now();
448
+ const result = await this.db
449
+ .prepare(`DELETE FROM analysis_results WHERE expires_at <= ?`)
450
+ .bind(now)
451
+ .run();
452
+ return result.meta?.changes ?? 0;
453
+ }
454
+ }