From 3f32dbdf945afe3146ce7b1f4d430894bdca1148 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 13 May 2026 17:09:25 +1000 Subject: [PATCH 1/3] feat(staged): add browser-accessible web mode with transport abstraction Restore the full Axum HTTPS web server (previously stubbed) and implement the frontend transport layer for running Staged in a browser. This enables phone and desktop browser access to a running Staged instance. Key changes: - Unstub web_server::start() with TLS listener, static file serving, and auth-protected API routes - Add HTTP transport in invokeCommand() that POSTs to /api/invoke/{command} with automatic 401 redirect to login - Add WebSocket singleton for server-sent events in web mode - Add WebLogin.svelte token entry screen with /api/auth session cookie flow - Implement localStorage backend for persistentStore in web mode - Expose web access token via Tauri command and settings UI with copy button - Add `just dev-web` recipe requiring HTTPS cert/key env vars - Add writeClipboardText/readClipboardText web fallbacks in transport layer Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/justfile | 29 ++++ apps/staged/package.json | 1 + apps/staged/src-tauri/Cargo.lock | 4 +- apps/staged/src-tauri/src/lib.rs | 19 ++- apps/staged/src-tauri/src/web_server.rs | 74 ++++++++-- apps/staged/src/App.svelte | 31 +++- apps/staged/src/lib/commands.ts | 9 ++ .../src/lib/features/layout/WebLogin.svelte | 137 ++++++++++++++++++ .../lib/features/settings/SettingsPage.svelte | 80 +++++++++- apps/staged/src/lib/shared/persistentStore.ts | 43 +++++- apps/staged/src/lib/transport.ts | 48 +++++- apps/staged/vite.config.ts | 30 ++++ 12 files changed, 474 insertions(+), 31 deletions(-) create mode 100644 apps/staged/src/lib/features/layout/WebLogin.svelte diff --git a/apps/staged/justfile b/apps/staged/justfile index a7009b6b4..6e833633c 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -53,6 +53,35 @@ dev repo="": {{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }} pnpm exec tauri dev --config "$TAURI_CONFIG" +# Run with the HTTPS web server enabled for phone/browser access. +# Requires PEM cert/key files and a hostname covered by the certificate. +dev-web repo="": + #!/usr/bin/env bash + set -euo pipefail + + [[ -d node_modules ]] || pnpm install + + if [[ -z "${STAGED_WEB_CERT_PATH:-}" || -z "${STAGED_WEB_KEY_PATH:-}" || -z "${STAGED_WEB_HOST:-}" ]]; then + printf '%s\n' \ + 'Error: `just dev-web` serves browser access over HTTPS.' \ + 'Provide PEM certificate/key files and a hostname covered by the certificate:' \ + '' \ + ' STAGED_WEB_CERT_PATH=/path/to/cert.pem \' \ + ' STAGED_WEB_KEY_PATH=/path/to/key.pem \' \ + ' STAGED_WEB_HOST=hostname.example.com \' \ + ' just dev-web' >&2 + exit 1 + fi + + VITE_PORT=$(python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)") + export VITE_PORT + export STAGED_WEB_SERVER=1 + TAURI_CONFIG="{\"build\":{\"devUrl\":\"https://${STAGED_WEB_HOST}:${VITE_PORT}\",\"beforeDevCommand\":\"exec ./node_modules/.bin/vite --port ${VITE_PORT} --strictPort --host 0.0.0.0\"}}" + + echo "Starting on https://${STAGED_WEB_HOST}:${VITE_PORT} (HTTPS web server on :5175)" + {{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }} + pnpm exec tauri dev --config "$TAURI_CONFIG" + # Build the app for production build: pnpm run tauri:build diff --git a/apps/staged/package.json b/apps/staged/package.json index a6e003cf5..6486ac157 100644 --- a/apps/staged/package.json +++ b/apps/staged/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:web": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.app.json --fail-on-warnings && tsc -p tsconfig.node.json", diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index c1024ff2e..afdae812e 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -1158,9 +1158,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "data-url" diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index d8b75c9c7..3bbf66854 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -59,6 +59,10 @@ struct DbState { needs_reset: Mutex>, } +/// Holds the bearer token for web server authentication so it can be +/// retrieved by the frontend (Tauri command) and shown to the user. +struct WebAccessToken(String); + #[derive(Default)] struct ShutdownState { quit_in_progress: AtomicBool, @@ -286,6 +290,12 @@ fn stop_actions_for_app_shutdown(app_handle: &tauri::AppHandle) { // Store status commands // ============================================================================= +/// Returns the bearer token used to authenticate web browser clients. +#[tauri::command] +fn get_web_access_token(token: tauri::State<'_, WebAccessToken>) -> String { + token.0.clone() +} + /// Returns null if the store is ready, or version info if a reset is needed. #[tauri::command] fn get_store_status(db_state: tauri::State<'_, DbState>) -> Option { @@ -2136,14 +2146,16 @@ pub fn run() { let (event_tx, _) = tokio::sync::broadcast::channel::(256); app.manage(event_tx.clone()); - // Web server startup is stubbed out in this build. - // TODO(web): restore web server startup from the `mobile-web` branch. + // Start the Axum web server only when opted-in via environment variable. + // This avoids exposing an HTTP server on all interfaces for users who + // don't need browser-based access. let web_server_enabled = std::env::var("STAGED_WEB_SERVER") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); if web_server_enabled { let auth_token = web_server::generate_token(); + app.manage(WebAccessToken(auth_token.clone())); web_server::start(web_server::WebAppState { app_handle: app.handle().clone(), event_tx, @@ -2152,6 +2164,8 @@ pub fn run() { std::collections::HashSet::new(), )), }); + } else { + app.manage(WebAccessToken(String::new())); } if cfg!(debug_assertions) { @@ -2183,6 +2197,7 @@ pub fn run() { } }) .invoke_handler(tauri::generate_handler![ + get_web_access_token, get_store_status, confirm_reset_store, list_projects, diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 915d596e7..44c834172 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -11,10 +11,6 @@ //! All `/api/*` routes (except `/api/auth`) require authentication via either //! an `Authorization: Bearer ` header or a valid `staged_session` cookie. -// The full implementation is preserved here but start() is currently stubbed out, -// so most items appear unused to the compiler. -#![allow(dead_code, unused_imports)] - use std::collections::HashSet; use std::io; use std::net::SocketAddr; @@ -189,14 +185,68 @@ impl Listener for TlsListener { /// Start the Axum web server in a background tokio task. /// -/// Stubbed — logs a warning and returns. The full implementation (TLS listener, -/// Axum router with static file serving) is intentionally disabled in this build. -/// All route handlers, auth middleware, and the `dispatch()` match block are kept -/// compiling so they stay in sync with the rest of the codebase. -/// -/// TODO(web): restore full web server startup from the `mobile-web` branch. -pub fn start(_state: WebAppState) { - log::warn!("Web server requested but this build has the web server stubbed out"); +/// This should be called from the Tauri `setup` hook after all managed state +/// has been registered. +pub fn start(state: WebAppState) { + let token = state.auth_token.clone(); + tauri::async_runtime::spawn(async move { + let dist_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + // In dev, the exe is in src-tauri/target/debug; dist is at ../../dist relative to src-tauri + .map(|p| { + // Try multiple candidate paths for the built frontend + let candidates = vec![ + p.join("../dist"), // production bundle + p.join("../../../../dist"), // dev (target/debug -> src-tauri -> apps/staged -> dist) + PathBuf::from("../dist"), // relative to cwd + ]; + candidates + .into_iter() + .find(|c| c.exists()) + .unwrap_or_else(|| PathBuf::from("../dist")) + }) + .unwrap_or_else(|| PathBuf::from("../dist")); + + // Protected API routes require auth (Bearer token or session cookie) + let api_routes = Router::new() + .route("/api/invoke/{command}", post(invoke_command)) + .route("/api/events", get(ws_events)) + .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)); + + // Auth endpoint is public (it's where you submit the token) + let auth_route = Router::new().route("/api/auth", post(authenticate)); + + let app = api_routes + .merge(auth_route) + .fallback_service(ServeDir::new(&dist_dir).append_index_html_on_directories(true)) + .layer(CorsLayer::permissive()) + .with_state(state); + + let addr = "0.0.0.0:5175"; + let tls_acceptor = match load_tls_acceptor() { + Ok(acceptor) => acceptor, + Err(e) => { + log::error!("[web_server] {e}"); + return; + } + }; + log::info!( + "[web_server] starting HTTPS on {addr}, serving static files from {}", + dist_dir.display() + ); + log::info!("[web_server] web access token: {token}"); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + log::error!("[web_server] failed to bind {addr}: {e}"); + return; + } + }; + if let Err(e) = axum::serve(TlsListener::new(listener, tls_acceptor), app).await { + log::error!("[web_server] server error: {e}"); + } + }); } // ============================================================================= diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 9f3c50da5..e5d4609f5 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -7,6 +7,7 @@ -{#if preferences.loaded} +{#if showLogin} + +{:else if preferences.loaded} {#if storeIncompat && storeIncompat.kind === 'needs_reset'}
diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 27b35a8a4..3c5810b8a 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -52,6 +52,15 @@ export interface WorktreeChangesPreview { conflictedPaths: string[]; } +// ============================================================================= +// Web access +// ============================================================================= + +/** Returns the bearer token for web server authentication (Tauri-only). */ +export function getWebAccessToken(): Promise { + return invokeCommand('get_web_access_token'); +} + // ============================================================================= // Store status // ============================================================================= diff --git a/apps/staged/src/lib/features/layout/WebLogin.svelte b/apps/staged/src/lib/features/layout/WebLogin.svelte new file mode 100644 index 000000000..ea10a83e0 --- /dev/null +++ b/apps/staged/src/lib/features/layout/WebLogin.svelte @@ -0,0 +1,137 @@ + + + + + + diff --git a/apps/staged/src/lib/features/settings/SettingsPage.svelte b/apps/staged/src/lib/features/settings/SettingsPage.svelte index a73fbd594..d2a463719 100644 --- a/apps/staged/src/lib/features/settings/SettingsPage.svelte +++ b/apps/staged/src/lib/features/settings/SettingsPage.svelte @@ -10,9 +10,12 @@ import DoctorSettingsPanel from './DoctorSettingsPanel.svelte'; import GeneralSettingsPanel from './GeneralSettingsPanel.svelte'; import KeyboardSettingsPanel from './KeyboardSettingsPanel.svelte'; - import { isTauri } from '../../transport'; + import { isTauri, writeClipboardText } from '../../transport'; + import * as commands from '../../commands'; let appVersion = $state(__APP_VERSION__); + let webToken = $state(null); + let tokenCopied = $state(false); onMount(async () => { if (!isTauri) return; @@ -23,7 +26,20 @@ } catch (error) { console.warn('[Settings] Could not load runtime app version', error); } + + try { + webToken = await commands.getWebAccessToken(); + } catch { + // web server may not be running + } }); + + async function copyToken() { + if (!webToken) return; + await writeClipboardText(webToken); + tokenCopied = true; + setTimeout(() => (tokenCopied = false), 2000); + } @@ -85,6 +101,18 @@
+ + {#if webToken} +
+ Web Access Token +
+ {webToken.slice(0, 8)}... + +
+
+ {/if}
@@ -240,5 +268,55 @@ .nav-meta { display: none; } + + .web-token-section { + display: none; + } + } + + .web-token-section { + margin-top: auto; + padding: 12px; + border-top: 1px solid var(--border-subtle); + } + + .web-token-label { + font-size: var(--size-xs); + color: var(--text-muted); + display: block; + margin-bottom: 6px; + } + + .web-token-row { + display: flex; + align-items: center; + gap: 8px; + } + + .web-token-value { + font-size: var(--size-xs); + color: var(--text-faint); + background: var(--bg-deepest); + padding: 2px 6px; + border-radius: 3px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + } + + .web-token-copy { + background: none; + border: 1px solid var(--border-muted); + border-radius: 4px; + color: var(--text-muted); + font-size: var(--size-xs); + padding: 2px 8px; + cursor: pointer; + white-space: nowrap; + } + + .web-token-copy:hover { + color: var(--text-primary); + border-color: var(--border-emphasis); } diff --git a/apps/staged/src/lib/shared/persistentStore.ts b/apps/staged/src/lib/shared/persistentStore.ts index b0bf6a897..b8eb96927 100644 --- a/apps/staged/src/lib/shared/persistentStore.ts +++ b/apps/staged/src/lib/shared/persistentStore.ts @@ -22,15 +22,20 @@ interface TauriStoreBackend { } // --------------------------------------------------------------------------- -// localStorage backend (web mode) — stubbed out -// TODO(web): restore localStorage backend from the `mobile-web` branch +// localStorage backend (web mode) // --------------------------------------------------------------------------- +const LOCAL_STORAGE_PREFIX = 'staged:pref:'; + +interface LocalStorageBackend { + kind: 'localStorage'; +} + // --------------------------------------------------------------------------- // Singleton // --------------------------------------------------------------------------- -type StoreBackend = TauriStoreBackend | null; +type StoreBackend = TauriStoreBackend | LocalStorageBackend | null; let backend: StoreBackend = null; @@ -50,8 +55,9 @@ export async function initPersistentStore(): Promise { overrideDefaults: true, }); backend = { kind: 'tauri', store }; + } else { + backend = { kind: 'localStorage' }; } - // TODO(web): restore localStorage backend initialization for web mode } /** @@ -64,7 +70,18 @@ export async function getStoreValue(key: string): Promise { return undefined; } - return backend.store.get(key); + if (backend.kind === 'tauri') { + return backend.store.get(key); + } + + // localStorage backend + const raw = localStorage.getItem(LOCAL_STORAGE_PREFIX + key); + if (raw === null) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return undefined; + } } /** @@ -77,7 +94,13 @@ export async function setStoreValue(key: string, value: T): Promise { return; } - await backend.store.set(key, value); + if (backend.kind === 'tauri') { + await backend.store.set(key, value); + return; + } + + // localStorage backend + localStorage.setItem(LOCAL_STORAGE_PREFIX + key, JSON.stringify(value)); } /** @@ -89,5 +112,11 @@ export async function deleteStoreValue(key: string): Promise { return; } - await backend.store.delete(key); + if (backend.kind === 'tauri') { + await backend.store.delete(key); + return; + } + + // localStorage backend + localStorage.removeItem(LOCAL_STORAGE_PREFIX + key); } diff --git a/apps/staged/src/lib/transport.ts b/apps/staged/src/lib/transport.ts index 0f89441b8..e632825fe 100644 --- a/apps/staged/src/lib/transport.ts +++ b/apps/staged/src/lib/transport.ts @@ -6,7 +6,6 @@ * - Event listening (Tauri events vs WebSocket) * - Window management (Tauri window vs no-op) * - Clipboard (Tauri plugin vs navigator.clipboard) - * */ // --------------------------------------------------------------------------- @@ -38,6 +37,11 @@ export async function invokeCommand( body: JSON.stringify(args ?? {}), }); + if (response.status === 401) { + redirectToLogin(); + throw new Error('Authentication required'); + } + if (!response.ok) { const text = await response.text(); let message = text; @@ -55,6 +59,35 @@ export async function invokeCommand( return (await response.json()) as T; } +// --------------------------------------------------------------------------- +// Web authentication +// --------------------------------------------------------------------------- + +let loginRedirectPending = false; + +function redirectToLogin(): void { + if (loginRedirectPending) return; + loginRedirectPending = true; + // Use a small delay to batch multiple 401s that fire simultaneously + setTimeout(() => { + window.location.hash = '#/login'; + loginRedirectPending = false; + }, 50); +} + +/** + * Submit a bearer token to the web server's auth endpoint. + * On success the server sets a session cookie and subsequent requests are authenticated. + */ +export async function submitWebToken(token: string): Promise { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + return response.ok; +} + // --------------------------------------------------------------------------- // Event listening // --------------------------------------------------------------------------- @@ -66,10 +99,10 @@ export type UnlistenFn = () => void; * API; in web mode it connects to the shared WebSocket event stream. * * Returns a synchronous unlisten function. Registration happens asynchronously - * in the background; if the unlisten is called before registration finishes, - * the eventual listener is torn down on arrival. This makes the helper safe to - * use directly in `onMount` cleanup blocks without an intermediate - * `Promise` reference that could race the unmount. + * in the background for Tauri; if the unlisten is called before registration + * finishes, the eventual listener is torn down on arrival. This makes the + * helper safe to use directly in `onMount` cleanup blocks without an + * intermediate `Promise` reference that could race the unmount. */ export function listenToEvent(event: string, callback: (payload: T) => void): UnlistenFn { if (!isTauri) { @@ -250,6 +283,7 @@ interface WindowHandle { const noopWindow: WindowHandle = { show: async () => {}, close: async () => { + // In browser mode, just close the tab/window window.close(); }, startDragging: async () => {}, @@ -258,7 +292,7 @@ const noopWindow: WindowHandle = { /** * Get a handle to the current window. In Tauri mode this returns the real - * Tauri window; in web mode it returns a no-op implementation. + * Tauri window; in web mode it returns a no-op (or limited) implementation. */ export async function getWindow(): Promise { if (isTauri) { @@ -276,6 +310,7 @@ export async function getWindow(): Promise { export function getWindowSync(): WindowHandle { if (!isTauri) return noopWindow; + // Return a proxy that lazily imports the Tauri window API return { show: async () => { const { getCurrentWindow } = await import('@tauri-apps/api/window'); @@ -330,5 +365,6 @@ export async function onDragDropEvent( // eslint-disable-next-line @typescript-eslint/no-explicit-any return getCurrentWebview().onDragDropEvent(callback as any); } + // No-op in web mode — native file drag is a Tauri-only feature return () => {}; } diff --git a/apps/staged/vite.config.ts b/apps/staged/vite.config.ts index ae91f011f..1d4b3b25f 100644 --- a/apps/staged/vite.config.ts +++ b/apps/staged/vite.config.ts @@ -14,6 +14,24 @@ const serviceWorkerCacheHashLength = 12; const packageJson = JSON.parse( readFileSync(resolve(rootDir, 'package.json'), 'utf8') ) as { version: string }; +const webCertPath = process.env.STAGED_WEB_CERT_PATH; +const webKeyPath = process.env.STAGED_WEB_KEY_PATH; +const webHost = process.env.STAGED_WEB_HOST; + +function requireWebPath(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`${name} must be set to enable HTTPS web mode`); + } + return resolve(value); +} + +const webHttps = + webCertPath || webKeyPath + ? { + cert: readFileSync(requireWebPath('STAGED_WEB_CERT_PATH', webCertPath)), + key: readFileSync(requireWebPath('STAGED_WEB_KEY_PATH', webKeyPath)), + } + : undefined; type HashInput = { contents: string | Uint8Array; @@ -123,7 +141,19 @@ export default defineConfig({ }, }, server: { + // Network access (0.0.0.0) is enabled via `--host` in `just dev-web`. + // Default `dev` stays on localhost to avoid exposing the dev server. port, strictPort: true, + https: webHttps, + allowedHosts: webHost ? [webHost] : undefined, + proxy: { + '/api': { + target: `${webHttps ? 'https' : 'http'}://localhost:5175`, + changeOrigin: true, + secure: false, + ws: true, // WebSocket proxy for /api/events + }, + }, }, }); From 837f035f6455da78bfbba2153d5509433edc56af Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 22 Jun 2026 17:09:31 +1000 Subject: [PATCH 2/3] feat(staged): add resilient web resume cache Cherry-pick 6606433087b8048c09457523ec1923b8bf97ed01 to add the web resume cache and page lifecycle refresh behavior. Cache command results with stale-while-revalidate semantics, register lifecycle and invalidation listeners, and add tests covering the browser resume path. Signed-off-by: Matt Toohey --- apps/staged/src/lib/commands.test.ts | 143 +++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index 9db0ed4dd..94812113a 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -521,3 +521,146 @@ describe('cached mutation command wrappers', () => { }); }); }); + +describe('cached mutation command wrappers', () => { + function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; + } + + let invokeCommand: ReturnType; + let cachedCommand: ReturnType; + let invalidateCache: ReturnType; + let invalidateCacheByCommand: ReturnType; + + beforeEach(() => { + vi.resetModules(); + invokeCommand = vi.fn(); + cachedCommand = vi.fn(); + invalidateCache = vi.fn(); + invalidateCacheByCommand = vi.fn(); + + vi.doMock('./transport', () => ({ + isTauri: false, + invokeCommand, + })); + vi.doMock('./cache', () => ({ + cachedCommand, + cachedInvoke: vi.fn(), + invalidateCache, + invalidateCacheByCommand, + })); + }); + + afterEach(() => { + vi.doUnmock('./transport'); + vi.doUnmock('./cache'); + }); + + it('waits for repo list invalidation before resolving addProjectRepo', async () => { + const repo = { id: 'repo-1' }; + const invalidated = deferred(); + invokeCommand.mockResolvedValue(repo); + invalidateCache.mockReturnValue(invalidated.promise); + + const { addProjectRepo } = await import('./commands'); + + let settled = false; + const result = addProjectRepo('project-1', 'block/builderbot').then((value) => { + settled = true; + return value; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(invalidateCache).toHaveBeenCalledWith('list_project_repos', { projectId: 'project-1' }); + expect(settled).toBe(false); + + invalidated.resolve(); + + await expect(result).resolves.toBe(repo); + }); + + it('waits for all project cache invalidations before resolving deleteProject', async () => { + const projectsInvalidated = deferred(); + const branchesInvalidated = deferred(); + const reposInvalidated = deferred(); + invokeCommand.mockResolvedValue(undefined); + invalidateCacheByCommand + .mockReturnValueOnce(projectsInvalidated.promise) + .mockReturnValueOnce(branchesInvalidated.promise) + .mockReturnValueOnce(reposInvalidated.promise); + + const { deleteProject } = await import('./commands'); + + let settled = false; + const result = deleteProject('project-1').then(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(invalidateCacheByCommand.mock.calls).toEqual([ + ['list_projects'], + ['list_branches_for_project'], + ['list_project_repos'], + ]); + expect(settled).toBe(false); + + projectsInvalidated.resolve(); + branchesInvalidated.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + reposInvalidated.resolve(); + + await expect(result).resolves.toBeUndefined(); + }); + + it('bypasses the SWR cache when fetching fresh session messages', async () => { + const messages = [{ id: 1, sessionId: 'session-1', role: 'assistant', content: 'done' }]; + invokeCommand.mockResolvedValue(messages); + + const { getFreshSessionMessages } = await import('./commands'); + + await expect(getFreshSessionMessages('session-1')).resolves.toBe(messages); + expect(invokeCommand).toHaveBeenCalledWith('get_session_messages', { + sessionId: 'session-1', + }); + expect(cachedCommand).not.toHaveBeenCalled(); + }); + + it('uses the standard provider discovery cache by default', async () => { + const providers = [{ id: 'goose', label: 'Goose' }]; + cachedCommand.mockResolvedValue({ data: providers, revalidating: null }); + + const { discoverAcpProviders } = await import('./commands'); + + await expect(discoverAcpProviders()).resolves.toEqual({ + data: providers, + revalidating: null, + }); + expect(cachedCommand).toHaveBeenCalledWith('discover_acp_providers', undefined, { + ttl: 30 * 60_000, + }); + }); + + it('forces provider discovery revalidation without bypassing the cached value', async () => { + const providers = [{ id: 'goose', label: 'Goose' }]; + const revalidating = Promise.resolve([{ id: 'codex', label: 'Codex' }]); + cachedCommand.mockResolvedValue({ data: providers, revalidating }); + + const { discoverAcpProviders } = await import('./commands'); + + await expect(discoverAcpProviders({ force: true })).resolves.toEqual({ + data: providers, + revalidating, + }); + expect(cachedCommand).toHaveBeenCalledWith('discover_acp_providers', undefined, { ttl: 0 }); + }); +}); From 9c5f9091d448200ab18a6ff3d48446dd3b6e5d48 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 3 Jul 2026 19:08:44 +1000 Subject: [PATCH 3/3] fix(staged): expose web access token in web dispatch Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/web_server.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 44c834172..ce9fcbcb2 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -506,6 +506,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Ok(serde_json::to_value(&state.auth_token).unwrap()), "get_store_status" => { // We don't have DbState in web context — return null (store ready) Ok(Value::Null)