diff options
| -rw-r--r-- | internal/api/events.go | 38 | ||||
| -rw-r--r-- | internal/api/events_test.go | 94 | ||||
| -rw-r--r-- | internal/api/server.go | 1 | ||||
| -rw-r--r-- | web/app.js | 98 | ||||
| -rw-r--r-- | web/style.css | 45 | ||||
| -rw-r--r-- | web/test/event-timeline.test.mjs | 112 |
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) @@ -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), []); + }); +}); |
