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 (
+
+
+ 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 (
+
)}
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.
+
+ 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.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."}
+
+ 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.
+
+ 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.`}
+
+
+ ) : (
+ []} />}>
+
+
+
+
Input artifact
+
Workload
+
Models
+
Devices
+
Runs
+
Excluded
+
Energy
+
Latest
+
+ {d.sets.map((s) => (
+
+
+
+ {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 && (
<>
-