From 4896adff3aaf69b7156f6aa540e7634d5c6c77c3 Mon Sep 17 00:00:00 2001 From: Taylor Addison Date: Wed, 21 Oct 2026 12:10:38 -0400 Subject: [PATCH 1/2] Cockpit reaches ultra by default; drop runner-host defaults runner-host is retired: ultra is now the coordinator and the only runner host. The cockpit's default route lived in six places (CLI help, three CLI fallbacks, the app's settings, the sentinel, the widget sample); it now lives in one, TransportConfig.defaultAliases = ["ultra", "ultra-ts"], and everything else reads it. Another or an extra host stays a setting (Settings -> Connection, or --via), not a release. host-sentinel.sh names the host after its target unless SENTINEL_NAME is set, instead of a hard-coded "runner-host". shoot-motion.mjs and the settle-stale-jobs examples ssh to ultra. Cockpit docs follow the new default; federation.md gets a short current-topology note pointing at "Adding a second runner host". Historical comments that measured something on runner-host are left as they were. Co-Authored-By: Claude Opus 5.5 --- cockpit/App/FleetCockpit/AppModel.swift | 2 +- cockpit/App/FleetCockpit/AppSettings.swift | 4 ++-- .../FleetCockpitWidgets/FleetWidgets.swift | 2 +- cockpit/Sources/CockpitCore/Transport.swift | 10 +++++++++- cockpit/Sources/cockpit/main.swift | 12 +++++------ cockpit/skills/fleet-cockpit/SKILL.md | 2 +- dashboard/scripts/settle-stale-jobs.mjs | 4 ++-- docs/cockpit/agents.md | 2 +- docs/cockpit/cli.md | 20 +++++++++---------- docs/cockpit/install.md | 10 +++++----- docs/cockpit/sentinel.md | 8 ++++---- docs/cockpit/settings.md | 2 +- docs/cockpit/troubleshooting.md | 2 +- docs/federation.md | 9 +++++++++ docs/reference/scripts.md | 4 ++-- docs/tools/shoot-motion.mjs | 2 +- scripts/host-sentinel.sh | 6 +++--- 17 files changed, 59 insertions(+), 42 deletions(-) diff --git a/cockpit/App/FleetCockpit/AppModel.swift b/cockpit/App/FleetCockpit/AppModel.swift index 75a308b..a18c105 100644 --- a/cockpit/App/FleetCockpit/AppModel.swift +++ b/cockpit/App/FleetCockpit/AppModel.swift @@ -562,7 +562,7 @@ final class AppModel { } func openTerminal(alias: String? = nil) { - let host = alias ?? settings.aliasList.first ?? "runner-host" + let host = alias ?? settings.aliasList.first ?? TransportConfig.defaultAliases[0] let script = "tell application \"Terminal\" to do script \"ssh \(host)\"\ntell application \"Terminal\" to activate" NSAppleScript(source: script)?.executeAndReturnError(nil) } diff --git a/cockpit/App/FleetCockpit/AppSettings.swift b/cockpit/App/FleetCockpit/AppSettings.swift index 2e6743e..0ac8da6 100644 --- a/cockpit/App/FleetCockpit/AppSettings.swift +++ b/cockpit/App/FleetCockpit/AppSettings.swift @@ -18,8 +18,8 @@ struct AppSettings: Codable, Equatable { } var mode: Mode = .tunnel - var aliases: String = "runner-host, runner-ts" - var remotePort: Int = 7878 + var aliases: String = TransportConfig.defaultAliases.joined(separator: ", ") + var remotePort: Int = TransportConfig.defaultRemotePort var directURL: String = "http://127.0.0.1:7878" var fixture: String = "live" var showCounts: Bool = true diff --git a/cockpit/App/FleetCockpitWidgets/FleetWidgets.swift b/cockpit/App/FleetCockpitWidgets/FleetWidgets.swift index 183b1eb..d244ca5 100644 --- a/cockpit/App/FleetCockpitWidgets/FleetWidgets.swift +++ b/cockpit/App/FleetCockpitWidgets/FleetWidgets.swift @@ -37,7 +37,7 @@ struct FleetProvider: TimelineProvider { static var sample: SnapshotFile? { guard let g = try? Fixtures.glance("waiting") else { return nil } - return SnapshotFile(writtenAt: Format.nowMs(), route: "runner-host", connection: "live", verdict: g.verdict, glance: g) + return SnapshotFile(writtenAt: Format.nowMs(), route: TransportConfig.defaultAliases[0], connection: "live", verdict: g.verdict, glance: g) } } diff --git a/cockpit/Sources/CockpitCore/Transport.swift b/cockpit/Sources/CockpitCore/Transport.swift index b8c8a98..d725c96 100644 --- a/cockpit/Sources/CockpitCore/Transport.swift +++ b/cockpit/Sources/CockpitCore/Transport.swift @@ -14,7 +14,15 @@ public enum TransportConfig: Codable, Sendable, Equatable { case direct(URL) case fixture(String) - public static let `default` = TransportConfig.tunnel(aliases: ["runner-host", "runner-ts"], remotePort: 7878) + /// The ssh aliases tried, in order, when nothing else is configured: the + /// coordinator's LAN name, then its tailnet name. This is the one place the + /// default route is written down; the app's settings, the CLI's `--via` and + /// the sentinel all start from it. A fleet on another host is a setting + /// (Settings → Connection, or `--via`), not a new build. + public static let defaultAliases = ["ultra", "ultra-ts"] + public static let defaultRemotePort = 7878 + + public static let `default` = TransportConfig.tunnel(aliases: defaultAliases, remotePort: defaultRemotePort) } /// The route in use right now, shown in the popover footer. diff --git a/cockpit/Sources/cockpit/main.swift b/cockpit/Sources/cockpit/main.swift index f7fb2df..326fce9 100644 --- a/cockpit/Sources/cockpit/main.swift +++ b/cockpit/Sources/cockpit/main.swift @@ -36,7 +36,7 @@ commands: options: --json machine-readable output --fixture use a bundled fixture instead of the live fleet - --via ssh alias to tunnel through (repeatable; default: runner-host, runner-ts) + --via ssh alias to tunnel through (repeatable; default: \(TransportConfig.defaultAliases.joined(separator: ", "))) --url reach the dashboard directly --fresh ignore the app's snapshot and connect now --port the dashboard's port on the host (default 7878) @@ -129,7 +129,7 @@ func obtain(_ o: Options) async -> Source { return Source(glance: snap.glance, verdict: trusted(snap.verdict, snap.glance), route: "app (\(snap.route ?? "?"), \(Format.ago(snap.writtenAt, now: Format.nowMs())))") } let transport: Transport = o.url.map { DirectTransport(url: $0) } - ?? TunnelTransport(aliases: o.via.isEmpty ? ["runner-host", "runner-ts"] : o.via) + ?? TunnelTransport(aliases: o.via.isEmpty ? defaultAliases : o.via) do { let route = try await transport.open() let client = FleetClient(base: route.baseURL) @@ -166,7 +166,7 @@ final class RouteCache: @unchecked Sendable { } } -let defaultAliases = ["runner-host", "runner-ts"] +let defaultAliases = TransportConfig.defaultAliases func transport(_ o: Options) -> Transport { o.url.map { DirectTransport(url: $0) } ?? TunnelTransport(aliases: o.via.isEmpty ? defaultAliases : o.via) @@ -257,7 +257,7 @@ case "status": src.transport?.close() exit(exitCode(src.verdict)) case "sentinel": - let aliases = opts.via.isEmpty ? ["runner-host", "runner-ts"] : opts.via + let aliases = opts.via.isEmpty ? defaultAliases : opts.via let targets = (try? SnapshotFile.read())?.glance.flatMap { SentinelTargets.from($0) } var probe = SentinelProbe(aliases: aliases) if let port = opts.flags["port"].flatMap(Int.init) { probe.remotePort = port } @@ -271,7 +271,7 @@ case "sentinel": } exit(exitCode(v)) case "pair": - let alias = opts.via.first ?? "runner-host" + let alias = opts.via.first ?? defaultAliases[0] let src = await obtain(Options(command: "status", fresh: true, positional: [], flags: [:]).with(via: opts.via, url: opts.url)) guard let client = src.client, let g = src.glance else { fail("cannot reach the dashboard to pair") } let account = "cli:" + (g.hosts.first { $0.local == true }?.id ?? alias) @@ -430,7 +430,7 @@ case "wait": fail(e) } case "top": - let alias = opts.via.first ?? "runner-host" + let alias = opts.via.first ?? defaultAliases[0] guard let top = await HostProbe.topCPU(alias: alias) else { fail("could not run ps on \(alias)", code: 3) } if opts.json { struct Top: Encodable { let verdict: String; let spotlightCPU: Double; let top: [String] } diff --git a/cockpit/skills/fleet-cockpit/SKILL.md b/cockpit/skills/fleet-cockpit/SKILL.md index 381010f..01d1b0d 100644 --- a/cockpit/skills/fleet-cockpit/SKILL.md +++ b/cockpit/skills/fleet-cockpit/SKILL.md @@ -91,5 +91,5 @@ frozen; mention it. - `cockpit sentinel` checks SSH reachability, GitHub's view of the host's runners, and another machine. - `fleet` reports `unreachable`: check `FLEET_URL`, or forward the port with - `ssh -L 7878:localhost:7878 `. + `ssh -L 7878:localhost:7878 `. - `cockpit brief` prints a markdown incident brief to paste into a report. diff --git a/dashboard/scripts/settle-stale-jobs.mjs b/dashboard/scripts/settle-stale-jobs.mjs index b3bd353..70707cb 100644 --- a/dashboard/scripts/settle-stale-jobs.mjs +++ b/dashboard/scripts/settle-stale-jobs.mjs @@ -9,11 +9,11 @@ // // Three steps, each restartable: // -// ssh runner-host "/usr/bin/sqlite3 -json ~/actions-runners/dashboard/fleet.db \ +// ssh ultra "/usr/bin/sqlite3 -json ~/actions-runners/dashboard/fleet.db \ // \"$(node scripts/settle-stale-jobs.mjs query)\"" > stale.json // node scripts/settle-stale-jobs.mjs fetch --in stale.json --out fetched.ndjson // node scripts/settle-stale-jobs.mjs sql --in fetched.ndjson \ -// | ssh runner-host /usr/bin/sqlite3 ~/actions-runners/dashboard/fleet.db +// | ssh ultra /usr/bin/sqlite3 ~/actions-runners/dashboard/fleet.db // // fetch skips ids already in --out, so an interrupted run resumes. It paces // itself under GitHub's primary limit (sleeps to the reset below --floor) and diff --git a/docs/cockpit/agents.md b/docs/cockpit/agents.md index e5810b2..8ede0e5 100644 --- a/docs/cockpit/agents.md +++ b/docs/cockpit/agents.md @@ -123,7 +123,7 @@ the MCP server, the Shortcuts and other local dashboards answer in milliseconds. ```json { "writtenAt": 1790781990012, - "route": "runner-host", + "route": "ultra", "connection": "live", "verdict": { "id": "waiting", "tone": "ok", "title": "Working", "...": "..." }, "glance": { "schema": 1, "...": "the full GET /api/glance payload" } diff --git a/docs/cockpit/cli.md b/docs/cockpit/cli.md index 7e0c9a3..75621be 100644 --- a/docs/cockpit/cli.md +++ b/docs/cockpit/cli.md @@ -19,11 +19,11 @@ trustworthy, in this order: 90 seconds old and you did not pass `--fresh`, `--url` or `--via`. This answers in milliseconds and opens nothing. 3. **Its own connection**: `--url` directly, otherwise an SSH tunnel through - `--via` aliases (default `runner-host`, then `runner-ts`), exactly as the app + `--via` aliases (default `ultra`, then `ultra-ts`), exactly as the app would open one. The human output always ends with the route it used, for example -`via app (runner-host, 12s ago)` or `via runner-ts`. +`via app (ultra, 12s ago)` or `via ultra-ts`. `why`, `wait`, `run` and `mcp` ask the dashboard's [`/api/v1`](../api.md#version-1-apiv1) instead, over the same route, and never read the snapshot or a fixture. The @@ -37,7 +37,7 @@ than `/api/v1` gets the message to update actions-runners on the host. |---|---| | `--json` | Machine-readable output (pretty-printed, sorted keys) | | `--fixture ` | Use a bundled fixture instead of the live fleet (`cockpit fixtures` lists them) | -| `--via ` | SSH alias to tunnel through; repeat for a fallback list. Default `runner-host`, `runner-ts` | +| `--via ` | SSH alias to tunnel through; repeat for a fallback list. Default `ultra`, `ultra-ts` | | `--url ` | Reach the dashboard directly, for example over Tailscale Serve | | `--fresh` | Ignore the app's snapshot and connect now | | `--port ` | The dashboard's port on the host, for `sentinel`. Other commands tunnel to 7878; use `--url` for a dashboard on another port | @@ -65,7 +65,7 @@ $ cockpit status · 3 jobs held at Set up runner: comet-web, comet-backend, ember-ios · Waiting does not fix this; freeing disk does → Preview cleanup - 0 running, 0 queued, 3 held, 54 runners · via app (runner-host, 4s ago) + 0 running, 0 queued, 3 held, 54 runners · via app (ultra, 4s ago) ``` The mark is `✔` for ok and info, `▲` warning, `✖` critical, `?` unknown. `→` is @@ -288,10 +288,10 @@ runner on another machine, githubstatus.com. Prints the verdict from the ```console $ cockpit sentinel -✖ runner-host is asleep, off or off the network +✖ ultra is asleep, off or off the network mini in the same house is online, so power and the network are fine. Only someone there can wake it. · SSH port closed on every route - · GitHub: 0 of 3 checked runners on runner-host online + · GitHub: 0 of 3 checked runners on ultra online · mini: online → Wake it in person via out of band @@ -317,7 +317,7 @@ Spotlight is using 150% CPU — it is indexing, most likely the runner work tree 11.2% mdworker_shared ``` -`--via ` picks the host (default `runner-host`). `--json` prints +`--via ` picks the host (default `ultra`). `--json` prints `{ verdict, spotlightCPU, top }`. Exit `1` when Spotlight is using 50% CPU or more, `3` when `ps` could not be run, else `0`. @@ -361,12 +361,12 @@ Pairs the command line with the dashboard, with a token of its own, separate from the app's and revocable on its own. ```bash -cockpit pair # over runner-host -cockpit pair --via runner-ts --fleet-root ~/actions-runners +cockpit pair # over ultra +cockpit pair --via ultra-ts --fleet-root ~/actions-runners ``` It runs `fleetctl.sh pair` on the host over the first `--via` alias (default -`runner-host`) to mint a six-digit code, exchanges it for a device token named +`ultra`) to mint a six-digit code, exchanges it for a device token named "cockpit CLI on ", and saves the token to `~/Library/Application Support/FleetCockpit/cli-token-cli:` with mode `0600`. `--fleet-root` is where the repository lives on the host (default diff --git a/docs/cockpit/install.md b/docs/cockpit/install.md index f9c377f..2f6a6b4 100644 --- a/docs/cockpit/install.md +++ b/docs/cockpit/install.md @@ -96,12 +96,12 @@ not the icon is visible. The dashboard binds to loopback on the runner host. The default route needs nothing changed there: the app opens an SSH tunnel through aliases from your -`~/.ssh/config`, trying each in turn — by default `runner-host`, then -`runner-ts` for a tailnet route. A minimal entry: +`~/.ssh/config`, trying each in turn — by default `ultra`, then +`ultra-ts` for a tailnet route. A minimal entry: ``` -Host runner-host - HostName runner-host.local +Host ultra + HostName ultra.local User you IdentityFile ~/.ssh/id_ed25519 ``` @@ -109,7 +109,7 @@ Host runner-host Test it the way the app will run it — no prompt allowed: ```bash -ssh -o BatchMode=yes runner-host 'curl -s 127.0.0.1:7878/api/health' +ssh -o BatchMode=yes ultra 'curl -s 127.0.0.1:7878/api/health' ``` If you serve the dashboard over diff --git a/docs/cockpit/sentinel.md b/docs/cockpit/sentinel.md index cfb762b..7b2823a 100644 --- a/docs/cockpit/sentinel.md +++ b/docs/cockpit/sentinel.md @@ -13,8 +13,8 @@ back, with how long it was gone: | Title | Body | Priority | |---|---|---| -| **runner-host is down** | Has not answered for 3 minutes. Asleep, off, or rebooted with nobody logged in — runners start at login. Only someone there can wake it. | urgent | -| **runner-host is back** | Answers again after 23 min. Queued jobs start on their own; dead services may need a repair. | default | +| **ultra is down** | Has not answered for 3 minutes. Asleep, off, or rebooted with nobody logged in — runners start at login. Only someone there can wake it. | urgent | +| **ultra is back** | Answers again after 23 min. Queued jobs start on their own; dead services may need a repair. | default | Messages carry the host's name and nothing else about the fleet. @@ -27,7 +27,7 @@ On the other machine, with a clone of this repository: 2. **Write the config** (mode `0600`): ```bash - printf '%s\n' 'SENTINEL_NAME=runner-host' 'SENTINEL_TARGET=runner' \ + printf '%s\n' 'SENTINEL_NAME=ultra' 'SENTINEL_TARGET=ultra' \ "SENTINEL_NTFY=https://ntfy.sh/fleet-$(openssl rand -hex 12)" > ~/.config/fleet-sentinel.env chmod 600 ~/.config/fleet-sentinel.env cat ~/.config/fleet-sentinel.env # note the topic URL @@ -51,7 +51,7 @@ another file. | Variable | Default | Meaning | |---|---|---| -| `SENTINEL_NAME` | `runner-host` | What the messages call the host | +| `SENTINEL_NAME` | the target | What the messages call the host | | `SENTINEL_TARGET` | `runner` | Tailnet name or IP to probe | | `SENTINEL_PROBE` | `tailscale` | `tailscale` (`tailscale ping` over the tailnet) or `tcp` (`nc -z 22`) | | `SENTINEL_TAILSCALE` | `/opt/homebrew/opt/tailscale/bin/tailscale` | The `tailscale` binary | diff --git a/docs/cockpit/settings.md b/docs/cockpit/settings.md index d97410e..b14b6dd 100644 --- a/docs/cockpit/settings.md +++ b/docs/cockpit/settings.md @@ -12,7 +12,7 @@ new build. | Setting | Default | Meaning | |---|---|---| | Reach the dashboard by | SSH tunnel | **SSH tunnel**, **Direct URL** or **Fixture (demo)** | -| SSH aliases, in order | `runner-host, runner-ts` | Aliases from `~/.ssh/config`, comma- or space-separated, tried in turn | +| SSH aliases, in order | `ultra, ultra-ts` | Aliases from `~/.ssh/config`, comma- or space-separated, tried in turn | | Dashboard port on the host | `7878` | Where the dashboard listens on the host's loopback | | Dashboard URL | `http://127.0.0.1:7878` | For Direct URL: a dashboard served over Tailscale Serve or bound to the LAN | | Scenario | `live` | For Fixture: which recorded state to show | diff --git a/docs/cockpit/troubleshooting.md b/docs/cockpit/troubleshooting.md index fed1f95..3d7fc64 100644 --- a/docs/cockpit/troubleshooting.md +++ b/docs/cockpit/troubleshooting.md @@ -8,7 +8,7 @@ first, then the handbook's [Troubleshooting](../troubleshooting.md). Run the connection by hand, exactly as the app does: ```bash -ssh -o BatchMode=yes runner-host 'curl -s -o /dev/null -w "%{http_code}\n" 127.0.0.1:7878/api/health' +ssh -o BatchMode=yes ultra 'curl -s -o /dev/null -w "%{http_code}\n" 127.0.0.1:7878/api/health' ``` | You see | Cause | Fix | diff --git a/docs/federation.md b/docs/federation.md index 26b312a..46d7abb 100644 --- a/docs/federation.md +++ b/docs/federation.md @@ -4,6 +4,15 @@ Federation lets the dashboard coordinate runners across several Macs. Each extra Mac runs a lightweight agent (`agent.js`) that reports outbound to the coordinator — no inbound port is needed on agent hosts. +!!! note "Current topology (2026-10-21)" + One Mac, `ultra`, is both the coordinator (fleetd, replica id `ultra`) and + the only runner host, with `FLEET_TIERS_MODE=off`. The previous runner host + was retired after [moving the coordinator](#moving-the-coordinator-to-another-mac). + Fleet Cockpit reaches the dashboard through the ssh aliases `ultra`, then + `ultra-ts`; a different or extra coordinator is a setting there, not a new + build. To add a runner host, follow + [Adding a second runner host](#adding-a-second-runner-host). + ## Architecture ![Two agent Macs above a coordinator Mac and a browser. Each agent runs agent.js and its own runners and opens no inbound port; its only connection is an outbound heartbeat every 30 seconds, with commands riding back in the reply. The second agent has been silent for six minutes and is marked stale, its runners greyed rather than removed. The coordinator runs fleetd, the dashboard and its own runners. Side notes say each host computes its own headroom, and that a coordinator may drain, resume, switch off and health-check an agent's runners, but removing one needs that host's own opt-in](img/federation.png) diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index faed329..2bdb20b 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -1205,9 +1205,9 @@ Source: [`dashboard/scripts/settle-stale-jobs.mjs`](https://github.com/addisdev/ ```bash cd dashboard -ssh runner-host "/usr/bin/sqlite3 -json ~/actions-runners/dashboard/fleet.db \"$(node scripts/settle-stale-jobs.mjs query)\"" > stale.json +ssh ultra "/usr/bin/sqlite3 -json ~/actions-runners/dashboard/fleet.db \"$(node scripts/settle-stale-jobs.mjs query)\"" > stale.json node scripts/settle-stale-jobs.mjs fetch --in stale.json --out fetched.ndjson -node scripts/settle-stale-jobs.mjs sql --in fetched.ndjson | ssh runner-host /usr/bin/sqlite3 ~/actions-runners/dashboard/fleet.db +node scripts/settle-stale-jobs.mjs sql --in fetched.ndjson | ssh ultra /usr/bin/sqlite3 ~/actions-runners/dashboard/fleet.db ``` ### `dashboard/scripts/mirror-plan.mjs` diff --git a/docs/tools/shoot-motion.mjs b/docs/tools/shoot-motion.mjs index c5afeb0..1f05d4b 100644 --- a/docs/tools/shoot-motion.mjs +++ b/docs/tools/shoot-motion.mjs @@ -84,7 +84,7 @@ const opt = (name, fallback = null) => { return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : fallback; }; -const SSH_HOST = opt('host', process.env.MOTION_SSH_HOST ?? 'runner-host'); +const SSH_HOST = opt('host', process.env.MOTION_SSH_HOST ?? 'ultra'); const RUNNER_DIR = opt('runner', process.env.MOTION_RUNNER ?? null); const FLEET_PORT = Number(opt('fleet-port', process.env.MOTION_FLEET_PORT ?? 7878)); const SERVE_ONLY = flag('serve'); diff --git a/scripts/host-sentinel.sh b/scripts/host-sentinel.sh index efefbf0..f289255 100755 --- a/scripts/host-sentinel.sh +++ b/scripts/host-sentinel.sh @@ -15,8 +15,8 @@ # # Configuration lives in ~/.config/fleet-sentinel.env (mode 0600), sourced here: # -# SENTINEL_NAME=runner-host # what the messages call the host -# SENTINEL_TARGET=runner # tailnet name or IP to probe +# SENTINEL_NAME=ultra # what the messages call the host (default: the target) +# SENTINEL_TARGET=ultra # tailnet name or IP to probe # SENTINEL_PROBE=tailscale # tailscale (ping over the tailnet) | tcp (nc -z host 22) # SENTINEL_TAILSCALE=/opt/homebrew/opt/tailscale/bin/tailscale # SENTINEL_TS_SOCKET= # --socket for a userspace tailscaled, if any @@ -33,8 +33,8 @@ ENV_FILE="${SENTINEL_ENV:-$HOME/.config/fleet-sentinel.env}" # shellcheck source=/dev/null [ -f "$ENV_FILE" ] && . "$ENV_FILE" -NAME="${SENTINEL_NAME:-runner-host}" TARGET="${SENTINEL_TARGET:-runner}" +NAME="${SENTINEL_NAME:-$TARGET}" PROBE="${SENTINEL_PROBE:-tailscale}" TS_BIN="${SENTINEL_TAILSCALE:-/opt/homebrew/opt/tailscale/bin/tailscale}" TS_SOCKET="${SENTINEL_TS_SOCKET:-}" From a02df3accbb20fe7dcaedbe6b9f48ee8955a3c69 Mon Sep 17 00:00:00 2001 From: Taylor Addison Date: Wed, 21 Oct 2026 12:12:36 -0400 Subject: [PATCH 2/2] CLI tries its own route when the app is not connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app keeps writing glance.json while it retries a route, so a running app still pointed at the retired runner-host made `cockpit status` answer "Collector stalled · via app (runner-host)" from a 20-minute-old glance, although ultra answered at once over the CLI's own tunnel. A fresh snapshot is now taken as-is only when the app is connected (live or collector-stale); otherwise the CLI opens its own route and falls back to the app's view (which may carry an out-of-band "host down") only when that fails too. Co-Authored-By: Claude Opus 5.5 --- cockpit/Sources/CockpitCore/SnapshotFile.swift | 8 ++++++++ cockpit/Sources/cockpit/main.swift | 10 ++++++++-- cockpit/Tests/CockpitCoreTests/CoreTests.swift | 13 +++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/cockpit/Sources/CockpitCore/SnapshotFile.swift b/cockpit/Sources/CockpitCore/SnapshotFile.swift index 0433503..2836923 100644 --- a/cockpit/Sources/CockpitCore/SnapshotFile.swift +++ b/cockpit/Sources/CockpitCore/SnapshotFile.swift @@ -18,6 +18,14 @@ public struct SnapshotFile: Codable, Sendable, Equatable { self.verdict = verdict; self.glance = glance } + /// True when the app was talking to a dashboard as it wrote this, not + /// connecting or retrying a route that may no longer exist (an alias for a + /// retired host keeps the snapshot fresh while its glance goes stale). + public var isConnected: Bool { + connection == String(describing: ConnectionState.live) + || connection == String(describing: ConnectionState.collectorStale) + } + public static var defaultURL: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("FleetCockpit", isDirectory: true) diff --git a/cockpit/Sources/cockpit/main.swift b/cockpit/Sources/cockpit/main.swift index 326fce9..f3f3df6 100644 --- a/cockpit/Sources/cockpit/main.swift +++ b/cockpit/Sources/cockpit/main.swift @@ -125,9 +125,14 @@ func obtain(_ o: Options) async -> Source { return Source(glance: g, verdict: g.verdict, route: "fixture: \(name)") } catch { fail("no fixture named \(name) (try: cockpit fixtures)") } } - if !o.fresh, o.url == nil, o.via.isEmpty, let snap = try? SnapshotFile.read(), snap.ageMs() < 90_000 { - return Source(glance: snap.glance, verdict: trusted(snap.verdict, snap.glance), route: "app (\(snap.route ?? "?"), \(Format.ago(snap.writtenAt, now: Format.nowMs())))") + // The app's view when it is fresh. If the app is not connected right now, + // try our own route first and keep its view (which may carry an + // out-of-band "host down") as the answer only when ours fails too. + let snap = (!o.fresh && o.url == nil && o.via.isEmpty) ? (try? SnapshotFile.read()).flatMap { $0.ageMs() < 90_000 ? $0 : nil } : nil + func fromApp(_ s: SnapshotFile) -> Source { + Source(glance: s.glance, verdict: trusted(s.verdict, s.glance), route: "app (\(s.route ?? "?"), \(Format.ago(s.writtenAt, now: Format.nowMs())))") } + if let snap, snap.isConnected { return fromApp(snap) } let transport: Transport = o.url.map { DirectTransport(url: $0) } ?? TunnelTransport(aliases: o.via.isEmpty ? defaultAliases : o.via) do { @@ -137,6 +142,7 @@ func obtain(_ o: Options) async -> Source { return Source(glance: g, verdict: trusted(g.verdict, g), route: route.label, client: client, transport: transport) } catch { transport.close() + if let snap { return fromApp(snap) } let v = Verdict(id: "unreachable", tone: .unknown, title: "Dashboard unreachable", sentence: "Could not reach the dashboard: \(error)", evidence: [], next: nil, rung: nil, open: []) return Source(glance: nil, verdict: v, route: "none") diff --git a/cockpit/Tests/CockpitCoreTests/CoreTests.swift b/cockpit/Tests/CockpitCoreTests/CoreTests.swift index 4b0a5c4..aa9e32a 100644 --- a/cockpit/Tests/CockpitCoreTests/CoreTests.swift +++ b/cockpit/Tests/CockpitCoreTests/CoreTests.swift @@ -223,6 +223,19 @@ struct SnapshotFileTests { let mode = try FileManager.default.attributesOfItem(atPath: url.path)[.posixPermissions] as? Int #expect(mode == 0o600) } + + /// The app writes its connection state with String(describing:); a + /// snapshot from an app retrying a dead route must not read as connected. + @Test func connectedOnlyWhileTheAppTalksToADashboard() throws { + let g = try Fixtures.glance("live") + func snap(_ c: ConnectionState) -> SnapshotFile { + SnapshotFile(writtenAt: 1000, route: "ultra", connection: String(describing: c), verdict: g.verdict, glance: g) + } + #expect(snap(.live).isConnected) + #expect(snap(.collectorStale).isConnected) + #expect(!snap(.connecting).isConnected) + #expect(!snap(.reconnecting(attempt: 26, error: "No route to the dashboard (tried runner-host, runner-ts)")).isConnected) + } } @Suite("transport and client against a stub daemon", .serialized)