omp-conductor 0.18.2 → 0.19.0

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 (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The dashboard's mutating surface (epic #292, slice 3/5 — #295).
3
+ *
4
+ * Slices 1/5 and 2/5 made the fleet legible from a browser; this one makes it
5
+ * drivable, so an operator can pause dispatch, extend a run, unblock an issue,
6
+ * control a live worker and answer a decision without a shell on the host.
7
+ *
8
+ * ## One rule, and everything here follows from it
9
+ *
10
+ * **No parallel implementations.** Every action routes to the exact code its CLI
11
+ * twin runs, and where that code already lives behind an HTTP endpoint the
12
+ * dashboard *calls that endpoint* rather than reaching past it to the store.
13
+ *
14
+ * That second half is the part worth stating, because the naive reading goes the
15
+ * other way. `extend` ultimately calls `store.setTurnOverride`, so it is tempting
16
+ * to call that from here. But `turnLimitResponse` is not a thin wrapper around it:
17
+ * it owns a whole validation ladder — the `workerMaxTurnsCeiling` bound, the
18
+ * live-controller check against the run's stored state, the "already has a higher
19
+ * ceiling" refusal, the live-registry extend that takes effect *this* turn versus
20
+ * the next-attempt override that does not. Reimplementing that here would be
21
+ * exactly the fork the slice forbids, and the copy nobody updated would be the
22
+ * one the browser used. So `extend` is proxied, like worker control.
23
+ *
24
+ * What is called directly is what has no HTTP owner: the pause sentinel and the
25
+ * tick marker are host-local files, and `unblock` and decision resolution are
26
+ * store operations the CLI performs in-process too.
27
+ *
28
+ * ## Attribution
29
+ *
30
+ * Every mutation records itself the way its CLI twin does, with source
31
+ * `"dashboard"` where the sink takes a source. That is what makes
32
+ * `omp-conductor status` able to say *who* paused the fleet, rather than only
33
+ * that it is paused.
34
+ *
35
+ * ## What is deliberately absent
36
+ *
37
+ * `arm`. It requires an inbound Telegram proof that a human answered a
38
+ * challenge, and a browser button cannot supply one. A dashboard `arm` would
39
+ * either weaken that proof or lie about having it, so arming stays CLI/Telegram
40
+ * exactly as the epic says.
41
+ */
42
+
43
+ import { disarmTicks, hold, releaseHold } from "../fleet.ts";
44
+ import { healthCheck, livingDaemon } from "../lifecycle.ts";
45
+ import { unblockIssue } from "../unblock.ts";
46
+ import { makeTracker } from "../tracker/github.ts";
47
+ import { dbPath, openStore } from "../store.ts";
48
+ import { loadConfig, resolveCaps } from "../config.ts";
49
+ import type { ProjectConfig } from "../types.ts";
50
+
51
+ /** The source string every dashboard mutation attributes itself with. */
52
+ /**
53
+ * The source token every dashboard mutation attributes itself with.
54
+ *
55
+ * A single word by contract: the pause fence matches on `source=` and whitespace
56
+ * there produces a pause it cannot prove (#552, `pauseSourceToken`). The
57
+ * operator's sentence rides in `reason=` instead.
58
+ */
59
+ export const DASHBOARD_SOURCE = "dashboard";
60
+
61
+ /** A mutation's outcome, already shaped as the HTTP answer it becomes. */
62
+ export interface ControlOutcome {
63
+ status: number;
64
+ body: unknown;
65
+ }
66
+
67
+ /** Injected so tests drive every branch without a daemon, a tracker or a clock. */
68
+ export interface ControlDeps {
69
+ /** The project this action names, already resolved by the router. */
70
+ project: ProjectConfig;
71
+ hold?: typeof hold;
72
+ releaseHold?: typeof releaseHold;
73
+ disarmTicks?: typeof disarmTicks;
74
+ /**
75
+ * Forwards one request to the owning daemon's own HTTP surface. Returns
76
+ * `undefined` when no living daemon serves this project — a 502 the caller
77
+ * words, never a silent success.
78
+ */
79
+ proxy?: (
80
+ project: ProjectConfig,
81
+ path: string,
82
+ body: unknown,
83
+ ) => Promise<{ status: number; body: unknown } | undefined>;
84
+ /** `unblockIssue` with the CLI's own tracker/store/caps wiring. */
85
+ unblock?: (
86
+ project: ProjectConfig,
87
+ issue: number,
88
+ opts: { force?: boolean },
89
+ ) => Promise<{ status: number; body: unknown }>;
90
+ /** Resolves one decision row; `false` when it changed nothing. */
91
+ answerDecision?: (id: string, answer: string) => boolean;
92
+ now?: () => number;
93
+ }
94
+
95
+ /** The reason string a pause/hold takes, validated once for every caller. */
96
+ function readReason(body: unknown): string | undefined {
97
+ if (body === null || typeof body !== "object") return undefined;
98
+ const reason = Reflect.get(body, "reason");
99
+ return typeof reason === "string" && reason.trim() !== "" ? reason.trim() : undefined;
100
+ }
101
+
102
+ /**
103
+ * Stop new claims, keeping the heartbeat.
104
+ *
105
+ * `keepTicks` is the whole reason this is `hold(..., { keepTicks: true })` and
106
+ * not a bare `hold`: disarming is the expensive half, because re-arming needs a
107
+ * Telegram challenge an operator has to answer in the chat. A browser button
108
+ * that cost a manual round trip to undo would not be used, so the dashboard's
109
+ * pause is the cheap, reversible one — `resume` alone restores the fleet.
110
+ */
111
+ export function dashboardPause(body: unknown, d: ControlDeps): ControlOutcome {
112
+ const reason = readReason(body);
113
+ if (reason === undefined) {
114
+ return { status: 400, body: { error: "reason must be a non-empty string" } };
115
+ }
116
+ const held = (d.hold ?? hold)(d.project.name, DASHBOARD_SOURCE, { keepTicks: true, reason });
117
+ return { status: 200, body: { paused: true, wasPaused: held.wasPaused, source: DASHBOARD_SOURCE } };
118
+ }
119
+
120
+ /** Clear the pause. Idempotent, exactly as `resume` is on the CLI. */
121
+ export function dashboardResume(d: ControlDeps): ControlOutcome {
122
+ (d.releaseHold ?? releaseHold)(d.project.name);
123
+ return { status: 200, body: { paused: false } };
124
+ }
125
+
126
+ /**
127
+ * The heavier hold: stop claims *and* disarm the heartbeat.
128
+ *
129
+ * Distinct from `pause` on purpose — this is the one that costs a Telegram
130
+ * challenge to undo — so the two are separate endpoints and the UI can warn
131
+ * about this one and not the other.
132
+ */
133
+ export function dashboardHold(body: unknown, d: ControlDeps): ControlOutcome {
134
+ const reason = readReason(body);
135
+ if (reason === undefined) {
136
+ return { status: 400, body: { error: "reason must be a non-empty string" } };
137
+ }
138
+ const held = (d.hold ?? hold)(d.project.name, DASHBOARD_SOURCE, { reason });
139
+ return {
140
+ status: 200,
141
+ body: {
142
+ paused: true,
143
+ wasPaused: held.wasPaused,
144
+ disarmed: held.disarmed?.wasArmed ?? false,
145
+ source: DASHBOARD_SOURCE,
146
+ },
147
+ };
148
+ }
149
+
150
+ /** Disarm ticks without touching dispatch. */
151
+ export function dashboardDisarm(d: ControlDeps): ControlOutcome {
152
+ const result = (d.disarmTicks ?? disarmTicks)(d.project.name);
153
+ return { status: 200, body: { armed: false, wasArmed: result.wasArmed, path: result.path } };
154
+ }
155
+
156
+ /**
157
+ * Raise one run's turn budget, through the daemon that owns it.
158
+ *
159
+ * Proxied rather than written directly: see this module's header. The daemon's
160
+ * `PUT /runs/:issue/turn-limit` owns every bound and refusal, and its answers —
161
+ * 409 for a settled run, 422 over the ceiling — pass through unchanged, so the
162
+ * browser and `omp-conductor extend` cannot come to disagree about what is
163
+ * allowed.
164
+ */
165
+ export async function dashboardExtend(
166
+ issue: number,
167
+ body: unknown,
168
+ d: ControlDeps,
169
+ ): Promise<ControlOutcome> {
170
+ const maxTurns = body === null || typeof body !== "object" ? undefined : Reflect.get(body, "maxTurns");
171
+ if (!Number.isSafeInteger(maxTurns) || (maxTurns as number) < 1) {
172
+ return { status: 400, body: { error: "maxTurns must be a positive integer" } };
173
+ }
174
+ return proxied(d, `/runs/${issue}/turn-limit`, {
175
+ project: d.project.name,
176
+ maxTurns: maxTurns as number,
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Pause, resume or stop one live worker, through the daemon that owns it.
182
+ *
183
+ * The daemon holds the session controller; nothing else can reach it. A dead
184
+ * daemon is a 502 naming it, never a 200 that pretends a worker was paused.
185
+ */
186
+ export async function dashboardWorkerControl(
187
+ issue: number,
188
+ action: "pause" | "resume" | "stop",
189
+ body: unknown,
190
+ d: ControlDeps,
191
+ ): Promise<ControlOutcome> {
192
+ const payload: Record<string, unknown> = { project: d.project.name };
193
+ if (action === "stop") {
194
+ const reason = readReason(body);
195
+ if (reason === undefined) {
196
+ // The CLI requires a reason to stop a run, because a terminal settlement
197
+ // with no recorded why is unauditable. A button must not be cheaper.
198
+ return { status: 400, body: { error: "reason must be a non-empty string" } };
199
+ }
200
+ payload["reason"] = `${DASHBOARD_SOURCE}: ${reason}`;
201
+ }
202
+ return proxied(d, `/runs/${issue}/${action}`, payload);
203
+ }
204
+
205
+ /** One proxied call, with the daemon-down answer worded once. */
206
+ async function proxied(d: ControlDeps, path: string, body: unknown): Promise<ControlOutcome> {
207
+ const forward = d.proxy ?? defaultProxy;
208
+ const answer = await forward(d.project, path, body);
209
+ if (answer === undefined) {
210
+ return {
211
+ status: 502,
212
+ body: {
213
+ error:
214
+ `no living daemon serves project "${d.project.name}", so ${path} could not be applied. ` +
215
+ "Start it with `omp-conductor start` and try again.",
216
+ },
217
+ };
218
+ }
219
+ return { status: answer.status, body: answer.body };
220
+ }
221
+
222
+ /**
223
+ * Take an issue out of a blocked/failed state, exactly as `omp-conductor
224
+ * unblock` does — same tracker, same store, same caps, same refusals.
225
+ *
226
+ * `force` is accepted and passed through, but the UI is required to confirm it
227
+ * by name: it records that a human inspected a worktree whose uncommitted work
228
+ * is the only copy, and a button that did that silently would destroy work
229
+ * nobody chose to lose.
230
+ */
231
+ export async function dashboardUnblock(
232
+ issue: number,
233
+ body: unknown,
234
+ d: ControlDeps,
235
+ ): Promise<ControlOutcome> {
236
+ const force = body !== null && typeof body === "object" && Reflect.get(body, "force") === true;
237
+ const run = d.unblock ?? defaultUnblock;
238
+ return run(d.project, issue, { force });
239
+ }
240
+
241
+ /**
242
+ * Answer one open decision row.
243
+ *
244
+ * `resolveDecision` returns false when it changed no rows, which is exactly the
245
+ * already-answered case — so a second answer is a 409 rather than a silent
246
+ * overwrite of the first. That matters more than it looks: the row is the record
247
+ * of what a human decided, and a later write would rewrite history.
248
+ */
249
+ export function dashboardAnswerDecision(id: string, body: unknown, d: ControlDeps): ControlOutcome {
250
+ const answer = body === null || typeof body !== "object" ? undefined : Reflect.get(body, "answer");
251
+ if (typeof answer !== "string" || answer.trim() === "") {
252
+ return { status: 400, body: { error: "answer must be a non-empty string" } };
253
+ }
254
+ const resolve = d.answerDecision ?? defaultAnswerDecision;
255
+ return resolve(id, answer.trim())
256
+ ? { status: 200, body: { id, state: "answered", resolution: answer.trim() } }
257
+ : {
258
+ status: 409,
259
+ body: { error: `decision ${id} is not open — it was already answered or withdrawn` },
260
+ };
261
+ }
262
+
263
+ // ------------------------------------------------------- production wiring --
264
+
265
+ /** Forward to the living daemon's own HTTP surface, or report there is none. */
266
+ async function defaultProxy(
267
+ project: ProjectConfig,
268
+ path: string,
269
+ body: unknown,
270
+ ): Promise<{ status: number; body: unknown } | undefined> {
271
+ const record = livingDaemon();
272
+ // The same membership rule every other dashboard read uses (#379): a record
273
+ // pinned to another project does not serve this one, and forwarding to it
274
+ // would apply an operator's action to the wrong fleet.
275
+ if (record === undefined) return undefined;
276
+ if (record.project !== undefined && record.project !== project.name) return undefined;
277
+ // Cheap liveness first, so an unreachable daemon is a 502 rather than a
278
+ // mutation attempt that hangs the browser.
279
+ if (!(await healthCheck(record.port)).ok) return undefined;
280
+ try {
281
+ const res = await fetch(`http://127.0.0.1:${record.port}${path}`, {
282
+ method: "PUT",
283
+ headers: { "content-type": "application/json" },
284
+ body: JSON.stringify(body),
285
+ signal: AbortSignal.timeout(10_000),
286
+ });
287
+ const text = await res.text();
288
+ let parsed: unknown;
289
+ try {
290
+ parsed = JSON.parse(text) as unknown;
291
+ } catch {
292
+ parsed = { error: text.trim() };
293
+ }
294
+ return { status: res.status, body: parsed };
295
+ } catch {
296
+ return undefined;
297
+ }
298
+ }
299
+
300
+ /** `unblockIssue` with the CLI's own wiring, translated to an HTTP answer. */
301
+ async function defaultUnblock(
302
+ project: ProjectConfig,
303
+ issue: number,
304
+ opts: { force?: boolean },
305
+ ): Promise<{ status: number; body: unknown }> {
306
+ const store = openStore(dbPath());
307
+ try {
308
+ const cfg = loadConfig();
309
+ const outcome = await unblockIssue(
310
+ project,
311
+ makeTracker(project),
312
+ store,
313
+ issue,
314
+ opts,
315
+ resolveCaps(project, cfg.defaults),
316
+ );
317
+ // A refusal is the store's own verdict — an uncommitted worktree that is the
318
+ // only copy of a worker's output — and it is a 409, not a 500: nothing went
319
+ // wrong, the answer is no.
320
+ return outcome.refused === undefined
321
+ ? { status: 200, body: outcome }
322
+ : { status: 409, body: outcome };
323
+ } finally {
324
+ store.close();
325
+ }
326
+ }
327
+
328
+ /** Resolve one decision through the same store call `decision resolve` uses. */
329
+ function defaultAnswerDecision(id: string, answer: string): boolean {
330
+ const store = openStore(dbPath());
331
+ try {
332
+ return store.resolveDecision(id, "answered", answer, Date.now());
333
+ } finally {
334
+ store.close();
335
+ }
336
+ }
@@ -35,18 +35,48 @@
35
35
  <h2 id="project-title"></h2>
36
36
  <p id="project-daemon" class="state"></p>
37
37
  <div id="project-summary" class="summary"></div>
38
+ <!-- The control surface (#295). `arm` is deliberately absent: it needs an
39
+ inbound Telegram proof a browser cannot supply, so it stays CLI. -->
40
+ <div class="controls" id="controls">
41
+ <button data-control="pause" type="button">Pause dispatch</button>
42
+ <button data-control="resume" type="button">Resume</button>
43
+ <button data-control="hold" type="button" class="destructive">Hold (disarms ticks)</button>
44
+ <button data-control="disarm" type="button" class="destructive">Disarm ticks</button>
45
+ <p id="control-result" class="state" hidden></p>
46
+ </div>
38
47
  <nav class="tabs" id="tabs">
39
48
  <button data-tab="board" type="button">Board</button>
40
49
  <button data-tab="ledger" type="button">Ledger</button>
41
50
  <button data-tab="reports" type="button">Reports</button>
51
+ <button data-tab="stats" type="button">Analytics</button>
42
52
  </nav>
43
53
  <section id="panel-board"></section>
44
54
  <section id="panel-ledger" hidden></section>
45
55
  <section id="panel-reports" hidden></section>
56
+ <section id="panel-stats" hidden>
57
+ <!-- Analytics (#297). The window selector mirrors `stats --since`, and the
58
+ numbers are the same aggregation `stats --json` prints. -->
59
+ <div class="controls" id="stats-window">
60
+ <button data-since="7d" type="button">7 days</button>
61
+ <button data-since="30d" type="button">30 days</button>
62
+ <button data-since="custom" type="button">Custom…</button>
63
+ <span id="stats-window-label" class="state"></span>
64
+ </div>
65
+ <div id="stats-body"></div>
66
+ </section>
46
67
 
47
68
  <div id="run-detail" hidden>
48
69
  <p class="back"><a href="#" id="back-to-board">← board</a></p>
49
70
  <h3 id="run-title"></h3>
71
+ <div class="controls" id="run-controls"></div>
72
+ <!-- Live transcript (#296): the same lines `omp-conductor tail` prints,
73
+ streamed as SSE. Autoscroll is on until the operator scrolls up. -->
74
+ <div class="transcript-head">
75
+ <button id="transcript-toggle" type="button">Watch transcript</button>
76
+ <label><input type="checkbox" id="transcript-autoscroll" checked /> autoscroll</label>
77
+ <span id="transcript-state" class="state"></span>
78
+ </div>
79
+ <pre id="transcript" class="transcript" hidden></pre>
50
80
  <table class="runs">
51
81
  <thead>
52
82
  <tr><th>Attempt</th><th>State</th><th>Spend</th><th>Failure class</th><th>PR</th><th>Started</th></tr>