Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/youaskm3/web-react/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />)
expect(screen.getByText(/localhost:3000/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
})
})
30 changes: 28 additions & 2 deletions apps/youaskm3/web-react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1>youaskm3</h1>
<p>Runtime: {runtimeUrl}</p>
<p>Runtime: {BASE_URL} · Workspace: {WORKSPACE}</p>

<button onClick={handleRun} disabled={state.phase === 'loading' || state.phase === 'polling'}>
Run
</button>

{state.phase === 'loading' && <p>Starting…</p>}
{state.phase === 'polling' && <p>Polling {state.executionId}…</p>}
{state.phase === 'succeeded' && (
<div>
<p>Done: {JSON.stringify(state.result.output)}</p>
{state.trace.length > 0 && <p>Trace events: {state.trace.length}</p>}
</div>
)}
{state.phase === 'failed' && <p>Error: {state.error}</p>}
</main>
)
}
Expand Down
52 changes: 52 additions & 0 deletions apps/youaskm3/web-react/src/client/traverseClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createTraverseClient } from './traverseClient'

const BASE = 'http://localhost:8787'

function mockFetch(responses: Record<string, unknown>) {
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')
})
})
54 changes: 54 additions & 0 deletions apps/youaskm3/web-react/src/client/traverseClient.ts
Original file line number Diff line number Diff line change
@@ -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<string>
pollExecution(workspaceId: string, executionId: string): Promise<ExecutionResult>
fetchTrace(workspaceId: string, executionId: string): Promise<TraceEvent[]>
}

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[]
},
}
}
77 changes: 77 additions & 0 deletions apps/youaskm3/web-react/src/hooks/useExecution.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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')
})
})
61 changes: 61 additions & 0 deletions apps/youaskm3/web-react/src/hooks/useExecution.ts
Original file line number Diff line number Diff line change
@@ -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<ExecutionState>({ phase: 'idle' })
const pollRef = useRef<ReturnType<typeof setInterval> | 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 }
}
Loading