omp-conductor 0.15.9 → 0.15.11
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 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -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 +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- 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 +49 -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 +98 -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 +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -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 +23 -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 +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -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 +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- 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,
|
|
@@ -292,6 +296,34 @@ interface MergeLockRow {
|
|
|
292
296
|
at: number;
|
|
293
297
|
}
|
|
294
298
|
|
|
299
|
+
/** The `intake_items` table exactly as SQLite hands it back (#299). */
|
|
300
|
+
interface IntakeRow {
|
|
301
|
+
id: string;
|
|
302
|
+
project: string;
|
|
303
|
+
text: string;
|
|
304
|
+
createdAt: number;
|
|
305
|
+
state: string;
|
|
306
|
+
issueUrl: string | null;
|
|
307
|
+
groomedAt: number | null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* NULL columns become absent properties, matching the other row converters:
|
|
312
|
+
* a record read back out of the store deep-equals the one that went in.
|
|
313
|
+
*/
|
|
314
|
+
function toIntakeItem(row: IntakeRow): IntakeItem {
|
|
315
|
+
const item: IntakeItem = {
|
|
316
|
+
id: row.id,
|
|
317
|
+
project: row.project,
|
|
318
|
+
text: row.text,
|
|
319
|
+
createdAt: row.createdAt,
|
|
320
|
+
state: row.state as IntakeState,
|
|
321
|
+
...(row.issueUrl === null ? {} : { issueUrl: row.issueUrl }),
|
|
322
|
+
...(row.groomedAt === null ? {} : { groomedAt: row.groomedAt }),
|
|
323
|
+
};
|
|
324
|
+
return item;
|
|
325
|
+
}
|
|
326
|
+
|
|
295
327
|
const SCHEMA = `
|
|
296
328
|
CREATE TABLE IF NOT EXISTS runs (
|
|
297
329
|
id TEXT PRIMARY KEY,
|
|
@@ -575,6 +607,23 @@ CREATE TABLE IF NOT EXISTS label_ops (
|
|
|
575
607
|
lastError TEXT
|
|
576
608
|
);
|
|
577
609
|
CREATE INDEX IF NOT EXISTS label_ops_project_next ON label_ops (project, nextAttemptAt);
|
|
610
|
+
|
|
611
|
+
-- Raw ideas captured by the intake command (#299), held durably until
|
|
612
|
+
-- #300 resolves them into issues. A thought from 02:00 must survive a restart
|
|
613
|
+
-- and a reinstall, so it lives here rather than in any process's memory. The
|
|
614
|
+
-- state CHECK keeps the three-state vocabulary exact; issueUrl and groomedAt
|
|
615
|
+
-- stay NULL until an idea becomes (or is dropped instead of) an issue.
|
|
616
|
+
CREATE TABLE IF NOT EXISTS intake_items (
|
|
617
|
+
id TEXT PRIMARY KEY,
|
|
618
|
+
project TEXT NOT NULL,
|
|
619
|
+
text TEXT NOT NULL,
|
|
620
|
+
createdAt INTEGER NOT NULL,
|
|
621
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'groomed', 'dismissed')),
|
|
622
|
+
issueUrl TEXT,
|
|
623
|
+
groomedAt INTEGER
|
|
624
|
+
);
|
|
625
|
+
CREATE INDEX IF NOT EXISTS intake_items_project_state
|
|
626
|
+
ON intake_items (project, state, createdAt);
|
|
578
627
|
`;
|
|
579
628
|
|
|
580
629
|
/**
|
|
@@ -937,10 +986,10 @@ export function openStore(dbPath: string): Store {
|
|
|
937
986
|
if (!columns.some((column) => column.name === "settlementFlags")) {
|
|
938
987
|
db.exec("ALTER TABLE runs ADD COLUMN settlementFlags TEXT");
|
|
939
988
|
}
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
//
|
|
989
|
+
// The settlement report was first persisted in #199; rows written before it
|
|
990
|
+
// simply have no report, which is the honest reading of a kill that never
|
|
991
|
+
// produced one. No backfill: the bytes only ever existed in a worker's
|
|
992
|
+
// memory.
|
|
944
993
|
if (!columns.some((column) => column.name === "report")) {
|
|
945
994
|
db.exec("ALTER TABLE runs ADD COLUMN report TEXT");
|
|
946
995
|
}
|
|
@@ -1101,6 +1150,38 @@ export function openStore(dbPath: string): Store {
|
|
|
1101
1150
|
AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
|
|
1102
1151
|
ORDER BY startedAt DESC`,
|
|
1103
1152
|
);
|
|
1153
|
+
// Every attempt that recorded one PR URL, newest first — deliberately NOT the
|
|
1154
|
+
// `recentRuns` newest-attempt-per-issue view. Mediated PR verbs resolve a
|
|
1155
|
+
// PR's owner through this: a requeued continuation hides its predecessor's
|
|
1156
|
+
// PR from `recentRuns` the moment it is created, so ownership must be able
|
|
1157
|
+
// to read past the issue's newest row (#434). Merged rows keep the same
|
|
1158
|
+
// recent-history bound as `recentRuns`.
|
|
1159
|
+
const selectRunsForPr = db.query<RunRow, [string, string, number]>(
|
|
1160
|
+
`SELECT * FROM runs
|
|
1161
|
+
WHERE project = ? AND prUrl = ?
|
|
1162
|
+
AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
|
|
1163
|
+
ORDER BY startedAt DESC, rowid DESC`,
|
|
1164
|
+
);
|
|
1165
|
+
// The stats window (#282): rows settling at or after `since` — merged rows
|
|
1166
|
+
// included, so a chain whose PR merged inside the window is an outcome —
|
|
1167
|
+
// plus, for those merged issues, their WHOLE attempt chain even when it
|
|
1168
|
+
// started before the window. A continuation that began weeks ago and merged
|
|
1169
|
+
// today must report one lead time from its first claim, not from the window
|
|
1170
|
+
// start; ordering by issue/startedAt makes the first row of each chain its
|
|
1171
|
+
// first queue-label claim.
|
|
1172
|
+
const selectStatsRuns = db.query<RunRow, [string, number, string, number]>(
|
|
1173
|
+
`SELECT * FROM runs
|
|
1174
|
+
WHERE project = ?
|
|
1175
|
+
AND (
|
|
1176
|
+
COALESCE(endedAt, startedAt) >= ?
|
|
1177
|
+
OR issue IN (
|
|
1178
|
+
SELECT issue FROM runs
|
|
1179
|
+
WHERE project = ? AND state = 'merged'
|
|
1180
|
+
AND COALESCE(endedAt, startedAt) >= ?
|
|
1181
|
+
)
|
|
1182
|
+
)
|
|
1183
|
+
ORDER BY issue ASC, startedAt ASC, rowid ASC`,
|
|
1184
|
+
);
|
|
1104
1185
|
const selectPendingBaseChecks = db.query<RunRow, [string, number]>(
|
|
1105
1186
|
`SELECT * FROM runs
|
|
1106
1187
|
WHERE project = ? AND state = 'merged' AND baseCheck = 'pending'
|
|
@@ -1215,6 +1296,14 @@ export function openStore(dbPath: string): Store {
|
|
|
1215
1296
|
ORDER BY startedAt DESC, rowid DESC
|
|
1216
1297
|
LIMIT 1`,
|
|
1217
1298
|
);
|
|
1299
|
+
// Every attempt for one issue, oldest first. An exhaustion escalation groups
|
|
1300
|
+
// these by `failureClass` to report how the continuation budget was spent
|
|
1301
|
+
// (#439); the numeric `*For` counters cannot reconstruct that on their own.
|
|
1302
|
+
const selectRunsForIssue = db.query<RunRow, [string, number]>(
|
|
1303
|
+
`SELECT * FROM runs
|
|
1304
|
+
WHERE project = ? AND issue = ?
|
|
1305
|
+
ORDER BY startedAt ASC, rowid ASC`,
|
|
1306
|
+
);
|
|
1218
1307
|
const upsertTurnOverride = db.query<unknown, [string, number, number, number]>(
|
|
1219
1308
|
`INSERT INTO turn_overrides (project, issue, maxTurns, setAt) VALUES (?, ?, ?, ?)
|
|
1220
1309
|
ON CONFLICT(project, issue) DO UPDATE SET
|
|
@@ -1247,14 +1336,8 @@ export function openStore(dbPath: string): Store {
|
|
|
1247
1336
|
ORDER BY setAt DESC, rowid DESC
|
|
1248
1337
|
LIMIT ?`,
|
|
1249
1338
|
);
|
|
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
|
-
);
|
|
1339
|
+
// The settlement report row is kept for the audit trail; nothing pools it
|
|
1340
|
+
// across attempts any more (#488).
|
|
1258
1341
|
const countStartedSince = db.query<{ n: number }, [string, number]>(
|
|
1259
1342
|
`SELECT COUNT(*) AS n FROM runs WHERE project = ? AND startedAt >= ?`,
|
|
1260
1343
|
);
|
|
@@ -1331,6 +1414,10 @@ export function openStore(dbPath: string): Store {
|
|
|
1331
1414
|
const selectGhCalls = db.query<{ source: string; calls: number }, [string]>(
|
|
1332
1415
|
`SELECT source, calls FROM gh_calls WHERE day = ? ORDER BY source`,
|
|
1333
1416
|
);
|
|
1417
|
+
const sumGhCallsBetween = db.query<{ total: number }, [string, string]>(
|
|
1418
|
+
`SELECT COALESCE(SUM(calls), 0) AS total FROM gh_calls
|
|
1419
|
+
WHERE day BETWEEN ? AND ?`,
|
|
1420
|
+
);
|
|
1334
1421
|
|
|
1335
1422
|
// The report outbox (#123). Every terminal transition names the attempt it is
|
|
1336
1423
|
// settling, because a request can come back *after* the stale-`sending` sweep
|
|
@@ -1531,12 +1618,20 @@ export function openStore(dbPath: string): Store {
|
|
|
1531
1618
|
LIMIT ?`,
|
|
1532
1619
|
);
|
|
1533
1620
|
const selectHeldNoticeBacklog = db.query<
|
|
1534
|
-
{
|
|
1621
|
+
{
|
|
1622
|
+
count: number;
|
|
1623
|
+
availabilityCount: number | null;
|
|
1624
|
+
oldestAt: number | null;
|
|
1625
|
+
digestOldestAt: number | null;
|
|
1626
|
+
availabilityOldestAt: number | null;
|
|
1627
|
+
},
|
|
1535
1628
|
[string]
|
|
1536
1629
|
>(
|
|
1537
1630
|
`SELECT COUNT(*) AS count,
|
|
1538
1631
|
SUM(CASE WHEN releaseOnAvailable = 1 THEN 1 ELSE 0 END) AS availabilityCount,
|
|
1539
|
-
MIN(createdAt) AS oldestAt
|
|
1632
|
+
MIN(createdAt) AS oldestAt,
|
|
1633
|
+
MIN(CASE WHEN releaseOnAvailable = 0 THEN createdAt END) AS digestOldestAt,
|
|
1634
|
+
MIN(CASE WHEN releaseOnAvailable = 1 THEN createdAt END) AS availabilityOldestAt
|
|
1540
1635
|
FROM held_notices
|
|
1541
1636
|
WHERE project = ? AND (
|
|
1542
1637
|
(digestReportId IS NULL AND digestedAt IS NULL)
|
|
@@ -1581,6 +1676,23 @@ export function openStore(dbPath: string): Store {
|
|
|
1581
1676
|
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
|
|
1582
1677
|
)`,
|
|
1583
1678
|
);
|
|
1679
|
+
const insertIntake = db.query<unknown, SqlValue[]>(
|
|
1680
|
+
`INSERT INTO intake_items (id, project, text, createdAt, state)
|
|
1681
|
+
VALUES (?, ?, ?, ?, 'pending')`,
|
|
1682
|
+
);
|
|
1683
|
+
const selectPendingIntake = db.query<IntakeRow, [string]>(
|
|
1684
|
+
`SELECT * FROM intake_items
|
|
1685
|
+
WHERE project = ? AND state = 'pending'
|
|
1686
|
+
ORDER BY createdAt ASC, rowid ASC`,
|
|
1687
|
+
);
|
|
1688
|
+
const groomIntake = db.query<unknown, SqlValue[]>(
|
|
1689
|
+
`UPDATE intake_items SET state = 'groomed', issueUrl = ?, groomedAt = ?
|
|
1690
|
+
WHERE id = ? AND state = 'pending'`,
|
|
1691
|
+
);
|
|
1692
|
+
const dismissIntake = db.query<unknown, [string]>(
|
|
1693
|
+
`UPDATE intake_items SET state = 'dismissed', issueUrl = NULL, groomedAt = NULL
|
|
1694
|
+
WHERE id = ? AND state = 'pending'`,
|
|
1695
|
+
);
|
|
1584
1696
|
// The newest digest dedupe key a project has actually run toward — an
|
|
1585
1697
|
// `updatedAt`-newest row whose key is a digest prefix and that did not end in
|
|
1586
1698
|
// failure. The CLI refuses an off-schedule digest by comparing this against
|
|
@@ -1949,11 +2061,15 @@ export function openStore(dbPath: string): Store {
|
|
|
1949
2061
|
return createRunTx(r);
|
|
1950
2062
|
},
|
|
1951
2063
|
|
|
1952
|
-
updateRun(id: string, patch:
|
|
2064
|
+
updateRun(id: string, patch: RunPatch): void {
|
|
1953
2065
|
const assignments: string[] = [];
|
|
1954
2066
|
const values: SqlValue[] = [];
|
|
1955
2067
|
for (const [column, value] of Object.entries(patch)) {
|
|
1956
|
-
|
|
2068
|
+
// An explicitly-`undefined` key is the same as an absent one: a patch
|
|
2069
|
+
// built by spreading a partial result must not widen into columns the
|
|
2070
|
+
// caller did not mean to write (#468). Clearing a column still works by
|
|
2071
|
+
// naming it with an explicit `null`, which `toSql` maps to SQL NULL.
|
|
2072
|
+
if (!UPDATABLE_COLUMNS[column] || value === undefined) continue;
|
|
1957
2073
|
assignments.push(`${column} = ?`);
|
|
1958
2074
|
values.push(toSql(value));
|
|
1959
2075
|
}
|
|
@@ -1986,6 +2102,9 @@ export function openStore(dbPath: string): Store {
|
|
|
1986
2102
|
recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[] {
|
|
1987
2103
|
return selectRecentRuns.all(project, mergedSinceEpochMs).map(toRecord);
|
|
1988
2104
|
},
|
|
2105
|
+
runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[] {
|
|
2106
|
+
return selectRunsForPr.all(project, prUrl, mergedSinceEpochMs).map(toRecord);
|
|
2107
|
+
},
|
|
1989
2108
|
runsNeedingBaseCheck(project: string, limit = 20): RunRecord[] {
|
|
1990
2109
|
return selectPendingBaseChecks.all(project, limit).map(toRecord);
|
|
1991
2110
|
},
|
|
@@ -2041,6 +2160,9 @@ export function openStore(dbPath: string): Store {
|
|
|
2041
2160
|
const row = selectLatestRun.get(project, issue);
|
|
2042
2161
|
return row ? toRecord(row) : undefined;
|
|
2043
2162
|
},
|
|
2163
|
+
runsForIssue(project: string, issue: number): RunRecord[] {
|
|
2164
|
+
return selectRunsForIssue.all(project, issue).map(toRecord);
|
|
2165
|
+
},
|
|
2044
2166
|
|
|
2045
2167
|
setTurnOverride(project: string, issue: number, maxTurns: number, setAt = Date.now()): void {
|
|
2046
2168
|
setTurnOverrideTx(project, issue, maxTurns, setAt);
|
|
@@ -2066,11 +2188,6 @@ export function openStore(dbPath: string): Store {
|
|
|
2066
2188
|
return selectTurnOverrideLedger.all(project, issue, issue, opts.limit ?? 50);
|
|
2067
2189
|
},
|
|
2068
2190
|
|
|
2069
|
-
attemptReports(project: string, issue: number): { attempt: number; report: string }[] {
|
|
2070
|
-
return selectAttemptReports.all(project, issue);
|
|
2071
|
-
},
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
2191
|
runsStartedSince(project: string, sinceEpochMs: number): number {
|
|
2075
2192
|
return countStartedSince.get(project, sinceEpochMs)?.n ?? 0;
|
|
2076
2193
|
},
|
|
@@ -2079,6 +2196,10 @@ export function openStore(dbPath: string): Store {
|
|
|
2079
2196
|
return sumSpendSince.get(project, sinceEpochMs)?.total ?? 0;
|
|
2080
2197
|
},
|
|
2081
2198
|
|
|
2199
|
+
statsRuns(project: string, sinceEpochMs: number): RunRecord[] {
|
|
2200
|
+
return selectStatsRuns.all(project, sinceEpochMs, project, sinceEpochMs).map(toRecord);
|
|
2201
|
+
},
|
|
2202
|
+
|
|
2082
2203
|
wasNotified(key: string): boolean {
|
|
2083
2204
|
return (countNotified.get(key)?.n ?? 0) > 0;
|
|
2084
2205
|
},
|
|
@@ -2203,6 +2324,12 @@ export function openStore(dbPath: string): Store {
|
|
|
2203
2324
|
...(held?.oldestAt === null || held?.oldestAt === undefined
|
|
2204
2325
|
? {}
|
|
2205
2326
|
: { heldNoticeOldestAt: held.oldestAt }),
|
|
2327
|
+
...(held?.digestOldestAt === null || held?.digestOldestAt === undefined
|
|
2328
|
+
? {}
|
|
2329
|
+
: { digestHeldNoticeOldestAt: held.digestOldestAt }),
|
|
2330
|
+
...(held?.availabilityOldestAt === null || held?.availabilityOldestAt === undefined
|
|
2331
|
+
? {}
|
|
2332
|
+
: { availabilityHeldNoticeOldestAt: held.availabilityOldestAt }),
|
|
2206
2333
|
};
|
|
2207
2334
|
},
|
|
2208
2335
|
|
|
@@ -2226,6 +2353,10 @@ export function openStore(dbPath: string): Store {
|
|
|
2226
2353
|
return selectGhCalls.all(day);
|
|
2227
2354
|
},
|
|
2228
2355
|
|
|
2356
|
+
ghCallsBetween(sinceDay: string, untilDay: string): number {
|
|
2357
|
+
return sumGhCallsBetween.get(sinceDay, untilDay)?.total ?? 0;
|
|
2358
|
+
},
|
|
2359
|
+
|
|
2229
2360
|
pendingFriction(
|
|
2230
2361
|
project: string,
|
|
2231
2362
|
sinceEpochMs: number,
|
|
@@ -2375,6 +2506,31 @@ export function openStore(dbPath: string): Store {
|
|
|
2375
2506
|
.map(toReport);
|
|
2376
2507
|
},
|
|
2377
2508
|
|
|
2509
|
+
/**
|
|
2510
|
+
* One escalation handoff per event while the first has not landed (#347,
|
|
2511
|
+
* #453). The notifications ledger cannot decide this — its rows are
|
|
2512
|
+
* permanent, so an identical retry would be refused forever once the first
|
|
2513
|
+
* occurrence was marked (#453). The question is instead "is the first
|
|
2514
|
+
* handoff still owed?": an escalation report still in the outbox (the
|
|
2515
|
+
* `openReports` pending/sending/failed set) for the same project, category
|
|
2516
|
+
* and folded text, or a held notice of the same category and text that has
|
|
2517
|
+
* not been digested yet. Once the report is delivered, or the notice
|
|
2518
|
+
* digested, the identical text is a new occurrence, not a double-send.
|
|
2519
|
+
* Material reports describe one ordinary outcome each time and never
|
|
2520
|
+
* dedupe.
|
|
2521
|
+
*/
|
|
2522
|
+
hasOpenEscalationHandoff(project: string, category: InterruptCategory, body: string): boolean {
|
|
2523
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
2524
|
+
return (
|
|
2525
|
+
selectOpenReports
|
|
2526
|
+
.all(project)
|
|
2527
|
+
.some((row) => row.kind === category && row.body.replace(/\s+/g, " ").trim() === flat) ||
|
|
2528
|
+
selectUndigestedNotices
|
|
2529
|
+
.all(project, DIGEST_BACKLOG_LIMIT)
|
|
2530
|
+
.some((row) => row.category === category && row.detail.replace(/\s+/g, " ").trim() === flat)
|
|
2531
|
+
);
|
|
2532
|
+
},
|
|
2533
|
+
|
|
2378
2534
|
openReports(project: string): ReportRecord[] {
|
|
2379
2535
|
return selectOpenReports.all(project).map(toReport);
|
|
2380
2536
|
},
|
|
@@ -2421,6 +2577,11 @@ export function openStore(dbPath: string): Store {
|
|
|
2421
2577
|
return selectOpenDecisions.all(project).map(toDecision);
|
|
2422
2578
|
},
|
|
2423
2579
|
|
|
2580
|
+
decision(id: string): DecisionRecord | undefined {
|
|
2581
|
+
const row = selectDecision.get(id);
|
|
2582
|
+
return row === null ? undefined : toDecision(row);
|
|
2583
|
+
},
|
|
2584
|
+
|
|
2424
2585
|
resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean {
|
|
2425
2586
|
return resolveDecisionRow.run(state, resolution, at, id).changes > 0;
|
|
2426
2587
|
},
|
|
@@ -2532,6 +2693,30 @@ export function openStore(dbPath: string): Store {
|
|
|
2532
2693
|
return row === null ? undefined : row.createdAt;
|
|
2533
2694
|
},
|
|
2534
2695
|
|
|
2696
|
+
recordIntake(draft: IntakeDraft): IntakeItem {
|
|
2697
|
+
const item: IntakeItem = {
|
|
2698
|
+
id: newReportId(),
|
|
2699
|
+
project: draft.project,
|
|
2700
|
+
text: draft.text,
|
|
2701
|
+
createdAt: draft.at,
|
|
2702
|
+
state: "pending",
|
|
2703
|
+
};
|
|
2704
|
+
insertIntake.run(item.id, item.project, item.text, item.createdAt);
|
|
2705
|
+
return item;
|
|
2706
|
+
},
|
|
2707
|
+
|
|
2708
|
+
pendingIntake(project: string): IntakeItem[] {
|
|
2709
|
+
return selectPendingIntake.all(project).map(toIntakeItem);
|
|
2710
|
+
},
|
|
2711
|
+
|
|
2712
|
+
resolveIntake(id: string, state: "groomed" | "dismissed", issueUrl?: string): boolean {
|
|
2713
|
+
const result =
|
|
2714
|
+
state === "groomed"
|
|
2715
|
+
? groomIntake.run(issueUrl ?? null, Date.now(), id)
|
|
2716
|
+
: dismissIntake.run(id);
|
|
2717
|
+
return result.changes > 0;
|
|
2718
|
+
},
|
|
2719
|
+
|
|
2535
2720
|
close(): void {
|
|
2536
2721
|
db.close(false);
|
|
2537
2722
|
},
|
package/src/tracker/github.ts
CHANGED
|
@@ -136,7 +136,7 @@ export class GhError extends Error {
|
|
|
136
136
|
* and closed, which is how bodies containing newlines, backticks or a leading
|
|
137
137
|
* `-` reach `gh` without ever being interpolated into argv.
|
|
138
138
|
*/
|
|
139
|
-
async function gh(argv: string[], stdin?: string): Promise<string> {
|
|
139
|
+
export async function gh(argv: string[], stdin?: string): Promise<string> {
|
|
140
140
|
const proc = Bun.spawn(["gh", ...argv], {
|
|
141
141
|
// Always a closed stream: commands that do not read stdin see immediate
|
|
142
142
|
// EOF instead of an open pipe nobody ends.
|
|
@@ -392,14 +392,33 @@ function checkVerdict(check: GhCheck): CheckVerdict {
|
|
|
392
392
|
* Verify one `gh pr view --json state,isDraft,headRefOid,statusCheckRollup`
|
|
393
393
|
* payload, optionally against a caller-observed head. A missing rollup is
|
|
394
394
|
* pending rather than green because GitHub may not have created the checks yet.
|
|
395
|
+
*
|
|
396
|
+
* By default this answers the merge gate's question — "may this merge" — and a
|
|
397
|
+
* non-open PR is a refusal (`expected OPEN`), so a mutating verb never acts on
|
|
398
|
+
* a merged or closed PR. `opts.read` (the `conductor_pr_status` surface) asks
|
|
399
|
+
* a different question: *what is this PR's state*, and a merged or closed PR
|
|
400
|
+
* is reported as a fact rather than a failure — the merge gate paths never
|
|
401
|
+
* pass `read`, so the `expected OPEN` refusal stays exactly where it gates.
|
|
395
402
|
*/
|
|
396
|
-
export function prVerificationFrom(
|
|
403
|
+
export function prVerificationFrom(
|
|
404
|
+
raw: string,
|
|
405
|
+
expectedHead?: string,
|
|
406
|
+
opts?: { read?: boolean },
|
|
407
|
+
): PrVerification {
|
|
397
408
|
const pr = JSON.parse(raw) as GhPrVerification;
|
|
398
409
|
if (typeof pr.headRefOid !== "string" || pr.headRefOid === "") {
|
|
399
410
|
throw new Error("PR head is unavailable");
|
|
400
411
|
}
|
|
401
412
|
const headSha = pr.headRefOid;
|
|
402
413
|
if (pr.state !== "OPEN") {
|
|
414
|
+
if (opts?.read === true) {
|
|
415
|
+
const stateName = pr.state?.toLowerCase() ?? "unknown";
|
|
416
|
+
return {
|
|
417
|
+
status: "failed",
|
|
418
|
+
reason: `PR is ${stateName}; checks are unavailable for a non-open PR`,
|
|
419
|
+
headSha,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
403
422
|
return { status: "failed", reason: `PR is ${pr.state ?? "unknown"}, expected OPEN`, headSha };
|
|
404
423
|
}
|
|
405
424
|
if (pr.isDraft !== false) {
|
|
@@ -1275,7 +1294,11 @@ export function makeTracker(
|
|
|
1275
1294
|
}
|
|
1276
1295
|
},
|
|
1277
1296
|
|
|
1278
|
-
async verifyPr(
|
|
1297
|
+
async verifyPr(
|
|
1298
|
+
url: string,
|
|
1299
|
+
expectedHead?: string,
|
|
1300
|
+
opts?: { read?: boolean },
|
|
1301
|
+
): Promise<PrVerification | undefined> {
|
|
1279
1302
|
if (!PR_URL.test(url)) return undefined;
|
|
1280
1303
|
try {
|
|
1281
1304
|
const raw = await runGh([
|
|
@@ -1285,7 +1308,7 @@ export function makeTracker(
|
|
|
1285
1308
|
"--json",
|
|
1286
1309
|
"state,isDraft,headRefOid,statusCheckRollup",
|
|
1287
1310
|
]);
|
|
1288
|
-
const verification = prVerificationFrom(raw, expectedHead);
|
|
1311
|
+
const verification = prVerificationFrom(raw, expectedHead, opts);
|
|
1289
1312
|
if (verification.status !== "failed") return verification;
|
|
1290
1313
|
|
|
1291
1314
|
const detailsUrl = failedCheck(raw)?.detailsUrl;
|