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