omp-conductor 0.15.11 → 0.15.13
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/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/setup.ts
CHANGED
|
@@ -155,6 +155,14 @@ export interface SetupAnswers {
|
|
|
155
155
|
* survive an amend of some other area (#369).
|
|
156
156
|
*/
|
|
157
157
|
groomBelow?: number;
|
|
158
|
+
/**
|
|
159
|
+
* Ordered fallback worker models and the provider-failure threshold that
|
|
160
|
+
* starts them. The wizard never asks for either — they are hand-edited —
|
|
161
|
+
* so they exist here only to survive an amend of some other area (#369,
|
|
162
|
+
* #286).
|
|
163
|
+
*/
|
|
164
|
+
modelFallbacks?: string[];
|
|
165
|
+
modelFallbackThreshold?: number;
|
|
158
166
|
telegramChatId?: string;
|
|
159
167
|
/** Forum topic for tier-2 Telegram pages; absent keeps flat-chat 0.13 behaviour. */
|
|
160
168
|
telegramTopicId?: number;
|
|
@@ -193,6 +201,13 @@ export interface SetupAnswers {
|
|
|
193
201
|
* merge provenance gate.
|
|
194
202
|
*/
|
|
195
203
|
recoveryMerges?: ProjectConfig["recoveryMerges"];
|
|
204
|
+
/**
|
|
205
|
+
* Hand-edited critical-base/safety markers (commit SHAs or refs) carried
|
|
206
|
+
* through setup unchanged, like {@link recoveryMerges}. The wizard never
|
|
207
|
+
* invents one; forgetting them during an unrelated amend would drop the
|
|
208
|
+
* stale-base interlock from a project that relies on it (#428).
|
|
209
|
+
*/
|
|
210
|
+
criticalBase?: ProjectConfig["criticalBase"];
|
|
196
211
|
/**
|
|
197
212
|
* Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
|
|
198
213
|
* part of the config — the brief is the operator's file, and the conductor
|
|
@@ -836,6 +851,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
836
851
|
: {}),
|
|
837
852
|
// A documented hand-edited key, so an unrelated amend must not delete it.
|
|
838
853
|
...(a.groomBelow === undefined ? {} : { groomBelow: a.groomBelow }),
|
|
854
|
+
// The failover chain is hand-edited too: dropping it on an unrelated amend
|
|
855
|
+
// would pin every run of the project onto a dead provider again (#286).
|
|
856
|
+
...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
|
|
857
|
+
...(a.modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold: a.modelFallbackThreshold }),
|
|
839
858
|
escalation,
|
|
840
859
|
authority: { ...a.authority },
|
|
841
860
|
// Written out in full, never as the legacy string: the file then says which
|
|
@@ -847,6 +866,9 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
847
866
|
...(a.recoveryMerges === undefined
|
|
848
867
|
? {}
|
|
849
868
|
: { recoveryMerges: a.recoveryMerges.map((entry) => ({ ...entry })) }),
|
|
869
|
+
// Hand-edited safety markers carried unchanged on an unrelated amend
|
|
870
|
+
// (#428): dropping them would silently disarm the stale-base interlock.
|
|
871
|
+
...(a.criticalBase === undefined ? {} : { criticalBase: [...a.criticalBase] }),
|
|
850
872
|
// Written out even when it is the default, so an operator amending the
|
|
851
873
|
// volume has a line in the file to point at. Opting into availability makes
|
|
852
874
|
// the schedule explicit and daily; omitting it preserves the preset's
|
|
@@ -1037,11 +1059,14 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
1037
1059
|
// what keeps the rewritten config identical to the one that was read.
|
|
1038
1060
|
if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
|
|
1039
1061
|
if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
|
|
1062
|
+
if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
|
|
1063
|
+
if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
|
|
1040
1064
|
if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
|
|
1041
1065
|
if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
|
|
1042
1066
|
if (p.recoveryMerges !== undefined) {
|
|
1043
1067
|
answers.recoveryMerges = p.recoveryMerges.map((entry) => ({ ...entry }));
|
|
1044
1068
|
}
|
|
1069
|
+
if (p.criticalBase !== undefined) answers.criticalBase = [...p.criticalBase];
|
|
1045
1070
|
if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
|
|
1046
1071
|
if (p.reporting?.digest.timezone !== undefined) {
|
|
1047
1072
|
answers.reportingTimezone = p.reporting.digest.timezone;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provenance for stop/restart requests (#378): who asked, from which project,
|
|
3
|
+
* for which daemon, naming every affected project and its live-run count.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the `stop` and `restart` verbs so a second convention cannot
|
|
6
|
+
* drift beside the first. Explicit facts only — caller pid/uid and session
|
|
7
|
+
* role, scope, control path, timestamp, reason — and specifically never raw
|
|
8
|
+
* argv, environment values or credentials: the incident this exists for was
|
|
9
|
+
* a stop nobody could attribute, and a "just serialise the command line"
|
|
10
|
+
* implementation would leak tokens into the audit trail.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { loadConfig } from "./config.ts";
|
|
14
|
+
import { openStore, dbPath } from "./store.ts";
|
|
15
|
+
import { SESSION_ROLE_ENV, type DaemonStopDraft } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
export interface StopProvenanceSpec {
|
|
18
|
+
/** The operator-visible control path: "cli stop", "cli restart", … */
|
|
19
|
+
controlPath: string;
|
|
20
|
+
/** Non-secret reason; a generated default is fine, never credentials. */
|
|
21
|
+
reason: string;
|
|
22
|
+
/** "global" when the request covered every project ("--all"); otherwise "project". */
|
|
23
|
+
scope: "global" | "project";
|
|
24
|
+
/** The originating project, when project-scoped. */
|
|
25
|
+
project?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the provenance draft for one stop/restart request.
|
|
30
|
+
*
|
|
31
|
+
* `affected` is every configured project with its live-run count at request
|
|
32
|
+
* time, not just the targeted one: the shared daemon serves all of them, and
|
|
33
|
+
* a project-scoped stop still stops the siblings — the record names them
|
|
34
|
+
* instead of silently interrupting them.
|
|
35
|
+
*
|
|
36
|
+
* Pure: nothing is written. The command decides when to persist (at request
|
|
37
|
+
* entry for drain-style restarts whose signal may never arrive, and at the
|
|
38
|
+
* lifecycle chokepoint immediately before signalling for every mediated stop).
|
|
39
|
+
*/
|
|
40
|
+
export function buildStopProvenance(spec: StopProvenanceSpec): DaemonStopDraft {
|
|
41
|
+
const cfg = loadConfig();
|
|
42
|
+
const store = openStore(dbPath());
|
|
43
|
+
try {
|
|
44
|
+
const affected = cfg.projects.map((project) => ({
|
|
45
|
+
project: project.name,
|
|
46
|
+
live: store.liveRuns(project.name).length,
|
|
47
|
+
}));
|
|
48
|
+
return {
|
|
49
|
+
controlPath: spec.controlPath,
|
|
50
|
+
callerPid: process.pid,
|
|
51
|
+
...(typeof process.getuid === "function" ? { callerUid: process.getuid() } : {}),
|
|
52
|
+
// The daemon stamps SESSION_ROLE_ENV on every session it spawns, so a
|
|
53
|
+
// stop that a worker session's tools trigger names itself as a worker;
|
|
54
|
+
// everything else — the operator's shell, a systemd unit, a script — is
|
|
55
|
+
// the orchestrator surface (same convention as the reporting CLI).
|
|
56
|
+
role: process.env[SESSION_ROLE_ENV] === "worker" ? "worker" : "orchestrator",
|
|
57
|
+
scope: spec.scope,
|
|
58
|
+
...(spec.project === undefined ? {} : { project: spec.project }),
|
|
59
|
+
affected,
|
|
60
|
+
reason: spec.reason,
|
|
61
|
+
unattributed: false,
|
|
62
|
+
};
|
|
63
|
+
} finally {
|
|
64
|
+
store.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type {
|
|
|
24
24
|
DecisionState,
|
|
25
25
|
DigestBacklog,
|
|
26
26
|
DispatchSummary,
|
|
27
|
+
DaemonStop,
|
|
28
|
+
DaemonStopDraft,
|
|
27
29
|
FrictionAdmissionReason,
|
|
28
30
|
FrictionKind,
|
|
29
31
|
FrictionObservation,
|
|
@@ -38,6 +40,9 @@ import type {
|
|
|
38
40
|
MaterialEventDraft,
|
|
39
41
|
LabelOp,
|
|
40
42
|
MergeLock,
|
|
43
|
+
OrchestratorDownMode,
|
|
44
|
+
OrchestratorIncident,
|
|
45
|
+
OrchestratorIncidentDraft,
|
|
41
46
|
ReportDeliveryState,
|
|
42
47
|
ReportDraft,
|
|
43
48
|
ReportEnqueue,
|
|
@@ -82,6 +87,7 @@ const ACTIVE_PLACEHOLDERS = ACTIVE_STATES.map(() => "?").join(", ");
|
|
|
82
87
|
const FRICTION_HOLD_REASONS: ReadonlySet<FrictionAdmissionReason> = new Set([
|
|
83
88
|
"failed-attempts",
|
|
84
89
|
"continuations",
|
|
90
|
+
"stale-base",
|
|
85
91
|
"parent-lookup-error",
|
|
86
92
|
"issue-state-lookup-error",
|
|
87
93
|
"open-pr-lookup-error",
|
|
@@ -133,6 +139,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
133
139
|
failureClass: true,
|
|
134
140
|
recoveryAction: true,
|
|
135
141
|
recoveredAt: true,
|
|
142
|
+
model: true,
|
|
136
143
|
};
|
|
137
144
|
|
|
138
145
|
/** Everything SQLite will accept from us. */
|
|
@@ -169,6 +176,7 @@ interface RunRow {
|
|
|
169
176
|
failureClass: string | null;
|
|
170
177
|
recoveryAction: string | null;
|
|
171
178
|
recoveredAt: number | null;
|
|
179
|
+
model: string | null;
|
|
172
180
|
}
|
|
173
181
|
|
|
174
182
|
/** The `base_health` table exactly as SQLite hands it back. */
|
|
@@ -324,6 +332,65 @@ function toIntakeItem(row: IntakeRow): IntakeItem {
|
|
|
324
332
|
return item;
|
|
325
333
|
}
|
|
326
334
|
|
|
335
|
+
/** The `orchestrator_incidents` table exactly as SQLite hands it back (#288). */
|
|
336
|
+
interface OrchestratorIncidentRow {
|
|
337
|
+
project: string;
|
|
338
|
+
mode: string;
|
|
339
|
+
cause: string | null;
|
|
340
|
+
since: number;
|
|
341
|
+
diverted: number;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** NULL columns become absent properties, matching the other row converters. */
|
|
345
|
+
function toOrchestratorIncident(row: OrchestratorIncidentRow): OrchestratorIncident {
|
|
346
|
+
return {
|
|
347
|
+
project: row.project,
|
|
348
|
+
mode: row.mode as OrchestratorDownMode,
|
|
349
|
+
...(row.cause === null ? {} : { cause: row.cause }),
|
|
350
|
+
since: row.since,
|
|
351
|
+
diverted: row.diverted,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The `daemon_stops` table exactly as SQLite hands it back (#378). */
|
|
356
|
+
interface DaemonStopRow {
|
|
357
|
+
id: string;
|
|
358
|
+
at: number;
|
|
359
|
+
controlPath: string;
|
|
360
|
+
callerPid: number | null;
|
|
361
|
+
callerUid: number | null;
|
|
362
|
+
role: string | null;
|
|
363
|
+
scope: string;
|
|
364
|
+
project: string | null;
|
|
365
|
+
daemonPid: number | null;
|
|
366
|
+
runtimeDir: string | null;
|
|
367
|
+
affected: string;
|
|
368
|
+
reason: string;
|
|
369
|
+
unattributed: number;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* NULL columns become absent properties, matching the other row converters:
|
|
374
|
+
* a record read back out of the store deep-equals the one that went in.
|
|
375
|
+
*/
|
|
376
|
+
function toDaemonStop(row: DaemonStopRow): DaemonStop {
|
|
377
|
+
return {
|
|
378
|
+
id: row.id,
|
|
379
|
+
at: row.at,
|
|
380
|
+
controlPath: row.controlPath,
|
|
381
|
+
...(row.callerPid === null ? {} : { callerPid: row.callerPid }),
|
|
382
|
+
...(row.callerUid === null ? {} : { callerUid: row.callerUid }),
|
|
383
|
+
...(row.role === null ? {} : { role: row.role as SessionRole }),
|
|
384
|
+
scope: row.scope as "global" | "project",
|
|
385
|
+
...(row.project === null ? {} : { project: row.project }),
|
|
386
|
+
...(row.daemonPid === null ? {} : { daemonPid: row.daemonPid }),
|
|
387
|
+
...(row.runtimeDir === null ? {} : { runtimeDir: row.runtimeDir }),
|
|
388
|
+
affected: JSON.parse(row.affected) as { project: string; live: number }[],
|
|
389
|
+
reason: row.reason,
|
|
390
|
+
unattributed: row.unattributed === 1,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
327
394
|
const SCHEMA = `
|
|
328
395
|
CREATE TABLE IF NOT EXISTS runs (
|
|
329
396
|
id TEXT PRIMARY KEY,
|
|
@@ -354,7 +421,8 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
354
421
|
report TEXT,
|
|
355
422
|
failureClass TEXT,
|
|
356
423
|
recoveryAction TEXT,
|
|
357
|
-
recoveredAt INTEGER
|
|
424
|
+
recoveredAt INTEGER,
|
|
425
|
+
model TEXT
|
|
358
426
|
);
|
|
359
427
|
CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
|
|
360
428
|
CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
|
|
@@ -624,6 +692,44 @@ CREATE TABLE IF NOT EXISTS intake_items (
|
|
|
624
692
|
);
|
|
625
693
|
CREATE INDEX IF NOT EXISTS intake_items_project_state
|
|
626
694
|
ON intake_items (project, state, createdAt);
|
|
695
|
+
-- The embedded orchestrator's lost-liveness incident (#288): durable so a
|
|
696
|
+
-- daemon restarted while its orchestrator is still down rediscovers the open
|
|
697
|
+
-- incident instead of forgetting it, and the diverted counter accumulates
|
|
698
|
+
-- across the outage and across restarts. One row per project; removed on
|
|
699
|
+
-- recovery. since is the dedupe anchor for both the down page and its
|
|
700
|
+
-- closing recovery page. The mode CHECK keeps the two causes distinct.
|
|
701
|
+
CREATE TABLE IF NOT EXISTS orchestrator_incidents (
|
|
702
|
+
project TEXT PRIMARY KEY,
|
|
703
|
+
mode TEXT NOT NULL CHECK (mode IN ('start-failed', 'crashed')),
|
|
704
|
+
cause TEXT,
|
|
705
|
+
since INTEGER NOT NULL,
|
|
706
|
+
diverted INTEGER NOT NULL DEFAULT 0
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
-- Every stop/restart of the shared daemon, and who asked for it and why
|
|
710
|
+
-- (#378). Deliberately NOT partitioned by project: the daemon serves every
|
|
711
|
+
-- configured project, so a record written by one project's CLI must be
|
|
712
|
+
-- readable from another project's status the moment the daemon is down —
|
|
713
|
+
-- that cross-project read is the whole point. Written before signalling on
|
|
714
|
+
-- the mediated paths (cli stop/restart) and as an honest unattributed
|
|
715
|
+
-- fallback when the daemon receives a signal with no mediated request.
|
|
716
|
+
-- Explicit fields only: never raw argv, environment values or credentials.
|
|
717
|
+
CREATE TABLE IF NOT EXISTS daemon_stops (
|
|
718
|
+
id TEXT PRIMARY KEY,
|
|
719
|
+
at INTEGER NOT NULL,
|
|
720
|
+
controlPath TEXT NOT NULL,
|
|
721
|
+
callerPid INTEGER,
|
|
722
|
+
callerUid INTEGER,
|
|
723
|
+
role TEXT,
|
|
724
|
+
scope TEXT NOT NULL,
|
|
725
|
+
project TEXT,
|
|
726
|
+
daemonPid INTEGER,
|
|
727
|
+
runtimeDir TEXT,
|
|
728
|
+
affected TEXT NOT NULL,
|
|
729
|
+
reason TEXT NOT NULL,
|
|
730
|
+
unattributed INTEGER NOT NULL
|
|
731
|
+
);
|
|
732
|
+
CREATE INDEX IF NOT EXISTS daemon_stops_at ON daemon_stops (at);
|
|
627
733
|
`;
|
|
628
734
|
|
|
629
735
|
/**
|
|
@@ -707,6 +813,7 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
707
813
|
if (row.failureClass !== null) record.failureClass = row.failureClass as FailureClass;
|
|
708
814
|
if (row.recoveryAction !== null) record.recoveryAction = row.recoveryAction as RecoveryAction;
|
|
709
815
|
if (row.recoveredAt !== null) record.recoveredAt = row.recoveredAt;
|
|
816
|
+
if (row.model !== null) record.model = row.model;
|
|
710
817
|
return record;
|
|
711
818
|
}
|
|
712
819
|
|
|
@@ -1006,6 +1113,13 @@ export function openStore(dbPath: string): Store {
|
|
|
1006
1113
|
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
1007
1114
|
}
|
|
1008
1115
|
}
|
|
1116
|
+
// The model a run dispatched on was first written by the modelFallbacks
|
|
1117
|
+
// failover chain (#286). Rows predating the column are NULL, which is the
|
|
1118
|
+
// honest reading: only a chain-configured project records a model, and the
|
|
1119
|
+
// harness's own downgrade stays on the worker result, never in this column.
|
|
1120
|
+
if (!columns.some((column) => column.name === "model")) {
|
|
1121
|
+
db.exec("ALTER TABLE runs ADD COLUMN model TEXT");
|
|
1122
|
+
}
|
|
1009
1123
|
// Existing held-notice rows predate exact digest ownership (#274). A NULL
|
|
1010
1124
|
// report id is truthful for already-digested history and means "still owed"
|
|
1011
1125
|
// only while digestedAt is also NULL.
|
|
@@ -1353,6 +1467,23 @@ export function openStore(dbPath: string): Store {
|
|
|
1353
1467
|
const insertNotified = db.query<unknown, [string, number]>(
|
|
1354
1468
|
`INSERT OR IGNORE INTO notifications ("key", at) VALUES (?, ?)`,
|
|
1355
1469
|
);
|
|
1470
|
+
// Orchestrator-down incident (#288). One row per project; INSERT OR IGNORE is
|
|
1471
|
+
// the dedupe so a flapping orchestrator opens (and pages) once per incident.
|
|
1472
|
+
const selectOrchestratorIncident = db.query<OrchestratorIncidentRow, [string]>(
|
|
1473
|
+
`SELECT project, mode, cause, since, diverted FROM orchestrator_incidents WHERE project = ?`,
|
|
1474
|
+
);
|
|
1475
|
+
const insertOrchestratorIncident = db.query<unknown, [string, string, string | null, number]>(
|
|
1476
|
+
`INSERT OR IGNORE INTO orchestrator_incidents (project, mode, cause, since)
|
|
1477
|
+
VALUES (?, ?, ?, ?)`,
|
|
1478
|
+
);
|
|
1479
|
+
const bumpOrchestratorIncident = db.query<unknown, [number, string]>(
|
|
1480
|
+
`UPDATE orchestrator_incidents SET diverted = diverted + ? WHERE project = ?`,
|
|
1481
|
+
);
|
|
1482
|
+
const deleteOrchestratorIncident = db.query<OrchestratorIncidentRow, [string]>(
|
|
1483
|
+
`DELETE FROM orchestrator_incidents
|
|
1484
|
+
WHERE project = ?
|
|
1485
|
+
RETURNING project, mode, cause, since, diverted`,
|
|
1486
|
+
);
|
|
1356
1487
|
const upsertDispatch = db.query<unknown, [string, string]>(
|
|
1357
1488
|
`INSERT INTO dispatch_summaries (project, summary) VALUES (?, ?)
|
|
1358
1489
|
ON CONFLICT(project) DO UPDATE SET summary = excluded.summary`,
|
|
@@ -1877,6 +2008,16 @@ export function openStore(dbPath: string): Store {
|
|
|
1877
2008
|
ORDER BY at DESC, rowid DESC
|
|
1878
2009
|
LIMIT ?`,
|
|
1879
2010
|
);
|
|
2011
|
+
const insertDaemonStop = db.query<unknown, SqlValue[]>(
|
|
2012
|
+
`INSERT INTO daemon_stops
|
|
2013
|
+
(id, at, controlPath, callerPid, callerUid, role, scope, project, daemonPid, runtimeDir, affected, reason, unattributed)
|
|
2014
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2015
|
+
);
|
|
2016
|
+
// `rowid` breaks the tie when two requests land in the same millisecond, so
|
|
2017
|
+
// "newest first" never silently hides one stop behind another.
|
|
2018
|
+
const selectLatestDaemonStop = db.query<DaemonStopRow, []>(
|
|
2019
|
+
`SELECT * FROM daemon_stops ORDER BY at DESC, rowid DESC LIMIT 1`,
|
|
2020
|
+
);
|
|
1880
2021
|
// Breaking a stale claim and taking it must be one transaction, or two
|
|
1881
2022
|
// daemons both see the stale row, both delete it, and both insert.
|
|
1882
2023
|
const breakStaleMergeLock = db.query<unknown, [string, number]>(
|
|
@@ -2211,6 +2352,58 @@ export function openStore(dbPath: string): Store {
|
|
|
2211
2352
|
insertNotified.run(key, Date.now());
|
|
2212
2353
|
},
|
|
2213
2354
|
|
|
2355
|
+
orchestratorIncident(project: string): OrchestratorIncident | undefined {
|
|
2356
|
+
const row = selectOrchestratorIncident.get(project);
|
|
2357
|
+
return row === null ? undefined : toOrchestratorIncident(row);
|
|
2358
|
+
},
|
|
2359
|
+
|
|
2360
|
+
openOrchestratorIncident(draft: OrchestratorIncidentDraft): boolean {
|
|
2361
|
+
return (
|
|
2362
|
+
insertOrchestratorIncident.run(
|
|
2363
|
+
draft.project,
|
|
2364
|
+
draft.mode,
|
|
2365
|
+
draft.cause ?? null,
|
|
2366
|
+
draft.since,
|
|
2367
|
+
).changes === 1
|
|
2368
|
+
);
|
|
2369
|
+
},
|
|
2370
|
+
|
|
2371
|
+
bumpOrchestratorDiverted(project: string, by = 1): void {
|
|
2372
|
+
// Only meaningful while an incident is open: a healthy or external
|
|
2373
|
+
// orchestrator has no row, and the UPDATE touches nothing.
|
|
2374
|
+
bumpOrchestratorIncident.run(by, project);
|
|
2375
|
+
},
|
|
2376
|
+
|
|
2377
|
+
closeOrchestratorIncident(project: string, _at: number): OrchestratorIncident | undefined {
|
|
2378
|
+
const row = deleteOrchestratorIncident.get(project);
|
|
2379
|
+
return row === null ? undefined : toOrchestratorIncident(row);
|
|
2380
|
+
},
|
|
2381
|
+
|
|
2382
|
+
recordDaemonStop(draft: DaemonStopDraft): DaemonStop {
|
|
2383
|
+
const entry: DaemonStop = { ...draft, id: crypto.randomUUID(), at: Date.now() };
|
|
2384
|
+
insertDaemonStop.run(
|
|
2385
|
+
entry.id,
|
|
2386
|
+
entry.at,
|
|
2387
|
+
entry.controlPath,
|
|
2388
|
+
toSql(entry.callerPid),
|
|
2389
|
+
toSql(entry.callerUid),
|
|
2390
|
+
toSql(entry.role),
|
|
2391
|
+
entry.scope,
|
|
2392
|
+
toSql(entry.project),
|
|
2393
|
+
toSql(entry.daemonPid),
|
|
2394
|
+
toSql(entry.runtimeDir),
|
|
2395
|
+
JSON.stringify(entry.affected),
|
|
2396
|
+
entry.reason,
|
|
2397
|
+
entry.unattributed ? 1 : 0,
|
|
2398
|
+
);
|
|
2399
|
+
return entry;
|
|
2400
|
+
},
|
|
2401
|
+
|
|
2402
|
+
latestDaemonStop(): DaemonStop | undefined {
|
|
2403
|
+
const row = selectLatestDaemonStop.get();
|
|
2404
|
+
return row === null ? undefined : toDaemonStop(row);
|
|
2405
|
+
},
|
|
2406
|
+
|
|
2214
2407
|
recordDispatch(project: string, summary: DispatchSummary): void {
|
|
2215
2408
|
const previous = selectDispatch.get(project);
|
|
2216
2409
|
upsertDispatch.run(project, JSON.stringify(summary));
|
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,
|
|
@@ -760,6 +761,36 @@ export function readyIssuesFromRest(raw: string): ReadyIssue[] {
|
|
|
760
761
|
return issues;
|
|
761
762
|
}
|
|
762
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
|
+
|
|
763
794
|
/** One cached page per request URL: the ETag GitHub answered, the raw JSON
|
|
764
795
|
* body it covered, and whether that page advertised a rel="next" successor.
|
|
765
796
|
* Module-level default so every makeTracker() in the process (board probe,
|
|
@@ -1103,6 +1134,22 @@ export function makeTracker(
|
|
|
1103
1134
|
return pages.flatMap(readyIssuesFromRest);
|
|
1104
1135
|
},
|
|
1105
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
|
+
|
|
1106
1153
|
async addLabel(issue: number, label: string): Promise<void> {
|
|
1107
1154
|
try {
|
|
1108
1155
|
// REST POST is natively idempotent: re-adding a label the issue already
|