From 45ef482feac4a77cb46aa590ac0b1330c6042004 Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 12:54:35 -0400 Subject: [PATCH 01/11] feat: decouple warren and integrate native orca proxy with console qr pairing Entire-Checkpoint: 36fe0d6ad374 --- PROTOCOL.md | 202 ++++++++++++++++++++ examples/agent.py | 164 ++++++++++++++++ server/pyproject.toml | 3 + server/src/warren/__main__.py | 98 +++++++++- server/src/warren/adapters/orca.py | 238 ++++++++++++++++++++++++ server/src/warren/adapters/websocket.py | 132 +++++++++++++ server/src/warren/app.py | 99 +++++++--- server/src/warren/config.py | 1 + server/tests/test_websocket_adapter.py | 94 ++++++++++ 9 files changed, 1006 insertions(+), 25 deletions(-) create mode 100644 PROTOCOL.md create mode 100755 examples/agent.py create mode 100644 server/src/warren/adapters/orca.py create mode 100644 server/src/warren/adapters/websocket.py create mode 100644 server/tests/test_websocket_adapter.py diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..5e50316 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,202 @@ +# Warren Agent Proxy Protocol + +Warren acts as a high-performance, general-purpose proxy layer bridging user interactions from a **Rabbit R1** (via Voice PTT or its 240x282 Creation WebView) to **arbitrary personal AI agents** (running on your home lab, VPS, local machine, etc.). + +By default, Warren decouples itself from any specific agent implementation (like Claude Code) by exposing a bidirectional, real-time, NAT-friendly **WebSocket Proxy Protocol**. + +--- + +## πŸ—οΈ Architecture + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Rabbit R1 β”‚ + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ (Voice/SSE) + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Warren β”‚ + β”‚ (Proxy VPS) β”‚ + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ (WebSocket Server) + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Personal Agentβ”‚ + β”‚ (Home Lab) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Because the **Personal Agent** connects to **Warren** as a WebSocket client, your agent can reside securely behind NAT, firewalls, or dynamic home IP addresses without exposing any public web servers or webhooks. + +--- + +## πŸ”Œ Connection details + +* **Endpoint URL:** `ws://:/api/agent/ws` (or `wss://` behind secure proxies). +* **Authentication:** Provide either: + 1. A `token` query parameter: `?token=YOUR_WARREN_INTERNAL_SECRET` + 2. An `Authorization` header: `Authorization: Bearer YOUR_WARREN_INTERNAL_SECRET` + +--- + +## πŸ“₯ Incoming Messages (Warren βž” Agent) + +When a user initiates an action or sends a message, Warren serializes the interaction and pushes it over the WebSocket as a JSON object: + +```json +{ + "kind": "new_session" | "user_message" | "cancel", + "session_id": "string", + "payload": { + "message": "string", + "repo_path": "string", + "model": "string", + "append_system_prompt": "string" + } +} +``` + +### Event Types +* **`new_session`**: Triggered when a new chat session is initiated. +* **`user_message`**: Triggered when a message is added to an existing session. +* **`cancel`**: Sent when the user taps the **[STOP]** button on their R1 screen to interrupt the agent's current task. + +--- + +## πŸ“€ Outgoing Events (Agent βž” Warren) + +The agent streams its progress, text tokens, status indicators, and tool executions back to Warren using JSON frames. Warren proxies these directly to the Rabbit R1: + +```json +{ + "kind": "text" | "tool" | "result" | "status" | "error" | "typing", + "session_id": "string", + "data": { + "type": "text" | "tool" | "result" | "status" | "error" | "typing", + "...": "fields mapping to type" + } +} +``` + +### Event Payload Schema + +#### 1. Streaming Text Response (`kind: "text"`) +Streams the assistant's verbal or terminal text response: +```json +{ + "kind": "text", + "session_id": "session-xyz", + "data": { + "type": "text", + "content": "This is a stream of text... " + } +} +``` + +#### 2. Tool Execution Log (`kind: "tool"`) +Logs tool-execution output to the R1 terminal monitor: +```json +{ + "kind": "tool", + "session_id": "session-xyz", + "data": { + "type": "tool", + "tool": "run_tests", + "summary": "running unit tests..." + } +} +``` + +#### 3. Agent Status (`kind: "status"`) +Controls the connection dot state and controls on the R1: +```json +{ + "kind": "status", + "session_id": "session-xyz", + "data": { + "type": "status", + "state": "working" | "active" | "waiting" | "done" | "error" + } +} +``` +* `working`: Shows a yellow status dot on the R1 (indicates background agent calculation/tool execution). +* `waiting`: Shows a green status dot (indicates agent is idling/awaiting user input). +* `error`: Shows a red status dot. + +#### 4. Final Task Result (`kind: "result"`) +Signals the final resolution/summary of a task: +```json +{ + "kind": "result", + "session_id": "session-xyz", + "data": { + "type": "result", + "summary": "Completed the refactoring successfully!" + } +} +``` + +#### 5. Errors (`kind: "error"`) +Pushes exception logs to the screen: +```json +{ + "kind": "error", + "session_id": "session-xyz", + "data": { + "type": "error", + "message": "Failed to connect to database" + } +} +``` + +#### 6. Typing Indicator (`kind: "typing"`) +Provides visual feedback that the agent is actively preparing a response: +```json +{ + "kind": "typing", + "session_id": "session-xyz", + "data": { + "type": "typing", + "is_typing": true + } +} +``` + +--- + +## πŸ“ˆ Step-by-Step Progress Tracking + +To leverage the Rabbit R1 Creation's built-in step progress visualization (the checklist under the status indicator), format progress logs in your `text` content using the bracketed step format: + +`[STEP:current_step/total_steps:status] Step description` + +Where `status` can be `pending`, `running`, `done`, or `failed`. + +### Example text block: +``` +[STEP:1/3:done] Read codebase +[STEP:2/3:running] Apply edits +[STEP:3/3:pending] Verify build +``` +When Warren receives this text block, it parses the bracketed meta-tags and updates the checklists dynamically on the R1 device screen. +- --- + +## πŸ‹ Native Orca Integration (Zero-Hop Architecture) + +If you are running **Orca** on your local machine or server, Warren can act as a **zero-hop native proxy**. In this setup, Warren connects directly to your live Orca runtime and pipes R1 voice/text inputs directly to active Orca terminals. + +### βš™οΈ How to Configure +Set the `WARREN_BACKEND` environment variable to `"orca"` (or configure `backend: orca` in `config.yaml`): +```bash +export WARREN_BACKEND="orca" +warren +``` + +### πŸ” How it works under the hood +1. When Warren starts, it automatically detects your computer's local Wi-Fi/network IP address and prints a pairing QR code right on your console. +2. Scan the QR code with your Rabbit R1 to pair it. +3. When you send a prompt from your R1: + * Warren queries the running Orca runtime for active terminal sessions via `orca terminal list`. + * If an active terminal exists (e.g. running your custom LLM or agent), Warren pipes your prompt into it using `orca terminal send`. + * If no active terminals are found, Warren automatically spawns a fresh terminal in your active workspace: `orca terminal create --worktree active --title "R1 Workspace"`. + * Warren reads the terminal stream back using `orca terminal read` and streams the text in real-time straight to your R1. diff --git a/examples/agent.py b/examples/agent.py new file mode 100755 index 0000000..b399a43 --- /dev/null +++ b/examples/agent.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Example Personal Agent client connecting to Warren's proxy layer. + +To run this example, install the websockets library: + pip install websockets + +Then run the agent: + export WARREN_URL="ws://localhost:4030" + export WARREN_SECRET="your-internal-secret" + python agent.py +""" + +import asyncio +import json +import os +import sys + +try: + import websockets +except ImportError: + print("Error: The 'websockets' library is required to run this example.") + print("Please install it via: pip install websockets") + sys.exit(1) + + +async def run_agent(): + warren_url = os.environ.get("WARREN_URL", "ws://localhost:4030").rstrip("/") + secret = os.environ.get("WARREN_SECRET", "your-internal-secret") + + # Connect with authorization token in the query params + ws_url = f"{warren_url}/api/agent/ws?token={secret}" + print(f"Connecting to Warren Proxy at {warren_url}/api/agent/ws...") + + try: + async with websockets.connect(ws_url) as ws: + print("Successfully connected! Awaiting sessions...") + + async for message_raw in ws: + message = json.loads(message_raw) + kind = message.get("kind") + session_id = message.get("session_id") + payload = message.get("payload", {}) + + print(f"\n[{kind.upper()}] Session: {session_id}") + if "message" in payload: + print(f"User Prompt: {payload['message']}") + + if kind in ("new_session", "user_message"): + # Process the prompt in a background task so we don't block + # receiving other messages (like cancellations) + asyncio.create_task(handle_prompt(ws, session_id, payload.get("message", ""))) + elif kind == "cancel": + print(f"Cancellation requested for session {session_id}!") + + except ConnectionRefusedError: + print("Could not connect to Warren server. Make sure it is running and accessible.") + except websockets.exceptions.ConnectionClosedOK: + print("Connection closed cleanly by server.") + except Exception as e: + print(f"Error: {e}") + + +async def send_event(ws, session_id, kind, data): + """Utility to build and send a protocol-compliant BackendEvent frame.""" + frame = { + "kind": kind, + "session_id": session_id, + "data": { + "type": kind, + **data + } + } + await ws.send(json.dumps(frame)) + + +async def handle_prompt(ws, session_id, prompt): + """Simulates agent execution with progress checklist, tool logs, and streaming response.""" + print(f"Processing prompt for session {session_id}...") + + # 1. Update status to working (yellow light on R1) + await send_event(ws, session_id, "status", {"state": "working"}) + + # 2. Show typing indicator + await send_event(ws, session_id, "typing", {"is_typing": True}) + await asyncio.sleep(0.5) + + # 3. Stream step-by-step checklist setup + steps = [ + "[STEP:1/3:running] Checking home directory contents", + "[STEP:2/3:pending] Resolving file paths", + "[STEP:3/3:pending] Compiling analysis report" + ] + await send_event(ws, session_id, "text", {"content": "\n".join(steps)}) + await asyncio.sleep(1.0) + + # 4. Log a simulated tool execution + await send_event(ws, session_id, "tool", { + "tool": "list_directory", + "summary": "Listing files in /home/user" + }) + await asyncio.sleep(1.0) + + # Update step checklist (step 1 done, step 2 running) + steps = [ + "[STEP:1/3:done] Checking home directory contents", + "[STEP:2/3:running] Resolving file paths", + "[STEP:3/3:pending] Compiling analysis report" + ] + await send_event(ws, session_id, "text", {"content": "\n".join(steps)}) + await asyncio.sleep(1.0) + + # 5. Log another simulated tool + await send_event(ws, session_id, "tool", { + "tool": "resolve_path", + "summary": "Resolving config paths under /home/user/.config" + }) + await asyncio.sleep(1.0) + + # Update checklist (step 2 done, step 3 running) + steps = [ + "[STEP:1/3:done] Checking home directory contents", + "[STEP:2/3:done] Resolving file paths", + "[STEP:3/3:running] Compiling analysis report" + ] + await send_event(ws, session_id, "text", {"content": "\n".join(steps)}) + await asyncio.sleep(1.0) + + # 6. Stream final text chunk by chunk + response_text = "I have successfully analyzed your home directory! Everything is configured correctly." + chunk_size = 5 + accumulated_text = "" + for i in range(0, len(response_text), chunk_size): + chunk = response_text[i:i+chunk_size] + accumulated_text += chunk + + # Combine the completed steps checklist with the text stream + full_payload = "\n".join([ + "[STEP:1/3:done] Checking home directory contents", + "[STEP:2/3:done] Resolving file paths", + "[STEP:3/3:done] Compiling analysis report", + "", + accumulated_text + ]) + await send_event(ws, session_id, "text", {"content": full_payload}) + await asyncio.sleep(0.1) + + # 7. Close typing indicator + await send_event(ws, session_id, "typing", {"is_typing": False}) + + # 8. Send final task result + await send_event(ws, session_id, "result", { + "summary": "Analysis report compiled: 2 issues resolved." + }) + + # 9. Update status back to waiting (green light on R1) + await send_event(ws, session_id, "status", {"state": "waiting"}) + print(f"Finished processing session {session_id}.") + + +if __name__ == "__main__": + try: + asyncio.run(run_agent()) + except KeyboardInterrupt: + print("\nAgent stopped.") diff --git a/server/pyproject.toml b/server/pyproject.toml index 95ba1d8..112d26b 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -18,6 +18,9 @@ dependencies = [ "python-multipart>=0.0.18", ] +[project.scripts] +warren = "warren.__main__:main" + [project.optional-dependencies] dev = [ "pytest>=8.0", diff --git a/server/src/warren/__main__.py b/server/src/warren/__main__.py index cc3f5a3..471be43 100644 --- a/server/src/warren/__main__.py +++ b/server/src/warren/__main__.py @@ -1,17 +1,111 @@ """Entry point: python -m warren""" -import os +from __future__ import annotations +import asyncio +import json +import logging +import os +import socket import uvicorn +import qrcode + from warren.config import Settings +from warren.db import Database + +logger = logging.getLogger(__name__) + + +def get_local_ip() -> str: + """Detect the primary local network IP address of the host machine.""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + # Does not need to be reachable, just triggers routing selection + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + except Exception: + ip = "127.0.0.1" + finally: + s.close() + return ip + + +def print_startup_qr_codes(settings: Settings) -> str: + """Pre-register device token and print ASCII QR codes for the R1.""" + local_ip = get_local_ip() + server_url = f"http://{local_ip}:{settings.port}" + token = "local-r1-token" + + # Pre-register a static token in the database + async def save_token(): + db = Database(settings.db) + await db.initialize() + await db.store_device_token(token, "Local Rabbit R1") + await db.close() + + try: + asyncio.run(save_token()) + except Exception as e: + logger.warning("Database startup token setup failed: %s", e) + + print("\n" + "─" * 60) + print("πŸŒ… WARREN RUNTIME SERVER STARTED πŸŒ…") + print(f"Local Host IP: {local_ip}") + print(f"Backend Adapter: {settings.backend.upper()}") + print(f"Database Path: {settings.db}") + print("─" * 60) + + # 1. R1 Creation / WebView QR Code + creation_url = f"{server_url}/creation/?token={token}" + creation_payload = json.dumps( + { + "title": f"Warren ({settings.backend.upper()})", + "url": creation_url, + "description": "Warren general proxy from R1", + "iconUrl": "", + "themeColor": "#7aa2f7", + } + ) + + print("\nπŸ“± SCAN TO PAIR R1 WEBVIEW / CREATION UI πŸ“±") + print("Scan this QR code with your Rabbit R1 to open the Warren WebView:") + qr1 = qrcode.QRCode() + qr1.add_data(creation_payload) + qr1.print_ascii(invert=True) + print(f"Direct URL: {creation_url}") + + # 2. Voice PTT Pairing QR Code (if enabled) + if settings.voice_enabled: + voice_payload = json.dumps( + { + "type": "clawdbot-gateway", + "version": 1, + "ips": [local_ip], + "port": settings.port, + "token": token, + "protocol": "ws", + } + ) + print("\nπŸŽ™οΈ SCAN TO PAIR R1 VOICE PTT GATEWAY πŸŽ™οΈ") + print("Scan this QR code with your R1 to route native voice prompts:") + qr2 = qrcode.QRCode() + qr2.add_data(voice_payload) + qr2.print_ascii(invert=True) + + print("─" * 60 + "\n") + return local_ip def main(): settings = Settings() + + # Print the QR codes on the console + print_startup_qr_codes(settings) + uvicorn.run( "warren.app:app", - host=settings.host, + host="0.0.0.0", # Accept connection on all local interfaces port=settings.port, proxy_headers=True, forwarded_allow_ips="*", diff --git a/server/src/warren/adapters/orca.py b/server/src/warren/adapters/orca.py new file mode 100644 index 0000000..a21c432 --- /dev/null +++ b/server/src/warren/adapters/orca.py @@ -0,0 +1,238 @@ +"""Orca backend adapter. + +Enables Warren to act as an R1 interface for Orca-managed worktrees, +active terminal sessions, and running agents. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import shutil +from collections.abc import AsyncIterator + +from warren.adapters import BackendAdapter, BackendEvent, BackendMessage + +logger = logging.getLogger(__name__) + + +class OrcaAdapter(BackendAdapter): + """Backend adapter that routes prompt commands to live Orca terminals.""" + + def __init__(self, orca_bin: str | None = None): + # Resolve the Orca CLI binary + self._orca_bin = ( + orca_bin + or shutil.which("orca") + or os.path.expanduser("~/.local/bin/orca") + ) + self._event_queue: asyncio.Queue[BackendEvent] = asyncio.Queue() + self._active_terminal: str | None = None + self._terminal_cursor: int = 0 + self._poll_task: asyncio.Task | None = None + self._session_id: str | None = None + + async def start(self) -> None: + """Start the adapter.""" + logger.info("OrcaAdapter started. Using Orca CLI: %s", self._orca_bin) + + async def stop(self) -> None: + """Clean shutdown.""" + if self._poll_task: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + logger.info("OrcaAdapter stopped.") + + async def _run_orca(self, *args: str) -> dict: + """Run an Orca CLI command and parse the JSON result.""" + cmd = [self._orca_bin] + list(args) + ["--json"] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + logger.warning( + "Orca CLI returned non-zero code %d: %s", + proc.returncode, + stderr.decode(), + ) + + # Parse output + output = stdout.decode().strip() + if not output: + return {"ok": False, "error": {"message": "Empty response from Orca"}} + return json.loads(output) + except Exception as e: + logger.exception("Failed to run Orca command %s", cmd) + return {"ok": False, "error": {"message": str(e)}} + + async def _get_or_create_terminal(self) -> str | None: + """Find an active Orca terminal or spawn a new one.""" + # 1. Check if the runtime server is reachable + status = await self._run_orca("status") + if not status.get("ok") or not status.get("result", {}).get("runtime", {}).get("reachable"): + logger.warning("Orca runtime server is not running or reachable.") + return None + + # 2. Check for existing live terminals + terminals_res = await self._run_orca("terminal", "list") + if terminals_res.get("ok"): + terminals = terminals_res.get("result", []) + if terminals: + # Reuse the most recent/first active terminal + self._active_terminal = terminals[0]["handle"] + return self._active_terminal + + # 3. Create a fresh terminal in the active workspace if none exist + logger.info("No active terminals found. Creating one...") + create_res = await self._run_orca( + "terminal", "create", + "--worktree", "active", + "--title", "R1 Workspace", + ) + if create_res.get("ok"): + self._active_terminal = create_res.get("result", {}).get("handle") + # Reset cursor for new terminal + self._terminal_cursor = 0 + return self._active_terminal + + return None + + async def _poll_terminal_output(self, terminal_handle: str) -> None: + """Continuously reads output from the target terminal and streams it back.""" + idle_counter = 0 + try: + while True: + read_res = await self._run_orca( + "terminal", "read", + "--terminal", terminal_handle, + "--cursor", str(self._terminal_cursor), + "--limit", "100", + ) + if read_res.get("ok"): + res = read_res.get("result", {}) + lines = res.get("lines", []) + new_cursor = res.get("cursor", self._terminal_cursor) + exited = res.get("exited", False) + + if lines: + idle_counter = 0 + text_content = "\n".join(lines) + "\n" + self._terminal_cursor = new_cursor + + # Stream output chunk + await self._event_queue.put( + BackendEvent( + kind="text", + session_id=self._session_id, + data={"type": "text", "content": text_content}, + ) + ) + else: + idle_counter += 1 + + if exited: + logger.info("Orca terminal exited.") + break + else: + idle_counter += 1 + + # If idle for ~5s, set state back to waiting + if idle_counter >= 25: + await self._event_queue.put( + BackendEvent( + kind="status", + session_id=self._session_id, + data={"type": "status", "state": "waiting"}, + ) + ) + idle_counter = 25 # Prevent overflow + + await asyncio.sleep(0.2) + except asyncio.CancelledError: + pass + + async def send(self, message: BackendMessage) -> None: + """Route user prompt to the target Orca terminal.""" + if message.kind not in ("new_session", "user_message"): + return + + self._session_id = message.session_id + prompt_text = message.payload.get("message", "") + + # 1. Update status to working + await self._event_queue.put( + BackendEvent( + kind="status", + session_id=self._session_id, + data={"type": "status", "state": "working"}, + ) + ) + + # 2. Get active terminal + terminal_handle = await self._get_or_create_terminal() + if not terminal_handle: + await self._event_queue.put( + BackendEvent( + kind="error", + session_id=self._session_id, + data={ + "type": "error", + "message": "Orca runtime unavailable. Start Orca with 'orca open' or 'orca serve' first.", + }, + ) + ) + await self._event_queue.put( + BackendEvent( + kind="status", + session_id=self._session_id, + data={"type": "status", "state": "error"}, + ) + ) + return + + # 3. Send prompt text + logger.info("Routing prompt to Orca terminal %s: %s", terminal_handle, prompt_text) + send_res = await self._run_orca( + "terminal", "send", + "--terminal", terminal_handle, + "--text", prompt_text, + "--enter", + ) + + if not send_res.get("ok"): + err_msg = send_res.get("error", {}).get("message", "Failed to send text to Orca") + await self._event_queue.put( + BackendEvent( + kind="error", + session_id=self._session_id, + data={"type": "error", "message": err_msg}, + ) + ) + return + + # 4. Spin up polling task + if self._poll_task: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + + self._poll_task = asyncio.create_task( + self._poll_terminal_output(terminal_handle) + ) + + async def events(self) -> AsyncIterator[BackendEvent]: + """Yield events received from the Orca terminal loop.""" + while True: + event = await self._event_queue.get() + yield event diff --git a/server/src/warren/adapters/websocket.py b/server/src/warren/adapters/websocket.py new file mode 100644 index 0000000..4bac324 --- /dev/null +++ b/server/src/warren/adapters/websocket.py @@ -0,0 +1,132 @@ +"""Agent WebSocket backend adapter. + +Exposes a WebSocket-based gateway endpoint for arbitrary personal agents +to connect to Warren. Acts as a general-purpose proxy layer. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator +from fastapi import WebSocket + +from warren.adapters import BackendAdapter, BackendEvent, BackendMessage + +logger = logging.getLogger(__name__) + + +class AgentWebSocketAdapter(BackendAdapter): + """Backend adapter that acts as a WebSocket server for arbitrary personal agents.""" + + def __init__(self, internal_secret: str = "", admin_token: str = ""): + self._internal_secret = internal_secret + self._admin_token = admin_token + self._event_queue: asyncio.Queue[BackendEvent] = asyncio.Queue() + self._active_connections: set[WebSocket] = set() + + async def start(self) -> None: + """Start the adapter.""" + logger.info("AgentWebSocketAdapter started. Awaiting agent connections...") + + async def stop(self) -> None: + """Clean shutdown. Close all active connections.""" + logger.info("AgentWebSocketAdapter stopping. Closing active agent connections...") + for ws in list(self._active_connections): + try: + await ws.close(code=1001, reason="Server shutting down") + except Exception: + pass + self._active_connections.clear() + + async def handle_websocket(self, websocket: WebSocket) -> None: + """Handle a new incoming agent WebSocket connection.""" + await websocket.accept() + + # Extract token for authentication + token = websocket.query_params.get("token") + if not token: + auth_header = websocket.headers.get("authorization") + if auth_header and auth_header.lower().startswith("bearer "): + token = auth_header[7:] + + # Validate token if configured + is_authenticated = True + if self._internal_secret or self._admin_token: + is_authenticated = False + if token: + if self._internal_secret and token == self._internal_secret: + is_authenticated = True + elif self._admin_token and token == self._admin_token: + is_authenticated = True + + if not is_authenticated: + logger.warning("Unauthenticated agent connection attempt from %s", websocket.client) + await websocket.close(code=4001, reason="Unauthorized") + return + + self._active_connections.add(websocket) + logger.info("Agent connected from %s", websocket.client) + + try: + while True: + data = await websocket.receive_json() + logger.debug("Received event from agent: %s", data) + + kind = data.get("kind") + session_id = data.get("session_id") + event_data = data.get("data") + + if kind and session_id and isinstance(event_data, dict): + # Ensure the data payload contains the type key + if "type" not in event_data: + event_data["type"] = kind + + event = BackendEvent( + kind=kind, + session_id=session_id, + data=event_data, + ) + await self._event_queue.put(event) + else: + logger.warning("Invalid event format received from agent: %s", data) + except Exception as e: + logger.info("Agent connection lost with %s: %s", websocket.client, e) + finally: + self._active_connections.discard(websocket) + + async def send(self, message: BackendMessage) -> None: + """Send a backend message to connected agents.""" + if not self._active_connections: + logger.warning("No connected agents to receive message: %s", message) + if message.session_id: + err_event = BackendEvent( + kind="error", + session_id=message.session_id, + data={ + "type": "error", + "content": "No agent connected to proxy layer. Please start your personal agent.", + }, + ) + await self._event_queue.put(err_event) + return + + payload = { + "kind": message.kind, + "session_id": message.session_id, + "payload": message.payload, + } + + # Broadcast to all active connections + for ws in list(self._active_connections): + try: + await ws.send_json(payload) + except Exception as e: + logger.exception("Failed to send message to agent: %s", e) + self._active_connections.discard(ws) + + async def events(self) -> AsyncIterator[BackendEvent]: + """Yield events received from connected agents.""" + while True: + event = await self._event_queue.get() + yield event diff --git a/server/src/warren/app.py b/server/src/warren/app.py index 379930f..b516f07 100644 --- a/server/src/warren/app.py +++ b/server/src/warren/app.py @@ -69,11 +69,23 @@ def _nanoclaw_paths(settings: Settings, config) -> tuple[str, str]: def create_adapter(settings: Settings, config): - """Create the NanoClaw backend adapter.""" - from warren.adapters.nanoclaw import NanoClawAdapter - - db_path, ipc_dir = _nanoclaw_paths(settings, config) - return NanoClawAdapter(ipc_dir=ipc_dir, db_path=db_path) + """Create the configured backend adapter (nanoclaw or websocket or orca).""" + if settings.backend == "nanoclaw": + from warren.adapters.nanoclaw import NanoClawAdapter + + db_path, ipc_dir = _nanoclaw_paths(settings, config) + return NanoClawAdapter(ipc_dir=ipc_dir, db_path=db_path) + elif settings.backend == "orca": + from warren.adapters.orca import OrcaAdapter + + return OrcaAdapter() + else: + from warren.adapters.websocket import AgentWebSocketAdapter + + return AgentWebSocketAdapter( + internal_secret=settings.internal_secret, + admin_token=settings.admin_token, + ) async def event_pump(adapter, bus: SessionBus, db): @@ -121,15 +133,16 @@ async def lifespan(app: FastAPI): app.state.config = config app.state.settings = settings - import os + if settings.backend == "nanoclaw": + import os - has_api_key = os.environ.get("ANTHROPIC_API_KEY") - has_oauth = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") - if not has_api_key and not has_oauth: - logger.warning( - "Neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN set β€” " - "Claude Code sessions will fail. Set one of these environment variables." - ) + has_api_key = os.environ.get("ANTHROPIC_API_KEY") + has_oauth = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") + if not has_api_key and not has_oauth: + logger.warning( + "Neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN set β€” " + "Claude Code sessions will fail. Set one of these environment variables." + ) adapter = create_adapter(settings, config) await adapter.start() @@ -613,20 +626,39 @@ async def monitor_health( request: Request, _token: str = Depends(require_auth), ): - import os - settings = request.app.state.settings result = {"warren": {"status": "ok"}} - db_path, _ = _nanoclaw_paths(settings, request.app.state.config) - if os.path.exists(db_path): - result["nanoclaw"] = {"status": "ok"} + if settings.backend == "nanoclaw": + import os + db_path, _ = _nanoclaw_paths(settings, request.app.state.config) + if os.path.exists(db_path): + result["nanoclaw"] = {"status": "ok"} + else: + result["nanoclaw"] = { + "status": "unavailable", + "detail": "DB not found", + } + elif settings.backend == "orca": + adapter = request.app.state.adapter + from warren.adapters.orca import OrcaAdapter + if isinstance(adapter, OrcaAdapter): + result["orca"] = { + "status": "ok", + "active_terminal": adapter._active_terminal + } + else: + result["orca"] = {"status": "unavailable"} else: - result["nanoclaw"] = { - "status": "unavailable", - "detail": "DB not found", - } - + adapter = request.app.state.adapter + from warren.adapters.websocket import AgentWebSocketAdapter + if isinstance(adapter, AgentWebSocketAdapter): + result["agent_websocket"] = { + "status": "ok", + "connected_agents_count": len(adapter._active_connections) + } + else: + result["agent_websocket"] = {"status": "unavailable"} return result @app.get("/monitor/agents") @@ -643,6 +675,9 @@ async def monitor_tasks( _token: str = Depends(require_auth), ): settings = request.app.state.settings + if settings.backend != "nanoclaw": + return {"tasks": []} + db_path, _ = _nanoclaw_paths(settings, request.app.state.config) from warren.nanoclaw_db import NanoClawDbReader @@ -657,6 +692,9 @@ async def pause_task( _token: str = Depends(require_auth), ): settings = request.app.state.settings + if settings.backend != "nanoclaw": + return {"status": "ignored", "reason": "Not supported on websocket backend"} + _, ipc_dir = _nanoclaw_paths(settings, request.app.state.config) from warren.ipc import write_ipc_task @@ -679,6 +717,9 @@ async def resume_task( _token: str = Depends(require_auth), ): settings = request.app.state.settings + if settings.backend != "nanoclaw": + return {"status": "ignored", "reason": "Not supported on websocket backend"} + _, ipc_dir = _nanoclaw_paths(settings, request.app.state.config) from warren.ipc import write_ipc_task @@ -807,6 +848,18 @@ async def nanoclaw_typing(request: Request): state = "working" if is_typing else "waiting" await bus.publish(session_id, {"type": "status", "state": state}) return {"status": "ok"} + # -- Agent WebSocket -- + + @app.websocket("/api/agent/ws") + async def agent_websocket(websocket: WebSocket): + adapter = websocket.app.state.adapter + from warren.adapters.websocket import AgentWebSocketAdapter + + if not isinstance(adapter, AgentWebSocketAdapter): + await websocket.accept() + await websocket.close(code=1011, reason="Agent WebSocket backend adapter not active") + return + await adapter.handle_websocket(websocket) # -- Voice WebSocket -- diff --git a/server/src/warren/config.py b/server/src/warren/config.py index 4eb1c37..a4c64ed 100644 --- a/server/src/warren/config.py +++ b/server/src/warren/config.py @@ -67,6 +67,7 @@ class Settings(BaseSettings): voice_enabled: bool = False voice_port: int = 443 internal_secret: str = "" + backend: str = "websocket" def save_commands(path: str, commands: CommandsConfig) -> None: diff --git a/server/tests/test_websocket_adapter.py b/server/tests/test_websocket_adapter.py new file mode 100644 index 0000000..55e3369 --- /dev/null +++ b/server/tests/test_websocket_adapter.py @@ -0,0 +1,94 @@ +import asyncio +import pytest +from fastapi import FastAPI, WebSocket +from fastapi.testclient import TestClient + +from warren.adapters import BackendEvent, BackendMessage +from warren.adapters.websocket import AgentWebSocketAdapter + + +def test_agent_websocket_adapter_init(): + adapter = AgentWebSocketAdapter(internal_secret="secret", admin_token="admin") + assert adapter._internal_secret == "secret" + assert adapter._admin_token == "admin" + assert len(adapter._active_connections) == 0 + + +def test_send_no_connections(): + adapter = AgentWebSocketAdapter() + + async def run(): + msg = BackendMessage(kind="user_message", session_id="abc", payload={"message": "hello"}) + await adapter.send(msg) + + # Should have generated an error event in queue + event = await adapter._event_queue.get() + assert event.kind == "error" + assert event.session_id == "abc" + assert "No agent connected" in event.data["content"] + + asyncio.run(run()) + + +def test_websocket_unauthenticated_close(): + from fastapi.websockets import WebSocketDisconnect + + app = FastAPI() + adapter = AgentWebSocketAdapter(internal_secret="super-secret") + + @app.websocket("/api/agent/ws") + async def ws_endpoint(websocket: WebSocket): + await adapter.handle_websocket(websocket) + + client = TestClient(app) + + # Test unauthenticated connection is rejected/closed + with pytest.raises(WebSocketDisconnect) as excinfo: + with client.websocket_connect("/api/agent/ws") as websocket: + websocket.receive_json() + assert excinfo.value.code == 4001 + +def test_websocket_authenticated_integration(): + app = FastAPI() + adapter = AgentWebSocketAdapter(internal_secret="super-secret") + + @app.websocket("/api/agent/ws") + async def ws_endpoint(websocket: WebSocket): + await adapter.handle_websocket(websocket) + + client = TestClient(app) + + # Test authenticated connection with token query param + with client.websocket_connect("/api/agent/ws?token=super-secret") as websocket: + assert len(adapter._active_connections) == 1 + + # Test sending a backend message (Warren -> Agent) + msg = BackendMessage( + kind="user_message", session_id="session123", payload={"message": "hello agent"} + ) + + # Run async send + asyncio.run(adapter.send(msg)) + + # Client receives message + data = websocket.receive_json() + assert data["kind"] == "user_message" + assert data["session_id"] == "session123" + assert data["payload"]["message"] == "hello agent" + + # Test client sending an event back to Warren (Agent -> Warren) + event_payload = { + "kind": "text", + "session_id": "session123", + "data": {"type": "text", "content": "hello user"}, + } + websocket.send_json(event_payload) + + # Adapter receives event + async def get_event(): + return await adapter._event_queue.get() + + event = asyncio.run(get_event()) + assert event.kind == "text" + assert event.session_id == "session123" + assert event.data["content"] == "hello user" From 58b6013c111a737c03f94852a06a1538176a9f34 Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 13:43:47 -0400 Subject: [PATCH 02/11] refactor: remove nanoclaw internal callback/config/typing endpoints Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 36fe0d6ad374 --- docs/plans/2026-02-22-voice-channel-design.md | 146 +++ docs/plans/2026-02-22-voice-channel-plan.md | 1164 +++++++++++++++++ .../plans/2026-06-18-remove-nanoclaw.md | 413 ++++++ server/src/warren/app.py | 114 -- server/tests/test_app.py | 65 - server/tests/test_nanoclaw_callback.py | 102 -- 6 files changed, 1723 insertions(+), 281 deletions(-) create mode 100644 docs/plans/2026-02-22-voice-channel-design.md create mode 100644 docs/plans/2026-02-22-voice-channel-plan.md create mode 100644 docs/superpowers/plans/2026-06-18-remove-nanoclaw.md delete mode 100644 server/tests/test_nanoclaw_callback.py diff --git a/docs/plans/2026-02-22-voice-channel-design.md b/docs/plans/2026-02-22-voice-channel-design.md new file mode 100644 index 0000000..9de402d --- /dev/null +++ b/docs/plans/2026-02-22-voice-channel-design.md @@ -0,0 +1,146 @@ +# Voice Channel Design + +## Problem + +The R1's `webkitSpeechRecognition` doesn't work in the Android WebView. The R1 +natively supports OpenClaw-compatible voice gateways: hardware PTT button +triggers cloud STT, transcripts arrive over WebSocket as plain text, responses +go back over the same WS for native TTS. No audio processing needed on our end. + +Currently, Warren only has one input path (HTTP from the Creation browser). +Voice adds a second path that shares the same sessions. + +## Architecture + +Warren becomes a protocol translator. Both input paths feed the same sessions +and adapter layer. NanoClaw doesn't know or care how the user is talking. + +``` +R1 Browser (Creation) ──HTTP/SSE──┐ + β”œβ”€β”€ Warren ──adapter──▢ NanoClaw +R1 PTT (Voice) ───────WebSocketβ”€β”€β”€β”˜ +``` + +Both paths read from the same event stream via a new pub/sub `SessionBus` that +replaces the current single-consumer `event_queues` dict. + +## Components + +### 1. SessionBus + +**Problem:** `event_queues` is `dict[str, asyncio.Queue]` β€” one queue per +session, one consumer. Voice needs a second consumer on the same session. + +**Solution:** `SessionBus` β€” publish/subscribe, multiple queues per session. + +``` +callback ──publish──▢ SessionBus ──▢ SSE subscriber queue + ──▢ Voice WS subscriber queue +``` + +- SSE handler calls `bus.subscribe(session_id)`, gets its own queue +- Voice WS handler does the same +- Callback endpoint calls `bus.publish(session_id, event)` β€” all subscribers get it +- On disconnect, each handler calls `bus.unsubscribe()` + +**File:** `server/src/warren/bus.py` (~40 lines) + +### 2. Voice WebSocket Endpoint + +**Path:** `/ws/voice` on Warren's existing port (4030). + +Speaks a minimal subset of the OpenClaw protocol: + +| Step | Direction | Message | +|------|-----------|---------| +| 1 | Sβ†’C | `connect.challenge` with random nonce | +| 2 | Cβ†’S | `connect` with `authToken` (device token) + nonce | +| 3 | Sβ†’C | `hello-ok` or `hello-error` | +| 4 | Cβ†’S | `chat.send` with `{message}` | +| 5 | Sβ†’C | `chat` with `state: "delta"` (streaming) | +| 6 | Sβ†’C | `chat` with `state: "final"` (done) | +| - | Sβ†’C | `tick` keepalive every 15s | + +**Session binding:** Voice targets the device's most recently active +non-terminated session. If none exists, creates one with the first configured +repo when the first `chat.send` arrives. + +**Event filtering:** Voice only sends `text` and `result` events as TTS. +Tool events and status updates are skipped (the user doesn't need to hear +"Running Bash: git status"). + +**File:** `server/src/warren/voice.py` (~200 lines) + +### 3. Voice Pairing + +Admin endpoint `GET /pair/voice/qr` generates a QR code in the format the R1 +expects for gateway pairing: + +```json +{ + "type": "clawdbot-gateway", + "version": 1, + "ips": [""], + "port": 4030, + "token": "" +} +``` + +Reuses Warren's existing device token system. The token in the QR is a real +device token stored in the DB β€” same token used by Creation, same auth +middleware. + +**Added to:** `server/src/warren/auth.py` (one function) and `app.py` (one route) + +### 4. Config + +Single new env var: `WARREN_VOICE_ENABLED` (default: `false`). + +When disabled, the WS endpoint rejects connections with 403. When enabled, +accepts and runs the protocol. + +No new ports β€” voice runs on Warren's existing port 4030. FastAPI handles +WebSocket upgrades natively. + +## What Doesn't Change + +- **NanoClaw:** Zero modifications. Doesn't know voice exists. +- **Creation UI:** Unchanged. Voice messages appear in shared sessions. +- **Adapter layer:** Voice calls the same `adapter.send()` as HTTP. +- **Auth:** Reuses device tokens. No new auth mechanism. +- **Docker networking:** No new ports. WS upgrade on existing port 4030. + +## Event Flow + +``` +Voice (PTT press): + R1 hardware STT ──▢ transcript text + R1 WS client ──────▢ chat.send {message: "check the tests"} + Warren voice.py ───▢ adapter.send(BackendMessage(kind="user_message",...)) + NanoClaw ──────────▢ runs agent, streams callbacks + Warren callback ───▢ bus.publish(session_id, {type:"text", content:"..."}) + bus ───────────────▢ SSE queue (Creation sees it) + β–Ά Voice WS queue (voice.py sends chat delta) + R1 native TTS ────▢ speaks the response + +Creation (typed): + R1 browser ────────▢ POST /sessions/{id}/msg + Warren app.py ─────▢ adapter.send(BackendMessage(kind="user_message",...)) + NanoClaw ──────────▢ runs agent, streams callbacks + Warren callback ───▢ bus.publish(session_id, {type:"text", content:"..."}) + bus ───────────────▢ SSE queue (Creation sees it) + β–Ά Voice WS queue (if connected, speaks it) +``` + +Both paths are symmetric. A voice message shows up in the Creation chat. +A typed message plays through TTS if the voice WS is connected. + +## Open Questions + +1. **WS path:** The R1 firmware may expect the WebSocket at `/` rather than + `/ws/voice`. Need to test with a real device and adjust if needed. +2. **TLS:** Traefik handles TLS termination for HTTPS. Need to confirm it also + handles `wss://` upgrade correctly (it should by default). +3. **Text accumulation:** Should we accumulate text events into one final TTS + utterance, or stream deltas for real-time speech? Start with accumulating + (simpler, avoids choppy TTS), optimize later if latency is bad. diff --git a/docs/plans/2026-02-22-voice-channel-plan.md b/docs/plans/2026-02-22-voice-channel-plan.md new file mode 100644 index 0000000..f7d8d1a --- /dev/null +++ b/docs/plans/2026-02-22-voice-channel-plan.md @@ -0,0 +1,1164 @@ +# Voice Channel Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a WebSocket voice endpoint to Warren that speaks the R1's OpenClaw protocol, sharing sessions with the existing Creation UI. + +**Architecture:** Warren gets a `SessionBus` (pub/sub replacing single-consumer event queues), a voice WebSocket handler speaking the OpenClaw protocol, and a pairing endpoint. NanoClaw is untouched β€” Warren translates the voice protocol into the same adapter calls the HTTP path uses. + +**Tech Stack:** Python 3.11+, FastAPI WebSocket, asyncio, pydantic, pytest, `websockets` (test client) + +--- + +### Task 1: SessionBus + +Replace the single-consumer `event_queues: dict[str, asyncio.Queue]` with a +pub/sub bus that supports multiple subscribers per session. + +**Files:** +- Create: `server/src/warren/bus.py` +- Create: `server/tests/test_bus.py` + +**Step 1: Write the failing tests** + +```python +# server/tests/test_bus.py +"""Tests for SessionBus pub/sub.""" + +import asyncio +import pytest +from warren.bus import SessionBus + + +@pytest.fixture +def bus(): + return SessionBus() + + +@pytest.mark.asyncio +async def test_single_subscriber_receives_event(bus): + q = bus.subscribe("s1") + await bus.publish("s1", {"type": "text", "content": "hello"}) + event = q.get_nowait() + assert event == {"type": "text", "content": "hello"} + + +@pytest.mark.asyncio +async def test_multiple_subscribers_receive_same_event(bus): + q1 = bus.subscribe("s1") + q2 = bus.subscribe("s1") + await bus.publish("s1", {"type": "text", "content": "hello"}) + assert q1.get_nowait() == {"type": "text", "content": "hello"} + assert q2.get_nowait() == {"type": "text", "content": "hello"} + + +@pytest.mark.asyncio +async def test_unsubscribe_stops_delivery(bus): + q = bus.subscribe("s1") + bus.unsubscribe("s1", q) + await bus.publish("s1", {"type": "text", "content": "hello"}) + assert q.empty() + + +@pytest.mark.asyncio +async def test_publish_to_nonexistent_session_is_noop(bus): + # Should not raise + await bus.publish("nonexistent", {"type": "text"}) + + +@pytest.mark.asyncio +async def test_different_sessions_are_isolated(bus): + q1 = bus.subscribe("s1") + q2 = bus.subscribe("s2") + await bus.publish("s1", {"type": "text", "content": "for s1"}) + assert not q1.empty() + assert q2.empty() + + +@pytest.mark.asyncio +async def test_has_subscribers(bus): + assert not bus.has_subscribers("s1") + q = bus.subscribe("s1") + assert bus.has_subscribers("s1") + bus.unsubscribe("s1", q) + assert not bus.has_subscribers("s1") + + +@pytest.mark.asyncio +async def test_remove_session(bus): + q = bus.subscribe("s1") + bus.remove_session("s1") + assert not bus.has_subscribers("s1") + # Queue still exists but won't receive new events + await bus.publish("s1", {"type": "text"}) + assert q.empty() +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd server && uv run pytest tests/test_bus.py -v` +Expected: FAIL β€” `ModuleNotFoundError: No module named 'warren.bus'` + +**Step 3: Implement SessionBus** + +```python +# server/src/warren/bus.py +"""Pub/sub event bus for session events. + +Multiple consumers (SSE, voice WS) can subscribe to the same session. +Each subscriber gets its own asyncio.Queue. Publishing broadcasts to all. +""" + +from __future__ import annotations + +import asyncio + + +class SessionBus: + def __init__(self) -> None: + self._subs: dict[str, list[asyncio.Queue]] = {} + + def subscribe(self, session_id: str) -> asyncio.Queue: + """Create and return a new subscriber queue for a session.""" + q: asyncio.Queue = asyncio.Queue() + self._subs.setdefault(session_id, []).append(q) + return q + + def unsubscribe(self, session_id: str, q: asyncio.Queue) -> None: + """Remove a subscriber queue. Safe to call if already removed.""" + subs = self._subs.get(session_id, []) + if q in subs: + subs.remove(q) + if not subs: + self._subs.pop(session_id, None) + + async def publish(self, session_id: str, event: dict) -> None: + """Send an event to all subscribers of a session.""" + for q in self._subs.get(session_id, []): + await q.put(event) + + def has_subscribers(self, session_id: str) -> bool: + """Check if any queues are subscribed to a session.""" + return bool(self._subs.get(session_id)) + + def remove_session(self, session_id: str) -> None: + """Remove all subscribers for a session.""" + self._subs.pop(session_id, None) +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd server && uv run pytest tests/test_bus.py -v` +Expected: 7 passed + +**Step 5: Commit** + +```bash +git add server/src/warren/bus.py server/tests/test_bus.py +git commit -m "feat: add SessionBus pub/sub for multi-consumer event delivery" +``` + +--- + +### Task 2: Migrate app.py from event_queues to SessionBus + +Replace all `event_queues` usage in `app.py` with `SessionBus`. This is a +refactor β€” behavior is identical from the outside, but now supports multiple +subscribers. + +**Files:** +- Modify: `server/src/warren/app.py` + +**Changes summary:** + +1. **Lifespan** (line 148-149): Replace `app.state.event_queues: dict[str, asyncio.Queue] = {}` + with `app.state.bus = SessionBus()` + +2. **event_pump** (line 85-116): Change `queue = event_queues.get(event.session_id)` / + `await queue.put(event.data)` to `await bus.publish(event.session_id, event.data)`. + The DB persistence logic stays the same but uses `bus.has_subscribers()` instead of + checking dict membership. + +3. **create_session** (line 477): Replace `queues[session_id] = asyncio.Queue()` with + `bus.subscribe(session_id)` β€” but we don't need the queue here yet. Actually, we just + need the session to exist in the bus. The SSE handler will subscribe when it connects. + Remove the queue creation entirely β€” SSE `stream_session` already creates/gets a queue. + +4. **send_message** (lines 521-522): Remove `if session_id not in queues: queues[session_id] = asyncio.Queue()`. + The bus handles this automatically when SSE subscribes. + +5. **stream_session** (lines 557-585): Replace queue lookup with `bus.subscribe()`. Add + cleanup via `bus.unsubscribe()` when the SSE generator exits. + +6. **stop_session** (lines 600-603): Replace `queue.put()` with `bus.publish()`. + +7. **delete_session** (lines 617-618): Replace `queues.pop()` with `bus.remove_session()`. + +8. **nanoclaw_callback** (lines 775-819): Replace all `queue = queues.get(session_id)` / + `await queue.put(...)` with `await bus.publish(session_id, ...)`. Remove queue existence + checks β€” publishing to a session with no subscribers is a safe no-op. + +9. **nanoclaw_typing** (lines 827-831): Same pattern β€” replace queue.put with bus.publish. + +**Step 1: Apply the refactor** + +In `app.py`, add import: +```python +from warren.bus import SessionBus +``` + +Change `event_pump` signature and body: +```python +async def event_pump(adapter, bus: SessionBus, db): + """Route backend events to session subscribers.""" + async for event in adapter.events(): + await bus.publish(event.session_id, event.data) + if event.kind == "result" and event.data.get("session_id"): + await db.update_claude_session_id(event.session_id, event.data["session_id"]) + + # Save assistant messages to database + event_type = event.data.get("type") + if event_type in ["text", "result", "tool"]: + content = "" + if event_type == "text": + content = event.data.get("content", "") + elif event_type == "result": + content = event.data.get("summary", "") + elif event_type == "tool": + tool = event.data.get("action", event.data.get("tool", "")) + detail = event.data.get( + "file", event.data.get("cmd", event.data.get("summary", "")) + ) + content = f"{tool} {detail}".strip() + + if content: + await db.save_message( + event.session_id, + content, + role="assistant", + event_type=event_type, + event_data=json.dumps(event.data), + ) +``` + +Change lifespan: +```python +app.state.bus = SessionBus() + +pump_task = asyncio.create_task( + event_pump(adapter, app.state.bus, db) +) +``` + +Change `create_session` β€” remove queue creation line (`queues[session_id] = asyncio.Queue()`). +The bus doesn't need pre-registration. + +Change `send_message` β€” remove queue existence check block. + +Change `stream_session`: +```python +bus: SessionBus = app.state.bus +queue = bus.subscribe(session_id) + +# Drain stale events (for backward compat with `fresh` param) +if fresh: + while not queue.empty(): + try: + queue.get_nowait() + except asyncio.QueueEmpty: + break + +async def event_generator(): + try: + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=30.0) + yield { + "event": event.get("type", "message"), + "data": json.dumps(event), + } + if event.get("type") == "status" and event.get("state") == "waiting": + break + except asyncio.TimeoutError: + yield {"event": "ping", "data": ""} + finally: + bus.unsubscribe(session_id, queue) + +return EventSourceResponse(event_generator()) +``` + +Change `stop_session`: +```python +bus: SessionBus = app.state.bus +await bus.publish(session_id, {"type": "status", "state": "waiting"}) +``` + +Change `delete_session`: +```python +bus: SessionBus = app.state.bus +bus.remove_session(session_id) +``` + +Change `nanoclaw_callback`: +```python +bus: SessionBus = request.app.state.bus +# Replace all `queue = queues.get(session_id)` / `await queue.put(event_data)` +# with `await bus.publish(session_id, event_data)` +# Remove if-queue checks β€” publish to no subscribers is a no-op +``` + +Change `nanoclaw_typing`: +```python +bus: SessionBus = request.app.state.bus +state = "working" if is_typing else "waiting" +await bus.publish(session_id, {"type": "status", "state": state}) +``` + +Change `monitor_agents`: +```python +# This returned list(queues.keys()). With SessionBus, expose _subs keys. +# Add a method to SessionBus: +def active_sessions(self) -> list[str]: + return list(self._subs.keys()) +``` + +Then in the endpoint: +```python +bus: SessionBus = request.app.state.bus +return {"active_sessions": bus.active_sessions()} +``` + +**Step 2: Run existing tests** + +Run: `cd server && uv run pytest tests/ -v` +Expected: All existing tests still pass. + +Run: `cd server && uv run ruff check src/ tests/` +Expected: Clean. + +**Step 3: Commit** + +```bash +git add server/src/warren/app.py server/src/warren/bus.py +git commit -m "refactor: replace event_queues with SessionBus pub/sub" +``` + +--- + +### Task 3: Voice Protocol Module + +Message types and builders for the OpenClaw voice protocol. Pure functions, +no I/O β€” easy to test. + +**Files:** +- Create: `server/src/warren/voice_protocol.py` +- Create: `server/tests/test_voice_protocol.py` + +**Step 1: Write the failing tests** + +```python +# server/tests/test_voice_protocol.py +"""Tests for OpenClaw voice protocol message builders/parsers.""" + +import json +import pytest +from warren.voice_protocol import ( + build_challenge, + build_hello_ok, + build_hello_error, + build_chat_delta, + build_chat_final, + build_tick, + parse_client_message, +) + + +def test_build_challenge(): + msg = build_challenge("abc123") + assert msg["type"] == "connect.challenge" + assert msg["nonce"] == "abc123" + + +def test_build_hello_ok(): + msg = build_hello_ok("device-1") + assert msg["type"] == "hello-ok" + assert msg["deviceId"] == "device-1" + + +def test_build_hello_error(): + msg = build_hello_error("bad token") + assert msg["type"] == "hello-error" + assert msg["reason"] == "bad token" + + +def test_build_chat_delta(): + msg = build_chat_delta("partial text") + assert msg["type"] == "chat" + assert msg["data"]["state"] == "delta" + assert msg["data"]["text"] == "partial text" + + +def test_build_chat_final(): + msg = build_chat_final("full response") + assert msg["type"] == "chat" + assert msg["data"]["state"] == "final" + assert msg["data"]["text"] == "full response" + + +def test_build_tick(): + msg = build_tick() + assert msg["type"] == "tick" + assert "timestamp" in msg + + +def test_parse_connect_message(): + raw = json.dumps({ + "type": "connect", + "authToken": "tok123", + "nonce": "abc", + "protocol": "1.0", + }) + parsed = parse_client_message(raw) + assert parsed["type"] == "connect" + assert parsed["authToken"] == "tok123" + assert parsed["nonce"] == "abc" + + +def test_parse_chat_send(): + raw = json.dumps({ + "type": "chat.send", + "data": {"message": "hello world"}, + }) + parsed = parse_client_message(raw) + assert parsed["type"] == "chat.send" + assert parsed["data"]["message"] == "hello world" + + +def test_parse_invalid_json(): + with pytest.raises(ValueError, match="Invalid message"): + parse_client_message("not json {{{") + + +def test_parse_missing_type(): + with pytest.raises(ValueError, match="Missing 'type'"): + parse_client_message(json.dumps({"data": "no type"})) +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd server && uv run pytest tests/test_voice_protocol.py -v` +Expected: FAIL β€” `ModuleNotFoundError` + +**Step 3: Implement the protocol module** + +```python +# server/src/warren/voice_protocol.py +"""OpenClaw voice protocol message builders and parsers. + +Implements the minimal subset of the OpenClaw WebSocket protocol needed +for R1 PTT voice input/output. Pure functions, no I/O. + +Protocol flow: + Sβ†’C: connect.challenge {nonce} + Cβ†’S: connect {authToken, nonce, protocol} + Sβ†’C: hello-ok {deviceId} | hello-error {reason} + Cβ†’S: chat.send {data: {message}} + Sβ†’C: chat {data: {state: "delta"|"final", text}} + Sβ†’C: tick {timestamp} +""" + +from __future__ import annotations + +import json +import time + + +def build_challenge(nonce: str) -> dict: + return {"type": "connect.challenge", "nonce": nonce} + + +def build_hello_ok(device_id: str) -> dict: + return {"type": "hello-ok", "deviceId": device_id} + + +def build_hello_error(reason: str) -> dict: + return {"type": "hello-error", "reason": reason} + + +def build_chat_delta(text: str) -> dict: + return {"type": "chat", "data": {"state": "delta", "text": text}} + + +def build_chat_final(text: str) -> dict: + return {"type": "chat", "data": {"state": "final", "text": text}} + + +def build_tick() -> dict: + return {"type": "tick", "timestamp": int(time.time() * 1000)} + + +def parse_client_message(raw: str) -> dict: + """Parse a client WebSocket message. Raises ValueError on bad input.""" + try: + msg = json.loads(raw) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid message: {e}") from e + if "type" not in msg: + raise ValueError("Missing 'type' field in message") + return msg +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd server && uv run pytest tests/test_voice_protocol.py -v` +Expected: 10 passed + +**Step 5: Commit** + +```bash +git add server/src/warren/voice_protocol.py server/tests/test_voice_protocol.py +git commit -m "feat: add OpenClaw voice protocol message builders/parsers" +``` + +--- + +### Task 4: Voice WebSocket Handler + +The core voice endpoint. Handles the OpenClaw handshake, routes `chat.send` +messages to sessions via the adapter, and streams responses back as `chat` +events for TTS. + +**Files:** +- Create: `server/src/warren/voice.py` +- Create: `server/tests/test_voice.py` + +**Step 1: Write the failing tests** + +```python +# server/tests/test_voice.py +"""Tests for voice WebSocket handler.""" + +import asyncio +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from fastapi.testclient import TestClient +from starlette.testclient import TestClient as StarletteTestClient + +from warren.app import create_app +from warren.config import Settings + + +@pytest.fixture +def settings(tmp_path): + db_path = str(tmp_path / "test.db") + config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text("repos: {}") + return Settings( + db=db_path, + config=config_path, + backend="direct", + admin_token="admin123", + voice_enabled=True, + ) + + +@pytest.fixture +def app(settings): + return create_app(settings) + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def test_voice_disabled_rejects_connection(tmp_path): + """When voice_enabled=False, WS connections are rejected.""" + db_path = str(tmp_path / "test.db") + config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text("repos: {}") + s = Settings( + db=db_path, + config=config_path, + backend="direct", + admin_token="admin123", + voice_enabled=False, + ) + app = create_app(s) + client = TestClient(app) + with pytest.raises(Exception): + with client.websocket_connect("/ws/voice"): + pass + + +def test_handshake_sends_challenge(client): + """Server sends connect.challenge immediately on connection.""" + with client.websocket_connect("/ws/voice") as ws: + msg = json.loads(ws.receive_text()) + assert msg["type"] == "connect.challenge" + assert "nonce" in msg + + +def test_handshake_valid_token(client): + """Valid device token completes handshake with hello-ok.""" + # Create a device token via the admin API + resp = client.get("/pair", headers={"x-admin-token": "admin123"}) + pairing_token = resp.json()["pairing_token"] + resp = client.post("/pair/confirm", json={ + "pairing_token": pairing_token, + "device_name": "test-voice", + }) + device_token = resp.json()["device_token"] + + with client.websocket_connect("/ws/voice") as ws: + challenge = json.loads(ws.receive_text()) + nonce = challenge["nonce"] + ws.send_text(json.dumps({ + "type": "connect", + "authToken": device_token, + "nonce": nonce, + "protocol": "1.0", + })) + hello = json.loads(ws.receive_text()) + assert hello["type"] == "hello-ok" + + +def test_handshake_invalid_token(client): + """Invalid token gets hello-error and connection closes.""" + with client.websocket_connect("/ws/voice") as ws: + challenge = json.loads(ws.receive_text()) + ws.send_text(json.dumps({ + "type": "connect", + "authToken": "bad-token", + "nonce": challenge["nonce"], + "protocol": "1.0", + })) + hello = json.loads(ws.receive_text()) + assert hello["type"] == "hello-error" +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd server && uv run pytest tests/test_voice.py -v` +Expected: FAIL β€” voice_enabled not a valid Setting field / no /ws/voice route + +**Step 3: Add `voice_enabled` to Settings** + +In `server/src/warren/config.py`, add to `Settings`: + +```python +voice_enabled: bool = False +``` + +**Step 4: Implement the voice handler** + +```python +# server/src/warren/voice.py +"""Voice WebSocket handler β€” OpenClaw protocol for R1 PTT. + +Handles the handshake, routes chat.send messages to the active session +via the adapter, and streams responses back as chat events for TTS. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import secrets + +from fastapi import WebSocket, WebSocketDisconnect + +from warren.bus import SessionBus +from warren.db import Database +from warren.voice_protocol import ( + build_challenge, + build_chat_delta, + build_chat_final, + build_hello_error, + build_hello_ok, + build_tick, + parse_client_message, +) + +logger = logging.getLogger(__name__) + +# How often to send keepalive ticks (seconds) +TICK_INTERVAL = 15 + + +async def voice_handler( + ws: WebSocket, + bus: SessionBus, + db: Database, + adapter, + config, + voice_enabled: bool, +) -> None: + """Main voice WebSocket handler. Called from the app route.""" + if not voice_enabled: + await ws.close(code=4003, reason="Voice disabled") + return + + await ws.accept() + + # --- Handshake --- + nonce = secrets.token_hex(16) + await ws.send_text(json.dumps(build_challenge(nonce))) + + try: + raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0) + except (asyncio.TimeoutError, WebSocketDisconnect): + return + + try: + msg = parse_client_message(raw) + except ValueError: + await ws.send_text(json.dumps(build_hello_error("invalid message"))) + await ws.close() + return + + if msg.get("type") != "connect": + await ws.send_text(json.dumps(build_hello_error("expected connect"))) + await ws.close() + return + + token = msg.get("authToken", "") + if not await db.validate_device_token(token): + await ws.send_text(json.dumps(build_hello_error("invalid token"))) + await ws.close() + return + + device_id = token[:8] + await ws.send_text(json.dumps(build_hello_ok(device_id))) + logger.info("Voice client authenticated: %s", device_id) + + # --- Connected session --- + # Track the session this voice connection is bound to + active_session_id: str | None = None + event_queue: asyncio.Queue | None = None + + async def find_or_create_session(message: str) -> str: + """Find the most recent active session or create one.""" + nonlocal active_session_id, event_queue + + # If already bound to a session, reuse it + if active_session_id: + session = await db.get_session(active_session_id) + if session and session.get("status") != "terminated": + return active_session_id + + # Find most recent non-terminated session + sessions = await db.list_sessions() + if sessions: + active_session_id = sessions[0]["id"] + else: + # Create a new session with the first configured repo + import uuid + + session_id = uuid.uuid4().hex + repo_name = "" + if config.repos: + repo_name = next(iter(config.repos)) + await db.create_session(session_id, "voice session", repo_name) + active_session_id = session_id + + # Subscribe to events for this session + if event_queue: + bus.unsubscribe(active_session_id, event_queue) + event_queue = bus.subscribe(active_session_id) + return active_session_id + + async def handle_chat_send(msg: dict) -> None: + """Route a voice message to the active session.""" + data = msg.get("data", {}) + message = data.get("message", "") + if not message: + return + + session_id = await find_or_create_session(message) + session = await db.get_session(session_id) + + repo_path = "" + model = "" + append_system_prompt = "" + repo_name = session.get("repo", "") if session else "" + + if repo_name and repo_name in config.repos: + repo_config = config.repos[repo_name] + repo_path = repo_config.path + model = repo_config.model + append_system_prompt = repo_config.append_system_prompt + + await db.touch(session_id) + await db.save_message(session_id, message) + await db.update_status(session_id, "active") + + claude_sid = session.get("claude_session_id") if session else None + + from warren.adapters import BackendMessage + + await adapter.send( + BackendMessage( + kind="user_message" if claude_sid else "new_session", + session_id=session_id, + payload={ + "message": message, + "repo_path": repo_path, + "resume_session_id": claude_sid, + "model": model, + "append_system_prompt": append_system_prompt, + }, + ) + ) + + async def tick_loop(): + """Send keepalive ticks.""" + try: + while True: + await asyncio.sleep(TICK_INTERVAL) + await ws.send_text(json.dumps(build_tick())) + except (WebSocketDisconnect, Exception): + pass + + async def event_relay(): + """Relay session events to voice client as chat messages.""" + nonlocal event_queue + accumulated_text = "" + try: + while True: + if not event_queue: + await asyncio.sleep(0.1) + continue + try: + event = await asyncio.wait_for(event_queue.get(), timeout=1.0) + except asyncio.TimeoutError: + continue + + event_type = event.get("type") + if event_type == "text": + content = event.get("content", "") + if content: + accumulated_text += content + await ws.send_text(json.dumps(build_chat_delta(content))) + elif event_type == "result": + summary = event.get("summary", accumulated_text) + await ws.send_text(json.dumps(build_chat_final(summary or accumulated_text))) + accumulated_text = "" + # Skip tool and status events β€” not useful for TTS + except (WebSocketDisconnect, Exception): + pass + + # --- Main loop --- + tick_task = asyncio.create_task(tick_loop()) + relay_task = asyncio.create_task(event_relay()) + + try: + while True: + raw = await ws.receive_text() + try: + msg = parse_client_message(raw) + except ValueError: + continue + + if msg["type"] == "chat.send": + await handle_chat_send(msg) + except WebSocketDisconnect: + logger.info("Voice client disconnected: %s", device_id) + finally: + tick_task.cancel() + relay_task.cancel() + if event_queue and active_session_id: + bus.unsubscribe(active_session_id, event_queue) +``` + +**Step 5: Run tests** + +Run: `cd server && uv run pytest tests/test_voice.py -v` +Expected: All pass (note: test_voice_disabled may need adjustment based on how +FastAPI handles WebSocket rejection β€” may raise a different exception type) + +**Step 6: Run all tests + lint** + +Run: `cd server && uv run pytest tests/ -v && uv run ruff check src/ tests/` +Expected: All pass, clean. + +**Step 7: Commit** + +```bash +git add server/src/warren/voice.py server/tests/test_voice.py server/src/warren/config.py +git commit -m "feat: add voice WebSocket handler with OpenClaw protocol" +``` + +--- + +### Task 5: Voice Pairing + Wire into app.py + +Add the voice pairing QR endpoint and mount the voice WebSocket route. + +**Files:** +- Modify: `server/src/warren/auth.py` β€” add `generate_voice_qr_png()` +- Modify: `server/src/warren/app.py` β€” add `/ws/voice` route + `/pair/voice/qr` endpoint + +**Step 1: Add voice QR generator to auth.py** + +```python +# Add to server/src/warren/auth.py + +def generate_voice_qr_png(server_host: str, port: int, device_token: str) -> bytes: + """Generate R1 voice gateway pairing QR code. + + Encodes connection info in the format the R1 expects for + OpenClaw-compatible gateway pairing. + """ + payload = json.dumps( + { + "type": "clawdbot-gateway", + "version": 1, + "ips": [server_host], + "port": port, + "token": device_token, + } + ) + img = qrcode.make(payload) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() +``` + +**Step 2: Add routes to app.py** + +Add import: +```python +from fastapi import WebSocket +from warren.voice import voice_handler +from warren.auth import generate_voice_qr_png +``` + +Add WebSocket route (inside `create_app`, after the other routes): +```python +@app.websocket("/ws/voice") +async def voice_ws(websocket: WebSocket): + await voice_handler( + ws=websocket, + bus=app.state.bus, + db=app.state.db, + adapter=app.state.adapter, + config=app.state.config, + voice_enabled=settings.voice_enabled, + ) +``` + +Add pairing endpoint (near the other `/pair` routes): +```python +@app.get("/pair/voice/qr", dependencies=[Depends(require_admin)]) +async def pair_voice_qr(request: Request, db: Database = Depends(get_db)): + """Generate QR code for R1 voice gateway pairing.""" + if not settings.voice_enabled: + raise HTTPException(status_code=400, detail="Voice not enabled") + device_token = secrets.token_hex(32) + await db.store_device_token(device_token, "R1 Voice") + server_host = request.base_url.hostname + png = generate_voice_qr_png(server_host, settings.port, device_token) + return Response(content=png, media_type="image/png") +``` + +**Step 3: Run all tests + lint** + +Run: `cd server && uv run pytest tests/ -v && uv run ruff check src/ tests/` +Expected: All pass, clean. + +**Step 4: Commit** + +```bash +git add server/src/warren/auth.py server/src/warren/app.py +git commit -m "feat: add voice pairing QR + mount /ws/voice route" +``` + +--- + +### Task 6: Config + Docker + +Add the `WARREN_VOICE_ENABLED` environment variable to docker-compose and +update CLAUDE.md. + +**Files:** +- Modify: `docker-compose.yml` +- Modify: `CLAUDE.md` + +**Step 1: Add env var to docker-compose.yml** + +In the `warren` service `environment` section, add: +```yaml +- WARREN_VOICE_ENABLED=${WARREN_VOICE_ENABLED:-false} +``` + +**Step 2: Update CLAUDE.md** + +Add to the SSE Event Types section or a new "Voice" section: +```markdown +## Voice Channel + +Warren supports an optional WebSocket voice endpoint for R1 PTT interaction. +Enable with `WARREN_VOICE_ENABLED=true`. Speaks the OpenClaw protocol at +`/ws/voice`. Voice messages route to the same sessions as the Creation UI. + +Pair via `GET /pair/voice/qr` (admin). The QR encodes gateway connection info +for the R1's native voice assistant pairing. +``` + +**Step 3: Commit** + +```bash +git add docker-compose.yml CLAUDE.md +git commit -m "feat: add voice channel config and documentation" +``` + +--- + +### Task 7: Integration Test + +WebSocket integration test using `websockets` library to verify the full +handshake and chat flow. + +**Files:** +- Create: `server/tests/test_voice_integration.py` + +**Step 1: Write integration test** + +```python +# server/tests/test_voice_integration.py +"""Integration test for voice WebSocket β€” full handshake + chat flow.""" + +import asyncio +import json +import pytest +from fastapi.testclient import TestClient + +from warren.app import create_app +from warren.config import Settings + + +@pytest.fixture +def settings(tmp_path): + db_path = str(tmp_path / "test.db") + config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text("repos:\n test:\n path: /tmp/test") + return Settings( + db=db_path, + config=config_path, + backend="direct", + admin_token="admin123", + voice_enabled=True, + ) + + +@pytest.fixture +def app(settings): + return create_app(settings) + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def _get_device_token(client) -> str: + """Helper: create a device token via pairing flow.""" + resp = client.get("/pair", headers={"x-admin-token": "admin123"}) + pairing_token = resp.json()["pairing_token"] + resp = client.post("/pair/confirm", json={ + "pairing_token": pairing_token, + "device_name": "test-voice", + }) + return resp.json()["device_token"] + + +def _do_handshake(ws, device_token: str) -> dict: + """Helper: complete the OpenClaw handshake.""" + challenge = json.loads(ws.receive_text()) + assert challenge["type"] == "connect.challenge" + ws.send_text(json.dumps({ + "type": "connect", + "authToken": device_token, + "nonce": challenge["nonce"], + "protocol": "1.0", + })) + hello = json.loads(ws.receive_text()) + assert hello["type"] == "hello-ok" + return hello + + +def test_full_handshake_flow(client): + """Complete handshake: challenge β†’ connect β†’ hello-ok.""" + token = _get_device_token(client) + with client.websocket_connect("/ws/voice") as ws: + _do_handshake(ws, token) + + +def test_chat_send_creates_session(client): + """Sending chat.send when no sessions exist creates one.""" + token = _get_device_token(client) + + # Verify no sessions exist + resp = client.get("/sessions", headers={"Authorization": f"Bearer {token}"}) + assert resp.json() == [] + + with client.websocket_connect("/ws/voice") as ws: + _do_handshake(ws, token) + ws.send_text(json.dumps({ + "type": "chat.send", + "data": {"message": "hello from voice"}, + })) + # Give the handler a moment to process + # (In test, DirectAdapter may not produce output, but the session + # should be created) + + # Session should now exist + resp = client.get("/sessions", headers={"Authorization": f"Bearer {token}"}) + sessions = resp.json() + assert len(sessions) >= 1 + + +def test_voice_pairing_qr(client): + """GET /pair/voice/qr returns a PNG image.""" + resp = client.get("/pair/voice/qr", headers={"x-admin-token": "admin123"}) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "image/png" + # PNG magic bytes + assert resp.content[:4] == b"\x89PNG" +``` + +**Step 2: Run integration tests** + +Run: `cd server && uv run pytest tests/test_voice_integration.py -v` +Expected: All pass. + +**Step 3: Run full test suite + lint** + +Run: `cd server && uv run pytest tests/ -v && uv run ruff check src/ tests/` +Expected: All pass, clean. + +**Step 4: Commit** + +```bash +git add server/tests/test_voice_integration.py +git commit -m "test: add voice WebSocket integration tests" +``` + +--- + +## Verification Checklist + +After all tasks: + +1. `cd server && uv run ruff check src/ tests/` β€” lint clean +2. `cd server && uv run pytest tests/ -v` β€” all tests pass +3. `docker compose build warren` β€” builds clean +4. Deploy with `WARREN_VOICE_ENABLED=true` +5. `GET /pair/voice/qr` β€” generates QR PNG +6. Scan QR with R1 β€” confirms pairing +7. Press PTT on R1 β€” voice transcript appears in Warren session +8. Response plays back via R1 TTS +9. Same session visible in Creation browser diff --git a/docs/superpowers/plans/2026-06-18-remove-nanoclaw.md b/docs/superpowers/plans/2026-06-18-remove-nanoclaw.md new file mode 100644 index 0000000..cfc12a5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-remove-nanoclaw.md @@ -0,0 +1,413 @@ +# Remove NanoClaw 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:** Remove all NanoClaw-specific code, config, tests, the git submodule, and docs from Warren, leaving a clean WebSocket-proxy + Orca backend architecture. + +**Architecture:** Warren keeps two backends selected by `settings.backend`: `websocket` (default, `AgentWebSocketAdapter`) and `orca` (`OrcaAdapter`). The `nanoclaw` branch, file-IPC transport, read-only SQLite monitoring, and HTTP callback/typing endpoints are deleted. Endpoints the R1 frontend still calls (`/monitor/tasks`, pause/resume) are kept as safe no-ops so the UI does not 404. + +**Tech Stack:** Python 3, FastAPI, pytest, ruff, uv. Work happens in the worktree `/home/dakota/Work/github/dsr-restyn/warren` on branch `feature/decouple-and-orca-proxy`. + +## Global Constraints + +- All commands run from `/home/dakota/Work/github/dsr-restyn/warren` (the PR-branch worktree), not the refactor worktree. +- Lint: `cd server && uv run ruff check src/ tests/` must pass before every commit. +- Tests: `cd server && uv run pytest -q` must pass (green) at the end of every task β€” the suite never goes red between commits. +- Keep `settings.internal_secret` (used by `AgentWebSocketAdapter` auth) and `settings.backend`. Remove only nanoclaw-specific fields. +- Keep the three frontend-facing monitor endpoints reachable (return empty/no-op), since `creation/js/api.js:135-143` calls them. +- One commit per task. Commit message trailer: `Co-Authored-By: Claude Opus 4.8 `. +- Do NOT touch the Orca adapter or the WebSocket adapter logic (out of scope; separate review). + +--- + +### Task 1: Remove NanoClaw internal HTTP endpoints + `require_internal` + +Removes the inbound callback/typing/config endpoints NanoClaw used, and the auth dependency that only guarded them. + +**Files:** +- Modify: `server/src/warren/app.py` β€” delete `require_internal` (~740-749), `/internal/nanoclaw/config` GET+PUT (~751-781), `/internal/nanoclaw/callback` (~783-839), `/internal/nanoclaw/typing` (~841-850) +- Modify: `server/tests/test_app.py` β€” delete `class TestInternalConfigEndpoints` (~409-472) +- Delete: `server/tests/test_nanoclaw_callback.py` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: removes `require_internal` symbol β€” no later task may reference it. + +- [ ] **Step 1: Delete the four nanoclaw internal endpoints and `require_internal` from `app.py`** + +Remove the `async def require_internal(...)` dependency and all four route handlers decorated with `dependencies=[Depends(require_internal)]` (`/internal/nanoclaw/config` GET, `/internal/nanoclaw/config` PUT, `/internal/nanoclaw/callback`, `/internal/nanoclaw/typing`). Leave the `/api/agent/ws` and voice WebSocket routes that follow intact. + +- [ ] **Step 2: Delete the callback test file** + +```bash +git rm server/tests/test_nanoclaw_callback.py +``` + +- [ ] **Step 3: Remove `TestInternalConfigEndpoints` from `test_app.py`** + +Delete the whole class (docstring `"Tests for /internal/nanoclaw/config (requires x-internal-secret)."`). + +- [ ] **Step 4: Verify the import for `secrets` is still needed** + +Run: `cd server && grep -n "secrets\." src/warren/app.py` +Expected: still used by `require_admin`/token compare. If zero hits, remove `import secrets`. + +- [ ] **Step 5: Lint + test** + +Run: `cd server && uv run ruff check src/ tests/ && uv run pytest -q` +Expected: PASS, no collection errors. + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "refactor: remove nanoclaw internal callback/config/typing endpoints" +``` + +--- + +### Task 2: Remove the NanoClaw adapter and swap test fixtures to WebSocket + +The largest task: deletes the adapter, its `create_adapter` branch and lifespan warning, and repoints every test fixture that instantiated `NanoClawAdapter` to `AgentWebSocketAdapter`. + +**Files:** +- Modify: `server/src/warren/app.py` β€” remove nanoclaw branch in `create_adapter` (~73-77) and the `if settings.backend == "nanoclaw":` API-key warning block in lifespan (~136-145) +- Delete: `server/src/warren/adapters/nanoclaw.py` +- Modify: `server/tests/test_adapters.py` β€” delete `class TestNanoClawAdapter` (~34-110) +- Modify: `server/tests/test_app.py`, `test_integration.py`, `test_streaming.py`, `test_voice.py`, `test_voice_integration.py`, `test_voice_pairing.py` β€” replace `NanoClawAdapter` fixtures with `AgentWebSocketAdapter` + +**Interfaces:** +- Consumes: `AgentWebSocketAdapter(internal_secret="", admin_token="")` from `warren.adapters.websocket` β€” constructor takes no IPC dir, `start()`/`stop()` are no-ops, `send()` queues an error event if no agent connected (safe for tests that only check session/DB behavior). +- Produces: `create_adapter` now returns only `OrcaAdapter` or `AgentWebSocketAdapter`. + +- [ ] **Step 1: Remove the nanoclaw branch from `create_adapter` in `app.py`** + +Delete the `if settings.backend == "nanoclaw": ...` block so the function starts at `if settings.backend == "orca":` then `else:` β†’ websocket. (Leave the orca/else branches unchanged.) + +- [ ] **Step 2: Remove the nanoclaw API-key warning in lifespan** + +Delete the `if settings.backend == "nanoclaw":` block (lines ~136-145) that warns about `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`. + +- [ ] **Step 3: Delete the adapter file** + +```bash +git rm server/src/warren/adapters/nanoclaw.py +``` + +- [ ] **Step 4: Delete `TestNanoClawAdapter` from `test_adapters.py`** + +Remove the entire class; keep the generic `BackendMessage`/`BackendEvent` tests. + +- [ ] **Step 5: Repoint test fixtures** + +In each of `test_app.py`, `test_integration.py`, `test_streaming.py`, `test_voice.py` (two fixtures), `test_voice_integration.py`, `test_voice_pairing.py`, replace the pattern: + +```python +from warren.adapters.nanoclaw import NanoClawAdapter +... +adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) +await adapter.start() +``` + +with: + +```python +from warren.adapters.websocket import AgentWebSocketAdapter +... +adapter = AgentWebSocketAdapter() +await adapter.start() +``` + +For `test_voice_integration.py`, keep the `adapter.send = AsyncMock()` line (still valid). Update any docstring/comment mentioning "NanoClaw backend". + +- [ ] **Step 6: Lint + test** + +Run: `cd server && uv run ruff check src/ tests/ && uv run pytest -q` +Expected: PASS. Watch for any test asserting IPC-file side effects β€” those belonged to the deleted `TestNanoClawAdapter` and should already be gone. + +- [ ] **Step 7: Commit** + +```bash +git add -A && git commit -m "refactor: remove NanoClawAdapter, default to websocket backend in tests" +``` + +--- + +### Task 3: Strip NanoClaw monitoring; keep frontend endpoints as no-ops + +Removes the SQLite reader and the nanoclaw health/task internals, but preserves the three endpoints the R1 UI calls. + +**Files:** +- Modify: `server/src/warren/app.py` β€” remove nanoclaw block in `/monitor/health` (~632-641); simplify `/monitor/tasks` to return `{"tasks": []}`; make pause/resume no-ops; remove `_nanoclaw_paths` (~56-68) once its last caller is gone +- Delete: `server/src/warren/nanoclaw_db.py` +- Delete: `server/tests/test_nanoclaw_db.py` +- Modify: `server/tests/test_monitor.py` β€” rename `test_returns_empty_when_no_nanoclaw` β†’ `test_returns_empty_tasks` (~100-113) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `_nanoclaw_paths` no longer exists β€” no later task may reference it. + +- [ ] **Step 1: Remove the nanoclaw branch in `/monitor/health`** + +Delete the `if settings.backend == "nanoclaw":` block that checks `os.path.exists(db_path)`. Keep the orca and websocket health branches. + +- [ ] **Step 2: Simplify `/monitor/tasks`** + +Replace the body with a backend-agnostic stub (frontend `getTasks()` still calls this): + +```python + @app.get("/monitor/tasks") + async def monitor_tasks( + request: Request, + _token: str = Depends(require_auth), + ): + # Live task monitoring was nanoclaw-specific; no tasks on websocket/orca. + return {"tasks": []} +``` + +- [ ] **Step 3: Make pause/resume no-ops (keep the routes β€” frontend calls them)** + +```python + @app.post("/monitor/tasks/{task_id}/pause") + async def pause_task(task_id: str, _token: str = Depends(require_auth)): + return {"status": "ignored", "reason": "Task control not supported on this backend"} + + @app.post("/monitor/tasks/{task_id}/resume") + async def resume_task(task_id: str, _token: str = Depends(require_auth)): + return {"status": "ignored", "reason": "Task control not supported on this backend"} +``` + +- [ ] **Step 4: Remove `_nanoclaw_paths` from `app.py`** + +Confirm no remaining callers, then delete the function: + +Run: `cd server && grep -n "_nanoclaw_paths" src/warren/app.py` +Expected: zero hits after Steps 1-3 β†’ delete the `def _nanoclaw_paths(...)` definition. + +- [ ] **Step 5: Delete the SQLite reader and its test** + +```bash +git rm server/src/warren/nanoclaw_db.py server/tests/test_nanoclaw_db.py +``` + +- [ ] **Step 6: Rename the monitor test** + +In `test_monitor.py`, rename `test_returns_empty_when_no_nanoclaw` to `test_returns_empty_tasks` and drop any nanoclaw wording. The assertion `data["tasks"] == []` stays valid. + +- [ ] **Step 7: Lint + test** + +Run: `cd server && uv run ruff check src/ tests/ && uv run pytest -q` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add -A && git commit -m "refactor: drop nanoclaw monitoring, keep frontend task endpoints as no-ops" +``` + +--- + +### Task 4: Delete the IPC module + +By now `ipc.py` has no importers (adapter gone in Task 2, pause/resume no-ops in Task 3). + +**Files:** +- Delete: `server/src/warren/ipc.py` +- Delete: `server/tests/test_ipc.py` + +- [ ] **Step 1: Confirm no importers remain** + +Run: `cd server && grep -rn "from warren.ipc\|import ipc\|write_ipc" src/ tests/` +Expected: zero hits. If any remain, stop and fix the referencing task first. + +- [ ] **Step 2: Delete the files** + +```bash +git rm server/src/warren/ipc.py server/tests/test_ipc.py +``` + +- [ ] **Step 3: Lint + test** + +Run: `cd server && uv run ruff check src/ tests/ && uv run pytest -q` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "refactor: remove file-IPC module (nanoclaw-only)" +``` + +--- + +### Task 5: Clean up config fields + +Removes nanoclaw-only settings; keeps `internal_secret` and `backend`. + +**Files:** +- Modify: `server/src/warren/config.py` β€” remove `backend_config` (~50), `nanoclaw_db` (~65), `nanoclaw_ipc_dir` (~66) +- Modify: `server/tests/test_config.py` β€” delete `test_config_with_backend_config` and `test_config_without_backend_config` (~194-220) + +**Interfaces:** +- Consumes: nothing. +- Produces: `Settings` no longer has `nanoclaw_db`/`nanoclaw_ipc_dir`; `WarrenConfig` no longer has `backend_config`. + +- [ ] **Step 1: Remove the three fields from `config.py`** + +Delete `backend_config: dict[str, dict] = {}` from `WarrenConfig`, and `nanoclaw_db: str = ""` + `nanoclaw_ipc_dir: str = ""` from `Settings`. Keep `internal_secret` and `backend`. + +- [ ] **Step 2: Confirm no remaining references** + +Run: `cd server && grep -rn "backend_config\|nanoclaw_db\|nanoclaw_ipc_dir" src/ tests/` +Expected: zero hits. + +- [ ] **Step 3: Delete the two backend_config tests in `test_config.py`** + +Remove `test_config_with_backend_config` and `test_config_without_backend_config`. + +- [ ] **Step 4: Lint + test** + +Run: `cd server && uv run ruff check src/ tests/ && uv run pytest -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "refactor: remove nanoclaw config fields" +``` + +--- + +### Task 6: Clean Docker entrypoint + +**Files:** +- Modify: `server/entrypoint.sh` β€” remove the nanoclaw IPC dir setup (~8-10) + +- [ ] **Step 1: Remove the nanoclaw lines** + +Delete: +```bash + # Ensure nanoclaw IPC dirs are writable by appuser (nanoclaw runs as root) + mkdir -p /data/nanoclaw/ipc/warren/messages + chown -R appuser:appuser /data/nanoclaw/ipc/warren +``` + +- [ ] **Step 2: Sanity-check the script parses** + +Run: `bash -n server/entrypoint.sh` +Expected: no output (valid syntax). + +- [ ] **Step 3: Commit** + +```bash +git add -A && git commit -m "chore: drop nanoclaw IPC dir setup from entrypoint" +``` + +--- + +### Task 7: Remove the NanoClaw git submodule + +**Files:** +- Delete: `.gitmodules` (only entry is nanoclaw) +- Delete: `nanoclaw/` (submodule working tree) +- Modify: `.git/config` (remove submodule section) + +- [ ] **Step 1: Deinit and remove the submodule** + +```bash +git submodule deinit -f nanoclaw +git rm -f nanoclaw +rm -rf .git/modules/nanoclaw +``` + +- [ ] **Step 2: Remove the now-empty `.gitmodules`** + +Run: `cat .gitmodules` +Expected: empty or only the nanoclaw section. If empty/only-nanoclaw: +```bash +git rm -f .gitmodules +``` + +- [ ] **Step 3: Verify clean status** + +Run: `git status && git config --file .git/config --get-regexp submodule || true` +Expected: no `submodule.nanoclaw` section remains; `nanoclaw/` gone. + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "chore: remove nanoclaw git submodule" +``` + +--- + +### Task 8: Update docs and skills; delete nanoclaw design docs + +**Files:** +- Modify: `CLAUDE.md` β€” rewrite intro, architecture list, key abstractions, deploy section, callback-types section to drop nanoclaw +- Modify: `README.md` β€” description, ASCII diagram, prerequisites, clone command (drop `--recurse-submodules`), `WARREN_NANOCLAW_*` env rows +- Modify: `.claude/skills/setup/SKILL.md` (~51), `.claude/skills/customize/SKILL.md` (~18), `.claude/skills/debug/SKILL.md` (nanoclaw references) +- Delete: `docs/plans/2026-02-20-nanoclaw-integration-design.md`, `-integration-plan.md`, `-service-design.md`, `-service-plan.md` + +- [ ] **Step 1: Delete the four nanoclaw design docs** + +```bash +git rm docs/plans/2026-02-20-nanoclaw-integration-design.md \ + docs/plans/2026-02-20-nanoclaw-integration-plan.md \ + docs/plans/2026-02-20-nanoclaw-service-design.md \ + docs/plans/2026-02-20-nanoclaw-service-plan.md +``` +(Leave other dated historical plans untouched β€” they are records, not live docs.) + +- [ ] **Step 2: Rewrite `CLAUDE.md`** + +Replace nanoclaw framing with the WebSocket-proxy/Orca model: intro sentence, the `adapters/` file list (drop `nanoclaw.py`, `ipc.py`, `nanoclaw_db.py`), key-abstractions bullets, the "Deploy with NanoClaw" section, and the "NanoClaw Callback Types" section. Reference `PROTOCOL.md` for the WebSocket protocol. + +- [ ] **Step 3: Rewrite `README.md`** + +Update the one-line description, ASCII flow diagram (Warren β†’ WebSocket β†’ Agent), prerequisites, `git clone` (remove `--recurse-submodules`), and remove the `WARREN_NANOCLAW_IPC_DIR` / `WARREN_NANOCLAW_DB` env-var rows. Keep `WARREN_INTERNAL_SECRET` (websocket auth). + +- [ ] **Step 4: Update the three skills** + +In `setup`, `customize`, and `debug` SKILL.md files, replace nanoclaw references with websocket/orca equivalents (backend options, adapter list, debug steps). + +- [ ] **Step 5: Final repo-wide nanoclaw sweep** + +Run: `grep -rin nanoclaw . --exclude-dir=.git` +Expected: only acceptable residue β€” incidental mentions inside *other* historical `docs/plans/*` files. Zero hits under `server/src`, `server/tests`, `creation/`, `CLAUDE.md`, `README.md`, `.claude/skills/`. If any code/doc-in-scope hit remains, fix it. + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "docs: remove nanoclaw references from docs and skills" +``` + +--- + +### Task 9: Full verification + +- [ ] **Step 1: Lint** + +Run: `cd server && uv run ruff check src/ tests/` +Expected: PASS. + +- [ ] **Step 2: Full test suite** + +Run: `cd server && uv run pytest -q` +Expected: PASS, no skipped-due-to-import-error, no collection errors. + +- [ ] **Step 3: App boots and routes resolve** + +Run: `cd server && uv run python -c "from warren.app import create_app; app = create_app(); print(sorted({r.path for r in app.routes}))"` +Expected: prints routes; includes `/api/agent/ws`, `/monitor/tasks`; excludes any `/internal/nanoclaw/*`. + +- [ ] **Step 4: Confirm no nanoclaw imports survive** + +Run: `cd server && grep -rin "nanoclaw\|from warren.ipc\|write_ipc" src/ tests/` +Expected: zero hits. + +--- + +## Open questions / out of scope + +1. **Frontend monitor view.** The R1 `creation/` monitor view still renders a task list and (likely) pause/resume controls that are now permanently empty/no-op. Cleaning that UI is a separate frontend task β€” not included here to keep changes minimal. Flag for follow-up if the empty monitor view is undesirable. +2. **Orca adapter.** Left untouched. The earlier architectural sketch suggested Orca could become an external WS client too, but that is a separate decision beyond "drop nanoclaw." +3. **`PROTOCOL.md` / `examples/agent.py`** already describe the websocket protocol and need no nanoclaw removal. diff --git a/server/src/warren/app.py b/server/src/warren/app.py index b516f07..543510b 100644 --- a/server/src/warren/app.py +++ b/server/src/warren/app.py @@ -9,7 +9,6 @@ from pathlib import Path from typing import Annotated -import yaml from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request, Response, WebSocket from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -735,119 +734,6 @@ async def resume_task( ) return {"status": "resumed", "task_id": task_id} - # -- NanoClaw internal callbacks (shared secret auth) -- - - async def require_internal(request: Request): - internal_secret = request.app.state.settings.internal_secret - if not internal_secret: - raise HTTPException( - status_code=503, - detail="Internal endpoints not configured (set WARREN_INTERNAL_SECRET)", - ) - provided = request.headers.get("x-internal-secret", "") - if not provided or not secrets.compare_digest(provided, internal_secret): - raise HTTPException(status_code=403, detail="Invalid internal secret") - - @app.get("/internal/nanoclaw/config", dependencies=[Depends(require_internal)]) - async def nanoclaw_get_config(request: Request): - config = request.app.state.config - return config.model_dump() - - @app.put("/internal/nanoclaw/config", dependencies=[Depends(require_internal)]) - async def nanoclaw_update_config(request: Request): - body = await request.json() - settings = request.app.state.settings - config = request.app.state.config - - if "commands" in body: - config.commands = CommandsConfig(**body["commands"]) - if "max_concurrent_sessions" in body: - config.max_concurrent_sessions = body["max_concurrent_sessions"] - if "idle_timeout_minutes" in body: - config.idle_timeout_minutes = body["idle_timeout_minutes"] - if "workflows" in body: - from warren.config import WorkflowConfig - - config.workflows = { - name: WorkflowConfig(**wf) - for name, wf in body["workflows"].items() - } - - p = Path(settings.config) - data = yaml.safe_load(p.read_text()) if p.exists() else {} - data.update(config.model_dump()) - p.write_text(yaml.dump(data, default_flow_style=False)) - - return config.model_dump() - - @app.post("/internal/nanoclaw/callback", dependencies=[Depends(require_internal)]) - async def nanoclaw_callback( - request: Request, db: Database = Depends(get_db) - ): - body = await request.json() - session_id = body.get("session_id") - if not session_id: - raise HTTPException(status_code=400, detail="Missing session_id") - session = await db.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail="Unknown session") - text = body.get("text", "") - event_type = body.get("type", "text") - - bus: SessionBus = request.app.state.bus - if event_type == "result": - event_data = { - "type": "result", - "summary": text, - "session_id": body.get("nanoclaw_session_id", ""), - } - await bus.publish(session_id, event_data) - if text: - await db.save_message( - session_id, - text, - role="assistant", - event_type="result", - event_data=json.dumps(event_data), - ) - elif event_type == "tool": - tool_name = body.get("tool", "") - summary = body.get("summary", "") - event_data = { - "type": "tool", - "tool": tool_name, - "summary": summary, - } - await bus.publish(session_id, event_data) - await db.save_message( - session_id, - f"{tool_name} {summary}", - role="assistant", - event_type="tool", - event_data=json.dumps(event_data), - ) - else: - await bus.publish(session_id, {"type": "text", "content": text}) - if text: - await db.save_message( - session_id, - text, - role="assistant", - event_type="text", - event_data=json.dumps({"type": "text", "content": text}), - ) - return {"status": "ok"} - - @app.post("/internal/nanoclaw/typing", dependencies=[Depends(require_internal)]) - async def nanoclaw_typing(request: Request): - body = await request.json() - session_id = body.get("session_id") - is_typing = body.get("is_typing", False) - - bus: SessionBus = request.app.state.bus - state = "working" if is_typing else "waiting" - await bus.publish(session_id, {"type": "status", "state": state}) - return {"status": "ok"} # -- Agent WebSocket -- @app.websocket("/api/agent/ws") diff --git a/server/tests/test_app.py b/server/tests/test_app.py index 851fc3d..0dec3f1 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -406,71 +406,6 @@ async def test_put_repos_round_trip(self, client): assert data["repos"]["test-repo"]["append_system_prompt"] == "New prompt." -class TestInternalConfigEndpoints: - """Tests for /internal/nanoclaw/config (requires x-internal-secret).""" - - INTERNAL_HEADERS = {"x-internal-secret": "test-internal-secret"} - - async def test_get_internal_config_requires_secret(self, client): - resp = await client.get("/internal/nanoclaw/config") - assert resp.status_code == 403 - - async def test_get_internal_config(self, client): - resp = await client.get("/internal/nanoclaw/config", headers=self.INTERNAL_HEADERS) - assert resp.status_code == 200 - data = resp.json() - assert "commands" in data - assert "max_concurrent_sessions" in data - assert data["max_concurrent_sessions"] == 2 - - async def test_put_internal_config_updates_commands(self, client): - resp = await client.put( - "/internal/nanoclaw/config", - json={ - "commands": { - "actions": [{"label": "STATUS", "prompt": "git status"}], - "templates": [], - }, - }, - headers=self.INTERNAL_HEADERS, - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data["commands"]["actions"]) == 1 - assert data["commands"]["actions"][0]["label"] == "STATUS" - - async def test_put_internal_config_updates_settings(self, client): - resp = await client.put( - "/internal/nanoclaw/config", - json={"idle_timeout_minutes": 30}, - headers=self.INTERNAL_HEADERS, - ) - assert resp.status_code == 200 - assert resp.json()["idle_timeout_minutes"] == 30 - - async def test_put_internal_config_persists_to_disk(self, client): - import yaml - - await client.put( - "/internal/nanoclaw/config", - json={"max_concurrent_sessions": 8}, - headers=self.INTERNAL_HEADERS, - ) - config_path = client._transport.app.state.settings.config - with open(config_path) as f: - on_disk = yaml.safe_load(f) - assert on_disk["max_concurrent_sessions"] == 8 - - async def test_put_internal_config_round_trip(self, client): - await client.put( - "/internal/nanoclaw/config", - json={"idle_timeout_minutes": 99}, - headers=self.INTERNAL_HEADERS, - ) - resp = await client.get("/internal/nanoclaw/config", headers=self.INTERNAL_HEADERS) - assert resp.json()["idle_timeout_minutes"] == 99 - - class TestRepoConfig: async def test_create_session_passes_config_to_adapter(self, authed_client): """Session creation passes repo config via BackendMessage.""" diff --git a/server/tests/test_nanoclaw_callback.py b/server/tests/test_nanoclaw_callback.py deleted file mode 100644 index 9fc02ab..0000000 --- a/server/tests/test_nanoclaw_callback.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Tests for NanoClaw callback endpoint.""" - -import asyncio - -import pytest -from httpx import ASGITransport, AsyncClient - -from warren.app import create_app -from warren.config import Settings - -INTERNAL_HEADERS = {"x-internal-secret": "test-internal-secret"} - - -@pytest.fixture -async def app_with_callbacks(tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text("repos: {}\n") - - settings = Settings( - db=str(tmp_path / "test.db"), - config=str(config_file), - admin_token="test-admin", - internal_secret="test-internal-secret", - ) - app = create_app(settings) - - async with asyncio.timeout(5): - async with app.router.lifespan_context(app): - await app.state.db.store_device_token("test-token", "test-device") - yield app - - -class TestNanoClawCallback: - async def test_callback_requires_internal_secret(self, app_with_callbacks): - app = app_with_callbacks - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as client: - resp = await client.post( - "/internal/nanoclaw/callback", - json={"session_id": "test", "text": "hello", "type": "text"}, - ) - assert resp.status_code == 403 - - async def test_callback_pushes_event_to_queue(self, app_with_callbacks): - app = app_with_callbacks - session_id = "test-session" - await app.state.db.create_session(session_id, "test", "") - queue = app.state.bus.subscribe(session_id) - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as client: - resp = await client.post( - "/internal/nanoclaw/callback", - json={"session_id": session_id, "text": "Hello from NanoClaw", "type": "text"}, - headers=INTERNAL_HEADERS, - ) - assert resp.status_code == 200 - - assert not queue.empty() - event = queue.get_nowait() - assert event["type"] == "text" - assert event["content"] == "Hello from NanoClaw" - - async def test_callback_unknown_session_returns_404(self, app_with_callbacks): - app = app_with_callbacks - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as client: - resp = await client.post( - "/internal/nanoclaw/callback", - json={"session_id": "nonexistent", "text": "hello", "type": "text"}, - headers=INTERNAL_HEADERS, - ) - assert resp.status_code == 404 - - async def test_typing_indicator(self, app_with_callbacks): - app = app_with_callbacks - session_id = "test-session" - await app.state.db.create_session(session_id, "test", "") - queue = app.state.bus.subscribe(session_id) - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as client: - resp = await client.post( - "/internal/nanoclaw/typing", - json={"session_id": session_id, "is_typing": True}, - headers=INTERNAL_HEADERS, - ) - assert resp.status_code == 200 - - event = queue.get_nowait() - assert event["type"] == "status" - assert event["state"] == "working" From 8f2e266b29b21cb98b82dcdb023d3b57a02b442d Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 13:46:05 -0400 Subject: [PATCH 03/11] refactor: remove NanoClawAdapter, default to websocket backend in tests Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 36fe0d6ad374 --- server/src/warren/adapters/nanoclaw.py | 76 ------------------------- server/src/warren/app.py | 20 +------ server/tests/test_adapters.py | 78 -------------------------- server/tests/test_app.py | 6 +- server/tests/test_integration.py | 4 +- server/tests/test_streaming.py | 6 +- server/tests/test_voice.py | 6 +- server/tests/test_voice_integration.py | 4 +- server/tests/test_voice_pairing.py | 4 +- 9 files changed, 17 insertions(+), 187 deletions(-) delete mode 100644 server/src/warren/adapters/nanoclaw.py diff --git a/server/src/warren/adapters/nanoclaw.py b/server/src/warren/adapters/nanoclaw.py deleted file mode 100644 index 783afef..0000000 --- a/server/src/warren/adapters/nanoclaw.py +++ /dev/null @@ -1,76 +0,0 @@ -"""NanoClaw backend adapter -- file-based IPC with NanoClaw. - -Warren writes IPC files to NanoClaw's message/task directories. -NanoClaw processes them and sends responses via HTTP callback to -Warren's /internal/nanoclaw/callback endpoint. - -See: https://github.com/dsr-restyn/nanoclaw -""" - -from __future__ import annotations - -import asyncio -import logging -import os -from collections.abc import AsyncIterator - -from warren.adapters import BackendEvent, BackendMessage -from warren.ipc import write_ipc_message, write_ipc_task - -logger = logging.getLogger(__name__) - - -class NanoClawAdapter: - """Backend adapter that communicates with NanoClaw via file-based IPC.""" - - def __init__(self, ipc_dir: str, db_path: str = ""): - self._ipc_dir = ipc_dir - self._db_path = db_path - self._event_queue: asyncio.Queue[BackendEvent] = asyncio.Queue() - - async def start(self) -> None: - """Verify IPC directories exist.""" - os.makedirs(os.path.join(self._ipc_dir, "warren", "messages"), exist_ok=True) - os.makedirs(os.path.join(self._ipc_dir, "warren", "tasks"), exist_ok=True) - logger.info("NanoClawAdapter started, IPC dir: %s", self._ipc_dir) - - async def stop(self) -> None: - """Clean shutdown.""" - logger.info("NanoClawAdapter stopped") - - async def send(self, message: BackendMessage) -> None: - """Write an IPC file for NanoClaw to process.""" - if message.kind in ("new_session", "user_message"): - write_ipc_message( - ipc_dir=self._ipc_dir, - group="warren", - sender=f"warren:{message.session_id}", - text=message.payload.get("message", ""), - ) - elif message.kind == "cancel": - write_ipc_task( - ipc_dir=self._ipc_dir, - group="warren", - task_id=f"cancel-{message.session_id}", - payload={ - "type": "cancel_task", - "task_id": f"warren-{message.session_id}", - "owner_group": "warren", - }, - ) - - async def events(self) -> AsyncIterator[BackendEvent]: - """Yield events pushed via HTTP callback.""" - while True: - event = await self._event_queue.get() - yield event - - async def push_callback(self, session_id: str, text: str) -> None: - """Called by the /internal/nanoclaw/callback endpoint.""" - await self._event_queue.put( - BackendEvent( - kind="text", - session_id=session_id, - data={"type": "text", "content": text}, - ) - ) diff --git a/server/src/warren/app.py b/server/src/warren/app.py index 543510b..c4655b1 100644 --- a/server/src/warren/app.py +++ b/server/src/warren/app.py @@ -68,13 +68,8 @@ def _nanoclaw_paths(settings: Settings, config) -> tuple[str, str]: def create_adapter(settings: Settings, config): - """Create the configured backend adapter (nanoclaw or websocket or orca).""" - if settings.backend == "nanoclaw": - from warren.adapters.nanoclaw import NanoClawAdapter - - db_path, ipc_dir = _nanoclaw_paths(settings, config) - return NanoClawAdapter(ipc_dir=ipc_dir, db_path=db_path) - elif settings.backend == "orca": + """Create the configured backend adapter (websocket or orca).""" + if settings.backend == "orca": from warren.adapters.orca import OrcaAdapter return OrcaAdapter() @@ -132,17 +127,6 @@ async def lifespan(app: FastAPI): app.state.config = config app.state.settings = settings - if settings.backend == "nanoclaw": - import os - - has_api_key = os.environ.get("ANTHROPIC_API_KEY") - has_oauth = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") - if not has_api_key and not has_oauth: - logger.warning( - "Neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN set β€” " - "Claude Code sessions will fail. Set one of these environment variables." - ) - adapter = create_adapter(settings, config) await adapter.start() app.state.adapter = adapter diff --git a/server/tests/test_adapters.py b/server/tests/test_adapters.py index c540b6c..bc4cd2e 100644 --- a/server/tests/test_adapters.py +++ b/server/tests/test_adapters.py @@ -29,81 +29,3 @@ def test_backend_message_with_payload(): ) assert msg.session_id == "abc" assert msg.payload["message"] == "hello" - - -class TestNanoClawAdapter: - async def test_instantiation(self): - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir="/tmp/ipc") - assert adapter._ipc_dir == "/tmp/ipc" - - async def test_start_creates_directories(self, tmp_path): - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) - await adapter.start() - assert (tmp_path / "ipc" / "warren" / "messages").is_dir() - assert (tmp_path / "ipc" / "warren" / "tasks").is_dir() - - async def test_send_new_session_writes_ipc_file(self, tmp_path): - import json - - from warren.adapters import BackendMessage - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) - await adapter.start() - - await adapter.send(BackendMessage( - kind="new_session", - session_id="test-session", - payload={"message": "hello", "repo_path": "/tmp/test"}, - )) - - msg_dir = tmp_path / "ipc" / "warren" / "messages" - files = list(msg_dir.glob("*.json")) - assert len(files) == 1 - - with open(files[0]) as f: - data = json.load(f) - assert data["text"] == "hello" - assert data["sender"] == "warren:test-session" - - async def test_send_user_message_writes_ipc_file(self, tmp_path): - from warren.adapters import BackendMessage - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) - await adapter.start() - - await adapter.send(BackendMessage( - kind="user_message", - session_id="test-session", - payload={"message": "continue working", "repo_path": "/tmp/test"}, - )) - - msg_dir = tmp_path / "ipc" / "warren" / "messages" - files = list(msg_dir.glob("*.json")) - assert len(files) == 1 - - async def test_push_callback_yields_event(self, tmp_path): - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) - await adapter.start() - - await adapter.push_callback("session-1", "Hello from NanoClaw") - - async for event in adapter.events(): - assert event.kind == "text" - assert event.session_id == "session-1" - assert event.data["content"] == "Hello from NanoClaw" - break - - async def test_stop_is_clean(self, tmp_path): - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) - await adapter.start() - await adapter.stop() # Should not raise diff --git a/server/tests/test_app.py b/server/tests/test_app.py index 0dec3f1..f9a884e 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -12,7 +12,7 @@ @pytest.fixture async def client(tmp_path): """Test client with in-memory DB and mock config.""" - from warren.adapters.nanoclaw import NanoClawAdapter + from warren.adapters.websocket import AgentWebSocketAdapter from warren.app import create_app from warren.config import Settings, load_config from warren.db import Database @@ -50,7 +50,7 @@ async def client(tmp_path): app.state.db = db app.state.config = load_config(str(config_file)) app.state.settings = settings - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) + adapter = AgentWebSocketAdapter() await adapter.start() app.state.adapter = adapter app.state.event_queues = {} @@ -185,7 +185,7 @@ async def test_list_sessions_empty(self, client): assert resp.json() == [] async def test_create_session_unknown_repo_allowed(self, client): - """NanoClaw backend allows unknown repos (managed externally).""" + """Backend allows unknown repos (managed externally).""" headers = await self._auth_header(client) resp = await client.post( "/sessions", diff --git a/server/tests/test_integration.py b/server/tests/test_integration.py index d8b54a4..95ebdbf 100644 --- a/server/tests/test_integration.py +++ b/server/tests/test_integration.py @@ -13,7 +13,7 @@ @pytest.fixture async def client(tmp_path): """Fully wired test client.""" - from warren.adapters.nanoclaw import NanoClawAdapter + from warren.adapters.websocket import AgentWebSocketAdapter from warren.app import create_app from warren.config import Settings, load_config from warren.db import Database @@ -37,7 +37,7 @@ async def client(tmp_path): app.state.db = db app.state.config = load_config(str(config_file)) app.state.settings = settings - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) + adapter = AgentWebSocketAdapter() await adapter.start() app.state.adapter = adapter app.state.bus = SessionBus() diff --git a/server/tests/test_streaming.py b/server/tests/test_streaming.py index 129ed36..e3904ea 100644 --- a/server/tests/test_streaming.py +++ b/server/tests/test_streaming.py @@ -10,7 +10,7 @@ @pytest.fixture async def authed_client(tmp_path): """Test client with auth already done.""" - from warren.adapters.nanoclaw import NanoClawAdapter + from warren.adapters.websocket import AgentWebSocketAdapter from warren.app import create_app from warren.config import Settings, load_config from warren.db import Database @@ -34,7 +34,7 @@ async def authed_client(tmp_path): app.state.db = db app.state.config = load_config(str(config_file)) app.state.settings = settings - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) + adapter = AgentWebSocketAdapter() await adapter.start() app.state.adapter = adapter app.state.event_queues = {} @@ -73,7 +73,7 @@ async def mock_send(message): assert "session_id" in data async def test_unknown_repo_allowed(self, authed_client): - """NanoClaw backend allows unknown repos (managed externally).""" + """Backend allows unknown repos (managed externally).""" resp = await authed_client.post("/sessions", json={ "repo": "nonexistent", "message": "hello", diff --git a/server/tests/test_voice.py b/server/tests/test_voice.py index c86487d..83a97ff 100644 --- a/server/tests/test_voice.py +++ b/server/tests/test_voice.py @@ -6,7 +6,7 @@ import yaml from starlette.testclient import TestClient -from warren.adapters.nanoclaw import NanoClawAdapter +from warren.adapters.websocket import AgentWebSocketAdapter from warren.app import create_app from warren.bus import SessionBus from warren.config import Settings, load_config @@ -39,7 +39,7 @@ async def voice_client(voice_app): app.state.db = db app.state.config = load_config(settings.config) app.state.settings = settings - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) + adapter = AgentWebSocketAdapter() await adapter.start() app.state.adapter = adapter app.state.bus = SessionBus() @@ -76,7 +76,7 @@ async def disabled_client(disabled_app): app.state.db = db app.state.config = load_config(settings.config) app.state.settings = settings - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) + adapter = AgentWebSocketAdapter() await adapter.start() app.state.adapter = adapter app.state.bus = SessionBus() diff --git a/server/tests/test_voice_integration.py b/server/tests/test_voice_integration.py index 9f30038..0945875 100644 --- a/server/tests/test_voice_integration.py +++ b/server/tests/test_voice_integration.py @@ -9,7 +9,7 @@ import yaml from starlette.testclient import TestClient -from warren.adapters.nanoclaw import NanoClawAdapter +from warren.adapters.websocket import AgentWebSocketAdapter from warren.app import create_app from warren.bus import SessionBus from warren.config import Settings, load_config @@ -45,7 +45,7 @@ async def client(voice_app): app.state.config = load_config(settings.config) app.state.settings = settings - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) + adapter = AgentWebSocketAdapter() await adapter.start() # Mock send so the adapter never tries to spawn a real subprocess adapter.send = AsyncMock() diff --git a/server/tests/test_voice_pairing.py b/server/tests/test_voice_pairing.py index 583e3b4..16bd8e3 100644 --- a/server/tests/test_voice_pairing.py +++ b/server/tests/test_voice_pairing.py @@ -5,7 +5,7 @@ import yaml from httpx import ASGITransport, AsyncClient -from warren.adapters.nanoclaw import NanoClawAdapter +from warren.adapters.websocket import AgentWebSocketAdapter from warren.app import create_app from warren.config import Settings, load_config from warren.db import Database @@ -29,7 +29,7 @@ async def _make_client(tmp_path, voice_enabled: bool): app.state.db = db app.state.config = load_config(str(config_file)) app.state.settings = settings - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) + adapter = AgentWebSocketAdapter() await adapter.start() app.state.adapter = adapter From b73627b97601b360635ad01313981c1f96ebcb48 Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 13:47:32 -0400 Subject: [PATCH 04/11] refactor: drop nanoclaw monitoring, keep frontend task endpoints as no-ops Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 36fe0d6ad374 --- server/src/warren/app.py | 89 +++----------------------- server/src/warren/nanoclaw_db.py | 84 ------------------------- server/tests/test_monitor.py | 2 +- server/tests/test_nanoclaw_db.py | 103 ------------------------------- 4 files changed, 8 insertions(+), 270 deletions(-) delete mode 100644 server/src/warren/nanoclaw_db.py delete mode 100644 server/tests/test_nanoclaw_db.py diff --git a/server/src/warren/app.py b/server/src/warren/app.py index c4655b1..a9c03ea 100644 --- a/server/src/warren/app.py +++ b/server/src/warren/app.py @@ -52,21 +52,6 @@ class SendMessageRequest(BaseModel): message: str -def _nanoclaw_paths(settings: Settings, config) -> tuple[str, str]: - """Resolve NanoClaw DB path and IPC dir from env vars or config.yaml.""" - backend_cfg = config.backend_config.get("nanoclaw", {}) - data_dir = str(Path(settings.db).parent) - db_path = ( - settings.nanoclaw_db - or backend_cfg.get("db_path", "") - or f"{data_dir}/nanoclaw/store/messages.db" - ) - ipc_dir = ( - settings.nanoclaw_ipc_dir or backend_cfg.get("ipc_dir", "") or f"{data_dir}/nanoclaw/ipc" - ) - return db_path, ipc_dir - - def create_adapter(settings: Settings, config): """Create the configured backend adapter (websocket or orca).""" if settings.backend == "orca": @@ -612,17 +597,7 @@ async def monitor_health( settings = request.app.state.settings result = {"warren": {"status": "ok"}} - if settings.backend == "nanoclaw": - import os - db_path, _ = _nanoclaw_paths(settings, request.app.state.config) - if os.path.exists(db_path): - result["nanoclaw"] = {"status": "ok"} - else: - result["nanoclaw"] = { - "status": "unavailable", - "detail": "DB not found", - } - elif settings.backend == "orca": + if settings.backend == "orca": adapter = request.app.state.adapter from warren.adapters.orca import OrcaAdapter if isinstance(adapter, OrcaAdapter): @@ -657,66 +632,16 @@ async def monitor_tasks( request: Request, _token: str = Depends(require_auth), ): - settings = request.app.state.settings - if settings.backend != "nanoclaw": - return {"tasks": []} - - db_path, _ = _nanoclaw_paths(settings, request.app.state.config) - from warren.nanoclaw_db import NanoClawDbReader - - reader = NanoClawDbReader(db_path) - tasks = await reader.list_tasks() - return {"tasks": tasks} + # Live task monitoring was nanoclaw-specific; no tasks on websocket/orca. + return {"tasks": []} @app.post("/monitor/tasks/{task_id}/pause") - async def pause_task( - task_id: str, - request: Request, - _token: str = Depends(require_auth), - ): - settings = request.app.state.settings - if settings.backend != "nanoclaw": - return {"status": "ignored", "reason": "Not supported on websocket backend"} - - _, ipc_dir = _nanoclaw_paths(settings, request.app.state.config) - from warren.ipc import write_ipc_task - - write_ipc_task( - ipc_dir=ipc_dir, - group="warren", - task_id=f"pause-{task_id}", - payload={ - "type": "pause_task", - "task_id": task_id, - "owner_group": "warren", - }, - ) - return {"status": "paused", "task_id": task_id} + async def pause_task(task_id: str, _token: str = Depends(require_auth)): + return {"status": "ignored", "reason": "Task control not supported on this backend"} @app.post("/monitor/tasks/{task_id}/resume") - async def resume_task( - task_id: str, - request: Request, - _token: str = Depends(require_auth), - ): - settings = request.app.state.settings - if settings.backend != "nanoclaw": - return {"status": "ignored", "reason": "Not supported on websocket backend"} - - _, ipc_dir = _nanoclaw_paths(settings, request.app.state.config) - from warren.ipc import write_ipc_task - - write_ipc_task( - ipc_dir=ipc_dir, - group="warren", - task_id=f"resume-{task_id}", - payload={ - "type": "resume_task", - "task_id": task_id, - "owner_group": "warren", - }, - ) - return {"status": "resumed", "task_id": task_id} + async def resume_task(task_id: str, _token: str = Depends(require_auth)): + return {"status": "ignored", "reason": "Task control not supported on this backend"} # -- Agent WebSocket -- diff --git a/server/src/warren/nanoclaw_db.py b/server/src/warren/nanoclaw_db.py deleted file mode 100644 index e54cb4f..0000000 --- a/server/src/warren/nanoclaw_db.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Read-only access to NanoClaw's SQLite database for monitoring.""" - -from __future__ import annotations - -import logging -import os -from pathlib import Path - -import aiosqlite - -logger = logging.getLogger(__name__) - - -class NanoClawDbReader: - """Read NanoClaw's SQLite database for monitoring data.""" - - def __init__(self, db_path: str): - self._db_path = db_path - - def _exists(self) -> bool: - return os.path.exists(self._db_path) - - def _uri(self) -> str: - """Build a file URI for read-only access.""" - return Path(self._db_path).as_uri() + "?mode=ro" - - async def list_tasks(self) -> list[dict]: - """List all scheduled tasks.""" - if not self._exists(): - return [] - try: - async with aiosqlite.connect(self._uri(), uri=True) as db: - db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT id, group_folder, prompt, schedule_type, " - "schedule_value, status, last_run, next_run " - "FROM scheduled_tasks ORDER BY next_run", - ) - rows = await cursor.fetchall() - return [dict(row) for row in rows] - except Exception: - logger.exception("Failed to read NanoClaw tasks") - return [] - - async def list_active_groups(self) -> list[dict]: - """List active groups (sessions) from NanoClaw.""" - if not self._exists(): - return [] - try: - async with aiosqlite.connect(self._uri(), uri=True) as db: - db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT jid, name, channel, last_message_time " - "FROM chats WHERE channel = 'warren' " - "ORDER BY last_message_time DESC", - ) - rows = await cursor.fetchall() - return [dict(row) for row in rows] - except Exception: - logger.exception("Failed to read NanoClaw groups") - return [] - - async def get_task_history( - self, - task_id: str, - limit: int = 10, - ) -> list[dict]: - """Get execution history for a task.""" - if not self._exists(): - return [] - try: - async with aiosqlite.connect(self._uri(), uri=True) as db: - db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT id, task_id, run_at, duration_ms, status, result " - "FROM task_run_logs WHERE task_id = ? " - "ORDER BY run_at DESC LIMIT ?", - (task_id, limit), - ) - rows = await cursor.fetchall() - return [dict(row) for row in rows] - except Exception: - logger.exception("Failed to read NanoClaw task history") - return [] diff --git a/server/tests/test_monitor.py b/server/tests/test_monitor.py index 119e718..e048128 100644 --- a/server/tests/test_monitor.py +++ b/server/tests/test_monitor.py @@ -97,7 +97,7 @@ async def test_requires_auth(self, monitor_app): resp = await client.get("/monitor/tasks") assert resp.status_code == 401 - async def test_returns_empty_when_no_nanoclaw( + async def test_returns_empty_tasks( self, monitor_app, auth_headers, ): async with AsyncClient( diff --git a/server/tests/test_nanoclaw_db.py b/server/tests/test_nanoclaw_db.py deleted file mode 100644 index 00d2aa5..0000000 --- a/server/tests/test_nanoclaw_db.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Tests for NanoClaw SQLite reader.""" - -import sqlite3 - -import pytest - - -@pytest.fixture -def nanoclaw_db(tmp_path): - """Create a minimal NanoClaw-compatible SQLite database.""" - db_path = str(tmp_path / "nanoclaw.db") - conn = sqlite3.connect(db_path) - conn.executescript(""" - CREATE TABLE IF NOT EXISTS scheduled_tasks ( - id TEXT PRIMARY KEY, - group_folder TEXT NOT NULL, - chat_jid TEXT NOT NULL, - prompt TEXT NOT NULL, - schedule_type TEXT NOT NULL, - schedule_value TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - last_run TEXT, - next_run TEXT, - last_result TEXT, - created_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS task_run_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL, - run_at TEXT NOT NULL, - duration_ms INTEGER NOT NULL, - status TEXT NOT NULL, - result TEXT, - error TEXT - ); - - CREATE TABLE IF NOT EXISTS chats ( - jid TEXT PRIMARY KEY, - name TEXT, - last_message_time TEXT, - channel TEXT, - is_group INTEGER DEFAULT 0 - ); - - INSERT INTO scheduled_tasks VALUES ( - 'task-1', 'repo-a', 'warren:repo-a', 'daily report', 'cron', - '0 9 * * *', 'active', '2024-02-19T08:00:00Z', - '2024-02-20T09:00:00Z', NULL, '2024-02-18T00:00:00Z' - ); - INSERT INTO scheduled_tasks VALUES ( - 'task-2', 'repo-b', 'warren:repo-b', 'backup', 'interval', - '86400000', 'paused', NULL, NULL, NULL, '2024-02-18T00:00:00Z' - ); - - INSERT INTO chats VALUES - ('warren:session-1', 'repo-a session', '2024-02-19T12:00:00Z', 'warren', 1), - ('warren:session-2', 'repo-b session', '2024-02-19T11:00:00Z', 'warren', 1); - - INSERT INTO task_run_logs VALUES - (1, 'task-1', '2024-02-19T08:00:00Z', 60000, 'completed', 'Report generated', NULL), - (2, 'task-1', '2024-02-18T08:00:00Z', 55000, 'completed', 'Report generated', NULL); - """) - conn.close() - return db_path - - -class TestNanoClawDbReader: - async def test_list_tasks(self, nanoclaw_db): - from warren.nanoclaw_db import NanoClawDbReader - - reader = NanoClawDbReader(nanoclaw_db) - tasks = await reader.list_tasks() - assert len(tasks) == 2 - # NULL next_run sorts first in SQLite, so task-2 (paused) comes first - task_ids = {t["id"] for t in tasks} - assert task_ids == {"task-1", "task-2"} - statuses = {t["id"]: t["status"] for t in tasks} - assert statuses["task-1"] == "active" - assert statuses["task-2"] == "paused" - - async def test_list_active_groups(self, nanoclaw_db): - from warren.nanoclaw_db import NanoClawDbReader - - reader = NanoClawDbReader(nanoclaw_db) - groups = await reader.list_active_groups() - assert len(groups) == 2 - assert groups[0]["jid"] == "warren:session-1" - - async def test_get_task_history(self, nanoclaw_db): - from warren.nanoclaw_db import NanoClawDbReader - - reader = NanoClawDbReader(nanoclaw_db) - history = await reader.get_task_history("task-1", limit=5) - assert len(history) == 2 - assert history[0]["status"] == "completed" - - async def test_missing_db_returns_empty(self, tmp_path): - from warren.nanoclaw_db import NanoClawDbReader - - reader = NanoClawDbReader(str(tmp_path / "missing.db")) - tasks = await reader.list_tasks() - assert tasks == [] From bfc21b5bd32e9d6bbc9247123547014528942baa Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 13:47:54 -0400 Subject: [PATCH 05/11] refactor: remove file-IPC module (nanoclaw-only) Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 36fe0d6ad374 --- server/src/warren/ipc.py | 73 ----------------------------------- server/tests/test_ipc.py | 82 ---------------------------------------- 2 files changed, 155 deletions(-) delete mode 100644 server/src/warren/ipc.py delete mode 100644 server/tests/test_ipc.py diff --git a/server/src/warren/ipc.py b/server/src/warren/ipc.py deleted file mode 100644 index 6eb0804..0000000 --- a/server/src/warren/ipc.py +++ /dev/null @@ -1,73 +0,0 @@ -"""NanoClaw IPC file writer. - -Writes JSON message and task files to NanoClaw's IPC directory using -atomic temp-file-then-rename to avoid partial reads. -""" - -from __future__ import annotations - -import json -import os -import tempfile -import time - - -def write_ipc_message( - ipc_dir: str, - group: str, - sender: str, - text: str, -) -> str: - """Write a message file to NanoClaw's IPC directory.""" - msg_dir = os.path.join(ipc_dir, group, "messages") - os.makedirs(msg_dir, exist_ok=True) - - payload = { - "type": "inbound", - "chatJid": sender, - "sender": sender, - "senderName": "Warren User", - "text": text, - "timestamp": int(time.time() * 1000), - } - - filename = f"{int(time.time() * 1000)}.json" - filepath = os.path.join(msg_dir, filename) - - fd, tmp = tempfile.mkstemp(dir=msg_dir, suffix=".tmp") - try: - with os.fdopen(fd, "w") as f: - json.dump(payload, f) - os.rename(tmp, filepath) - except BaseException: - if os.path.exists(tmp): - os.unlink(tmp) - raise - - return filepath - - -def write_ipc_task( - ipc_dir: str, - group: str, - task_id: str, - payload: dict, -) -> str: - """Write a task file to NanoClaw's IPC directory.""" - task_dir = os.path.join(ipc_dir, group, "tasks") - os.makedirs(task_dir, exist_ok=True) - - filename = f"{task_id}.json" - filepath = os.path.join(task_dir, filename) - - fd, tmp = tempfile.mkstemp(dir=task_dir, suffix=".tmp") - try: - with os.fdopen(fd, "w") as f: - json.dump(payload, f) - os.rename(tmp, filepath) - except BaseException: - if os.path.exists(tmp): - os.unlink(tmp) - raise - - return filepath diff --git a/server/tests/test_ipc.py b/server/tests/test_ipc.py deleted file mode 100644 index 45994cc..0000000 --- a/server/tests/test_ipc.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Tests for NanoClaw IPC file writing.""" - -import json -import os - - -class TestWriteIpcMessage: - def test_creates_message_file(self, tmp_path): - from warren.ipc import write_ipc_message - - path = write_ipc_message( - ipc_dir=str(tmp_path), - group="warren", - sender="warren:session-123", - text="hello world", - ) - assert os.path.exists(path) - with open(path) as f: - data = json.load(f) - assert data["type"] == "inbound" - assert data["text"] == "hello world" - assert data["chatJid"] == "warren:session-123" - assert data["sender"] == "warren:session-123" - assert "timestamp" in data - - def test_creates_directory_structure(self, tmp_path): - from warren.ipc import write_ipc_message - - write_ipc_message( - ipc_dir=str(tmp_path), - group="mygroup", - sender="warren:abc", - text="test", - ) - assert os.path.isdir(tmp_path / "mygroup" / "messages") - - def test_atomic_write_no_tmp_files(self, tmp_path): - from warren.ipc import write_ipc_message - - write_ipc_message( - ipc_dir=str(tmp_path), - group="warren", - sender="warren:x", - text="test", - ) - msg_dir = tmp_path / "warren" / "messages" - files = list(msg_dir.iterdir()) - assert all(f.suffix == ".json" for f in files) - - -class TestWriteIpcTask: - def test_creates_task_file(self, tmp_path): - from warren.ipc import write_ipc_task - - path = write_ipc_task( - ipc_dir=str(tmp_path), - group="warren", - task_id="daily-report", - payload={ - "type": "schedule_task", - "task_id": "daily-report", - "prompt": "generate daily report", - "schedule": {"cron": "0 9 * * *"}, - "owner_group": "warren", - }, - ) - assert os.path.exists(path) - with open(path) as f: - data = json.load(f) - assert data["type"] == "schedule_task" - assert data["task_id"] == "daily-report" - - def test_creates_tasks_directory(self, tmp_path): - from warren.ipc import write_ipc_task - - write_ipc_task( - ipc_dir=str(tmp_path), - group="warren", - task_id="test", - payload={"type": "cancel_task", "task_id": "test", "owner_group": "warren"}, - ) - assert os.path.isdir(tmp_path / "warren" / "tasks") From 3488d191546e2a07ec954a443e2083e63b380cda Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 13:48:48 -0400 Subject: [PATCH 06/11] refactor: remove nanoclaw config fields Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 36fe0d6ad374 --- server/src/warren/config.py | 3 --- server/tests/test_config.py | 29 ----------------------------- 2 files changed, 32 deletions(-) diff --git a/server/src/warren/config.py b/server/src/warren/config.py index a4c64ed..5340c60 100644 --- a/server/src/warren/config.py +++ b/server/src/warren/config.py @@ -47,7 +47,6 @@ class WarrenConfig(BaseModel): suspend_timeout_hours: int = 2 cleanup_days: int = 7 workflows: dict[str, WorkflowConfig] = {} - backend_config: dict[str, dict] = {} class Settings(BaseSettings): @@ -62,8 +61,6 @@ class Settings(BaseSettings): anthropic_api_key: str = "" admin_token: str = "" data_dir: str = "data" - nanoclaw_db: str = "" - nanoclaw_ipc_dir: str = "" voice_enabled: bool = False voice_port: int = 443 internal_secret: str = "" diff --git a/server/tests/test_config.py b/server/tests/test_config.py index db15a88..f78fc46 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -191,35 +191,6 @@ def test_save_repos_updates_model_and_prompt(tmp_path): assert data["commands"]["actions"][0]["label"] == "TEST" -def test_config_with_backend_config(tmp_path): - """backend_config section parsed from YAML.""" - from warren.config import load_config - - config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump({ - "repos": {"test": {"path": "/tmp"}}, - "backend_config": { - "nanoclaw": {"ipc_dir": "/opt/warren-data/ipc"}, - }, - })) - - config = load_config(str(config_file)) - assert config.backend_config["nanoclaw"]["ipc_dir"] == "/opt/warren-data/ipc" - - -def test_config_without_backend_config(tmp_path): - """backend_config is optional -- defaults to empty dict.""" - from warren.config import load_config - - config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump({ - "repos": {"test": {"path": "/tmp"}}, - })) - - config = load_config(str(config_file)) - assert config.backend_config == {} - - def test_workflow_config_from_yaml(): """Workflows parsed from dict with label, steps, and optional input.""" from warren.config import WarrenConfig, WorkflowConfig # noqa: F401 From c3801b5b61e32099bae76167ccdf68d5337a1f21 Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 13:49:05 -0400 Subject: [PATCH 07/11] chore: drop nanoclaw IPC dir setup from entrypoint Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 36fe0d6ad374 --- server/entrypoint.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/entrypoint.sh b/server/entrypoint.sh index 911e67f..5d04dea 100755 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -5,9 +5,6 @@ set -e # This runs as root before switching to appuser via gosu if [ "$(id -u)" = "0" ]; then chown appuser:appuser /data - # Ensure nanoclaw IPC dirs are writable by appuser (nanoclaw runs as root) - mkdir -p /data/nanoclaw/ipc/warren/messages - chown -R appuser:appuser /data/nanoclaw/ipc/warren exec gosu appuser "$0" "$@" fi From ba785d36c71c7b6ffbbeea3a2fbf2329cf6b50c3 Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 13:49:33 -0400 Subject: [PATCH 08/11] chore: remove nanoclaw git submodule Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 36fe0d6ad374 --- .gitmodules | 4 ---- nanoclaw | 1 - 2 files changed, 5 deletions(-) delete mode 100644 .gitmodules delete mode 160000 nanoclaw diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b5b4cf7..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "nanoclaw"] - path = nanoclaw - url = https://github.com/dsr-restyn/nanoclaw.git - ignore = untracked diff --git a/nanoclaw b/nanoclaw deleted file mode 160000 index 4bb72cc..0000000 --- a/nanoclaw +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4bb72cc3e329c87e7432db648947c5143ffdb1e6 From 43d4800073a5b8c0da3cbd88a3004d51aa3e5bfa Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 13:54:45 -0400 Subject: [PATCH 09/11] docs: remove nanoclaw references from docs, compose, and skills Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 36fe0d6ad374 --- .claude/skills/customize/SKILL.md | 3 +- .claude/skills/debug/SKILL.md | 16 +- .claude/skills/setup/SKILL.md | 4 +- CLAUDE.md | 49 +- README.md | 37 +- docker-compose.yml | 23 - .../2026-02-20-nanoclaw-integration-design.md | 320 --- .../2026-02-20-nanoclaw-integration-plan.md | 529 ----- .../2026-02-20-nanoclaw-service-design.md | 389 ---- .../plans/2026-02-20-nanoclaw-service-plan.md | 1737 ----------------- server/src/warren/app.py | 2 +- 11 files changed, 53 insertions(+), 3056 deletions(-) delete mode 100644 docs/plans/2026-02-20-nanoclaw-integration-design.md delete mode 100644 docs/plans/2026-02-20-nanoclaw-integration-plan.md delete mode 100644 docs/plans/2026-02-20-nanoclaw-service-design.md delete mode 100644 docs/plans/2026-02-20-nanoclaw-service-plan.md diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md index a0b2870..2f47238 100644 --- a/.claude/skills/customize/SKILL.md +++ b/.claude/skills/customize/SKILL.md @@ -15,7 +15,8 @@ Before making changes, understand the codebase: - `server/src/warren/app.py` β€” FastAPI app factory, all endpoints, SSE streaming - `server/src/warren/adapters/` β€” Backend adapter interface and implementations - `__init__.py` β€” BackendAdapter protocol, BackendMessage, BackendEvent types - - `nanoclaw.py` β€” NanoClawAdapter (file-based IPC + HTTP callback) + - `websocket.py` β€” AgentWebSocketAdapter (WebSocket agent proxy, default) + - `orca.py` β€” OrcaAdapter (local Orca terminal control) - `server/src/warren/config.py` β€” pydantic-settings (env) + YAML config - `server/src/warren/db.py` β€” SQLite via aiosqlite - `server/src/warren/lifecycle.py` β€” Background session lifecycle sweep diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index b1b8421..90642ba 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -36,15 +36,15 @@ print(f'Backend config: {c.backend_config}') ``` ### 3. Check adapter connectivity -For direct backend: -- Verify `claude` binary exists: `which claude` -- Test Claude CLI: `claude --version` -- Check API key: verify `ANTHROPIC_API_KEY` is set +For the websocket backend: +- Confirm an agent is connected: `GET /monitor/health` reports + `agent_websocket.connected_agents_count` +- Verify the agent uses the correct `WARREN_INTERNAL_SECRET` token +- Check the agent can reach `ws[s]:///api/agent/ws` (TLS, firewall, NAT) -For NanoClaw backend: -- Check IPC directory exists and is writable -- Check NanoClaw process is running -- Check Docker socket is accessible +For the orca backend: +- Verify the `orca` CLI exists: `which orca` +- Check the Orca runtime is reachable: `orca status --json` ### 4. Check database ```bash diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 31b44f6..8142f5d 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -47,8 +47,8 @@ configuration. Then: 1. **config.yaml** β€” Create or update with repo paths. Ask the user which repos they want Warren to manage. -2. **.env** β€” Set `WARREN_ANTHROPIC_API_KEY`, `WARREN_ADMIN_TOKEN` (generate - if not set), `WARREN_BACKEND` (direct or nanoclaw). +2. **.env** β€” Set `WARREN_ADMIN_TOKEN` and `WARREN_INTERNAL_SECRET` (generate + if not set), and `WARREN_BACKEND` (`websocket` or `orca`). 3. **Deployment** β€” Based on their deployment method: - Dokploy: guide service creation, volume mounts, env vars - systemd: create service unit file diff --git a/CLAUDE.md b/CLAUDE.md index c766b6e..7677f8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +2,9 @@ ## What is this -Warren bridges Rabbit R1 to Claude Code sessions over HTTPS. A FastAPI server -communicates with NanoClaw via IPC. An R1 Creation provides the 240x282 UI. +Warren bridges Rabbit R1 to arbitrary personal AI agents over HTTPS. A FastAPI +server proxies sessions to an agent over a WebSocket protocol (see +`PROTOCOL.md`), or to local Orca terminals. An R1 Creation provides the 240x282 UI. ## Commands @@ -23,14 +24,13 @@ server/src/warren/ app.py # FastAPI, endpoints, SSE, WebSocket adapters/ # Backend adapter interface + implementations __init__.py # BackendAdapter protocol, BackendMessage, BackendEvent - nanoclaw.py # NanoClawAdapter (file-based IPC + HTTP callback) + websocket.py # AgentWebSocketAdapter (WebSocket proxy, default) + orca.py # OrcaAdapter (local Orca terminal control) bus.py # SessionBus pub/sub (multi-consumer event delivery) voice.py # Voice WebSocket handler (OpenClaw protocol) voice_protocol.py # OpenClaw message builders/parsers lifecycle.py # Background session lifecycle sweep auth.py # QR pairing, device tokens, voice QR - ipc.py # NanoClaw IPC file writer (atomic JSON) - nanoclaw_db.py # Read-only NanoClaw SQLite for monitoring db.py # SQLite (sessions, tokens) config.py # pydantic-settings + YAML @@ -53,23 +53,18 @@ to Claude Code when working in the Warren repo. ### Key abstractions - `BackendAdapter` (`server/src/warren/adapters/__init__.py`) β€” protocol for AI backends - `BackendMessage` / `BackendEvent` β€” message types for adapter communication -- `NanoClawAdapter` (`server/src/warren/adapters/nanoclaw.py`) β€” NanoClaw IPC adapter +- `AgentWebSocketAdapter` (`server/src/warren/adapters/websocket.py`) β€” WebSocket agent proxy +- `OrcaAdapter` (`server/src/warren/adapters/orca.py`) β€” local Orca terminal control - `SessionBus` (`server/src/warren/bus.py`) β€” pub/sub for multi-consumer event delivery - `Database` (`server/src/warren/db.py`) β€” SQLite via aiosqlite - `WarrenConfig` / `Settings` (`server/src/warren/config.py`) β€” YAML + env config -- `NanoClawDbReader` (`server/src/warren/nanoclaw_db.py`) β€” read-only monitoring - -### Deploy with NanoClaw -Configure `backend_config` in config.yaml or use environment variables: -```yaml -backend_config: - nanoclaw: - ipc_dir: /opt/warren-data/ipc - db_path: /opt/warren-data/nanoclaw.db -``` -Or set `WARREN_NANOCLAW_IPC_DIR` and `WARREN_NANOCLAW_DB` environment variables. -Both services need the shared volume `/opt/warren-data/` mounted. -NanoClaw needs `WARREN_CALLBACK_URL=http://warren:4030/internal/nanoclaw/callback`. + +### Choose a backend +Set `WARREN_BACKEND` (or `backend` in config). Options: +- `websocket` (default) β€” agents connect as clients to `ws[s]:///api/agent/ws`. + Set `WARREN_INTERNAL_SECRET` to require token auth on that endpoint. See `PROTOCOL.md` + and `examples/agent.py` for the client protocol. +- `orca` β€” control local Orca-managed terminals from the R1 (requires the `orca` CLI). ## R1 Creation Constraints @@ -93,7 +88,7 @@ for the R1's native voice assistant pairing. ``` R1 Browser (Creation) ──HTTP/SSE──┐ - β”œβ”€β”€ Warren ──adapter──▢ NanoClaw + β”œβ”€β”€ Warren ──adapter──▢ Agent (WebSocket) / Orca R1 PTT (Voice) ───────WebSocketβ”€β”€β”€β”˜ ``` @@ -107,15 +102,13 @@ R1 PTT (Voice) ───────WebSocketβ”€β”€β”€β”˜ | `status` | `state` (working/waiting) | Typing indicator | | `error` | `message` | Error occurred | -### NanoClaw Callback Types (NanoClaw β†’ Warren) - -POST `/internal/nanoclaw/callback`: -- `{type: "text", session_id, text}` β€” agent text -- `{type: "tool", session_id, tool, summary}` β€” tool progress -- `{type: "result", session_id, text, nanoclaw_session_id}` β€” completion +### Agent WebSocket Protocol (Agent ↔ Warren) -POST `/internal/nanoclaw/typing`: -- `{session_id, is_typing: bool}` β€” typing state +The default `websocket` backend exposes `ws[s]:///api/agent/ws`. Agents +connect as clients, receive `BackendMessage` frames (`new_session`, +`user_message`, `cancel`) and stream back `BackendEvent` frames (`text`, `tool`, +`result`, `status`, `error`). Full schema and a reference client live in +`PROTOCOL.md` and `examples/agent.py`. ## Coding Guidelines diff --git a/README.md b/README.md index 9669613..b45b3e3 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,53 @@ # Warren -Talk to Claude Code from a Rabbit R1. +Talk to your AI agents from a Rabbit R1. -Warren bridges the R1's voice PTT and WebView interfaces to Claude Code sessions via NanoClaw. A FastAPI server handles auth, session management, and event routing. NanoClaw orchestrates agent containers. An R1 Creation provides the 240x282 terminal UI. +Warren bridges the R1's voice PTT and WebView interfaces to an arbitrary personal AI agent. A FastAPI server handles auth, session management, and event routing, and proxies sessions to an agent over a WebSocket protocol (or to local Orca terminals). An R1 Creation provides the 240x282 terminal UI. ``` R1 PTT (Voice) ───────WebSocket───┐ - β”œβ”€β”€ Warren ──IPC──▢ NanoClaw ──▢ Agent containers + β”œβ”€β”€ Warren ──WebSocket──▢ Personal Agent R1 Creation (240x282 WebView) ─SSEβ”˜ ``` +The agent connects to Warren as a client, so it can run behind NAT/firewalls with no public webhook. See `PROTOCOL.md` and `examples/agent.py`. + ## Prerequisites -- [Anthropic API key](https://console.anthropic.com/) +- A personal AI agent that speaks the Warren WebSocket protocol (see `examples/agent.py`) - Rabbit R1 (or any browser for testing) - A server reachable over HTTPS (required for R1 WebView and voice) -- Docker (for NanoClaw agent containers) ## Setup ### Docker Compose (recommended) ```bash -git clone --recurse-submodules https://github.com/dsr-restyn/warren.git +git clone https://github.com/dsr-restyn/warren.git cd warren ``` Set required environment variables: ```bash -export ANTHROPIC_API_KEY=sk-ant-... export WARREN_ADMIN_TOKEN=your-secret-admin-token -export WARREN_INTERNAL_SECRET=your-internal-secret +export WARREN_INTERNAL_SECRET=your-agent-auth-secret ``` -Start both services: +Start Warren: ```bash docker compose up -d ``` +Then connect your agent to `wss://your-server/api/agent/ws?token=$WARREN_INTERNAL_SECRET`. + Put a reverse proxy (nginx, Caddy, Traefik) in front of port 4030 to terminate TLS. ### Bare metal ```bash -git clone --recurse-submodules https://github.com/dsr-restyn/warren.git +git clone https://github.com/dsr-restyn/warren.git cd warren/server uv sync cp config.yaml.example config.yaml @@ -53,9 +55,8 @@ cp config.yaml.example config.yaml ``` ```bash -export ANTHROPIC_API_KEY=sk-ant-... export WARREN_ADMIN_TOKEN=your-secret-admin-token -export WARREN_INTERNAL_SECRET=your-internal-secret +export WARREN_INTERNAL_SECRET=your-agent-auth-secret uv run python -m warren # Listens on 0.0.0.0:4030 ``` @@ -80,15 +81,13 @@ Sessions auto-transition based on inactivity: `active β†’ idle β†’ suspended β†’ | Variable | Default | Description | |---|---|---| -| `ANTHROPIC_API_KEY` | (required) | Anthropic API key for Claude Code | | `WARREN_ADMIN_TOKEN` | (required) | Token for admin operations (pairing, config) | -| `WARREN_INTERNAL_SECRET` | (required) | Shared secret for NanoClaw ↔ Warren auth | +| `WARREN_INTERNAL_SECRET` | (recommended) | Token required of agents connecting to `/api/agent/ws` | +| `WARREN_BACKEND` | `websocket` | Backend adapter: `websocket` or `orca` | | `WARREN_CONFIG` | `config.yaml` | Path to YAML config file | | `WARREN_DB` | `warren.db` | Path to SQLite database | | `WARREN_HOST` | `0.0.0.0` | Server bind address | | `WARREN_PORT` | `4030` | Server port | -| `WARREN_NANOCLAW_IPC_DIR` | | Path to NanoClaw IPC directory | -| `WARREN_NANOCLAW_DB` | | Path to NanoClaw SQLite database | | `WARREN_VOICE_ENABLED` | `false` | Enable voice WebSocket endpoint | | `WARREN_VOICE_PORT` | `443` | Port advertised in voice pairing QR | @@ -149,7 +148,8 @@ docker compose -f tests/e2e/docker-compose.test.yml down -v | File | Purpose | |---|---| | `server/src/warren/app.py` | FastAPI app, endpoints, SSE streaming | -| `server/src/warren/adapters/nanoclaw.py` | NanoClaw IPC adapter | +| `server/src/warren/adapters/websocket.py` | WebSocket agent proxy adapter (default) | +| `server/src/warren/adapters/orca.py` | Local Orca terminal adapter | | `server/src/warren/voice.py` | Voice WebSocket handler (OpenClaw protocol) | | `server/src/warren/bus.py` | SessionBus pub/sub (multi-consumer events) | | `server/src/warren/lifecycle.py` | Background session lifecycle sweep | @@ -157,7 +157,8 @@ docker compose -f tests/e2e/docker-compose.test.yml down -v | `server/src/warren/db.py` | SQLite (sessions, tokens, messages) | | `server/src/warren/config.py` | pydantic-settings + YAML config | | `creation/` | R1 WebView (vanilla HTML/CSS/JS) | -| `nanoclaw/` | Agent container orchestrator (git submodule) | +| `PROTOCOL.md` | WebSocket agent protocol reference | +| `examples/agent.py` | Reference Python agent client | ## License diff --git a/docker-compose.yml b/docker-compose.yml index f33fb12..fddfb4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,6 @@ services: - WARREN_CONFIG=/data/config.yaml - WARREN_DB=/data/warren.db - WARREN_ADMIN_TOKEN=${WARREN_ADMIN_TOKEN} - - WARREN_NANOCLAW_DB=/data/nanoclaw/store/messages.db - - WARREN_NANOCLAW_IPC_DIR=/data/nanoclaw/ipc - WARREN_VOICE_ENABLED=${WARREN_VOICE_ENABLED:-false} - WARREN_VOICE_PORT=${WARREN_VOICE_PORT:-443} - WARREN_INTERNAL_SECRET=${WARREN_INTERNAL_SECRET} @@ -25,27 +23,6 @@ services: networks: - dokploy-network - nanoclaw: - build: - context: ./nanoclaw - environment: - - WARREN_CALLBACK_URL=http://warren:4030/internal/nanoclaw/callback - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN} - - GITHUB_TOKEN=${GITHUB_TOKEN} - - DATA_DIR=/data/nanoclaw - - WARREN_INTERNAL_SECRET=${WARREN_INTERNAL_SECRET} - - WHATSAPP_ENABLED=false - - NTFY_SERVER=${NTFY_SERVER:-https://ntfy.sh} - - NTFY_TOPIC=${NTFY_TOPIC:-} - - NTFY_TOKEN=${NTFY_TOKEN:-} - volumes: - - warren-data:/data - - /var/run/docker.sock:/var/run/docker.sock - restart: unless-stopped - networks: - - dokploy-network - volumes: warren-data: diff --git a/docs/plans/2026-02-20-nanoclaw-integration-design.md b/docs/plans/2026-02-20-nanoclaw-integration-design.md deleted file mode 100644 index abf4d1f..0000000 --- a/docs/plans/2026-02-20-nanoclaw-integration-design.md +++ /dev/null @@ -1,320 +0,0 @@ -# Warren Autonomous Operations & AI-Native Platform Design - -## Summary - -Transform Warren from an AI-assisted tool into an AI-native platform. Three -capabilities layered together: - -1. **Containerized execution** -- all Claude runs in Docker containers -2. **Autonomous operations** -- scheduled tasks, agent swarms, monitoring, ntfy.sh -3. **AI-native extensibility** -- skills, self-setup, self-healing, self-documentation - -Inspired by studying NanoClaw's source code (github.com/qwibitai/nanoclaw). -Built entirely in Python within Warren's existing codebase. - -## Architecture - -``` -+----------------------------------------------------------+ -| VPS | -| | -| +----------------------------------------------------+ | -| | Warren (FastAPI) | | -| | | | -| | Interactive: Autonomous: | | -| | - R1 sessions - Task scheduler (cron) | | -| | - SSE streaming - Swarm orchestrator | | -| | - QR pairing - Health monitor | | -| | - Command palette - ntfy.sh notifications | | -| | | | -| | Container runner: AI-native: | | -| | - Docker subprocess - .claude/skills/* | | -| | - Mount security - Self-setup (/setup) | | -| | - Session isolation - Self-healing (/debug) | | -| | - Self-extending | | -| | | | -| | /api/tasks/* /api/swarms/* /api/monitor/* | | -| +----------------------------------------------------+ | -| | | -| | docker run ... | -| v | -| +----------------------+ +----------------------+ | -| | Container A | | Container B | | -| | claude -p "..." | | claude -p "..." | | -| +----------------------+ +----------------------+ | -| | -| Cloudflare Tunnel | -+----------------------------------------------------------+ -``` - ---- - -## Part 1: AI-Native Extensibility - -This comes first because it makes everything else easier to build and maintain. - -### Warren Skills - -Skills live in `.claude/skills/` in the Warren repo. They guide Claude Code -through complex operations on Warren itself. - -``` -.claude/skills/ - setup/ - SKILL.md # VPS deployment wizard - scripts/ - 01-check-env.sh # Detect OS, Docker, Python, node - 02-install-deps.sh # uv sync, npm install - 03-setup-docker.sh # Build container image - 04-configure.sh # config.yaml, .env, systemd - 05-verify.sh # End-to-end health check - customize/ - SKILL.md # Open-ended: "add X to Warren" - debug/ - SKILL.md # Diagnose containers, scheduler, networking - add-notification/ - SKILL.md # Add a notification channel - add/ # Template: notify_{channel}.py - modify/ # Intent files for wiring - add-scheduled-task/ - SKILL.md # Define + test a new scheduled task -``` - -**Design principle from NanoClaw:** "When something is broken or missing, fix -it. Don't tell the user to go fix it themselves unless it genuinely requires -their manual action." - -### Self-Documenting CLAUDE.md - -Warren's CLAUDE.md expands to serve as an AI navigation guide: - -```markdown -## Extension Points - -To add a notification channel: create server/src/warren/notify_{name}.py -implementing send(title, message). Register in config.py NotifyChannel enum. - -To add a scheduled task: add entry to config.yaml scheduled_tasks section. -See docs/plans/2026-02-20-nanoclaw-integration-design.md for schema. - -To add a container profile: add entry to config.yaml container_profiles. - -To add an API endpoint: add route in app.py inside create_app(). -``` - -### Self-Healing Pattern - -Health check scripts emit structured JSON status blocks: - -```json -{"component": "docker", "status": "ok"} -{"component": "scheduler", "status": "error", "detail": "croniter not installed"} -{"component": "ntfy", "status": "ok"} -``` - -The `/debug` skill reads these and fixes problems automatically. - ---- - -## Part 2: Container Isolation - -### Container Image - -```dockerfile -FROM node:22-slim -RUN apt-get update && apt-get install -y curl git python3 \ - && rm -rf /var/lib/apt/lists/* -RUN npm install -g @anthropic-ai/claude-code -RUN mkdir -p /workspace/repo -USER node -WORKDIR /workspace/repo -ENTRYPOINT ["claude"] -``` - -### ClaudeRunner Container Mode - -When `WARREN_CONTAINER_ENABLED=true`, ClaudeRunner spawns Claude CLI inside -Docker containers via `asyncio.create_subprocess_exec("docker", "run", ...)`. - -The existing `parse_stream_line()` works unchanged -- Docker passes container -stdout transparently. - -Falls back to bare subprocess when containers disabled (local dev). - -### Container Profiles - -```yaml -container_profiles: - interactive: - repo_access: readwrite - network: true - max_runtime: 3600 - memory: 1g - readonly: - repo_access: readonly - network: false - max_runtime: 300 - memory: 512m - writable: - repo_access: readwrite - network: false - max_runtime: 600 - memory: 1g - networked: - repo_access: readonly - network: true - max_runtime: 300 - memory: 512m -``` - -### Mount Security - -Only repo paths from `config.yaml` are mountable. Container runner validates -before spawning. Inspired by NanoClaw's allowlist model. - -### Session Isolation - -Each session gets its own `.claude/` dir at -`data/sessions/{session_id}/.claude/`, mounted into the container at -`/home/node/.claude/`. Same pattern as NanoClaw. - ---- - -## Part 3: Autonomous Operations - -### Scheduled Tasks - -```yaml -scheduled_tasks: - security-scan: - schedule: "0 3 * * *" - repo: warren - prompt: "Run ruff check and bandit. Report any new findings." - container_profile: readonly - notify: [ntfy, r1] - dependency-check: - schedule: "0 9 * * 1" - repo: warren - prompt: "Check for outdated dependencies and security advisories." - container_profile: networked - notify: [ntfy] - health-report: - schedule: "*/30 * * * *" - prompt: "Check Warren service health, recent errors, request patterns." - container_profile: readonly - notify: [r1] -``` - -Scheduler loop ticks every 30s, evaluates cron with croniter, spawns -containerized agents, stores results in SQLite, sends notifications. - -### Agent Swarms - -**Parallel:** N containers via asyncio.gather, then synthesizer combines. - -```yaml -swarms: - security-review: - type: parallel - repo: warren - agents: - - name: auth-reviewer - prompt: "Review auth code." - container_profile: readonly - - name: owasp-scanner - prompt: "Check OWASP top 10." - container_profile: readonly - synthesizer: - prompt: "Combine findings into executive summary." - notify: [ntfy, r1] -``` - -**Pipeline:** Sequential, `{previous_result}` substitution between steps. - -### Notifications (ntfy.sh) - -```python -async def send(self, title: str, message: str) -> None: - async with httpx.AsyncClient() as client: - await client.post( - f"{self._server}/{self._topic}", - content=message, - headers={"Title": title}, - ) -``` - -### Monitoring - -- `GET /api/monitor/health` -- active containers, disk, errors -- `GET /api/monitor/alerts` -- from failed tasks, health anomalies -- SQLite tables: `task_runs`, `swarm_runs`, `alerts` - -### Self-Improving Ops - -A scheduled task that reviews its own task history: - -```yaml -scheduled_tasks: - meta-review: - schedule: "0 9 * * 5" - prompt: "Review this week's task results. Suggest improvements." - container_profile: readonly - notify: [ntfy, r1] -``` - -Human-in-the-loop: you approve or reject suggestions. - ---- - -## Part 4: R1 UI - -New views via command palette (240x282): - -- **Task Dashboard** -- task list, status, history, "Run Now" button -- **Swarm View** -- agent progress, synthesized results -- **Monitor View** -- health indicators, alerts, resource gauges - ---- - -## API Additions - -- `GET /api/tasks` | `POST /api/tasks/{name}/trigger` | `GET /api/tasks/{name}/history` -- `GET /api/swarms` | `POST /api/swarms/{name}/run` | `GET /api/swarms/runs/{id}` -- `GET /api/monitor/health` | `GET /api/monitor/alerts` - ---- - -## New Files - -``` -.claude/skills/ - setup/SKILL.md + scripts/ - customize/SKILL.md - debug/SKILL.md - add-notification/SKILL.md + templates - add-scheduled-task/SKILL.md - -server/src/warren/ - container.py # Docker runner, mount security - scheduler.py # Cron scheduler loop - swarm.py # Parallel + pipeline orchestrator - notify.py # ntfy.sh client - -server/container/ - Dockerfile # Warren agent container image - build.sh - -server/tests/ - test_container.py - test_scheduler.py - test_swarm.py - test_notify.py - test_config.py - test_db.py -``` - -## Dependencies - -- `croniter` -- cron parsing -- `httpx` -- ntfy (move to prod deps) -- Docker -- container runtime on VPS diff --git a/docs/plans/2026-02-20-nanoclaw-integration-plan.md b/docs/plans/2026-02-20-nanoclaw-integration-plan.md deleted file mode 100644 index 00eafc5..0000000 --- a/docs/plans/2026-02-20-nanoclaw-integration-plan.md +++ /dev/null @@ -1,529 +0,0 @@ -# Warren Autonomous Operations & AI-Native Platform Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Transform Warren into an AI-native platform with containerized execution, autonomous operations, and self-extensibility. Inspired by NanoClaw's architecture. - -**Architecture:** Single FastAPI process. Docker containers for Claude CLI. Cron scheduler. Parallel/pipeline swarms. ntfy.sh notifications. Claude Code skills for self-setup, customization, and debugging. - -**Tech Stack:** Python 3.11, FastAPI, aiosqlite, Docker CLI (asyncio subprocess), croniter, httpx, pydantic - -**Design Doc:** `docs/plans/2026-02-20-nanoclaw-integration-design.md` - ---- - -## Phase 0: AI-Native Foundation (Skills) - -Skills come first because they make everything else easier to build, deploy, -and debug. They also establish the AI-native philosophy from the start. - -### Task 0a: Create /setup skill - -**Files:** -- Create: `.claude/skills/setup/SKILL.md` -- Create: `.claude/skills/setup/scripts/01-check-env.sh` -- Create: `.claude/skills/setup/scripts/02-install-deps.sh` -- Create: `.claude/skills/setup/scripts/03-setup-docker.sh` -- Create: `.claude/skills/setup/scripts/04-configure.sh` -- Create: `.claude/skills/setup/scripts/05-verify.sh` - -**SKILL.md** guides Claude through first-time Warren deployment on a VPS: -1. Check environment (OS, Python, Docker, node, uv) -2. Install dependencies (uv sync, npm for container image) -3. Build Docker container image -4. Configure (config.yaml, .env, systemd service, Cloudflare tunnel) -5. End-to-end verification - -Each script emits structured JSON status blocks. Claude parses them and -fixes failures automatically. Only pauses for things that genuinely need -human action (API keys, domain config). - -**Principle:** "Fix it. Don't tell the user to go fix it themselves." - -**Step 1: Write SKILL.md** - -Follow NanoClaw's setup skill as a reference for structure and tone. Adapt -for Warren's Python/FastAPI stack and Linux VPS target. - -**Step 2: Write check scripts** - -Each script checks one thing and outputs a JSON status block: -```bash -echo '{"component":"docker","status":"ok","version":"24.0.7"}' -``` - -**Step 3: Test by running /setup in the Warren repo** - -Verify Claude correctly navigates the skill on a fresh checkout. - -**Step 4: Commit** - -```bash -git add .claude/skills/setup/ -git commit -m "feat: add /setup skill for AI-guided VPS deployment" -``` - -### Task 0b: Create /customize skill - -**Files:** -- Create: `.claude/skills/customize/SKILL.md` - -Open-ended customization skill. Documents Warren's architecture, extension -points, and modification patterns so Claude can handle any request: - -- Adding API endpoints -- Adding notification channels -- Changing container behavior -- Modifying R1 UI -- Adding scheduled tasks - -Reference: NanoClaw's `/customize` skill pattern -- understand the request, -plan changes, implement, test. - -```bash -git add .claude/skills/customize/ -git commit -m "feat: add /customize skill for AI-driven extensibility" -``` - -### Task 0c: Create /debug skill - -**Files:** -- Create: `.claude/skills/debug/SKILL.md` - -Troubleshooting skill for: -- Container failures (build, startup, timeout) -- Scheduler issues (tasks not running, cron syntax) -- Session problems (SSE not streaming, auth failures) -- Networking (Cloudflare tunnel, port conflicts) - -Pattern: run diagnostic commands, parse structured output, diagnose, fix. - -```bash -git add .claude/skills/debug/ -git commit -m "feat: add /debug skill for AI-guided troubleshooting" -``` - -### Task 0d: Expand CLAUDE.md with extension points - -**Files:** -- Modify: `CLAUDE.md` - -Add an "Extension Points" section documenting: -- How to add notification channels -- How to add scheduled tasks -- How to add container profiles -- How to add API endpoints -- Where key abstractions live - -This teaches Claude how to extend Warren without needing a specific skill. - -```bash -git add CLAUDE.md -git commit -m "docs: add extension points to CLAUDE.md for AI-native development" -``` - ---- - -## Phase 1: Container Image - -### Task 1: Create Warren agent container Dockerfile - -**Files:** -- Create: `server/container/Dockerfile` -- Create: `server/container/build.sh` - -**Step 1: Create Dockerfile** - -A minimal container with Claude CLI installed. No Chromium or browser -automation (NanoClaw includes these; we don't need them). - -```dockerfile -FROM node:22-slim - -RUN apt-get update && apt-get install -y curl git python3 \ - && rm -rf /var/lib/apt/lists/* - -RUN npm install -g @anthropic-ai/claude-code - -RUN mkdir -p /workspace/repo - -USER node -WORKDIR /workspace/repo - -ENTRYPOINT ["claude"] -``` - -**Step 2: Create build script** - -```bash -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -docker build -t warren-agent:latest "$SCRIPT_DIR" -``` - -**Step 3: Build and verify** - -Run: `cd server/container && chmod +x build.sh && ./build.sh` -Run: `docker run --rm warren-agent:latest --version` -Expected: Claude CLI version string - -**Step 4: Commit** - -```bash -git add server/container/ -git commit -m "feat: add Warren agent container Dockerfile" -``` - ---- - -## Phase 2: Container Runner Module - -### Task 2: Write container runner with mount security - -**Files:** -- Create: `server/src/warren/container.py` -- Create: `server/tests/test_container.py` - -**Step 1: Write failing tests** - -```python -"""Tests for container runner.""" - -import pytest -from warren.container import ( - build_docker_args, - validate_mount_path, - ContainerProfile, -) - - -def test_validate_mount_path_allowed(): - allowed = ["/opt/warren", "/opt/myproject"] - assert validate_mount_path("/opt/warren", allowed) is True - assert validate_mount_path("/opt/warren/subdir", allowed) is True - - -def test_validate_mount_path_rejected(): - allowed = ["/opt/warren"] - assert validate_mount_path("/etc/passwd", allowed) is False - assert validate_mount_path("/home/user/.ssh", allowed) is False - - -def test_build_docker_args_readonly(): - profile = ContainerProfile( - repo_access="readonly", network=False, - max_runtime=300, memory="512m", - ) - args = build_docker_args( - repo_path="/opt/warren", - session_dir="/data/sessions/abc/.claude", - profile=profile, - api_key="sk-test", - container_name="warren-abc", - ) - joined = " ".join(args) - assert "--rm" in args - assert "warren-abc" in args - assert "/opt/warren:/workspace/repo:ro" in joined - assert "--network" in args - - -def test_build_docker_args_readwrite(): - profile = ContainerProfile( - repo_access="readwrite", network=True, - max_runtime=3600, memory="1g", - ) - args = build_docker_args( - repo_path="/opt/warren", - session_dir="/data/sessions/abc/.claude", - profile=profile, - api_key="sk-test", - container_name="warren-abc", - ) - joined = " ".join(args) - assert "/opt/warren:/workspace/repo" in joined - # No :ro suffix - assert ":ro" not in [a for a in args if "/workspace/repo" in a][0] -``` - -**Step 2: Run test to verify it fails** - -Run: `cd server && uv run pytest tests/test_container.py -v` -Expected: FAIL -- ModuleNotFoundError - -**Step 3: Implement container.py** - -Core functions: -- `validate_mount_path(path, allowed_roots)` -- security check -- `build_docker_args(...)` -- construct docker run argument list -- `run_in_container(...)` -- spawn Docker container via asyncio subprocess -- `stop_container(name)` -- graceful stop - -Uses `asyncio.create_subprocess_exec("docker", "run", ...)` -- same pattern -as current ClaudeRunner but targeting Docker instead of bare claude CLI. - -**Step 4: Run tests, verify pass** - -Run: `cd server && uv run pytest tests/test_container.py -v` - -**Step 5: Commit** - -```bash -git add server/src/warren/container.py server/tests/test_container.py -git commit -m "feat: add container runner with mount security" -``` - ---- - -## Phase 3: Config Expansion - -### Task 3: Add config models for container profiles, tasks, swarms - -**Files:** -- Modify: `server/src/warren/config.py` -- Create: `server/tests/test_config.py` - -**Step 1: Write failing tests** - -Test loading config with `scheduled_tasks`, `container_profiles`, and -`swarms` sections. Also test backwards compatibility (existing configs -without new fields still load). - -**Step 2: Add pydantic models** - -New models: `ContainerProfileConfig`, `ScheduledTaskConfig`, -`SwarmAgentConfig`, `SwarmSynthesizerConfig`, `SwarmConfig`. - -Add fields to `WarrenConfig`: -```python -container_profiles: dict[str, ContainerProfileConfig] = {} -scheduled_tasks: dict[str, ScheduledTaskConfig] = {} -swarms: dict[str, SwarmConfig] = {} -``` - -**Step 3: Run all tests, commit** - -```bash -git add server/src/warren/config.py server/tests/test_config.py -git commit -m "feat: config models for container profiles, tasks, swarms" -``` - ---- - -## Phase 4: Settings + Dependencies - -### Task 4: Add container and notification settings - -**Files:** -- Modify: `server/src/warren/config.py` - -Add to Settings: -```python -container_image: str = "warren-agent:latest" -container_enabled: bool = False -data_dir: str = "data" -ntfy_topic: str = "" -ntfy_server: str = "https://ntfy.sh" -``` - -```bash -git add server/src/warren/config.py -git commit -m "feat: add container and notification settings" -``` - -### Task 5: Add croniter dependency - -Run: `cd server && uv add croniter` - -```bash -git add server/pyproject.toml server/uv.lock -git commit -m "deps: add croniter for cron expression parsing" -``` - ---- - -## Phase 5: Database Schema Extensions - -### Task 6: Add task_runs, swarm_runs, alerts tables - -**Files:** -- Modify: `server/src/warren/db.py` -- Create: `server/tests/test_db.py` - -Add three new tables to `initialize()`. Add CRUD methods: -- `create_task_run`, `get_task_run`, `complete_task_run`, `list_task_runs` -- `create_swarm_run`, `get_swarm_run`, `complete_swarm_run` -- `create_alert`, `list_alerts` - -TDD: write tests first, then implement. - -```bash -git add server/src/warren/db.py server/tests/test_db.py -git commit -m "feat: add task_runs, swarm_runs, alerts DB tables" -``` - ---- - -## Phase 6: Notification Client - -### Task 7: Build ntfy.sh notification client - -**Files:** -- Create: `server/src/warren/notify.py` -- Create: `server/tests/test_notify.py` - -Simple async client: `NtfyClient(server, topic)` with `send(title, message)`. -Skips silently when topic is empty (notifications disabled). - -```bash -git add server/src/warren/notify.py server/tests/test_notify.py -git commit -m "feat: add ntfy.sh notification client" -``` - ---- - -## Phase 7: Task Scheduler - -### Task 8: Build cron-based task scheduler - -**Files:** -- Create: `server/src/warren/scheduler.py` -- Create: `server/tests/test_scheduler.py` - -`should_run_now(task, now, last_run)` -- evaluates cron with croniter. -`TaskScheduler` -- holds task state, ticks every 30s, dispatches due tasks. -`scheduler_loop(...)` -- async background loop (like lifecycle_loop). - -Dispatch = spawn containerized Claude CLI, capture result, store in DB, -send notification. - -```bash -git add server/src/warren/scheduler.py server/tests/test_scheduler.py -git commit -m "feat: add cron-based task scheduler" -``` - ---- - -## Phase 8: Swarm Orchestrator - -### Task 9: Build parallel + pipeline swarm orchestrator - -**Files:** -- Create: `server/src/warren/swarm.py` -- Create: `server/tests/test_swarm.py` - -`ParallelSwarm` -- asyncio.gather on N container runs, then synthesizer. -`PipelineSwarm` -- sequential loop, `{previous_result}` substitution. - -Error handling: partial failures produce partial results, not crashes. - -```bash -git add server/src/warren/swarm.py server/tests/test_swarm.py -git commit -m "feat: add parallel and pipeline swarm orchestrator" -``` - ---- - -## Phase 9: API Endpoints - -### Task 10: Add /api/tasks endpoints - -- `GET /api/tasks` -- task definitions + latest run status -- `POST /api/tasks/{name}/trigger` -- manually run a task -- `GET /api/tasks/{name}/history` -- past runs - -### Task 11: Add /api/swarms endpoints - -- `GET /api/swarms` -- swarm definitions -- `POST /api/swarms/{name}/run` -- launch a swarm -- `GET /api/swarms/runs/{run_id}` -- progress and results - -### Task 12: Add /api/monitor endpoints - -- `GET /api/monitor/health` -- system health (active containers, disk, errors) -- `GET /api/monitor/alerts` -- recent alerts - -### Task 13: Wire scheduler + notifications into lifespan - -Start scheduler loop alongside lifecycle loop. -Initialize NtfyClient in app.state. -Pass notification callback to scheduler. - -**Commit after each task (10, 11, 12, 13).** - ---- - -## Phase 10: Container Mode for ClaudeRunner - -### Task 14: Add container mode to ClaudeRunner - -When `container_enabled=True`, ClaudeRunner uses `container.run_in_container()` -instead of bare subprocess. Per-session `.claude/` directories at -`{data_dir}/sessions/{session_id}/.claude/`. - -Falls back to bare subprocess when disabled (local dev). - -Both modes tested. - -```bash -git commit -m "feat: container mode for ClaudeRunner" -``` - ---- - -## Phase 11: R1 UI - -### Task 15: Task dashboard view -### Task 16: Swarm view -### Task 17: Monitor view - -New 240x282 views accessible from the command palette. - ---- - -## Phase 12: Self-Improving Ops - -### Task 18: Meta-review task example - -Config-only: document a weekly meta-review scheduled task that reviews -past task results and suggests improvements. - ---- - -## Dependency Graph - -``` -Tasks 0a-0d (Skills + CLAUDE.md) -- can be done independently, no deps - | -Task 1 (Dockerfile) - | -Task 2 (Container runner) - | -Task 3 (Config) + Task 4 (Settings) + Task 5 (croniter) - | -Task 6 (DB schema) - | -Task 7 (ntfy) + Task 8 (Scheduler) + Task 9 (Swarms) - | -Tasks 10-13 (API + wiring) - | -Task 14 (ClaudeRunner container mode) - | -Tasks 15-17 (R1 UI) - | -Task 18 (Self-improving ops) -``` - -## Notes - -- **Phase 0 (skills) has no code dependencies** -- can start immediately, in - parallel with infrastructure work. Skills are documentation + scripts, not - Warren server code. -- `WARREN_CONTAINER_ENABLED=false` is default -- existing deploys unaffected. -- Docker must be installed; `docker` available to the Warren process user. -- Build image first: `cd server/container && ./build.sh` -- Add httpx to prod dependencies (currently dev-only). -- The `/setup` skill itself guides through Docker installation, image building, - and service configuration -- so deploying the infrastructure becomes - AI-assisted from day one. diff --git a/docs/plans/2026-02-20-nanoclaw-service-design.md b/docs/plans/2026-02-20-nanoclaw-service-design.md deleted file mode 100644 index 61ce5ff..0000000 --- a/docs/plans/2026-02-20-nanoclaw-service-design.md +++ /dev/null @@ -1,389 +0,0 @@ -# NanoClaw Service Integration Design - -## Summary - -Deploy a forked NanoClaw alongside Warren as two Dokploy services on the -same VPS. Warren becomes the R1 gateway to NanoClaw's container-based agent -execution, scheduled tasks, and multi-agent orchestration. - -The fork adds one file to NanoClaw: a Warren channel that sends responses -via HTTP callback. Warren writes IPC files for requests, receives callbacks -for responses, and reads NanoClaw's SQLite for monitoring data. The R1 -Creation gets a new monitor view for system health, running agents, and -scheduled tasks. - -**Supersedes:** The NanoClaw adapter stub (currently `NotImplementedError`) -gets a full implementation. - ---- - -## Architecture - -``` -R1 Device - | - | HTTPS / SSE - v -+-----------------------------------+ -| Warren (Dokploy service) | -| | -| FastAPI | -| - R1 UI / auth / pairing | -| - NanoClawAdapter | -| - writes IPC files ------------|-------> shared volume -| - receives HTTP callbacks <----|------+ /opt/warren-data/ -| - reads NanoClaw SQLite -------|--+ | -| - Monitor proxy endpoints | | | -| - SSE streaming to R1 | | | -+-----------------------------------+ | | - | | -+-----------------------------------+ | | -| NanoClaw fork (Dokploy service) | | | -| | | | -| Node.js | | | -| - Warren channel (new) ----------|--+---+ -| - HTTP POST to Warren callback | -| - IPC watcher (reads task files) | -| - Container runner | -| - Scheduler | -| - Group management | -| - SQLite DB <--------------------|--+ (read by Warren) -+-----------------------------------+ - | - | Docker socket (/var/run/docker.sock) - v - +-----------+ +-----------+ - | Agent | | Agent | - | Container | | Container | - +-----------+ +-----------+ -``` - ---- - -## Part 1: NanoClaw Fork - -**Repo:** Fork `github.com/gavrielc/nanoclaw` to `github.com/dsr-restyn/nanoclaw`. - -### Changes (minimal) - -**1. Add `src/channels/warren.ts`** - -Implements the Channel interface: - -```typescript -interface Channel { - name: string; - connect(): Promise; - sendMessage(jid: string, text: string): Promise; - isConnected(): boolean; - ownsJid(jid: string): boolean; - disconnect(): Promise; - setTyping?(jid: string, isTyping: boolean): Promise; -} -``` - -Implementation: -- `name`: `"warren"` -- `ownsJid(jid)`: returns `true` for JIDs matching `warren:*` -- `sendMessage(jid, text)`: HTTP POST to `WARREN_CALLBACK_URL` with - `{ session_id: jid.replace("warren:", ""), text }` -- `connect()` / `disconnect()`: no-ops (stateless HTTP) -- `isConnected()`: always `true` -- `setTyping(jid, isTyping)`: HTTP POST typing indicator to Warren - -**2. Register in `src/index.ts`** - -```typescript -if (process.env.WARREN_CALLBACK_URL) { - const warrenChannel = new WarrenChannel({ - callbackUrl: process.env.WARREN_CALLBACK_URL, - ...channelOpts, - }); - channels.push(warrenChannel); -} -``` - -**3. Environment variable** - -- `WARREN_CALLBACK_URL`: e.g., `http://warren:8000/internal/nanoclaw/callback` - -That's it. No other NanoClaw code changes. Container runner, scheduler, IPC -watcher, group management all stay untouched. - ---- - -## Part 2: Warren NanoClawAdapter - -Replace the current `NotImplementedError` stub with a working adapter. - -### Request Path (Warren β†’ NanoClaw) - -Warren writes JSON files to NanoClaw's IPC directory on the shared volume. - -**Interactive session (new_session / user_message):** - -Write to `{ipc_dir}/warren/messages/{timestamp}.json`: -```json -{ - "type": "message", - "text": "the user's message", - "sender": "warren:{session_id}", - "timestamp": 1708382400000 -} -``` - -The Warren group uses JID `warren:{session_id}` so NanoClaw routes -responses back through the Warren channel. - -**Scheduled task:** - -Write to `{ipc_dir}/warren/tasks/{task_id}.json`: -```json -{ - "type": "schedule_task", - "task_id": "warren-task-{id}", - "prompt": "the task prompt", - "schedule": { "cron": "0 9 * * *" }, - "owner_group": "warren" -} -``` - -**Cancel:** - -Write to `{ipc_dir}/warren/tasks/{task_id}.json`: -```json -{ - "type": "cancel_task", - "task_id": "warren-task-{id}", - "owner_group": "warren" -} -``` - -All writes use atomic temp-file-then-rename pattern. - -### Response Path (NanoClaw β†’ Warren) - -**Callback endpoint:** `POST /internal/nanoclaw/callback` - -NanoClaw's Warren channel POSTs response chunks: -```json -{ - "session_id": "abc123", - "text": "Claude's response text", - "type": "text" -} -``` - -The adapter: -1. Parses the callback payload -2. Creates a `BackendEvent(kind="text", session_id=..., data=...)` -3. Pushes to the session's event queue -4. Event pump routes to SSE for the R1 - -**Typing indicator:** `POST /internal/nanoclaw/typing` -```json -{ - "session_id": "abc123", - "is_typing": true -} -``` - -Translated to a `BackendEvent(kind="status", data={"state": "working"})`. - -### Monitoring (Warren reads NanoClaw's SQLite) - -Warren opens NanoClaw's SQLite DB (read-only) from the shared volume for -monitoring endpoints. Key tables: - -- `scheduled_tasks`: task list, cron/interval, last run, next run -- `chats`: group metadata, activity timestamps -- `task_run_logs`: execution history - -Warren exposes proxy endpoints for the R1 Creation: - -- `GET /monitor/health` β€” Warren + NanoClaw + Docker status -- `GET /monitor/agents` β€” running containers / active sessions -- `GET /monitor/tasks` β€” scheduled tasks with status - -These endpoints are authenticated via `require_auth` (device token). - -### start() / stop() - -- `start()`: verify IPC directory exists and is writable, verify NanoClaw - DB is readable, register the Warren group in NanoClaw's IPC if needed -- `stop()`: clean up, cancel pending operations - ---- - -## Part 3: R1 Creation Monitor View - -New view added to the Creation UI, accessible via `[MON]` button in the -sessions list footer. - -### Layout - -``` -+------------------------+ -| <- MONITOR [ok] | -| | -| HEALTH | -| Warren [green] | -| NanoClaw [green] | -| Docker [green] | -| | -| AGENTS (2) | -| > repo-a: working... | -| > repo-b: idle | -| | -| TASKS (3) | -| > daily-report 09:00 | -| > backup 00:00 | -| > cleanup paused | -| | -+------------------------+ -| [<- BACK] | -+------------------------+ -``` - -### Interactions - -- **Click agent** β†’ opens chat/log view for that session -- **Click task** β†’ shows task detail with pause/resume controls -- **Auto-refresh** β€” polls monitor endpoints every 5 seconds -- **Health colors** β€” green/yellow/red dots matching CRT aesthetic - -### New API Endpoints - -| Method | Endpoint | Purpose | -|--------|----------|---------| -| GET | `/monitor/health` | System health (Warren, NanoClaw, Docker) | -| GET | `/monitor/agents` | Running containers and active sessions | -| GET | `/monitor/tasks` | Scheduled tasks with status | -| POST | `/monitor/tasks/{id}/pause` | Pause a scheduled task | -| POST | `/monitor/tasks/{id}/resume` | Resume a scheduled task | - -Task pause/resume writes IPC files to NanoClaw's task directory. - -### Files Modified - -``` -creation/ - index.html # Add #view-monitor DOM - css/styles.css # Monitor view styles - js/app.js # Monitor view logic, [MON] button - js/api.js # Add monitor endpoint calls -``` - ---- - -## Part 4: Deployment (Dokploy) - -### Warren Service (existing, updated) - -- Same FastAPI container -- **New volume mount:** `/opt/warren-data/` (shared with NanoClaw) -- **New env vars:** - - `WARREN_BACKEND=nanoclaw` - - NanoClaw DB path and IPC dir configured via `backend_config` in - config.yaml - -### NanoClaw Service (new) - -**Dockerfile:** -```dockerfile -FROM node:22-slim - -RUN npm install -g @anthropic-ai/claude-code - -WORKDIR /app -COPY . . -RUN npm install - -CMD ["npm", "start"] -``` - -**Dokploy configuration:** -- Docker socket mount: `/var/run/docker.sock:/var/run/docker.sock` -- Shared volume: `/opt/warren-data/:/app/data/` -- Environment variables: - - `ANTHROPIC_API_KEY` - - `WARREN_CALLBACK_URL=http://warren:8000/internal/nanoclaw/callback` - - `DATA_DIR=/app/data` - -### Shared Volume Structure - -``` -/opt/warren-data/ - ipc/ - warren/ - messages/ # Warren β†’ NanoClaw interactive messages - tasks/ # Warren β†’ NanoClaw scheduled tasks - nanoclaw.db # NanoClaw's SQLite (read by Warren) -``` - -### Networking - -Both services on the same Dokploy Docker network. Warren reaches NanoClaw -by service name. NanoClaw reaches Warren's callback endpoint by service -name. - -### Setup Flow - -1. Fork NanoClaw, add Warren channel -2. Create NanoClaw service in Dokploy (Docker source from fork repo) -3. Configure shared volume in both services -4. Mount Docker socket in NanoClaw service -5. Set env vars in both services -6. Set `WARREN_BACKEND=nanoclaw` in Warren -7. Deploy both -8. Verify: R1 β†’ Warren β†’ NanoClaw β†’ container β†’ response β†’ R1 - ---- - -## Part 5: Initial Scope - -### Day 1 -- Interactive Claude sessions via NanoClaw containers (same UX as - DirectAdapter but containerized) -- Scheduled task creation from R1 command palette -- Monitor view: health + running agents + task list - -### Later -- Task pause/resume from R1 -- Multi-agent workflows -- Container resource configuration from R1 -- NanoClaw group management - ---- - -## New/Modified Files - -### NanoClaw Fork -``` -src/channels/warren.ts # New: Warren channel implementation -src/index.ts # Modified: register Warren channel -``` - -### Warren -``` -server/src/warren/adapters/nanoclaw.py # Replace stub with full impl -server/src/warren/app.py # Add callback + monitor endpoints -server/tests/test_nanoclaw_adapter.py # New: adapter tests -server/tests/test_monitor.py # New: monitor endpoint tests -``` - -### R1 Creation -``` -creation/index.html # Add monitor view DOM -creation/css/styles.css # Monitor styles -creation/js/app.js # Monitor view logic -creation/js/api.js # Monitor API calls -``` - ---- - -## Dependencies - -- **Warren:** `aiosqlite` (already present) for reading NanoClaw's DB -- **NanoClaw fork:** No new dependencies (HTTP POST via built-in `fetch`) -- **VPS:** Docker Engine, Dokploy diff --git a/docs/plans/2026-02-20-nanoclaw-service-plan.md b/docs/plans/2026-02-20-nanoclaw-service-plan.md deleted file mode 100644 index db27961..0000000 --- a/docs/plans/2026-02-20-nanoclaw-service-plan.md +++ /dev/null @@ -1,1737 +0,0 @@ -# NanoClaw Service Integration Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Deploy a forked NanoClaw alongside Warren as two Dokploy services, with Warren acting as the R1 gateway to NanoClaw's container-based agent execution, scheduling, and monitoring. - -**Architecture:** Fork NanoClaw and add a Warren channel that sends responses via HTTP callback. Warren writes IPC files for requests, receives callbacks for responses, and reads NanoClaw's SQLite for monitoring. The R1 Creation gets a monitor view for health, agents, and tasks. - -**Tech Stack:** Python 3.11 (Warren), TypeScript/Node.js (NanoClaw fork), FastAPI, aiosqlite, vanilla HTML/CSS/JS (Creation), Docker/Dokploy - -**Design Doc:** `docs/plans/2026-02-20-nanoclaw-service-design.md` - ---- - -## Phase 1: NanoClaw Fork (TypeScript β€” separate repo) - -### Task 1: Fork NanoClaw and add Warren channel - -**Repo:** Fork `github.com/gavrielc/nanoclaw` to `github.com/dsr-restyn/nanoclaw` - -**Files:** -- Create: `src/channels/warren.ts` - -**Step 1: Fork and clone** - -```bash -gh repo fork gavrielc/nanoclaw --org dsr-restyn --clone -cd nanoclaw -npm install -``` - -**Step 2: Create Warren channel** - -Create `src/channels/warren.ts`: - -```typescript -/** - * Warren channel β€” sends NanoClaw responses to Warren via HTTP callback. - * - * Warren writes IPC files to NanoClaw's message directory. NanoClaw - * processes them and routes responses through this channel, which POSTs - * them to Warren's internal callback endpoint. - */ - -import type { Channel } from "../types.js"; - -interface WarrenChannelOpts { - callbackUrl: string; -} - -export class WarrenChannel implements Channel { - name = "warren"; - private callbackUrl: string; - - constructor(opts: WarrenChannelOpts) { - this.callbackUrl = opts.callbackUrl; - } - - async connect(): Promise { - // Stateless HTTP β€” nothing to connect - } - - async disconnect(): Promise { - // Stateless HTTP β€” nothing to disconnect - } - - isConnected(): boolean { - return true; - } - - ownsJid(jid: string): boolean { - return jid.startsWith("warren:"); - } - - async sendMessage(jid: string, text: string): Promise { - const sessionId = jid.replace("warren:", ""); - const resp = await fetch(this.callbackUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: sessionId, text, type: "text" }), - }); - if (!resp.ok) { - throw new Error(`Warren callback failed: ${resp.status}`); - } - } - - async setTyping(jid: string, isTyping: boolean): Promise { - const sessionId = jid.replace("warren:", ""); - const url = this.callbackUrl.replace("/callback", "/typing"); - await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: sessionId, is_typing: isTyping }), - }).catch(() => { - // Typing indicators are best-effort - }); - } -} -``` - -**Step 3: Register channel in `src/index.ts`** - -Find where channels are instantiated (in `main()` near -`channels.push(whatsapp)`). Add: - -```typescript -import { WarrenChannel } from "./channels/warren.js"; - -// ... inside main(), after other channel registrations: -if (process.env.WARREN_CALLBACK_URL) { - const warrenChannel = new WarrenChannel({ - callbackUrl: process.env.WARREN_CALLBACK_URL, - }); - channels.push(warrenChannel); - console.log("[NanoClaw] Warren channel registered, callback:", process.env.WARREN_CALLBACK_URL); -} -``` - -**Step 4: Verify build** - -```bash -npm run build # or npx tsc -``` - -Expected: No type errors. - -**Step 5: Commit** - -```bash -git add src/channels/warren.ts src/index.ts -git commit -m "feat: add Warren channel for HTTP callback responses" -``` - ---- - -### Task 2: Add Dockerfile for Dokploy deployment - -**Files:** -- Create: `Dockerfile` - -**Step 1: Create Dockerfile** - -Create `Dockerfile` at repo root: - -```dockerfile -FROM node:22-slim - -# Install Claude Code CLI -RUN npm install -g @anthropic-ai/claude-code - -# Install Docker CLI (for container management) -RUN apt-get update && \ - apt-get install -y --no-install-recommends docker.io curl && \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Install dependencies -COPY package*.json ./ -RUN npm ci --production - -# Copy source -COPY . . - -# Build TypeScript -RUN npm run build || true - -# Default environment -ENV DATA_DIR=/app/data -ENV NODE_ENV=production - -CMD ["npm", "start"] -``` - -**Step 2: Test Docker build locally** - -```bash -docker build -t nanoclaw-warren . -``` - -Expected: Image builds successfully. - -**Step 3: Commit and push** - -```bash -git add Dockerfile -git commit -m "feat: add Dockerfile for Dokploy deployment" -git push origin main -``` - ---- - -## Phase 2: Warren NanoClawAdapter Core (Python β€” warren repo) - -### Task 3: IPC file writer utility - -**Files:** -- Create: `server/src/warren/ipc.py` -- Create: `server/tests/test_ipc.py` - -**Step 1: Write failing tests** - -Create `server/tests/test_ipc.py`: - -```python -"""Tests for NanoClaw IPC file writing.""" - -import json -import os - -import pytest - - -class TestWriteIpcMessage: - def test_creates_message_file(self, tmp_path): - from warren.ipc import write_ipc_message - - path = write_ipc_message( - ipc_dir=str(tmp_path), - group="warren", - sender="warren:session-123", - text="hello world", - ) - assert os.path.exists(path) - with open(path) as f: - data = json.load(f) - assert data["type"] == "message" - assert data["text"] == "hello world" - assert data["sender"] == "warren:session-123" - assert "timestamp" in data - - def test_creates_directory_structure(self, tmp_path): - from warren.ipc import write_ipc_message - - write_ipc_message( - ipc_dir=str(tmp_path), - group="mygroup", - sender="warren:abc", - text="test", - ) - assert os.path.isdir(tmp_path / "mygroup" / "messages") - - def test_atomic_write_no_tmp_files(self, tmp_path): - from warren.ipc import write_ipc_message - - write_ipc_message( - ipc_dir=str(tmp_path), - group="warren", - sender="warren:x", - text="test", - ) - msg_dir = tmp_path / "warren" / "messages" - files = list(msg_dir.iterdir()) - assert all(f.suffix == ".json" for f in files) - - -class TestWriteIpcTask: - def test_creates_task_file(self, tmp_path): - from warren.ipc import write_ipc_task - - path = write_ipc_task( - ipc_dir=str(tmp_path), - group="warren", - task_id="daily-report", - payload={ - "type": "schedule_task", - "task_id": "daily-report", - "prompt": "generate daily report", - "schedule": {"cron": "0 9 * * *"}, - "owner_group": "warren", - }, - ) - assert os.path.exists(path) - with open(path) as f: - data = json.load(f) - assert data["type"] == "schedule_task" - assert data["task_id"] == "daily-report" - - def test_creates_tasks_directory(self, tmp_path): - from warren.ipc import write_ipc_task - - write_ipc_task( - ipc_dir=str(tmp_path), - group="warren", - task_id="test", - payload={"type": "cancel_task", "task_id": "test", "owner_group": "warren"}, - ) - assert os.path.isdir(tmp_path / "warren" / "tasks") -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_ipc.py -v` -Expected: FAIL β€” `ModuleNotFoundError: No module named 'warren.ipc'` - -**Step 3: Write implementation** - -Create `server/src/warren/ipc.py`: - -```python -"""NanoClaw IPC file writer. - -Writes JSON message and task files to NanoClaw's IPC directory using -atomic temp-file-then-rename to avoid partial reads. -""" - -from __future__ import annotations - -import json -import os -import tempfile -import time - - -def write_ipc_message( - ipc_dir: str, group: str, sender: str, text: str, -) -> str: - """Write a message file to NanoClaw's IPC directory.""" - msg_dir = os.path.join(ipc_dir, group, "messages") - os.makedirs(msg_dir, exist_ok=True) - - payload = { - "type": "message", - "text": text, - "sender": sender, - "timestamp": int(time.time() * 1000), - } - - filename = f"{int(time.time() * 1000)}.json" - filepath = os.path.join(msg_dir, filename) - - fd, tmp = tempfile.mkstemp(dir=msg_dir, suffix=".tmp") - try: - with os.fdopen(fd, "w") as f: - json.dump(payload, f) - os.rename(tmp, filepath) - except BaseException: - if os.path.exists(tmp): - os.unlink(tmp) - raise - - return filepath - - -def write_ipc_task( - ipc_dir: str, group: str, task_id: str, payload: dict, -) -> str: - """Write a task file to NanoClaw's IPC directory.""" - task_dir = os.path.join(ipc_dir, group, "tasks") - os.makedirs(task_dir, exist_ok=True) - - filename = f"{task_id}.json" - filepath = os.path.join(task_dir, filename) - - fd, tmp = tempfile.mkstemp(dir=task_dir, suffix=".tmp") - try: - with os.fdopen(fd, "w") as f: - json.dump(payload, f) - os.rename(tmp, filepath) - except BaseException: - if os.path.exists(tmp): - os.unlink(tmp) - raise - - return filepath -``` - -**Step 4: Run tests** - -Run: `cd server && uv run pytest tests/test_ipc.py -v` -Expected: All 5 tests PASS - -**Step 5: Commit** - -```bash -git add server/src/warren/ipc.py server/tests/test_ipc.py -git commit -m "feat: add IPC file writer for NanoClaw communication" -``` - ---- - -### Task 4: NanoClaw callback endpoint - -**Files:** -- Modify: `server/src/warren/app.py` -- Create: `server/tests/test_nanoclaw_callback.py` - -**Step 1: Write failing tests** - -Create `server/tests/test_nanoclaw_callback.py`: - -```python -"""Tests for NanoClaw callback endpoint.""" - -import asyncio - -import pytest -from httpx import ASGITransport, AsyncClient - -from warren.app import create_app -from warren.config import Settings - - -@pytest.fixture -async def app_with_nanoclaw(tmp_path): - """Create app with a mock NanoClaw-like adapter that has an event queue.""" - config_file = tmp_path / "config.yaml" - config_file.write_text("repos: {}\n") - - settings = Settings( - db=str(tmp_path / "test.db"), - config=str(config_file), - admin_token="test-admin", - ) - app = create_app(settings) - - async with asyncio.timeout(5): - async with app.router.lifespan_context(app): - # Store a device token for auth - await app.state.db.store_device_token("test-token", "test-device") - yield app - - -class TestNanoClawCallback: - async def test_callback_pushes_event_to_queue(self, app_with_nanoclaw): - app = app_with_nanoclaw - # Create a session queue - session_id = "test-session" - app.state.event_queues[session_id] = asyncio.Queue() - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as client: - resp = await client.post( - "/internal/nanoclaw/callback", - json={"session_id": session_id, "text": "Hello from NanoClaw", "type": "text"}, - ) - assert resp.status_code == 200 - - # Check event was pushed to queue - queue = app.state.event_queues[session_id] - assert not queue.empty() - event = queue.get_nowait() - assert event["type"] == "text" - assert event["content"] == "Hello from NanoClaw" - - async def test_callback_unknown_session_returns_ok(self, app_with_nanoclaw): - app = app_with_nanoclaw - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as client: - resp = await client.post( - "/internal/nanoclaw/callback", - json={"session_id": "nonexistent", "text": "hello", "type": "text"}, - ) - # Should still return 200 (best-effort delivery) - assert resp.status_code == 200 - - async def test_typing_indicator(self, app_with_nanoclaw): - app = app_with_nanoclaw - session_id = "test-session" - app.state.event_queues[session_id] = asyncio.Queue() - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as client: - resp = await client.post( - "/internal/nanoclaw/typing", - json={"session_id": session_id, "is_typing": True}, - ) - assert resp.status_code == 200 - - queue = app.state.event_queues[session_id] - event = queue.get_nowait() - assert event["type"] == "status" - assert event["state"] == "working" -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_nanoclaw_callback.py -v` -Expected: FAIL β€” no `/internal/nanoclaw/callback` endpoint - -**Step 3: Add callback endpoints to app.py** - -In `server/src/warren/app.py`, inside `create_app()`, add before the -`creation_no_cache` middleware (around line 473): - -```python - # -- NanoClaw internal callbacks (no auth β€” internal network only) -- - - @app.post("/internal/nanoclaw/callback") - async def nanoclaw_callback(request: Request): - body = await request.json() - session_id = body.get("session_id") - text = body.get("text", "") - event_type = body.get("type", "text") - - queues: dict[str, asyncio.Queue] = request.app.state.event_queues - queue = queues.get(session_id) - if queue: - if event_type == "result": - await queue.put({ - "type": "result", - "summary": text, - "session_id": body.get("nanoclaw_session_id", ""), - }) - else: - await queue.put({"type": "text", "content": text}) - return {"status": "ok"} - - @app.post("/internal/nanoclaw/typing") - async def nanoclaw_typing(request: Request): - body = await request.json() - session_id = body.get("session_id") - is_typing = body.get("is_typing", False) - - queues: dict[str, asyncio.Queue] = request.app.state.event_queues - queue = queues.get(session_id) - if queue: - state = "working" if is_typing else "waiting" - await queue.put({"type": "status", "state": state}) - return {"status": "ok"} -``` - -**Step 4: Run tests** - -Run: `cd server && uv run pytest tests/test_nanoclaw_callback.py -v` -Expected: All 3 tests PASS - -**Step 5: Run full suite to verify no regressions** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All tests PASS (97 existing + 3 new = 100) - -**Step 6: Commit** - -```bash -git add server/src/warren/app.py server/tests/test_nanoclaw_callback.py -git commit -m "feat: add NanoClaw callback endpoints for response delivery" -``` - ---- - -### Task 5: Implement NanoClawAdapter (replace stub) - -**Files:** -- Modify: `server/src/warren/adapters/nanoclaw.py` -- Modify: `server/tests/test_adapters.py` -- Modify: `server/src/warren/app.py` (update `create_adapter`) - -**Step 1: Write failing tests** - -Replace the `TestNanoClawAdapter` class in `server/tests/test_adapters.py` -(lines 162-175) with: - -```python -class TestNanoClawAdapter: - async def test_instantiation(self): - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir="/tmp/ipc") - assert adapter._ipc_dir == "/tmp/ipc" - - async def test_start_creates_directories(self, tmp_path): - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) - await adapter.start() - assert (tmp_path / "ipc" / "warren" / "messages").is_dir() - assert (tmp_path / "ipc" / "warren" / "tasks").is_dir() - - async def test_send_new_session_writes_ipc_file(self, tmp_path): - from warren.adapters import BackendMessage - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) - await adapter.start() - - await adapter.send(BackendMessage( - kind="new_session", - session_id="test-session", - payload={"message": "hello", "repo_path": "/tmp/test"}, - )) - - msg_dir = tmp_path / "ipc" / "warren" / "messages" - files = list(msg_dir.glob("*.json")) - assert len(files) == 1 - - import json - with open(files[0]) as f: - data = json.load(f) - assert data["text"] == "hello" - assert data["sender"] == "warren:test-session" - - async def test_send_user_message_writes_ipc_file(self, tmp_path): - from warren.adapters import BackendMessage - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) - await adapter.start() - - await adapter.send(BackendMessage( - kind="user_message", - session_id="test-session", - payload={"message": "continue working", "repo_path": "/tmp/test"}, - )) - - msg_dir = tmp_path / "ipc" / "warren" / "messages" - files = list(msg_dir.glob("*.json")) - assert len(files) == 1 - - async def test_push_callback_yields_event(self, tmp_path): - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) - await adapter.start() - - await adapter.push_callback("session-1", "Hello from NanoClaw") - - # Drain one event - async for event in adapter.events(): - assert event.kind == "text" - assert event.session_id == "session-1" - assert event.data["content"] == "Hello from NanoClaw" - break - - async def test_stop_is_clean(self, tmp_path): - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) - await adapter.start() - await adapter.stop() # Should not raise -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_adapters.py::TestNanoClawAdapter -v` -Expected: Most FAIL β€” stub raises `NotImplementedError` - -**Step 3: Replace stub with full implementation** - -Replace `server/src/warren/adapters/nanoclaw.py`: - -```python -"""NanoClaw backend adapter -- file-based IPC with NanoClaw. - -Warren writes IPC files to NanoClaw's message/task directories. -NanoClaw processes them and sends responses via HTTP callback to -Warren's /internal/nanoclaw/callback endpoint. - -See: https://github.com/dsr-restyn/nanoclaw -""" - -from __future__ import annotations - -import asyncio -import logging -from collections.abc import AsyncIterator - -from warren.adapters import BackendEvent, BackendMessage -from warren.ipc import write_ipc_message - -logger = logging.getLogger(__name__) - - -class NanoClawAdapter: - """Backend adapter that communicates with NanoClaw via file-based IPC.""" - - def __init__(self, ipc_dir: str, db_path: str = ""): - self._ipc_dir = ipc_dir - self._db_path = db_path - self._event_queue: asyncio.Queue[BackendEvent] = asyncio.Queue() - - async def start(self) -> None: - """Verify IPC directories exist.""" - import os - os.makedirs(os.path.join(self._ipc_dir, "warren", "messages"), exist_ok=True) - os.makedirs(os.path.join(self._ipc_dir, "warren", "tasks"), exist_ok=True) - logger.info("NanoClawAdapter started, IPC dir: %s", self._ipc_dir) - - async def stop(self) -> None: - """Clean shutdown.""" - logger.info("NanoClawAdapter stopped") - - async def send(self, message: BackendMessage) -> None: - """Write an IPC file for NanoClaw to process.""" - if message.kind in ("new_session", "user_message"): - write_ipc_message( - ipc_dir=self._ipc_dir, - group="warren", - sender=f"warren:{message.session_id}", - text=message.payload.get("message", ""), - ) - elif message.kind == "cancel": - from warren.ipc import write_ipc_task - write_ipc_task( - ipc_dir=self._ipc_dir, - group="warren", - task_id=f"cancel-{message.session_id}", - payload={ - "type": "cancel_task", - "task_id": f"warren-{message.session_id}", - "owner_group": "warren", - }, - ) - - async def events(self) -> AsyncIterator[BackendEvent]: - """Yield events pushed via HTTP callback.""" - while True: - event = await self._event_queue.get() - yield event - - async def push_callback(self, session_id: str, text: str) -> None: - """Called by the /internal/nanoclaw/callback endpoint.""" - await self._event_queue.put( - BackendEvent( - kind="text", - session_id=session_id, - data={"type": "text", "content": text}, - ) - ) -``` - -**Step 4: Update `create_adapter` in `app.py`** - -In `server/src/warren/app.py`, update the nanoclaw case in -`create_adapter()` to pass `db_path`: - -```python - if settings.backend == "nanoclaw": - from warren.adapters.nanoclaw import NanoClawAdapter - backend_cfg = config.backend_config.get("nanoclaw", {}) - return NanoClawAdapter( - ipc_dir=backend_cfg.get("ipc_dir", "data/ipc"), - db_path=backend_cfg.get("db_path", "data/nanoclaw.db"), - ) -``` - -**Step 5: Run tests** - -Run: `cd server && uv run pytest tests/test_adapters.py -v` -Expected: All 14 adapter tests PASS (8 existing + 6 new) - -**Step 6: Run full suite** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All tests PASS - -**Step 7: Commit** - -```bash -git add server/src/warren/adapters/nanoclaw.py server/tests/test_adapters.py server/src/warren/app.py -git commit -m "feat: implement NanoClawAdapter with IPC writes and callback events" -``` - ---- - -### Task 6: NanoClaw DB reader for monitoring - -**Files:** -- Create: `server/src/warren/nanoclaw_db.py` -- Create: `server/tests/test_nanoclaw_db.py` - -**Step 1: Write failing tests** - -Create `server/tests/test_nanoclaw_db.py`: - -```python -"""Tests for NanoClaw SQLite reader.""" - -import sqlite3 - -import pytest - - -@pytest.fixture -def nanoclaw_db(tmp_path): - """Create a minimal NanoClaw-compatible SQLite database.""" - db_path = str(tmp_path / "nanoclaw.db") - conn = sqlite3.connect(db_path) - conn.executescript(""" - CREATE TABLE IF NOT EXISTS scheduled_tasks ( - id TEXT PRIMARY KEY, - group_jid TEXT NOT NULL, - prompt TEXT NOT NULL, - schedule_type TEXT NOT NULL, - schedule_value TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - last_run INTEGER, - next_run INTEGER, - created_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS task_run_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL, - started_at INTEGER NOT NULL, - finished_at INTEGER, - status TEXT NOT NULL, - output TEXT - ); - - CREATE TABLE IF NOT EXISTS chats ( - jid TEXT PRIMARY KEY, - name TEXT, - channel TEXT, - is_group INTEGER DEFAULT 0, - last_activity_timestamp INTEGER - ); - - INSERT INTO scheduled_tasks VALUES - ('task-1', 'warren:repo-a', 'daily report', 'cron', '0 9 * * *', 'active', 1708300000000, 1708386000000, 1708200000000), - ('task-2', 'warren:repo-b', 'backup', 'interval', '86400000', 'paused', NULL, NULL, 1708200000000); - - INSERT INTO chats VALUES - ('warren:session-1', 'repo-a session', 'warren', 1, 1708382400000), - ('warren:session-2', 'repo-b session', 'warren', 1, 1708380000000); - - INSERT INTO task_run_logs VALUES - (1, 'task-1', 1708300000000, 1708300060000, 'completed', 'Report generated'), - (2, 'task-1', 1708213600000, 1708213660000, 'completed', 'Report generated'); - """) - conn.close() - return db_path - - -class TestNanoClawDbReader: - async def test_list_tasks(self, nanoclaw_db): - from warren.nanoclaw_db import NanoClawDbReader - - reader = NanoClawDbReader(nanoclaw_db) - tasks = await reader.list_tasks() - assert len(tasks) == 2 - assert tasks[0]["id"] == "task-1" - assert tasks[0]["status"] == "active" - assert tasks[1]["status"] == "paused" - - async def test_list_active_groups(self, nanoclaw_db): - from warren.nanoclaw_db import NanoClawDbReader - - reader = NanoClawDbReader(nanoclaw_db) - groups = await reader.list_active_groups() - assert len(groups) == 2 - assert groups[0]["jid"] == "warren:session-1" - - async def test_get_task_history(self, nanoclaw_db): - from warren.nanoclaw_db import NanoClawDbReader - - reader = NanoClawDbReader(nanoclaw_db) - history = await reader.get_task_history("task-1", limit=5) - assert len(history) == 2 - assert history[0]["status"] == "completed" - - async def test_missing_db_returns_empty(self, tmp_path): - from warren.nanoclaw_db import NanoClawDbReader - - reader = NanoClawDbReader(str(tmp_path / "missing.db")) - tasks = await reader.list_tasks() - assert tasks == [] -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_nanoclaw_db.py -v` -Expected: FAIL β€” `ModuleNotFoundError` - -**Step 3: Write implementation** - -Create `server/src/warren/nanoclaw_db.py`: - -```python -"""Read-only access to NanoClaw's SQLite database for monitoring.""" - -from __future__ import annotations - -import logging -import os - -import aiosqlite - -logger = logging.getLogger(__name__) - - -class NanoClawDbReader: - """Read NanoClaw's SQLite database for monitoring data.""" - - def __init__(self, db_path: str): - self._db_path = db_path - - def _exists(self) -> bool: - return os.path.exists(self._db_path) - - async def list_tasks(self) -> list[dict]: - """List all scheduled tasks.""" - if not self._exists(): - return [] - try: - async with aiosqlite.connect( - self._db_path, mode="ro", - ) as db: - db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT id, group_jid, prompt, schedule_type, " - "schedule_value, status, last_run, next_run " - "FROM scheduled_tasks ORDER BY next_run", - ) - rows = await cursor.fetchall() - return [dict(row) for row in rows] - except Exception: - logger.exception("Failed to read NanoClaw tasks") - return [] - - async def list_active_groups(self) -> list[dict]: - """List active groups (sessions) from NanoClaw.""" - if not self._exists(): - return [] - try: - async with aiosqlite.connect( - self._db_path, mode="ro", - ) as db: - db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT jid, name, channel, last_activity_timestamp " - "FROM chats WHERE channel = 'warren' " - "ORDER BY last_activity_timestamp DESC", - ) - rows = await cursor.fetchall() - return [dict(row) for row in rows] - except Exception: - logger.exception("Failed to read NanoClaw groups") - return [] - - async def get_task_history( - self, task_id: str, limit: int = 10, - ) -> list[dict]: - """Get execution history for a task.""" - if not self._exists(): - return [] - try: - async with aiosqlite.connect( - self._db_path, mode="ro", - ) as db: - db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT id, task_id, started_at, finished_at, status, output " - "FROM task_run_logs WHERE task_id = ? " - "ORDER BY started_at DESC LIMIT ?", - (task_id, limit), - ) - rows = await cursor.fetchall() - return [dict(row) for row in rows] - except Exception: - logger.exception("Failed to read NanoClaw task history") - return [] -``` - -**Step 4: Run tests** - -Run: `cd server && uv run pytest tests/test_nanoclaw_db.py -v` -Expected: All 4 tests PASS - -**Step 5: Commit** - -```bash -git add server/src/warren/nanoclaw_db.py server/tests/test_nanoclaw_db.py -git commit -m "feat: add read-only NanoClaw DB reader for monitoring" -``` - ---- - -## Phase 3: Warren Monitor Endpoints - -### Task 7: Monitor health endpoint - -**Files:** -- Modify: `server/src/warren/app.py` -- Create: `server/tests/test_monitor.py` - -**Step 1: Write failing tests** - -Create `server/tests/test_monitor.py`: - -```python -"""Tests for monitor endpoints.""" - -import asyncio - -import pytest -from httpx import ASGITransport, AsyncClient - -from warren.app import create_app -from warren.config import Settings - - -@pytest.fixture -async def monitor_app(tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text("repos:\n test-repo:\n path: /tmp/test\n") - - settings = Settings( - db=str(tmp_path / "test.db"), - config=str(config_file), - admin_token="test-admin", - ) - app = create_app(settings) - - async with asyncio.timeout(5): - async with app.router.lifespan_context(app): - await app.state.db.store_device_token("test-token", "test-device") - yield app - - -@pytest.fixture -def auth_headers(): - return {"Authorization": "Bearer test-token"} - - -class TestMonitorHealth: - async def test_requires_auth(self, monitor_app): - async with AsyncClient( - transport=ASGITransport(app=monitor_app), - base_url="http://test", - ) as client: - resp = await client.get("/monitor/health") - assert resp.status_code == 401 - - async def test_returns_health_status(self, monitor_app, auth_headers): - async with AsyncClient( - transport=ASGITransport(app=monitor_app), - base_url="http://test", - ) as client: - resp = await client.get("/monitor/health", headers=auth_headers) - assert resp.status_code == 200 - data = resp.json() - assert "warren" in data - assert data["warren"]["status"] == "ok" -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_monitor.py -v` -Expected: FAIL β€” no `/monitor/health` endpoint (404) - -**Step 3: Add monitor endpoints to app.py** - -In `server/src/warren/app.py`, inside `create_app()`, add after the -session endpoints and before the NanoClaw callback endpoints: - -```python - # -- Monitor -- - - @app.get("/monitor/health") - async def monitor_health( - request: Request, _token: str = Depends(require_auth), - ): - settings = request.app.state.settings - result = {"warren": {"status": "ok"}} - - if settings.backend == "nanoclaw": - backend_cfg = request.app.state.config.backend_config.get("nanoclaw", {}) - db_path = backend_cfg.get("db_path", "data/nanoclaw.db") - import os - if os.path.exists(db_path): - result["nanoclaw"] = {"status": "ok"} - else: - result["nanoclaw"] = {"status": "unavailable", "detail": "DB not found"} - else: - result["backend"] = {"status": "ok", "type": settings.backend} - - return result -``` - -**Step 4: Run tests** - -Run: `cd server && uv run pytest tests/test_monitor.py -v` -Expected: All 2 tests PASS - -**Step 5: Commit** - -```bash -git add server/src/warren/app.py server/tests/test_monitor.py -git commit -m "feat: add /monitor/health endpoint" -``` - ---- - -### Task 8: Monitor agents and tasks endpoints - -**Files:** -- Modify: `server/src/warren/app.py` -- Modify: `server/tests/test_monitor.py` - -**Step 1: Write failing tests** - -Append to `server/tests/test_monitor.py`: - -```python -class TestMonitorAgents: - async def test_requires_auth(self, monitor_app): - async with AsyncClient( - transport=ASGITransport(app=monitor_app), - base_url="http://test", - ) as client: - resp = await client.get("/monitor/agents") - assert resp.status_code == 401 - - async def test_returns_active_sessions(self, monitor_app, auth_headers): - # Add a session queue to simulate an active session - monitor_app.state.event_queues["session-1"] = asyncio.Queue() - - async with AsyncClient( - transport=ASGITransport(app=monitor_app), - base_url="http://test", - ) as client: - resp = await client.get("/monitor/agents", headers=auth_headers) - assert resp.status_code == 200 - data = resp.json() - assert "active_sessions" in data - assert "session-1" in data["active_sessions"] - - -class TestMonitorTasks: - async def test_requires_auth(self, monitor_app): - async with AsyncClient( - transport=ASGITransport(app=monitor_app), - base_url="http://test", - ) as client: - resp = await client.get("/monitor/tasks") - assert resp.status_code == 401 - - async def test_returns_empty_when_no_nanoclaw(self, monitor_app, auth_headers): - async with AsyncClient( - transport=ASGITransport(app=monitor_app), - base_url="http://test", - ) as client: - resp = await client.get("/monitor/tasks", headers=auth_headers) - assert resp.status_code == 200 - data = resp.json() - assert data["tasks"] == [] -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_monitor.py -v` -Expected: New tests FAIL β€” no `/monitor/agents` or `/monitor/tasks` endpoints - -**Step 3: Add endpoints to app.py** - -Add after the `/monitor/health` endpoint: - -```python - @app.get("/monitor/agents") - async def monitor_agents( - request: Request, _token: str = Depends(require_auth), - ): - queues: dict[str, asyncio.Queue] = request.app.state.event_queues - return {"active_sessions": list(queues.keys())} - - @app.get("/monitor/tasks") - async def monitor_tasks( - request: Request, _token: str = Depends(require_auth), - ): - settings = request.app.state.settings - if settings.backend != "nanoclaw": - return {"tasks": []} - - backend_cfg = request.app.state.config.backend_config.get("nanoclaw", {}) - db_path = backend_cfg.get("db_path", "data/nanoclaw.db") - - from warren.nanoclaw_db import NanoClawDbReader - reader = NanoClawDbReader(db_path) - tasks = await reader.list_tasks() - return {"tasks": tasks} - - @app.post("/monitor/tasks/{task_id}/pause") - async def pause_task( - task_id: str, - request: Request, - _token: str = Depends(require_auth), - ): - settings = request.app.state.settings - if settings.backend != "nanoclaw": - raise HTTPException(status_code=400, detail="Not using NanoClaw backend") - - backend_cfg = request.app.state.config.backend_config.get("nanoclaw", {}) - ipc_dir = backend_cfg.get("ipc_dir", "data/ipc") - - from warren.ipc import write_ipc_task - write_ipc_task( - ipc_dir=ipc_dir, - group="warren", - task_id=f"pause-{task_id}", - payload={"type": "pause_task", "task_id": task_id, "owner_group": "warren"}, - ) - return {"status": "paused", "task_id": task_id} - - @app.post("/monitor/tasks/{task_id}/resume") - async def resume_task( - task_id: str, - request: Request, - _token: str = Depends(require_auth), - ): - settings = request.app.state.settings - if settings.backend != "nanoclaw": - raise HTTPException(status_code=400, detail="Not using NanoClaw backend") - - backend_cfg = request.app.state.config.backend_config.get("nanoclaw", {}) - ipc_dir = backend_cfg.get("ipc_dir", "data/ipc") - - from warren.ipc import write_ipc_task - write_ipc_task( - ipc_dir=ipc_dir, - group="warren", - task_id=f"resume-{task_id}", - payload={"type": "resume_task", "task_id": task_id, "owner_group": "warren"}, - ) - return {"status": "resumed", "task_id": task_id} -``` - -**Step 4: Run tests** - -Run: `cd server && uv run pytest tests/test_monitor.py -v` -Expected: All 6 monitor tests PASS - -**Step 5: Run full suite** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All tests PASS - -**Step 6: Commit** - -```bash -git add server/src/warren/app.py server/tests/test_monitor.py -git commit -m "feat: add /monitor/agents and /monitor/tasks endpoints" -``` - ---- - -## Phase 4: R1 Creation Monitor View - -### Task 9: Add monitor API methods to api.js - -**Files:** -- Modify: `creation/js/api.js` - -**Step 1: Add monitor methods** - -In `creation/js/api.js`, add before the `return` statement (line 115): - -```javascript - async function fetchMonitorHealth() { - return _fetch("/monitor/health"); - } - - async function fetchMonitorAgents() { - return _fetch("/monitor/agents"); - } - - async function fetchMonitorTasks() { - return _fetch("/monitor/tasks"); - } - - async function pauseTask(taskId) { - return _fetch(`/monitor/tasks/${taskId}/pause`, { method: "POST" }); - } - - async function resumeTask(taskId) { - return _fetch(`/monitor/tasks/${taskId}/resume`, { method: "POST" }); - } -``` - -Update the `return` statement to include the new methods: - -```javascript - return { - init, - initFromUrl, - fetchRepos, - listSessions, - createSession, - sendMessage, - deleteSession, - streamSession, - fetchCommands, - fetchHistory, - fetchMonitorHealth, - fetchMonitorAgents, - fetchMonitorTasks, - pauseTask, - resumeTask, - get serverUrl() { return _serverUrl; }, - get connected() { return !!_token; }, - }; -``` - -**Step 2: Verify no syntax errors** - -Open `creation/index.html` in browser, check console for errors. - -**Step 3: Commit** - -```bash -git add creation/js/api.js -git commit -m "feat: add monitor API methods to Creation client" -``` - ---- - -### Task 10: Add monitor view HTML and CSS - -**Files:** -- Modify: `creation/index.html` -- Modify: `creation/css/styles.css` - -**Step 1: Add monitor view HTML** - -In `creation/index.html`, add after the `view-new` div (after line 57) -and before the command palette overlay: - -```html - -
-
- - MONITOR - -
-
-
-
HEALTH
-
-
-
-
AGENTS
-
-
-
-
TASKS
-
-
-
- -
-``` - -Add the `[MON]` button to the sessions footer. Replace the -`session-footer-row` div (lines 18-21): - -```html - -``` - -**Step 2: Add monitor CSS** - -Append to `creation/css/styles.css`: - -```css -/* Monitor View */ -.monitor-section { - margin-bottom: 8px; -} - -.monitor-section-title { - color: #0a6e0a; - font-size: 10px; - padding: 2px 8px; - border-bottom: 1px solid #0a3e0a; -} - -.monitor-item { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - font-size: 11px; - cursor: pointer; -} - -.monitor-item:active { - background: #0a3e0a; -} - -.monitor-item .label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.monitor-item .value { - color: #0a6e0a; - font-size: 10px; -} - -.monitor-empty { - color: #0a6e0a; - font-size: 10px; - padding: 4px 8px; - font-style: italic; -} - -.btn-cmd { - background: none; - color: #33ff33; - border: 1px solid #0a6e0a; - font-family: "Courier New", "Lucida Console", monospace; - font-size: 11px; - padding: 4px 6px; - cursor: pointer; -} -``` - -**Step 3: Commit** - -```bash -git add creation/index.html creation/css/styles.css -git commit -m "feat: add monitor view HTML and CSS to Creation" -``` - ---- - -### Task 11: Add monitor view logic to app.js - -**Files:** -- Modify: `creation/js/app.js` - -**Step 1: Register monitor view in the view router** - -In `creation/js/app.js`, update the `views` object (line 17) to include -monitor: - -```javascript - const views = { - sessions: document.getElementById("view-sessions"), - chat: document.getElementById("view-chat"), - new: document.getElementById("view-new"), - monitor: document.getElementById("view-monitor"), - }; -``` - -Add DOM refs for monitor elements after the existing refs (around line 29): - -```javascript - const monitorContent = document.getElementById("monitor-content"); - const monitorHealth = document.getElementById("monitor-health"); - const monitorAgents = document.getElementById("monitor-agents"); - const monitorTasks = document.getElementById("monitor-tasks"); - const monitorStatus = document.getElementById("monitor-status"); -``` - -Update `showView()` to handle monitor: - -```javascript - function showView(name) { - Object.values(views).forEach((v) => v.classList.remove("active")); - views[name].classList.add("active"); - currentView = name; - selectedIndex = 0; - - if (name === "sessions") refreshSessions(); - if (name === "new") loadRepos(); - if (name === "monitor") refreshMonitor(); - } -``` - -**Step 2: Add monitor rendering functions** - -Add after the `updateChatStatus` function (around line 305): - -```javascript - // -- Monitor View -- - - let monitorRefreshInterval = null; - - async function refreshMonitor() { - monitorStatus.className = "dot dot-yellow"; - try { - const [health, agents, tasks] = await Promise.all([ - Warren.fetchMonitorHealth(), - Warren.fetchMonitorAgents(), - Warren.fetchMonitorTasks(), - ]); - renderMonitorHealth(health); - renderMonitorAgents(agents); - renderMonitorTasks(tasks); - monitorStatus.className = "dot dot-green"; - } catch (err) { - monitorStatus.className = "dot dot-red"; - monitorHealth.textContent = ""; - const errEl = document.createElement("div"); - errEl.className = "monitor-empty"; - errEl.textContent = "FAILED TO LOAD"; - monitorHealth.appendChild(errEl); - } - } - - function renderMonitorHealth(data) { - monitorHealth.textContent = ""; - for (const [name, info] of Object.entries(data)) { - const item = document.createElement("div"); - item.className = "monitor-item"; - - const dot = document.createElement("span"); - dot.className = "dot " + (info.status === "ok" ? "dot-green" : "dot-red"); - - const label = document.createElement("span"); - label.className = "label"; - label.textContent = name; - - const value = document.createElement("span"); - value.className = "value"; - value.textContent = info.status === "ok" ? "ok" : (info.detail || info.status); - - item.appendChild(dot); - item.appendChild(label); - item.appendChild(value); - monitorHealth.appendChild(item); - } - } - - function renderMonitorAgents(data) { - monitorAgents.textContent = ""; - const sessionIds = data.active_sessions || []; - if (sessionIds.length === 0) { - const empty = document.createElement("div"); - empty.className = "monitor-empty"; - empty.textContent = "No active agents"; - monitorAgents.appendChild(empty); - return; - } - sessionIds.forEach((sid) => { - const item = document.createElement("div"); - item.className = "monitor-item"; - - const dot = document.createElement("span"); - dot.className = "dot dot-green"; - - const label = document.createElement("span"); - label.className = "label"; - label.textContent = sid.substring(0, 12) + "..."; - - item.appendChild(dot); - item.appendChild(label); - - item.addEventListener("click", () => { - // Try to open this session - const session = sessions.find((s) => s.id === sid); - if (session) openSession(session); - }); - - monitorAgents.appendChild(item); - }); - } - - function renderMonitorTasks(data) { - monitorTasks.textContent = ""; - const taskList = data.tasks || []; - if (taskList.length === 0) { - const empty = document.createElement("div"); - empty.className = "monitor-empty"; - empty.textContent = "No scheduled tasks"; - monitorTasks.appendChild(empty); - return; - } - taskList.forEach((task) => { - const item = document.createElement("div"); - item.className = "monitor-item"; - - const dot = document.createElement("span"); - dot.className = "dot " + (task.status === "active" ? "dot-green" : "dot-yellow"); - - const label = document.createElement("span"); - label.className = "label"; - label.textContent = task.id; - - const value = document.createElement("span"); - value.className = "value"; - if (task.status === "paused") { - value.textContent = "paused"; - } else if (task.next_run) { - const next = new Date(task.next_run); - value.textContent = next.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - } else { - value.textContent = task.schedule_value; - } - - item.appendChild(dot); - item.appendChild(label); - item.appendChild(value); - monitorTasks.appendChild(item); - }); - } -``` - -**Step 3: Wire button handlers and hardware bindings** - -Add button handlers. Find the button handler section (around line 608) -and add: - -```javascript - document.getElementById("btn-monitor").addEventListener("click", () => showView("monitor")); - document.getElementById("btn-back-monitor").addEventListener("click", () => { - if (monitorRefreshInterval) { - clearInterval(monitorRefreshInterval); - monitorRefreshInterval = null; - } - showView("sessions"); - }); - document.getElementById("btn-monitor-refresh").addEventListener("click", refreshMonitor); -``` - -Update the `scrollUp` and `scrollDown` hardware bindings to handle the -monitor view. In the `scrollUp` handler, add: - -```javascript - } else if (currentView === "monitor") { - monitorContent.scrollTop -= 40; -``` - -In the `scrollDown` handler, add: - -```javascript - } else if (currentView === "monitor") { - monitorContent.scrollTop += 40; -``` - -**Step 4: Test manually in browser** - -Open `creation/index.html` at 240x282 viewport. Verify: -- `[MON]` button appears in sessions footer -- Clicking `[MON]` opens monitor view -- Back button returns to sessions -- Health/agents/tasks sections render (may show errors if no server) - -**Step 5: Commit** - -```bash -git add creation/js/app.js -git commit -m "feat: add monitor view logic to Creation app" -``` - ---- - -## Phase 5: Deployment - -### Task 12: Update Warren Dockerfile and config for NanoClaw support - -**Files:** -- Modify: `server/src/warren/app.py` (minor: import guard) -- Modify: `CLAUDE.md` (update deployment docs) - -**Step 1: Update CLAUDE.md with NanoClaw deployment info** - -Append to the Extension Points section in `CLAUDE.md`: - -```markdown - -### Deploy with NanoClaw -Set `WARREN_BACKEND=nanoclaw` and configure `backend_config` in config.yaml: -```yaml -backend_config: - nanoclaw: - ipc_dir: /opt/warren-data/ipc - db_path: /opt/warren-data/nanoclaw.db -``` -Both services need the shared volume `/opt/warren-data/` mounted. -NanoClaw needs `WARREN_CALLBACK_URL=http://warren:8000/internal/nanoclaw/callback`. -``` - -**Step 2: Run full test suite** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All tests PASS - -Run: `cd server && uv run ruff check src/ tests/` -Expected: All checks passed - -**Step 3: Commit** - -```bash -git add CLAUDE.md -git commit -m "docs: add NanoClaw deployment instructions" -``` - ---- - -## Dependency Graph - -``` -Phase 1 (NanoClaw fork β€” separate repo): - Task 1 (Warren channel) β†’ Task 2 (Dockerfile) - -Phase 2 (Warren adapter β€” this repo): - Task 3 (IPC writer) β†’ Task 5 (NanoClawAdapter) - Task 4 (Callback endpoint) β†’ Task 5 (NanoClawAdapter) - Task 6 (DB reader) β€” independent - -Phase 3 (Monitor endpoints β€” this repo): - Task 6 (DB reader) β†’ Task 8 (agents/tasks endpoints) - Task 7 (health endpoint) β€” independent after Phase 2 - -Phase 4 (Creation UI β€” this repo): - Task 9 (API client) β†’ Task 10 (HTML/CSS) β†’ Task 11 (JS logic) - Depends on Phase 3 endpoints existing - -Phase 5 (Deployment): - Task 12 β€” after everything else -``` - -**Parallelism:** -- Phase 1 (NanoClaw fork) can happen in parallel with Phase 2-3 (Warren) -- Tasks 3, 4, 6 can run in parallel within Phase 2 -- Tasks 7, 8 can run in parallel within Phase 3 - -## Notes - -- **NanoClaw fork tasks (1-2) are in a separate repo** β€” TypeScript/Node.js -- **All other tasks are in the Warren repo** β€” Python + HTML/CSS/JS -- The `aiosqlite` `mode="ro"` flag requires Python 3.12+ or `uri=True`; - if it fails, open normally and just don't write -- NanoClaw's exact DB schema may differ from what we've stubbed β€” verify - against the actual `src/db.ts` during implementation -- The Creation monitor view has no automated tests β€” verify manually at - 240x282 viewport -- The `[MON]` button uses the existing `btn-cmd` CSS class to stay - consistent with `[CMD]` diff --git a/server/src/warren/app.py b/server/src/warren/app.py index a9c03ea..d826f29 100644 --- a/server/src/warren/app.py +++ b/server/src/warren/app.py @@ -632,7 +632,7 @@ async def monitor_tasks( request: Request, _token: str = Depends(require_auth), ): - # Live task monitoring was nanoclaw-specific; no tasks on websocket/orca. + # Live task monitoring is not supported on the websocket/orca backends. return {"tasks": []} @app.post("/monitor/tasks/{task_id}/pause") From 9f697a7db410a24a8c0fc71f7cc96b10a0fa3317 Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 14:00:30 -0400 Subject: [PATCH 10/11] test: remove Warren+NanoClaw E2E pipeline harness Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 36fe0d6ad374 --- CLAUDE.md | 5 -- README.md | 10 --- tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 88 ----------------------- tests/e2e/docker-compose.test.yml | 40 ----------- tests/e2e/mock-claude | 19 ----- tests/e2e/test_creation_e2e.py | 48 ------------- tests/e2e/test_pipeline_e2e.py | 107 ---------------------------- tests/e2e/test_voice_e2e.py | 112 ------------------------------ 9 files changed, 429 deletions(-) delete mode 100644 tests/e2e/__init__.py delete mode 100644 tests/e2e/conftest.py delete mode 100644 tests/e2e/docker-compose.test.yml delete mode 100755 tests/e2e/mock-claude delete mode 100644 tests/e2e/test_creation_e2e.py delete mode 100644 tests/e2e/test_pipeline_e2e.py delete mode 100644 tests/e2e/test_voice_e2e.py diff --git a/CLAUDE.md b/CLAUDE.md index 7677f8d..cbce12f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,11 +13,6 @@ cd server && uv run ruff check src/ tests/ # Lint cd server && uv run pytest tests/ -v # Unit tests cd server && uv run python -m warren # Run -# E2E tests (requires Docker): -docker compose -f tests/e2e/docker-compose.test.yml up -d --build -WARREN_E2E_URL=http://localhost:14030 uv run --directory server pytest tests/e2e/ -v -docker compose -f tests/e2e/docker-compose.test.yml down -v - ## Architecture server/src/warren/ diff --git a/README.md b/README.md index b45b3e3..5225020 100644 --- a/README.md +++ b/README.md @@ -133,16 +133,6 @@ uv run ruff check src/ tests/ # Lint uv run pytest tests/ -v # Unit tests ``` -### E2E Tests - -Run against a local docker-compose stack with a mock Claude CLI (no API key needed): - -```bash -docker compose -f tests/e2e/docker-compose.test.yml up -d --build -WARREN_E2E_URL=http://localhost:14030 uv run --directory server pytest tests/e2e/ -v -docker compose -f tests/e2e/docker-compose.test.yml down -v -``` - ## Architecture | File | Purpose | diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py deleted file mode 100644 index 61e6f7a..0000000 --- a/tests/e2e/conftest.py +++ /dev/null @@ -1,88 +0,0 @@ -"""E2E test fixtures β€” manages docker-compose lifecycle.""" - -import os -import subprocess -import time - -import httpx -import pytest - -WARREN_URL = os.environ.get("WARREN_E2E_URL", "http://localhost:14030") -COMPOSE_FILE = "tests/e2e/docker-compose.test.yml" -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - -def _compose_cmd(*args: str) -> list[str]: - """Build a docker compose command with the right -f flag.""" - cmd = ["docker", "compose", "-f", os.path.join(PROJECT_ROOT, COMPOSE_FILE)] - cmd.extend(args) - return cmd - - -def _wait_healthy(url: str, timeout: int = 60) -> bool: - """Poll health endpoint until it responds 200.""" - deadline = time.time() + timeout - while time.time() < deadline: - try: - resp = httpx.get(f"{url}/health", timeout=2) - if resp.status_code == 200: - return True - except httpx.HTTPError: - pass - time.sleep(1) - return False - - -@pytest.fixture(scope="session") -def compose_stack(): - """Start docker-compose stack for the test session, tear down after. - - If WARREN_E2E_URL is set, skip compose management (stack is external). - """ - external = "WARREN_E2E_URL" in os.environ - - if not external: - subprocess.run(_compose_cmd("up", "-d", "--build"), check=True, cwd=PROJECT_ROOT) - - if not _wait_healthy(WARREN_URL): - if not external: - subprocess.run(_compose_cmd("logs"), cwd=PROJECT_ROOT) - pytest.fail("Warren did not become healthy within 60 seconds") - - yield WARREN_URL - - if not external: - subprocess.run(_compose_cmd("down", "-v"), cwd=PROJECT_ROOT) - - -@pytest.fixture -def warren_url(compose_stack) -> str: - """Return the Warren base URL.""" - return compose_stack - - -@pytest.fixture -def admin_headers() -> dict: - """Admin auth headers for test endpoints.""" - return {"X-Admin-Token": "test-admin-token"} - - -@pytest.fixture -def device_token(warren_url, admin_headers) -> str: - """Create a device token via pairing flow.""" - resp = httpx.get(f"{warren_url}/pair", headers=admin_headers) - assert resp.status_code == 200 - pairing_token = resp.json()["pairing_token"] - - resp = httpx.post( - f"{warren_url}/pair/confirm", - json={"pairing_token": pairing_token, "device_name": "e2e-test"}, - ) - assert resp.status_code == 200 - return resp.json()["device_token"] - - -@pytest.fixture -def auth_headers(device_token) -> dict: - """Device auth headers.""" - return {"Authorization": f"Bearer {device_token}"} diff --git a/tests/e2e/docker-compose.test.yml b/tests/e2e/docker-compose.test.yml deleted file mode 100644 index 862727e..0000000 --- a/tests/e2e/docker-compose.test.yml +++ /dev/null @@ -1,40 +0,0 @@ -# tests/e2e/docker-compose.test.yml -# Standalone compose for E2E testing β€” uses mock Claude CLI, no real API key. -# Usage: docker compose -f tests/e2e/docker-compose.test.yml up -d - -services: - warren: - build: - context: ../.. - dockerfile: server/Dockerfile - ports: - - "14030:4030" - volumes: - - warren-test-data:/data - environment: - - ANTHROPIC_API_KEY=sk-test-mock - - WARREN_CONFIG=/data/config.yaml - - WARREN_DB=/data/warren.db - - WARREN_ADMIN_TOKEN=test-admin-token - - WARREN_INTERNAL_SECRET=test-internal-secret - - WARREN_NANOCLAW_DB=/data/nanoclaw/store/messages.db - - WARREN_NANOCLAW_IPC_DIR=/data/nanoclaw/ipc - - WARREN_VOICE_ENABLED=true - - WARREN_VOICE_PORT=443 - - nanoclaw: - build: - context: ../../nanoclaw - environment: - - WARREN_CALLBACK_URL=http://warren:4030/internal/nanoclaw/callback - - ANTHROPIC_API_KEY=sk-test-mock - - WARREN_INTERNAL_SECRET=test-internal-secret - - DATA_DIR=/data/nanoclaw - - WHATSAPP_ENABLED=false - volumes: - - warren-test-data:/data - - /var/run/docker.sock:/var/run/docker.sock - - ./mock-claude:/usr/local/bin/claude:ro - -volumes: - warren-test-data: diff --git a/tests/e2e/mock-claude b/tests/e2e/mock-claude deleted file mode 100755 index dc7e9d3..0000000 --- a/tests/e2e/mock-claude +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -# Mock Claude CLI for E2E testing. -# Reads -p argument and returns canned stream-json output. - -MESSAGE="" -while [[ $# -gt 0 ]]; do - case $1 in - -p) MESSAGE="$2"; shift 2 ;; - *) shift ;; - esac -done - -# Simulate short processing delay -sleep 0.5 - -# Emit stream-json lines matching real Claude CLI output format -echo '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I received your message: '"${MESSAGE}"'"}]}}' -sleep 0.2 -echo '{"type":"result","result":"Mock response complete.","session_id":"mock-session-001"}' diff --git a/tests/e2e/test_creation_e2e.py b/tests/e2e/test_creation_e2e.py deleted file mode 100644 index 70d76e6..0000000 --- a/tests/e2e/test_creation_e2e.py +++ /dev/null @@ -1,48 +0,0 @@ -"""E2E tests for R1 Creation UI at 240x282 viewport.""" - -import re - -import pytest -from playwright.sync_api import Page, expect - - -@pytest.fixture -def r1_page(page: Page, warren_url, device_token) -> Page: - """Navigate to Creation UI at R1 viewport size.""" - page.set_viewport_size({"width": 240, "height": 282}) - page.goto(f"{warren_url}/creation/?token={device_token}") - return page - - -class TestCreationLoads: - def test_session_list_renders(self, r1_page): - """Creation UI loads and shows session list or empty state.""" - expect(r1_page.locator("#view-sessions")).to_be_visible() - r1_page.wait_for_selector("#session-list", timeout=5000) - - def test_connection_dot_green(self, r1_page): - """Connection indicator turns green when server is reachable.""" - dot = r1_page.locator("#connection-dot") - expect(dot).to_have_class(re.compile(r"dot-green")) - - def test_viewport_is_240x282(self, r1_page): - """Verify viewport matches R1 dimensions.""" - size = r1_page.viewport_size - assert size["width"] == 240 - assert size["height"] == 282 - - -class TestMonitorView: - def test_monitor_loads(self, r1_page): - """Clicking MON button shows the monitor view.""" - r1_page.click("#btn-monitor") - expect(r1_page.locator("#view-monitor")).to_have_class(re.compile(r"active")) - expect(r1_page.locator("#monitor-health")).to_be_visible() - - def test_monitor_shows_health(self, r1_page): - """Monitor view shows health status after refresh.""" - r1_page.click("#btn-monitor") - r1_page.click("#btn-monitor-refresh") - r1_page.wait_for_timeout(1000) - health = r1_page.locator("#monitor-health") - expect(health).to_contain_text("warren") diff --git a/tests/e2e/test_pipeline_e2e.py b/tests/e2e/test_pipeline_e2e.py deleted file mode 100644 index 78ea142..0000000 --- a/tests/e2e/test_pipeline_e2e.py +++ /dev/null @@ -1,107 +0,0 @@ -"""E2E test for full NanoClaw pipeline: voice -> agent -> callback -> response.""" - -import json -import time - -import httpx -import websockets.sync.client as ws_client - - -def _ws_url(warren_url: str) -> str: - return warren_url.replace("http://", "ws://") + "/" - - -def _handshake(ws, device_token: str) -> None: - """Complete OpenClaw handshake.""" - challenge = json.loads(ws.recv(timeout=5)) - nonce = challenge["payload"]["nonce"] - ws.send(json.dumps({ - "type": "req", - "id": "connect-1", - "method": "connect", - "params": { - "minProtocol": 3, - "maxProtocol": 3, - "auth": {"token": device_token}, - "client": {"id": "e2e-test", "version": "1.0", "platform": "test"}, - "role": "node", - "device": {"id": "e2e-device", "nonce": nonce}, - }, - })) - hello = json.loads(ws.recv(timeout=5)) - assert hello["ok"] is True - - -class TestFullPipeline: - def test_voice_message_round_trip(self, warren_url, device_token, auth_headers): - """Voice chat.send -> NanoClaw processes -> response arrives via WebSocket.""" - with ws_client.connect(_ws_url(warren_url), close_timeout=5) as ws: - _handshake(ws, device_token) - - ws.send(json.dumps({ - "type": "req", - "id": "chat-1", - "method": "chat.send", - "params": { - "sessionKey": "main", - "message": "Full pipeline test", - "idempotencyKey": "pipeline-1", - }, - })) - - events = [] - deadline = time.time() + 120 - got_final = False - - while time.time() < deadline and not got_final: - try: - raw = ws.recv(timeout=10) - msg = json.loads(raw) - events.append(msg) - - if ( - msg.get("type") == "event" - and msg.get("event") == "chat" - and msg.get("payload", {}).get("state") == "final" - ): - got_final = True - except TimeoutError: - continue - - acks = [e for e in events if e.get("type") == "res" and e.get("id") == "chat-1"] - assert len(acks) == 1, f"Expected 1 ack, got {len(acks)}: {acks}" - - chat_events = [e for e in events if e.get("event") == "chat"] - assert len(chat_events) >= 1, f"Expected chat events, got: {events}" - - if got_final: - final = [e for e in chat_events if e["payload"].get("state") == "final"] - assert len(final) == 1 - # Final event may or may not have message content - if "message" in final[0]["payload"]: - assert isinstance(final[0]["payload"]["message"], dict) - - def test_voice_message_visible_in_session_list( - self, warren_url, device_token, auth_headers, - ): - """After voice chat.send, session appears in HTTP session list.""" - with ws_client.connect(_ws_url(warren_url), close_timeout=5) as ws: - _handshake(ws, device_token) - - ws.send(json.dumps({ - "type": "req", - "id": "chat-1", - "method": "chat.send", - "params": { - "sessionKey": "main", - "message": "Session visibility test", - "idempotencyKey": "visibility-1", - }, - })) - - time.sleep(2) - - resp = httpx.get(f"{warren_url}/sessions", headers=auth_headers) - assert resp.status_code == 200 - sessions = resp.json() - assert len(sessions) >= 1 diff --git a/tests/e2e/test_voice_e2e.py b/tests/e2e/test_voice_e2e.py deleted file mode 100644 index 0cd3c50..0000000 --- a/tests/e2e/test_voice_e2e.py +++ /dev/null @@ -1,112 +0,0 @@ -"""E2E tests for R1 voice PTT interface (OpenClaw WebSocket protocol).""" - -import json - -import websockets.sync.client as ws_client - - -def _ws_url(warren_url: str) -> str: - """Convert http://host:port to ws://host:port/.""" - return warren_url.replace("http://", "ws://") + "/" - - -class TestVoiceHandshake: - def test_challenge_on_connect(self, warren_url): - """Server sends connect.challenge immediately after WebSocket open.""" - with ws_client.connect(_ws_url(warren_url)) as ws: - msg = json.loads(ws.recv(timeout=5)) - assert msg["type"] == "event" - assert msg["event"] == "connect.challenge" - assert "nonce" in msg["payload"] - - def test_full_handshake(self, warren_url, device_token): - """Challenge -> connect -> hello-ok with valid token.""" - with ws_client.connect(_ws_url(warren_url)) as ws: - challenge = json.loads(ws.recv(timeout=5)) - nonce = challenge["payload"]["nonce"] - - ws.send(json.dumps({ - "type": "req", - "id": "connect-1", - "method": "connect", - "params": { - "minProtocol": 3, - "maxProtocol": 3, - "auth": {"token": device_token}, - "client": {"id": "e2e-test", "version": "1.0", "platform": "test"}, - "role": "node", - "device": {"id": "e2e-device", "nonce": nonce}, - }, - })) - - hello = json.loads(ws.recv(timeout=5)) - assert hello["type"] == "res" - assert hello["ok"] is True - assert hello["payload"]["type"] == "hello-ok" - - def test_invalid_token_rejected(self, warren_url): - """Connect with bad token returns hello-error.""" - with ws_client.connect(_ws_url(warren_url)) as ws: - challenge = json.loads(ws.recv(timeout=5)) - nonce = challenge["payload"]["nonce"] - - ws.send(json.dumps({ - "type": "req", - "id": "connect-1", - "method": "connect", - "params": { - "minProtocol": 3, - "maxProtocol": 3, - "auth": {"token": "bad-token"}, - "client": {"id": "e2e-test", "version": "1.0", "platform": "test"}, - "role": "node", - "device": {"id": "e2e-device", "nonce": nonce}, - }, - })) - - hello = json.loads(ws.recv(timeout=5)) - assert hello["type"] == "res" - assert hello["ok"] is False - - -class TestVoiceChatSend: - def _handshake(self, ws, device_token: str) -> None: - """Helper: complete handshake.""" - challenge = json.loads(ws.recv(timeout=5)) - nonce = challenge["payload"]["nonce"] - ws.send(json.dumps({ - "type": "req", - "id": "connect-1", - "method": "connect", - "params": { - "minProtocol": 3, - "maxProtocol": 3, - "auth": {"token": device_token}, - "client": {"id": "e2e-test", "version": "1.0", "platform": "test"}, - "role": "node", - "device": {"id": "e2e-device", "nonce": nonce}, - }, - })) - hello = json.loads(ws.recv(timeout=5)) - assert hello["ok"] is True - - def test_chat_send_gets_ack(self, warren_url, device_token): - """Sending chat.send returns a chat-ack response.""" - with ws_client.connect(_ws_url(warren_url)) as ws: - self._handshake(ws, device_token) - - ws.send(json.dumps({ - "type": "req", - "id": "chat-1", - "method": "chat.send", - "params": { - "sessionKey": "main", - "message": "E2E test message", - "idempotencyKey": "e2e-idem-1", - }, - })) - - ack = json.loads(ws.recv(timeout=10)) - assert ack["type"] == "res" - assert ack["ok"] is True - assert ack["id"] == "chat-1" From 62c6b72f17c63c312c70e0c4e2b046da228252a4 Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Thu, 18 Jun 2026 14:25:27 -0400 Subject: [PATCH 11/11] style: fix pre-existing ruff errors to unblock CI Wrap two long error strings and sort imports / drop an unused import in the websocket/orca adapters, __main__, and the websocket test. These were failing the CI lint step independently of the nanoclaw removal. Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 36fe0d6ad374 --- server/src/warren/__main__.py | 2 +- server/src/warren/adapters/orca.py | 3 ++- server/src/warren/adapters/websocket.py | 4 +++- server/tests/test_websocket_adapter.py | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/server/src/warren/__main__.py b/server/src/warren/__main__.py index 471be43..2927910 100644 --- a/server/src/warren/__main__.py +++ b/server/src/warren/__main__.py @@ -7,9 +7,9 @@ import logging import os import socket -import uvicorn import qrcode +import uvicorn from warren.config import Settings from warren.db import Database diff --git a/server/src/warren/adapters/orca.py b/server/src/warren/adapters/orca.py index a21c432..e6b5649 100644 --- a/server/src/warren/adapters/orca.py +++ b/server/src/warren/adapters/orca.py @@ -186,7 +186,8 @@ async def send(self, message: BackendMessage) -> None: session_id=self._session_id, data={ "type": "error", - "message": "Orca runtime unavailable. Start Orca with 'orca open' or 'orca serve' first.", + "message": "Orca runtime unavailable. " + "Start Orca with 'orca open' or 'orca serve' first.", }, ) ) diff --git a/server/src/warren/adapters/websocket.py b/server/src/warren/adapters/websocket.py index 4bac324..21b5da3 100644 --- a/server/src/warren/adapters/websocket.py +++ b/server/src/warren/adapters/websocket.py @@ -9,6 +9,7 @@ import asyncio import logging from collections.abc import AsyncIterator + from fastapi import WebSocket from warren.adapters import BackendAdapter, BackendEvent, BackendMessage @@ -105,7 +106,8 @@ async def send(self, message: BackendMessage) -> None: session_id=message.session_id, data={ "type": "error", - "content": "No agent connected to proxy layer. Please start your personal agent.", + "content": "No agent connected to proxy layer. " + "Please start your personal agent.", }, ) await self._event_queue.put(err_event) diff --git a/server/tests/test_websocket_adapter.py b/server/tests/test_websocket_adapter.py index 55e3369..4ba46b3 100644 --- a/server/tests/test_websocket_adapter.py +++ b/server/tests/test_websocket_adapter.py @@ -1,9 +1,10 @@ import asyncio + import pytest from fastapi import FastAPI, WebSocket from fastapi.testclient import TestClient -from warren.adapters import BackendEvent, BackendMessage +from warren.adapters import BackendMessage from warren.adapters.websocket import AgentWebSocketAdapter