diff --git a/.github/workflows/no-private-leak.yml b/.github/workflows/no-private-leak.yml new file mode 100644 index 0000000..aed5ae7 --- /dev/null +++ b/.github/workflows/no-private-leak.yml @@ -0,0 +1,27 @@ +name: no-private-leak + +# Hard gate: the public repo must never track private strategy material. +# This is the backstop for the one footgun that bit us before — a `git add -f` +# slipping a private/ file past .gitignore. Runs on every push and PR. + +on: + push: + pull_request: + +jobs: + guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fail if any private path is tracked + run: | + set -euo pipefail + # Paths that must never be committed to the public repo. + forbidden='^(private/|site/|docs/ANTIOCH_PARITY\.md$|docs/DEMO_SPEC\.md$|docs/RL_HARNESS_SPEC\.md$)' + hits="$(git ls-files | grep -E "$forbidden" || true)" + if [ -n "$hits" ]; then + echo "::error::Private material is tracked in the public repo:" + echo "$hits" + exit 1 + fi + echo "OK — no private paths tracked." diff --git a/.gitignore b/.gitignore index 5b388cf..1f0ac0c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,17 @@ private/ *.pem *.key +# Strategy / planning docs — live in the private website repo only, never public +/docs/ANTIOCH_PARITY.md +/docs/DEMO_SPEC.md +/docs/RL_HARNESS_SPEC.md + +# Local agent / dev tooling — belongs in neither published repo +.agents/ +.claude/ +.goalkeeper/ +skills-lock.json + # Logs *.log *.tmp diff --git a/CHANGELOG.md b/CHANGELOG.md index b147c47..de35ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +### Added +- **FLEET sandbox** (`/fleet`) — a multi-quadruped teaching arena reachable from the right of the robot picker. Configure the radio (range, link reliability), the airtime each robot gets, and its onboard memory + inbox depth, then watch a fleet split a field under four coordination strategies (lone wolves, gossip, claim-and-yield, one commander). Live links, in-flight messages, dropped packets, coverage and wasted re-walks all rendered. +- Fleet **hover-to-inspect**: mouse over any robot to highlight what *it* knows — tiles it sensed firsthand (filled) vs. only heard from peers (outlined) — with a live knowledge/inbox/data card. +- Fleet **base station + data points**: discover data and relay it home with greedy geographic routing (multi-hop / data-mule), plus selectable **environments** (open / obstacles / building with line-of-sight-blocking walls). +- Fleet **CONCEPTS panel** (expandable): in-UI explanations of delivered vs. dropped, overlapping searches, optimisation, "is this libp2p?", swarm intelligence/stigmergy, and the base-station sink — plus hover tooltips on every metric. +- Fleet **bring-your-own algorithm**: a "✨ Your algorithm" strategy with a live JS editor, and a **Generate** button that asks the local runtime's LLM (`POST /api/fleet/strategy`) to draft a coordination policy from a plain-language goal — runnable on the spot against the same radio limits. +- **`roborun/swarm/`** — the comms model, four strategies, base-station relay and data points as runnable Python (`python -m roborun.swarm`), the headless twin of the sandbox; ships with the package. +- ROS card **network scan**: an "Allow network scan to load robots" button that trips the browser's local-network permission and lists every rosbridge robot found as its own one-click view. + +### Changed +- ROS-connected robots land in the **exact same multi-panel deck** as the rapier.js sim — the live camera (EYES) now docks into the deck layout where the sim shows its POV, instead of floating. +- The FLEET picker card is now a single **Open Fleet Lab** button; every knob lives inside the sandbox. +- Replaced the last native `confirm()` (deploy-to-robot) with the in-app styled modal, usable from the deck as well as the cockpit. + +### Deploy +- `vercel.json` with `cleanUrls` so `/fleet` resolves on the static Vercel build, matching the local server's route. + ## v0.9.2 — 2026-06-09 ### Fixed diff --git a/README.md b/README.md index 1cfff8e..1de0173 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

RoboRun: Write a Robot Behavior Once, Run It on Any ROS 1/2 Robot

-

The base layer for coding robots: see / move / ask primitives, hot-reload Python behaviors,
the same file from webcam + MuJoCo to real hardware. MCP-native for AI agents, every run flight-recorded.

+

The base layer for coding robots: see / move / ask primitives, hot-reload Python behaviors,
the same file from webcam + MuJoCo to real hardware. MCP-native for AI agents — and every run is flight-recorded, sealed, and searchable, so you can find anything your robots ever saw, across all of time.

PyPI @@ -19,10 +19,19 @@ ```bash pip install ros-agent # the package keeps its PyPI name; the command is roborun -roborun +roborun # serves the UI at http://localhost:8765 (prints the URL) ``` -The browser opens live, and a `behaviors/` folder appears with the robot's brain. Open **`/arena`** — a robot dog in a browser sim, body and eyes in the same world (what it does changes what it sees). Nothing else to install; the base package is three small dependencies, no torch. The robot's brain: +Open the printed URL and a `behaviors/` folder appears with the robot's brain. Open **`/arena`** — a robot dog in a browser sim, body and eyes in the same world (what it does changes what it sees). Nothing else to install; the base package is three small dependencies, no torch. + +Prefer the terminal? It runs fully without the web UI: + +```bash +roborun run # headless: drives behaviors, streams the see/move/ask loop to stdout +roborun tui # full-screen terminal dashboard (pip install 'ros-agent[tui]') +``` + +The robot's brain: ```python # behaviors/follow_person.py (already running) @@ -55,6 +64,7 @@ Want real eyes instead of the sim? `pip install 'ros-agent[vision]'` (YOLO + CLI | `robot.delegate("fix my search pattern")` | async LLM **with tools** — it can call any MCP tool, including rewriting the running policy (hot reload applies it live) | | `robot.tool("navigate", x=2, y=3)` | call any MCP tool from inside the policy | | `robot.lidar()` | 360° ranges in meters, `[0]` = straight ahead | +| `robot.go_to_place("the charging dock")` | semantic navigation — recall where it last saw something, then drive there | | `robot.remember(k, v)` / `robot.recall(k)` | memory that survives restarts | | `robot.state` | dict that survives across loop ticks | @@ -101,6 +111,33 @@ What this proves: the recorded run — images, detections, and decisions include The UI at `http://localhost:8765` is the flight deck itself: live camera with YOLO boxes, the black box streaming, the live anchor badge, a command bar, and director keys. `M` record/seal · `V` verify · `T` tamper · `R` runs/replay · `C` sources. +## Track and search everything over time + +Every run — sim, real robot, or webcam — flows through one loop: **YOLO + CLIP → sealed MCAP → a live index you can search across all of history.** So "where did I last see the forklift", "who was in the lobby yesterday", "every red mug across the fleet" are one query — semantic (CLIP), label (YOLO), place, or time window — over every run and robot. + +The cockpit's **▤ VIEWS** menu opens the dashboards (also `roborun demo` to populate them instantly): + +- **/search** — find anything/anyone over time; export the hits as a labeled dataset (sealed provenance). +- **/scenarios** — give a behavior a task, run it, and see if it passed; group runs into suites with a pass-rate, and re-run after every change. +- **/run** — per-run trajectory · velocity · clearance · LiDAR, with **synced playback** (scrub a moment → the frame the robot saw) and **⚑ Flag** to bookmark incidents to revisit. +- **/analytics** — detections over time, suite pass-rates, per-robot fleet activity. +- **/timeline** — the live event stream + recent sightings. + +From the local runner, no browser needed: + +```bash +roborun demo # load sample data so the dashboards aren't empty +roborun ask "patrol the lobby" # tell the robot what to do in plain English +roborun search "person" # across every recorded run, all-time +roborun scenarios run mjx_reach # score a scenario (vectorized MuJoCo, sealed) +roborun dataset "forklift" ./ds # curate a labeled training set from a search +roborun status # is it running, what's connected, how much recorded +``` + +(`roborun help` lists every verb.) + +The robot handle gets it too: `robot.go_to_place("the charging dock")` navigates to where it last saw something (semantic memory), and the same `recall_place` is an MCP tool any agent can call. + ## Connect a real robot ```bash @@ -109,7 +146,7 @@ roborun connect 192.168.1.42 --move # proves it: clamped 0.5s nudge, then stop roborun connect --scan # DDS discovery — nothing to install on the robot ``` -If rosbridge isn't running on the robot yet, the command prints the exact two lines to run there — that's the whole setup. **No ROS install on your machine.** Once connected, plain `roborun` drives that robot and the same `behaviors/*.py` files now move real hardware: Unitree Go2/G1, TurtleBot, arms, drones, NVIDIA Isaac Sim, Gazebo. `robot.move()` goes to the sim if it's running, otherwise to the connected robot, always through the same safety clamps. +If rosbridge isn't running on the robot yet, the command prints the exact two lines to run there — that's the whole setup. **No ROS install on your machine, and it works with both ROS 1 and ROS 2** — rosbridge speaks both, and RoboRun detects which (the DDS path is ROS 2-only). Once connected, plain `roborun` drives that robot and the same `behaviors/*.py` files now move real hardware: Unitree Go2/G1, TurtleBot, arms, drones, NVIDIA Isaac Sim, Gazebo. `robot.move()` goes to the sim if it's running, otherwise to the connected robot, always through the same safety clamps. Optional extras: `pip install ros-agent[vision]` (YOLO + CLIP), `[sim]` (MuJoCo), `[ros]` (direct DDS), `[crypto]` (Ed25519 signing), `[anchor]` (RFC 3161 timestamping), `[fleet]` (R2 + DuckDB cross-robot), `[all]`. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..b8d8ea1 --- /dev/null +++ b/app/index.html @@ -0,0 +1,14 @@ + + + + + + RoboRun Studio + + + + +

+ + + diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..be2fd79 --- /dev/null +++ b/app/package.json @@ -0,0 +1,25 @@ +{ + "name": "roborun-studio", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0", + "uplot": "^1.6.31", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/app/src/App.tsx b/app/src/App.tsx new file mode 100644 index 0000000..cb0d058 --- /dev/null +++ b/app/src/App.tsx @@ -0,0 +1,44 @@ +import { useEffect } from "react"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { AppShell } from "./shell/AppShell"; +import { useStudio } from "./store"; +import { Home } from "./routes/Home"; +import { Live } from "./routes/Live"; +import { Runs } from "./routes/Runs"; +import { Sims } from "./routes/Sims"; +import { Search } from "./routes/Search"; +import { Scenarios } from "./routes/Scenarios"; +import { Analytics } from "./routes/Analytics"; +import { Agent } from "./routes/Agent"; +import { Hosted } from "./routes/Hosted"; + +// One throttled loop drives the playhead. 10Hz is plenty — the camera polls at +// 5Hz and events are coarse — and it avoids 60fps whole-app re-renders. +function usePlayhead() { + const tick = useStudio((s) => s.tick); + useEffect(() => { + const id = setInterval(tick, 100); + return () => clearInterval(id); + }, [tick]); +} + +export function App() { + usePlayhead(); + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/app/src/main.tsx b/app/src/main.tsx new file mode 100644 index 0000000..752b3d7 --- /dev/null +++ b/app/src/main.tsx @@ -0,0 +1,18 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +// Dashboards use the system monospace stack (RoboRun DS) — no webfont. +import "./theme.css"; +import { App } from "./App"; +import { resolveBase, installFetchBridge } from "./runtime"; + +// Find the backend (same-origin local server, or a localhost roborun reached +// from the hosted site), THEN install the /api+/mcp fetch shim and mount. The +// probe is quick and fails fast when nothing's listening. +resolveBase().finally(() => { + installFetchBridge(); + createRoot(document.getElementById("root")!).render( + + + + ); +}); diff --git a/app/src/panels/CameraPanel.tsx b/app/src/panels/CameraPanel.tsx new file mode 100644 index 0000000..bd1c89e --- /dev/null +++ b/app/src/panels/CameraPanel.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { useStudio } from "../store"; +import { Panel } from "./Panel"; + +// Source-agnostic: live shows the latest frame (polled ~5Hz via the playhead), +// replay shows the frame nearest the scrubbed t. Same component, same . +export function CameraPanel() { + const { source, t } = useStudio(); + const [ok, setOk] = useState(true); + // quantize to 5Hz so live polling and replay scrubbing both stay sane + const qt = Math.floor(t * 5) / 5; + const url = source.frameURL(qt); + return ( + +
+ camera feed setOk(true)} + onError={() => setOk(false)} + /> + {!ok && ( +
+ {source.kind === "live" ? ( +
+
○ no camera
+
+ Start a sim in Sims or connect a robot. +
+
+ ) : ( +
○ no camera frame at this point in the run
+ )} +
+ )} +
+
+ ); +} diff --git a/app/src/panels/DetectionsPanel.tsx b/app/src/panels/DetectionsPanel.tsx new file mode 100644 index 0000000..7411650 --- /dev/null +++ b/app/src/panels/DetectionsPanel.tsx @@ -0,0 +1,29 @@ +import { useStudio } from "../store"; +import { Panel } from "./Panel"; + +// What the robot sees at the current playhead. sceneAt(t) is source-agnostic. +export function DetectionsPanel() { + const { source, t } = useStudio(); + const { detections, pose } = source.sceneAt(t); + return ( + + {pose && ( +
+ pose x={pose.x.toFixed(2)} y={pose.y.toFixed(2)} z={pose.z.toFixed(2)} +
+ )} + {detections.length ? ( + detections.map((d, i) => ( +
+ {d.label} + {d.confidence != null && {(d.confidence * 100).toFixed(0)}%} +
+ )) + ) : ( +
+ {source.kind === "live" ? "No detections yet — start a sim or connect a robot." : "Nothing detected at this point in the run."} +
+ )} +
+ ); +} diff --git a/app/src/panels/DropZone.tsx b/app/src/panels/DropZone.tsx new file mode 100644 index 0000000..2f3db9e --- /dev/null +++ b/app/src/panels/DropZone.tsx @@ -0,0 +1,60 @@ +import { useRef, useState } from "react"; + +// BAGEL-style: drop a .mcap and view it. Uploads raw bytes to /api/run/upload, +// which saves it as a replayable run, then opens it. +export function DropZone({ onLoaded }: { onLoaded: (run: string) => void }) { + const [busy, setBusy] = useState(false); + const [over, setOver] = useState(false); + const [err, setErr] = useState(""); + const input = useRef(null); + + const upload = async (file: File) => { + setBusy(true); + setErr(""); + try { + const r = await fetch(`/api/run/upload?name=${encodeURIComponent(file.name)}`, { method: "POST", body: file }); + const d = await r.json(); + if (d.ok) onLoaded(d.run); + else setErr(d.error || "upload failed"); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } + }; + + return ( +
{ + e.preventDefault(); + setOver(true); + }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { + e.preventDefault(); + setOver(false); + const f = e.dataTransfer.files[0]; + if (f) upload(f); + }} + onClick={() => input.current?.click()} + > + e.target.files?.[0] && upload(e.target.files[0])} + /> + {busy ? ( + importing… + ) : err ? ( + {err} + ) : ( + + Drop a recording (.mcap) to view it — or click to choose + + )} +
+ ); +} diff --git a/app/src/panels/EventLogPanel.tsx b/app/src/panels/EventLogPanel.tsx new file mode 100644 index 0000000..8be9e3c --- /dev/null +++ b/app/src/panels/EventLogPanel.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef } from "react"; +import { useStudio } from "../store"; +import type { EventMsg } from "../source/Source"; +import { Panel } from "./Panel"; + +// Newcomers shouldn't drown in system chatter. Drop low-signal noise (the +// "no actuator" warning, behavior-load lines, per-frame camera hashes) and +// collapse consecutive repeats, so the feed shows what actually happened. +function isNoise(e: EventMsg): boolean { + const t = e.title || ""; + if (/wants to move/i.test(t)) return true; // "no actuator" nag + if (e.type === "system" && /^loaded /i.test(t)) return true; // behavior autoload + if (e.type === "frame") return true; // per-frame camera hash heartbeat + return false; +} + +type Row = EventMsg & { _n?: number }; +function clean(events: EventMsg[]): Row[] { + const out: Row[] = []; + for (const e of events) { + if (isNoise(e)) continue; + const prev = out[out.length - 1]; + if (prev && prev.type === e.type && prev.title === e.title) { + prev._n = (prev._n || 1) + 1; // collapse consecutive duplicates + } else { + out.push({ ...e }); + } + } + return out.slice(-200); +} + +export function EventLogPanel() { + const source = useStudio((s) => s.source); + const t = useStudio((s) => s.t); + const rows = clean(source.snapshotEvents(t)); + const ref = useRef(null); + + useEffect(() => { + if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; + }, [rows.length]); + + return ( + +
+ {rows.map((e, i) => ( +
+ {new Date(e.ts * 1000).toLocaleTimeString()} + {e.type} {e.title} + {e._n && e._n > 1 && ×{e._n}} +
+ ))} + {!rows.length && ( +
+ {source.kind === "live" + ? "No activity yet. Start a sim or connect a robot and what it does shows up here." + : "No events at this point in the run — scrub forward."} +
+ )} +
+
+ ); +} diff --git a/app/src/panels/Panel.tsx b/app/src/panels/Panel.tsx new file mode 100644 index 0000000..c717863 --- /dev/null +++ b/app/src/panels/Panel.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +export function Panel({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} diff --git a/app/src/panels/PlotPanel.tsx b/app/src/panels/PlotPanel.tsx new file mode 100644 index 0000000..8168690 --- /dev/null +++ b/app/src/panels/PlotPanel.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState } from "react"; +import uPlot from "uplot"; +import "uplot/dist/uPlot.min.css"; +import { useStudio } from "../store"; +import type { Sample } from "../source/Source"; +import { Panel } from "./Panel"; + +const COLORS = ["#00d47e", "#4090e0", "#d4a030", "#e0563f"]; + +// Plots every numeric field of a telemetry channel. Source-agnostic: live pulls +// the WS ring buffer, replay pulls run_series' named arrays. +export function PlotPanel({ channel }: { channel: string }) { + const source = useStudio((s) => s.source); + const host = useRef(null); + const plot = useRef(undefined); + const [empty, setEmpty] = useState(true); + + useEffect(() => { + let alive = true; + const draw = async () => { + const rows: Sample[] = await source.series(channel); + if (!alive || !host.current) return; + setEmpty(rows.length === 0); + const fields = [...new Set(rows.flatMap((r) => Object.keys(r)))].filter((k) => k !== "t"); + const xs = rows.map((r) => r.t); + const data: uPlot.AlignedData = [xs, ...fields.map((f) => rows.map((r) => r[f] ?? null))]; + if (!plot.current) { + const series: uPlot.Series[] = [ + {}, + ...fields.map((f, i) => ({ label: f, stroke: COLORS[i % COLORS.length], width: 1.5 })), + ]; + plot.current = new uPlot( + { + width: host.current.clientWidth || 360, + height: 160, + series, + axes: [ + { stroke: "#6f8a78", grid: { stroke: "#212c22" } }, + { stroke: "#6f8a78", grid: { stroke: "#212c22" } }, + ], + }, + data, + host.current + ); + } else { + plot.current.setData(data); + } + }; + draw(); + const iv = source.kind === "live" ? setInterval(draw, 500) : 0; + return () => { + alive = false; + if (iv) clearInterval(iv); + plot.current?.destroy(); + plot.current = undefined; + }; + }, [source, channel]); + + return ( + +
+
+ {empty && ( +
+ {source.kind === "live" ? "No telemetry yet — start a sim or connect a robot." : "No telemetry recorded in this run."} +
+ )} +
+ + ); +} diff --git a/app/src/routes/Agent.tsx b/app/src/routes/Agent.tsx new file mode 100644 index 0000000..b415712 --- /dev/null +++ b/app/src/routes/Agent.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef, useState } from "react"; +import { Panel } from "../panels/Panel"; +import type { EventMsg } from "../source/Source"; +import { apiUrl, mcpUrl } from "../runtime"; + +// Antioch parity: make the agent loop visible. Shows how to point an MCP client +// at this robot, the REAL tools it gets (fetched live from the server, never a +// hand-maintained list that drifts), and a live feed of its tool calls. +// MCP_URL / COMMANDS are computed in the component (after the backend base is +// resolved), so the hosted page shows the user's localhost endpoint, not its own. + +// agent loop events worth surfacing (skip plain system noise) +const AGENT_TYPES = new Set(["agent", "mcp_tool", "task", "delegate", "notify"]); + +type Tool = { name: string; desc: string }; + +// Keyword buckets so the capability surface stays organized as the server's +// tool set grows — derived from the name, not a hand-kept membership list. +const GROUPS: { label: string; icon: string; test: RegExp }[] = [ + { label: "Perceive", icon: "◉", test: /^(see|seen|detect|scan_surround|find_object|camera|watch_topic|arena_status)/ }, + { label: "Move & navigate", icon: "▸", test: /(move|navigate|estop|follow_me|patrol|find_object)/ }, + { label: "Behaviors & workflows", icon: "✦", test: /(behavior|workflow|sequence)/ }, + { label: "Record & verify", icon: "▦", test: /(telemetry|record|mcap)/ }, + { label: "ROS & control", icon: "⊞", test: /.*/ }, +]; +function groupOf(name: string): string { + return (GROUPS.find((g) => g.test.test(name)) ?? GROUPS[GROUPS.length - 1]).label; +} + +function Copy({ cmd }: { cmd: string }) { + const [done, setDone] = useState(false); + return ( + + ); +} + +export function Agent() { + const [events, setEvents] = useState([]); + const [connected, setConnected] = useState(false); + const [tools, setTools] = useState(null); + const ref = useRef(null); + + const MCP_URL = mcpUrl(); + const COMMANDS = [ + { label: "Claude Code", cmd: `claude mcp add --transport http roborun ${MCP_URL}` }, + { label: "Codex", cmd: `codex mcp add roborun -- npx -y mcp-remote ${MCP_URL}` }, + ]; + + // live agent activity feed + useEffect(() => { + const es = new EventSource(apiUrl("/api/events/stream")); + es.onopen = () => setConnected(true); + es.onerror = () => setConnected(false); + es.onmessage = (m) => { + try { + const e = JSON.parse(m.data) as EventMsg; + if (AGENT_TYPES.has(e.type)) setEvents((prev) => [...prev.slice(-199), e]); + } catch { + /* ping */ + } + }; + return () => es.close(); + }, []); + + // the real tool surface, straight from the MCP server (JSON-RPC tools/list) + useEffect(() => { + let alive = true; + fetch("/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + }) + .then((r) => r.json()) + .then((d) => { + if (!alive) return; + const list: Tool[] = (d?.result?.tools || []).map((t: { name: string; description?: string }) => ({ + name: t.name, + desc: (t.description || "").split("\n")[0], + })); + setTools(list); + }) + .catch(() => alive && setTools([])); + return () => { alive = false; }; + }, []); + + useEffect(() => { + if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; + }, [events]); + + const grouped = GROUPS.map((g) => ({ ...g, items: (tools || []).filter((t) => groupOf(t.name) === g.label) })).filter((g) => g.items.length); + + return ( +
+
+ +

+ RoboRun is an MCP server — point any agent at it and it can see, move, and drive this robot + with the same tools you use. Endpoint: +

+
+ {MCP_URL} + {tools && tools.length > 0 && ( + · {tools.length} tools + )} +
+ {COMMANDS.map((c) => ( +
+
+ {c.label} +
+
+ + {c.cmd} + + +
+
+ ))} +
+ + +
+ {events.map((e, i) => ( +
+ {new Date(e.ts * 1000).toLocaleTimeString()} + {e.type}{" "} + {e.title} +
+ ))} + {!events.length && ( +
+ No agent activity yet. Connect an agent (left) and ask it to drive the robot — its tool calls + and decisions stream here. +
+ )} +
+
+
+ + {/* the real capability surface — what an attached agent actually gets */} + + {tools === null &&
reading tools from the MCP server…
} + {tools && !tools.length && ( +
+ Couldn't reach the MCP server. Start it with roborun, then this lists every tool an agent gets. +
+ )} + {!!grouped.length && ( +
+ {grouped.map((g) => ( +
+
+ {g.icon}{g.label} +
+
+ {g.items.map((t) => ( + + {t.name} + + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/src/routes/Analytics.tsx b/app/src/routes/Analytics.tsx new file mode 100644 index 0000000..7fce499 --- /dev/null +++ b/app/src/routes/Analytics.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from "react"; +import uPlot from "uplot"; +import "uplot/dist/uPlot.min.css"; +import { Panel } from "../panels/Panel"; + +// Native Studio analytics — a live read of /api/analytics, laid out in Studio +// panels so it matches the rest of the app (no more hosted-iframe inconsistency). +type Analytics = { + observations: { total: number; with_embeddings: number; with_position: number; detections: number; runs: number }; + labels: { label: string; count: number }[]; + over_time: { t: number; count: number }[]; + sources: { source: string; count: number }[]; + robots: { robot_id: string; observations: number; last_seen: number; top_label: string }[]; + suites: unknown[]; + runs: { count: number; sealed: number; anchored: number }; + storage: { used_gb: number; cap_gb: number; pct: number }; + fleet: { total: number; online: number }; +}; + +function Stat({ v, label, sub }: { v: string; label: string; sub?: string }) { + return ( +
+ {/* DS KPI numerals: 22px bold, .06em label tracking — matches Home tiles */} +
{v}
+
+ {label} +
+ {sub &&
{sub}
} +
+ ); +} + +function Bars({ rows }: { rows: { label: string; count: number }[] }) { + const max = Math.max(1, ...rows.map((r) => r.count)); + return ( +
+ {rows.map((r) => ( +
+ {r.label} + + + + {r.count} +
+ ))} + {!rows.length &&
no data yet
} +
+ ); +} + +function TimeChart({ rows }: { rows: { t: number; count: number }[] }) { + const host = useRef(null); + useEffect(() => { + if (!host.current || !rows.length) return; + const data: uPlot.AlignedData = [rows.map((r) => r.t), rows.map((r) => r.count)]; + const u = new uPlot( + { + width: host.current.clientWidth || 360, + height: 170, + series: [{}, { label: "obs", stroke: "#00d47e", width: 1.5, fill: "rgba(0,212,126,.1)" }], + axes: [ + { stroke: "#6f8a78", grid: { stroke: "#212c22" } }, + { stroke: "#6f8a78", grid: { stroke: "#212c22" } }, + ], + legend: { show: false }, + }, + data, + host.current + ); + return () => u.destroy(); + }, [rows]); + return
; +} + +export function Analytics() { + const [a, setA] = useState(null); + useEffect(() => { + fetch("/api/analytics") + .then((r) => r.json()) + .then(setA) + .catch(() => {}); + }, []); + + if (!a) return
loading analytics…
; + + return ( +
+
+ + + + + + +
+ +
+ + + + + + + + ({ label: s.source, count: s.count }))} /> + + +
+ {a.robots.map((r) => ( +
+ ● {r.robot_id} + + {" "} + · {fmt(r.observations)} obs · mostly {r.top_label} + +
last active {new Date(r.last_seen * 1000).toLocaleString()}
+
+ ))} + {!a.robots.length &&
no robots yet
} +
+
+
+
+ ); +} + +const fmt = (n: number) => (n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, "") + "k" : String(n)); diff --git a/app/src/routes/Home.tsx b/app/src/routes/Home.tsx new file mode 100644 index 0000000..3993eec --- /dev/null +++ b/app/src/routes/Home.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useStudio } from "../store"; +import { Live } from "./Live"; +import { runTime, fmtSize, summarize } from "./Runs"; + +// The DS "Welcome back" landing (claude.ai/design f84aeaa8 · HomePage): a tinted +// hero + CTAs, an instrument-grade KPI overview, and recent runs. It replaces the +// old redirect-straight-to-Live so a returning user lands on a real dashboard. +// All numbers are read from the live backend — nothing is fabricated. + +type Analytics = { + observations: { total: number; with_embeddings: number; detections: number }; + runs: { count: number; sealed: number; anchored: number }; + storage: { used_gb: number; cap_gb: number; pct: number }; + fleet: { total: number; online: number }; + robots: { robot_id: string }[]; +}; +type Run = { run: string; robot_id?: string; size?: number; sealed?: boolean; anchored?: boolean; message_counts?: Record }; + +const fmt = (n: number) => (n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, "") + "k" : String(n)); + +function Section({ label, count, more, onMore }: { label: string; count?: string; more?: string; onMore?: () => void }) { + return ( +
+ {label} + {count && {count}} + {more && ( + + )} +
+ ); +} + +function Kpi({ n, unit, label, sub }: { n: string; unit?: string; label: string; sub?: string }) { + return ( +
+
+ {n}{unit && {unit}} +
+
{label}
+ {sub &&
{sub}
} +
+ ); +} + +export function Home() { + const navigate = useNavigate(); + const openRun = useStudio((s) => s.openRun); + const scopeKey = useStudio((s) => s.scopeKey); + const [a, setA] = useState(null); + const [runs, setRuns] = useState([]); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + let alive = true; + Promise.all([ + fetch("/api/analytics").then((r) => r.json()).catch(() => null), + fetch("/api/run/list").then((r) => r.json()).catch(() => ({ runs: [] })), + fetch("/api/run/mcap").then((r) => r.json()).catch(() => ({ runs: [] })), + ]).then(([an, list, mcap]) => { + if (!alive) return; + setA(an); + const mcapMap = new Map((mcap.runs || []).map((m: Run) => [m.run, m])); + const names = new Set([...(list.runs || []).map((l: Run) => l.run), ...mcapMap.keys()]); + const merged = [...names].map((run) => ({ run, ...(mcapMap.get(run) || {}) })); + merged.sort((x, y) => (x.run < y.run ? 1 : -1)); + setRuns(merged); + setLoaded(true); + }); + return () => { alive = false; }; + }, [scopeKey]); + + // first-run: nothing recorded and nothing seen → fall through to Live itself, + // so Home === Live when there's no dashboard to show. If a sim/robot is + // streaming, the live panels render; if truly idle, Live shows the welcome. + if (loaded && !runs.length && !(a && a.observations.total)) return ; + + const open = (run: string) => { openRun(run); navigate("/runs"); }; + + return ( + <> + {/* hero — the one tinted surface (radial accent wash + soft shadow) */} +
+
+
◇ RoboRun Studio
+ + runtime online + +
+

Welcome back

+
Your local runtime is recording and indexing. Launch a sim, open the cockpit, or browse what your robots have seen.
+
+ + ▦ Open cockpit + + +
+
+ + {a && ( + <> +
navigate("/analytics")} /> +
+ + + + + +
+ + )} + +
navigate("/runs")} /> +
+ {runs.slice(0, 6).map((r) => ( + + ))} + {loaded && !runs.length && ( +
no runs yet — start a sim and press ● Record, or run roborun demo.
+ )} +
+ + ); +} diff --git a/app/src/routes/Hosted.tsx b/app/src/routes/Hosted.tsx new file mode 100644 index 0000000..96af14b --- /dev/null +++ b/app/src/routes/Hosted.tsx @@ -0,0 +1,10 @@ +// Folds a still-useful legacy page into Studio's single shell via an iframe +// (shell.js hides its own chrome when framed). One nav, one front door — no +// rewrite of pages that already work. Native ports are a later polish. +export function Hosted({ title, src }: { title: string; src: string }) { + return ( +
+