diff --git a/apps/youaskm3/web-react/src/App.test.tsx b/apps/youaskm3/web-react/src/App.test.tsx index aff8c84..fb8f759 100644 --- a/apps/youaskm3/web-react/src/App.test.tsx +++ b/apps/youaskm3/web-react/src/App.test.tsx @@ -7,8 +7,8 @@ describe('App', () => { expect(screen.getByRole('heading', { name: /youaskm3/i })).toBeInTheDocument() }) - it('shows the default runtime URL', () => { + it('renders the run button', () => { render() - expect(screen.getByText(/localhost:3000/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() }) }) diff --git a/apps/youaskm3/web-react/src/App.tsx b/apps/youaskm3/web-react/src/App.tsx index deb197b..627fc57 100644 --- a/apps/youaskm3/web-react/src/App.tsx +++ b/apps/youaskm3/web-react/src/App.tsx @@ -1,10 +1,36 @@ +import { useMemo } from 'react' +import { createTraverseClient } from './client/traverseClient' +import { useExecution } from './hooks/useExecution' + +const BASE_URL = import.meta.env.VITE_TRAVERSE_BASE_URL ?? 'http://127.0.0.1:8787' +const WORKSPACE = import.meta.env.VITE_TRAVERSE_WORKSPACE ?? 'local-default' + function App() { - const runtimeUrl = import.meta.env.VITE_TRAVERSE_RUNTIME_URL || 'http://localhost:3000' + const client = useMemo(() => createTraverseClient(BASE_URL), []) + const { state, run } = useExecution(client, WORKSPACE) + + const handleRun = () => { + run('youaskm3.answer', { question: 'What is Traverse?' }) + } return (

youaskm3

-

Runtime: {runtimeUrl}

+

Runtime: {BASE_URL} · Workspace: {WORKSPACE}

+ + + + {state.phase === 'loading' &&

Starting…

} + {state.phase === 'polling' &&

Polling {state.executionId}…

} + {state.phase === 'succeeded' && ( +
+

Done: {JSON.stringify(state.result.output)}

+ {state.trace.length > 0 &&

Trace events: {state.trace.length}

} +
+ )} + {state.phase === 'failed' &&

Error: {state.error}

}
) } diff --git a/apps/youaskm3/web-react/src/client/traverseClient.test.ts b/apps/youaskm3/web-react/src/client/traverseClient.test.ts new file mode 100644 index 0000000..82358eb --- /dev/null +++ b/apps/youaskm3/web-react/src/client/traverseClient.test.ts @@ -0,0 +1,52 @@ +import { createTraverseClient } from './traverseClient' + +const BASE = 'http://localhost:8787' + +function mockFetch(responses: Record) { + return vi.fn((url: string) => { + const key = Object.keys(responses).find(k => url.includes(k)) + if (!key) return Promise.resolve({ ok: false, status: 404 }) + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(responses[key]), + }) + }) as unknown as typeof fetch +} + +describe('createTraverseClient', () => { + it('execute posts and returns execution_id', async () => { + const fetch = mockFetch({ '/execute': { execution_id: 'exec-1' } }) + global.fetch = fetch + const client = createTraverseClient(BASE) + const id = await client.execute('local-default', 'cap.foo', { q: 'hi' }) + expect(id).toBe('exec-1') + expect(fetch).toHaveBeenCalledWith( + `${BASE}/v1/workspaces/local-default/execute`, + expect.objectContaining({ method: 'POST' }), + ) + }) + + it('pollExecution returns result', async () => { + const fetch = mockFetch({ '/executions/exec-1': { execution_id: 'exec-1', status: 'succeeded', output: 42 } }) + global.fetch = fetch + const client = createTraverseClient(BASE) + const result = await client.pollExecution('local-default', 'exec-1') + expect(result.status).toBe('succeeded') + expect(result.output).toBe(42) + }) + + it('fetchTrace returns events', async () => { + const fetch = mockFetch({ '/traces/exec-1': [{ event_type: 'start', timestamp: 't0' }] }) + global.fetch = fetch + const client = createTraverseClient(BASE) + const trace = await client.fetchTrace('local-default', 'exec-1') + expect(trace).toHaveLength(1) + expect(trace[0].event_type).toBe('start') + }) + + it('execute throws on non-ok response', async () => { + global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 500 })) as unknown as typeof fetch + const client = createTraverseClient(BASE) + await expect(client.execute('ws', 'cap', {})).rejects.toThrow('execute failed: 500') + }) +}) diff --git a/apps/youaskm3/web-react/src/client/traverseClient.ts b/apps/youaskm3/web-react/src/client/traverseClient.ts new file mode 100644 index 0000000..dc538f3 --- /dev/null +++ b/apps/youaskm3/web-react/src/client/traverseClient.ts @@ -0,0 +1,54 @@ +export type ExecutionStatus = 'pending' | 'running' | 'succeeded' | 'failed' + +export interface ExecutionResult { + execution_id: string + status: ExecutionStatus + output?: unknown + error?: string +} + +export interface TraceEvent { + event_type: string + timestamp: string + data?: unknown +} + +export interface TraverseClient { + execute(workspaceId: string, capability: string, input: unknown): Promise + pollExecution(workspaceId: string, executionId: string): Promise + fetchTrace(workspaceId: string, executionId: string): Promise +} + +export function createTraverseClient(baseUrl: string): TraverseClient { + return { + async execute(workspaceId, capability, input) { + const res = await fetch( + `${baseUrl}/v1/workspaces/${workspaceId}/execute`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability, input }), + }, + ) + if (!res.ok) throw new Error(`execute failed: ${res.status}`) + const data = (await res.json()) as { execution_id: string } + return data.execution_id + }, + + async pollExecution(workspaceId, executionId) { + const res = await fetch( + `${baseUrl}/v1/workspaces/${workspaceId}/executions/${executionId}`, + ) + if (!res.ok) throw new Error(`poll failed: ${res.status}`) + return (await res.json()) as ExecutionResult + }, + + async fetchTrace(workspaceId, executionId) { + const res = await fetch( + `${baseUrl}/v1/workspaces/${workspaceId}/traces/${executionId}`, + ) + if (!res.ok) throw new Error(`trace fetch failed: ${res.status}`) + return (await res.json()) as TraceEvent[] + }, + } +} diff --git a/apps/youaskm3/web-react/src/hooks/useExecution.test.ts b/apps/youaskm3/web-react/src/hooks/useExecution.test.ts new file mode 100644 index 0000000..e6135c1 --- /dev/null +++ b/apps/youaskm3/web-react/src/hooks/useExecution.test.ts @@ -0,0 +1,77 @@ +import { renderHook, act } from '@testing-library/react' +import { useExecution } from './useExecution' +import type { TraverseClient, ExecutionResult } from '../client/traverseClient' + +function makeClient(overrides: Partial = {}): TraverseClient { + return { + execute: vi.fn().mockResolvedValue('exec-1'), + pollExecution: vi.fn().mockResolvedValue({ + execution_id: 'exec-1', + status: 'succeeded', + output: 'ok', + } as ExecutionResult), + fetchTrace: vi.fn().mockResolvedValue([]), + ...overrides, + } +} + +const POLL_MS = 10 + +describe('useExecution', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('starts idle', () => { + const { result } = renderHook(() => useExecution(makeClient(), 'ws', POLL_MS)) + expect(result.current.state.phase).toBe('idle') + }) + + it('transitions loading → succeeded via polling', async () => { + const client = makeClient() + const { result } = renderHook(() => useExecution(client, 'ws', POLL_MS)) + + act(() => { result.current.run('cap', {}) }) + expect(result.current.state.phase).toBe('loading') + + // flush execute promise, interval not yet fired + await act(async () => { await Promise.resolve() }) + expect(result.current.state.phase).toBe('polling') + + // fire the poll interval + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS) }) + expect(result.current.state.phase).toBe('succeeded') + }) + + it('transitions to failed when execute throws', async () => { + const client = makeClient({ + execute: vi.fn().mockRejectedValue(new Error('net error')), + }) + const { result } = renderHook(() => useExecution(client, 'ws', POLL_MS)) + + await act(async () => { + result.current.run('cap', {}) + await Promise.resolve() + }) + + expect(result.current.state.phase).toBe('failed') + expect((result.current.state as { phase: 'failed'; error: string }).error).toContain('net error') + }) + + it('transitions to failed when poll returns failed status', async () => { + const client = makeClient({ + pollExecution: vi.fn().mockResolvedValue({ + execution_id: 'exec-1', + status: 'failed', + error: 'boom', + } as ExecutionResult), + }) + const { result } = renderHook(() => useExecution(client, 'ws', POLL_MS)) + + act(() => { result.current.run('cap', {}) }) + await act(async () => { await Promise.resolve() }) + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS) }) + + expect(result.current.state.phase).toBe('failed') + expect((result.current.state as { phase: 'failed'; error: string }).error).toBe('boom') + }) +}) diff --git a/apps/youaskm3/web-react/src/hooks/useExecution.ts b/apps/youaskm3/web-react/src/hooks/useExecution.ts new file mode 100644 index 0000000..03c712d --- /dev/null +++ b/apps/youaskm3/web-react/src/hooks/useExecution.ts @@ -0,0 +1,61 @@ +import { useState, useCallback, useRef } from 'react' +import { type TraverseClient, type ExecutionResult, type TraceEvent } from '../client/traverseClient' + +export type ExecutionState = + | { phase: 'idle' } + | { phase: 'loading' } + | { phase: 'polling'; executionId: string } + | { phase: 'succeeded'; result: ExecutionResult; trace: TraceEvent[] } + | { phase: 'failed'; error: string } + +const POLL_TERMINAL: readonly string[] = ['succeeded', 'failed'] + +export function useExecution(client: TraverseClient, workspaceId: string, pollIntervalMs = 1000) { + const [state, setState] = useState({ phase: 'idle' }) + const pollRef = useRef | null>(null) + + const stopPolling = useCallback(() => { + if (pollRef.current !== null) { + clearInterval(pollRef.current) + pollRef.current = null + } + }, []) + + const run = useCallback( + async (capability: string, input: unknown) => { + stopPolling() + setState({ phase: 'loading' }) + + let executionId: string + try { + executionId = await client.execute(workspaceId, capability, input) + } catch (e) { + setState({ phase: 'failed', error: String(e) }) + return + } + + setState({ phase: 'polling', executionId }) + + pollRef.current = setInterval(async () => { + try { + const result = await client.pollExecution(workspaceId, executionId) + if (POLL_TERMINAL.includes(result.status)) { + stopPolling() + const trace = await client.fetchTrace(workspaceId, executionId).catch(() => []) + if (result.status === 'succeeded') { + setState({ phase: 'succeeded', result, trace }) + } else { + setState({ phase: 'failed', error: result.error ?? 'execution failed' }) + } + } + } catch (e) { + stopPolling() + setState({ phase: 'failed', error: String(e) }) + } + }, pollIntervalMs) + }, + [client, workspaceId, stopPolling, pollIntervalMs], + ) + + return { state, run } +}