Skip to content

Commit ec61db9

Browse files
Embed WASM runtime: web-react clients (#113) (#153)
* Embed Traverse runtime in web-react clients (#113) Migrate traverse-starter and doc-approval web apps from the HTTP sidecar to vendored traverse-embedder-web, with EmbedderTestDouble tests and Embedded Zone 1 status. Doc-approval stays Unavailable until manifests (#112). Co-authored-by: Cursor <cursoragent@cursor.com> * Include vendored traverse-embedder-web dist for CI installs Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 33d63ea commit ec61db9

41 files changed

Lines changed: 2693 additions & 478 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Dependencies
22
node_modules/
33
dist/
4+
!vendor/**/dist/
5+
!vendor/**/dist/**
46

57
# Test coverage
68
coverage/
@@ -18,3 +20,6 @@ coverage/
1820
manifests/traverse-starter/_traverse
1921
.traverse/
2022
apps/traverse-starter/TraverseCore/.build/
23+
24+
# Vite-served app bundles (run scripts/ci/sync_web_starter_bundle.sh)
25+
apps/*/web-react/public/bundles/

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ gh project item-list 2 --owner traverse-framework --format json --limit 300 \
1111

1212
### Blocked work summary
1313

14-
- **Phase 3 embedded runtime** ([#113](https://github.com/traverse-framework/reference-apps/issues/113)[#116](https://github.com/traverse-framework/reference-apps/issues/116)) — blocked on remaining consumable embedder SDKs: Traverse [#646](https://github.com/traverse-framework/Traverse/issues/646) (web/TS), [#647](https://github.com/traverse-framework/Traverse/issues/647) (Swift), [#648](https://github.com/traverse-framework/Traverse/issues/648) (Kotlin), [#649](https://github.com/traverse-framework/Traverse/issues/649) (.NET). Rust SDK shipped ([#650](https://github.com/traverse-framework/Traverse/issues/650) / PR #672); App-Refs [#117](https://github.com/traverse-framework/reference-apps/issues/117) Linux GTK + CLI embed shipped.
15-
- **doc-approval multi-capability showcase** ([#111](https://github.com/traverse-framework/reference-apps/issues/111), [#112](https://github.com/traverse-framework/reference-apps/issues/112)) — blocked only on Traverse [#555](https://github.com/traverse-framework/Traverse/issues/555) (`recommend`). Spec 069 closed [#538](https://github.com/traverse-framework/Traverse/issues/538): pipeline is `analyze → recommend` (no extract).
14+
- **Phase 3 embedded runtime** ([#114](https://github.com/traverse-framework/reference-apps/issues/114)[#116](https://github.com/traverse-framework/reference-apps/issues/116)) — blocked on remaining consumable embedder SDKs: Traverse [#647](https://github.com/traverse-framework/Traverse/issues/647) (Swift), [#648](https://github.com/traverse-framework/Traverse/issues/648) (Kotlin), [#649](https://github.com/traverse-framework/Traverse/issues/649) (.NET). Rust (#650) and Web/TS (#646) embedder SDKs shipped; App-Refs [#117](https://github.com/traverse-framework/reference-apps/issues/117) Linux/CLI and [#113](https://github.com/traverse-framework/reference-apps/issues/113) web-react embed shipped.
15+
- **doc-approval multi-capability showcase** ([#111](https://github.com/traverse-framework/reference-apps/issues/111), [#112](https://github.com/traverse-framework/reference-apps/issues/112)) — Ready after Traverse [#555](https://github.com/traverse-framework/Traverse/issues/555) (`recommend`). Spec 069: pipeline is `analyze → recommend` (no extract).
1616

17-
Ready: none.
17+
Ready: [#111](https://github.com/traverse-framework/reference-apps/issues/111), [#112](https://github.com/traverse-framework/reference-apps/issues/112) (after #113 merges).
1818

1919
Update this section when a PR changes platform status (see PR template checklist).
2020

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ All clients are **native UI shells** separated from business logic. Phase 1/2 us
103103

104104
| Platform | Status | Path |
105105
|---|---|---|
106-
| Web (React + TypeScript) | Shipped | [`apps/traverse-starter/web-react/`](apps/traverse-starter/web-react/) |
106+
| Web (React + TypeScript) | Shipped (embedded) | [`apps/traverse-starter/web-react/`](apps/traverse-starter/web-react/) |
107107
| trace-explorer (React) | Shipped | [`apps/trace-explorer/web-react/`](apps/trace-explorer/web-react/) |
108108
| iOS (SwiftUI) | Shipped | [`apps/traverse-starter/ios-swift/`](apps/traverse-starter/ios-swift/) |
109109
| macOS (SwiftUI + AppKit) | Shipped | [`apps/traverse-starter/macos-swift/`](apps/traverse-starter/macos-swift/) |
@@ -116,7 +116,7 @@ All clients are **native UI shells** separated from business logic. Phase 1/2 us
116116

117117
| Platform | Status | Path |
118118
|---|---|---|
119-
| Web (React + TypeScript) | Shipped | [`apps/doc-approval/web-react/`](apps/doc-approval/web-react/) |
119+
| Web (React + TypeScript) | Shipped (embedded; bundle waits on #112) | [`apps/doc-approval/web-react/`](apps/doc-approval/web-react/) |
120120
| iOS (SwiftUI) | Shipped | [`apps/doc-approval/ios-swift/`](apps/doc-approval/ios-swift/) |
121121
| macOS (SwiftUI + AppKit) | Shipped | [`apps/doc-approval/macos-swift/`](apps/doc-approval/macos-swift/) |
122122
| Android (Jetpack Compose) | Shipped | [`apps/doc-approval/android-compose/`](apps/doc-approval/android-compose/) |
@@ -174,4 +174,4 @@ Active blockers on [Project 2](https://github.com/orgs/traverse-framework/projec
174174
- **Phase 3 embedded runtime** ([#113](https://github.com/traverse-framework/reference-apps/issues/113)[#116](https://github.com/traverse-framework/reference-apps/issues/116)) — blocked on remaining embedder SDKs: Traverse [#646](https://github.com/traverse-framework/Traverse/issues/646) (web/TS), [#647](https://github.com/traverse-framework/Traverse/issues/647) (Swift), [#648](https://github.com/traverse-framework/Traverse/issues/648) (Kotlin), [#649](https://github.com/traverse-framework/Traverse/issues/649) (.NET). Rust SDK shipped ([#650](https://github.com/traverse-framework/Traverse/issues/650)); [#117](https://github.com/traverse-framework/reference-apps/issues/117) is Ready. HTTP sidecar remains dev-only.
175175
- **doc-approval multi-capability showcase** ([#111](https://github.com/traverse-framework/reference-apps/issues/111), [#112](https://github.com/traverse-framework/reference-apps/issues/112)) — blocked only on Traverse [#555](https://github.com/traverse-framework/Traverse/issues/555) (`recommend`). Spec 069: pipeline is `analyze → recommend` (no extract; [#538](https://github.com/traverse-framework/Traverse/issues/538) closed).
176176

177-
Ready on Project 2: none (after #117 merges).
177+
Ready on Project 2: [#111](https://github.com/traverse-framework/reference-apps/issues/111), [#112](https://github.com/traverse-framework/reference-apps/issues/112) (after #113 merges).

apps/doc-approval/web-react/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
},
1515
"dependencies": {
1616
"react": "^19.2.7",
17-
"react-dom": "^19.2.7"
17+
"react-dom": "^19.2.7",
18+
"traverse-embedder-web": "file:../../../vendor/traverse-embedder-web"
1819
},
1920
"devDependencies": {
2021
"@eslint/js": "^10.0.1",

apps/doc-approval/web-react/src/App.test.tsx

Lines changed: 32 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,54 @@
11
import { render, screen, act, fireEvent } from '@testing-library/react'
2-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { describe, it, expect } from 'vitest'
33
import App from './App'
4+
import { createTestEmbedder } from './host/embeddedHost'
5+
import type { DocApprovalOutput } from './client/traverseOutput'
46

5-
describe('App', () => {
6-
beforeEach(() => vi.useFakeTimers())
7-
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() })
7+
const sampleOutput: DocApprovalOutput = {
8+
docType: 'invoice',
9+
parties: ['Acme Corp'],
10+
amounts: ['$500.00'],
11+
confidence: 0.88,
12+
recommendation: 'approve',
13+
}
814

9-
it('renders UI shell', async () => {
10-
vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({}) } as Response)
11-
await act(async () => { render(<App />) })
15+
describe('App', () => {
16+
it('renders UI shell with Embedded zone 1', () => {
17+
render(<App embedder={createTestEmbedder(sampleOutput)} />)
1218
expect(screen.getByText('Doc Approval')).toBeInTheDocument()
19+
expect(screen.getByText('Embedded')).toBeInTheDocument()
20+
expect(screen.getByText('Ready')).toBeInTheDocument()
1321
expect(screen.getByRole('heading', { name: 'Submit Document', level: 2 })).toBeInTheDocument()
14-
expect(screen.getByRole('button', { name: 'Analyze Document' })).toBeInTheDocument()
1522
})
1623

17-
it('shows Offline when health check fails', async () => {
18-
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'))
19-
render(<App />)
20-
await act(async () => { await vi.runOnlyPendingTimersAsync() })
21-
expect(screen.getByText('Offline')).toBeInTheDocument()
24+
it('shows Unavailable when embedder is null', () => {
25+
render(<App embedder={null} />)
26+
expect(screen.getByText('Unavailable')).toBeInTheDocument()
2227
expect(screen.getByRole('button', { name: 'Analyze Document' })).toBeDisabled()
23-
expect(screen.getByText(/The runtime is offline/)).toBeInTheDocument()
24-
})
25-
26-
it('shows Online when health check succeeds', async () => {
27-
vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({}) } as Response)
28-
render(<App />)
29-
await act(async () => { await vi.runOnlyPendingTimersAsync() })
30-
expect(screen.getByText('Online')).toBeInTheDocument()
28+
expect(screen.getByText(/doc-approval manifests require issue #112/)).toBeInTheDocument()
3129
})
3230

33-
it('submits document and displays analysis fields', async () => {
34-
const output = {
35-
docType: 'invoice',
36-
parties: ['Acme Corp'],
37-
amounts: ['$500.00'],
38-
confidence: 0.88,
39-
recommendation: 'approve',
40-
}
41-
vi.spyOn(globalThis, 'fetch').mockImplementation((url, init) => {
42-
const path = String(url)
43-
if (path.includes('/healthz')) {
44-
return Promise.resolve({ ok: true } as Response)
45-
}
46-
if (path.includes('/execute') && init?.method === 'POST') {
47-
return Promise.resolve({
48-
ok: true,
49-
json: () => Promise.resolve({ execution_id: 'exec-1' }),
50-
} as Response)
51-
}
52-
if (path.includes('/executions/exec-1')) {
53-
return Promise.resolve({
54-
ok: true,
55-
json: () => Promise.resolve({ execution_id: 'exec-1', status: 'succeeded', output }),
56-
} as Response)
57-
}
58-
if (path.includes('/traces/exec-1')) {
59-
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response)
60-
}
61-
return Promise.resolve({ ok: false, status: 404 } as Response)
31+
it('submits document via test double and displays analysis fields', async () => {
32+
render(<App embedder={createTestEmbedder(sampleOutput)} />)
33+
fireEvent.change(screen.getByPlaceholderText(/Paste or type document/i), {
34+
target: { value: 'Invoice #123' },
35+
})
36+
await act(async () => {
37+
fireEvent.click(screen.getByRole('button', { name: 'Analyze Document' }))
6238
})
63-
64-
render(<App />)
65-
await act(async () => { await vi.runOnlyPendingTimersAsync() })
66-
67-
fireEvent.change(screen.getByPlaceholderText(/Paste or type document/i), { target: { value: 'Invoice #123' } })
68-
fireEvent.click(screen.getByRole('button', { name: 'Analyze Document' }))
69-
70-
await act(async () => { await Promise.resolve() })
71-
await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
72-
7339
expect(screen.getByText('Document Type')).toBeInTheDocument()
7440
expect(screen.getByText('invoice')).toBeInTheDocument()
7541
expect(screen.getByText('88%')).toBeInTheDocument()
7642
})
7743

7844
it('reset returns to idle', async () => {
79-
const output = {
80-
docType: 'invoice',
81-
parties: [],
82-
amounts: [],
83-
confidence: 0.5,
84-
recommendation: 'review',
85-
}
86-
vi.spyOn(globalThis, 'fetch').mockImplementation((url) => {
87-
const path = String(url)
88-
if (path.includes('/healthz')) return Promise.resolve({ ok: true } as Response)
89-
if (path.includes('/execute')) {
90-
return Promise.resolve({ ok: true, json: () => Promise.resolve({ execution_id: 'exec-1' }) } as Response)
91-
}
92-
if (path.includes('/executions/exec-1')) {
93-
return Promise.resolve({
94-
ok: true,
95-
json: () => Promise.resolve({ execution_id: 'exec-1', status: 'succeeded', output }),
96-
} as Response)
97-
}
98-
if (path.includes('/traces/exec-1')) {
99-
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response)
100-
}
101-
return Promise.resolve({ ok: false, status: 404 } as Response)
45+
render(<App embedder={createTestEmbedder(sampleOutput)} />)
46+
fireEvent.change(screen.getByPlaceholderText(/Paste or type document/i), {
47+
target: { value: 'doc' },
48+
})
49+
await act(async () => {
50+
fireEvent.click(screen.getByRole('button', { name: 'Analyze Document' }))
10251
})
103-
104-
render(<App />)
105-
await act(async () => { await vi.runOnlyPendingTimersAsync() })
106-
fireEvent.change(screen.getByPlaceholderText(/Paste or type document/i), { target: { value: 'doc' } })
107-
fireEvent.click(screen.getByRole('button', { name: 'Analyze Document' }))
108-
await act(async () => { await Promise.resolve() })
109-
await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
110-
11152
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
11253
expect(screen.getByText(/Submit a document above/)).toBeInTheDocument()
11354
})

apps/doc-approval/web-react/src/App.tsx

Lines changed: 70 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,88 @@
1-
import { useState, useEffect, useMemo, useCallback } from 'react'
2-
import { createTraverseClient } from './client/traverseClient'
3-
import { useExecution } from './hooks/useExecution'
1+
import { useState, useEffect, useCallback } from 'react'
42
import { HealthIndicator } from './components/HealthIndicator'
53
import { DocumentInput } from './components/DocumentInput'
64
import { AnalysisResult } from './components/AnalysisResult'
5+
import {
6+
DEFAULT_WORKFLOW_ID,
7+
DEFAULT_WORKSPACE,
8+
initProductionEmbedder,
9+
submitDocument,
10+
type HostRunResult,
11+
type RuntimeStatus,
12+
type TraverseEmbedderApi,
13+
} from './host/embeddedHost'
714

8-
const BASE_URL =
9-
import.meta.env.VITE_TRAVERSE_BASE_URL ??
10-
import.meta.env.VITE_TRAVERSE_RUNTIME_URL ??
11-
'http://127.0.0.1:8787'
12-
const WORKSPACE = import.meta.env.VITE_TRAVERSE_WORKSPACE ?? 'local-default'
13-
const CAPABILITY_ID = import.meta.env.VITE_TRAVERSE_CAPABILITY_ID ?? 'doc-approval.analyze'
1415
const DOCUMENT_MAX_LENGTH = 10000
1516

16-
function App() {
17+
export interface AppProps {
18+
embedder?: TraverseEmbedderApi | null
19+
}
20+
21+
function App({ embedder: injectedEmbedder }: AppProps = {}) {
22+
const injected = injectedEmbedder !== undefined
1723
const [document, setDocument] = useState('')
18-
const [runtimeStatus, setRuntimeStatus] = useState<'checking' | 'online' | 'offline'>('checking')
24+
const [prodStatus, setProdStatus] = useState<RuntimeStatus>('starting')
25+
const [prodEmbedder, setProdEmbedder] = useState<TraverseEmbedderApi | null>(null)
26+
const [submitting, setSubmitting] = useState(false)
27+
const [result, setResult] = useState<HostRunResult | null>(null)
1928

20-
const client = useMemo(() => createTraverseClient(BASE_URL), [])
21-
const { state, run, reset } = useExecution(client, WORKSPACE)
29+
const embedder = injected ? injectedEmbedder : prodEmbedder
30+
const runtimeStatus: RuntimeStatus = injected
31+
? injectedEmbedder
32+
? 'ready'
33+
: 'unavailable'
34+
: prodStatus
2235

2336
useEffect(() => {
37+
if (injected) return
2438
let active = true
25-
const check = async () => {
26-
try {
27-
const res = await fetch(`${BASE_URL}/healthz`)
28-
if (active) setRuntimeStatus(res.ok ? 'online' : 'offline')
29-
} catch {
30-
if (active) setRuntimeStatus('offline')
31-
}
39+
void initProductionEmbedder().then((instance) => {
40+
if (!active) return
41+
setProdEmbedder(instance)
42+
setProdStatus(instance ? 'ready' : 'unavailable')
43+
})
44+
return () => {
45+
active = false
3246
}
33-
check()
34-
const interval = setInterval(check, 5000)
35-
return () => { active = false; clearInterval(interval) }
36-
}, [])
47+
}, [injected])
3748

38-
const isRunning = state.phase === 'loading' || state.phase === 'polling'
39-
const canSubmit = document.trim().length > 0 && runtimeStatus === 'online' && !isRunning
49+
const isRunning = submitting
50+
const canSubmit = document.trim().length > 0 && runtimeStatus === 'ready' && !isRunning
4051

41-
const handleSubmit = useCallback((e?: React.FormEvent) => {
42-
e?.preventDefault()
43-
if (!canSubmit) return
44-
run(CAPABILITY_ID, { document })
45-
}, [canSubmit, run, document])
52+
const handleSubmit = useCallback(
53+
(e?: React.FormEvent) => {
54+
e?.preventDefault()
55+
if (!canSubmit || !embedder) return
56+
setSubmitting(true)
57+
setResult(null)
58+
try {
59+
setResult(submitDocument(embedder, document))
60+
} finally {
61+
setSubmitting(false)
62+
}
63+
},
64+
[canSubmit, embedder, document],
65+
)
4666

4767
const handleReset = useCallback(() => {
48-
reset()
68+
setResult(null)
4969
setDocument('')
50-
}, [reset])
70+
}, [])
5171

5272
return (
5373
<div style={{ maxWidth: '800px', margin: '40px auto', padding: '0 20px' }}>
5474
<header style={{ marginBottom: '40px', textAlign: 'center' }}>
55-
<h1 style={{
56-
fontSize: '2.5rem',
57-
fontWeight: 700,
58-
letterSpacing: '-0.03em',
59-
background: 'linear-gradient(to right, #a78bfa, #06b6d4)',
60-
WebkitBackgroundClip: 'text',
61-
WebkitTextFillColor: 'transparent',
62-
marginBottom: '8px',
63-
}}>
75+
<h1
76+
style={{
77+
fontSize: '2.5rem',
78+
fontWeight: 700,
79+
letterSpacing: '-0.03em',
80+
background: 'linear-gradient(to right, #a78bfa, #06b6d4)',
81+
WebkitBackgroundClip: 'text',
82+
WebkitTextFillColor: 'transparent',
83+
marginBottom: '8px',
84+
}}
85+
>
6486
Doc Approval
6587
</h1>
6688
<p style={{ color: 'var(--text-secondary)', fontSize: '1.05rem' }}>
@@ -70,9 +92,8 @@ function App() {
7092

7193
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
7294
<HealthIndicator
73-
baseUrl={BASE_URL}
74-
workspace={WORKSPACE}
75-
capabilityId={CAPABILITY_ID}
95+
workspace={DEFAULT_WORKSPACE}
96+
workflowId={DEFAULT_WORKFLOW_ID}
7697
status={runtimeStatus}
7798
/>
7899

@@ -81,14 +102,15 @@ function App() {
81102
maxLength={DOCUMENT_MAX_LENGTH}
82103
canSubmit={canSubmit}
83104
isRunning={isRunning}
84-
runtimeOffline={runtimeStatus === 'offline'}
105+
runtimeUnavailable={runtimeStatus === 'unavailable'}
85106
onChange={setDocument}
86107
onSubmit={handleSubmit}
87108
/>
88109

89110
<AnalysisResult
90-
state={state}
91-
runtimeOffline={runtimeStatus === 'offline'}
111+
result={result}
112+
submitting={submitting}
113+
runtimeUnavailable={runtimeStatus === 'unavailable'}
92114
onReset={handleReset}
93115
/>
94116
</div>

0 commit comments

Comments
 (0)