summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-25 05:05:27 +0000
committerClaude <noreply@anthropic.com>2026-05-25 05:05:27 +0000
commitb44c8277465c3b4e03fe4de4dd93850413023b69 (patch)
treef79131cb5f4d7444f2c358f35ad7749bad612bda
parent6890bafa65a309b540cbe1562c39df137f202369 (diff)
feat(api,web): event-stream timeline (Phase 3)
Adds GET /api/tasks/{id}/events (seq-ordered, ?since_seq for incremental polling) as the timeline data source, and a per-task Timeline section in the web UI that fetches and renders the observability event stream. New pure, unit-tested JS helpers: formatEventText, renderEventTimeline, fetchTaskEvents. The timeline load is async and guarded on a global fetch so the synchronous renderTaskPanel path (and its DOM-mock unit tests) is unaffected. Verified via Go endpoint tests and node --test (272 JS tests pass). Note: the live in-browser panel render was not exercised in this environment — only unit-level coverage. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
-rw-r--r--internal/api/events.go38
-rw-r--r--internal/api/events_test.go94
-rw-r--r--internal/api/server.go1
-rw-r--r--web/app.js98
-rw-r--r--web/style.css45
-rw-r--r--web/test/event-timeline.test.mjs112
6 files changed, 388 insertions, 0 deletions
diff --git a/internal/api/events.go b/internal/api/events.go
new file mode 100644
index 0000000..eefe064
--- /dev/null
+++ b/internal/api/events.go
@@ -0,0 +1,38 @@
+package api
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/thepeterstone/claudomator/internal/event"
+)
+
+// handleListTaskEvents returns a task's observability event stream in seq order.
+// Pass ?since_seq=N to fetch only events newer than seq N (incremental polling).
+func (s *Server) handleListTaskEvents(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if _, err := s.store.GetTask(id); err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
+ return
+ }
+
+ var sinceSeq int64
+ if v := r.URL.Query().Get("since_seq"); v != "" {
+ n, err := strconv.ParseInt(v, 10, 64)
+ if err != nil || n < 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid since_seq"})
+ return
+ }
+ sinceSeq = n
+ }
+
+ events, err := s.store.ListEvents(id, sinceSeq)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if events == nil {
+ events = []*event.Event{}
+ }
+ writeJSON(w, http.StatusOK, events)
+}
diff --git a/internal/api/events_test.go b/internal/api/events_test.go
new file mode 100644
index 0000000..456b6d8
--- /dev/null
+++ b/internal/api/events_test.go
@@ -0,0 +1,94 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+func TestListTaskEvents_ReturnsStreamInSeqOrder(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "ev-task", task.StateReady)
+
+ req := httptest.NewRequest("GET", "/api/tasks/ev-task/events", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d; body %s", w.Code, w.Body.String())
+ }
+ var events []*event.Event
+ if err := json.Unmarshal(w.Body.Bytes(), &events); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(events) == 0 {
+ t.Fatal("expected state-change events from the walk to READY")
+ }
+ for i := 1; i < len(events); i++ {
+ if events[i].Seq <= events[i-1].Seq {
+ t.Errorf("events not in ascending seq order: %d then %d", events[i-1].Seq, events[i].Seq)
+ }
+ }
+}
+
+func TestListTaskEvents_SinceSeqFilters(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "ev-since", task.StateReady)
+
+ all := getEvents(t, srv, "ev-since", "")
+ if len(all) < 2 {
+ t.Skipf("need at least 2 events to test since_seq, got %d", len(all))
+ }
+ firstSeq := all[0].Seq
+ rest := getEvents(t, srv, "ev-since", "?since_seq="+itoa(firstSeq))
+ for _, e := range rest {
+ if e.Seq <= firstSeq {
+ t.Errorf("since_seq=%d returned event with seq %d", firstSeq, e.Seq)
+ }
+ }
+}
+
+func TestListTaskEvents_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/tasks/missing/events", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Errorf("want 404, got %d", w.Code)
+ }
+}
+
+func TestListTaskEvents_InvalidSinceSeq(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "ev-bad", task.StateReady)
+ req := httptest.NewRequest("GET", "/api/tasks/ev-bad/events?since_seq=abc", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("want 400, got %d", w.Code)
+ }
+}
+
+func getEvents(t *testing.T, srv *Server, taskID, query string) []*event.Event {
+ t.Helper()
+ req := httptest.NewRequest("GET", "/api/tasks/"+taskID+"/events"+query, nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("getEvents %s%s: status %d", taskID, query, w.Code)
+ }
+ var events []*event.Event
+ if err := json.Unmarshal(w.Body.Bytes(), &events); err != nil {
+ t.Fatalf("getEvents unmarshal: %v", err)
+ }
+ return events
+}
+
+func itoa(n int64) string {
+ return strconv.FormatInt(n, 10)
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 6564280..a4b7ea1 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -144,6 +144,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("DELETE /api/tasks/{id}", s.handleDeleteTask)
s.mux.HandleFunc("GET /api/tasks/{id}/subtasks", s.handleListSubtasks)
s.mux.HandleFunc("GET /api/tasks/{id}/executions", s.handleListExecutions)
+ s.mux.HandleFunc("GET /api/tasks/{id}/events", s.handleListTaskEvents)
s.mux.HandleFunc("GET /api/executions", s.handleListRecentExecutions)
s.mux.HandleFunc("GET /api/stats", s.handleGetDashboardStats)
s.mux.HandleFunc("GET /api/agents/status", s.handleGetAgentStatus)
diff --git a/web/app.js b/web/app.js
index ff8e381..09b545b 100644
--- a/web/app.js
+++ b/web/app.js
@@ -115,6 +115,89 @@ export function renderDeploymentBadge(status, doc = (typeof document !== 'undefi
return span;
}
+// ── Event timeline ──────────────────────────────────────────────────────────
+// The observability event stream (GET /api/tasks/{id}/events) replaces the
+// ad-hoc question/summary panels. formatEventText turns one event into a human
+// line; renderEventTimeline builds the list element.
+
+// formatEventText returns a one-line human description of a task event.
+export function formatEventText(event) {
+ if (!event) return '';
+ const p = event.payload || {};
+ switch (event.kind) {
+ case 'state_change':
+ return `State: ${p.from || '?'} → ${p.to || '?'}`;
+ case 'summary':
+ return `Summary: ${p.text || p.summary || ''}`;
+ case 'clarification_request':
+ return `Question: ${p.text || ''}`;
+ case 'clarification_answer':
+ return `Answer: ${p.answer || ''}`;
+ case 'subtask_spawned':
+ return `Spawned subtask: ${p.name || p.subtask_id || ''}`;
+ case 'commit_made':
+ return `Commit: ${p.message || p.hash || ''}`;
+ case 'cost_report':
+ return `Cost: $${p.cost_usd != null ? p.cost_usd : 0}`;
+ case 'agent_message':
+ case 'human_message':
+ return p.text || p.message || '';
+ case 'execution_started':
+ return 'Execution started';
+ case 'execution_ended':
+ return p.status ? `Execution ended (${p.status})` : 'Execution ended';
+ default:
+ return event.kind || '';
+ }
+}
+
+// renderEventTimeline builds a <ul class="event-timeline"> from an events array.
+// Accepts an optional doc parameter for testability (defaults to document).
+export function renderEventTimeline(events, doc = (typeof document !== 'undefined' ? document : null)) {
+ if (doc == null) return null;
+ const ul = doc.createElement('ul');
+ ul.className = 'event-timeline';
+ if (!events || events.length === 0) {
+ const empty = doc.createElement('li');
+ empty.className = 'event-timeline__empty';
+ empty.textContent = 'No events yet.';
+ ul.appendChild(empty);
+ return ul;
+ }
+ for (const event of events) {
+ const li = doc.createElement('li');
+ li.className = `event-timeline__item event-timeline__item--${event.kind || 'unknown'}`;
+
+ const actor = doc.createElement('span');
+ actor.className = 'event-timeline__actor';
+ actor.textContent = event.actor || '';
+ li.appendChild(actor);
+
+ const text = doc.createElement('span');
+ text.className = 'event-timeline__text';
+ text.textContent = formatEventText(event);
+ li.appendChild(text);
+
+ ul.appendChild(li);
+ }
+ return ul;
+}
+
+// fetchTaskEvents loads a task's event stream. fetchImpl defaults to the global
+// fetch; pass a stub in tests. Returns [] on any error so the UI degrades
+// gracefully.
+export async function fetchTaskEvents(taskId, fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) {
+ if (!fetchImpl) return [];
+ try {
+ const resp = await fetchImpl(`/api/tasks/${taskId}/events`);
+ if (!resp.ok) return [];
+ const data = await resp.json();
+ return Array.isArray(data) ? data : [];
+ } catch {
+ return [];
+ }
+}
+
function truncateToWordBoundary(text, maxLen = 120) {
if (!text || text.length <= maxLen) return text;
const cut = text.lastIndexOf(' ', maxLen);
@@ -2055,6 +2138,21 @@ export function renderTaskPanel(task, executions) {
execSection.appendChild(list);
}
content.appendChild(execSection);
+
+ // ── Timeline ──
+ // Observability event stream, loaded asynchronously. Guarded so the sync
+ // panel render (and its unit tests, which have no fetch) is unaffected.
+ const timelineSection = makeSection('Timeline');
+ const timelineContainer = document.createElement('div');
+ timelineContainer.className = 'event-timeline-container';
+ timelineSection.appendChild(timelineContainer);
+ content.appendChild(timelineSection);
+ if (typeof fetch !== 'undefined') {
+ fetchTaskEvents(task.id).then(events => {
+ timelineContainer.innerHTML = '';
+ timelineContainer.appendChild(renderEventTimeline(events));
+ });
+ }
}
async function handleViewLogs(execId) {
diff --git a/web/style.css b/web/style.css
index d3b01d0..f4a9d91 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1989,3 +1989,48 @@ dialog label select:focus {
opacity: 0.85;
padding: 0.1rem 0;
}
+
+/* Event timeline (task observability stream) */
+.event-timeline {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.event-timeline__item {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding: 6px 10px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--border);
+ border-radius: 6px;
+ font-size: 0.85rem;
+}
+.event-timeline__item--state_change { border-left-color: var(--state-queued); }
+.event-timeline__item--clarification_request { border-left-color: var(--state-blocked); }
+.event-timeline__item--clarification_answer { border-left-color: var(--state-blocked); }
+.event-timeline__item--summary { border-left-color: var(--state-completed); }
+.event-timeline__item--subtask_spawned { border-left-color: var(--accent); }
+.event-timeline__item--execution_started,
+.event-timeline__item--execution_ended { border-left-color: var(--state-running); }
+.event-timeline__actor {
+ flex: 0 0 auto;
+ text-transform: uppercase;
+ font-size: 0.65rem;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+ min-width: 56px;
+}
+.event-timeline__text {
+ color: var(--text);
+ word-break: break-word;
+}
+.event-timeline__empty {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ padding: 6px 10px;
+}
diff --git a/web/test/event-timeline.test.mjs b/web/test/event-timeline.test.mjs
new file mode 100644
index 0000000..20ef61a
--- /dev/null
+++ b/web/test/event-timeline.test.mjs
@@ -0,0 +1,112 @@
+// event-timeline.test.mjs — Unit tests for the event timeline component.
+//
+// Run with: node --test web/test/event-timeline.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { formatEventText, renderEventTimeline, fetchTaskEvents } from '../app.js';
+
+function makeDoc() {
+ return {
+ createElement(tag) {
+ return {
+ tag,
+ className: '',
+ textContent: '',
+ children: [],
+ appendChild(child) { this.children.push(child); return child; },
+ };
+ },
+ };
+}
+
+describe('formatEventText', () => {
+ it('formats state_change with from/to', () => {
+ assert.equal(
+ formatEventText({ kind: 'state_change', payload: { from: 'QUEUED', to: 'RUNNING' } }),
+ 'State: QUEUED → RUNNING',
+ );
+ });
+
+ it('formats summary text', () => {
+ assert.equal(
+ formatEventText({ kind: 'summary', payload: { text: 'did the thing' } }),
+ 'Summary: did the thing',
+ );
+ });
+
+ it('formats clarification_request and answer', () => {
+ assert.equal(
+ formatEventText({ kind: 'clarification_request', payload: { text: 'which branch?' } }),
+ 'Question: which branch?',
+ );
+ assert.equal(
+ formatEventText({ kind: 'clarification_answer', payload: { answer: 'main' } }),
+ 'Answer: main',
+ );
+ });
+
+ it('formats subtask_spawned by name', () => {
+ assert.equal(
+ formatEventText({ kind: 'subtask_spawned', payload: { name: 'write tests', subtask_id: 'x' } }),
+ 'Spawned subtask: write tests',
+ );
+ });
+
+ it('falls back to kind for unknown events', () => {
+ assert.equal(formatEventText({ kind: 'mystery', payload: {} }), 'mystery');
+ });
+
+ it('handles null/empty defensively', () => {
+ assert.equal(formatEventText(null), '');
+ assert.equal(formatEventText({ kind: 'agent_message', payload: { message: 'hi' } }), 'hi');
+ });
+});
+
+describe('renderEventTimeline', () => {
+ it('renders one item per event with actor and text', () => {
+ const events = [
+ { kind: 'state_change', actor: 'system', payload: { from: 'PENDING', to: 'QUEUED' } },
+ { kind: 'summary', actor: 'agent', payload: { text: 'done' } },
+ ];
+ const ul = renderEventTimeline(events, makeDoc());
+ assert.equal(ul.className, 'event-timeline');
+ assert.equal(ul.children.length, 2);
+ // each item has [actor, text]
+ assert.equal(ul.children[0].children[0].textContent, 'system');
+ assert.equal(ul.children[0].children[1].textContent, 'State: PENDING → QUEUED');
+ assert.equal(ul.children[1].children[1].textContent, 'Summary: done');
+ });
+
+ it('renders an empty-state item for no events', () => {
+ const ul = renderEventTimeline([], makeDoc());
+ assert.equal(ul.children.length, 1);
+ assert.equal(ul.children[0].className, 'event-timeline__empty');
+ });
+
+ it('returns null when doc is null', () => {
+ assert.equal(renderEventTimeline([], null), null);
+ });
+});
+
+describe('fetchTaskEvents', () => {
+ it('returns parsed array on success', async () => {
+ const stub = async () => ({ ok: true, json: async () => [{ kind: 'summary', payload: {} }] });
+ const events = await fetchTaskEvents('t1', stub);
+ assert.equal(events.length, 1);
+ });
+
+ it('returns [] on non-ok response', async () => {
+ const stub = async () => ({ ok: false, json: async () => ({}) });
+ assert.deepEqual(await fetchTaskEvents('t1', stub), []);
+ });
+
+ it('returns [] when fetch throws', async () => {
+ const stub = async () => { throw new Error('network'); };
+ assert.deepEqual(await fetchTaskEvents('t1', stub), []);
+ });
+
+ it('returns [] when no fetch implementation is available', async () => {
+ assert.deepEqual(await fetchTaskEvents('t1', null), []);
+ });
+});