← Corpus / memopop-orchestrator / plan
Wire memopop-native to the FastAPI sidecar
End-to-end plan for closing the loop from Tauri click to live log stream — Rust dispatcher forwards JSON, browser EventSource handles SSE, sidecar spawns lazily.
- Path
- plans/Wire-Memopop-Native-To-The-FastAPI-Sidecar.md
- Authors
- Michael Staton
- Augmented with
- Claude Code (Opus 4.7)
- Tags
- Tauri-Framework · FastAPI · Sidecar-Process · Rust · SSE · Server-Sent-Events · Integration · Memopop-Native
Plan — Wire memopop-native to the FastAPI sidecar
Context
We just shipped a FastAPI sidecar inside investment-memo-orchestrator/src/server/ that wraps generate_memo() over HTTP (changelog: 2026-04-30_01.md). It boots, all 7 routes register, error paths return correct codes — but no real POST /memos has been driven end-to-end through the orchestrator (would take 15–45 min and burn API credits).
memopop-native (the Tauri app at memopop-ai/apps/memopop-native/) currently stops at a “Ready to generate” placeholder panel inside DealCreationModal.svelte — when the user clicks Generate, nothing happens. The journey from “click an outline” to “see a memo run” has a missing middle.
Standing question was: should we set up a testing framework, or wire up Tauri to drive the sidecar? The orchestrator has no formal test suite today (pytest is in dev-deps but unused; the only .github/workflows runs GH Pages doc deploy). Standing up one for one new module is overkill. The high-leverage next step is closing the loop: Tauri → Rust dispatcher → FastAPI sidecar → orchestrator → live log stream → artifacts. That’s the moment the entire onboarding journey actually pays off.
Approach
Wire Tauri to the sidecar end-to-end, with a small targeted unit-test subphase along the way for the security/tricky bits.
Architecture decisions:
- JSON requests go through the Rust dispatcher.
POST /memos,GET /memos/{id},GET /memos/{id}/artifacts*get new match arms insrc-tauri/src/api/router.rsthat forward to the sidecar viareqwest. The Transport seam from session 02 stays unchanged. - SSE goes direct from webview to localhost:8765.
EventSourceis browser-native; the FastAPI CORS allowlist already covers Tauri origins (tauri://localhost,http://tauri.localhost). No Rust streaming proxy needed. NewsubscribeEvents()method on the Transport interface. - Lazy sidecar spawn on first
/memoscall. FirstPOST /memosfrom Rust checks if sidecar is running; if not, spawns{repoPath}/.venv/bin/python -m src.serverwithcwd={repoPath}, polls/healthzuntil ready, then forwards. Stored asMutex<Option<CommandChild>>in Tauri state. Killed onWindowEvent::Destroyed. running_jobflow stage holds the live state (job_id, status, log buffer). Lives inflow.svelte.tsalongside the existingcreate_deal/ready_to_runstages.- JobLogViewer component renders status pill + virtualized log tail. New file.
Why direct EventSource (not Rust-proxied): FastAPI already streams SSE correctly; webview natively consumes it; bypassing Rust avoids reimplementing chunked HTTP forwarding in the dispatcher. If CORS blocks (unlikely given the allowlist), fall back to tauri::Emitter events as a Rust-driven proxy — but cross that bridge only if we hit it.
Phases
Phase 0 — Targeted unit tests in the orchestrator (~30 min)
Lock down the tricky/security bits before they get exercised by Tauri. Not a framework — just a tests/ directory with 5 tests.
investment-memo-orchestrator/tests/__init__.py(empty).investment-memo-orchestrator/tests/test_server.py— five tests:test_create_memo_request_rejects_empty_body— Pydantic validation, expects 422.test_log_sink_splits_on_newlines— write"a\nb\nc", expect 3 events; partial line buffered until flush.test_event_bus_replays_backlog_to_late_subscribers— publish 5 events, then subscribe, expect all 5 + live tail.test_artifact_path_traversal_rejected—GET /memos/{id}/artifacts/../../etc/passwd→ 400.test_get_unknown_job_returns_404.
pyproject.toml— add[tool.pytest.ini_options]block:testpaths = ["tests"].- Run:
.venv/bin/python -m pytest tests/ -v.
Uses FastAPI’s TestClient (no real generate_memo() calls — those tests would cost $5+ each). Total time: minutes to write, milliseconds to run.
Phase 1 — Tauri Rust: sidecar process + dispatcher forwarder (~2 hours)
Files in memopop-ai/apps/memopop-native/:
src-tauri/Cargo.toml— add:reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["process", "time", "sync"] }src-tauri/src/api/sidecar.rs(new):pub struct SidecarManager { child: Mutex<Option<CommandChild>>, port: u16 }async fn ensure_running(&self, repo_path: &Path) -> Result<(), ApiError>— checkschild; if none, spawns{repo_path}/.venv/bin/python -m src.server --port 8765withcwd=repo_path, pollsGET /healthzfor up to 10 seconds, stores child handle.async fn forward(&self, method: &str, path: &str, body: Option<Value>) -> Result<Value, ApiError>— usesreqwest::Clientto callhttp://127.0.0.1:8765{path}, maps reqwest errors toApiError.pub fn shutdown(&self)— kills child via stored handle.
src-tauri/src/api/mod.rs— declarepub mod sidecar;.src-tauri/src/api/router.rs— add four match arms after the existing routes:
(SSE endpoint("POST", "/memos") | ("GET", "/memos") => { let repo_path = require_string(&body, "repoPath")?; let manager = state.sidecar(); manager.ensure_running(Path::new(repo_path)).await?; manager.forward(&method, &path, body).await } ("GET", p) if p.starts_with("/memos/") && !p.ends_with("/events") => { // same as above for status + artifact endpoints }/memos/{id}/eventsdeliberately not routed through Rust — JS opens EventSource directly.)src-tauri/src/lib.rs— registerSidecarManagerinapp.manage(...), add.on_window_eventhandler that callsmanager.shutdown()onWindowEvent::Destroyed.
Phase 2 — Frontend: transport SSE + flow stage + UI (~1.5 hours)
Files in memopop-ai/apps/memopop-native/src/:
lib/transport/types.ts— extendTransport:export type JobEvent = { type: 'log' | 'status' | 'complete' | 'error'; ts: string; [k: string]: any }; export interface Transport { request<T>(method: HttpMethod, path: string, body?: unknown): Promise<T>; subscribeEvents(jobId: string, onEvent: (ev: JobEvent) => void): () => void; }lib/transport/local.ts— implementsubscribeEvents:subscribeEvents(jobId, onEvent) { const es = new EventSource(`http://127.0.0.1:8765/memos/${jobId}/events`); es.onmessage = (e) => onEvent(JSON.parse(e.data)); return () => es.close(); }lib/stores/flow.svelte.ts— add stage:
Methods:| { kind: 'running_job'; outline: Outline; jobId: string; status: 'queued'|'running'|'completed'|'failed'; events: JobEvent[]; outputDir?: string }markRunning(outline, jobId),appendEvent(event),markFinished(status, outputDir).lib/components/DealCreationModal.svelte— replacesubmit()body with:const result = await getTransport().request<{job_id: string}>('POST', '/memos', { repoPath: settings.repoPath, company_name: companyName.trim() || companyUrl.trim(), company_url: companyUrl.trim() || undefined, investment_type: outline.outline_type === 'fund_commitment' ? 'fund' : 'direct', memo_mode: mode, firm: settings.activeFirm, deck_path: deckPath, outline_name: outline.id, }); flow.markRunning(outline, result.job_id);lib/components/JobLogViewer.svelte(new) — modal-shaped component, takesjobIdas prop:- On mount:
getTransport().subscribeEvents(jobId, flow.appendEvent). - Renders status pill (queued / running / completed / failed) at top.
- Scrollable log pane below, auto-scrolls on new events, tail is reverse-chronological-friendly.
- On
completeevent: shows “View artifacts →” CTA that opens the output directory in Finder viatauri-plugin-shell’sopen(or just lists files viaGET /memos/{id}/artifacts). - “Close” returns to gallery.
- On mount:
routes/+page.svelte— add{:else if flow.stage.kind === 'running_job'} <JobLogViewer ... />.lib/components/JourneyBreadcrumbs.svelte— recognizerunning_jobkind, set “Generate” step to active (currently this step lights up only forready_to_run; needs extension).
Phase 3 — End-to-end smoke test (~10 min, but 15–45 min wall time for a full run)
- Boot the app:
cd memopop-ai/apps/memopop-native && bun run tauri dev. - If repo path not yet anchored, pick
investment-memo-orchestrator/. - Click an outline (Standard Direct Investment).
- Click “Try this on a company →”.
- Enter a company name and URL (if no firm set, create one —
alpha-partnersalready exists per the user). - Click Generate.
- Watch: sidecar spawns (~2 sec), POST /memos returns job_id, SSE stream opens, log lines stream in.
- Partial validation: confirm log streaming, status transitions, sidecar process visible in
psfor 30–60 seconds, then close the modal (kills the run via close → idle → orphans the worker thread, which is fine for testing). - Optional full validation: let it run end-to-end, see “completed” status, click “View artifacts,” verify files exist on disk.
Critical files
Orchestrator (investment-memo-orchestrator/):
tests/test_server.py(new) — Phase 0pyproject.toml— add pytest config
Tauri Rust (memopop-ai/apps/memopop-native/src-tauri/):
Cargo.toml— add reqwest, tokiosrc/api/sidecar.rs(new) — process manager + forwardersrc/api/mod.rs— declare new modulesrc/api/router.rs— add/memos*match arms; reuse existingrequire_stringandApiErrorsrc/lib.rs— register state, wire shutdown hook
Frontend (memopop-ai/apps/memopop-native/src/):
lib/transport/types.ts— addsubscribeEventsto Transport interfacelib/transport/local.ts— implement viaEventSourcelib/stores/flow.svelte.ts— addrunning_jobstage and methodslib/components/DealCreationModal.svelte— realPOST /memossubmitlib/components/JobLogViewer.svelte(new) — live log + status panellib/components/JourneyBreadcrumbs.svelte— recognize new stageroutes/+page.svelte— render JobLogViewer forrunning_jobstage
Reused, not reinvented
ApiErrorshape andrequire_stringhelper insrc-tauri/src/api/mod.rsandrouter.rs— same patterns as/firms,/outlines,/actions/create-firm.JobEventBus2000-event backlog (already in orchestrator’sevents.py) — late SSE subscribers automatically replay.flow.svelte.tsdiscriminated-union pattern —running_jobslots in alongside existing stages.Transportsingleton seam —subscribeEventsis the second method on the interface; everything else stays.
Verification
Phase 0 verification:
cd investment-memo-orchestrator
.venv/bin/python -m pytest tests/ -v
# expect: 5 passed
Phase 1 verification (Rust side compiles + dispatches):
cd memopop-ai/apps/memopop-native
cargo check --manifest-path src-tauri/Cargo.toml
# manual: stub a sidecar manager test that calls forward() against a running server
Phase 2 verification (frontend types + builds):
cd memopop-ai/apps/memopop-native
bun run check # svelte-check, types clean
bun run build # vite build, no errors
Phase 3 verification (the real one):
- Sidecar process appears in
ps aux | grep 'src.server'after first POST. curl http://127.0.0.1:8765/healthzreturns 200 from outside the app.- Log lines visible streaming in the JobLogViewer within 5 seconds of submit.
- Status pill transitions queued → running.
- App quit cleanly stops the sidecar process (no orphan in
psafter exit).
Out of scope (deliberately deferred)
- PyInstaller-bundled sidecar. Today’s plan assumes the user has Python + venv set up in the orchestrator. Standalone-binary distribution is its own multi-day platform-by-platform effort.
- Multiple concurrent jobs.
max_workers=1stays. Multi-job is aJobRegistrychange later. - Persistent job history across restarts. Process memory is the store; the artifact tree on disk is the durable record.
- Auth, hosted deployment, multi-tenant. Those are option 3 from the exploration doc, not this work.
- Comprehensive test framework + CI. A future effort. Phase 0’s five tests are a seed, not a framework.
Estimated effort
~4 hours total: 30 min Phase 0, 2 hours Phase 1, 1.5 hours Phase 2, 10 min smoke test (15–45 min if running a full memo). Most likely sequence to take longer: the Rust sidecar manager (spawn + healthz polling + lifecycle) is the part with the most platform-specific moving parts.