diff --git a/SESSION_LOG_2026_08_10.md b/SESSION_LOG_2026_08_10.md new file mode 100644 index 0000000..18b90ff --- /dev/null +++ b/SESSION_LOG_2026_08_10.md @@ -0,0 +1,170 @@ +# 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 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, 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. +- 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. + +## Verification + +- `cargo fmt --check` +- `cargo clippy --all-targets -- -D warnings` +- Focused operator, web, CLI, group, and dashboard end-to-end tests. +- 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. +- 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 + +- 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. +- 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. + +## 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. + +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. + +## 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. + +## Topology final proof + +- Producer: `operator_topology::collect_topology` reads live inventory, peer records, group rosters, and machine descriptors. Caller: installed `wire dash --web --no-open` PID `50977` served the authenticated loopback dashboard at `http://127.0.0.1:54553`. +- RED: the end-to-end caller linked two sessions, then created a three-session group. `direct_links` rose from one to two because a group-only `introduced_via` trust pin appeared as a one-sided direct edge. +- Fix: `dash::read_peers` retains the non-serialized `introduced_via` provenance. The topology builder excludes group-only verification pins from direct-link observations. Direct pairing still replaces the trust record, and group introduction never marks an existing direct pin. +- GREEN: the end-to-end test now compares `direct_links` before and after group creation, checks all three group members, and preserves the final assertion that the third member is not directly paired. +- Gate: `cargo fmt --check`, Clippy with all targets/features and warnings denied, 23 Node topology/dashboard tests, and `cargo test --all-targets --all-features` passed. The library suite reported 697 passes and one expected ignore; every enabled integration and stress target passed. +- Release install: `cargo build --release` completed in 1m35s. Atomic install used `wire.new` followed by `mv`. The release and installed binaries shared SHA-256 `21ddfe22c985ec7658ea96918bd9435842a94de6bec74a2fdb6069c4e045ff8c`. +- API timing: three fresh installed cold launches returned topology in 1.399s, 1.364s, and 1.367s. Clean Playwright polling made seven requests, peaked at 1.561s, and never exceeded one in-flight request. One probe run immediately after the compile load took 2.085s; fresh cold trials did not repeat it. +- Live browser proof: 63 live sessions; `rusted-butte` and `umber-savanna` rendered once with one bilateral edge. Map was the default; List remained available; two-node selection survived Map/List/Map; action counts and the link confirmation held; Fit returned every machine cluster to view. +- No suitable live group existed. The dashboard created and retained `operator-topology-proof-20260810` for the two named sessions. The group region rendered and `direct_links` stayed at two before and after creation. +- Desktop 1440×900 and narrow 390×844 each had document and body scroll width equal to viewport width. Console errors, failed requests, and HTTP error responses were zero. +- Screenshots: `/tmp/wire-task6-live.E5rTku/desktop-selected.png`, `/tmp/wire-task6-live.E5rTku/desktop-group.png`, and `/tmp/wire-task6-live.E5rTku/narrow-group.png`. Visual inspection found no clipping, blank state, or failed render. +- Review cycle 1 raised a MAJOR concern that `introduced_via` could outlive a later direct pair. Source tracing rejected it: every production pair writer calls `add_agent_card_pin`, which replaces the record; `promote_to_verified` has no production caller. Cycle 2 raised direct-pair-then-group ordering; the exact end-to-end order and `introduce_pin` existing-record branch disproved it. Final AMANALAP review returned no BLOCKER or MAJOR and cut speculative deserialization hardening and duplicate assertions. +- Retrospective proposal recorded only: provenance-classifier review packets should include unchanged producer transition branches. No policy or skill changed, and no proposal was queued. +- Incidental gate repair: Rust 1.95 Clippy rejected `read_dir(&dir)` in `group::list_groups_in`; the semantics-preserving `read_dir(dir)` edit cleared the required warnings-denied gate. +- Persistent Wire daemon PID `20898` and monitor PIDs `17795`, `29336`, and `34962` stayed running. No persistent listener stopped. + +## Final whole-branch fix wave + +- Scope followed the final AMANALAP disposition: fixed findings 1, 2, 3, 4, 5, 7, 8, 9, 12, and 14; deferred 6, 10, 11, and 13; cut 15. +- Group Cancel is now an explicit non-submit button. Its handler closes and resets the dialog. Unit and installed-browser probes observed zero POST requests. +- Group confirmation snapshots members and creator when the dialog opens. A successful poll that removes a member now closes the dialog, reports the race, and makes zero POST requests. +- A higher group epoch now replaces its holder evidence. The epoch-2 Alice/Bob then epoch-3 Alice/Carol regression retains only Alice and Carol. +- Inventory direct-link counts now exclude `introduced_via` group pins. The pure inventory regression and three-session end-to-end test show group creation leaves every direct-link count unchanged. +- Poll rerenders preserve keyboard focus by control kind and stable session ID for map nodes, row checkboxes, and Inspect buttons. Each browser regression waits for a successful topology poll before asserting focus. +- The map inspector renders topology anomalies with kind, subject group ID, and message through text-only DOM construction. +- Link and group POST handlers share the server scan lock with inventory GETs. The browser waits for any in-flight scan, then orders mutation work as GET, POST, fresh GET even on conflict or partial failure. Safe notices include bounded string `changed_sessions`. +- Fit records its actual scale as the wheel lower bound. Installed proof used a 0.1545 Fit: zoom-out stayed at 0.1545 and zoom-in stopped at 2.5. +- Once group creation succeeds, missing create output and invite generation/execution failures become `Partial` results carrying the creator in `changed_sessions`. +- State records the last successful scan time. Selection and filter renders no longer change it. +- GitNexus warned that `collect_live_from` has HIGH upstream impact through two direct callers and the link/topology flows; the authorized fix continued after reporting. Other edited production symbols were LOW or MEDIUM. Staged change detection found only the intended files and flows. +- RED/GREEN evidence: nine new browser regressions failed before the controller changes; the server lock regression failed to compile before `run_mutation`; the group epoch, direct-count, end-to-end count, and partial-boundary regressions failed before the Rust fixes. Final focused results were 24 browser/controller tests, 25 operator tests, and three operator-web tests, all passing. +- Full gates: `cargo fmt --check`; Clippy for all targets and features with warnings denied; 32 Node tests; `cargo test --all-targets --all-features` with 700 library passes, one expected library ignore, and every enabled integration and stress target passing. +- Release install: `cargo build --release` completed, then atomic `wire.new` install and rename produced an installed SHA-256 matching the release artifact: `d8c74aebd953d154982386e222e2f69a32c6069854c7b61a637dd633395b32fb`. +- Installed Playwright proof: real Cancel zero POST; group liveness race zero POST; focus survived three successful polls; anomaly subject text created no injected node; held-scan mutation order was GET, POST, GET; conflict and partial responses each forced a fresh GET; `changed_sessions` stayed literal text; desktop 1280×720 and mobile 390×844 had no horizontal overflow, clipped controls, or console errors. +- Commits `0042329` and `6517384` were pushed to `origin/feat/operator-dashboard`. + +## Operator-authorized residual partial boundary repair + +- Defect: a successful `wire group create` could mutate the creator home, then emit malformed JSON. `run_wire_at` parsed that output before group creation established the creator as changed, so the operator route returned `Internal` instead of `Partial`. +- RED: the focused operator regression fed malformed successful-create bytes through the same stdout parser used by group creation. Before the repair it failed to compile because no separable post-success parser existed. +- Fix: command execution now retains its existing nonzero-to-`Internal` behavior. Group creation checks success before parsing stdout, then routes parse and missing-ID failures through the existing post-create partial boundary. Link creation and later invite/join behavior remain unchanged; invite command, parse, and missing-code failures retain the creator-only partial result. +- GREEN: focused operator tests and the three-session operator dashboard end-to-end test passed. `cargo fmt --check`, Clippy with all targets/features and warnings denied, 32 Node tests, and `cargo test --all-targets --all-features` passed (700 library tests, one expected ignore, plus all enabled integration and stress targets). +- Scope: `src/operator.rs` adds only the command-status/stdout-parsing split required for the irreversible create boundary and one malformed-byte regression. + +## Artifacts + +- `src/operator.rs` — live inventory and explicit-home topology operations. +- `src/operator_topology.rs` — sanitized machine, direct-link, group, and anomaly producer. +- `src/dash.rs` — peer provenance read without changing `wire-dash-v1` serialization. +- `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. +- `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. +- `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/assets/operator-dashboard.css b/assets/operator-dashboard.css new file mode 100644 index 0000000..a7f420a --- /dev/null +++ b/assets/operator-dashboard.css @@ -0,0 +1,337 @@ +: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; } +.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; } +.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); } + +.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; overflow: hidden; place-items: stretch; padding: 0; 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; cursor: grab; touch-action: none; } +.map-stage:active { cursor: grabbing; } +.map-stage p { margin: 0; font-family: var(--utility); font-size: 0.75rem; } +.topology-svg { display: block; width: 100%; min-height: 420px; } +.topology-machine rect { fill: rgba(255, 250, 240, 0.62); stroke: rgba(64, 16, 32, 0.44); stroke-width: 1.5; } +.topology-machine--verified rect { stroke: var(--dial-dark); } +.topology-machine--unverified rect { stroke: #b67b21; stroke-dasharray: 7 5; } +.topology-machine text { font-family: var(--utility); font-size: 11px; pointer-events: none; } +.topology-machine__name { fill: var(--frame); font-weight: 800; text-transform: uppercase; } +.topology-machine__confidence { fill: var(--muted); font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; } +.topology-group { color: var(--group-color); pointer-events: none; } +.topology-group rect { fill: color-mix(in srgb, var(--group-color) 12%, transparent); stroke: var(--group-color); stroke-dasharray: 4 4; stroke-width: 1.5; } +.topology-group text { fill: var(--group-color); font-family: var(--utility); font-size: 9px; font-weight: 800; text-transform: uppercase; } +.topology-edge { fill: none; pointer-events: none; stroke-linecap: round; stroke-width: 3; } +.topology-edge--bilateral { stroke: var(--frame); } +.topology-edge--one-sided { stroke: #b67b21; stroke-dasharray: 9 6; } +.topology-node { color: var(--persona-color); cursor: pointer; outline: none; } +.topology-node__body { fill: #fffaf0; stroke: currentColor; stroke-width: 2; } +.topology-node--selected .topology-node__body { fill: rgba(143, 176, 74, 0.24); stroke-width: 4; } +.topology-node:focus-visible .topology-node__body { stroke: var(--phosphor); stroke-width: 4; filter: drop-shadow(0 0 4px var(--frame-deep)); } +.topology-health-ring { fill: transparent; stroke: var(--dial-dark); stroke-width: 3; } +.topology-node--warning .topology-health-ring { stroke: var(--danger); stroke-dasharray: 4 3; } +.topology-node text { pointer-events: none; } +.topology-node__emoji { fill: currentColor; font-size: 14px; } +.topology-node__handle { fill: var(--ink); font-family: var(--utility); font-size: 10px; font-weight: 800; } +.topology-node__harness { fill: var(--muted); font-family: var(--utility); font-size: 8px; } +.map-inspector { min-height: 38px; padding: 10px 13px; border-top: 1px solid var(--line); color: var(--muted); font-family: var(--utility); font-size: 0.7rem; } +.map-inspector--selected { background: rgba(143, 176, 74, 0.12); } +.map-inspector section { display: grid; gap: 8px; } +.map-inspector h3 { margin: 0; color: var(--frame); font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase; } +.map-inspector dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 18px; margin: 0; } +.map-inspector dl > div { min-width: 0; } +.map-inspector dt { color: var(--muted); font-size: 0.57rem; text-transform: uppercase; } +.map-inspector dd { margin: 2px 0 0; overflow-wrap: anywhere; color: var(--ink); } +.fit-map { position: absolute; z-index: 2; 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 { + 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-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; } +.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); } +.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; } +.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; + 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; } + .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; } + .topology-svg { min-height: 320px; } + .map-inspector dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .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-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; } +} + +@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; } + .topology-svg { min-height: 260px; } + .map-inspector dl { grid-template-columns: 1fr; } + .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 new file mode 100644 index 0000000..c33f0b5 --- /dev/null +++ b/assets/operator-dashboard.html @@ -0,0 +1,160 @@ + + + + + + + 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..c21ba2a --- /dev/null +++ b/assets/operator-dashboard.js @@ -0,0 +1,764 @@ +(() => { + "use strict"; + + const query = new URLSearchParams(window.location.search); + 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 emptyTopology = Object.freeze({ + schema: "wire-topology-v1", + generated_at: "", + machines: [], + sessions: [], + direct_links: [], + groups: [], + anomalies: [] + }); + 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, + confirmedPair: [], + confirmedGroup: null, + lastSuccessfulScanAt: "" + }; + const rows = document.querySelector("#session-rows"); + const tableWrap = document.querySelector("#table-wrap"); + const mapPanel = document.querySelector("#map-panel"); + const listPanel = document.querySelector("#list-panel"); + const topologyMap = document.querySelector("#topology-map"); + const mapInspector = document.querySelector("#map-inspector"); + const fitMapButton = document.getElementById?.("fit-map"); + const mapViewButton = document.querySelector("#map-view-button"); + const listViewButton = document.querySelector("#list-view-button"); + const loading = document.querySelector("#loading"); + const empty = document.querySelector("#empty"); + const emptyTitle = document.querySelector("#empty-title"); + const emptyCopy = document.querySelector("#empty-copy"); + 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 cancelGroup = document.querySelector("#cancel-group"); + const searchFilter = document.querySelector("#search-filter"); + const machineFilter = document.querySelector("#machine-filter"); + const harnessFilter = document.querySelector("#harness-filter"); + const projectFilter = document.querySelector("#project-filter"); + const healthFilter = document.querySelector("#health-filter"); + const connectedFilter = document.querySelector("#connected-filter"); + const svgNamespace = "http\u003a//www.w3.org/2000/svg"; + let mapLayout = null; + let mapViewport = null; + let mapTransform = { x: 0, y: 0, scale: 1 }; + let mapMinimumScale = 0.35; + let mapHasFit = false; + let mapDrag = null; + let mapNodesById = new Map(); + + const focusedControlIdentity = () => { + const active = document.activeElement; + const control = active?.dataset?.focusControl; + const sessionId = active?.dataset?.sessionId; + return control && sessionId ? { control, sessionId } : null; + }; + + const restoreFocusedControl = (identity, listControls) => { + if (!identity) return; + const target = identity.control === "map-node" + ? mapNodesById.get(identity.sessionId) + : listControls.get(`${identity.control}:${identity.sessionId}`); + target?.focus({ preventScroll: true }); + }; + + const known = (value) => value === null || value === undefined || value === "" ? "Unknown" : String(value); + + 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 sessionEntries = () => Array.isArray(state.topology.sessions) ? state.topology.sessions : []; + const allSessions = () => sessionEntries() + .filter((entry) => entry && entry.session) + .map((entry) => entry.session); + const selectedSessions = () => allSessions().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 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 svgElement = (tagName, attributes = {}) => { + const element = document.createElementNS + ? document.createElementNS(svgNamespace, tagName) + : document.createElement(tagName); + for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value); + return element; + }; + + const mapViewportSize = () => { + const bounds = topologyMap.getBoundingClientRect?.(); + return { + width: Number.isFinite(bounds?.width) && bounds.width > 0 ? bounds.width : 800, + height: Number.isFinite(bounds?.height) && bounds.height > 0 ? bounds.height : 520 + }; + }; + + const boundedViewport = ({ x, y, scale }) => { + const viewport = mapViewportSize(); + const nextScale = Number.isFinite(scale) && scale > 0 ? scale : 1; + const bounds = mapLayout?.bounds; + if (!bounds?.width || !bounds?.height) { + return { + x: Number.isFinite(x) ? x : viewport.width / 2, + y: Number.isFinite(y) ? y : viewport.height / 2, + scale: nextScale + }; + } + const contentWidth = bounds.width * nextScale; + const contentHeight = bounds.height * nextScale; + const xLimits = [64 - contentWidth, viewport.width - 64].sort((left, right) => left - right); + const yLimits = [64 - contentHeight, viewport.height - 64].sort((left, right) => left - right); + return { + x: Math.min(xLimits[1], Math.max(xLimits[0], Number.isFinite(x) ? x : 0)), + y: Math.min(yLimits[1], Math.max(yLimits[0], Number.isFinite(y) ? y : 0)), + scale: nextScale + }; + }; + + const setViewport = (transform) => { + mapTransform = boundedViewport(transform || {}); + mapViewport?.setAttribute("transform", `translate(${mapTransform.x} ${mapTransform.y}) scale(${mapTransform.scale})`); + }; + + const fitMap = () => { + if (!mapLayout) return; + const fitted = window.WireTopology.fitTransform(mapLayout, mapViewportSize()); + mapMinimumScale = Math.min(0.35, fitted.scale); + setViewport(fitted); + }; + + const paintInspector = (visible, layout) => { + const visibleById = new Map(visible.sessions.map((entry) => [entry.session.id, entry.session])); + const selected = [...state.selected].reverse().map((id) => visibleById.get(id)).find(Boolean); + const anomalies = Array.isArray(state.topology.anomalies) + ? state.topology.anomalies.filter((anomaly) => anomaly && typeof anomaly === "object") + : []; + const content = []; + mapInspector.classList.toggle("map-inspector--selected", Boolean(selected)); + if (!selected) { + const summary = document.createElement("p"); + summary.textContent = `${layout.nodes.length} visible session${layout.nodes.length === 1 ? "" : "s"} · ${layout.edges.length} direct link${layout.edges.length === 1 ? "" : "s"}`; + content.push(summary); + } else { + content.push(detailSection("Selected session", [ + ["Handle", selected.handle], + ["Harness", selected.harness?.label], + ["Project", selected.project?.name], + ["Machine", selected.machine?.hostname], + ["DID", selected.did], + ["Health", selected.health] + ])); + } + if (anomalies.length) { + content.push(detailSection("Topology anomalies", anomalies.map((anomaly) => [ + `${known(anomaly.kind)} · ${known(anomaly.subject_id)}`, + anomaly.message + ]))); + } + mapInspector.replaceChildren(...content); + }; + + const emitMapSelection = (id) => { + topologyMap.dispatchEvent(new CustomEvent("wire:toggle-selection", { detail: { id } })); + mapNodesById.get(id)?.focus(); + }; + + const renderMap = (visible) => { + const viewportSize = mapViewportSize(); + const layout = window.WireTopology.layoutTopology(visible, viewportSize); + const svg = svgElement("svg", { + class: "topology-svg", + viewBox: `0 0 ${viewportSize.width} ${viewportSize.height}`, + role: "group", + "aria-label": "Live Wire session topology" + }); + const viewport = svgElement("g", { class: "topology-viewport" }); + const machinesLayer = svgElement("g", { class: "topology-layer topology-machines" }); + const groupsLayer = svgElement("g", { class: "topology-layer topology-groups" }); + const edgesLayer = svgElement("g", { class: "topology-layer topology-edges" }); + const nodesLayer = svgElement("g", { class: "topology-layer topology-nodes" }); + machinesLayer.dataset.layer = "machines"; + groupsLayer.dataset.layer = "groups"; + edgesLayer.dataset.layer = "edges"; + nodesLayer.dataset.layer = "nodes"; + + for (const machine of layout.machines) { + const confidence = machine.identity_confidence || "unverified"; + const cluster = svgElement("g", { + class: `topology-machine topology-machine--${confidence === "verified" ? "verified" : "unverified"}`, + role: "group", + "aria-label": `${known(machine.hostname)} machine, ${confidence}` + }); + cluster.append( + svgElement("rect", { x: machine.x, y: machine.y, width: machine.width, height: machine.height, rx: 6 }), + svgElement("text", { class: "topology-machine__name", x: machine.x + 18, y: machine.y + 25 }), + svgElement("text", { class: "topology-machine__confidence", x: machine.x + machine.width - 18, y: machine.y + 25, "text-anchor": "end" }) + ); + cluster.children[1].textContent = known(machine.hostname); + cluster.children[2].textContent = confidence; + machinesLayer.append(cluster); + } + + for (const region of layout.groupRegions) { + const fragment = svgElement("g", { + class: "topology-group", + role: "group", + "aria-label": `${known(region.name)} group on ${known(region.machineId)}` + }); + if (fragment.style?.setProperty) fragment.style.setProperty("--group-color", window.WireTopology.groupColor(region.groupId)); + const rectangle = svgElement("rect", { x: region.x, y: region.y, width: region.width, height: region.height, rx: 8 }); + const label = svgElement("text", { x: region.x + 9, y: region.y + 15 }); + label.textContent = known(region.name); + fragment.append(rectangle, label); + groupsLayer.append(fragment); + } + + for (const edge of layout.edges) { + const bilateral = edge.state === "bilateral"; + edgesLayer.append(svgElement("path", { + class: `topology-edge topology-edge--${bilateral ? "bilateral" : "one-sided"}`, + d: edge.path, + "aria-label": `${bilateral ? "Bilateral" : "One-sided"} direct link` + })); + } + + const nextNodesById = new Map(); + for (const node of layout.nodes) { + const session = node.session; + const selected = state.selected.has(session.id); + const healthClass = session.health === "healthy" ? "healthy" : "warning"; + const group = svgElement("g", { + class: `topology-node topology-node--${healthClass}${selected ? " topology-node--selected" : ""}`, + role: "button", + tabindex: "0", + "aria-pressed": String(selected), + "aria-label": `${known(session.handle)}, ${known(session.harness?.label)}, ${known(session.health)}` + }); + group.dataset.sessionId = session.id; + group.dataset.focusControl = "map-node"; + if (group.style?.setProperty) group.style.setProperty("--persona-color", session.primary_hex || "#5b1a2e"); + const body = svgElement("rect", { class: "topology-node__body", x: node.left, y: node.top, width: node.width, height: node.height, rx: 5 }); + const ring = svgElement("circle", { class: "topology-health-ring", cx: node.left + 18, cy: node.top + 18, r: 11 }); + const emoji = svgElement("text", { class: "topology-node__emoji", x: node.left + 18, y: node.top + 22, "text-anchor": "middle" }); + const handle = svgElement("text", { class: "topology-node__handle", x: node.left + 36, y: node.top + 21 }); + const harness = svgElement("text", { class: "topology-node__harness", x: node.left + 36, y: node.top + 38 }); + emoji.textContent = known(session.emoji); + handle.textContent = known(session.handle); + harness.textContent = known(session.harness?.label); + group.append(body, ring, emoji, handle, harness); + group.addEventListener("click", () => emitMapSelection(session.id)); + group.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + emitMapSelection(session.id); + }); + nextNodesById.set(session.id, group); + nodesLayer.append(group); + } + + viewport.append(machinesLayer, groupsLayer, edgesLayer, nodesLayer); + svg.append(viewport); + const fitOnFirstContent = layout.nodes.length > 0 && (!mapHasFit || !mapLayout?.nodes.length); + mapLayout = layout; + mapViewport = viewport; + mapNodesById = nextNodesById; + topologyMap.replaceChildren(svg); + if (fitOnFirstContent) { + fitMap(); + mapHasFit = true; + } else { + setViewport(mapTransform); + } + paintInspector(visible, layout); + }; + + const toggleSelection = (id) => { + if (!allSessions().some((session) => session.id === id)) return; + if (state.selected.has(id)) state.selected.delete(id); + else state.selected.add(id); + render(); + }; + + const option = (value, label = value) => { + const element = document.createElement("option"); + element.value = value; + element.textContent = label; + return element; + }; + + const replaceOptions = (select, values, labels = new Map()) => { + const current = select.value; + const options = [option("", "All")]; + for (const value of [...values].sort((left, right) => left.localeCompare(right))) { + options.push(option(value, labels.get(value) || value)); + } + select.replaceChildren(...options); + select.value = values.has(current) ? current : ""; + }; + + const populateFilterOptions = () => { + const entries = sessionEntries().filter((entry) => entry && entry.session); + const machineLabels = new Map((state.topology.machines || []).map((machine) => [machine.id, machine.hostname || machine.id])); + replaceOptions(machineFilter, new Set(entries.map((entry) => entry.machine_id).filter(Boolean)), machineLabels); + replaceOptions(harnessFilter, new Set(entries.map((entry) => entry.session.harness?.label).filter(Boolean))); + replaceOptions(projectFilter, new Set(entries.map((entry) => entry.session.project?.name).filter(Boolean))); + replaceOptions(healthFilter, new Set(entries.map((entry) => entry.session.health).filter(Boolean))); + state.filters.machine = machineFilter.value; + state.filters.harness = harnessFilter.value; + state.filters.project = projectFilter.value; + state.filters.health = healthFilter.value; + }; + + const render = () => { + const focusIdentity = focusedControlIdentity(); + const visible = window.WireTopology.visibleTopology(state.topology, state.filters); + const sessions = visible.sessions.map((entry) => entry.session); + const fragment = document.createDocumentFragment(); + const listControls = new Map(); + + for (const session of sessions) { + const row = document.createElement("tr"); + row.dataset.sessionId = session.id; + const selectCell = cell("Select"); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "session-check"; + checkbox.checked = state.selected.has(session.id); + checkbox.dataset.sessionId = session.id; + checkbox.dataset.focusControl = "row-checkbox"; + checkbox.setAttribute("aria-label", `Select ${session.handle}`); + checkbox.addEventListener("change", () => toggleSelection(session.id)); + listControls.set(`row-checkbox:${session.id}`, checkbox); + 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; + const identity = document.createElement("span"); + identity.className = "session-identity"; + identity.append(handle); + const uptime = document.createElement("small"); + uptime.textContent = `${formatAge(session.age_seconds)} · PID ${known(session.pid)}`; + 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.dataset.sessionId = session.id; + detailsButton.dataset.focusControl = "inspect"; + 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(); + }); + listControls.set(`inspect:${session.id}`, detailsButton); + identity.append(uptime, detailsButton); + name.append(emoji, identity); + nameCell.append(name); + + const host = cell("Harness", "utility"); + host.append(stack(session.harness?.label, session.harness?.confidence)); + const project = cell("Project", "project"); + 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"); + 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, machine, identityCell, links, health); + 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 = 8; + 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); + const liveSessions = allSessions(); + const hasLiveSessions = liveSessions.length !== 0; + const hasVisibleSessions = sessions.length !== 0; + liveCount.textContent = String(liveSessions.length); + loading.hidden = true; + empty.hidden = hasVisibleSessions; + emptyTitle.textContent = hasLiveSessions ? "Filters hide all live sessions" : "No live agent sessions"; + emptyCopy.textContent = hasLiveSessions + ? "Clear or change filters to bring sessions back into view." + : "Start a Codex, Claude, or Goose session with Wire enabled. It will appear on the next scan."; + mapPanel.hidden = state.activeView !== "map" || !hasVisibleSessions; + listPanel.hidden = state.activeView !== "list" || !hasVisibleSessions; + tableWrap.hidden = !hasVisibleSessions; + mapViewButton.setAttribute("aria-pressed", String(state.activeView === "map")); + listViewButton.setAttribute("aria-pressed", String(state.activeView === "list")); + topologyMap.dataset.visibleSessionIds = sessions.map((session) => session.id).join(","); + renderMap(visible); + if (!state.stale && state.lastSuccessfulScanAt) lastScan.textContent = `Scan ${state.lastSuccessfulScanAt}`; + restoreFocusedControl(focusIdentity, listControls); + updateActions(); + }; + + const scan = () => { + if (state.scanPromise) return state.scanPromise; + state.scanPromise = (async () => { + try { + const response = await fetch("/api/topology", { + cache: "no-store", + headers: { "X-Wire-Token": token } + }); + if (!response.ok) throw new Error("Could not refresh topology."); + const topology = await response.json(); + const wasStale = state.stale; + state.topology = topology && typeof topology === "object" ? topology : emptyTopology; + const liveIds = new Set(allSessions().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))); + state.stale = false; + state.lastSuccessfulScanAt = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + populateFilterOptions(); + if (wasStale) showNotice(token ? "" : "Launch token missing. Restart wire dash --web.", token ? "ok" : "error"); + render(); + } catch (error) { + state.stale = true; + loading.hidden = true; + const failedAt = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + lastScan.textContent = `Refresh failed ${failedAt} · showing stale data`; + showNotice(`${error.message || "Topology refresh failed."} Showing the last known topology.`, "error"); + updateActions(); + } + })().finally(() => { state.scanPromise = null; }); + return state.scanPromise; + }; + + const mutate = async (path, body) => { + state.busy = true; + updateActions(); + let outcome = { message: "Topology action failed.", kind: "error" }; + try { + if (state.scanPromise) await state.scanPromise; + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Wire-Token": token }, + body: JSON.stringify(body) + }); + let payload = {}; + try { + payload = await response.json(); + } catch (_) { + payload = {}; + } + const changed = Array.isArray(payload.changed_sessions) + ? payload.changed_sessions + .filter((value) => typeof value === "string") + .slice(0, 50) + .map((value) => value.slice(0, 128)) + : []; + const baseMessage = response.ok + ? payload.message || "Topology updated." + : payload.error || "Topology action failed."; + outcome = { + message: `${baseMessage}${changed.length ? ` Changed sessions: ${changed.join(", ")}.` : ""}`, + kind: response.ok ? "ok" : "error" + }; + if (response.ok) state.selected.clear(); + await scan(); + } catch (error) { + outcome = { message: error.message || "Topology action failed.", kind: "error" }; + await scan(); + } finally { + if (state.stale) outcome.message += " Topology refresh failed; showing stale data."; + showNotice(outcome.message, outcome.kind); + state.busy = false; + updateActions(); + } + }; + + 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", async (event) => { + event.preventDefault(); + const sessions = [...state.confirmedPair]; + confirmDialog.close(); + if (state.scanPromise) await state.scanPromise; + const liveIds = new Set(allSessions().map((session) => session.id)); + 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 }); + }); + + groupButton.addEventListener("click", () => { + const selected = selectedSessions(); + if (selected.length < 2) return; + const members = selected.map((session) => session.id); + state.confirmedGroup = { members, creator: members[0] }; + const options = selected.map((session) => { + const option = document.createElement("option"); + option.value = session.id; + option.textContent = session.handle; + return option; + }); + groupCreator.replaceChildren(...options); + groupCreator.value = state.confirmedGroup.creator; + groupDialog.showModal(); + groupName.focus(); + }); + + groupCreator.addEventListener("change", () => { + if (state.confirmedGroup?.members.includes(groupCreator.value)) { + state.confirmedGroup.creator = groupCreator.value; + } + }); + + const closeGroupDialog = () => { + groupDialog.close(); + groupForm.reset(); + state.confirmedGroup = null; + }; + + cancelGroup.addEventListener("click", closeGroupDialog); + + groupForm.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!groupName.reportValidity()) return; + if (state.scanPromise) await state.scanPromise; + const confirmed = state.confirmedGroup; + const liveIds = new Set(allSessions().map((session) => session.id)); + const selectedIds = new Set(selectedSessions().map((session) => session.id)); + const unchanged = confirmed + && selectedIds.size === confirmed.members.length + && confirmed.members.every((id) => selectedIds.has(id) && liveIds.has(id)) + && confirmed.members.includes(confirmed.creator) + && confirmed.creator === groupCreator.value; + if (!unchanged) { + closeGroupDialog(); + showNotice("The selected group sessions changed or are no longer live. Select them again.", "error"); + return; + } + const body = { + name: groupName.value.trim(), + creator: confirmed.creator, + members: [...confirmed.members] + }; + closeGroupDialog(); + void mutate("/api/groups", body); + }); + + const setView = (view) => { + state.activeView = view; + render(); + }; + mapViewButton.addEventListener("click", () => setView("map")); + listViewButton.addEventListener("click", () => setView("list")); + topologyMap.addEventListener("wire:toggle-selection", (event) => toggleSelection(event.detail?.id)); + fitMapButton?.addEventListener("click", fitMap); + topologyMap.addEventListener("pointerdown", (event) => { + if ((event.button !== undefined && event.button !== 0) || event.target.closest?.(".topology-node")) return; + mapDrag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, originX: mapTransform.x, originY: mapTransform.y }; + topologyMap.setPointerCapture?.(event.pointerId); + }); + topologyMap.addEventListener("pointermove", (event) => { + if (!mapDrag || event.pointerId !== mapDrag.pointerId) return; + setViewport({ + x: mapDrag.originX + event.clientX - mapDrag.x, + y: mapDrag.originY + event.clientY - mapDrag.y, + scale: mapTransform.scale + }); + }); + const finishMapDrag = (event) => { + if (!mapDrag || event.pointerId !== mapDrag.pointerId) return; + topologyMap.releasePointerCapture?.(event.pointerId); + mapDrag = null; + }; + topologyMap.addEventListener("pointerup", finishMapDrag); + topologyMap.addEventListener("pointercancel", finishMapDrag); + topologyMap.addEventListener("wheel", (event) => { + if (!mapLayout?.nodes.length) return; + event.preventDefault(); + const bounds = topologyMap.getBoundingClientRect?.() || { left: 0, top: 0 }; + const pointX = event.clientX - bounds.left; + const pointY = event.clientY - bounds.top; + const scale = Math.min(2.5, Math.max(mapMinimumScale, mapTransform.scale * Math.exp(-event.deltaY * 0.001))); + const ratio = scale / mapTransform.scale; + setViewport({ + x: pointX - (pointX - mapTransform.x) * ratio, + y: pointY - (pointY - mapTransform.y) * ratio, + scale + }); + }, { passive: false }); + + const bindFilter = (element, key, eventName = "change") => { + element.addEventListener(eventName, () => { + state.filters[key] = element.value; + render(); + }); + }; + bindFilter(searchFilter, "search", "input"); + bindFilter(machineFilter, "machine"); + bindFilter(harnessFilter, "harness"); + bindFilter(projectFilter, "project"); + bindFilter(healthFilter, "health"); + connectedFilter.addEventListener("change", () => { + state.filters.connectedOnly = connectedFilter.checked; + render(); + }); + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || state.selected.size === 0) return; + state.selected.clear(); + render(); + }); + + if (!token) showNotice("Launch token missing. Restart wire dash --web.", "error"); + void scan(); + window.setInterval(() => { if (!state.busy) void scan(); }, 2000); +})(); diff --git a/assets/operator-topology.js b/assets/operator-topology.js new file mode 100644 index 0000000..ddf8fc6 --- /dev/null +++ b/assets/operator-topology.js @@ -0,0 +1,196 @@ +(() => { + "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]))) { + 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/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. 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. 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. 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. 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. diff --git a/src/cli/dash.rs b/src/cli/dash.rs index 09dffa4..ba6a5b5 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), @@ -591,6 +598,7 @@ mod tests { handle: format!("peer{i}"), did: format!("did:wire:peer{i}-0000"), tier: "VERIFIED".to_string(), + introduced_via: None, }) .collect(), cwd: None, @@ -692,6 +700,7 @@ mod tests { handle: "evil\x1b[2Jhandle".to_string(), did: "did:wire:evil-0000".to_string(), tier: "VERIFIED".to_string(), + introduced_via: None, }]; s.likely_idle = false; let report = DashReport { 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/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/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/dash.rs b/src/dash.rs index 5dc7d16..2e7d8b3 100644 --- a/src/dash.rs +++ b/src/dash.rs @@ -47,6 +47,8 @@ pub struct PeerRow { pub handle: String, pub did: String, pub tier: String, + #[serde(skip_serializing)] + pub introduced_via: Option, } /// A single wire identity on this box + its live-ish state. @@ -170,6 +172,10 @@ pub fn read_peers(home: &Path, own_did: Option<&str>, own_handle: Option<&str>) .and_then(|t| t.as_str()) .unwrap_or("UNTRUSTED") .to_string(), + introduced_via: rec + .get("introduced_via") + .and_then(|value| value.as_str()) + .map(str::to_string), }); } out.sort_by(|a, b| a.handle.cmp(&b.handle)); 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 diff --git a/src/group.rs b/src/group.rs index 4ff6d5f..daeec14 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,12 +290,19 @@ 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()); } let mut out = Vec::new(); - for entry in std::fs::read_dir(&dir)?.flatten() { + for entry in std::fs::read_dir(dir)?.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; diff --git a/src/lib.rs b/src/lib.rs index f4b5850..d0b9593 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,9 @@ pub mod nostr_key; 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; pub mod org_policy; @@ -56,6 +59,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/operator.rs b/src/operator.rs new file mode 100644 index 0000000..3c74618 --- /dev/null +++ b/src/operator.rs @@ -0,0 +1,907 @@ +use std::collections::HashSet; +use std::path::Path; +use std::process::{Command, Output}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use time::OffsetDateTime; + +pub const LIVE_SESSION_SCHEMA: &str = "wire-live-sessions-v2"; + +#[derive(Clone, Debug, Serialize)] +pub struct LiveSession { + pub id: String, + pub handle: String, + pub did: String, + pub emoji: String, + pub primary_hex: String, + 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, + pub health: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LiveSessionReport { + pub schema: &'static str, + 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) +} + +const MAX_WIRE_OUTPUT: usize = 256 * 1024; + +fn capped_wire_output(bytes: &[u8]) -> String { + let end = bytes.len().min(MAX_WIRE_OUTPUT); + String::from_utf8_lossy(&bytes[..end]) + .chars() + .filter(|character| !character.is_control() || matches!(character, '\n' | '\t')) + .collect() +} + +fn run_wire_command_at(home: &Path, args: &[String]) -> Result { + 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()))?; + if !output.status.success() { + return Err(OperatorError::Internal(anyhow::anyhow!( + "wire command failed with {}: {}", + output.status, + capped_wire_output(&output.stderr).trim() + ))); + } + Ok(output) +} + +fn parse_wire_stdout(stdout: &[u8]) -> Result { + serde_json::from_str(capped_wire_output(stdout).trim()).map_err(|error| { + OperatorError::Internal(anyhow::anyhow!( + "wire command returned invalid JSON: {error}" + )) + }) +} + +fn run_wire_at(home: &Path, args: &[String]) -> Result { + let output = run_wire_command_at(home, args)?; + parse_wire_stdout(&output.stdout) +} + +fn post_create_output_field( + output: Result, + field: &str, + failure: &str, + creator: &str, +) -> Result { + let partial = |message| OperatorError::Partial { + message, + changed_sessions: vec![creator.to_string()], + }; + let output = output.map_err(|error| partial(format!("{failure}: {error}")))?; + output + .get(field) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .ok_or_else(|| partial(failure.to_string())) +} + +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_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( + 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_command_at( + &creator.home_dir, + &[ + "group".to_string(), + "create".to_string(), + request.name.trim().to_string(), + "--json".to_string(), + ], + )?; + let group_id = post_create_output_field( + parse_wire_stdout(&created.stdout), + "id", + "group create response omitted id", + &creator.name, + )?; + let code = post_create_output_field( + run_wire_at( + &creator.home_dir, + &[ + "group".to_string(), + "invite".to_string(), + group_id.clone(), + "--json".to_string(), + ], + ), + "code", + "group invite response omitted code", + &creator.name, + )?; + + 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_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, + ) +} + +#[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, + is_alive: impl Fn(u32) -> bool + Copy, +) -> anyhow::Result { + let mut candidates = Vec::new(); + for session in sessions { + if session.did.is_none() || session.handle.is_none() { + 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 lease = leases + .iter() + .filter(|lease| lease.role == "mcp") + .max_by(|left, right| left.heartbeat_at.cmp(&right.heartbeat_at)) + .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, runtime)); + } + let snapshot = crate::session_metadata::process_snapshot( + &candidates + .iter() + .map(|(_, runtime)| runtime.pid()) + .collect::>(), + ); + let mut live = Vec::new(); + 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 + .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 = runtime.started_at().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" + }; + 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) + }), + ) + } + LiveRuntime::Daemon(record) => ( + daemon_harness(), + daemon_identity(&session.home_dir), + session + .cwd + .as_deref() + .map(Path::new) + .map(crate::session_metadata::describe_project) + .unwrap_or_else(|| { + 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: runtime.pid(), + machine, + harness, + identity, + project, + started_at: runtime.started_at().map(str::to_string), + age_seconds, + direct_link_count: peers + .iter() + .filter(|peer| peer.introduced_via.is_none()) + .count(), + 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 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(), + 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, + 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 { + 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(); + } + + 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_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("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); + 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(); + + 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), + session(&mismatched_home, "66666666", true), + ]; + let report = collect_live_from(&sessions, now + time::Duration::seconds(2), |pid| { + matches!(pid, 101..=104 | 106) + }) + .unwrap(); + + assert_eq!(report.schema, "wire-live-sessions-v2"); + 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")); + assert!(!json.contains("slot_token")); + assert!(!json.contains("private.key")); + assert!(!json.contains("command_line")); + } + + #[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 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(), + introduced_via: None, + }; + + 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")]; + 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()); + } + } + + #[test] + fn inventory_direct_link_count_excludes_group_introduced_pins() { + let tmp = tempdir().unwrap(); + let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); + let home = tmp.path().join("live"); + lease(&home, "mcp", 101, now, 90); + let trust_dir = home.join("config/wire"); + std::fs::create_dir_all(&trust_dir).unwrap(); + std::fs::write( + trust_dir.join("trust.json"), + serde_json::to_vec(&serde_json::json!({ + "agents": { + "session-11111111": { + "did": "did:wire:session-11111111", + "tier": "ATTESTED" + }, + "direct-peer": { + "did": "did:wire:direct-peer-22222222", + "tier": "VERIFIED" + }, + "group-peer": { + "did": "did:wire:group-peer-33333333", + "tier": "VERIFIED", + "introduced_via": "crew" + } + }, + "version": 1 + })) + .unwrap(), + ) + .unwrap(); + + let report = collect_live_from( + &[session(&home, "11111111", true)], + now + time::Duration::seconds(2), + |pid| pid == 101, + ) + .unwrap(); + + assert_eq!(report.sessions[0].direct_link_count, 1); + } + + #[test] + fn post_create_output_failures_report_the_creator_as_changed() { + let malformed_create_stdout = post_create_output_field( + parse_wire_stdout(b"{not json}"), + "id", + "group create response omitted id", + "alice", + ) + .unwrap_err(); + let malformed_create = post_create_output_field( + Ok(serde_json::json!({})), + "id", + "group create response omitted id", + "alice", + ) + .unwrap_err(); + let failed_invite = post_create_output_field( + Err(OperatorError::Internal(anyhow::anyhow!("invite failed"))), + "code", + "group invite failed", + "alice", + ) + .unwrap_err(); + let malformed_invite = post_create_output_field( + Ok(serde_json::json!({})), + "code", + "group invite response omitted code", + "alice", + ) + .unwrap_err(); + + for error in [ + malformed_create_stdout, + malformed_create, + failed_invite, + malformed_invite, + ] { + match error { + OperatorError::Partial { + changed_sessions, .. + } => assert_eq!(changed_sessions, vec!["alice"]), + other => panic!("expected partial mutation, got {other:?}"), + } + } + } +} diff --git a/src/operator_topology.rs b/src/operator_topology.rs new file mode 100644 index 0000000..81618dd --- /dev/null +++ b/src/operator_topology.rs @@ -0,0 +1,656 @@ +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 { + group: crate::group::Group, + holders: BTreeSet, + }, + Conflicted { + epoch: u64, + holders: BTreeSet, + }, +} + +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.introduced_via.is_some() + || 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 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.clear(); + 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, .. }) if group.epoch > *epoch => { + group_resolutions.insert( + group.id.clone(), + GroupResolution::Accepted { + group: group.clone(), + holders: BTreeSet::from([holder]), + }, + ); + } + Some(GroupResolution::Conflicted { epoch, holders }) if group.epoch == *epoch => { + holders.insert(holder); + } + Some(GroupResolution::Accepted { .. }) + | Some(GroupResolution::Conflicted { .. }) => {} + } + } + } + let mut groups = Vec::new(); + for (id, resolution) in group_resolutions { + match resolution { + GroupResolution::Accepted { group, holders } => { + 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::>(); + 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) + .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(), + introduced_via: None, + } + } + + 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 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_alice = source(live("alice", ALICE, Some("machine-1"))); + older_alice.groups.push(group( + "crew", + 2, + ALICE, + &[(ALICE, GroupTier::Creator), (BOB, GroupTier::Member)], + )); + let mut older_bob = source(live("bob", BOB, Some("machine-1"))); + older_bob.groups.push(group( + "crew", + 2, + ALICE, + &[(ALICE, GroupTier::Creator), (BOB, GroupTier::Member)], + )); + let mut newer_carol = source(live("carol", CAROL, Some("machine-1"))); + newer_carol.groups.push(group( + "crew", + 3, + ALICE, + &[(ALICE, GroupTier::Creator), (CAROL, GroupTier::Member)], + )); + + let report = build_topology(vec![older_alice, older_bob, newer_carol], generated_at()); + + assert_eq!(report.groups.len(), 1); + assert_eq!(report.groups[0].epoch, 3); + assert_eq!( + report.groups[0] + .members + .iter() + .map(|member| (member.did.as_str(), member.tier.as_str())) + .collect::>(), + vec![(ALICE, "creator"), (CAROL, "member")], + "holders of lower-epoch copies must not return as introduced members" + ); + } + + #[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", + ], + ); + } +} diff --git a/src/operator_web.rs b/src/operator_web.rs new file mode 100644 index 0000000..7d09848 --- /dev/null +++ b/src/operator_web.rs @@ -0,0 +1,491 @@ +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"); +const TOPOLOGY_JAVASCRIPT: &str = include_str!("../assets/operator-topology.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, HOST, ORIGIN}; +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 { + Router::new() + .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)) + .route("/api/links", post(post_links)) + .route("/api/groups", post(post_groups)) + .with_state(AppState { + token, + scan_lock: Arc::new(tokio::sync::Mutex::new(())), + }) + .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 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")); + 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 { + let token_matches = headers + .get("X-Wire-Token") + .and_then(|value| value.to_str().ok()) + == 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 { + ( + 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(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::collect_live_sessions).await { + Ok(Ok(report)) => Json(report).into_response(), + _ => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "session inventory failed", + Vec::new(), + ), + } +} + +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 run_mutation( + state: &AppState, + action: impl FnOnce() -> Result + + Send + + 'static, +) -> Response { + let _scan = state.scan_lock.lock().await; + match tokio::task::spawn_blocking(action).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_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()); + } + }; + run_mutation(&state, move || { + crate::operator::link_local_sessions(request) + }) + .await +} + +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()); + } + }; + run_mutation(&state, move || crate::operator::create_local_group(request)).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mutation_work_waits_for_the_inventory_scan_lock() { + let state = AppState { + token: "test-token".to_string(), + scan_lock: Arc::new(tokio::sync::Mutex::new(())), + }; + let scan = state.scan_lock.lock().await; + let action_state = state.clone(); + let (started_tx, mut started_rx) = tokio::sync::oneshot::channel(); + let action = tokio::spawn(async move { + run_mutation(&action_state, move || { + let _ = started_tx.send(()); + Ok(crate::operator::MutationResult { + ok: true, + message: "changed".to_string(), + changed_sessions: Vec::new(), + }) + }) + .await + }); + + tokio::task::yield_now().await; + assert!(matches!( + started_rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + drop(scan); + + let response = action.await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + started_rx.await.unwrap(); + } + + #[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::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") + .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(); + } + + #[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\"")); + assert!( + html.contains("id=\"cancel-group\" type=\"button\""), + "group Cancel must not submit the creation form" + ); + 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), + "missing dashboard heading {heading}" + ); + } + + 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")); + 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)}")); + + 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/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/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..3bd42c3 100644 --- a/src/session_lifecycle.rs +++ b/src/session_lifecycle.rs @@ -19,6 +19,16 @@ 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, + #[serde(default)] + pub machine: Option, + #[serde(default)] + pub harness: Option, + #[serde(default)] + pub project: Option, } pub fn lease_dir(home: &Path) -> PathBuf { @@ -59,6 +69,7 @@ pub fn write_lease_at( wire_version: &str, bin_path: &Path, session_source: &str, + cwd: Option<&Path>, ) -> Result { if role.is_empty() || !role @@ -69,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(), @@ -78,6 +90,17 @@ 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()), + 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) @@ -87,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) @@ -151,6 +198,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 +208,7 @@ impl LeaseGuard { env!("CARGO_PKG_VERSION"), &bin, crate::session::session_source(), + cwd.as_deref(), ) } @@ -177,6 +226,7 @@ impl LeaseGuard { wire_version: &str, bin_path: &Path, session_source: &str, + cwd: Option<&Path>, ) -> Result { let path = write_lease_at( home, @@ -187,6 +237,7 @@ impl LeaseGuard { wire_version, bin_path, session_source, + cwd, )?; Ok(Self { path, ttl }) } @@ -225,6 +276,7 @@ mod tests { "0.17.0", Path::new("/opt/wire"), "override", + Some(Path::new("/work/wire")), ) .unwrap() } @@ -243,6 +295,52 @@ 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); + 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] @@ -275,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(); @@ -304,6 +430,7 @@ mod tests { "0.17.0", Path::new("/opt/wire"), "codex-cli", + Some(Path::new("/work/wire")), ) .unwrap(); let path = guard.path.clone(); diff --git a/src/session_metadata.rs b/src/session_metadata.rs new file mode 100644 index 0000000..d2affa5 --- /dev/null +++ b/src/session_metadata.rs @@ -0,0 +1,1002 @@ +use std::collections::HashMap; +use std::path::PathBuf; +#[cfg(any(target_os = "macos", windows))] +use std::process::Command; +use std::sync::{Mutex, OnceLock}; +#[cfg(any(target_os = "macos", windows))] +use std::time::Duration; + +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, +} + +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, + 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(), + } + } +} + +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(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()) + .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, + pub parent_pid: Option, + pub executable: String, + pub arguments: Vec, + pub cwd: Option, +} + +pub(crate) const MAX_ANCESTORS: usize = 8; + +#[derive(Clone, Debug, Default)] +pub(crate) struct ProcessSnapshot { + observations: HashMap, +} + +impl ProcessSnapshot { + #[cfg(test)] + 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) +} + +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 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() { + 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; + }; + 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, + cwd: None, + }, + ); + } + + 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); + } + } + + 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, + }) +} + +#[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); + for depth in 0..MAX_ANCESTORS { + let Some(pid) = current else { break }; + if observations.contains_key(&pid) { + break; + } + 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:")) + .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, + 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 { + 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() + .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 { + "codex-cli" => harness( + "codex-cli", + "Codex CLI", + Some("mcp-host"), + MetadataConfidence::Explicit, + "lease-source", + ), + _ => harness( + "unknown", + "Unknown", + None, + MetadataConfidence::Unknown, + "unavailable", + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + 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, "codex", &["resume"]), + ]; + 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"); + } + + #[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(); + } + 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_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(); + 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); + } + + #[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"); + } + + #[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); + } +} 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..deeb5ec --- /dev/null +++ b/tests/e2e_operator_dashboard.rs @@ -0,0 +1,366 @@ +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 direct_link_counts(topology: &Value) -> std::collections::BTreeMap { + topology["sessions"] + .as_array() + .unwrap() + .iter() + .map(|entry| { + ( + entry["session"]["id"].as_str().unwrap().to_string(), + entry["session"]["direct_link_count"].as_u64().unwrap(), + ) + }) + .collect() +} + +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 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, + "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 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) + .send() + .await + .unwrap() + .json() + .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["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()) + .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 topology_before_group: Value = client + .get(format!("{origin}/api/topology")) + .header("X-Wire-Token", &token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let direct_links_before_group = topology_before_group["direct_links"] + .as_array() + .unwrap() + .len(); + assert_eq!(direct_links_before_group, 1); + let direct_link_counts_before_group = direct_link_counts(&topology_before_group); + + 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() + ); + + let topology: Value = client + .get(format!("{origin}/api/topology")) + .header("X-Wire-Token", &token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + topology["direct_links"].as_array().unwrap().len(), + direct_links_before_group, + "group creation must not synthesize direct links: {topology}" + ); + assert_eq!( + direct_link_counts(&topology), + direct_link_counts_before_group, + "group creation must not inflate per-session direct link counts" + ); + 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!( + 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}" + ); +} diff --git a/tests/operator_dashboard_polling.test.mjs b/tests/operator_dashboard_polling.test.mjs new file mode 100644 index 0000000..0591fdb --- /dev/null +++ b/tests/operator_dashboard_polling.test.mjs @@ -0,0 +1,203 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +class ElementStub { + constructor(id = "") { + this.id = id; + this.children = []; + this.listeners = new Map(); + this.attributes = new Map(); + this.classList = { toggle() {} }; + this.dataset = {}; + this.hidden = false; + this.disabled = false; + this.checked = false; + this._textContent = ""; + this.style = {}; + } + + get textContent() { + return this._textContent + this.children.map((child) => child?.textContent || "").join(""); + } + + set textContent(value) { + this._textContent = String(value); + this.children = []; + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + dispatch(type, detail = {}) { + const event = { preventDefault() {}, target: this, ...detail }; + for (const listener of this.listeners.get(type) || []) listener(event); + } + + append(...children) { this.children.push(...children); } + replaceChildren(...children) { this._textContent = ""; this.children = children; } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + showModal() { this.open = true; } + close() { this.open = false; } + focus() {} + reset() {} + reportValidity() { return true; } +} + +const emptyTopology = () => ({ + schema: "wire-topology-v1", + generated_at: "2026-08-10T20:00:00Z", + machines: [], sessions: [], direct_links: [], groups: [], anomalies: [] +}); + +const sessionEntry = (id) => ({ + machine_id: "machine-a", + session: { + id, + did: `did:wire:${id}-00000001`, + handle: `${id}-handle`, + health: "healthy", + harness: { label: "Codex CLI" }, + project: { name: "Wire" }, + machine: { hostname: "alpha", os: "macos", arch: "aarch64" }, + identity: { class: "session-keyed", source: "wire-session-id" }, + direct_link_count: 0, + age_seconds: 3, + pid: 42, + emoji: "◆", + primary_hex: "#5b1a2e" + } +}); + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +const selectors = [ + "#session-rows", "#table-wrap", "#map-panel", "#list-panel", "#topology-map", "#map-inspector", + "#map-view-button", "#list-view-button", "#loading", "#empty", "#empty-title", "#empty-copy", + "#notice", "#live-count", "#last-scan", "#selection-count", "#action-hint", "#link-button", + "#group-button", "#confirm-dialog", "#confirm-copy", "#confirm-link", "#group-dialog", "#group-form", + "#group-name", "#group-creator", "#cancel-group", "#search-filter", "#machine-filter", "#harness-filter", + "#project-filter", "#health-filter", "#connected-filter" +]; + +const dashboard = ({ fetch }) => { + const intervals = []; + const elements = new Map(selectors.map((selector) => [selector, new ElementStub(selector.slice(1))])); + const created = []; + const document = new ElementStub("document"); + document.querySelector = (selector) => { + if (!elements.has(selector)) throw new Error(`Unexpected dashboard selector: ${selector}`); + return elements.get(selector); + }; + document.createElement = (tagName) => { + const element = new ElementStub(); + element.tagName = tagName.toUpperCase(); + created.push(element); + return element; + }; + document.createDocumentFragment = () => new ElementStub("fragment"); + + 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, fetch, window }; + vm.runInNewContext(readFileSync(new URL("../assets/operator-topology.js", import.meta.url), "utf8"), context); + vm.runInNewContext(readFileSync(new URL("../assets/operator-dashboard.js", import.meta.url), "utf8"), context); + return { created, document, elements, intervals }; +}; + +test("initial load fetches one topology snapshot and unfinished poll ticks coalesce", async () => { + let fetchCalls = 0; + let finishFetch; + const paths = []; + const page = dashboard({ + fetch: (path) => { + fetchCalls += 1; + paths.push(path); + return new Promise((resolve) => { + finishFetch = () => resolve({ ok: true, json: async () => emptyTopology() }); + }); + } + }); + + assert.equal(fetchCalls, 1, "initial page load starts one scan"); + assert.deepEqual(paths, ["/api/topology"]); + assert.equal(page.intervals.length, 1); + for (let index = 0; index < 4; index += 1) page.intervals[0](); + assert.equal(fetchCalls, 1, "poll ticks must coalesce behind the unfinished scan"); + + finishFetch(); + await flush(); + page.intervals[0](); + assert.equal(fetchCalls, 2, "polling must resume after the prior scan settles"); +}); + +test("failed refresh retains the last topology and reports a stale scan", async () => { + const responses = [ + { ok: true, json: async () => ({ ...emptyTopology(), sessions: [sessionEntry("amber")] }) }, + { ok: false, json: async () => ({}) } + ]; + const page = dashboard({ fetch: async () => responses.shift() }); + + await flush(); + assert.equal(page.elements.get("#live-count").textContent, "1"); + page.intervals[0](); + await flush(); + + assert.equal(page.elements.get("#live-count").textContent, "1", "failed refresh preserves the prior snapshot"); + assert.equal(page.elements.get("#notice").dataset.kind, "error"); + assert.match(page.elements.get("#notice").textContent, /stale|failed|could not/i); + assert.match(page.elements.get("#last-scan").textContent, /failed|stale/i); +}); + +test("successful refresh removes vanished session IDs from the shared selection", async () => { + const responses = [ + { ok: true, json: async () => ({ ...emptyTopology(), sessions: [sessionEntry("amber"), sessionEntry("bravo")] }) }, + { ok: true, json: async () => ({ ...emptyTopology(), sessions: [sessionEntry("bravo")] }) } + ]; + const page = dashboard({ fetch: async () => responses.shift() }); + + await flush(); + const amber = page.created.find((element) => element.attributes.get("aria-label") === "Select amber-handle"); + amber.checked = true; + amber.dispatch("change"); + assert.equal(page.elements.get("#selection-count").textContent, "1"); + + page.intervals[0](); + await flush(); + assert.equal(page.elements.get("#selection-count").textContent, "0"); +}); + +test("successful refresh clears a filter whose option vanished", async () => { + const machineA = { id: "machine-a", hostname: "alpha", os: "macos", arch: "aarch64" }; + const machineB = { id: "machine-b", hostname: "bravo", os: "linux", arch: "x86_64" }; + const amber = sessionEntry("amber"); + const bravo = { ...sessionEntry("bravo"), machine_id: "machine-b" }; + const responses = [ + { ok: true, json: async () => ({ ...emptyTopology(), machines: [machineA], sessions: [amber] }) }, + { ok: true, json: async () => ({ ...emptyTopology(), machines: [machineB], sessions: [bravo] }) } + ]; + const page = dashboard({ fetch: async () => responses.shift() }); + + await flush(); + const machineFilter = page.elements.get("#machine-filter"); + machineFilter.value = "machine-a"; + machineFilter.dispatch("change"); + page.intervals[0](); + await flush(); + + assert.equal(machineFilter.value, "", "the control returns to All when its option vanishes"); + assert.equal(page.elements.get("#empty").hidden, true, "the stale filter value no longer hides the new snapshot"); + assert.equal(page.elements.get("#map-inspector").textContent, "1 visible session · 0 direct links"); +}); diff --git a/tests/operator_dashboard_topology.test.mjs b/tests/operator_dashboard_topology.test.mjs new file mode 100644 index 0000000..6b06824 --- /dev/null +++ b/tests/operator_dashboard_topology.test.mjs @@ -0,0 +1,621 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +class ElementStub { + constructor(id = "") { + this.id = id; + this.children = []; + this.listeners = new Map(); + this.attributes = new Map(); + this.classList = { toggle() {} }; + this.dataset = {}; + this.hidden = false; + this.disabled = false; + this.checked = false; + this.value = ""; + this.ownerDocument = null; + this.resetCount = 0; + this._textContent = ""; + this.style = { + values: new Map(), + setProperty(name, value) { this.values.set(name, String(value)); }, + getPropertyValue(name) { return this.values.get(name) || ""; } + }; + this.clientWidth = 800; + this.clientHeight = 520; + } + + get textContent() { + return this._textContent + this.children.map((child) => child && child.textContent || "").join(""); + } + + set textContent(value) { + this._textContent = String(value); + this.children = []; + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + dispatch(type, detail = {}) { + const event = { + defaultPrevented: false, + preventDefault() { this.defaultPrevented = true; }, + target: this, + type, + ...detail + }; + for (const listener of this.listeners.get(type) || []) listener(event); + return event; + } + + dispatchEvent(event) { return !this.dispatch(event.type, event).defaultPrevented; } + + append(...children) { this.children.push(...children); } + replaceChildren(...children) { this._textContent = ""; this.children = children; } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + showModal() { this.open = true; } + close() { this.open = false; } + focus() { + this.focused = true; + if (this.ownerDocument) this.ownerDocument.activeElement = this; + } + getBoundingClientRect() { return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight }; } + setPointerCapture() {} + releasePointerCapture() {} + reset() { this.resetCount += 1; } + reportValidity() { return true; } +} + +const entry = (id, { machineId = "machine-a", harness = "Codex CLI", project = "Wire", health = "healthy" } = {}) => ({ + machine_id: machineId, + session: { + id, + did: `did:wire:${id}-00000001`, + handle: `${id}-handle`, + health, + harness: { label: harness }, + project: { name: project, branch: "main" }, + machine: { hostname: machineId, os: "macos", arch: "aarch64" }, + identity: { class: "session-keyed", source: "wire-session-id" }, + direct_link_count: 0, + age_seconds: 3, + pid: 42, + emoji: "◆", + primary_hex: "#5b1a2e" + } +}); + +const snapshot = () => ({ + schema: "wire-topology-v1", + generated_at: "2026-08-10T20:00:00Z", + machines: [ + { id: "machine-a", hostname: "alpha", os: "macos", arch: "aarch64", identity_confidence: "verified" }, + { id: "machine-b", hostname: "bravo", os: "linux", arch: "x86_64", identity_confidence: "unverified" } + ], + sessions: [ + entry("amber"), + entry("bravo", { machineId: "machine-b", harness: "Claude Code", project: "Studio", health: "sync-stale" }), + entry("cedar", { machineId: "machine-b", harness: "Goose Shell", project: "Wire" }), + entry("delta") + ], + direct_links: [ + { + id: "amber-bravo", + source_did: "did:wire:amber-00000001", + target_did: "did:wire:bravo-00000001", + state: "bilateral" + }, + { + id: "cedar-delta", + source_did: "did:wire:cedar-00000001", + target_did: "did:wire:delta-00000001", + state: "one-sided" + } + ], + groups: [{ + id: "crew", + name: "Crew", + members: ["amber", "bravo", "cedar", "delta"].map((id) => ({ + did: `did:wire:${id}-00000001`, live: true, tier: id === "amber" ? "creator" : "member" + })) + }], + anomalies: [] +}); + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +const selectors = [ + "#session-rows", "#table-wrap", "#map-panel", "#list-panel", "#topology-map", "#map-inspector", "#fit-map", + "#map-view-button", "#list-view-button", "#loading", "#empty", "#empty-title", "#empty-copy", + "#notice", "#live-count", "#last-scan", "#selection-count", "#action-hint", "#link-button", + "#group-button", "#confirm-dialog", "#confirm-copy", "#confirm-link", "#group-dialog", "#group-form", + "#group-name", "#group-creator", "#cancel-group", "#search-filter", "#machine-filter", "#harness-filter", + "#project-filter", "#health-filter", "#connected-filter" +]; + +const dashboard = async ({ token = "test-token", fetchImpl, DateImpl = Date } = {}) => { + const elements = new Map(selectors.map((selector) => [selector, new ElementStub(selector.slice(1))])); + const created = []; + const requests = []; + const intervals = []; + const document = new ElementStub("document"); + document.activeElement = null; + for (const element of elements.values()) element.ownerDocument = document; + document.querySelector = (selector) => { + if (!elements.has(selector)) throw new Error(`Unexpected dashboard selector: ${selector}`); + return elements.get(selector); + }; + document.createElement = (tagName) => { + const element = new ElementStub(); + element.tagName = tagName.toUpperCase(); + element.ownerDocument = document; + created.push(element); + return element; + }; + document.createElementNS = (namespaceURI, tagName) => { + const element = document.createElement(tagName); + element.namespaceURI = namespaceURI; + return element; + }; + document.createDocumentFragment = () => new ElementStub("fragment"); + document.getElementById = (id) => elements.get(`#${id}`) || null; + + const storage = new Map(); + const window = { + location: { search: token ? `?token=${token}` : "", pathname: "/" }, + history: { replaceState() {} }, + sessionStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value) + }, + setInterval: (callback) => intervals.push(callback) + }; + class CustomEvent { + constructor(type, options = {}) { + this.type = type; + this.detail = options.detail; + } + } + const context = { + CustomEvent, + Date: DateImpl, + URLSearchParams, + console, + document, + fetch: async (path, options) => { + requests.push({ path, options }); + return fetchImpl + ? fetchImpl(path, options) + : { ok: true, json: async () => snapshot() }; + }, + window + }; + vm.runInNewContext(readFileSync(new URL("../assets/operator-topology.js", import.meta.url), "utf8"), context); + vm.runInNewContext(readFileSync(new URL("../assets/operator-dashboard.js", import.meta.url), "utf8"), context); + await flush(); + return { created, document, elements, intervals, requests }; +}; + +const descendants = (element) => element.children.flatMap((child) => [child, ...descendants(child)]); +const classNames = (element) => (element.attributes.get("class") || "").split(/\s+/).filter(Boolean); +const withClass = (element, className) => descendants(element).filter((candidate) => classNames(candidate).includes(className)); +const mapNode = (page, id) => withClass(page.elements.get("#topology-map"), "topology-node") + .find((node) => node.dataset.sessionId === id); + +const renderedRows = (page) => { + const fragment = page.elements.get("#session-rows").children[0]; + return fragment ? fragment.children.filter((element) => element.dataset.sessionId) : []; +}; + +const checkboxFor = (page, handle) => renderedRows(page) + .map((row) => row.children[0].children[0]) + .find((element) => element.attributes.get("aria-label") === `Select ${handle}`); + +const inspectFor = (page, id) => descendants( + renderedRows(page).find((row) => row.dataset.sessionId === id) +).find((element) => element.className === "details-button"); + +test("map selection survives List and Map view changes", async () => { + const page = await dashboard(); + const map = page.elements.get("#topology-map"); + const mapButton = page.elements.get("#map-view-button"); + const listButton = page.elements.get("#list-view-button"); + + map.dispatch("wire:toggle-selection", { detail: { id: "amber" } }); + listButton.dispatch("click"); + assert.equal(checkboxFor(page, "amber-handle").checked, true); + assert.equal(page.elements.get("#map-panel").hidden, true); + assert.equal(page.elements.get("#list-panel").hidden, false); + mapButton.dispatch("click"); + + assert.equal(page.elements.get("#selection-count").textContent, "1"); + assert.equal(page.elements.get("#map-panel").hidden, false); + assert.equal(page.elements.get("#list-panel").hidden, true); + assert.equal(mapButton.attributes.get("aria-pressed"), "true"); + assert.equal(listButton.attributes.get("aria-pressed"), "false"); +}); + +test("map renders labeled machines, group fragments, direct edges, and accessible session nodes in paint order", async () => { + const page = await dashboard(); + const map = page.elements.get("#topology-map"); + const svg = map.children[0]; + const viewport = svg.children[0]; + + assert.equal(svg.tagName, "SVG"); + assert.equal(svg.attributes.get("role"), "group", "the SVG root must preserve descendant button semantics"); + assert.deepEqual(viewport.children.map((layer) => layer.dataset.layer), ["machines", "groups", "edges", "nodes"]); + assert.equal(withClass(viewport.children[0], "topology-machine").length, 2); + assert.match(withClass(viewport.children[0], "topology-machine")[0].attributes.get("aria-label"), /alpha.*verified/i); + assert.match(withClass(viewport.children[0], "topology-machine")[1].attributes.get("aria-label"), /bravo.*unverified/i); + + const groupFragments = withClass(viewport.children[1], "topology-group"); + assert.equal(groupFragments.length, 2, "a cross-machine group paints one fragment in each machine"); + assert.ok(groupFragments.every((fragment) => /Crew/.test(fragment.textContent))); + + const edges = withClass(viewport.children[2], "topology-edge"); + assert.equal(edges.length, 2, "group membership must not synthesize direct edges"); + assert.ok(classNames(edges[0]).includes("topology-edge--bilateral")); + assert.ok(classNames(edges[1]).includes("topology-edge--one-sided")); + + const nodes = withClass(viewport.children[3], "topology-node"); + assert.equal(nodes.length, 4); + for (const node of nodes) { + assert.equal(node.attributes.get("role"), "button"); + assert.equal(node.attributes.get("tabindex"), "0"); + assert.equal(node.attributes.get("aria-pressed"), "false"); + assert.equal(withClass(node, "topology-health-ring").length, 1); + } + assert.match(mapNode(page, "amber").textContent, /◆.*amber-handle.*Codex CLI/); +}); + +test("click, Enter, and Space emit shared map selection and keep the node keyboard reachable", async () => { + const page = await dashboard(); + + mapNode(page, "amber").dispatch("click"); + assert.equal(page.elements.get("#selection-count").textContent, "1"); + assert.equal(mapNode(page, "amber").attributes.get("aria-pressed"), "true"); + assert.match(page.elements.get("#map-inspector").textContent, /amber-handle.*Codex CLI.*machine-a/i); + + const enter = mapNode(page, "bravo").dispatch("keydown", { key: "Enter" }); + assert.equal(enter.defaultPrevented, true); + assert.equal(page.elements.get("#selection-count").textContent, "2"); + assert.equal(mapNode(page, "bravo").focused, true); + + const space = mapNode(page, "bravo").dispatch("keydown", { key: " " }); + assert.equal(space.defaultPrevented, true); + assert.equal(page.elements.get("#selection-count").textContent, "1"); + assert.equal(mapNode(page, "bravo").attributes.get("aria-pressed"), "false"); +}); + +test("Fit map restores the helper-derived viewport after zoom and pan changes", async () => { + const page = await dashboard(); + const map = page.elements.get("#topology-map"); + const fit = page.elements.get("#fit-map"); + const transform = () => map.children[0].children[0].attributes.get("transform"); + const fitted = transform(); + + map.dispatch("wheel", { deltaY: -120, clientX: 400, clientY: 260 }); + map.dispatch("pointerdown", { pointerId: 7, clientX: 100, clientY: 100 }); + map.dispatch("pointermove", { pointerId: 7, clientX: 160, clientY: 140 }); + map.dispatch("pointerup", { pointerId: 7 }); + assert.notEqual(transform(), fitted); + + fit.dispatch("click"); + assert.equal(transform(), fitted); +}); + +test("Fit map can use a helper scale below the interactive wheel minimum for crowded topology", async () => { + const crowded = snapshot(); + crowded.machines = crowded.machines.slice(0, 1); + crowded.sessions = Array.from({ length: 40 }, (_, index) => entry(`session-${String(index).padStart(2, "0")}`)); + crowded.direct_links = []; + crowded.groups = []; + const page = await dashboard({ fetchImpl: async () => ({ ok: true, json: async () => crowded }) }); + const transform = page.elements.get("#topology-map").children[0].children[0].attributes.get("transform"); + const scale = Number(transform.match(/scale\(([^)]+)\)/)[1]); + + assert.ok(scale < 0.35, `expected Fit scale below wheel floor, got ${scale}`); + assert.ok(scale > 0); +}); + +test("wheel zoom stays within the fitted lower bound and interactive upper bound", async () => { + const crowded = snapshot(); + crowded.machines = crowded.machines.slice(0, 1); + crowded.sessions = Array.from({ length: 40 }, (_, index) => entry(`session-${String(index).padStart(2, "0")}`)); + crowded.direct_links = []; + crowded.groups = []; + const page = await dashboard({ fetchImpl: async () => ({ ok: true, json: async () => crowded }) }); + const map = page.elements.get("#topology-map"); + const scale = () => Number(map.children[0].children[0].attributes.get("transform").match(/scale\(([^)]+)\)/)[1]); + const fitted = scale(); + assert.ok(fitted < 0.35); + + map.dispatch("wheel", { deltaY: 100_000, clientX: 400, clientY: 260 }); + assert.ok(scale() <= fitted, `zooming out from ${fitted} must not jump in to ${scale()}`); + + map.dispatch("wheel", { deltaY: -100_000, clientX: 400, clientY: 260 }); + assert.equal(scale(), 2.5); +}); + +test("selection count enables Link for exactly two and Create group for two or more", async () => { + const page = await dashboard(); + const map = page.elements.get("#topology-map"); + const link = page.elements.get("#link-button"); + const group = page.elements.get("#group-button"); + + map.dispatch("wire:toggle-selection", { detail: { id: "amber" } }); + assert.equal(link.disabled, true); + assert.equal(group.disabled, true); + map.dispatch("wire:toggle-selection", { detail: { id: "bravo" } }); + assert.equal(link.disabled, false); + assert.equal(group.disabled, false); + map.dispatch("wire:toggle-selection", { detail: { id: "cedar" } }); + assert.equal(link.disabled, true); + assert.equal(group.disabled, false); +}); + +test("Escape clears the shared selection", async () => { + const page = await dashboard(); + page.elements.get("#topology-map").dispatch("wire:toggle-selection", { detail: { id: "amber" } }); + page.document.dispatch("keydown", { key: "Escape" }); + + assert.equal(page.elements.get("#selection-count").textContent, "0"); + assert.equal(checkboxFor(page, "amber-handle").checked, false); +}); + +test("filter changes render List and Map from the same visible topology", async () => { + const page = await dashboard(); + const search = page.elements.get("#search-filter"); + search.value = "amber"; + search.dispatch("input"); + + const visibleRows = renderedRows(page); + assert.deepEqual(visibleRows.map((row) => row.dataset.sessionId), ["amber"]); + assert.match(page.elements.get("#map-inspector").textContent, /1 visible session/i); + + search.value = "missing"; + search.dispatch("input"); + assert.match(page.elements.get("#empty-title").textContent, /filters/i); + assert.equal(page.elements.get("#empty").hidden, false); +}); + +test("successful scan repopulates filter options from the unfiltered snapshot", async () => { + const page = await dashboard(); + const values = (selector) => page.elements.get(selector).children.map((option) => option.value); + + assert.deepEqual(values("#machine-filter"), ["", "machine-a", "machine-b"]); + assert.deepEqual(values("#harness-filter"), ["", "Claude Code", "Codex CLI", "Goose Shell"]); + assert.deepEqual(values("#project-filter"), ["", "Studio", "Wire"]); + assert.deepEqual(values("#health-filter"), ["", "healthy", "sync-stale"]); + assert.equal(page.requests[0].path, "/api/topology"); + assert.equal(page.requests[0].options.headers["X-Wire-Token"], "test-token"); +}); + +test("successful inventory refresh does not hide the missing launch-token warning", async () => { + const page = await dashboard({ token: "" }); + + assert.equal(page.elements.get("#notice").dataset.kind, "error"); + assert.match(page.elements.get("#notice").textContent, /launch token missing/i); + assert.equal(page.elements.get("#link-button").disabled, true); + assert.equal(page.elements.get("#group-button").disabled, true); +}); + +test("interaction after a failed refresh preserves the stale scan timestamp", async () => { + const responses = [ + { ok: true, json: async () => snapshot() }, + { ok: false, json: async () => ({}) }, + { ok: true, json: async () => snapshot() } + ]; + const page = await dashboard({ fetchImpl: async () => responses.shift() }); + + assert.equal(page.intervals.length, 1); + page.intervals[0](); + await flush(); + const failedLabel = page.elements.get("#last-scan").textContent; + assert.match(failedLabel, /^Refresh failed .* · showing stale data$/); + + const search = page.elements.get("#search-filter"); + search.value = "amber"; + search.dispatch("input"); + + assert.equal(page.elements.get("#last-scan").textContent, failedLabel); + + page.intervals[0](); + await flush(); + const recoveredLabel = page.elements.get("#last-scan").textContent; + assert.match(recoveredLabel, /^Scan /); + assert.notEqual(recoveredLabel, failedLabel); + assert.equal(responses.length, 0); +}); + +test("successful scan timestamp remains stable across selection and filter renders", async () => { + let tick = 0; + class TickDate { + toLocaleTimeString() { return `time-${++tick}`; } + } + const page = await dashboard({ DateImpl: TickDate }); + const initial = page.elements.get("#last-scan").textContent; + + page.elements.get("#topology-map").dispatch("wire:toggle-selection", { detail: { id: "amber" } }); + assert.equal(page.elements.get("#last-scan").textContent, initial); + + const search = page.elements.get("#search-filter"); + search.value = "amber"; + search.dispatch("input"); + assert.equal(page.elements.get("#last-scan").textContent, initial); + assert.equal(tick, 1, "only the successful scan records a time"); +}); + +test("successful polls restore focus to stable map, checkbox, and Inspect controls", async () => { + const page = await dashboard(); + + const oldMapNode = mapNode(page, "amber"); + oldMapNode.focus(); + page.intervals[0](); + await flush(); + assert.notEqual(mapNode(page, "amber"), oldMapNode); + assert.equal(page.document.activeElement, mapNode(page, "amber")); + + page.elements.get("#list-view-button").dispatch("click"); + const oldCheckbox = checkboxFor(page, "amber-handle"); + oldCheckbox.focus(); + page.intervals[0](); + await flush(); + assert.notEqual(checkboxFor(page, "amber-handle"), oldCheckbox); + assert.equal(page.document.activeElement, checkboxFor(page, "amber-handle")); + + const oldInspect = inspectFor(page, "amber"); + oldInspect.focus(); + page.intervals[0](); + await flush(); + assert.notEqual(inspectFor(page, "amber"), oldInspect); + assert.equal(page.document.activeElement, inspectFor(page, "amber")); +}); + +test("group Cancel closes and resets the dialog without a POST", async () => { + const page = await dashboard(); + const map = page.elements.get("#topology-map"); + map.dispatch("wire:toggle-selection", { detail: { id: "amber" } }); + map.dispatch("wire:toggle-selection", { detail: { id: "bravo" } }); + page.elements.get("#group-button").dispatch("click"); + page.elements.get("#group-name").value = "crew"; + page.elements.get("#group-creator").value = "amber"; + + page.elements.get("#cancel-group").dispatch("click"); + + assert.equal(page.elements.get("#group-dialog").open, false); + assert.equal(page.elements.get("#group-form").resetCount, 1); + assert.equal(page.requests.filter((request) => request.options?.method === "POST").length, 0); +}); + +test("group submit aborts with zero POST when a successful poll changes confirmed members", async () => { + let topologyFetches = 0; + let finishPoll; + const page = await dashboard({ + fetchImpl: async (path) => { + if (path === "/api/groups") { + return { ok: true, json: async () => ({ ok: true, message: "created", changed_sessions: [] }) }; + } + topologyFetches += 1; + if (topologyFetches === 1) return { ok: true, json: async () => snapshot() }; + return new Promise((resolve) => { + const changed = snapshot(); + changed.sessions = changed.sessions.filter((item) => item.session.id !== "bravo"); + finishPoll = () => resolve({ ok: true, json: async () => changed }); + }); + } + }); + const map = page.elements.get("#topology-map"); + map.dispatch("wire:toggle-selection", { detail: { id: "amber" } }); + map.dispatch("wire:toggle-selection", { detail: { id: "bravo" } }); + page.elements.get("#group-button").dispatch("click"); + page.elements.get("#group-name").value = "crew"; + page.elements.get("#group-creator").value = "amber"; + + page.intervals[0](); + finishPoll(); + await flush(); + page.elements.get("#group-form").dispatch("submit"); + + assert.equal(page.requests.filter((request) => request.path === "/api/groups").length, 0); + assert.match(page.elements.get("#notice").textContent, /changed|no longer live|select again/i); +}); + +test("mutation waits for a pre-action poll and starts a fresh post-action scan", async () => { + let topologyFetches = 0; + let finishPoll; + const page = await dashboard({ + fetchImpl: async (path) => { + if (path === "/api/links") { + return { ok: true, json: async () => ({ ok: true, message: "linked", changed_sessions: ["amber", "bravo"] }) }; + } + topologyFetches += 1; + if (topologyFetches === 2) { + return new Promise((resolve) => { + finishPoll = () => resolve({ ok: true, json: async () => snapshot() }); + }); + } + return { ok: true, json: async () => snapshot() }; + } + }); + const map = page.elements.get("#topology-map"); + map.dispatch("wire:toggle-selection", { detail: { id: "amber" } }); + map.dispatch("wire:toggle-selection", { detail: { id: "bravo" } }); + page.intervals[0](); + page.elements.get("#link-button").dispatch("click"); + page.elements.get("#confirm-link").dispatch("click"); + + assert.equal(page.requests.filter((request) => request.path === "/api/links").length, 0); + finishPoll(); + await flush(); + await flush(); + + assert.deepEqual( + page.requests.map((request) => request.path), + ["/api/topology", "/api/topology", "/api/links", "/api/topology"] + ); +}); + +test("link conflicts trigger a fresh topology scan", async () => { + const page = await dashboard({ + fetchImpl: async (path) => path === "/api/links" + ? { ok: false, json: async () => ({ error: "session vanished", changed_sessions: [] }) } + : { ok: true, json: async () => snapshot() } + }); + const map = page.elements.get("#topology-map"); + map.dispatch("wire:toggle-selection", { detail: { id: "amber" } }); + map.dispatch("wire:toggle-selection", { detail: { id: "bravo" } }); + page.elements.get("#link-button").dispatch("click"); + page.elements.get("#confirm-link").dispatch("click"); + await flush(); + await flush(); + + assert.equal(page.requests.filter((request) => request.path === "/api/topology").length, 2); + assert.match(page.elements.get("#notice").textContent, /session vanished/i); +}); + +test("group partial failures refresh and expose changed sessions as text", async () => { + const changed = "bravo"; + const page = await dashboard({ + fetchImpl: async (path) => path === "/api/groups" + ? { ok: false, json: async () => ({ error: "join failed", changed_sessions: ["amber", changed] }) } + : { ok: true, json: async () => snapshot() } + }); + const map = page.elements.get("#topology-map"); + map.dispatch("wire:toggle-selection", { detail: { id: "amber" } }); + map.dispatch("wire:toggle-selection", { detail: { id: "bravo" } }); + page.elements.get("#group-button").dispatch("click"); + page.elements.get("#group-name").value = "crew"; + page.elements.get("#group-creator").value = "amber"; + page.elements.get("#group-creator").dispatch("change"); + page.elements.get("#group-form").dispatch("submit"); + await flush(); + await flush(); + + assert.equal(page.requests.filter((request) => request.path === "/api/topology").length, 2); + assert.match(page.elements.get("#notice").textContent, /amber.*bravo<\/b>/); + assert.equal(page.created.some((element) => element.tagName === "B"), false); +}); + +test("safe topology anomalies retain the conflicting group subject ID in the inspector", async () => { + const subject = "crew-"; + const topology = snapshot(); + topology.anomalies = [{ + kind: "conflicting-group", + subject_id: subject, + message: "Live sessions disagree about the highest group roster" + }]; + const page = await dashboard({ fetchImpl: async () => ({ ok: true, json: async () => topology }) }); + + assert.match(page.elements.get("#map-inspector").textContent, /Topology anomalies/); + assert.match(page.elements.get("#map-inspector").textContent, /crew-/); + page.elements.get("#topology-map").dispatch("wire:toggle-selection", { detail: { id: "amber" } }); + assert.match(page.elements.get("#map-inspector").textContent, /crew-/); + assert.equal(page.created.some((element) => element.tagName === "IMG"), false); +}); diff --git a/tests/operator_topology_model.test.mjs b/tests/operator_topology_model.test.mjs new file mode 100644 index 0000000..78f134d --- /dev/null +++ b/tests/operator_topology_model.test.mjs @@ -0,0 +1,198 @@ +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("layoutTopology emits one group fragment per machine for members split across machines", () => { + const WireTopology = topology(); + const snapshot = fixture(); + snapshot.groups[0].members = [ + { did: "did:wire:amber-00000001", tier: "creator", live: true }, + { did: "did:wire:bravo-00000002", tier: "member", live: true } + ]; + const visible = WireTopology.visibleTopology(snapshot, {}); + const layout = WireTopology.layoutTopology(visible, { width: 800, height: 600 }); + + assert.equal(visible.groups.length, 1, "the two-member group remains visible"); + assert.deepEqual(Array.from(layout.groupRegions, (region) => ({ + machineId: region.machineId, + memberDids: Array.from(region.memberDids), + color: region.color + })), [ + { machineId: "machine-a", memberDids: ["did:wire:amber-00000001"], color: WireTopology.groupColor("crew") }, + { machineId: "machine-b", memberDids: ["did:wire:bravo-00000002"], color: WireTopology.groupColor("crew") } + ]); +}); + +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)); +});