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.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
@@ -28,8 +28,12 @@ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync }
28
28
  import { join } from "node:path";
29
29
  import { loadConfig, stateDir } from "../config.ts";
30
30
  import { healthCheck, livingDaemon, type DaemonRecord } from "../lifecycle.ts";
31
- import { classifyDaemonProjectHealth } from "../fleet.ts";
31
+ import { classifyDaemonProjectHealth, fleetLayers, type FleetLayers } from "../fleet.ts";
32
+ import { statusSnapshot, type StatusSnapshot } from "../daemon.ts";
33
+ import { boardJson, boardSnapshotOnce } from "../board.ts";
34
+ import { dbPath, openStore } from "../store.ts";
32
35
  import type { ProjectConfig } from "../types.ts";
36
+ import type { BaseHealth, DigestBacklog, HeldNotice, ReportRecord, RunRecord, TurnOverride, VerbLedgerEntry } from "../types.ts";
33
37
 
34
38
  /** The dashboard's default bind address — loopback, like the daemon port. */
35
39
  export const DASHBOARD_HOST = "127.0.0.1";
@@ -162,11 +166,154 @@ export async function dashboardProjects(): Promise<DashboardProjectView[]> {
162
166
  return Promise.all(cfg.projects.map((project) => probeProject(project, record)));
163
167
  }
164
168
 
169
+ /**
170
+ * One project on the fleet-overview row (epic #292, slice 2/5). Every field is
171
+ * the existing producer's own number — `statusSnapshot` for workers, spend and
172
+ * base health, `fleetLayers` for the dispatch verdict — so a row can never
173
+ * disagree with `omp-conductor status` read at the same moment.
174
+ */
175
+ export interface FleetOverviewRow {
176
+ name: string;
177
+ repo: string;
178
+ /** The same shared daemon-health verdict `/api/projects` reports (#379). */
179
+ daemon: DashboardProjectView["daemon"];
180
+ /** The dispatch layer: `stopped` | `paused` | `running`. */
181
+ dispatch: FleetLayers["dispatch"];
182
+ liveWorkers: number;
183
+ spendTodayUsd: number;
184
+ baseHealth: BaseHealth[];
185
+ /**
186
+ * True when the daemon this project is pinned to is unreachable or serves
187
+ * another project. The UI renders DEGRADED instead of a fabricated healthy
188
+ * empty row — never zeros that read as a working fleet.
189
+ */
190
+ degraded: boolean;
191
+ }
192
+
193
+ /**
194
+ * Every configured project as a fleet-overview row. Reuses `dashboardProjects`
195
+ * for the membership verdict (one pidfile + one `/healthz` read serves the
196
+ * whole call), then `statusSnapshot` and `fleetLayers` per project for the
197
+ * numbers the `status` CLI renders from the very same producers.
198
+ */
199
+ export async function fleetOverview(): Promise<FleetOverviewRow[]> {
200
+ const views = await dashboardProjects();
201
+ return views.map((view) => {
202
+ const snap = statusSnapshot(view.name);
203
+ const layers = fleetLayers(view.name);
204
+ return {
205
+ name: view.name,
206
+ repo: view.repo,
207
+ daemon: view.daemon,
208
+ dispatch: layers.dispatch,
209
+ liveWorkers: snap.liveWorkers,
210
+ spendTodayUsd: snap.spendTodayUsd,
211
+ baseHealth: snap.baseHealth,
212
+ degraded: view.daemon.state === "unreachable" || view.daemon.state === "other-project",
213
+ };
214
+ });
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // The read-only monitoring surface (epic #292, slice 2/5). Each handler hands
219
+ // the existing CLI producer its arguments and returns what it already computes
220
+ // verbatim — nothing here re-derives a lane, a spend figure or a hold.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /** `/api/projects/:name/board` — the exact lanes `omp-conductor board --json`
224
+ * prints for the same project at the same moment, parsed so the UI consumes
225
+ * them as an object. */
226
+ async function boardProducer(name: string): Promise<unknown> {
227
+ const snapshot = await boardSnapshotOnce(name);
228
+ return JSON.parse(boardJson(snapshot)) as unknown;
229
+ }
230
+
231
+ /** `/api/projects/:name/status` — the `StatusSnapshot` plus the `fleetLayers`
232
+ * verdict, structured (not the rendered text). */
233
+ async function statusProducer(name: string): Promise<{ status: StatusSnapshot; fleetLayers: FleetLayers }> {
234
+ return { status: statusSnapshot(name), fleetLayers: fleetLayers(name) };
235
+ }
236
+
237
+ /** `/api/projects/:name/ledger` — the verb ledger + turn-override ledger, with
238
+ * the CLI's own defaults (limit 50) so the two cannot disagree. */
239
+ async function ledgerProducer(
240
+ name: string,
241
+ opts: { issue?: number; limit: number },
242
+ ): Promise<{ verb: VerbLedgerEntry[]; turnOverrides: TurnOverride[] }> {
243
+ const store = openStore(dbPath());
244
+ try {
245
+ return {
246
+ verb: store.verbLedger(name, opts),
247
+ turnOverrides: store.turnOverrideLedger(name, opts),
248
+ };
249
+ } finally {
250
+ store.close();
251
+ }
252
+ }
253
+
254
+ /** `/api/projects/:name/reports` — the store readers `status` already uses:
255
+ * open reports, the digest backlog, and the held notices awaiting a digest. */
256
+ async function reportsProducer(
257
+ name: string,
258
+ ): Promise<{ openReports: ReportRecord[]; digestBacklog: DigestBacklog; heldNotices: HeldNotice[] }> {
259
+ const store = openStore(dbPath());
260
+ try {
261
+ return {
262
+ openReports: store.openReports(name),
263
+ digestBacklog: store.digestBacklog(name),
264
+ heldNotices: store.undigestedNotices(name),
265
+ };
266
+ } finally {
267
+ store.close();
268
+ }
269
+ }
270
+
271
+ /** `/api/projects/:name/runs/:issue` — every run row for the issue: attempts,
272
+ * states, spend, failure classes, PR URLs. */
273
+ async function runsProducer(name: string, issue: number): Promise<{ runs: RunRecord[] }> {
274
+ const store = openStore(dbPath());
275
+ try {
276
+ return { runs: store.runsForIssue(name, issue) };
277
+ } finally {
278
+ store.close();
279
+ }
280
+ }
281
+
282
+ /**
283
+ * The deps a live dashboard is wired with. Every endpoint defaults to the real
284
+ * producer above; a test overrides only the slice it exercises.
285
+ */
165
286
  export interface DashboardHttpDeps {
166
287
  /** The bearer token every `/api/*` request must carry. */
167
288
  token: string;
168
289
  /** The `/api/projects` payload. */
169
290
  projects(): Promise<DashboardProjectView[]>;
291
+ /** The `/api/overview` fleet rows. */
292
+ overview(): Promise<FleetOverviewRow[]>;
293
+ board(name: string): Promise<unknown>;
294
+ status(name: string): Promise<{ status: StatusSnapshot; fleetLayers: FleetLayers }>;
295
+ ledger(
296
+ name: string,
297
+ opts: { issue?: number; limit: number },
298
+ ): Promise<{ verb: VerbLedgerEntry[]; turnOverrides: TurnOverride[] }>;
299
+ reports(
300
+ name: string,
301
+ ): Promise<{ openReports: ReportRecord[]; digestBacklog: DigestBacklog; heldNotices: HeldNotice[] }>;
302
+ runs(name: string, issue: number): Promise<{ runs: RunRecord[] }>;
303
+ }
304
+
305
+ /** The deps bound to the real producers — what `startDashboard` serves with. */
306
+ export function defaultDashboardDeps(token: string): DashboardHttpDeps {
307
+ return {
308
+ token,
309
+ projects: dashboardProjects,
310
+ overview: fleetOverview,
311
+ board: boardProducer,
312
+ status: statusProducer,
313
+ ledger: ledgerProducer,
314
+ reports: reportsProducer,
315
+ runs: runsProducer,
316
+ };
170
317
  }
171
318
 
172
319
  const STATIC_FILES: Record<string, string> = {
@@ -190,22 +337,89 @@ function bearerToken(req: Request): string | undefined {
190
337
  return header.startsWith(prefix) ? header.slice(prefix.length).trim() : undefined;
191
338
  }
192
339
 
340
+ /**
341
+ * The ledger's query params. `limit` defaults to 50 — the CLI's own default —
342
+ * so the endpoint and `omp-conductor ledger` cannot disagree. An explicit
343
+ * non-positive or non-integral limit or issue is a client error (400), never a
344
+ * silent fallback that hides a typo'd URL.
345
+ */
346
+ function parseLedgerParams(url: URL): { issue?: number; limit: number } | null {
347
+ const issueParam = url.searchParams.get("issue");
348
+ const limitParam = url.searchParams.get("limit");
349
+ // `Number`, not `parseInt`: `1.5` and `-1` must be rejected, never truncated
350
+ // down to a valid-seeming value.
351
+ const issueNumber = issueParam === null ? undefined : Number(issueParam);
352
+ const issue = issueNumber !== undefined && Number.isInteger(issueNumber) ? issueNumber : undefined;
353
+ const limitParamValue = limitParam === null ? 50 : Number(limitParam);
354
+ const limit = Number.isInteger(limitParamValue) ? limitParamValue : Number.NaN;
355
+ if (issueParam !== null && (issue === undefined || issue < 1)) return null;
356
+ if (!Number.isSafeInteger(limit) || limit < 1) return null;
357
+ return { ...(issue === undefined ? {} : { issue }), limit };
358
+ }
359
+
193
360
  /**
194
361
  * The dashboard's whole HTTP surface, separated from the socket exactly like
195
362
  * `daemonHttpResponse` so a test can drive it without binding a port. The UI
196
363
  * ships on disk and is served without auth; `/api/*` is bearer-authenticated.
197
364
  */
198
365
  export async function dashboardResponse(req: Request, d: DashboardHttpDeps): Promise<Response> {
199
- const path = new URL(req.url).pathname;
366
+ const url = new URL(req.url);
367
+ const path = url.pathname;
200
368
  if (path.startsWith("/api/")) {
201
369
  const presented = bearerToken(req);
202
370
  if (presented === undefined || !verifyDashboardToken(presented, d.token)) {
203
371
  // Bare 401: no body, no scheme hint — nothing about the token leaks.
204
372
  return new Response("", { status: 401 });
205
373
  }
206
- if (req.method === "GET" && path === "/api/projects") {
207
- return Response.json(await d.projects());
374
+ // Read-only surface: every /api route is a GET. Everything else stays
375
+ // 404 (after the auth check, so the surface cannot be probed).
376
+ if (req.method !== "GET") return new Response("", { status: 404 });
377
+
378
+ if (path === "/api/projects") return Response.json(await d.projects());
379
+ if (path === "/api/overview") return Response.json(await d.overview());
380
+
381
+ // `/api/projects/:name/{board,status,ledger,reports}` and
382
+ // `/api/projects/:name/runs/:issue`. Project names are matched against the
383
+ // configured set so an unknown name is a 404, never the producer's throw.
384
+ const name = (m: RegExpExecArray): string | undefined => {
385
+ try {
386
+ return decodeURIComponent(m[1]!);
387
+ } catch {
388
+ return undefined;
389
+ }
390
+ };
391
+ const known = (project: string | undefined): project is string =>
392
+ project !== undefined && loadConfig().projects.some((p) => p.name === project);
393
+
394
+ const proj = /^\/api\/projects\/([^/]+)\/(board|status|ledger|reports)$/.exec(path);
395
+ if (proj !== null) {
396
+ const project = name(proj);
397
+ if (!known(project)) return new Response("", { status: 404 });
398
+ switch (proj[2]) {
399
+ case "board":
400
+ return Response.json(await d.board(project!));
401
+ case "status":
402
+ return Response.json(await d.status(project!));
403
+ case "ledger": {
404
+ const opts = parseLedgerParams(url);
405
+ if (opts === null) return new Response("", { status: 400 });
406
+ return Response.json(await d.ledger(project!, opts));
407
+ }
408
+ case "reports":
409
+ return Response.json(await d.reports(project!));
410
+ }
208
411
  }
412
+
413
+ const runs = /^\/api\/projects\/([^/]+)\/runs\/(\d+)$/.exec(path);
414
+ if (runs !== null) {
415
+ const project = name(runs);
416
+ const issue = Number.parseInt(runs[2]!, 10);
417
+ if (!known(project) || !Number.isSafeInteger(issue)) {
418
+ return new Response("", { status: 404 });
419
+ }
420
+ return Response.json(await d.runs(project!, issue));
421
+ }
422
+
209
423
  return new Response("", { status: 404 });
210
424
  }
211
425
  // Everything outside /api is static UI, unauthenticated by contract.
@@ -243,7 +457,7 @@ export async function startDashboard(opts: DashboardStartOpts = {}): Promise<voi
243
457
  const server = Bun.serve({
244
458
  hostname: host,
245
459
  port: opts.port ?? DASHBOARD_PORT,
246
- fetch: (req) => dashboardResponse(req, { token, projects: dashboardProjects }),
460
+ fetch: (req) => dashboardResponse(req, defaultDashboardDeps(token)),
247
461
  });
248
462
  // An IPv6 literal needs brackets to be a clickable URL.
249
463
  const displayHost = host.includes(":") ? `[${host}]` : host;
@@ -35,7 +35,7 @@ h1 {
35
35
 
36
36
  main {
37
37
  padding: 0 1.5rem 2rem;
38
- max-width: 56rem;
38
+ max-width: 76rem;
39
39
  }
40
40
 
41
41
  code {
@@ -167,6 +167,174 @@ button {
167
167
  font-weight: 600;
168
168
  }
169
169
 
170
+ /* Monitoring surface (#294) */
171
+ .is-clickable {
172
+ cursor: pointer;
173
+ }
174
+
175
+ .is-clickable:hover {
176
+ border-color: var(--muted);
177
+ }
178
+
179
+ .back {
180
+ font-size: 0.9rem;
181
+ }
182
+
183
+ a {
184
+ color: var(--ink);
185
+ text-decoration: underline;
186
+ text-underline-offset: 2px;
187
+ }
188
+
189
+ .state-degraded {
190
+ color: var(--down);
191
+ font-weight: 800;
192
+ letter-spacing: 0.04em;
193
+ }
194
+
195
+ .state-muted,
196
+ .state-red {
197
+ color: var(--down);
198
+ }
199
+
200
+ .state-green {
201
+ color: var(--up);
202
+ }
203
+
204
+ .meta {
205
+ color: var(--muted);
206
+ font-size: 0.85rem;
207
+ }
208
+
209
+ .base {
210
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
211
+ }
212
+
213
+ .summary {
214
+ display: flex;
215
+ flex-direction: column;
216
+ gap: 0.25rem;
217
+ margin: 0.25rem 0 1rem;
218
+ }
219
+
220
+ .holds {
221
+ margin: 0.25rem 0 0;
222
+ padding-left: 1.25rem;
223
+ color: var(--warn);
224
+ font-size: 0.85rem;
225
+ }
226
+
227
+ .tabs {
228
+ display: flex;
229
+ gap: 0.4rem;
230
+ margin: 1rem 0;
231
+ flex-wrap: wrap;
232
+ }
233
+
234
+ .tabs button {
235
+ background: transparent;
236
+ color: var(--ink);
237
+ border: 1px solid var(--line);
238
+ }
239
+
240
+ .tabs button.active {
241
+ background: var(--ink);
242
+ color: var(--bg);
243
+ border-color: var(--ink);
244
+ }
245
+
246
+ .lanes {
247
+ display: flex;
248
+ gap: 0.75rem;
249
+ overflow-x: auto;
250
+ align-items: flex-start;
251
+ }
252
+
253
+ .lane {
254
+ flex: 0 0 13rem;
255
+ background: var(--card);
256
+ border: 1px solid var(--line);
257
+ border-radius: 10px;
258
+ padding: 0.6rem;
259
+ }
260
+
261
+ .lane-title {
262
+ font-weight: 700;
263
+ font-size: 0.8rem;
264
+ letter-spacing: 0.05em;
265
+ color: var(--muted);
266
+ margin-bottom: 0.5rem;
267
+ }
268
+
269
+ .cards {
270
+ display: flex;
271
+ flex-direction: column;
272
+ gap: 0.4rem;
273
+ }
274
+
275
+ .card {
276
+ display: flex;
277
+ flex-direction: column;
278
+ align-items: flex-start;
279
+ gap: 0.1rem;
280
+ text-align: left;
281
+ background: var(--bg);
282
+ border: 1px solid var(--line);
283
+ color: var(--ink);
284
+ border-radius: 8px;
285
+ padding: 0.5rem 0.6rem;
286
+ cursor: pointer;
287
+ }
288
+
289
+ .card:hover {
290
+ border-color: var(--muted);
291
+ }
292
+
293
+ .card-state {
294
+ font-size: 0.78rem;
295
+ color: var(--muted);
296
+ }
297
+
298
+ .empty {
299
+ color: var(--muted);
300
+ font-size: 0.85rem;
301
+ }
302
+
303
+ table.ledger,
304
+ table.runs {
305
+ width: 100%;
306
+ border-collapse: collapse;
307
+ font-size: 0.85rem;
308
+ margin-bottom: 1.25rem;
309
+ }
310
+
311
+ table.ledger th,
312
+ table.ledger td,
313
+ table.runs th,
314
+ table.runs td {
315
+ border-bottom: 1px solid var(--line);
316
+ padding: 0.4rem 0.6rem;
317
+ text-align: left;
318
+ vertical-align: top;
319
+ }
320
+
321
+ .mono {
322
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
323
+ font-size: 0.82rem;
324
+ }
325
+
326
+ ul.reports,
327
+ #run-attempts .empty {
328
+ list-style: none;
329
+ margin: 0 0 1rem;
330
+ padding: 0;
331
+ color: var(--ink);
332
+ }
333
+
334
+ ul.reports li {
335
+ padding: 0.15rem 0;
336
+ }
337
+
170
338
  @media (prefers-color-scheme: dark) {
171
339
  :root {
172
340
  --bg: #17171a;