diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ba4aa9b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,133 @@ +name: ci + +# The entry point for every push and pull request, and the only workflow whose +# name belongs in branch protection. +# +# Why this exists at all: the four component suites are path-filtered, because +# a change to a phone runner has no business spinning up a Node matrix or a +# macOS runner. But GitHub reports a required check that never ran as *pending +# forever*, so a path-filtered workflow can never itself be a required check -- +# an iOS-only pull request would sit blocked on the collector's suite until +# somebody force-merged it. +# +# So the suites became reusable workflows, called from here behind a change +# detector, and `ci-ok` at the bottom always runs and reports. It is the one +# check to require. A suite that was skipped because nothing it watches changed +# counts as success; a suite that actually failed fails the gate. + +on: + push: + branches: [main, develop] + pull_request: + +# A second push to a pull request makes the first one's answer irrelevant. +concurrency: + group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changes: + name: what changed + runs-on: ubuntu-latest + outputs: + collector: ${{ steps.filter.outputs.collector }} + android: ${{ steps.filter.outputs.android }} + ios: ${{ steps.filter.outputs.ios }} + machine: ${{ steps.filter.outputs.machine }} + steps: + - uses: actions/checkout@v4 + with: + # The diff below needs both ends of the range in this checkout, and + # the default shallow clone has neither. + fetch-depth: 0 + + - id: filter + env: + BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + run: | + set -euo pipefail + + # An absent base, the all-zero sha of a branch's first push, or a sha + # this checkout does not have all mean the same thing: the range is + # unknowable. Run everything. Guessing wrong in the other direction + # skips a suite silently, which is the failure this file exists to + # prevent. + if [ -z "${BASE:-}" ] \ + || [ "$BASE" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then + echo "base unusable ('${BASE:-}') -- running every suite" + CHANGED="__ALL__" + else + CHANGED="$(git diff --name-only "$BASE" HEAD)" + echo "changed files:" + printf '%s\n' "$CHANGED" | sed 's/^/ /' + fi + + match() { + if [ "$CHANGED" = "__ALL__" ]; then + echo true + elif printf '%s\n' "$CHANGED" | grep -qE "$1"; then + echo true + else + echo false + fi + } + + # ci.yml is in every pattern on purpose: a change to the gate itself + # should be proved against all four suites, not merged on the word of + # a detector it just edited. + { + echo "collector=$(match '^(collector/|\.github/workflows/(collector|ci)\.yml)')" + echo "android=$(match '^(runner-android/|\.github/workflows/(runner-android|ci)\.yml)')" + echo "ios=$(match '^(runner-ios/|\.github/workflows/(runner-ios|ci)\.yml)')" + echo "machine=$(match '^(runner-machine/|\.github/workflows/(runner-machine|ci)\.yml)')" + } | tee -a "$GITHUB_OUTPUT" + + collector: + needs: changes + if: needs.changes.outputs.collector == 'true' + uses: ./.github/workflows/collector.yml + + runner-android: + needs: changes + if: needs.changes.outputs.android == 'true' + uses: ./.github/workflows/runner-android.yml + + runner-ios: + needs: changes + if: needs.changes.outputs.ios == 'true' + uses: ./.github/workflows/runner-ios.yml + + runner-machine: + needs: changes + if: needs.changes.outputs.machine == 'true' + uses: ./.github/workflows/runner-machine.yml + + ci-ok: + name: ci-ok + needs: [changes, collector, runner-android, runner-ios, runner-machine] + # Without this the gate inherits the default "only if every dependency + # succeeded", which makes a skipped suite skip the gate -- and a skipped + # required check never reports, which is the exact bug being fixed. + if: always() + runs-on: ubuntu-latest + steps: + - name: Every suite green or skipped + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + set -euo pipefail + echo "suite results: $RESULTS" + for result in $RESULTS; do + case "$result" in + success|skipped) ;; + *) + echo "::error::a suite reported '$result'" + exit 1 + ;; + esac + done + echo "all suites green or skipped" diff --git a/.github/workflows/collector.yml b/.github/workflows/collector.yml index eeaf1cd..380c5e4 100644 --- a/.github/workflows/collector.yml +++ b/.github/workflows/collector.yml @@ -1,18 +1,15 @@ name: collector -# Path-filtered: a change to a phone runner should not spin up a Node matrix. +# Called by ci.yml, which owns the change detection and the `ci-ok` check +# that branch protection requires. Nothing else triggers this file. # -# The trap this creates is worth knowing about before you add a required check -# in branch protection. A filtered workflow does not run on a PR that touches -# nothing it watches, and GitHub reports a required check that never ran as -# pending forever, so an iOS-only PR would block on the collector's suite. Keep -# these advisory, or add a merge-gate job that always runs. +# This suite is still only run when something under collector/ changed -- a +# change to a phone runner has no business spinning up a Node matrix. What +# moved to ci.yml is the consequence: a path-filtered workflow can never +# itself be a required check, because GitHub reports a check that never ran +# as pending forever. The gate there always runs and reports for all four. on: - push: - branches: [main, develop] - paths: ["collector/**", ".github/workflows/collector.yml"] - pull_request: - paths: ["collector/**", ".github/workflows/collector.yml"] + workflow_call: defaults: run: diff --git a/.github/workflows/runner-android.yml b/.github/workflows/runner-android.yml index ed9e797..a2bf5cf 100644 --- a/.github/workflows/runner-android.yml +++ b/.github/workflows/runner-android.yml @@ -1,11 +1,9 @@ name: runner-android +# Called by ci.yml, which owns the change detection and the `ci-ok` check +# that branch protection requires. Nothing else triggers this file. on: - push: - branches: [main, develop] - paths: ["runner-android/**", ".github/workflows/runner-android.yml"] - pull_request: - paths: ["runner-android/**", ".github/workflows/runner-android.yml"] + workflow_call: defaults: run: diff --git a/.github/workflows/runner-ios.yml b/.github/workflows/runner-ios.yml index 85cb74d..654abf2 100644 --- a/.github/workflows/runner-ios.yml +++ b/.github/workflows/runner-ios.yml @@ -1,11 +1,9 @@ name: runner-ios +# Called by ci.yml, which owns the change detection and the `ci-ok` check +# that branch protection requires. Nothing else triggers this file. on: - push: - branches: [main, develop] - paths: ["runner-ios/**", ".github/workflows/runner-ios.yml"] - pull_request: - paths: ["runner-ios/**", ".github/workflows/runner-ios.yml"] + workflow_call: defaults: run: diff --git a/.github/workflows/runner-machine.yml b/.github/workflows/runner-machine.yml index 68efcaa..424205d 100644 --- a/.github/workflows/runner-machine.yml +++ b/.github/workflows/runner-machine.yml @@ -1,11 +1,9 @@ name: runner-machine +# Called by ci.yml, which owns the change detection and the `ci-ok` check +# that branch protection requires. Nothing else triggers this file. on: - push: - branches: [main, develop] - paths: ["runner-machine/**", ".github/workflows/runner-machine.yml"] - pull_request: - paths: ["runner-machine/**", ".github/workflows/runner-machine.yml"] + workflow_call: defaults: run: diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..14224e1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,10 @@ +# At the repository root because that is the only place git reads it from. +# +# The migration to a mono repo moved the Android runner into a subdirectory and +# its .gitmodules moved with it, where git never looks — so the gitlink at +# runner-android/third_party/llama.cpp survived with no URL registered against +# it, and `git submodule update --init` in a fresh clone silently found nothing +# to do. The path below is the only line that had to change. +[submodule "runner-android/third_party/llama.cpp"] + path = runner-android/third_party/llama.cpp + url = https://github.com/ggml-org/llama.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c324fc1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,121 @@ +# Changelog + +Notable changes to Fleet Runner. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project +follows [semantic versioning](https://semver.org/spec/v2.0.0.html). + +One version covers all four components. They ship no shared code, only a JSON +protocol — and that protocol is the thing that changes, so it is the thing the +version tracks. A release note says which components moved. The wire protocol +itself is `"schema": 1` and stays there until something breaks compatibility, +independently of the version below. + +## [Unreleased] + +## [0.2.0] — 2026-09-05 + +The first release from the mono repo, covering two waves of work plus the +migration itself. + +### Added + +- **Job chains.** `depends_on` makes build → install → ui-test a single + enqueue. A promoted spec can carry `${jobs..artifact}` and + `${jobs..metrics.}`, substituted whole so a metric stays a number. + Chains parked on a dependency are counted as `waiting`, not `queued`, so a + healthy pipeline does not read as a backlog. +- **Preemption.** A twelve-hour `drain` steps aside for a two-minute + `install`, checkpoints, and is requeued with `params.resume_from`. Stepping + aside never counts against `max_attempts`, because it is not a failure. +- **The `build` workload**, on the machine agent: check out a ref, build it, + publish the product to the artifact store, and let a later job resolve + `"sha256": "latest"` to it. +- **Nine more workloads.** `speech-eval` and `embed-eval` on the phones; + `vantage` from wherever an agent sits; `locale-shots`, `app-soak` and + `a11y-audit` on the host; `self-check` on the hosts themselves; + `model-convert`, `dataset-prep` and `serve` on the machine agent. +- **The `shell` workload, gated by a locally pinned allowlist.** `POST /jobs` + is unauthenticated by design, so the one workload that runs arbitrary code + declares its capability only when the owner has pinned a sha256 by hand in a + local file. A machine that has pinned nothing is never offered the job at + all, rather than claiming it and refusing afterwards. +- **Energy measurement**, reported in watt-hours at the wall above a measured + idle baseline, or not reported at all. `energy_method` is declared in + configuration and never inferred; a `plug-shared` pool reports the pool's + draw and refuses to divide it between devices. +- **An Evals page and `/api/evals`**, pivoting eval sets by model and device + with a Markdown export. It counts and lists the rows it excluded, naming the + metrics each one carries. +- **A live job mirror.** MJPEG frames in a bounded in-memory ring, dropped when + the job ends, so an `` can answer "what is it doing now" without the + permanent storage cost of a video stream. +- **Real iPhone screen capture**, via a `WKWebView` inside the runner app. The + profile is named `webkit` rather than `safari`, because a `WKWebView` has no + reader mode and no content blockers. +- **A status screen and launcher icon for the Android runner**, including the + pre-API-26 icon fallback that was missing. +- `CHANGELOG.md`, and a `ci-ok` merge gate that always reports. + +### Changed + +- **Four repositories became one.** `fleet-collector`, `fleet-runner-ios`, + `fleet-runner-android` and `fleet-runner-machine` are now `collector/`, + `runner-ios/`, `runner-android/` and `runner-machine/`. Every commit came + across with `git subtree`, so history and authorship survive and + `git log --follow` works through the move. The old repositories are archived + and still resolve. +- **The component suites are reusable workflows** called by a single `ci` + workflow, which decides which of them need to run. A path-filtered workflow + can never be a required check, because GitHub reports a check that never ran + as pending forever; `ci-ok` always runs and reports for all four. +- The host executor's handlers began moving into a directory per workload, + starting with `install`, behind a loader that falls back to the existing + chain for everything not yet moved. + +### Fixed + +- **Swift's `convertToSnakeCase` splits on capitals, and a digit is not one**, + so `recallAt1` encoded as `recall_at1` — one underscore short of the declared + metric name, which meant it silently never arrived. Explicit `CodingKeys` now. +- **A real-device soak ran `simctl` against a physical iPhone's UDID** and + reported the process dead at every check. +- **The mono-repo migration left `.gitmodules` inside `runner-android/`**, + where git never looks, so the gitlink for llama.cpp survived with no URL + registered against it and `git submodule update --init` in a fresh clone + silently found nothing to do. +- Four machine workloads referenced metric names that were never mirrored into + the agent's own protocol type, so they did not compile; the same four were + never dispatched or capability-gated, so they were dead code. + +### Known gaps + +Stated rather than implied. `push-latency` needs FCM and APNs credentials; +`camera-eval` needs a physical rig; `desktop-ui-test` has no desktop app to +test against. `model-convert`, `dataset-prep` and `serve` have never run +against a real toolchain — none of the converters resolves on the development +machine, and the capability probes correctly decline to declare them. Only +`install` has moved into the executor's plugin layout. + +## [0.1.0] — 2026-09-03 + +The first public release, when the project was still four repositories. + +### Added + +- **The collector**: device registry, job queue with leases, artifact store + addressed by content hash, results database, scheduler, alert engine, and a + dashboard with live updates over SSE. Node, Fastify and SQLite in WAL mode, + with no broker and no cloud. +- **Runner agents for Android, iOS and desktop**, sharing a JSON protocol and + no code. Their synthetic SHA-256 benchmark is identical token for token on + every platform, which is what makes a 2019 phone's number comparable to a + laptop's. +- **The first real payload**: an on-device plant-identification evaluation + across both platforms, at 77% top-1 and 7 ms per image on a phone. +- MIT licensing, third-party notices, CI on every component, and a `npm test` + that starts a throwaway collector on a spare port so it never touches a live + fleet's history. + +[Unreleased]: https://github.com/addisdev/fleet-runner/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/addisdev/fleet-runner/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/addisdev/fleet-runner/releases/tag/v0.1.0 diff --git a/collector/README.md b/collector/README.md index 309e08d..665cd0e 100644 --- a/collector/README.md +++ b/collector/README.md @@ -89,6 +89,12 @@ so it never touches a real fleet's history. | `thermal` | device | The same benchmark run back to back for a quarter hour, so the answer is a curve: does the cold number survive the device getting warm | | `cold-start` | host | Launch the installed build from cold, warm and hot; p50 and p95 per state, per device | | `drain` / `soak` | host | Battery curve under a replayed GPX track; and whether a runner is still alive hours later | +| `build` | machine | Check out a ref, build it, publish the product as an artifact the next `install` can name | +| `speech-eval` / `embed-eval` | device | Word error rate and real-time factor for on-device transcription; recall-at-k and throughput for on-device embeddings | +| `vantage` | device, machine | DNS, connect, TLS and TTFB to your own sites from wherever each agent is, so "the site is slow" and "my wifi is slow" stop being the same answer | +| `locale-shots` | host | A screenshot flow under every locale, including RTL, bundled as a store-ready contact sheet | +| `app-soak` / `a11y-audit` | host | Memory, jank and crashes over hours; and the accessibility tree diffed against a baseline at the largest dynamic type | +| `self-check` | host, machine | The fleet inspects its own hosts: disk, tool versions, clock drift, agents actually loaded | | `archive` / `digest` | host | Pull store reviews and Search Console data; then have the shelf summarize its own reviews using its own models | Full reference, including every parameter and the failure modes worth knowing: diff --git a/collector/dash/src/api.ts b/collector/dash/src/api.ts index 8104497..e6fb67c 100644 --- a/collector/dash/src/api.ts +++ b/collector/dash/src/api.ts @@ -128,7 +128,11 @@ export type Job = { job_id: string; workload: string; executor: "device" | "host"; - status: "queued" | "claimed" | "done" | "failed" | "cancelled"; + status: "waiting" | "queued" | "claimed" | "done" | "failed" | "cancelled"; + /** Job ids this one waits for; it stays `waiting` until they are all done. */ + depends_on?: string[] | null; + /** Whether a higher-priority job may ask this one to checkpoint and step aside. */ + preemptible?: boolean; claimed_by: string | null; attempts: number; max_attempts: number; diff --git a/collector/dash/src/app.tsx b/collector/dash/src/app.tsx index 3f6b244..f434d3a 100644 --- a/collector/dash/src/app.tsx +++ b/collector/dash/src/app.tsx @@ -2,7 +2,7 @@ import { useKeyboard, SHORTCUTS } from "./keys.js"; import { TokenBanner } from "./TokenBanner.js"; import { useLiveState } from "./live.js"; import { match, useRoute } from "./router.js"; -import { Glyph } from "./icons.js"; +import { Glyph, Icon, NAV_ICON } from "./icons.js"; import { Link, Panel } from "./ui.js"; import { Compose } from "./pages/Compose.js"; import { DeviceDetail } from "./pages/DeviceDetail.js"; @@ -14,6 +14,7 @@ import { Overview } from "./pages/Overview.js"; import { Schedules } from "./pages/Schedules.js"; import { AlertBanner, Alerts } from "./pages/Alerts.js"; import { Artifacts } from "./pages/Artifacts.js"; +import { Evals, EvalSetPage } from "./pages/Evals.js"; import { Events } from "./pages/Events.js"; import { Results } from "./pages/Results.js"; import { Visual } from "./pages/Visual.js"; @@ -25,6 +26,7 @@ const NAV = [ ["/devices", "Devices"], ["/jobs", "Jobs"], ["/results", "Results"], + ["/evals", "Evals"], ["/visual", "Visual"], ["/schedules", "Schedules"], ["/artifacts", "Artifacts"], @@ -82,6 +84,7 @@ function Router() { // Before /jobs/:id, or "new" would be read as a job id. if (match("/jobs/new", route)) return ; if (match("/results", route)) return ; + if (match("/evals", route)) return ; if (match("/visual", route)) return ; if (match("/artifacts", route)) return ; if (match("/events", route)) return ; @@ -93,6 +96,10 @@ function Router() { if (device) return ; const job = match("/jobs/:id", route); if (job) return ; + // After the literal /evals above, for the same reason /devices/new is before + // /devices/:id — a literal segment must never be read as an id. + const evalSet = match("/evals/:sha", route); + if (evalSet) return ; return ; @@ -134,11 +141,15 @@ export function App() { diff --git a/collector/dash/src/art.tsx b/collector/dash/src/art.tsx new file mode 100644 index 0000000..295696d --- /dev/null +++ b/collector/dash/src/art.tsx @@ -0,0 +1,127 @@ +// Empty-state drawings. +// +// A panel with nothing in it is a state worth drawing, not a gap to apologise +// for — and the four that matter are different states, not one "no data": +// the fleet being idle is good news, no results yet is a beginning, and no +// device matching a filter is a filter problem. Each gets its own picture and +// keeps the sentence the dashboard already printed underneath it. +// +// Same drawing language as icons.tsx: stroke on a flat ground, theme colours +// through CSS classes (never a hard-coded fill except the brand amber, which +// is a brand colour rather than a theme one), and no illustration bigger than +// the paragraph it introduces. +import type { ComponentChildren } from "preact"; + +function Art({ children, caption }: { children: ComponentChildren; caption: ComponentChildren }) { + return ( +
+ {children} +

{caption}

+
+ ); +} + +/** + * Nothing claimed — the shelf is powered and waiting. + * + * The middle phone's pulse draws every few seconds. Idle is not the same as + * dead, and a completely still drawing would say the wrong one. + */ +export function ArtIdle({ caption }: { caption: ComponentChildren }) { + return ( + + + + + + + + + + + + + + + + ); +} + +/** + * No device here — either the shelf is empty or the filters exclude everything. + * The enrolment QR pointing at an outline is the actual fix in both cases. + */ +export function ArtNoDevices({ caption }: { caption: ComponentChildren }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** + * Nothing failed. A flat trace running into a tick — the same pulse the mark + * uses, ending well. + */ +export function ArtAllClear({ caption }: { caption: ComponentChildren }) { + return ( + + + + + + + + ); +} + +/** + * No results yet. Dotted bars in the accent: the chart that will be here, not + * a chart of nothing — a solid empty chart would read as a measurement of zero. + */ +export function ArtNoResults({ caption }: { caption: ComponentChildren }) { + return ( + + + + + + + + + + + ); +} diff --git a/collector/dash/src/icons.tsx b/collector/dash/src/icons.tsx index b24e5ac..359ebb8 100644 --- a/collector/dash/src/icons.tsx +++ b/collector/dash/src/icons.tsx @@ -53,6 +53,82 @@ const PATHS = { ), + convert: ( + <> + + + ), + dataset: ( + <> + + + + ), + serve: ( + <> + + + + + ), + terminal: ( + <> + + + + ), + bell: ( + <> + + + + ), + camera: ( + <> + + + + ), + desktop: ( + <> + + + + ), + hammer: ( + <> + + + ), + waveform: ( + <> + + + ), + vector: ( + <> + + + + + ), + globe: ( + <> + + + + ), + translate: ( + <> + + + ), + accessibility: ( + <> + + + + ), stopwatch: ( <> @@ -125,6 +201,130 @@ const PATHS = { ), + + // --- one per navigation tab --- + // + // The nav used to be ten words that all looked alike at a glance, which on a + // phone (where the row scrolls and two labels are visible) made getting + // somewhere a reading exercise. Each of these is the screen's subject drawn + // literally: four tiles for the overview, a phone and a tablet for devices, + // a queue for jobs, bars for results. + overview: ( + <> + + + + + + ), + devices: ( + <> + + + + + ), + // Solid ticks on the left, dashed queue stretching right: work waiting. + jobs: ( + <> + + + + ), + results: ( + <> + + + + + + ), + // A target: an eval is a score against a known answer. + evals: ( + <> + + + + + ), + schedules: ( + <> + + + + + ), + events: ( + <> + + + + + + + ), + alerts: ( + <> + + + + ), + system: ( + <> + + + + + ), + + // --- job states --- + // + // The same circle at the same size for every state, so a column of them + // reads down cleanly: only what is inside the circle changes. Never used + // alone — the pill still says the word. + queued: , + claimed: ( + <> + + + + ), + done: ( + <> + + + + ), + failed: ( + <> + + + + ), + cancelled: ( + <> + + + + ), + retry: ( + <> + + + + ), + locked: ( + <> + + + + ), + download: ( + <> + + + + ), } satisfies Record; export type IconName = keyof typeof PATHS; @@ -195,6 +395,20 @@ const WORKLOAD_ICON: Record = { thermal: "thermal", "cold-start": "stopwatch", "self-check": "heartbeat", + build: "hammer", + "speech-eval": "waveform", + "embed-eval": "vector", + vantage: "globe", + "locale-shots": "translate", + "app-soak": "soak", + "a11y-audit": "accessibility", + "model-convert": "convert", + "dataset-prep": "dataset", + serve: "serve", + shell: "terminal", + "push-latency": "bell", + "camera-eval": "camera", + "desktop-ui-test": "desktop", }; /** The workload's name with its icon, for job tables. */ @@ -208,3 +422,70 @@ export function Workload({ name, children }: { name: string; children?: Componen ); } + +/** + * Which icon a nav tab gets. Keyed by route, so a tab and its icon are + * declared in one place and a new route without an icon simply shows its word + * rather than the wrong picture. + */ +export const NAV_ICON: Record = { + "/": "overview", + "/devices": "devices", + "/jobs": "jobs", + "/results": "results", + "/evals": "evals", + "/visual": "visual", + "/schedules": "schedules", + "/artifacts": "archive", + "/events": "events", + "/alerts": "alerts", + "/system": "system", +}; + +/** + * A device, drawn as the object it is. + * + * The shelf view shows machines, not rows, so each one gets a silhouette: a + * phone, or a laptop for a desktop runner, dashed when it is a simulator. The + * screen carries two live facts at once — its fill is the device's status + * colour, and the pulse is drawn on it only while the device is actually + * online — so a shelf reads before any word on it does. + * + * `kind` comes from the reported OS rather than the API's `platform` field: + * platform is a two-value ios/android split (anything not iOS is called + * android), which would draw a MacBook running the machine runner as a phone. + */ +export function DeviceGlyph({ + kind, + status, + busy, + simulator, + size = 44, +}: { + kind: "phone" | "laptop"; + status: "online" | "stale" | "offline"; + busy?: boolean; + simulator?: boolean; + size?: number; +}) { + const cls = ["dev-glyph", status, busy ? "busy" : "", simulator ? "simulator" : ""].filter(Boolean).join(" "); + + if (kind === "laptop") + return ( + + ); + + return ( + + ); +} diff --git a/collector/dash/src/pages/Devices.tsx b/collector/dash/src/pages/Devices.tsx index 57ed975..f8d1bea 100644 --- a/collector/dash/src/pages/Devices.tsx +++ b/collector/dash/src/pages/Devices.tsx @@ -1,8 +1,9 @@ // The shelf. What is online, what it is doing, and how to get to it. import { useState } from "preact/hooks"; import { useApi, type Device, type DeviceList } from "../api.js"; -import { Icon } from "../icons.js"; -import { mutate, useMutation } from "../mutate.js"; +import { ArtNoDevices } from "../art.js"; +import { DeviceGlyph, Icon } from "../icons.js"; +import { mutate } from "../mutate.js"; import { refreshNames } from "../names.js"; import { useQuery } from "../router.js"; import { Filters, Link, Loaded, Panel, Pill, Search, Select, Stat, agoFrom } from "../ui.js"; @@ -101,6 +102,204 @@ export function Thermal({ state }: { state: string | null }) { ); } +/** Battery as a bar as well as a number, for the shelf cards. */ +function BatteryMeter({ pct, charging }: { pct: number | null; charging: boolean | null }) { + if (pct == null || pct < 0) return ; + const tone = pct < 15 && !charging ? "critical" : pct < 30 && !charging ? "low" : ""; + return ( + + + + + ); +} + +/** + * Which silhouette a device gets. + * + * Read from the reported OS, not from the API's `platform` field: platform is + * an ios/android split where anything not iOS is called android, so a MacBook + * running the machine runner would be drawn as a phone. + */ +function glyphKind(device: Device): "phone" | "laptop" { + const os = String(device.descriptor.os ?? "").toLowerCase(); + return /^(macos|darwin|linux|windows|win32)/.test(os) ? "laptop" : "phone"; +} + +function hardware(device: Device): string { + const d = device.descriptor; + const bits = [ + String(d.os ?? "?"), + d.soc ? String(d.soc) : null, + d.ram_mb ? `${Math.round(Number(d.ram_mb) / 1024)} GB` : null, + ].filter(Boolean); + return bits.join(" · "); +} + +/** One device, as an object on a shelf. */ +function DeviceCard({ device, onDone }: { device: Device; onDone: () => void }) { + return ( +
+ +
+
+ + +
+ {device.simulator && ( +
+ + simulator +
+ )} +
+ {String(device.descriptor.model ?? "?")} · {hardware(device)} +
+
+ + + {device.current_job ? ( + + {device.current_job} + + ) : ( + idle + )} + {device.lock && locked} +
+
+ + open + + {device.pools.join(", ") || "no pool"} + {agoFrom(device.last_seen)} +
+
+
+ ); +} + +/** + * Hardware first, simulators under it. + * + * The two are not the same kind of thing: a wedged physical phone needs + * someone to walk over to the shelf, a wedged simulator needs a `simctl`. They + * used to interleave in one id-sorted table, so the question "is any real + * device in trouble" meant reading every row. + */ +function Shelf({ devices, onDone }: { devices: Device[]; onDone: () => void }) { + const real = devices.filter((d) => !d.simulator); + const sims = devices.filter((d) => d.simulator); + return ( + <> + {real.length > 0 && ( +
+ {sims.length > 0 &&

Hardware

} +
+ {real.map((d) => ( + + ))} +
+
+ )} + {sims.length > 0 && ( +
+ {real.length > 0 &&

Simulators and emulators

} +
+ {sims.map((d) => ( + + ))} +
+
+ )} + + ); +} + +/** The original table, unchanged — still the right view for comparing columns. */ +function Table({ devices, onDone }: { devices: Device[]; onDone: () => void }) { + return ( +
+ + + + + + + + + + + + {devices.map((dev) => ( + + + + + + + + + + + ))} +
DeviceHardwarePoolsRunsBatteryThermalDoingLast seen
+ +
+ + open + {" "} + {" "} + {dev.simulator && ( + + + simulator + + )} +
+
+ {String(dev.descriptor.model ?? "?")} +
+ {String(dev.descriptor.os ?? "?")} + {dev.descriptor.soc ? ` · ${dev.descriptor.soc}` : ""} + {dev.descriptor.ram_mb ? ` · ${Math.round(Number(dev.descriptor.ram_mb) / 1024)} GB` : ""} +
+
{dev.pools.join(", ") || "—"} + {dev.capabilities === null ? ( + + all + + ) : ( + dev.capabilities.join(", ") || "—" + )} + + + + + + {dev.current_job ? ( + + {dev.current_job} + + ) : ( + idle + )} + {dev.lock &&
locked
} +
+ {agoFrom(dev.last_seen)} + {dev.last_net &&
{dev.last_net}
} +
+
+ ); +} + export function Devices() { const [q, setQuery] = useQuery(); const status = q.get("status") ?? ""; @@ -108,6 +307,10 @@ export function Devices() { const platform = q.get("platform") ?? ""; const search = q.get("q") ?? ""; const hideSims = q.get("simulator") === "false"; + // The shelf leads because the fleet is objects on a rack and that is the + // question this screen usually answers. The table is one click away and, + // because the choice lives in the URL, a link to either stays that view. + const view = q.get("view") === "table" ? "table" : "shelf"; const params = new URLSearchParams(); for (const [k, v] of [ @@ -134,11 +337,14 @@ export function Devices() { + add a device - + {(d) => ( <> - setQuery({ status: null, pool: null, platform: null, q: null, simulator: null })}> + setQuery({ status: null, pool: null, platform: null, q: null, simulator: null })} + > setQuery({ pool: v })} /> setQuery({ simulator: (e.target as HTMLInputElement).checked ? "false" : null })} /> hardware only + +
+ + +
{d.devices.length === 0 ? ( -

No device matches these filters.

+ + ) : view === "shelf" ? ( + ) : ( -
- - - - - - - - - - - - {d.devices.map((dev) => ( - - - - - - - - - - - ))} -
DeviceHardwarePoolsRunsBatteryThermalDoingLast seen
- -
- - open - {" "} - {" "} - {dev.simulator && ( - - - simulator - - )} -
-
- {String(dev.descriptor.model ?? "?")} -
- {String(dev.descriptor.os ?? "?")} - {dev.descriptor.soc ? ` · ${dev.descriptor.soc}` : ""} - {dev.descriptor.ram_mb ? ` · ${Math.round(Number(dev.descriptor.ram_mb) / 1024)} GB` : ""} -
-
{dev.pools.join(", ") || "—"} - {dev.capabilities === null ? ( - - all - - ) : ( - dev.capabilities.join(", ") || "—" - )} - - - - - - {dev.current_job ? ( - - {dev.current_job} - - ) : ( - idle - )} - {dev.lock &&
locked
} -
- {agoFrom(dev.last_seen)} - {dev.last_net &&
{dev.last_net}
} -
-
+ )} diff --git a/collector/dash/src/pages/Evals.tsx b/collector/dash/src/pages/Evals.tsx new file mode 100644 index 0000000..7a6cc8d --- /dev/null +++ b/collector/dash/src/pages/Evals.tsx @@ -0,0 +1,593 @@ +// Evals as a page, so the next one needs no hand-written report. +// +// The plant-ID writeup in evals/ was typed by hand because the rows behind it +// could not be queried: top-1 accuracy lived in `decode_tok_s`, p50 in +// `ttft_ms`, and top-5 and p95 lived nowhere at all. Those rows are the reason +// this page exists, so this page must not do to them what the old dashboard +// did — hide them. Every row that cannot be pivoted is COUNTED, ATTRIBUTED and +// LISTED, above the table it is missing from. +// +// House style is Results.tsx: useApi + Loaded + Panel, a Csv button on the +// panel that holds the rows it exports. The Markdown button is the new one, and +// is the point of the page: it emits the table a writeup would have opened with, +// caveats included, so nobody starts the next eval report from a blank file. +import { useApi } from "../api.js"; +import { Bars } from "../chart.js"; +import { Link, Loaded, Panel, Pill, agoFrom, clock, num, short } from "../ui.js"; + +// --- shapes (mirrors src/api/evals.ts) --- + +type Family = "vision" | "llm" | "speech" | "embed"; +type PerJoule = { value: number; unit: string; basis: string }; +type EnergyView = { + wh: number; + method: "plug" | "plug-shared" | "os"; + includes_charging: boolean | null; + source: "result" | "integrated"; + baseline_source: string | null; + note: string | null; +}; +type Cell = { + job_id: string; + device_id: string; + device_model: string | null; + simulator: boolean; + at: string | null; + metrics: Record; + energy: EnergyView | null; + per_joule: PerJoule | null; +}; +type ModelRow = { + key: string; + model: string | null; + quant: string | null; + backend: string | null; + accel: string | null; + cells: Record; +}; +type Excluded = { + job_id: string; + device_id: string; + at: string | null; + workload: string; + backend: string | null; + input_sha256: string | null; + reason: string; + present: string[]; +}; +type EvalSet = { + input_sha256: string; + family: Family; + headline: { key: string; label: string; unit: string; higherIsBetter: boolean }; + named_metrics: string[]; + workloads: string[]; + devices: { device_id: string; device_model: string | null; simulator: boolean }[]; + models: ModelRow[]; + runs: number; + latest_at: string | null; + excluded: number; + has_energy: boolean; +}; +type EvalsIndex = { + sets: { + input_sha256: string; family: Family; workloads: string[]; models: number; devices: number; + runs: number; latest_at: string | null; excluded: number; has_energy: boolean; + }[]; + excluded: { count: number; by_reason: { reason: string; count: number }[]; rows: Excluded[] }; + scanned: number; + energy_configured: boolean; +}; +type EvalSetResponse = { set: EvalSet | null; excluded: Excluded[]; energy_configured: boolean }; + +// --- exports --- + +function save(name: string, text: string, mime: string) { + const url = URL.createObjectURL(new Blob([text], { type: mime })); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); +} + +/** Same CSV writer Results.tsx uses, over the rows the page is showing. */ +function downloadCsv(name: string, rows: Record[]) { + if (rows.length === 0) return; + const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))]; + const esc = (v: unknown) => { + const s = v == null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + save(`${name}.csv`, [cols.join(","), ...rows.map((r) => cols.map((c) => esc(r[c])).join(","))].join("\n"), "text/csv"); +} + +const Csv = ({ name, rows }: { name: string; rows: Record[] }) => ( + +); + +/** Flat rows for CSV: one line per (model, device) cell. */ +function flatten(set: EvalSet): Record[] { + const out: Record[] = []; + for (const m of set.models) + for (const d of set.devices) { + const c = m.cells[d.device_id]; + if (!c) continue; + out.push({ + input_sha256: set.input_sha256, + model: m.model, quant: m.quant, backend: m.backend, accel: m.accel, + device_id: c.device_id, device_model: c.device_model, simulator: c.simulator, + job_id: c.job_id, at: c.at, + ...c.metrics, + energy_wh: c.energy?.wh ?? null, + energy_method: c.energy?.method ?? null, + energy_source: c.energy?.source ?? null, + includes_charging: c.energy?.includes_charging ?? null, + per_joule: c.per_joule?.value ?? null, + per_joule_unit: c.per_joule?.unit ?? null, + }); + } + return out; +} + +/** + * The Markdown a writeup would have opened with. + * + * It carries the caveats, not just the numbers. An export that emitted a clean + * table and left the excluded rows behind would let the next report repeat the + * original mistake with less typing, which is worse than no export at all. + */ +function markdown(set: EvalSet, excluded: Excluded[], energyConfigured: boolean): string { + const L: string[] = []; + const cols = set.devices; + L.push(`# Eval set \`${set.input_sha256.slice(0, 12)}…\``); + L.push(""); + L.push(`- **Input artifact:** \`${set.input_sha256}\` — every device below scored these exact bytes.`); + L.push(`- **Workload:** ${set.workloads.join(", ")} (${set.family})`); + L.push(`- **Runs:** ${set.runs} across ${set.models.length} model(s) and ${cols.length} device(s)`); + L.push(`- **Latest run:** ${set.latest_at ?? "—"}`); + L.push(`- **Generated from** \`/api/evals/${set.input_sha256}\` — this table is a query, not a transcription.`); + L.push(""); + + const anyPerJoule = set.models.some((m) => Object.values(m.cells).some((c) => c.per_joule)); + for (const key of set.named_metrics) { + const present = set.models.some((m) => Object.values(m.cells).some((c) => c.metrics[key] != null)); + if (!present) continue; + L.push(`## ${key}`); + L.push(""); + L.push(`| Model | ${cols.map((d) => d.device_model ?? d.device_id).join(" | ")} |`); + L.push(`|---|${cols.map(() => "---:").join("|")}|`); + for (const m of set.models) { + const name = `${m.model ?? "?"}${m.quant ? ` ${m.quant}` : ""} (${m.backend ?? "?"}${m.accel ? `/${m.accel}` : ""})`; + L.push(`| ${name} | ${cols.map((d) => fmt(m.cells[d.device_id]?.metrics[key])).join(" | ")} |`); + } + L.push(""); + } + + if (anyPerJoule) { + L.push("## Energy"); + L.push(""); + L.push(`| Model | Device | Wh | Method | Per joule | Includes charging |`); + L.push("|---|---|---:|---|---:|---|"); + for (const m of set.models) + for (const d of cols) { + const c = m.cells[d.device_id]; + if (!c?.energy) continue; + L.push( + `| ${m.model ?? "?"} | ${c.device_model ?? c.device_id} | ${c.energy.wh.toFixed(3)} | ${c.energy.method} | ${ + c.per_joule ? `${c.per_joule.value.toPrecision(3)} ${c.per_joule.unit}` : "—" + } | ${c.energy.includes_charging === null ? "unknown" : c.energy.includes_charging ? "yes" : "no"} |`, + ); + } + L.push(""); + L.push( + "> Watt-hours are measured at the wall, above the pool's idle baseline, over the job's claim window. " + + "`plug-shared` figures are the whole pool's and must not be divided per device. " + + "Where *includes charging* is yes, the figure includes energy that went into the battery rather than into the work — " + + "an idle baseline removes the charger's standing draw, not its charging current.", + ); + L.push(""); + } else if (!energyConfigured) { + L.push("## Energy"); + L.push(""); + L.push("_No pool in `power.json` declares `read_url` + `watts_path` + `energy_method`, so no run here has energy data._"); + L.push(""); + } + + L.push("## Rows this table excludes"); + L.push(""); + if (excluded.length === 0) { + L.push("None — every result in scope carried named metrics."); + } else { + L.push( + `**${excluded.length} result row(s) are not in the tables above.** They are listed rather than dropped: ` + + "rows that predate the named metric fields are exactly why this page exists, and hiding them would repeat " + + "the mistake that made the last eval a hand-written document.", + ); + L.push(""); + L.push("| Job | Device | Workload | Metrics it does carry | Why |"); + L.push("|---|---|---|---|---|"); + for (const e of excluded) + L.push(`| \`${e.job_id}\` | ${e.device_id} | ${e.workload} | ${e.present.join(", ") || "none"} | ${e.reason} |`); + } + L.push(""); + return L.join("\n"); +} + +const fmt = (v: number | null | undefined) => (typeof v === "number" ? num(v) : "—"); + +const Markdown = ({ set, excluded, energyConfigured }: { set: EvalSet; excluded: Excluded[]; energyConfigured: boolean }) => ( + +); + +// --- excluded rows --- + +function ExcludedPanel({ rows, scope }: { rows: Excluded[]; scope: string }) { + if (rows.length === 0) + return ( + +

None — every {scope} carried named metrics and named an eval set.

+
+ ); + return ( + []} />}> +

+ These rows are real results that this page cannot pivot, and they are listed rather than filtered out.{" "} + Rows that predate the named metric fields are the reason this page exists — the plant-ID eval + had to be written by hand precisely because its numbers sat in fields that mean something else. Dropping them + quietly would make the same mistake with a nicer table. +

+
+
+ + + + + + + + + {rows.map((e) => ( + + + + + + + + + ))} +
JobDeviceWorkloadCarriesWhy it is outWhen
+ + {short(e.job_id)} + + + + {short(e.device_id, 22)} + + + {e.workload} + {e.backend ? ` · ${e.backend}` : ""} + {e.present.length ? e.present.map((p) => {p} ) : nothing}{e.reason}{clock(e.at)}
+ +
+ ); +} + +// --- energy caveat, stated once per page that shows a watt-hour --- + +function EnergyNote({ set }: { set: EvalSet }) { + const cells = set.models.flatMap((m) => Object.values(m.cells)).filter((c) => c.energy); + if (cells.length === 0) return null; + const shared = cells.some((c) => c.energy!.method === "plug-shared"); + const charging = cells.filter((c) => c.energy!.includes_charging === true).length; + const unknown = cells.filter((c) => c.energy!.includes_charging === null).length; + const estimated = cells.filter((c) => c.energy!.baseline_source === "estimated-p10").length; + + return ( + +

+ Energy is measured at the wall, above the pool's idle baseline, across the job's claim window. + It is not the energy the model used. +

+ {shared && ( +

+ Some of these pools are plug-shared: several devices sit behind one plug, so the figure is the{" "} + whole pool's. Do not divide it by the number of devices — the devices are not identical, the + idle ones are already inside the baseline, and a per-device number reached by division would be indistinguishable + from a measured one. +

+ )} + {charging > 0 && ( +

+ {charging} of {cells.length} runs were charging while they ran. A phone on a plug is also + filling its battery, and subtracting an idle baseline removes the charger's standing draw but not its charging + current. Those watt-hours include battery replenishment as well as the work. +

+ )} + {unknown > 0 && ( +

+ {unknown} run(s) had no beacon covering the window, so whether the device was also charging is unknown. +

+ )} + {estimated > 0 && ( +

+ {estimated} run(s) used an estimated idle baseline (the 10th percentile of the pool's own samples) + because power.json declares no idle_watts for that pool. Measure it with the shelf + quiet and the figures get better. +

+ )} +
+ ); +} + +// --- one eval set --- + +export function EvalSetPage({ sha }: { sha: string }) { + const state = useApi(`/api/evals/${encodeURIComponent(sha)}`, ["result"], 60_000); + + return ( + <> +

Eval set

+ +

+ ← all eval sets · input artifact {sha} +

+
+ + {(d) => + !d.set ? ( + <> + +

+ No pivotable result carries this input_sha256. + {d.excluded.length > 0 && " Every row that names it is listed below."} +

+
+ + + ) : ( + <> + + {" "} + + + } + > +

+ {d.set.workloads.join(", ")} · {d.set.family} · {d.set.runs} runs · latest{" "} + {agoFrom(d.set.latest_at)} +

+

+ {d.set.headline.label} — {d.set.headline.higherIsBetter ? "higher is better" : "lower is better"} +

+ + d.set!.devices + .filter((dev) => m.cells[dev.device_id]) + .map((dev) => ({ + label: `${m.model ?? "?"}${m.quant ? ` ${m.quant}` : ""} · ${dev.device_model ?? dev.device_id}`, + value: m.cells[dev.device_id].metrics[d.set!.headline.key] ?? null, + muted: dev.simulator, + })), + )} + unit={d.set.headline.unit} + /> +
+ + {d.set.named_metrics + .filter((key) => d.set!.models.some((m) => Object.values(m.cells).some((c) => c.metrics[key] != null))) + .map((key) => ( + +
+ + + + {d.set!.devices.map((dev) => ( + + ))} + + {d.set!.models.map((m) => ( + + + {d.set!.devices.map((dev) => { + const c = m.cells[dev.device_id]; + return ( + + ); + })} + + ))} +
Model + {dev.device_model ?? dev.device_id} + {dev.simulator &&
simulator
} +
+ {m.model ?? "?"} + {m.quant ? ` ${m.quant}` : ""} +
+ {m.backend ?? "?"} + {m.accel ? ` · ${m.accel}` : ""} +
+
+ {c ? ( + {fmt(c.metrics[key])} + ) : ( + + not run + + )} +
+
+
+ ))} + + + + {d.set.has_energy ? ( + +
+ + + + + + + + + + {d.set.models.flatMap((m) => + d.set!.devices.map((dev) => { + const c = m.cells[dev.device_id]; + if (!c?.energy) return null; + return ( + + + + + + {/* Omitted cleanly where the run has no countable + work: a rate times a duration would look like a + measurement without being one. */} + + + + ); + }), + )} +
ModelDeviceWhMethodPer jouleCharging
+ {m.model ?? "?"} + {m.quant ? ` ${m.quant}` : ""} + + {c.device_model ?? c.device_id} + {c.energy.wh.toFixed(3)} + {c.energy.method} +
{c.energy.source === "result" ? "from the runner" : "integrated here"}
+
+ {c.per_joule ? ( + + {c.per_joule.value.toPrecision(3)} {c.per_joule.unit} + + ) : ( + + — + + )} + + {c.energy.includes_charging === null + ? "unknown" + : c.energy.includes_charging + ? "yes — includes battery" + : "no"} +
+
+
+ ) : ( + +

+ {d.energy_configured + ? "No run in this set has energy data: either the runner stored none and the plug had too few samples inside the job window, or the device's pool declares no energy_method." + : "No pool in power.json declares read_url, watts_path and energy_method, so nothing here has energy data. See power.example.json."} +

+
+ )} + + + + ) + } +
+ + ); +} + +// --- the index --- + +export function Evals() { + const state = useApi("/api/evals", ["result"], 60_000); + + return ( + <> +

Evals

+ +

+ An eval set is one params.input_sha256 — the exact bytes every device scored. + Results group by model and pivot across devices, which is the shape a writeup wants, so the next eval report + starts from the Markdown button rather than from a blank file. +

+
+ + {(d) => ( + <> + {d.sets.length === 0 ? ( + +

+ No eval set has a pivotable result yet. + {d.excluded.count > 0 && ` ${d.excluded.count} row(s) were found but could not be pivoted — see below.`} +

+
+ ) : ( + []} />}> +
+ + + + + + + + + + + + {d.sets.map((s) => ( + + + + + + + + + + + ))} +
Input artifactWorkloadModelsDevicesRunsExcludedEnergyLatest
+ + {s.input_sha256.slice(0, 16)}… + + + {s.workloads.join(", ")} +
{s.family}
+
{s.models}{s.devices}{s.runs}{s.excluded > 0 ? {s.excluded} : "0"}{s.has_energy ? "yes" : —}{clock(s.latest_at)}
+
+
+ )} + + {d.excluded.count > 0 && ( + +

+ Counted here rather than filtered away. The breakdown:{" "} + {d.excluded.by_reason.map((r) => `${r.count} × ${r.reason}`).join(", ")}. Open a set to see which rows, + and what each one does carry. +

+
+ )} + + + + )} +
+ + ); +} diff --git a/collector/dash/src/pages/JobDetail.tsx b/collector/dash/src/pages/JobDetail.tsx index 2f07daa..f690f78 100644 --- a/collector/dash/src/pages/JobDetail.tsx +++ b/collector/dash/src/pages/JobDetail.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "preact/hooks"; import { useApi, type JobDetail as Detail, type ResultRow } from "../api.js"; import { Workload } from "../icons.js"; import { mutate, useMutation } from "../mutate.js"; @@ -6,6 +7,12 @@ import { useDeviceNames } from "../names.js"; import { Actions, Button, ConfirmButton, CopyId, DeviceName, ErrorBox, Json, Link, Loaded, Panel, Pill, Stat, bytes, clock, duration, num } from "../ui.js"; import { JobRows, LeaseCell } from "./Jobs.js"; +// The collector gained a 'waiting' status with dependency chains. `Job` in +// api.ts still describes the older set, and that type is shared with every +// other page, so the status is read as a plain string here rather than +// pretending the union is complete. +const statusOf = (job: { status: string }) => job.status as string; + /** One line that says what a result row actually reported, per workload. */ function summarize(payload: Record): string { const m = payload.metrics; @@ -57,6 +64,51 @@ function Results({ results, names }: { results: ResultRow[]; names: Record + Waiting on{" "} + {deps.map((d, i) => ( + + {i > 0 && ", "} + + {d} + + + ))} + .{" "} + + {statusOf(job) === "waiting" + ? "It is queued the moment the last of them is done, with any ${jobs.…} references in its spec filled in from their final rows. If one fails or is cancelled, this job fails with it rather than waiting forever." + : "The chain has already resolved; this is what it hung off."} + +

+ ); +} + +/** Whether the job will stand aside for higher-priority work. */ +function Preemptible({ job }: { job: Detail }) { + if (job.spec?.preemptible !== true) return null; + return ( +

+ Preemptible.{" "} + + {statusOf(job) === "claimed" + ? "If a queued job with a strictly higher priority could take this device, the next beacon is answered with preempt:true. The runner checkpoints and posts a final row; the job goes back on the queue with its progress and keeps the attempt — raise something above it and watch." + : "When it runs, higher-priority work can ask it to stand aside; it is requeued with its progress rather than failed."} + +

+ ); +} + function JobActions({ job, onDone }: { job: Detail; onDone: () => void }) { const cancel = useMutation(async () => { const r = await mutate<{ note: string }>("POST", `/api/jobs/${encodeURIComponent(job.job_id)}/cancel`, {}); @@ -81,7 +133,12 @@ function JobActions({ job, onDone }: { job: Detail; onDone: () => void }) { return r; }); - const stoppable = job.status === "queued" || job.status === "claimed"; + const stoppable = ["queued", "claimed", "waiting"].includes(statusOf(job)); + // Priority is worth changing for as long as the job has not run. On a claimed + // job it is not cosmetic either: raising a *queued* job above a running + // preemptible one is what asks the runner to stand aside, so the controls + // stay live rather than disappearing the moment work starts. + const reorderable = stoppable; return ( <> @@ -98,9 +155,13 @@ function JobActions({ job, onDone }: { job: Detail; onDone: () => void }) { - {job.status === "queued" && ( + {reorderable && ( <> -