diff --git a/.gitignore b/.gitignore index e57dc45..5792028 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Dependencies node_modules/ dist/ +!vendor/**/dist/ +!vendor/**/dist/** # Test coverage coverage/ @@ -18,3 +20,6 @@ coverage/ manifests/traverse-starter/_traverse .traverse/ apps/traverse-starter/TraverseCore/.build/ + +# Vite-served app bundles (run scripts/ci/sync_web_starter_bundle.sh) +apps/*/web-react/public/bundles/ diff --git a/AGENTS.md b/AGENTS.md index fb8764f..5ce9872 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,10 +11,10 @@ gh project item-list 2 --owner traverse-framework --format json --limit 300 \ ### Blocked work summary -- **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. -- **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). +- **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. +- **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). -Ready: none. +Ready: [#111](https://github.com/traverse-framework/reference-apps/issues/111), [#112](https://github.com/traverse-framework/reference-apps/issues/112) (after #113 merges). Update this section when a PR changes platform status (see PR template checklist). diff --git a/README.md b/README.md index daae9a0..8618797 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ All clients are **native UI shells** separated from business logic. Phase 1/2 us | Platform | Status | Path | |---|---|---| -| Web (React + TypeScript) | Shipped | [`apps/traverse-starter/web-react/`](apps/traverse-starter/web-react/) | +| Web (React + TypeScript) | Shipped (embedded) | [`apps/traverse-starter/web-react/`](apps/traverse-starter/web-react/) | | trace-explorer (React) | Shipped | [`apps/trace-explorer/web-react/`](apps/trace-explorer/web-react/) | | iOS (SwiftUI) | Shipped | [`apps/traverse-starter/ios-swift/`](apps/traverse-starter/ios-swift/) | | 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 | Platform | Status | Path | |---|---|---| -| Web (React + TypeScript) | Shipped | [`apps/doc-approval/web-react/`](apps/doc-approval/web-react/) | +| Web (React + TypeScript) | Shipped (embedded; bundle waits on #112) | [`apps/doc-approval/web-react/`](apps/doc-approval/web-react/) | | iOS (SwiftUI) | Shipped | [`apps/doc-approval/ios-swift/`](apps/doc-approval/ios-swift/) | | macOS (SwiftUI + AppKit) | Shipped | [`apps/doc-approval/macos-swift/`](apps/doc-approval/macos-swift/) | | 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 - **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. - **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). -Ready on Project 2: none (after #117 merges). +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). diff --git a/apps/doc-approval/web-react/package.json b/apps/doc-approval/web-react/package.json index da5a469..a53ce71 100644 --- a/apps/doc-approval/web-react/package.json +++ b/apps/doc-approval/web-react/package.json @@ -14,7 +14,8 @@ }, "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "traverse-embedder-web": "file:../../../vendor/traverse-embedder-web" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/apps/doc-approval/web-react/src/App.test.tsx b/apps/doc-approval/web-react/src/App.test.tsx index cf904a5..99be338 100644 --- a/apps/doc-approval/web-react/src/App.test.tsx +++ b/apps/doc-approval/web-react/src/App.test.tsx @@ -1,113 +1,54 @@ import { render, screen, act, fireEvent } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { describe, it, expect } from 'vitest' import App from './App' +import { createTestEmbedder } from './host/embeddedHost' +import type { DocApprovalOutput } from './client/traverseOutput' -describe('App', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() }) +const sampleOutput: DocApprovalOutput = { + docType: 'invoice', + parties: ['Acme Corp'], + amounts: ['$500.00'], + confidence: 0.88, + recommendation: 'approve', +} - it('renders UI shell', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({}) } as Response) - await act(async () => { render() }) +describe('App', () => { + it('renders UI shell with Embedded zone 1', () => { + render() expect(screen.getByText('Doc Approval')).toBeInTheDocument() + expect(screen.getByText('Embedded')).toBeInTheDocument() + expect(screen.getByText('Ready')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Submit Document', level: 2 })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Analyze Document' })).toBeInTheDocument() }) - it('shows Offline when health check fails', async () => { - vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')) - render() - await act(async () => { await vi.runOnlyPendingTimersAsync() }) - expect(screen.getByText('Offline')).toBeInTheDocument() + it('shows Unavailable when embedder is null', () => { + render() + expect(screen.getByText('Unavailable')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Analyze Document' })).toBeDisabled() - expect(screen.getByText(/The runtime is offline/)).toBeInTheDocument() - }) - - it('shows Online when health check succeeds', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({}) } as Response) - render() - await act(async () => { await vi.runOnlyPendingTimersAsync() }) - expect(screen.getByText('Online')).toBeInTheDocument() + expect(screen.getByText(/doc-approval manifests require issue #112/)).toBeInTheDocument() }) - it('submits document and displays analysis fields', async () => { - const output = { - docType: 'invoice', - parties: ['Acme Corp'], - amounts: ['$500.00'], - confidence: 0.88, - recommendation: 'approve', - } - vi.spyOn(globalThis, 'fetch').mockImplementation((url, init) => { - const path = String(url) - 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')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ execution_id: 'exec-1', status: 'succeeded', output }), - } as Response) - } - if (path.includes('/traces/exec-1')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response) - } - return Promise.resolve({ ok: false, status: 404 } as Response) + it('submits document via test double and displays analysis fields', async () => { + render() + fireEvent.change(screen.getByPlaceholderText(/Paste or type document/i), { + target: { value: 'Invoice #123' }, + }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Analyze Document' })) }) - - render() - await act(async () => { await vi.runOnlyPendingTimersAsync() }) - - fireEvent.change(screen.getByPlaceholderText(/Paste or type document/i), { target: { value: 'Invoice #123' } }) - fireEvent.click(screen.getByRole('button', { name: 'Analyze Document' })) - - await act(async () => { await Promise.resolve() }) - await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) - expect(screen.getByText('Document Type')).toBeInTheDocument() expect(screen.getByText('invoice')).toBeInTheDocument() expect(screen.getByText('88%')).toBeInTheDocument() }) it('reset returns to idle', async () => { - const output = { - docType: 'invoice', - parties: [], - amounts: [], - confidence: 0.5, - recommendation: 'review', - } - vi.spyOn(globalThis, 'fetch').mockImplementation((url) => { - const path = String(url) - if (path.includes('/healthz')) return Promise.resolve({ ok: true } as Response) - if (path.includes('/execute')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve({ execution_id: 'exec-1' }) } as Response) - } - if (path.includes('/executions/exec-1')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ execution_id: 'exec-1', status: 'succeeded', output }), - } as Response) - } - if (path.includes('/traces/exec-1')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response) - } - return Promise.resolve({ ok: false, status: 404 } as Response) + render() + fireEvent.change(screen.getByPlaceholderText(/Paste or type document/i), { + target: { value: 'doc' }, + }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Analyze Document' })) }) - - render() - await act(async () => { await vi.runOnlyPendingTimersAsync() }) - fireEvent.change(screen.getByPlaceholderText(/Paste or type document/i), { target: { value: 'doc' } }) - fireEvent.click(screen.getByRole('button', { name: 'Analyze Document' })) - await act(async () => { await Promise.resolve() }) - await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) - fireEvent.click(screen.getByRole('button', { name: 'Reset' })) expect(screen.getByText(/Submit a document above/)).toBeInTheDocument() }) diff --git a/apps/doc-approval/web-react/src/App.tsx b/apps/doc-approval/web-react/src/App.tsx index ea88f93..6027cc0 100644 --- a/apps/doc-approval/web-react/src/App.tsx +++ b/apps/doc-approval/web-react/src/App.tsx @@ -1,66 +1,88 @@ -import { useState, useEffect, useMemo, useCallback } from 'react' -import { createTraverseClient } from './client/traverseClient' -import { useExecution } from './hooks/useExecution' +import { useState, useEffect, useCallback } from 'react' import { HealthIndicator } from './components/HealthIndicator' import { DocumentInput } from './components/DocumentInput' import { AnalysisResult } from './components/AnalysisResult' +import { + DEFAULT_WORKFLOW_ID, + DEFAULT_WORKSPACE, + initProductionEmbedder, + submitDocument, + type HostRunResult, + type RuntimeStatus, + type TraverseEmbedderApi, +} from './host/embeddedHost' -const BASE_URL = - import.meta.env.VITE_TRAVERSE_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 ?? 'doc-approval.analyze' const DOCUMENT_MAX_LENGTH = 10000 -function App() { +export interface AppProps { + embedder?: TraverseEmbedderApi | null +} + +function App({ embedder: injectedEmbedder }: AppProps = {}) { + const injected = injectedEmbedder !== undefined const [document, setDocument] = useState('') - const [runtimeStatus, setRuntimeStatus] = useState<'checking' | 'online' | 'offline'>('checking') + const [prodStatus, setProdStatus] = useState('starting') + const [prodEmbedder, setProdEmbedder] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [result, setResult] = useState(null) - const client = useMemo(() => createTraverseClient(BASE_URL), []) - const { state, run, reset } = useExecution(client, WORKSPACE) + const embedder = injected ? injectedEmbedder : prodEmbedder + const runtimeStatus: RuntimeStatus = injected + ? injectedEmbedder + ? 'ready' + : 'unavailable' + : prodStatus useEffect(() => { + if (injected) return let active = true - const check = async () => { - try { - const res = await fetch(`${BASE_URL}/healthz`) - if (active) setRuntimeStatus(res.ok ? 'online' : 'offline') - } catch { - if (active) setRuntimeStatus('offline') - } + void initProductionEmbedder().then((instance) => { + if (!active) return + setProdEmbedder(instance) + setProdStatus(instance ? 'ready' : 'unavailable') + }) + return () => { + active = false } - check() - const interval = setInterval(check, 5000) - return () => { active = false; clearInterval(interval) } - }, []) + }, [injected]) - const isRunning = state.phase === 'loading' || state.phase === 'polling' - const canSubmit = document.trim().length > 0 && runtimeStatus === 'online' && !isRunning + const isRunning = submitting + const canSubmit = document.trim().length > 0 && runtimeStatus === 'ready' && !isRunning - const handleSubmit = useCallback((e?: React.FormEvent) => { - e?.preventDefault() - if (!canSubmit) return - run(CAPABILITY_ID, { document }) - }, [canSubmit, run, document]) + const handleSubmit = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault() + if (!canSubmit || !embedder) return + setSubmitting(true) + setResult(null) + try { + setResult(submitDocument(embedder, document)) + } finally { + setSubmitting(false) + } + }, + [canSubmit, embedder, document], + ) const handleReset = useCallback(() => { - reset() + setResult(null) setDocument('') - }, [reset]) + }, []) return (
-

+

Doc Approval

@@ -70,9 +92,8 @@ function App() {

@@ -81,14 +102,15 @@ function App() { maxLength={DOCUMENT_MAX_LENGTH} canSubmit={canSubmit} isRunning={isRunning} - runtimeOffline={runtimeStatus === 'offline'} + runtimeUnavailable={runtimeStatus === 'unavailable'} onChange={setDocument} onSubmit={handleSubmit} />
diff --git a/apps/doc-approval/web-react/src/components/AnalysisResult.tsx b/apps/doc-approval/web-react/src/components/AnalysisResult.tsx index a9ca266..768f40b 100644 --- a/apps/doc-approval/web-react/src/components/AnalysisResult.tsx +++ b/apps/doc-approval/web-react/src/components/AnalysisResult.tsx @@ -1,72 +1,102 @@ -import type { ExecutionState } from '../hooks/useExecution' -import { parseAnalysisOutput } from '../client/traverseOutput' +import type { HostRunResult } from '../host/embeddedHost' interface AnalysisResultProps { - state: ExecutionState - runtimeOffline: boolean + result: HostRunResult | null + submitting: boolean + runtimeUnavailable: boolean onReset: () => void } -export function AnalysisResult({ state, runtimeOffline, onReset }: AnalysisResultProps) { - const showReset = state.phase === 'succeeded' || state.phase === 'failed' +export function AnalysisResult({ + result, + submitting, + runtimeUnavailable, + onReset, +}: AnalysisResultProps) { + const showReset = Boolean(result) + const output = result?.output ?? null return (
-
-

- Analysis Result -

+
+

Analysis Result

{showReset && ( - )}
- {runtimeOffline && state.phase === 'idle' && ( -
- Connect to the Traverse runtime to see analysis output here. + {runtimeUnavailable && !result && !submitting && ( +
+ Initialize the embedded runtime (manifests #112) to see output here.
)} - {!runtimeOffline && state.phase === 'idle' && ( -
+ {!runtimeUnavailable && !result && !submitting && ( +
Submit a document above to start analysis.
)} - {state.phase === 'loading' && ( -
Starting analysis…
- )} + {submitting &&
Running embedded workflow…
} - {state.phase === 'polling' && ( -
- Polling execution {state.executionId}… + {result?.error && ( +
+ Error: {result.error}
)} - {state.phase === 'failed' && ( -
- Error: {state.error} + {output && ( +
+ + + + +
)} - {state.phase === 'succeeded' && (() => { - const output = parseAnalysisOutput(state.result.output) - return output ? ( -
- - - - - -
- ) : ( -
-            {JSON.stringify(state.result.output, null, 2)}
-          
- ) - })()} + {!output && result && !result.error && result.rawOutput != null && ( +
+          {JSON.stringify(result.rawOutput, null, 2)}
+        
+ )}
) } @@ -79,7 +109,17 @@ function formatConfidence(value: number): string { function OutputField({ label, value }: { label: string; value: string }) { return (
-
{label}
+
+ {label} +
{value}
) @@ -88,13 +128,32 @@ function OutputField({ label, value }: { label: string; value: string }) { function TagList({ label, items }: { label: string; items: string[] }) { return (
-
{label}
+
+ {label} +
{items.length === 0 ? (
None
) : (
- {items.map(item => ( - + {items.map((item) => ( + {item} ))} diff --git a/apps/doc-approval/web-react/src/components/DocumentInput.tsx b/apps/doc-approval/web-react/src/components/DocumentInput.tsx index 6bbcd6c..3d8dfd0 100644 --- a/apps/doc-approval/web-react/src/components/DocumentInput.tsx +++ b/apps/doc-approval/web-react/src/components/DocumentInput.tsx @@ -3,7 +3,7 @@ interface DocumentInputProps { maxLength: number canSubmit: boolean isRunning: boolean - runtimeOffline: boolean + runtimeUnavailable: boolean onChange: (value: string) => void onSubmit: (e?: React.FormEvent) => void } @@ -13,7 +13,7 @@ export function DocumentInput({ maxLength, canSubmit, isRunning, - runtimeOffline, + runtimeUnavailable, onChange, onSubmit, }: DocumentInputProps) { @@ -31,7 +31,16 @@ export function DocumentInput({
-