@obsunified/collector 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
- package/dist/chunk-2FALTDCE.js.map +1 -0
- package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
- package/dist/chunk-RASSTGNB.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +2892 -2240
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BmP5-f96.d.ts} +32 -1
- package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
- package/package.json +4 -3
- package/src/__tests__/autosre-closed-loop.test.ts +700 -0
- package/src/__tests__/db-parity.integration.test.ts +961 -0
- package/src/__tests__/internal-contracts-routes.test.ts +451 -0
- package/src/auth/dashboard-auth.test.ts +61 -0
- package/src/auth/dashboard-auth.ts +17 -2
- package/src/framework/env.ts +105 -0
- package/src/index.ts +13 -4
- package/src/lib/ai-store/calls.ts +43 -23
- package/src/lib/ai-store/evaluations.test.ts +3 -3
- package/src/lib/ai-store.ts +12 -1
- package/src/lib/analyses-store.test.ts +6 -6
- package/src/lib/autosre-findings.ts +169 -14
- package/src/lib/autosre-remediation-gate.ts +156 -26
- package/src/lib/db-parity.ts +883 -0
- package/src/lib/eval-cases-store.test.ts +1 -1
- package/src/lib/eval-cases-store.ts +16 -4
- package/src/lib/evidence-references.ts +30 -22
- package/src/lib/projects-store.test.ts +38 -1
- package/src/lib/projects-store.ts +23 -4
- package/src/lib/sql-db-postgres.integration.test.ts +75 -2
- package/src/lib/stage6.test.ts +1 -1
- package/src/lib/usage-store.ts +9 -3
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -0
- package/src/migrations/parity.test.ts +105 -3
- package/src/migrations-postgres/052_remediation_callback.sql +10 -0
- package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
- package/src/plugins/ai-call-by-id.test.ts +73 -0
- package/src/plugins/ai-receiver.ts +15 -0
- package/src/plugins/autosre-routes.ts +409 -147
- package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
- package/src/plugins/connected-routes/manifest.test.ts +224 -0
- package/src/plugins/connected-routes/manifest.ts +131 -28
- package/src/plugins/connected-routes-links.test.ts +96 -0
- package/src/plugins/connected-routes.test.ts +13 -11
- package/src/plugins/connected-routes.ts +53 -20
- package/src/plugins/eval-cases-routes.ts +33 -10
- package/src/plugins/evidence-retrieval-routes.ts +89 -24
- package/dist/chunk-KMEZKGSO.js.map +0 -1
- package/dist/chunk-ZSF5XP3B.js.map +0 -1
- /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workstream 4 (RFC 0014): Native-engine migration and query parity suite.
|
|
3
|
+
*
|
|
4
|
+
* Executes all 53 SQLite migrations on `node:sqlite` (`DatabaseSync`) and all
|
|
5
|
+
* 53 Postgres migrations on a live PostgreSQL instance (`OBS_PG_TEST_URL` or an
|
|
6
|
+
* ephemeral `postgres:15` Docker container), then verifies:
|
|
7
|
+
* 1. Full 53-migration schema semantic parity (`compareDatabaseSchemas`).
|
|
8
|
+
* 2. Negative regression fixtures proving the gate catches:
|
|
9
|
+
* - a missing UNIQUE or CHECK constraint,
|
|
10
|
+
* - a modified partial-index WHERE predicate,
|
|
11
|
+
* - invalid Postgres queries (SQLSTATE 42P10 SELECT DISTINCT ... ORDER BY
|
|
12
|
+
* and SQLSTATE 42803 ungrouped correlated subquery).
|
|
13
|
+
* 3. Upgrade data-preservation fixtures across 050 -> 053 (including 051
|
|
14
|
+
* duplicate active-incident collapse and partial unique index enforcement).
|
|
15
|
+
* 4. Dual-engine application query parity across `/internal/connected/change/:id`,
|
|
16
|
+
* `/internal/usage/sessions`, `recordChangeEvent`, `computeChangeBlastRadius`,
|
|
17
|
+
* `openOrDeduplicateIncident`, `recordInvestigationFinding`, and
|
|
18
|
+
* `proposeRemediationAction`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { DatabaseSync } from "node:sqlite";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import type { StoredSpan, UsageEventRecord } from "@obsunified/types";
|
|
29
|
+
import { AgentAutonomyLevel } from "@obsunified/types/constants";
|
|
30
|
+
import { Hono } from "hono";
|
|
31
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
32
|
+
import { CollectorRuntime } from "../framework/collector";
|
|
33
|
+
import type { CollectorEnv } from "../framework/env";
|
|
34
|
+
import {
|
|
35
|
+
computeChangeBlastRadius,
|
|
36
|
+
listChangeEvents,
|
|
37
|
+
recordChangeEvent,
|
|
38
|
+
} from "../lib/autosre-change-correlation";
|
|
39
|
+
import {
|
|
40
|
+
getInvestigationFinding,
|
|
41
|
+
recordInvestigationFinding,
|
|
42
|
+
searchInvestigationFindings,
|
|
43
|
+
} from "../lib/autosre-findings";
|
|
44
|
+
import {
|
|
45
|
+
acquireIncidentLease,
|
|
46
|
+
approveRemediationProposal,
|
|
47
|
+
claimRemediationGrant,
|
|
48
|
+
openOrDeduplicateIncident,
|
|
49
|
+
proposeRemediationAction,
|
|
50
|
+
} from "../lib/autosre-remediation-gate";
|
|
51
|
+
import {
|
|
52
|
+
compareDatabaseSchemas,
|
|
53
|
+
introspectPostgresSchema,
|
|
54
|
+
introspectSqliteSchema,
|
|
55
|
+
} from "../lib/db-parity";
|
|
56
|
+
import type { SqlDb } from "../lib/sql-db";
|
|
57
|
+
import {
|
|
58
|
+
configurePostgresTypeParsers,
|
|
59
|
+
PostgresAdapter,
|
|
60
|
+
} from "../lib/sql-db-postgres";
|
|
61
|
+
import { ingestTelemetrySpans } from "../lib/store/ingest";
|
|
62
|
+
import {
|
|
63
|
+
applySqliteMigrations,
|
|
64
|
+
createMigratedSqliteDb,
|
|
65
|
+
type NodeSqliteDb,
|
|
66
|
+
} from "../lib/test-utils/node-sqlite-db";
|
|
67
|
+
import { UsageStore } from "../lib/usage-store";
|
|
68
|
+
import { connectedRoutesPlugin } from "../plugins/connected-routes";
|
|
69
|
+
import { usageQueryRoutesPlugin } from "../plugins/usage-query-routes";
|
|
70
|
+
|
|
71
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
72
|
+
const pgMigrationsDir = path.resolve(here, "../migrations-postgres");
|
|
73
|
+
|
|
74
|
+
type PgModule = typeof import("pg");
|
|
75
|
+
type PgPool = import("pg").Pool;
|
|
76
|
+
type PgClient = import("pg").Client;
|
|
77
|
+
|
|
78
|
+
function resolveDockerEnv(): NodeJS.ProcessEnv | null {
|
|
79
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
80
|
+
if (!env.DOCKER_HOST) {
|
|
81
|
+
const colimaSock = path.join(
|
|
82
|
+
os.homedir(),
|
|
83
|
+
".colima/obs-unified-smoke/docker.sock",
|
|
84
|
+
);
|
|
85
|
+
if (existsSync(colimaSock)) {
|
|
86
|
+
env.DOCKER_HOST = `unix://${colimaSock}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
execFileSync("docker", ["info"], {
|
|
91
|
+
env,
|
|
92
|
+
stdio: "ignore",
|
|
93
|
+
timeout: 5_000,
|
|
94
|
+
});
|
|
95
|
+
return env;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const configuredPgUrl = process.env.OBS_PG_TEST_URL;
|
|
102
|
+
const dockerEnv = configuredPgUrl ? null : resolveDockerEnv();
|
|
103
|
+
const canRunPostgres = Boolean(configuredPgUrl || dockerEnv);
|
|
104
|
+
|
|
105
|
+
if (process.env.OBS_REQUIRE_PG_PARITY === "1" && !canRunPostgres) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"OBS_REQUIRE_PG_PARITY=1 requires either OBS_PG_TEST_URL or a reachable Docker daemon to run native-engine Postgres parity tests.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe.skipIf(!canRunPostgres)(
|
|
112
|
+
"Workstream 4: Native-engine SQLite and Postgres schema & query parity",
|
|
113
|
+
() => {
|
|
114
|
+
const runSuffix = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
115
|
+
const mainPgSchema = `obs_parity_main_${runSuffix}`;
|
|
116
|
+
const negPgSchema = `obs_parity_neg_${runSuffix}`;
|
|
117
|
+
const upgPgSchema = `obs_parity_upg_${runSuffix}`;
|
|
118
|
+
|
|
119
|
+
let startedContainerId: string | null = null;
|
|
120
|
+
let activePgUrl = configuredPgUrl ?? "";
|
|
121
|
+
let pg: PgModule;
|
|
122
|
+
let pool: PgPool;
|
|
123
|
+
let pgDb: PostgresAdapter;
|
|
124
|
+
let sqliteDb: NodeSqliteDb;
|
|
125
|
+
let pgMigrationFiles: string[] = [];
|
|
126
|
+
let sqliteVersion = "";
|
|
127
|
+
let postgresVersion = "";
|
|
128
|
+
|
|
129
|
+
const connectClient = async (schema?: string): Promise<PgClient> => {
|
|
130
|
+
const client = new pg.Client({ connectionString: activePgUrl });
|
|
131
|
+
await client.connect();
|
|
132
|
+
if (schema) {
|
|
133
|
+
await client.query(`SET search_path TO ${schema}`);
|
|
134
|
+
}
|
|
135
|
+
return client;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const applyPgMigrationsToSchema = async (
|
|
139
|
+
schema: string,
|
|
140
|
+
options: { from?: string; upTo?: string } = {},
|
|
141
|
+
): Promise<string[]> => {
|
|
142
|
+
const client = await connectClient();
|
|
143
|
+
const applied: string[] = [];
|
|
144
|
+
try {
|
|
145
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
|
|
146
|
+
await client.query(`SET search_path TO ${schema}`);
|
|
147
|
+
for (const file of pgMigrationFiles) {
|
|
148
|
+
const prefix = file.slice(0, 3);
|
|
149
|
+
if (options.from && prefix < options.from) continue;
|
|
150
|
+
if (options.upTo && prefix > options.upTo) continue;
|
|
151
|
+
const sql = await readFile(path.join(pgMigrationsDir, file), "utf8");
|
|
152
|
+
await client.query("BEGIN");
|
|
153
|
+
try {
|
|
154
|
+
await client.query(sql);
|
|
155
|
+
await client.query("COMMIT");
|
|
156
|
+
applied.push(file);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
await client.query("ROLLBACK");
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Postgres migration ${file} failed in ${schema}: ${(err as Error).message}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
await client.query("RESET search_path").catch(() => {});
|
|
166
|
+
await client.end();
|
|
167
|
+
}
|
|
168
|
+
return applied;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
beforeAll(async () => {
|
|
172
|
+
if (!activePgUrl && dockerEnv) {
|
|
173
|
+
startedContainerId = execFileSync(
|
|
174
|
+
"docker",
|
|
175
|
+
[
|
|
176
|
+
"run",
|
|
177
|
+
"-d",
|
|
178
|
+
"--rm",
|
|
179
|
+
"-e",
|
|
180
|
+
"POSTGRES_USER=obs",
|
|
181
|
+
"-e",
|
|
182
|
+
"POSTGRES_PASSWORD=obs",
|
|
183
|
+
"-e",
|
|
184
|
+
"POSTGRES_DB=obs_parity",
|
|
185
|
+
"-p",
|
|
186
|
+
"127.0.0.1::5432",
|
|
187
|
+
"postgres:15",
|
|
188
|
+
],
|
|
189
|
+
{ env: dockerEnv, encoding: "utf8" },
|
|
190
|
+
).trim();
|
|
191
|
+
|
|
192
|
+
const portOut = execFileSync(
|
|
193
|
+
"docker",
|
|
194
|
+
["port", startedContainerId, "5432/tcp"],
|
|
195
|
+
{ env: dockerEnv, encoding: "utf8" },
|
|
196
|
+
).trim();
|
|
197
|
+
const hostPort = portOut.split(":").pop()?.trim();
|
|
198
|
+
if (!hostPort) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Could not determine mapped port for postgres:15 container (${portOut})`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
activePgUrl = `postgres://obs:obs@127.0.0.1:${hostPort}/obs_parity`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
pg = (await import("pg")).default as unknown as PgModule;
|
|
207
|
+
configurePostgresTypeParsers(pg.types);
|
|
208
|
+
|
|
209
|
+
// Wait for Postgres readiness if we just launched a container
|
|
210
|
+
let ready = false;
|
|
211
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
212
|
+
try {
|
|
213
|
+
const probe = new pg.Client({ connectionString: activePgUrl });
|
|
214
|
+
await probe.connect();
|
|
215
|
+
const ver = await probe.query<{ server_version: string }>(
|
|
216
|
+
"SHOW server_version",
|
|
217
|
+
);
|
|
218
|
+
postgresVersion = ver.rows[0]?.server_version ?? "unknown";
|
|
219
|
+
await probe.end();
|
|
220
|
+
ready = true;
|
|
221
|
+
break;
|
|
222
|
+
} catch {
|
|
223
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!ready) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Postgres at ${activePgUrl} did not become ready within timeout`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
pgMigrationFiles = (await readdir(pgMigrationsDir))
|
|
233
|
+
.filter((f) => f.endsWith(".sql"))
|
|
234
|
+
.sort();
|
|
235
|
+
|
|
236
|
+
await applyPgMigrationsToSchema(mainPgSchema);
|
|
237
|
+
|
|
238
|
+
pool = new pg.Pool({
|
|
239
|
+
connectionString: activePgUrl,
|
|
240
|
+
max: 1,
|
|
241
|
+
});
|
|
242
|
+
await pool.query(`SET search_path TO ${mainPgSchema}`);
|
|
243
|
+
pgDb = new PostgresAdapter(pool);
|
|
244
|
+
|
|
245
|
+
sqliteDb = createMigratedSqliteDb();
|
|
246
|
+
const sVer = sqliteDb.raw
|
|
247
|
+
.prepare("SELECT sqlite_version() AS v")
|
|
248
|
+
.get() as { v: string };
|
|
249
|
+
sqliteVersion = sVer.v;
|
|
250
|
+
}, 120_000);
|
|
251
|
+
|
|
252
|
+
afterAll(async () => {
|
|
253
|
+
sqliteDb?.close();
|
|
254
|
+
if (pool) {
|
|
255
|
+
await pool.query("RESET search_path").catch(() => {});
|
|
256
|
+
await pool.end().catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
if (pg && activePgUrl && !startedContainerId) {
|
|
259
|
+
const cleanup = await connectClient().catch(() => null);
|
|
260
|
+
if (cleanup) {
|
|
261
|
+
try {
|
|
262
|
+
for (const s of [mainPgSchema, negPgSchema, upgPgSchema]) {
|
|
263
|
+
await cleanup.query(`DROP SCHEMA IF EXISTS ${s} CASCADE`);
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
await cleanup.end().catch(() => {});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (startedContainerId && dockerEnv) {
|
|
271
|
+
try {
|
|
272
|
+
execFileSync("docker", ["rm", "-f", startedContainerId], {
|
|
273
|
+
env: dockerEnv,
|
|
274
|
+
stdio: "ignore",
|
|
275
|
+
});
|
|
276
|
+
} catch {
|
|
277
|
+
// Ignore container cleanup errors on teardown
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("1. verifies full 53-migration schema semantic parity between SQLite and Postgres", async () => {
|
|
283
|
+
expect(sqliteVersion).toMatch(/^\d+\.\d+/);
|
|
284
|
+
expect(postgresVersion).toMatch(/^\d+/);
|
|
285
|
+
expect(pgMigrationFiles.length).toBeGreaterThanOrEqual(53);
|
|
286
|
+
|
|
287
|
+
const sqliteSchema = introspectSqliteSchema(sqliteDb.raw);
|
|
288
|
+
const client = await connectClient(mainPgSchema);
|
|
289
|
+
try {
|
|
290
|
+
const postgresSchema = await introspectPostgresSchema(
|
|
291
|
+
client,
|
|
292
|
+
mainPgSchema,
|
|
293
|
+
);
|
|
294
|
+
const report = compareDatabaseSchemas(sqliteSchema, postgresSchema);
|
|
295
|
+
expect(report.differences).toEqual([]);
|
|
296
|
+
expect(report.ok).toBe(true);
|
|
297
|
+
expect(report.tableCount).toBe(38);
|
|
298
|
+
|
|
299
|
+
// Explicitly spot-check critical AutoSRE constraints and partial indexes
|
|
300
|
+
const sIncidents = sqliteSchema.tables.incident_investigations;
|
|
301
|
+
const pIncidents = postgresSchema.tables.incident_investigations;
|
|
302
|
+
expect(sIncidents.indexes.uq_incidents_active_dedup).toEqual({
|
|
303
|
+
name: "uq_incidents_active_dedup",
|
|
304
|
+
unique: true,
|
|
305
|
+
method: "btree",
|
|
306
|
+
columns: ["project_id ASC", "environment ASC", "dedup_key ASC"],
|
|
307
|
+
where:
|
|
308
|
+
"status IN ('open', 'dispatched', 'leased', 'remediating', 'verifying')",
|
|
309
|
+
});
|
|
310
|
+
expect(pIncidents.indexes.uq_incidents_active_dedup).toEqual(
|
|
311
|
+
sIncidents.indexes.uq_incidents_active_dedup,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
expect(
|
|
315
|
+
sqliteSchema.tables.remediation_proposals.uniqueConstraints,
|
|
316
|
+
).toEqual([["project_id", "environment", "idempotency_key"]]);
|
|
317
|
+
expect(
|
|
318
|
+
postgresSchema.tables.remediation_proposals.uniqueConstraints,
|
|
319
|
+
).toEqual([["project_id", "environment", "idempotency_key"]]);
|
|
320
|
+
} finally {
|
|
321
|
+
await client.end();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("2. fails negative regression fixtures when a constraint is removed, a partial-index predicate changes, or a query violates Postgres SQL rules", async () => {
|
|
326
|
+
const sNeg = new DatabaseSync(":memory:");
|
|
327
|
+
const client = await connectClient();
|
|
328
|
+
try {
|
|
329
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS ${negPgSchema}`);
|
|
330
|
+
await client.query(`SET search_path TO ${negPgSchema}`);
|
|
331
|
+
|
|
332
|
+
// SQLite has UNIQUE (project_id, idempotency_key), CHECK (confidence >= 0),
|
|
333
|
+
// and the full partial index WHERE predicate.
|
|
334
|
+
sNeg.exec(`
|
|
335
|
+
CREATE TABLE regression_proposals (
|
|
336
|
+
project_id TEXT NOT NULL,
|
|
337
|
+
id TEXT NOT NULL,
|
|
338
|
+
idempotency_key TEXT NOT NULL,
|
|
339
|
+
confidence REAL NOT NULL,
|
|
340
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
341
|
+
PRIMARY KEY (project_id, id),
|
|
342
|
+
UNIQUE (project_id, idempotency_key),
|
|
343
|
+
CHECK (confidence >= 0)
|
|
344
|
+
);
|
|
345
|
+
CREATE UNIQUE INDEX uq_regression_active
|
|
346
|
+
ON regression_proposals (project_id, idempotency_key)
|
|
347
|
+
WHERE status IN ('open', 'dispatched', 'verifying');
|
|
348
|
+
`);
|
|
349
|
+
|
|
350
|
+
// Postgres is missing the UNIQUE constraint, missing the CHECK constraint,
|
|
351
|
+
// and omits 'verifying' from the partial index WHERE predicate.
|
|
352
|
+
await client.query(`
|
|
353
|
+
CREATE TABLE regression_proposals (
|
|
354
|
+
project_id TEXT NOT NULL,
|
|
355
|
+
id TEXT NOT NULL,
|
|
356
|
+
idempotency_key TEXT NOT NULL,
|
|
357
|
+
confidence DOUBLE PRECISION NOT NULL,
|
|
358
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
359
|
+
PRIMARY KEY (project_id, id)
|
|
360
|
+
);
|
|
361
|
+
CREATE UNIQUE INDEX uq_regression_active
|
|
362
|
+
ON regression_proposals (project_id, idempotency_key)
|
|
363
|
+
WHERE status IN ('open', 'dispatched');
|
|
364
|
+
`);
|
|
365
|
+
|
|
366
|
+
const sSchema = introspectSqliteSchema(sNeg);
|
|
367
|
+
const pSchema = await introspectPostgresSchema(client, negPgSchema);
|
|
368
|
+
const report = compareDatabaseSchemas(sSchema, pSchema);
|
|
369
|
+
|
|
370
|
+
expect(report.ok).toBe(false);
|
|
371
|
+
const kinds = report.differences.map((d) => d.kind);
|
|
372
|
+
expect(kinds).toContain("unique_constraint");
|
|
373
|
+
expect(kinds).toContain("check_constraint");
|
|
374
|
+
expect(kinds).toContain("index_where");
|
|
375
|
+
|
|
376
|
+
// Dialect-specific invalid Postgres queries that SQLite silently permits:
|
|
377
|
+
// (a) SQLSTATE 42P10: SELECT DISTINCT ... ORDER BY non-selected column
|
|
378
|
+
const distinctOrderBySql = `
|
|
379
|
+
SELECT DISTINCT trace_id, span_id
|
|
380
|
+
FROM telemetry_spans
|
|
381
|
+
WHERE project_id = ?
|
|
382
|
+
ORDER BY start_time DESC
|
|
383
|
+
`;
|
|
384
|
+
await expect(
|
|
385
|
+
sqliteDb.prepare(distinctOrderBySql).bind("default").all(),
|
|
386
|
+
).resolves.toEqual({ results: [] });
|
|
387
|
+
await expect(
|
|
388
|
+
pgDb.prepare(distinctOrderBySql).bind("default").all(),
|
|
389
|
+
).rejects.toThrow(
|
|
390
|
+
/SELECT DISTINCT, ORDER BY expressions must appear in select list/i,
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// (b) SQLSTATE 42803: correlated subquery referencing ungrouped outer column
|
|
394
|
+
const ungroupedSubquerySql = `
|
|
395
|
+
SELECT session_id,
|
|
396
|
+
(SELECT page_path FROM usage_events u2 WHERE u2.event_id = usage_events.event_id LIMIT 1) AS bad_col
|
|
397
|
+
FROM usage_events
|
|
398
|
+
WHERE project_id = ?
|
|
399
|
+
GROUP BY session_id
|
|
400
|
+
`;
|
|
401
|
+
await expect(
|
|
402
|
+
sqliteDb.prepare(ungroupedSubquerySql).bind("default").all(),
|
|
403
|
+
).resolves.toEqual({ results: [] });
|
|
404
|
+
await expect(
|
|
405
|
+
pgDb.prepare(ungroupedSubquerySql).bind("default").all(),
|
|
406
|
+
).rejects.toThrow(
|
|
407
|
+
/subquery uses ungrouped column|must appear in the GROUP BY clause/i,
|
|
408
|
+
);
|
|
409
|
+
} finally {
|
|
410
|
+
sNeg.close();
|
|
411
|
+
await client
|
|
412
|
+
.query(`DROP SCHEMA IF EXISTS ${negPgSchema} CASCADE`)
|
|
413
|
+
.catch(() => {});
|
|
414
|
+
await client.end();
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("3. preserves rows, tenant isolation, and collapses duplicate active incidents during 050 -> 053 upgrades on both engines", async () => {
|
|
419
|
+
const sUpg = createMigratedSqliteDb({ upTo: "050" });
|
|
420
|
+
await applyPgMigrationsToSchema(upgPgSchema, { upTo: "050" });
|
|
421
|
+
const upgPool = new pg.Pool({ connectionString: activePgUrl, max: 1 });
|
|
422
|
+
await upgPool.query(`SET search_path TO ${upgPgSchema}`);
|
|
423
|
+
const pUpg = new PostgresAdapter(upgPool);
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const seedPre051 = async (db: SqlDb) => {
|
|
427
|
+
const insertIncident = db.prepare(`
|
|
428
|
+
INSERT INTO incident_investigations (
|
|
429
|
+
project_id, environment, id, dedup_key, status, target_node_id,
|
|
430
|
+
symptom_anchor_kind, symptom_anchor_id, alert_count, opened_at, updated_at
|
|
431
|
+
) VALUES (?, ?, ?, ?, ?, 'service:default:default:checkout', 'trace', 'trace-1', 1, ?, ?)
|
|
432
|
+
`);
|
|
433
|
+
await db.batch([
|
|
434
|
+
// Duplicate active incidents for tenant-a (pre-051 race condition)
|
|
435
|
+
insertIncident.bind(
|
|
436
|
+
"tenant-a",
|
|
437
|
+
"default",
|
|
438
|
+
"inc-older",
|
|
439
|
+
"dedup-shared",
|
|
440
|
+
"open",
|
|
441
|
+
"2026-06-01T00:00:00.000Z",
|
|
442
|
+
"2026-06-01T00:00:00.000Z",
|
|
443
|
+
),
|
|
444
|
+
insertIncident.bind(
|
|
445
|
+
"tenant-a",
|
|
446
|
+
"default",
|
|
447
|
+
"inc-newer",
|
|
448
|
+
"dedup-shared",
|
|
449
|
+
"leased",
|
|
450
|
+
"2026-06-01T00:05:00.000Z",
|
|
451
|
+
"2026-06-01T00:05:00.000Z",
|
|
452
|
+
),
|
|
453
|
+
// Resolved incident with same dedup_key (must stay resolved)
|
|
454
|
+
insertIncident.bind(
|
|
455
|
+
"tenant-a",
|
|
456
|
+
"default",
|
|
457
|
+
"inc-resolved",
|
|
458
|
+
"dedup-shared",
|
|
459
|
+
"resolved",
|
|
460
|
+
"2026-05-31T23:00:00.000Z",
|
|
461
|
+
"2026-05-31T23:30:00.000Z",
|
|
462
|
+
),
|
|
463
|
+
// Active incident in tenant-b with same dedup_key (must stay isolated & open)
|
|
464
|
+
insertIncident.bind(
|
|
465
|
+
"tenant-b",
|
|
466
|
+
"default",
|
|
467
|
+
"inc-older",
|
|
468
|
+
"dedup-shared",
|
|
469
|
+
"open",
|
|
470
|
+
"2026-06-01T00:00:00.000Z",
|
|
471
|
+
"2026-06-01T00:00:00.000Z",
|
|
472
|
+
),
|
|
473
|
+
db.prepare(`
|
|
474
|
+
INSERT INTO investigation_findings (
|
|
475
|
+
project_id, environment, id, incident_id, fingerprint,
|
|
476
|
+
fingerprint_input_json, title, summary, causal_proof_tier,
|
|
477
|
+
confidence, confidence_breakdown_json, symptom_entity_kind,
|
|
478
|
+
symptom_entity_id, fault_locus_entity_kind, fault_locus_entity_id,
|
|
479
|
+
causal_chain_json, cited_evidence_ids_json, actor_type, actor_id,
|
|
480
|
+
created_at, updated_at
|
|
481
|
+
) VALUES (
|
|
482
|
+
'tenant-a', 'default', 'fnd-pre-051', 'inc-newer', 'fp-1',
|
|
483
|
+
'{}', 'Checkout 500s', 'DB timeout', 'unverified_locus',
|
|
484
|
+
0.6, '{}', 'trace', 'trace-1', 'service', 'service:default:default:checkout',
|
|
485
|
+
'[]', '["trace-1"]', 'agent', 'sre-agent',
|
|
486
|
+
'2026-06-01T00:06:00.000Z', '2026-06-01T00:06:00.000Z'
|
|
487
|
+
)
|
|
488
|
+
`),
|
|
489
|
+
]);
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
await seedPre051(sUpg);
|
|
493
|
+
await seedPre051(pUpg);
|
|
494
|
+
|
|
495
|
+
// Apply 051-053 on both engines
|
|
496
|
+
applySqliteMigrations(sUpg.raw, { from: "051", upTo: "053" });
|
|
497
|
+
await applyPgMigrationsToSchema(upgPgSchema, {
|
|
498
|
+
from: "051",
|
|
499
|
+
upTo: "053",
|
|
500
|
+
});
|
|
501
|
+
// Re-applying 051-053 on Postgres is a no-op (IF NOT EXISTS)
|
|
502
|
+
await applyPgMigrationsToSchema(upgPgSchema, {
|
|
503
|
+
from: "051",
|
|
504
|
+
upTo: "053",
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const inspectUpgradedState = async (db: SqlDb) => {
|
|
508
|
+
const incidents = await db
|
|
509
|
+
.prepare(
|
|
510
|
+
`SELECT project_id, id, status
|
|
511
|
+
FROM incident_investigations
|
|
512
|
+
ORDER BY project_id, id`,
|
|
513
|
+
)
|
|
514
|
+
.all<{ project_id: string; id: string; status: string }>();
|
|
515
|
+
const finding = await db
|
|
516
|
+
.prepare(
|
|
517
|
+
`SELECT id, consecutive_ok_checks
|
|
518
|
+
FROM investigation_findings
|
|
519
|
+
WHERE project_id = 'tenant-a' AND id = 'fnd-pre-051'`,
|
|
520
|
+
)
|
|
521
|
+
.first<{ id: string; consecutive_ok_checks: number }>();
|
|
522
|
+
|
|
523
|
+
// Partial unique index uq_incidents_active_dedup must reject a direct duplicate active insert
|
|
524
|
+
let duplicateRejected = false;
|
|
525
|
+
try {
|
|
526
|
+
await db
|
|
527
|
+
.prepare(
|
|
528
|
+
`INSERT INTO incident_investigations (
|
|
529
|
+
project_id, environment, id, dedup_key, status, target_node_id,
|
|
530
|
+
symptom_anchor_kind, symptom_anchor_id, alert_count, opened_at, updated_at
|
|
531
|
+
) VALUES ('tenant-a', 'default', 'inc-dup-after-051', 'dedup-shared', 'open', 'service:default:default:checkout', 'trace', 'trace-2', 1, '2026-06-01T00:10:00.000Z', '2026-06-01T00:10:00.000Z')`,
|
|
532
|
+
)
|
|
533
|
+
.run();
|
|
534
|
+
} catch {
|
|
535
|
+
duplicateRejected = true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
incidents: incidents.results,
|
|
540
|
+
finding: {
|
|
541
|
+
id: finding?.id,
|
|
542
|
+
consecutive_ok_checks: Number(finding?.consecutive_ok_checks),
|
|
543
|
+
},
|
|
544
|
+
duplicateRejected,
|
|
545
|
+
};
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const sqliteState = await inspectUpgradedState(sUpg);
|
|
549
|
+
const pgState = await inspectUpgradedState(pUpg);
|
|
550
|
+
|
|
551
|
+
expect(sqliteState).toEqual({
|
|
552
|
+
incidents: [
|
|
553
|
+
{ project_id: "tenant-a", id: "inc-newer", status: "leased" },
|
|
554
|
+
{ project_id: "tenant-a", id: "inc-older", status: "escalated" },
|
|
555
|
+
{ project_id: "tenant-a", id: "inc-resolved", status: "resolved" },
|
|
556
|
+
{ project_id: "tenant-b", id: "inc-older", status: "open" },
|
|
557
|
+
],
|
|
558
|
+
finding: { id: "fnd-pre-051", consecutive_ok_checks: 0 },
|
|
559
|
+
duplicateRejected: true,
|
|
560
|
+
});
|
|
561
|
+
expect(pgState).toEqual(sqliteState);
|
|
562
|
+
} finally {
|
|
563
|
+
sUpg.close();
|
|
564
|
+
await upgPool.query("RESET search_path").catch(() => {});
|
|
565
|
+
await upgPool.end().catch(() => {});
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("4. produces matching logical results across SQLite and Postgres for connected routes, usage sessions, and AutoSRE flows", async () => {
|
|
570
|
+
const projectId = "parity-proj";
|
|
571
|
+
const environment = "prod";
|
|
572
|
+
const baseMs = Date.parse("2026-07-01T12:00:00.000Z");
|
|
573
|
+
const changeTimeIso = new Date(baseMs).toISOString();
|
|
574
|
+
const preTimeIso = new Date(baseMs - 10 * 60_000).toISOString();
|
|
575
|
+
const postTime1Iso = new Date(baseMs + 60_000).toISOString();
|
|
576
|
+
const nowIso = new Date().toISOString();
|
|
577
|
+
const expiresIso = new Date(Date.now() + 24 * 3600_000).toISOString();
|
|
578
|
+
|
|
579
|
+
const runFlowOnEngine = async (db: SqlDb) => {
|
|
580
|
+
// 1. Record an explicit deployment change event
|
|
581
|
+
const change = await recordChangeEvent(db, projectId, {
|
|
582
|
+
id: "chg-parity-1",
|
|
583
|
+
environment,
|
|
584
|
+
kind: "deploy",
|
|
585
|
+
source: "explicit_api",
|
|
586
|
+
serviceName: "checkout",
|
|
587
|
+
version: "v2.0.0",
|
|
588
|
+
previousVersion: "v1.0.0",
|
|
589
|
+
commitSha: "bbbb2222",
|
|
590
|
+
previousCommitSha: "aaaa1111",
|
|
591
|
+
summary: "Deploy checkout v2.0.0",
|
|
592
|
+
touchedPaths: ["src/checkout/pay.ts"],
|
|
593
|
+
occurredAt: changeTimeIso,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// 2. Seed spans:
|
|
597
|
+
// - 55 healthy v1.0.0 spans pre-deploy and 55 healthy v1.0.0 spans post-deploy
|
|
598
|
+
// - 55 failing v2.0.0 spans post-deploy (to satisfy minComparableSample=50 and isolate cohort lift)
|
|
599
|
+
const makeSpan = (
|
|
600
|
+
idx: number,
|
|
601
|
+
version: string,
|
|
602
|
+
statusCode: number,
|
|
603
|
+
startTime: string,
|
|
604
|
+
): StoredSpan => ({
|
|
605
|
+
projectId,
|
|
606
|
+
environment,
|
|
607
|
+
traceId: `trace-${version}-${idx}`,
|
|
608
|
+
spanId: `span-${version}-${idx}`,
|
|
609
|
+
parentSpanId: null,
|
|
610
|
+
traceState: null,
|
|
611
|
+
serviceName: "checkout",
|
|
612
|
+
serviceVersion: version,
|
|
613
|
+
scopeName: null,
|
|
614
|
+
scopeVersion: null,
|
|
615
|
+
spanName: "POST /checkout/pay",
|
|
616
|
+
spanKind: 1,
|
|
617
|
+
statusCode,
|
|
618
|
+
statusMessage: statusCode === 2 ? "PoolExhaustedError" : null,
|
|
619
|
+
errorSignature: statusCode === 2 ? "PoolExhaustedError" : null,
|
|
620
|
+
startTime,
|
|
621
|
+
endTime: startTime,
|
|
622
|
+
durationMs: statusCode === 2 ? 450 : 25,
|
|
623
|
+
attributesJson: JSON.stringify({
|
|
624
|
+
"code.filepath": "src/checkout/pay.ts",
|
|
625
|
+
}),
|
|
626
|
+
droppedAttributesCount: 0,
|
|
627
|
+
resourceAttributesJson: JSON.stringify({
|
|
628
|
+
"service.name": "checkout",
|
|
629
|
+
"service.version": version,
|
|
630
|
+
"deployment.environment": environment,
|
|
631
|
+
}),
|
|
632
|
+
eventsJson: "[]",
|
|
633
|
+
droppedEventsCount: 0,
|
|
634
|
+
linksJson: "[]",
|
|
635
|
+
droppedLinksCount: 0,
|
|
636
|
+
receivedAt: nowIso,
|
|
637
|
+
expiresAt: expiresIso,
|
|
638
|
+
sessionId: null,
|
|
639
|
+
interactionId: null,
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const spans: StoredSpan[] = [];
|
|
643
|
+
for (let i = 0; i < 55; i += 1) {
|
|
644
|
+
const preSpanTime = new Date(
|
|
645
|
+
baseMs - 10 * 60_000 + i * 1000,
|
|
646
|
+
).toISOString();
|
|
647
|
+
const postSpanTime = new Date(
|
|
648
|
+
baseMs + 60_000 + i * 1000,
|
|
649
|
+
).toISOString();
|
|
650
|
+
spans.push(makeSpan(i, "v1.0.0", 1, preSpanTime));
|
|
651
|
+
spans.push(makeSpan(i + 100, "v1.0.0", 1, postSpanTime));
|
|
652
|
+
spans.push(makeSpan(i, "v2.0.0", 2, postSpanTime));
|
|
653
|
+
}
|
|
654
|
+
await ingestTelemetrySpans(db, spans);
|
|
655
|
+
|
|
656
|
+
// 3. Seed usage events for /internal/usage/sessions (tests 42803 correlated subquery parity)
|
|
657
|
+
const usageStore = new UsageStore(db);
|
|
658
|
+
const makeUsage = (
|
|
659
|
+
over: Partial<UsageEventRecord>,
|
|
660
|
+
): UsageEventRecord => ({
|
|
661
|
+
projectId,
|
|
662
|
+
eventId: "evt-1",
|
|
663
|
+
sessionId: "sess-alpha",
|
|
664
|
+
visitorId: "vis-1",
|
|
665
|
+
eventType: "page_view",
|
|
666
|
+
eventName: "page_view",
|
|
667
|
+
pagePath: "/home",
|
|
668
|
+
pageTitle: "Home",
|
|
669
|
+
referrer: null,
|
|
670
|
+
severity: "info",
|
|
671
|
+
source: "web",
|
|
672
|
+
contextJson: "{}",
|
|
673
|
+
propertiesJson: "{}",
|
|
674
|
+
userAgent: "Mozilla/5.0",
|
|
675
|
+
occurredAt: preTimeIso,
|
|
676
|
+
receivedAt: nowIso,
|
|
677
|
+
expiresAt: expiresIso,
|
|
678
|
+
country: "US",
|
|
679
|
+
browser: "Chrome",
|
|
680
|
+
os: "macOS",
|
|
681
|
+
deviceType: "desktop",
|
|
682
|
+
isBot: false,
|
|
683
|
+
utmSource: null,
|
|
684
|
+
utmMedium: null,
|
|
685
|
+
utmCampaign: null,
|
|
686
|
+
...over,
|
|
687
|
+
});
|
|
688
|
+
await usageStore.ingest([
|
|
689
|
+
makeUsage({
|
|
690
|
+
eventId: "evt-1",
|
|
691
|
+
sessionId: "sess-alpha",
|
|
692
|
+
pagePath: "/cart",
|
|
693
|
+
occurredAt: preTimeIso,
|
|
694
|
+
}),
|
|
695
|
+
makeUsage({
|
|
696
|
+
eventId: "evt-2",
|
|
697
|
+
sessionId: "sess-alpha",
|
|
698
|
+
pagePath: "/checkout",
|
|
699
|
+
eventType: "frontend_error",
|
|
700
|
+
severity: "error",
|
|
701
|
+
occurredAt: postTime1Iso,
|
|
702
|
+
}),
|
|
703
|
+
]);
|
|
704
|
+
|
|
705
|
+
// 4. Compute change blast radius
|
|
706
|
+
const blast = await computeChangeBlastRadius(db, projectId, change.id, {
|
|
707
|
+
environment,
|
|
708
|
+
minComparableSample: 50,
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// 5. Open incident + deduplicate + acquire lease
|
|
712
|
+
const opened1 = await openOrDeduplicateIncident(db, projectId, {
|
|
713
|
+
id: "inc-parity-1",
|
|
714
|
+
environment,
|
|
715
|
+
targetNodeId: change.targetNodeId,
|
|
716
|
+
symptomCategory: "PoolExhaustedError",
|
|
717
|
+
symptomAnchorKind: "trace",
|
|
718
|
+
symptomAnchorId: "trace-v2.0.0-54",
|
|
719
|
+
});
|
|
720
|
+
const opened2 = await openOrDeduplicateIncident(db, projectId, {
|
|
721
|
+
id: "inc-parity-2",
|
|
722
|
+
environment,
|
|
723
|
+
targetNodeId: change.targetNodeId,
|
|
724
|
+
symptomCategory: "PoolExhaustedError",
|
|
725
|
+
symptomAnchorKind: "trace",
|
|
726
|
+
symptomAnchorId: "trace-v2.0.0-54",
|
|
727
|
+
});
|
|
728
|
+
const lease = await acquireIncidentLease(
|
|
729
|
+
db,
|
|
730
|
+
projectId,
|
|
731
|
+
opened1.incident.id,
|
|
732
|
+
{
|
|
733
|
+
environment,
|
|
734
|
+
runnerId: "runner-1",
|
|
735
|
+
leaseDurationSeconds: 120,
|
|
736
|
+
nowIso,
|
|
737
|
+
},
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
// 6. Record investigation finding linked to the change & span fault locus
|
|
741
|
+
const finding = await recordInvestigationFinding(db, projectId, {
|
|
742
|
+
idempotencyKey: "idem-finding-parity-1",
|
|
743
|
+
environment,
|
|
744
|
+
incidentId: opened1.incident.id,
|
|
745
|
+
title: "POST /checkout/pay PoolExhaustedError after v2.0.0 deploy",
|
|
746
|
+
summary: "v2.0.0 introduced connection pool leak in pay.ts",
|
|
747
|
+
symptomEntityKind: "trace",
|
|
748
|
+
symptomEntityId: "trace-v2.0.0-54",
|
|
749
|
+
faultLocusEntityKind: "span",
|
|
750
|
+
faultLocusEntityId: "trace-v2.0.0-54:span-v2.0.0-54",
|
|
751
|
+
rootCauseEntityKind: "change",
|
|
752
|
+
rootCauseEntityId: change.id,
|
|
753
|
+
contributingChangeId: change.id,
|
|
754
|
+
causalChain: [
|
|
755
|
+
{
|
|
756
|
+
subjectKind: "change",
|
|
757
|
+
subjectId: change.id,
|
|
758
|
+
predicate: "isolated_to_version_cohort",
|
|
759
|
+
objectKind: "service",
|
|
760
|
+
objectId: change.targetNodeId,
|
|
761
|
+
relationshipCertainty: "exact",
|
|
762
|
+
causalProofTier: "cohort_rate_isolated",
|
|
763
|
+
confidence: 0.9,
|
|
764
|
+
reason: "Isolated to v2.0.0 cohort",
|
|
765
|
+
},
|
|
766
|
+
],
|
|
767
|
+
citedEvidenceIds: ["trace-v2.0.0-54:span-v2.0.0-54", change.id],
|
|
768
|
+
codeReferences: [
|
|
769
|
+
{
|
|
770
|
+
relativePath: "src/checkout/pay.ts",
|
|
771
|
+
symbolName: "processPayment",
|
|
772
|
+
lineNumber: 42,
|
|
773
|
+
},
|
|
774
|
+
],
|
|
775
|
+
actorType: "agent",
|
|
776
|
+
actorId: "autosre-investigator",
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// 7. Propose remediation action, approve, and claim grant
|
|
780
|
+
const proposal = await proposeRemediationAction(db, projectId, {
|
|
781
|
+
environment,
|
|
782
|
+
incidentId: opened1.incident.id,
|
|
783
|
+
findingId: finding.id,
|
|
784
|
+
idempotencyKey: "idem-rem-parity-1",
|
|
785
|
+
actionKind: "rollback_deploy",
|
|
786
|
+
targetNodeId: change.targetNodeId,
|
|
787
|
+
expectedCurrentState: { version: "v2.0.0" },
|
|
788
|
+
desiredTargetState: { version: "v1.0.0" },
|
|
789
|
+
requestedAutonomy: AgentAutonomyLevel.HumanApprovedWrite,
|
|
790
|
+
});
|
|
791
|
+
const approved = await approveRemediationProposal(
|
|
792
|
+
db,
|
|
793
|
+
projectId,
|
|
794
|
+
proposal.id,
|
|
795
|
+
{
|
|
796
|
+
environment,
|
|
797
|
+
approvedByUserId: "oncall@example.com",
|
|
798
|
+
},
|
|
799
|
+
);
|
|
800
|
+
const claimed = await claimRemediationGrant(
|
|
801
|
+
db,
|
|
802
|
+
projectId,
|
|
803
|
+
proposal.id,
|
|
804
|
+
{
|
|
805
|
+
environment,
|
|
806
|
+
grantToken: approved.grantToken ?? "",
|
|
807
|
+
runnerId: "runner-1",
|
|
808
|
+
},
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
// 8. Query HTTP routes (/internal/connected/change/:id & /internal/usage/sessions)
|
|
812
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
813
|
+
app.use("*", async (c, next) => {
|
|
814
|
+
(c as unknown as { set: (k: string, v: string) => void }).set(
|
|
815
|
+
"projectId",
|
|
816
|
+
projectId,
|
|
817
|
+
);
|
|
818
|
+
await next();
|
|
819
|
+
});
|
|
820
|
+
const runtime = new CollectorRuntime();
|
|
821
|
+
connectedRoutesPlugin.register(app, runtime);
|
|
822
|
+
usageQueryRoutesPlugin.register(app, runtime);
|
|
823
|
+
const env = { DB: db as unknown as D1Database } as CollectorEnv;
|
|
824
|
+
|
|
825
|
+
const connectedRes = await app.request(
|
|
826
|
+
`/internal/connected/change/${change.id}?environment=${environment}`,
|
|
827
|
+
{},
|
|
828
|
+
env,
|
|
829
|
+
);
|
|
830
|
+
expect(connectedRes.status).toBe(200);
|
|
831
|
+
const connectedBody = (await connectedRes.json()) as {
|
|
832
|
+
anchor: { entityId: string };
|
|
833
|
+
across: Array<{
|
|
834
|
+
kind: string;
|
|
835
|
+
links: Array<{ entityKind: string; entityId: string }>;
|
|
836
|
+
}>;
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
const sessionsRes = await app.request(
|
|
840
|
+
`/internal/usage/sessions?hours=24`,
|
|
841
|
+
{},
|
|
842
|
+
env,
|
|
843
|
+
);
|
|
844
|
+
expect(sessionsRes.status).toBe(200);
|
|
845
|
+
const sessionsBody = (await sessionsRes.json()) as {
|
|
846
|
+
sessions: Array<{
|
|
847
|
+
sessionId: string;
|
|
848
|
+
eventCount: number;
|
|
849
|
+
errorCount: number;
|
|
850
|
+
lastPath: string | null;
|
|
851
|
+
}>;
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const changesList = await listChangeEvents(db, {
|
|
855
|
+
projectId,
|
|
856
|
+
environment,
|
|
857
|
+
});
|
|
858
|
+
const findingsList = await searchInvestigationFindings(db, {
|
|
859
|
+
projectId,
|
|
860
|
+
environment,
|
|
861
|
+
fingerprint: finding.fingerprint,
|
|
862
|
+
});
|
|
863
|
+
const loadedFinding = await getInvestigationFinding(
|
|
864
|
+
db,
|
|
865
|
+
projectId,
|
|
866
|
+
finding.id,
|
|
867
|
+
environment,
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
changeTargetNodeId: change.targetNodeId,
|
|
872
|
+
changesCount: changesList.length,
|
|
873
|
+
blast: blast
|
|
874
|
+
? {
|
|
875
|
+
cohortVerdict: blast.cohortVerdict,
|
|
876
|
+
causalProofTier: blast.causalProofTier,
|
|
877
|
+
newErrorCount: blast.newCohort.errorCount,
|
|
878
|
+
newRequestCount: blast.newCohort.requestCount,
|
|
879
|
+
oldErrorCount: blast.oldCohort.errorCount,
|
|
880
|
+
oldRequestCount: blast.oldCohort.requestCount,
|
|
881
|
+
overlappingCodePaths: blast.overlappingCodePaths,
|
|
882
|
+
}
|
|
883
|
+
: null,
|
|
884
|
+
incident: {
|
|
885
|
+
firstDeduplicated: opened1.deduplicated,
|
|
886
|
+
secondDeduplicated: opened2.deduplicated,
|
|
887
|
+
alertCount: opened2.incident.alertCount,
|
|
888
|
+
leaseAcquired: lease.acquired,
|
|
889
|
+
},
|
|
890
|
+
finding: {
|
|
891
|
+
id: finding.id,
|
|
892
|
+
fingerprint: finding.fingerprint,
|
|
893
|
+
causalProofTier: finding.causalProofTier,
|
|
894
|
+
cohortVerdict: finding.cohortVerdict,
|
|
895
|
+
confidenceBreakdown: finding.confidenceBreakdown,
|
|
896
|
+
matchesLoaded: loadedFinding?.id === finding.id,
|
|
897
|
+
searchCount: findingsList.length,
|
|
898
|
+
},
|
|
899
|
+
remediation: {
|
|
900
|
+
initialStatus: proposal.status,
|
|
901
|
+
effectiveAutonomy: proposal.effectiveAutonomy,
|
|
902
|
+
approvedStatus: approved.status,
|
|
903
|
+
hasGrantToken: Boolean(approved.grantToken?.startsWith("rgt_")),
|
|
904
|
+
claimedStatus: claimed.status,
|
|
905
|
+
claimedByRunnerId: claimed.claimedByRunnerId,
|
|
906
|
+
},
|
|
907
|
+
connectedSpanLinks: connectedBody.across.flatMap((g) =>
|
|
908
|
+
g.links.map((l) => `${l.entityKind}:${l.entityId}`),
|
|
909
|
+
),
|
|
910
|
+
sessions: sessionsBody.sessions.map((s) => ({
|
|
911
|
+
sessionId: s.sessionId,
|
|
912
|
+
eventCount: s.eventCount,
|
|
913
|
+
errorCount: s.errorCount,
|
|
914
|
+
lastPath: s.lastPath,
|
|
915
|
+
})),
|
|
916
|
+
};
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
const sqliteResult = await runFlowOnEngine(sqliteDb);
|
|
920
|
+
const postgresResult = await runFlowOnEngine(pgDb);
|
|
921
|
+
|
|
922
|
+
expect(postgresResult).toEqual(sqliteResult);
|
|
923
|
+
expect(sqliteResult.blast).toEqual({
|
|
924
|
+
cohortVerdict: "isolated_to_new_version",
|
|
925
|
+
causalProofTier: "cohort_rate_isolated",
|
|
926
|
+
newErrorCount: 55,
|
|
927
|
+
newRequestCount: 55,
|
|
928
|
+
oldErrorCount: 0,
|
|
929
|
+
oldRequestCount: 55,
|
|
930
|
+
overlappingCodePaths: ["src/checkout/pay.ts"],
|
|
931
|
+
});
|
|
932
|
+
expect(sqliteResult.finding.causalProofTier).toBe("cohort_rate_isolated");
|
|
933
|
+
expect(sqliteResult.incident).toEqual({
|
|
934
|
+
firstDeduplicated: false,
|
|
935
|
+
secondDeduplicated: true,
|
|
936
|
+
alertCount: 2,
|
|
937
|
+
leaseAcquired: true,
|
|
938
|
+
});
|
|
939
|
+
expect(sqliteResult.remediation).toEqual({
|
|
940
|
+
initialStatus: "pending_human_approval",
|
|
941
|
+
effectiveAutonomy: "human_approved_write",
|
|
942
|
+
approvedStatus: "grant_issued",
|
|
943
|
+
hasGrantToken: true,
|
|
944
|
+
claimedStatus: "claimed_executing",
|
|
945
|
+
claimedByRunnerId: "runner-1",
|
|
946
|
+
});
|
|
947
|
+
// Most recent span first (trace-v2.0.0-54 at postTime2Iso)
|
|
948
|
+
expect(sqliteResult.connectedSpanLinks[0]).toBe(
|
|
949
|
+
"span:trace-v2.0.0-54:span-v2.0.0-54",
|
|
950
|
+
);
|
|
951
|
+
expect(sqliteResult.sessions).toEqual([
|
|
952
|
+
{
|
|
953
|
+
sessionId: "sess-alpha",
|
|
954
|
+
eventCount: 2,
|
|
955
|
+
errorCount: 1,
|
|
956
|
+
lastPath: "/checkout",
|
|
957
|
+
},
|
|
958
|
+
]);
|
|
959
|
+
});
|
|
960
|
+
},
|
|
961
|
+
);
|