omp-conductor 0.12.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,
@@ -41,6 +46,7 @@ import type {
41
46
  SessionRole,
42
47
  SettlementFlag,
43
48
  Store,
49
+ TurnOverride,
44
50
  VerbDecision,
45
51
  VerbLedgerDraft,
46
52
  VerbLedgerEntry,
@@ -73,6 +79,7 @@ const FRICTION_HOLD_REASONS: ReadonlySet<FrictionAdmissionReason> = new Set([
73
79
  "failed-attempts",
74
80
  "continuations",
75
81
  "parent-lookup-error",
82
+ "issue-state-lookup-error",
76
83
  "open-pr-lookup-error",
77
84
  "unroutable:no-repo-label",
78
85
  "unroutable:multiple-repo-labels",
@@ -107,6 +114,10 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
107
114
  sessionFile: true,
108
115
  prUrl: true,
109
116
  headSha: true,
117
+ mergeSha: true,
118
+ baseRef: true,
119
+ baseCheck: true,
120
+ baseCheckAt: true,
110
121
  salvageSha: true,
111
122
  salvageError: true,
112
123
  salvageAckAt: true,
@@ -139,6 +150,10 @@ interface RunRow {
139
150
  sessionFile: string | null;
140
151
  prUrl: string | null;
141
152
  headSha: string | null;
153
+ mergeSha: string | null;
154
+ baseRef: string | null;
155
+ baseCheck: string | null;
156
+ baseCheckAt: number | null;
142
157
  salvageSha: string | null;
143
158
  salvageError: string | null;
144
159
  salvageAckAt: number | null;
@@ -152,6 +167,31 @@ interface RunRow {
152
167
  recoveredAt: number | null;
153
168
  }
154
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
+
155
195
  interface FrictionRollupRow {
156
196
  project: string;
157
197
  day: string;
@@ -216,6 +256,18 @@ interface ReportRow {
216
256
  lastError: string | null;
217
257
  }
218
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
+
219
271
  /** The `verb_ledger` table exactly as SQLite hands it back (#126). */
220
272
  interface VerbLedgerRow {
221
273
  id: string;
@@ -256,6 +308,10 @@ CREATE TABLE IF NOT EXISTS runs (
256
308
  sessionFile TEXT,
257
309
  prUrl TEXT,
258
310
  headSha TEXT,
311
+ mergeSha TEXT,
312
+ baseRef TEXT,
313
+ baseCheck TEXT,
314
+ baseCheckAt INTEGER,
259
315
  salvageSha TEXT,
260
316
  salvageError TEXT,
261
317
  salvageAckAt INTEGER,
@@ -271,11 +327,45 @@ CREATE TABLE IF NOT EXISTS runs (
271
327
  CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
272
328
  CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
273
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
+
274
342
  CREATE TABLE IF NOT EXISTS notifications (
275
343
  "key" TEXT PRIMARY KEY,
276
344
  at INTEGER NOT NULL
277
345
  );
278
346
 
347
+ -- A one-shot operator-selected ceiling for an issue's next claimed attempt.
348
+ -- The run insert and consumption happen in one transaction.
349
+ CREATE TABLE IF NOT EXISTS turn_overrides (
350
+ project TEXT NOT NULL,
351
+ issue INTEGER NOT NULL CHECK (issue > 0),
352
+ maxTurns INTEGER NOT NULL CHECK (maxTurns > 0),
353
+ setAt INTEGER NOT NULL CHECK (setAt >= 0),
354
+ PRIMARY KEY (project, issue)
355
+ );
356
+
357
+ -- Append-only audit of every operator-selected ceiling. Consuming or replacing
358
+ -- the pending value never erases what was requested.
359
+ CREATE TABLE IF NOT EXISTS turn_override_ledger (
360
+ id TEXT PRIMARY KEY,
361
+ project TEXT NOT NULL,
362
+ issue INTEGER NOT NULL CHECK (issue > 0),
363
+ maxTurns INTEGER NOT NULL CHECK (maxTurns > 0),
364
+ setAt INTEGER NOT NULL CHECK (setAt >= 0)
365
+ );
366
+ CREATE INDEX IF NOT EXISTS turn_override_ledger_project_at
367
+ ON turn_override_ledger (project, setAt);
368
+
279
369
  CREATE TABLE IF NOT EXISTS dispatch_summaries (
280
370
  project TEXT PRIMARY KEY,
281
371
  summary TEXT NOT NULL
@@ -308,10 +398,10 @@ CREATE TABLE IF NOT EXISTS friction_surfaces (
308
398
  -- on the same row. This one is keyed by report id and carries delivery state,
309
399
  -- the attempt in flight, and the message id Telegram actually returned.
310
400
  --
311
- -- The partial unique index is the digest guard: a digest:<day> key can exist at
312
- -- most once per project, so "has today's digest been handed over?" is a question
313
- -- the ledger answers rather than the model's memory of the last tick. Material
314
- -- 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.
315
405
  CREATE TABLE IF NOT EXISTS reports (
316
406
  id TEXT PRIMARY KEY,
317
407
  project TEXT NOT NULL,
@@ -330,10 +420,26 @@ CREATE TABLE IF NOT EXISTS reports (
330
420
  lastError TEXT
331
421
  );
332
422
  CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
333
- ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL;
423
+ ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL AND state <> 'failed';
334
424
  CREATE INDEX IF NOT EXISTS reports_project_state
335
425
  ON reports (project, state, nextAttemptAt);
336
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
+
337
443
  -- Interrupts a project's reporting.interruptOn policy deferred to the digest
338
444
  -- (#229): an escalation that must not page the operator's phone now is held
339
445
  -- here so the daily rollup can surface it. digestedAt set -> re-surfaced by a
@@ -341,17 +447,33 @@ CREATE INDEX IF NOT EXISTS reports_project_state
341
447
  -- are escalations that never reached a send, distinct from reports the model
342
448
  -- wrote on purpose.
343
449
  CREATE TABLE IF NOT EXISTS held_notices (
344
- id TEXT PRIMARY KEY,
345
- project TEXT NOT NULL,
346
- category TEXT NOT NULL,
347
- summary TEXT NOT NULL,
348
- detail TEXT NOT NULL,
349
- createdAt INTEGER NOT NULL,
350
- 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)
351
460
  );
352
461
  CREATE INDEX IF NOT EXISTS held_notices_project_undigested
353
462
  ON held_notices (project, digestedAt) WHERE digestedAt IS NULL;
354
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
+
355
477
  -- The action ledger for the conductor-owned mutation verbs (#126). Every
356
478
  -- decided call lands here, refusals included: the question an escalation asks
357
479
  -- is "what did this run try", and a table that only records what succeeded
@@ -519,6 +641,10 @@ function toRecord(row: RunRow): RunRecord {
519
641
  if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
520
642
  if (row.prUrl !== null) record.prUrl = row.prUrl;
521
643
  if (row.headSha !== null) record.headSha = row.headSha;
644
+ if (row.mergeSha !== null) record.mergeSha = row.mergeSha;
645
+ if (row.baseRef !== null) record.baseRef = row.baseRef;
646
+ if (row.baseCheck !== null) record.baseCheck = row.baseCheck as RunRecord["baseCheck"];
647
+ if (row.baseCheckAt !== null) record.baseCheckAt = row.baseCheckAt;
522
648
  if (row.salvageSha !== null) record.salvageSha = row.salvageSha;
523
649
  if (row.salvageError !== null) record.salvageError = row.salvageError;
524
650
  if (row.salvageAckAt !== null) record.salvageAckAt = row.salvageAckAt;
@@ -560,6 +686,19 @@ function toReport(row: ReportRow): ReportRecord {
560
686
  return record;
561
687
  }
562
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
+
563
702
  interface DecisionRow {
564
703
  id: string;
565
704
  project: string;
@@ -731,6 +870,23 @@ export function openStore(dbPath: string): Store {
731
870
  db.exec("PRAGMA foreign_keys = ON;");
732
871
  db.exec("PRAGMA busy_timeout = 5000;");
733
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
+ }
734
890
  // Additive migrations are idempotent and preserve every existing row.
735
891
  // Historical rows predate per-run ceilings, so the package default is the
736
892
  // only truthful recoverable value; every new run persists its actual cap.
@@ -744,6 +900,18 @@ export function openStore(dbPath: string): Store {
744
900
  if (!columns.some((column) => column.name === "headSha")) {
745
901
  db.exec("ALTER TABLE runs ADD COLUMN headSha TEXT");
746
902
  }
903
+ // Historical merged rows were never watched after settlement, so NULL is
904
+ // "not observed", never permission to claim their base branch was green.
905
+ for (const [name, type] of [
906
+ ["mergeSha", "TEXT"],
907
+ ["baseRef", "TEXT"],
908
+ ["baseCheck", "TEXT"],
909
+ ["baseCheckAt", "INTEGER"],
910
+ ] as const) {
911
+ if (!columns.some((column) => column.name === name)) {
912
+ db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
913
+ }
914
+ }
747
915
  // v0.3.22 and earlier removed a blocked run's dirty tree without saving it
748
916
  // (#118), so no row before this release has anywhere to record where the
749
917
  // work went. A NULL `salvageSha` on a historical row therefore means "never
@@ -785,6 +953,24 @@ export function openStore(dbPath: string): Store {
785
953
  db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
786
954
  }
787
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
+ }
788
974
 
789
975
  // One-time repair, and the reason it lives here rather than in the classifier:
790
976
  // a turn-0 environment fault that was ALREADY classified `unknown` and
@@ -878,9 +1064,10 @@ export function openStore(dbPath: string): Store {
878
1064
  const insertRun = db.query<unknown, SqlValue[]>(
879
1065
  `INSERT INTO runs (
880
1066
  id, project, issue, repo, branch, worktree, state, attempt, turns,
881
- maxTurns, spendUsd, sessionFile, prUrl, headSha, salvageSha, salvageError,
882
- salvageAckAt, startedAt, endedAt, lastError, settlementFlags, report
883
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1067
+ maxTurns, spendUsd, sessionFile, prUrl, headSha, mergeSha, baseRef,
1068
+ baseCheck, baseCheckAt, salvageSha, salvageError, salvageAckAt, startedAt,
1069
+ endedAt, lastError, settlementFlags, report
1070
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
884
1071
  );
885
1072
  const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
886
1073
  const selectActive = db.query<RunRow, SqlValue[]>(
@@ -910,6 +1097,33 @@ export function openStore(dbPath: string): Store {
910
1097
  AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
911
1098
  ORDER BY startedAt DESC`,
912
1099
  );
1100
+ const selectPendingBaseChecks = db.query<RunRow, [string, number]>(
1101
+ `SELECT * FROM runs
1102
+ WHERE project = ? AND state = 'merged' AND baseCheck = 'pending'
1103
+ ORDER BY COALESCE(endedAt, startedAt) ASC, rowid ASC
1104
+ LIMIT ?`,
1105
+ );
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`,
1126
+ );
913
1127
  const countAttempts = db.query<{ n: number }, [string, number]>(
914
1128
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
915
1129
  );
@@ -922,12 +1136,16 @@ export function openStore(dbPath: string): Store {
922
1136
  const countFailures = db.query<{ n: number }, [string, number]>(
923
1137
  `SELECT COUNT(*) AS n FROM runs
924
1138
  WHERE project = ? AND issue = ? AND state = 'failed'
925
- 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'))`,
926
1140
  );
927
1141
  const countContinuations = db.query<{ n: number }, [string, number]>(
928
1142
  `SELECT COUNT(*) AS n FROM runs
929
- WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
930
- 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
+ )`,
931
1149
  );
932
1150
  // How many times one issue reached a given class. Recovery uses it to bound a
933
1151
  // retry loop whose cause is persistent (e.g. a mirror that will not refresh):
@@ -993,6 +1211,38 @@ export function openStore(dbPath: string): Store {
993
1211
  ORDER BY startedAt DESC, rowid DESC
994
1212
  LIMIT 1`,
995
1213
  );
1214
+ const upsertTurnOverride = db.query<unknown, [string, number, number, number]>(
1215
+ `INSERT INTO turn_overrides (project, issue, maxTurns, setAt) VALUES (?, ?, ?, ?)
1216
+ ON CONFLICT(project, issue) DO UPDATE SET
1217
+ maxTurns = excluded.maxTurns,
1218
+ setAt = excluded.setAt`,
1219
+ );
1220
+ const insertTurnOverrideLedger = db.query<
1221
+ unknown,
1222
+ [string, string, number, number, number]
1223
+ >(
1224
+ `INSERT INTO turn_override_ledger (id, project, issue, maxTurns, setAt)
1225
+ VALUES (?, ?, ?, ?, ?)`,
1226
+ );
1227
+ const selectTurnOverride = db.query<{ maxTurns: number }, [string, number]>(
1228
+ `SELECT maxTurns FROM turn_overrides WHERE project = ? AND issue = ?`,
1229
+ );
1230
+ const deleteTurnOverride = db.query<unknown, [string, number]>(
1231
+ `DELETE FROM turn_overrides WHERE project = ? AND issue = ?`,
1232
+ );
1233
+ const selectTurnOverrides = db.query<TurnOverride, [string]>(
1234
+ `SELECT project, issue, maxTurns, setAt FROM turn_overrides
1235
+ WHERE project = ? ORDER BY issue ASC`,
1236
+ );
1237
+ const selectTurnOverrideLedger = db.query<
1238
+ TurnOverride,
1239
+ [string, number, number, number]
1240
+ >(
1241
+ `SELECT project, issue, maxTurns, setAt FROM turn_override_ledger
1242
+ WHERE project = ? AND (? = 0 OR issue = ?)
1243
+ ORDER BY setAt DESC, rowid DESC
1244
+ LIMIT ?`,
1245
+ );
996
1246
  // Every attempt that settled with a report, in attempt order. NULLs are the
997
1247
  // rows written before #199, or one killed before the worker returned a
998
1248
  // report — skipping them makes a continuation read exactly today's priors.
@@ -1091,7 +1341,9 @@ export function openStore(dbPath: string): Store {
1091
1341
  );
1092
1342
  const selectReport = db.query<ReportRow, [string]>(`SELECT * FROM reports WHERE id = ?`);
1093
1343
  const selectReportByDedupe = db.query<ReportRow, [string, string]>(
1094
- `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`,
1095
1347
  );
1096
1348
  const selectDueReports = db.query<ReportRow, [string, number, number]>(
1097
1349
  `SELECT * FROM reports
@@ -1099,23 +1351,231 @@ export function openStore(dbPath: string): Store {
1099
1351
  ORDER BY createdAt ASC, rowid ASC
1100
1352
  LIMIT ?`,
1101
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
+ );
1102
1437
 
1103
- // Held notices (#229): escalations deferred to the digest by the project's
1104
- // `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.
1105
1440
  const insertHeldNotice = db.query<unknown, SqlValue[]>(
1106
- `INSERT INTO held_notices (id, project, category, summary, detail, createdAt, digestedAt)
1107
- 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
+ },
1503
+ );
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 ?`,
1108
1522
  );
1109
- const selectUndigestedNotices = db.query<
1110
- { id: string; category: string; summary: string; detail: string; createdAt: number },
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 },
1111
1531
  [string]
1112
1532
  >(
1113
- `SELECT id, category, summary, detail, createdAt FROM held_notices
1114
- WHERE project = ? AND digestedAt IS NULL
1115
- 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 ?`,
1565
+ );
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
+ )`,
1116
1572
  );
1117
- const markNoticesDigestedRow = db.query<unknown, [number, string]>(
1118
- `UPDATE held_notices SET digestedAt = ? WHERE project = ? AND digestedAt IS NULL`,
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
+ )`,
1119
1579
  );
1120
1580
  // The newest digest dedupe key a project has actually run toward — an
1121
1581
  // `updatedAt`-newest row whose key is a digest prefix and that did not end in
@@ -1171,6 +1631,90 @@ export function openStore(dbPath: string): Store {
1171
1631
  ORDER BY createdAt ASC, rowid ASC`,
1172
1632
  );
1173
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
+
1174
1718
  const insertDecision = db.query<never, [string, string, string, SqlValue, number, number, SqlValue, string]>(
1175
1719
  `INSERT INTO decisions
1176
1720
  (id, project, question, blocks, askedAt, expiresAt, condition, state)
@@ -1347,34 +1891,58 @@ export function openStore(dbPath: string): Store {
1347
1891
  recordGhRefusalTx(at);
1348
1892
  };
1349
1893
 
1894
+ const insertRunRecord = (r: Omit<RunRecord, "id">): RunRecord => {
1895
+ const record: RunRecord = { ...r, id: crypto.randomUUID() };
1896
+ insertRun.run(
1897
+ record.id,
1898
+ record.project,
1899
+ record.issue,
1900
+ record.repo,
1901
+ record.branch,
1902
+ record.worktree,
1903
+ record.state,
1904
+ record.attempt,
1905
+ record.turns,
1906
+ record.maxTurns,
1907
+ record.spendUsd,
1908
+ toSql(record.sessionFile),
1909
+ toSql(record.prUrl),
1910
+ toSql(record.headSha),
1911
+ toSql(record.mergeSha),
1912
+ toSql(record.baseRef),
1913
+ toSql(record.baseCheck),
1914
+ toSql(record.baseCheckAt),
1915
+ toSql(record.salvageSha),
1916
+ toSql(record.salvageError),
1917
+ toSql(record.salvageAckAt),
1918
+ record.startedAt,
1919
+ toSql(record.endedAt),
1920
+ toSql(record.lastError),
1921
+ toSql(record.settlementFlags),
1922
+ toSql(record.report),
1923
+ );
1924
+ return record;
1925
+ };
1926
+ const createRunTx = db.transaction((r: Omit<RunRecord, "id">): RunRecord => {
1927
+ if (r.state !== "claimed") return insertRunRecord(r);
1928
+ const turnOverride = selectTurnOverride.get(r.project, r.issue)?.maxTurns;
1929
+ const record = insertRunRecord({
1930
+ ...r,
1931
+ maxTurns: Math.max(r.maxTurns, turnOverride ?? r.maxTurns),
1932
+ });
1933
+ if (turnOverride !== undefined) deleteTurnOverride.run(r.project, r.issue);
1934
+ return record;
1935
+ });
1936
+ const setTurnOverrideTx = db.transaction(
1937
+ (project: string, issue: number, maxTurns: number, setAt: number): void => {
1938
+ upsertTurnOverride.run(project, issue, maxTurns, setAt);
1939
+ insertTurnOverrideLedger.run(crypto.randomUUID(), project, issue, maxTurns, setAt);
1940
+ },
1941
+ );
1942
+
1350
1943
  return {
1351
1944
  createRun(r: Omit<RunRecord, "id">): RunRecord {
1352
- const record: RunRecord = { ...r, id: crypto.randomUUID() };
1353
- insertRun.run(
1354
- record.id,
1355
- record.project,
1356
- record.issue,
1357
- record.repo,
1358
- record.branch,
1359
- record.worktree,
1360
- record.state,
1361
- record.attempt,
1362
- record.turns,
1363
- record.maxTurns,
1364
- record.spendUsd,
1365
- toSql(record.sessionFile),
1366
- toSql(record.prUrl),
1367
- toSql(record.headSha),
1368
- toSql(record.salvageSha),
1369
- toSql(record.salvageError),
1370
- toSql(record.salvageAckAt),
1371
- record.startedAt,
1372
- toSql(record.endedAt),
1373
- toSql(record.lastError),
1374
- toSql(record.settlementFlags),
1375
- toSql(record.report),
1376
- );
1377
- return record;
1945
+ return createRunTx(r);
1378
1946
  },
1379
1947
 
1380
1948
  updateRun(id: string, patch: Partial<RunRecord>): void {
@@ -1414,6 +1982,36 @@ export function openStore(dbPath: string): Store {
1414
1982
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[] {
1415
1983
  return selectRecentRuns.all(project, mergedSinceEpochMs).map(toRecord);
1416
1984
  },
1985
+ runsNeedingBaseCheck(project: string, limit = 20): RunRecord[] {
1986
+ return selectPendingBaseChecks.all(project, limit).map(toRecord);
1987
+ },
1988
+
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
+ );
2013
+ },
2014
+
1417
2015
 
1418
2016
  salvagedRuns(project: string): RunRecord[] {
1419
2017
  return selectSalvaged.all(project).map(toRecord);
@@ -1440,6 +2038,30 @@ export function openStore(dbPath: string): Store {
1440
2038
  return row ? toRecord(row) : undefined;
1441
2039
  },
1442
2040
 
2041
+ setTurnOverride(project: string, issue: number, maxTurns: number, setAt = Date.now()): void {
2042
+ setTurnOverrideTx(project, issue, maxTurns, setAt);
2043
+ },
2044
+
2045
+ turnOverride(project: string, issue: number): number | undefined {
2046
+ return selectTurnOverride.get(project, issue)?.maxTurns;
2047
+ },
2048
+
2049
+ clearTurnOverride(project: string, issue: number): void {
2050
+ deleteTurnOverride.run(project, issue);
2051
+ },
2052
+
2053
+ listTurnOverrides(project: string): TurnOverride[] {
2054
+ return selectTurnOverrides.all(project);
2055
+ },
2056
+
2057
+ turnOverrideLedger(
2058
+ project: string,
2059
+ opts: { issue?: number; limit?: number } = {},
2060
+ ): TurnOverride[] {
2061
+ const issue = opts.issue ?? 0;
2062
+ return selectTurnOverrideLedger.all(project, issue, issue, opts.limit ?? 50);
2063
+ },
2064
+
1443
2065
  attemptReports(project: string, issue: number): { attempt: number; report: string }[] {
1444
2066
  return selectAttemptReports.all(project, issue);
1445
2067
  },
@@ -1498,22 +2120,86 @@ export function openStore(dbPath: string): Store {
1498
2120
  },
1499
2121
 
1500
2122
  addHeldNotice(notice: HeldNoticeDraft): void {
1501
- insertHeldNotice.run(
1502
- crypto.randomUUID(),
1503
- notice.project,
1504
- notice.category,
1505
- notice.summary,
1506
- notice.detail,
1507
- 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,
1508
2176
  );
2177
+ return record;
1509
2178
  },
1510
2179
 
1511
- undigestedNotices(project: string): HeldNotice[] {
1512
- return selectUndigestedNotices.all(project).map((row) => ({ ...row }) as HeldNotice);
2180
+ getMaterialEvent(id: string): MaterialEvent | undefined {
2181
+ const row = selectMaterialEvent.get(id);
2182
+ return row === null ? undefined : toMaterialEvent(row);
1513
2183
  },
1514
2184
 
1515
- markNoticesDigested(project: string, at: number): void {
1516
- markNoticesDigestedRow.run(at, project);
2185
+ undigestedMaterialEvents(project: string, limit = DIGEST_BACKLOG_LIMIT): MaterialEvent[] {
2186
+ return selectUndigestedMaterialEvents.all(project, limit).map(toMaterialEvent);
2187
+ },
2188
+
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
+ };
1517
2203
  },
1518
2204
 
1519
2205
  recordFriction,
@@ -1591,50 +2277,22 @@ export function openStore(dbPath: string): Store {
1591
2277
  },
1592
2278
 
1593
2279
  enqueueReport(draft: ReportDraft): ReportEnqueue {
1594
- const report: ReportRecord = {
1595
- id: newReportId(),
1596
- project: draft.project,
1597
- kind: draft.kind,
1598
- body: draft.body,
1599
- state: "pending",
1600
- attempts: 0,
1601
- ambiguous: false,
1602
- ...(draft.dedupeKey === undefined ? {} : { dedupeKey: draft.dedupeKey }),
1603
- createdAt: draft.at,
1604
- updatedAt: draft.at,
1605
- // Due immediately. A backoff nobody has earned yet would delay the
1606
- // first attempt at a report an operator is already waiting for.
1607
- nextAttemptAt: draft.at,
1608
- };
1609
- const inserted = insertReport.run(
1610
- report.id,
1611
- report.project,
1612
- report.kind,
1613
- report.body,
1614
- report.state,
1615
- report.attempts,
1616
- null,
1617
- 0,
1618
- toSql(draft.dedupeKey),
1619
- report.createdAt,
1620
- report.updatedAt,
1621
- report.nextAttemptAt,
1622
- null,
1623
- null,
1624
- null,
1625
- );
1626
- if (inserted.changes > 0) return { report, deduped: false };
1627
- // `INSERT OR IGNORE` swallowed a conflict, and the only conflict this
1628
- // table has is the digest guard — a 48-bit id collision is not a thing
1629
- // anyone needs to handle. Return the row that won rather than the one
1630
- // that lost, so whoever handed the digest over is told the truth about
1631
- // which report their operator will actually receive.
1632
- const existing =
1633
- draft.dedupeKey === undefined ? null : selectReportByDedupe.get(draft.project, draft.dedupeKey);
1634
- if (existing === null) {
1635
- throw new Error(`report ${report.id} could not be enqueued for ${draft.project}`);
1636
- }
1637
- 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);
1638
2296
  },
1639
2297
 
1640
2298
  getReport(id: string): ReportRecord | undefined {
@@ -1642,6 +2300,21 @@ export function openStore(dbPath: string): Store {
1642
2300
  return row === null ? undefined : toReport(row);
1643
2301
  },
1644
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
+
1645
2318
  dueReports(project: string, now: number, limit: number): ReportRecord[] {
1646
2319
  return selectDueReports.all(project, now, limit).map(toReport);
1647
2320
  },