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.
Files changed (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
@@ -0,0 +1,120 @@
1
+ // The dashboard UI (conductor #293): ask for the bearer token once, keep it in
2
+ // localStorage, and render `/api/projects` with every daemon state explicit —
3
+ // a stopped, unreachable or foreign daemon renders as down, never as an empty
4
+ // healthy fleet.
5
+ //
6
+ // Plain browser JS, no runtime deps: the page is served unauthenticated and
7
+ // the token lives only in the browser's localStorage, never in a URL.
8
+
9
+ const TOKEN_KEY = "omp-conductor-dashboard-token";
10
+ const tokenPanel = document.getElementById("token-panel");
11
+ const tokenForm = document.getElementById("token-form");
12
+ const tokenInput = document.getElementById("token-input");
13
+ const tokenError = document.getElementById("token-error");
14
+ const fleet = document.getElementById("fleet");
15
+ const projects = document.getElementById("projects");
16
+
17
+ /** Human verdict per daemon state — "down" spelled out by design. */
18
+ const STATE_TEXT = {
19
+ ok: "daemon up",
20
+ stopped: "daemon down",
21
+ unreachable: "daemon down — not answering /healthz",
22
+ "other-project": "daemon down — serves another project",
23
+ };
24
+
25
+ async function apiProjects(token) {
26
+ const res = await fetch("/api/projects", {
27
+ headers: { Authorization: `Bearer ${token}` },
28
+ });
29
+ if (res.status === 401) return null;
30
+ if (!res.ok) throw new Error(`/api/projects answered ${res.status}`);
31
+ return res.json();
32
+ }
33
+
34
+ function showTokenPrompt() {
35
+ localStorage.removeItem(TOKEN_KEY);
36
+ tokenInput.value = "";
37
+ tokenError.hidden = true;
38
+ tokenPanel.hidden = false;
39
+ fleet.hidden = true;
40
+ tokenInput.focus();
41
+ }
42
+
43
+ function daemonLine(view) {
44
+ const { daemon } = view;
45
+ const text =
46
+ daemon.state === "other-project" && daemon.serves !== undefined
47
+ ? `daemon down — it serves "${daemon.serves}"`
48
+ : STATE_TEXT[daemon.state];
49
+ const parts = [text];
50
+ if (daemon.port !== null && daemon.port !== undefined) parts.push(`port ${daemon.port}`);
51
+ if (daemon.state === "ok") parts.push("healthz answering");
52
+ return parts.join(" · ");
53
+ }
54
+
55
+ function showProject(view) {
56
+ const li = document.createElement("li");
57
+ li.className = "project";
58
+ const dot = document.createElement("span");
59
+ dot.className = `dot dot-${view.daemon.state}`;
60
+ dot.setAttribute("aria-hidden", "true");
61
+ const body = document.createElement("div");
62
+ body.className = "body";
63
+ const name = document.createElement("span");
64
+ name.className = "name";
65
+ name.textContent = view.name;
66
+ const repo = document.createElement("span");
67
+ repo.className = "repo";
68
+ repo.textContent = view.repo;
69
+ const state = document.createElement("span");
70
+ state.className = `state state-${view.daemon.state}`;
71
+ state.textContent = daemonLine(view);
72
+ body.append(name, repo, state);
73
+ li.append(dot, body);
74
+ projects.appendChild(li);
75
+ }
76
+
77
+ function showFleet(list) {
78
+ projects.replaceChildren();
79
+ if (list.length === 0) {
80
+ const li = document.createElement("li");
81
+ li.className = "project";
82
+ li.textContent = "No projects configured.";
83
+ projects.appendChild(li);
84
+ } else {
85
+ for (const view of list) showProject(view);
86
+ }
87
+ tokenPanel.hidden = true;
88
+ fleet.hidden = false;
89
+ }
90
+
91
+ async function start(token) {
92
+ tokenError.hidden = true;
93
+ const list = await apiProjects(token);
94
+ if (list === null) {
95
+ showTokenPrompt();
96
+ return;
97
+ }
98
+ localStorage.setItem(TOKEN_KEY, token);
99
+ showFleet(list);
100
+ }
101
+
102
+ tokenForm.addEventListener("submit", (event) => {
103
+ event.preventDefault();
104
+ start(tokenInput.value.trim()).catch((err) => {
105
+ tokenError.textContent = `Could not load the fleet: ${err.message}`;
106
+ tokenError.hidden = false;
107
+ });
108
+ });
109
+
110
+ // A stored token skips the prompt, but a 401 still clears it and asks again —
111
+ // the server, not the storage, is the source of truth.
112
+ const stored = localStorage.getItem(TOKEN_KEY);
113
+ if (stored === null) {
114
+ showTokenPrompt();
115
+ } else {
116
+ start(stored).catch((err) => {
117
+ tokenError.textContent = `Could not load the fleet: ${err.message}`;
118
+ showTokenPrompt();
119
+ });
120
+ }
@@ -0,0 +1,34 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Conductor fleet</title>
7
+ <link rel="stylesheet" href="/style.css" />
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <h1>Conductor fleet</h1>
12
+ </header>
13
+ <main>
14
+ <section id="token-panel" hidden>
15
+ <p>
16
+ The dashboard is bearer-authenticated. The token is the file
17
+ <code>dashboard-token</code> next to <code>config.json</code> under
18
+ <code>$OMP_CONDUCTOR_HOME</code> on the fleet host (minted 0600 the first
19
+ time <code>omp-conductor dashboard</code> started).
20
+ </p>
21
+ <form id="token-form">
22
+ <label for="token-input">Token</label>
23
+ <input id="token-input" type="password" autocomplete="off" required spellcheck="false" />
24
+ <button type="submit">Show fleet</button>
25
+ <p id="token-error" class="error" hidden>That token was refused.</p>
26
+ </form>
27
+ </section>
28
+ <section id="fleet" hidden>
29
+ <ul id="projects" class="projects"></ul>
30
+ </section>
31
+ </main>
32
+ <script src="/app.js"></script>
33
+ </body>
34
+ </html>
@@ -0,0 +1,267 @@
1
+ /**
2
+ * The fleet dashboard server (epic #292, slice 1/5): a separate `Bun.serve`
3
+ * process that renders the fleet in a browser.
4
+ *
5
+ * Deliberately NOT a route on the daemon port: that surface is unauthenticated
6
+ * and loopback-only by design, and `verbs/server.test.ts` pins every non-daemon
7
+ * path (and every mutating method) to 404. The dashboard is its own process
8
+ * with its own bearer token, and it only ever *reads* fleet state — it never
9
+ * starts, stops or mutates a daemon.
10
+ *
11
+ * The token is minted once at `<stateDir()>/dashboard-token` — the same
12
+ * 0700-dir / 0600-file atomic discipline `writeConfigRaw` uses — and reused on
13
+ * every later start. Every `/api/*` request must present it as
14
+ * `Authorization: Bearer <token>`; anything else is a bare 401 with no body
15
+ * detail. Static assets ship on disk next to this module and are served
16
+ * unauthenticated; the UI asks for the token once and keeps it in
17
+ * localStorage.
18
+ *
19
+ * Project liveness is the one membership convention `status` and `board`
20
+ * already share (#379): `livingDaemon()` + `healthCheck(port)`, classified by
21
+ * `classifyDaemonProjectHealth`. A project whose record is absent, whose
22
+ * daemon will not answer `/healthz`, or whose record is pinned to another
23
+ * project renders as down — never as an empty healthy fleet.
24
+ */
25
+
26
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
27
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { loadConfig, stateDir } from "../config.ts";
30
+ import { healthCheck, livingDaemon, type DaemonRecord } from "../lifecycle.ts";
31
+ import { classifyDaemonProjectHealth } from "../fleet.ts";
32
+ import type { ProjectConfig } from "../types.ts";
33
+
34
+ /** The dashboard's default bind address — loopback, like the daemon port. */
35
+ export const DASHBOARD_HOST = "127.0.0.1";
36
+ /** The dashboard's default port. Away from the daemon's 8787 and the verbs. */
37
+ export const DASHBOARD_PORT = 8788;
38
+
39
+ const TOKEN_FILE = "dashboard-token";
40
+
41
+ /** Where the bearer token lives, next to config.json under `$OMP_CONDUCTOR_HOME`. */
42
+ export function tokenPath(): string {
43
+ return join(stateDir(), TOKEN_FILE);
44
+ }
45
+
46
+ /** The stored token, or undefined when the dashboard has never been started. */
47
+ export function readDashboardToken(): string | undefined {
48
+ try {
49
+ const raw = readFileSync(tokenPath(), "utf8").trim();
50
+ return raw.length > 0 ? raw : undefined;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ function hashToken(value: string): Buffer {
57
+ return createHash("sha256").update(value).digest();
58
+ }
59
+
60
+ /**
61
+ * Constant-time bearer check. Both sides are hashed to fixed length first so a
62
+ * wrong-length candidate cannot leak the stored token's length through the
63
+ * compare — the arm-challenge spirit: verify by hash, never by a plaintext
64
+ * comparison that leaks shape in timing.
65
+ */
66
+ export function verifyDashboardToken(presented: string | undefined, stored: string): boolean {
67
+ if (presented === undefined || presented.length === 0) return false;
68
+ return timingSafeEqual(hashToken(presented), hashToken(stored));
69
+ }
70
+
71
+ /**
72
+ * Mints `<stateDir()>/dashboard-token` on first start; later starts reuse the
73
+ * existing token. The write discipline is `writeConfigRaw`'s: the state dir is
74
+ * created 0700 (chmod only what this call created, so an operator-chosen mode
75
+ * survives), the file is written 0600 through a same-directory temp file and
76
+ * an atomic rename so a crash mid-write leaves the previous token intact
77
+ * rather than a truncated one.
78
+ */
79
+ export function ensureDashboardToken(): string {
80
+ const existing = readDashboardToken();
81
+ if (existing !== undefined) return existing;
82
+
83
+ const token = randomBytes(32).toString("base64url");
84
+ const dir = stateDir();
85
+ const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
86
+ // mkdir's mode is masked by umask; chmod only what we just created so an
87
+ // existing directory keeps whatever the user chose for it.
88
+ if (created !== undefined) chmodSync(dir, 0o700);
89
+
90
+ const target = tokenPath();
91
+ const tmp = join(dir, `.dashboard-token.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`);
92
+ try {
93
+ writeFileSync(tmp, `${token}\n`, { mode: 0o600 });
94
+ renameSync(tmp, target);
95
+ } catch (err) {
96
+ rmSync(tmp, { force: true });
97
+ throw err;
98
+ }
99
+ // The rename preserves the temp's 0600; state it anyway so the promise never
100
+ // leans on umask moods.
101
+ chmodSync(target, 0o600);
102
+ return token;
103
+ }
104
+
105
+ /** One configured project as `/api/projects` reports it. */
106
+ export interface DashboardProjectView {
107
+ name: string;
108
+ /** `tracker.repo` — the issue source the project dispatches. */
109
+ repo: string;
110
+ daemon: {
111
+ /** The shared verdict every viewer uses (#379). */
112
+ state: "ok" | "stopped" | "unreachable" | "other-project";
113
+ /** The living daemon's port when a record exists; null when there is none. */
114
+ port: number | null;
115
+ /** The raw `/healthz` body, parsed, when this project's daemon answered it. */
116
+ healthz?: unknown;
117
+ /** Which project a foreign living record is pinned to, when state is other-project. */
118
+ serves?: string;
119
+ };
120
+ }
121
+
122
+ /**
123
+ * One project against a record already read for the request. The record is
124
+ * shared across projects so one pidfile read serves one whole request.
125
+ */
126
+ async function probeProject(project: ProjectConfig, record: DaemonRecord | undefined): Promise<DashboardProjectView> {
127
+ // The status/board gate (#379): never health-check a record pinned to
128
+ // another project — its `/healthz` would describe a daemon that does not
129
+ // serve this one, and mixing the two is how an empty fleet reads as healthy.
130
+ const pinnedElsewhere =
131
+ record !== undefined && record.project !== undefined && record.project !== project.name;
132
+ const health =
133
+ record !== undefined && !pinnedElsewhere ? await healthCheck(record.port) : undefined;
134
+ const classified = classifyDaemonProjectHealth(record, health, project.name);
135
+
136
+ const view: DashboardProjectView = {
137
+ name: project.name,
138
+ repo: project.tracker.repo,
139
+ daemon: { state: classified.kind, port: record?.port ?? null },
140
+ };
141
+ if (classified.kind === "ok") {
142
+ let healthz: unknown;
143
+ try {
144
+ healthz = JSON.parse(health?.body ?? "null") as unknown;
145
+ } catch {
146
+ healthz = undefined;
147
+ }
148
+ view.daemon.healthz = healthz;
149
+ } else if (classified.kind === "other-project") {
150
+ view.daemon.serves = classified.serves;
151
+ }
152
+ return view;
153
+ }
154
+
155
+ /**
156
+ * Every configured project, annotated for the dashboard. Read-only: never
157
+ * starts a daemon, never touches a pidfile beyond `livingDaemon()`'s read.
158
+ */
159
+ export async function dashboardProjects(): Promise<DashboardProjectView[]> {
160
+ const cfg = loadConfig();
161
+ const record = livingDaemon();
162
+ return Promise.all(cfg.projects.map((project) => probeProject(project, record)));
163
+ }
164
+
165
+ export interface DashboardHttpDeps {
166
+ /** The bearer token every `/api/*` request must carry. */
167
+ token: string;
168
+ /** The `/api/projects` payload. */
169
+ projects(): Promise<DashboardProjectView[]>;
170
+ }
171
+
172
+ const STATIC_FILES: Record<string, string> = {
173
+ "/": "index.html",
174
+ "/index.html": "index.html",
175
+ "/app.js": "app.js",
176
+ "/style.css": "style.css",
177
+ };
178
+
179
+ const STATIC_MIME: Record<string, string> = {
180
+ ".html": "text/html; charset=utf-8",
181
+ ".js": "text/javascript; charset=utf-8",
182
+ ".css": "text/css; charset=utf-8",
183
+ };
184
+
185
+ /** The value of `Authorization: Bearer <token>`, or undefined for any other form. */
186
+ function bearerToken(req: Request): string | undefined {
187
+ const header = req.headers.get("authorization");
188
+ if (header === null) return undefined;
189
+ const prefix = "Bearer ";
190
+ return header.startsWith(prefix) ? header.slice(prefix.length).trim() : undefined;
191
+ }
192
+
193
+ /**
194
+ * The dashboard's whole HTTP surface, separated from the socket exactly like
195
+ * `daemonHttpResponse` so a test can drive it without binding a port. The UI
196
+ * ships on disk and is served without auth; `/api/*` is bearer-authenticated.
197
+ */
198
+ export async function dashboardResponse(req: Request, d: DashboardHttpDeps): Promise<Response> {
199
+ const path = new URL(req.url).pathname;
200
+ if (path.startsWith("/api/")) {
201
+ const presented = bearerToken(req);
202
+ if (presented === undefined || !verifyDashboardToken(presented, d.token)) {
203
+ // Bare 401: no body, no scheme hint — nothing about the token leaks.
204
+ return new Response("", { status: 401 });
205
+ }
206
+ if (req.method === "GET" && path === "/api/projects") {
207
+ return Response.json(await d.projects());
208
+ }
209
+ return new Response("", { status: 404 });
210
+ }
211
+ // Everything outside /api is static UI, unauthenticated by contract.
212
+ if (req.method !== "GET" && req.method !== "HEAD") {
213
+ return new Response("", { status: 405 });
214
+ }
215
+ const name = STATIC_FILES[path];
216
+ if (name === undefined) return new Response("not found", { status: 404 });
217
+ // Small fixed set of files, read on demand like the config: no cache to go
218
+ // stale when an operator edits the UI beside a running dashboard.
219
+ const body = readFileSync(join(import.meta.dir, name));
220
+ const mime = STATIC_MIME[name.slice(name.lastIndexOf("."))] ?? "application/octet-stream";
221
+ return new Response(body, { headers: { "content-type": mime } });
222
+ }
223
+
224
+ export interface DashboardStartOpts {
225
+ port?: number;
226
+ /** Bind address; defaults to loopback. A non-loopback host still starts,
227
+ * with a one-line warning naming the token file. */
228
+ host?: string;
229
+ }
230
+
231
+ /** Loopback is the safe posture; anything else gets the token warning. */
232
+ function isLoopbackHost(host: string): boolean {
233
+ return host === "localhost" || host === "::1" || host.startsWith("127.");
234
+ }
235
+
236
+ /**
237
+ * Runs the dashboard until SIGINT/SIGTERM, like the foreground daemon. Mints
238
+ * the token on first start and prints where the server is listening.
239
+ */
240
+ export async function startDashboard(opts: DashboardStartOpts = {}): Promise<void> {
241
+ const token = ensureDashboardToken();
242
+ const host = opts.host ?? DASHBOARD_HOST;
243
+ const server = Bun.serve({
244
+ hostname: host,
245
+ port: opts.port ?? DASHBOARD_PORT,
246
+ fetch: (req) => dashboardResponse(req, { token, projects: dashboardProjects }),
247
+ });
248
+ // An IPv6 literal needs brackets to be a clickable URL.
249
+ const displayHost = host.includes(":") ? `[${host}]` : host;
250
+ process.stdout.write(`dashboard on http://${displayHost}:${server.port}\n`);
251
+ if (!isLoopbackHost(host)) {
252
+ // The one-line warning the contract promises: the token file is the only
253
+ // door onto the project list, so say where it is before a request arrives.
254
+ process.stderr.write(
255
+ `warning: dashboard bound to ${host} — anyone who can reach it can use the token at ${tokenPath()}; keep it secret\n`,
256
+ );
257
+ }
258
+ await new Promise<void>((resolve) => {
259
+ const stop = (): void => {
260
+ process.off("SIGINT", stop);
261
+ process.off("SIGTERM", stop);
262
+ void server.stop(true).finally(() => resolve());
263
+ };
264
+ process.on("SIGINT", stop);
265
+ process.on("SIGTERM", stop);
266
+ });
267
+ }
@@ -0,0 +1,180 @@
1
+ /* Conductor fleet dashboard — minimal, no framework. */
2
+ :root {
3
+ color-scheme: light dark;
4
+ --bg: #f6f6f4;
5
+ --card: #ffffff;
6
+ --ink: #1c1c1a;
7
+ --muted: #6b6b64;
8
+ --line: #dcdcd6;
9
+ --up: #2e7d32;
10
+ --down: #b3261e;
11
+ --warn: #b26a00;
12
+ --foreign: #8a5a00;
13
+ }
14
+
15
+ * {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ body {
20
+ margin: 0;
21
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
22
+ background: var(--bg);
23
+ color: var(--ink);
24
+ line-height: 1.5;
25
+ }
26
+
27
+ header {
28
+ padding: 1.25rem 1.5rem 0.5rem;
29
+ }
30
+
31
+ h1 {
32
+ font-size: 1.35rem;
33
+ margin: 0;
34
+ }
35
+
36
+ main {
37
+ padding: 0 1.5rem 2rem;
38
+ max-width: 56rem;
39
+ }
40
+
41
+ code {
42
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
43
+ font-size: 0.9em;
44
+ background: color-mix(in srgb, var(--ink) 8%, transparent);
45
+ padding: 0.05em 0.3em;
46
+ border-radius: 4px;
47
+ }
48
+
49
+ #token-panel {
50
+ background: var(--card);
51
+ border: 1px solid var(--line);
52
+ border-radius: 10px;
53
+ padding: 1.25rem;
54
+ margin-top: 1rem;
55
+ }
56
+
57
+ #token-panel p {
58
+ margin: 0 0 0.75rem;
59
+ color: var(--muted);
60
+ }
61
+
62
+ form {
63
+ display: flex;
64
+ gap: 0.5rem;
65
+ flex-wrap: wrap;
66
+ align-items: center;
67
+ }
68
+
69
+ label {
70
+ font-weight: 600;
71
+ }
72
+
73
+ input[type="password"] {
74
+ flex: 1;
75
+ min-width: 14rem;
76
+ padding: 0.45rem 0.6rem;
77
+ border: 1px solid var(--line);
78
+ border-radius: 6px;
79
+ font: inherit;
80
+ background: var(--bg);
81
+ color: var(--ink);
82
+ }
83
+
84
+ button {
85
+ padding: 0.45rem 1rem;
86
+ border: 0;
87
+ border-radius: 6px;
88
+ font: inherit;
89
+ font-weight: 600;
90
+ background: var(--ink);
91
+ color: var(--bg);
92
+ cursor: pointer;
93
+ }
94
+
95
+ .error {
96
+ color: var(--down);
97
+ font-weight: 600;
98
+ margin: 0.75rem 0 0;
99
+ width: 100%;
100
+ }
101
+
102
+ .projects {
103
+ list-style: none;
104
+ margin: 1rem 0 0;
105
+ padding: 0;
106
+ display: grid;
107
+ gap: 0.75rem;
108
+ }
109
+
110
+ .project {
111
+ display: flex;
112
+ gap: 0.85rem;
113
+ align-items: flex-start;
114
+ background: var(--card);
115
+ border: 1px solid var(--line);
116
+ border-radius: 10px;
117
+ padding: 0.9rem 1rem;
118
+ }
119
+
120
+ .dot {
121
+ flex: 0 0 auto;
122
+ width: 0.7rem;
123
+ height: 0.7rem;
124
+ border-radius: 50%;
125
+ margin-top: 0.4rem;
126
+ }
127
+
128
+ .dot-ok {
129
+ background: var(--up);
130
+ }
131
+
132
+ .dot-stopped,
133
+ .dot-unreachable,
134
+ .dot-other-project {
135
+ background: var(--down);
136
+ }
137
+
138
+ .body {
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: 0.15rem;
142
+ min-width: 0;
143
+ }
144
+
145
+ .name {
146
+ font-weight: 700;
147
+ }
148
+
149
+ .repo {
150
+ color: var(--muted);
151
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
152
+ font-size: 0.85rem;
153
+ }
154
+
155
+ .state {
156
+ font-size: 0.9rem;
157
+ }
158
+
159
+ .state-ok {
160
+ color: var(--up);
161
+ }
162
+
163
+ .state-stopped,
164
+ .state-unreachable,
165
+ .state-other-project {
166
+ color: var(--down);
167
+ font-weight: 600;
168
+ }
169
+
170
+ @media (prefers-color-scheme: dark) {
171
+ :root {
172
+ --bg: #17171a;
173
+ --card: #202024;
174
+ --ink: #ececea;
175
+ --muted: #9b9b93;
176
+ --line: #33333a;
177
+ --up: #7fc97f;
178
+ --down: #ff8a80;
179
+ }
180
+ }
package/src/decisions.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { DecisionRecord, Store, Tracker } from "./types.ts";
16
+ import { fetchRateLimit, RATE_LIMIT_COOLDOWN_MS } from "./tracker/github.ts";
16
17
 
17
18
  /**
18
19
  * A precondition whose truth this package can check on its own.
@@ -112,29 +113,45 @@ export const probeNpmVersion: NpmProbe = async (spec) => {
112
113
  /** Probe for `rate-limit-reset`, injectable so tests never reach the network. */
113
114
  export type RateLimitProbe = () => Promise<boolean>;
114
115
 
116
+ /** The `gh`-shaped runner {@link probeRateLimitReset} drives (#349). */
117
+ type RateLimitGh = (argv: string[], stdin?: string) => Promise<string>;
118
+
115
119
  /**
116
- * `gh api rate_limit` — any remaining GraphQL quota means the limit has reset
117
- * (or never bound). Bounded at 10 s like {@link probeNpmVersion}, for the same
118
- * reason: this runs inside a tick, and a hung `gh` must cost one unevaluated
119
- * decision, not the tick.
120
+ * Run `gh api rate_limit` under a 10 s bound — a hung `gh` must cost one
121
+ * unevaluated decision, never the tick, the same reason as
122
+ * {@link probeNpmVersion}.
120
123
  */
121
- export const probeRateLimitReset: RateLimitProbe = async () => {
122
- const proc = Bun.spawn(
123
- ["gh", "api", "rate_limit", "--jq", ".resources.graphql.remaining"],
124
- { stdout: "pipe", stderr: "ignore" },
125
- );
124
+ async function ghRateLimit(argv: string[]): Promise<string> {
125
+ const proc = Bun.spawn(["gh", ...argv], { stdout: "pipe", stderr: "pipe" });
126
126
  const timer = setTimeout(() => {
127
127
  proc.kill();
128
128
  }, 10_000);
129
129
  try {
130
130
  const [text, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
131
- return code === 0 && Number.parseInt(text.trim(), 10) > 0;
132
- } catch {
133
- return false;
131
+ if (code !== 0) throw new Error(`gh rate_limit exited ${code}`);
132
+ return text;
134
133
  } finally {
135
134
  clearTimeout(timer);
136
135
  }
137
- };
136
+ }
137
+
138
+ /**
139
+ * `gh api rate_limit` reports a budget on every GitHub resource the app's
140
+ * token serves from, and this probe is met only when **both** buckets have
141
+ * quota left. REST `core` exhaustion alongside a still-warm GraphQL budget is
142
+ * the exact false positive this probe used to raise (#349), so one warm bucket
143
+ * must never stand in for recovery on the resource that was actually refused.
144
+ *
145
+ * This is half the recovery evidence. The enforced half — no rate refusal on
146
+ * the tracker's recent breaker ledger — is applied where the store is in view,
147
+ * in {@link evaluateDecisionConditions}. `rate_limit` alone can report quota
148
+ * an app would still refuse, so the probe answers a necessary condition, not a
149
+ * sufficient one.
150
+ */
151
+ export async function probeRateLimitReset(runGh: RateLimitGh = ghRateLimit): Promise<boolean> {
152
+ const status = await fetchRateLimit(runGh);
153
+ return status !== undefined && status.graphql.remaining > 0 && status.core.remaining > 0;
154
+ }
138
155
 
139
156
  /**
140
157
  * Check every open decision that carries a condition and has not met it yet.
@@ -180,7 +197,15 @@ export async function evaluateDecisionConditions(
180
197
  // conflict are both unsatisfied (#189).
181
198
  satisfied = (await tracker.mergeable(condition.url)) === "clean";
182
199
  } else {
183
- satisfied = await probes.rateLimit();
200
+ // `rate-limit-reset` (#349). The probe above (both GitHub buckets warm)
201
+ // is only half the evidence; recovery is proven by the enforced refusal
202
+ // ledger — no rate refusal inside the breaker's cooldown window.
203
+ // `/rate_limit` alone can report quota an app would still refuse, which
204
+ // is exactly the false positive this fixes, so a recent refusal on the
205
+ // tracker's breaker keeps the row unmet. A store that cannot report
206
+ // refusals cannot prove the enforced half, so it fails closed too.
207
+ const refusals = store.ghRefusalsSince?.(now() - RATE_LIMIT_COOLDOWN_MS);
208
+ satisfied = (await probes.rateLimit()) && refusals !== undefined && refusals.count === 0;
184
209
  }
185
210
  } catch {
186
211
  continue;