From 4ee7ab8da76a53401bb69d0d65440d9adf1e7204 Mon Sep 17 00:00:00 2001
From: Taylor Addison
Date: Fri, 4 Sep 2026 18:51:02 -0400
Subject: [PATCH 1/5] Jobs that wait for each other, and measurements that
admit what they are
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wave 2. Chains, builds, energy, and five new workloads — plus a migration
defect this wave surfaced.
depends_on makes a chain one enqueue. A job names ids it waits for, is inserted
'waiting', and is promoted when the last of them closes; a failed or cancelled
dependency fails its waiters down the whole chain rather than parking them
forever. A promoted spec can carry ${jobs..artifact} and
${jobs..metrics.}, substituted whole so a metric stays a number. That
is build → install → ui-test without a script watching the queue.
preemptible lets a twelve-hour drain step aside for a two-minute install. The
beacon answers preempt:true only when a strictly higher-priority queued job
would go to the same claimant; the runner checkpoints, uploads, and posts
preempted:true, which requeues with params.resume_from and hands the attempt
back. Stepping aside never counts against max_attempts, because it is not a
failure. Equal priority does not preempt.
build on the machine agent closes the loop the artifact store was built for:
check out a ref, build it, publish it with the headers that stamp publish_seq,
and a later job asking for "latest" resolves to it. It posts pending at claim
under its own status context rather than sharing the collector's, because two
writers on one GitHub context means the loser's verdict silently disappears.
Energy is measured honestly or not at all. energy_method is declared in config,
never inferred; a shared plug reports the pool's draw and refuses to divide it;
the integration skips gaps rather than bridging them and returns null rather
than zero when it has fewer than two samples. What it reports is watt-hours at
the wall above a measured idle baseline, including charging — stated everywhere
it appears, because a wall measurement cannot separate the two.
The Evals page counts what it excludes. The plant-ID write-up's accuracy rode in
decode_tok_s because vision had no field of its own, and those rows cannot be
queried today; a page that quietly dropped them would repeat the mistake it
exists to prevent.
Five workloads arrive with their metric names, their views and their refusals:
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. Every one of them refuses rather than approximates where a platform
cannot answer: no PSS on an iOS simulator, no locale on a physical iPhone, no
bold text below Android 12, no in-device shaping on an unrooted phone.
Three bugs found by doing the work.
Swift's convertToSnakeCase splits on capitals and a digit is not one, so
recallAt1 encoded as recall_at1 — one underscore short of the declared name,
which means a metric that silently never arrives. Explicit CodingKeys now.
The old runSoak ran simctl against a physical iPhone's UDID, so a real-device
soak reported the process DEAD at every check.
And the mono repo migration left .gitmodules inside runner-android/, where git
never looks. The gitlink survived with no URL registered against it, so
`git submodule update --init` in a fresh clone silently found nothing to do.
Moved to the root with its path rewritten; verified it registers.
Verified: the collector's suite passes with 552 assertions, the machine agent's
110 tests pass, the Android unit tests and debug build succeed, and the iOS app
builds from a clean xcodegen run off project.yml with zero xcframework
references in the committed project.
Co-Authored-By: Claude Opus 5
---
.gitmodules | 10 +
collector/README.md | 6 +
collector/dash/src/api.ts | 6 +-
collector/dash/src/app.tsx | 23 +-
collector/dash/src/art.tsx | 127 ++
collector/dash/src/icons.tsx | 232 ++++
collector/dash/src/pages/Devices.tsx | 301 +++--
collector/dash/src/pages/Evals.tsx | 593 ++++++++++
collector/dash/src/pages/JobDetail.tsx | 68 +-
collector/dash/src/pages/Jobs.tsx | 5 +-
collector/dash/src/pages/Overview.tsx | 88 +-
collector/dash/src/pages/System.tsx | 20 +-
collector/dash/src/style.css | 238 ++++
collector/dash/src/ui.tsx | 142 ++-
collector/docs/operations.md | 172 ++-
collector/power.example.json | 26 +-
collector/schemas/job.schema.json | 14 +-
collector/schemas/metrics.json | 24 +-
collector/schemas/result.schema.json | 32 +
collector/scripts/smoke.ts | 266 +++++
collector/src/a11y-tree.ts | 447 +++++++
collector/src/api/evals.test.ts | 167 +++
collector/src/api/evals.ts | 427 +++++++
collector/src/api/index.ts | 2 +
collector/src/api/jobs.ts | 18 +-
collector/src/api/mutations.ts | Bin 24127 -> 35037 bytes
collector/src/api/overview.ts | 5 +
collector/src/api/shared.ts | 4 +
collector/src/contact-sheet.ts | 119 ++
collector/src/db.ts | 37 +-
collector/src/device-parsers.test.ts | 485 ++++++++
collector/src/device-state.ts | 518 ++++++++
collector/src/display-settings.ts | 161 +++
collector/src/executor.ts | 1038 ++++++++++++++++-
collector/src/locale.ts | 124 ++
collector/src/power.test.ts | 144 +++
collector/src/power.ts | 355 ++++++
collector/src/server.ts | 374 ++++--
collector/src/soak-samples.ts | 282 +++++
collector/src/watts.ts | 221 ++++
runner-android/.gitmodules | 3 -
.../app/src/main/AndroidManifest.xml | 5 +
.../app/src/main/cpp/fleet_llama.cpp | 97 +-
.../com/taylab/fleetrunner/RunnerService.kt | 146 ++-
.../fleetrunner/backend/LlamaCppBackend.kt | 34 +-
.../taylab/fleetrunner/backend/LlamaNative.kt | 16 +-
.../taylab/fleetrunner/protocol/Protocol.kt | 39 +
.../taylab/fleetrunner/telemetry/Telemetry.kt | 32 +
.../fleetrunner/workload/EmbedEvalEngine.kt | 399 +++++++
.../taylab/fleetrunner/workload/Percentile.kt | 28 +
.../fleetrunner/workload/VantageEngine.kt | 371 ++++++
.../main/res/drawable/bg_button_primary.xml | 12 +
.../main/res/drawable/bg_button_secondary.xml | 11 +
.../app/src/main/res/drawable/bg_card.xml | 8 +
.../app/src/main/res/drawable/bg_field.xml | 31 +
.../src/main/res/drawable/bg_meter_fill.xml | 5 +
.../src/main/res/drawable/bg_meter_track.xml | 5 +
.../app/src/main/res/drawable/bg_pill.xml | 6 +
.../app/src/main/res/drawable/bg_tile.xml | 6 +
.../app/src/main/res/drawable/divider.xml | 4 +
.../res/drawable/ic_launcher_background.xml | 8 +
.../res/drawable/ic_launcher_foreground.xml | 23 +
.../res/drawable/ic_launcher_monochrome.xml | 19 +
.../app/src/main/res/drawable/ic_stat.xml | 29 +-
.../res/mipmap-anydpi-v26/ic_launcher.xml | 6 +
.../mipmap-anydpi-v26/ic_launcher_round.xml | 6 +
.../app/src/main/res/values-night/colors.xml | 20 +
.../app/src/main/res/values/colors.xml | 37 +
.../app/src/main/res/values/themes.xml | 21 +
.../taylab/fleetrunner/CancellationTest.kt | 12 +-
.../com/taylab/fleetrunner/EmbedEvalTest.kt | 300 +++++
.../taylab/fleetrunner/VantageTimingTest.kt | 227 ++++
.../FleetRunner.xcodeproj/project.pbxproj | 16 +
.../FleetRunner/EmbedEvalWorkload.swift | 349 ++++++
runner-ios/FleetRunner/FleetAgent.swift | 172 ++-
runner-ios/FleetRunner/FleetRunnerApp.swift | 476 +++++++-
runner-ios/FleetRunner/LlamaCppBackend.swift | 73 +-
runner-ios/FleetRunner/Percentile.swift | 22 +
runner-ios/FleetRunner/Protocol.swift | 65 +-
runner-ios/FleetRunner/Telemetry.swift | 42 +
runner-ios/FleetRunner/Theme.swift | 194 +++
runner-ios/FleetRunner/VantageWorkload.swift | 299 +++++
runner-machine/README.md | 51 +-
runner-machine/src/agent.ts | 13 +-
runner-machine/src/buildkinds.ts | 157 +++
runner-machine/src/capabilities.ts | 71 +-
runner-machine/src/clock.ts | 117 ++
runner-machine/src/collector.ts | 50 +-
runner-machine/src/git.ts | 113 ++
runner-machine/src/github.ts | 85 ++
runner-machine/src/logtail.ts | 147 +++
runner-machine/src/protocol.ts | 47 +
runner-machine/src/versions.ts | 96 ++
runner-machine/src/workloads/build.ts | 396 +++++++
runner-machine/src/workloads/selfcheck.ts | 180 +++
runner-machine/test/build.test.ts | 128 ++
runner-machine/test/capabilities.test.ts | 60 +-
runner-machine/test/logtail.test.ts | 128 ++
runner-machine/test/selfcheck.test.ts | 188 +++
99 files changed, 12667 insertions(+), 355 deletions(-)
create mode 100644 .gitmodules
create mode 100644 collector/dash/src/art.tsx
create mode 100644 collector/dash/src/pages/Evals.tsx
create mode 100644 collector/src/a11y-tree.ts
create mode 100644 collector/src/api/evals.test.ts
create mode 100644 collector/src/api/evals.ts
create mode 100644 collector/src/contact-sheet.ts
create mode 100644 collector/src/device-parsers.test.ts
create mode 100644 collector/src/device-state.ts
create mode 100644 collector/src/display-settings.ts
create mode 100644 collector/src/locale.ts
create mode 100644 collector/src/power.test.ts
create mode 100644 collector/src/power.ts
create mode 100644 collector/src/soak-samples.ts
create mode 100644 collector/src/watts.ts
delete mode 100644 runner-android/.gitmodules
create mode 100644 runner-android/app/src/main/java/com/taylab/fleetrunner/workload/EmbedEvalEngine.kt
create mode 100644 runner-android/app/src/main/java/com/taylab/fleetrunner/workload/Percentile.kt
create mode 100644 runner-android/app/src/main/java/com/taylab/fleetrunner/workload/VantageEngine.kt
create mode 100644 runner-android/app/src/main/res/drawable/bg_button_primary.xml
create mode 100644 runner-android/app/src/main/res/drawable/bg_button_secondary.xml
create mode 100644 runner-android/app/src/main/res/drawable/bg_card.xml
create mode 100644 runner-android/app/src/main/res/drawable/bg_field.xml
create mode 100644 runner-android/app/src/main/res/drawable/bg_meter_fill.xml
create mode 100644 runner-android/app/src/main/res/drawable/bg_meter_track.xml
create mode 100644 runner-android/app/src/main/res/drawable/bg_pill.xml
create mode 100644 runner-android/app/src/main/res/drawable/bg_tile.xml
create mode 100644 runner-android/app/src/main/res/drawable/divider.xml
create mode 100644 runner-android/app/src/main/res/drawable/ic_launcher_background.xml
create mode 100644 runner-android/app/src/main/res/drawable/ic_launcher_foreground.xml
create mode 100644 runner-android/app/src/main/res/drawable/ic_launcher_monochrome.xml
create mode 100644 runner-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
create mode 100644 runner-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
create mode 100644 runner-android/app/src/main/res/values-night/colors.xml
create mode 100644 runner-android/app/src/main/res/values/colors.xml
create mode 100644 runner-android/app/src/main/res/values/themes.xml
create mode 100644 runner-android/app/src/test/java/com/taylab/fleetrunner/EmbedEvalTest.kt
create mode 100644 runner-android/app/src/test/java/com/taylab/fleetrunner/VantageTimingTest.kt
create mode 100644 runner-ios/FleetRunner/EmbedEvalWorkload.swift
create mode 100644 runner-ios/FleetRunner/Percentile.swift
create mode 100644 runner-ios/FleetRunner/Theme.swift
create mode 100644 runner-ios/FleetRunner/VantageWorkload.swift
create mode 100644 runner-machine/src/buildkinds.ts
create mode 100644 runner-machine/src/clock.ts
create mode 100644 runner-machine/src/git.ts
create mode 100644 runner-machine/src/github.ts
create mode 100644 runner-machine/src/logtail.ts
create mode 100644 runner-machine/src/versions.ts
create mode 100644 runner-machine/src/workloads/build.ts
create mode 100644 runner-machine/src/workloads/selfcheck.ts
create mode 100644 runner-machine/test/build.test.ts
create mode 100644 runner-machine/test/logtail.test.ts
create mode 100644 runner-machine/test/selfcheck.test.ts
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/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..210ae65 100644
--- a/collector/dash/src/icons.tsx
+++ b/collector/dash/src/icons.tsx
@@ -53,6 +53,40 @@ const PATHS = {
>
),
+ hammer: (
+ <>
+
+ >
+ ),
+ waveform: (
+ <>
+
+ >
+ ),
+ vector: (
+ <>
+
+
+
+ >
+ ),
+ globe: (
+ <>
+
+
+ >
+ ),
+ translate: (
+ <>
+
+ >
+ ),
+ accessibility: (
+ <>
+
+
+ >
+ ),
stopwatch: (
<>
@@ -125,6 +159,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 +353,13 @@ 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",
};
/** The workload's name with its icon, for job tables. */
@@ -208,3 +373,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..66aaf48 100644
--- a/collector/dash/src/pages/JobDetail.tsx
+++ b/collector/dash/src/pages/JobDetail.tsx
@@ -6,6 +6,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 +63,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 +132,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 +154,13 @@ function JobActions({ job, onDone }: { job: Detail; onDone: () => void }) {
- {job.status === "queued" && (
+ {reorderable && (
<>
-