omp-conductor 0.3.24 → 0.4.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
@@ -21,9 +21,22 @@ import type {
21
21
  FrictionKind,
22
22
  FrictionObservation,
23
23
  FrictionSignal,
24
+ MergeLock,
25
+ ReportDeliveryState,
26
+ ReportDraft,
27
+ ReportEnqueue,
28
+ ReportKind,
29
+ ReportRecord,
24
30
  RunRecord,
25
31
  RunState,
32
+ SessionRole,
33
+ SettlementFlag,
26
34
  Store,
35
+ VerbDecision,
36
+ VerbLedgerDraft,
37
+ VerbLedgerEntry,
38
+ VerbName,
39
+ VerbRefusal,
27
40
  } from "./types.ts";
28
41
 
29
42
  /**
@@ -85,9 +98,13 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
85
98
  sessionFile: true,
86
99
  prUrl: true,
87
100
  headSha: true,
101
+ salvageSha: true,
102
+ salvageError: true,
103
+ salvageAckAt: true,
88
104
  startedAt: true,
89
105
  endedAt: true,
90
106
  lastError: true,
107
+ settlementFlags: true,
91
108
  };
92
109
 
93
110
  /** Everything SQLite will accept from us. */
@@ -109,9 +126,13 @@ interface RunRow {
109
126
  sessionFile: string | null;
110
127
  prUrl: string | null;
111
128
  headSha: string | null;
129
+ salvageSha: string | null;
130
+ salvageError: string | null;
131
+ salvageAckAt: number | null;
112
132
  startedAt: number;
113
133
  endedAt: number | null;
114
134
  lastError: string | null;
135
+ settlementFlags: string | null;
115
136
  }
116
137
 
117
138
  interface FrictionRollupRow {
@@ -130,6 +151,49 @@ interface FrictionSurfaceRow {
130
151
  at: number;
131
152
  }
132
153
 
154
+ /** The `reports` table exactly as SQLite hands it back. */
155
+ interface ReportRow {
156
+ id: string;
157
+ project: string;
158
+ kind: string;
159
+ body: string;
160
+ state: string;
161
+ attempts: number;
162
+ attemptId: string | null;
163
+ ambiguous: number;
164
+ dedupeKey: string | null;
165
+ createdAt: number;
166
+ updatedAt: number;
167
+ nextAttemptAt: number;
168
+ messageId: number | null;
169
+ deliveredAt: number | null;
170
+ lastError: string | null;
171
+ }
172
+
173
+ /** The `verb_ledger` table exactly as SQLite hands it back (#126). */
174
+ interface VerbLedgerRow {
175
+ id: string;
176
+ project: string;
177
+ runId: string | null;
178
+ issue: number | null;
179
+ verb: string;
180
+ role: string;
181
+ args: string;
182
+ decision: string;
183
+ refusal: string | null;
184
+ detail: string;
185
+ sha: string | null;
186
+ at: number;
187
+ }
188
+
189
+ /** The `merge_locks` table exactly as SQLite hands it back (#126). */
190
+ interface MergeLockRow {
191
+ project: string;
192
+ holder: string;
193
+ prUrl: string;
194
+ at: number;
195
+ }
196
+
133
197
  const SCHEMA = `
134
198
  CREATE TABLE IF NOT EXISTS runs (
135
199
  id TEXT PRIMARY KEY,
@@ -146,9 +210,13 @@ CREATE TABLE IF NOT EXISTS runs (
146
210
  sessionFile TEXT,
147
211
  prUrl TEXT,
148
212
  headSha TEXT,
213
+ salvageSha TEXT,
214
+ salvageError TEXT,
215
+ salvageAckAt INTEGER,
149
216
  startedAt INTEGER NOT NULL,
150
217
  endedAt INTEGER,
151
- lastError TEXT
218
+ lastError TEXT,
219
+ settlementFlags TEXT
152
220
  );
153
221
  CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
154
222
  CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
@@ -183,6 +251,72 @@ CREATE TABLE IF NOT EXISTS friction_surfaces (
183
251
  at INTEGER NOT NULL,
184
252
  PRIMARY KEY (project, kind)
185
253
  );
254
+
255
+ -- The report outbox (#123). Deliberately NOT an extension of notifications:
256
+ -- that table's primary key is its dedupe key, so it cannot hold two attempts of
257
+ -- one report, and widening it would put escalation dedupe and report delivery
258
+ -- on the same row. This one is keyed by report id and carries delivery state,
259
+ -- the attempt in flight, and the message id Telegram actually returned.
260
+ --
261
+ -- The partial unique index is the digest guard: a digest:<day> key can exist at
262
+ -- most once per project, so "has today's digest been handed over?" is a question
263
+ -- the ledger answers rather than the model's memory of the last tick. Material
264
+ -- reports carry no key and are never suppressed.
265
+ CREATE TABLE IF NOT EXISTS reports (
266
+ id TEXT PRIMARY KEY,
267
+ project TEXT NOT NULL,
268
+ kind TEXT NOT NULL,
269
+ body TEXT NOT NULL,
270
+ state TEXT NOT NULL,
271
+ attempts INTEGER NOT NULL,
272
+ attemptId TEXT,
273
+ ambiguous INTEGER NOT NULL,
274
+ dedupeKey TEXT,
275
+ createdAt INTEGER NOT NULL,
276
+ updatedAt INTEGER NOT NULL,
277
+ nextAttemptAt INTEGER NOT NULL,
278
+ messageId INTEGER,
279
+ deliveredAt INTEGER,
280
+ lastError TEXT
281
+ );
282
+ CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
283
+ ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL;
284
+ CREATE INDEX IF NOT EXISTS reports_project_state
285
+ ON reports (project, state, nextAttemptAt);
286
+
287
+ -- The action ledger for the conductor-owned mutation verbs (#126). Every
288
+ -- decided call lands here, refusals included: the question an escalation asks
289
+ -- is "what did this run try", and a table that only records what succeeded
290
+ -- cannot answer it. The 'args' column is the payload verbatim, so the row is
291
+ -- evidence rather than a summary of evidence.
292
+ CREATE TABLE IF NOT EXISTS verb_ledger (
293
+ id TEXT PRIMARY KEY,
294
+ project TEXT NOT NULL,
295
+ runId TEXT,
296
+ issue INTEGER,
297
+ verb TEXT NOT NULL,
298
+ role TEXT NOT NULL,
299
+ args TEXT NOT NULL,
300
+ decision TEXT NOT NULL,
301
+ refusal TEXT,
302
+ detail TEXT NOT NULL,
303
+ sha TEXT,
304
+ at INTEGER NOT NULL
305
+ );
306
+ CREATE INDEX IF NOT EXISTS verb_ledger_project_at ON verb_ledger (project, at);
307
+ CREATE INDEX IF NOT EXISTS verb_ledger_run ON verb_ledger (runId, at);
308
+
309
+ -- One merge in flight per project (#126). A table rather than a mutex because
310
+ -- the CLI, the daemon and every session are separate processes: an in-process
311
+ -- lock serialises one process against itself and nothing else, which is the
312
+ -- shape "one merge in flight" had on main -- none at all. The project is the
313
+ -- primary key, so the claim is the row's existence and the race is SQLite's.
314
+ CREATE TABLE IF NOT EXISTS merge_locks (
315
+ project TEXT PRIMARY KEY,
316
+ holder TEXT NOT NULL,
317
+ prUrl TEXT NOT NULL,
318
+ at INTEGER NOT NULL
319
+ );
186
320
  `;
187
321
 
188
322
  /**
@@ -192,9 +326,41 @@ CREATE TABLE IF NOT EXISTS friction_surfaces (
192
326
  function toSql(value: unknown): SqlValue {
193
327
  if (value === undefined || value === null) return null;
194
328
  if (typeof value === "boolean") return value ? 1 : 0;
329
+ // `RunRecord.settlementFlags` is the only structured field on a run row, and
330
+ // it is JSON in one TEXT column rather than a table of its own: the flags are
331
+ // 1:1 with the run, written once at settlement, and never read except beside
332
+ // the row they belong to. A join per `status` render would buy a query nobody
333
+ // makes (#128).
334
+ if (Array.isArray(value)) return JSON.stringify(value);
195
335
  return value as SqlValue;
196
336
  }
197
337
 
338
+ /**
339
+ * Settlement flags out of their column, or undefined for anything that is not
340
+ * an array of flag-shaped objects.
341
+ *
342
+ * Same posture as {@link toDispatchSummary}: this is advisory evidence, so a
343
+ * row hand-edited or written by a future version drops its flags rather than
344
+ * throwing and taking `status` down with it.
345
+ */
346
+ function toSettlementFlags(text: string): SettlementFlag[] | undefined {
347
+ try {
348
+ const value: unknown = JSON.parse(text);
349
+ if (!Array.isArray(value) || value.length === 0) return undefined;
350
+ const flags = value.filter(
351
+ (flag): flag is SettlementFlag =>
352
+ typeof flag === "object" &&
353
+ flag !== null &&
354
+ typeof (flag as SettlementFlag).kind === "string" &&
355
+ typeof (flag as SettlementFlag).file === "string" &&
356
+ typeof (flag as SettlementFlag).detail === "string",
357
+ );
358
+ return flags.length === 0 ? undefined : flags;
359
+ } catch {
360
+ return undefined;
361
+ }
362
+ }
363
+
198
364
  /**
199
365
  * A NULL column becomes an absent property rather than an `undefined` one, so
200
366
  * a record read back out of the store deep-equals the one that went in.
@@ -217,11 +383,87 @@ function toRecord(row: RunRow): RunRecord {
217
383
  if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
218
384
  if (row.prUrl !== null) record.prUrl = row.prUrl;
219
385
  if (row.headSha !== null) record.headSha = row.headSha;
386
+ if (row.salvageSha !== null) record.salvageSha = row.salvageSha;
387
+ if (row.salvageError !== null) record.salvageError = row.salvageError;
388
+ if (row.salvageAckAt !== null) record.salvageAckAt = row.salvageAckAt;
220
389
  if (row.endedAt !== null) record.endedAt = row.endedAt;
221
390
  if (row.lastError !== null) record.lastError = row.lastError;
391
+ if (row.settlementFlags !== null) {
392
+ const flags = toSettlementFlags(row.settlementFlags);
393
+ if (flags !== undefined) record.settlementFlags = flags;
394
+ }
395
+ return record;
396
+ }
397
+
398
+ /**
399
+ * NULL columns become absent properties, matching {@link toRecord}: a report
400
+ * read back out of the store deep-equals the one that went in.
401
+ */
402
+ function toReport(row: ReportRow): ReportRecord {
403
+ const record: ReportRecord = {
404
+ id: row.id,
405
+ project: row.project,
406
+ kind: row.kind as ReportKind,
407
+ body: row.body,
408
+ state: row.state as ReportDeliveryState,
409
+ attempts: row.attempts,
410
+ ambiguous: row.ambiguous !== 0,
411
+ createdAt: row.createdAt,
412
+ updatedAt: row.updatedAt,
413
+ nextAttemptAt: row.nextAttemptAt,
414
+ };
415
+ if (row.attemptId !== null) record.attemptId = row.attemptId;
416
+ if (row.dedupeKey !== null) record.dedupeKey = row.dedupeKey;
417
+ if (row.messageId !== null) record.messageId = row.messageId;
418
+ if (row.deliveredAt !== null) record.deliveredAt = row.deliveredAt;
419
+ if (row.lastError !== null) record.lastError = row.lastError;
222
420
  return record;
223
421
  }
224
422
 
423
+ /**
424
+ * Long enough that a collision is not a thing anyone has to handle, short
425
+ * enough that an operator can compare it against the id printed in a Telegram
426
+ * message by eye — which is the only reconciliation this transport allows.
427
+ */
428
+ function newReportId(): string {
429
+ return crypto.randomUUID().replaceAll("-", "").slice(0, 12);
430
+ }
431
+
432
+ /**
433
+ * A ledger row, with the same NULL-becomes-absent posture as {@link toReport}.
434
+ *
435
+ * `args` is stored as JSON and read back defensively: a row whose payload no
436
+ * longer parses is still a row that says which verb was called and how it was
437
+ * decided, and losing the whole entry to a malformed blob would erase exactly
438
+ * the call somebody is trying to audit.
439
+ */
440
+ function toVerbLedgerEntry(row: VerbLedgerRow): VerbLedgerEntry {
441
+ let args: Record<string, unknown> = {};
442
+ try {
443
+ const parsed: unknown = JSON.parse(row.args);
444
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
445
+ args = parsed as Record<string, unknown>;
446
+ }
447
+ } catch {
448
+ args = { "(unreadable)": row.args };
449
+ }
450
+ const entry: VerbLedgerEntry = {
451
+ id: row.id,
452
+ project: row.project,
453
+ verb: row.verb as VerbName,
454
+ role: row.role as SessionRole,
455
+ args,
456
+ decision: row.decision as VerbDecision,
457
+ detail: row.detail,
458
+ at: row.at,
459
+ };
460
+ if (row.runId !== null) entry.runId = row.runId;
461
+ if (row.issue !== null) entry.issue = row.issue;
462
+ if (row.refusal !== null) entry.refusal = row.refusal as VerbRefusal;
463
+ if (row.sha !== null) entry.sha = row.sha;
464
+ return entry;
465
+ }
466
+
225
467
  function toDispatchSummary(text: string): DispatchSummary | undefined {
226
468
  try {
227
469
  const value = JSON.parse(text) as DispatchSummary;
@@ -324,12 +566,34 @@ export function openStore(dbPath: string): Store {
324
566
  if (!columns.some((column) => column.name === "headSha")) {
325
567
  db.exec("ALTER TABLE runs ADD COLUMN headSha TEXT");
326
568
  }
569
+ // v0.3.22 and earlier removed a blocked run's dirty tree without saving it
570
+ // (#118), so no row before this release has anywhere to record where the
571
+ // work went. A NULL `salvageSha` on a historical row therefore means "never
572
+ // asked", which reads the same as "clean tree" — the honest reading for a
573
+ // release that never salvaged one.
574
+ for (const [name, type] of [
575
+ ["salvageSha", "TEXT"],
576
+ ["salvageError", "TEXT"],
577
+ ["salvageAckAt", "INTEGER"],
578
+ ] as const) {
579
+ if (!columns.some((column) => column.name === name)) {
580
+ db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
581
+ }
582
+ }
583
+ // Rows written before the settlement audit existed (#128) were never audited,
584
+ // so a NULL here means "no audit ran" and reads the same as "found nothing".
585
+ // That conflation is deliberate and harmless: the flags are advisory, and no
586
+ // decision anywhere is taken on their absence.
587
+ if (!columns.some((column) => column.name === "settlementFlags")) {
588
+ db.exec("ALTER TABLE runs ADD COLUMN settlementFlags TEXT");
589
+ }
327
590
 
328
591
  const insertRun = db.query<unknown, SqlValue[]>(
329
592
  `INSERT INTO runs (
330
593
  id, project, issue, repo, branch, worktree, state, attempt, turns,
331
- maxTurns, spendUsd, sessionFile, prUrl, headSha, startedAt, endedAt, lastError
332
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
594
+ maxTurns, spendUsd, sessionFile, prUrl, headSha, salvageSha, salvageError,
595
+ salvageAckAt, startedAt, endedAt, lastError, settlementFlags
596
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
333
597
  );
334
598
  const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
335
599
  const selectActive = db.query<RunRow, SqlValue[]>(
@@ -370,6 +634,21 @@ export function openStore(dbPath: string): Store {
370
634
  `SELECT COUNT(*) AS n FROM runs
371
635
  WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')`,
372
636
  );
637
+ // Salvage state that still describes an issue's present, so an operator is
638
+ // shown a preserved WIP tip exactly while it is the thing a re-claim would
639
+ // build on — and an unsalvaged tree for as long as it is the only copy.
640
+ // Newest-attempt-only for the same reason the board is: a later attempt has
641
+ // already consumed or superseded whatever an older one left behind.
642
+ const selectSalvaged = db.query<RunRow, [string]>(
643
+ `SELECT * FROM runs
644
+ WHERE rowid IN (
645
+ SELECT MAX(rowid) FROM runs
646
+ WHERE project = ?
647
+ GROUP BY issue
648
+ )
649
+ AND (salvageSha IS NOT NULL OR salvageError IS NOT NULL)
650
+ ORDER BY issue ASC`,
651
+ );
373
652
  // Newest attempt for one issue. `startedAt` is millisecond-resolution and two
374
653
  // attempts could in principle share one, so rowid breaks the tie by insertion
375
654
  // order — a `tail` that attached to the older of two same-millisecond attempts
@@ -432,6 +711,106 @@ export function openStore(dbPath: string): Store {
432
711
  ON CONFLICT(project, kind) DO UPDATE SET at = excluded.at`,
433
712
  );
434
713
 
714
+ // The report outbox (#123). Every terminal transition names the attempt it is
715
+ // settling, because a request can come back *after* the stale-`sending` sweep
716
+ // has already reclaimed its row — and a late answer must not overwrite a
717
+ // newer attempt's outcome. That predicate is also what stops one report being
718
+ // concurrently in flight twice: only one claim can move a `pending` row.
719
+ const insertReport = db.query<unknown, SqlValue[]>(
720
+ `INSERT OR IGNORE INTO reports (
721
+ id, project, kind, body, state, attempts, attemptId, ambiguous, dedupeKey,
722
+ createdAt, updatedAt, nextAttemptAt, messageId, deliveredAt, lastError
723
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
724
+ );
725
+ const selectReport = db.query<ReportRow, [string]>(`SELECT * FROM reports WHERE id = ?`);
726
+ const selectReportByDedupe = db.query<ReportRow, [string, string]>(
727
+ `SELECT * FROM reports WHERE project = ? AND dedupeKey = ?`,
728
+ );
729
+ const selectDueReports = db.query<ReportRow, [string, number, number]>(
730
+ `SELECT * FROM reports
731
+ WHERE project = ? AND state = 'pending' AND nextAttemptAt <= ?
732
+ ORDER BY createdAt ASC, rowid ASC
733
+ LIMIT ?`,
734
+ );
735
+ const claimReportRow = db.query<unknown, [string, number, string, number]>(
736
+ `UPDATE reports
737
+ SET state = 'sending', attemptId = ?, attempts = attempts + 1, updatedAt = ?
738
+ WHERE id = ? AND state = 'pending' AND nextAttemptAt <= ?`,
739
+ );
740
+ const deliverReportRow = db.query<unknown, SqlValue[]>(
741
+ `UPDATE reports
742
+ SET state = 'delivered', attemptId = NULL, messageId = ?, deliveredAt = ?,
743
+ updatedAt = ?, lastError = NULL
744
+ WHERE id = ? AND state = 'sending' AND attemptId = ?`,
745
+ );
746
+ const requeueReportRow = db.query<unknown, [number, string, number, string, string]>(
747
+ `UPDATE reports
748
+ SET state = 'pending', attemptId = NULL, nextAttemptAt = ?, lastError = ?, updatedAt = ?
749
+ WHERE id = ? AND state = 'sending' AND attemptId = ?`,
750
+ );
751
+ // Stays `sending` and keeps its attempt id: the request is over but its
752
+ // outcome is not known, which is precisely what `sending` means. `updatedAt`
753
+ // is left alone so the stale window keeps running from when the request left.
754
+ const uncertainReportRow = db.query<unknown, [string, string, string]>(
755
+ `UPDATE reports
756
+ SET ambiguous = 1, lastError = ?
757
+ WHERE id = ? AND state = 'sending' AND attemptId = ?`,
758
+ );
759
+ const failReportRow = db.query<unknown, [string, number, string, string]>(
760
+ `UPDATE reports
761
+ SET state = 'failed', attemptId = NULL, lastError = ?, updatedAt = ?
762
+ WHERE id = ? AND state = 'sending' AND attemptId = ?`,
763
+ );
764
+ const selectStaleSending = db.query<ReportRow, [string, number]>(
765
+ `SELECT * FROM reports
766
+ WHERE project = ? AND state = 'sending' AND updatedAt <= ?
767
+ ORDER BY createdAt ASC, rowid ASC`,
768
+ );
769
+ const recoverSendingRows = db.query<unknown, [number, number, string, number]>(
770
+ `UPDATE reports
771
+ SET state = 'pending', attemptId = NULL, ambiguous = 1, nextAttemptAt = ?, updatedAt = ?
772
+ WHERE project = ? AND state = 'sending' AND updatedAt <= ?`,
773
+ );
774
+ const selectOpenReports = db.query<ReportRow, [string]>(
775
+ `SELECT * FROM reports
776
+ WHERE project = ? AND state IN ('pending', 'sending', 'failed')
777
+ ORDER BY createdAt ASC, rowid ASC`,
778
+ );
779
+
780
+ const insertVerbLedger = db.query<unknown, SqlValue[]>(
781
+ `INSERT INTO verb_ledger
782
+ (id, project, runId, issue, verb, role, args, decision, refusal, detail, sha, at)
783
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
784
+ );
785
+ const selectVerbLedger = db.query<VerbLedgerRow, [string, string, string, number, number, number]>(
786
+ `SELECT * FROM verb_ledger
787
+ WHERE project = ?
788
+ AND (? = '' OR runId = ?)
789
+ AND (? = 0 OR issue = ?)
790
+ ORDER BY at DESC, rowid DESC
791
+ LIMIT ?`,
792
+ );
793
+ // Breaking a stale claim and taking it must be one transaction, or two
794
+ // daemons both see the stale row, both delete it, and both insert.
795
+ const breakStaleMergeLock = db.query<unknown, [string, number]>(
796
+ `DELETE FROM merge_locks WHERE project = ? AND at <= ?`,
797
+ );
798
+ const claimMergeLock = db.query<unknown, [string, string, string, number]>(
799
+ `INSERT OR IGNORE INTO merge_locks (project, holder, prUrl, at) VALUES (?, ?, ?, ?)`,
800
+ );
801
+ const dropMergeLock = db.query<unknown, [string, string]>(
802
+ `DELETE FROM merge_locks WHERE project = ? AND holder = ?`,
803
+ );
804
+ const selectMergeLock = db.query<MergeLockRow, [string]>(
805
+ `SELECT * FROM merge_locks WHERE project = ?`,
806
+ );
807
+ const takeMergeLock = db.transaction(
808
+ (project: string, holder: string, prUrl: string, now: number, staleAt: number): boolean => {
809
+ breakStaleMergeLock.run(project, staleAt);
810
+ return claimMergeLock.run(project, holder, prUrl, now).changes > 0;
811
+ },
812
+ );
813
+
435
814
  const recordFriction = (project: string, observation: FrictionObservation): void => {
436
815
  if (
437
816
  !Number.isSafeInteger(observation.occurrences) ||
@@ -487,9 +866,13 @@ export function openStore(dbPath: string): Store {
487
866
  toSql(record.sessionFile),
488
867
  toSql(record.prUrl),
489
868
  toSql(record.headSha),
869
+ toSql(record.salvageSha),
870
+ toSql(record.salvageError),
871
+ toSql(record.salvageAckAt),
490
872
  record.startedAt,
491
873
  toSql(record.endedAt),
492
874
  toSql(record.lastError),
875
+ toSql(record.settlementFlags),
493
876
  );
494
877
  return record;
495
878
  },
@@ -532,6 +915,10 @@ export function openStore(dbPath: string): Store {
532
915
  return selectRecentRuns.all(project, mergedSinceEpochMs).map(toRecord);
533
916
  },
534
917
 
918
+ salvagedRuns(project: string): RunRecord[] {
919
+ return selectSalvaged.all(project).map(toRecord);
920
+ },
921
+
535
922
  attemptsFor(project: string, issue: number): number {
536
923
  return countAttempts.get(project, issue)?.n ?? 0;
537
924
  },
@@ -653,6 +1040,171 @@ export function openStore(dbPath: string): Store {
653
1040
  for (const kind of new Set(kinds)) upsertFrictionSurface.run(project, kind, at);
654
1041
  },
655
1042
 
1043
+ enqueueReport(draft: ReportDraft): ReportEnqueue {
1044
+ const report: ReportRecord = {
1045
+ id: newReportId(),
1046
+ project: draft.project,
1047
+ kind: draft.kind,
1048
+ body: draft.body,
1049
+ state: "pending",
1050
+ attempts: 0,
1051
+ ambiguous: false,
1052
+ ...(draft.dedupeKey === undefined ? {} : { dedupeKey: draft.dedupeKey }),
1053
+ createdAt: draft.at,
1054
+ updatedAt: draft.at,
1055
+ // Due immediately. A backoff nobody has earned yet would delay the
1056
+ // first attempt at a report an operator is already waiting for.
1057
+ nextAttemptAt: draft.at,
1058
+ };
1059
+ const inserted = insertReport.run(
1060
+ report.id,
1061
+ report.project,
1062
+ report.kind,
1063
+ report.body,
1064
+ report.state,
1065
+ report.attempts,
1066
+ null,
1067
+ 0,
1068
+ toSql(draft.dedupeKey),
1069
+ report.createdAt,
1070
+ report.updatedAt,
1071
+ report.nextAttemptAt,
1072
+ null,
1073
+ null,
1074
+ null,
1075
+ );
1076
+ if (inserted.changes > 0) return { report, deduped: false };
1077
+ // `INSERT OR IGNORE` swallowed a conflict, and the only conflict this
1078
+ // table has is the digest guard — a 48-bit id collision is not a thing
1079
+ // anyone needs to handle. Return the row that won rather than the one
1080
+ // that lost, so whoever handed the digest over is told the truth about
1081
+ // which report their operator will actually receive.
1082
+ const existing =
1083
+ draft.dedupeKey === undefined ? null : selectReportByDedupe.get(draft.project, draft.dedupeKey);
1084
+ if (existing === null) {
1085
+ throw new Error(`report ${report.id} could not be enqueued for ${draft.project}`);
1086
+ }
1087
+ return { report: toReport(existing), deduped: true };
1088
+ },
1089
+
1090
+ getReport(id: string): ReportRecord | undefined {
1091
+ const row = selectReport.get(id);
1092
+ return row === null ? undefined : toReport(row);
1093
+ },
1094
+
1095
+ dueReports(project: string, now: number, limit: number): ReportRecord[] {
1096
+ return selectDueReports.all(project, now, limit).map(toReport);
1097
+ },
1098
+
1099
+ claimReport(id: string, attemptId: string, at: number): ReportRecord | undefined {
1100
+ // The row is written `sending` *before* the caller's request leaves, so a
1101
+ // crash in the window leaves an explicitly ambiguous row rather than a
1102
+ // silently lost one. Losing the race is normal, not an error: it means
1103
+ // another pass already owns this attempt.
1104
+ if (claimReportRow.run(attemptId, at, id, at).changes === 0) return undefined;
1105
+ const row = selectReport.get(id);
1106
+ return row === null ? undefined : toReport(row);
1107
+ },
1108
+
1109
+ markReportDelivered(
1110
+ id: string,
1111
+ attemptId: string,
1112
+ messageId: number | undefined,
1113
+ at: number,
1114
+ ): boolean {
1115
+ return (
1116
+ deliverReportRow.run(toSql(messageId), at, at, id, attemptId).changes > 0
1117
+ );
1118
+ },
1119
+
1120
+ markReportUncertain(id: string, attemptId: string, error: string): boolean {
1121
+ return uncertainReportRow.run(error, id, attemptId).changes > 0;
1122
+ },
1123
+
1124
+ markReportPending(
1125
+ id: string,
1126
+ attemptId: string,
1127
+ nextAttemptAt: number,
1128
+ error: string,
1129
+ at: number,
1130
+ ): boolean {
1131
+ return requeueReportRow.run(nextAttemptAt, error, at, id, attemptId).changes > 0;
1132
+ },
1133
+
1134
+ markReportFailed(id: string, attemptId: string, error: string, at: number): boolean {
1135
+ return failReportRow.run(error, at, id, attemptId).changes > 0;
1136
+ },
1137
+
1138
+ recoverSendingReports(project: string, staleAt: number, at: number): ReportRecord[] {
1139
+ const stale = selectStaleSending.all(project, staleAt);
1140
+ if (stale.length === 0) return [];
1141
+ recoverSendingRows.run(at, at, project, staleAt);
1142
+ // Re-read rather than patching the pre-update rows: the caller logs and
1143
+ // renders these, and a row that says `sending` while the ledger says
1144
+ // `pending` is exactly the kind of quiet disagreement #123 is about.
1145
+ return stale
1146
+ .map((row) => selectReport.get(row.id))
1147
+ .filter((row): row is ReportRow => row !== null)
1148
+ .map(toReport);
1149
+ },
1150
+
1151
+ openReports(project: string): ReportRecord[] {
1152
+ return selectOpenReports.all(project).map(toReport);
1153
+ },
1154
+
1155
+ appendVerbLedger(draft: VerbLedgerDraft): VerbLedgerEntry {
1156
+ const entry: VerbLedgerEntry = { ...draft, id: crypto.randomUUID(), at: Date.now() };
1157
+ insertVerbLedger.run(
1158
+ entry.id,
1159
+ entry.project,
1160
+ toSql(entry.runId),
1161
+ toSql(entry.issue),
1162
+ entry.verb,
1163
+ entry.role,
1164
+ JSON.stringify(entry.args),
1165
+ entry.decision,
1166
+ toSql(entry.refusal),
1167
+ entry.detail,
1168
+ toSql(entry.sha),
1169
+ entry.at,
1170
+ );
1171
+ return entry;
1172
+ },
1173
+
1174
+ verbLedger(
1175
+ project: string,
1176
+ opts: { runId?: string; issue?: number; limit?: number } = {},
1177
+ ): VerbLedgerEntry[] {
1178
+ // Sentinels rather than a composed SQL string: the filters are optional,
1179
+ // and building the WHERE clause by concatenation is how a project name
1180
+ // ends up interpolated into a query in a package that otherwise binds
1181
+ // every value (see `UPDATABLE_COLUMNS` for the one place that cannot).
1182
+ const runId = opts.runId ?? "";
1183
+ const issue = opts.issue ?? 0;
1184
+ const limit = opts.limit ?? 50;
1185
+ return selectVerbLedger.all(project, runId, runId, issue, issue, limit).map(toVerbLedgerEntry);
1186
+ },
1187
+
1188
+ acquireMergeLock(
1189
+ project: string,
1190
+ holder: string,
1191
+ prUrl: string,
1192
+ now: number,
1193
+ staleAfterMs: number,
1194
+ ): MergeLock | undefined {
1195
+ if (!takeMergeLock(project, holder, prUrl, now, now - staleAfterMs)) return undefined;
1196
+ return { project, holder, prUrl, at: now };
1197
+ },
1198
+
1199
+ releaseMergeLock(project: string, holder: string): void {
1200
+ dropMergeLock.run(project, holder);
1201
+ },
1202
+
1203
+ mergeLock(project: string): MergeLock | undefined {
1204
+ const row = selectMergeLock.get(project);
1205
+ return row === null ? undefined : { ...row };
1206
+ },
1207
+
656
1208
  close(): void {
657
1209
  db.close(false);
658
1210
  },