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/README.md +253 -84
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/board.ts +1 -1
- package/src/briefs/orchestrator.md +95 -28
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +226 -37
- package/src/config.ts +123 -7
- package/src/daemon.ts +1018 -145
- package/src/diff-flags.ts +77 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/failure-class.ts +7 -5
- package/src/fleet.ts +39 -3
- package/src/omp.ts +8 -4
- package/src/orchestrator-tick.ts +471 -39
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +166 -14
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +202 -5
- package/src/session-host.ts +4 -3
- package/src/setup.ts +197 -35
- package/src/store.ts +785 -112
- package/src/tracker/github.ts +299 -56
- package/src/types.ts +289 -37
- package/src/verbs/actions.ts +245 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +94 -13
- package/src/worker.ts +51 -11
- package/src/worktree.ts +5 -0
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
|
|
312
|
-
-- most once per project, so "has today's digest been handed over?"
|
|
313
|
-
-- the ledger answers rather than the model's memory of the last
|
|
314
|
-
-- reports carry no key
|
|
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
|
|
345
|
-
project
|
|
346
|
-
category
|
|
347
|
-
summary
|
|
348
|
-
detail
|
|
349
|
-
createdAt
|
|
350
|
-
|
|
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,
|
|
882
|
-
|
|
883
|
-
|
|
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 = ?
|
|
930
|
-
AND (
|
|
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
|
|
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)
|
|
1104
|
-
//
|
|
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
|
|
1107
|
-
|
|
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
|
|
1110
|
-
|
|
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
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
|
1118
|
-
`UPDATE
|
|
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
|
-
|
|
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
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
2180
|
+
getMaterialEvent(id: string): MaterialEvent | undefined {
|
|
2181
|
+
const row = selectMaterialEvent.get(id);
|
|
2182
|
+
return row === null ? undefined : toMaterialEvent(row);
|
|
1513
2183
|
},
|
|
1514
2184
|
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
},
|