omp-conductor 0.15.10 → 0.15.12
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 +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +150 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +100 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +32 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- package/systemd/recover-unit-test.sh +428 -0
package/src/store.ts
CHANGED
|
@@ -31,6 +31,9 @@ import type {
|
|
|
31
31
|
HeldNotice,
|
|
32
32
|
InterruptCategory,
|
|
33
33
|
HeldNoticeDraft,
|
|
34
|
+
IntakeDraft,
|
|
35
|
+
IntakeItem,
|
|
36
|
+
IntakeState,
|
|
34
37
|
MaterialEvent,
|
|
35
38
|
MaterialEventDraft,
|
|
36
39
|
LabelOp,
|
|
@@ -41,6 +44,7 @@ import type {
|
|
|
41
44
|
ReportKind,
|
|
42
45
|
ReportRecord,
|
|
43
46
|
RecoveryAction,
|
|
47
|
+
RunPatch,
|
|
44
48
|
RunRecord,
|
|
45
49
|
RunState,
|
|
46
50
|
SessionRole,
|
|
@@ -78,6 +82,7 @@ const ACTIVE_PLACEHOLDERS = ACTIVE_STATES.map(() => "?").join(", ");
|
|
|
78
82
|
const FRICTION_HOLD_REASONS: ReadonlySet<FrictionAdmissionReason> = new Set([
|
|
79
83
|
"failed-attempts",
|
|
80
84
|
"continuations",
|
|
85
|
+
"stale-base",
|
|
81
86
|
"parent-lookup-error",
|
|
82
87
|
"issue-state-lookup-error",
|
|
83
88
|
"open-pr-lookup-error",
|
|
@@ -129,6 +134,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
129
134
|
failureClass: true,
|
|
130
135
|
recoveryAction: true,
|
|
131
136
|
recoveredAt: true,
|
|
137
|
+
model: true,
|
|
132
138
|
};
|
|
133
139
|
|
|
134
140
|
/** Everything SQLite will accept from us. */
|
|
@@ -165,6 +171,7 @@ interface RunRow {
|
|
|
165
171
|
failureClass: string | null;
|
|
166
172
|
recoveryAction: string | null;
|
|
167
173
|
recoveredAt: number | null;
|
|
174
|
+
model: string | null;
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
/** The `base_health` table exactly as SQLite hands it back. */
|
|
@@ -292,6 +299,34 @@ interface MergeLockRow {
|
|
|
292
299
|
at: number;
|
|
293
300
|
}
|
|
294
301
|
|
|
302
|
+
/** The `intake_items` table exactly as SQLite hands it back (#299). */
|
|
303
|
+
interface IntakeRow {
|
|
304
|
+
id: string;
|
|
305
|
+
project: string;
|
|
306
|
+
text: string;
|
|
307
|
+
createdAt: number;
|
|
308
|
+
state: string;
|
|
309
|
+
issueUrl: string | null;
|
|
310
|
+
groomedAt: number | null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* NULL columns become absent properties, matching the other row converters:
|
|
315
|
+
* a record read back out of the store deep-equals the one that went in.
|
|
316
|
+
*/
|
|
317
|
+
function toIntakeItem(row: IntakeRow): IntakeItem {
|
|
318
|
+
const item: IntakeItem = {
|
|
319
|
+
id: row.id,
|
|
320
|
+
project: row.project,
|
|
321
|
+
text: row.text,
|
|
322
|
+
createdAt: row.createdAt,
|
|
323
|
+
state: row.state as IntakeState,
|
|
324
|
+
...(row.issueUrl === null ? {} : { issueUrl: row.issueUrl }),
|
|
325
|
+
...(row.groomedAt === null ? {} : { groomedAt: row.groomedAt }),
|
|
326
|
+
};
|
|
327
|
+
return item;
|
|
328
|
+
}
|
|
329
|
+
|
|
295
330
|
const SCHEMA = `
|
|
296
331
|
CREATE TABLE IF NOT EXISTS runs (
|
|
297
332
|
id TEXT PRIMARY KEY,
|
|
@@ -322,7 +357,8 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
322
357
|
report TEXT,
|
|
323
358
|
failureClass TEXT,
|
|
324
359
|
recoveryAction TEXT,
|
|
325
|
-
recoveredAt INTEGER
|
|
360
|
+
recoveredAt INTEGER,
|
|
361
|
+
model TEXT
|
|
326
362
|
);
|
|
327
363
|
CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
|
|
328
364
|
CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
|
|
@@ -575,6 +611,23 @@ CREATE TABLE IF NOT EXISTS label_ops (
|
|
|
575
611
|
lastError TEXT
|
|
576
612
|
);
|
|
577
613
|
CREATE INDEX IF NOT EXISTS label_ops_project_next ON label_ops (project, nextAttemptAt);
|
|
614
|
+
|
|
615
|
+
-- Raw ideas captured by the intake command (#299), held durably until
|
|
616
|
+
-- #300 resolves them into issues. A thought from 02:00 must survive a restart
|
|
617
|
+
-- and a reinstall, so it lives here rather than in any process's memory. The
|
|
618
|
+
-- state CHECK keeps the three-state vocabulary exact; issueUrl and groomedAt
|
|
619
|
+
-- stay NULL until an idea becomes (or is dropped instead of) an issue.
|
|
620
|
+
CREATE TABLE IF NOT EXISTS intake_items (
|
|
621
|
+
id TEXT PRIMARY KEY,
|
|
622
|
+
project TEXT NOT NULL,
|
|
623
|
+
text TEXT NOT NULL,
|
|
624
|
+
createdAt INTEGER NOT NULL,
|
|
625
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'groomed', 'dismissed')),
|
|
626
|
+
issueUrl TEXT,
|
|
627
|
+
groomedAt INTEGER
|
|
628
|
+
);
|
|
629
|
+
CREATE INDEX IF NOT EXISTS intake_items_project_state
|
|
630
|
+
ON intake_items (project, state, createdAt);
|
|
578
631
|
`;
|
|
579
632
|
|
|
580
633
|
/**
|
|
@@ -658,6 +711,7 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
658
711
|
if (row.failureClass !== null) record.failureClass = row.failureClass as FailureClass;
|
|
659
712
|
if (row.recoveryAction !== null) record.recoveryAction = row.recoveryAction as RecoveryAction;
|
|
660
713
|
if (row.recoveredAt !== null) record.recoveredAt = row.recoveredAt;
|
|
714
|
+
if (row.model !== null) record.model = row.model;
|
|
661
715
|
return record;
|
|
662
716
|
}
|
|
663
717
|
|
|
@@ -937,10 +991,10 @@ export function openStore(dbPath: string): Store {
|
|
|
937
991
|
if (!columns.some((column) => column.name === "settlementFlags")) {
|
|
938
992
|
db.exec("ALTER TABLE runs ADD COLUMN settlementFlags TEXT");
|
|
939
993
|
}
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
//
|
|
994
|
+
// The settlement report was first persisted in #199; rows written before it
|
|
995
|
+
// simply have no report, which is the honest reading of a kill that never
|
|
996
|
+
// produced one. No backfill: the bytes only ever existed in a worker's
|
|
997
|
+
// memory.
|
|
944
998
|
if (!columns.some((column) => column.name === "report")) {
|
|
945
999
|
db.exec("ALTER TABLE runs ADD COLUMN report TEXT");
|
|
946
1000
|
}
|
|
@@ -957,6 +1011,13 @@ export function openStore(dbPath: string): Store {
|
|
|
957
1011
|
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
958
1012
|
}
|
|
959
1013
|
}
|
|
1014
|
+
// The model a run dispatched on was first written by the modelFallbacks
|
|
1015
|
+
// failover chain (#286). Rows predating the column are NULL, which is the
|
|
1016
|
+
// honest reading: only a chain-configured project records a model, and the
|
|
1017
|
+
// harness's own downgrade stays on the worker result, never in this column.
|
|
1018
|
+
if (!columns.some((column) => column.name === "model")) {
|
|
1019
|
+
db.exec("ALTER TABLE runs ADD COLUMN model TEXT");
|
|
1020
|
+
}
|
|
960
1021
|
// Existing held-notice rows predate exact digest ownership (#274). A NULL
|
|
961
1022
|
// report id is truthful for already-digested history and means "still owed"
|
|
962
1023
|
// only while digestedAt is also NULL.
|
|
@@ -1101,6 +1162,38 @@ export function openStore(dbPath: string): Store {
|
|
|
1101
1162
|
AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
|
|
1102
1163
|
ORDER BY startedAt DESC`,
|
|
1103
1164
|
);
|
|
1165
|
+
// Every attempt that recorded one PR URL, newest first — deliberately NOT the
|
|
1166
|
+
// `recentRuns` newest-attempt-per-issue view. Mediated PR verbs resolve a
|
|
1167
|
+
// PR's owner through this: a requeued continuation hides its predecessor's
|
|
1168
|
+
// PR from `recentRuns` the moment it is created, so ownership must be able
|
|
1169
|
+
// to read past the issue's newest row (#434). Merged rows keep the same
|
|
1170
|
+
// recent-history bound as `recentRuns`.
|
|
1171
|
+
const selectRunsForPr = db.query<RunRow, [string, string, number]>(
|
|
1172
|
+
`SELECT * FROM runs
|
|
1173
|
+
WHERE project = ? AND prUrl = ?
|
|
1174
|
+
AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
|
|
1175
|
+
ORDER BY startedAt DESC, rowid DESC`,
|
|
1176
|
+
);
|
|
1177
|
+
// The stats window (#282): rows settling at or after `since` — merged rows
|
|
1178
|
+
// included, so a chain whose PR merged inside the window is an outcome —
|
|
1179
|
+
// plus, for those merged issues, their WHOLE attempt chain even when it
|
|
1180
|
+
// started before the window. A continuation that began weeks ago and merged
|
|
1181
|
+
// today must report one lead time from its first claim, not from the window
|
|
1182
|
+
// start; ordering by issue/startedAt makes the first row of each chain its
|
|
1183
|
+
// first queue-label claim.
|
|
1184
|
+
const selectStatsRuns = db.query<RunRow, [string, number, string, number]>(
|
|
1185
|
+
`SELECT * FROM runs
|
|
1186
|
+
WHERE project = ?
|
|
1187
|
+
AND (
|
|
1188
|
+
COALESCE(endedAt, startedAt) >= ?
|
|
1189
|
+
OR issue IN (
|
|
1190
|
+
SELECT issue FROM runs
|
|
1191
|
+
WHERE project = ? AND state = 'merged'
|
|
1192
|
+
AND COALESCE(endedAt, startedAt) >= ?
|
|
1193
|
+
)
|
|
1194
|
+
)
|
|
1195
|
+
ORDER BY issue ASC, startedAt ASC, rowid ASC`,
|
|
1196
|
+
);
|
|
1104
1197
|
const selectPendingBaseChecks = db.query<RunRow, [string, number]>(
|
|
1105
1198
|
`SELECT * FROM runs
|
|
1106
1199
|
WHERE project = ? AND state = 'merged' AND baseCheck = 'pending'
|
|
@@ -1215,6 +1308,14 @@ export function openStore(dbPath: string): Store {
|
|
|
1215
1308
|
ORDER BY startedAt DESC, rowid DESC
|
|
1216
1309
|
LIMIT 1`,
|
|
1217
1310
|
);
|
|
1311
|
+
// Every attempt for one issue, oldest first. An exhaustion escalation groups
|
|
1312
|
+
// these by `failureClass` to report how the continuation budget was spent
|
|
1313
|
+
// (#439); the numeric `*For` counters cannot reconstruct that on their own.
|
|
1314
|
+
const selectRunsForIssue = db.query<RunRow, [string, number]>(
|
|
1315
|
+
`SELECT * FROM runs
|
|
1316
|
+
WHERE project = ? AND issue = ?
|
|
1317
|
+
ORDER BY startedAt ASC, rowid ASC`,
|
|
1318
|
+
);
|
|
1218
1319
|
const upsertTurnOverride = db.query<unknown, [string, number, number, number]>(
|
|
1219
1320
|
`INSERT INTO turn_overrides (project, issue, maxTurns, setAt) VALUES (?, ?, ?, ?)
|
|
1220
1321
|
ON CONFLICT(project, issue) DO UPDATE SET
|
|
@@ -1247,14 +1348,8 @@ export function openStore(dbPath: string): Store {
|
|
|
1247
1348
|
ORDER BY setAt DESC, rowid DESC
|
|
1248
1349
|
LIMIT ?`,
|
|
1249
1350
|
);
|
|
1250
|
-
//
|
|
1251
|
-
//
|
|
1252
|
-
// report — skipping them makes a continuation read exactly today's priors.
|
|
1253
|
-
const selectAttemptReports = db.query<{ attempt: number; report: string }, [string, number]>(
|
|
1254
|
-
`SELECT attempt, report FROM runs
|
|
1255
|
-
WHERE project = ? AND issue = ? AND report IS NOT NULL
|
|
1256
|
-
ORDER BY attempt`,
|
|
1257
|
-
);
|
|
1351
|
+
// The settlement report row is kept for the audit trail; nothing pools it
|
|
1352
|
+
// across attempts any more (#488).
|
|
1258
1353
|
const countStartedSince = db.query<{ n: number }, [string, number]>(
|
|
1259
1354
|
`SELECT COUNT(*) AS n FROM runs WHERE project = ? AND startedAt >= ?`,
|
|
1260
1355
|
);
|
|
@@ -1331,6 +1426,10 @@ export function openStore(dbPath: string): Store {
|
|
|
1331
1426
|
const selectGhCalls = db.query<{ source: string; calls: number }, [string]>(
|
|
1332
1427
|
`SELECT source, calls FROM gh_calls WHERE day = ? ORDER BY source`,
|
|
1333
1428
|
);
|
|
1429
|
+
const sumGhCallsBetween = db.query<{ total: number }, [string, string]>(
|
|
1430
|
+
`SELECT COALESCE(SUM(calls), 0) AS total FROM gh_calls
|
|
1431
|
+
WHERE day BETWEEN ? AND ?`,
|
|
1432
|
+
);
|
|
1334
1433
|
|
|
1335
1434
|
// The report outbox (#123). Every terminal transition names the attempt it is
|
|
1336
1435
|
// settling, because a request can come back *after* the stale-`sending` sweep
|
|
@@ -1531,12 +1630,20 @@ export function openStore(dbPath: string): Store {
|
|
|
1531
1630
|
LIMIT ?`,
|
|
1532
1631
|
);
|
|
1533
1632
|
const selectHeldNoticeBacklog = db.query<
|
|
1534
|
-
{
|
|
1633
|
+
{
|
|
1634
|
+
count: number;
|
|
1635
|
+
availabilityCount: number | null;
|
|
1636
|
+
oldestAt: number | null;
|
|
1637
|
+
digestOldestAt: number | null;
|
|
1638
|
+
availabilityOldestAt: number | null;
|
|
1639
|
+
},
|
|
1535
1640
|
[string]
|
|
1536
1641
|
>(
|
|
1537
1642
|
`SELECT COUNT(*) AS count,
|
|
1538
1643
|
SUM(CASE WHEN releaseOnAvailable = 1 THEN 1 ELSE 0 END) AS availabilityCount,
|
|
1539
|
-
MIN(createdAt) AS oldestAt
|
|
1644
|
+
MIN(createdAt) AS oldestAt,
|
|
1645
|
+
MIN(CASE WHEN releaseOnAvailable = 0 THEN createdAt END) AS digestOldestAt,
|
|
1646
|
+
MIN(CASE WHEN releaseOnAvailable = 1 THEN createdAt END) AS availabilityOldestAt
|
|
1540
1647
|
FROM held_notices
|
|
1541
1648
|
WHERE project = ? AND (
|
|
1542
1649
|
(digestReportId IS NULL AND digestedAt IS NULL)
|
|
@@ -1581,6 +1688,23 @@ export function openStore(dbPath: string): Store {
|
|
|
1581
1688
|
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
|
|
1582
1689
|
)`,
|
|
1583
1690
|
);
|
|
1691
|
+
const insertIntake = db.query<unknown, SqlValue[]>(
|
|
1692
|
+
`INSERT INTO intake_items (id, project, text, createdAt, state)
|
|
1693
|
+
VALUES (?, ?, ?, ?, 'pending')`,
|
|
1694
|
+
);
|
|
1695
|
+
const selectPendingIntake = db.query<IntakeRow, [string]>(
|
|
1696
|
+
`SELECT * FROM intake_items
|
|
1697
|
+
WHERE project = ? AND state = 'pending'
|
|
1698
|
+
ORDER BY createdAt ASC, rowid ASC`,
|
|
1699
|
+
);
|
|
1700
|
+
const groomIntake = db.query<unknown, SqlValue[]>(
|
|
1701
|
+
`UPDATE intake_items SET state = 'groomed', issueUrl = ?, groomedAt = ?
|
|
1702
|
+
WHERE id = ? AND state = 'pending'`,
|
|
1703
|
+
);
|
|
1704
|
+
const dismissIntake = db.query<unknown, [string]>(
|
|
1705
|
+
`UPDATE intake_items SET state = 'dismissed', issueUrl = NULL, groomedAt = NULL
|
|
1706
|
+
WHERE id = ? AND state = 'pending'`,
|
|
1707
|
+
);
|
|
1584
1708
|
// The newest digest dedupe key a project has actually run toward — an
|
|
1585
1709
|
// `updatedAt`-newest row whose key is a digest prefix and that did not end in
|
|
1586
1710
|
// failure. The CLI refuses an off-schedule digest by comparing this against
|
|
@@ -1949,11 +2073,15 @@ export function openStore(dbPath: string): Store {
|
|
|
1949
2073
|
return createRunTx(r);
|
|
1950
2074
|
},
|
|
1951
2075
|
|
|
1952
|
-
updateRun(id: string, patch:
|
|
2076
|
+
updateRun(id: string, patch: RunPatch): void {
|
|
1953
2077
|
const assignments: string[] = [];
|
|
1954
2078
|
const values: SqlValue[] = [];
|
|
1955
2079
|
for (const [column, value] of Object.entries(patch)) {
|
|
1956
|
-
|
|
2080
|
+
// An explicitly-`undefined` key is the same as an absent one: a patch
|
|
2081
|
+
// built by spreading a partial result must not widen into columns the
|
|
2082
|
+
// caller did not mean to write (#468). Clearing a column still works by
|
|
2083
|
+
// naming it with an explicit `null`, which `toSql` maps to SQL NULL.
|
|
2084
|
+
if (!UPDATABLE_COLUMNS[column] || value === undefined) continue;
|
|
1957
2085
|
assignments.push(`${column} = ?`);
|
|
1958
2086
|
values.push(toSql(value));
|
|
1959
2087
|
}
|
|
@@ -1986,6 +2114,9 @@ export function openStore(dbPath: string): Store {
|
|
|
1986
2114
|
recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[] {
|
|
1987
2115
|
return selectRecentRuns.all(project, mergedSinceEpochMs).map(toRecord);
|
|
1988
2116
|
},
|
|
2117
|
+
runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[] {
|
|
2118
|
+
return selectRunsForPr.all(project, prUrl, mergedSinceEpochMs).map(toRecord);
|
|
2119
|
+
},
|
|
1989
2120
|
runsNeedingBaseCheck(project: string, limit = 20): RunRecord[] {
|
|
1990
2121
|
return selectPendingBaseChecks.all(project, limit).map(toRecord);
|
|
1991
2122
|
},
|
|
@@ -2041,6 +2172,9 @@ export function openStore(dbPath: string): Store {
|
|
|
2041
2172
|
const row = selectLatestRun.get(project, issue);
|
|
2042
2173
|
return row ? toRecord(row) : undefined;
|
|
2043
2174
|
},
|
|
2175
|
+
runsForIssue(project: string, issue: number): RunRecord[] {
|
|
2176
|
+
return selectRunsForIssue.all(project, issue).map(toRecord);
|
|
2177
|
+
},
|
|
2044
2178
|
|
|
2045
2179
|
setTurnOverride(project: string, issue: number, maxTurns: number, setAt = Date.now()): void {
|
|
2046
2180
|
setTurnOverrideTx(project, issue, maxTurns, setAt);
|
|
@@ -2066,11 +2200,6 @@ export function openStore(dbPath: string): Store {
|
|
|
2066
2200
|
return selectTurnOverrideLedger.all(project, issue, issue, opts.limit ?? 50);
|
|
2067
2201
|
},
|
|
2068
2202
|
|
|
2069
|
-
attemptReports(project: string, issue: number): { attempt: number; report: string }[] {
|
|
2070
|
-
return selectAttemptReports.all(project, issue);
|
|
2071
|
-
},
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
2203
|
runsStartedSince(project: string, sinceEpochMs: number): number {
|
|
2075
2204
|
return countStartedSince.get(project, sinceEpochMs)?.n ?? 0;
|
|
2076
2205
|
},
|
|
@@ -2079,6 +2208,10 @@ export function openStore(dbPath: string): Store {
|
|
|
2079
2208
|
return sumSpendSince.get(project, sinceEpochMs)?.total ?? 0;
|
|
2080
2209
|
},
|
|
2081
2210
|
|
|
2211
|
+
statsRuns(project: string, sinceEpochMs: number): RunRecord[] {
|
|
2212
|
+
return selectStatsRuns.all(project, sinceEpochMs, project, sinceEpochMs).map(toRecord);
|
|
2213
|
+
},
|
|
2214
|
+
|
|
2082
2215
|
wasNotified(key: string): boolean {
|
|
2083
2216
|
return (countNotified.get(key)?.n ?? 0) > 0;
|
|
2084
2217
|
},
|
|
@@ -2203,6 +2336,12 @@ export function openStore(dbPath: string): Store {
|
|
|
2203
2336
|
...(held?.oldestAt === null || held?.oldestAt === undefined
|
|
2204
2337
|
? {}
|
|
2205
2338
|
: { heldNoticeOldestAt: held.oldestAt }),
|
|
2339
|
+
...(held?.digestOldestAt === null || held?.digestOldestAt === undefined
|
|
2340
|
+
? {}
|
|
2341
|
+
: { digestHeldNoticeOldestAt: held.digestOldestAt }),
|
|
2342
|
+
...(held?.availabilityOldestAt === null || held?.availabilityOldestAt === undefined
|
|
2343
|
+
? {}
|
|
2344
|
+
: { availabilityHeldNoticeOldestAt: held.availabilityOldestAt }),
|
|
2206
2345
|
};
|
|
2207
2346
|
},
|
|
2208
2347
|
|
|
@@ -2226,6 +2365,10 @@ export function openStore(dbPath: string): Store {
|
|
|
2226
2365
|
return selectGhCalls.all(day);
|
|
2227
2366
|
},
|
|
2228
2367
|
|
|
2368
|
+
ghCallsBetween(sinceDay: string, untilDay: string): number {
|
|
2369
|
+
return sumGhCallsBetween.get(sinceDay, untilDay)?.total ?? 0;
|
|
2370
|
+
},
|
|
2371
|
+
|
|
2229
2372
|
pendingFriction(
|
|
2230
2373
|
project: string,
|
|
2231
2374
|
sinceEpochMs: number,
|
|
@@ -2375,6 +2518,31 @@ export function openStore(dbPath: string): Store {
|
|
|
2375
2518
|
.map(toReport);
|
|
2376
2519
|
},
|
|
2377
2520
|
|
|
2521
|
+
/**
|
|
2522
|
+
* One escalation handoff per event while the first has not landed (#347,
|
|
2523
|
+
* #453). The notifications ledger cannot decide this — its rows are
|
|
2524
|
+
* permanent, so an identical retry would be refused forever once the first
|
|
2525
|
+
* occurrence was marked (#453). The question is instead "is the first
|
|
2526
|
+
* handoff still owed?": an escalation report still in the outbox (the
|
|
2527
|
+
* `openReports` pending/sending/failed set) for the same project, category
|
|
2528
|
+
* and folded text, or a held notice of the same category and text that has
|
|
2529
|
+
* not been digested yet. Once the report is delivered, or the notice
|
|
2530
|
+
* digested, the identical text is a new occurrence, not a double-send.
|
|
2531
|
+
* Material reports describe one ordinary outcome each time and never
|
|
2532
|
+
* dedupe.
|
|
2533
|
+
*/
|
|
2534
|
+
hasOpenEscalationHandoff(project: string, category: InterruptCategory, body: string): boolean {
|
|
2535
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
2536
|
+
return (
|
|
2537
|
+
selectOpenReports
|
|
2538
|
+
.all(project)
|
|
2539
|
+
.some((row) => row.kind === category && row.body.replace(/\s+/g, " ").trim() === flat) ||
|
|
2540
|
+
selectUndigestedNotices
|
|
2541
|
+
.all(project, DIGEST_BACKLOG_LIMIT)
|
|
2542
|
+
.some((row) => row.category === category && row.detail.replace(/\s+/g, " ").trim() === flat)
|
|
2543
|
+
);
|
|
2544
|
+
},
|
|
2545
|
+
|
|
2378
2546
|
openReports(project: string): ReportRecord[] {
|
|
2379
2547
|
return selectOpenReports.all(project).map(toReport);
|
|
2380
2548
|
},
|
|
@@ -2421,6 +2589,11 @@ export function openStore(dbPath: string): Store {
|
|
|
2421
2589
|
return selectOpenDecisions.all(project).map(toDecision);
|
|
2422
2590
|
},
|
|
2423
2591
|
|
|
2592
|
+
decision(id: string): DecisionRecord | undefined {
|
|
2593
|
+
const row = selectDecision.get(id);
|
|
2594
|
+
return row === null ? undefined : toDecision(row);
|
|
2595
|
+
},
|
|
2596
|
+
|
|
2424
2597
|
resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean {
|
|
2425
2598
|
return resolveDecisionRow.run(state, resolution, at, id).changes > 0;
|
|
2426
2599
|
},
|
|
@@ -2532,6 +2705,30 @@ export function openStore(dbPath: string): Store {
|
|
|
2532
2705
|
return row === null ? undefined : row.createdAt;
|
|
2533
2706
|
},
|
|
2534
2707
|
|
|
2708
|
+
recordIntake(draft: IntakeDraft): IntakeItem {
|
|
2709
|
+
const item: IntakeItem = {
|
|
2710
|
+
id: newReportId(),
|
|
2711
|
+
project: draft.project,
|
|
2712
|
+
text: draft.text,
|
|
2713
|
+
createdAt: draft.at,
|
|
2714
|
+
state: "pending",
|
|
2715
|
+
};
|
|
2716
|
+
insertIntake.run(item.id, item.project, item.text, item.createdAt);
|
|
2717
|
+
return item;
|
|
2718
|
+
},
|
|
2719
|
+
|
|
2720
|
+
pendingIntake(project: string): IntakeItem[] {
|
|
2721
|
+
return selectPendingIntake.all(project).map(toIntakeItem);
|
|
2722
|
+
},
|
|
2723
|
+
|
|
2724
|
+
resolveIntake(id: string, state: "groomed" | "dismissed", issueUrl?: string): boolean {
|
|
2725
|
+
const result =
|
|
2726
|
+
state === "groomed"
|
|
2727
|
+
? groomIntake.run(issueUrl ?? null, Date.now(), id)
|
|
2728
|
+
: dismissIntake.run(id);
|
|
2729
|
+
return result.changes > 0;
|
|
2730
|
+
},
|
|
2731
|
+
|
|
2535
2732
|
close(): void {
|
|
2536
2733
|
db.close(false);
|
|
2537
2734
|
},
|
package/src/tracker/github.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { credentialedEnv } from "../gitops.ts";
|
|
25
25
|
import { parsePrDiff } from "../diff-flags.ts";
|
|
26
26
|
import type {
|
|
27
|
+
IssueComment,
|
|
27
28
|
IssueState,
|
|
28
29
|
IssueSnapshot,
|
|
29
30
|
MergedPrInfo,
|
|
@@ -136,7 +137,7 @@ export class GhError extends Error {
|
|
|
136
137
|
* and closed, which is how bodies containing newlines, backticks or a leading
|
|
137
138
|
* `-` reach `gh` without ever being interpolated into argv.
|
|
138
139
|
*/
|
|
139
|
-
async function gh(argv: string[], stdin?: string): Promise<string> {
|
|
140
|
+
export async function gh(argv: string[], stdin?: string): Promise<string> {
|
|
140
141
|
const proc = Bun.spawn(["gh", ...argv], {
|
|
141
142
|
// Always a closed stream: commands that do not read stdin see immediate
|
|
142
143
|
// EOF instead of an open pipe nobody ends.
|
|
@@ -392,14 +393,33 @@ function checkVerdict(check: GhCheck): CheckVerdict {
|
|
|
392
393
|
* Verify one `gh pr view --json state,isDraft,headRefOid,statusCheckRollup`
|
|
393
394
|
* payload, optionally against a caller-observed head. A missing rollup is
|
|
394
395
|
* pending rather than green because GitHub may not have created the checks yet.
|
|
396
|
+
*
|
|
397
|
+
* By default this answers the merge gate's question — "may this merge" — and a
|
|
398
|
+
* non-open PR is a refusal (`expected OPEN`), so a mutating verb never acts on
|
|
399
|
+
* a merged or closed PR. `opts.read` (the `conductor_pr_status` surface) asks
|
|
400
|
+
* a different question: *what is this PR's state*, and a merged or closed PR
|
|
401
|
+
* is reported as a fact rather than a failure — the merge gate paths never
|
|
402
|
+
* pass `read`, so the `expected OPEN` refusal stays exactly where it gates.
|
|
395
403
|
*/
|
|
396
|
-
export function prVerificationFrom(
|
|
404
|
+
export function prVerificationFrom(
|
|
405
|
+
raw: string,
|
|
406
|
+
expectedHead?: string,
|
|
407
|
+
opts?: { read?: boolean },
|
|
408
|
+
): PrVerification {
|
|
397
409
|
const pr = JSON.parse(raw) as GhPrVerification;
|
|
398
410
|
if (typeof pr.headRefOid !== "string" || pr.headRefOid === "") {
|
|
399
411
|
throw new Error("PR head is unavailable");
|
|
400
412
|
}
|
|
401
413
|
const headSha = pr.headRefOid;
|
|
402
414
|
if (pr.state !== "OPEN") {
|
|
415
|
+
if (opts?.read === true) {
|
|
416
|
+
const stateName = pr.state?.toLowerCase() ?? "unknown";
|
|
417
|
+
return {
|
|
418
|
+
status: "failed",
|
|
419
|
+
reason: `PR is ${stateName}; checks are unavailable for a non-open PR`,
|
|
420
|
+
headSha,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
403
423
|
return { status: "failed", reason: `PR is ${pr.state ?? "unknown"}, expected OPEN`, headSha };
|
|
404
424
|
}
|
|
405
425
|
if (pr.isDraft !== false) {
|
|
@@ -741,6 +761,36 @@ export function readyIssuesFromRest(raw: string): ReadyIssue[] {
|
|
|
741
761
|
return issues;
|
|
742
762
|
}
|
|
743
763
|
|
|
764
|
+
/**
|
|
765
|
+
* Parse the REST `/repos/{owner}/{repo}/issues/{n}/comments` body — already
|
|
766
|
+
* projected by jq to `[{author, body}]` — onto {@link IssueComment}.
|
|
767
|
+
*
|
|
768
|
+
* The REST endpoint answers chronological order (oldest first), so the array
|
|
769
|
+
* order is the order the brief renders. Nulls and unrecognizable payloads are
|
|
770
|
+
* tolerated the same way {@link readyIssuesFromRest} tolerates them: an empty
|
|
771
|
+
* answer means "no comments", and *only* a thrown read means "could not tell" —
|
|
772
|
+
* the caller distinguishes the two, because eliding them is the #517 failure.
|
|
773
|
+
*/
|
|
774
|
+
export function commentsFrom(raw: string): IssueComment[] {
|
|
775
|
+
let parsed: unknown;
|
|
776
|
+
try {
|
|
777
|
+
parsed = JSON.parse(raw) as unknown;
|
|
778
|
+
} catch {
|
|
779
|
+
return [];
|
|
780
|
+
}
|
|
781
|
+
if (!Array.isArray(parsed)) return [];
|
|
782
|
+
const comments: IssueComment[] = [];
|
|
783
|
+
for (const entry of parsed) {
|
|
784
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
785
|
+
const row = entry as { readonly [key: string]: unknown };
|
|
786
|
+
const author = row["author"];
|
|
787
|
+
const body = row["body"];
|
|
788
|
+
if (typeof author !== "string" || typeof body !== "string") continue;
|
|
789
|
+
comments.push({ author, body });
|
|
790
|
+
}
|
|
791
|
+
return comments;
|
|
792
|
+
}
|
|
793
|
+
|
|
744
794
|
/** One cached page per request URL: the ETag GitHub answered, the raw JSON
|
|
745
795
|
* body it covered, and whether that page advertised a rel="next" successor.
|
|
746
796
|
* Module-level default so every makeTracker() in the process (board probe,
|
|
@@ -1084,6 +1134,22 @@ export function makeTracker(
|
|
|
1084
1134
|
return pages.flatMap(readyIssuesFromRest);
|
|
1085
1135
|
},
|
|
1086
1136
|
|
|
1137
|
+
async listComments(issue: number): Promise<IssueComment[]> {
|
|
1138
|
+
// REST, like `issueSnapshot` — deliberately not `gh issue view
|
|
1139
|
+
// --comments`, whose renderer goes through the interactive pager and
|
|
1140
|
+
// prints nothing on a non-TTY while still exiting 0 (the #517 failure).
|
|
1141
|
+
// `gh api` output is never paged. One page: the brief truncates a huge
|
|
1142
|
+
// thread with an explicit marker rather than paginating.
|
|
1143
|
+
return commentsFrom(
|
|
1144
|
+
await runGh([
|
|
1145
|
+
"api",
|
|
1146
|
+
`repos/${repo}/issues/${issue}/comments`,
|
|
1147
|
+
"--jq",
|
|
1148
|
+
"[.[] | {author: .user.login, body: .body}]",
|
|
1149
|
+
]),
|
|
1150
|
+
);
|
|
1151
|
+
},
|
|
1152
|
+
|
|
1087
1153
|
async addLabel(issue: number, label: string): Promise<void> {
|
|
1088
1154
|
try {
|
|
1089
1155
|
// REST POST is natively idempotent: re-adding a label the issue already
|
|
@@ -1275,7 +1341,11 @@ export function makeTracker(
|
|
|
1275
1341
|
}
|
|
1276
1342
|
},
|
|
1277
1343
|
|
|
1278
|
-
async verifyPr(
|
|
1344
|
+
async verifyPr(
|
|
1345
|
+
url: string,
|
|
1346
|
+
expectedHead?: string,
|
|
1347
|
+
opts?: { read?: boolean },
|
|
1348
|
+
): Promise<PrVerification | undefined> {
|
|
1279
1349
|
if (!PR_URL.test(url)) return undefined;
|
|
1280
1350
|
try {
|
|
1281
1351
|
const raw = await runGh([
|
|
@@ -1285,7 +1355,7 @@ export function makeTracker(
|
|
|
1285
1355
|
"--json",
|
|
1286
1356
|
"state,isDraft,headRefOid,statusCheckRollup",
|
|
1287
1357
|
]);
|
|
1288
|
-
const verification = prVerificationFrom(raw, expectedHead);
|
|
1358
|
+
const verification = prVerificationFrom(raw, expectedHead, opts);
|
|
1289
1359
|
if (verification.status !== "failed") return verification;
|
|
1290
1360
|
|
|
1291
1361
|
const detailsUrl = failedCheck(raw)?.detailsUrl;
|