Skip to content

Commit 317fef9

Browse files
Kasper Jungeclaude
authored andcommitted
feat: wire persistence layer and add iterations endpoint so users can review past runs
The Store was defined but never connected. Now: - Events are persisted to ~/.ralph/ui.db during the drain loop - GET /api/runs/{run_id}/iterations returns iteration + check data from SQLite - Frontend auto-fetches persisted iterations when selecting a run with no in-memory data, rebuilding timeline and check health sparklines This means users can close the dashboard, come back later, and still see full iteration details for historical runs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9f4d7a2 commit 317fef9

3 files changed

Lines changed: 73 additions & 4 deletions

File tree

src/ralphify/ui/api/runs.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ralphify.manager import ManagedRun, RunManager
1212
from ralphify.prompts import resolve_prompt_name
1313
from ralphify.ui.models import RunCreate, RunResponse, RunSettingsUpdate
14+
from ralphify.ui.persistence import Store
1415

1516
def _load_agent_config(project_dir: str) -> dict:
1617
"""Read ``[agent]`` from ralph.toml so the UI never hard-codes command/args."""
@@ -37,6 +38,14 @@ def _get_manager(request: Request) -> RunManager:
3738
return mgr
3839

3940

41+
def _get_store(request: Request) -> Store:
42+
"""Extract the Store from app state (set during lifespan startup)."""
43+
store: Store | None = getattr(request.app.state, "store", None)
44+
if store is None:
45+
raise RuntimeError("Store not initialised")
46+
return store
47+
48+
4049
def _get_run_or_404(mgr: RunManager, run_id: str) -> ManagedRun:
4150
managed = mgr.get_run(run_id)
4251
if managed is None:
@@ -156,3 +165,25 @@ async def update_settings(run_id: str, body: RunSettingsUpdate, mgr: RunManager
156165
managed.config.stop_on_error = body.stop_on_error
157166

158167
return _run_response(managed)
168+
169+
170+
@router.get("/runs/{run_id}/iterations")
171+
async def get_iterations(run_id: str, store: Store = Depends(_get_store)) -> list[dict]:
172+
"""Return persisted iteration data with check results for a run."""
173+
iters = await store.get_iterations(run_id)
174+
result = []
175+
for it in iters:
176+
status_map = {"completed": "success", "failed": "failure", "timed_out": "timeout", "started": "running"}
177+
checks_raw = await store.get_check_results(run_id, it["iteration"])
178+
checks = [
179+
{"name": c["check_name"], "passed": bool(c["passed"]), "exit_code": c["exit_code"], "timed_out": bool(c["timed_out"])}
180+
for c in checks_raw
181+
]
182+
result.append({
183+
"iteration": it["iteration"],
184+
"status": status_map.get(it["status"], it["status"]),
185+
"returncode": it["returncode"],
186+
"duration": f"{it['duration']:.1f}s" if it["duration"] is not None else None,
187+
"checks": checks if checks else None,
188+
})
189+
return result

src/ralphify/ui/app.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515
from ralphify.ui.api import runs as runs_module
1616
from ralphify.ui.api import primitives as primitives_module
1717
from ralphify.ui.api.ws import ws_manager
18+
from ralphify.ui.persistence import Store
1819

1920

20-
async def _drain_events(manager: RunManager) -> None:
21+
async def _drain_events(manager: RunManager, store: Store) -> None:
2122
"""Background task: pull events from all run queues and fan out.
2223
2324
Bridges the synchronous engine threads (which push events into
24-
``queue.Queue``) to the async world (WebSocket broadcast).
25+
``queue.Queue``) to the async world (WebSocket broadcast + persistence).
2526
"""
2627
while True:
2728
for managed in manager.list_runs():
@@ -30,7 +31,9 @@ async def _drain_events(manager: RunManager) -> None:
3031
event: Event = managed.emitter.queue.get_nowait()
3132
except Empty:
3233
break
33-
await ws_manager.broadcast(event.run_id, event.to_dict())
34+
event_dict = event.to_dict()
35+
await ws_manager.broadcast(event.run_id, event_dict)
36+
await store.save_event(event_dict)
3437
await asyncio.sleep(0.05)
3538

3639

@@ -44,8 +47,12 @@ async def lifespan(app: FastAPI):
4447
manager = RunManager()
4548
app.state.manager = manager
4649

50+
store = Store()
51+
await store.init()
52+
app.state.store = store
53+
4754
# Start event drain task
48-
drain_task = asyncio.create_task(_drain_events(manager))
55+
drain_task = asyncio.create_task(_drain_events(manager, store))
4956

5057
yield
5158

@@ -55,6 +62,7 @@ async def lifespan(app: FastAPI):
5562
await drain_task
5663
except asyncio.CancelledError:
5764
pass
65+
await store.close()
5866

5967
app = FastAPI(title="ralphify", lifespan=lifespan)
6068

src/ralphify/ui/static/dashboard.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,36 @@ function selectRun(run_id) {
6969
// Auto-select the latest iteration for the new run
7070
const runIters = iterations.value[run_id] || [];
7171
activeIteration.value = runIters.length > 0 ? runIters[runIters.length - 1].iteration : null;
72+
// Load persisted iteration data if none in memory
73+
if (runIters.length === 0) {
74+
loadIterations(run_id);
75+
}
76+
}
77+
78+
async function loadIterations(run_id) {
79+
try {
80+
const data = await api('GET', `/runs/${run_id}/iterations`);
81+
if (data && data.length > 0) {
82+
iterations.value = { ...iterations.value, [run_id]: data };
83+
// Also rebuild check health sparklines from the loaded data
84+
const health = {};
85+
for (const it of data) {
86+
if (it.checks) {
87+
for (const c of it.checks) {
88+
if (!health[c.name]) health[c.name] = [];
89+
health[c.name].push(c.passed ? 'pass' : c.timed_out ? 'timeout' : 'fail');
90+
}
91+
}
92+
}
93+
if (Object.keys(health).length > 0) {
94+
checkHealth.value = { ...checkHealth.value, [run_id]: health };
95+
}
96+
// Auto-select the latest iteration
97+
if (run_id === activeRunId.value) {
98+
activeIteration.value = data[data.length - 1].iteration;
99+
}
100+
}
101+
} catch { /* endpoint may not exist on older servers */ }
72102
}
73103

74104
function showToast(text, type = 'error') {

0 commit comments

Comments
 (0)