@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.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
- package/dist/chunk-ZSF5XP3B.js.map +1 -0
- package/dist/index.d.ts +630 -27
- package/dist/index.js +5819 -853
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
- package/package.json +5 -5
- package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +59 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +69 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/autosre-change-correlation.ts +780 -0
- package/src/lib/autosre-findings.ts +725 -0
- package/src/lib/autosre-ontology.ts +914 -0
- package/src/lib/autosre-remediation-gate.ts +1663 -0
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/eval-cases-store.ts +63 -1
- package/src/lib/evidence-references.ts +6 -0
- package/src/lib/housekeeping.test.ts +153 -0
- package/src/lib/housekeeping.ts +193 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/store/ingest.ts +94 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +68 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +181 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/autosre-routes.ts +1019 -0
- package/src/plugins/connected-routes/manifest.ts +13 -1
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +285 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/default-span-enrichment.ts +13 -1
- package/src/plugins/eval-cases-routes.ts +65 -19
- package/src/plugins/evidence-retrieval-routes.ts +300 -4
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- package/dist/chunk-UGWDOGGH.js.map +0 -1
- package/dist/chunk-XKQTVIRE.js.map +0 -1
- /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
|
+
}
|
package/src/lib/actions-store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|