/** * The dashboard's mutating surface (epic #292, slice 3/5 — #295). * * Slices 1/5 and 2/5 made the fleet legible from a browser; this one makes it * drivable, so an operator can pause dispatch, extend a run, unblock an issue, * control a live worker and answer a decision without a shell on the host. * * ## One rule, and everything here follows from it * * **No parallel implementations.** Every action routes to the exact code its CLI * twin runs, and where that code already lives behind an HTTP endpoint the * dashboard *calls that endpoint* rather than reaching past it to the store. * * That second half is the part worth stating, because the naive reading goes the * other way. `extend` ultimately calls `store.setTurnOverride`, so it is tempting * to call that from here. But `turnLimitResponse` is not a thin wrapper around it: * it owns a whole validation ladder — the `workerMaxTurnsCeiling` bound, the * live-controller check against the run's stored state, the "already has a higher * ceiling" refusal, the live-registry extend that takes effect *this* turn versus * the next-attempt override that does not. Reimplementing that here would be * exactly the fork the slice forbids, and the copy nobody updated would be the * one the browser used. So `extend` is proxied, like worker control. * * What is called directly is what has no HTTP owner: the pause sentinel and the * tick marker are host-local files, and `unblock` and decision resolution are * store operations the CLI performs in-process too. * * ## Attribution * * Every mutation records itself the way its CLI twin does, with source * `"dashboard"` where the sink takes a source. That is what makes * `omp-conductor status` able to say *who* paused the fleet, rather than only * that it is paused. * * ## What is deliberately absent * * `arm`. It requires an inbound Telegram proof that a human answered a * challenge, and a browser button cannot supply one. A dashboard `arm` would * either weaken that proof or lie about having it, so arming stays CLI/Telegram * exactly as the epic says. */ import { disarmTicks, hold, releaseHold } from "../fleet.ts"; import { healthCheck, livingDaemon } from "../lifecycle.ts"; import { unblockIssue } from "../unblock.ts"; import { makeTracker } from "../tracker/github.ts"; import { dbPath, openStore } from "../store.ts"; import { loadConfig, resolveCaps } from "../config.ts"; import { acknowledgeUpgradeRecovery } from "../upgrade-verify.ts"; import { httpAuthHeader, missingHttpTokenMessage } from "../http-token.ts"; import type { ProjectConfig } from "../types.ts"; /** The source string every dashboard mutation attributes itself with. */ /** * The source token every dashboard mutation attributes itself with. * * A single word by contract: the pause fence matches on `source=` and whitespace * there produces a pause it cannot prove (#552, `pauseSourceToken`). The * operator's sentence rides in `reason=` instead. */ export const DASHBOARD_SOURCE = "dashboard"; /** A mutation's outcome, already shaped as the HTTP answer it becomes. */ export interface ControlOutcome { status: number; body: unknown; } /** Injected so tests drive every branch without a daemon, a tracker or a clock. */ export interface ControlDeps { /** The project this action names, already resolved by the router. */ project: ProjectConfig; hold?: typeof hold; releaseHold?: typeof releaseHold; acknowledgeUpgradeRecovery?: typeof acknowledgeUpgradeRecovery; disarmTicks?: typeof disarmTicks; /** * Forwards one request to the owning daemon's own HTTP surface. Returns * `undefined` when no living daemon serves this project — a 502 the caller * words, never a silent success. */ proxy?: ( project: ProjectConfig, path: string, body: unknown, ) => Promise<{ status: number; body: unknown } | undefined>; /** `unblockIssue` with the CLI's own tracker/store/caps wiring. */ unblock?: ( project: ProjectConfig, issue: number, opts: { force?: boolean }, ) => Promise<{ status: number; body: unknown }>; /** Resolves one decision row; `false` when it changed nothing. */ answerDecision?: (id: string, answer: string) => boolean; now?: () => number; } /** The reason string a pause/hold takes, validated once for every caller. */ function readReason(body: unknown): string | undefined { if (body === null || typeof body !== "object") return undefined; const reason = Reflect.get(body, "reason"); return typeof reason === "string" && reason.trim() !== "" ? reason.trim() : undefined; } /** * Stop new claims, keeping the heartbeat. * * `keepTicks` is the whole reason this is `hold(..., { keepTicks: true })` and * not a bare `hold`: disarming is the expensive half, because re-arming needs a * Telegram challenge an operator has to answer in the chat. A browser button * that cost a manual round trip to undo would not be used, so the dashboard's * pause is the cheap, reversible one — `resume` alone restores the fleet. */ export function dashboardPause(body: unknown, d: ControlDeps): ControlOutcome { const reason = readReason(body); if (reason === undefined) { return { status: 400, body: { error: "reason must be a non-empty string" } }; } const held = (d.hold ?? hold)(d.project.name, DASHBOARD_SOURCE, { keepTicks: true, reason }); return { status: 200, body: { paused: true, wasPaused: held.wasPaused, source: DASHBOARD_SOURCE } }; } /** Clear the pause. Idempotent, exactly as `resume` is on the CLI. */ export function dashboardResume(d: ControlDeps): ControlOutcome { (d.acknowledgeUpgradeRecovery ?? acknowledgeUpgradeRecovery)(); (d.releaseHold ?? releaseHold)(d.project.name); return { status: 200, body: { paused: false } }; } /** * The heavier hold: stop claims *and* disarm the heartbeat. * * Distinct from `pause` on purpose — this is the one that costs a Telegram * challenge to undo — so the two are separate endpoints and the UI can warn * about this one and not the other. */ export function dashboardHold(body: unknown, d: ControlDeps): ControlOutcome { const reason = readReason(body); if (reason === undefined) { return { status: 400, body: { error: "reason must be a non-empty string" } }; } const held = (d.hold ?? hold)(d.project.name, DASHBOARD_SOURCE, { reason }); return { status: 200, body: { paused: true, wasPaused: held.wasPaused, disarmed: held.disarmed?.wasArmed ?? false, source: DASHBOARD_SOURCE, }, }; } /** Disarm ticks without touching dispatch. */ export function dashboardDisarm(d: ControlDeps): ControlOutcome { const result = (d.disarmTicks ?? disarmTicks)(d.project.name); return { status: 200, body: { armed: false, wasArmed: result.wasArmed, path: result.path } }; } /** * Raise one run's turn budget, through the daemon that owns it. * * Proxied rather than written directly: see this module's header. The daemon's * `PUT /runs/:issue/turn-limit` owns every bound and refusal, and its answers — * 409 for a settled run, 422 over the ceiling — pass through unchanged, so the * browser and `omp-conductor extend` cannot come to disagree about what is * allowed. */ export async function dashboardExtend( issue: number, body: unknown, d: ControlDeps, ): Promise { const maxTurns = body === null || typeof body !== "object" ? undefined : Reflect.get(body, "maxTurns"); if (!Number.isSafeInteger(maxTurns) || (maxTurns as number) < 1) { return { status: 400, body: { error: "maxTurns must be a positive integer" } }; } return proxied(d, `/runs/${issue}/turn-limit`, { project: d.project.name, maxTurns: maxTurns as number, }); } /** * Pause, resume or stop one live worker, through the daemon that owns it. * * The daemon holds the session controller; nothing else can reach it. A dead * daemon is a 502 naming it, never a 200 that pretends a worker was paused. */ export async function dashboardWorkerControl( issue: number, action: "pause" | "resume" | "stop", body: unknown, d: ControlDeps, ): Promise { const payload: Record = { project: d.project.name, source: "dashboard" }; if (action === "stop") { const reason = readReason(body); if (reason === undefined) { // The CLI requires a reason to stop a run, because a terminal settlement // with no recorded why is unauditable. A button must not be cheaper. return { status: 400, body: { error: "reason must be a non-empty string" } }; } payload["reason"] = `${DASHBOARD_SOURCE}: ${reason}`; } return proxied(d, `/runs/${issue}/${action}`, payload); } /** One proxied call, with the daemon-down answer worded once. */ async function proxied(d: ControlDeps, path: string, body: unknown): Promise { const forward = d.proxy ?? defaultProxy; const answer = await forward(d.project, path, body); if (answer === undefined) { return { status: 502, body: { error: `no living daemon serves project "${d.project.name}", so ${path} could not be applied. ` + "Start it with `omp-conductor start` and try again.", }, }; } return { status: answer.status, body: answer.body }; } /** * Take an issue out of a blocked/failed state, exactly as `omp-conductor * unblock` does — same tracker, same store, same caps, same refusals. * * `force` is accepted and passed through, but the UI is required to confirm it * by name: it records that a human inspected a worktree whose uncommitted work * is the only copy, and a button that did that silently would destroy work * nobody chose to lose. */ export async function dashboardUnblock( issue: number, body: unknown, d: ControlDeps, ): Promise { const force = body !== null && typeof body === "object" && Reflect.get(body, "force") === true; const run = d.unblock ?? defaultUnblock; return run(d.project, issue, { force }); } /** * Answer one open decision row. * * `resolveDecision` returns false when it changed no rows, which is exactly the * already-answered case — so a second answer is a 409 rather than a silent * overwrite of the first. That matters more than it looks: the row is the record * of what a human decided, and a later write would rewrite history. */ export function dashboardAnswerDecision(id: string, body: unknown, d: ControlDeps): ControlOutcome { const answer = body === null || typeof body !== "object" ? undefined : Reflect.get(body, "answer"); if (typeof answer !== "string" || answer.trim() === "") { return { status: 400, body: { error: "answer must be a non-empty string" } }; } const resolve = d.answerDecision ?? defaultAnswerDecision; return resolve(id, answer.trim()) ? { status: 200, body: { id, state: "answered", resolution: answer.trim() } } : { status: 409, body: { error: `decision ${id} is not open — it was already answered or withdrawn` }, }; } // ------------------------------------------------------- production wiring -- /** * Forward to the living daemon's own HTTP surface, or report there is none. * * Every route this forwards to is mutating, so the request carries the * daemon's bearer token (Phase 4). Note this is deliberately *not* the * dashboard's own token: the browser authenticated to the dashboard, and the * dashboard authenticates to the daemon as a second, separately-scoped hop. * A missing daemon token is its own answer rather than the 502 "no living * daemon" — the daemon may be perfectly alive; what is missing is the * credential file, and telling the operator "start the daemon" when it is * already running would send them chasing the wrong thing. * * Exported so the suite can drive the *production* forwarder — the one that * actually builds the request — against a stub daemon. Every other test injects * `ControlDeps.proxy`, which by construction cannot prove this function sends * the credential. */ export async function defaultProxy( project: ProjectConfig, path: string, body: unknown, ): Promise<{ status: number; body: unknown } | undefined> { const record = livingDaemon(); // The same membership rule every other dashboard read uses (#379): a record // pinned to another project does not serve this one, and forwarding to it // would apply an operator's action to the wrong fleet. if (record === undefined) return undefined; if (record.project !== undefined && record.project !== project.name) return undefined; const auth = httpAuthHeader(); if (auth === undefined) return { status: 503, body: { error: missingHttpTokenMessage() } }; // Cheap liveness first, so an unreachable daemon is a 502 rather than a // mutation attempt that hangs the browser. if (!(await healthCheck(record.port)).ok) return undefined; try { const res = await fetch(`http://127.0.0.1:${record.port}${path}`, { method: "PUT", headers: { "content-type": "application/json", ...auth }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000), }); const text = await res.text(); let parsed: unknown; try { parsed = JSON.parse(text) as unknown; } catch { parsed = { error: text.trim() }; } return { status: res.status, body: parsed }; } catch { return undefined; } } /** `unblockIssue` with the CLI's own wiring, translated to an HTTP answer. */ async function defaultUnblock( project: ProjectConfig, issue: number, opts: { force?: boolean }, ): Promise<{ status: number; body: unknown }> { const store = openStore(dbPath()); try { const cfg = loadConfig(); const outcome = await unblockIssue( project, makeTracker(project), store, issue, opts, resolveCaps(project, cfg.defaults), ); // A refusal is the store's own verdict — an uncommitted worktree that is the // only copy of a worker's output — and it is a 409, not a 500: nothing went // wrong, the answer is no. return outcome.refused === undefined ? { status: 200, body: outcome } : { status: 409, body: outcome }; } finally { store.close(); } } /** Resolve one decision through the same store call `decision resolve` uses. */ function defaultAnswerDecision(id: string, answer: string): boolean { const store = openStore(dbPath()); try { return store.resolveDecision(id, "answered", answer, Date.now()); } finally { store.close(); } }