Development¶
How the cockpit is put together, and how to change it without breaking the promise that every surface tells the same story.
Shape¶
dashboard/lib/verdict.js the ladder, runner states, buildGlance() (on the runner host)
│ GET /api/glance · /api/stream?view=glance
▼
cockpit/Sources/CockpitCore decode, connect, decide, present (Swift package, no UI)
├── cockpit/Sources/cockpit the CLI and the MCP server
└── cockpit/App the menu bar app, widgets, intents (links CockpitCore)
The rule that shapes the code: the daemon decides, the cockpit presents. The cockpit never recomputes a rung from raw parts. Its only judgements are the ones the daemon cannot make — whether the view it holds is still current, and, when it cannot reach the daemon at all, what the out-of-band witnesses say.
Everything with logic lives in CockpitCore, which builds and tests with
swift test alone: no Xcode project, no simulator, no signing. The app is a
thin SwiftUI layer over it.
Code map¶
cockpit/Sources/CockpitCore/¶
| File | Responsibility |
|---|---|
Glance.swift |
The /api/glance model: verdict, counts, hosts, runners, queue, failures, incidents, posture, runs. Tolerant decoding: unknown fields are ignored |
Transport.swift |
Routes to the daemon: TunnelTransport (ssh with a stdin lifeline), DirectTransport, fixtures; the /api/health probe |
Connection.swift |
ConnectionState and the jittered Backoff |
SSE.swift |
A minimal server-sent events line parser |
FleetClient.swift |
HTTP to one daemon: glance, stream, timeline, actions, runner detail, bundle, dismiss |
GlanceStore.swift |
Owns the connection and the latest glance; keeps a stale view greyed rather than replacing it |
CockpitVerdict.swift |
What the top of the popover shows: the daemon's verdict when it can be trusted, otherwise an honest unknown or the out-of-band verdict |
Ladder.swift |
The rungs as the Why view shows them, with the two client-only rungs on top; the markdown incident brief |
Presenter.swift |
Glance → view models: lanes, pills and their shapes, queue rows, cause labels, ETA ranges, the menu bar mark, the posture chip |
Rollup.swift |
A commit's checks as one row, for the popover; watches |
API.swift |
/api/v1 for the command line: the diagnosis, checks, actions and MCP calls, decoded against Fixtures/v1/ |
CheckWait.swift |
cockpit wait: asks the daemon until the deadline, retries a dashboard that stopped answering, asks GitHub meanwhile, recovers orphans with --recover |
Why.swift |
cockpit why's text |
GitHubChecks.swift |
A PR's checks from gh pr view, for a wait while the dashboard is unreachable |
History.swift |
/api/timeline → incident lanes, today's line, the weekly digest |
IncidentTracker.swift |
Successive glances' incidents → notifications: transitions, storms, snoozes, severity, quiet hours |
Sentinel.swift |
The out-of-band classification table, as one pure function |
SentinelProbe.swift |
The probes behind it: SSH reachability, gh runner checks, githubstatus.com, host uptime and console user; HostProbe.topCPU |
Control.swift |
The action catalogue and the subset the cockpit accepts; pairing; Keychain and file token stores |
SnapshotFile.swift |
glance.json: write atomically, read, age |
Replay.swift |
The recorded incident sequences for the replay window |
Fixtures.swift, Fixtures/ |
The bundled glance payloads, generated from the daemon (below) |
Format.swift |
Durations, ages, clocks |
cockpit/App/¶
| File | Responsibility |
|---|---|
project.yml |
XcodeGen spec: the app, the widget extension, the app group, the URL scheme |
Signing.xcconfig, Local.xcconfig |
Ad-hoc by default; your identity and team when the untracked local file exists |
FleetCockpit/FleetCockpitApp.swift |
Scenes (menu bar, floating panel, replay, settings); the Apple Event URL handler |
FleetCockpit/AppModel.swift |
App state: store, settings, pairing, actions, sentinel loop, notifications, snapshot writing, login item, URL routes |
FleetCockpit/PopoverView.swift, Components.swift, PillView.swift |
The popover |
FleetCockpit/ControlViews.swift |
A runner's panel, inline confirmations, action results |
FleetCockpit/HistoryViews.swift |
History, sparklines, standing risks |
FleetCockpit/Notifier.swift |
UNUserNotificationCenter, categories and buttons |
FleetCockpit/Intents.swift |
App Intents: the three shortcuts and the Focus filter |
FleetCockpit/Hotkey.swift |
⌃⌥⌘F through Carbon |
FleetCockpit/Renderer.swift |
--render: draw the popover to a PNG |
FleetCockpit/ReplayView.swift |
The incident replay window |
FleetCockpitWidgets/FleetWidgets.swift |
The widget extension (sandboxed, reads the app group copy of the snapshot) |
Build and test¶
cd cockpit
swift test # CockpitCore
swift run cockpit status --fixture diskHold # the CLI against a fixture
cd App && xcodegen generate
xcodebuild -project FleetCockpit.xcodeproj -scheme FleetCockpit -configuration Debug build
cd ../../dashboard && npm test # the daemon, including verdict.js
CI's Fleet Cockpit (macOS) job runs swift test, smoke-tests the CLI
against two fixtures, and builds the app unsigned. The Node unit tests job
runs the daemon's tests and checks the fixtures are current.
The tests that carry the most weight:
| Test | Holds |
|---|---|
dashboard/test/verdict.test.js |
Every rung against every recorded incident, and the cases that must not open a rung |
CoreTests |
Decoding real glances (and refusing a newer schema with a reason), the SSE parser, backoff, the presenter, never-stale-green, the snapshot file, transports and the store against a stub daemon |
ControlTests |
Notification transitions, storms, quiet hours, mutes and snoozes; the accepted-action subset; parsing a pairing code |
SentinelTests |
The out-of-band table, row by row; the Why ladder; the brief |
RollupTests |
Check rollups and Waiter.decide: green, red, cancelled, reruns, pointless, a public repo worth waiting for |
HistoryTests |
Timeline lanes, today's line and the digest, posture and forecast, unchecked probes named rather than hidden, every replay step being a real fixture |
APITests |
Every /api/v1 fixture decoded; why and wait text; the wait loop against a fake clock: asking again, the deadline, refusals, an unreachable dashboard with GitHub asked meanwhile, orphan recovery |
Fixtures: one contract, two languages¶
The cockpit's fixtures are not hand-written. Each is the daemon's own
/api/glance output for one recorded scenario in
dashboard/test/fixtures/scenarios.js, written by:
cd dashboard
node scripts/make-glance-fixtures.mjs # regenerate
node scripts/make-glance-fixtures.mjs --check # exit 1 if any is out of date (CI runs this)
So a change to verdict.js that changes the contract fails CI until the
fixtures are regenerated, and the regenerated fixtures then run through the
Swift decoder and presenter tests. The two sides cannot drift silently.
The same script writes Fixtures/v1/, a set of /api/v1 answers made by the
daemon's route handler: a diagnosis, checks that are green, red, pending and
orphaned, an action preview and its run, and an error. APITests decodes each
one, so a change to an /api/v1 response fails CI the same way.
To add a scenario: add it to scenarios.js, assert its verdict in
verdict.test.js, regenerate, and add its name to Fixtures.names in
Fixtures.swift. If it is an incident worth learning from, add a sequence to
Replay.swift.
Changing the ladder¶
The ladder is the product. Change it in this order:
- Write the incident down first as a scenario: the snapshot the daemon saw, and the verdict it should have given.
- Change
verdict.jsand its tests, including a case for what must not open the rung. Most past bugs here were rungs that opened on something normal: pressure "warning", a stale hold reason, a public repo's queue. - Regenerate the fixtures.
- Check every surface reads the new rung: the Swift
Ladder(its title and blurb), the presenter's mark and cause labels, and the tables in Verdict reference andapi.md. Give the rung an entry inAGENT_GUIDEinverdict.js(a test fails until you do), then runnode dashboard/scripts/make-skill.mjs: the skill's table and the MCP instructions are generated from it. - Deploy the daemon to the runner host. Restart it only when no job is running: a restart under load drops the stream every client is reading.
The glance's schema changes only on a breaking change. Adding a field is not
breaking; decoders ignore what they do not know, and so must yours.
Screenshots¶
The images in these pages are drawn by the app itself from fixtures, so they are reproducible and never show a real fleet:
APP="/Applications/Fleet Cockpit.app/Contents/MacOS/Fleet Cockpit"
"$APP" --fixture diskHold --render docs/img/cockpit-disk-floor.png --dark
"$APP" --fixture saturated --render docs/img/cockpit-why.png --why
See launch arguments for --hover, --select and
--pending.
Releases¶
.github/workflows/cockpit-release.yml runs on a cockpit-v* tag:
- Imports a Developer ID certificate into a throwaway keychain.
- Builds the app (hardened runtime) and the CLI, signs both.
- Packages a DMG and a CLI zip, notarizes both with
notarytool, staples. - Publishes them to a GitHub release named after the tag.
- Deletes the keychain.
It is gated on secrets: until they are set, the job notes that and succeeds without building, so forks and fresh clones run CI without anybody's certificate.
| Secret | Contents |
|---|---|
DEVELOPER_ID_P12 |
The Developer ID Application certificate and key, base64 .p12 |
DEVELOPER_ID_P12_PASSWORD |
Its password |
APPLE_TEAM_ID |
The team |
APPLE_ID |
The Apple ID that notarizes |
APPLE_APP_PASSWORD |
An app-specific password for that Apple ID |
To cut a release, bump MARKETING_VERSION in project.yml, add a
CHANGELOG
entry, then:
Conventions¶
- Swift 6, strict concurrency. Core types are
Sendable; the app's model is@MainActor. - No dependencies in the package or the app. The MCP server is under 150 lines of JSON-RPC over stdio rather than an SDK.
- Words come from the daemon. A label written in Swift for something the daemon also names is a second source of truth; prefer the daemon's string.
- Nothing that changes the fleet behind a URL, an MCP tool, or a confirmation-free CLI call. See the design rules.