Skip to content

feat: add /api/events SSE endpoint for live run events (#178)#332

Closed
spboyer wants to merge 1 commit into
mainfrom
spboyer/ideal-disco
Closed

feat: add /api/events SSE endpoint for live run events (#178)#332
spboyer wants to merge 1 commit into
mainfrom
spboyer/ideal-disco

Conversation

@spboyer

@spboyer spboyer commented Jun 17, 2026

Copy link
Copy Markdown
Member

Closes #178.

Summary

The Live view in the dashboard (web/src/hooks/useSSE.ts + LiveView.tsx) opens EventSource('/api/events') and expects task_start, task_complete, grader_result, and run_complete events. The backend never registered that route, so curl /api/events returned the dashboard HTML shell. This PR adds the SSE endpoint and wires it to the existing run/session event stream.

Changes

  • internal/webapi/sse.go — new Broker (drop-oldest pub/sub) + HandleEvents SSE handler. Sends : connected preamble, 15s heartbeats, JSON-encoded {type, data, timestamp} frames. Filters to the four contract event types.
  • internal/webapi/sse_tailer.goSessionLogTailer polls the results directory, tails new *-session.jsonl files written by waza run --session-log, and republishes events through the broker. Maps session-event field names (task_name, duration_ms, …) to the camelCase keys the frontend reads. Synthesizes run_complete from session_complete. Skips pre-existing finished runs.
  • internal/webapi/handlers.go — adds broker to Handlers, factors out registerHandlerRoutes, exposes new RegisterRoutesWithHandlers so callers can pre-configure state. Registers GET /api/events.
  • internal/webserver/{server,routes}.go — creates a Broker in New, calls StartSessionLogTailer against cfg.ResultsDir from ListenAndServe, threads the broker through registerRoutes.
  • internal/webapi/sse_test.go — 8 new tests covering broker fan-out, unknown-type filtering, unsubscribe idempotency, response headers/body, route registration, the 503 path when no broker is configured, tailer event translation, and skipping pre-existing logs.

Process model note

waza serve and waza run are separate processes. The only shared channel is the filesystem session log, which is why the wiring tails *-session.jsonl rather than using an in-process bus. If we later run dashboard + run in one process, we can additionally have cmd_run publish directly via Handlers.PublishLiveEvent.

Validation

  • go test ./... — all packages green
  • golangci-lint run ./... — 0 issues
  • Manual repro:
    waza serve --port 18080 --no-browser
    curl -i http://127.0.0.1:18080/api/events
    
    Now returns HTTP/1.1 200, Content-Type: text/event-stream, and a : connected body — previously returned the dashboard HTML.

Out of scope

Implements server-sent events streaming to satisfy the live view
contract expected by web/src/hooks/useSSE.ts. Adds:

- internal/webapi/sse.go: in-memory pub/sub Broker and HandleEvents
  SSE handler that emits text/event-stream with a connected
  comment, periodic heartbeats, and JSON-encoded events shaped as
  {type, data, timestamp}. Supports task_start, task_complete,
  grader_result, run_complete; other types are dropped.
- internal/webapi/sse_tailer.go: SessionLogTailer that watches the
  configured results directory for *-session.jsonl files written by
  'waza run --session-log' and republishes their events to the
  broker. Synthesizes run_complete from session_complete.
- internal/webserver: wires a Broker and StartSessionLogTailer into
  the dashboard server lifecycle.
- New RegisterRoutesWithHandlers entrypoint so callers can attach
  state (such as a Broker) before exposing routes.
- Tests covering broker fan-out, unknown-type filtering, unsubscribe
  idempotency, SSE response headers/body, route registration, the
  503 path when no broker is configured, the tailer's event
  translation, and skipping pre-existing logs.

Verified manually against the issue repro: 'waza serve --port 18080
--no-browser' now returns Content-Type: text/event-stream from
/api/events instead of the dashboard HTML shell.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 17, 2026 13:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a backend Server-Sent Events (SSE) stream at GET /api/events so the dashboard Live view can receive real-time run events, using a new in-process broker plus a filesystem session-log tailer (for the waza serve / waza run separate-process model).

Changes:

  • Introduces an SSE Broker and /api/events handler that emits task_start, task_complete, grader_result, and run_complete frames with heartbeats.
  • Adds a SessionLogTailer that polls *-session.jsonl files and republishes translated events to the broker.
  • Wires the broker/tailer into the webserver route registration and adds tests for broker/handler/tailer behavior.
Show a summary per file
File Description
internal/webserver/server.go Creates a broker on server startup and starts the session-log tailer during ListenAndServe.
internal/webserver/routes.go Threads broker into handler setup and registers routes via a pre-configured Handlers instance.
internal/webapi/handlers.go Adds broker field + /api/events route registration and a new RegisterRoutesWithHandlers helper.
internal/webapi/sse.go Implements the SSE broker and the /api/events streaming handler.
internal/webapi/sse_tailer.go Implements polling/tailing of *-session.jsonl and translation into SSE-shaped events.
internal/webapi/sse_test.go Adds tests for broker fan-out/filtering, SSE handler behavior, route registration, and tailer translation/skip logic.

Copilot's findings

  • Files reviewed: 6/6 changed files
  • Comments generated: 3

Comment on lines +176 to +191
case "task_complete":
passed := stringField(raw.Data, "status") == "pass"
if passed {
e.passCount++
}
e.totalTasks++
broker.Publish(SSEEvent{
Type: "task_complete",
Timestamp: raw.Timestamp,
Data: map[string]any{
"taskName": stringField(raw.Data, "task_name"),
"outcome": stringField(raw.Data, "status"),
"score": floatField(raw.Data, "score"),
"duration": floatField(raw.Data, "duration_ms"),
},
})
Comment on lines +227 to +230
writeEvent(t, "task_start", map[string]any{"task_name": "alpha", "task_num": 1, "total_tasks": 2})
writeEvent(t, "task_complete", map[string]any{"task_name": "alpha", "status": "pass", "score": 1.0, "duration_ms": int64(123)})
writeEvent(t, "grader_result", map[string]any{"grader_name": "code", "grader_type": "code", "passed": true, "score": 1.0, "feedback": "ok"})
writeEvent(t, "session_complete", map[string]any{"total_tests": 1, "passed": 1, "failed": 0, "errors": 0, "duration_ms": int64(456)})
Comment on lines +22 to +25
// It is intentionally simple: it polls the directory for the most
// recently modified `*-session.jsonl` file and tails it. When a newer
// session log appears, it switches to the new file. The previous file
// is closed and any unread tail is read first to flush late events.
@spboyer

spboyer commented Jun 30, 2026

Copy link
Copy Markdown
Member Author

Closing in favor of #397 (latest iteration, green CI).

@spboyer spboyer closed this Jun 30, 2026
@spboyer spboyer deleted the spboyer/ideal-disco branch June 30, 2026 14:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Platform: SSE live events during run execution

3 participants