Repository navigation
feat: webui v2 static port - #4061
Conversation
Ports ArtemSirobaba's feat/static-v2 chat SPA into a new ironclaw_webui_v2_static crate mounted under /v2 by ironclaw_reborn_composition. The API client and chat hooks now drive only the nine /api/webchat/v2/* endpoints from PR #3815; every page and hook that called /api/engine, /api/chat, /auth/*, or /api/profile is removed or rewritten. The SPA shell substitutes a CSP nonce per request and emits a matching CSP header so its inline scripts and CDN imports execute. v1 gateway UI is left untouched.
…m budget The SPA was missing replies because the Rust handler tags each SSE frame with a typed `event:` name, so `EventSource.onmessage` never fires; the chat handler now subscribes to every `WebChatV2Event` name explicitly. The live runtime also only emits `projection_snapshot` / `projection_update` today, so the handler walks the projection items (`run_status`, `text`, `gate`) to update isProcessing, append the assistant reply, and surface recovery_required as an error bubble. SSE rate-limit budget is bumped from 12/60s to 60/60s (parity with mutation budget) so a browser doing exponential-backoff reconnects no longer locks itself out before the per-caller concurrency cap recycles.
# Conflicts: # crates/ironclaw_reborn_composition/Cargo.toml
GET /threads was 503ing because Filesystem-backed SessionThreadService fell through to the trait's default unimplemented list_threads_for_scope when the libsql feature is on. Reborn's projection bridge doesn't mint Text items yet, so SSE delivers only run_status and the UI never showed the assistant reply — frontend now refetches /timeline on terminal success as a fallback until projection-side Text emit lands.
Routes for v2-unsupported pages (workspace, projects, jobs, routines, missions, settings, admin) now carry hidden: true so sidebar-nav skips them, satisfying AC #4 of #3886 — entries stay registered for direct URL / breadcrumb resolution. Reborn binary also calls dotenvy::dotenv() on startup so .env works like it does for the v1 binary.
There was a problem hiding this comment.
Code Review
This pull request introduces a new crate, ironclaw_webui_v2_static, which embeds and serves the static assets for the WebChat v2 SPA under the /v2 route. It also implements thread listing and pagination in ironclaw_threads, increases the stream rate limit in ironclaw_webui_v2, and adds comprehensive integration tests. The review feedback highlights a critical security issue where the CSP header lacks 'unsafe-eval' (required by widgets using new Function), a fail-open vulnerability if the CSP header generation fails, a silent failure in thread deletion, and a potential race condition with overlapping history loading requests.
…dance, history reentrancy guard CSP header build now returns 500 instead of silently serving the SPA shell without it when HeaderValue::from_str ever fails. Sidebar trash button is gated on `onDelete` and the gateway layout no longer wires the stub `deleteThread` no-op — v2 has no DELETE thread endpoint, so the affordance shouldn't appear at all. `useHistory.loadHistory` adds a synchronous loadingRef guard so overlapping calls (scroll-to-load racing the onRunCompleted refetch) no longer fire duplicate timeline requests.
CI `check_no_panics.py` flagged seven `.unwrap()` / `.expect()` calls in build.rs. Build-script panics fail the build cleanly by design, so suppress with inline `// safety: build script — <reason>` per the script's same-line annotation convention.
…_port test stub Test-stub line tripped clippy::unwrap-or-default after the merge from reborn-integration. TurnRunId's Default impl just calls new(), so the two are byte-equivalent.
…rvice slot reborn-integration's build_default_planned_runtime returns RebornRuntimeLoopComposition<_, dyn SessionThreadService, _>, but the harness's from_composition signature still required the concrete FilesystemSessionThreadService<LocalFilesystem>. The five tests that build a real composition (reborn_trace_core_builtin_tools_parity, reborn_trace_wasm_github_fixture_parity, and the three identity/scope isolation parity tests) failed E0308 on the merged tree.
3709b79 to
0786039
Compare
Resolved a trivial path-qualification conflict in tests/support/reborn/harness.rs — both sides type-erase the same SessionThreadService slot, so kept the unqualified form that matches the existing import.
Code review on #3886 surfaced a real perf hotspot — list_threads_for_scope page reads were sequential, now fanned out with join_all — alongside missing tests around the path-traversal and SPA-fallback rules and a handful of defense-in-depth items: admin defaults closed, CSP connect-src narrowed to self, SSE/WS rate window halved to shrink the URL-token replay surface, single corrupted thread.json tolerated, and the static crate mounted through a single mount_at_prefix factory so future routes propagate to host composition automatically.
e19a285 to
2e10e67
Compare
Second code-review pass turned up a real auth bug — a crafted `?token=` link could replace a working bearer in sessionStorage with garbage — plus gaps in test coverage: the filesystem session-thread service had no contract for list_threads_for_scope despite its layout diverging from the in-memory impl, and the resolve-gate route test never verified that axum percent-decodes the `gate_ref` segment before the handler sees it. The directory-scan cost in the same service is documented in-line; a proper fix needs an upstream filesystem-port change.
2e10e67 to
c99568b
Compare
…bui-v2-static-port-3886 # Conflicts: # Cargo.toml # crates/ironclaw_threads/Cargo.toml # crates/ironclaw_threads/src/filesystem_service.rs # crates/ironclaw_threads/src/in_memory.rs # crates/ironclaw_threads/tests/session_thread_contract.rs
serrrfirat
left a comment
There was a problem hiding this comment.
Conflict resolution looks good after merging current reborn-integration. Local verification passed: cargo test -p ironclaw_threads; cargo check -p ironclaw_webui_v2_static; cargo test -p ironclaw_reborn_composition --features webui-v2-beta --test webui_v2_serve.
* feat(webui-v2): port static SPA to Reborn WebChat ingress (nearai#3886) Ports ArtemSirobaba's feat/static-v2 chat SPA into a new ironclaw_webui_v2_static crate mounted under /v2 by ironclaw_reborn_composition. The API client and chat hooks now drive only the nine /api/webchat/v2/* endpoints from PR nearai#3815; every page and hook that called /api/engine, /api/chat, /auth/*, or /api/profile is removed or rewritten. The SPA shell substitutes a CSP nonce per request and emits a matching CSP header so its inline scripts and CDN imports execute. v1 gateway UI is left untouched. * fix(webui-v2): wire typed SSE events + projection items + raise stream budget The SPA was missing replies because the Rust handler tags each SSE frame with a typed `event:` name, so `EventSource.onmessage` never fires; the chat handler now subscribes to every `WebChatV2Event` name explicitly. The live runtime also only emits `projection_snapshot` / `projection_update` today, so the handler walks the projection items (`run_status`, `text`, `gate`) to update isProcessing, append the assistant reply, and surface recovery_required as an error bubble. SSE rate-limit budget is bumped from 12/60s to 60/60s (parity with mutation budget) so a browser doing exponential-backoff reconnects no longer locks itself out before the per-caller concurrency cap recycles. * fix(webui-v2): unblock chat flow after reborn-integration merge GET /threads was 503ing because Filesystem-backed SessionThreadService fell through to the trait's default unimplemented list_threads_for_scope when the libsql feature is on. Reborn's projection bridge doesn't mint Text items yet, so SSE delivers only run_status and the UI never showed the assistant reply — frontend now refetches /timeline on terminal success as a fallback until projection-side Text emit lands. * fix(webui-v2): hide unsupported pages + auto-load .env in Reborn binary Routes for v2-unsupported pages (workspace, projects, jobs, routines, missions, settings, admin) now carry hidden: true so sidebar-nav skips them, satisfying AC #4 of nearai#3886 — entries stay registered for direct URL / breadcrumb resolution. Reborn binary also calls dotenvy::dotenv() on startup so .env works like it does for the v1 binary. * fix(webui-v2): address PR review — CSP fail-closed, drop delete affordance, history reentrancy guard CSP header build now returns 500 instead of silently serving the SPA shell without it when HeaderValue::from_str ever fails. Sidebar trash button is gated on `onDelete` and the gateway layout no longer wires the stub `deleteThread` no-op — v2 has no DELETE thread endpoint, so the affordance shouldn't appear at all. `useHistory.loadHistory` adds a synchronous loadingRef guard so overlapping calls (scroll-to-load racing the onRunCompleted refetch) no longer fire duplicate timeline requests. * fix(webui-v2-static): annotate build.rs panics for the no-panic check CI `check_no_panics.py` flagged seven `.unwrap()` / `.expect()` calls in build.rs. Build-script panics fail the build cleanly by design, so suppress with inline `// safety: build script — <reason>` per the script's same-line annotation convention. * fix(loop-support): satisfy clippy unwrap_or_default in subagent_spawn_port test stub Test-stub line tripped clippy::unwrap-or-default after the merge from reborn-integration. TurnRunId's Default impl just calls new(), so the two are byte-equivalent. * fix(tests): align harness composition type with type-erased thread service slot reborn-integration's build_default_planned_runtime returns RebornRuntimeLoopComposition<_, dyn SessionThreadService, _>, but the harness's from_composition signature still required the concrete FilesystemSessionThreadService<LocalFilesystem>. The five tests that build a real composition (reborn_trace_core_builtin_tools_parity, reborn_trace_wasm_github_fixture_parity, and the three identity/scope isolation parity tests) failed E0308 on the merged tree. * fix(webui-v2-static): address review findings on the static SPA port Code review on nearai#3886 surfaced a real perf hotspot — list_threads_for_scope page reads were sequential, now fanned out with join_all — alongside missing tests around the path-traversal and SPA-fallback rules and a handful of defense-in-depth items: admin defaults closed, CSP connect-src narrowed to self, SSE/WS rate window halved to shrink the URL-token replay surface, single corrupted thread.json tolerated, and the static crate mounted through a single mount_at_prefix factory so future routes propagate to host composition automatically. * fix(webui-v2-static): address second-pass review findings Second code-review pass turned up a real auth bug — a crafted `?token=` link could replace a working bearer in sessionStorage with garbage — plus gaps in test coverage: the filesystem session-thread service had no contract for list_threads_for_scope despite its layout diverging from the in-memory impl, and the resolve-gate route test never verified that axum percent-decodes the `gate_ref` segment before the handler sees it. The directory-scan cost in the same service is documented in-line; a proper fix needs an upstream filesystem-port change. --------- Co-authored-by: serrrfirat <f@nuff.tech>
Summary
ironclaw_webui_v2_staticcrate (build-time asset embed, per-request CSP nonce,static_router(...)factory) and mount it under/v2from the Reborn WebUI host surface./api/webchat/v2/*— zero/api/engine/,/api/chat/,/auth/,/api/profilecalls remain in browser code.list_threads_for_scopeonInMemorySessionThreadServiceandFilesystemSessionThreadServicesoGET /api/webchat/v2/threadsno longer surfacesservice_unavailable..envviadotenvy::dotenv()on startup, mirroring v1 dev UX.Change Type
list_threads_for_scope, timeline refetch fallback, race guards, CSP fail-closed)/v2mount?token=shim scope preserved, CSP header build fail-closedLinked Issue
Closes #3886.
Validation
cargo fmt --all -- --checkcargo clippy --all --benches --tests --examples --all-features -- -D warningscargo build(with--features webui-v2-betaonironclaw_reborn_cli)session_thread_contract::list_threads_for_scope_is_scope_filtered_and_paginated,webui_v2_descriptors_contract,webui_v2_serveintegration suitecargo test --features integration— N/A: no database-backed change (libSQL local-dev backend is filesystem-style storage, not Postgres integration)?token=login, thread create / list, send message via Moonshot Kimi (openai_compatibleprovider), SSE deliversaccepted → running → completed, timeline refetch surfaces assistant reply, cancel run, attemptsetup_extension(returnsnot_implementedcleanly), v1-unsupported nav items not shownpython3 scripts/check_no_panics.pycleannode --checkon every changed JS filereview-pr— not runSecurity Impact
?token=exception scope unchanged: composition middleware still allowlists?token=only forGET /api/webchat/v2/threads/{id}/events; mutations and timeline reads stay bearer-only.HeaderValue::from_strever rejects the CSP string the request returns 500 instead of serving the shell without a CSP header (banned silent fail-open pererror-handling.md).js/app/auth.jsreduced to?token=URL ingestion +sessionStorage; no OAuth UI, no/auth/*calls.onDeleteand no longer rendered — eliminates the silent no-op the prior stub created.Reborn Trust-Boundary Checklist
WebUiAuthenticatedCallerextension comes from existing composition middleware; static-asset router doesn't mint trust types.RebornServicesErrorandSessionThreadErrorvariants are reused.serde(default)fields fail closed: N/A — no new wire fields added.list_threads_for_scopeclamps caller-suppliedlimitto[1, 200]on both backends; SPA asset table is build-time const.list_threads_for_scopesurfacesSessionThreadError::Backendonly when the backend itself fails; trait default'sservice_unavailablemapping at the gateway is unchanged.WebUiAuthenticatedCallersynthesized server-side from a verified bearer.Database Impact
FilesystemSessionThreadService::list_threads_for_scopereads existing thread records under/tenants/{tenant}/users/{user}/threads/agents/{agent}/.../threads/<thread_id>/thread.jsonand paginates by sorted thread_id. No schema migration, no new index, no new storage shape. The libSQL local-dev backend stores the same records viaLibSqlRootFilesystemmounted at/tenants— same path layout, same record bodies. Both PostgreSQL and libSQL paths inherit the new method through the trait.Blast Radius
ironclaw_webui_v2_static— touched only by composition'swebui_servemodule behindwebui-v2-betafeature; binaries built without that feature are not affected./v2route under the existing v2 middleware stack; does not modify existing routes.ironclaw_threads— adds method impls only; trait default unchanged so other consumers continue to surfaceservice_unavailableif they're on a backend without the method.ironclaw_webui_v2— SSE per-route rate limit bumped from 12/60s to 60/60s (5×); could increase request volume per caller during reconnect storms.ironclaw_reborn_cli— addsdotenvy::dotenv()at startup. Production hosts that don't have a.envare unaffected (silent on missing file).git diffover those paths against the PR base is empty.Rollback Plan
This PR is feature-gated behind
webui-v2-beta. To roll back:ironclaw-rebornwithout--features webui-v2-beta(or drop the feature fromironclaw_reborn_cli's default features once enabled)./v2mount is excluded; nine v2 endpoints stop being reachable. v1 gateway path is unaffected.git revert <merge commit>. Reverts the new crate, the mount, the filesystemlist_threads_for_scopeimpl, and the in-memory test. Trait default reverts toservice_unavailable— callers that were depending on enumeration go back to the M0 baseline.No DB migration to reverse.
Review Follow-Through
Three follow-up items surfaced during integration; each belongs on its own ticket, not this PR:
ProductProjectionItem::Text { id, body }. Today no production code path mints Text items, so assistant replies are not pushed via SSE — this PR's frontend refetches/timelineon terminalrun_status: completedas a fallback. When Reborn projection lands Text emit, the existingitem.textbranch inuseChatEventswill activate and the refetch becomes redundant.setup_extensionv2 lifecycle. Route is wired but backend returnsNotImplemented. UI surfaces thenot_implementedtaxonomy cleanly; no UI changes needed when the backend lands.default_owner↔WebUiAuthenticatedCaller.user_id. Two independent config sources for the same logical identity; mismatch fails silently asunknown threadat the prompt port. Should fail closed at startup or derive both from a single source.Review track: B (new feature — issue-approved)