omp-conductor 0.13.0 → 0.14.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/src/store.ts CHANGED
@@ -14,20 +14,25 @@ import { mkdirSync } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
15
 
16
16
  import { stateDir } from "./config.ts";
17
- import { DECISION_TTL_MS, DEFAULT_CAPS } from "./types.ts";
17
+ import { DECISION_TTL_MS, DEFAULT_CAPS, DIGEST_BACKLOG_LIMIT } from "./types.ts";
18
18
  import { dispatchInfra, neverStarted } from "./failure-class.ts";
19
19
  import type {
20
+ BaseHealth,
20
21
  DecisionDraft,
21
22
  FailureClass,
22
23
  DecisionRecord,
23
24
  DecisionState,
25
+ DigestBacklog,
24
26
  DispatchSummary,
25
27
  FrictionAdmissionReason,
26
28
  FrictionKind,
27
29
  FrictionObservation,
28
30
  FrictionSignal,
29
31
  HeldNotice,
32
+ InterruptCategory,
30
33
  HeldNoticeDraft,
34
+ MaterialEvent,
35
+ MaterialEventDraft,
31
36
  LabelOp,
32
37
  MergeLock,
33
38
  ReportDeliveryState,
@@ -162,6 +167,31 @@ interface RunRow {
162
167
  recoveredAt: number | null;
163
168
  }
164
169
 
170
+ /** The `base_health` table exactly as SQLite hands it back. */
171
+ interface BaseHealthRow {
172
+ project: string;
173
+ repo: string;
174
+ branch: string;
175
+ headSha: string;
176
+ verdict: string;
177
+ runsCount: number;
178
+ detail: string | null;
179
+ checkedAt: number;
180
+ }
181
+
182
+ function toBaseHealth(row: BaseHealthRow): BaseHealth {
183
+ const health: BaseHealth = {
184
+ repo: row.repo,
185
+ branch: row.branch,
186
+ headSha: row.headSha,
187
+ verdict: row.verdict as BaseHealth["verdict"],
188
+ runsCount: row.runsCount,
189
+ checkedAt: row.checkedAt,
190
+ };
191
+ if (row.detail !== null) health.detail = row.detail;
192
+ return health;
193
+ }
194
+
165
195
  interface FrictionRollupRow {
166
196
  project: string;
167
197
  day: string;
@@ -226,6 +256,18 @@ interface ReportRow {
226
256
  lastError: string | null;
227
257
  }
228
258
 
259
+ /** The `material_events` table exactly as SQLite hands it back (#274). */
260
+ interface MaterialEventRow {
261
+ id: string;
262
+ project: string;
263
+ category: string;
264
+ summary: string;
265
+ evidence: string;
266
+ occurredAt: number;
267
+ recordedAt: number;
268
+ digestReportId: string | null;
269
+ }
270
+
229
271
  /** The `verb_ledger` table exactly as SQLite hands it back (#126). */
230
272
  interface VerbLedgerRow {
231
273
  id: string;
@@ -285,6 +327,18 @@ CREATE TABLE IF NOT EXISTS runs (
285
327
  CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
286
328
  CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
287
329
 
330
+ CREATE TABLE IF NOT EXISTS base_health (
331
+ project TEXT NOT NULL,
332
+ repo TEXT NOT NULL,
333
+ branch TEXT NOT NULL,
334
+ headSha TEXT NOT NULL,
335
+ verdict TEXT NOT NULL,
336
+ runsCount INTEGER NOT NULL,
337
+ detail TEXT,
338
+ checkedAt INTEGER NOT NULL,
339
+ PRIMARY KEY (project, repo)
340
+ );
341
+
288
342
  CREATE TABLE IF NOT EXISTS notifications (
289
343
  "key" TEXT PRIMARY KEY,
290
344
  at INTEGER NOT NULL
@@ -344,10 +398,10 @@ CREATE TABLE IF NOT EXISTS friction_surfaces (
344
398
  -- on the same row. This one is keyed by report id and carries delivery state,
345
399
  -- the attempt in flight, and the message id Telegram actually returned.
346
400
  --
347
- -- The partial unique index is the digest guard: a digest:<day> key can exist at
348
- -- most once per project, so "has today's digest been handed over?" is a question
349
- -- the ledger answers rather than the model's memory of the last tick. Material
350
- -- reports carry no key and are never suppressed.
401
+ -- The partial unique index is the digest guard: a non-failed digest:<day> key
402
+ -- can exist at most once per project, so "has today's digest been handed over?"
403
+ -- is a question the ledger answers rather than the model's memory of the last
404
+ -- tick. A failed handoff can be replaced; material reports carry no key.
351
405
  CREATE TABLE IF NOT EXISTS reports (
352
406
  id TEXT PRIMARY KEY,
353
407
  project TEXT NOT NULL,
@@ -366,10 +420,26 @@ CREATE TABLE IF NOT EXISTS reports (
366
420
  lastError TEXT
367
421
  );
368
422
  CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
369
- ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL;
423
+ ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL AND state <> 'failed';
370
424
  CREATE INDEX IF NOT EXISTS reports_project_state
371
425
  ON reports (project, state, nextAttemptAt);
372
426
 
427
+ -- Ordinary material outcomes awaiting authorship in a deferred digest (#274).
428
+ -- Association happens when a digest is accepted into the outbox, not when the
429
+ -- session happens to remember the event or when Telegram later delivers it.
430
+ CREATE TABLE IF NOT EXISTS material_events (
431
+ id TEXT PRIMARY KEY,
432
+ project TEXT NOT NULL,
433
+ category TEXT NOT NULL,
434
+ summary TEXT NOT NULL,
435
+ evidence TEXT NOT NULL,
436
+ occurredAt INTEGER NOT NULL,
437
+ recordedAt INTEGER NOT NULL,
438
+ digestReportId TEXT REFERENCES reports(id)
439
+ );
440
+ CREATE INDEX IF NOT EXISTS material_events_project_undigested
441
+ ON material_events (project, occurredAt) WHERE digestReportId IS NULL;
442
+
373
443
  -- Interrupts a project's reporting.interruptOn policy deferred to the digest
374
444
  -- (#229): an escalation that must not page the operator's phone now is held
375
445
  -- here so the daily rollup can surface it. digestedAt set -> re-surfaced by a
@@ -377,17 +447,33 @@ CREATE INDEX IF NOT EXISTS reports_project_state
377
447
  -- are escalations that never reached a send, distinct from reports the model
378
448
  -- wrote on purpose.
379
449
  CREATE TABLE IF NOT EXISTS held_notices (
380
- id TEXT PRIMARY KEY,
381
- project TEXT NOT NULL,
382
- category TEXT NOT NULL,
383
- summary TEXT NOT NULL,
384
- detail TEXT NOT NULL,
385
- createdAt INTEGER NOT NULL,
386
- digestedAt INTEGER
450
+ id TEXT PRIMARY KEY,
451
+ project TEXT NOT NULL,
452
+ category TEXT NOT NULL,
453
+ summary TEXT NOT NULL,
454
+ detail TEXT NOT NULL,
455
+ createdAt INTEGER NOT NULL,
456
+ releaseOnAvailable INTEGER NOT NULL DEFAULT 0,
457
+ urgent INTEGER NOT NULL DEFAULT 0,
458
+ digestedAt INTEGER,
459
+ digestReportId TEXT REFERENCES reports(id)
387
460
  );
388
461
  CREATE INDEX IF NOT EXISTS held_notices_project_undigested
389
462
  ON held_notices (project, digestedAt) WHERE digestedAt IS NULL;
390
463
 
464
+ -- A due digest gets one bounded handoff lease per oldest availability notice
465
+ -- and local day. The lease closes the snapshot-to-CLI race; expiry hands
466
+ -- ownership back to the daemon catch-up instead of starving the notice.
467
+ CREATE TABLE IF NOT EXISTS availability_digest_reservations (
468
+ project TEXT NOT NULL,
469
+ cycleKey TEXT NOT NULL,
470
+ firstNoticeId TEXT NOT NULL,
471
+ expiresAt INTEGER NOT NULL,
472
+ PRIMARY KEY (project, cycleKey, firstNoticeId)
473
+ );
474
+ CREATE INDEX IF NOT EXISTS availability_digest_reservations_active
475
+ ON availability_digest_reservations (project, expiresAt);
476
+
391
477
  -- The action ledger for the conductor-owned mutation verbs (#126). Every
392
478
  -- decided call lands here, refusals included: the question an escalation asks
393
479
  -- is "what did this run try", and a table that only records what succeeded
@@ -600,6 +686,19 @@ function toReport(row: ReportRow): ReportRecord {
600
686
  return record;
601
687
  }
602
688
 
689
+ function toMaterialEvent(row: MaterialEventRow): MaterialEvent {
690
+ return {
691
+ id: row.id,
692
+ project: row.project,
693
+ category: row.category,
694
+ summary: row.summary,
695
+ evidence: row.evidence,
696
+ occurredAt: row.occurredAt,
697
+ recordedAt: row.recordedAt,
698
+ ...(row.digestReportId === null ? {} : { digestReportId: row.digestReportId }),
699
+ };
700
+ }
701
+
603
702
  interface DecisionRow {
604
703
  id: string;
605
704
  project: string;
@@ -771,6 +870,23 @@ export function openStore(dbPath: string): Store {
771
870
  db.exec("PRAGMA foreign_keys = ON;");
772
871
  db.exec("PRAGMA busy_timeout = 5000;");
773
872
  db.exec(SCHEMA);
873
+ // #274: a terminally failed digest did not count as sent, but the old unique
874
+ // index still blocked a replacement under the same daily key. Replace that
875
+ // one-time schema so owed ledger rows can be handed off again.
876
+ const reportDedupeIndex = db
877
+ .query<{ sql: string | null }, []>(
878
+ `SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'reports_dedupe'`,
879
+ )
880
+ .get();
881
+ if (!(reportDedupeIndex?.sql ?? "").includes("state <> 'failed'")) {
882
+ db.exec(
883
+ `BEGIN IMMEDIATE;
884
+ DROP INDEX IF EXISTS reports_dedupe;
885
+ CREATE UNIQUE INDEX reports_dedupe
886
+ ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL AND state <> 'failed';
887
+ COMMIT;`,
888
+ );
889
+ }
774
890
  // Additive migrations are idempotent and preserve every existing row.
775
891
  // Historical rows predate per-run ceilings, so the package default is the
776
892
  // only truthful recoverable value; every new run persists its actual cap.
@@ -837,6 +953,24 @@ export function openStore(dbPath: string): Store {
837
953
  db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
838
954
  }
839
955
  }
956
+ // Existing held-notice rows predate exact digest ownership (#274). A NULL
957
+ // report id is truthful for already-digested history and means "still owed"
958
+ // only while digestedAt is also NULL.
959
+ const heldNoticeColumns = db.query<{ name: string }, []>("PRAGMA table_info(held_notices)").all();
960
+ if (!heldNoticeColumns.some((column) => column.name === "digestReportId")) {
961
+ db.exec("ALTER TABLE held_notices ADD COLUMN digestReportId TEXT");
962
+ }
963
+ // #273 distinguishes category-deferred notices from otherwise-interruptible
964
+ // notices held only because the operator was outside their working window.
965
+ // Historical rows were category-deferred, so false is the only safe default.
966
+ if (!heldNoticeColumns.some((column) => column.name === "releaseOnAvailable")) {
967
+ db.exec("ALTER TABLE held_notices ADD COLUMN releaseOnAvailable INTEGER NOT NULL DEFAULT 0");
968
+ }
969
+ // Urgent recovery notices bypass category batching only; they still wait for
970
+ // the configured availability window. Historical notices were not urgent.
971
+ if (!heldNoticeColumns.some((column) => column.name === "urgent")) {
972
+ db.exec("ALTER TABLE held_notices ADD COLUMN urgent INTEGER NOT NULL DEFAULT 0");
973
+ }
840
974
 
841
975
  // One-time repair, and the reason it lives here rather than in the classifier:
842
976
  // a turn-0 environment fault that was ALREADY classified `unknown` and
@@ -969,19 +1103,26 @@ export function openStore(dbPath: string): Store {
969
1103
  ORDER BY COALESCE(endedAt, startedAt) ASC, rowid ASC
970
1104
  LIMIT ?`,
971
1105
  );
972
- const selectLatestBaseChecks = db.query<RunRow & { rank: number }, [string, number]>(
973
- `SELECT * FROM (
974
- SELECT runs.*,
975
- ROW_NUMBER() OVER (
976
- PARTITION BY repo
977
- ORDER BY COALESCE(endedAt, startedAt) DESC, runs.rowid DESC
978
- ) AS rank
979
- FROM runs
980
- WHERE project = ? AND state = 'merged' AND baseCheck IS NOT NULL
981
- AND COALESCE(baseCheckAt, endedAt, startedAt) >= ?
982
- )
983
- WHERE rank = 1
984
- ORDER BY repo ASC`,
1106
+ const upsertBaseHealthRow = db.query<
1107
+ unknown,
1108
+ [string, string, string, string, string, number, SqlValue, number]
1109
+ >(
1110
+ `INSERT OR REPLACE INTO base_health
1111
+ (project, repo, branch, headSha, verdict, runsCount, detail, checkedAt)
1112
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1113
+ );
1114
+ const selectBaseHealth = db.query<BaseHealthRow, [string]>(
1115
+ `SELECT * FROM base_health WHERE project = ? ORDER BY repo ASC`,
1116
+ );
1117
+ const selectMergedRepoBranches = db.query<
1118
+ { repo: string; baseRef: string | null },
1119
+ [string, number]
1120
+ >(
1121
+ `SELECT repo, baseRef FROM runs
1122
+ WHERE project = ? AND state = 'merged'
1123
+ AND COALESCE(endedAt, startedAt) >= ?
1124
+ GROUP BY repo, baseRef
1125
+ ORDER BY repo ASC, baseRef ASC`,
985
1126
  );
986
1127
  const countAttempts = db.query<{ n: number }, [string, number]>(
987
1128
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
@@ -995,12 +1136,16 @@ export function openStore(dbPath: string): Store {
995
1136
  const countFailures = db.query<{ n: number }, [string, number]>(
996
1137
  `SELECT COUNT(*) AS n FROM runs
997
1138
  WHERE project = ? AND issue = ? AND state = 'failed'
998
- AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
1139
+ AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient', 'returned-for-revision'))`,
999
1140
  );
1000
1141
  const countContinuations = db.query<{ n: number }, [string, number]>(
1001
1142
  `SELECT COUNT(*) AS n FROM runs
1002
- WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
1003
- AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
1143
+ WHERE project = ? AND issue = ?
1144
+ AND (
1145
+ (state IN ('killed', 'orphaned', 'blocked')
1146
+ AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient')))
1147
+ OR (state = 'failed' AND failureClass = 'returned-for-revision')
1148
+ )`,
1004
1149
  );
1005
1150
  // How many times one issue reached a given class. Recovery uses it to bound a
1006
1151
  // retry loop whose cause is persistent (e.g. a mirror that will not refresh):
@@ -1196,7 +1341,9 @@ export function openStore(dbPath: string): Store {
1196
1341
  );
1197
1342
  const selectReport = db.query<ReportRow, [string]>(`SELECT * FROM reports WHERE id = ?`);
1198
1343
  const selectReportByDedupe = db.query<ReportRow, [string, string]>(
1199
- `SELECT * FROM reports WHERE project = ? AND dedupeKey = ?`,
1344
+ `SELECT * FROM reports
1345
+ WHERE project = ? AND dedupeKey = ? AND state <> 'failed'
1346
+ ORDER BY createdAt DESC, rowid DESC LIMIT 1`,
1200
1347
  );
1201
1348
  const selectDueReports = db.query<ReportRow, [string, number, number]>(
1202
1349
  `SELECT * FROM reports
@@ -1204,23 +1351,231 @@ export function openStore(dbPath: string): Store {
1204
1351
  ORDER BY createdAt ASC, rowid ASC
1205
1352
  LIMIT ?`,
1206
1353
  );
1354
+ const deletePendingMaterialReport = db.query<unknown, [string, string]>(
1355
+ `DELETE FROM reports
1356
+ WHERE id = ? AND project = ? AND kind = 'material' AND state = 'pending'`,
1357
+ );
1358
+ const deletePendingAvailabilityReport = db.query<unknown, [string, string]>(
1359
+ `DELETE FROM reports
1360
+ WHERE id = ? AND project = ? AND kind = 'digest' AND state = 'pending'
1361
+ AND dedupeKey LIKE 'availability/%'`,
1362
+ );
1363
+ const selectPendingAvailabilityReports = db.query<
1364
+ { id: string; ambiguous: number },
1365
+ [string]
1366
+ >(
1367
+ `SELECT id, ambiguous FROM reports
1368
+ WHERE project = ? AND kind = 'digest' AND state = 'pending'
1369
+ AND dedupeKey LIKE 'availability/%'
1370
+ ORDER BY createdAt ASC, rowid ASC`,
1371
+ );
1372
+ const releaseAvailabilityNotices = db.query<
1373
+ unknown,
1374
+ [number, string, string, string, string, string]
1375
+ >(
1376
+ `UPDATE held_notices
1377
+ SET digestReportId = NULL,
1378
+ digestedAt = NULL,
1379
+ summary = CASE
1380
+ WHEN ? = 1 AND summary NOT LIKE 'POSSIBLE REPEAT%'
1381
+ THEN 'POSSIBLE REPEAT of catch-up ' || ? || ': ' || summary
1382
+ ELSE summary
1383
+ END
1384
+ WHERE project = ? AND digestReportId = ? AND EXISTS (
1385
+ SELECT 1 FROM reports
1386
+ WHERE id = ? AND project = ? AND kind = 'digest' AND state = 'pending'
1387
+ AND dedupeKey LIKE 'availability/%'
1388
+ )`,
1389
+ );
1390
+ const selectAvailabilityReservationCandidate = db.query<{ id: string }, [string]>(
1391
+ `SELECT held_notices.id FROM held_notices
1392
+ WHERE held_notices.project = ? AND held_notices.releaseOnAvailable = 1 AND (
1393
+ (held_notices.digestReportId IS NULL AND held_notices.digestedAt IS NULL)
1394
+ OR EXISTS (
1395
+ SELECT 1 FROM reports
1396
+ WHERE reports.id = held_notices.digestReportId AND (
1397
+ reports.state = 'failed'
1398
+ OR (
1399
+ reports.state = 'pending' AND reports.kind = 'digest'
1400
+ AND reports.dedupeKey LIKE 'availability/%'
1401
+ )
1402
+ )
1403
+ )
1404
+ )
1405
+ ORDER BY held_notices.createdAt ASC, held_notices.rowid ASC
1406
+ LIMIT 1`,
1407
+ );
1408
+ const selectAvailabilityReservation = db.query<
1409
+ { expiresAt: number },
1410
+ [string, string, string]
1411
+ >(
1412
+ `SELECT expiresAt FROM availability_digest_reservations
1413
+ WHERE project = ? AND cycleKey = ? AND firstNoticeId = ?`,
1414
+ );
1415
+ const insertAvailabilityReservation = db.query<
1416
+ unknown,
1417
+ [string, string, string, number]
1418
+ >(
1419
+ `INSERT OR IGNORE INTO availability_digest_reservations
1420
+ (project, cycleKey, firstNoticeId, expiresAt)
1421
+ VALUES (?, ?, ?, ?)`,
1422
+ );
1423
+ const countActiveAvailabilityReservations = db.query<
1424
+ { count: number },
1425
+ [string, number]
1426
+ >(
1427
+ `SELECT COUNT(*) AS count FROM availability_digest_reservations
1428
+ WHERE project = ? AND expiresAt > ?`,
1429
+ );
1430
+ const deleteAvailabilityReservationForNotice = db.query<
1431
+ unknown,
1432
+ [string, string]
1433
+ >(
1434
+ `DELETE FROM availability_digest_reservations
1435
+ WHERE project = ? AND firstNoticeId = ?`,
1436
+ );
1207
1437
 
1208
- // Held notices (#229): escalations deferred to the digest by the project's
1209
- // `reporting.interruptOn` policy. `digestedAt` NULL = still owed.
1438
+ // Held notices (#229) and ordinary material events (#274) stay separate
1439
+ // sources. Both are associated with one accepted digest by exact row id.
1210
1440
  const insertHeldNotice = db.query<unknown, SqlValue[]>(
1211
- `INSERT INTO held_notices (id, project, category, summary, detail, createdAt, digestedAt)
1212
- VALUES (?, ?, ?, ?, ?, ?, NULL)`,
1441
+ `INSERT INTO held_notices
1442
+ (id, project, category, summary, detail, createdAt, releaseOnAvailable, urgent, digestedAt)
1443
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
1444
+ ON CONFLICT(id) DO NOTHING`,
1445
+ );
1446
+ const insertHeldNoticeDraft = (notice: HeldNoticeDraft): boolean =>
1447
+ insertHeldNotice.run(
1448
+ notice.id ?? crypto.randomUUID(),
1449
+ notice.project,
1450
+ notice.category,
1451
+ notice.summary,
1452
+ notice.detail,
1453
+ notice.createdAt,
1454
+ notice.releaseOnAvailable === true ? 1 : 0,
1455
+ notice.urgent === true ? 1 : 0,
1456
+ ).changes === 1;
1457
+ const deferPendingReportToNoticeTx = db.transaction(
1458
+ (id: string, notice: HeldNoticeDraft): boolean => {
1459
+ if (deletePendingMaterialReport.run(id, notice.project).changes !== 1) return false;
1460
+ if (!insertHeldNoticeDraft(notice)) {
1461
+ throw new Error(`held notice ${notice.id ?? "(generated)"} already exists`);
1462
+ }
1463
+ return true;
1464
+ },
1465
+ );
1466
+ const releasePendingAvailabilityReportRecord = (
1467
+ id: string,
1468
+ project: string,
1469
+ possibleRepeat = false,
1470
+ ): boolean => {
1471
+ if (
1472
+ releaseAvailabilityNotices.run(possibleRepeat ? 1 : 0, id, project, id, id, project)
1473
+ .changes === 0
1474
+ ) {
1475
+ return false;
1476
+ }
1477
+ if (deletePendingAvailabilityReport.run(id, project).changes !== 1) {
1478
+ throw new Error(`availability catch-up ${id} changed during release`);
1479
+ }
1480
+ return true;
1481
+ };
1482
+ const releasePendingAvailabilityReportTx = db.transaction(
1483
+ releasePendingAvailabilityReportRecord,
1484
+ );
1485
+ const reserveAvailabilityDigestTx = db.transaction(
1486
+ (project: string, cycleKey: string, at: number, expiresAt: number): boolean => {
1487
+ const candidate = selectAvailabilityReservationCandidate.get(project);
1488
+ if (candidate === null) return false;
1489
+ if (selectAvailabilityReservation.get(project, cycleKey, candidate.id) !== null) {
1490
+ return false;
1491
+ }
1492
+ if (
1493
+ insertAvailabilityReservation.run(project, cycleKey, candidate.id, expiresAt).changes ===
1494
+ 0
1495
+ ) {
1496
+ return false;
1497
+ }
1498
+ for (const row of selectPendingAvailabilityReports.all(project)) {
1499
+ releasePendingAvailabilityReportRecord(row.id, project, row.ambiguous !== 0);
1500
+ }
1501
+ return true;
1502
+ },
1213
1503
  );
1214
- const selectUndigestedNotices = db.query<
1215
- { id: string; category: string; summary: string; detail: string; createdAt: number },
1504
+ type HeldNoticeRow = {
1505
+ id: string;
1506
+ category: string;
1507
+ summary: string;
1508
+ detail: string;
1509
+ createdAt: number;
1510
+ releaseOnAvailable: number;
1511
+ urgent: number;
1512
+ };
1513
+ const undigestedNoticeWhere = `project = ? AND (
1514
+ (digestReportId IS NULL AND digestedAt IS NULL)
1515
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state = 'failed')
1516
+ )`;
1517
+ const selectUndigestedNotices = db.query<HeldNoticeRow, [string, number]>(
1518
+ `SELECT id, category, summary, detail, createdAt, releaseOnAvailable, urgent FROM held_notices
1519
+ WHERE ${undigestedNoticeWhere}
1520
+ ORDER BY createdAt ASC, rowid ASC
1521
+ LIMIT ?`,
1522
+ );
1523
+ const selectAvailabilityHeldNotices = db.query<HeldNoticeRow, [string, number]>(
1524
+ `SELECT id, category, summary, detail, createdAt, releaseOnAvailable, urgent FROM held_notices
1525
+ WHERE ${undigestedNoticeWhere} AND releaseOnAvailable = 1
1526
+ ORDER BY createdAt ASC, rowid ASC
1527
+ LIMIT ?`,
1528
+ );
1529
+ const selectHeldNoticeBacklog = db.query<
1530
+ { count: number; availabilityCount: number | null; oldestAt: number | null },
1216
1531
  [string]
1217
1532
  >(
1218
- `SELECT id, category, summary, detail, createdAt FROM held_notices
1219
- WHERE project = ? AND digestedAt IS NULL
1220
- ORDER BY createdAt ASC`,
1533
+ `SELECT COUNT(*) AS count,
1534
+ SUM(CASE WHEN releaseOnAvailable = 1 THEN 1 ELSE 0 END) AS availabilityCount,
1535
+ MIN(createdAt) AS oldestAt
1536
+ FROM held_notices
1537
+ WHERE project = ? AND (
1538
+ (digestReportId IS NULL AND digestedAt IS NULL)
1539
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state = 'failed')
1540
+ )`,
1541
+ );
1542
+ const assignHeldNoticeToDigest = db.query<unknown, [number, string, string, string]>(
1543
+ `UPDATE held_notices SET digestedAt = ?, digestReportId = ?
1544
+ WHERE project = ? AND id = ? AND (
1545
+ (digestReportId IS NULL AND digestedAt IS NULL)
1546
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state = 'failed')
1547
+ )`,
1548
+ );
1549
+ const insertMaterialEvent = db.query<unknown, SqlValue[]>(
1550
+ `INSERT INTO material_events
1551
+ (id, project, category, summary, evidence, occurredAt, recordedAt, digestReportId)
1552
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`,
1553
+ );
1554
+ const selectMaterialEvent = db.query<MaterialEventRow, [string]>(
1555
+ `SELECT * FROM material_events WHERE id = ?`,
1556
+ );
1557
+ const selectUndigestedMaterialEvents = db.query<MaterialEventRow, [string, number]>(
1558
+ `SELECT * FROM material_events
1559
+ WHERE project = ? AND (
1560
+ digestReportId IS NULL
1561
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
1562
+ )
1563
+ ORDER BY occurredAt ASC, recordedAt ASC, rowid ASC
1564
+ LIMIT ?`,
1221
1565
  );
1222
- const markNoticesDigestedRow = db.query<unknown, [number, string]>(
1223
- `UPDATE held_notices SET digestedAt = ? WHERE project = ? AND digestedAt IS NULL`,
1566
+ const selectMaterialEventBacklog = db.query<{ count: number; oldestAt: number | null }, [string]>(
1567
+ `SELECT COUNT(*) AS count, MIN(occurredAt) AS oldestAt FROM material_events
1568
+ WHERE project = ? AND (
1569
+ digestReportId IS NULL
1570
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
1571
+ )`,
1572
+ );
1573
+ const assignMaterialEventToDigest = db.query<unknown, [string, string, string]>(
1574
+ `UPDATE material_events SET digestReportId = ?
1575
+ WHERE project = ? AND id = ? AND (
1576
+ digestReportId IS NULL
1577
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
1578
+ )`,
1224
1579
  );
1225
1580
  // The newest digest dedupe key a project has actually run toward — an
1226
1581
  // `updatedAt`-newest row whose key is a digest prefix and that did not end in
@@ -1276,6 +1631,90 @@ export function openStore(dbPath: string): Store {
1276
1631
  ORDER BY createdAt ASC, rowid ASC`,
1277
1632
  );
1278
1633
 
1634
+ const enqueueReportRecord = (draft: ReportDraft): ReportEnqueue => {
1635
+ const report: ReportRecord = {
1636
+ id: newReportId(),
1637
+ project: draft.project,
1638
+ kind: draft.kind,
1639
+ body: draft.body,
1640
+ state: "pending",
1641
+ attempts: 0,
1642
+ ambiguous: false,
1643
+ ...(draft.dedupeKey === undefined ? {} : { dedupeKey: draft.dedupeKey }),
1644
+ createdAt: draft.at,
1645
+ updatedAt: draft.at,
1646
+ // Due immediately. No report has earned a backoff before its first send.
1647
+ nextAttemptAt: draft.at,
1648
+ };
1649
+ const inserted = insertReport.run(
1650
+ report.id,
1651
+ report.project,
1652
+ report.kind,
1653
+ report.body,
1654
+ report.state,
1655
+ report.attempts,
1656
+ null,
1657
+ 0,
1658
+ toSql(draft.dedupeKey),
1659
+ report.createdAt,
1660
+ report.updatedAt,
1661
+ report.nextAttemptAt,
1662
+ null,
1663
+ null,
1664
+ null,
1665
+ );
1666
+ if (inserted.changes > 0) return { report, deduped: false };
1667
+ // `INSERT OR IGNORE` swallowed a conflict. A digest-key conflict is the
1668
+ // expected path; a random 48-bit id collision has no matching key and fails
1669
+ // closed below instead of returning an unrelated report.
1670
+ const existing =
1671
+ draft.dedupeKey === undefined ? null : selectReportByDedupe.get(draft.project, draft.dedupeKey);
1672
+ if (existing === null) {
1673
+ throw new Error(`report ${report.id} could not be enqueued for ${draft.project}`);
1674
+ }
1675
+ return { report: toReport(existing), deduped: true };
1676
+ };
1677
+
1678
+ const enqueueDigestReportRecord = (
1679
+ draft: ReportDraft,
1680
+ materialEventIds: readonly string[],
1681
+ heldNoticeIds: readonly string[],
1682
+ ): ReportEnqueue => {
1683
+ if (draft.kind !== "digest") {
1684
+ throw new Error("digest handoff needs kind digest");
1685
+ }
1686
+ const enqueued = enqueueReportRecord(draft);
1687
+ if (enqueued.deduped) return enqueued;
1688
+ for (const id of new Set(materialEventIds)) {
1689
+ if (assignMaterialEventToDigest.run(enqueued.report.id, draft.project, id).changes !== 1) {
1690
+ throw new Error(`material event ${id} is not owed by project ${draft.project}`);
1691
+ }
1692
+ }
1693
+ for (const id of new Set(heldNoticeIds)) {
1694
+ if (assignHeldNoticeToDigest.run(draft.at, enqueued.report.id, draft.project, id).changes !== 1) {
1695
+ throw new Error(`held notice ${id} is not owed by project ${draft.project}`);
1696
+ }
1697
+ if (!draft.dedupeKey?.startsWith("availability/")) {
1698
+ deleteAvailabilityReservationForNotice.run(draft.project, id);
1699
+ }
1700
+ }
1701
+ return enqueued;
1702
+ };
1703
+ const enqueueDigestReportTx = db.transaction(enqueueDigestReportRecord);
1704
+ const enqueueAvailabilityReportTx = db.transaction(
1705
+ (draft: ReportDraft, heldNoticeIds: readonly string[]): ReportEnqueue | undefined => {
1706
+ if (draft.kind !== "digest" || !draft.dedupeKey?.startsWith("availability/")) {
1707
+ throw new Error("availability handoff needs an availability digest key");
1708
+ }
1709
+ if (
1710
+ (countActiveAvailabilityReservations.get(draft.project, draft.at)?.count ?? 0) > 0
1711
+ ) {
1712
+ return undefined;
1713
+ }
1714
+ return enqueueDigestReportRecord(draft, [], heldNoticeIds);
1715
+ },
1716
+ );
1717
+
1279
1718
  const insertDecision = db.query<never, [string, string, string, SqlValue, number, number, SqlValue, string]>(
1280
1719
  `INSERT INTO decisions
1281
1720
  (id, project, question, blocks, askedAt, expiresAt, condition, state)
@@ -1547,8 +1986,30 @@ export function openStore(dbPath: string): Store {
1547
1986
  return selectPendingBaseChecks.all(project, limit).map(toRecord);
1548
1987
  },
1549
1988
 
1550
- latestBaseChecks(project: string, sinceEpochMs: number): RunRecord[] {
1551
- return selectLatestBaseChecks.all(project, sinceEpochMs).map(toRecord);
1989
+ upsertBaseHealth(project: string, row: BaseHealth): void {
1990
+ upsertBaseHealthRow.run(
1991
+ project,
1992
+ row.repo,
1993
+ row.branch,
1994
+ row.headSha,
1995
+ row.verdict,
1996
+ row.runsCount,
1997
+ row.detail ?? null,
1998
+ row.checkedAt,
1999
+ );
2000
+ },
2001
+
2002
+ baseHealth(project: string): BaseHealth[] {
2003
+ return selectBaseHealth.all(project).map(toBaseHealth);
2004
+ },
2005
+
2006
+ mergedRepoBranches(
2007
+ project: string,
2008
+ sinceEpochMs: number,
2009
+ ): { repo: string; baseRef?: string }[] {
2010
+ return selectMergedRepoBranches.all(project, sinceEpochMs).map((row) =>
2011
+ row.baseRef === null ? { repo: row.repo } : { repo: row.repo, baseRef: row.baseRef },
2012
+ );
1552
2013
  },
1553
2014
 
1554
2015
 
@@ -1659,22 +2120,86 @@ export function openStore(dbPath: string): Store {
1659
2120
  },
1660
2121
 
1661
2122
  addHeldNotice(notice: HeldNoticeDraft): void {
1662
- insertHeldNotice.run(
1663
- crypto.randomUUID(),
1664
- notice.project,
1665
- notice.category,
1666
- notice.summary,
1667
- notice.detail,
1668
- notice.createdAt,
2123
+ insertHeldNoticeDraft(notice);
2124
+ },
2125
+
2126
+ undigestedNotices(
2127
+ project: string,
2128
+ limit = DIGEST_BACKLOG_LIMIT,
2129
+ availabilityOnly = false,
2130
+ categories?: readonly InterruptCategory[],
2131
+ urgent?: boolean,
2132
+ ): HeldNotice[] {
2133
+ if (categories?.length === 0) return [];
2134
+ const rows =
2135
+ availabilityOnly && categories !== undefined
2136
+ ? db
2137
+ .query<HeldNoticeRow, SqlValue[]>(
2138
+ `SELECT id, category, summary, detail, createdAt, releaseOnAvailable, urgent
2139
+ FROM held_notices
2140
+ WHERE ${undigestedNoticeWhere}
2141
+ AND releaseOnAvailable = 1
2142
+ AND category IN (${categories.map(() => "?").join(", ")})
2143
+ ${urgent === undefined ? "" : "AND urgent = ?"}
2144
+ ORDER BY createdAt ASC, rowid ASC
2145
+ LIMIT ?`,
2146
+ )
2147
+ .all(project, ...categories, ...(urgent === undefined ? [] : [urgent ? 1 : 0]), limit)
2148
+ : (availabilityOnly ? selectAvailabilityHeldNotices : selectUndigestedNotices).all(
2149
+ project,
2150
+ limit,
2151
+ );
2152
+ return rows.map((row) => ({
2153
+ id: row.id,
2154
+ category: row.category as HeldNotice["category"],
2155
+ summary: row.summary,
2156
+ detail: row.detail,
2157
+ createdAt: row.createdAt,
2158
+ ...(row.releaseOnAvailable === 1 ? { releaseOnAvailable: true as const } : {}),
2159
+ ...(row.urgent === 1 ? { urgent: true as const } : {}),
2160
+ }));
2161
+ },
2162
+
2163
+ recordMaterialEvent(event: MaterialEventDraft): MaterialEvent {
2164
+ const record: MaterialEvent = {
2165
+ id: newReportId(),
2166
+ ...event,
2167
+ };
2168
+ insertMaterialEvent.run(
2169
+ record.id,
2170
+ record.project,
2171
+ record.category,
2172
+ record.summary,
2173
+ record.evidence,
2174
+ record.occurredAt,
2175
+ record.recordedAt,
1669
2176
  );
2177
+ return record;
2178
+ },
2179
+
2180
+ getMaterialEvent(id: string): MaterialEvent | undefined {
2181
+ const row = selectMaterialEvent.get(id);
2182
+ return row === null ? undefined : toMaterialEvent(row);
1670
2183
  },
1671
2184
 
1672
- undigestedNotices(project: string): HeldNotice[] {
1673
- return selectUndigestedNotices.all(project).map((row) => ({ ...row }) as HeldNotice);
2185
+ undigestedMaterialEvents(project: string, limit = DIGEST_BACKLOG_LIMIT): MaterialEvent[] {
2186
+ return selectUndigestedMaterialEvents.all(project, limit).map(toMaterialEvent);
1674
2187
  },
1675
2188
 
1676
- markNoticesDigested(project: string, at: number): void {
1677
- markNoticesDigestedRow.run(at, project);
2189
+ digestBacklog(project: string): DigestBacklog {
2190
+ const material = selectMaterialEventBacklog.get(project);
2191
+ const held = selectHeldNoticeBacklog.get(project);
2192
+ return {
2193
+ materialCount: material?.count ?? 0,
2194
+ ...(material?.oldestAt === null || material?.oldestAt === undefined
2195
+ ? {}
2196
+ : { materialOldestAt: material.oldestAt }),
2197
+ heldNoticeCount: held?.count ?? 0,
2198
+ availabilityHeldNoticeCount: held?.availabilityCount ?? 0,
2199
+ ...(held?.oldestAt === null || held?.oldestAt === undefined
2200
+ ? {}
2201
+ : { heldNoticeOldestAt: held.oldestAt }),
2202
+ };
1678
2203
  },
1679
2204
 
1680
2205
  recordFriction,
@@ -1752,50 +2277,22 @@ export function openStore(dbPath: string): Store {
1752
2277
  },
1753
2278
 
1754
2279
  enqueueReport(draft: ReportDraft): ReportEnqueue {
1755
- const report: ReportRecord = {
1756
- id: newReportId(),
1757
- project: draft.project,
1758
- kind: draft.kind,
1759
- body: draft.body,
1760
- state: "pending",
1761
- attempts: 0,
1762
- ambiguous: false,
1763
- ...(draft.dedupeKey === undefined ? {} : { dedupeKey: draft.dedupeKey }),
1764
- createdAt: draft.at,
1765
- updatedAt: draft.at,
1766
- // Due immediately. A backoff nobody has earned yet would delay the
1767
- // first attempt at a report an operator is already waiting for.
1768
- nextAttemptAt: draft.at,
1769
- };
1770
- const inserted = insertReport.run(
1771
- report.id,
1772
- report.project,
1773
- report.kind,
1774
- report.body,
1775
- report.state,
1776
- report.attempts,
1777
- null,
1778
- 0,
1779
- toSql(draft.dedupeKey),
1780
- report.createdAt,
1781
- report.updatedAt,
1782
- report.nextAttemptAt,
1783
- null,
1784
- null,
1785
- null,
1786
- );
1787
- if (inserted.changes > 0) return { report, deduped: false };
1788
- // `INSERT OR IGNORE` swallowed a conflict, and the only conflict this
1789
- // table has is the digest guard — a 48-bit id collision is not a thing
1790
- // anyone needs to handle. Return the row that won rather than the one
1791
- // that lost, so whoever handed the digest over is told the truth about
1792
- // which report their operator will actually receive.
1793
- const existing =
1794
- draft.dedupeKey === undefined ? null : selectReportByDedupe.get(draft.project, draft.dedupeKey);
1795
- if (existing === null) {
1796
- throw new Error(`report ${report.id} could not be enqueued for ${draft.project}`);
1797
- }
1798
- return { report: toReport(existing), deduped: true };
2280
+ return enqueueReportRecord(draft);
2281
+ },
2282
+
2283
+ enqueueDigestReport(
2284
+ draft: ReportDraft,
2285
+ materialEventIds: readonly string[],
2286
+ heldNoticeIds: readonly string[],
2287
+ ): ReportEnqueue {
2288
+ return enqueueDigestReportTx(draft, materialEventIds, heldNoticeIds);
2289
+ },
2290
+
2291
+ enqueueAvailabilityReport(
2292
+ draft: ReportDraft,
2293
+ heldNoticeIds: readonly string[],
2294
+ ): ReportEnqueue | undefined {
2295
+ return enqueueAvailabilityReportTx.immediate(draft, heldNoticeIds);
1799
2296
  },
1800
2297
 
1801
2298
  getReport(id: string): ReportRecord | undefined {
@@ -1803,6 +2300,21 @@ export function openStore(dbPath: string): Store {
1803
2300
  return row === null ? undefined : toReport(row);
1804
2301
  },
1805
2302
 
2303
+ deferPendingReportToNotice(id: string, notice: HeldNoticeDraft): boolean {
2304
+ return deferPendingReportToNoticeTx(id, notice);
2305
+ },
2306
+ releasePendingAvailabilityReport(id: string, project: string, possibleRepeat = false): boolean {
2307
+ return releasePendingAvailabilityReportTx(id, project, possibleRepeat);
2308
+ },
2309
+ reserveAvailabilityDigest(
2310
+ project: string,
2311
+ cycleKey: string,
2312
+ at: number,
2313
+ expiresAt: number,
2314
+ ): boolean {
2315
+ return reserveAvailabilityDigestTx.immediate(project, cycleKey, at, expiresAt);
2316
+ },
2317
+
1806
2318
  dueReports(project: string, now: number, limit: number): ReportRecord[] {
1807
2319
  return selectDueReports.all(project, now, limit).map(toReport);
1808
2320
  },