From a5175d8f3c41ec92a7da30ca261ef6448c146a45 Mon Sep 17 00:00:00 2001 From: Enrico Piovesan Date: Sat, 11 Jul 2026 09:09:56 -0600 Subject: [PATCH] Replace web-react polling with runtime SSE + sendCommand Make traverse-starter the reference client for app state subscriptions (033 SSE + 059 command dispatch) instead of a local poll state machine. Co-authored-by: Cursor --- .../web-react/src/App.test.tsx | 87 ++++++++-- apps/traverse-starter/web-react/src/App.tsx | 147 +++++++++------- .../src/client/traverseClient.test.ts | 45 +++-- .../web-react/src/client/traverseClient.ts | 56 +++--- .../web-react/src/hooks/useAppState.test.ts | 159 ++++++++++++++++++ .../web-react/src/hooks/useAppState.ts | 135 +++++++++++++++ .../web-react/src/hooks/useExecution.test.ts | 64 ------- .../web-react/src/hooks/useExecution.ts | 61 ------- ci/coverage-targets.txt | 2 +- docs/traverse-runtime.md | 9 +- 10 files changed, 527 insertions(+), 238 deletions(-) create mode 100644 apps/traverse-starter/web-react/src/hooks/useAppState.test.ts create mode 100644 apps/traverse-starter/web-react/src/hooks/useAppState.ts delete mode 100644 apps/traverse-starter/web-react/src/hooks/useExecution.test.ts delete mode 100644 apps/traverse-starter/web-react/src/hooks/useExecution.ts diff --git a/apps/traverse-starter/web-react/src/App.test.tsx b/apps/traverse-starter/web-react/src/App.test.tsx index 312945e..ec4d625 100644 --- a/apps/traverse-starter/web-react/src/App.test.tsx +++ b/apps/traverse-starter/web-react/src/App.test.tsx @@ -2,9 +2,54 @@ import { render, screen, act, fireEvent } from '@testing-library/react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import App from './App' +type Listener = (event: MessageEvent) => void + +class MockEventSource { + static instances: MockEventSource[] = [] + url: string + onmessage: Listener | null = null + onerror: ((event: Event) => void) | null = null + private listeners = new Map>() + + constructor(url: string) { + this.url = url + MockEventSource.instances.push(this) + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject) { + const fn = listener as Listener + const set = this.listeners.get(type) ?? new Set() + set.add(fn) + this.listeners.set(type, set) + } + + removeEventListener(type: string, listener: EventListenerOrEventListenerObject) { + this.listeners.get(type)?.delete(listener as Listener) + } + + close() {} + + emit(type: string, data: unknown) { + const event = { type, data: JSON.stringify(data) } as MessageEvent + for (const listener of this.listeners.get(type) ?? []) listener(event) + } + + static reset() { + MockEventSource.instances = [] + } +} + describe('App', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() }) + beforeEach(() => { + vi.useFakeTimers() + MockEventSource.reset() + vi.stubGlobal('EventSource', MockEventSource) + }) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) it('renders UI shell', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({}) } as Response) @@ -14,7 +59,7 @@ describe('App', () => { expect(screen.getByRole('button', { name: 'Start Workflow' })).toBeInTheDocument() }) - it('shows Offline when health check fails', async () => { + it('shows offline when health check fails', async () => { vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')) render() await act(async () => { await vi.runOnlyPendingTimersAsync() }) @@ -39,7 +84,7 @@ describe('App', () => { expect(screen.getByText('5 / 2000')).toBeInTheDocument() }) - it('submits on Cmd+Enter when runtime is online', async () => { + it('submits on Cmd+Enter and renders SSE capability_result output', async () => { const output = { title: 'T', tags: [], @@ -52,16 +97,20 @@ describe('App', () => { if (path.includes('/healthz')) { return Promise.resolve({ ok: true } as Response) } - if (path.includes('/execute') && init?.method === 'POST') { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ execution_id: 'exec-1' }), - } as Response) - } - if (path.includes('/executions/exec-1')) { + if (path.includes('/commands') && init?.method === 'POST') { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ execution_id: 'exec-1', status: 'succeeded', output }), + json: () => Promise.resolve({ + api_version: 'v1', + status: 'accepted', + workspace_id: 'local-default', + app_id: 'traverse-starter', + session_id: 'sess-1', + command: 'submit', + state: 'processing', + execution_id: 'exec-1', + links: {}, + }), } as Response) } if (path.includes('/traces/exec-1')) { @@ -81,8 +130,20 @@ describe('App', () => { fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) await act(async () => { await Promise.resolve() }) - await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + + const source = MockEventSource.instances.at(-1) + expect(source).toBeTruthy() + act(() => { + source!.emit('capability_result', { + session_id: 'sess-1', + execution_id: 'exec-1', + state: 'results', + previous_state: 'processing', + output, + }) + }) expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('T')).toBeInTheDocument() }) }) diff --git a/apps/traverse-starter/web-react/src/App.tsx b/apps/traverse-starter/web-react/src/App.tsx index 0b1299d..b0de3ad 100644 --- a/apps/traverse-starter/web-react/src/App.tsx +++ b/apps/traverse-starter/web-react/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react' -import { createTraverseClient } from './client/traverseClient' -import { useExecution } from './hooks/useExecution' +import { createTraverseClient, type TraceEvent } from './client/traverseClient' +import { useAppState } from './hooks/useAppState' import { parseOutput } from './client/traverseOutput' const BASE_URL = @@ -8,16 +8,19 @@ const BASE_URL = import.meta.env.VITE_TRAVERSE_RUNTIME_URL ?? 'http://127.0.0.1:8787' const WORKSPACE = import.meta.env.VITE_TRAVERSE_WORKSPACE ?? 'local-default' -const CAPABILITY_ID = import.meta.env.VITE_TRAVERSE_CAPABILITY_ID ?? 'traverse-starter.process' +const APP_ID = import.meta.env.VITE_TRAVERSE_APP_ID ?? 'traverse-starter' const NOTE_MAX_LENGTH = 2000 function App() { const [note, setNote] = useState('') const [runtimeStatus, setRuntimeStatus] = useState<'checking' | 'online' | 'offline'>('checking') + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + const [trace, setTrace] = useState([]) const formRef = useRef(null) const client = useMemo(() => createTraverseClient(BASE_URL), []) - const { state, run } = useExecution(client, WORKSPACE) + const { state, output, error, executionId } = useAppState(client, WORKSPACE, APP_ID) useEffect(() => { let active = true @@ -34,19 +37,41 @@ function App() { return () => { active = false; clearInterval(interval) } }, []) - const isRunning = state.phase === 'loading' || state.phase === 'polling' + useEffect(() => { + if (state !== 'results' || !executionId) { + return + } + let active = true + client.fetchTrace(WORKSPACE, executionId).then( + (events) => { if (active) setTrace(events) }, + () => { if (active) setTrace([]) }, + ) + return () => { active = false } + }, [client, state, executionId]) + + const isRunning = state === 'processing' || submitting const canSubmit = note.trim().length > 0 && runtimeStatus === 'online' && !isRunning - const handleStartWorkflow = useCallback((e?: React.FormEvent) => { + const handleStartWorkflow = useCallback(async (e?: React.FormEvent) => { e?.preventDefault() if (!canSubmit) return - run(CAPABILITY_ID, { note }) - }, [canSubmit, run, note]) + setSubmitting(true) + setSubmitError(null) + setTrace([]) + try { + // Omit session_id so each submit starts a new runtime session at initial_state. + await client.sendCommand(WORKSPACE, APP_ID, 'submit', { note }) + } catch (err) { + setSubmitError(String(err)) + } finally { + setSubmitting(false) + } + }, [canSubmit, client, note]) const handleNoteKeyDown = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault() - handleStartWorkflow() + void handleStartWorkflow() } } @@ -54,6 +79,9 @@ function App() { setNote(e.target.value.slice(0, NOTE_MAX_LENGTH)) } + const displayError = submitError ?? (state === 'error' ? error : null) + const parsed = state === 'results' ? parseOutput(output) : null + return (
@@ -110,7 +138,7 @@ function App() {
- Workspace: {WORKSPACE} · Capability: {CAPABILITY_ID} + Workspace: {WORKSPACE} · App: {APP_ID}
@@ -118,7 +146,7 @@ function App() {

Start Workflow

-
+ { void handleStartWorkflow(e) }}>