Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ All notable changes to ralphify are documented here.
- **Empty arg values breaking `./` working directory detection** — when an `{{ args.name }}` placeholder resolved to an empty string, the substitution could introduce leading whitespace that prevented the `./` prefix from being detected, causing the command to run from the project root instead of the ralph directory.
- **Windows `.cmd`/`.exe` extension breaking streaming mode detection** — on Windows, `claude` is installed as `claude.cmd` or `claude.exe`. The streaming mode check compared the full filename (including extension) against `"claude"`, so it never matched. Ralphify now compares the stem only, enabling real-time activity tracking on Windows.

### Added

- **`ralph dashboard` command (POC)** — launches a local web server serving a real-time dashboard UI. Connects to running ralphs via SSE for live event streaming. Uses stdlib `http.server` — no new dependencies. Start with `ralph dashboard` (default port 8420).

### Improved

- **`BoundEmitter` convenience methods** — `log_info(message)` and `log_error(message, traceback=...)` let Python API users emit log events without constructing `Event` objects manually.
Expand Down
7 changes: 7 additions & 0 deletions pm/INBOX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Inbox

<!-- Add work items here. Use checkboxes. The agent picks the top unchecked item. -->
<!-- Formats: plain text descriptions, GitHub issue URLs, or ideas -->
- [x] https://github.com/computerlovetech/ralphify/issues/1 (see fleet-of-ralphs.md) POC Disclaimer in draft PR
- [ ] Make a test for a full software lifecycle (multiple devs + qa + tester and kanban and an INBOX.md example for a todo app) use websearch to find good examples, make a subdir with no code just the things ready to test our fleet
- [ ] Make sure we handle worktrees in a good way use websearch to check how https://github.com/juliusmarminge does it
19 changes: 19 additions & 0 deletions pm/TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Tasks

## Active

## Done
- [x] dashboard-poc — Visual dashboard for ralphify (#15) → PR #33
- [x] fleet-parallel-ralphs — Add `ralph fleet` command to run multiple ralphs in parallel (#1) → PR #32
- [x] merge-countdown-to-idle — Move countdown timer from PR #31 into PR #29 (idle-detection), fix live ticking → PR #29
- [x] reliable-idle-detection — Check full agent stdout for idle marker as fallback → PR #29
- [x] fix-idle-detection — Fix pm/RALPH.md typo and idle marker instruction so idle mode works
- [x] minimize-pr29-v3 — Reduce PR #29 diff by 76 lines (inlined constants, consolidated tests, trimmed docs)
- [x] delay-countdown — Replace static "Waiting" message with live countdown timer → PR #31
- [x] minimize-pr29-further — Further reduce PR #29 diff (~150 lines of dead code, redundancy, verbosity) → PR #29
- [x] minimize-pr29 — Reduce PR #29 test redundancy to shrink diff → PR #29
- [x] setup-pm-idle-config — Add idle detection config to setup-pm.md prompt → PR #30
- [x] idle-detection — State-aware idle detection with backoff and max idle duration (#28) → PR #29
- [x] minimize-pr27-tests — Further reduce test diff in PR#27 test_agent.py
- [x] ghost-agent-cleanup — Fix ghost agent processes surviving Ctrl+C (#26) → PR #27
- [x] minimize-pr27-diff — Reduce PR #27 diff size by consolidating tests
40 changes: 40 additions & 0 deletions pm/tasks/dashboard-poc/PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# dashboard-poc — Visual dashboard for ralphify (#15)

## Summary

Add a `ralph dashboard` CLI command that launches a local web server serving a real-time dashboard UI. The dashboard connects to the existing event system via `RunManager` and displays live run status, iteration history, and terminal output in a cyberpunk-styled interface matching the reference design (`pm/dashboard-ref.html`).

This is a **proof-of-concept** — the draft PR will include a POC disclaimer.

## Architecture

- New module `_dashboard.py` — HTTP server (stdlib `http.server` + threading) serving a single-page HTML dashboard
- New module `_dashboard_ws.py` — Simple WebSocket server for real-time event streaming (using stdlib or minimal approach)
- Actually, simpler: use **SSE (Server-Sent Events)** over HTTP — no extra deps needed
- The dashboard HTML is embedded as a string or served from a `_dashboard_static/` directory
- `cli.py` gets a new `dashboard` command that starts the server and optionally opens a browser
- The dashboard connects to running ralphs via `RunManager` event queues

## Files to modify

- `src/ralphify/cli.py` — Add `ralph dashboard` command
- `src/ralphify/_dashboard.py` — New: HTTP server + SSE endpoint + static HTML
- `src/ralphify/_dashboard_static/index.html` — New: Dashboard HTML (based on reference design)
- `tests/test_dashboard.py` — New: Tests for dashboard server
- `docs/changelog.md` — Add entry

## Steps

- [x] 1. Create `_dashboard.py` with a minimal HTTP server that serves static HTML and exposes an SSE `/events` endpoint that streams RunManager events as JSON
- [x] 2. Create `_dashboard_static/index.html` — the dashboard UI adapted from the reference design, connecting to the SSE endpoint for live updates
- [x] 3. Add `ralph dashboard` command to `cli.py` that starts the dashboard server (with `--port` option, default 8420)
- [x] 4. Write tests for the dashboard server (server starts, serves HTML, SSE endpoint streams events)
- [x] 5. Add changelog entry and create draft PR with POC disclaimer

## Acceptance criteria

- `ralph dashboard` starts a local web server and serves the dashboard UI
- The dashboard displays live run events via SSE when ralphs are running
- All existing tests still pass
- Draft PR includes a clear POC disclaimer
- No new runtime dependencies (uses stdlib http.server)
176 changes: 176 additions & 0 deletions src/ralphify/_dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Minimal HTTP server for the ralphify dashboard.

Serves a single-page dashboard UI and streams run events via Server-Sent
Events (SSE). Uses only the stdlib ``http.server`` module — no extra
dependencies.
"""

from __future__ import annotations

import json
import queue
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from importlib import resources
from typing import TYPE_CHECKING

from ralphify._events import Event, EventEmitter, QueueEmitter
from ralphify._run_types import RunStatus

if TYPE_CHECKING:
from ralphify.manager import RunManager


class _DashboardEmitter:
"""Broadcasts events to all connected SSE clients.

Implements :class:`EventEmitter` so it can be registered as a listener
on each :class:`ManagedRun`. Incoming events are fanned out to every
connected client's individual queue.
"""

def __init__(self) -> None:
self._clients: list[queue.Queue[Event]] = []
self._lock = threading.Lock()

def subscribe(self) -> queue.Queue[Event]:
q: queue.Queue[Event] = queue.Queue()
with self._lock:
self._clients.append(q)
return q

def unsubscribe(self, q: queue.Queue[Event]) -> None:
with self._lock:
try:
self._clients.remove(q)
except ValueError:
pass

def emit(self, event: Event) -> None:
with self._lock:
for q in self._clients:
q.put(event)


class _DashboardHandler(BaseHTTPRequestHandler):
"""HTTP request handler for dashboard routes."""

manager: RunManager
emitter: _DashboardEmitter

def log_message(self, format: str, *args: object) -> None: # noqa: A002
# Silence default stderr logging.
pass

def do_GET(self) -> None: # noqa: N802
if self.path == "/" or self.path == "/index.html":
self._serve_html()
elif self.path == "/events":
self._serve_sse()
elif self.path == "/api/runs":
self._serve_runs()
else:
self.send_error(404)

def _serve_html(self) -> None:
html = resources.files("ralphify").joinpath(
"_dashboard_static/index.html",
).read_text(encoding="utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(html.encode())

def _serve_runs(self) -> None:
runs = []
for managed in self.manager.list_runs():
s = managed.state
runs.append({
"run_id": s.run_id,
"status": s.status.value,
"iteration": s.iteration,
"completed": s.completed,
"failed": s.failed,
"timed_out": s.timed_out,
"ralph_name": managed.config.ralph_dir.name,
})
payload = json.dumps(runs).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(payload)

def _serve_sse(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.end_headers()

client_q = self.emitter.subscribe()
try:
while True:
try:
event = client_q.get(timeout=15)
except queue.Empty:
# Send keepalive comment to prevent connection timeout.
self.wfile.write(b": keepalive\n\n")
self.wfile.flush()
continue

data = json.dumps(event.to_dict())
self.wfile.write(f"event: {event.type.value}\n".encode())
self.wfile.write(f"data: {data}\n\n".encode())
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
pass
finally:
self.emitter.unsubscribe(client_q)


def start_dashboard(
manager: RunManager,
*,
port: int = 8420,
open_browser: bool = True,
) -> HTTPServer:
"""Start the dashboard HTTP server in a daemon thread.

Returns the :class:`HTTPServer` instance so the caller can call
``server.shutdown()`` to stop it.
"""
emitter = _DashboardEmitter()

# Attach the emitter to all existing runs.
for managed in manager.list_runs():
managed.add_listener(emitter)

# Monkey-patch create_run so future runs also get the emitter.
_original_create = manager.create_run

def _patched_create(config): # type: ignore[no-untyped-def]
managed = _original_create(config)
managed.add_listener(emitter)
return managed

manager.create_run = _patched_create # type: ignore[method-assign]

# Create a handler subclass that closes over the manager and emitter.
# Setting attributes on a functools.partial does not propagate to
# handler instances, so a dynamic subclass is the simplest approach.
handler_cls = type(
"_BoundHandler",
(_DashboardHandler,),
{"manager": manager, "emitter": emitter},
)

server = HTTPServer(("127.0.0.1", port), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True, name="dashboard")
thread.start()

if open_browser:
import webbrowser

webbrowser.open(f"http://127.0.0.1:{port}")

return server
Loading
Loading