Skip to content

Commit 2e33e8d

Browse files
Replace web-react polling with runtime SSE + sendCommand (#137)
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 <cursoragent@cursor.com>
1 parent 331a274 commit 2e33e8d

10 files changed

Lines changed: 527 additions & 238 deletions

File tree

apps/traverse-starter/web-react/src/App.test.tsx

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,54 @@ import { render, screen, act, fireEvent } from '@testing-library/react'
22
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
33
import App from './App'
44

5+
type Listener = (event: MessageEvent<string>) => void
6+
7+
class MockEventSource {
8+
static instances: MockEventSource[] = []
9+
url: string
10+
onmessage: Listener | null = null
11+
onerror: ((event: Event) => void) | null = null
12+
private listeners = new Map<string, Set<Listener>>()
13+
14+
constructor(url: string) {
15+
this.url = url
16+
MockEventSource.instances.push(this)
17+
}
18+
19+
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
20+
const fn = listener as Listener
21+
const set = this.listeners.get(type) ?? new Set()
22+
set.add(fn)
23+
this.listeners.set(type, set)
24+
}
25+
26+
removeEventListener(type: string, listener: EventListenerOrEventListenerObject) {
27+
this.listeners.get(type)?.delete(listener as Listener)
28+
}
29+
30+
close() {}
31+
32+
emit(type: string, data: unknown) {
33+
const event = { type, data: JSON.stringify(data) } as MessageEvent<string>
34+
for (const listener of this.listeners.get(type) ?? []) listener(event)
35+
}
36+
37+
static reset() {
38+
MockEventSource.instances = []
39+
}
40+
}
41+
542
describe('App', () => {
6-
beforeEach(() => vi.useFakeTimers())
7-
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() })
43+
beforeEach(() => {
44+
vi.useFakeTimers()
45+
MockEventSource.reset()
46+
vi.stubGlobal('EventSource', MockEventSource)
47+
})
48+
afterEach(() => {
49+
vi.useRealTimers()
50+
vi.unstubAllGlobals()
51+
vi.restoreAllMocks()
52+
})
853

954
it('renders UI shell', async () => {
1055
vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({}) } as Response)
@@ -14,7 +59,7 @@ describe('App', () => {
1459
expect(screen.getByRole('button', { name: 'Start Workflow' })).toBeInTheDocument()
1560
})
1661

17-
it('shows Offline when health check fails', async () => {
62+
it('shows offline when health check fails', async () => {
1863
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'))
1964
render(<App />)
2065
await act(async () => { await vi.runOnlyPendingTimersAsync() })
@@ -39,7 +84,7 @@ describe('App', () => {
3984
expect(screen.getByText('5 / 2000')).toBeInTheDocument()
4085
})
4186

42-
it('submits on Cmd+Enter when runtime is online', async () => {
87+
it('submits on Cmd+Enter and renders SSE capability_result output', async () => {
4388
const output = {
4489
title: 'T',
4590
tags: [],
@@ -52,16 +97,20 @@ describe('App', () => {
5297
if (path.includes('/healthz')) {
5398
return Promise.resolve({ ok: true } as Response)
5499
}
55-
if (path.includes('/execute') && init?.method === 'POST') {
56-
return Promise.resolve({
57-
ok: true,
58-
json: () => Promise.resolve({ execution_id: 'exec-1' }),
59-
} as Response)
60-
}
61-
if (path.includes('/executions/exec-1')) {
100+
if (path.includes('/commands') && init?.method === 'POST') {
62101
return Promise.resolve({
63102
ok: true,
64-
json: () => Promise.resolve({ execution_id: 'exec-1', status: 'succeeded', output }),
103+
json: () => Promise.resolve({
104+
api_version: 'v1',
105+
status: 'accepted',
106+
workspace_id: 'local-default',
107+
app_id: 'traverse-starter',
108+
session_id: 'sess-1',
109+
command: 'submit',
110+
state: 'processing',
111+
execution_id: 'exec-1',
112+
links: {},
113+
}),
65114
} as Response)
66115
}
67116
if (path.includes('/traces/exec-1')) {
@@ -81,8 +130,20 @@ describe('App', () => {
81130
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
82131

83132
await act(async () => { await Promise.resolve() })
84-
await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
133+
134+
const source = MockEventSource.instances.at(-1)
135+
expect(source).toBeTruthy()
136+
act(() => {
137+
source!.emit('capability_result', {
138+
session_id: 'sess-1',
139+
execution_id: 'exec-1',
140+
state: 'results',
141+
previous_state: 'processing',
142+
output,
143+
})
144+
})
85145

86146
expect(screen.getByText('Title')).toBeInTheDocument()
147+
expect(screen.getByText('T')).toBeInTheDocument()
87148
})
88149
})

apps/traverse-starter/web-react/src/App.tsx

Lines changed: 85 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
2-
import { createTraverseClient } from './client/traverseClient'
3-
import { useExecution } from './hooks/useExecution'
2+
import { createTraverseClient, type TraceEvent } from './client/traverseClient'
3+
import { useAppState } from './hooks/useAppState'
44
import { parseOutput } from './client/traverseOutput'
55

66
const BASE_URL =
77
import.meta.env.VITE_TRAVERSE_BASE_URL ??
88
import.meta.env.VITE_TRAVERSE_RUNTIME_URL ??
99
'http://127.0.0.1:8787'
1010
const WORKSPACE = import.meta.env.VITE_TRAVERSE_WORKSPACE ?? 'local-default'
11-
const CAPABILITY_ID = import.meta.env.VITE_TRAVERSE_CAPABILITY_ID ?? 'traverse-starter.process'
11+
const APP_ID = import.meta.env.VITE_TRAVERSE_APP_ID ?? 'traverse-starter'
1212
const NOTE_MAX_LENGTH = 2000
1313

1414
function App() {
1515
const [note, setNote] = useState('')
1616
const [runtimeStatus, setRuntimeStatus] = useState<'checking' | 'online' | 'offline'>('checking')
17+
const [submitting, setSubmitting] = useState(false)
18+
const [submitError, setSubmitError] = useState<string | null>(null)
19+
const [trace, setTrace] = useState<TraceEvent[]>([])
1720
const formRef = useRef<HTMLFormElement>(null)
1821

1922
const client = useMemo(() => createTraverseClient(BASE_URL), [])
20-
const { state, run } = useExecution(client, WORKSPACE)
23+
const { state, output, error, executionId } = useAppState(client, WORKSPACE, APP_ID)
2124

2225
useEffect(() => {
2326
let active = true
@@ -34,26 +37,51 @@ function App() {
3437
return () => { active = false; clearInterval(interval) }
3538
}, [])
3639

37-
const isRunning = state.phase === 'loading' || state.phase === 'polling'
40+
useEffect(() => {
41+
if (state !== 'results' || !executionId) {
42+
return
43+
}
44+
let active = true
45+
client.fetchTrace(WORKSPACE, executionId).then(
46+
(events) => { if (active) setTrace(events) },
47+
() => { if (active) setTrace([]) },
48+
)
49+
return () => { active = false }
50+
}, [client, state, executionId])
51+
52+
const isRunning = state === 'processing' || submitting
3853
const canSubmit = note.trim().length > 0 && runtimeStatus === 'online' && !isRunning
3954

40-
const handleStartWorkflow = useCallback((e?: React.FormEvent) => {
55+
const handleStartWorkflow = useCallback(async (e?: React.FormEvent) => {
4156
e?.preventDefault()
4257
if (!canSubmit) return
43-
run(CAPABILITY_ID, { note })
44-
}, [canSubmit, run, note])
58+
setSubmitting(true)
59+
setSubmitError(null)
60+
setTrace([])
61+
try {
62+
// Omit session_id so each submit starts a new runtime session at initial_state.
63+
await client.sendCommand(WORKSPACE, APP_ID, 'submit', { note })
64+
} catch (err) {
65+
setSubmitError(String(err))
66+
} finally {
67+
setSubmitting(false)
68+
}
69+
}, [canSubmit, client, note])
4570

4671
const handleNoteKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
4772
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
4873
e.preventDefault()
49-
handleStartWorkflow()
74+
void handleStartWorkflow()
5075
}
5176
}
5277

5378
const handleNoteChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
5479
setNote(e.target.value.slice(0, NOTE_MAX_LENGTH))
5580
}
5681

82+
const displayError = submitError ?? (state === 'error' ? error : null)
83+
const parsed = state === 'results' ? parseOutput(output) : null
84+
5785
return (
5886
<div style={{ maxWidth: '800px', margin: '40px auto', padding: '0 20px' }}>
5987
<header style={{ marginBottom: '40px', textAlign: 'center' }}>
@@ -110,15 +138,15 @@ function App() {
110138
</div>
111139
</div>
112140
<div style={{ marginTop: '20px', paddingTop: '16px', borderTop: '1px solid rgba(255,255,255,0.05)', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
113-
Workspace: <strong>{WORKSPACE}</strong> · Capability: <strong>{CAPABILITY_ID}</strong>
141+
Workspace: <strong>{WORKSPACE}</strong> · App: <strong>{APP_ID}</strong>
114142
</div>
115143
</section>
116144

117145
<section className="glass-panel" style={{ padding: '24px' }}>
118146
<h2 style={{ fontSize: '1.25rem', marginBottom: '16px', fontWeight: 600 }}>
119147
Start Workflow
120148
</h2>
121-
<form ref={formRef} onSubmit={handleStartWorkflow}>
149+
<form ref={formRef} onSubmit={(e) => { void handleStartWorkflow(e) }}>
122150
<div style={{ marginBottom: '20px' }}>
123151
<label htmlFor="note-input" style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '8px', fontWeight: 500 }}>
124152
Starter Input Note
@@ -170,78 +198,73 @@ function App() {
170198
Execution Output
171199
</h2>
172200

173-
{runtimeStatus === 'offline' && state.phase === 'idle' && (
201+
{runtimeStatus === 'offline' && state === 'idle' && !displayError && (
174202
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem', textAlign: 'center', paddingTop: '32px' }}>
175203
Connect to the Traverse runtime to see workflow output here.
176204
</div>
177205
)}
178206

179-
{runtimeStatus !== 'offline' && state.phase === 'idle' && (
207+
{runtimeStatus !== 'offline' && state === 'idle' && !displayError && !submitting && (
180208
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem', textAlign: 'center', paddingTop: '32px' }}>
181209
Submit a note above to start a workflow.
182210
</div>
183211
)}
184212

185-
{state.phase === 'loading' && (
186-
<div style={{ color: 'var(--text-secondary)' }}>Starting execution…</div>
187-
)}
188-
189-
{state.phase === 'polling' && (
213+
{(state === 'processing' || submitting) && !displayError && (
190214
<div style={{ color: 'var(--text-secondary)' }}>
191-
Polling execution <code>{state.executionId}</code>
215+
{state === 'processing' ? 'Processing…' : 'Submitting command…'}
192216
</div>
193217
)}
194218

195-
{state.phase === 'failed' && (
219+
{displayError && (
196220
<div style={{ color: '#ef4444' }}>
197-
<strong>Error:</strong> {state.error}
221+
<strong>Error:</strong> {displayError}
198222
</div>
199223
)}
200224

201-
{state.phase === 'succeeded' && (() => {
202-
const output = parseOutput(state.result.output)
203-
return output ? (
204-
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
205-
<OutputField label="Title" value={output.title} />
206-
<OutputField label="Note Type" value={output.noteType} />
207-
<OutputField label="Status" value={output.status} />
208-
<OutputField label="Suggested Next Action" value={output.suggestedNextAction} />
225+
{state === 'results' && parsed && (
226+
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
227+
<OutputField label="Title" value={parsed.title} />
228+
<OutputField label="Note Type" value={parsed.noteType} />
229+
<OutputField label="Status" value={parsed.status} />
230+
<OutputField label="Suggested Next Action" value={parsed.suggestedNextAction} />
231+
<div>
232+
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '6px' }}>Tags</div>
233+
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
234+
{parsed.tags.map(tag => (
235+
<span key={tag} style={{ padding: '2px 10px', background: 'rgba(139,92,246,0.15)', color: 'var(--color-accent)', borderRadius: '20px', fontSize: '0.85rem' }}>
236+
{tag}
237+
</span>
238+
))}
239+
</div>
240+
</div>
241+
{trace.length > 0 && (
209242
<div>
210-
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '6px' }}>Tags</div>
211-
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
212-
{output.tags.map(tag => (
213-
<span key={tag} style={{ padding: '2px 10px', background: 'rgba(139,92,246,0.15)', color: 'var(--color-accent)', borderRadius: '20px', fontSize: '0.85rem' }}>
214-
{tag}
215-
</span>
216-
))}
243+
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px' }}>
244+
Trace Events ({trace.length})
217245
</div>
246+
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
247+
{trace.map((event, index) => (
248+
<li key={`${event.timestamp}-${index}`} style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', background: 'rgba(0,0,0,0.25)', padding: '8px 12px', borderRadius: '6px' }}>
249+
<div><strong>{event.event_type}</strong> · {event.timestamp}</div>
250+
{event.data !== undefined && (
251+
<pre style={{ marginTop: '6px', fontSize: '0.8rem', color: 'var(--text-muted)', overflow: 'auto' }}>
252+
{JSON.stringify(event.data, null, 2)}
253+
</pre>
254+
)}
255+
</li>
256+
))}
257+
</ul>
218258
</div>
219-
{state.trace.length > 0 && (
220-
<div>
221-
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px' }}>
222-
Trace Events ({state.trace.length})
223-
</div>
224-
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
225-
{state.trace.map((event, index) => (
226-
<li key={`${event.timestamp}-${index}`} style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', background: 'rgba(0,0,0,0.25)', padding: '8px 12px', borderRadius: '6px' }}>
227-
<div><strong>{event.event_type}</strong> · {event.timestamp}</div>
228-
{event.data !== undefined && (
229-
<pre style={{ marginTop: '6px', fontSize: '0.8rem', color: 'var(--text-muted)', overflow: 'auto' }}>
230-
{JSON.stringify(event.data, null, 2)}
231-
</pre>
232-
)}
233-
</li>
234-
))}
235-
</ul>
236-
</div>
237-
)}
238-
</div>
239-
) : (
240-
<pre style={{ background: 'rgba(0,0,0,0.4)', padding: '16px', borderRadius: '6px', fontSize: '0.85rem', color: 'var(--text-primary)', overflow: 'auto' }}>
241-
{JSON.stringify(state.result.output, null, 2)}
242-
</pre>
243-
)
244-
})()}
259+
)}
260+
</div>
261+
)}
262+
263+
{state === 'results' && !parsed && output != null && (
264+
<pre style={{ background: 'rgba(0,0,0,0.4)', padding: '16px', borderRadius: '6px', fontSize: '0.85rem', color: 'var(--text-primary)', overflow: 'auto' }}>
265+
{JSON.stringify(output, null, 2)}
266+
</pre>
267+
)}
245268
</section>
246269

247270
</div>

0 commit comments

Comments
 (0)