From fb6ac9255ec162c00badaaa3fc013fdbd3f72aa2 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 00:29:52 -0700 Subject: [PATCH 01/39] docs: design local operator dashboard --- .../2026-08-10-operator-dashboard-design.md | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-operator-dashboard-design.md diff --git a/docs/superpowers/specs/2026-08-10-operator-dashboard-design.md b/docs/superpowers/specs/2026-08-10-operator-dashboard-design.md new file mode 100644 index 0000000..ae9cd37 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-operator-dashboard-design.md @@ -0,0 +1,232 @@ +# Wire Operator Dashboard Design + +## Product vision + +Wire needs one local control surface for the sessions an operator owns. The +first release covers one machine and 10–20 concurrent agent sessions. It shows +only sessions backed by a live agent host, lets the operator link two sessions, +and creates a named Wire group from selected sessions. + +The dashboard is a topology tool. It does not display or send messages. It does +not expose historical identities, retire old sessions, or accept network +connections. A later release can add other operator-owned machines without +changing the local interaction model. + +## Confirmed product choices + +- Scope: one machine, owned by the current operating-system user. +- Inventory: live agent-host sessions only; daemon-only and historical homes are + hidden. +- Scale: 10–20 live sessions. +- Direct link: selecting two sessions creates a bilateral local pair after one + confirmation. No second peer-acceptance step is required on the same machine. +- Group: selecting two or more sessions creates one shared Wire group room. It + does not create a pairwise mesh. +- Surface: browser UI bound to `127.0.0.1` and launched by a Wire command. +- MVP actions: inspect, link two, and create a group. Messaging and retirement + remain outside this build. +- Layout: compact operations list with row selection and an action bar. +- Visual direction: Wire's Open Band system—paper, burgundy frame, green dial, + phosphor status, serif headings, and monospace operational labels. + +## Root-cause repair before dashboard work + +The live Codex process exposes `CODEX_THREAD_ID`. Wire 0.17.0 originally read +`CODEX_SESSION_ID` but ignored the current variable, so MCP fell through to a +machine-default identity. Commit `eaca903` on `main` adds the current Codex +adapter. The installed binary must be rebuilt from the dashboard branch so the +fix reaches the runtime that launches MCP servers. + +Goose 1.45.0 injects `AGENT_SESSION_ID` into standard-input/output extensions +and Developer shell commands. Wire will use that key only when `AGENT=goose`. +The guard matters because `AGENT_SESSION_ID` is a cross-agent convention, not a +Goose-specific name. Resolution precedence remains: + +1. `WIRE_SESSION_ID` +2. `CLAUDE_CODE_SESSION_ID` +3. `CODEX_SESSION_ID` +4. `CODEX_THREAD_ID` +5. guarded `AGENT_SESSION_ID` when `AGENT=goose` +6. existing Copilot and VS Code adapters +7. existing Claude PID-file fallback + +The runtime repair will: + +1. Build and install the branch binary through the repository's normal install + path. +2. Restart or reconnect the active MCP host so its process loads the new binary + and resolves the current thread identity. +3. Read supervisor state and process ownership. +4. Stop only daemon or monitor processes proven to be unmanaged manual starts + for the same Wire home. The supervisor and its per-session children stay + intact. +5. Verify one session identity across host environment, MCP `wire_whoami`, + daemon state, and dashboard inventory. + +No wildcard process kill is permitted. + +## Architecture + +`wire dash --web` extends the existing dashboard command. It starts an Axum +server on `127.0.0.1` with a kernel-selected port, prints the complete URL, and +opens the default browser unless `--no-open` is set. Axum already ships in the +Wire dependency graph. + +The server and terminal dashboard share one inventory producer. The browser +does not invoke shell commands or parse terminal output. Mutation routes call +Rust functions that also remain available to command-line callers. + +The first release uses server-owned HTML, CSS, and a small JavaScript file +embedded in the Wire binary. It adds no Node runtime, package manager, browser +framework, or persistent web service. + +### Components + +1. **Session inventory** + - Reads registered session homes through the existing session registry. + - Joins persona, project directory, lifecycle lease, runtime-role PID files, + peer state, and health state. + - Includes a row only when a non-expired lifecycle lease belongs to a live + agent-host process. + - Produces a stable JSON shape used by the terminal renderer and web API. + +2. **Local topology operations** + - `link_local_sessions(a, b)` validates two distinct live sessions owned by + the current user, then materializes the bilateral local pair in both homes. + - `create_local_group(name, creator, members)` validates the creator and at + least one other selected live session, creates the signed group in the + creator home, and materializes the group into each member home. + - Both operations return a verified result. A partial write returns an error + that names completed and failed homes; the UI never reports success from an + attempted call alone. + +3. **Loopback web server** + - Serves the Open Band operations list and JSON mutation routes. + - Binds only to `127.0.0.1`; no configurable network host ships in the MVP. + - Generates a random launch token and places it in the initial URL. The + browser sends it on every mutation request. + - Rejects missing or incorrect tokens before reading a mutation body. + +4. **Browser client** + - Polls inventory every two seconds. + - Preserves selection for rows that remain live and drops vanished rows. + - Enables **Link selected** for exactly two rows. + - Enables **Create group** for two or more rows. + - Uses a confirmation step for linking and a name/creator dialog for groups. + - Shows success only from the verified server response and leaves failures + visible until dismissed or retried. + +## Inventory and API contracts + +Each session row contains: + +- stable session-home identifier, never the raw host session key; +- DID, handle, persona emoji, and palette; +- agent host label; +- project directory; +- session start time and age; +- direct-link count; +- health summary; +- whether the row is eligible for local link and group actions. + +Routes: + +- `GET /` — embedded application shell. +- `GET /api/sessions` — current live-session inventory. +- `POST /api/links` — `{ "sessions": [a, b] }`. +- `POST /api/groups` — `{ "name": name, "creator": a, "members": [a, b, ...] }`. + +Mutation responses use one shape: + +```json +{ + "ok": true, + "message": "Linked cobalt-nettle and warmer-cedar", + "changed_sessions": ["session-a", "session-b"] +} +``` + +Errors set `ok` to `false`, return a non-2xx HTTP status, and include no secret, +relay slot token, raw session key, or private filesystem state. + +## Error handling + +- A session that exits between selection and confirmation yields `409 Conflict` + and a refreshed inventory. +- Repeating an existing link succeeds as an idempotent no-op. +- A duplicate group name follows the existing group-name rules and returns the + existing domain error without overwriting a group. +- Invalid selection cardinality returns `400 Bad Request`. +- Missing launch token returns `403 Forbidden` before mutation parsing. +- Failure in one member home stops further group materialization and reports the + exact completed/failed boundary. It does not claim group-wide success. +- Browser-open failure leaves the server running and prints the complete URL. + +## Security boundaries + +- Loopback bind is fixed for this release. +- Mutation requests require the random launch token and a JSON content type. +- Session identifiers are opaque home IDs; API payloads never accept arbitrary + paths. +- Server resolution maps every ID back through the registered-session inventory + before filesystem access. +- Same-machine automatic pairing relies on the existing operating-system user + and machine-fingerprint trust model. +- The dashboard cannot accept, reject, send, retire, stop, or delete. + +## Verification + +Deterministic checks: + +- Unit tests for guarded Goose resolution, precedence, placeholder rejection, + and distinct session-home mapping. +- Inventory tests proving live lifecycle leases appear while daemon-only, + expired, retired, and historical homes do not. +- Local-link tests proving both homes gain the same bilateral relationship and + repeated calls remain idempotent. +- Group tests proving one creator and selected members receive the same signed + room while unselected sessions remain unchanged. +- HTTP tests for token rejection, content-type rejection, invalid selection, + stale-session conflict, and successful mutations. +- `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, focused test + suites, then `cargo test`. +- Browser proof through the real `wire dash --web --no-open` server: rows render, + selection gates actions, link completes, group completes, refresh shows the + resulting topology, and console/network logs contain no errors. +- Installed-runtime proof: a Codex thread resolves from `CODEX_THREAD_ID`; a + Goose standard-input/output extension resolves from guarded + `AGENT_SESSION_ID`; MCP, daemon, and dashboard agree on identity. + +The baseline full suite has one observed parallel-only failure in +`os_notify::tests::toast_dedup_public_api_suppresses_repeat`; it passes alone. +Any completion claim must report whether that baseline flake recurs and must +show a green focused run for the test. + +## Success criteria + +1. The installed Wire binary resolves current Codex and Goose sessions to + stable, distinct Wire identities without machine-default fallback. +2. Supervisor diagnostics report bounded managed workers and identify no + unmanaged daemon serving the active session home. +3. `wire dash --web` opens an Open Band dashboard bound to `127.0.0.1`. +4. The main view shows only live agent-host sessions and remains usable with 20 + rows. +5. The operator can select two sessions and create a verified bilateral local + link. +6. The operator can select two or more sessions and create one shared Wire group + room without creating a full mesh. +7. The real browser path observes both mutations and the refreshed inventory. +8. Relevant deterministic checks pass, with baseline flake evidence reported. + +## Deferred work + +- Historical-session archive and retirement controls. +- Messaging and conversation views. +- Network exposure, authentication, and remote browser access. +- Operator-owned machine enrollment and federated inventory. +- Cross-machine linking or group creation from this dashboard. +- Topology graph and split list/map views. + +The structural next step after the local MVP is an operator-owned machine +registry that supplies the same inventory contract. It should replace, not +fork, the local inventory source. From 71de69fce40450948c2416d2c80e964a92a499fe Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 00:38:51 -0700 Subject: [PATCH 02/39] docs: plan local operator dashboard --- .../plans/2026-08-10-operator-dashboard.md | 660 ++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-operator-dashboard.md diff --git a/docs/superpowers/plans/2026-08-10-operator-dashboard.md b/docs/superpowers/plans/2026-08-10-operator-dashboard.md new file mode 100644 index 0000000..b1285f3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-operator-dashboard.md @@ -0,0 +1,660 @@ +# Wire Operator Dashboard 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:** Repair Codex and Goose session identity resolution, then ship a localhost Wire dashboard that lists live local agent sessions, links two, and creates one shared group room. + +**Architecture:** Extend the existing Rust binary. A new operator domain module reads active MCP lifecycle leases and runs existing Wire commands against explicit session homes without a shell. A small Axum server exposes that domain to embedded Open Band HTML, CSS, and JavaScript through token-guarded loopback routes. + +**Tech Stack:** Rust 2024, Axum 0.7, Tokio, Serde, existing Wire session/group/pairing primitives, embedded HTML/CSS/vanilla JavaScript. + +## Global Constraints + +- Bind only to `127.0.0.1`; do not ship a configurable network host. +- Show only sessions with a live `mcp` lifecycle lease. +- Support 10–20 live sessions without pagination. +- Link exactly two selected sessions through the existing local-sister bilateral path. +- Create one shared group room from two or more selected sessions; do not create a full mesh. +- Keep messaging, history, retirement, remote machines, and network exposure out of scope. +- Require a random launch token on every mutation request. +- Never expose raw host session keys, relay slot tokens, private keys, or arbitrary filesystem paths. +- Do not edit `add_local_sister_core`; GitNexus rates its upstream impact CRITICAL. +- Preserve unrelated `AGENTS.md` changes and all files in the original working tree. +- Baseline note: `os_notify::tests::toast_dedup_public_api_suppresses_repeat` failed once under the parallel suite and passed alone. + +--- + +### Task 1: Guarded Goose identity and lifecycle metadata + +**Files:** +- Modify: `src/session.rs` +- Modify: `src/session_lifecycle.rs` +- Test: `src/session.rs` +- Test: `src/session_lifecycle.rs` + +**Interfaces:** +- Consumes: host environment variables already read by `resolve_session_key()`. +- Produces: `resolve_session_key() -> Option<(String, &'static str)>` with source `goose`; `LeaseRecord.started_at: Option` and `LeaseRecord.cwd: Option` for live inventory. + +- [ ] **Step 1: Add a failing guarded-Goose resolver test** + +Add a serial environment test beside the Codex adapter test: + +```rust +#[test] +fn resolve_session_key_goose_adapter_is_guarded_and_ordered() { + let _guard = crate::config::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let names = [ + "WIRE_SESSION_ID", + "CLAUDE_CODE_SESSION_ID", + "CODEX_SESSION_ID", + "CODEX_THREAD_ID", + "AGENT", + "AGENT_SESSION_ID", + "COPILOT_AGENT_SESSION_ID", + "VSCODE_GIT_REPOSITORY_ROOT", + ]; + let previous: Vec<_> = names + .iter() + .map(|name| (*name, std::env::var_os(name))) + .collect(); + unsafe { + for name in names { + std::env::remove_var(name); + } + } + unsafe { + std::env::set_var("AGENT", "goose"); + std::env::set_var("AGENT_SESSION_ID", "20260810_7"); + } + assert_eq!( + resolve_session_key(), + Some(("20260810_7".into(), "goose")) + ); + unsafe { std::env::set_var("AGENT", "another-host") }; + assert!(!matches!(resolve_session_key(), Some((key, _)) if key == "20260810_7")); + unsafe { + std::env::set_var("AGENT", "goose"); + std::env::set_var("AGENT_SESSION_ID", "${UNEXPANDED}"); + } + assert!(!matches!(resolve_session_key(), Some((key, _)) if key.contains("${"))); + unsafe { + for (name, value) in previous { + std::env::remove_var(name); + if let Some(value) = value { + std::env::set_var(name, value); + } + } + } +} +``` + +Keep the test's save/restore list synchronized with every adapter variable read by `resolve_session_key()`. + +- [ ] **Step 2: Run the resolver test and prove it fails** + +Run: + +```bash +cargo test session::tests::resolve_session_key_goose_adapter_is_guarded_and_ordered -- --exact +``` + +Expected: FAIL because `resolve_session_key()` does not return source `goose`. + +- [ ] **Step 3: Implement guarded Goose resolution** + +Add this branch after `CODEX_THREAD_ID` and before Copilot: + +```rust +if std::env::var("AGENT").ok().as_deref() == Some("goose") + && let Ok(value) = std::env::var("AGENT_SESSION_ID") + && valid_session_key(&value) +{ + return Some((value.trim().to_string(), "goose")); +} +``` + +Update session-source documentation, startup warnings, and every isolated child-command environment scrub to remove `AGENT_SESSION_ID` and `AGENT` when `WIRE_HOME` is pinned. + +- [ ] **Step 4: Add failing lifecycle metadata tests** + +Extend the lease round-trip test: + +```rust +assert_eq!(leases[0].started_at.as_deref(), Some("2023-11-14T22:13:20Z")); +assert_eq!(leases[0].cwd.as_deref(), Some("/work/wire")); +``` + +Add a compatibility test that parses a lease JSON document without either field and expects both fields to be `None`. + +- [ ] **Step 5: Run lifecycle tests and prove they fail** + +Run: + +```bash +cargo test session_lifecycle::tests --lib +``` + +Expected: compile failure because `LeaseRecord` lacks the two fields. + +- [ ] **Step 6: Implement additive lease metadata** + +Add optional fields with Serde defaults: + +```rust +#[serde(default)] +pub started_at: Option, +#[serde(default)] +pub cwd: Option, +``` + +New leases set `started_at` to the acquisition time and `cwd` to `std::env::current_dir()` when available. Heartbeats preserve both values. Old leases remain readable. + +Extend `write_lease_at` with one final path argument and update its internal callers: + +```rust +pub fn write_lease_at( + home: &Path, + role: &str, + pid: u32, + now: OffsetDateTime, + ttl: Duration, + wire_version: &str, + bin_path: &Path, + session_source: &str, + cwd: Option<&Path>, +) -> Result +``` + +- [ ] **Step 7: Run focused identity and lifecycle checks** + +Run: + +```bash +cargo test session::tests::resolve_session_key_codex_cli_adapter_and_priority -- --exact +cargo test session::tests::resolve_session_key_goose_adapter_is_guarded_and_ordered -- --exact +cargo test session_lifecycle::tests --lib +cargo fmt --check +``` + +Expected: all PASS. + +- [ ] **Step 8: Run GitNexus change detection and commit** + +Run: + +```bash +git add src/session.rs src/session_lifecycle.rs +node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope staged --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard +git commit -m "fix: resolve Goose sessions by agent session id" +``` + +Expected: identity startup flows affected; no unrelated files staged. + +### Task 2: Live operator inventory + +**Files:** +- Create: `src/operator.rs` +- Modify: `src/lib.rs` +- Test: `src/operator.rs` + +**Interfaces:** +- Consumes: `session::list_sessions()`, `session_lifecycle::active_leases_at()`, `dash::read_peers()`, session daemon state, and retire markers. +- Produces: `collect_live_sessions() -> anyhow::Result` and opaque `LiveSession.id` values used by mutation routes. + +- [ ] **Step 1: Write failing inventory fixture tests** + +Define the public JSON types: + +```rust +#[derive(Clone, Debug, Serialize)] +pub struct LiveSession { + pub id: String, + pub handle: String, + pub did: String, + pub emoji: String, + pub primary_hex: String, + pub agent_host: String, + pub project_dir: Option, + pub started_at: Option, + pub age_seconds: Option, + pub direct_link_count: usize, + pub health: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LiveSessionReport { + pub schema: &'static str, + pub sessions: Vec, +} +``` + +Create temp homes for: live MCP lease, live daemon-only lease, expired MCP lease, retired MCP home, and live MCP lease with a dead PID. Assert only the first appears. + +- [ ] **Step 2: Run the inventory test and prove it fails** + +Run: + +```bash +cargo test operator::tests::inventory_includes_only_live_mcp_sessions -- --exact +``` + +Expected: compile failure because `operator` is absent. + +- [ ] **Step 3: Implement the inventory producer** + +Use a testable internal function: + +```rust +fn collect_live_from( + sessions: &[crate::session::SessionInfo], + now: time::OffsetDateTime, + is_alive: impl Fn(u32) -> bool + Copy, +) -> anyhow::Result +``` + +Rules: + +- require an initialized DID and handle; +- reject retired homes; +- require at least one active lease with `role == "mcp"`; +- derive `agent_host` from the newest MCP lease's `session_source`; +- derive project and start metadata from that lease, then fall back to `SessionInfo.cwd`; +- count direct peers with `dash::read_peers`; +- map daemon running and sync age to `healthy`, `sync-stale`, or `daemon-down`; +- sort by handle. + +The opaque ID is the registered session name/home key already returned by `list_sessions()`, never a raw host thread ID. + +- [ ] **Step 4: Add negative disclosure assertions** + +Serialize a report and assert it excludes: + +```rust +assert!(!json.contains("AGENT_SESSION_ID")); +assert!(!json.contains("slot_token")); +assert!(!json.contains("private.key")); +``` + +- [ ] **Step 5: Run inventory checks** + +Run: + +```bash +cargo test operator::tests --lib +cargo test dash::tests --lib +cargo fmt --check +``` + +Expected: all PASS; existing `wire dash --json` shape stays green. + +- [ ] **Step 6: Run GitNexus change detection and commit** + +Run: + +```bash +git add src/operator.rs src/lib.rs +node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope staged --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard +git commit -m "feat: collect live operator sessions" +``` + +Expected: new operator inventory plus module export only. + +### Task 3: Explicit-home link and group operations + +**Files:** +- Continue: `src/operator.rs` created in Task 2 +- Test: `src/operator.rs` +- Test: `tests/e2e_group.rs` + +**Interfaces:** +- Consumes: live opaque session IDs and the current Wire executable. +- Produces: `link_local_sessions(request) -> Result` and `create_local_group(request) -> Result`. + +- [ ] **Step 1: Write failing validation tests** + +Define request and result types: + +```rust +#[derive(Debug, Deserialize)] +pub struct LinkRequest { pub sessions: Vec } + +#[derive(Debug, Deserialize)] +pub struct GroupRequest { + pub name: String, + pub creator: String, + pub members: Vec, +} + +#[derive(Debug, Serialize)] +pub struct MutationResult { + pub ok: bool, + pub message: String, + pub changed_sessions: Vec, +} +``` + +Assert link rejects one, three, duplicate, unknown, retired, and non-live IDs. Assert group rejects an empty name, fewer than two distinct members, unknown creator, and creator absent from members. + +- [ ] **Step 2: Run validation tests and prove they fail** + +Run: + +```bash +cargo test operator::tests::link_validation_rejects_invalid_selection -- --exact +cargo test operator::tests::group_validation_rejects_invalid_selection -- --exact +``` + +Expected: compile failure because mutation functions are absent. + +- [ ] **Step 3: Implement one explicit-home command runner** + +The runner calls the current Wire executable directly, never `sh -c`: + +```rust +fn run_wire_at(home: &Path, args: &[&str]) -> anyhow::Result +``` + +Set `WIRE_HOME`, `WIRE_HOME_FORCE=1`, and `WIRE_QUIET_AUTOSESSION=1`. Remove every session adapter variable, including `AGENT`, `AGENT_SESSION_ID`, `CODEX_THREAD_ID`, and existing Claude/Codex/Copilot/VS Code names. Require a successful exit and parse one JSON value from stdout. Cap captured stdout and stderr at 256 KiB before including sanitized errors. + +- [ ] **Step 4: Implement bilateral link through the existing caller** + +Resolve both IDs from a fresh live inventory. Run from A's explicit home: + +```text +wire add --local-sister --json +``` + +Then read both homes' trust state and require `VERIFIED` in both directions. If already verified, return an idempotent success without launching a child. + +Do not modify `add_local_sister_core`. + +- [ ] **Step 5: Implement shared group materialization** + +From the creator home: + +```text +wire group create --json +wire group invite --json +``` + +For every other selected home: + +```text +wire group join --json +``` + +Verify `/config/wire/groups/.json` exists and parses for every selected member. Return the completed/failed boundary on error. Do not call local pairing and do not create a full mesh. + +- [ ] **Step 6: Add an end-to-end local topology test** + +Extend the existing hermetic group relay fixture to create three session homes, acquire live MCP leases, create a dashboard group, and assert: + +```rust +assert!(group_exists(&alice, &group_id)); +assert!(group_exists(&bob, &group_id)); +assert!(group_exists(&carol, &group_id)); +assert!(!directly_paired(&bob, &carol)); +``` + +Add a two-session link case that checks bilateral `VERIFIED` state. + +- [ ] **Step 7: Run topology checks** + +Run: + +```bash +cargo test operator::tests --lib +cargo test --test e2e_group +cargo test --test stress_within_system pair_all_local_mesh_pairs_every_sister_session_v0_6_0 -- --exact +cargo fmt --check +``` + +Expected: all PASS, including the untouched legacy pairing path. + +- [ ] **Step 8: Run GitNexus change detection and commit** + +Run: + +```bash +git add src/operator.rs tests/e2e_group.rs +node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope staged --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard +git commit -m "feat: add local topology operations" +``` + +Expected: operator and group test flows affected; CRITICAL pairing core unchanged. + +### Task 4: Loopback server and Open Band interface + +**Files:** +- Create: `src/operator_web.rs` +- Create: `assets/operator-dashboard.html` +- Create: `assets/operator-dashboard.css` +- Create: `assets/operator-dashboard.js` +- Modify: `src/lib.rs` +- Modify: `src/cli/mod.rs` +- Modify: `src/cli/dash.rs` +- Test: `src/operator_web.rs` +- Test: `tests/cli.rs` + +**Interfaces:** +- Consumes: `operator::collect_live_sessions`, `operator::link_local_sessions`, and `operator::create_local_group`. +- Produces: `serve(ServeOptions) -> anyhow::Result<()>`; CLI flags `wire dash --web --no-open`. + +- [ ] **Step 1: Write failing route-security tests** + +Build the router with a fixed test token and assert: + +```rust +assert_eq!(post_json("/api/links", None, body).status(), StatusCode::FORBIDDEN); +assert_eq!(post_json("/api/links", Some("wrong"), body).status(), StatusCode::FORBIDDEN); +assert_eq!(post_text("/api/links", "test-token", body).status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); +assert_eq!(get("/api/sessions").status(), StatusCode::OK); +``` + +- [ ] **Step 2: Run route tests and prove they fail** + +Run: + +```bash +cargo test operator_web::tests --lib +``` + +Expected: compile failure because `operator_web` is absent. + +- [ ] **Step 3: Implement server state and routes** + +Define: + +```rust +pub struct ServeOptions { pub open_browser: bool } + +struct AppState { + token: String, +} +``` + +Routes: + +- `GET /` embeds the three assets and injects no secret into logs; +- `GET /api/sessions` returns `LiveSessionReport`; +- `POST /api/links` and `POST /api/groups` require `application/json` and `X-Wire-Token`; +- domain validation errors map to 400, vanished live sessions to 409, token errors to 403, and internal failures to sanitized 500 responses. + +Bind with `TcpListener::bind((Ipv4Addr::LOCALHOST, 0))`. Print the complete tokenized URL before opening the browser. Browser-open failure prints a warning but leaves the server alive. + +- [ ] **Step 4: Add CLI flags and dispatch** + +Extend `Command::Dash` and `DashArgs`: + +```rust +#[arg(long, conflicts_with_all = ["watch", "json", "retire_idle"])] +web: bool, +#[arg(long, requires = "web")] +no_open: bool, +``` + +`cmd_dash` enters the Axum runtime only for `--web`; every existing terminal path stays unchanged. + +- [ ] **Step 5: Build the Open Band browser client** + +The HTML contains semantic table, empty, loading, error, confirmation, and group-dialog states. CSS uses existing Wire tokens: + +```css +:root { + --paper: #eee3ce; + --paper-shadow: #d9c8a7; + --ink: #241712; + --frame: #5b1a2e; + --frame-deep: #401020; + --dial: #8fb04a; + --phosphor: #7fffb0; + --phosphor-bg: #0b130d; +} +``` + +JavaScript reads the token from the initial query string, removes it from the visible URL with `history.replaceState`, polls every two seconds, preserves still-live selections, and sends the token only in the custom header. Buttons enforce exact selection cardinality before requests. + +- [ ] **Step 6: Add CLI and asset contract tests** + +Add tests that: + +- `wire dash --web --json` fails argument parsing; +- `wire dash --no-open` fails without `--web`; +- embedded HTML references both mutation actions and accessible dialog labels; +- JavaScript contains no remote URL and no `innerHTML` assignment from API data; +- the server reports a `127.0.0.1` URL. + +- [ ] **Step 7: Run server and CLI checks** + +Run: + +```bash +cargo test operator_web::tests --lib +cargo test --test cli dash +cargo test operator::tests --lib +cargo fmt --check +cargo clippy --all-targets -- -D warnings +``` + +Expected: all PASS. + +- [ ] **Step 8: Run GitNexus change detection and commit** + +Run: + +```bash +git add src/operator_web.rs src/lib.rs src/cli/mod.rs src/cli/dash.rs assets/operator-dashboard.html assets/operator-dashboard.css assets/operator-dashboard.js tests/cli.rs +node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope staged --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard +git commit -m "feat: add localhost operator dashboard" +``` + +Expected: dashboard CLI and new web flows only. + +### Task 5: Installed runtime, live browser proof, and evidence + +**Files:** +- Create: `SESSION_LOG_2026_08_10.md` + +**Interfaces:** +- Consumes: built `wire` binary, current Codex `CODEX_THREAD_ID`, a Goose STDIO extension environment, supervisor state, and the real browser. +- Produces: installed working binary, managed daemon topology, live dashboard proof, and persisted session evidence. + +- [ ] **Step 1: Run complete deterministic verification** + +Run: + +```bash +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test operator::tests --lib +cargo test operator_web::tests --lib +cargo test --test cli +cargo test --test e2e_group +cargo test +cargo test os_notify::tests::toast_dedup_public_api_suppresses_repeat -- --exact +``` + +Record pass/fail counts and whether the known parallel-only toast failure recurs. + +- [ ] **Step 2: Install through the repository path** + +Run the repository's documented local install command after inspecting `install.sh` for its exact binary destination. Verify: + +```bash +which wire +wire --version +``` + +The resolved binary must be the freshly built branch artifact or its installed copy. + +- [ ] **Step 3: Verify Codex and Goose identity resolution in situ** + +Run the installed binary under explicit representative host signals: + +```bash +CODEX_THREAD_ID="$CODEX_THREAD_ID" wire whoami --json +AGENT=goose AGENT_SESSION_ID=wire-goose-proof wire whoami --json +``` + +Verify source labels `codex-cli` and `goose`, distinct config homes, schema v3.2+, and suffixed DIDs. Do not print private keys or relay tokens in the session log. + +- [ ] **Step 4: Repair daemon topology without wildcard kills** + +Read `wire supervisor --json`, role PID files, parent PIDs, and each candidate's Wire home. Stop only processes that are all of: + +- daemon or monitor role; +- parent PID 1 or otherwise outside the supervisor tree; +- serving a home already owned by the managed supervisor or a machine-default manual start; +- not the active MCP server. + +Restart through the existing service manager. Verify the supervisor is alive, workers stay within its cap, and no unmanaged daemon serves the active home. + +- [ ] **Step 5: Run the real localhost dashboard** + +Start: + +```bash +wire dash --web --no-open +``` + +Capture the printed tokenized localhost URL without committing it. Drive the real page in Playwright: load, inspect console and failed requests, verify only live rows render, select two fixture sessions, link them, create a group from selected fixtures, refresh, and confirm topology changes. + +Use temporary session homes and a local-only relay for mutation proof; never pair or group unrelated real sessions during verification. + +- [ ] **Step 6: Run rendered-page audit** + +Check desktop and narrow widths, keyboard selection, focus visibility, dialog labels, loading/empty/error states, overflow, console errors, and failed network requests. Fix only defects that block the approved success criteria or accessibility floor. + +- [ ] **Step 7: Run independent semantic and AMANALAP reviews** + +Build the required review packet with goal, boundaries, success criteria, diff, named CLI/browser callers, exact verification, and assumptions. Run one fresh read-only semantic review through the build-loop reviewer. Send its findings through a separate AMANALAP cut review. Fix surviving BLOCKER/MAJOR findings, remove CUT work, and rerun affected checks. + +- [ ] **Step 8: Write the session log** + +Record: + +- root causes and why earlier `wire up` repairs targeted fallback identities; +- Codex and Goose adapter evidence; +- files changed and named callers; +- exact verification results; +- unmanaged processes stopped and whether recovery is possible; +- semantic-review findings and AMANALAP dispositions; +- deferred remote-machine registry and session retirement work. + +- [ ] **Step 9: Run final GitNexus check and commit** + +Run: + +```bash +git diff --check +git status --short +git add SESSION_LOG_2026_08_10.md +node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope staged --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard +node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope compare --base-ref main --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard +git commit -m "docs: record operator dashboard verification" +``` + +Expected: only approved feature files and evidence commits on `feat/operator-dashboard`; unrelated `AGENTS.md` remains uncommitted. From 279be8c8f847cbb0c87159ee4d2fdd1ab59b96c5 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 00:45:24 -0700 Subject: [PATCH 03/39] fix: resolve Goose sessions by agent session id --- src/cli/pairing.rs | 2 + src/cli/session.rs | 2 + src/session.rs | 119 ++++++++++++++++++++++++++++++++++++--- src/session_lifecycle.rs | 38 +++++++++++++ 4 files changed, 154 insertions(+), 7 deletions(-) diff --git a/src/cli/pairing.rs b/src/cli/pairing.rs index cf6190f..75ca5be 100644 --- a/src/cli/pairing.rs +++ b/src/cli/pairing.rs @@ -836,6 +836,8 @@ fn run_wire_for_session(home: &std::path::Path, args: &[&str], one_way: bool) -> .env_remove("CLAUDE_CODE_SESSION_ID") .env_remove("CODEX_SESSION_ID") .env_remove("CODEX_THREAD_ID") + .env_remove("AGENT") + .env_remove("AGENT_SESSION_ID") .env_remove("COPILOT_AGENT_SESSION_ID") .env_remove("VSCODE_GIT_REPOSITORY_ROOT"); if one_way { diff --git a/src/cli/session.rs b/src/cli/session.rs index f73f353..8c5e36a 100644 --- a/src/cli/session.rs +++ b/src/cli/session.rs @@ -1224,6 +1224,8 @@ fn drive_bilateral_pair( .env_remove("CLAUDE_CODE_SESSION_ID") .env_remove("CODEX_SESSION_ID") .env_remove("CODEX_THREAD_ID") + .env_remove("AGENT") + .env_remove("AGENT_SESSION_ID") .env_remove("COPILOT_AGENT_SESSION_ID") .env_remove("VSCODE_GIT_REPOSITORY_ROOT") .env_remove("RUST_LOG") diff --git a/src/session.rs b/src/session.rs index 4b5d56f..94e842e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -839,12 +839,13 @@ pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option { /// forward this older name. /// 4. `CODEX_THREAD_ID` — current OpenAI Codex runtime adapter. Stable /// per thread and inherited by tool subprocesses. -/// 5. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` / +/// 5. `AGENT_SESSION_ID` — Goose adapter, accepted only when `AGENT=goose`. +/// 6. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` / /// `copilot`) adapter. Set by the Copilot CLI host for every /// session; stable per conversation; UUID-shaped. -/// 6. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based +/// 7. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based /// identity (stable per workspace). -/// 7. `None` — caller falls back to legacy cwd-detect (bare CLI / +/// 8. `None` — caller falls back to legacy cwd-detect (bare CLI / /// pre-v0.13 hosts). Future host adapters slot in before this. /// /// Returns `(key, source-label)`. @@ -854,8 +855,6 @@ pub fn resolve_session_key() -> Option<(String, &'static str)> { ("CLAUDE_CODE_SESSION_ID", "claude-code"), ("CODEX_SESSION_ID", "codex-cli"), ("CODEX_THREAD_ID", "codex-cli"), - ("COPILOT_AGENT_SESSION_ID", "copilot-cli"), - ("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"), ] { if let Ok(v) = std::env::var(var) && valid_session_key(&v) @@ -863,6 +862,22 @@ pub fn resolve_session_key() -> Option<(String, &'static str)> { return Some((v.trim().to_string(), source)); } } + if std::env::var("AGENT").ok().as_deref() == Some("goose") + && let Ok(value) = std::env::var("AGENT_SESSION_ID") + && valid_session_key(&value) + { + return Some((value.trim().to_string(), "goose")); + } + for (var, source) in [ + ("COPILOT_AGENT_SESSION_ID", "copilot-cli"), + ("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"), + ] { + if let Ok(value) = std::env::var(var) + && valid_session_key(&value) + { + return Some((value.trim().to_string(), source)); + } + } // Claude Code adapter (host-agnostic fallback). On some platforms the MCP // server process does not inherit CLAUDE_CODE_SESSION_ID and the MCP // `initialize` handshake carries no session id, so the env checks above @@ -1551,7 +1566,7 @@ static SESSION_SOURCE: std::sync::OnceLock<&'static str> = std::sync::OnceLock:: /// The signal that won session/home resolution for this process. One of: /// `env:WIRE_HOME`, `env:WIRE_HOME_FORCE` (RFC-008 §C legacy-shape force), /// `override` (`WIRE_SESSION_ID`), `claude-code`, `claude-code-pidfile`, -/// `codex-cli`, `copilot-cli`, `vscode-workspace`, `minted`, +/// `codex-cli`, `goose`, `copilot-cli`, `vscode-workspace`, `minted`, /// `machine-default`, or `unknown` if adoption never ran. pub fn session_source() -> &'static str { SESSION_SOURCE.get().copied().unwrap_or("unknown") @@ -1591,7 +1606,7 @@ pub fn warn_if_unexpected_session_source(role: &str) { let strict = std::env::var("WIRE_STRICT_SESSION").is_ok_and(|v| !v.is_empty() && v != "0"); let message = format!( "wire {role}: session-source=`{source}` — the launcher did not pass a session-key \ - (WIRE_HOME / WIRE_SESSION_ID / CLAUDE_CODE_SESSION_ID), so this process is running \ + (WIRE_HOME / WIRE_SESSION_ID / host session id), so this process is running \ against the {kind} identity. If a sibling agent is serving the real session-key \ home, they will race the inbox cursor. Pass an explicit `WIRE_SESSION_ID=` or \ `WIRE_HOME=` to fix.", @@ -1868,6 +1883,8 @@ mod tests { let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID"); let prev_codex = std::env::var_os("CODEX_SESSION_ID"); let prev_codex_thread = std::env::var_os("CODEX_THREAD_ID"); + let prev_agent = std::env::var_os("AGENT"); + let prev_agent_session = std::env::var_os("AGENT_SESSION_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); // SAFETY: ENV_LOCK is held, serializing all env access. @@ -1876,6 +1893,8 @@ mod tests { std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); std::env::remove_var("CODEX_THREAD_ID"); + std::env::remove_var("AGENT"); + std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); } @@ -1933,6 +1952,8 @@ mod tests { std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); std::env::remove_var("CODEX_THREAD_ID"); + std::env::remove_var("AGENT"); + std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); if let Some(v) = prev_override { @@ -1947,6 +1968,12 @@ mod tests { if let Some(v) = prev_codex_thread { std::env::set_var("CODEX_THREAD_ID", v); } + if let Some(v) = prev_agent { + std::env::set_var("AGENT", v); + } + if let Some(v) = prev_agent_session { + std::env::set_var("AGENT_SESSION_ID", v); + } if let Some(v) = prev_copilot { std::env::set_var("COPILOT_AGENT_SESSION_ID", v); } @@ -1984,6 +2011,8 @@ mod tests { let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID"); let prev_codex = std::env::var_os("CODEX_SESSION_ID"); let prev_codex_thread = std::env::var_os("CODEX_THREAD_ID"); + let prev_agent = std::env::var_os("AGENT"); + let prev_agent_session = std::env::var_os("AGENT_SESSION_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); // SAFETY: ENV_LOCK is held, serializing all env access. @@ -1992,6 +2021,8 @@ mod tests { std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); std::env::remove_var("CODEX_THREAD_ID"); + std::env::remove_var("AGENT"); + std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); } @@ -2053,6 +2084,8 @@ mod tests { std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); std::env::remove_var("CODEX_THREAD_ID"); + std::env::remove_var("AGENT"); + std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); if let Some(v) = prev_override { @@ -2067,6 +2100,12 @@ mod tests { if let Some(v) = prev_codex_thread { std::env::set_var("CODEX_THREAD_ID", v); } + if let Some(v) = prev_agent { + std::env::set_var("AGENT", v); + } + if let Some(v) = prev_agent_session { + std::env::set_var("AGENT_SESSION_ID", v); + } if let Some(v) = prev_copilot { std::env::set_var("COPILOT_AGENT_SESSION_ID", v); } @@ -2100,6 +2139,8 @@ mod tests { let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID"); let prev_codex = std::env::var_os("CODEX_SESSION_ID"); let prev_codex_thread = std::env::var_os("CODEX_THREAD_ID"); + let prev_agent = std::env::var_os("AGENT"); + let prev_agent_session = std::env::var_os("AGENT_SESSION_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); // SAFETY: ENV_LOCK is held, serializing all env access. @@ -2108,6 +2149,8 @@ mod tests { std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); std::env::remove_var("CODEX_THREAD_ID"); + std::env::remove_var("AGENT"); + std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); } @@ -2195,6 +2238,8 @@ mod tests { std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); std::env::remove_var("CODEX_THREAD_ID"); + std::env::remove_var("AGENT"); + std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); if let Some(v) = prev_override { @@ -2209,6 +2254,12 @@ mod tests { if let Some(v) = prev_codex_thread { std::env::set_var("CODEX_THREAD_ID", v); } + if let Some(v) = prev_agent { + std::env::set_var("AGENT", v); + } + if let Some(v) = prev_agent_session { + std::env::set_var("AGENT_SESSION_ID", v); + } if let Some(v) = prev_copilot { std::env::set_var("COPILOT_AGENT_SESSION_ID", v); } @@ -2218,6 +2269,59 @@ mod tests { } } + #[test] + fn resolve_session_key_goose_adapter_is_guarded_and_ordered() { + let _guard = crate::config::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let names = [ + "WIRE_SESSION_ID", + "CLAUDE_CODE_SESSION_ID", + "CODEX_SESSION_ID", + "CODEX_THREAD_ID", + "AGENT", + "AGENT_SESSION_ID", + "COPILOT_AGENT_SESSION_ID", + "VSCODE_GIT_REPOSITORY_ROOT", + ]; + let previous: Vec<_> = names + .iter() + .map(|name| (*name, std::env::var_os(name))) + .collect(); + // SAFETY: ENV_LOCK serializes all environment access in these tests. + unsafe { + for name in names { + std::env::remove_var(name); + } + std::env::set_var("AGENT", "goose"); + std::env::set_var("AGENT_SESSION_ID", "20260810_7"); + } + + assert_eq!(resolve_session_key(), Some(("20260810_7".into(), "goose"))); + + unsafe { std::env::set_var("COPILOT_AGENT_SESSION_ID", "copilot-later") }; + assert_eq!(resolve_session_key(), Some(("20260810_7".into(), "goose"))); + unsafe { std::env::remove_var("COPILOT_AGENT_SESSION_ID") }; + + unsafe { std::env::set_var("AGENT", "another-host") }; + assert!(!matches!(resolve_session_key(), Some((key, _)) if key == "20260810_7")); + + unsafe { + std::env::set_var("AGENT", "goose"); + std::env::set_var("AGENT_SESSION_ID", "${UNEXPANDED}"); + } + assert!(!matches!(resolve_session_key(), Some((key, _)) if key.contains("${"))); + + unsafe { + for (name, value) in previous { + std::env::remove_var(name); + if let Some(value) = value { + std::env::set_var(name, value); + } + } + } + } + #[test] fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() { // Regression (v0.13.2): v0.13 moved session homes under @@ -2921,6 +3025,7 @@ mod tests { "claude-code", "claude-code-pidfile", "codex-cli", + "goose", "copilot-cli", "vscode-workspace", ] { diff --git a/src/session_lifecycle.rs b/src/session_lifecycle.rs index 6a25724..01f3f03 100644 --- a/src/session_lifecycle.rs +++ b/src/session_lifecycle.rs @@ -19,6 +19,10 @@ pub struct LeaseRecord { pub wire_version: String, pub bin_path: String, pub session_source: String, + #[serde(default)] + pub started_at: Option, + #[serde(default)] + pub cwd: Option, } pub fn lease_dir(home: &Path) -> PathBuf { @@ -59,6 +63,7 @@ pub fn write_lease_at( wire_version: &str, bin_path: &Path, session_source: &str, + cwd: Option<&Path>, ) -> Result { if role.is_empty() || !role @@ -78,6 +83,8 @@ pub fn write_lease_at( wire_version: wire_version.to_string(), bin_path: bin_path.to_string_lossy().into_owned(), session_source: session_source.to_string(), + started_at: Some(format_time(now)?), + cwd: cwd.map(|path| path.to_string_lossy().into_owned()), }; persist_record(&path, &record)?; Ok(path) @@ -151,6 +158,7 @@ impl LeaseGuard { .and_then(Path::parent) .ok_or_else(|| anyhow!("state directory has no session-home parent"))?; let bin = crate::platform::current_exe_resolved()?; + let cwd = std::env::current_dir().ok(); Self::acquire_at( home, role, @@ -160,6 +168,7 @@ impl LeaseGuard { env!("CARGO_PKG_VERSION"), &bin, crate::session::session_source(), + cwd.as_deref(), ) } @@ -177,6 +186,7 @@ impl LeaseGuard { wire_version: &str, bin_path: &Path, session_source: &str, + cwd: Option<&Path>, ) -> Result { let path = write_lease_at( home, @@ -187,6 +197,7 @@ impl LeaseGuard { wire_version, bin_path, session_source, + cwd, )?; Ok(Self { path, ttl }) } @@ -225,6 +236,7 @@ mod tests { "0.17.0", Path::new("/opt/wire"), "override", + Some(Path::new("/work/wire")), ) .unwrap() } @@ -243,6 +255,31 @@ mod tests { assert_eq!(leases[0].role, "mcp"); assert_eq!(leases[0].pid, 42); assert_eq!(leases[0].session_source, "override"); + assert_eq!( + leases[0].started_at.as_deref(), + Some("2023-11-14T22:13:20Z") + ); + assert_eq!(leases[0].cwd.as_deref(), Some("/work/wire")); + } + + #[test] + fn lease_without_inventory_metadata_remains_readable() { + let record: LeaseRecord = serde_json::from_str( + r#"{ + "schema":"wire-session-lease-v1", + "role":"mcp", + "pid":42, + "heartbeat_at":"2023-11-14T22:13:20Z", + "expires_at":"2023-11-14T22:14:50Z", + "wire_version":"0.17.0", + "bin_path":"/opt/wire", + "session_source":"override" + }"#, + ) + .unwrap(); + + assert_eq!(record.started_at, None); + assert_eq!(record.cwd, None); } #[test] @@ -304,6 +341,7 @@ mod tests { "0.17.0", Path::new("/opt/wire"), "codex-cli", + Some(Path::new("/work/wire")), ) .unwrap(); let path = guard.path.clone(); From 30321b2dbb30c3a1cceeb36232766232bb3e2636 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 00:48:59 -0700 Subject: [PATCH 04/39] feat: collect live operator sessions --- src/lib.rs | 1 + src/operator.rs | 184 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/operator.rs diff --git a/src/lib.rs b/src/lib.rs index f4b5850..7870971 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ pub mod nostr_key; pub mod nostr_relay; pub mod nostr_ws; pub mod nuke; +pub mod operator; pub mod org_bind; pub mod org_membership; pub mod org_policy; diff --git a/src/operator.rs b/src/operator.rs new file mode 100644 index 0000000..cfba3d3 --- /dev/null +++ b/src/operator.rs @@ -0,0 +1,184 @@ +use serde::Serialize; +use time::OffsetDateTime; + +pub const LIVE_SESSION_SCHEMA: &str = "wire-live-sessions-v1"; + +#[derive(Clone, Debug, Serialize)] +pub struct LiveSession { + pub id: String, + pub handle: String, + pub did: String, + pub emoji: String, + pub primary_hex: String, + pub agent_host: String, + pub project_dir: Option, + pub started_at: Option, + pub age_seconds: Option, + pub direct_link_count: usize, + pub health: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LiveSessionReport { + pub schema: &'static str, + pub sessions: Vec, +} + +pub fn collect_live_sessions() -> anyhow::Result { + let sessions = crate::session::list_sessions()?; + collect_live_from( + &sessions, + OffsetDateTime::now_utc(), + crate::platform::process_alive, + ) +} + +fn collect_live_from( + sessions: &[crate::session::SessionInfo], + now: OffsetDateTime, + is_alive: impl Fn(u32) -> bool + Copy, +) -> anyhow::Result { + let mut live = Vec::new(); + for session in sessions { + let (Some(did), Some(handle)) = (session.did.as_deref(), session.handle.as_deref()) else { + continue; + }; + if crate::retire::is_retired(&session.home_dir) { + continue; + } + let leases = crate::session_lifecycle::active_leases_at(&session.home_dir, now, is_alive); + let Some(lease) = leases + .iter() + .filter(|lease| lease.role == "mcp") + .max_by(|left, right| left.heartbeat_at.cmp(&right.heartbeat_at)) + else { + continue; + }; + let character = session + .character + .clone() + .unwrap_or_else(|| crate::character::Character::from_did(did)); + let peers = crate::dash::read_peers(&session.home_dir, Some(did), Some(handle)); + let age_seconds = lease.started_at.as_deref().and_then(|started| { + OffsetDateTime::parse(started, &time::format_description::well_known::Rfc3339) + .ok() + .and_then(|started| { + let seconds = (now - started).whole_seconds(); + (seconds >= 0).then_some(seconds as u64) + }) + }); + let health = if !session.daemon_running { + "daemon-down" + } else if crate::dash::last_sync_age_s(&session.home_dir).is_some_and(|age| age > 60) { + "sync-stale" + } else { + "healthy" + }; + live.push(LiveSession { + id: session.name.clone(), + handle: handle.to_string(), + did: did.to_string(), + emoji: character.emoji, + primary_hex: character.palette.primary_hex, + agent_host: lease.session_source.clone(), + project_dir: lease.cwd.clone().or_else(|| session.cwd.clone()), + started_at: lease.started_at.clone(), + age_seconds, + direct_link_count: peers.len(), + health: health.to_string(), + }); + } + live.sort_by(|left, right| left.handle.cmp(&right.handle)); + Ok(LiveSessionReport { + schema: LIVE_SESSION_SCHEMA, + sessions: live, + }) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::time::Duration; + + use tempfile::tempdir; + use time::OffsetDateTime; + + use super::*; + + fn session(home: &Path, suffix: &str, daemon_running: bool) -> crate::session::SessionInfo { + let did = format!("did:wire:session-{suffix}"); + crate::session::SessionInfo { + name: format!("session-{suffix}"), + cwd: Some(format!("/projects/{suffix}")), + home_dir: home.to_path_buf(), + did: Some(did.clone()), + handle: Some(format!("session-{suffix}")), + daemon_running, + character: Some(crate::character::Character::from_did(&did)), + } + } + + fn lease(home: &Path, role: &str, pid: u32, now: OffsetDateTime, ttl_seconds: u64) { + crate::session_lifecycle::write_lease_at( + home, + role, + pid, + now, + Duration::from_secs(ttl_seconds), + "0.17.0", + Path::new("/opt/wire"), + "codex-cli", + Some(Path::new("/work/wire")), + ) + .unwrap(); + } + + #[test] + fn inventory_includes_only_live_mcp_sessions() { + let tmp = tempdir().unwrap(); + let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); + let live_home = tmp.path().join("live"); + let daemon_home = tmp.path().join("daemon-only"); + let expired_home = tmp.path().join("expired"); + let retired_home = tmp.path().join("retired"); + let dead_home = tmp.path().join("dead"); + + lease(&live_home, "mcp", 101, now, 90); + lease(&daemon_home, "daemon", 102, now, 90); + lease(&expired_home, "mcp", 103, now, 1); + lease(&retired_home, "mcp", 104, now, 90); + lease(&dead_home, "mcp", 105, now, 90); + std::fs::create_dir_all(retired_home.join("state/wire")).unwrap(); + std::fs::write(retired_home.join("state/wire/retired.json"), "{}").unwrap(); + + let sessions = vec![ + session(&live_home, "11111111", true), + session(&daemon_home, "22222222", true), + session(&expired_home, "33333333", true), + session(&retired_home, "44444444", true), + session(&dead_home, "55555555", true), + ]; + let report = collect_live_from(&sessions, now + time::Duration::seconds(2), |pid| { + matches!(pid, 101..=104) + }) + .unwrap(); + + assert_eq!(report.schema, "wire-live-sessions-v1"); + assert_eq!(report.sessions.len(), 1); + assert_eq!(report.sessions[0].id, "session-11111111"); + assert_eq!(report.sessions[0].agent_host, "codex-cli"); + assert_eq!( + report.sessions[0].project_dir.as_deref(), + Some("/work/wire") + ); + assert_eq!( + report.sessions[0].started_at.as_deref(), + Some("2023-11-14T22:13:20Z") + ); + + let json = serde_json::to_string(&report).unwrap(); + assert!(!json.contains("AGENT_SESSION_ID")); + assert!(!json.contains("slot_token")); + assert!(!json.contains("private.key")); + } +} From fa39c7a77bbe99d70104cc3b4ed3ddf937c2e324 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 00:55:23 -0700 Subject: [PATCH 05/39] feat: add local topology operations --- src/operator.rs | 371 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 370 insertions(+), 1 deletion(-) diff --git a/src/operator.rs b/src/operator.rs index cfba3d3..2fba02c 100644 --- a/src/operator.rs +++ b/src/operator.rs @@ -1,4 +1,9 @@ -use serde::Serialize; +use std::collections::HashSet; +use std::path::Path; +use std::process::Command; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; use time::OffsetDateTime; pub const LIVE_SESSION_SCHEMA: &str = "wire-live-sessions-v1"; @@ -24,6 +29,302 @@ pub struct LiveSessionReport { pub sessions: Vec, } +#[derive(Debug, Deserialize)] +pub struct LinkRequest { + pub sessions: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct GroupRequest { + pub name: String, + pub creator: String, + pub members: Vec, +} + +#[derive(Debug, Serialize)] +pub struct MutationResult { + pub ok: bool, + pub message: String, + pub changed_sessions: Vec, +} + +#[derive(Debug, Error)] +pub enum OperatorError { + #[error("{0}")] + Validation(String), + #[error("{0}")] + Conflict(String), + #[error("operator action failed")] + Internal(#[source] anyhow::Error), + #[error("{message}")] + Partial { + message: String, + changed_sessions: Vec, + }, +} + +fn validate_link_request<'a>( + request: &LinkRequest, + live: &'a [LiveSession], +) -> Result<[&'a LiveSession; 2], OperatorError> { + if request.sessions.len() != 2 || request.sessions[0] == request.sessions[1] { + return Err(OperatorError::Validation( + "select exactly two distinct live sessions".to_string(), + )); + } + let find = |id: &str| { + live.iter() + .find(|session| session.id == id) + .ok_or_else(|| OperatorError::Conflict(format!("session `{id}` is no longer live"))) + }; + Ok([find(&request.sessions[0])?, find(&request.sessions[1])?]) +} + +fn validate_group_request<'a>( + request: &GroupRequest, + live: &'a [LiveSession], +) -> Result, OperatorError> { + if request.name.trim().is_empty() { + return Err(OperatorError::Validation( + "group name cannot be empty".to_string(), + )); + } + let distinct: HashSet<&str> = request.members.iter().map(String::as_str).collect(); + if distinct.len() < 2 || distinct.len() != request.members.len() { + return Err(OperatorError::Validation( + "select at least two distinct live sessions".to_string(), + )); + } + if !distinct.contains(request.creator.as_str()) { + return Err(OperatorError::Validation( + "group creator must be selected".to_string(), + )); + } + let mut selected = Vec::with_capacity(request.members.len()); + for id in &request.members { + selected.push( + live.iter() + .find(|session| session.id == *id) + .ok_or_else(|| { + OperatorError::Conflict(format!("session `{id}` is no longer live")) + })?, + ); + } + Ok(selected) +} + +fn run_wire_at(home: &Path, args: &[String]) -> Result { + const MAX_OUTPUT: usize = 256 * 1024; + let binary = crate::platform::current_exe_resolved() + .map_err(|error| OperatorError::Internal(error.into()))?; + let output = Command::new(binary) + .args(args) + .env("WIRE_HOME", home) + .env("WIRE_HOME_FORCE", "1") + .env("WIRE_QUIET_AUTOSESSION", "1") + .env_remove("WIRE_SESSION_ID") + .env_remove("CLAUDE_CODE_SESSION_ID") + .env_remove("CODEX_SESSION_ID") + .env_remove("CODEX_THREAD_ID") + .env_remove("AGENT") + .env_remove("AGENT_SESSION_ID") + .env_remove("COPILOT_AGENT_SESSION_ID") + .env_remove("VSCODE_GIT_REPOSITORY_ROOT") + .env_remove("WIRE_LOCAL_PAIR_ONE_WAY") + .output() + .map_err(|error| OperatorError::Internal(error.into()))?; + let capped = |bytes: &[u8]| { + let end = bytes.len().min(MAX_OUTPUT); + String::from_utf8_lossy(&bytes[..end]) + .chars() + .filter(|character| !character.is_control() || matches!(character, '\n' | '\t')) + .collect::() + }; + if !output.status.success() { + return Err(OperatorError::Internal(anyhow::anyhow!( + "wire command failed with {}: {}", + output.status, + capped(&output.stderr).trim() + ))); + } + serde_json::from_str(capped(&output.stdout).trim()).map_err(|error| { + OperatorError::Internal(anyhow::anyhow!( + "wire command returned invalid JSON: {error}" + )) + }) +} + +fn session_info<'a>( + sessions: &'a [crate::session::SessionInfo], + id: &str, +) -> Result<&'a crate::session::SessionInfo, OperatorError> { + sessions + .iter() + .find(|session| session.name == id) + .ok_or_else(|| OperatorError::Conflict(format!("session `{id}` is no longer available"))) +} + +fn has_verified_peer( + owner: &crate::session::SessionInfo, + peer: &crate::session::SessionInfo, +) -> bool { + crate::dash::read_peers( + &owner.home_dir, + owner.did.as_deref(), + owner.handle.as_deref(), + ) + .iter() + .any(|row| { + row.tier == "VERIFIED" + && (peer.did.as_deref() == Some(row.did.as_str()) + || peer.handle.as_deref() == Some(row.handle.as_str())) + }) +} + +fn bilateral_verified( + first: &crate::session::SessionInfo, + second: &crate::session::SessionInfo, +) -> bool { + has_verified_peer(first, second) && has_verified_peer(second, first) +} + +pub fn link_local_sessions(request: LinkRequest) -> Result { + let report = collect_live_sessions().map_err(OperatorError::Internal)?; + let selected = validate_link_request(&request, &report.sessions)?; + let ids = [selected[0].id.clone(), selected[1].id.clone()]; + let sessions = crate::session::list_sessions().map_err(OperatorError::Internal)?; + let first = session_info(&sessions, &ids[0])?; + let second = session_info(&sessions, &ids[1])?; + + if bilateral_verified(first, second) { + return Ok(MutationResult { + ok: true, + message: format!("{} and {} are already linked", first.name, second.name), + changed_sessions: Vec::new(), + }); + } + + run_wire_at( + &first.home_dir, + &[ + "add".to_string(), + second.name.clone(), + "--local-sister".to_string(), + "--json".to_string(), + ], + )?; + if !bilateral_verified(first, second) { + return Err(OperatorError::Internal(anyhow::anyhow!( + "local link did not converge to bilateral VERIFIED" + ))); + } + Ok(MutationResult { + ok: true, + message: format!("linked {} and {}", first.name, second.name), + changed_sessions: ids.into_iter().collect(), + }) +} + +pub fn create_local_group(request: GroupRequest) -> Result { + let report = collect_live_sessions().map_err(OperatorError::Internal)?; + let selected = validate_group_request(&request, &report.sessions)?; + let ids: Vec = selected.iter().map(|session| session.id.clone()).collect(); + let sessions = crate::session::list_sessions().map_err(OperatorError::Internal)?; + let creator = session_info(&sessions, &request.creator)?; + let created = run_wire_at( + &creator.home_dir, + &[ + "group".to_string(), + "create".to_string(), + request.name.trim().to_string(), + "--json".to_string(), + ], + )?; + let group_id = created + .get("id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + OperatorError::Internal(anyhow::anyhow!("group create response omitted id")) + })? + .to_string(); + let invite = run_wire_at( + &creator.home_dir, + &[ + "group".to_string(), + "invite".to_string(), + group_id.clone(), + "--json".to_string(), + ], + )?; + let code = invite + .get("code") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + OperatorError::Internal(anyhow::anyhow!("group invite response omitted code")) + })? + .to_string(); + + let mut changed = vec![creator.name.clone()]; + for id in &ids { + if id == &creator.name { + continue; + } + let member = session_info(&sessions, id)?; + if let Err(error) = run_wire_at( + &member.home_dir, + &[ + "group".to_string(), + "join".to_string(), + code.clone(), + "--json".to_string(), + ], + ) { + return Err(OperatorError::Partial { + message: format!( + "created group `{}` for {}; failed while joining {}: {error}", + request.name.trim(), + changed.join(", "), + member.name + ), + changed_sessions: changed, + }); + } + changed.push(member.name.clone()); + } + + for id in &ids { + let member = session_info(&sessions, id)?; + let path = member + .home_dir + .join("config/wire/groups") + .join(format!("{group_id}.json")); + let valid = std::fs::read(&path) + .ok() + .and_then(|body| serde_json::from_slice::(&body).ok()) + .is_some_and(|group| group.id == group_id); + if !valid { + return Err(OperatorError::Partial { + message: format!( + "group `{}` did not materialize for {}", + request.name.trim(), + member.name + ), + changed_sessions: changed, + }); + } + } + + Ok(MutationResult { + ok: true, + message: format!( + "created shared group `{}` for {} sessions", + request.name.trim(), + ids.len() + ), + changed_sessions: changed, + }) +} + pub fn collect_live_sessions() -> anyhow::Result { let sessions = crate::session::list_sessions()?; collect_live_from( @@ -105,6 +406,22 @@ mod tests { use super::*; + fn live(id: &str) -> LiveSession { + LiveSession { + id: id.to_string(), + handle: id.to_string(), + did: format!("did:wire:{id}-11111111"), + emoji: "🦎".to_string(), + primary_hex: "#45e456".to_string(), + agent_host: "codex-cli".to_string(), + project_dir: None, + started_at: None, + age_seconds: None, + direct_link_count: 0, + health: "healthy".to_string(), + } + } + fn session(home: &Path, suffix: &str, daemon_running: bool) -> crate::session::SessionInfo { let did = format!("did:wire:session-{suffix}"); crate::session::SessionInfo { @@ -181,4 +498,56 @@ mod tests { assert!(!json.contains("slot_token")); assert!(!json.contains("private.key")); } + + #[test] + fn link_validation_rejects_invalid_selection() { + let live = vec![live("alice"), live("bob"), live("carol")]; + for sessions in [ + vec!["alice".to_string()], + vec!["alice".to_string(), "bob".to_string(), "carol".to_string()], + vec!["alice".to_string(), "alice".to_string()], + ] { + let error = validate_link_request(&LinkRequest { sessions }, &live).unwrap_err(); + assert!(matches!(error, OperatorError::Validation(_))); + } + + let error = validate_link_request( + &LinkRequest { + sessions: vec!["alice".to_string(), "missing".to_string()], + }, + &live, + ) + .unwrap_err(); + assert!(matches!(error, OperatorError::Conflict(_))); + } + + #[test] + fn group_validation_rejects_invalid_selection() { + let live = vec![live("alice"), live("bob"), live("carol")]; + let cases = [ + GroupRequest { + name: " ".to_string(), + creator: "alice".to_string(), + members: vec!["alice".to_string(), "bob".to_string()], + }, + GroupRequest { + name: "crew".to_string(), + creator: "alice".to_string(), + members: vec!["alice".to_string()], + }, + GroupRequest { + name: "crew".to_string(), + creator: "missing".to_string(), + members: vec!["alice".to_string(), "bob".to_string()], + }, + GroupRequest { + name: "crew".to_string(), + creator: "carol".to_string(), + members: vec!["alice".to_string(), "bob".to_string()], + }, + ]; + for request in cases { + assert!(validate_group_request(&request, &live).is_err()); + } + } } From cedc27eadc41b52320007b2031231220d1b3936a Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 01:15:08 -0700 Subject: [PATCH 06/39] feat: add localhost operator dashboard --- assets/operator-dashboard.css | 253 ++++++++++++++++++++++++++ assets/operator-dashboard.html | 115 ++++++++++++ assets/operator-dashboard.js | 208 ++++++++++++++++++++++ src/cli/dash.rs | 7 + src/cli/mod.rs | 10 ++ src/lib.rs | 1 + src/operator_web.rs | 302 ++++++++++++++++++++++++++++++++ tests/cli.rs | 16 ++ tests/e2e_operator_dashboard.rs | 203 +++++++++++++++++++++ 9 files changed, 1115 insertions(+) create mode 100644 assets/operator-dashboard.css create mode 100644 assets/operator-dashboard.html create mode 100644 assets/operator-dashboard.js create mode 100644 src/operator_web.rs create mode 100644 tests/e2e_operator_dashboard.rs diff --git a/assets/operator-dashboard.css b/assets/operator-dashboard.css new file mode 100644 index 0000000..0c5d430 --- /dev/null +++ b/assets/operator-dashboard.css @@ -0,0 +1,253 @@ +:root { + --paper: #eee3ce; + --paper-shadow: #d9c8a7; + --ink: #241712; + --muted: #6e5b50; + --frame: #5b1a2e; + --frame-deep: #401020; + --dial: #8fb04a; + --dial-dark: #526925; + --phosphor: #7fffb0; + --phosphor-bg: #0b130d; + --danger: #a32d2d; + --line: rgba(64, 16, 32, 0.24); + --display: Georgia, "Times New Roman", serif; + --body: "Avenir Next", Avenir, "Segoe UI", sans-serif; + --utility: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-width: 320px; + color: var(--ink); + background: + linear-gradient(rgba(64, 16, 32, 0.035) 1px, transparent 1px), + var(--paper-shadow); + background-size: 100% 6px; + font-family: var(--body); + font-size: 16px; +} + +button, input, select { font: inherit; } + +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 3px solid var(--phosphor); + outline-offset: 3px; +} + +.console-shell { + width: min(1180px, calc(100% - 32px)); + margin: 28px auto; + border: 8px solid var(--frame-deep); + background: var(--paper); + box-shadow: 0 18px 0 rgba(64, 16, 32, 0.18), 0 30px 70px rgba(36, 23, 18, 0.24); +} + +.console-header { + display: grid; + grid-template-columns: 1fr auto; + gap: 32px; + align-items: end; + padding: 30px 34px 26px; + border-bottom: 2px solid var(--frame); +} + +.eyebrow { + margin: 0 0 7px; + color: var(--frame); + font-family: var(--utility); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +h1, h2 { margin: 0; font-family: var(--display); font-weight: 700; } +h1 { font-size: clamp(2.4rem, 6vw, 5rem); line-height: 0.9; letter-spacing: -0.055em; } +h2 { font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -0.025em; } + +.lede { max-width: 640px; margin: 15px 0 0; color: var(--muted); font-size: 1.05rem; } + +.machine-stamp { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 9px; + min-width: 176px; + padding: 12px 14px; + border: 2px solid var(--frame); + font-family: var(--utility); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.machine-stamp strong { grid-column: 2; color: var(--frame); } +.machine-stamp__light { + grid-row: 1 / span 2; + align-self: center; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--dial); + box-shadow: 0 0 0 3px rgba(143, 176, 74, 0.25); +} + +.band-scope { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 24px; + align-items: center; + padding: 17px 24px; + color: var(--phosphor); + background: var(--phosphor-bg); + border-bottom: 7px solid var(--frame); + font-family: var(--utility); + box-shadow: inset 0 0 28px rgba(127, 255, 176, 0.08); +} + +.band-scope__readout { display: grid; grid-template-columns: auto auto; column-gap: 10px; align-items: baseline; } +.band-scope__readout span { grid-column: 1 / -1; color: #b3d7be; font-size: 0.65rem; letter-spacing: 0.13em; text-transform: uppercase; } +.band-scope__readout strong { font-size: 2rem; line-height: 1; } +.band-scope__readout small { color: #b3d7be; } + +.band-scope__line { + display: grid; + grid-template-columns: repeat(7, 1fr); + align-items: center; + height: 34px; + border-top: 1px solid rgba(127, 255, 176, 0.7); + border-bottom: 1px solid rgba(127, 255, 176, 0.35); +} + +.band-scope__line span { height: 15px; border-left: 1px solid rgba(127, 255, 176, 0.5); } +.band-scope__line span:nth-child(4) { height: 30px; border-left-width: 3px; filter: drop-shadow(0 0 5px var(--phosphor)); } +.band-scope > p { margin: 0; color: #b3d7be; font-size: 0.7rem; white-space: nowrap; } + +.operations { padding: 30px 34px 18px; } +.operations__heading { display: flex; justify-content: space-between; gap: 20px; align-items: end; } + +.selection-readout { + display: flex; + gap: 8px; + align-items: baseline; + color: var(--muted); + font-family: var(--utility); + font-size: 0.75rem; + text-transform: uppercase; +} +.selection-readout strong { color: var(--frame); font-size: 1.5rem; } + +.action-strip { + display: flex; + gap: 10px; + align-items: center; + margin: 22px 0 18px; + padding: 13px; + border-block: 1px solid var(--line); +} + +.button { + min-height: 42px; + padding: 9px 15px; + border: 2px solid var(--frame-deep); + border-radius: 0; + font-family: var(--utility); + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.04em; + cursor: pointer; +} +.button:disabled { cursor: not-allowed; filter: grayscale(1); opacity: 0.42; } +.button--primary { color: #fff6e8; background: var(--frame); box-shadow: 4px 4px 0 var(--frame-deep); } +.button--secondary { color: var(--ink); background: var(--dial); box-shadow: 4px 4px 0 var(--dial-dark); } +.button--quiet { color: var(--ink); background: transparent; box-shadow: none; } +.button:not(:disabled):active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 var(--frame-deep); } + +.action-hint { margin: 0 0 0 auto; color: var(--muted); font-size: 0.86rem; } +.notice { margin: 0 0 16px; padding: 12px 14px; border-left: 5px solid var(--dial-dark); background: rgba(143, 176, 74, 0.2); } +.notice[data-kind="error"] { border-color: var(--danger); background: rgba(163, 45, 45, 0.12); } +.loading-state, .empty-state { padding: 42px 18px; border: 1px dashed var(--line); text-align: center; } +.loading-state { font-family: var(--utility); color: var(--muted); } +.empty-state strong { font-family: var(--display); font-size: 1.5rem; } +.empty-state p { max-width: 560px; margin: 8px auto 0; color: var(--muted); } + +.session-table-wrap { overflow-x: auto; border: 1px solid var(--line); } +.session-table { width: 100%; border-collapse: collapse; } +.session-table th { + padding: 10px 12px; + color: #f5ead6; + background: var(--frame); + font-family: var(--utility); + font-size: 0.65rem; + letter-spacing: 0.08em; + text-align: left; + text-transform: uppercase; +} +.session-table td { padding: 14px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; } +.session-table tr:last-child td { border-bottom: 0; } +.session-table tbody tr { background: rgba(255, 255, 255, 0.17); } +.session-table tbody tr:nth-child(even) { background: rgba(91, 26, 46, 0.045); } +.session-table tbody tr:has(input:checked) { background: rgba(143, 176, 74, 0.22); box-shadow: inset 5px 0 var(--dial-dark); } + +.session-check { width: 19px; height: 19px; accent-color: var(--frame); } +.session-name { display: flex; gap: 10px; align-items: center; min-width: 185px; font-weight: 800; } +.session-emoji { display: grid; width: 34px; height: 34px; place-items: center; border: 2px solid currentColor; background: var(--paper); font-size: 1.1rem; } +.utility { font-family: var(--utility); font-size: 0.77rem; } +.project { max-width: 280px; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } +.link-count { font-family: var(--utility); font-weight: 800; } +.signal { display: inline-flex; gap: 7px; align-items: center; font-family: var(--utility); font-size: 0.68rem; font-weight: 800; text-transform: uppercase; } +.signal::before { width: 9px; height: 9px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 18%, transparent); content: ""; } +.signal--healthy { color: var(--dial-dark); } +.signal--sync-stale, .signal--daemon-down { color: var(--danger); } + +.console-footer { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 12px 34px; + color: var(--paper-shadow); + background: var(--frame-deep); + font-family: var(--utility); + font-size: 0.65rem; + letter-spacing: 0.03em; +} + +.wire-dialog { width: min(520px, calc(100% - 30px)); padding: 0; border: 7px solid var(--frame-deep); color: var(--ink); background: var(--paper); box-shadow: 16px 16px 0 rgba(36, 23, 18, 0.28); } +.wire-dialog::backdrop { background: rgba(36, 23, 18, 0.72); } +.wire-dialog form { padding: 28px; } +.wire-dialog p:not(.eyebrow) { color: var(--muted); } +.wire-dialog label { display: block; margin: 18px 0 6px; font-family: var(--utility); font-size: 0.72rem; font-weight: 800; text-transform: uppercase; } +.wire-dialog input, .wire-dialog select { width: 100%; min-height: 44px; padding: 8px 10px; border: 2px solid var(--frame); color: var(--ink); background: #fffaf0; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 26px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@media (max-width: 760px) { + .console-shell { width: calc(100% - 18px); margin: 9px auto; border-width: 5px; } + .console-header { grid-template-columns: 1fr; padding: 24px 20px 20px; } + .machine-stamp { justify-self: start; } + .band-scope { grid-template-columns: auto 1fr; gap: 14px; } + .band-scope > p { grid-column: 1 / -1; } + .operations { padding: 24px 16px 12px; } + .action-strip { align-items: stretch; flex-direction: column; } + .action-hint { margin: 5px 0 0; } + .session-table-wrap { border: 0; overflow: visible; } + .session-table thead { display: none; } + .session-table, .session-table tbody, .session-table tr, .session-table td { display: block; width: 100%; } + .session-table tr { position: relative; margin-bottom: 12px; padding: 12px 12px 12px 48px; border: 1px solid var(--line); } + .session-table td { display: grid; grid-template-columns: 86px 1fr; padding: 6px 0; border: 0; } + .session-table td::before { color: var(--muted); font-family: var(--utility); font-size: 0.62rem; text-transform: uppercase; content: attr(data-label); } + .session-table td:first-child { position: absolute; top: 16px; left: 14px; display: block; width: auto; } + .session-table td:first-child::before { display: none; } + .session-name { min-width: 0; } + .project { max-width: none; } + .console-footer { align-items: flex-start; flex-direction: column; padding-inline: 20px; } +} + +@media (prefers-reduced-motion: no-preference) { + .machine-stamp__light { animation: carrier-pulse 2.4s ease-in-out infinite; } + @keyframes carrier-pulse { 50% { box-shadow: 0 0 0 7px rgba(143, 176, 74, 0.08); } } +} diff --git a/assets/operator-dashboard.html b/assets/operator-dashboard.html new file mode 100644 index 0000000..f292be3 --- /dev/null +++ b/assets/operator-dashboard.html @@ -0,0 +1,115 @@ + + + + + + + Wire operator + + + + +
+
+
+

Open Band / local operator

+

Wire operator

+

Tune the live sessions on this machine. Link two directly or open one shared room.

+
+
+ + Loopback only + This machine +
+
+ +
+
+ Live carrier + + agent sessions +
+ +

Waiting for first scan

+
+ +
+
+
+

Operations list

+

Live sessions

+
+
+ 0 + selected +
+
+ +
+ + +

Select two sessions to link them.

+
+ + +
Scanning local session leases…
+ + + +
+ +
+ Reads active MCP leases only + Retired and historical identities stay off-band +
+
+ + +
+

Direct local trust

+

Link these sessions?

+

+
+ + +
+
+
+ + +
+

Shared room

+

Create group

+

Every selected session joins one room. This does not create a full mesh.

+ + + + +
+ + +
+
+
+ + diff --git a/assets/operator-dashboard.js b/assets/operator-dashboard.js new file mode 100644 index 0000000..87b25e4 --- /dev/null +++ b/assets/operator-dashboard.js @@ -0,0 +1,208 @@ +(() => { + "use strict"; + + const query = new URLSearchParams(window.location.search); + const token = query.get("token") || ""; + window.history.replaceState({}, "", window.location.pathname); + + const state = { sessions: [], selected: new Set(), busy: false }; + const rows = document.querySelector("#session-rows"); + const tableWrap = document.querySelector("#table-wrap"); + const loading = document.querySelector("#loading"); + const empty = document.querySelector("#empty"); + const notice = document.querySelector("#notice"); + const liveCount = document.querySelector("#live-count"); + const lastScan = document.querySelector("#last-scan"); + const selectionCount = document.querySelector("#selection-count"); + const actionHint = document.querySelector("#action-hint"); + const linkButton = document.querySelector("#link-button"); + const groupButton = document.querySelector("#group-button"); + const confirmDialog = document.querySelector("#confirm-dialog"); + const confirmCopy = document.querySelector("#confirm-copy"); + const confirmLink = document.querySelector("#confirm-link"); + const groupDialog = document.querySelector("#group-dialog"); + const groupForm = document.querySelector("#group-form"); + const groupName = document.querySelector("#group-name"); + const groupCreator = document.querySelector("#group-creator"); + + const hostLabel = (source) => ({ + "codex-cli": "Codex thread", + "claude-code": "Claude thread", + "claude-code-pidfile": "Claude thread", + "goose": "Goose thread", + "copilot-cli": "Copilot thread", + "vscode-workspace": "VS Code workspace", + "override": "Pinned session" + }[source] || source || "Agent session"); + + const formatAge = (seconds) => { + if (seconds === null || seconds === undefined) return "—"; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 86400)}d`; + }; + + const showNotice = (message, kind = "ok") => { + notice.textContent = message; + notice.dataset.kind = kind; + notice.hidden = !message; + }; + + const selectedSessions = () => state.sessions.filter((session) => state.selected.has(session.id)); + + const updateActions = () => { + const count = state.selected.size; + selectionCount.textContent = String(count); + linkButton.disabled = state.busy || count !== 2 || !token; + groupButton.disabled = state.busy || count < 2 || !token; + if (!token) actionHint.textContent = "Launch token missing. Restart wire dash --web."; + else if (count === 0) actionHint.textContent = "Select two sessions to link them."; + else if (count === 1) actionHint.textContent = "Select one more session for a topology action."; + else if (count === 2) actionHint.textContent = "Link the pair or create a shared room."; + else actionHint.textContent = "Create one shared room for the selected sessions."; + }; + + const cell = (label, className = "") => { + const element = document.createElement("td"); + element.dataset.label = label; + if (className) element.className = className; + return element; + }; + + const render = () => { + const liveIds = new Set(state.sessions.map((session) => session.id)); + state.selected = new Set([...state.selected].filter((id) => liveIds.has(id))); + const fragment = document.createDocumentFragment(); + + for (const session of state.sessions) { + const row = document.createElement("tr"); + const selectCell = cell("Select"); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "session-check"; + checkbox.checked = state.selected.has(session.id); + checkbox.setAttribute("aria-label", `Select ${session.handle}`); + checkbox.addEventListener("change", () => { + if (checkbox.checked) state.selected.add(session.id); + else state.selected.delete(session.id); + updateActions(); + }); + selectCell.append(checkbox); + + const nameCell = cell("Session"); + const name = document.createElement("span"); + name.className = "session-name"; + const emoji = document.createElement("span"); + emoji.className = "session-emoji"; + emoji.style.color = session.primary_hex; + emoji.textContent = session.emoji; + const handle = document.createElement("span"); + handle.textContent = session.handle; + name.append(emoji, handle); + nameCell.append(name); + + const host = cell("Agent", "utility"); + host.textContent = hostLabel(session.agent_host); + const project = cell("Project", "project"); + project.textContent = session.project_dir || "—"; + project.title = session.project_dir || ""; + const age = cell("Uptime", "utility"); + age.textContent = formatAge(session.age_seconds); + const links = cell("Links", "link-count"); + links.textContent = String(session.direct_link_count); + const health = cell("Signal"); + const signal = document.createElement("span"); + signal.className = `signal signal--${session.health}`; + signal.textContent = session.health.replaceAll("-", " "); + health.append(signal); + + row.append(selectCell, nameCell, host, project, age, links, health); + fragment.append(row); + } + rows.replaceChildren(fragment); + liveCount.textContent = String(state.sessions.length); + loading.hidden = true; + empty.hidden = state.sessions.length !== 0; + tableWrap.hidden = state.sessions.length === 0; + lastScan.textContent = `Scan ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`; + updateActions(); + }; + + const scan = async () => { + try { + const response = await fetch("/api/sessions", { cache: "no-store" }); + if (!response.ok) throw new Error("Could not read live sessions."); + const report = await response.json(); + state.sessions = Array.isArray(report.sessions) ? report.sessions : []; + render(); + } catch (error) { + loading.hidden = true; + showNotice(error.message || "Session scan failed.", "error"); + } + }; + + const mutate = async (path, body) => { + state.busy = true; + updateActions(); + try { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Wire-Token": token }, + body: JSON.stringify(body) + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "Topology action failed."); + showNotice(payload.message || "Topology updated."); + state.selected.clear(); + await scan(); + } catch (error) { + showNotice(error.message || "Topology action failed.", "error"); + } finally { + state.busy = false; + updateActions(); + } + }; + + linkButton.addEventListener("click", () => { + const selected = selectedSessions(); + if (selected.length !== 2) return; + confirmCopy.textContent = `${selected[0].handle} and ${selected[1].handle} will trust each other on this machine.`; + confirmDialog.showModal(); + }); + + confirmLink.addEventListener("click", (event) => { + event.preventDefault(); + const sessions = selectedSessions().map((session) => session.id); + confirmDialog.close(); + void mutate("/api/links", { sessions }); + }); + + groupButton.addEventListener("click", () => { + const selected = selectedSessions(); + if (selected.length < 2) return; + const options = selected.map((session) => { + const option = document.createElement("option"); + option.value = session.id; + option.textContent = session.handle; + return option; + }); + groupCreator.replaceChildren(...options); + groupDialog.showModal(); + groupName.focus(); + }); + + groupForm.addEventListener("submit", (event) => { + event.preventDefault(); + if (!groupName.reportValidity()) return; + const members = selectedSessions().map((session) => session.id); + const body = { name: groupName.value.trim(), creator: groupCreator.value, members }; + groupDialog.close(); + groupForm.reset(); + void mutate("/api/groups", body); + }); + + if (!token) showNotice("Launch token missing. Restart wire dash --web.", "error"); + void scan(); + window.setInterval(() => { if (!state.busy) void scan(); }, 2000); +})(); diff --git a/src/cli/dash.rs b/src/cli/dash.rs index 09dffa4..413186c 100644 --- a/src/cli/dash.rs +++ b/src/cli/dash.rs @@ -38,6 +38,8 @@ fn emit(text: &str) { /// Parsed `wire dash` flags. pub struct DashArgs { + pub web: bool, + pub no_open: bool, pub watch: bool, pub json: bool, pub all: bool, @@ -76,6 +78,11 @@ fn split_banner(color: bool) -> String { } pub fn cmd_dash(args: DashArgs) -> Result<()> { + if args.web { + return crate::operator_web::serve(crate::operator_web::ServeOptions { + open_browser: !args.no_open, + }); + } if args.retire_idle { return cmd_retire_idle( args.older_than.unwrap_or(7), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index dbbcd9f..a216f9b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -131,6 +131,12 @@ pub enum Command { /// pinned peers, relay binding, and sync recency. Read-only; paired /// sessions float to the top, idle solo daemons collapse into a count. Dash { + /// Open the local operator dashboard in a browser. + #[arg(long, conflicts_with_all = ["watch", "json", "retire_idle"])] + web: bool, + /// Start the web dashboard without opening a browser. + #[arg(long, requires = "web")] + no_open: bool, /// Live-refresh every 2s (Ctrl-C to exit). #[arg(long)] watch: bool, @@ -1912,6 +1918,8 @@ pub fn run() -> Result<()> { } => identity::cmd_whoami(json_default(json), short, colored), Command::Peers { json } => comms::cmd_peers(json_default(json)), Command::Dash { + web, + no_open, watch, json, all, @@ -1922,6 +1930,8 @@ pub fn run() -> Result<()> { dry_run, force, } => dash::cmd_dash(dash::DashArgs { + web, + no_open, watch, json: json_default(json), all, diff --git a/src/lib.rs b/src/lib.rs index 7870971..5d4728e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,7 @@ pub mod nostr_relay; pub mod nostr_ws; pub mod nuke; pub mod operator; +pub mod operator_web; pub mod org_bind; pub mod org_membership; pub mod org_policy; diff --git a/src/operator_web.rs b/src/operator_web.rs new file mode 100644 index 0000000..b62baf6 --- /dev/null +++ b/src/operator_web.rs @@ -0,0 +1,302 @@ +use axum::Router; +use rand::RngCore; + +const HTML: &str = include_str!("../assets/operator-dashboard.html"); +const CSS: &str = include_str!("../assets/operator-dashboard.css"); +const JAVASCRIPT: &str = include_str!("../assets/operator-dashboard.js"); + +pub struct ServeOptions { + pub open_browser: bool, +} + +pub fn serve(options: ServeOptions) -> anyhow::Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(serve_async(options)) +} + +async fn serve_async(options: ServeOptions) -> anyhow::Result<()> { + let mut token_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut token_bytes); + let token = hex::encode(token_bytes); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; + let address = listener.local_addr()?; + let url = format!("http://{address}/?token={token}"); + println!("Wire operator dashboard: {url}"); + use std::io::Write as _; + std::io::stdout().flush()?; + if options.open_browser + && let Err(error) = open_browser(&url) + { + eprintln!("wire dash: could not open browser: {error}"); + } + axum::serve(listener, router(token)).await?; + Ok(()) +} + +fn open_browser(url: &str) -> std::io::Result<()> { + #[cfg(target_os = "macos")] + let child = std::process::Command::new("open").arg(url).spawn(); + #[cfg(all(unix, not(target_os = "macos")))] + let child = std::process::Command::new("xdg-open").arg(url).spawn(); + #[cfg(windows)] + let child = std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn(); + child.map(|_| ()) +} +use axum::extract::rejection::JsonRejection; +use axum::extract::{Json, State}; +use axum::http::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::middleware; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::{get, post}; + +#[derive(Clone)] +struct AppState { + token: String, +} + +fn router(token: String) -> Router { + Router::new() + .route("/", get(index)) + .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT })) + .route("/dashboard.css", get(stylesheet)) + .route("/dashboard.js", get(javascript)) + .route("/api/sessions", get(get_sessions)) + .route("/api/links", post(post_links)) + .route("/api/groups", post(post_groups)) + .with_state(AppState { token }) + .layer(middleware::map_response(security_headers)) +} + +async fn index() -> Html<&'static str> { + Html(HTML) +} + +async fn stylesheet() -> impl IntoResponse { + ([(CONTENT_TYPE, "text/css; charset=utf-8")], CSS) +} + +async fn javascript() -> impl IntoResponse { + ( + [(CONTENT_TYPE, "text/javascript; charset=utf-8")], + JAVASCRIPT, + ) +} + +async fn security_headers(mut response: Response) -> Response { + let headers = response.headers_mut(); + headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert( + "X-Content-Type-Options", + HeaderValue::from_static("nosniff"), + ); + headers.insert("X-Frame-Options", HeaderValue::from_static("DENY")); + headers.insert( + CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + ), + ); + response +} + +fn authorized(headers: &HeaderMap, state: &AppState) -> bool { + headers + .get("X-Wire-Token") + .and_then(|value| value.to_str().ok()) + == Some(state.token.as_str()) +} + +fn error_response(status: StatusCode, message: &str, changed_sessions: Vec) -> Response { + ( + status, + Json(serde_json::json!({ + "error": message, + "changed_sessions": changed_sessions, + })), + ) + .into_response() +} + +fn operator_error(error: crate::operator::OperatorError) -> Response { + match error { + crate::operator::OperatorError::Validation(message) => { + error_response(StatusCode::BAD_REQUEST, &message, Vec::new()) + } + crate::operator::OperatorError::Conflict(message) => { + error_response(StatusCode::CONFLICT, &message, Vec::new()) + } + crate::operator::OperatorError::Partial { + message, + changed_sessions, + } => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &message, + changed_sessions, + ), + crate::operator::OperatorError::Internal(_) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "operator action failed", + Vec::new(), + ), + } +} + +async fn get_sessions() -> Response { + match tokio::task::spawn_blocking(crate::operator::collect_live_sessions).await { + Ok(Ok(report)) => Json(report).into_response(), + _ => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "session inventory failed", + Vec::new(), + ), + } +} + +async fn post_links( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Response { + if !authorized(&headers, &state) { + return error_response(StatusCode::FORBIDDEN, "invalid launch token", Vec::new()); + } + let Json(request) = match payload { + Ok(payload) => payload, + Err(error) => { + return error_response(error.status(), "request must be JSON", Vec::new()); + } + }; + match tokio::task::spawn_blocking(move || crate::operator::link_local_sessions(request)).await { + Ok(Ok(result)) => Json(result).into_response(), + Ok(Err(error)) => operator_error(error), + Err(_) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "operator action failed", + Vec::new(), + ), + } +} + +async fn post_groups( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Response { + if !authorized(&headers, &state) { + return error_response(StatusCode::FORBIDDEN, "invalid launch token", Vec::new()); + } + let Json(request) = match payload { + Ok(payload) => payload, + Err(error) => { + return error_response(error.status(), "request must be JSON", Vec::new()); + } + }; + match tokio::task::spawn_blocking(move || crate::operator::create_local_group(request)).await { + Ok(Ok(result)) => Json(result).into_response(), + Ok(Err(error)) => operator_error(error), + Err(_) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "operator action failed", + Vec::new(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mutation_routes_require_token_and_json() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router("test-token".to_string())) + .await + .unwrap(); + }); + let client = reqwest::Client::new(); + let links = format!("http://{address}/api/links"); + + let missing = client + .post(&links) + .json(&serde_json::json!({"sessions":["alice","bob"]})) + .send() + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::FORBIDDEN); + + let wrong = client + .post(&links) + .header("X-Wire-Token", "wrong") + .json(&serde_json::json!({"sessions":["alice","bob"]})) + .send() + .await + .unwrap(); + assert_eq!(wrong.status(), StatusCode::FORBIDDEN); + + let text = client + .post(&links) + .header("X-Wire-Token", "test-token") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(text.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + + let sessions = client + .get(format!("http://{address}/api/sessions")) + .send() + .await + .unwrap(); + assert_eq!(sessions.status(), StatusCode::OK); + server.abort(); + } + + #[tokio::test] + async fn dashboard_assets_are_served_with_local_security_contract() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router("test-token".to_string())) + .await + .unwrap(); + }); + let client = reqwest::Client::new(); + let page = client + .get(format!("http://{address}/?token=test-token")) + .send() + .await + .unwrap(); + assert_eq!(page.status(), StatusCode::OK); + assert_eq!( + page.headers() + .get("X-Frame-Options") + .and_then(|value| value.to_str().ok()), + Some("DENY") + ); + let html = page.text().await.unwrap(); + assert!(html.contains("Wire operator")); + assert!(html.contains("Link selected")); + assert!(html.contains("Create group")); + assert!(html.contains("aria-labelledby=\"group-title\"")); + + let script = client + .get(format!("http://{address}/dashboard.js")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(!script.contains("http://")); + assert!(!script.contains("https://")); + assert!(!script.contains("innerHTML")); + server.abort(); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 14b09c4..1daf21c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -61,6 +61,22 @@ fn help_flag_lists_subcommands() { } } +#[test] +fn dash_web_rejects_terminal_json_mode() { + let home = fresh_home(); + let out = run(&home, &["dash", "--web", "--json"]); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("cannot be used with")); +} + +#[test] +fn dash_no_open_requires_web_mode() { + let home = fresh_home(); + let out = run(&home, &["dash", "--no-open"]); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("required arguments")); +} + #[test] fn whoami_before_init_degrades_when_piped() { // Contract change (v0.14.x): `wire whoami` with stdout piped auto-selects diff --git a/tests/e2e_operator_dashboard.rs b/tests/e2e_operator_dashboard.rs new file mode 100644 index 0000000..4fa92ea --- /dev/null +++ b/tests/e2e_operator_dashboard.rs @@ -0,0 +1,203 @@ +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use serde_json::{Value, json}; + +fn wire_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_wire")) +} + +fn wire(root: &Path, args: &[&str]) -> std::process::Output { + let output = Command::new(wire_bin()) + .args(args) + .env("WIRE_HOME", root) + .env("WIRE_HOME_FORCE", "1") + .env_remove("RUST_LOG") + .output() + .expect("spawn wire"); + assert!( + output.status.success(), + "wire {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn session_home(root: &Path, name: &str) -> PathBuf { + root.join("sessions") + .join("by-key") + .join(wire::session::by_key_dir_name( + &wire::session::sanitize_name(name), + )) +} + +fn add_live_lease(home: &Path, source: &str) { + wire::session_lifecycle::write_lease_at( + home, + "mcp", + std::process::id(), + time::OffsetDateTime::now_utc(), + Duration::from_secs(90), + env!("CARGO_PKG_VERSION"), + &wire_bin(), + source, + Some(Path::new("/work/operator-proof")), + ) + .unwrap(); +} + +struct Dashboard(Child); + +impl Drop for Dashboard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dashboard_links_two_and_materializes_one_shared_group() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("operator-root"); + std::fs::create_dir_all(&root).unwrap(); + + let relay = wire::relay_server::Relay::new(temp.path().join("relay")) + .await + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, relay.router()).await.ok() }); + let relay_url = format!("http://{relay_address}"); + + for name in ["alice", "bob", "carol"] { + wire( + &root, + &[ + "session", + "new", + name, + "--relay", + &relay_url, + "--no-daemon", + "--json", + ], + ); + } + add_live_lease(&session_home(&root, "alice"), "codex-cli"); + add_live_lease(&session_home(&root, "bob"), "goose"); + add_live_lease(&session_home(&root, "carol"), "claude-code"); + + let mut child = Command::new(wire_bin()) + .args(["dash", "--web", "--no-open"]) + .env("WIRE_HOME", &root) + .env("WIRE_HOME_FORCE", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut first_line = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut first_line) + .unwrap(); + let url = first_line + .split_once("dashboard: ") + .map(|(_, url)| url.trim().to_string()) + .expect("dashboard URL"); + let parsed_url = reqwest::Url::parse(&url).unwrap(); + assert_eq!(parsed_url.host_str(), Some("127.0.0.1")); + let token = parsed_url + .query_pairs() + .find(|(key, _)| key == "token") + .map(|(_, value)| value.into_owned()) + .unwrap(); + let origin = format!( + "{}://{}:{}", + parsed_url.scheme(), + parsed_url.host_str().unwrap(), + parsed_url.port().unwrap() + ); + let _dashboard = Dashboard(child); + let client = reqwest::Client::new(); + + let report: Value = client + .get(format!("{origin}/api/sessions")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let sessions = report["sessions"].as_array().unwrap(); + assert_eq!(sessions.len(), 3, "live inventory: {report}"); + assert!( + sessions + .iter() + .any(|session| session["agent_host"] == "goose") + ); + let ids: Vec = sessions + .iter() + .map(|session| session["id"].as_str().unwrap().to_string()) + .collect(); + + let linked = client + .post(format!("{origin}/api/links")) + .header("X-Wire-Token", &token) + .json(&json!({"sessions": [&ids[0], &ids[1]]})) + .send() + .await + .unwrap(); + assert!( + linked.status().is_success(), + "link failed: {}", + linked.text().await.unwrap() + ); + + let grouped = client + .post(format!("{origin}/api/groups")) + .header("X-Wire-Token", &token) + .json(&json!({ + "name": "operator-proof", + "creator": &ids[0], + "members": &ids, + })) + .send() + .await + .unwrap(); + assert!( + grouped.status().is_success(), + "group failed: {}", + grouped.text().await.unwrap() + ); + + for name in ["alice", "bob", "carol"] { + let groups = session_home(&root, name).join("config/wire/groups"); + assert_eq!( + std::fs::read_dir(groups).unwrap().count(), + 1, + "{name} should hold the shared group" + ); + } + + let second_home = sessions + .iter() + .find(|session| session["id"] == ids[1]) + .and_then(|_| { + ["alice", "bob", "carol"].iter().find(|name| { + let output = wire(&session_home(&root, name), &["whoami", "--json"]); + let who: Value = serde_json::from_slice(&output.stdout).unwrap(); + who["handle"] == ids[1] + }) + }) + .unwrap(); + let third_id = &ids[2]; + let peers: Value = serde_json::from_slice( + &wire(&session_home(&root, second_home), &["peers", "--json"]).stdout, + ) + .unwrap(); + assert!( + !peers.to_string().contains(third_id), + "group creation must not directly pair every member: {peers}" + ); +} From 851f60f2fb06564515abcb850bcdc8314824e5bc Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 01:31:42 -0700 Subject: [PATCH 07/39] fix: harden operator dashboard boundaries --- SESSION_LOG_2026_08_10.md | 66 ++ assets/operator-dashboard.js | 17 +- .../plans/2026-08-10-operator-dashboard.md | 660 ------------------ src/operator.rs | 40 +- src/operator_web.rs | 56 +- tests/e2e_operator_dashboard.rs | 1 + 6 files changed, 168 insertions(+), 672 deletions(-) create mode 100644 SESSION_LOG_2026_08_10.md delete mode 100644 docs/superpowers/plans/2026-08-10-operator-dashboard.md diff --git a/SESSION_LOG_2026_08_10.md b/SESSION_LOG_2026_08_10.md new file mode 100644 index 0000000..c3fd86e --- /dev/null +++ b/SESSION_LOG_2026_08_10.md @@ -0,0 +1,66 @@ +# Session log — 2026-08-10 + +## Goal + +Repair local Wire identity/daemon ambiguity and ship a one-machine operator dashboard for live agent sessions. The dashboard may view sessions, link exactly two, and create one shared group. Remote machines, retired-session management, history, and messaging remain deferred. + +## Root causes + +- Bare shell and background monitor processes used the `machine-default` identity because no session key reached them. +- One standalone daemon ran outside the managed all-session supervisor. +- Goose exposed its thread identity through `AGENT_SESSION_ID`, but Wire did not recognize that source. +- Existing MCP leases lacked enough safe metadata to render agent host, project, and uptime. + +## Changes + +- Resolve guarded Goose sessions when `AGENT=goose`; preserve Codex precedence and scrub adapter identity variables from child commands. +- Record lease acquisition time and working directory while remaining compatible with old leases. +- Collect only live, initialized, non-retired MCP leases. Expose no raw thread ID, token, or private path. +- Add explicit-home local pair and shared-group operations with postcondition checks. +- Add `wire dash --web [--no-open]`: loopback-only Axum server, per-launch 256-bit token, authenticated inventory and mutations, local Host/Origin checks, CSP/security headers, and confirmation-race protection. +- Add the Open Band operator UI with Codex, Claude, and Goose thread labels. + +## Live callers and producers + +- Caller: installed `wire dash --web` starts `operator_web::serve`, which calls inventory and topology operations. +- Producer: MCP startup writes `state/wire/leases/mcp-.json`; the dashboard reads active leases. +- Producer: `wire group create/invite/join` writes the same group into each selected session home. +- Producer: managed `wire daemon --all-sessions` supervises per-session workers. + +## Verification + +- `cargo fmt --check` +- `cargo clippy --all-targets -- -D warnings` +- Focused operator, web, CLI, group, and dashboard end-to-end tests. +- Full `cargo test`: exit 0 after review fixes; 666 library tests passed, one expected library ignore, and every enabled integration test passed. +- Dashboard end-to-end test: three live sessions including Goose, exact bilateral pair, one shared group, no full mesh. +- Playwright: desktop and 390 px mobile render, token removed from visible URL, no horizontal overflow, no console errors, assets/API successful. +- Playwright confirmation-race probe: selected session removed during polling, zero link POSTs, actionable notice rendered. +- Installed identity probes: schema v3.2, distinct `codex-cli` and `goose` session sources and session-keyed homes. +- Installed dashboard API and security headers exercised on loopback. + +## Daemon repair + +- Kept managed launchd supervisor PID `4613` and its active worker children. +- Stopped exact unmanaged processes: daemon `79368`, monitors `79416` and `75012`, and monitor wrapper `75010`. +- Postcondition: supervisor alive, `unmanaged_pids: []`, no stale binary or stale unmanaged sessions. +- No session data was deleted. + +## Review dispositions + +- Kept and fixed: DID-first verified-peer matching; authenticated inventory; local Host/Origin validation; confirmation snapshot across polling. +- Rejected with evidence: group invite replay concern (three-member end-to-end test passes); missing launch authorization (256-bit token already enforced). +- Cut: redundant 660-line implementation plan. Retained the concise design spec. +- Deferred: storage abstraction, cookie redemption, history/retirement, remote machines, extra browser scenarios. + +## Recovery note + +A browser race probe accidentally ran an older debug binary and linked `agate-starshine` to the `bubbling-kelp` session at `.../9583f4349f98ddea`. The exact bilateral pins were removed immediately with `wire forget-peer` on both homes; verification showed only each session's self-attestation remained. No files were purged. + +## Artifacts + +- `src/operator.rs` — live inventory and explicit-home topology operations. +- `src/operator_web.rs` — loopback HTTP server and security boundary. +- `assets/operator-dashboard.{html,css,js}` — operator interface. +- `tests/e2e_operator_dashboard.rs` — installed caller-path topology proof. +- `docs/superpowers/specs/2026-08-10-operator-dashboard-design.md` — approved product and architecture boundary. diff --git a/assets/operator-dashboard.js b/assets/operator-dashboard.js index 87b25e4..59c879a 100644 --- a/assets/operator-dashboard.js +++ b/assets/operator-dashboard.js @@ -5,7 +5,7 @@ const token = query.get("token") || ""; window.history.replaceState({}, "", window.location.pathname); - const state = { sessions: [], selected: new Set(), busy: false }; + const state = { sessions: [], selected: new Set(), confirmedPair: [], busy: false }; const rows = document.querySelector("#session-rows"); const tableWrap = document.querySelector("#table-wrap"); const loading = document.querySelector("#loading"); @@ -131,7 +131,10 @@ const scan = async () => { try { - const response = await fetch("/api/sessions", { cache: "no-store" }); + const response = await fetch("/api/sessions", { + cache: "no-store", + headers: { "X-Wire-Token": token } + }); if (!response.ok) throw new Error("Could not read live sessions."); const report = await response.json(); state.sessions = Array.isArray(report.sessions) ? report.sessions : []; @@ -167,14 +170,22 @@ linkButton.addEventListener("click", () => { const selected = selectedSessions(); if (selected.length !== 2) return; + state.confirmedPair = selected.map((session) => session.id); confirmCopy.textContent = `${selected[0].handle} and ${selected[1].handle} will trust each other on this machine.`; confirmDialog.showModal(); }); confirmLink.addEventListener("click", (event) => { event.preventDefault(); - const sessions = selectedSessions().map((session) => session.id); + const liveIds = new Set(state.sessions.map((session) => session.id)); + const sessions = [...state.confirmedPair]; confirmDialog.close(); + if (sessions.length !== 2 || sessions.some((id) => !liveIds.has(id))) { + showNotice("One of those sessions is no longer live. Select the pair again.", "error"); + state.confirmedPair = []; + return; + } + state.confirmedPair = []; void mutate("/api/links", { sessions }); }); diff --git a/docs/superpowers/plans/2026-08-10-operator-dashboard.md b/docs/superpowers/plans/2026-08-10-operator-dashboard.md deleted file mode 100644 index b1285f3..0000000 --- a/docs/superpowers/plans/2026-08-10-operator-dashboard.md +++ /dev/null @@ -1,660 +0,0 @@ -# Wire Operator Dashboard 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:** Repair Codex and Goose session identity resolution, then ship a localhost Wire dashboard that lists live local agent sessions, links two, and creates one shared group room. - -**Architecture:** Extend the existing Rust binary. A new operator domain module reads active MCP lifecycle leases and runs existing Wire commands against explicit session homes without a shell. A small Axum server exposes that domain to embedded Open Band HTML, CSS, and JavaScript through token-guarded loopback routes. - -**Tech Stack:** Rust 2024, Axum 0.7, Tokio, Serde, existing Wire session/group/pairing primitives, embedded HTML/CSS/vanilla JavaScript. - -## Global Constraints - -- Bind only to `127.0.0.1`; do not ship a configurable network host. -- Show only sessions with a live `mcp` lifecycle lease. -- Support 10–20 live sessions without pagination. -- Link exactly two selected sessions through the existing local-sister bilateral path. -- Create one shared group room from two or more selected sessions; do not create a full mesh. -- Keep messaging, history, retirement, remote machines, and network exposure out of scope. -- Require a random launch token on every mutation request. -- Never expose raw host session keys, relay slot tokens, private keys, or arbitrary filesystem paths. -- Do not edit `add_local_sister_core`; GitNexus rates its upstream impact CRITICAL. -- Preserve unrelated `AGENTS.md` changes and all files in the original working tree. -- Baseline note: `os_notify::tests::toast_dedup_public_api_suppresses_repeat` failed once under the parallel suite and passed alone. - ---- - -### Task 1: Guarded Goose identity and lifecycle metadata - -**Files:** -- Modify: `src/session.rs` -- Modify: `src/session_lifecycle.rs` -- Test: `src/session.rs` -- Test: `src/session_lifecycle.rs` - -**Interfaces:** -- Consumes: host environment variables already read by `resolve_session_key()`. -- Produces: `resolve_session_key() -> Option<(String, &'static str)>` with source `goose`; `LeaseRecord.started_at: Option` and `LeaseRecord.cwd: Option` for live inventory. - -- [ ] **Step 1: Add a failing guarded-Goose resolver test** - -Add a serial environment test beside the Codex adapter test: - -```rust -#[test] -fn resolve_session_key_goose_adapter_is_guarded_and_ordered() { - let _guard = crate::config::test_support::ENV_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let names = [ - "WIRE_SESSION_ID", - "CLAUDE_CODE_SESSION_ID", - "CODEX_SESSION_ID", - "CODEX_THREAD_ID", - "AGENT", - "AGENT_SESSION_ID", - "COPILOT_AGENT_SESSION_ID", - "VSCODE_GIT_REPOSITORY_ROOT", - ]; - let previous: Vec<_> = names - .iter() - .map(|name| (*name, std::env::var_os(name))) - .collect(); - unsafe { - for name in names { - std::env::remove_var(name); - } - } - unsafe { - std::env::set_var("AGENT", "goose"); - std::env::set_var("AGENT_SESSION_ID", "20260810_7"); - } - assert_eq!( - resolve_session_key(), - Some(("20260810_7".into(), "goose")) - ); - unsafe { std::env::set_var("AGENT", "another-host") }; - assert!(!matches!(resolve_session_key(), Some((key, _)) if key == "20260810_7")); - unsafe { - std::env::set_var("AGENT", "goose"); - std::env::set_var("AGENT_SESSION_ID", "${UNEXPANDED}"); - } - assert!(!matches!(resolve_session_key(), Some((key, _)) if key.contains("${"))); - unsafe { - for (name, value) in previous { - std::env::remove_var(name); - if let Some(value) = value { - std::env::set_var(name, value); - } - } - } -} -``` - -Keep the test's save/restore list synchronized with every adapter variable read by `resolve_session_key()`. - -- [ ] **Step 2: Run the resolver test and prove it fails** - -Run: - -```bash -cargo test session::tests::resolve_session_key_goose_adapter_is_guarded_and_ordered -- --exact -``` - -Expected: FAIL because `resolve_session_key()` does not return source `goose`. - -- [ ] **Step 3: Implement guarded Goose resolution** - -Add this branch after `CODEX_THREAD_ID` and before Copilot: - -```rust -if std::env::var("AGENT").ok().as_deref() == Some("goose") - && let Ok(value) = std::env::var("AGENT_SESSION_ID") - && valid_session_key(&value) -{ - return Some((value.trim().to_string(), "goose")); -} -``` - -Update session-source documentation, startup warnings, and every isolated child-command environment scrub to remove `AGENT_SESSION_ID` and `AGENT` when `WIRE_HOME` is pinned. - -- [ ] **Step 4: Add failing lifecycle metadata tests** - -Extend the lease round-trip test: - -```rust -assert_eq!(leases[0].started_at.as_deref(), Some("2023-11-14T22:13:20Z")); -assert_eq!(leases[0].cwd.as_deref(), Some("/work/wire")); -``` - -Add a compatibility test that parses a lease JSON document without either field and expects both fields to be `None`. - -- [ ] **Step 5: Run lifecycle tests and prove they fail** - -Run: - -```bash -cargo test session_lifecycle::tests --lib -``` - -Expected: compile failure because `LeaseRecord` lacks the two fields. - -- [ ] **Step 6: Implement additive lease metadata** - -Add optional fields with Serde defaults: - -```rust -#[serde(default)] -pub started_at: Option, -#[serde(default)] -pub cwd: Option, -``` - -New leases set `started_at` to the acquisition time and `cwd` to `std::env::current_dir()` when available. Heartbeats preserve both values. Old leases remain readable. - -Extend `write_lease_at` with one final path argument and update its internal callers: - -```rust -pub fn write_lease_at( - home: &Path, - role: &str, - pid: u32, - now: OffsetDateTime, - ttl: Duration, - wire_version: &str, - bin_path: &Path, - session_source: &str, - cwd: Option<&Path>, -) -> Result -``` - -- [ ] **Step 7: Run focused identity and lifecycle checks** - -Run: - -```bash -cargo test session::tests::resolve_session_key_codex_cli_adapter_and_priority -- --exact -cargo test session::tests::resolve_session_key_goose_adapter_is_guarded_and_ordered -- --exact -cargo test session_lifecycle::tests --lib -cargo fmt --check -``` - -Expected: all PASS. - -- [ ] **Step 8: Run GitNexus change detection and commit** - -Run: - -```bash -git add src/session.rs src/session_lifecycle.rs -node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope staged --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard -git commit -m "fix: resolve Goose sessions by agent session id" -``` - -Expected: identity startup flows affected; no unrelated files staged. - -### Task 2: Live operator inventory - -**Files:** -- Create: `src/operator.rs` -- Modify: `src/lib.rs` -- Test: `src/operator.rs` - -**Interfaces:** -- Consumes: `session::list_sessions()`, `session_lifecycle::active_leases_at()`, `dash::read_peers()`, session daemon state, and retire markers. -- Produces: `collect_live_sessions() -> anyhow::Result` and opaque `LiveSession.id` values used by mutation routes. - -- [ ] **Step 1: Write failing inventory fixture tests** - -Define the public JSON types: - -```rust -#[derive(Clone, Debug, Serialize)] -pub struct LiveSession { - pub id: String, - pub handle: String, - pub did: String, - pub emoji: String, - pub primary_hex: String, - pub agent_host: String, - pub project_dir: Option, - pub started_at: Option, - pub age_seconds: Option, - pub direct_link_count: usize, - pub health: String, -} - -#[derive(Clone, Debug, Serialize)] -pub struct LiveSessionReport { - pub schema: &'static str, - pub sessions: Vec, -} -``` - -Create temp homes for: live MCP lease, live daemon-only lease, expired MCP lease, retired MCP home, and live MCP lease with a dead PID. Assert only the first appears. - -- [ ] **Step 2: Run the inventory test and prove it fails** - -Run: - -```bash -cargo test operator::tests::inventory_includes_only_live_mcp_sessions -- --exact -``` - -Expected: compile failure because `operator` is absent. - -- [ ] **Step 3: Implement the inventory producer** - -Use a testable internal function: - -```rust -fn collect_live_from( - sessions: &[crate::session::SessionInfo], - now: time::OffsetDateTime, - is_alive: impl Fn(u32) -> bool + Copy, -) -> anyhow::Result -``` - -Rules: - -- require an initialized DID and handle; -- reject retired homes; -- require at least one active lease with `role == "mcp"`; -- derive `agent_host` from the newest MCP lease's `session_source`; -- derive project and start metadata from that lease, then fall back to `SessionInfo.cwd`; -- count direct peers with `dash::read_peers`; -- map daemon running and sync age to `healthy`, `sync-stale`, or `daemon-down`; -- sort by handle. - -The opaque ID is the registered session name/home key already returned by `list_sessions()`, never a raw host thread ID. - -- [ ] **Step 4: Add negative disclosure assertions** - -Serialize a report and assert it excludes: - -```rust -assert!(!json.contains("AGENT_SESSION_ID")); -assert!(!json.contains("slot_token")); -assert!(!json.contains("private.key")); -``` - -- [ ] **Step 5: Run inventory checks** - -Run: - -```bash -cargo test operator::tests --lib -cargo test dash::tests --lib -cargo fmt --check -``` - -Expected: all PASS; existing `wire dash --json` shape stays green. - -- [ ] **Step 6: Run GitNexus change detection and commit** - -Run: - -```bash -git add src/operator.rs src/lib.rs -node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope staged --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard -git commit -m "feat: collect live operator sessions" -``` - -Expected: new operator inventory plus module export only. - -### Task 3: Explicit-home link and group operations - -**Files:** -- Continue: `src/operator.rs` created in Task 2 -- Test: `src/operator.rs` -- Test: `tests/e2e_group.rs` - -**Interfaces:** -- Consumes: live opaque session IDs and the current Wire executable. -- Produces: `link_local_sessions(request) -> Result` and `create_local_group(request) -> Result`. - -- [ ] **Step 1: Write failing validation tests** - -Define request and result types: - -```rust -#[derive(Debug, Deserialize)] -pub struct LinkRequest { pub sessions: Vec } - -#[derive(Debug, Deserialize)] -pub struct GroupRequest { - pub name: String, - pub creator: String, - pub members: Vec, -} - -#[derive(Debug, Serialize)] -pub struct MutationResult { - pub ok: bool, - pub message: String, - pub changed_sessions: Vec, -} -``` - -Assert link rejects one, three, duplicate, unknown, retired, and non-live IDs. Assert group rejects an empty name, fewer than two distinct members, unknown creator, and creator absent from members. - -- [ ] **Step 2: Run validation tests and prove they fail** - -Run: - -```bash -cargo test operator::tests::link_validation_rejects_invalid_selection -- --exact -cargo test operator::tests::group_validation_rejects_invalid_selection -- --exact -``` - -Expected: compile failure because mutation functions are absent. - -- [ ] **Step 3: Implement one explicit-home command runner** - -The runner calls the current Wire executable directly, never `sh -c`: - -```rust -fn run_wire_at(home: &Path, args: &[&str]) -> anyhow::Result -``` - -Set `WIRE_HOME`, `WIRE_HOME_FORCE=1`, and `WIRE_QUIET_AUTOSESSION=1`. Remove every session adapter variable, including `AGENT`, `AGENT_SESSION_ID`, `CODEX_THREAD_ID`, and existing Claude/Codex/Copilot/VS Code names. Require a successful exit and parse one JSON value from stdout. Cap captured stdout and stderr at 256 KiB before including sanitized errors. - -- [ ] **Step 4: Implement bilateral link through the existing caller** - -Resolve both IDs from a fresh live inventory. Run from A's explicit home: - -```text -wire add --local-sister --json -``` - -Then read both homes' trust state and require `VERIFIED` in both directions. If already verified, return an idempotent success without launching a child. - -Do not modify `add_local_sister_core`. - -- [ ] **Step 5: Implement shared group materialization** - -From the creator home: - -```text -wire group create --json -wire group invite --json -``` - -For every other selected home: - -```text -wire group join --json -``` - -Verify `/config/wire/groups/.json` exists and parses for every selected member. Return the completed/failed boundary on error. Do not call local pairing and do not create a full mesh. - -- [ ] **Step 6: Add an end-to-end local topology test** - -Extend the existing hermetic group relay fixture to create three session homes, acquire live MCP leases, create a dashboard group, and assert: - -```rust -assert!(group_exists(&alice, &group_id)); -assert!(group_exists(&bob, &group_id)); -assert!(group_exists(&carol, &group_id)); -assert!(!directly_paired(&bob, &carol)); -``` - -Add a two-session link case that checks bilateral `VERIFIED` state. - -- [ ] **Step 7: Run topology checks** - -Run: - -```bash -cargo test operator::tests --lib -cargo test --test e2e_group -cargo test --test stress_within_system pair_all_local_mesh_pairs_every_sister_session_v0_6_0 -- --exact -cargo fmt --check -``` - -Expected: all PASS, including the untouched legacy pairing path. - -- [ ] **Step 8: Run GitNexus change detection and commit** - -Run: - -```bash -git add src/operator.rs tests/e2e_group.rs -node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope staged --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard -git commit -m "feat: add local topology operations" -``` - -Expected: operator and group test flows affected; CRITICAL pairing core unchanged. - -### Task 4: Loopback server and Open Band interface - -**Files:** -- Create: `src/operator_web.rs` -- Create: `assets/operator-dashboard.html` -- Create: `assets/operator-dashboard.css` -- Create: `assets/operator-dashboard.js` -- Modify: `src/lib.rs` -- Modify: `src/cli/mod.rs` -- Modify: `src/cli/dash.rs` -- Test: `src/operator_web.rs` -- Test: `tests/cli.rs` - -**Interfaces:** -- Consumes: `operator::collect_live_sessions`, `operator::link_local_sessions`, and `operator::create_local_group`. -- Produces: `serve(ServeOptions) -> anyhow::Result<()>`; CLI flags `wire dash --web --no-open`. - -- [ ] **Step 1: Write failing route-security tests** - -Build the router with a fixed test token and assert: - -```rust -assert_eq!(post_json("/api/links", None, body).status(), StatusCode::FORBIDDEN); -assert_eq!(post_json("/api/links", Some("wrong"), body).status(), StatusCode::FORBIDDEN); -assert_eq!(post_text("/api/links", "test-token", body).status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); -assert_eq!(get("/api/sessions").status(), StatusCode::OK); -``` - -- [ ] **Step 2: Run route tests and prove they fail** - -Run: - -```bash -cargo test operator_web::tests --lib -``` - -Expected: compile failure because `operator_web` is absent. - -- [ ] **Step 3: Implement server state and routes** - -Define: - -```rust -pub struct ServeOptions { pub open_browser: bool } - -struct AppState { - token: String, -} -``` - -Routes: - -- `GET /` embeds the three assets and injects no secret into logs; -- `GET /api/sessions` returns `LiveSessionReport`; -- `POST /api/links` and `POST /api/groups` require `application/json` and `X-Wire-Token`; -- domain validation errors map to 400, vanished live sessions to 409, token errors to 403, and internal failures to sanitized 500 responses. - -Bind with `TcpListener::bind((Ipv4Addr::LOCALHOST, 0))`. Print the complete tokenized URL before opening the browser. Browser-open failure prints a warning but leaves the server alive. - -- [ ] **Step 4: Add CLI flags and dispatch** - -Extend `Command::Dash` and `DashArgs`: - -```rust -#[arg(long, conflicts_with_all = ["watch", "json", "retire_idle"])] -web: bool, -#[arg(long, requires = "web")] -no_open: bool, -``` - -`cmd_dash` enters the Axum runtime only for `--web`; every existing terminal path stays unchanged. - -- [ ] **Step 5: Build the Open Band browser client** - -The HTML contains semantic table, empty, loading, error, confirmation, and group-dialog states. CSS uses existing Wire tokens: - -```css -:root { - --paper: #eee3ce; - --paper-shadow: #d9c8a7; - --ink: #241712; - --frame: #5b1a2e; - --frame-deep: #401020; - --dial: #8fb04a; - --phosphor: #7fffb0; - --phosphor-bg: #0b130d; -} -``` - -JavaScript reads the token from the initial query string, removes it from the visible URL with `history.replaceState`, polls every two seconds, preserves still-live selections, and sends the token only in the custom header. Buttons enforce exact selection cardinality before requests. - -- [ ] **Step 6: Add CLI and asset contract tests** - -Add tests that: - -- `wire dash --web --json` fails argument parsing; -- `wire dash --no-open` fails without `--web`; -- embedded HTML references both mutation actions and accessible dialog labels; -- JavaScript contains no remote URL and no `innerHTML` assignment from API data; -- the server reports a `127.0.0.1` URL. - -- [ ] **Step 7: Run server and CLI checks** - -Run: - -```bash -cargo test operator_web::tests --lib -cargo test --test cli dash -cargo test operator::tests --lib -cargo fmt --check -cargo clippy --all-targets -- -D warnings -``` - -Expected: all PASS. - -- [ ] **Step 8: Run GitNexus change detection and commit** - -Run: - -```bash -git add src/operator_web.rs src/lib.rs src/cli/mod.rs src/cli/dash.rs assets/operator-dashboard.html assets/operator-dashboard.css assets/operator-dashboard.js tests/cli.rs -node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope staged --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard -git commit -m "feat: add localhost operator dashboard" -``` - -Expected: dashboard CLI and new web flows only. - -### Task 5: Installed runtime, live browser proof, and evidence - -**Files:** -- Create: `SESSION_LOG_2026_08_10.md` - -**Interfaces:** -- Consumes: built `wire` binary, current Codex `CODEX_THREAD_ID`, a Goose STDIO extension environment, supervisor state, and the real browser. -- Produces: installed working binary, managed daemon topology, live dashboard proof, and persisted session evidence. - -- [ ] **Step 1: Run complete deterministic verification** - -Run: - -```bash -cargo fmt --check -cargo clippy --all-targets -- -D warnings -cargo test operator::tests --lib -cargo test operator_web::tests --lib -cargo test --test cli -cargo test --test e2e_group -cargo test -cargo test os_notify::tests::toast_dedup_public_api_suppresses_repeat -- --exact -``` - -Record pass/fail counts and whether the known parallel-only toast failure recurs. - -- [ ] **Step 2: Install through the repository path** - -Run the repository's documented local install command after inspecting `install.sh` for its exact binary destination. Verify: - -```bash -which wire -wire --version -``` - -The resolved binary must be the freshly built branch artifact or its installed copy. - -- [ ] **Step 3: Verify Codex and Goose identity resolution in situ** - -Run the installed binary under explicit representative host signals: - -```bash -CODEX_THREAD_ID="$CODEX_THREAD_ID" wire whoami --json -AGENT=goose AGENT_SESSION_ID=wire-goose-proof wire whoami --json -``` - -Verify source labels `codex-cli` and `goose`, distinct config homes, schema v3.2+, and suffixed DIDs. Do not print private keys or relay tokens in the session log. - -- [ ] **Step 4: Repair daemon topology without wildcard kills** - -Read `wire supervisor --json`, role PID files, parent PIDs, and each candidate's Wire home. Stop only processes that are all of: - -- daemon or monitor role; -- parent PID 1 or otherwise outside the supervisor tree; -- serving a home already owned by the managed supervisor or a machine-default manual start; -- not the active MCP server. - -Restart through the existing service manager. Verify the supervisor is alive, workers stay within its cap, and no unmanaged daemon serves the active home. - -- [ ] **Step 5: Run the real localhost dashboard** - -Start: - -```bash -wire dash --web --no-open -``` - -Capture the printed tokenized localhost URL without committing it. Drive the real page in Playwright: load, inspect console and failed requests, verify only live rows render, select two fixture sessions, link them, create a group from selected fixtures, refresh, and confirm topology changes. - -Use temporary session homes and a local-only relay for mutation proof; never pair or group unrelated real sessions during verification. - -- [ ] **Step 6: Run rendered-page audit** - -Check desktop and narrow widths, keyboard selection, focus visibility, dialog labels, loading/empty/error states, overflow, console errors, and failed network requests. Fix only defects that block the approved success criteria or accessibility floor. - -- [ ] **Step 7: Run independent semantic and AMANALAP reviews** - -Build the required review packet with goal, boundaries, success criteria, diff, named CLI/browser callers, exact verification, and assumptions. Run one fresh read-only semantic review through the build-loop reviewer. Send its findings through a separate AMANALAP cut review. Fix surviving BLOCKER/MAJOR findings, remove CUT work, and rerun affected checks. - -- [ ] **Step 8: Write the session log** - -Record: - -- root causes and why earlier `wire up` repairs targeted fallback identities; -- Codex and Goose adapter evidence; -- files changed and named callers; -- exact verification results; -- unmanaged processes stopped and whether recovery is possible; -- semantic-review findings and AMANALAP dispositions; -- deferred remote-machine registry and session retirement work. - -- [ ] **Step 9: Run final GitNexus check and commit** - -Run: - -```bash -git diff --check -git status --short -git add SESSION_LOG_2026_08_10.md -node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope staged --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard -node /Users/laul_pogan/Source/wire/.gitnexus/run.cjs detect-changes --scope compare --base-ref main --repo /Users/laul_pogan/Source/wire/.worktrees/operator-dashboard -git commit -m "docs: record operator dashboard verification" -``` - -Expected: only approved feature files and evidence commits on `feat/operator-dashboard`; unrelated `AGENTS.md` remains uncommitted. diff --git a/src/operator.rs b/src/operator.rs index 2fba02c..20ec9b4 100644 --- a/src/operator.rs +++ b/src/operator.rs @@ -174,11 +174,15 @@ fn has_verified_peer( owner.handle.as_deref(), ) .iter() - .any(|row| { - row.tier == "VERIFIED" - && (peer.did.as_deref() == Some(row.did.as_str()) - || peer.handle.as_deref() == Some(row.handle.as_str())) - }) + .any(|row| row.tier == "VERIFIED" && peer_row_matches(row, peer)) +} + +fn peer_row_matches(row: &crate::dash::PeerRow, peer: &crate::session::SessionInfo) -> bool { + if row.did.is_empty() { + peer.handle.as_deref() == Some(row.handle.as_str()) + } else { + peer.did.as_deref() == Some(row.did.as_str()) + } } fn bilateral_verified( @@ -521,6 +525,32 @@ mod tests { assert!(matches!(error, OperatorError::Conflict(_))); } + #[test] + fn verified_peer_match_never_overrides_a_mismatched_did_with_handle() { + let peer = crate::session::SessionInfo { + name: "same-handle".to_string(), + cwd: None, + home_dir: Path::new("/tmp/same-handle").to_path_buf(), + did: Some("did:wire:same-handle-11111111".to_string()), + handle: Some("same-handle".to_string()), + daemon_running: true, + character: None, + }; + let row = crate::dash::PeerRow { + handle: peer.handle.clone().unwrap(), + did: "did:wire:different-peer-22222222".to_string(), + tier: "VERIFIED".to_string(), + }; + + assert!(!peer_row_matches(&row, &peer)); + + let legacy_row = crate::dash::PeerRow { + did: String::new(), + ..row + }; + assert!(peer_row_matches(&legacy_row, &peer)); + } + #[test] fn group_validation_rejects_invalid_selection() { let live = vec![live("alice"), live("bob"), live("carol")]; diff --git a/src/operator_web.rs b/src/operator_web.rs index b62baf6..27b55ac 100644 --- a/src/operator_web.rs +++ b/src/operator_web.rs @@ -48,7 +48,7 @@ fn open_browser(url: &str) -> std::io::Result<()> { } use axum::extract::rejection::JsonRejection; use axum::extract::{Json, State}; -use axum::http::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE}; +use axum::http::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE, HOST, ORIGIN}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::middleware; use axum::response::{Html, IntoResponse, Response}; @@ -105,10 +105,27 @@ async fn security_headers(mut response: Response) -> Response { } fn authorized(headers: &HeaderMap, state: &AppState) -> bool { - headers + let token_matches = headers .get("X-Wire-Token") .and_then(|value| value.to_str().ok()) - == Some(state.token.as_str()) + == Some(state.token.as_str()); + token_matches && local_browser_request(headers) +} + +fn local_browser_request(headers: &HeaderMap) -> bool { + let Some(authority) = headers.get(HOST).and_then(|value| value.to_str().ok()) else { + return false; + }; + let Ok(host_url) = reqwest::Url::parse(&format!("http://{authority}")) else { + return false; + }; + if !matches!(host_url.host_str(), Some("127.0.0.1" | "localhost")) { + return false; + } + headers + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .is_none_or(|origin| origin.trim_end_matches('/') == format!("http://{authority}")) } fn error_response(status: StatusCode, message: &str, changed_sessions: Vec) -> Response { @@ -146,7 +163,10 @@ fn operator_error(error: crate::operator::OperatorError) -> Response { } } -async fn get_sessions() -> Response { +async fn get_sessions(State(state): State, headers: HeaderMap) -> Response { + if !authorized(&headers, &state) { + return error_response(StatusCode::FORBIDDEN, "invalid launch token", Vec::new()); + } match tokio::task::spawn_blocking(crate::operator::collect_live_sessions).await { Ok(Ok(report)) => Json(report).into_response(), _ => error_response( @@ -254,7 +274,35 @@ mod tests { .send() .await .unwrap(); + assert_eq!(sessions.status(), StatusCode::FORBIDDEN); + + let sessions = client + .get(format!("http://{address}/api/sessions")) + .header("X-Wire-Token", "test-token") + .send() + .await + .unwrap(); assert_eq!(sessions.status(), StatusCode::OK); + + let rebound = client + .post(&links) + .header("Host", "attacker.example") + .header("X-Wire-Token", "test-token") + .json(&serde_json::json!({"sessions":["alice","bob"]})) + .send() + .await + .unwrap(); + assert_eq!(rebound.status(), StatusCode::FORBIDDEN); + + let cross_origin = client + .post(&links) + .header("Origin", "https://attacker.example") + .header("X-Wire-Token", "test-token") + .json(&serde_json::json!({"sessions":["alice","bob"]})) + .send() + .await + .unwrap(); + assert_eq!(cross_origin.status(), StatusCode::FORBIDDEN); server.abort(); } diff --git a/tests/e2e_operator_dashboard.rs b/tests/e2e_operator_dashboard.rs index 4fa92ea..2b1e985 100644 --- a/tests/e2e_operator_dashboard.rs +++ b/tests/e2e_operator_dashboard.rs @@ -123,6 +123,7 @@ async fn dashboard_links_two_and_materializes_one_shared_group() { let report: Value = client .get(format!("{origin}/api/sessions")) + .header("X-Wire-Token", &token) .send() .await .unwrap() From d6c7cf42fc9738f125f6422ece90aa6974b8e62d Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 09:01:36 -0700 Subject: [PATCH 08/39] fix: retain dashboard token across reloads --- SESSION_LOG_2026_08_10.md | 2 ++ assets/operator-dashboard.js | 4 +++- src/operator_web.rs | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/SESSION_LOG_2026_08_10.md b/SESSION_LOG_2026_08_10.md index c3fd86e..9fb6dff 100644 --- a/SESSION_LOG_2026_08_10.md +++ b/SESSION_LOG_2026_08_10.md @@ -57,6 +57,8 @@ Repair local Wire identity/daemon ambiguity and ship a one-machine operator dash A browser race probe accidentally ran an older debug binary and linked `agate-starshine` to the `bubbling-kelp` session at `.../9583f4349f98ddea`. The exact bilateral pins were removed immediately with `wire forget-peer` on both homes; verification showed only each session's self-attestation remained. No files were purged. +After the first installed launch, refreshing the clean URL lost the in-memory launch token and left the inventory request unauthorized. The browser now stores the token in per-tab `sessionStorage` before removing it from the visible URL. Installed Playwright proof showed 34 rows before and after reload, no notice, and zero console errors. + ## Artifacts - `src/operator.rs` — live inventory and explicit-home topology operations. diff --git a/assets/operator-dashboard.js b/assets/operator-dashboard.js index 59c879a..f2cfe2a 100644 --- a/assets/operator-dashboard.js +++ b/assets/operator-dashboard.js @@ -2,7 +2,9 @@ "use strict"; const query = new URLSearchParams(window.location.search); - const token = query.get("token") || ""; + const queryToken = query.get("token") || ""; + if (queryToken) window.sessionStorage.setItem("wire-launch-token", queryToken); + const token = queryToken || window.sessionStorage.getItem("wire-launch-token") || ""; window.history.replaceState({}, "", window.location.pathname); const state = { sessions: [], selected: new Set(), confirmedPair: [], busy: false }; diff --git a/src/operator_web.rs b/src/operator_web.rs index 27b55ac..2c996c9 100644 --- a/src/operator_web.rs +++ b/src/operator_web.rs @@ -345,6 +345,7 @@ mod tests { assert!(!script.contains("http://")); assert!(!script.contains("https://")); assert!(!script.contains("innerHTML")); + assert!(script.contains("sessionStorage")); server.abort(); } } From e54098a414577430a3fb05ab8047369afc351c8f Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 09:15:57 -0700 Subject: [PATCH 09/39] docs: design fleet-ready session provenance --- ...6-08-10-fleet-session-provenance-design.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-fleet-session-provenance-design.md diff --git a/docs/superpowers/specs/2026-08-10-fleet-session-provenance-design.md b/docs/superpowers/specs/2026-08-10-fleet-session-provenance-design.md new file mode 100644 index 0000000..7da790d --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-fleet-session-provenance-design.md @@ -0,0 +1,160 @@ +# Fleet-ready session provenance + +Date: 2026-08-10 +Status: approved design +Implementation scope: one machine + +## Goal + +Make each live dashboard row explain what the agent is, where it runs, and what work it owns. Preserve enough provenance for a future cross-machine dashboard without adding remote collection now. + +Success means an operator can distinguish the harness, identity source, machine, repository, branch, worktree, and working directory. Missing facts remain unknown. Wire never presents an inference as an observed fact. + +## Boundaries + +This slice: + +- enriches active local MCP sessions; +- records richer metadata for new leases; +- recovers safe facts from old live processes; +- adds compact rows with expandable details; +- keeps the server bound to loopback. + +This slice does not: + +- collect from another machine; +- spawn, stop, supervise, or budget agents; +- add task boards or workflow state; +- retire old identities; +- create permanent boss identities; +- depend on the public relay. + +Remote aggregation, role delegation, and fleet control require a separate design after relay staleness and `daemon_seen: false` pass a sustained pressure test. + +## Correct the data model + +`session_source` records how Wire resolved an identity. It does not identify the agent harness. The dashboard must stop using it as the Agent column. + +Each live session gains four separate descriptors. + +### Machine + +- stable machine fingerprint already available to Wire; +- operator-facing hostname; +- operating system; +- architecture; +- Wire version. + +The local collector emits this descriptor now. A future fleet collector can merge reports by machine fingerprint without changing the session shape. + +### Harness + +- normalized kind: `codex-cli`, `chatgpt-codex`, `claude-code`, `claude-desktop`, `goose`, `cursor`, `vscode`, or `unknown`; +- display label; +- launch mode when observed: interactive, resume, app-server, or MCP host; +- confidence: `explicit`, `inferred`, or `unknown`; +- evidence class, never a raw command line. + +Harness inference may inspect a bounded parent-process chain. It must match executable boundaries, not arbitrary command substrings. + +### Identity + +- Wire persona and DID fingerprint; +- identity source from the existing session resolver; +- classification: session-keyed, explicit override, registry fallback, or machine-default; +- warning when a live agent uses machine-default. + +Machine-default stays visible because it marks an identity propagation defect. It must never appear as a harness name. + +### Project + +- repository name; +- repository root; +- process working directory; +- path relative to repository root; +- version-control branch; +- worktree name and worktree path; +- remote repository name or URL when present; +- confidence and evidence class. + +Project discovery walks from the working directory to the nearest Git root. It reads `.git`, `HEAD`, worktree metadata, and repository config from the filesystem. It does not run one `git` process per session and does not compute dirty state. + +## Source precedence + +For each field, prefer: + +1. explicit lease metadata written by the current MCP process; +2. session registry metadata; +3. cached live-process inference; +4. unknown. + +New leases record the machine, harness, and project snapshot at acquisition. Heartbeats preserve acquisition time and refresh facts that were unknown. + +Old live leases need immediate value. The collector takes one bounded process snapshot only when the active PID set changes, then caches results by PID. It must not spawn `ps` or `lsof` once per row or on every two-second browser poll. + +Platform adapters may recover different fields: + +- Linux reads `/proc` for parentage and working directory. +- macOS uses one bounded process snapshot and one bounded working-directory snapshot. +- Windows uses one bounded process snapshot; unavailable working directories remain unknown. + +Probe failure leaves fields unknown and never blocks the inventory. + +## API contract + +The live-session report advances to a new schema version. It keeps existing topology and health fields and adds structured `machine`, `harness`, `identity`, and `project` objects. + +The API exposes no raw thread ID, environment value, launch token, private key, slot token, or complete process command line. Full local paths remain available because this dashboard is operator-owned and loopback-only. A remote fleet endpoint must add explicit operator authentication and field policy before reusing them. + +## Interface + +The compact row shows: + +- persona and handle; +- harness label and confidence marker; +- repository and branch; +- machine label; +- identity warning when needed; +- topology count and health. + +Selecting the row does not toggle details. A separate detail control expands: + +- full DID fingerprint and identity source; +- process and launch mode; +- repository root, relative directory, branch, worktree, and remote; +- machine fingerprint, operating system, architecture, and Wire version; +- provenance for inferred fields. + +Unknown values render as `Unknown`, not a dash that could mean empty, unavailable, or not applicable. + +## Future role and fleet model + +Wire session DIDs stay disposable. A future conductor may assign a short-lived, signed lease that lets one session act for a logical role such as `ws2-critic`. The role is an operator-owned address, not a shared session private key. + +ACP or a harness supervisor owns process lifecycle. Wire carries role requests, decisions, acknowledgements, and results. Short-lived workers join an operator-approved room or star topology instead of forming an all-to-all mesh. + +No role or remote-control code lands in this slice. + +## Verification + +- Unit fixtures cover explicit, inferred, and unknown harnesses without substring false positives. +- Lease compatibility tests read old records and round-trip new metadata. +- Project fixtures cover a normal repository, linked worktree, repository subdirectory, detached HEAD, missing remote, and non-Git directory. +- Collector tests prove explicit metadata beats inference and probe failure fails open to unknown. +- A process-probe test proves work is bounded by snapshot, not session count. +- The dashboard end-to-end test includes Codex, Claude, Goose, machine-default identity, full project metadata, and unknown fields. +- Playwright verifies compact and expanded rows on desktop and mobile with zero console errors. +- Installed proof compares displayed harness and working directory against live process ancestry for sampled sessions. + +## Fleet reliability gate + +Before Wire becomes a fleet message bus, run at least twelve roles for forty-five minutes, restart the supervisor during active work, and prove: + +- no lost work orders; +- idempotent duplicate handling; +- bounded acknowledgement latency; +- recovery after supervisor restart; +- role takeover by a replacement session; +- healthy sync state and `daemon_seen: true`. + +Until that gate passes, files remain the durable work artifact and Wire remains an optional coordination channel. From 38c1dd2e6e53e6dc073897612ad62a59c7950a77 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 09:20:08 -0700 Subject: [PATCH 10/39] docs: plan fleet session provenance --- .../2026-08-10-fleet-session-provenance.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-fleet-session-provenance.md diff --git a/docs/superpowers/plans/2026-08-10-fleet-session-provenance.md b/docs/superpowers/plans/2026-08-10-fleet-session-provenance.md new file mode 100644 index 0000000..71f6897 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-fleet-session-provenance.md @@ -0,0 +1,141 @@ +# Fleet Session Provenance 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:** Make every live operator-dashboard row identify its harness, Wire identity source, machine, and Git project with explicit provenance and safe unknowns. + +**Architecture:** Add a focused `session_metadata` module containing serializable descriptors, pure harness inference, Git filesystem discovery, and a bounded cached process snapshot. New MCP leases persist descriptors; the operator collector merges lease, registry, and process evidence into schema v2. The browser renders compact summaries and a separate expandable detail row without changing selection behavior. + +**Tech stack:** Rust, Serde, Axum, macOS/Linux/Windows platform adapters, vanilla JavaScript/CSS, Playwright. + +**Approved design:** `docs/superpowers/specs/2026-08-10-fleet-session-provenance-design.md` + +--- + +## Task 1: Define metadata descriptors and harness inference + +**Files:** + +- Create: `src/session_metadata.rs` +- Modify: `src/lib.rs` +- Test: `src/session_metadata.rs` + +- [ ] Add failing unit tests for explicit, inferred, and unknown harnesses. Include exact executable-boundary cases for Codex CLI, ChatGPT Codex app-server, Claude Code, Goose, Cursor, VS Code, and a false-positive command argument containing `codex`. +- [ ] Run `cargo test session_metadata::tests::harness -- --nocapture` and confirm the new test target fails before implementation. +- [ ] Add the smallest descriptor model: + +```rust +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum MetadataConfidence { Explicit, Inferred, Unknown } + +pub struct MachineDescriptor { /* fingerprint, hostname, os, arch, wire_version */ } +pub struct HarnessDescriptor { /* kind, label, mode, confidence, evidence */ } +pub struct IdentityDescriptor { /* source, class, warning */ } +pub struct ProjectDescriptor { /* repo/root/cwd/relative/branch/revision/worktree/remote/provenance */ } +``` + +- [ ] Add a private `ProcessObservation` value and pure `infer_harness(session_source, ancestry)` function. Match executable basenames and explicit app-server arguments only; return evidence classes such as `lease-source`, `process-executable`, or `process-ancestry`, never a raw command line. +- [ ] Export the module from `src/lib.rs`. +- [ ] Run the focused test and `cargo fmt --check`. +- [ ] Commit: `feat: model session provenance` + +## Task 2: Discover Git projects without per-row subprocesses + +**Files:** + +- Create: `src/session_metadata.rs` (continued from Task 1) +- Test: `src/session_metadata.rs` + +- [ ] Add failing fixture tests for a normal repository, nested working directory, linked worktree, detached HEAD, missing remote, and non-Git directory. +- [ ] Run `cargo test session_metadata::tests::project -- --nocapture` and confirm failure. +- [ ] Implement `describe_project(cwd)` by walking ancestors for `.git`, resolving directory and `gitdir:` file forms, reading `HEAD`, `commondir`, and Git config. Do not invoke `git`. +- [ ] Normalize an origin remote to a descriptive repository name while retaining the local operator-visible remote string. For unknown facts, keep `Option::None` and `MetadataConfidence::Unknown`. +- [ ] Run focused project tests and `cargo fmt --check`. +- [ ] Commit: `feat: discover session projects` + +## Task 3: Capture one bounded process snapshot + +**Files:** + +- Create: `src/session_metadata.rs` (continued from Task 1) +- Modify: `src/platform.rs` +- Test: `src/session_metadata.rs` + +- [ ] Before modifying existing platform helpers, run GitNexus upstream impact analysis for each touched symbol and stop on a HIGH or CRITICAL result. +- [ ] Add failing tests proving one snapshot serves multiple sessions, the cache refreshes only when the sorted live PID set changes, ancestry is bounded, and probe failure returns unknown metadata. +- [ ] Run `cargo test session_metadata::tests::process -- --nocapture` and confirm failure. +- [ ] Implement a platform-neutral snapshot interface. On macOS, issue one bounded `ps` process-table read and one bounded `lsof` cwd read for the active PIDs. On Linux, read `/proc` for requested PIDs plus at most eight ancestors. On Windows, use one bounded process-table probe; leave unavailable cwd values unknown. +- [ ] Cache the snapshot by sorted active PID set. Keep only executable basename, parent PID, safe launch classification inputs, and cwd; never serialize raw arguments. +- [ ] Run focused tests plus `cargo test platform -- --nocapture`. +- [ ] Commit: `feat: snapshot live agent processes` + +## Task 4: Enrich leases with backward-compatible snapshots + +**Files:** + +- Modify: `src/session_lifecycle.rs` +- Test: `src/session_lifecycle.rs` + +- [ ] Run GitNexus upstream impact analysis for `LeaseRecord`, `write_lease_at`, `heartbeat_lease_at`, and `LeaseGuard::acquire_at`. Report the blast radius before editing. +- [ ] Add failing tests that deserialize the old lease shape, round-trip new optional machine/harness/project fields, and prove heartbeat preserves known acquisition metadata while filling only unknown facts. +- [ ] Run `cargo test session_lifecycle::tests -- --nocapture` and confirm failure. +- [ ] Add `#[serde(default)]` optional descriptors to `LeaseRecord`. Keep old records readable and preserve the existing lease schema unless compatibility requires a version union. +- [ ] At MCP lease acquisition, capture machine, harness, and project once. During heartbeat, retain explicit values and refresh fields that remain unknown. +- [ ] Update all test helpers and call sites intentionally; do not hide new arguments behind speculative builders. +- [ ] Run focused lease tests. +- [ ] Commit: `feat: persist session provenance in leases` + +## Task 5: Serve schema-v2 live inventory + +**Files:** + +- Modify: `src/operator.rs` +- Modify: `tests/e2e_operator_dashboard.rs` +- Test: `src/operator.rs` +- Test: `tests/e2e_operator_dashboard.rs` + +- [ ] Run GitNexus upstream impact analysis for `LiveSession`, `LiveSessionReport`, `collect_live_sessions`, and `collect_live_from`. Warn before editing if risk is HIGH or CRITICAL. +- [ ] Replace old assertions on `agent_host` and `project_dir` with failing assertions for structured `machine`, `harness`, `identity`, and `project` objects. Cover explicit lease precedence, inferred old leases, registry fallback, machine-default warning, and failed probes. +- [ ] Expand the end-to-end fixture with Codex, Claude, Goose, machine-default, full Git metadata, and unknown values. Confirm `/api/sessions` exposes no raw thread IDs, environment values, or command lines. +- [ ] Run `cargo test operator::tests -- --nocapture` and `cargo test --test e2e_operator_dashboard -- --nocapture`; confirm failures. +- [ ] Advance `LIVE_SESSION_SCHEMA` to `wire-live-sessions-v2`. Replace the two overloaded flat fields with the four descriptors while preserving session ID, persona, age, topology, and health. +- [ ] Merge facts in this order: explicit lease metadata, session registry metadata, cached live-process inference, unknown. Classify identity source independently from harness. +- [ ] Run focused unit and end-to-end tests. +- [ ] Commit: `feat: serve descriptive live sessions` + +## Task 6: Render compact rows and expandable details + +**Files:** + +- Modify: `assets/operator-dashboard.html` +- Modify: `assets/operator-dashboard.css` +- Modify: `assets/operator-dashboard.js` +- Modify: `src/operator_web.rs` +- Modify: `tests/e2e_operator_dashboard.rs` + +- [ ] Run GitNexus impact analysis for any existing Rust symbol changed in `operator_web.rs`. +- [ ] Add failing asset/API assertions for Harness, Project, Machine, Identity, and the detail control. Add browser assertions that row selection and detail expansion are independent. +- [ ] Run `cargo test operator_web::tests -- --nocapture` and the dashboard end-to-end test; confirm failure. +- [ ] Render compact columns: session, harness/confidence, repository/branch, machine, identity warning, links, and signal. Render `Unknown` for missing facts. +- [ ] Add an explicit details button with `aria-expanded` and a sibling detail row containing DID fingerprint, identity source/class, PID and launch mode, project paths/worktree/remote, machine fingerprint/platform/Wire version, and evidence classes. +- [ ] Preserve sessionStorage selection, link/group actions, responsive layout, keyboard access, and loopback-only presentation. +- [ ] Run focused tests and `cargo fmt --check`. +- [ ] Commit: `feat: show session provenance in dashboard` + +## Task 7: Full verification, live proof, review, and install + +**Files:** + +- Modify: `SESSION_LOG_2026_08_10.md` +- Modify only if a verified defect requires it: implementation files above + +- [ ] Run `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo test --all-targets --all-features`. +- [ ] Run GitNexus `detect_changes({scope: "compare", base_ref: "main"})`; verify only planned symbols and flows changed before the final commit. +- [ ] Build the release binary, stop only the prior dashboard process, install the verified binary with the repository’s existing install path, and relaunch the loopback dashboard. +- [ ] Use Playwright against the installed dashboard at desktop and mobile widths. Verify compact rows, expanded details, unknown rendering, selection persistence, link/group controls, and zero console errors. +- [ ] Sample at least Codex, Claude, and Goose live rows. Compare displayed harness and cwd against one fresh process snapshot; record evidence without exposing command lines. +- [ ] Run the build-loop semantic review. Then run a separate read-only AMANALAP scope-cut review. Apply only observed defects or missing approved success criteria, then rerun affected checks. +- [ ] Update `SESSION_LOG_2026_08_10.md` with the model correction, source precedence, caller/producer wiring, tests, live proof, and artifact catalog. +- [ ] Commit: `docs: record session provenance delivery` +- [ ] Push `feat/operator-dashboard`. Leave the verified dashboard open for the operator. Do not merge or delete the branch. From cc404b01394b27dc54a0e7da29f0bbe05c525f89 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 09:22:44 -0700 Subject: [PATCH 11/39] feat: model session provenance --- src/lib.rs | 1 + src/session_metadata.rs | 269 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 src/session_metadata.rs diff --git a/src/lib.rs b/src/lib.rs index 5d4728e..26531c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,6 +58,7 @@ pub mod send; pub mod service; pub mod session; pub mod session_lifecycle; +pub mod session_metadata; pub mod signing; pub mod sso_provider; pub mod tls; diff --git a/src/session_metadata.rs b/src/session_metadata.rs new file mode 100644 index 0000000..0452abe --- /dev/null +++ b/src/session_metadata.rs @@ -0,0 +1,269 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum MetadataConfidence { + Explicit, + Inferred, + Unknown, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct MachineDescriptor { + pub fingerprint: Option, + pub hostname: String, + pub os: String, + pub arch: String, + pub wire_version: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct HarnessDescriptor { + pub kind: String, + pub label: String, + pub mode: Option, + pub confidence: MetadataConfidence, + pub evidence: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct IdentityDescriptor { + pub source: String, + pub class: String, + pub warning: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct ProjectDescriptor { + pub name: Option, + pub root: Option, + pub cwd: Option, + pub relative_cwd: Option, + pub branch: Option, + pub revision: Option, + pub worktree_name: Option, + pub worktree_path: Option, + pub remote: Option, + pub confidence: MetadataConfidence, + pub evidence: String, +} + +impl ProjectDescriptor { + pub fn unknown(cwd: Option) -> Self { + Self { + name: None, + root: None, + cwd: cwd.map(|path| path.to_string_lossy().into_owned()), + relative_cwd: None, + branch: None, + revision: None, + worktree_name: None, + worktree_path: None, + remote: None, + confidence: MetadataConfidence::Unknown, + evidence: "unavailable".to_string(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ProcessObservation { + pub pid: u32, + pub parent_pid: Option, + pub executable: String, + pub arguments: Vec, + pub cwd: Option, +} + +fn harness( + kind: &str, + label: &str, + mode: Option<&str>, + confidence: MetadataConfidence, + evidence: &str, +) -> HarnessDescriptor { + HarnessDescriptor { + kind: kind.to_string(), + label: label.to_string(), + mode: mode.map(str::to_string), + confidence, + evidence: evidence.to_string(), + } +} + +pub(crate) fn infer_harness( + session_source: &str, + ancestry: &[ProcessObservation], +) -> HarnessDescriptor { + for process in ancestry { + let executable = std::path::Path::new(&process.executable) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&process.executable) + .to_ascii_lowercase(); + match executable.as_str() { + "codex" if process.arguments.iter().any(|arg| arg == "app-server") => { + return harness( + "chatgpt-codex", + "ChatGPT Codex", + Some("app-server"), + MetadataConfidence::Inferred, + "process-executable", + ); + } + "codex" => { + let mode = if process.arguments.iter().any(|arg| arg == "resume") { + "resume" + } else { + "interactive" + }; + return harness( + "codex-cli", + "Codex CLI", + Some(mode), + MetadataConfidence::Inferred, + "process-executable", + ); + } + "claude" => { + return harness( + "claude-code", + "Claude Code", + Some("interactive"), + MetadataConfidence::Inferred, + "process-executable", + ); + } + "goose" => { + return harness( + "goose", + "Goose", + Some("interactive"), + MetadataConfidence::Inferred, + "process-executable", + ); + } + "cursor" | "cursor.exe" => { + return harness( + "cursor", + "Cursor", + Some("app-server"), + MetadataConfidence::Inferred, + "process-executable", + ); + } + "code" | "code.exe" => { + return harness( + "vscode", + "VS Code", + Some("app-server"), + MetadataConfidence::Inferred, + "process-executable", + ); + } + _ => {} + } + } + + match session_source { + "claude-code" => harness( + "claude-code", + "Claude Code", + Some("mcp-host"), + MetadataConfidence::Explicit, + "lease-source", + ), + "codex-cli" => harness( + "codex-cli", + "Codex CLI", + Some("mcp-host"), + MetadataConfidence::Explicit, + "lease-source", + ), + "goose" => harness( + "goose", + "Goose", + Some("mcp-host"), + MetadataConfidence::Explicit, + "lease-source", + ), + "copilot-cli" => harness( + "copilot-cli", + "GitHub Copilot CLI", + Some("mcp-host"), + MetadataConfidence::Explicit, + "lease-source", + ), + "vscode-workspace" => harness( + "vscode", + "VS Code", + Some("mcp-host"), + MetadataConfidence::Explicit, + "lease-source", + ), + _ => harness( + "unknown", + "Unknown", + None, + MetadataConfidence::Unknown, + "unavailable", + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn process(pid: u32, parent_pid: Option, executable: &str, arguments: &[&str]) -> ProcessObservation { + ProcessObservation { + pid, + parent_pid, + executable: executable.to_string(), + arguments: arguments.iter().map(|value| (*value).to_string()).collect(), + cwd: None, + } + } + + #[test] + fn harness_prefers_explicit_source() { + let ancestry = vec![process(20, Some(10), "wire", &["mcp"]), process(10, None, "python", &[])]; + let harness = infer_harness("goose", &ancestry); + + assert_eq!(harness.kind, "goose"); + assert_eq!(harness.label, "Goose"); + assert_eq!(harness.confidence, MetadataConfidence::Explicit); + assert_eq!(harness.evidence, "lease-source"); + } + + #[test] + fn harness_infers_supported_executable_boundaries_and_modes() { + let cases = [ + (process(1, None, "codex", &["resume", "thread"]), "codex-cli", "resume"), + (process(1, None, "codex", &["app-server"]), "chatgpt-codex", "app-server"), + (process(1, None, "claude", &["--dangerously-skip-permissions"]), "claude-code", "interactive"), + (process(1, None, "goose", &["session"]), "goose", "interactive"), + (process(1, None, "Cursor", &["--type=utility"]), "cursor", "app-server"), + (process(1, None, "Code", &["--ms-enable-electron-run-as-node"]), "vscode", "app-server"), + ]; + + for (observation, expected_kind, expected_mode) in cases { + let harness = infer_harness("machine-default", &[observation]); + assert_eq!(harness.kind, expected_kind); + assert_eq!(harness.mode.as_deref(), Some(expected_mode)); + assert_eq!(harness.confidence, MetadataConfidence::Inferred); + } + } + + #[test] + fn harness_does_not_match_arguments_as_executables() { + let ancestry = vec![process(1, None, "python", &["worker.py", "codex", "claude"] )]; + let harness = infer_harness("machine-default", &ancestry); + + assert_eq!(harness.kind, "unknown"); + assert_eq!(harness.confidence, MetadataConfidence::Unknown); + assert_eq!(harness.evidence, "unavailable"); + } +} From f70257679832dec6e8c957d23849ad41b0b75424 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 09:24:19 -0700 Subject: [PATCH 12/39] feat: discover session projects --- src/session_metadata.rs | 261 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 252 insertions(+), 9 deletions(-) diff --git a/src/session_metadata.rs b/src/session_metadata.rs index 0452abe..7b863d7 100644 --- a/src/session_metadata.rs +++ b/src/session_metadata.rs @@ -68,6 +68,124 @@ impl ProjectDescriptor { } } +fn read_trimmed(path: &std::path::Path) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn origin_remote(config: &std::path::Path) -> Option { + let body = std::fs::read_to_string(config).ok()?; + let mut in_origin = false; + for raw_line in body.lines() { + let line = raw_line.trim(); + if line.starts_with('[') { + in_origin = line == "[remote \"origin\"]"; + } else if in_origin { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if key.trim() == "url" { + return Some(value.trim().to_string()); + } + } + } + None +} + +fn repository_name(remote: Option<&str>, root: &std::path::Path) -> Option { + remote + .and_then(|value| value.trim_end_matches('/').rsplit(['/', ':']).next()) + .map(|value| value.trim_end_matches(".git")) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + root.file_name() + .and_then(|value| value.to_str()) + .map(str::to_string) + }) +} + +pub fn describe_project(cwd: &std::path::Path) -> ProjectDescriptor { + let cwd = cwd.to_path_buf(); + let Some(root) = cwd + .ancestors() + .find(|candidate| candidate.join(".git").exists()) + .map(std::path::Path::to_path_buf) + else { + return ProjectDescriptor::unknown(Some(cwd)); + }; + + let dot_git = root.join(".git"); + let (gitdir, worktree_name) = if dot_git.is_dir() { + (dot_git.clone(), None) + } else { + let Some(pointer) = read_trimmed(&dot_git).and_then(|value| { + value + .strip_prefix("gitdir:") + .map(str::trim) + .map(str::to_string) + }) else { + return ProjectDescriptor::unknown(Some(cwd)); + }; + let path = std::path::PathBuf::from(pointer); + let gitdir = if path.is_absolute() { + path + } else { + root.join(path) + }; + let name = gitdir + .file_name() + .and_then(|value| value.to_str()) + .map(str::to_string); + (gitdir, name) + }; + let common_dir = read_trimmed(&gitdir.join("commondir")) + .map(std::path::PathBuf::from) + .map(|path| { + if path.is_absolute() { + path + } else { + gitdir.join(path) + } + }) + .unwrap_or_else(|| gitdir.clone()); + let head = read_trimmed(&gitdir.join("HEAD")); + let (branch, revision) = match head.as_deref() { + Some(value) if value.starts_with("ref: refs/heads/") => ( + Some(value.trim_start_matches("ref: refs/heads/").to_string()), + None, + ), + Some(value) => (None, Some(value.to_string())), + None => (None, None), + }; + let remote = origin_remote(&common_dir.join("config")); + let relative_cwd = cwd.strip_prefix(&root).ok().map(|path| { + if path.as_os_str().is_empty() { + ".".to_string() + } else { + path.to_string_lossy().into_owned() + } + }); + + ProjectDescriptor { + name: repository_name(remote.as_deref(), &root), + root: Some(root.to_string_lossy().into_owned()), + cwd: Some(cwd.to_string_lossy().into_owned()), + relative_cwd, + branch, + revision, + worktree_name, + worktree_path: dot_git + .is_file() + .then(|| root.to_string_lossy().into_owned()), + remote, + confidence: MetadataConfidence::Inferred, + evidence: "git-filesystem".to_string(), + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ProcessObservation { pub pid: u32, @@ -216,8 +334,15 @@ pub(crate) fn infer_harness( #[cfg(test)] mod tests { use super::*; + use std::fs; + use tempfile::tempdir; - fn process(pid: u32, parent_pid: Option, executable: &str, arguments: &[&str]) -> ProcessObservation { + fn process( + pid: u32, + parent_pid: Option, + executable: &str, + arguments: &[&str], + ) -> ProcessObservation { ProcessObservation { pid, parent_pid, @@ -229,7 +354,10 @@ mod tests { #[test] fn harness_prefers_explicit_source() { - let ancestry = vec![process(20, Some(10), "wire", &["mcp"]), process(10, None, "python", &[])]; + let ancestry = vec![ + process(20, Some(10), "wire", &["mcp"]), + process(10, None, "python", &[]), + ]; let harness = infer_harness("goose", &ancestry); assert_eq!(harness.kind, "goose"); @@ -241,12 +369,36 @@ mod tests { #[test] fn harness_infers_supported_executable_boundaries_and_modes() { let cases = [ - (process(1, None, "codex", &["resume", "thread"]), "codex-cli", "resume"), - (process(1, None, "codex", &["app-server"]), "chatgpt-codex", "app-server"), - (process(1, None, "claude", &["--dangerously-skip-permissions"]), "claude-code", "interactive"), - (process(1, None, "goose", &["session"]), "goose", "interactive"), - (process(1, None, "Cursor", &["--type=utility"]), "cursor", "app-server"), - (process(1, None, "Code", &["--ms-enable-electron-run-as-node"]), "vscode", "app-server"), + ( + process(1, None, "codex", &["resume", "thread"]), + "codex-cli", + "resume", + ), + ( + process(1, None, "codex", &["app-server"]), + "chatgpt-codex", + "app-server", + ), + ( + process(1, None, "claude", &["--dangerously-skip-permissions"]), + "claude-code", + "interactive", + ), + ( + process(1, None, "goose", &["session"]), + "goose", + "interactive", + ), + ( + process(1, None, "Cursor", &["--type=utility"]), + "cursor", + "app-server", + ), + ( + process(1, None, "Code", &["--ms-enable-electron-run-as-node"]), + "vscode", + "app-server", + ), ]; for (observation, expected_kind, expected_mode) in cases { @@ -259,11 +411,102 @@ mod tests { #[test] fn harness_does_not_match_arguments_as_executables() { - let ancestry = vec![process(1, None, "python", &["worker.py", "codex", "claude"] )]; + let ancestry = vec![process( + 1, + None, + "python", + &["worker.py", "codex", "claude"], + )]; let harness = infer_harness("machine-default", &ancestry); assert_eq!(harness.kind, "unknown"); assert_eq!(harness.confidence, MetadataConfidence::Unknown); assert_eq!(harness.evidence, "unavailable"); } + + fn write(path: &std::path::Path, body: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, body).unwrap(); + } + + #[test] + fn project_discovers_normal_repository_and_nested_cwd() { + let temp = tempdir().unwrap(); + let root = temp.path().join("descriptive-repo"); + let cwd = root.join("crates/wire/src"); + fs::create_dir_all(&cwd).unwrap(); + write( + &root.join(".git/HEAD"), + "ref: refs/heads/feature/provenance\n", + ); + write( + &root.join(".git/config"), + "[remote \"origin\"]\n\turl = git@github.com:SlanchaAI/wire.git\n", + ); + + let project = describe_project(&cwd); + + assert_eq!(project.name.as_deref(), Some("wire")); + assert_eq!(project.root.as_deref(), root.to_str()); + assert_eq!(project.relative_cwd.as_deref(), Some("crates/wire/src")); + assert_eq!(project.branch.as_deref(), Some("feature/provenance")); + assert_eq!( + project.remote.as_deref(), + Some("git@github.com:SlanchaAI/wire.git") + ); + assert_eq!(project.confidence, MetadataConfidence::Inferred); + assert_eq!(project.evidence, "git-filesystem"); + } + + #[test] + fn project_discovers_linked_worktree() { + let temp = tempdir().unwrap(); + let common = temp.path().join("repo/.git"); + let worktree = temp.path().join("operator-dashboard"); + let gitdir = common.join("worktrees/operator-dashboard"); + fs::create_dir_all(&worktree).unwrap(); + write( + &worktree.join(".git"), + &format!("gitdir: {}\n", gitdir.display()), + ); + write( + &gitdir.join("HEAD"), + "ref: refs/heads/feat/operator-dashboard\n", + ); + write(&gitdir.join("commondir"), "../..\n"); + write( + &common.join("config"), + "[remote \"origin\"]\n\turl = https://github.com/SlanchaAI/wire.git\n", + ); + + let project = describe_project(&worktree); + + assert_eq!(project.name.as_deref(), Some("wire")); + assert_eq!(project.worktree_name.as_deref(), Some("operator-dashboard")); + assert_eq!(project.worktree_path.as_deref(), worktree.to_str()); + assert_eq!(project.branch.as_deref(), Some("feat/operator-dashboard")); + } + + #[test] + fn project_handles_detached_missing_remote_and_non_git_directory() { + let temp = tempdir().unwrap(); + let detached = temp.path().join("detached"); + fs::create_dir_all(&detached).unwrap(); + write(&detached.join(".git/HEAD"), "0123456789abcdef\n"); + write(&detached.join(".git/config"), "[core]\n\tbare = false\n"); + + let project = describe_project(&detached); + assert_eq!(project.branch, None); + assert_eq!(project.revision.as_deref(), Some("0123456789abcdef")); + assert_eq!(project.remote, None); + + let plain = temp.path().join("plain"); + fs::create_dir_all(&plain).unwrap(); + let unknown = describe_project(&plain); + assert_eq!(unknown.cwd.as_deref(), plain.to_str()); + assert_eq!(unknown.name, None); + assert_eq!(unknown.confidence, MetadataConfidence::Unknown); + } } From 05c25c1692ac32bbf252e688c6b23141d4b2f0f9 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 09:27:19 -0700 Subject: [PATCH 13/39] feat: snapshot live agent processes --- src/session_metadata.rs | 307 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) diff --git a/src/session_metadata.rs b/src/session_metadata.rs index 7b863d7..fa0edd7 100644 --- a/src/session_metadata.rs +++ b/src/session_metadata.rs @@ -1,4 +1,8 @@ +use std::collections::HashMap; use std::path::PathBuf; +use std::process::Command; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -195,6 +199,264 @@ pub(crate) struct ProcessObservation { pub cwd: Option, } +pub(crate) const MAX_ANCESTORS: usize = 8; + +#[derive(Clone, Debug, Default)] +pub(crate) struct ProcessSnapshot { + observations: HashMap, +} + +impl ProcessSnapshot { + fn from_observations(observations: Vec) -> Self { + Self { + observations: observations + .into_iter() + .map(|observation| (observation.pid, observation)) + .collect(), + } + } + + pub(crate) fn ancestry(&self, pid: u32) -> Vec { + let mut rows = Vec::new(); + let mut current = Some(pid); + while let Some(pid) = current { + if rows.len() == MAX_ANCESTORS { + break; + } + let Some(observation) = self.observations.get(&pid) else { + break; + }; + rows.push(observation.clone()); + current = observation.parent_pid.filter(|parent| *parent != pid); + } + rows + } + + pub(crate) fn cwd(&self, pid: u32) -> Option { + self.observations + .get(&pid) + .and_then(|observation| observation.cwd.clone()) + } +} + +#[derive(Default)] +struct ProcessSnapshotCache { + pids: Vec, + snapshot: ProcessSnapshot, + initialized: bool, +} + +impl ProcessSnapshotCache { + fn get_or_refresh( + &mut self, + pids: &[u32], + mut probe: impl FnMut(&[u32]) -> Result, + ) -> ProcessSnapshot { + let mut key = pids.to_vec(); + key.sort_unstable(); + key.dedup(); + if !self.initialized || self.pids != key { + self.snapshot = probe(&key).unwrap_or_default(); + self.pids = key; + self.initialized = true; + } + self.snapshot.clone() + } +} + +static PROCESS_SNAPSHOT_CACHE: OnceLock> = OnceLock::new(); + +pub(crate) fn process_snapshot(pids: &[u32]) -> ProcessSnapshot { + PROCESS_SNAPSHOT_CACHE + .get_or_init(|| Mutex::new(ProcessSnapshotCache::default())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get_or_refresh(pids, capture_process_snapshot) +} + +#[cfg(target_os = "macos")] +fn capture_process_snapshot(pids: &[u32]) -> Result { + if pids.is_empty() { + return Ok(ProcessSnapshot::default()); + } + let mut ps = Command::new("ps"); + ps.args(["-axo", "pid=,ppid=,comm=,args="]); + let output = crate::platform::run_with_timeout(ps, Duration::from_secs(5)) + .filter(|output| output.status.success()) + .ok_or_else(|| "process table unavailable".to_string())?; + let body = String::from_utf8_lossy(&output.stdout); + let mut all = HashMap::new(); + for line in body.lines() { + let mut fields = line.split_whitespace(); + let (Some(pid), Some(parent_pid), Some(executable)) = + (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + let (Ok(pid), Ok(parent_pid)) = (pid.parse::(), parent_pid.parse::()) else { + continue; + }; + all.insert( + pid, + ProcessObservation { + pid, + parent_pid: (parent_pid != 0).then_some(parent_pid), + executable: executable.to_string(), + arguments: fields.map(str::to_string).collect(), + cwd: None, + }, + ); + } + + let pid_list = pids + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + let mut lsof = Command::new("lsof"); + lsof.args(["-a", "-d", "cwd", "-p", &pid_list, "-Fn"]); + if let Some(output) = crate::platform::run_with_timeout(lsof, Duration::from_secs(5)) { + let mut current_pid = None; + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some(value) = line.strip_prefix('p') { + current_pid = value.parse::().ok(); + } else if let (Some(pid), Some(path)) = (current_pid, line.strip_prefix('n')) + && let Some(observation) = all.get_mut(&pid) + { + observation.cwd = Some(PathBuf::from(path)); + } + } + } + + let mut selected = HashMap::new(); + for pid in pids { + let mut current = Some(*pid); + for _ in 0..MAX_ANCESTORS { + let Some(process_pid) = current else { break }; + let Some(observation) = all.get(&process_pid).cloned() else { + break; + }; + current = observation.parent_pid; + selected.entry(process_pid).or_insert(observation); + } + } + Ok(ProcessSnapshot { + observations: selected, + }) +} + +#[cfg(target_os = "linux")] +fn capture_process_snapshot(pids: &[u32]) -> Result { + let mut observations = HashMap::new(); + for root_pid in pids { + let mut current = Some(*root_pid); + for depth in 0..MAX_ANCESTORS { + let Some(pid) = current else { break }; + if observations.contains_key(&pid) { + break; + } + let proc_dir = PathBuf::from(format!("/proc/{pid}")); + let status = std::fs::read_to_string(proc_dir.join("status")) + .map_err(|error| format!("reading process {pid}: {error}"))?; + let parent_pid = status + .lines() + .find_map(|line| line.strip_prefix("PPid:")) + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value != 0); + let executable = std::fs::read_link(proc_dir.join("exe")) + .ok() + .and_then(|path| { + path.file_name() + .map(|value| value.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| "unknown".to_string()); + let arguments = std::fs::read(proc_dir.join("cmdline")) + .unwrap_or_default() + .split(|byte| *byte == 0) + .filter(|value| !value.is_empty()) + .map(|value| String::from_utf8_lossy(value).into_owned()) + .collect(); + let cwd = (depth == 0) + .then(|| std::fs::read_link(proc_dir.join("cwd")).ok()) + .flatten(); + observations.insert( + pid, + ProcessObservation { + pid, + parent_pid, + executable, + arguments, + cwd, + }, + ); + current = parent_pid; + } + } + Ok(ProcessSnapshot { observations }) +} + +#[cfg(windows)] +fn capture_process_snapshot(pids: &[u32]) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "PascalCase")] + struct WindowsProcess { + process_id: u32, + parent_process_id: u32, + executable_path: Option, + command_line: Option, + } + + let mut command = Command::new("powershell.exe"); + command.args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,ExecutablePath,CommandLine | ConvertTo-Json -Compress", + ]); + let output = crate::platform::run_with_timeout(command, Duration::from_secs(5)) + .filter(|output| output.status.success()) + .ok_or_else(|| "process table unavailable".to_string())?; + let mut rows: Vec = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("parsing process table: {error}"))?; + let all: HashMap = + rows.drain(..).map(|row| (row.process_id, row)).collect(); + let mut observations = HashMap::new(); + for root_pid in pids { + let mut current = Some(*root_pid); + for _ in 0..MAX_ANCESTORS { + let Some(pid) = current else { break }; + let Some(row) = all.get(&pid) else { break }; + let parent_pid = (row.parent_process_id != 0).then_some(row.parent_process_id); + observations.insert( + pid, + ProcessObservation { + pid, + parent_pid, + executable: row + .executable_path + .clone() + .unwrap_or_else(|| "unknown".to_string()), + arguments: row + .command_line + .as_deref() + .unwrap_or_default() + .split_whitespace() + .map(str::to_string) + .collect(), + cwd: None, + }, + ); + current = parent_pid; + } + } + Ok(ProcessSnapshot { observations }) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +fn capture_process_snapshot(_pids: &[u32]) -> Result { + Ok(ProcessSnapshot::default()) +} + fn harness( kind: &str, label: &str, @@ -509,4 +771,49 @@ mod tests { assert_eq!(unknown.name, None); assert_eq!(unknown.confidence, MetadataConfidence::Unknown); } + + #[test] + fn process_snapshot_cache_refreshes_only_when_pid_set_changes() { + let mut cache = ProcessSnapshotCache::default(); + let probes = std::cell::Cell::new(0); + let mut probe = |pids: &[u32]| { + probes.set(probes.get() + 1); + Ok(ProcessSnapshot::from_observations( + pids.iter() + .map(|pid| process(*pid, None, "wire", &["mcp"])) + .collect(), + )) + }; + + cache.get_or_refresh(&[20, 10, 20], &mut probe); + cache.get_or_refresh(&[10, 20], &mut probe); + assert_eq!(probes.get(), 1); + + cache.get_or_refresh(&[10, 30], &mut probe); + assert_eq!(probes.get(), 2); + } + + #[test] + fn process_ancestry_is_bounded() { + let observations = (1..=20) + .map(|pid| process(pid, (pid > 1).then_some(pid - 1), "parent", &[])) + .collect(); + let snapshot = ProcessSnapshot::from_observations(observations); + + let ancestry = snapshot.ancestry(20); + + assert_eq!(ancestry.len(), MAX_ANCESTORS); + assert_eq!(ancestry.first().map(|row| row.pid), Some(20)); + assert_eq!(ancestry.last().map(|row| row.pid), Some(13)); + } + + #[test] + fn process_probe_failure_fails_open() { + let mut cache = ProcessSnapshotCache::default(); + let snapshot = cache.get_or_refresh(&[42], |_| Err("probe failed".to_string())); + + assert!(snapshot.ancestry(42).is_empty()); + assert_eq!(snapshot.cwd(42), None); + assert_eq!(infer_harness("machine-default", &[]).kind, "unknown"); + } } From e3de0bb36cba8b9bb778154f7229f812d3285047 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 09:30:37 -0700 Subject: [PATCH 14/39] feat: persist session provenance in leases --- src/session_lifecycle.rs | 89 ++++++++++++++++++++++++++++++++++++++++ src/session_metadata.rs | 67 ++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/src/session_lifecycle.rs b/src/session_lifecycle.rs index 01f3f03..3bd42c3 100644 --- a/src/session_lifecycle.rs +++ b/src/session_lifecycle.rs @@ -23,6 +23,12 @@ pub struct LeaseRecord { pub started_at: Option, #[serde(default)] pub cwd: Option, + #[serde(default)] + pub machine: Option, + #[serde(default)] + pub harness: Option, + #[serde(default)] + pub project: Option, } pub fn lease_dir(home: &Path) -> PathBuf { @@ -74,6 +80,7 @@ pub fn write_lease_at( } let path = lease_dir(home).join(format!("{role}-{pid}.json")); let expires = now + time::Duration::seconds(ttl.as_secs() as i64); + let snapshot = crate::session_metadata::process_snapshot(&[pid]); let record = LeaseRecord { schema: LEASE_SCHEMA.to_string(), role: role.to_string(), @@ -85,6 +92,15 @@ pub fn write_lease_at( session_source: session_source.to_string(), started_at: Some(format_time(now)?), cwd: cwd.map(|path| path.to_string_lossy().into_owned()), + machine: Some(crate::session_metadata::machine_descriptor(wire_version)), + harness: Some(crate::session_metadata::harness_from_snapshot( + &snapshot, + pid, + session_source, + )), + project: Some(crate::session_metadata::project_from_snapshot( + &snapshot, pid, cwd, + )), }; persist_record(&path, &record)?; Ok(path) @@ -94,6 +110,30 @@ pub fn heartbeat_lease_at(path: &Path, now: OffsetDateTime, ttl: Duration) -> Re let body = std::fs::read(path).with_context(|| format!("reading session lease {path:?}"))?; let mut record: LeaseRecord = serde_json::from_slice(&body).with_context(|| format!("parsing session lease {path:?}"))?; + let snapshot = crate::session_metadata::process_snapshot(&[record.pid]); + if record.machine.is_none() { + record.machine = Some(crate::session_metadata::machine_descriptor( + &record.wire_version, + )); + } + if record.harness.as_ref().is_none_or(|value| { + value.confidence == crate::session_metadata::MetadataConfidence::Unknown + }) { + record.harness = Some(crate::session_metadata::harness_from_snapshot( + &snapshot, + record.pid, + &record.session_source, + )); + } + if record.project.as_ref().is_none_or(|value| { + value.confidence == crate::session_metadata::MetadataConfidence::Unknown + }) { + record.project = Some(crate::session_metadata::project_from_snapshot( + &snapshot, + record.pid, + record.cwd.as_deref().map(Path::new), + )); + } record.heartbeat_at = format_time(now)?; record.expires_at = format_time(now + time::Duration::seconds(ttl.as_secs() as i64))?; persist_record(path, &record) @@ -280,6 +320,27 @@ mod tests { assert_eq!(record.started_at, None); assert_eq!(record.cwd, None); + assert_eq!(record.machine, None); + assert_eq!(record.harness, None); + assert_eq!(record.project, None); + } + + #[test] + fn new_lease_round_trips_structured_metadata() { + let tmp = tempdir().unwrap(); + let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); + let path = write_test_lease(tmp.path(), std::process::id(), now, Duration::from_secs(90)); + + let record: LeaseRecord = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert!(record.machine.is_some()); + assert!(record.harness.is_some()); + assert_eq!( + record + .project + .as_ref() + .and_then(|value| value.cwd.as_deref()), + Some("/work/wire") + ); } #[test] @@ -312,6 +373,34 @@ mod tests { assert_eq!(leases[0].pid, 42); } + #[test] + fn heartbeat_preserves_known_metadata() { + let tmp = tempdir().unwrap(); + let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); + let path = write_test_lease(tmp.path(), 42, now, Duration::from_secs(90)); + let mut record: LeaseRecord = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + record.harness = Some(crate::session_metadata::HarnessDescriptor { + kind: "goose".to_string(), + label: "Goose".to_string(), + mode: Some("mcp-host".to_string()), + confidence: crate::session_metadata::MetadataConfidence::Explicit, + evidence: "lease-source".to_string(), + }); + std::fs::write(&path, serde_json::to_vec_pretty(&record).unwrap()).unwrap(); + + heartbeat_lease_at( + &path, + now + time::Duration::seconds(30), + Duration::from_secs(90), + ) + .unwrap(); + + let refreshed: LeaseRecord = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(refreshed.harness, record.harness); + assert_eq!(refreshed.started_at, record.started_at); + } + #[test] fn pruning_removes_expired_dead_and_malformed_records_only() { let tmp = tempdir().unwrap(); diff --git a/src/session_metadata.rs b/src/session_metadata.rs index fa0edd7..15c1a92 100644 --- a/src/session_metadata.rs +++ b/src/session_metadata.rs @@ -39,6 +39,50 @@ pub struct IdentityDescriptor { pub warning: Option, } +pub fn machine_descriptor(wire_version: &str) -> MachineDescriptor { + let fingerprint = crate::platform::machine_id_raw() + .zip(crate::platform::os_user_id_bytes()) + .map(|(machine, user)| { + hex::encode(crate::same_machine::machine_fingerprint(&machine, &user)) + }); + let hostname = hostname::get() + .ok() + .and_then(|value| value.into_string().ok()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "Unknown".to_string()); + MachineDescriptor { + fingerprint, + hostname, + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + wire_version: wire_version.to_string(), + } +} + +pub fn identity_descriptor(source: &str) -> IdentityDescriptor { + let (class, warning) = match source { + "override" => ("explicit-override", None), + "claude-code" | "codex-cli" | "goose" | "copilot-cli" | "vscode-workspace" => { + ("session-keyed", None) + } + "cwd-registry" | "registry" => ("registry-fallback", None), + "machine-default" => ( + "machine-default", + Some("Identity propagation missing: this agent uses the machine-default session."), + ), + "minted" => ( + "machine-default", + Some("Identity propagation missing: this agent uses a minted fallback session."), + ), + _ => ("unknown", None), + }; + IdentityDescriptor { + source: source.to_string(), + class: class.to_string(), + warning: warning.map(str::to_string), + } +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct ProjectDescriptor { pub name: Option, @@ -207,6 +251,7 @@ pub(crate) struct ProcessSnapshot { } impl ProcessSnapshot { + #[cfg(test)] fn from_observations(observations: Vec) -> Self { Self { observations: observations @@ -274,6 +319,28 @@ pub(crate) fn process_snapshot(pids: &[u32]) -> ProcessSnapshot { .get_or_refresh(pids, capture_process_snapshot) } +pub(crate) fn harness_from_snapshot( + snapshot: &ProcessSnapshot, + pid: u32, + session_source: &str, +) -> HarnessDescriptor { + infer_harness(session_source, &snapshot.ancestry(pid)) +} + +pub(crate) fn project_from_snapshot( + snapshot: &ProcessSnapshot, + pid: u32, + fallback_cwd: Option<&std::path::Path>, +) -> ProjectDescriptor { + if let Some(cwd) = fallback_cwd { + describe_project(cwd) + } else if let Some(cwd) = snapshot.cwd(pid) { + describe_project(&cwd) + } else { + ProjectDescriptor::unknown(None) + } +} + #[cfg(target_os = "macos")] fn capture_process_snapshot(pids: &[u32]) -> Result { if pids.is_empty() { From f0a18c2c1900b9e5d1c0ea6d7adec7f7d69c7fb8 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 09:33:53 -0700 Subject: [PATCH 15/39] feat: serve descriptive live sessions --- src/operator.rs | 90 ++++++++++++++++++++++++++++----- src/session_metadata.rs | 44 ++++++---------- tests/e2e_operator_dashboard.rs | 28 +++++++++- 3 files changed, 120 insertions(+), 42 deletions(-) diff --git a/src/operator.rs b/src/operator.rs index 20ec9b4..5633f28 100644 --- a/src/operator.rs +++ b/src/operator.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use time::OffsetDateTime; -pub const LIVE_SESSION_SCHEMA: &str = "wire-live-sessions-v1"; +pub const LIVE_SESSION_SCHEMA: &str = "wire-live-sessions-v2"; #[derive(Clone, Debug, Serialize)] pub struct LiveSession { @@ -15,8 +15,11 @@ pub struct LiveSession { pub did: String, pub emoji: String, pub primary_hex: String, - pub agent_host: String, - pub project_dir: Option, + pub pid: u32, + pub machine: crate::session_metadata::MachineDescriptor, + pub harness: crate::session_metadata::HarnessDescriptor, + pub identity: crate::session_metadata::IdentityDescriptor, + pub project: crate::session_metadata::ProjectDescriptor, pub started_at: Option, pub age_seconds: Option, pub direct_link_count: usize, @@ -343,11 +346,11 @@ fn collect_live_from( now: OffsetDateTime, is_alive: impl Fn(u32) -> bool + Copy, ) -> anyhow::Result { - let mut live = Vec::new(); + let mut candidates = Vec::new(); for session in sessions { - let (Some(did), Some(handle)) = (session.did.as_deref(), session.handle.as_deref()) else { + if session.did.is_none() || session.handle.is_none() { continue; - }; + } if crate::retire::is_retired(&session.home_dir) { continue; } @@ -359,6 +362,18 @@ fn collect_live_from( else { continue; }; + candidates.push((session, lease.clone())); + } + let snapshot = crate::session_metadata::process_snapshot( + &candidates + .iter() + .map(|(_, lease)| lease.pid) + .collect::>(), + ); + let mut live = Vec::new(); + for (session, lease) in candidates { + let did = session.did.as_deref().expect("candidate DID"); + let handle = session.handle.as_deref().expect("candidate handle"); let character = session .character .clone() @@ -379,14 +394,49 @@ fn collect_live_from( } else { "healthy" }; + let harness = lease + .harness + .clone() + .filter(|value| { + value.confidence != crate::session_metadata::MetadataConfidence::Unknown + }) + .unwrap_or_else(|| { + crate::session_metadata::harness_from_snapshot( + &snapshot, + lease.pid, + &lease.session_source, + ) + }); + let project = lease + .project + .clone() + .filter(|value| { + value.confidence != crate::session_metadata::MetadataConfidence::Unknown + }) + .unwrap_or_else(|| { + lease + .cwd + .as_deref() + .or(session.cwd.as_deref()) + .map(Path::new) + .map(crate::session_metadata::describe_project) + .unwrap_or_else(|| { + crate::session_metadata::project_from_snapshot(&snapshot, lease.pid, None) + }) + }); live.push(LiveSession { id: session.name.clone(), handle: handle.to_string(), did: did.to_string(), emoji: character.emoji, primary_hex: character.palette.primary_hex, - agent_host: lease.session_source.clone(), - project_dir: lease.cwd.clone().or_else(|| session.cwd.clone()), + pid: lease.pid, + machine: lease.machine.clone().unwrap_or_else(|| { + crate::session_metadata::machine_descriptor(&lease.wire_version) + }), + harness, + identity: crate::session_metadata::identity_descriptor(&lease.session_source), + project, started_at: lease.started_at.clone(), age_seconds, direct_link_count: peers.len(), @@ -417,8 +467,17 @@ mod tests { did: format!("did:wire:{id}-11111111"), emoji: "🦎".to_string(), primary_hex: "#45e456".to_string(), - agent_host: "codex-cli".to_string(), - project_dir: None, + pid: 42, + machine: crate::session_metadata::machine_descriptor("0.17.0"), + harness: crate::session_metadata::HarnessDescriptor { + kind: "codex-cli".to_string(), + label: "Codex CLI".to_string(), + mode: Some("interactive".to_string()), + confidence: crate::session_metadata::MetadataConfidence::Explicit, + evidence: "test-fixture".to_string(), + }, + identity: crate::session_metadata::identity_descriptor("codex-cli"), + project: crate::session_metadata::ProjectDescriptor::unknown(None), started_at: None, age_seconds: None, direct_link_count: 0, @@ -484,14 +543,18 @@ mod tests { }) .unwrap(); - assert_eq!(report.schema, "wire-live-sessions-v1"); + assert_eq!(report.schema, "wire-live-sessions-v2"); assert_eq!(report.sessions.len(), 1); assert_eq!(report.sessions[0].id, "session-11111111"); - assert_eq!(report.sessions[0].agent_host, "codex-cli"); + assert_eq!(report.sessions[0].harness.kind, "codex-cli"); + assert_eq!(report.sessions[0].identity.source, "codex-cli"); + assert_eq!(report.sessions[0].identity.class, "session-keyed"); assert_eq!( - report.sessions[0].project_dir.as_deref(), + report.sessions[0].project.cwd.as_deref(), Some("/work/wire") ); + assert_eq!(report.sessions[0].machine.wire_version, "0.17.0"); + assert_eq!(report.sessions[0].pid, 101); assert_eq!( report.sessions[0].started_at.as_deref(), Some("2023-11-14T22:13:20Z") @@ -501,6 +564,7 @@ mod tests { assert!(!json.contains("AGENT_SESSION_ID")); assert!(!json.contains("slot_token")); assert!(!json.contains("private.key")); + assert!(!json.contains("command_line")); } #[test] diff --git a/src/session_metadata.rs b/src/session_metadata.rs index 15c1a92..196a90b 100644 --- a/src/session_metadata.rs +++ b/src/session_metadata.rs @@ -544,6 +544,22 @@ pub(crate) fn infer_harness( session_source: &str, ancestry: &[ProcessObservation], ) -> HarnessDescriptor { + let explicit = match session_source { + "claude-code" => Some(("claude-code", "Claude Code")), + "goose" => Some(("goose", "Goose")), + "copilot-cli" => Some(("copilot-cli", "GitHub Copilot CLI")), + "vscode-workspace" => Some(("vscode", "VS Code")), + _ => None, + }; + if let Some((kind, label)) = explicit { + return harness( + kind, + label, + Some("mcp-host"), + MetadataConfidence::Explicit, + "lease-source", + ); + } for process in ancestry { let executable = std::path::Path::new(&process.executable) .file_name() @@ -615,13 +631,6 @@ pub(crate) fn infer_harness( } match session_source { - "claude-code" => harness( - "claude-code", - "Claude Code", - Some("mcp-host"), - MetadataConfidence::Explicit, - "lease-source", - ), "codex-cli" => harness( "codex-cli", "Codex CLI", @@ -629,27 +638,6 @@ pub(crate) fn infer_harness( MetadataConfidence::Explicit, "lease-source", ), - "goose" => harness( - "goose", - "Goose", - Some("mcp-host"), - MetadataConfidence::Explicit, - "lease-source", - ), - "copilot-cli" => harness( - "copilot-cli", - "GitHub Copilot CLI", - Some("mcp-host"), - MetadataConfidence::Explicit, - "lease-source", - ), - "vscode-workspace" => harness( - "vscode", - "VS Code", - Some("mcp-host"), - MetadataConfidence::Explicit, - "lease-source", - ), _ => harness( "unknown", "Unknown", diff --git a/tests/e2e_operator_dashboard.rs b/tests/e2e_operator_dashboard.rs index 2b1e985..6c28fa6 100644 --- a/tests/e2e_operator_dashboard.rs +++ b/tests/e2e_operator_dashboard.rs @@ -131,12 +131,38 @@ async fn dashboard_links_two_and_materializes_one_shared_group() { .await .unwrap(); let sessions = report["sessions"].as_array().unwrap(); + assert_eq!(report["schema"], "wire-live-sessions-v2"); assert_eq!(sessions.len(), 3, "live inventory: {report}"); assert!( sessions .iter() - .any(|session| session["agent_host"] == "goose") + .any(|session| session["harness"]["kind"] == "goose") ); + assert!( + sessions + .iter() + .all(|session| session["machine"]["hostname"].is_string()) + ); + assert!( + sessions + .iter() + .all(|session| session["identity"]["source"].is_string()) + ); + assert!( + sessions + .iter() + .all(|session| session["project"]["cwd"] == "/work/operator-proof") + ); + let serialized = report.to_string(); + for secret_field in [ + "thread_id", + "session_key", + "command_line", + "slot_token", + "private_key", + ] { + assert!(!serialized.contains(secret_field)); + } let ids: Vec = sessions .iter() .map(|session| session["id"].as_str().unwrap().to_string()) From 86774d776e501b745040c6f9114091c2abae2698 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 09:41:59 -0700 Subject: [PATCH 16/39] feat: show session provenance in dashboard --- assets/operator-dashboard.css | 19 +++++ assets/operator-dashboard.html | 6 +- assets/operator-dashboard.js | 130 ++++++++++++++++++++++++++++----- src/operator_web.rs | 9 +++ 4 files changed, 144 insertions(+), 20 deletions(-) diff --git a/assets/operator-dashboard.css b/assets/operator-dashboard.css index 0c5d430..c71f9df 100644 --- a/assets/operator-dashboard.css +++ b/assets/operator-dashboard.css @@ -192,10 +192,16 @@ h2 { font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -0.025em; } .session-table tbody tr { background: rgba(255, 255, 255, 0.17); } .session-table tbody tr:nth-child(even) { background: rgba(91, 26, 46, 0.045); } .session-table tbody tr:has(input:checked) { background: rgba(143, 176, 74, 0.22); box-shadow: inset 5px 0 var(--dial-dark); } +.session-table tbody tr.detail-row { background: #e2d4ba; box-shadow: inset 5px 0 var(--frame); } +.session-table tbody tr.detail-row[hidden] { display: none; } .session-check { width: 19px; height: 19px; accent-color: var(--frame); } .session-name { display: flex; gap: 10px; align-items: center; min-width: 185px; font-weight: 800; } .session-emoji { display: grid; width: 34px; height: 34px; place-items: center; border: 2px solid currentColor; background: var(--paper); font-size: 1.1rem; } +.session-identity, .cell-stack { display: flex; min-width: 0; flex-direction: column; gap: 2px; } +.session-identity small, .cell-stack small { color: var(--muted); font-family: var(--utility); font-size: 0.64rem; font-weight: 500; text-transform: none; } +.cell-stack strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.identity-warning strong { color: var(--danger); } .utility { font-family: var(--utility); font-size: 0.77rem; } .project { max-width: 280px; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } .link-count { font-family: var(--utility); font-weight: 800; } @@ -203,6 +209,14 @@ h2 { font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -0.025em; } .signal::before { width: 9px; height: 9px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 18%, transparent); content: ""; } .signal--healthy { color: var(--dial-dark); } .signal--sync-stale, .signal--daemon-down { color: var(--danger); } +.details-button { padding: 5px 8px; border: 1px solid var(--frame); color: var(--frame); background: transparent; font-family: var(--utility); font-size: 0.68rem; font-weight: 800; cursor: pointer; } +.detail-row > td { padding: 20px 22px; } +.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px 30px; } +.detail-grid h3 { margin: 0 0 9px; color: var(--frame); font-family: var(--utility); font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; } +.detail-grid dl { display: grid; gap: 6px; margin: 0; } +.detail-grid dl > div { display: grid; grid-template-columns: minmax(100px, 0.35fr) minmax(0, 1fr); gap: 12px; } +.detail-grid dt { color: var(--muted); font-family: var(--utility); font-size: 0.65rem; text-transform: uppercase; } +.detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-family: var(--utility); font-size: 0.72rem; } .console-footer { display: flex; @@ -242,6 +256,11 @@ h2 { font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -0.025em; } .session-table td::before { color: var(--muted); font-family: var(--utility); font-size: 0.62rem; text-transform: uppercase; content: attr(data-label); } .session-table td:first-child { position: absolute; top: 16px; left: 14px; display: block; width: auto; } .session-table td:first-child::before { display: none; } + .session-table tr.detail-row { margin-top: -12px; padding: 0; } + .session-table tr.detail-row td { display: block; padding: 16px; } + .session-table tr.detail-row td::before { display: none; } + .detail-grid { grid-template-columns: 1fr; gap: 18px; } + .detail-grid dl > div { grid-template-columns: 100px minmax(0, 1fr); } .session-name { min-width: 0; } .project { max-width: none; } .console-footer { align-items: flex-start; flex-direction: column; padding-inline: 20px; } diff --git a/assets/operator-dashboard.html b/assets/operator-dashboard.html index f292be3..148742a 100644 --- a/assets/operator-dashboard.html +++ b/assets/operator-dashboard.html @@ -66,11 +66,13 @@

Live sessions

Select Session - Agent + Harness Project - Uptime + Machine + Identity Links Signal + Details diff --git a/assets/operator-dashboard.js b/assets/operator-dashboard.js index f2cfe2a..8a7b401 100644 --- a/assets/operator-dashboard.js +++ b/assets/operator-dashboard.js @@ -7,7 +7,7 @@ const token = queryToken || window.sessionStorage.getItem("wire-launch-token") || ""; window.history.replaceState({}, "", window.location.pathname); - const state = { sessions: [], selected: new Set(), confirmedPair: [], busy: false }; + const state = { sessions: [], selected: new Set(), expanded: new Set(), confirmedPair: [], busy: false }; const rows = document.querySelector("#session-rows"); const tableWrap = document.querySelector("#table-wrap"); const loading = document.querySelector("#loading"); @@ -27,15 +27,7 @@ const groupName = document.querySelector("#group-name"); const groupCreator = document.querySelector("#group-creator"); - const hostLabel = (source) => ({ - "codex-cli": "Codex thread", - "claude-code": "Claude thread", - "claude-code-pidfile": "Claude thread", - "goose": "Goose thread", - "copilot-cli": "Copilot thread", - "vscode-workspace": "VS Code workspace", - "override": "Pinned session" - }[source] || source || "Agent session"); + const known = (value) => value === null || value === undefined || value === "" ? "Unknown" : String(value); const formatAge = (seconds) => { if (seconds === null || seconds === undefined) return "—"; @@ -72,9 +64,41 @@ return element; }; + const stack = (primary, secondary, className = "") => { + const wrapper = document.createElement("span"); + wrapper.className = `cell-stack ${className}`.trim(); + const main = document.createElement("strong"); + main.textContent = known(primary); + const sub = document.createElement("small"); + sub.textContent = known(secondary); + wrapper.append(main, sub); + return wrapper; + }; + + const detailItem = (label, value) => { + const wrapper = document.createElement("div"); + const term = document.createElement("dt"); + const description = document.createElement("dd"); + term.textContent = label; + description.textContent = known(value); + wrapper.append(term, description); + return wrapper; + }; + + const detailSection = (title, items) => { + const section = document.createElement("section"); + const heading = document.createElement("h3"); + const list = document.createElement("dl"); + heading.textContent = title; + for (const [label, value] of items) list.append(detailItem(label, value)); + section.append(heading, list); + return section; + }; + const render = () => { const liveIds = new Set(state.sessions.map((session) => session.id)); state.selected = new Set([...state.selected].filter((id) => liveIds.has(id))); + state.expanded = new Set([...state.expanded].filter((id) => liveIds.has(id))); const fragment = document.createDocumentFragment(); for (const session of state.sessions) { @@ -101,16 +125,25 @@ emoji.textContent = session.emoji; const handle = document.createElement("span"); handle.textContent = session.handle; - name.append(emoji, handle); + const identity = document.createElement("span"); + identity.className = "session-identity"; + identity.append(handle); + const uptime = document.createElement("small"); + uptime.textContent = `${formatAge(session.age_seconds)} · PID ${session.pid}`; + identity.append(uptime); + name.append(emoji, identity); nameCell.append(name); - const host = cell("Agent", "utility"); - host.textContent = hostLabel(session.agent_host); + const host = cell("Harness", "utility"); + host.append(stack(session.harness?.label, session.harness?.confidence)); const project = cell("Project", "project"); - project.textContent = session.project_dir || "—"; - project.title = session.project_dir || ""; - const age = cell("Uptime", "utility"); - age.textContent = formatAge(session.age_seconds); + project.append(stack(session.project?.name, session.project?.branch || session.project?.relative_cwd)); + project.title = known(session.project?.cwd); + const machine = cell("Machine", "utility"); + machine.append(stack(session.machine?.hostname, `${known(session.machine?.os)} / ${known(session.machine?.arch)}`)); + const identityCell = cell("Identity", "utility"); + const identityLabel = session.identity?.warning ? "Needs session key" : session.identity?.class; + identityCell.append(stack(identityLabel, session.identity?.source, session.identity?.warning ? "identity-warning" : "")); const links = cell("Links", "link-count"); links.textContent = String(session.direct_link_count); const health = cell("Signal"); @@ -119,8 +152,69 @@ signal.textContent = session.health.replaceAll("-", " "); health.append(signal); - row.append(selectCell, nameCell, host, project, age, links, health); + const detailsCell = cell("Details"); + const detailsButton = document.createElement("button"); + const expanded = state.expanded.has(session.id); + const detailId = `details-${session.id}`; + detailsButton.type = "button"; + detailsButton.className = "details-button"; + detailsButton.textContent = expanded ? "Hide" : "Inspect"; + detailsButton.setAttribute("aria-expanded", String(expanded)); + detailsButton.setAttribute("aria-controls", detailId); + detailsButton.addEventListener("click", () => { + if (state.expanded.has(session.id)) state.expanded.delete(session.id); + else state.expanded.add(session.id); + render(); + }); + detailsCell.append(detailsButton); + + row.append(selectCell, nameCell, host, project, machine, identityCell, links, health, detailsCell); fragment.append(row); + + const detailRow = document.createElement("tr"); + detailRow.id = detailId; + detailRow.className = "detail-row"; + detailRow.hidden = !expanded; + const detailCell = document.createElement("td"); + detailCell.colSpan = 9; + const grid = document.createElement("div"); + grid.className = "detail-grid"; + grid.append( + detailSection("Identity", [ + ["DID", session.did], + ["Source", session.identity?.source], + ["Class", session.identity?.class], + ["Warning", session.identity?.warning] + ]), + detailSection("Harness", [ + ["Kind", session.harness?.kind], + ["Launch mode", session.harness?.mode], + ["Confidence", session.harness?.confidence], + ["Evidence", session.harness?.evidence] + ]), + detailSection("Project", [ + ["Repository", session.project?.name], + ["Root", session.project?.root], + ["Working directory", session.project?.cwd], + ["Relative directory", session.project?.relative_cwd], + ["Branch", session.project?.branch], + ["Revision", session.project?.revision], + ["Worktree", session.project?.worktree_name], + ["Worktree path", session.project?.worktree_path], + ["Remote", session.project?.remote], + ["Evidence", session.project?.evidence] + ]), + detailSection("Machine", [ + ["Fingerprint", session.machine?.fingerprint], + ["Hostname", session.machine?.hostname], + ["Operating system", session.machine?.os], + ["Architecture", session.machine?.arch], + ["Wire version", session.machine?.wire_version] + ]) + ); + detailCell.append(grid); + detailRow.append(detailCell); + fragment.append(detailRow); } rows.replaceChildren(fragment); liveCount.textContent = String(state.sessions.length); diff --git a/src/operator_web.rs b/src/operator_web.rs index 2c996c9..76eee75 100644 --- a/src/operator_web.rs +++ b/src/operator_web.rs @@ -333,6 +333,12 @@ mod tests { assert!(html.contains("Link selected")); assert!(html.contains("Create group")); assert!(html.contains("aria-labelledby=\"group-title\"")); + for heading in ["Harness", "Project", "Machine", "Identity", "Details"] { + assert!( + html.contains(heading), + "missing dashboard heading {heading}" + ); + } let script = client .get(format!("http://{address}/dashboard.js")) @@ -346,6 +352,9 @@ mod tests { assert!(!script.contains("https://")); assert!(!script.contains("innerHTML")); assert!(script.contains("sessionStorage")); + assert!(script.contains("aria-expanded")); + assert!(script.contains("detail-row")); + assert!(script.contains("Unknown")); server.abort(); } } From 6f8bb0148d63ab644c8d6bfae8ec3c200b59b1b4 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 10:01:06 -0700 Subject: [PATCH 17/39] fix: harden session provenance fallbacks --- SESSION_LOG_2026_08_10.md | 18 +++++++-- assets/operator-dashboard.js | 2 +- src/operator_web.rs | 1 + src/session_metadata.rs | 73 +++++++++++++++++++++++++++++++++--- 4 files changed, 84 insertions(+), 10 deletions(-) diff --git a/SESSION_LOG_2026_08_10.md b/SESSION_LOG_2026_08_10.md index 9fb6dff..0040da8 100644 --- a/SESSION_LOG_2026_08_10.md +++ b/SESSION_LOG_2026_08_10.md @@ -9,21 +9,24 @@ Repair local Wire identity/daemon ambiguity and ship a one-machine operator dash - Bare shell and background monitor processes used the `machine-default` identity because no session key reached them. - One standalone daemon ran outside the managed all-session supervisor. - Goose exposed its thread identity through `AGENT_SESSION_ID`, but Wire did not recognize that source. -- Existing MCP leases lacked enough safe metadata to render agent host, project, and uptime. +- Existing MCP leases lacked enough safe metadata to render harness, identity provenance, machine, project, and uptime. The dashboard also mislabeled `session_source` as the agent harness. ## Changes - Resolve guarded Goose sessions when `AGENT=goose`; preserve Codex precedence and scrub adapter identity variables from child commands. -- Record lease acquisition time and working directory while remaining compatible with old leases. +- Record lease acquisition time, working directory, machine, harness, and project descriptors while remaining compatible with old leases. - Collect only live, initialized, non-retired MCP leases. Expose no raw thread ID, token, or private path. - Add explicit-home local pair and shared-group operations with postcondition checks. - Add `wire dash --web [--no-open]`: loopback-only Axum server, per-launch 256-bit token, authenticated inventory and mutations, local Host/Origin checks, CSP/security headers, and confirmation-race protection. -- Add the Open Band operator UI with Codex, Claude, and Goose thread labels. +- Advance inventory to `wire-live-sessions-v2` with separate machine, harness, identity, and project objects. Old leases recover facts from one PID-set-cached process snapshot; Git discovery reads repository/worktree files without per-row `git` subprocesses. +- Add the Open Band operator UI with compact harness/project/machine/identity columns and an independent expandable provenance panel. Missing facts render `Unknown`. +- Strip URL userinfo before exposing Git remotes. On Linux, a vanished `/proc` ancestor now fails open for that row instead of emptying the whole cached snapshot. ## Live callers and producers - Caller: installed `wire dash --web` starts `operator_web::serve`, which calls inventory and topology operations. - Producer: MCP startup writes `state/wire/leases/mcp-.json`; the dashboard reads active leases. +- Producer: `session_metadata::process_snapshot` takes one bounded active-PID snapshot; `operator::collect_live_from` merges lease, registry, then inferred facts. - Producer: `wire group create/invite/join` writes the same group into each selected session home. - Producer: managed `wire daemon --all-sessions` supervises per-session workers. @@ -32,12 +35,13 @@ Repair local Wire identity/daemon ambiguity and ship a one-machine operator dash - `cargo fmt --check` - `cargo clippy --all-targets -- -D warnings` - Focused operator, web, CLI, group, and dashboard end-to-end tests. -- Full `cargo test`: exit 0 after review fixes; 666 library tests passed, one expected library ignore, and every enabled integration test passed. +- Full `cargo test --all-targets --all-features`: exit 0 after review fixes; 679 library tests passed, one expected library ignore, and every enabled integration/stress test passed. - Dashboard end-to-end test: three live sessions including Goose, exact bilateral pair, one shared group, no full mesh. - Playwright: desktop and 390 px mobile render, token removed from visible URL, no horizontal overflow, no console errors, assets/API successful. - Playwright confirmation-race probe: selected session removed during polling, zero link POSTs, actionable notice rendered. - Installed identity probes: schema v3.2, distinct `codex-cli` and `goose` session sources and session-keyed homes. - Installed dashboard API and security headers exercised on loopback. +- Provenance Playwright proof: 35 live rows; Codex, Claude, machine-default warnings, repository/branch data, expanded details, selection persistence, desktop and 390 px mobile, no horizontal overflow, zero console errors, zero bad responses. ## Daemon repair @@ -51,6 +55,9 @@ Repair local Wire identity/daemon ambiguity and ship a one-machine operator dash - Kept and fixed: DID-first verified-peer matching; authenticated inventory; local Host/Origin validation; confirmation snapshot across polling. - Rejected with evidence: group invite replay concern (three-member end-to-end test passes); missing launch authorization (256-bit token already enforced). - Cut: redundant 660-line implementation plan. Retained the concise design spec. +- Kept and fixed after provenance review: credential-bearing Git remote sanitization, per-row Linux `/proc` race handling, safe missing-PID rendering. +- Rejected with cumulative caller evidence: cutting `identity_descriptor` (live schema-v2 consumer exists); cutting Cursor/VS Code inference (explicit approved harness list); treating Codex like other explicit sources (`codex-cli` covers both CLI and ChatGPT app-server and needs process disambiguation). +- Deferred by AMANALAP: cache TTL, Git config includes/exotic remotes, transient hostname retries, macOS command paths with spaces, zebra-striping polish, and duplicate-Unknown copy. - Deferred: storage abstraction, cookie redemption, history/retirement, remote machines, extra browser scenarios. ## Recovery note @@ -63,6 +70,9 @@ After the first installed launch, refreshing the clean URL lost the in-memory la - `src/operator.rs` — live inventory and explicit-home topology operations. - `src/operator_web.rs` — loopback HTTP server and security boundary. +- `src/session_metadata.rs` — provenance descriptors, Git discovery, identity classification, and bounded process snapshots. - `assets/operator-dashboard.{html,css,js}` — operator interface. - `tests/e2e_operator_dashboard.rs` — installed caller-path topology proof. - `docs/superpowers/specs/2026-08-10-operator-dashboard-design.md` — approved product and architecture boundary. +- `docs/superpowers/specs/2026-08-10-fleet-session-provenance-design.md` — approved local metadata and future fleet boundary. +- `docs/superpowers/plans/2026-08-10-fleet-session-provenance.md` — linted execution plan. diff --git a/assets/operator-dashboard.js b/assets/operator-dashboard.js index 8a7b401..48f4a60 100644 --- a/assets/operator-dashboard.js +++ b/assets/operator-dashboard.js @@ -129,7 +129,7 @@ identity.className = "session-identity"; identity.append(handle); const uptime = document.createElement("small"); - uptime.textContent = `${formatAge(session.age_seconds)} · PID ${session.pid}`; + uptime.textContent = `${formatAge(session.age_seconds)} · PID ${known(session.pid)}`; identity.append(uptime); name.append(emoji, identity); nameCell.append(name); diff --git a/src/operator_web.rs b/src/operator_web.rs index 76eee75..3b043a4 100644 --- a/src/operator_web.rs +++ b/src/operator_web.rs @@ -355,6 +355,7 @@ mod tests { assert!(script.contains("aria-expanded")); assert!(script.contains("detail-row")); assert!(script.contains("Unknown")); + assert!(script.contains("PID ${known(session.pid)}")); server.abort(); } } diff --git a/src/session_metadata.rs b/src/session_metadata.rs index 196a90b..de10e01 100644 --- a/src/session_metadata.rs +++ b/src/session_metadata.rs @@ -135,13 +135,24 @@ fn origin_remote(config: &std::path::Path) -> Option { continue; }; if key.trim() == "url" { - return Some(value.trim().to_string()); + return Some(sanitize_remote(value.trim())); } } } None } +fn sanitize_remote(remote: &str) -> String { + let Ok(mut url) = reqwest::Url::parse(remote) else { + return remote.to_string(); + }; + if !url.username().is_empty() || url.password().is_some() { + let _ = url.set_username(""); + let _ = url.set_password(None); + } + url.to_string() +} + fn repository_name(remote: Option<&str>, root: &std::path::Path) -> Option { remote .and_then(|value| value.trim_end_matches('/').rsplit(['/', ':']).next()) @@ -414,6 +425,14 @@ fn capture_process_snapshot(pids: &[u32]) -> Result { #[cfg(target_os = "linux")] fn capture_process_snapshot(pids: &[u32]) -> Result { + capture_linux_process_snapshot_at(std::path::Path::new("/proc"), pids) +} + +#[cfg(any(test, target_os = "linux"))] +fn capture_linux_process_snapshot_at( + proc_root: &std::path::Path, + pids: &[u32], +) -> Result { let mut observations = HashMap::new(); for root_pid in pids { let mut current = Some(*root_pid); @@ -422,9 +441,10 @@ fn capture_process_snapshot(pids: &[u32]) -> Result { if observations.contains_key(&pid) { break; } - let proc_dir = PathBuf::from(format!("/proc/{pid}")); - let status = std::fs::read_to_string(proc_dir.join("status")) - .map_err(|error| format!("reading process {pid}: {error}"))?; + let proc_dir = proc_root.join(pid.to_string()); + let Ok(status) = std::fs::read_to_string(proc_dir.join("status")) else { + break; + }; let parent_pid = status .lines() .find_map(|line| line.strip_prefix("PPid:")) @@ -673,7 +693,7 @@ mod tests { fn harness_prefers_explicit_source() { let ancestry = vec![ process(20, Some(10), "wire", &["mcp"]), - process(10, None, "python", &[]), + process(10, None, "codex", &["resume"]), ]; let harness = infer_harness("goose", &ancestry); @@ -777,6 +797,30 @@ mod tests { assert_eq!(project.evidence, "git-filesystem"); } + #[test] + fn project_remote_strips_url_credentials() { + let temp = tempdir().unwrap(); + let root = temp.path().join("credentialed"); + fs::create_dir_all(&root).unwrap(); + write(&root.join(".git/HEAD"), "ref: refs/heads/main\n"); + write( + &root.join(".git/config"), + "[remote \"origin\"]\n\turl = https://operator:secret-token@github.com/SlanchaAI/wire.git\n", + ); + + let project = describe_project(&root); + + assert_eq!( + project.remote.as_deref(), + Some("https://github.com/SlanchaAI/wire.git") + ); + assert!( + !serde_json::to_string(&project) + .unwrap() + .contains("secret-token") + ); + } + #[test] fn project_discovers_linked_worktree() { let temp = tempdir().unwrap(); @@ -871,4 +915,23 @@ mod tests { assert_eq!(snapshot.cwd(42), None); assert_eq!(infer_harness("machine-default", &[]).kind, "unknown"); } + + #[test] + fn linux_process_snapshot_keeps_other_rows_when_one_ancestor_disappears() { + let temp = tempdir().unwrap(); + for (pid, parent) in [(10, 999), (20, 0)] { + let process = temp.path().join(pid.to_string()); + fs::create_dir_all(&process).unwrap(); + write( + &process.join("status"), + &format!("Name:\ttest\nPPid:\t{parent}\n"), + ); + write(&process.join("cmdline"), "wire\0mcp\0"); + } + + let snapshot = capture_linux_process_snapshot_at(temp.path(), &[10, 20]).unwrap(); + + assert_eq!(snapshot.ancestry(10).len(), 1); + assert_eq!(snapshot.ancestry(20).len(), 1); + } } From e1f8a4c3c8751551e5e498d25ba04edf319c5bbc Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 10:11:13 -0700 Subject: [PATCH 18/39] fix: keep dashboard details in frame --- SESSION_LOG_2026_08_10.md | 1 + assets/operator-dashboard.css | 2 +- assets/operator-dashboard.html | 1 - assets/operator-dashboard.js | 35 ++++++++++++++++------------------ src/operator_web.rs | 3 ++- 5 files changed, 20 insertions(+), 22 deletions(-) diff --git a/SESSION_LOG_2026_08_10.md b/SESSION_LOG_2026_08_10.md index 0040da8..95aa631 100644 --- a/SESSION_LOG_2026_08_10.md +++ b/SESSION_LOG_2026_08_10.md @@ -42,6 +42,7 @@ Repair local Wire identity/daemon ambiguity and ship a one-machine operator dash - Installed identity probes: schema v3.2, distinct `codex-cli` and `goose` session sources and session-keyed homes. - Installed dashboard API and security headers exercised on loopback. - Provenance Playwright proof: 35 live rows; Codex, Claude, machine-default warnings, repository/branch data, expanded details, selection persistence, desktop and 390 px mobile, no horizontal overflow, zero console errors, zero bad responses. +- Installed 1440 px proof found and fixed 78 px of table overflow: moving Inspect into the Session cell reduced `scrollWidth` from 1172 to the 1094 px frame width; expanding details leaves `scrollLeft: 0`. ## Daemon repair diff --git a/assets/operator-dashboard.css b/assets/operator-dashboard.css index c71f9df..5e2fa84 100644 --- a/assets/operator-dashboard.css +++ b/assets/operator-dashboard.css @@ -209,7 +209,7 @@ h2 { font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -0.025em; } .signal::before { width: 9px; height: 9px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 18%, transparent); content: ""; } .signal--healthy { color: var(--dial-dark); } .signal--sync-stale, .signal--daemon-down { color: var(--danger); } -.details-button { padding: 5px 8px; border: 1px solid var(--frame); color: var(--frame); background: transparent; font-family: var(--utility); font-size: 0.68rem; font-weight: 800; cursor: pointer; } +.details-button { align-self: flex-start; margin-top: 3px; padding: 2px 5px; border: 1px solid var(--frame); color: var(--frame); background: transparent; font-family: var(--utility); font-size: 0.58rem; font-weight: 800; cursor: pointer; } .detail-row > td { padding: 20px 22px; } .detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px 30px; } .detail-grid h3 { margin: 0 0 9px; color: var(--frame); font-family: var(--utility); font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; } diff --git a/assets/operator-dashboard.html b/assets/operator-dashboard.html index 148742a..81ade25 100644 --- a/assets/operator-dashboard.html +++ b/assets/operator-dashboard.html @@ -72,7 +72,6 @@

Live sessions

Identity Links Signal - Details diff --git a/assets/operator-dashboard.js b/assets/operator-dashboard.js index 48f4a60..4279f93 100644 --- a/assets/operator-dashboard.js +++ b/assets/operator-dashboard.js @@ -130,7 +130,20 @@ identity.append(handle); const uptime = document.createElement("small"); uptime.textContent = `${formatAge(session.age_seconds)} · PID ${known(session.pid)}`; - identity.append(uptime); + const detailsButton = document.createElement("button"); + const expanded = state.expanded.has(session.id); + const detailId = `details-${session.id}`; + detailsButton.type = "button"; + detailsButton.className = "details-button"; + detailsButton.textContent = expanded ? "Hide details" : "Inspect details"; + detailsButton.setAttribute("aria-expanded", String(expanded)); + detailsButton.setAttribute("aria-controls", detailId); + detailsButton.addEventListener("click", () => { + if (state.expanded.has(session.id)) state.expanded.delete(session.id); + else state.expanded.add(session.id); + render(); + }); + identity.append(uptime, detailsButton); name.append(emoji, identity); nameCell.append(name); @@ -152,23 +165,7 @@ signal.textContent = session.health.replaceAll("-", " "); health.append(signal); - const detailsCell = cell("Details"); - const detailsButton = document.createElement("button"); - const expanded = state.expanded.has(session.id); - const detailId = `details-${session.id}`; - detailsButton.type = "button"; - detailsButton.className = "details-button"; - detailsButton.textContent = expanded ? "Hide" : "Inspect"; - detailsButton.setAttribute("aria-expanded", String(expanded)); - detailsButton.setAttribute("aria-controls", detailId); - detailsButton.addEventListener("click", () => { - if (state.expanded.has(session.id)) state.expanded.delete(session.id); - else state.expanded.add(session.id); - render(); - }); - detailsCell.append(detailsButton); - - row.append(selectCell, nameCell, host, project, machine, identityCell, links, health, detailsCell); + row.append(selectCell, nameCell, host, project, machine, identityCell, links, health); fragment.append(row); const detailRow = document.createElement("tr"); @@ -176,7 +173,7 @@ detailRow.className = "detail-row"; detailRow.hidden = !expanded; const detailCell = document.createElement("td"); - detailCell.colSpan = 9; + detailCell.colSpan = 8; const grid = document.createElement("div"); grid.className = "detail-grid"; grid.append( diff --git a/src/operator_web.rs b/src/operator_web.rs index 3b043a4..657ea90 100644 --- a/src/operator_web.rs +++ b/src/operator_web.rs @@ -333,7 +333,7 @@ mod tests { assert!(html.contains("Link selected")); assert!(html.contains("Create group")); assert!(html.contains("aria-labelledby=\"group-title\"")); - for heading in ["Harness", "Project", "Machine", "Identity", "Details"] { + for heading in ["Harness", "Project", "Machine", "Identity"] { assert!( html.contains(heading), "missing dashboard heading {heading}" @@ -353,6 +353,7 @@ mod tests { assert!(!script.contains("innerHTML")); assert!(script.contains("sessionStorage")); assert!(script.contains("aria-expanded")); + assert!(script.contains("details-button")); assert!(script.contains("detail-row")); assert!(script.contains("Unknown")); assert!(script.contains("PID ${known(session.pid)}")); From 6dcde82d99756cdb487933238f819f2c92b81fc5 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 11:17:56 -0700 Subject: [PATCH 19/39] fix: identify macOS harness processes reliably --- src/session_metadata.rs | 105 ++++++++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 21 deletions(-) diff --git a/src/session_metadata.rs b/src/session_metadata.rs index de10e01..ca949c5 100644 --- a/src/session_metadata.rs +++ b/src/session_metadata.rs @@ -352,6 +352,30 @@ pub(crate) fn project_from_snapshot( } } +#[cfg(target_os = "macos")] +fn apply_macos_lsof_output(observations: &mut HashMap, body: &[u8]) { + let mut current_pid = None; + let mut current_descriptor = None; + for line in String::from_utf8_lossy(body).lines() { + if let Some(value) = line.strip_prefix('p') { + current_pid = value.parse::().ok(); + current_descriptor = None; + } else if let (Some(pid), Some(executable)) = (current_pid, line.strip_prefix('c')) + && let Some(observation) = observations.get_mut(&pid) + { + observation.executable = executable.to_string(); + } else if let Some(value) = line.strip_prefix('f') { + current_descriptor = Some(value); + } else if let (Some(pid), Some(path), Some(descriptor)) = + (current_pid, line.strip_prefix('n'), current_descriptor) + && let Some(observation) = observations.get_mut(&pid) + && descriptor == "cwd" + { + observation.cwd = Some(PathBuf::from(path)); + } + } +} + #[cfg(target_os = "macos")] fn capture_process_snapshot(pids: &[u32]) -> Result { if pids.is_empty() { @@ -374,38 +398,19 @@ fn capture_process_snapshot(pids: &[u32]) -> Result { let (Ok(pid), Ok(parent_pid)) = (pid.parse::(), parent_pid.parse::()) else { continue; }; + let arguments: Vec = fields.map(str::to_string).collect(); all.insert( pid, ProcessObservation { pid, parent_pid: (parent_pid != 0).then_some(parent_pid), executable: executable.to_string(), - arguments: fields.map(str::to_string).collect(), + arguments, cwd: None, }, ); } - let pid_list = pids - .iter() - .map(u32::to_string) - .collect::>() - .join(","); - let mut lsof = Command::new("lsof"); - lsof.args(["-a", "-d", "cwd", "-p", &pid_list, "-Fn"]); - if let Some(output) = crate::platform::run_with_timeout(lsof, Duration::from_secs(5)) { - let mut current_pid = None; - for line in String::from_utf8_lossy(&output.stdout).lines() { - if let Some(value) = line.strip_prefix('p') { - current_pid = value.parse::().ok(); - } else if let (Some(pid), Some(path)) = (current_pid, line.strip_prefix('n')) - && let Some(observation) = all.get_mut(&pid) - { - observation.cwd = Some(PathBuf::from(path)); - } - } - } - let mut selected = HashMap::new(); for pid in pids { let mut current = Some(*pid); @@ -418,6 +423,19 @@ fn capture_process_snapshot(pids: &[u32]) -> Result { selected.entry(process_pid).or_insert(observation); } } + + let mut selected_pids: Vec = selected.keys().copied().collect(); + selected_pids.sort_unstable(); + let pid_list = selected_pids + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + let mut lsof = Command::new("lsof"); + lsof.args(["-a", "-d", "cwd", "-p", &pid_list, "-Fpcfn"]); + if let Some(output) = crate::platform::run_with_timeout(lsof, Duration::from_secs(5)) { + apply_macos_lsof_output(&mut selected, &output.stdout); + } Ok(ProcessSnapshot { observations: selected, }) @@ -761,6 +779,51 @@ mod tests { assert_eq!(harness.evidence, "unavailable"); } + #[cfg(target_os = "macos")] + #[test] + fn macos_snapshot_uses_untruncated_executable_path() { + let mut observations = HashMap::from([( + 42, + process( + 42, + None, + "/private/var/fol", + &[ + "/Applications/Goose Desktop.app/Contents/MacOS/goose", + "serve", + ], + ), + )]); + apply_macos_lsof_output( + &mut observations, + b"p42\ncgoose\nfcwd\nn/Users/operator/Project With Spaces\n", + ); + let snapshot = ProcessSnapshot { observations }; + let harness = harness_from_snapshot(&snapshot, 42, "machine-default"); + + assert_eq!(harness.kind, "goose"); + assert_eq!(harness.confidence, MetadataConfidence::Inferred); + assert_eq!( + snapshot.cwd(42).as_deref(), + Some(std::path::Path::new("/Users/operator/Project With Spaces")) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_snapshot_rejects_spoofed_executable_path() { + let mut observations = HashMap::from([( + 42, + process(42, None, "/private/var/fol", &["/tmp/goose", "30"]), + )]); + apply_macos_lsof_output(&mut observations, b"p42\ncsleep\n"); + let snapshot = ProcessSnapshot { observations }; + let harness = harness_from_snapshot(&snapshot, 42, "machine-default"); + + assert_eq!(harness.kind, "unknown"); + assert_eq!(harness.confidence, MetadataConfidence::Unknown); + } + fn write(path: &std::path::Path, body: &str) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); From d99dc33a1f950b9513532dd6125b7b4524d0e58f Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 11:18:03 -0700 Subject: [PATCH 20/39] feat: show live daemon-only sessions --- SESSION_LOG_2026_08_10.md | 21 ++++ src/operator.rs | 246 +++++++++++++++++++++++++++++--------- 2 files changed, 212 insertions(+), 55 deletions(-) diff --git a/SESSION_LOG_2026_08_10.md b/SESSION_LOG_2026_08_10.md index 95aa631..91aa860 100644 --- a/SESSION_LOG_2026_08_10.md +++ b/SESSION_LOG_2026_08_10.md @@ -61,6 +61,27 @@ Repair local Wire identity/daemon ambiguity and ship a one-machine operator dash - Deferred by AMANALAP: cache TTL, Git config includes/exotic remotes, transient hostname retries, macOS command paths with spaces, zebra-striping polish, and duplicate-Unknown copy. - Deferred: storage abstraction, cookie redemption, history/retirement, remote machines, extra browser scenarios. +## macOS harness detector repair + +- Observed defect: 12 Goose sessions and two Claude Desktop sessions rendered as `Unknown` although their live parent chains reached `Goose.app` or `Claude.app`. +- Root cause: macOS `ps -o comm` truncated executable paths to 16 characters (`/private/var/fol`, `/Applications/Cl`). +- RED: the long-path detector regression classified the test process as its Codex parent instead of Goose; the spoof regression accepted a fake `/tmp/goose` argv path. +- Fix: retain the bounded `ps` ancestry snapshot, then enrich each selected process from the kernel command-name `c` field in the existing bounded `lsof` cwd probe. Never classify from argv. +- GREEN: both macOS parser regressions pass; arbitrary argument mentions still remain unknown; `cargo fmt --check`, Clippy with warnings denied, and 681 library tests pass with one expected ignore. +- Review cycle 1 kept and fixed the argv spoofing blocker and path-with-spaces concern. +- Review cycle 2 kept and removed the unverified first-`txt` ordering and high-volume mapped-image scan by using `lsof`'s command field instead. +- Final review findings cut/deferred: `+c 0` is unnecessary for every supported command (`codex`, `Claude`, `goose`, `Cursor`, `Code`); fail-open diagnostics and generic future descriptor hardening do not affect the observed defect. + +## Live daemon-only sessions + +- Observed defect: `rusted-butte` was initialized by bare `wire up` and had a live daemon, but the board omitted it because inventory required an MCP lease. +- RED: inventory fixture expected one MCP-backed and one daemon-only row but received only the MCP row. +- Fix: when no active MCP lease exists, accept an initialized, non-retired session whose versioned daemon pidfile names a live PID and does not contradict the home DID. MCP remains the preferred runtime, preventing duplicate rows. +- Daemon-only rows use the daemon pid/start/version, live process cwd for project discovery, `Wire daemon` as the observed runtime, and by-key versus registry identity provenance. +- DID guard mutation check: removing the mismatch filter changed the expected two rows to three; restoring it returned green. +- Review disposition: cut the alleged missing-DID panic because the existing initialized-session gate proves DID and handle before candidate creation; kept and tested DID mismatch rejection; deferred legacy pidfiles with no DID and registry-label coverage. +- Full `cargo test --all-targets --all-features`: exit 0 after the daemon inventory change. + ## Recovery note A browser race probe accidentally ran an older debug binary and linked `agate-starshine` to the `bubbling-kelp` session at `.../9583f4349f98ddea`. The exact bilateral pins were removed immediately with `wire forget-peer` on both homes; verification showed only each session's self-attestation remained. No files were purged. diff --git a/src/operator.rs b/src/operator.rs index 5633f28..5672328 100644 --- a/src/operator.rs +++ b/src/operator.rs @@ -341,6 +341,65 @@ pub fn collect_live_sessions() -> anyhow::Result { ) } +#[derive(Clone)] +enum LiveRuntime { + Mcp(Box), + Daemon(crate::ensure_up::DaemonPid), +} + +impl LiveRuntime { + fn pid(&self) -> u32 { + match self { + Self::Mcp(lease) => lease.pid, + Self::Daemon(record) => record.pid, + } + } + + fn started_at(&self) -> Option<&str> { + match self { + Self::Mcp(lease) => lease.started_at.as_deref(), + Self::Daemon(record) => Some(&record.started_at), + } + } +} + +fn daemon_record(home: &Path) -> Option { + let body = std::fs::read(home.join("state/wire/daemon.pid")).ok()?; + let record: crate::ensure_up::DaemonPid = serde_json::from_slice(&body).ok()?; + (record.schema == crate::ensure_up::DAEMON_PID_SCHEMA).then_some(record) +} + +fn daemon_identity(home: &Path) -> crate::session_metadata::IdentityDescriptor { + let session_keyed = home + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + == Some("by-key"); + crate::session_metadata::IdentityDescriptor { + source: if session_keyed { + "wire-session".to_string() + } else { + "registry".to_string() + }, + class: if session_keyed { + "session-keyed".to_string() + } else { + "registry-fallback".to_string() + }, + warning: None, + } +} + +fn daemon_harness() -> crate::session_metadata::HarnessDescriptor { + crate::session_metadata::HarnessDescriptor { + kind: "wire-daemon".to_string(), + label: "Wire daemon".to_string(), + mode: Some("background".to_string()), + confidence: crate::session_metadata::MetadataConfidence::Explicit, + evidence: "daemon-pidfile".to_string(), + } +} + fn collect_live_from( sessions: &[crate::session::SessionInfo], now: OffsetDateTime, @@ -355,23 +414,37 @@ fn collect_live_from( continue; } let leases = crate::session_lifecycle::active_leases_at(&session.home_dir, now, is_alive); - let Some(lease) = leases + let lease = leases .iter() .filter(|lease| lease.role == "mcp") .max_by(|left, right| left.heartbeat_at.cmp(&right.heartbeat_at)) - else { + .cloned(); + let runtime = if let Some(lease) = lease { + LiveRuntime::Mcp(Box::new(lease)) + } else if session.daemon_running { + let Some(record) = daemon_record(&session.home_dir).filter(|record| { + is_alive(record.pid) + && record + .did + .as_deref() + .is_none_or(|did| session.did.as_deref() == Some(did)) + }) else { + continue; + }; + LiveRuntime::Daemon(record) + } else { continue; }; - candidates.push((session, lease.clone())); + candidates.push((session, runtime)); } let snapshot = crate::session_metadata::process_snapshot( &candidates .iter() - .map(|(_, lease)| lease.pid) + .map(|(_, runtime)| runtime.pid()) .collect::>(), ); let mut live = Vec::new(); - for (session, lease) in candidates { + for (session, runtime) in candidates { let did = session.did.as_deref().expect("candidate DID"); let handle = session.handle.as_deref().expect("candidate handle"); let character = session @@ -379,7 +452,7 @@ fn collect_live_from( .clone() .unwrap_or_else(|| crate::character::Character::from_did(did)); let peers = crate::dash::read_peers(&session.home_dir, Some(did), Some(handle)); - let age_seconds = lease.started_at.as_deref().and_then(|started| { + let age_seconds = runtime.started_at().and_then(|started| { OffsetDateTime::parse(started, &time::format_description::well_known::Rfc3339) .ok() .and_then(|started| { @@ -394,50 +467,75 @@ fn collect_live_from( } else { "healthy" }; - let harness = lease - .harness - .clone() - .filter(|value| { - value.confidence != crate::session_metadata::MetadataConfidence::Unknown - }) - .unwrap_or_else(|| { - crate::session_metadata::harness_from_snapshot( - &snapshot, - lease.pid, - &lease.session_source, + let (harness, identity, project, machine) = match &runtime { + LiveRuntime::Mcp(lease) => { + let harness = lease + .harness + .clone() + .filter(|value| { + value.confidence != crate::session_metadata::MetadataConfidence::Unknown + }) + .unwrap_or_else(|| { + crate::session_metadata::harness_from_snapshot( + &snapshot, + lease.pid, + &lease.session_source, + ) + }); + let project = lease + .project + .clone() + .filter(|value| { + value.confidence != crate::session_metadata::MetadataConfidence::Unknown + }) + .unwrap_or_else(|| { + lease + .cwd + .as_deref() + .or(session.cwd.as_deref()) + .map(Path::new) + .map(crate::session_metadata::describe_project) + .unwrap_or_else(|| { + crate::session_metadata::project_from_snapshot( + &snapshot, lease.pid, None, + ) + }) + }); + ( + harness, + crate::session_metadata::identity_descriptor(&lease.session_source), + project, + lease.machine.clone().unwrap_or_else(|| { + crate::session_metadata::machine_descriptor(&lease.wire_version) + }), ) - }); - let project = lease - .project - .clone() - .filter(|value| { - value.confidence != crate::session_metadata::MetadataConfidence::Unknown - }) - .unwrap_or_else(|| { - lease + } + LiveRuntime::Daemon(record) => ( + daemon_harness(), + daemon_identity(&session.home_dir), + session .cwd .as_deref() - .or(session.cwd.as_deref()) .map(Path::new) .map(crate::session_metadata::describe_project) .unwrap_or_else(|| { - crate::session_metadata::project_from_snapshot(&snapshot, lease.pid, None) - }) - }); + crate::session_metadata::project_from_snapshot(&snapshot, record.pid, None) + }), + crate::session_metadata::machine_descriptor(&record.version), + ), + }; live.push(LiveSession { id: session.name.clone(), handle: handle.to_string(), did: did.to_string(), emoji: character.emoji, primary_hex: character.palette.primary_hex, - pid: lease.pid, - machine: lease.machine.clone().unwrap_or_else(|| { - crate::session_metadata::machine_descriptor(&lease.wire_version) - }), + pid: runtime.pid(), + machine, harness, - identity: crate::session_metadata::identity_descriptor(&lease.session_source), + identity, project, - started_at: lease.started_at.clone(), + started_at: runtime.started_at().map(str::to_string), age_seconds, direct_link_count: peers.len(), health: health.to_string(), @@ -513,21 +611,49 @@ mod tests { .unwrap(); } + fn daemon_pidfile(home: &Path, pid: u32, did: &str, now: OffsetDateTime) { + let state = home.join("state/wire"); + std::fs::create_dir_all(&state).unwrap(); + std::fs::write( + state.join("daemon.pid"), + serde_json::to_vec(&serde_json::json!({ + "schema": "wire-daemon-pid-v1", + "pid": pid, + "bin_path": "/opt/wire", + "version": "0.17.0", + "started_at": now + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + "did": did, + "relay_url": "https://wireup.net" + })) + .unwrap(), + ) + .unwrap(); + } + #[test] - fn inventory_includes_only_live_mcp_sessions() { + fn inventory_includes_live_mcp_and_daemon_sessions_only() { let tmp = tempdir().unwrap(); let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); let live_home = tmp.path().join("live"); - let daemon_home = tmp.path().join("daemon-only"); + let daemon_home = tmp.path().join("sessions/by-key/daemon-only"); let expired_home = tmp.path().join("expired"); let retired_home = tmp.path().join("retired"); let dead_home = tmp.path().join("dead"); + let mismatched_home = tmp.path().join("sessions/by-key/mismatched"); lease(&live_home, "mcp", 101, now, 90); - lease(&daemon_home, "daemon", 102, now, 90); + daemon_pidfile(&daemon_home, 102, "did:wire:session-22222222", now); lease(&expired_home, "mcp", 103, now, 1); lease(&retired_home, "mcp", 104, now, 90); lease(&dead_home, "mcp", 105, now, 90); + daemon_pidfile( + &mismatched_home, + 106, + "did:wire:other-session-99999999", + now, + ); std::fs::create_dir_all(retired_home.join("state/wire")).unwrap(); std::fs::write(retired_home.join("state/wire/retired.json"), "{}").unwrap(); @@ -537,28 +663,38 @@ mod tests { session(&expired_home, "33333333", true), session(&retired_home, "44444444", true), session(&dead_home, "55555555", true), + session(&mismatched_home, "66666666", true), ]; let report = collect_live_from(&sessions, now + time::Duration::seconds(2), |pid| { - matches!(pid, 101..=104) + matches!(pid, 101..=104 | 106) }) .unwrap(); assert_eq!(report.schema, "wire-live-sessions-v2"); - assert_eq!(report.sessions.len(), 1); - assert_eq!(report.sessions[0].id, "session-11111111"); - assert_eq!(report.sessions[0].harness.kind, "codex-cli"); - assert_eq!(report.sessions[0].identity.source, "codex-cli"); - assert_eq!(report.sessions[0].identity.class, "session-keyed"); - assert_eq!( - report.sessions[0].project.cwd.as_deref(), - Some("/work/wire") - ); - assert_eq!(report.sessions[0].machine.wire_version, "0.17.0"); - assert_eq!(report.sessions[0].pid, 101); - assert_eq!( - report.sessions[0].started_at.as_deref(), - Some("2023-11-14T22:13:20Z") - ); + assert_eq!(report.sessions.len(), 2); + let mcp = report + .sessions + .iter() + .find(|session| session.id == "session-11111111") + .unwrap(); + assert_eq!(mcp.harness.kind, "codex-cli"); + assert_eq!(mcp.identity.source, "codex-cli"); + assert_eq!(mcp.identity.class, "session-keyed"); + assert_eq!(mcp.project.cwd.as_deref(), Some("/work/wire")); + assert_eq!(mcp.machine.wire_version, "0.17.0"); + assert_eq!(mcp.pid, 101); + assert_eq!(mcp.started_at.as_deref(), Some("2023-11-14T22:13:20Z")); + + let daemon = report + .sessions + .iter() + .find(|session| session.id == "session-22222222") + .unwrap(); + assert_eq!(daemon.pid, 102); + assert_eq!(daemon.harness.kind, "wire-daemon"); + assert_eq!(daemon.identity.source, "wire-session"); + assert_eq!(daemon.identity.class, "session-keyed"); + assert_eq!(daemon.started_at.as_deref(), Some("2023-11-14T22:13:20Z")); let json = serde_json::to_string(&report).unwrap(); assert!(!json.contains("AGENT_SESSION_ID")); From a21fa2dfe6f2d60f6b95cc2e9017abd6c031cf8b Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 12:26:42 -0700 Subject: [PATCH 21/39] fix: preserve operator-started daemons --- SESSION_LOG_2026_08_10.md | 9 ++++ src/daemon_supervisor.rs | 98 +++++++++++++++++++++++++++++++++++++-- src/ensure_up.rs | 43 +++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/SESSION_LOG_2026_08_10.md b/SESSION_LOG_2026_08_10.md index 91aa860..4cadcc1 100644 --- a/SESSION_LOG_2026_08_10.md +++ b/SESSION_LOG_2026_08_10.md @@ -88,6 +88,15 @@ A browser race probe accidentally ran an older debug binary and linked `agate-st After the first installed launch, refreshing the clean URL lost the in-memory launch token and left the inventory request unauthorized. The browser now stores the token in per-tab `sessionStorage` before removing it from the visible URL. Installed Playwright proof showed 34 rows before and after reload, no notice, and zero console errors. +## Operator daemon ownership repair + +- Observed defect: the machine-wide supervisor retired `rusted-butte` PID `98594`, although `wire up` had started that daemon explicitly. The live-only board then correctly removed the stopped session. +- Root cause: inactive-worker cleanup identified ownership from a shared pidfile and generic `wire daemon` command line; both supervisor children and operator-started daemons have those properties. +- Fix: supervisor-spawned workers carry `WIRE_SUPERVISOR_MANAGED=1` into a backward-compatible pidfile marker. Cleanup requires that explicit marker; operator-started daemons and older pidfiles omit it. The durable marker survives supervisor restarts. +- RED/GREEN: a real orphaned process with an operator-shaped daemon pidfile was killed before the fix and preserved after it; a complementary child-process test proves explicitly supervisor-owned cleanup remains active. Pidfile tests cover owner publication and legacy records without the field. +- Installed caller proof: launchd supervisor PID `20898` spawned workers including `agate-starshine` PID `25241`; their pidfiles contain `supervisor_managed: true`. Operator-started `rusted-butte` PID `32746` omits the marker and remained healthy through repeated supervisor polls. +- Chrome proof: refreshed the existing dashboard tab, found exactly one `rusted-butte` row, and left it visibly centered with `Wire daemon`, project `wire`, and `HEALTHY` status. + ## Artifacts - `src/operator.rs` — live inventory and explicit-home topology operations. diff --git a/src/daemon_supervisor.rs b/src/daemon_supervisor.rs index 2236470..b63ad11 100644 --- a/src/daemon_supervisor.rs +++ b/src/daemon_supervisor.rs @@ -117,10 +117,9 @@ fn classify_session_worker( }) } -/// Stop one pidfile-owned worker for a session that lifecycle planning did -/// not select. Validation is deliberately narrow: JSON pidfile, live PID, -/// daemon command line, and never the all-session supervisor. No process -/// family signal and no home mutation. +/// Stop one supervisor-owned worker for a session that lifecycle planning did +/// not select. A standalone daemon started by `wire up` may share the same +/// pidfile and command line, so the pidfile's explicit owner is the boundary. fn retire_inactive_worker(session: &crate::session::SessionInfo) { let pidfile = session .home_dir @@ -133,6 +132,9 @@ fn retire_inactive_worker(session: &crate::session::SessionInfo) { let Ok(record) = serde_json::from_str::(&body) else { return; }; + if !record.supervisor_managed { + return; + } let alive = crate::platform::process_alive(record.pid); let cmdline = crate::platform::pid_cmdline(record.pid); let Some(version) = classify_session_worker(&record, alive, cmdline.as_deref()) else { @@ -671,6 +673,7 @@ fn spawn_child_for_session( cmd.env_remove(&k); } cmd.env("WIRE_HOME", home_dir); + cmd.env("WIRE_SUPERVISOR_MANAGED", "1"); // Children inherit stdout/stderr → land in the launchd log file // (StandardOutPath in the plist). Operators see "supervisor: // spawned ..." lines interleaved with each session's daemon log. @@ -1125,6 +1128,7 @@ mod tests { started_at: "2026-07-17T00:00:00Z".to_string(), did: None, relay_url: None, + supervisor_managed: false, }; assert_eq!( classify_session_worker(&record, true, Some("/opt/wire daemon --interval 5")), @@ -1143,6 +1147,92 @@ mod tests { assert_eq!(classify_session_worker(&record, false, None), None); } + #[cfg(unix)] + #[test] + fn supervisor_does_not_retire_operator_started_daemon() { + let tmp = tempdir().unwrap(); + let state = tmp.path().join("state/wire"); + std::fs::create_dir_all(&state).unwrap(); + let output = std::process::Command::new("sh") + .args([ + "-c", + "sh -c 'while :; do sleep 1; done' wire daemon >/dev/null 2>&1 & echo $!", + ]) + .output() + .unwrap(); + let pid = String::from_utf8(output.stdout) + .unwrap() + .trim() + .parse::() + .unwrap(); + let record = crate::ensure_up::DaemonPid { + schema: crate::ensure_up::DAEMON_PID_SCHEMA.to_string(), + pid, + bin_path: "/opt/wire".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + started_at: "2026-08-10T00:00:00Z".to_string(), + did: None, + relay_url: None, + supervisor_managed: false, + }; + std::fs::write( + state.join("daemon.pid"), + serde_json::to_vec(&record).unwrap(), + ) + .unwrap(); + let mut session = initialized_session("operator-started", false); + session.home_dir = tmp.path().to_path_buf(); + + retire_inactive_worker(&session); + std::thread::sleep(Duration::from_millis(100)); + let alive = crate::platform::process_alive(pid); + let _ = crate::platform::kill_process(pid, false); + + assert!( + alive, + "all-session supervisor killed an operator-started daemon" + ); + } + + #[cfg(unix)] + #[test] + fn supervisor_retires_its_own_inactive_daemon() { + let tmp = tempdir().unwrap(); + let state = tmp.path().join("state/wire"); + std::fs::create_dir_all(&state).unwrap(); + let mut child = std::process::Command::new("sh") + .args(["-c", "while :; do sleep 1; done", "wire", "daemon"]) + .spawn() + .unwrap(); + let record = crate::ensure_up::DaemonPid { + schema: crate::ensure_up::DAEMON_PID_SCHEMA.to_string(), + pid: child.id(), + bin_path: "/opt/wire".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + started_at: "2026-08-10T00:00:00Z".to_string(), + did: None, + relay_url: None, + supervisor_managed: true, + }; + std::fs::write( + state.join("daemon.pid"), + serde_json::to_vec(&record).unwrap(), + ) + .unwrap(); + let mut session = initialized_session("supervisor-owned", false); + session.home_dir = tmp.path().to_path_buf(); + + retire_inactive_worker(&session); + std::thread::sleep(Duration::from_millis(100)); + let status = child.try_wait().unwrap(); + if status.is_none() { + let _ = child.kill(); + let _ = child.wait(); + } + + assert!(status.is_some(), "supervisor left its inactive child alive"); + } + #[test] fn crashed_worker_backoff_is_bounded_and_healthy_run_resets() { assert_eq!(next_worker_backoff(None, true), Duration::from_secs(2)); diff --git a/src/ensure_up.rs b/src/ensure_up.rs index d6b6969..003d2b8 100644 --- a/src/ensure_up.rs +++ b/src/ensure_up.rs @@ -87,6 +87,10 @@ pub struct DaemonPid { /// Relay this daemon was bound to at spawn. Catches daemon-bound-to- /// old-relay-after-migration drift. pub relay_url: Option, + /// True when the all-session supervisor spawned this daemon. False for + /// operator-started daemons and older pidfiles. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub supervisor_managed: bool, } /// Result of reading a pid file. JSON (full metadata) is the only @@ -515,6 +519,7 @@ fn build_pid_record(pid: u32) -> DaemonPid { .format(&time::format_description::well_known::Rfc3339) .unwrap_or_default(); let (did, relay_url) = identity_for_pid_record(); + let supervisor_managed = std::env::var("WIRE_SUPERVISOR_MANAGED").is_ok_and(|raw| raw == "1"); DaemonPid { schema: DAEMON_PID_SCHEMA.to_string(), pid, @@ -523,6 +528,7 @@ fn build_pid_record(pid: u32) -> DaemonPid { started_at, did, relay_url, + supervisor_managed, } } @@ -736,6 +742,7 @@ mod tests { started_at: "2026-05-16T01:23:45Z".to_string(), did: Some("did:wire:paul-mac".to_string()), relay_url: Some("https://wireup.net".to_string()), + supervisor_managed: false, }; write_pid_record("daemon", &record).unwrap(); let read = read_pid_record("daemon"); @@ -746,6 +753,42 @@ mod tests { }); } + #[test] + fn pid_record_marks_supervisor_owned_worker() { + let _guard = crate::config::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let previous = std::env::var_os("WIRE_SUPERVISOR_MANAGED"); + // SAFETY: ENV_LOCK serializes environment mutation in tests. + unsafe { std::env::set_var("WIRE_SUPERVISOR_MANAGED", "1") }; + let value = serde_json::to_value(build_pid_record(12345)).unwrap(); + // SAFETY: ENV_LOCK remains held through restoration. + unsafe { + match previous { + Some(value) => std::env::set_var("WIRE_SUPERVISOR_MANAGED", value), + None => std::env::remove_var("WIRE_SUPERVISOR_MANAGED"), + } + } + + assert_eq!(value["supervisor_managed"], true); + } + + #[test] + fn pid_record_without_supervisor_owner_remains_readable() { + let record: DaemonPid = serde_json::from_value(serde_json::json!({ + "schema": DAEMON_PID_SCHEMA, + "pid": 12345, + "bin_path": "/usr/local/bin/wire", + "version": "0.17.0", + "started_at": "2026-08-10T00:00:00Z", + "did": null, + "relay_url": null + })) + .unwrap(); + + assert!(!record.supervisor_managed); + } + #[test] fn pid_record_corrupt_reports_corrupt_not_panic() { // Today's debug had a stale pidfile pointing at a dead PID. The From b3acbf22b8f002990daa73c8ac9ba7d414ff6fcc Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 13:09:14 -0700 Subject: [PATCH 22/39] fix: speed up dashboard session scans --- SESSION_LOG_2026_08_10.md | 11 +++++ assets/operator-dashboard.js | 34 +++++++------ src/platform.rs | 34 +++++++++---- tests/operator_dashboard_polling.test.mjs | 59 +++++++++++++++++++++++ 4 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 tests/operator_dashboard_polling.test.mjs diff --git a/SESSION_LOG_2026_08_10.md b/SESSION_LOG_2026_08_10.md index 4cadcc1..e9638fe 100644 --- a/SESSION_LOG_2026_08_10.md +++ b/SESSION_LOG_2026_08_10.md @@ -97,6 +97,16 @@ After the first installed launch, refreshing the clean URL lost the in-memory la - Installed caller proof: launchd supervisor PID `20898` spawned workers including `agate-starshine` PID `25241`; their pidfiles contain `supervisor_managed: true`. Operator-started `rusted-butte` PID `32746` omits the marker and remained healthy through repeated supervisor polls. - Chrome proof: refreshed the existing dashboard tab, found exactly one `rusted-butte` row, and left it visibly centered with `Wire daemon`, project `wire`, and `HEALTHY` status. +## Dashboard responsiveness repair + +- Observed defect: `/api/sessions` took 14–25 seconds while the browser requested another scan every two seconds; link confirmation waited behind the same inventory backlog. +- Root cause: this machine has 3,003 historical session homes and 1,739 daemon pidfiles. macOS liveness forked `/bin/kill -0` once per pidfile, so each inventory created roughly 1,739 subprocesses. Concurrent polling multiplied the scan. +- RED/GREEN: 512 self-PID checks took 3.51 seconds and failed the one-second regression ceiling before the fix; the same test took 0.01 seconds after the fix. `wire session list --json` fell from 21.36 seconds to 1.01 seconds. +- Fix: macOS/BSD liveness now invokes `kill(2)` with signal zero in-process and treats permission-denied as proof the PID exists. The browser coalesces poll ticks behind one shared scan promise, so mutation refreshes and interval ticks cannot overlap inventory requests. +- Review cycle 1 kept and added proof that polling resumes after a completed scan. AMANALAP deferred an unobserved never-settling fetch timeout and timing-test hardening; it cut an errno-comment polish item. +- Live caller: `GET /api/sessions` and link/group validation call `collect_live_sessions`; the dashboard JavaScript owns the two-second refresh cadence. +- Persistent Wire monitor: `rusted-butte` monitor session remained armed throughout the repair. + ## Artifacts - `src/operator.rs` — live inventory and explicit-home topology operations. @@ -104,6 +114,7 @@ After the first installed launch, refreshing the clean URL lost the in-memory la - `src/session_metadata.rs` — provenance descriptors, Git discovery, identity classification, and bounded process snapshots. - `assets/operator-dashboard.{html,css,js}` — operator interface. - `tests/e2e_operator_dashboard.rs` — installed caller-path topology proof. +- `tests/operator_dashboard_polling.test.mjs` — browser polling single-flight regression. - `docs/superpowers/specs/2026-08-10-operator-dashboard-design.md` — approved product and architecture boundary. - `docs/superpowers/specs/2026-08-10-fleet-session-provenance-design.md` — approved local metadata and future fleet boundary. - `docs/superpowers/plans/2026-08-10-fleet-session-provenance.md` — linted execution plan. diff --git a/assets/operator-dashboard.js b/assets/operator-dashboard.js index 4279f93..bb60e77 100644 --- a/assets/operator-dashboard.js +++ b/assets/operator-dashboard.js @@ -7,7 +7,7 @@ const token = queryToken || window.sessionStorage.getItem("wire-launch-token") || ""; window.history.replaceState({}, "", window.location.pathname); - const state = { sessions: [], selected: new Set(), expanded: new Set(), confirmedPair: [], busy: false }; + const state = { sessions: [], selected: new Set(), expanded: new Set(), confirmedPair: [], busy: false, scanPromise: null }; const rows = document.querySelector("#session-rows"); const tableWrap = document.querySelector("#table-wrap"); const loading = document.querySelector("#loading"); @@ -222,20 +222,24 @@ updateActions(); }; - const scan = async () => { - try { - const response = await fetch("/api/sessions", { - cache: "no-store", - headers: { "X-Wire-Token": token } - }); - if (!response.ok) throw new Error("Could not read live sessions."); - const report = await response.json(); - state.sessions = Array.isArray(report.sessions) ? report.sessions : []; - render(); - } catch (error) { - loading.hidden = true; - showNotice(error.message || "Session scan failed.", "error"); - } + const scan = () => { + if (state.scanPromise) return state.scanPromise; + state.scanPromise = (async () => { + try { + const response = await fetch("/api/sessions", { + cache: "no-store", + headers: { "X-Wire-Token": token } + }); + if (!response.ok) throw new Error("Could not read live sessions."); + const report = await response.json(); + state.sessions = Array.isArray(report.sessions) ? report.sessions : []; + render(); + } catch (error) { + loading.hidden = true; + showNotice(error.message || "Session scan failed.", "error"); + } + })().finally(() => { state.scanPromise = null; }); + return state.scanPromise; }; const mutate = async (path, body) => { diff --git a/src/platform.rs b/src/platform.rs index 03b6a6b..6cd2024 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -118,7 +118,7 @@ fn kill_pid_best_effort(pid: u32) { /// True iff pid is alive. /// /// - Linux: `/proc/` exists (no fork, no shell-out). -/// - macOS / BSD: `kill -0 ` (signal 0 = check only). +/// - macOS / BSD: `kill(2)` with signal 0 (check only, no subprocess). /// - Windows: `tasklist /FI "PID eq " /FO CSV /NH`. A miss prints /// `INFO: No tasks are running...` to stdout AND exits 0, so we /// detect by content rather than exit code. @@ -129,14 +129,17 @@ pub fn process_alive(pid: u32) -> bool { } #[cfg(all(unix, not(target_os = "linux")))] { - Command::new("kill") - .args(["-0", &pid.to_string()]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + let Ok(pid) = i32::try_from(pid) else { + return false; + }; + unsafe extern "C" { + fn kill(pid: i32, signal: i32) -> i32; + } + // SAFETY: signal 0 never delivers a signal; it only asks the kernel + // whether the process exists and is visible to this user. + let result = unsafe { kill(pid, 0) }; + result == 0 + || std::io::Error::last_os_error().kind() == std::io::ErrorKind::PermissionDenied } #[cfg(windows)] { @@ -622,6 +625,19 @@ mod tests { ); } + #[cfg(all(unix, not(target_os = "linux")))] + #[test] + fn repeated_process_liveness_checks_stay_in_process() { + let started = std::time::Instant::now(); + for _ in 0..512 { + assert!(process_alive(std::process::id())); + } + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "512 liveness checks must not fork one subprocess per PID" + ); + } + #[test] fn parse_session_arg_extracts_following_value() { assert_eq!( diff --git a/tests/operator_dashboard_polling.test.mjs b/tests/operator_dashboard_polling.test.mjs new file mode 100644 index 0000000..439952e --- /dev/null +++ b/tests/operator_dashboard_polling.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +test("dashboard polling coalesces unfinished scans and resumes after completion", async () => { + const intervals = []; + let fetchCalls = 0; + let finishFetch; + const element = { + addEventListener() {}, + append() {}, + replaceChildren() {}, + classList: { toggle() {} }, + dataset: {}, + hidden: false, + disabled: false, + textContent: "" + }; + const storage = new Map(); + const window = { + location: { search: "?token=test-token", pathname: "/" }, + history: { replaceState() {} }, + sessionStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value) + }, + setInterval: (callback) => intervals.push(callback) + }; + const context = { + URLSearchParams, + console, + document: { + createElement: () => element, + createDocumentFragment: () => element, + querySelector: () => element + }, + fetch: () => { + fetchCalls += 1; + return new Promise((resolve) => { + finishFetch = () => resolve({ ok: true, json: async () => ({ sessions: [] }) }); + }); + }, + window + }; + + const source = readFileSync(new URL("../assets/operator-dashboard.js", import.meta.url), "utf8"); + vm.runInNewContext(source, context); + + assert.equal(fetchCalls, 1, "initial page load starts one scan"); + assert.equal(intervals.length, 1); + for (let index = 0; index < 4; index += 1) intervals[0](); + assert.equal(fetchCalls, 1, "poll ticks must coalesce behind the unfinished scan"); + + finishFetch(); + await new Promise((resolve) => setImmediate(resolve)); + intervals[0](); + assert.equal(fetchCalls, 2, "polling must resume after the prior scan settles"); +}); From 6c2eb4096697e8566a42b1e41bc12cfbbbddd13f Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 15:26:46 -0700 Subject: [PATCH 23/39] docs: design operator topology map --- SESSION_LOG_2026_08_10.md | 1 + ...2026-08-10-operator-topology-map-design.md | 301 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-operator-topology-map-design.md diff --git a/SESSION_LOG_2026_08_10.md b/SESSION_LOG_2026_08_10.md index e9638fe..c6dad95 100644 --- a/SESSION_LOG_2026_08_10.md +++ b/SESSION_LOG_2026_08_10.md @@ -117,4 +117,5 @@ After the first installed launch, refreshing the clean URL lost the in-memory la - `tests/operator_dashboard_polling.test.mjs` — browser polling single-flight regression. - `docs/superpowers/specs/2026-08-10-operator-dashboard-design.md` — approved product and architecture boundary. - `docs/superpowers/specs/2026-08-10-fleet-session-provenance-design.md` — approved local metadata and future fleet boundary. +- `docs/superpowers/specs/2026-08-10-operator-topology-map-design.md` — approved clustered map, topology contract, and select-then-link boundary. - `docs/superpowers/plans/2026-08-10-fleet-session-provenance.md` — linted execution plan. diff --git a/docs/superpowers/specs/2026-08-10-operator-topology-map-design.md b/docs/superpowers/specs/2026-08-10-operator-topology-map-design.md new file mode 100644 index 0000000..3b6313d --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-operator-topology-map-design.md @@ -0,0 +1,301 @@ +# Wire Operator Topology Map Design + +## Goal + +Add a map view to Wire Operator that shows how live sessions connect. The map +must distinguish bilateral Wire links from group membership, preserve the +existing consent model, and scale from one machine to an operator-owned fleet. + +The map extends the current dashboard. It does not create a second operator +application or a new agent harness. + +## Confirmed choices + +- Use a clustered graph. +- Place each live session inside its machine boundary. +- Draw bilateral links as solid lines. +- Draw Wire groups as labeled translucent regions around their live members. +- Let the operator select nodes, then invoke **Link selected** or + **Create group** through the existing explicit actions. +- Keep drag-to-connect out of scope. +- Show live sessions only. Historical and retired identities stay off the map. +- Preserve the list view for dense inspection and keyboard fallback. +- Design the data contract for multiple machines while shipping one-machine + discovery first. + +## Placement options considered + +### Integrated Map tab — selected + +Add **Map** and **List** tabs to Wire Operator. Both views consume one topology +snapshot and share selection, filters, mutations, notices, and inspection +state. This keeps topology changes in the existing loopback security boundary. + +### List overlay + +Draw edges behind the current table. This preserves row density but produces +long crossing lines and fails once sessions span machines. + +### Separate topology application + +Build a dedicated graph service and link to it from Wire Operator. This creates +two security boundaries, two inventories, and two selection models for one +operator task. + +## Interaction design + +The dashboard opens on **Map**. A segmented **Map / List** control switches +views without losing selection or filters. + +### Graph + +- A machine cluster uses its stable machine fingerprint as its key. The header + shows hostname, operating system, architecture, and live-session count. +- A session node shows persona emoji, handle, harness label, and health ring. +- A solid line joins two sessions only when both homes contain the bilateral + relationship. +- A one-sided relationship renders as an amber dashed anomaly. The map never + upgrades it to a bilateral link. +- A group region shows group name and live-member count. Its boundary encloses + live roster members. A group spanning machines gets one same-colored region + fragment per machine. Group membership never creates pairwise link edges. +- Cross-machine links cross cluster boundaries. The edge keeps the same + bilateral semantics. + +The first layout uses stable positions: machine clusters form a grid; nodes use +a DID-derived order inside each cluster. Refreshes keep nodes in place unless +the topology changes. Native SVG provides pan, zoom, edges, regions, node +focus, and selection. The build adds no graph dependency or remote script. + +### Selection and actions + +- Clicking a node toggles selection. +- A selected node opens its existing details in the inspector. +- Two selected nodes enable **Link selected**. +- Two or more selected nodes enable **Create group**. +- Switching to List preserves the same selected session IDs. +- A session that disappears drops from selection during the next snapshot. +- `Escape` clears selection. `Enter` or `Space` toggles a focused node. +- The existing confirmation dialog remains the link consent ceremony. + +The map never mutates topology through dragging, edge clicks, or group-region +movement. + +### Filters + +Map and List share these filters: + +- text search across handle, project, branch, and harness; +- machine; +- harness; +- project; +- health; +- connected only. + +Filtering hides nodes from the presentation, not from the topology snapshot. +Edges and group regions recompute from visible nodes. A hidden endpoint never +causes a visible dangling edge. + +## Topology contract + +Add a read-only `GET /api/topology` route. It returns one server-built snapshot: + +```json +{ + "schema": "wire-topology-v1", + "generated_at": "2026-08-10T20:00:00Z", + "machines": [ + { + "id": "", + "hostname": "Pauls-MacBook-Pro-2.local", + "os": "macos", + "arch": "aarch64", + "identity_confidence": "verified" + } + ], + "sessions": [ + { + "machine_id": "", + "session": "" + } + ], + "direct_links": [ + { + "id": ":", + "source_did": "did:wire:rusted-butte-b1616319", + "target_did": "did:wire:umber-savanna-e98187b5", + "state": "bilateral" + } + ], + "groups": [ + { + "id": "", + "name": "crew-health", + "creator_did": "did:wire:rusted-butte-b1616319", + "epoch": 1, + "members": [ + { + "did": "did:wire:rusted-butte-b1616319", + "tier": "creator", + "live": true + } + ] + } + ], + "anomalies": [] +} +``` + +Each `sessions` entry wraps one complete, unchanged `wire-live-sessions-v2` row +with the containing `machine_id`. The topology route does not revise the live +session schema. + +When a machine fingerprint is unavailable, the server emits an +`unverified:::` ID and sets `identity_confidence` to +`unverified`. The UI marks that cluster. This fallback groups rows for display; +it does not authorize a cross-machine mutation. + +### Direct-link construction + +The server reads the peer state for every live session. It canonicalizes each +candidate edge by sorted endpoint DID. An edge is `bilateral` only when both +live endpoints name each other. A relationship present on one side becomes an +anomaly and an edge with state `one-sided`. + +The response omits edges whose endpoint DID does not match a live session. It +may report the omitted stale peer count in `anomalies`; it never creates a +historical node. + +### Group construction + +The server reads group rosters already accepted by the existing group subsystem +from live session homes and merges copies by group ID. The highest epoch wins. +Copies at the same epoch must agree on creator DID and member DIDs. A conflict +becomes an anomaly and suppresses the group region until resolved. + +The response exposes group ID, name, creator DID, epoch, member DID, membership +tier, and live state. It never exposes relay URLs, slot IDs, slot tokens, +signing keys, signatures, or filesystem paths. + +## Components + +### Topology producer + +`operator` gains one read-only topology builder. It composes the live-session +inventory, peer records, group rosters, and machine descriptors. Link and group +mutations continue to validate against the live-session inventory and use the +existing routes. + +### HTTP route + +`operator_web` serves `GET /api/topology` under the existing loopback launch +token, Host, Origin, content-security, and no-store rules. The route performs +one single-flight snapshot at a time, matching the session inventory polling +contract. + +### Browser state + +One client state object owns the topology snapshot, selected session IDs, +filters, active view, expanded inspector, notice, and in-flight scan promise. +Map and List render from that state. Neither view fetches its own competing +inventory. + +### Map renderer + +The renderer is a focused module inside the embedded dashboard JavaScript. It +computes stable machine and node positions, group boundaries, visible edges, +and SVG accessibility attributes. It has no mutation authority; it emits +selection intents to the shared controller. + +## Data flow + +1. The browser requests `/api/topology`. +2. The server collects live sessions once. +3. The topology builder joins machine, bilateral peer, and sanitized group + state. +4. The browser replaces its snapshot and drops vanished selections. +5. Map and List render from the same state. +6. Selection enables the existing mutation actions. +7. A successful mutation triggers one topology refresh. + +## Error handling + +- Inventory failure preserves the last rendered snapshot and shows a stale + banner with the failed scan time. +- A partial peer relationship renders as a dashed anomaly, not a solid link. +- Conflicting group copies suppress the affected region and surface the group + ID in the inspector. +- Missing machine fingerprint marks the cluster unverified. +- A mutation against a vanished session returns the existing conflict response, + clears vanished selection, and refreshes topology. +- An empty topology renders a start-session prompt, not a blank canvas. +- Pan and zoom reset through a visible **Fit map** control. + +## Security and privacy + +- The server remains loopback-only. +- The launch token protects the topology route as it protects session inventory. +- The response contains DIDs and operator-facing metadata already visible in + Wire Operator. +- The response strips group room credentials, peer transport credentials, + public keys, signatures, raw host session keys, and command lines. Existing + project paths remain part of the embedded live-session row. +- Map selection grants no new authority. Existing link confirmation and group + creation rules remain the mutation boundary. + +## Verification + +### Rust + +- Topology builder emits one machine cluster for same-fingerprint sessions. +- Bilateral peer records produce one canonical solid edge. +- One-sided peer records produce one anomaly and never a bilateral edge. +- Group membership produces a region record without pairwise edges. +- Conflicting same-epoch group copies suppress the group and report an anomaly. +- Stale peers and historical group members never become live nodes. +- Serialized output excludes every credential and key field. +- HTTP tests cover token rejection and the topology schema. + +### Browser + +- Map and List share selection across view changes. +- Two selected nodes enable Link; two or more enable Create group. +- Polling coalesces unfinished topology scans and resumes after completion. +- Filtering removes dangling edges and recomputes group regions. +- Keyboard focus, `Enter`, `Space`, and `Escape` work on map nodes. +- A stale response preserves the last map and displays a warning. +- Desktop and narrow viewport renders have no horizontal page overflow. + +### Live proof + +- Run the installed `wire dash --web --no-open` caller. +- Observe `rusted-butte` and `umber-savanna` as live nodes joined by one solid + edge. +- Create a test group from selected live sessions and observe one group region + without new pairwise edges. +- Switch Map to List and confirm selection persists. +- Capture console and network logs with no errors or failed requests. + +## Success criteria + +1. The dashboard opens on a clustered map of live sessions. +2. Every session belongs to one explicit machine cluster. +3. Solid edges mean bilateral Wire links and nothing else. +4. Group regions show group membership without implying a full mesh. +5. Select-then-link and select-then-group use the existing confirmation and + mutation routes. +6. Map and List share filters, selection, notices, and one polling loop. +7. The map remains legible with 20 live sessions and structurally supports more + than one machine. +8. The topology response exposes no credentials, raw session keys, or hidden + historical identities. +9. Focused, full, and live-browser verification passes. + +## Deferred work + +- Cross-machine discovery, enrollment, and mutation. +- Drag-to-connect or canvas editing. +- Supervisor hierarchy and boss/subagent control. +- Messaging, conversation, retirement, and process lifecycle controls. +- Salud health overlays and per-group external links. +- Historical topology playback. From c16dd388e12ee817b73dfb72c6b97fe75b9124e5 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 15:35:32 -0700 Subject: [PATCH 24/39] docs: plan operator topology map --- SESSION_LOG_2026_08_10.md | 1 + .../plans/2026-08-10-operator-topology-map.md | 534 ++++++++++++++++++ 2 files changed, 535 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-operator-topology-map.md diff --git a/SESSION_LOG_2026_08_10.md b/SESSION_LOG_2026_08_10.md index c6dad95..85aed59 100644 --- a/SESSION_LOG_2026_08_10.md +++ b/SESSION_LOG_2026_08_10.md @@ -119,3 +119,4 @@ After the first installed launch, refreshing the clean URL lost the in-memory la - `docs/superpowers/specs/2026-08-10-fleet-session-provenance-design.md` — approved local metadata and future fleet boundary. - `docs/superpowers/specs/2026-08-10-operator-topology-map-design.md` — approved clustered map, topology contract, and select-then-link boundary. - `docs/superpowers/plans/2026-08-10-fleet-session-provenance.md` — linted execution plan. +- `docs/superpowers/plans/2026-08-10-operator-topology-map.md` — task-level TDD plan for the topology producer, authenticated route, shared browser state, native SVG map, and installed proof. diff --git a/docs/superpowers/plans/2026-08-10-operator-topology-map.md b/docs/superpowers/plans/2026-08-10-operator-topology-map.md new file mode 100644 index 0000000..898a110 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-operator-topology-map.md @@ -0,0 +1,534 @@ +# Wire Operator Topology Map 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:** Add a default clustered Map view to Wire Operator that shows live sessions by machine, bilateral links as edges, and Wire groups as regions while preserving the existing select-then-link and select-then-group consent flow. + +**Architecture:** A new read-only topology producer composes the existing live-session report with each live home’s peers and sanitized group rosters. `GET /api/topology` exposes that snapshot behind the current loopback token and one shared scan lock. A dependency-free browser helper filters and lays out the graph; Map and List render from one client state object and one polling loop. + +**Tech Stack:** Rust, Axum, Serde, embedded HTML/CSS/JavaScript, native SVG, Node’s built-in test runner, Cargo integration tests, Playwright live-browser verification. + +## Global Constraints + +- Work only on `feat/operator-dashboard`; never stage the user-owned `AGENTS.md` or `.superpowers/` visual-companion output. +- Before editing an existing Rust or JavaScript symbol, run GitNexus upstream impact analysis and report any HIGH or CRITICAL result before proceeding. +- After every task, run `node .gitnexus/run.cjs detect-changes --scope staged --repo wire` before committing. +- Keep the current mutation contracts: `/api/links` links exactly two live sessions after confirmation; `/api/groups` creates one shared room for two or more selected live sessions. +- Keep the dashboard loopback-only and token-gated. Do not add remote scripts, graph dependencies, lifecycle control, messaging, or cross-machine mutation. +- Emit live sessions only. Never synthesize nodes for stale peers, retired sessions, or historical group members. +- Never serialize group relay coordinates, room tokens, signing keys, signatures, filesystem homes, raw session keys, or command lines. +- Preserve `wire-live-sessions-v2` unchanged inside each topology session wrapper. +- Use Test-Driven Development (TDD): establish RED, implement the minimum GREEN change, then refactor only if checks remain green. +- Each task ends with a verified commit and push. Do not merge. + +--- + +## File Structure + +| Path | Responsibility | +|---|---| +| `src/operator_topology.rs` | Topology Data Transfer Objects (DTOs), pure merge/canonicalization logic, live-home collection, and focused Rust tests. | +| `src/operator.rs` | Reuse the existing live inventory from a caller-supplied `SessionInfo` slice so topology collection lists homes once. | +| `src/group.rs` | Add an explicit-home, read-only group-list function; retain the current session-scoped public wrapper. | +| `src/lib.rs` | Export the topology module. | +| `src/operator_web.rs` | Serve `/api/topology`, share one inventory scan lock, embed the topology helper asset, and test auth/security. | +| `assets/operator-topology.js` | Pure filtering, canonical visible-subgraph construction, and deterministic machine/node/group geometry. | +| `assets/operator-dashboard.js` | Shared topology state, polling, Map/List controller, SVG renderer, pan/zoom, selection, dialogs, and stale-snapshot behavior. | +| `assets/operator-dashboard.html` | Map/List switch, filters, map canvas, inspector, Fit map control, and retained table/dialog markup. | +| `assets/operator-dashboard.css` | Machine clusters, nodes, edges, group regions, health states, responsive controls, and accessible focus states. | +| `tests/operator_topology_model.test.mjs` | Pure browser filtering and deterministic-layout tests. | +| `tests/operator_dashboard_topology.test.mjs` | Real dashboard controller tests for one polling loop, shared selection, keyboard actions, and stale responses. | +| `tests/e2e_operator_dashboard.rs` | Real binary/API proof for topology schema, bilateral edges, sanitized groups, and existing mutations. | +| `SESSION_LOG_2026_08_10.md` | Decision, caller, verification, and artifact record. | + +--- + +### Task 1: Build the topology snapshot from explicit live-session homes + +**Files:** +- Create: `src/operator_topology.rs` +- Modify: `src/operator.rs` +- Modify: `src/group.rs` +- Modify: `src/lib.rs` + +**Interfaces:** + +```rust +pub const TOPOLOGY_SCHEMA: &str = "wire-topology-v1"; + +#[derive(Clone, Debug, Serialize)] +pub struct TopologyReport { + pub schema: &'static str, + pub generated_at: String, + pub machines: Vec, + pub sessions: Vec, + pub direct_links: Vec, + pub groups: Vec, + pub anomalies: Vec, +} + +pub fn collect_topology() -> anyhow::Result; +pub(crate) fn list_groups_at(home: &Path) -> anyhow::Result>; +pub(crate) fn collect_live_sessions_from( + sessions: &[crate::session::SessionInfo], +) -> anyhow::Result; + +#[derive(Clone)] +struct TopologySource { + session: LiveSession, + peers: Vec, + groups: Vec, +} + +fn build_topology( + sources: Vec, + generated_at: OffsetDateTime, +) -> TopologyReport; +``` + +`TopologyMachine` contains `id`, `hostname`, `os`, `arch`, and `identity_confidence`. `TopologySession` contains `machine_id` plus the complete `LiveSession`. `DirectLink` contains `id`, `source_did`, `target_did`, and `state`. `TopologyGroup` contains only `id`, `name`, `creator_did`, `epoch`, and sanitized `{did, tier, live}` members. `TopologyAnomaly` contains `kind`, `subject_id`, and an operator-safe `message`. + +- [ ] Run impact analysis before modifying existing symbols: + +```bash +node .gitnexus/run.cjs impact --target collect_live_sessions --direction upstream --repo wire +node .gitnexus/run.cjs impact --target list_groups --direction upstream --repo wire +``` + +- [ ] Add focused RED tests inside `src/operator_topology.rs` using pure `TopologySource` fixtures. Cover: + + - two sessions with one machine fingerprint produce one verified machine; + - missing fingerprints produce `unverified:::` and unverified confidence; + - reciprocal peer DIDs produce one sorted, canonical `bilateral` edge; + - one peer record produces one `one-sided` edge and one anomaly; + - a peer DID absent from live sessions produces no node or edge; + - group membership produces one region record and no pairwise direct links; + - the highest group epoch wins; + - equal highest epochs with different creator/member DIDs suppress the group and emit an anomaly; + - historical members remain in the sanitized roster with `live: false` but never become session nodes; + - serialized output omits `relay_url`, `slot_id`, `slot_token`, `key_id`, `key`, `creator_sig`, `home_dir`, and `command_line`. + +- [ ] Run the new test target and confirm RED because `operator_topology` does not exist: + +```bash +cargo test operator_topology --lib +``` + +Expected: compile failure for the missing module/types. + +- [ ] Add `pub mod operator_topology;` to `src/lib.rs`. + +- [ ] Refactor `src/group.rs` without changing behavior: + +```rust +pub(crate) fn list_groups_at(home: &Path) -> Result> { + list_groups_in(&home.join("config/wire/groups")) +} + +pub fn list_groups() -> Result> { + list_groups_in(&groups_dir()?) +} +``` + +Keep the existing JSON-extension filter, parse-failure skip, and name sort in the shared private `list_groups_in` body. + +- [ ] Refactor `src/operator.rs` so `collect_live_sessions()` lists once and delegates: + +```rust +pub fn collect_live_sessions() -> anyhow::Result { + let sessions = crate::session::list_sessions()?; + collect_live_sessions_from(&sessions) +} + +pub(crate) fn collect_live_sessions_from( + sessions: &[crate::session::SessionInfo], +) -> anyhow::Result { + collect_live_from( + sessions, + OffsetDateTime::now_utc(), + crate::platform::process_alive, + ) +} +``` + +- [ ] Implement `collect_topology()` with one `session::list_sessions()` call, one `collect_live_sessions_from(&sessions)` call, and an `id -> home_dir` map drawn only from the already-listed `SessionInfo` records. Read peers with `dash::read_peers` and groups with `group::list_groups_at` only for IDs present in the live report. + +- [ ] Implement the pure builder with `BTreeMap`/sorted vectors for deterministic output. Canonicalize every link by sorting endpoint DIDs. Mark it bilateral only when both directed observations exist. Merge group copies by highest epoch; compare creator DID and the sorted `(did, tier)` roster at equal highest epoch. + +- [ ] Format and run focused GREEN checks: + +```bash +cargo fmt --check +cargo test operator_topology --lib +cargo test group::tests --lib +cargo test operator::tests --lib +``` + +Expected: all pass. + +- [ ] Stage only Task 1 files, inspect scope, commit, and push: + +```bash +git add src/operator_topology.rs src/operator.rs src/group.rs src/lib.rs +node .gitnexus/run.cjs detect-changes --scope staged --repo wire +git diff --cached --check +git commit -m "feat: build live operator topology" +git push +``` + +--- + +### Task 2: Serve one authenticated, single-flight topology route + +**Files:** +- Modify: `src/operator_web.rs` +- Modify: `tests/e2e_operator_dashboard.rs` + +**Interfaces:** + +```rust +#[derive(Clone)] +struct AppState { + token: String, + scan_lock: Arc>, +} + +async fn get_topology(State(state): State, headers: HeaderMap) -> Response; +``` + +- [ ] Run impact analysis: + +```bash +node .gitnexus/run.cjs impact --target router --direction upstream --repo wire +node .gitnexus/run.cjs impact --target get_sessions --direction upstream --repo wire +``` + +- [ ] Extend `operator_web::tests::mutation_routes_require_token_and_json` with RED assertions that `/api/topology` rejects a missing/wrong token and hostile Host/Origin exactly like `/api/sessions`. + +- [ ] Extend `tests/e2e_operator_dashboard.rs` so the real dashboard binary fetches `/api/topology` and asserts: + +```rust +assert_eq!(topology["schema"], "wire-topology-v1"); +assert_eq!(topology["sessions"].as_array().unwrap().len(), 3); +assert_eq!(topology["machines"].as_array().unwrap().len(), 1); +``` + +After the existing link and group POSTs, refetch topology and assert one bilateral edge exists for the selected pair, the group contains all three DIDs, and the serialized response contains none of the secret field names from Task 1. + +- [ ] Run RED: + +```bash +cargo test operator_web::tests::mutation_routes_require_token_and_json --lib +cargo test --test e2e_operator_dashboard +``` + +Expected: `/api/topology` returns 404. + +- [ ] Add `.route("/api/topology", get(get_topology))`. Initialize one `Arc>` in `router`; acquire it in both `get_sessions` and `get_topology` before their `spawn_blocking` calls so old List clients and new Map clients cannot overlap full inventory scans. + +- [ ] Return `operator_topology::collect_topology()` as JSON. Preserve the current generic `session inventory failed`/`topology inventory failed` response boundary; never return internal filesystem or parse errors. + +- [ ] Run GREEN and security checks: + +```bash +cargo fmt --check +cargo test operator_web::tests --lib +cargo test --test e2e_operator_dashboard +``` + +- [ ] Stage, inspect, commit, and push: + +```bash +git add src/operator_web.rs tests/e2e_operator_dashboard.rs +node .gitnexus/run.cjs detect-changes --scope staged --repo wire +git diff --cached --check +git commit -m "feat: serve operator topology snapshot" +git push +``` + +--- + +### Task 3: Add the pure browser topology model and stable layout + +**Files:** +- Create: `assets/operator-topology.js` +- Create: `tests/operator_topology_model.test.mjs` +- Modify: `src/operator_web.rs` +- Modify: `assets/operator-dashboard.html` + +**Interfaces:** + +```javascript +window.WireTopology = Object.freeze({ + visibleTopology, + layoutTopology, + fitTransform, + groupColor +}); +``` + +`visibleTopology(snapshot, filters)` returns `{machines, sessions, directLinks, groups}` without mutating the snapshot. `layoutTopology(visible, viewport)` returns machine rectangles, node points, edge paths, and per-machine group-region rectangles. `fitTransform(layout, viewport)` returns `{x, y, scale}`. + +- [ ] Run impact analysis before changing asset routes and page scripts: + +```bash +node .gitnexus/run.cjs impact --target router --direction upstream --repo wire +node .gitnexus/run.cjs impact --target index --direction upstream --repo wire +``` + +- [ ] Write RED Node tests with a two-machine, four-session fixture. Assert: + + - search matches handle, project name, branch, and harness label case-insensitively; + - machine, harness, project, health, and connected-only filters compose; + - a filtered endpoint removes its edge; + - a group region contains only visible members and disappears below two visible live members; + - machine order and DID-sorted node positions remain identical when input arrays are reversed; + - a cross-machine bilateral edge retains both endpoints; + - `fitTransform` returns a finite positive scale for empty and populated layouts. + +- [ ] Run RED: + +```bash +node --test tests/operator_topology_model.test.mjs +``` + +Expected: missing `assets/operator-topology.js`. + +- [ ] Implement `assets/operator-topology.js` as a strict-mode Immediately Invoked Function Expression (IIFE). Use no DOM APIs in the helper. Use stable string comparison on machine ID and session DID. Build a machine grid with fixed cluster/node dimensions; calculate each group fragment as the padded bounding rectangle of visible members on that machine. Use a deterministic hue derived from group ID. + +- [ ] Add `const TOPOLOGY_JAVASCRIPT = include_str!("../assets/operator-topology.js");`, serve it as `/topology.js` with the existing JavaScript content type, and load it before `/dashboard.js` with `defer`. + +- [ ] Extend the asset security test to assert the helper route has no remote URL, `innerHTML`, `eval`, or dynamic script construction. + +- [ ] Run GREEN: + +```bash +node --test tests/operator_topology_model.test.mjs +cargo test operator_web::tests::dashboard_assets_are_served_with_local_security_contract --lib +``` + +- [ ] Stage, inspect, commit, and push: + +```bash +git add assets/operator-topology.js assets/operator-dashboard.html src/operator_web.rs tests/operator_topology_model.test.mjs +node .gitnexus/run.cjs detect-changes --scope staged --repo wire +git diff --cached --check +git commit -m "feat: add deterministic topology model" +git push +``` + +--- + +### Task 4: Move Map and List onto one topology state and polling loop + +**Files:** +- Modify: `assets/operator-dashboard.html` +- Modify: `assets/operator-dashboard.js` +- Modify: `assets/operator-dashboard.css` +- Modify: `tests/operator_dashboard_polling.test.mjs` +- Create: `tests/operator_dashboard_topology.test.mjs` + +**Interfaces:** + +```javascript +const state = { + topology: emptyTopology, + selected: new Set(), + expanded: new Set(), + filters: { + search: "", machine: "", harness: "", project: "", health: "", + connectedOnly: false + }, + activeView: "map", + busy: false, + scanPromise: null, + stale: false +}; +``` + +The controller fetches only `/api/topology`. Session rows become a derived list +of wrapped `entry.session` values, preserving current mutation request bodies. + +- [ ] Run GitNexus impact analysis for the existing dashboard `scan`, `render`, and selection flow. If the JavaScript symbols are absent from the index, record that limitation and inspect all direct DOM listeners before editing. + +- [ ] Update the real-script VM harnesses with distinct element stubs and write RED behaviors: + + - initial load calls `/api/topology` once; + - unfinished poll ticks coalesce and polling resumes after settlement; + - failed refresh keeps the previous topology and exposes a stale/error notice; + - vanished session IDs are removed from `selected` after a successful refresh; + - selecting in Map, switching to List, and switching back preserves the same selected IDs; + - exactly two selections enable Link and two or more enable Create group; + - `Escape` clears selection; + - filter changes render both views from the same visible topology. + +- [ ] Run RED: + +```bash +node --test tests/operator_dashboard_polling.test.mjs tests/operator_dashboard_topology.test.mjs +``` + +Expected: the controller still requests `/api/sessions` and has no Map/List/filter state. + +- [ ] Add the segmented Map/List control with `aria-pressed`, a filter bar for text/machine/harness/project/health/connected-only, `#map-panel`, retained `#list-panel`, `#topology-map`, `#map-inspector`, and `#fit-map`. Open on Map. + +- [ ] Change `scan()` to fetch `/api/topology`. On success, replace `state.topology`, intersect selection with the new live session IDs, clear stale state, repopulate filter options, and call one `render()`. On failure, preserve the prior snapshot, mark it stale, and render a warning with the failed scan time. + +- [ ] Extract `toggleSelection(id)` and call it from both table checkboxes and map intents. Keep `selectedSessions()` as the sole source for the existing link and group dialogs. Preserve `confirmedPair` race validation against current live IDs. + +- [ ] Render the List from `WireTopology.visibleTopology(state.topology, state.filters)`, not from a second inventory. Hide filtered rows rather than deleting snapshot data. Update empty copy based on “no live sessions” versus “filters hide all sessions.” + +- [ ] Implement view and filter CSS without map geometry yet. At 390 px, controls stack and the page body must not overflow horizontally. + +- [ ] Run GREEN: + +```bash +node --test tests/operator_dashboard_polling.test.mjs tests/operator_dashboard_topology.test.mjs +cargo test operator_web::tests::dashboard_assets_are_served_with_local_security_contract --lib +``` + +- [ ] Stage, inspect, commit, and push: + +```bash +git add assets/operator-dashboard.html assets/operator-dashboard.js assets/operator-dashboard.css tests/operator_dashboard_polling.test.mjs tests/operator_dashboard_topology.test.mjs +node .gitnexus/run.cjs detect-changes --scope staged --repo wire +git diff --cached --check +git commit -m "feat: share topology state across map and list" +git push +``` + +--- + +### Task 5: Render and operate the native SVG topology map + +**Files:** +- Modify: `assets/operator-dashboard.js` +- Modify: `assets/operator-dashboard.css` +- Modify: `tests/operator_dashboard_topology.test.mjs` + +**Interfaces:** + +`renderMap(visible)` owns SVG rendering but no mutations. +`setViewport({x, y, scale})` updates one viewport ``. +`fitMap()` passes `WireTopology.fitTransform(layout, viewport)` to +`setViewport`. + +- [ ] Run GitNexus impact analysis for `render` and `toggleSelection`, or document the index gap and inspect their direct callers. + +- [ ] Add RED VM assertions that rendered SVG semantics include: + + - one labeled cluster per machine; + - `role="button"`, `tabindex="0"`, and `aria-pressed` on session nodes; + - solid bilateral edges and amber dashed one-sided edges; + - group regions before edges/nodes in paint order; + - one group fragment per machine for a cross-machine group; + - no direct edge created from group membership; + - `Enter` and `Space` toggle a focused node; + - Fit map resets a changed transform. + +- [ ] Run RED: + +```bash +node --test tests/operator_dashboard_topology.test.mjs +``` + +Expected: map panel exists but contains no graph semantics or interactions. + +- [ ] Implement `renderMap` with `document.createElementNS`. Paint in this order: machine rectangles/labels, translucent group fragments/labels, direct-link paths, then session nodes. A node shows emoji, handle, harness label, and a health ring. The inspector reuses the existing safe text-node detail fields for the selected node. + +- [ ] Wire click and keyboard selection to `toggleSelection`. Wire `Escape` at document level. Do not attach mutations to drag, edges, or group regions. + +- [ ] Implement bounded pan and zoom on the viewport group: pointer drag pans; wheel zooms between `0.35` and `2.5`; Fit map calls the pure helper. Keep nodes keyboard reachable independent of current zoom. + +- [ ] Style machine boundaries, verified/unverified labels, persona colors, health rings, solid/dashed edges, translucent group fragments, selected/focused nodes, and the inspector. Respect `prefers-reduced-motion`; use no animated layout. + +- [ ] Run GREEN: + +```bash +node --test tests/operator_topology_model.test.mjs tests/operator_dashboard_polling.test.mjs tests/operator_dashboard_topology.test.mjs +cargo test operator_web::tests --lib +``` + +- [ ] Stage, inspect, commit, and push: + +```bash +git add assets/operator-dashboard.js assets/operator-dashboard.css tests/operator_dashboard_topology.test.mjs +node .gitnexus/run.cjs detect-changes --scope staged --repo wire +git diff --cached --check +git commit -m "feat: render interactive operator topology map" +git push +``` + +--- + +### Task 6: Prove the installed caller, responsive UI, and complete scope + +**Files:** +- Modify: `tests/e2e_operator_dashboard.rs` +- Modify: `SESSION_LOG_2026_08_10.md` + +- [ ] Extend the end-to-end test to prove group creation does not increase `direct_links`, while the created group appears with all selected members. Preserve the existing assertion that the third member is not directly paired. + +- [ ] Run the complete automated gate: + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +node --test tests/operator_topology_model.test.mjs tests/operator_dashboard_polling.test.mjs tests/operator_dashboard_topology.test.mjs +cargo test --all-targets --all-features +``` + +- [ ] Build release and atomically install without overwriting a running Mach-O image in place: + +```bash +cargo build --release +install -m 755 target/release/wire /Users/laul_pogan/.cargo/bin/wire.new +mv /Users/laul_pogan/.cargo/bin/wire.new /Users/laul_pogan/.cargo/bin/wire +``` + +- [ ] Start the real caller without stopping the persistent Wire daemon/monitor: + +```bash +wire dash --web --no-open +``` + +Capture the printed loopback URL/token, then exercise `/api/topology` with the launch token. Confirm response time stays below two seconds on the current 3,000-home machine and no second inventory request overlaps an unfinished request. + +- [ ] Use Playwright against the installed dashboard at 1440×900 and 390×844. Record: + + - Map is the default and List remains available; + - `rusted-butte` and `umber-savanna` are live nodes with exactly one solid bilateral edge; + - selecting both nodes, switching Map/List, and switching back preserves selection; + - selected link/group controls obey counts and existing confirmation; + - an existing live group renders a region without extra pairwise edges; if no suitable group exists, create one through the dashboard and retain it as the authorized live proof; + - Fit map restores all clusters to view; + - desktop and narrow layouts have no horizontal page overflow; + - console contains no errors and network contains no failed asset/API requests. + +- [ ] Run the required fresh-eyes adversarial review, then a separate read-only AMANALAP scope review. Fix only an original success-criterion failure, essential safety issue, or observed defect; rerun the affected and complete gates. + +- [ ] Update `SESSION_LOG_2026_08_10.md` with topology producer/caller, review dispositions, timings, installed proof, and artifact paths. + +- [ ] Run final GitNexus and repository checks: + +```bash +git status --short +git diff --check +node .gitnexus/run.cjs detect-changes --scope compare --base-ref main --repo wire +``` + +Confirm `AGENTS.md` and `.superpowers/` remain unstaged. + +- [ ] Stage only the proof/logical finalization files, inspect, commit, and push: + +```bash +git add tests/e2e_operator_dashboard.rs SESSION_LOG_2026_08_10.md +node .gitnexus/run.cjs detect-changes --scope staged --repo wire +git diff --cached --check +git commit -m "test: prove operator topology map" +git push +``` + +## Completion Evidence + +Implementation is complete only when all six tasks are committed and pushed, the full automated gate is green, the installed `wire dash --web` caller renders the live map, `rusted-butte` and `umber-savanna` appear with one bilateral edge, a group region appears without implying a full mesh, Map/List selection persists, and desktop/mobile browser evidence has no console, network, or overflow failures. From 668986702cbc0b6317b50e62050a31451d653c32 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 18:45:12 -0700 Subject: [PATCH 25/39] feat: build live operator topology --- src/group.rs | 11 +- src/lib.rs | 1 + src/operator.rs | 8 +- src/operator_topology.rs | 565 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 582 insertions(+), 3 deletions(-) create mode 100644 src/operator_topology.rs diff --git a/src/group.rs b/src/group.rs index 4ff6d5f..cf7f2fd 100644 --- a/src/group.rs +++ b/src/group.rs @@ -21,7 +21,7 @@ use anyhow::{Context, Result, bail}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::signing::{b64decode, b64encode, canonical_event}; @@ -290,7 +290,14 @@ pub fn load_group(id: &str) -> Result { /// List all persisted groups (skips unparseable files). pub fn list_groups() -> Result> { - let dir = groups_dir()?; + list_groups_in(&groups_dir()?) +} + +pub(crate) fn list_groups_at(home: &Path) -> Result> { + list_groups_in(&home.join("config/wire/groups")) +} + +fn list_groups_in(dir: &Path) -> Result> { if !dir.exists() { return Ok(Vec::new()); } diff --git a/src/lib.rs b/src/lib.rs index 26531c0..d0b9593 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,7 @@ pub mod nostr_relay; pub mod nostr_ws; pub mod nuke; pub mod operator; +pub mod operator_topology; pub mod operator_web; pub mod org_bind; pub mod org_membership; diff --git a/src/operator.rs b/src/operator.rs index 5672328..c83377b 100644 --- a/src/operator.rs +++ b/src/operator.rs @@ -334,8 +334,14 @@ pub fn create_local_group(request: GroupRequest) -> Result anyhow::Result { let sessions = crate::session::list_sessions()?; + collect_live_sessions_from(&sessions) +} + +pub(crate) fn collect_live_sessions_from( + sessions: &[crate::session::SessionInfo], +) -> anyhow::Result { collect_live_from( - &sessions, + sessions, OffsetDateTime::now_utc(), crate::platform::process_alive, ) diff --git a/src/operator_topology.rs b/src/operator_topology.rs new file mode 100644 index 0000000..08317ad --- /dev/null +++ b/src/operator_topology.rs @@ -0,0 +1,565 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Result, anyhow}; +use serde::Serialize; +use time::OffsetDateTime; + +pub const TOPOLOGY_SCHEMA: &str = "wire-topology-v1"; + +#[derive(Clone, Debug, Serialize)] +pub struct TopologyReport { + pub schema: &'static str, + pub generated_at: String, + pub machines: Vec, + pub sessions: Vec, + pub direct_links: Vec, + pub groups: Vec, + pub anomalies: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TopologyMachine { + pub id: String, + pub hostname: String, + pub os: String, + pub arch: String, + pub identity_confidence: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TopologySession { + pub machine_id: String, + pub session: crate::operator::LiveSession, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DirectLink { + pub id: String, + pub source_did: String, + pub target_did: String, + pub state: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TopologyGroup { + pub id: String, + pub name: String, + pub creator_did: String, + pub epoch: u64, + pub members: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TopologyGroupMember { + pub did: String, + pub tier: String, + pub live: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TopologyAnomaly { + pub kind: String, + pub subject_id: String, + pub message: String, +} + +#[derive(Clone)] +struct TopologySource { + session: crate::operator::LiveSession, + peers: Vec, + groups: Vec, +} + +enum GroupResolution { + Accepted(crate::group::Group), + Conflicted { epoch: u64 }, +} + +pub fn collect_topology() -> Result { + let sessions = crate::session::list_sessions()?; + let live = crate::operator::collect_live_sessions_from(&sessions)?; + let homes = sessions + .iter() + .map(|session| (session.name.as_str(), session.home_dir.as_path())) + .collect::>(); + let mut sources = Vec::with_capacity(live.sessions.len()); + for session in live.sessions { + let home = homes + .get(session.id.as_str()) + .ok_or_else(|| anyhow!("live session was not present in the session inventory"))?; + sources.push(TopologySource { + peers: crate::dash::read_peers(home, Some(&session.did), Some(&session.handle)), + groups: crate::group::list_groups_at(home)?, + session, + }); + } + Ok(build_topology(sources, OffsetDateTime::now_utc())) +} + +fn machine_id(session: &crate::operator::LiveSession) -> (String, String) { + match &session.machine.fingerprint { + Some(fingerprint) => (fingerprint.clone(), "verified".to_string()), + None => ( + format!( + "unverified:{}:{}:{}", + session.machine.hostname, session.machine.os, session.machine.arch + ), + "unverified".to_string(), + ), + } +} + +fn canonical_roster(group: &crate::group::Group) -> Vec<(String, String)> { + let mut roster = group + .members + .iter() + .map(|member| (member.did.clone(), member.tier.as_str().to_string())) + .collect::>(); + roster.sort(); + roster +} + +fn groups_agree(left: &crate::group::Group, right: &crate::group::Group) -> bool { + left.creator_did == right.creator_did && canonical_roster(left) == canonical_roster(right) +} + +fn build_topology( + mut sources: Vec, + generated_at: OffsetDateTime, +) -> TopologyReport { + sources.sort_by(|left, right| left.session.did.cmp(&right.session.did)); + let live_dids = sources + .iter() + .map(|source| source.session.did.clone()) + .collect::>(); + + let mut machines = BTreeMap::new(); + let mut topology_sessions = Vec::with_capacity(sources.len()); + for source in &sources { + let (id, identity_confidence) = machine_id(&source.session); + machines + .entry(id.clone()) + .or_insert_with(|| TopologyMachine { + id: id.clone(), + hostname: source.session.machine.hostname.clone(), + os: source.session.machine.os.clone(), + arch: source.session.machine.arch.clone(), + identity_confidence, + }); + topology_sessions.push(TopologySession { + machine_id: id, + session: source.session.clone(), + }); + } + + let mut observations = BTreeMap::<(String, String), BTreeSet<(String, String)>>::new(); + for source in &sources { + for peer in &source.peers { + if peer.did.is_empty() + || !live_dids.contains(&peer.did) + || peer.did == source.session.did + { + continue; + } + let (source_did, target_did) = if source.session.did < peer.did { + (source.session.did.clone(), peer.did.clone()) + } else { + (peer.did.clone(), source.session.did.clone()) + }; + observations + .entry((source_did, target_did)) + .or_default() + .insert((source.session.did.clone(), peer.did.clone())); + } + } + let mut direct_links = Vec::with_capacity(observations.len()); + let mut anomalies = Vec::new(); + for ((source_did, target_did), directions) in observations { + let id = format!("{source_did}:{target_did}"); + let bilateral = directions.contains(&(source_did.clone(), target_did.clone())) + && directions.contains(&(target_did.clone(), source_did.clone())); + let state = if bilateral { "bilateral" } else { "one-sided" }; + direct_links.push(DirectLink { + id: id.clone(), + source_did, + target_did, + state: state.to_string(), + }); + if !bilateral { + anomalies.push(TopologyAnomaly { + kind: "one-sided-link".to_string(), + subject_id: id, + message: "Live sessions disagree about this direct link".to_string(), + }); + } + } + + let mut group_resolutions = BTreeMap::::new(); + for group in sources.iter().flat_map(|source| source.groups.iter()) { + match group_resolutions.get_mut(&group.id) { + None => { + group_resolutions + .insert(group.id.clone(), GroupResolution::Accepted(group.clone())); + } + Some(GroupResolution::Accepted(existing)) if group.epoch > existing.epoch => { + *existing = group.clone(); + } + Some(GroupResolution::Accepted(existing)) if group.epoch == existing.epoch => { + if !groups_agree(existing, group) { + let epoch = existing.epoch; + group_resolutions + .insert(group.id.clone(), GroupResolution::Conflicted { epoch }); + } + } + Some(GroupResolution::Conflicted { epoch }) if group.epoch > *epoch => { + group_resolutions + .insert(group.id.clone(), GroupResolution::Accepted(group.clone())); + } + _ => {} + } + } + let mut groups = Vec::new(); + for (id, resolution) in group_resolutions { + match resolution { + GroupResolution::Accepted(group) => { + let mut members = group + .members + .iter() + .map(|member| TopologyGroupMember { + did: member.did.clone(), + tier: member.tier.as_str().to_string(), + live: live_dids.contains(&member.did), + }) + .collect::>(); + members.sort_by(|left, right| { + left.did + .cmp(&right.did) + .then_with(|| left.tier.cmp(&right.tier)) + }); + groups.push(TopologyGroup { + id: group.id, + name: group.name, + creator_did: group.creator_did, + epoch: group.epoch, + members, + }); + } + GroupResolution::Conflicted { .. } => anomalies.push(TopologyAnomaly { + kind: "conflicting-group".to_string(), + subject_id: id, + message: "Live sessions disagree about the highest group roster".to_string(), + }), + } + } + anomalies.sort_by(|left, right| { + left.kind + .cmp(&right.kind) + .then_with(|| left.subject_id.cmp(&right.subject_id)) + }); + + TopologyReport { + schema: TOPOLOGY_SCHEMA, + generated_at: generated_at + .format(&time::format_description::well_known::Rfc3339) + .expect("valid RFC3339 format description"), + machines: machines.into_values().collect(), + sessions: topology_sessions, + direct_links, + groups, + anomalies, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::group::{Group, GroupTier, Member}; + use crate::operator::LiveSession; + use crate::session_metadata::{ + HarnessDescriptor, IdentityDescriptor, MachineDescriptor, MetadataConfidence, + ProjectDescriptor, + }; + use time::OffsetDateTime; + + const ALICE: &str = "did:wire:alice-11111111"; + const BOB: &str = "did:wire:bob-22222222"; + const CAROL: &str = "did:wire:carol-33333333"; + + fn generated_at() -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap() + } + + fn live(id: &str, did: &str, fingerprint: Option<&str>) -> LiveSession { + LiveSession { + id: id.to_string(), + handle: id.to_string(), + did: did.to_string(), + emoji: "🦎".to_string(), + primary_hex: "#45e456".to_string(), + pid: 42, + machine: MachineDescriptor { + fingerprint: fingerprint.map(str::to_string), + hostname: "wire-host".to_string(), + os: "macos".to_string(), + arch: "aarch64".to_string(), + wire_version: "0.17.0".to_string(), + }, + harness: HarnessDescriptor { + kind: "codex-cli".to_string(), + label: "Codex CLI".to_string(), + mode: Some("interactive".to_string()), + confidence: MetadataConfidence::Explicit, + evidence: "test-fixture".to_string(), + }, + identity: IdentityDescriptor { + source: "codex-cli".to_string(), + class: "session-keyed".to_string(), + warning: None, + }, + project: ProjectDescriptor::unknown(None), + started_at: None, + age_seconds: None, + direct_link_count: 0, + health: "healthy".to_string(), + } + } + + fn peer(did: &str) -> crate::dash::PeerRow { + crate::dash::PeerRow { + handle: did.to_string(), + did: did.to_string(), + tier: "VERIFIED".to_string(), + } + } + + fn group(id: &str, epoch: u64, creator_did: &str, members: &[(&str, GroupTier)]) -> Group { + Group { + id: id.to_string(), + name: format!("group-{id}"), + creator_did: creator_did.to_string(), + epoch, + members: members + .iter() + .map(|(did, tier)| Member { + handle: did.to_string(), + did: (*did).to_string(), + tier: *tier, + key_id: "key-id-secret".to_string(), + key: "key-secret".to_string(), + }) + .collect(), + relay_url: "https://relay.example".to_string(), + slot_id: "slot-secret".to_string(), + slot_token: "token-secret".to_string(), + creator_sig: "signature-secret".to_string(), + } + } + + fn source(session: LiveSession) -> TopologySource { + TopologySource { + session, + peers: Vec::new(), + groups: Vec::new(), + } + } + + fn assert_no_forbidden_keys(value: &serde_json::Value, forbidden: &[&str]) { + match value { + serde_json::Value::Object(object) => { + for (key, value) in object { + assert!( + !forbidden.contains(&key.as_str()), + "serialized topology leaked {key}" + ); + assert_no_forbidden_keys(value, forbidden); + } + } + serde_json::Value::Array(values) => { + for value in values { + assert_no_forbidden_keys(value, forbidden); + } + } + _ => {} + } + } + + #[test] + fn same_fingerprint_sessions_share_one_verified_machine() { + let report = build_topology( + vec![ + source(live("alice", ALICE, Some("machine-1"))), + source(live("bob", BOB, Some("machine-1"))), + ], + generated_at(), + ); + + assert_eq!(report.machines.len(), 1); + assert_eq!(report.machines[0].id, "machine-1"); + assert_eq!(report.machines[0].identity_confidence, "verified"); + assert!( + report + .sessions + .iter() + .all(|session| session.machine_id == "machine-1") + ); + } + + #[test] + fn missing_fingerprint_uses_unverified_machine_id() { + let report = build_topology(vec![source(live("alice", ALICE, None))], generated_at()); + + assert_eq!(report.machines[0].id, "unverified:wire-host:macos:aarch64"); + assert_eq!(report.machines[0].identity_confidence, "unverified"); + } + + #[test] + fn reciprocal_peers_produce_one_sorted_bilateral_edge() { + let mut alice = source(live("alice", ALICE, Some("machine-1"))); + alice.peers.push(peer(BOB)); + let mut bob = source(live("bob", BOB, Some("machine-1"))); + bob.peers.push(peer(ALICE)); + + let report = build_topology(vec![bob, alice], generated_at()); + + assert_eq!(report.direct_links.len(), 1); + assert_eq!(report.direct_links[0].id, format!("{ALICE}:{BOB}")); + assert_eq!(report.direct_links[0].source_did, ALICE); + assert_eq!(report.direct_links[0].target_did, BOB); + assert_eq!(report.direct_links[0].state, "bilateral"); + assert!(report.anomalies.is_empty()); + } + + #[test] + fn one_sided_peer_produces_edge_and_anomaly() { + let mut alice = source(live("alice", ALICE, Some("machine-1"))); + alice.peers.push(peer(BOB)); + let report = build_topology( + vec![alice, source(live("bob", BOB, Some("machine-1")))], + generated_at(), + ); + + assert_eq!(report.direct_links.len(), 1); + assert_eq!(report.direct_links[0].state, "one-sided"); + assert_eq!(report.anomalies.len(), 1); + assert_eq!(report.anomalies[0].kind, "one-sided-link"); + assert_eq!(report.anomalies[0].subject_id, format!("{ALICE}:{BOB}")); + } + + #[test] + fn stale_peer_never_creates_a_node_or_edge() { + let mut alice = source(live("alice", ALICE, Some("machine-1"))); + alice.peers.push(peer(CAROL)); + let report = build_topology(vec![alice], generated_at()); + + assert_eq!(report.sessions.len(), 1); + assert!(report.direct_links.is_empty()); + } + + #[test] + fn group_membership_is_one_region_without_direct_links() { + let mut alice = source(live("alice", ALICE, Some("machine-1"))); + alice.groups.push(group( + "crew", + 1, + ALICE, + &[(ALICE, GroupTier::Creator), (BOB, GroupTier::Member)], + )); + let report = build_topology( + vec![alice, source(live("bob", BOB, Some("machine-1")))], + generated_at(), + ); + + assert_eq!(report.groups.len(), 1); + assert!(report.direct_links.is_empty()); + } + + #[test] + fn highest_group_epoch_wins() { + let mut older = source(live("alice", ALICE, Some("machine-1"))); + older.groups.push(group( + "crew", + 1, + ALICE, + &[(ALICE, GroupTier::Creator), (BOB, GroupTier::Member)], + )); + let mut newer = source(live("bob", BOB, Some("machine-1"))); + newer.groups.push(group( + "crew", + 2, + ALICE, + &[(ALICE, GroupTier::Creator), (CAROL, GroupTier::Member)], + )); + + let report = build_topology(vec![older, newer], generated_at()); + + assert_eq!(report.groups.len(), 1); + assert_eq!(report.groups[0].epoch, 2); + assert_eq!(report.groups[0].members[1].did, CAROL); + } + + #[test] + fn conflicting_equal_epoch_groups_are_suppressed_with_anomaly() { + let mut alice = source(live("alice", ALICE, Some("machine-1"))); + alice.groups.push(group( + "crew", + 2, + ALICE, + &[(ALICE, GroupTier::Creator), (BOB, GroupTier::Member)], + )); + let mut bob = source(live("bob", BOB, Some("machine-1"))); + bob.groups.push(group( + "crew", + 2, + BOB, + &[(BOB, GroupTier::Creator), (ALICE, GroupTier::Member)], + )); + + let report = build_topology(vec![alice, bob], generated_at()); + + assert!(report.groups.is_empty()); + assert_eq!(report.anomalies.len(), 1); + assert_eq!(report.anomalies[0].kind, "conflicting-group"); + assert_eq!(report.anomalies[0].subject_id, "crew"); + } + + #[test] + fn historical_group_members_remain_sanitized_but_do_not_become_nodes() { + let mut alice = source(live("alice", ALICE, Some("machine-1"))); + alice.groups.push(group( + "crew", + 1, + ALICE, + &[(ALICE, GroupTier::Creator), (CAROL, GroupTier::Introduced)], + )); + let report = build_topology(vec![alice], generated_at()); + + assert_eq!(report.sessions.len(), 1); + assert_eq!(report.groups[0].members.len(), 2); + assert!(!report.groups[0].members[1].live); + assert_eq!(report.groups[0].members[1].did, CAROL); + } + + #[test] + fn serialized_topology_omits_secret_and_host_fields() { + let mut alice = source(live("alice", ALICE, Some("machine-1"))); + alice + .groups + .push(group("crew", 1, ALICE, &[(ALICE, GroupTier::Creator)])); + let serialized = + serde_json::to_string(&build_topology(vec![alice], generated_at())).unwrap(); + let value = serde_json::from_str(&serialized).unwrap(); + assert_no_forbidden_keys( + &value, + &[ + "relay_url", + "slot_id", + "slot_token", + "key_id", + "key", + "creator_sig", + "home_dir", + "command_line", + ], + ); + } +} From 425befd1d8f71ebcc3df1205cc070fde605f2693 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 18:58:06 -0700 Subject: [PATCH 26/39] fix: project materialized group holders --- src/operator_topology.rs | 131 ++++++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 23 deletions(-) diff --git a/src/operator_topology.rs b/src/operator_topology.rs index 08317ad..dfc6858 100644 --- a/src/operator_topology.rs +++ b/src/operator_topology.rs @@ -71,8 +71,14 @@ struct TopologySource { } enum GroupResolution { - Accepted(crate::group::Group), - Conflicted { epoch: u64 }, + Accepted { + group: crate::group::Group, + holders: BTreeSet, + }, + Conflicted { + epoch: u64, + holders: BTreeSet, + }, } pub fn collect_topology() -> Result { @@ -195,33 +201,64 @@ fn build_topology( } let mut group_resolutions = BTreeMap::::new(); - for group in sources.iter().flat_map(|source| source.groups.iter()) { - match group_resolutions.get_mut(&group.id) { - None => { - group_resolutions - .insert(group.id.clone(), GroupResolution::Accepted(group.clone())); - } - Some(GroupResolution::Accepted(existing)) if group.epoch > existing.epoch => { - *existing = group.clone(); - } - Some(GroupResolution::Accepted(existing)) if group.epoch == existing.epoch => { - if !groups_agree(existing, group) { - let epoch = existing.epoch; - group_resolutions - .insert(group.id.clone(), GroupResolution::Conflicted { epoch }); + for source in &sources { + for group in &source.groups { + let holder = source.session.did.clone(); + match group_resolutions.get_mut(&group.id) { + None => { + group_resolutions.insert( + group.id.clone(), + GroupResolution::Accepted { + group: group.clone(), + holders: BTreeSet::from([holder]), + }, + ); + } + Some(GroupResolution::Accepted { + group: existing, + holders, + }) if group.epoch > existing.epoch => { + *existing = group.clone(); + holders.insert(holder); + } + Some(GroupResolution::Accepted { + group: existing, + holders, + }) if group.epoch == existing.epoch => { + if !groups_agree(existing, group) { + let epoch = existing.epoch; + let mut holders = std::mem::take(holders); + holders.insert(holder); + group_resolutions.insert( + group.id.clone(), + GroupResolution::Conflicted { epoch, holders }, + ); + } else { + holders.insert(holder); + } + } + Some(GroupResolution::Conflicted { epoch, holders }) if group.epoch > *epoch => { + let mut holders = std::mem::take(holders); + holders.insert(holder); + group_resolutions.insert( + group.id.clone(), + GroupResolution::Accepted { + group: group.clone(), + holders, + }, + ); + } + Some(GroupResolution::Accepted { holders, .. }) + | Some(GroupResolution::Conflicted { holders, .. }) => { + holders.insert(holder); } } - Some(GroupResolution::Conflicted { epoch }) if group.epoch > *epoch => { - group_resolutions - .insert(group.id.clone(), GroupResolution::Accepted(group.clone())); - } - _ => {} } } let mut groups = Vec::new(); for (id, resolution) in group_resolutions { match resolution { - GroupResolution::Accepted(group) => { + GroupResolution::Accepted { group, holders } => { let mut members = group .members .iter() @@ -231,6 +268,15 @@ fn build_topology( live: live_dids.contains(&member.did), }) .collect::>(); + for did in holders { + if members.iter().all(|member| member.did != did) { + members.push(TopologyGroupMember { + did, + tier: "introduced".to_string(), + live: true, + }); + } + } members.sort_by(|left, right| { left.did .cmp(&right.did) @@ -473,6 +519,34 @@ mod tests { assert!(report.direct_links.is_empty()); } + #[test] + fn materialized_group_homes_appear_as_introduced_live_members() { + let creator_roster = group("crew", 1, ALICE, &[(ALICE, GroupTier::Creator)]); + let mut alice = source(live("alice", ALICE, Some("machine-1"))); + alice.groups.push(creator_roster.clone()); + let mut bob = source(live("bob", BOB, Some("machine-1"))); + bob.groups.push(creator_roster.clone()); + let mut carol = source(live("carol", CAROL, Some("machine-1"))); + carol.groups.push(creator_roster); + + let report = build_topology(vec![alice, bob, carol], generated_at()); + + assert!(report.direct_links.is_empty()); + assert_eq!(report.groups.len(), 1); + assert_eq!( + report.groups[0] + .members + .iter() + .map(|member| (member.did.clone(), member.tier.clone(), member.live)) + .collect::>(), + vec![ + (ALICE.to_string(), "creator".to_string(), true), + (BOB.to_string(), "introduced".to_string(), true), + (CAROL.to_string(), "introduced".to_string(), true), + ] + ); + } + #[test] fn highest_group_epoch_wins() { let mut older = source(live("alice", ALICE, Some("machine-1"))); @@ -494,7 +568,18 @@ mod tests { assert_eq!(report.groups.len(), 1); assert_eq!(report.groups[0].epoch, 2); - assert_eq!(report.groups[0].members[1].did, CAROL); + assert!( + report.groups[0] + .members + .iter() + .any(|member| member.did == CAROL && member.tier == "member") + ); + assert!( + report.groups[0] + .members + .iter() + .any(|member| member.did == BOB && member.tier == "introduced") + ); } #[test] From 3ac32f727761ceeaa96096db4d6a6eb363ec2d12 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 18:58:20 -0700 Subject: [PATCH 27/39] feat: serve operator topology snapshot --- src/operator_web.rs | 54 ++++++++++++++++++- tests/e2e_operator_dashboard.rs | 96 +++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/operator_web.rs b/src/operator_web.rs index 657ea90..d86074d 100644 --- a/src/operator_web.rs +++ b/src/operator_web.rs @@ -53,10 +53,12 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::middleware; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; +use std::sync::Arc; #[derive(Clone)] struct AppState { token: String, + scan_lock: Arc>, } fn router(token: String) -> Router { @@ -66,9 +68,13 @@ fn router(token: String) -> Router { .route("/dashboard.css", get(stylesheet)) .route("/dashboard.js", get(javascript)) .route("/api/sessions", get(get_sessions)) + .route("/api/topology", get(get_topology)) .route("/api/links", post(post_links)) .route("/api/groups", post(post_groups)) - .with_state(AppState { token }) + .with_state(AppState { + token, + scan_lock: Arc::new(tokio::sync::Mutex::new(())), + }) .layer(middleware::map_response(security_headers)) } @@ -167,6 +173,7 @@ async fn get_sessions(State(state): State, headers: HeaderMap) -> Resp if !authorized(&headers, &state) { return error_response(StatusCode::FORBIDDEN, "invalid launch token", Vec::new()); } + let _scan = state.scan_lock.lock().await; match tokio::task::spawn_blocking(crate::operator::collect_live_sessions).await { Ok(Ok(report)) => Json(report).into_response(), _ => error_response( @@ -177,6 +184,21 @@ async fn get_sessions(State(state): State, headers: HeaderMap) -> Resp } } +async fn get_topology(State(state): State, headers: HeaderMap) -> Response { + if !authorized(&headers, &state) { + return error_response(StatusCode::FORBIDDEN, "invalid launch token", Vec::new()); + } + let _scan = state.scan_lock.lock().await; + match tokio::task::spawn_blocking(crate::operator_topology::collect_topology).await { + Ok(Ok(report)) => Json(report).into_response(), + _ => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "topology inventory failed", + Vec::new(), + ), + } +} + async fn post_links( State(state): State, headers: HeaderMap, @@ -276,6 +298,36 @@ mod tests { .unwrap(); assert_eq!(sessions.status(), StatusCode::FORBIDDEN); + let topology = format!("http://{address}/api/topology"); + let missing = client.get(&topology).send().await.unwrap(); + assert_eq!(missing.status(), StatusCode::FORBIDDEN); + + let wrong = client + .get(&topology) + .header("X-Wire-Token", "wrong") + .send() + .await + .unwrap(); + assert_eq!(wrong.status(), StatusCode::FORBIDDEN); + + let rebound = client + .get(&topology) + .header("Host", "attacker.example") + .header("X-Wire-Token", "test-token") + .send() + .await + .unwrap(); + assert_eq!(rebound.status(), StatusCode::FORBIDDEN); + + let cross_origin = client + .get(&topology) + .header("Origin", "https://attacker.example") + .header("X-Wire-Token", "test-token") + .send() + .await + .unwrap(); + assert_eq!(cross_origin.status(), StatusCode::FORBIDDEN); + let sessions = client .get(format!("http://{address}/api/sessions")) .header("X-Wire-Token", "test-token") diff --git a/tests/e2e_operator_dashboard.rs b/tests/e2e_operator_dashboard.rs index 6c28fa6..865c252 100644 --- a/tests/e2e_operator_dashboard.rs +++ b/tests/e2e_operator_dashboard.rs @@ -33,6 +33,23 @@ fn session_home(root: &Path, name: &str) -> PathBuf { )) } +fn assert_no_forbidden_keys(value: &Value, forbidden: &[&str]) { + match value { + Value::Object(object) => { + for (key, value) in object { + assert!(!forbidden.contains(&key.as_str()), "topology leaked {key}"); + assert_no_forbidden_keys(value, forbidden); + } + } + Value::Array(values) => { + for value in values { + assert_no_forbidden_keys(value, forbidden); + } + } + _ => {} + } +} + fn add_live_lease(home: &Path, source: &str) { wire::session_lifecycle::write_lease_at( home, @@ -121,6 +138,19 @@ async fn dashboard_links_two_and_materializes_one_shared_group() { let _dashboard = Dashboard(child); let client = reqwest::Client::new(); + let topology: Value = client + .get(format!("{origin}/api/topology")) + .header("X-Wire-Token", &token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(topology["schema"], "wire-topology-v1"); + assert_eq!(topology["sessions"].as_array().unwrap().len(), 3); + assert_eq!(topology["machines"].as_array().unwrap().len(), 1); + let report: Value = client .get(format!("{origin}/api/sessions")) .header("X-Wire-Token", &token) @@ -198,6 +228,72 @@ async fn dashboard_links_two_and_materializes_one_shared_group() { grouped.text().await.unwrap() ); + let topology: Value = client + .get(format!("{origin}/api/topology")) + .header("X-Wire-Token", &token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let first_did = topology["sessions"] + .as_array() + .unwrap() + .iter() + .find(|session| session["session"]["id"] == ids[0]) + .unwrap()["session"]["did"] + .as_str() + .unwrap(); + let second_did = topology["sessions"] + .as_array() + .unwrap() + .iter() + .find(|session| session["session"]["id"] == ids[1]) + .unwrap()["session"]["did"] + .as_str() + .unwrap(); + assert!( + topology["direct_links"] + .as_array() + .unwrap() + .iter() + .any(|link| { + link["state"] == "bilateral" + && ((link["source_did"] == first_did && link["target_did"] == second_did) + || (link["source_did"] == second_did && link["target_did"] == first_did)) + }) + ); + let group = topology["groups"] + .as_array() + .unwrap() + .iter() + .find(|group| group["name"] == "operator-proof") + .unwrap(); + let group_members = group["members"].as_array().unwrap(); + assert_eq!(group_members.len(), 3); + for did in topology["sessions"] + .as_array() + .unwrap() + .iter() + .map(|session| session["session"]["did"].as_str().unwrap()) + { + assert!(group_members.iter().any(|member| member["did"] == did)); + } + assert_no_forbidden_keys( + &topology, + &[ + "relay_url", + "slot_id", + "slot_token", + "key_id", + "key", + "creator_sig", + "home_dir", + "command_line", + ], + ); + for name in ["alice", "bob", "carol"] { let groups = session_home(&root, name).join("config/wire/groups"); assert_eq!( From a40cdc8391f777d60030c37dcc0536709fb7944a Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 19:11:26 -0700 Subject: [PATCH 28/39] feat: add deterministic topology model --- assets/operator-dashboard.html | 1 + assets/operator-topology.js | 197 +++++++++++++++++++++++++ src/operator_web.rs | 35 +++++ tests/operator_topology_model.test.mjs | 177 ++++++++++++++++++++++ 4 files changed, 410 insertions(+) create mode 100644 assets/operator-topology.js create mode 100644 tests/operator_topology_model.test.mjs diff --git a/assets/operator-dashboard.html b/assets/operator-dashboard.html index 81ade25..8c1ff6f 100644 --- a/assets/operator-dashboard.html +++ b/assets/operator-dashboard.html @@ -6,6 +6,7 @@ Wire operator + diff --git a/assets/operator-topology.js b/assets/operator-topology.js new file mode 100644 index 0000000..d52babd --- /dev/null +++ b/assets/operator-topology.js @@ -0,0 +1,197 @@ +(() => { + "use strict"; + + const CLUSTER_WIDTH = 320; + const CLUSTER_MIN_HEIGHT = 160; + const CLUSTER_GAP = 32; + const CLUSTER_PADDING = 20; + const CLUSTER_HEADER_HEIGHT = 40; + const NODE_WIDTH = 128; + const NODE_HEIGHT = 52; + const NODE_GAP_X = 16; + const NODE_GAP_Y = 16; + const REGION_PADDING = 12; + + const list = (value) => Array.isArray(value) ? value : []; + const text = (value) => value === null || value === undefined ? "" : String(value); + const compare = (left, right) => text(left) < text(right) ? -1 : text(left) > text(right) ? 1 : 0; + const folded = (value) => text(value).toLocaleLowerCase(); + const matches = (value, filter) => !filter || folded(value) === folded(filter); + const includes = (value, query) => !query || folded(value).includes(folded(query)); + + const sessionEntry = (entry) => entry && entry.session && typeof entry.session === "object" ? entry : null; + const sortedEntries = (entries) => list(entries) + .map(sessionEntry) + .filter(Boolean) + .sort((left, right) => compare(left.session.did, right.session.did)); + + const visibleTopology = (snapshot, filters = {}) => { + const source = snapshot && typeof snapshot === "object" ? snapshot : {}; + const entries = sortedEntries(source.sessions); + const liveDids = new Set(entries.map((entry) => entry.session.did)); + const connected = new Set(); + for (const link of list(source.direct_links)) { + if (liveDids.has(link.source_did) && liveDids.has(link.target_did)) { + connected.add(link.source_did); + connected.add(link.target_did); + } + } + + const visibleSessions = entries.filter((entry) => { + const session = entry.session; + const query = text(filters.search).trim(); + const searchable = [session.handle, session.project && session.project.name, session.project && session.project.branch, session.harness && session.harness.label]; + return searchable.some((value) => includes(value, query)) + && matches(entry.machine_id, text(filters.machine).trim()) + && matches(session.harness && session.harness.label, text(filters.harness).trim()) + && matches(session.project && session.project.name, text(filters.project).trim()) + && matches(session.health, text(filters.health).trim()) + && (!filters.connectedOnly || connected.has(session.did)); + }).map((entry) => ({ machine_id: entry.machine_id, session: { ...entry.session } })); + + const visibleDids = new Set(visibleSessions.map((entry) => entry.session.did)); + const machineIds = new Set(visibleSessions.map((entry) => entry.machine_id)); + const machines = list(source.machines) + .filter((machine) => machine && machineIds.has(machine.id)) + .map((machine) => ({ ...machine })) + .sort((left, right) => compare(left.id, right.id)); + const directLinks = list(source.direct_links) + .filter((link) => link && visibleDids.has(link.source_did) && visibleDids.has(link.target_did)) + .map((link) => ({ ...link })) + .sort((left, right) => compare(left.id, right.id)); + const groups = list(source.groups).map((group) => { + const members = list(group.members) + .filter((member) => member && member.live && visibleDids.has(member.did)) + .map((member) => ({ ...member })) + .sort((left, right) => compare(left.did, right.did)); + return { ...group, members }; + }).filter((group) => group.members.length >= 2) + .sort((left, right) => compare(left.id, right.id)); + + return { machines, sessions: visibleSessions, directLinks, groups }; + }; + + const normalizedViewport = (viewport) => ({ + width: Number.isFinite(viewport && viewport.width) && viewport.width > 0 ? viewport.width : CLUSTER_WIDTH, + height: Number.isFinite(viewport && viewport.height) && viewport.height > 0 ? viewport.height : CLUSTER_MIN_HEIGHT + }); + + const groupColor = (id) => { + let hash = 0; + for (const character of text(id)) hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + return `hsl(${hash % 360} 64% 42%)`; + }; + + const layoutTopology = (visible, viewport) => { + const input = visible && typeof visible === "object" ? visible : {}; + const viewportSize = normalizedViewport(viewport); + const entries = sortedEntries(input.sessions); + const entriesByMachine = new Map(); + for (const entry of entries) { + const machineEntries = entriesByMachine.get(entry.machine_id) || []; + machineEntries.push(entry); + entriesByMachine.set(entry.machine_id, machineEntries); + } + + const machineInputs = list(input.machines) + .filter((machine) => machine && entriesByMachine.has(machine.id)) + .map((machine) => ({ ...machine, entries: entriesByMachine.get(machine.id) })) + .sort((left, right) => compare(left.id, right.id)); + const columns = Math.max(1, Math.floor((viewportSize.width + CLUSTER_GAP) / (CLUSTER_WIDTH + CLUSTER_GAP))); + const dimensions = machineInputs.map((machine) => { + const rows = Math.max(1, Math.ceil(machine.entries.length / 2)); + return { ...machine, width: CLUSTER_WIDTH, height: Math.max(CLUSTER_MIN_HEIGHT, CLUSTER_HEADER_HEIGHT + CLUSTER_PADDING * 2 + rows * NODE_HEIGHT + (rows - 1) * NODE_GAP_Y) }; + }); + const rowHeights = []; + for (let index = 0; index < dimensions.length; index += 1) { + const row = Math.floor(index / columns); + rowHeights[row] = Math.max(rowHeights[row] || 0, dimensions[index].height); + } + const rowOffsets = []; + let nextRow = CLUSTER_GAP; + for (let row = 0; row < rowHeights.length; row += 1) { + rowOffsets[row] = nextRow; + nextRow += rowHeights[row] + CLUSTER_GAP; + } + + const machines = []; + const nodes = []; + const nodeByDid = new Map(); + for (let index = 0; index < dimensions.length; index += 1) { + const machine = dimensions[index]; + const column = index % columns; + const row = Math.floor(index / columns); + const rectangle = { ...machine, x: CLUSTER_GAP + column * (CLUSTER_WIDTH + CLUSTER_GAP), y: rowOffsets[row] }; + delete rectangle.entries; + machines.push(rectangle); + for (let entryIndex = 0; entryIndex < machine.entries.length; entryIndex += 1) { + const entry = machine.entries[entryIndex]; + const nodeColumn = entryIndex % 2; + const nodeRow = Math.floor(entryIndex / 2); + const left = rectangle.x + CLUSTER_PADDING + nodeColumn * (NODE_WIDTH + NODE_GAP_X); + const top = rectangle.y + CLUSTER_HEADER_HEIGHT + CLUSTER_PADDING + nodeRow * (NODE_HEIGHT + NODE_GAP_Y); + const node = { + machineId: machine.id, did: entry.session.did, session: entry.session, + x: left + NODE_WIDTH / 2, y: top + NODE_HEIGHT / 2, + left, top, width: NODE_WIDTH, height: NODE_HEIGHT + }; + nodes.push(node); + nodeByDid.set(node.did, node); + } + } + + const edges = list(input.directLinks).map((link) => { + const source = nodeByDid.get(link.source_did); + const target = nodeByDid.get(link.target_did); + if (!source || !target) return null; + return { + ...link, sourceDid: source.did, targetDid: target.did, source, target, + path: `M ${source.x} ${source.y} L ${target.x} ${target.y}` + }; + }).filter(Boolean).sort((left, right) => compare(left.id, right.id)); + + const groupRegions = []; + for (const group of list(input.groups).slice().sort((left, right) => compare(left.id, right.id))) { + const byMachine = new Map(); + for (const member of list(group.members).slice().sort((left, right) => compare(left.did, right.did))) { + const node = nodeByDid.get(member.did); + if (!node) continue; + const members = byMachine.get(node.machineId) || []; + members.push(node); + byMachine.set(node.machineId, members); + } + for (const [machineId, members] of [...byMachine.entries()].sort((left, right) => compare(left[0], right[0]))) { + if (members.length < 2) continue; + const left = Math.min(...members.map((node) => node.left)) - REGION_PADDING; + const top = Math.min(...members.map((node) => node.top)) - REGION_PADDING; + const right = Math.max(...members.map((node) => node.left + node.width)) + REGION_PADDING; + const bottom = Math.max(...members.map((node) => node.top + node.height)) + REGION_PADDING; + groupRegions.push({ + groupId: group.id, name: group.name, machineId, memberDids: members.map((node) => node.did).sort(compare), + color: groupColor(group.id), x: left, y: top, width: right - left, height: bottom - top + }); + } + } + + const width = machines.length ? Math.max(...machines.map((machine) => machine.x + machine.width)) + CLUSTER_GAP : 0; + const height = machines.length ? Math.max(...machines.map((machine) => machine.y + machine.height)) + CLUSTER_GAP : 0; + return { machines, nodes, edges, groupRegions, bounds: { x: 0, y: 0, width, height } }; + }; + + const fitTransform = (layout, viewport) => { + const viewportSize = normalizedViewport(viewport); + const bounds = layout && layout.bounds ? layout.bounds : { x: 0, y: 0, width: 0, height: 0 }; + const width = Number.isFinite(bounds.width) && bounds.width > 0 ? bounds.width : 0; + const height = Number.isFinite(bounds.height) && bounds.height > 0 ? bounds.height : 0; + if (!width || !height) return { x: viewportSize.width / 2, y: viewportSize.height / 2, scale: 1 }; + const padding = 32; + const scale = Math.max(0.01, Math.min(1, (viewportSize.width - padding * 2) / width, (viewportSize.height - padding * 2) / height)); + return { + x: (viewportSize.width - width * scale) / 2 - (bounds.x || 0) * scale, + y: (viewportSize.height - height * scale) / 2 - (bounds.y || 0) * scale, + scale + }; + }; + + window.WireTopology = Object.freeze({ visibleTopology, layoutTopology, fitTransform, groupColor }); +})(); diff --git a/src/operator_web.rs b/src/operator_web.rs index d86074d..658c838 100644 --- a/src/operator_web.rs +++ b/src/operator_web.rs @@ -4,6 +4,7 @@ use rand::RngCore; const HTML: &str = include_str!("../assets/operator-dashboard.html"); const CSS: &str = include_str!("../assets/operator-dashboard.css"); const JAVASCRIPT: &str = include_str!("../assets/operator-dashboard.js"); +const TOPOLOGY_JAVASCRIPT: &str = include_str!("../assets/operator-topology.js"); pub struct ServeOptions { pub open_browser: bool, @@ -66,6 +67,7 @@ fn router(token: String) -> Router { .route("/", get(index)) .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT })) .route("/dashboard.css", get(stylesheet)) + .route("/topology.js", get(topology_javascript)) .route("/dashboard.js", get(javascript)) .route("/api/sessions", get(get_sessions)) .route("/api/topology", get(get_topology)) @@ -93,6 +95,13 @@ async fn javascript() -> impl IntoResponse { ) } +async fn topology_javascript() -> impl IntoResponse { + ( + [(CONTENT_TYPE, "text/javascript; charset=utf-8")], + TOPOLOGY_JAVASCRIPT, + ) +} + async fn security_headers(mut response: Response) -> Response { let headers = response.headers_mut(); headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); @@ -385,6 +394,9 @@ mod tests { assert!(html.contains("Link selected")); assert!(html.contains("Create group")); assert!(html.contains("aria-labelledby=\"group-title\"")); + let topology_script = html.find("/topology.js").unwrap(); + let dashboard_script = html.find("/dashboard.js").unwrap(); + assert!(topology_script < dashboard_script); for heading in ["Harness", "Project", "Machine", "Identity"] { assert!( html.contains(heading), @@ -409,6 +421,29 @@ mod tests { assert!(script.contains("detail-row")); assert!(script.contains("Unknown")); assert!(script.contains("PID ${known(session.pid)}")); + + let topology = client + .get(format!("http://{address}/topology.js")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + for forbidden in [ + "http://", + "https://", + "innerHTML", + "eval", + "createElement(\"script\")", + "createElement('script')", + ] { + assert!( + !topology.contains(forbidden), + "topology helper contains forbidden content: {forbidden}" + ); + } + assert!(topology.contains("WireTopology")); server.abort(); } } diff --git a/tests/operator_topology_model.test.mjs b/tests/operator_topology_model.test.mjs new file mode 100644 index 0000000..08f2782 --- /dev/null +++ b/tests/operator_topology_model.test.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const fixture = () => ({ + machines: [ + { id: "machine-b", hostname: "Bravo", os: "linux", arch: "x86_64", identity_confidence: "verified" }, + { id: "machine-a", hostname: "Alpha", os: "macos", arch: "aarch64", identity_confidence: "verified" } + ], + sessions: [ + { + machine_id: "machine-b", + session: { + id: "bravo", did: "did:wire:bravo-00000002", handle: "Brass-Marten", health: "healthy", + harness: { label: "Goose Shell" }, project: { name: "Signal", branch: "ops" } + } + }, + { + machine_id: "machine-a", + session: { + id: "amber", did: "did:wire:amber-00000001", handle: "Amber-Finch", health: "healthy", + harness: { label: "Codex CLI" }, project: { name: "Signal", branch: "main" } + } + }, + { + machine_id: "machine-b", + session: { + id: "delta", did: "did:wire:delta-00000004", handle: "Delta-Kite", health: "healthy", + harness: { label: "Claude Code" }, project: { name: "Archive", branch: "release" } + } + }, + { + machine_id: "machine-a", + session: { + id: "cedar", did: "did:wire:cedar-00000003", handle: "Cedar-Wren", health: "warning", + harness: { label: "Claude Code" }, project: { name: "Studio", branch: "feature/map" } + } + } + ], + direct_links: [ + { + id: "amber-bravo", source_did: "did:wire:amber-00000001", + target_did: "did:wire:bravo-00000002", state: "bilateral" + }, + { + id: "cedar-delta", source_did: "did:wire:cedar-00000003", + target_did: "did:wire:delta-00000004", state: "bilateral" + } + ], + groups: [ + { + id: "crew", name: "Crew", creator_did: "did:wire:amber-00000001", epoch: 1, + members: [ + { did: "did:wire:amber-00000001", tier: "creator", live: true }, + { did: "did:wire:bravo-00000002", tier: "member", live: true }, + { did: "did:wire:cedar-00000003", tier: "member", live: true }, + { did: "did:wire:historical-99999999", tier: "member", live: false } + ] + } + ] +}); + +const topology = () => { + const source = readFileSync(new URL("../assets/operator-topology.js", import.meta.url), "utf8"); + const window = {}; + vm.runInNewContext(source, { window }); + return window.WireTopology; +}; + +const dids = (visible) => Array.from(visible.sessions, (entry) => entry.session.did); + +test("visibleTopology searches session fields case-insensitively without mutating its snapshot", () => { + const WireTopology = topology(); + const snapshot = fixture(); + const before = structuredClone(snapshot); + + assert.deepEqual(dids(WireTopology.visibleTopology(snapshot, { search: "amber-finch" })), ["did:wire:amber-00000001"]); + assert.deepEqual(dids(WireTopology.visibleTopology(snapshot, { search: "signal" })), ["did:wire:amber-00000001", "did:wire:bravo-00000002"]); + assert.deepEqual(dids(WireTopology.visibleTopology(snapshot, { search: "FEATURE/MAP" })), ["did:wire:cedar-00000003"]); + assert.deepEqual(dids(WireTopology.visibleTopology(snapshot, { search: "goose shell" })), ["did:wire:bravo-00000002"]); + assert.deepEqual(snapshot, before); +}); + +test("visibleTopology composes machine, harness, project, health, and connected filters", () => { + const WireTopology = topology(); + const visible = WireTopology.visibleTopology(fixture(), { + machine: "machine-a", harness: "codex cli", project: "signal", health: "healthy", connectedOnly: true + }); + + assert.deepEqual(dids(visible), ["did:wire:amber-00000001"]); + assert.deepEqual(visible.machines.map((machine) => machine.id), ["machine-a"]); + assert.deepEqual(visible.directLinks, []); +}); + +test("visibleTopology removes edges with filtered endpoints and trims group members to visible live sessions", () => { + const WireTopology = topology(); + const machineVisible = WireTopology.visibleTopology(fixture(), { machine: "machine-a" }); + const layout = WireTopology.layoutTopology(machineVisible, { width: 800, height: 600 }); + + assert.deepEqual(machineVisible.directLinks, []); + assert.deepEqual(Array.from(machineVisible.groups[0].members, (member) => member.did), [ + "did:wire:amber-00000001", "did:wire:cedar-00000003" + ]); + assert.deepEqual(Array.from(layout.groupRegions[0].memberDids), [ + "did:wire:amber-00000001", "did:wire:cedar-00000003" + ]); + + const oneMember = WireTopology.visibleTopology(fixture(), { search: "amber" }); + assert.deepEqual(oneMember.groups, []); + assert.deepEqual(Array.from(WireTopology.layoutTopology(oneMember, { width: 800, height: 600 }).groupRegions), []); +}); + +test("layoutTopology has stable machine and DID-sorted node positions when source arrays reverse", () => { + const WireTopology = topology(); + const original = fixture(); + const reversed = structuredClone(original); + reversed.machines.reverse(); + reversed.sessions.reverse(); + reversed.direct_links.reverse(); + reversed.groups.reverse(); + + const viewport = { width: 800, height: 600 }; + const first = WireTopology.layoutTopology(WireTopology.visibleTopology(original, {}), viewport); + const second = WireTopology.layoutTopology(WireTopology.visibleTopology(reversed, {}), viewport); + const positions = (layout) => Array.from(layout.nodes, ({ machineId, did, x, y }) => ({ machineId, did, x, y })); + + assert.deepEqual(Array.from(first.machines, (machine) => machine.id), ["machine-a", "machine-b"]); + assert.deepEqual(Array.from(first.machines, ({ id, x, y }) => ({ id, x, y })), Array.from(second.machines, ({ id, x, y }) => ({ id, x, y }))); + assert.deepEqual(positions(first), positions(second)); + assert.deepEqual(Array.from(first.nodes, (node) => node.did), [ + "did:wire:amber-00000001", "did:wire:cedar-00000003", "did:wire:bravo-00000002", "did:wire:delta-00000004" + ]); +}); + +test("layoutTopology preserves both endpoints for a cross-machine bilateral edge", () => { + const WireTopology = topology(); + const layout = WireTopology.layoutTopology(WireTopology.visibleTopology(fixture(), {}), { width: 800, height: 600 }); + const edge = layout.edges.find((candidate) => candidate.id === "cedar-delta"); + + assert.deepEqual( + { sourceDid: edge.sourceDid, targetDid: edge.targetDid, sourceMachineId: edge.source.machineId, targetMachineId: edge.target.machineId }, + { + sourceDid: "did:wire:cedar-00000003", targetDid: "did:wire:delta-00000004", + sourceMachineId: "machine-a", targetMachineId: "machine-b" + } + ); + assert.match(edge.path, /^M /); +}); + +test("fitTransform returns a finite positive scale for empty and populated layouts", () => { + const WireTopology = topology(); + const viewport = { width: 800, height: 600 }; + const empty = WireTopology.layoutTopology(WireTopology.visibleTopology({ machines: [], sessions: [], direct_links: [], groups: [] }, {}), viewport); + const populated = WireTopology.layoutTopology(WireTopology.visibleTopology(fixture(), {}), viewport); + + for (const transform of [WireTopology.fitTransform(empty, viewport), WireTopology.fitTransform(populated, viewport)]) { + assert.ok(Number.isFinite(transform.x)); + assert.ok(Number.isFinite(transform.y)); + assert.ok(Number.isFinite(transform.scale)); + assert.ok(transform.scale > 0); + } +}); + +test("layoutTopology does not reorder visible group data", () => { + const WireTopology = topology(); + const visible = WireTopology.visibleTopology(fixture(), {}); + visible.groups = [ + { ...visible.groups[0], id: "zeta", members: [...visible.groups[0].members].reverse() }, + { ...visible.groups[0], id: "alpha", members: [...visible.groups[0].members] } + ]; + const before = structuredClone(visible); + + WireTopology.layoutTopology(visible, { width: 800, height: 600 }); + + assert.equal(JSON.stringify(visible), JSON.stringify(before)); +}); From 702441727f46d348c660cb831d0b5657bec40a97 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 10 Aug 2026 19:41:46 -0700 Subject: [PATCH 29/39] feat: share topology state across map and list --- assets/operator-dashboard.css | 30 +++ assets/operator-dashboard.html | 79 +++++-- assets/operator-dashboard.js | 166 +++++++++++++-- tests/operator_dashboard_polling.test.mjs | 199 +++++++++++++++--- tests/operator_dashboard_topology.test.mjs | 228 +++++++++++++++++++++ 5 files changed, 632 insertions(+), 70 deletions(-) create mode 100644 tests/operator_dashboard_topology.test.mjs diff --git a/assets/operator-dashboard.css b/assets/operator-dashboard.css index 5e2fa84..b3f8ab4 100644 --- a/assets/operator-dashboard.css +++ b/assets/operator-dashboard.css @@ -168,6 +168,17 @@ h2 { font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -0.025em; } .button:not(:disabled):active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 var(--frame-deep); } .action-hint { margin: 0 0 0 auto; color: var(--muted); font-size: 0.86rem; } +.view-toolbar { display: grid; gap: 14px; margin-bottom: 18px; } +.view-switch { display: inline-grid; grid-template-columns: repeat(2, minmax(80px, 1fr)); justify-self: start; border: 2px solid var(--frame-deep); } +.view-switch button { min-height: 40px; padding: 7px 18px; border: 0; color: var(--frame); background: transparent; font-family: var(--utility); font-size: 0.74rem; font-weight: 800; cursor: pointer; } +.view-switch button + button { border-left: 1px solid var(--frame-deep); } +.view-switch button[aria-pressed="true"] { color: #fff6e8; background: var(--frame); } +.filter-bar { display: grid; grid-template-columns: minmax(180px, 1.5fr) repeat(4, minmax(110px, 1fr)) auto; gap: 10px; align-items: end; } +.filter-bar label:not(.connected-filter) { display: grid; min-width: 0; gap: 5px; } +.filter-bar label > span { color: var(--muted); font-family: var(--utility); font-size: 0.64rem; font-weight: 800; text-transform: uppercase; } +.filter-bar input[type="search"], .filter-bar select { width: 100%; min-width: 0; min-height: 40px; padding: 7px 9px; border: 1px solid var(--frame); color: var(--ink); background: #fffaf0; } +.connected-filter { display: flex; min-height: 40px; gap: 7px; align-items: center; padding: 7px 9px; border: 1px solid var(--frame); background: rgba(255, 255, 255, 0.22); white-space: nowrap; } +.connected-filter input { width: 18px; height: 18px; margin: 0; accent-color: var(--frame); } .notice { margin: 0 0 16px; padding: 12px 14px; border-left: 5px solid var(--dial-dark); background: rgba(143, 176, 74, 0.2); } .notice[data-kind="error"] { border-color: var(--danger); background: rgba(163, 45, 45, 0.12); } .loading-state, .empty-state { padding: 42px 18px; border: 1px dashed var(--line); text-align: center; } @@ -175,6 +186,13 @@ h2 { font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -0.025em; } .empty-state strong { font-family: var(--display); font-size: 1.5rem; } .empty-state p { max-width: 560px; margin: 8px auto 0; color: var(--muted); } +.map-panel { position: relative; min-width: 0; border: 1px solid var(--line); background: rgba(255, 255, 255, 0.16); } +.map-panel[hidden] { display: none; } +.map-stage { display: grid; min-height: 420px; place-items: center; padding: 24px; color: var(--muted); background: linear-gradient(rgba(91, 26, 46, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(91, 26, 46, 0.06) 1px, transparent 1px); background-size: 24px 24px; } +.map-stage p { margin: 0; font-family: var(--utility); font-size: 0.75rem; } +.map-inspector { padding: 10px 13px; border-top: 1px solid var(--line); color: var(--muted); font-family: var(--utility); font-size: 0.7rem; } +.fit-map { position: absolute; top: 12px; right: 12px; background: var(--paper); } + .session-table-wrap { overflow-x: auto; border: 1px solid var(--line); } .session-table { width: 100%; border-collapse: collapse; } .session-table th { @@ -248,6 +266,10 @@ h2 { font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -0.025em; } .operations { padding: 24px 16px 12px; } .action-strip { align-items: stretch; flex-direction: column; } .action-hint { margin: 5px 0 0; } + .view-switch { width: 100%; justify-self: stretch; } + .filter-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .search-filter, .connected-filter { grid-column: 1 / -1; } + .map-stage { min-height: 320px; } .session-table-wrap { border: 0; overflow: visible; } .session-table thead { display: none; } .session-table, .session-table tbody, .session-table tr, .session-table td { display: block; width: 100%; } @@ -266,6 +288,14 @@ h2 { font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -0.025em; } .console-footer { align-items: flex-start; flex-direction: column; padding-inline: 20px; } } +@media (max-width: 420px) { + .filter-bar { grid-template-columns: 1fr; } + .search-filter, .connected-filter { grid-column: auto; } + .operations__heading { align-items: flex-start; flex-direction: column; } + .map-stage { min-height: 260px; padding: 18px; } + .fit-map { position: static; width: calc(100% - 24px); margin: 0 12px 12px; } +} + @media (prefers-reduced-motion: no-preference) { .machine-stamp__light { animation: carrier-pulse 2.4s ease-in-out infinite; } @keyframes carrier-pulse { 50% { box-shadow: 0 0 0 7px rgba(143, 176, 74, 0.08); } } diff --git a/assets/operator-dashboard.html b/assets/operator-dashboard.html index 8c1ff6f..69ba28c 100644 --- a/assets/operator-dashboard.html +++ b/assets/operator-dashboard.html @@ -54,29 +54,72 @@

Live sessions

Select two sessions to link them.

+
+
+ + +
+
+ + + + + + +
+
+
Scanning local session leases…
-