omp-conductor 0.3.25 → 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
  /**
@@ -91,6 +104,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
91
104
  startedAt: true,
92
105
  endedAt: true,
93
106
  lastError: true,
107
+ settlementFlags: true,
94
108
  };
95
109
 
96
110
  /** Everything SQLite will accept from us. */
@@ -118,6 +132,7 @@ interface RunRow {
118
132
  startedAt: number;
119
133
  endedAt: number | null;
120
134
  lastError: string | null;
135
+ settlementFlags: string | null;
121
136
  }
122
137
 
123
138
  interface FrictionRollupRow {
@@ -136,6 +151,49 @@ interface FrictionSurfaceRow {
136
151
  at: number;
137
152
  }
138
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
+
139
197
  const SCHEMA = `
140
198
  CREATE TABLE IF NOT EXISTS runs (
141
199
  id TEXT PRIMARY KEY,
@@ -157,7 +215,8 @@ CREATE TABLE IF NOT EXISTS runs (
157
215
  salvageAckAt INTEGER,
158
216
  startedAt INTEGER NOT NULL,
159
217
  endedAt INTEGER,
160
- lastError TEXT
218
+ lastError TEXT,
219
+ settlementFlags TEXT
161
220
  );
162
221
  CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
163
222
  CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
@@ -192,6 +251,72 @@ CREATE TABLE IF NOT EXISTS friction_surfaces (
192
251
  at INTEGER NOT NULL,
193
252
  PRIMARY KEY (project, kind)
194
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
+ );
195
320
  `;
196
321
 
197
322
  /**
@@ -201,9 +326,41 @@ CREATE TABLE IF NOT EXISTS friction_surfaces (
201
326
  function toSql(value: unknown): SqlValue {
202
327
  if (value === undefined || value === null) return null;
203
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);
204
335
  return value as SqlValue;
205
336
  }
206
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
+
207
364
  /**
208
365
  * A NULL column becomes an absent property rather than an `undefined` one, so
209
366
  * a record read back out of the store deep-equals the one that went in.
@@ -231,9 +388,82 @@ function toRecord(row: RunRow): RunRecord {
231
388
  if (row.salvageAckAt !== null) record.salvageAckAt = row.salvageAckAt;
232
389
  if (row.endedAt !== null) record.endedAt = row.endedAt;
233
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
+ }
234
395
  return record;
235
396
  }
236
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;
420
+ return record;
421
+ }
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
+
237
467
  function toDispatchSummary(text: string): DispatchSummary | undefined {
238
468
  try {
239
469
  const value = JSON.parse(text) as DispatchSummary;
@@ -350,13 +580,20 @@ export function openStore(dbPath: string): Store {
350
580
  db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
351
581
  }
352
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
+ }
353
590
 
354
591
  const insertRun = db.query<unknown, SqlValue[]>(
355
592
  `INSERT INTO runs (
356
593
  id, project, issue, repo, branch, worktree, state, attempt, turns,
357
594
  maxTurns, spendUsd, sessionFile, prUrl, headSha, salvageSha, salvageError,
358
- salvageAckAt, startedAt, endedAt, lastError
359
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
595
+ salvageAckAt, startedAt, endedAt, lastError, settlementFlags
596
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
360
597
  );
361
598
  const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
362
599
  const selectActive = db.query<RunRow, SqlValue[]>(
@@ -474,6 +711,106 @@ export function openStore(dbPath: string): Store {
474
711
  ON CONFLICT(project, kind) DO UPDATE SET at = excluded.at`,
475
712
  );
476
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
+
477
814
  const recordFriction = (project: string, observation: FrictionObservation): void => {
478
815
  if (
479
816
  !Number.isSafeInteger(observation.occurrences) ||
@@ -535,6 +872,7 @@ export function openStore(dbPath: string): Store {
535
872
  record.startedAt,
536
873
  toSql(record.endedAt),
537
874
  toSql(record.lastError),
875
+ toSql(record.settlementFlags),
538
876
  );
539
877
  return record;
540
878
  },
@@ -702,6 +1040,171 @@ export function openStore(dbPath: string): Store {
702
1040
  for (const kind of new Set(kinds)) upsertFrictionSurface.run(project, kind, at);
703
1041
  },
704
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
+
705
1208
  close(): void {
706
1209
  db.close(false);
707
1210
  },
@@ -5,6 +5,14 @@
5
5
  * daemon never handles a token itself: credentials stay in the user's keychain
6
6
  * or `gh` config and are never passed as argv, written to a file, or logged.
7
7
  *
8
+ * That reachability is exactly what #125 confines. The environment this adapter
9
+ * hands `gh` comes from `credentialedEnv()` — the one named construction site
10
+ * of credential material in the daemon — so "which processes can reach the
11
+ * operator's GitHub write credential" is answered by that function's call
12
+ * sites, not by auditing every spawn in the tree. Sessions get the opposite
13
+ * environment, and on a `per-run` host they are a different OS principal
14
+ * entirely and could not use this credential even if they found it.
15
+ *
8
16
  * ponytail: shelling out to `gh` is the deliberate simplification. The ceiling
9
17
  * is per-call cost (one process spawn plus one TLS handshake per operation,
10
18
  * ~200-400ms) and failure classification by matching human-readable stderr
@@ -13,9 +21,12 @@
13
21
  * `gh auth token`; the eleven Tracker methods above it stay untouched.
14
22
  */
15
23
 
24
+ import { credentialedEnv } from "../credentials.ts";
25
+ import { parsePrDiff } from "../diff-flags.ts";
16
26
  import type {
17
27
  IssueState,
18
28
  OpenCloser,
29
+ PrDiff,
19
30
  PrState,
20
31
  PrVerification,
21
32
  ProjectConfig,
@@ -129,6 +140,11 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
129
140
  stdin: new Blob([stdin ?? ""]),
130
141
  stdout: "pipe",
131
142
  stderr: "pipe",
143
+ // The privileged side of the boundary (#125). Explicit rather than
144
+ // inherited so that adding a credential-scrubbing default to this process
145
+ // could never silently break the tracker, and so that the grep for
146
+ // `credentialedEnv` finds this call.
147
+ env: credentialedEnv(),
132
148
  });
133
149
 
134
150
  const [stdout, stderr, code] = await Promise.all([
@@ -208,6 +224,17 @@ export function firstOpenCloser(raw: string): OpenCloser | undefined {
208
224
  */
209
225
  const PR_URL = /^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+\/?$/;
210
226
 
227
+ /**
228
+ * How much of a pull request's diff the settlement audit will hold in memory.
229
+ *
230
+ * The daemon runs every worker in its own process, so an unbounded read of a
231
+ * PR that regenerated a 90 MB lockfile would be paid for out of the same
232
+ * resident set the live workers are using. Truncation only ever loses trailing
233
+ * files, which costs findings and never invents them, and the parsed diff
234
+ * carries `truncated` so the report never presents a short read as a clean one.
235
+ */
236
+ const MAX_DIFF_BYTES = 4 * 1024 * 1024;
237
+
211
238
  type CheckVerdict = PrVerification["status"];
212
239
 
213
240
  function checkName(check: GhCheck): string {
@@ -528,5 +555,23 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
528
555
  return undefined;
529
556
  }
530
557
  },
558
+
559
+ async prDiff(url: string): Promise<PrDiff | undefined> {
560
+ if (!PR_URL.test(url)) return undefined;
561
+ try {
562
+ // `--color never` rather than relying on the default: `auto` is decided
563
+ // from the child's stdout, and this adapter pipes it, but a diff with
564
+ // ANSI escapes in it would silently stop matching every pattern the
565
+ // audit looks for — a failure that looks exactly like a clean PR.
566
+ const raw = await runGh(["pr", "diff", url, "--color", "never"]);
567
+ return raw.length > MAX_DIFF_BYTES
568
+ ? parsePrDiff(raw.slice(0, MAX_DIFF_BYTES), true)
569
+ : parsePrDiff(raw);
570
+ } catch {
571
+ // Advisory: an unreadable diff costs one unaudited settlement, never a
572
+ // run's state. Undefined is "could not tell", not "clean".
573
+ return undefined;
574
+ }
575
+ },
531
576
  };
532
577
  }