Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
103 commits
Select commit Hold shift + click to select a range
f3a5112
Phase 0: collector skeleton — schemas, queue, artifacts, dashboard
addisdev Aug 13, 2026
e8c5cf8
Placeholder: Phase 3 Swift runner, protocol contract documented
addisdev Aug 13, 2026
e2baa22
Phase 1: Android runner MVP — agent loop, beacon, synthetic benchmark
addisdev Aug 13, 2026
def6d85
Phase 1b: llama.cpp backend — NDK/JNI, GGUF artifact download
addisdev Aug 13, 2026
f364c98
Build llama.cpp at -O3 in all app variants
addisdev Aug 13, 2026
3980082
Lease claimed jobs so a dead runner cannot strand them
addisdev Aug 14, 2026
d82a611
Keep the collector up with a launchd agent
addisdev Aug 14, 2026
6c06c29
Phase 2: host executor — install + Maestro ui-test workloads
addisdev Aug 14, 2026
75081d6
Merge branch 'claude/laughing-wu-953cc0'
addisdev Aug 14, 2026
f0c7ba0
Phase 3: iOS runner — agent loop, beacon, synthetic benchmark
addisdev Aug 14, 2026
496db63
Phase 3b: executor drives iOS simulators; permission-dialog-safe smok…
addisdev Aug 14, 2026
d48e5c4
Phase 3b: llama.cpp backend — xcframework link, GGUF artifact cache
addisdev Aug 14, 2026
3683e47
Phase 4: cron scheduler, job fan-out, device locks, power control
addisdev Aug 14, 2026
5aabcd8
Phase 5, dark: CI enqueue script + commit statuses, off by default
addisdev Aug 14, 2026
ecf5006
Soak workload, XCUITest path, streaming uploads, benchmark dashboard
addisdev Aug 14, 2026
7d038e6
Beacons carry the running job_id to renew its lease
addisdev Aug 14, 2026
64f0719
Lease-renewing beacons, agent-owned collector URL, XCUITest smoke bundle
addisdev Aug 14, 2026
ac1edfd
Pipeline event rails: broker-free pub/sub for tiered device pipelines
addisdev Aug 14, 2026
c9850a8
Batch + pipeline workloads, real llama.cpp generation, device-state h…
addisdev Aug 14, 2026
227d2b5
LiteRT vision backend + vision-eval workload (plant-ID model evaluation)
addisdev Aug 15, 2026
43b9d25
GreenFolio plant-ID eval: method, first results, and shelf schedule
addisdev Aug 15, 2026
f146be8
Plant-ID eval: int8 candidate — 4x smaller, 5x faster, ~1 point of ac…
addisdev Aug 15, 2026
f07887b
Remaining plan items: targets.match, drain workload, devicectl target…
addisdev Aug 15, 2026
e5e9464
Core ML vision-eval, batch + pipeline parity, constraints, sim CPU guard
addisdev Aug 15, 2026
8ae1f1e
Sustained-load benchmark mode; default collector URL :8788
addisdev Aug 15, 2026
05e78e3
Move the collector to its own always-on host; real-hardware plant-ID …
addisdev Aug 18, 2026
4d47ddc
Default to the fleet host, not loopback
addisdev Aug 18, 2026
939121a
Default to the fleet host, not loopback
addisdev Aug 18, 2026
30de22a
Add GET /devices so enrollment can verify itself
addisdev Aug 18, 2026
2fec353
enroll.sh: one command to adopt every attached device
addisdev Aug 18, 2026
cf53b2b
Dashboard: read API, live stream, and job control (#1)
addisdev Aug 19, 2026
fa1670f
Results, operations, alerts and polish (#2)
addisdev Aug 19, 2026
eb7b1a5
Drain metric gets its own field; plan checked in (#3)
addisdev Aug 19, 2026
a7e3141
vision-eval reports accuracy and latency under their own names (#1)
addisdev Aug 19, 2026
251ae48
vision-eval reports accuracy and latency under their own names (#1)
addisdev Aug 19, 2026
f00cf9e
Record that the runner apps now emit the named vision metrics (#4)
addisdev Aug 19, 2026
2795fcb
Document FLEET_DASH_TOKEN in the plist templates (#5)
addisdev Aug 19, 2026
6bdeca8
An enrolment screen for adding devices to the fleet (#6)
addisdev Aug 19, 2026
204adb0
Schedules as code, targeting by match instead of pool (#7)
addisdev Aug 19, 2026
262dd44
Show device nicknames everywhere, and let you set them inline (#8)
addisdev Aug 19, 2026
9ab1ceb
Run the host executor on fleet-host (#9)
addisdev Aug 19, 2026
cd87dbd
The UI-test matrix was blind to every real run (#10)
addisdev Aug 19, 2026
4b2a4b0
Point the nightly UI smoke at the current runner build (#11)
addisdev Aug 19, 2026
4f7fabb
Click a device's name to rename it; drop the nickname concept (#12)
addisdev Aug 19, 2026
92e9cd8
Ask for the dashboard token before a rename fails on it (#13)
addisdev Aug 19, 2026
cb219d9
Turn the dashboard token off on fleet-host (#14)
addisdev Aug 19, 2026
557f124
U0: route host jobs to a named executor (#15)
addisdev Aug 19, 2026
73a8001
U1: the iOS executor, on the machine with the GitHub runners (#16)
addisdev Aug 19, 2026
979e9c9
U4: the web-test workload (#17)
addisdev Aug 20, 2026
021854c
U5: nightlies that test the build CI just published (#18)
addisdev Aug 20, 2026
1119783
U2/U3: real suites on real devices, and honest counts (#19)
addisdev Aug 20, 2026
952bef7
Do not mistake echoed source for a build error (#20)
addisdev Aug 20, 2026
40aff85
The shelf is online when it is actually attached (#21)
addisdev Aug 20, 2026
ebc3958
Physical iPhones, and one membership rule for every virtual device (#22)
addisdev Aug 20, 2026
b476b02
A host job runs on the devices it asked for (#23)
addisdev Aug 20, 2026
12c2093
UI suites can sign in, without the fleet ever holding a password (#24)
addisdev Aug 20, 2026
1046096
Tell an unpaired phone to pair, not to unlock (#25)
addisdev Aug 20, 2026
1d9983b
An iOS-only host, and the test failure the artifact kept dropping (#26)
addisdev Aug 20, 2026
fa4d38b
docs: bring the UI-test plan current, and plan the three iOS suites
addisdev Aug 20, 2026
ca22d71
docs: N0–N2 done, and the ordering error they exposed
addisdev Aug 20, 2026
60ec3aa
Membership gates jobs, not just presence (#27)
addisdev Aug 20, 2026
58e4144
Make a red nightly reach somebody who is not looking at the dashboard
addisdev Aug 20, 2026
997d46b
Visual regression, site audits, and the weekly review digest
addisdev Sep 3, 2026
ec10383
Scrub the fleet's own addresses out; make the agents installable by a…
addisdev Sep 3, 2026
83185eb
Default to loopback, not to one house's collector
addisdev Sep 3, 2026
8c9f5df
Default to loopback, not to one house's collector
addisdev Sep 3, 2026
17fef6c
Scrub the runner host's serial number too
addisdev Sep 3, 2026
3feb782
The power example pointed at a real smart plug on a real LAN
addisdev Sep 3, 2026
2e54480
A fresh clone could not build this app at all
addisdev Sep 3, 2026
19b48a0
CI, and a flag that lets it skip the fifteen-minute native build
addisdev Sep 3, 2026
f1efd54
`npm test`: a suite a stranger can run without owning the fleet
addisdev Sep 3, 2026
8accf32
Close a high-severity advisory, and stop the dashboard borrowing types
addisdev Sep 3, 2026
ae5e4f4
"No keychain here" is a miss, not a denial
addisdev Sep 3, 2026
e7f77d2
MIT, and a note on what else is in the box
addisdev Sep 3, 2026
5e11394
MIT, and a note on what else is in the box
addisdev Sep 3, 2026
1437195
MIT, and a note on what else is in the box
addisdev Sep 3, 2026
9270c3d
A front door, and the manual behind it
addisdev Sep 3, 2026
8417ed0
A front door for the runner, with proof it runs
addisdev Sep 3, 2026
6773462
A front door for the runner
addisdev Sep 3, 2026
16e06f0
The mark, a favicon, and icons beside the words
addisdev Sep 3, 2026
2335671
Review fixes: the space before the simulator marker, and three notes
addisdev Sep 3, 2026
b0ec676
Merge pull request #28 from addisdev/feat/dashboard-assets
addisdev Sep 3, 2026
9702bb7
The last three workloads get icons, and the tile lifts on dark
addisdev Sep 4, 2026
c73b6c3
Review fixes: the digest outline, the tile's own ink, and a guard
addisdev Sep 4, 2026
2a39f5e
Merge pull request #29 from addisdev/feat/workload-icons-dark-glyph
addisdev Sep 4, 2026
1f21cca
A cancelled job kept running to the end anyway
addisdev Sep 4, 2026
cf6be70
Cancelled jobs actually stop, and the runner declares what it can run
addisdev Sep 4, 2026
c84595d
An agent says what it can run, and the collector believes it
addisdev Sep 4, 2026
f791353
A thermal workload, so the sustained number is measured and not guessed
addisdev Sep 4, 2026
6dd3409
The benchmark measures a cold device, and nobody ships a cold device
addisdev Sep 4, 2026
1cf321b
A laptop is a fleet device too
addisdev Sep 4, 2026
7429af2
Laptops can be devices, and a benchmark can be a curve
addisdev Sep 4, 2026
eed3324
Merge pull request #30 from addisdev/feat/wave-0-foundations
addisdev Sep 4, 2026
64d317c
Merge pull request #2 from addisdev/feat/wave-0-cancel-and-capabilities
addisdev Sep 4, 2026
2172b7d
Merge pull request #2 from addisdev/feat/wave-0-cancel-and-capabilities
addisdev Sep 4, 2026
3d6fe93
Merge pull request #32 from addisdev/feat/wave-1-machines-and-curves
addisdev Sep 4, 2026
37def13
Merge pull request #4 from addisdev/feat/wave-1-thermal
addisdev Sep 4, 2026
6402579
Merge pull request #4 from addisdev/feat/wave-1-thermal
addisdev Sep 4, 2026
40812b5
Add 'collector/' from commit '3d6fe934779c01be89e9edfcabf636a6990182cd'
addisdev Sep 4, 2026
eac2b81
Add 'runner-ios/' from commit '6402579b0f7d79ed2da9c947900ca5cae3dc21eb'
addisdev Sep 4, 2026
8013681
Add 'runner-android/' from commit '37def1316bafe40246c9377b5a625d336d…
addisdev Sep 4, 2026
86b9f2c
Add 'runner-machine/' from commit '1cf321bc899165f0bd22bbb41200d3d408…
addisdev Sep 4, 2026
663b9f1
Four repositories become one
addisdev Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/collector.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: collector

# Path-filtered: a change to a phone runner should not spin up a Node matrix.
#
# 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.
on:
push:
branches: [main, develop]
paths: ["collector/**", ".github/workflows/collector.yml"]
pull_request:
paths: ["collector/**", ".github/workflows/collector.yml"]

defaults:
run:
working-directory: collector

jobs:
test:
runs-on: ubuntu-latest
strategy:
# Both versions report, so a failure on one does not hide the other.
fail-fast: false
matrix:
node: ["22", "24"]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: |
collector/package-lock.json
collector/dash/package-lock.json

- run: npm ci

# The dashboard has its own package.json, so `npm test` skips building it
# when its modules are absent rather than failing on a missing binary.
# This step is what makes that build actually happen here.
- name: Install dashboard dependencies
run: npm --prefix dash ci

# Playwright is a dependency but its browsers are not downloaded: nothing
# in this suite drives a browser. The web-* workloads that do are run by
# an executor on real hardware, not here.
#
# CI=1 also makes the metric-name check fail on a stale schemas/metrics.json
# rather than quietly regenerating it. That file is what an agent working in
# a runner directory reads, and a mirror that lags its schema states a
# contract confidently and wrongly.
- run: npm test
39 changes: 39 additions & 0 deletions .github/workflows/runner-android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: runner-android

on:
push:
branches: [main, develop]
paths: ["runner-android/**", ".github/workflows/runner-android.yml"]
pull_request:
paths: ["runner-android/**", ".github/workflows/runner-android.yml"]

defaults:
run:
working-directory: runner-android

jobs:
unit-tests:
runs-on: ubuntu-latest

steps:
# Deliberately without submodules. -Pfleet.skipNative drops the native
# build, so llama.cpp is never compiled here and cloning it would cost
# more than the tests do.
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"

- uses: gradle/actions/setup-gradle@v4
with:
build-root-directory: runner-android

# The JVM tests cover the protocol, the synthetic backend's scaling, the
# cancellation registry and the thermal curve's bookkeeping -- none of
# which call into the native library. The arm64 llama.cpp build takes
# about fifteen minutes and needs a pinned NDK, so it is verified on a
# developer machine (and by the phones that actually run the benchmarks)
# rather than on every push.
- run: ./gradlew :app:testDebugUnitTest -Pfleet.skipNative
37 changes: 37 additions & 0 deletions .github/workflows/runner-ios.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: runner-ios

on:
push:
branches: [main, develop]
paths: ["runner-ios/**", ".github/workflows/runner-ios.yml"]
pull_request:
paths: ["runner-ios/**", ".github/workflows/runner-ios.yml"]

defaults:
run:
working-directory: runner-ios

jobs:
build:
runs-on: macos-latest

steps:
- uses: actions/checkout@v4

- run: brew install xcodegen

# Picks the spec this checkout can build. There is no llama.xcframework
# here -- it is 850 MB and gitignored -- so this generates the project
# without the llama.cpp backend, which is exactly the state a stranger
# cloning the repo is in. If that ever stops building, this job is how
# we find out.
- run: ./generate.sh

- name: Build for the simulator
run: |
xcodebuild build \
-project FleetRunner.xcodeproj \
-scheme FleetRunner \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
CODE_SIGNING_ALLOWED=NO
37 changes: 37 additions & 0 deletions .github/workflows/runner-machine.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: runner-machine

on:
push:
branches: [main, develop]
paths: ["runner-machine/**", ".github/workflows/runner-machine.yml"]
pull_request:
paths: ["runner-machine/**", ".github/workflows/runner-machine.yml"]

defaults:
run:
working-directory: runner-machine

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ["22", "24"]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: runner-machine/package-lock.json

- run: npm ci

# A GitHub runner has no GPU, no llama-bench and no mlx_lm, which is
# exactly the machine the capability probes exist for: the suite passes
# there by declaring `benchmark` and nothing else. If a probe ever starts
# guessing instead of answering, this is where it shows up.
- run: npm test
47 changes: 38 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,35 @@ hardware?** It turned out to be yes, and the fleet is how I know.

![The dashboard: llama.cpp benchmark numbers measured on a real phone](docs/img/results.png)

## The repositories
## What is in here

Four projects in one repository. They ship independently and share no code —
only a JSON protocol — but they are versioned together, because the protocol is
the thing that breaks and a change to it touches three of them at once.

| | What it is |
|---|---|
| **[fleet-collector](https://github.com/addisdev/fleet-collector)** | The brain. Device registry, job queue with leases, artifact store, results database, scheduler, alert engine, and the dashboard above. Node + Fastify + SQLite, no broker, no cloud. |
| **[fleet-runner-android](https://github.com/addisdev/fleet-runner-android)** | The Android agent. A foreground service on anything back to Android 7, with llama.cpp (NDK/JNI) and LiteRT backends. |
| **[fleet-runner-ios](https://github.com/addisdev/fleet-runner-ios)** | The iOS agent. SwiftUI, with llama.cpp and Core ML backends, speaking the same JSON protocol without sharing a line of code. |
| **[collector/](collector)** | The brain. Device registry, job queue with leases, artifact store, results database, scheduler, alert engine, and the dashboard above. Node + Fastify + SQLite, no broker, no cloud. |
| **[runner-android/](runner-android)** | The Android agent. A foreground service on anything back to Android 7, with llama.cpp (NDK/JNI) and LiteRT backends. |
| **[runner-ios/](runner-ios)** | The iOS agent. SwiftUI, with llama.cpp and Core ML backends, speaking the same JSON protocol without sharing a line of code. |
| **[runner-machine/](runner-machine)** | The desktop agent. A Node process that makes a laptop or desktop a fleet device, so a phone's tok/s and a laptop's land in the same table. |

Each directory has its own README, its own tests and its own CI job, filtered by
path so a change to a phone runner does not build the dashboard.

### Why one repository

These were four repositories until the protocol started changing. A metric name
lives in `collector/schemas/result.schema.json` and is mirrored by hand in three
runners; a capability list is declared by an agent and enforced by the queue.
Every one of those is a change that has to land in several places at once, and
across repositories it lands in several pull requests that can each merge alone.
The drift is not hypothetical — an eval's accuracy once rode in a field named
`decode_tok_s` because vision had no field of its own, and no query can
reproduce that report's numbers today.

One repository makes such a change one reviewable diff, and lets `npm test`
in `collector/` fail when the schema and its mirror disagree.

## How it fits together

Expand All @@ -33,6 +55,7 @@ flowchart LR
A["Android runner"]
I["iOS runner"]
end
M["machine runner<br/><i>laptop or desktop</i>"]
subgraph host["a Mac with devices plugged in"]
X["host executor<br/><i>adb · Maestro · simctl · Playwright</i>"]
end
Expand All @@ -41,6 +64,7 @@ flowchart LR

A -- "long-poll, claim, report" --> C
I -- "long-poll, claim, report" --> C
M -- "long-poll, claim, report" --> C
X -- "claims host jobs" --> C
X -- "drives from outside" --> shelf
C --- D
Expand All @@ -51,10 +75,15 @@ claimed by an executor on a Mac and drive a device from outside, because
installing an APK or tapping through a UI test is not something an app can do
to itself.

The two runners share a protocol, not code — including a synthetic SHA-256
benchmark that is identical on both platforms token for token. That is what
lets a 2019 Android phone and a current iPhone produce numbers you can put in
the same table, which is the difference between a fleet and a pile of phones.
The runners share a protocol, not code — including a synthetic SHA-256
benchmark that is identical on every platform token for token. That is what
lets a 2019 Android phone, a current iPhone and a laptop produce numbers you can
put in the same table, which is the difference between a fleet and a pile of
phones.

A runner also says what it can run. The queue routes on those declared
capabilities rather than on a label someone applied, so adding a workload is
something a runner can do without the collector shipping a release.

## What came out of it

Expand Down Expand Up @@ -87,7 +116,7 @@ Three findings worth the whole build:
trustworthy.

Full write-up, including the quantization scripts and the licensing of every
model and dataset: **[the eval](https://github.com/addisdev/fleet-collector/blob/main/evals/greenfolio-plant-id.md)**.
model and dataset: **[the eval](collector/evals/greenfolio-plant-id.md)**.

## Things I learned the hard way

Expand Down
11 changes: 11 additions & 0 deletions collector/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules/
data/
artifacts/store/
*.log
.DS_Store
power.json
dash/dist/

# Playwright's per-run output. Traces and screenshots that matter are uploaded
# to the artifact store by the executor; what lands here is scratch.
test-results/
1 change: 1 addition & 0 deletions collector/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
21 changes: 21 additions & 0 deletions collector/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Taylor Addison

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading
Loading