diff --git a/.gitignore b/.gitignore index ea8c4bf7..cc2e6ce5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.worktrees/ diff --git a/docs/superpowers/plans/2026-10-24-tines-fleet-console.md b/docs/superpowers/plans/2026-10-24-tines-fleet-console.md new file mode 100644 index 00000000..ba877b16 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-tines-fleet-console.md @@ -0,0 +1,435 @@ +# tines Fleet Console Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A desktop console (`tines-gui`) that operates tines nodes in two containerized modes — a Bluetooth container talking to real robots, and a simulated fleet of containers exchanging frames over a new IPC-bus transport — with the service IPC widened to expose the full node surface. + +**Architecture:** Three new pieces plus widenings. `tines-bus` (workspace member): a `Transport` impl over a host Unix-socket bus daemon. `tines-gui` (standalone crate outside the workspace, like `fuzz/`): egui/eframe launcher + one process per container, speaking the service IPC and driving docker via CLI. The `tines` service gains a transport choice and a widened IPC (addressed observe, contact/lost/dropped events, store listing). Core gains exactly two additive `NodeEvent` variants. + +**Tech Stack:** Rust 1.99.0 (pinned), tines-core (no_std, untouched except Task 1), bluer/BlueZ, std Unix sockets, egui/eframe 0.36 (glow, wayland), docker CLI subprocess. + +**Spec:** `docs/superpowers/specs/2026-10-24-tines-fleet-console-design.md` — the plan argues from the spec, so the spec travels with it; executors read both. + +## Global Constraints + +- Repo rules (AGENTS.md): TDD (failing test → fail → implement → pass → commit), conventional commits, `sh scripts/ci.sh` green before every commit, specs/plans are read-only history, README tracks code, THREAT_MODEL.md updated when a change touches a finding. +- Toolchain: `1.99.0`, edition 2021, workspace resolver 2; `thumbv7em-none-eabihf` target installed. +- `tines-core` stays `no_std`, no async/threads/I/O, no panic on untrusted input; the ONLY core change in this work is Task 1 (two additive `NodeEvent` variants, enum is `#[non_exhaustive]`). +- Wire constants frozen: `MAX_FRAME=1500`, `WIRE_VERSION=4`; the bus carries core wire frames verbatim inside a bus-local envelope (no wire-format change). +- Service IPC: length-prefixed (u32 LE) JSON, 1 MiB cap, topics/payloads/ids as lowercase hex on the wire; malformed input → `Error` response, never a panic. Service `Config` keeps `deny_unknown_fields`. +- The `Node` is never in the GUI process; the GUI only speaks IPC to service processes. +- `tines-gui` is a standalone crate at repo root (NOT a workspace member; precedent: `fuzz/`). `tines-bus` IS a workspace member. +- eframe features exactly `glow`, `default_fonts`, `wayland`; `vsync = false`; opaque window painted with the omarchy theme (`~/.local/state/omarchy/current/theme/colors.toml`, keys `background`/`foreground`/`accent`, directory watch via `notify` — copy the mechanism from `~/src/aleph-keyring/crates/aleph-gui`). +- Docker is driven as a CLI subprocess (`docker` binary); no docker SDK. +- New public names in `tines-core` (Task 1) are called out in the commit message and AGENTS.md. + +## Review Focus + +The five failure modes most likely to bite a person using this software, with the test that pins each, owned by the task that owns the code: + +1. **A bus client lies about who it is** (frame's declared `from` ≠ its registered NodeId) — the receiving transport must drop the frame (bt's F6 rule), and the daemon must drop/close on a routing-header mismatch. → Task 3 tests. +2. **`observe` with `to` but no `lifetime_ms`** (or vice versa) — must be an `Error`, not a half-addressed observation. → Task 2 tests. +3. **A huge store breaks `List`** — the response must cap at `LIST_CAP` entries and set `truncated: true`, never exceed the 1 MiB frame cap. → Task 2 tests. +4. **A config that pairs the wrong transport with the wrong keys** (bt without `peer`, bus without `bus_socket`, unknown key) — startup error, no silent fallback. → Task 4 tests. +5. **BLE reconnect storms** (bt re-fires `Contact` on every reconnection) — `NodeEvent::Contact` fires only for a newly-added peer, so the console feed isn't flooded; `Lost` fires only for a peer that was present. → Task 1 tests. + +--- + +### Task 1: Core — `NodeEvent::Contact` / `NodeEvent::Lost` + +**Files:** +- Modify: `tines-core/src/exchange.rs` (the `NodeEvent` enum, `on_event`, `add_peer`/`remove_peer` call sites) +- Test: tests in `tines-core/src/exchange.rs`'s `#[cfg(test)]` module + +**Interfaces:** +- Produces: `NodeEvent::Contact { addr: A }` and `NodeEvent::Lost { addr: A }` — additive variants on the `#[non_exhaustive]` enum. Semantics: emitted from `Exchange::on_event` when a transport `Event::Contact(addr)` adds a NEW peer (repeat `Contact` for a known peer restarts its idle clock and emits nothing), and when `Event::Lost(addr)` removes a peer that was present (`Lost` for an unknown addr emits nothing). Idle eviction (`evict_idle`, F8) stays silent. The variant carries the transport `Addr` (core never maps addr→NodeId); for both bt and bus the addr IS the `NodeId`. +- Consumes: nothing new; `NodeEvent` already derives `Clone, Copy, Debug, PartialEq`. + +- [ ] **Step 1: Write the failing tests** in `tines-core/src/exchange.rs`: + +`contact_for_new_peer_emits_event` — `on_event(Event::Contact(addr))` then `drain_events()` contains exactly `Contact { addr }`; a second `on_event(Event::Contact(addr))` for the same addr drains empty. +`lost_for_known_peer_emits_event` — `Contact` then `Lost(addr)` drains `Lost { addr }`; `Lost` for a never-seen addr drains empty. +`idle_eviction_emits_nothing` — drive `next_frame` with `now` past `PEER_IDLE_MS` so the peer is evicted; `drain_events()` is empty. +`contact_event_rides_the_drop_oldest_queue` — fill the event queue past `EVENT_QUEUE_CAP` with `Delivered`-style pushes, then a `Contact`; the drained oldest event is gone, the `Contact` survives (heap-safe drop-oldest behavior, no panic). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p tines-core contact_for_new_peer_emits_event lost_for_known_peer_emits_event idle_eviction_emits_nothing contact_event_rides_the_drop_oldest_queue` +Expected: compile errors (variants don't exist) / assertion failures. + +- [ ] **Step 3: Implement** — add the two variants to `NodeEvent` with the doc comments from the spec (decision C); in `on_event`, replace the `Contact`/`Lost` arms to push into `self.events` with the drop-oldest pattern already used by `Delivered` (`let _ = self.events.push(...)`), gated on new-peer/known-peer as above. Check peer presence via the peers table before `add_peer`/`remove_peer`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p tines-core && sh scripts/check-no-std.sh && cargo clippy -p tines-core --all-targets -- -D warnings` +Expected: all green; the thumbv7em no_std build still passes. + +- [ ] **Step 5: Commit** + +```bash +git add tines-core/src/exchange.rs +git commit -m "feat(core): emit Contact/Lost NodeEvents (decision C)" +``` + +--- + +### Task 2: Service IPC widening + +**Files:** +- Modify: `tines/src/ipc.rs` (Request/Response enums, new out types, `handle`) +- Test: `tines/tests/ipc.rs` (existing `MockTransport` already surfaces one `Contact`) + +**Interfaces:** +- Consumes: Task 1's `NodeEvent::Contact/Lost`; `Node::observe_to(to: NodeId, lifetime_ms: u64, topic, payload, observed_at, now) -> Result`; `Store::for_each_id` + `Store::get(id, now)`. +- Produces (all new variants are additive; the existing four operations keep their exact wire shape): + - `Request::Observe { topic, payload, to: Option, lifetime_ms: Option }` — `to` and `lifetime_ms` both set → `observe_to`; both unset → `observe`; exactly one set → `Response::Error { message: "to and lifetime_ms must be set together" }`. + - `Request::List` → `Response::List { observations: Vec, truncated: bool }` with `LIST_CAP = 1024` entries max; `truncated` is `true` when more exist. `ObservationOut { id: String, observer: String, topic: String, payload: String, to: Option, lifetime_ms: Option, age_ms: u64, observed_at: Option }` (all hex where binary; `observed_at` from the stored observation). + - `Response::Events` gains two fields (existing `events` field unchanged): `contacts: Vec` and `drops: Vec`. `ContactOut { peer: String, joined: bool }` (hex addr; `joined: false` = lost). `DropOut { peer: Option, reason: String, count: u32 }` (`None` = overflow slot). Drops now surface here AND on stderr (stderr line stays). + - `Status` unchanged. + +- [ ] **Step 1: Write the failing tests** in `tines/tests/ipc.rs`: + +`observe_addressed_roundtrip` — `Observe { to, lifetime_ms }` → `Response::Observe { id }`; then `List` shows that observation with matching `to`/`lifetime_ms`. +`observe_half_addressed_is_error` — `to` set, `lifetime_ms` none → `Error`; and vice versa. +`list_returns_all_observations` — observe one broadcast + one addressed → `List` has both, fields correct, `truncated` false. +`list_caps_at_list_cap` — observe `LIST_CAP + 1` small observations in a loop → `List` has exactly `LIST_CAP` entries and `truncated: true`. +`events_include_contacts` — fresh `serve` with the mock (its first poll surfaces `Contact`) → `Events` response has `contacts == [ContactOut { peer: , joined: true }]`. +`events_include_lost_and_drops` — push `Event::Lost(peer)` into the mock's pending queue and inject a garbage frame; `Events` reports `contacts` with `joined: false` and a `drops` entry with the right `reason`. +`existing_events_field_unchanged` — the Delivered path (mock auto-acks) still fills `events` exactly as before. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p tines --test ipc observe_addressed_roundtrip list_returns_all_observations events_include_contacts` +Expected: FAIL (unknown fields/variants). + +- [ ] **Step 3: Implement** — extend the enums and add the `handle` arms in `tines/src/ipc.rs`; encode the drop path (`NodeEvent::Dropped`) into `DropOut` instead of stderr-only; keep the existing `_ => None` arm for future variants. Reuse the existing `encode_hex`/`decode_hex`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p tines && cargo clippy -p tines --all-targets -- -D warnings` +Expected: PASS; existing ipc tests still green (wire compat for the four old ops). + +- [ ] **Step 5: Commit** + +```bash +git add tines/src/ipc.rs tines/tests/ipc.rs +git commit -m "feat(host): widen service IPC — addressed observe, List, contact/drop events" +``` + +--- + +### Task 3: `tines-bus` crate — bus transport + daemon + +**Files:** +- Create: `tines-bus/Cargo.toml` (deps: `tines-core = { path = "../tines-core", version = "1" }`; std only, no serde), `tines-bus/src/lib.rs`, `tines-bus/src/transport.rs`, `tines-bus/src/daemon.rs`, `tines-bus/src/bin/tines-busd.rs` +- Test: `tines-bus/tests/transport.rs`, `tines-bus/tests/daemon.rs` +- Modify: `Cargo.toml` (workspace members += `tines-bus`) + +**Interfaces:** +- Consumes: `tines_core::transport::{Event, FrameBuf, SendError, Transport}`, `tines_core::frame_sender`, `tines_core::consts::MAX_FRAME`, `NodeId`. +- Produces: + - `pub trait BusLink` (the narrow I/O seam, mirroring bt's `Radio`): `fn poll(&mut self) -> Option; fn send(&mut self, to: &NodeId, data: &[u8]) -> Result<(), BusError>; fn mtu(&self) -> usize;` with `BusEvent::{ Joined(NodeId), Left(NodeId), Frame(NodeId, FrameBuf), Closed }` and `pub enum BusError { Io(std::io::Error), Unreachable }`. The real link (daemon client) lives in the same crate, thread-driven, NOT part of the pure logic. + - `pub struct BusTransport` — `pub fn new(me: NodeId, allowlist: Option, socket: impl AsRef) -> std::io::Result`; `impl Transport for BusTransport { type Addr = NodeId; ... }`. Poll maps `Joined`/`Left` → `Event::Contact`/`Event::Lost`, `Frame` → `Event::Frame` after the spoofing check (`frame_sender(frame) == peer`), `Closed` → `Event::Lost` for every bound peer. Frames/events from a non-allowlisted peer are dropped (allowlist `None` = any peer). `send` to a non-allowlisted or unknown peer → `SendError::Unreachable`; outbound frames must declare `me` as sender. + - `pub fn daemon::serve(socket: impl AsRef) -> std::io::Result<()>` — bind (removing a stale socket file first), accept, run the protocol below. Binary `tines-busd` takes the socket path as argv[1]. + - **Bus envelope (pinned, bus-local — the core frame inside is untouched):** every message is `[u32 LE length][payload]`, length counting payload bytes only. + - Hello (client→daemon, first message): payload = 16-byte `NodeId` (len 16). A second hello for an already-registered id replaces the old connection (old socket closed). + - Data (client→daemon): payload = `[16B from][16B to][frame]`. Daemon drops (and counts on stderr) any message whose `from` ≠ that socket's registered id, or whose `to` is not registered; otherwise forwards the envelope verbatim to `to`'s socket. Frames longer than `MAX_FRAME` close the connection. + - Control (daemon→client): payload = `[1B kind][16B NodeId]` (len 17); kind `0 = joined`, `1 = left`; sent to every other registered client on join, and to all remaining on leave. EOF/error from a client's socket = leave + close, daemon stays up. +- The daemon is single-threaded `poll`-based over nonblocking sockets; per-connection state = registered id (`Option`), read buffer. No frame content is decoded by the daemon beyond the routing header. + +- [ ] **Step 1: Write the failing tests** + +`tines-bus/tests/transport.rs` (spawn `daemon::serve` on a thread with a tempfile dir socket, then drive pure `BusTransport`s; two nodes, allowlist `None`): +- `two_transports_exchange_frames` — A.send(to=B, frame declaring `from`=A) → B.poll yields `Event::Frame(A, bytes)` with identical bytes. +- `spoofed_frame_is_dropped` — B's link delivers a frame declaring `from`=C (not B's bound peer); B.poll does not yield it. +- `join_and_leave_become_contact_and_lost` — A connects first; when B connects, A.poll yields `Event::Contact(B)`; when B drops, A yields `Event::Lost(B)`. +- `allowlist_filters` — A with allowlist `Some(B)`: frames/join events for C never surface; `send` to C → `SendError::Unreachable`. + +`tines-bus/tests/daemon.rs` (drive raw sockets against the daemon): +- `duplicate_hello_replaces` — second connection helloing an already-registered id kills the first (its read returns EOF). +- `mismatched_from_is_dropped` — a socket registered as A sends a data envelope with `from`=B: the addressed peer receives nothing; the daemon remains alive for the next message. +- `unknown_dest_is_dropped` — data to an unregistered id: dropped, sender stays connected. +- `oversized_frame_closes_connection` — a data envelope with a frame larger than `MAX_FRAME`: daemon closes that connection only. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p tines-bus` +Expected: FAIL (crate does not exist). + +- [ ] **Step 3: Implement** the crate: pure `BusTransport` logic over the `BusLink` trait in `transport.rs`; the daemon and its real socket link in `daemon.rs`; the thin `tines-busd` bin. Add `tines-bus` to the workspace members. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p tines-bus && cargo clippy -p tines-bus --all-targets -- -D warnings && cargo fmt --all -- --check` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Cargo.toml Cargo.lock tines-bus/ +git commit -m "feat(bus): tines-bus transport and daemon over a host IPC bus" +``` + +--- + +### Task 4: Service transport choice + config moves to the lib + +**Files:** +- Modify: `tines/Cargo.toml` (add `tines-bus` path dep), `tines/src/main.rs` (config parse + node construction), `tines/src/lib.rs` (new modules) +- Create: `tines/src/config.rs` (the `Config`/`TrustedClock`/`TransportKind` types, moved from `main.rs` so clients can parse generated configs), `tines/src/transport.rs` (`HostTransport`) +- Test: config tests in `tines/src/config.rs` (moved), new integration test `tines/tests/service_bus.rs` + +**Interfaces:** +- Consumes: Task 3's `BusTransport`; `BtTransport` as today. +- Produces: + - `tines::config::Config` (pub, `deny_unknown_fields`): existing keys unchanged except `peer: Option`; new keys `transport: Option` (default `bt`; `TransportKind` serde-rename lowercase: `bt` | `bus`) and `bus_socket: Option`. + - Validation (pub fn on `Config`, called by `main` before anything else): `transport = bt` + `peer = None` → error `"transport bt requires peer"`; `transport = bus` + `bus_socket = None` → error `"transport bus requires bus_socket"`; bus + `peer = Some(..)` = allowlist of one, bus + `None` = any peer. + - `tines::transport::HostTransport` — `pub enum HostTransport { Bt(BtTransport), Bus(BusTransport) }` with `impl Transport for HostTransport { type Addr = NodeId; ... }` delegating to the inner transport. + - `main.rs` builds `HostTransport` per config (bt: `BluerRadio::new(me, peer)` + `BtTransport::new(me, peer, radio)`; bus: `BusTransport::new(me, peer, &config.bus_socket)`) and hands it to the unchanged `serve`. Everything else in `main` is untouched. + +- [ ] **Step 1: Write the failing tests** + +Config tests (moved + new, in `tines/src/config.rs`): existing parse tests keep passing (minimal bt config, `trusted_clock`, unknown field, peer-time window); new: `bus_config_parses` (transport "bus", bus_socket, no peer), `bt_without_peer_is_invalid`, `bus_without_socket_is_invalid`, `unknown_transport_is_rejected`, `transport_defaults_to_bt`. + +`tines/tests/service_bus.rs` (the sim-fleet deliverable, no docker): spawn the real `tines-busd` on a tempdir socket; spawn two real `tines` service processes (`env!("CARGO_BIN_EXE_tines")`) with generated bus configs (`trusted_clock: "system"`); connect each socket with the length-prefixed framing; `Observe` on node A; poll `Belief` on node B until it sees the payload (deadline ~5 s); `List` on B contains it; `Events` on both eventually reports `Contact { joined: true }`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p tines --test service_bus` +Expected: FAIL (no `service_bus.rs`, config types not in lib). + +- [ ] **Step 3: Implement** — move `Config`/`TrustedClock` into `tines/src/config.rs` (pub, re-export from `lib.rs`), add `TransportKind` + validation, add `HostTransport` in `tines/src/transport.rs`, rewire `main.rs` (`tines::config::Config`, the validation call, `HostTransport` construction). `peer` becomes `Option`; the bt branch errors via validation. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p tines && cargo clippy -p tines --all-targets -- -D warnings` +Expected: PASS — including `service_bus.rs` (two real services converge over the bus) and all pre-existing ipc tests. + +- [ ] **Step 5: Commit** + +```bash +git add tines/ +git commit -m "feat(host): service transport choice (bt|bus) and lib-level config" +``` + +--- + +### Task 5: Runtime docker image + +**Files:** +- Create: `Dockerfile.tines` +- Modify: `.dockerignore` (verify it excludes `target/`, `.git/`, `tines-gui/`) + +**Interfaces:** +- Produces: image tag `tines-stack:latest`, built with `docker build -f Dockerfile.tines -t tines-stack:latest .`. Two stages: + - build: `FROM ubuntu:24.04`; apt: `build-essential pkg-config cmake curl ca-certificates libclang-dev clang libdbus-1-dev libudev-dev`; rustup minimal (profile minimal, default host); `WORKDIR /workspace`; `COPY Cargo.toml Cargo.lock ./` then each workspace crate dir (`tines-core`, `tines-store`, `tines-bt`, `tines-bus`, `tines-sim`, `tines-ros2`, `tines` — every member's Cargo.toml must be present for cargo to resolve); `RUN cargo build --release -p tines -p tines-bus`. + - runtime: `FROM ubuntu:24.04`; apt: `libdbus-1-3 libudev1 bluez dbus`; `COPY --from=build` the two release binaries to `/usr/local/bin/`; `ENTRYPOINT ["tines"]` (config path comes as the container arg; no default `CMD`). +- The same image serves both modes; BT mode's `--privileged`/host-network/D-Bus flags are runtime args in Task 7. + +- [ ] **Step 1: Write the failing verification** — nothing to test in Rust; the check is the build + smoke below. + +- [ ] **Step 2: Verify it fails** + +Run: `docker build -f Dockerfile.tines -t tines-stack:latest .` +Expected: FAIL (no Dockerfile). + +- [ ] **Step 3: Implement** the Dockerfile as pinned above. + +- [ ] **Step 4: Verify it passes** + +Run: `docker build -f Dockerfile.tines -t tines-stack:latest .` then smoke: `docker run --rm -d --name tines-smoke tines-stack:latest /data/config.json` against a host-written bt config in a mounted dir → `sleep 2; docker logs tines-smoke` contains `tines: listening on`; then `docker stop tines-smoke`. +Expected: build succeeds; smoke log line appears. (The radio-less bt service must still start and serve IPC — no adapter is not fatal.) + +- [ ] **Step 5: Commit** + +```bash +git add Dockerfile.tines +git commit -m "feat(docker): runtime image for the tines service" +``` + +--- + +### Task 6: `tines-gui` skeleton — IPC client, config generation, theme + +**Files:** +- Create: `tines-gui/Cargo.toml` (deps: `tines = { path = "../tines" }`, `eframe = { version = "0.36", default-features = false, features = ["glow", "default_fonts", "wayland"] }`, `egui = "0.36"`, `serde`, `serde_json`, `notify = "8"`; dev-deps: `egui_kittest = "0.36"` with the `wgpu` feature, `tempfile`), `tines-gui/src/main.rs`, `tines-gui/src/ipc_client.rs`, `tines-gui/src/config.rs`, `tines-gui/src/theme.rs` +- Test: `tines-gui/tests/ipc_client.rs`, `tines-gui/tests/config.rs` +- Modify: `scripts/ci.sh` (append gui checks mirroring the fuzz lines: `cargo fmt --manifest-path tines-gui/Cargo.toml -- --check`, `cargo clippy --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings`, `cargo test --manifest-path tines-gui/Cargo.toml`) + +**Interfaces:** +- Consumes: `tines::{Request, Response}` and `tines::config::Config` (Task 2/4); the service's wire framing (u32 LE length + JSON). +- Produces: + - `tines_gui::ipc_client::IpcClient` — `pub fn spawn(socket: PathBuf) -> io::Result` (connects on a worker thread; queues requests); `pub fn request(&self, req: Request) -> Result` (encodes + queues, returns a monotonically increasing message id); `pub fn try_recv(&self) -> Option<(u64, Response)>` (nonblocking); `pub fn reconnect(&self)` (re-dials; drops nothing). `pub enum ClientError { Io(io::Error), Json(serde_json::Error) }`. Request→response correlation by id; responses arrive out of order safely (client tags each frame with the id — **client-side framing**: `[u32 LE len][u32 LE msg_id][Request JSON]`, response `[u32 LE len][u32 LE msg_id][Response JSON]`; the msg_id sits inside the length-prefixed body, so the service-side protocol is untouched and the client keeps its own correlation). Timeouts are the GUI's business (the client only reports what arrives). + - `tines_gui::config::{fleet_dir, node_dir, generate_node_id, NodeConfig}` — `pub fn fleet_dir(fleet: &str) -> PathBuf` = `$XDG_STATE_HOME|~/.local/state` + `tines-console/fleets/` (created `0700`); `pub fn node_dir(fleet: &str, node: &str) -> PathBuf`; `pub fn generate_node_id() -> String` (32 lowercase hex from 16 bytes of `/dev/urandom`); `pub struct NodeConfig { node_id: String, peer: Option, log_path: String, socket_path: String, transport: String, bus_socket: Option, trusted_clock: Option }` serializing to EXACTLY the service `Config` schema (unknown keys are fatal on the service side). `write(fleet, node) -> io::Result` writes `/config.json`. + - `tines_gui::theme::OmarchyTheme` — the aleph-keyring mechanism: load `~/.local/state/omarchy/current/theme/colors.toml` keys `background`/`foreground`/`accent`, `notify` watch on the `current` directory, repaint on change. + +- [ ] **Step 1: Write the failing tests** + +`tines-gui/tests/config.rs` — `generated_config_parses_as_service_config`: build a `NodeConfig` for bt and for bus, serialize, and deserialize each with `serde_json` into `tines::config::Config` (proves the schema matches the service exactly); `node_id_is_32_hex_chars`; `fleet_dir_is_0700`. + +`tines-gui/tests/ipc_client.rs` — a test-local fake server (std `UnixListener`) speaking the service framing: `client_roundtrips_observe` (server decodes `Request::Observe`, replies `Response::Observe`; client's `try_recv` yields the matching id); `client_survives_server_restart` (kill listener, `reconnect`, next request round-trips). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --manifest-path tines-gui/Cargo.toml` +Expected: FAIL (crate does not exist). + +- [ ] **Step 3: Implement** the three modules + the crate manifest. `main.rs` for now: parse args, if none print `"tines-gui: launcher"` and exit 0 (Task 8 wires the real UI). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --manifest-path tines-gui/Cargo.toml && cargo clippy --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings && cargo fmt --manifest-path tines-gui/Cargo.toml -- --check` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tines-gui/ scripts/ci.sh +git commit -m "feat(gui): tines-gui skeleton — IPC client, config generation, omarchy theme" +``` + +--- + +### Task 7: Docker orchestration + bus-daemon supervision + +**Files:** +- Create: `tines-gui/src/docker.rs`, `tines-gui/src/fleet.rs` +- Test: `tines-gui/tests/docker.rs` (command-building only — no real docker), `tines-gui/tests/fleet.rs` + +**Interfaces:** +- Consumes: Task 6's `config` module; `tines-busd` binary on PATH (built in Task 3). +- Produces: + - `tines_gui::docker::Docker` — `pub fn image() -> &'static str` = `"tines-stack:latest"`; `pub fn build_image(&self) -> Result<(), DockerError>` (`docker build -f Dockerfile.tines -t tines-stack:latest .` from the repo root); `pub fn start_bt(&self, name: &str, fleet: &str) -> Result` — flags pinned: `docker run -d --name tines-bt- --privileged --net=host -v /run/dbus/system_bus_socket:/run/dbus/system_bus_socket -v /:/data tines-stack:latest tines /data/config.json`; `pub fn start_sim(&self, name: &str, fleet: &str)` — same minus privileged/net-host/dbus, name `tines-sim-`; `pub fn stop(&self, id: &ContainerId)`; `pub fn remove(&self, id: &ContainerId)`; `pub fn logs(&self, id: &ContainerId, tail: u32) -> Result`; `pub fn is_running(&self, id: &ContainerId) -> bool` (docker inspect exit status). `pub struct ContainerId(pub String);` `pub enum DockerError { NotInstalled, Spawn(io::Error), Exit { code: i32, stderr: String } }`. All commands run via `std::process::Command`, args as pinned Vecs (never shell-interpolated). + - `tines_gui::fleet::Fleet` — `pub fn create(fleet: &str) -> io::Result` (mkdir 0700 via Task 6 `fleet_dir`; spawn `tines-busd /bus.sock` as a child, stdout/stderr inherited); `pub fn bus_socket(&self) -> PathBuf`; `pub fn add_node(&self, name: &str, node_id: Option<&str>, mode: Mode, peer: Option<&str>) -> io::Result` (writes the config JSON; bus mode sets `bus_socket` = `/data/bus.sock` — the in-container path, since the fleet dir is mounted at `/data`; log/socket paths `/data//log`, `/data//sock`); `pub fn stop_bus(&mut self)` (kill child). `pub enum Mode { Bt, Sim }`; `pub struct NodeSpec { pub name: String, pub dir: PathBuf }`. On drop without `stop_bus`, the daemon child is killed. + +- [ ] **Step 1: Write the failing tests** + +`tines-gui/tests/docker.rs` — the exact command vectors: `start_bt_args_match_pinned_flags`, `start_sim_args_match_pinned_flags` (assert the full argv), `stop_and_remove_args`. +`tines-gui/tests/fleet.rs` — `fleet_dir_layout` (dirs exist, bus.sock parent exists, daemon child spawned), `add_node_bus_config_mounts_data` (config JSON: `transport == "bus"`, `bus_socket == "/data/bus.sock"`, `socket_path == "/data//sock"`, and it parses as `tines::config::Config`), `add_node_bt_config_has_peer`, `generated_id_roundtrips`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --manifest-path tines-gui/Cargo.toml` +Expected: FAIL (modules do not exist). + +- [ ] **Step 3: Implement** `docker.rs` and `fleet.rs` exactly as pinned. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --manifest-path tines-gui/Cargo.toml && cargo clippy --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tines-gui/ +git commit -m "feat(gui): docker orchestration and bus-daemon supervision" +``` + +--- + +### Task 8a: Launcher UI + +**Files:** +- Create: `tines-gui/src/launcher.rs` +- Modify: `tines-gui/src/main.rs` (arg dispatch: no args → launcher; `--node ` → node window (Task 8b)) +- Test: `tines-gui/tests/launcher.rs` (egui_kittest) + +**Interfaces:** +- Consumes: Tasks 6/7 (`Fleet`, `Docker`, `config`, theme). +- Produces: `pub struct Launcher { fleet: Option, docker: Docker, containers: Vec, children: Vec, actions: VecDeque }` with `pub struct ContainerEntry { name: String, id: ContainerId, mode: Mode }` and `pub enum LauncherAction { StartContainer { name: String, mode: Mode }, StopContainer(ContainerId), RemoveContainer(ContainerId), OpenWindow { name: String, dir: PathBuf, id: ContainerId, mode: Mode }, BuildImage }`. `pub fn update(&mut self, ctx: &egui::Context)` renders: mode picker (Bt / Sim), fleet name field + create, node list (name, mode, running state), per-node Open/Stop/Remove buttons, image build button, log tail pane for the selected container. State changes and subprocess spawns happen ONLY by draining `actions` in `main.rs` (keeps the UI testable headless). + +- [ ] **Step 1: Write the failing tests** (`egui_kittest`, no real docker/daemon — inject a `Fleet`-less state and assert on `actions`): + +`create_fleet_action_queues_spawn` — type a fleet name, click Create → `actions` contains `StartContainer`-preceding spawn intent (assert via the fleet field set + action drain helper). +`open_window_queues_spawn` — click a node's Open → `actions` contains `OpenWindow` with the right name/dir. +`stop_queues_stop_action` — click Stop → `StopContainer` with the right id. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --manifest-path tines-gui/Cargo.toml --test launcher` +Expected: FAIL. + +- [ ] **Step 3: Implement** `launcher.rs` + the `main.rs` dispatch + action drain (spawn children with `--node`; build image on `BuildImage`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --manifest-path tines-gui/Cargo.toml --test launcher && cargo clippy --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tines-gui/ +git commit -m "feat(gui): launcher — fleet creation, container controls, window spawn" +``` + +--- + +### Task 8b: Node window UI + +**Files:** +- Create: `tines-gui/src/window.rs` +- Modify: `tines-gui/src/main.rs` (the `--node` arm builds the window app) +- Test: `tines-gui/tests/window.rs` (egui_kittest) + +**Interfaces:** +- Consumes: Task 6's `IpcClient`, Task 2's widened protocol, Task 8a's arg dispatch. +- Produces: `pub struct NodeWin { client: IpcClient, name: String, mode: Mode, transport: TransportKind, status: Option, beliefs: VecDeque, feed: VecDeque, store_rows: Vec, last_id: Option }` with `pub enum FeedEntry { Contact { peer: String, joined: bool }, Delivered { peer: String, id: String }, Dropped { peer: Option, reason: String, count: u32 } }`. `pub fn update(&mut self, ctx: &egui::Context)` renders the spec's layout: status strip (node id from `Status`, next_seq, stored, transport from the launcher-passed arg — NOT from the wire), observe panel (topic, payload, optional recipient + lifetime, text-with-hex-toggle), belief panel, store browser (from `List`), events feed, container panel (`docker logs` via `Docker`, stop/remove buttons). All I/O goes through the `IpcClient`; the UI thread never blocks (requests queue, responses drain each frame; `Events` polled every 500 ms). BT windows show the single-peer hint from the spec. +- `eframe` app wiring follows the wayland-gui skill: `NativeOptions { vsync: false, ... }`, `with_transparent(false)`, `OmarchyTheme` colors painted every frame. + +- [ ] **Step 1: Write the failing tests** (egui_kittest against `NodeWin::update` with the fake server from Task 6's test helpers moved into a shared test util): + +`status_strip_shows_node_id` — after the fake server answers `Status`, the strip text contains the hex id. +`observe_button_queues_observe` — type topic/payload, click Send → the client queue carries `Request::Observe` with hex-encoded topic/payload and no `to`. +`addressed_fields_queue_observe_to` — fill recipient + lifetime → `Request::Observe` carries `to`/`lifetime_ms`. +`feed_shows_contact` — fake server answers `Events` with a contact → feed contains the peer hex. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --manifest-path tines-gui/Cargo.toml --test window` +Expected: FAIL. + +- [ ] **Step 3: Implement** `window.rs` and the `--node` arm. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --manifest-path tines-gui/Cargo.toml --test window && cargo clippy --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 5: Manual verification** — `cargo run --manifest-path tines-gui/Cargo.toml`; build the image; create a 2-node sim fleet; confirm both windows show contact events, an observation typed in one appears in the other's store and beliefs; then stop and remove the fleet. Screenshot the window and match chrome to the desktop theme (wayland-gui skill). + +- [ ] **Step 6: Commit** + +```bash +git add tines-gui/ +git commit -m "feat(gui): node window — observe, beliefs, store, events, container panel" +``` + +--- + +### Task 9: Docs and housekeeping + +**Files:** +- Modify: `AGENTS.md` (crate layout: add `tines-bus`; note `tines-gui` as a standalone local tool like `fuzz/`; note decision C in the core section), `README.md` (console + bus transport sections tracking the code), `CHANGELOG.md` (Unreleased: bus transport, widened IPC, transport choice, console), `THREAT_MODEL.md` (F3: bus drops the single-peer limit as a test medium; new entries: bus daemon as a host attack surface — 0700 fleet dir, routing-header checks; IPC widening — new request kinds keep the Error posture) + +**Interfaces:** none — prose only, tracking code that exists. + +- [ ] **Step 1:** Write the doc edits (nothing to test). +- [ ] **Step 2:** Run the full gate: `sh scripts/ci.sh` — everything green, including the new gui lines. +- [ ] **Step 3: Commit** + +```bash +git add AGENTS.md README.md CHANGELOG.md THREAT_MODEL.md +git commit -m "docs: fleet console, bus transport, widened IPC" +``` + +--- + +## Final Verification (before considering the work done) + +- `sh scripts/ci.sh` fully green (workspace + gui + fuzz lines). +- `cargo test --manifest-path tines-gui/Cargo.toml` green. +- Manual run from Task 8b Step 5 completed: sim fleet of 2 converges, contacts visible, addressed observe visible, BT mode smoke (container starts, window opens, radio optional). +- `docker build -f Dockerfile.tines -t tines-stack:latest .` green. +- Design doc + plan committed with the code; CHANGELOG entry present. diff --git a/docs/superpowers/specs/2026-10-24-tines-fleet-console-design.md b/docs/superpowers/specs/2026-10-24-tines-fleet-console-design.md new file mode 100644 index 00000000..d98cadd8 --- /dev/null +++ b/docs/superpowers/specs/2026-10-24-tines-fleet-console-design.md @@ -0,0 +1,230 @@ +# tines fleet console + simulated fleet — design + +Date: 2026-10-24 +Status: approved by Kyle (2026-10-24) — decision C: add +`NodeEvent::Contact`/`NodeEvent::Lost` to core (see "The how") + +## Intent + +A desktop GUI (new `tines-gui` crate) that operates tines nodes in two +containerized modes: + +1. **Bluetooth**: a container with the host Bluetooth adapter passed + through runs the full robot stack; its node talks to real tines + robots over BLE. +2. **Simulated fleet**: containers on the host run the full stack with a + new IPC-bus transport (new `tines-bus` crate); they exchange tines + frames with each other over a host bus. + +The GUI is a console: a launcher screen plus one window per container. +Each window exposes the whole node control surface — observe (including +addressed), belief, store listing, events (contacts, drops, confirmed +deliveries), status — so tines can be exercised without hand-rolled JSON. + +## Why + +- Testing tines today means hand-crafting length-prefixed JSON to a Unix + socket; contacts and custody can only be watched in stderr. +- The two-device BLE check (`tines-bt/README.md`) has never been + recorded; a BT container plus a console makes it a routine manual test. +- The only exchange transports are BLE (real radio) and tines-sim's + in-process memory transport; nothing lets separate processes exchange + frames. A simulated fleet on one machine needs a host IPC transport. +- No runtime container image exists (`Dockerfile.ros2` is a CI + build-check). +- Kyle wants to test tines functionality and interact with tines-enabled + robots (hexapod on a Jetson running ROS2, Pi-class robots). + +## Goals + +- G1. `tines-gui`: an egui/eframe Wayland app following this desktop's + conventions (the `wayland-gui` skill): a launcher, plus one GUI process + per container. +- G2. BT mode: a containerized full stack with host Bluetooth passthrough; + the container's node is a real radio peer for robots in range. +- G3. Sim mode: a new `tines-bus` host transport crate over a host IPC + bus; containers plug in and exchange frames; fleet size is + operator-chosen. +- G4. The `tines` service config gains a transport choice (`bt` default, + `bus`); existing config files keep working. +- G5. Widened service IPC: addressed observe (`to` + `lifetime_ms` via + `Node::observe_to`), contact/lost events, dropped-frame events, and a + store listing; the existing four operations are unchanged. +- G6. GUI windows expose every node operation; the docker lifecycle + (create, start, stop, remove, logs) is managed from the GUI. +- G7. A runtime container image in the repo (new Dockerfile); both modes + run from the same image with different runtime flags. + +## Non-goals + +- `tines-core` changes limited to one additive pair (decision C, approved + 2026-10-24): `NodeEvent::Contact`/`NodeEvent::Lost` variants, so the + console's event feed can show live contacts. Nothing else in core + changes: it stays `no_std`, and the 1.0 branch is unaffected (the enum + is non-exhaustive). The bus is a host crate implementing the existing + `Transport` trait; the IPC widening is in the `tines` service crate. +- No `tines-sim` changes: the simulator remains the protocol test + harness; the simulated fleet runs real services over the bus transport. +- No ROS2 integration this round: containers run the service (Pi-class + load). `tines-ros2` is untouched; a ROS2-backed container is future + work. +- Not a fleet deployment tool: the console is for development, testing + and interacting; systemd units and production deployment stay out of + scope. +- No node in the GUI process: the `Node` is not `Send` and the GUI never + hosts one; it only speaks IPC to containerized services. + +## Anti-goals + +- No weakening of the trust boundary: the widened IPC keeps the existing + posture — malformed input gets an `Error` response, never a panic; + frame caps stay; hex-on-the-wire stays. +- No silent config breakage: `transport` defaults to `bt`; existing + configs parse unchanged; unknown keys are still rejected. +- No radio in the GUI process; no BLE access outside the BT container. +- No X11: eframe features `glow`/`default_fonts`/`wayland` only. +- No docker SDK dependency: docker is driven as a subprocess (CLI). + +## Constraints + +- AGENTS.md applies unchanged: spec-first, TDD with + `sh scripts/ci.sh` before commit, conventional commits. New crates go + in the workspace and the AGENTS.md crate layout. +- The `Node` is not `Send`; the service's inline poll architecture is + unchanged. `tines-bus` is a `Transport` implementation and must not + require `Send` of the node (same as `BtTransport`). +- The bus carries the core's wire frames (`MAX_FRAME` = 1500 and friends + are unchanged). +- Docker is available on this desktop; the GUI shells out to it. +- The console follows `wayland-gui` conventions: theme watch on + `~/.local/state/omarchy/current`, `vsync = false`, opaque window + painted with the theme background. + +## The how (decided with Kyle) + +### Core change (decision C, approved 2026-10-24) + +Add `NodeEvent::Contact` / `NodeEvent::Lost` variants to core, emitted +from the exchange's `on_event` when the transport reports a contact +starting or ending. The enum is non-exhaustive and the variants ride the +existing drop-oldest event queue, so this is additive and changes no +wire format. This is the only core change in this work. + +### Modes + +- **Launcher**: two choices — Bluetooth (real robots) and Simulated + fleet. From it: create containers (name them, choose or generate node + ids), see the running fleet, open any node's window. +- **One GUI process per container** (Kyle: "one GUI instance per + container"): the launcher spawns a child process of the same binary in + node-window mode, so a hung container can't freeze the fleet view. +- **BT mode**: `docker run` privileged, host network, D-Bus access + (bluer/BlueZ needs the system bus and the hci device). Service config: + node id, peer (the service still takes one — F3 remainder; the GUI + exposes exactly what the service supports), log path on a named + volume, socket path on a mounted directory. The GUI connects to the + socket on the host filesystem. +- **Sim mode**: the launcher starts a bus daemon on the host, then N + containers, each running the service with the bus transport and the + socket directory mounted. GUI windows drive each node. + +- **Bus access control** (wntrmute's call): the bus daemon's socket lives + in a per-fleet directory under the operator's XDG state directory, + created `0700`. Same-user local processes are already inside the trust + boundary (THREAT_MODEL); the bus adds no new posture. +- **Status strip transport** (wntrmute's call): the launcher knows the + transport because it wrote the container's config; it passes it to the + window process. `Status` on the wire is unchanged. +- **Addressed panel in BT mode** (wntrmute's call): the panel stays in + every window — `observe_to` is a valid node operation whatever the + transport. BT windows show a hint that the transport currently + exchanges with exactly one configured peer, so an addressed + observation to anyone else will sit until its lifetime expires. + +### The bus transport (`tines-bus`, new crate) + +- Implements `Transport` with `Addr = NodeId`, exactly like `tines-bt`: + frames are the core wire frames; a frame's declared sender is checked + against the bound peer (same address-spoofing rule). +- **Topology** (wntrmute's call; Kyle can override): hub-and-spoke. A + small bus daemon (a bin in the crate) binds a Unix socket in a + per-fleet directory on the host; each node connects once. The bus fans + every frame out to all connected nodes — a shared medium, like radio — + and reports joins/leaves so the transport can emit `Contact`/`Lost`. +- **Peers**: the bus transport enforces core's `MAX_PEERS` (8), as + tines-bt does; an empty allowlist means any bus peer (the bus is a + test medium, so it drops bt's single-peer F3 limit). +- **Framing**: length-prefixed core frames over the socket; no new wire + format. +- **Contact model**: any frame from a bus peer is an implicit contact + (core already treats unknown addresses that way); daemon disconnect → + `Lost` for every peer. + +### Service changes (`tines` crate) + +- **Config**: new optional `transport` key, `"bt"` (default) or `"bus"`; + with `bus`, a `bus_socket` path key. `peer` stays required for `bt`; + for `bus` it is an optional allowlist (empty = any peer). +- **IPC widening** (backward-compatible: new tagged variants; the + existing four operations are untouched): + - `Observe` gains optional `to` (hex `NodeId`) and `lifetime_ms` + (present together or not at all) → `Node::observe_to`; without them, + the existing `observe` path. + - `Events` also returns contact/lost events (peer hex + kind) and + dropped-frame events (reason + count), alongside confirmed + deliveries. This changes the current D3 surfacing (drops to stderr + only) so the console can show them; the stderr line stays. + - New `List` request → the stored observations (id, observer, topic, + payload, addressed destination, age), capped so a response fits the + 1 MiB frame cap. + - `Status` unchanged. +- Trusted-time behavior is unchanged; the console shows service stderr + via `docker logs`. + +### The GUI (`tines-gui`, new crate) + +- **Window layout**: status strip (node id, next seq, stored count, + transport, socket path); observe panel (topic, payload, optional + recipient + lifetime, broadcast default; text mode with a hex toggle — + the wire is hex, the operator should not have to hex-encode by hand); + belief panel (query a topic; list known topics from `List`); store + browser (`List` view with refresh); events feed (contacts, lost, + delivered, dropped, live); container panel (`docker logs` tail, + stop/remove/restart). +- **Config generation**: the launcher writes each container's service + config JSON (matching the `deny_unknown_fields` schema), generates + random node ids unless the operator supplies them, and manages + volume/socket paths under a per-fleet state directory. +- **IPC client**: the GUI speaks the widened IPC through a client module + implementing the length-prefixed JSON framing. The request/response + types are public in the `tines` crate (lib + bin), so the GUI can + depend on the lib and share the types (builder's call, leaning yes). + +### Docker image + +- New `Dockerfile.tines`: builds the workspace, installs BlueZ runtime + dependencies; the entrypoint runs the `tines` service with a mounted + config. Both modes use this image; BT mode adds the privileged/host + network and D-Bus/hci plumbing at run time. + +## What's left to the builder + +- Exact JSON schemas for the new IPC variants (within the constraints + above). +- Bus daemon protocol details (framing, join/leave messages, fan-out). +- egui layout specifics and the theme integration. +- Docker run flags per mode (device passthrough, volume layout, image + build/tag flow). +- Launcher supervision of child processes and daemons (PID tracking, + cleanup on exit). + +## Success looks like + +- Sim mode: N containers exchange over the bus; broadcast observations + converge in every node; an addressed observation with custody relay is + watchable in the events feeds. +- BT mode: the container's node exchanges with a real tines robot over + BLE — the two-device check from `tines-bt/README.md` is finally run + and recorded from the console. +- Every operation in the widened IPC is reachable from a window without + hand-rolled JSON.