From d4f956c33163b1433c302fe837a722c8fbef2175 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 12 May 2026 14:38:13 +1000 Subject: [PATCH] feat(staged): add browser-accessible web mode Add a web server backend and HTTP/SSE transport layer so Staged can run in a browser without Tauri. The Rust side gains an Actix-based web server (web_server.rs) that mirrors every Tauri command as an HTTP endpoint and streams events over SSE. On the frontend, a transport abstraction replaces direct Tauri invoke/listen calls, choosing between Tauri IPC and HTTP/SSE at runtime. Key changes: - New Actix web server with session auth, SSE broadcasting, and REST endpoints for all existing commands - Transport layer (transport.ts) that abstracts Tauri vs HTTP calls - Updated all frontend commands and listeners to use the transport layer - Web login flow (WebLogin.svelte) for browser-based authentication - Persistent stores adapted for both localStorage and Tauri fs backends - Vite config and justfile updates for web development workflow 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 | 112 + apps/staged/src-tauri/Cargo.toml | 10 +- apps/staged/src-tauri/src/actions/commands.rs | 179 +- apps/staged/src-tauri/src/actions/events.rs | 16 +- apps/staged/src-tauri/src/branches.rs | 56 +- apps/staged/src-tauri/src/diff_commands.rs | 33 +- apps/staged/src-tauri/src/github_commands.rs | 17 +- apps/staged/src-tauri/src/image_commands.rs | 16 +- apps/staged/src-tauri/src/lib.rs | 75 +- apps/staged/src-tauri/src/project_mcp.rs | 26 +- apps/staged/src-tauri/src/prs.rs | 144 +- apps/staged/src-tauri/src/session_commands.rs | 29 +- apps/staged/src-tauri/src/session_runner.rs | 14 +- apps/staged/src-tauri/src/timeline.rs | 17 + apps/staged/src-tauri/src/web_server.rs | 3502 +++++++++++++++++ apps/staged/src/App.svelte | 54 +- apps/staged/src/lib/commands.test.ts | 63 + apps/staged/src/lib/commands.ts | 283 +- .../src/lib/features/actions/actions.ts | 44 +- .../lib/features/branches/BranchCard.svelte | 39 +- .../branches/BranchCardActionsBar.svelte | 6 +- .../branches/BranchCardPrButton.svelte | 46 +- .../src/lib/features/branches/branch.ts | 18 +- .../src/lib/features/branches/dragDrop.ts | 9 +- .../diff/DiffCommitSessionLauncher.svelte | 6 +- .../src/lib/features/diff/DiffModal.svelte | 4 +- .../src/lib/features/layout/TopBar.svelte | 4 +- .../src/lib/features/layout/WebLogin.svelte | 137 + .../lib/features/projects/ProjectHome.svelte | 11 +- .../features/projects/ProjectSection.svelte | 8 +- .../lib/features/projects/ProjectsList.svelte | 5 +- .../features/sessions/PipelineSteps.svelte | 8 +- .../features/sessions/SessionLauncher.svelte | 33 +- .../lib/features/settings/SettingsPage.svelte | 81 +- .../lib/listeners/sessionStatusListener.ts | 6 +- apps/staged/src/lib/shared/persistentStore.ts | 92 +- .../lib/stores/projectRunActions.svelte.ts | 2 +- .../src/lib/stores/projectState.svelte.ts | 8 +- apps/staged/src/lib/transport.ts | 305 ++ apps/staged/vite.config.ts | 30 + 42 files changed, 5120 insertions(+), 458 deletions(-) create mode 100644 apps/staged/src-tauri/src/web_server.rs create mode 100644 apps/staged/src/lib/commands.test.ts create mode 100644 apps/staged/src/lib/features/layout/WebLogin.svelte create mode 100644 apps/staged/src/lib/transport.ts 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 8f6b002bf..2916ad0db 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 a01ac9268..c0097b0dc 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -10,24 +10,29 @@ dependencies = [ "anyhow", "async-trait", "axum", + "axum-extra", "base64 0.22.1", "blox-cli", "builderbot-actions", "dirs", "doctor", "git-diff", + "hex", "include_dir", "libc", "log", + "rand 0.9.4", "regex", "reqwest", "rmcp", "rusqlite", "rusqlite_migration", + "rustls", "serde", "serde_json", "sha2 0.11.0", "strip-ansi-escapes", + "subtle", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -40,8 +45,11 @@ dependencies = [ "tauri-plugin-window-state", "tempfile", "thiserror 2.0.18", + "time", "tokio", + "tokio-rustls", "tokio-util", + "tower-http", "uuid", ] @@ -396,6 +404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -414,8 +423,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -441,6 +452,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -879,6 +913,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] @@ -1084,6 +1119,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.8" @@ -2195,6 +2236,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -2887,6 +2934,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minisign-verify" version = "0.2.5" @@ -4774,6 +4831,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.10.9" @@ -5787,6 +5855,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5930,14 +6010,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.11.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6023,6 +6113,22 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "typeid" version = "1.0.3" @@ -6087,6 +6193,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 7e0dfe22d..40d359abb 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -63,7 +63,15 @@ tauri-plugin-store = "2.4.2" # MCP server for project sessions rmcp = { version = "0.17", features = ["server", "transport-streamable-http-server"] } -axum = { version = "0.8" } +axum = { version = "0.8", features = ["ws"] } +axum-extra = { version = "0.10", features = ["cookie"] } +tower-http = { version = "0.6", features = ["fs", "cors"] } +rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } +rand = "0.9" +hex = "0.4" +subtle = "2.6" +time = "0.3" # Debug binaries archived — uncomment when needed # [[bin]] diff --git a/apps/staged/src-tauri/src/actions/commands.rs b/apps/staged/src-tauri/src/actions/commands.rs index 19d6ab33e..d193ecd0f 100644 --- a/apps/staged/src-tauri/src/actions/commands.rs +++ b/apps/staged/src-tauri/src/actions/commands.rs @@ -6,7 +6,7 @@ use builderbot_actions::{ RunDetectionMode, StopOptions, SuggestedAction, }; use std::sync::{Arc, Mutex}; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; use tokio::sync::watch; use crate::store::Store; @@ -104,15 +104,12 @@ fn resolve_branch_repo_context( Ok((repo.to_string(), project.subpath.clone())) } -/// Detect available actions for a specific repo+subpath context using AI. -#[tauri::command(rename_all = "camelCase")] -pub async fn detect_repo_actions( +pub(crate) async fn detect_repo_actions_impl( github_repo: String, subpath: Option, app: AppHandle, - store: State<'_, Mutex>>>, + store: Arc, ) -> Result, String> { - let store = get_store(&store)?; let context = store .get_or_create_action_context(&github_repo, subpath.as_deref()) .map_err(|e| format!("Failed to get action context: {e}"))?; @@ -122,7 +119,8 @@ pub async fn detect_repo_actions( store .set_action_context_detecting(&context.id, true) .map_err(|e| format!("Failed to set detection status: {e}"))?; - let _ = app.emit( + crate::web_server::emit_to_all( + &app, "repo-actions-detection", DetectingActionsEvent { github_repo: github_repo.clone(), @@ -136,7 +134,8 @@ pub async fn detect_repo_actions( store .mark_action_context_detected(&context.id) .map_err(|e| format!("Failed to update detection status: {e}"))?; - let _ = app.emit( + crate::web_server::emit_to_all( + &app, "repo-actions-detection", DetectingActionsEvent { github_repo, @@ -147,18 +146,26 @@ pub async fn detect_repo_actions( result } -/// Run an action for a branch -#[tauri::command] -pub async fn run_branch_action( - branch_id: String, - action_id: String, +/// Detect available actions for a specific repo+subpath context using AI. +#[tauri::command(rename_all = "camelCase")] +pub async fn detect_repo_actions( + github_repo: String, + subpath: Option, app: AppHandle, store: State<'_, Mutex>>>, - executor: State<'_, Arc>, - registry: State<'_, Arc>, -) -> Result { +) -> Result, String> { let store = get_store(&store)?; + detect_repo_actions_impl(github_repo, subpath, app, store).await +} +pub(crate) async fn run_branch_action_impl( + branch_id: String, + action_id: String, + app: AppHandle, + store: Arc, + executor: Arc, + registry: Arc, +) -> Result { // Get the action let action = store .get_repo_action(&action_id) @@ -186,7 +193,7 @@ pub async fn run_branch_action( let is_remote = branch.branch_type == crate::store::BranchType::Remote; - let reg = registry.inner().clone(); + let reg = Arc::clone(®istry); // Create event listener let listener = Arc::new(TauriExecutionListener::new( @@ -411,17 +418,46 @@ pub async fn run_branch_action( Ok(execution_id) } -/// Stop a running action +/// Run an action for a branch #[tauri::command] -pub fn stop_branch_action( - execution_id: String, +pub async fn run_branch_action( + branch_id: String, + action_id: String, + app: AppHandle, + store: State<'_, Mutex>>>, executor: State<'_, Arc>, + registry: State<'_, Arc>, +) -> Result { + let store = get_store(&store)?; + run_branch_action_impl( + branch_id, + action_id, + app, + store, + executor.inner().clone(), + registry.inner().clone(), + ) + .await +} + +pub(crate) fn stop_branch_action_impl( + execution_id: String, + executor: &ActionExecutor, ) -> Result<(), String> { executor .stop(&execution_id) .map_err(|e| format!("Failed to stop action: {e}")) } +/// Stop a running action +#[tauri::command] +pub fn stop_branch_action( + execution_id: String, + executor: State<'_, Arc>, +) -> Result<(), String> { + stop_branch_action_impl(execution_id, &executor) +} + /// Stop all running actions for the given branch IDs (best-effort). pub fn stop_actions_for_branches( executor: &ActionExecutor, @@ -460,12 +496,10 @@ pub fn stop_all_actions( stopped_execution_ids } -/// Get all currently running actions for a branch -#[tauri::command] -pub fn get_running_branch_actions( +pub(crate) fn get_running_branch_actions_impl( branch_id: String, - executor: State<'_, Arc>, - registry: State<'_, Arc>, + executor: &ActionExecutor, + registry: &ActionRegistry, ) -> Result, String> { // Get running actions from registry for this branch let running_actions = registry.get_running_for_branch(&branch_id); @@ -482,13 +516,37 @@ pub fn get_running_branch_actions( Ok(active_actions) } +/// Get all currently running actions for a branch +#[tauri::command] +pub fn get_running_branch_actions( + branch_id: String, + executor: State<'_, Arc>, + registry: State<'_, Arc>, +) -> Result, String> { + get_running_branch_actions_impl(branch_id, &executor, ®istry) +} + +pub(crate) fn get_action_output_buffer_impl( + execution_id: String, + executor: &ActionExecutor, +) -> Result>, String> { + Ok(executor.get_buffered_output(&execution_id)) +} + /// Get buffered output for an action execution #[tauri::command] pub fn get_action_output_buffer( execution_id: String, executor: State<'_, Arc>, ) -> Result>, String> { - Ok(executor.get_buffered_output(&execution_id)) + get_action_output_buffer_impl(execution_id, &executor) +} + +pub(crate) fn clear_action_execution_impl( + execution_id: String, + executor: &ActionExecutor, +) -> Result { + Ok(executor.clear_execution(&execution_id)) } /// Clear buffered output for a completed execution @@ -497,20 +555,16 @@ pub fn clear_action_execution( execution_id: String, executor: State<'_, Arc>, ) -> Result { - Ok(executor.clear_execution(&execution_id)) + clear_action_execution_impl(execution_id, &executor) } -/// Run all prerun actions for a branch after creation -#[tauri::command] -pub async fn run_prerun_actions( +pub(crate) async fn run_prerun_actions_impl( branch_id: String, app: AppHandle, - store: State<'_, Mutex>>>, - executor: State<'_, Arc>, - registry: State<'_, Arc>, + store: Arc, + executor: Arc, + registry: Arc, ) -> Result, String> { - let store = get_store(&store)?; - // Get the branch and project (for repo context + subpath) let branch = store .get_branch(&branch_id) @@ -532,7 +586,8 @@ pub async fn run_prerun_actions( store .set_action_context_detecting(&context.id, true) .map_err(|e| format!("Failed to set detection status: {e}"))?; - let _ = app.emit( + crate::web_server::emit_to_all( + &app, "repo-actions-detection", DetectingActionsEvent { github_repo: github_repo.clone(), @@ -588,7 +643,8 @@ pub async fn run_prerun_actions( store .mark_action_context_detected(&context.id) .map_err(|e| format!("Failed to update detection status: {e}"))?; - let _ = app.emit( + crate::web_server::emit_to_all( + &app, "repo-actions-detection", DetectingActionsEvent { github_repo: github_repo.clone(), @@ -632,7 +688,7 @@ pub async fn run_prerun_actions( action.id.clone(), action.name.clone(), action.action_type.as_str().to_string(), - registry.inner().clone(), + Arc::clone(®istry), )); let metadata = ActionMetadata { @@ -652,27 +708,51 @@ pub async fn run_prerun_actions( Ok(execution_ids) } +/// Run all prerun actions for a branch after creation +#[tauri::command] +pub async fn run_prerun_actions( + branch_id: String, + app: AppHandle, + store: State<'_, Mutex>>>, + executor: State<'_, Arc>, + registry: State<'_, Arc>, +) -> Result, String> { + let store = get_store(&store)?; + run_prerun_actions_impl( + branch_id, + app, + store, + executor.inner().clone(), + registry.inner().clone(), + ) + .await +} + // ============================================================================= // Run detection commands // ============================================================================= +pub(crate) fn get_run_phase_impl( + registry: &ActionRegistry, + execution_id: String, +) -> Result, String> { + Ok(registry.get_run_phase(&execution_id)) +} + /// Get the current run phase for an execution. #[tauri::command] pub async fn get_run_phase( registry: State<'_, Arc>, execution_id: String, ) -> Result, String> { - Ok(registry.get_run_phase(&execution_id)) + get_run_phase_impl(®istry, execution_id) } -/// Update the run detection mode for a repo action. -#[tauri::command] -pub async fn update_run_detection_mode( - store: State<'_, Mutex>>>, +pub(crate) fn update_run_detection_mode_impl( + store: Arc, action_id: String, mode: RunDetectionMode, ) -> Result<(), String> { - let store = get_store(&store)?; let mut action = store .get_repo_action(&action_id) .map_err(|e| e.to_string())? @@ -683,3 +763,14 @@ pub async fn update_run_detection_mode( .map_err(|e| e.to_string())?; Ok(()) } + +/// Update the run detection mode for a repo action. +#[tauri::command] +pub async fn update_run_detection_mode( + store: State<'_, Mutex>>>, + action_id: String, + mode: RunDetectionMode, +) -> Result<(), String> { + let store = get_store(&store)?; + update_run_detection_mode_impl(store, action_id, mode) +} diff --git a/apps/staged/src-tauri/src/actions/events.rs b/apps/staged/src-tauri/src/actions/events.rs index 3a282e263..4f0a4e1f1 100644 --- a/apps/staged/src-tauri/src/actions/events.rs +++ b/apps/staged/src-tauri/src/actions/events.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use builderbot_actions::{ExecutionEvent, ExecutionListener}; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tauri::{AppHandle, Emitter}; +use tauri::AppHandle; use super::registry::{ActionRegistry, RunPhase}; @@ -83,7 +83,8 @@ impl ExecutionListener for TauriExecutionListener { ); // We emit running status immediately - let _ = self.app.emit( + crate::web_server::emit_to_all( + &self.app, "action_status", ActionStatusEvent { execution_id: execution_id.clone(), @@ -109,7 +110,8 @@ impl ExecutionListener for TauriExecutionListener { // receives the original chunk for live display. self.registry.append_output_chunk(&execution_id, &chunk); - let _ = self.app.emit( + crate::web_server::emit_to_all( + &self.app, "action_output", ActionOutputEvent { execution_id, @@ -137,7 +139,8 @@ impl ExecutionListener for TauriExecutionListener { self.registry.unregister(&execution_id); } - let _ = self.app.emit( + crate::web_server::emit_to_all( + &self.app, "action_status", ActionStatusEvent { execution_id, @@ -156,7 +159,8 @@ impl ExecutionListener for TauriExecutionListener { execution_id, action_name, } => { - let _ = self.app.emit( + crate::web_server::emit_to_all( + &self.app, "action_auto_commit", serde_json::json!({ "executionId": execution_id, @@ -185,5 +189,5 @@ pub struct RunPhaseChangedEvent { /// Emit an `action:run-phase-changed` event to the frontend. pub fn emit_run_phase_changed(app_handle: &AppHandle, event: RunPhaseChangedEvent) { - let _ = app_handle.emit("action:run-phase-changed", event); + crate::web_server::emit_to_all(app_handle, "action:run-phase-changed", event); } diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index 2ef9f8773..625f063c2 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Instant; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Manager}; use crate::actions::events::TauriExecutionListener; use crate::actions::{ActionExecutor, ActionMetadata, ActionRegistry, ActionType}; @@ -21,17 +21,17 @@ pub(crate) struct WorktreeSetupProgress { } /// Default idle timeout (in minutes) for Staged workstations. -const WORKSPACE_IDLE_TIMEOUT_MINUTES: u32 = 10080; +pub(crate) const WORKSPACE_IDLE_TIMEOUT_MINUTES: u32 = 10080; // In-memory cache: workspace name → numeric workstation ID. // Populated by `poll_workspace_status` and `start_workspace` when `blox ws info` // returns an ID; read by `to_branch_with_workdir` when serializing for the frontend. -fn workstation_id_cache() -> &'static Mutex> { +pub(crate) fn workstation_id_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } -fn cached_workstation_id(workspace_name: &str) -> Option { +pub(crate) fn cached_workstation_id(workspace_name: &str) -> Option { workstation_id_cache() .lock() .ok() @@ -62,7 +62,15 @@ fn get_store(store: &tauri::State<'_, Mutex>>>) -> Result, +) -> BranchWithWorkdir { + to_branch_with_workdir(branch, workdir_path) +} + +pub(crate) fn to_branch_with_workdir( branch: store::Branch, workdir_path: Option, ) -> BranchWithWorkdir { @@ -250,7 +258,7 @@ pub(crate) fn run_workspace_git_bytes( blox::ws_exec_bytes(workspace_name, &borrowed) } -async fn run_blox_blocking(op: F) -> Result +pub(crate) async fn run_blox_blocking(op: F) -> Result where T: Send + 'static, F: FnOnce() -> Result + Send + 'static, @@ -260,7 +268,10 @@ where .map_err(|e| blox::BloxError::CommandFailed(format!("blox task failed: {e}")))? } -async fn ws_exec_async(workspace_name: &str, args: &[&str]) -> Result { +pub(crate) async fn ws_exec_async( + workspace_name: &str, + args: &[&str], +) -> Result { let ws_name = workspace_name.to_string(); let owned_args = args .iter() @@ -273,7 +284,7 @@ async fn ws_exec_async(workspace_name: &str, args: &[&str]) -> Result, git_args: &[&str], @@ -356,7 +367,7 @@ pub(crate) fn resolve_branch_workspace_subpath( Ok(Some(workspace_path)) } -fn normalize_branch_ref(branch: &str) -> String { +pub(crate) fn normalize_branch_ref(branch: &str) -> String { branch.strip_prefix("origin/").unwrap_or(branch).to_string() } @@ -366,7 +377,7 @@ fn normalize_branch_ref(branch: &str) -> String { /// This is used both by `start_workspace` (secondary repo in a shared /// workspace) and by `add_project_repo` (adding a repo to a remote project /// whose workspace is already running). -async fn clone_repo_into_workspace( +pub(crate) async fn clone_repo_into_workspace( ws_name: &str, repo_subpath: &str, repo_slug: &str, @@ -661,7 +672,7 @@ fn create_worktree_with_fallback( } } -fn is_blox_onboarding_precondition_error(err: &blox::BloxError) -> bool { +pub(crate) fn is_blox_onboarding_precondition_error(err: &blox::BloxError) -> bool { match err { blox::BloxError::CommandFailed(stderr) => { let lower = stderr.to_ascii_lowercase(); @@ -905,7 +916,7 @@ pub fn create_branch( /// and links the workdir in the database. /// /// Returns the worktree path as a string. -fn create_and_link_worktree( +pub(crate) fn create_and_link_worktree( store: &Arc, branch: &store::Branch, repo_path: &std::path::Path, @@ -1578,7 +1589,7 @@ pub async fn get_workspace_info( /// During initial startup, Blox may briefly report "stopped" before the /// workspace transitions to "running". If the DB still says Starting, /// treat a Blox "stopped" as still Starting so we keep polling. -fn map_blox_status_to_workspace_status( +pub(crate) fn map_blox_status_to_workspace_status( blox_status: Option<&str>, db_status: Option<&store::WorkspaceStatus>, ) -> store::WorkspaceStatus { @@ -1797,7 +1808,7 @@ pub async fn poll_workspace_status( /// 2 = COMMAND_TYPE_EXECUTE_PROCESS /// 3 = COMMAND_TYPE_PROJECT_BOOTSTRAP /// 4 = COMMAND_TYPE_PROVISION_WORKSPACE -fn bootstrap_command_type_name(command_type: u32) -> &'static str { +pub(crate) fn bootstrap_command_type_name(command_type: u32) -> &'static str { match command_type { 1 => "checkout", 2 => "execute_process", @@ -1809,7 +1820,7 @@ fn bootstrap_command_type_name(command_type: u32) -> &'static str { /// Derive workspace setup progress from bootstrap commands and emit events /// for all branches sharing the workspace. -fn emit_workspace_setup_progress( +pub(crate) fn emit_workspace_setup_progress( app_handle: &AppHandle, branch_ids: &[String], commands: &[blox::WorkspaceCommand], @@ -1842,7 +1853,8 @@ fn emit_workspace_setup_progress( let detail = Some(format!("Step {} of {}", completed + 1, total)); for bid in branch_ids { - let _ = app_handle.emit( + crate::web_server::emit_to_all( + app_handle, "workspace-setup-progress", WorktreeSetupProgress { branch_id: bid.clone(), @@ -2247,7 +2259,8 @@ pub(crate) fn setup_worktree_sync( ) -> Result { let emit_progress = |phase: &str, detail: Option| { if let Some(handle) = app_handle { - let _ = handle.emit( + crate::web_server::emit_to_all( + handle, "worktree-setup-progress", WorktreeSetupProgress { branch_id: branch_id.to_string(), @@ -2289,7 +2302,8 @@ pub(crate) fn setup_worktree_sync( if now.duration_since(last_emit) >= std::time::Duration::from_millis(250) { last_emit = now; let detail = format!("{phase_name} \u{2014} {pct}%"); - let _ = handle.emit( + crate::web_server::emit_to_all( + &handle, "worktree-setup-progress", WorktreeSetupProgress { branch_id: bid.clone(), @@ -2368,7 +2382,8 @@ pub(crate) async fn run_prerun_actions_for_branch( .set_action_context_detecting(&context.id, true) .map_err(|e| format!("Failed to set detection status: {e}"))?; - let _ = app_handle.emit( + crate::web_server::emit_to_all( + app_handle, "repo-actions-detection", serde_json::json!({ "githubRepo": github_repo, @@ -2431,7 +2446,8 @@ pub(crate) async fn run_prerun_actions_for_branch( .mark_action_context_detected(&context.id) .map_err(|e| format!("Failed to update detection status: {e}"))?; - let _ = app_handle.emit( + crate::web_server::emit_to_all( + app_handle, "repo-actions-detection", serde_json::json!({ "githubRepo": github_repo, diff --git a/apps/staged/src-tauri/src/diff_commands.rs b/apps/staged/src-tauri/src/diff_commands.rs index 0a6a2c43f..a0ee7b088 100644 --- a/apps/staged/src-tauri/src/diff_commands.rs +++ b/apps/staged/src-tauri/src/diff_commands.rs @@ -93,6 +93,7 @@ fn run_remote_git_bytes(ctx: &BranchDiffContext, args: &[&str]) -> Result, scope: String, +) -> Result { + let store = crate::get_store(&store)?; + get_diff_files_impl(store, branch_id, commit_sha, scope) +} + +pub(crate) fn get_diff_files_impl( + store: Arc, + branch_id: String, + commit_sha: Option, + scope: String, ) -> Result { let start = std::time::Instant::now(); log::info!("get_diff_files: branch_id={branch_id} scope={scope} commit_sha={commit_sha:?}"); - let store = crate::get_store(&store)?; let ctx = resolve_branch_context(&store, &branch_id)?; if let Some(worktree_path) = ctx.worktree_path.as_deref() { let worktree = Path::new(worktree_path); @@ -592,10 +602,20 @@ pub async fn get_file_diff( commit_sha: String, scope: String, path: String, +) -> Result { + let store = crate::get_store(&store)?; + get_file_diff_impl(store, branch_id, commit_sha, scope, path) +} + +pub(crate) fn get_file_diff_impl( + store: Arc, + branch_id: String, + commit_sha: String, + scope: String, + path: String, ) -> Result { let start = std::time::Instant::now(); log::info!("get_file_diff: path={path} scope={scope}"); - let store = crate::get_store(&store)?; let ctx = resolve_branch_context(&store, &branch_id)?; if let Some(worktree_path) = ctx.worktree_path.as_deref() { let worktree = Path::new(worktree_path); @@ -686,6 +706,15 @@ pub async fn get_file_at_ref( path: String, ) -> Result { let store = crate::get_store(&store)?; + get_file_at_ref_impl(store, branch_id, ref_name, path) +} + +pub(crate) fn get_file_at_ref_impl( + store: Arc, + branch_id: String, + ref_name: String, + path: String, +) -> Result { let ctx = resolve_branch_context(&store, &branch_id)?; if let Some(worktree_path) = ctx.worktree_path.as_deref() { let worktree = Path::new(worktree_path); diff --git a/apps/staged/src-tauri/src/github_commands.rs b/apps/staged/src-tauri/src/github_commands.rs index 429f94a22..285b8f686 100644 --- a/apps/staged/src-tauri/src/github_commands.rs +++ b/apps/staged/src-tauri/src/github_commands.rs @@ -193,14 +193,12 @@ pub async fn list_issues(github_repo: String) -> Result, /// posted), the existing GitHub comment is updated. Otherwise a new comment /// is created. The resulting GitHub comment ID is persisted in the local DB /// so subsequent edits can update in-place. -#[tauri::command(rename_all = "camelCase")] -pub async fn post_comment_to_github( - store: tauri::State<'_, Mutex>>>, +pub(crate) async fn post_comment_to_github_impl( + store: Arc, branch_id: String, pr_number: u64, comment: store::Comment, ) -> Result { - let store = crate::get_store(&store)?; let branch = store .get_branch(&branch_id) .map_err(|e| e.to_string())? @@ -257,6 +255,17 @@ pub async fn post_comment_to_github( Ok(result) } +#[tauri::command(rename_all = "camelCase")] +pub async fn post_comment_to_github( + store: tauri::State<'_, Mutex>>>, + branch_id: String, + pr_number: u64, + comment: store::Comment, +) -> Result { + let store = crate::get_store(&store)?; + post_comment_to_github_impl(store, branch_id, pr_number, comment).await +} + async fn current_branch_head_sha( store: &Arc, branch: &store::Branch, diff --git a/apps/staged/src-tauri/src/image_commands.rs b/apps/staged/src-tauri/src/image_commands.rs index 4d2347632..d8300cb76 100644 --- a/apps/staged/src-tauri/src/image_commands.rs +++ b/apps/staged/src-tauri/src/image_commands.rs @@ -187,6 +187,21 @@ pub fn create_image_from_data( mime_type: String, data: String, pending: Option, +) -> Result { + let store = crate::get_store(&store)?; + create_image_from_data_impl( + store, branch_id, project_id, filename, mime_type, data, pending, + ) +} + +pub(crate) fn create_image_from_data_impl( + store: Arc, + branch_id: Option, + project_id: String, + filename: String, + mime_type: String, + data: String, + pending: Option, ) -> Result { use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD @@ -212,7 +227,6 @@ pub fn create_image_from_data( return Err(format!("Unsupported image format: .{ext}")); } - let store = crate::get_store(&store)?; const ALLOWED_MIME_TYPES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; let mime = if mime_type.is_empty() { mime_type_for_extension(&ext).to_string() diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 209da5f87..f002ffc8c 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ pub mod store; pub(crate) mod terminal_output; pub mod timeline; pub mod util_commands; +pub mod web_server; #[cfg(test)] pub mod test_utils; @@ -50,6 +51,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, @@ -214,7 +219,8 @@ fn emit_setup_progress( phase: &str, detail: Option, ) { - let _ = handle.emit( + web_server::emit_to_all( + handle, "worktree-setup-progress", branches::WorktreeSetupProgress { branch_id: branch_id.to_string(), @@ -251,6 +257,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 { @@ -470,7 +482,7 @@ fn create_project( let project_id = project.id.clone(); let store_bg = Arc::clone(&store); tauri::async_runtime::spawn(async move { - let _ = app_handle.emit("project-setup-progress", project_id.clone()); + web_server::emit_to_all(&app_handle, "project-setup-progress", project_id.clone()); let store_clone = Arc::clone(&store_bg); let branch_id_clone = branch_id.clone(); @@ -487,7 +499,11 @@ fn create_project( let worktree_path = match worktree_result { Ok(Ok(path)) => { log::info!("[create_project] worktree ready at {path}"); - let _ = app_handle.emit("project-setup-progress", project_id.clone()); + web_server::emit_to_all( + &app_handle, + "project-setup-progress", + project_id.clone(), + ); path } Ok(Err(e)) => { @@ -518,7 +534,11 @@ fn create_project( { Ok(count) => { log::info!("[create_project] ran {count} prerun actions"); - let _ = app_handle.emit("project-setup-progress", project_id); + web_server::emit_to_all( + &app_handle, + "project-setup-progress", + project_id, + ); } Err(e) => { log::warn!("[create_project] prerun actions failed: {e}"); @@ -634,7 +654,7 @@ async fn add_project_repo( tauri::async_runtime::spawn({ let repo_id = repo.id.clone(); async move { - let _ = app_handle.emit("project-setup-progress", project_id.clone()); + web_server::emit_to_all(&app_handle, "project-setup-progress", project_id.clone()); let branch = match store.list_branches_for_project(&project_id) { Ok(branches) => branches @@ -666,7 +686,11 @@ async fn add_project_repo( let worktree_path = match worktree_result { Ok(Ok(path)) => { log::info!("[add_project_repo] worktree ready at {path}"); - let _ = app_handle.emit("project-setup-progress", project_id.clone()); + web_server::emit_to_all( + &app_handle, + "project-setup-progress", + project_id.clone(), + ); path } Ok(Err(e)) => { @@ -696,8 +720,11 @@ async fn add_project_repo( { Ok(count) => { log::info!("[add_project_repo] ran {count} prerun actions"); - let _ = - app_handle.emit("project-setup-progress", project_id.clone()); + web_server::emit_to_all( + &app_handle, + "project-setup-progress", + project_id.clone(), + ); } Err(e) => { log::warn!("[add_project_repo] prerun actions failed: {e}"); @@ -739,11 +766,11 @@ async fn add_project_repo( "[add_project_repo] remote repo clone failed for branch '{}': {e}", branch.branch_name ); - let _ = app_handle.emit("project-setup-progress", project_id); + web_server::emit_to_all(&app_handle, "project-setup-progress", project_id); return; } } - let _ = app_handle.emit("project-setup-progress", project_id); + web_server::emit_to_all(&app_handle, "project-setup-progress", project_id); // If the repo already has commits on this branch, kick off // an automatic code review so the user gets immediate feedback. @@ -1721,6 +1748,33 @@ pub fn run() { needs_reset: Mutex::new(reset_info), }); + // Create the broadcast channel for web event streaming and manage it + // so the web server and event emitters can access it. + let (event_tx, _) = tokio::sync::broadcast::channel::(256); + app.manage(event_tx.clone()); + + // 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, + auth_token, + sessions: std::sync::Arc::new(std::sync::Mutex::new( + std::collections::HashSet::new(), + )), + }); + } else { + app.manage(WebAccessToken(String::new())); + } + if cfg!(debug_assertions) { app.handle().plugin( tauri_plugin_log::Builder::default() @@ -1749,6 +1803,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/project_mcp.rs b/apps/staged/src-tauri/src/project_mcp.rs index 52dc1ebbc..4518116a7 100644 --- a/apps/staged/src-tauri/src/project_mcp.rs +++ b/apps/staged/src-tauri/src/project_mcp.rs @@ -13,7 +13,7 @@ use rmcp::transport::streamable_http_server::{ session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, }; use rmcp::{schemars, tool, tool_handler, tool_router, ServerHandler}; -use tauri::{AppHandle, Emitter}; +use tauri::AppHandle; use crate::actions::{ActionExecutor, ActionRegistry}; use crate::session_runner::SessionRegistry; @@ -626,9 +626,11 @@ impl ProjectToolsHandler { }; // Notify the UI so the repo appears immediately - let _ = self - .app_handle - .emit("project-setup-progress", self.project_id.clone()); + crate::web_server::emit_to_all( + &self.app_handle, + "project-setup-progress", + self.project_id.clone(), + ); // Find the branch that was just created for this repo let branch = match self.store.list_branches_for_project(&self.project_id) { @@ -669,9 +671,11 @@ impl ProjectToolsHandler { Ok(Ok(path)) => { log::debug!("[project_mcp] add_project_repo: worktree ready at {}", path); // Notify UI that the worktree is ready so branch state updates - let _ = self - .app_handle - .emit("project-setup-progress", self.project_id.clone()); + crate::web_server::emit_to_all( + &self.app_handle, + "project-setup-progress", + self.project_id.clone(), + ); path } Ok(Err(e)) => { @@ -719,9 +723,11 @@ impl ProjectToolsHandler { branch.id ); // Notify UI that prerun actions finished - let _ = self - .app_handle - .emit("project-setup-progress", self.project_id.clone()); + crate::web_server::emit_to_all( + &self.app_handle, + "project-setup-progress", + self.project_id.clone(), + ); } Err(e) => { log::warn!( diff --git a/apps/staged/src-tauri/src/prs.rs b/apps/staged/src-tauri/src/prs.rs index 2d09292df..f81941047 100644 --- a/apps/staged/src-tauri/src/prs.rs +++ b/apps/staged/src-tauri/src/prs.rs @@ -1,7 +1,6 @@ use serde::Serialize; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use tauri::Emitter; use crate::git; use crate::session_runner; @@ -15,7 +14,7 @@ fn get_store(store: &tauri::State<'_, Mutex>>>) -> Result, project: &store::Project, branch: &store::Branch, @@ -34,16 +33,16 @@ fn resolve_branch_repo_and_subpath( #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] -struct PrStatusEvent { - branch_id: String, - pr_state: String, - pr_checks_status: String, - pr_review_decision: Option, - pr_mergeable: bool, - pr_draft: bool, - pr_head_sha: Option, - pr_fetched_at: i64, - failed_checks: Vec, +pub(crate) struct PrStatusEvent { + pub(crate) branch_id: String, + pub(crate) pr_state: String, + pub(crate) pr_checks_status: String, + pub(crate) pr_review_decision: Option, + pub(crate) pr_mergeable: bool, + pub(crate) pr_draft: bool, + pub(crate) pr_head_sha: Option, + pub(crate) pr_fetched_at: i64, + pub(crate) failed_checks: Vec, } // ============================================================================= @@ -346,7 +345,7 @@ async fn start_running_commit_pipeline_for_branch( } #[allow(clippy::too_many_arguments)] -async fn start_or_queue_commit_pipeline_for_branch( +pub(crate) async fn start_or_queue_commit_pipeline_for_branch( store: Arc, registry: Arc, app_handle: tauri::AppHandle, @@ -572,16 +571,14 @@ fn build_create_pr_pipeline_steps( // ============================================================================= /// Create a pull request for a branch by kicking off an agent session. -#[tauri::command(rename_all = "camelCase")] -pub async fn create_pr( - store: tauri::State<'_, Mutex>>>, - registry: tauri::State<'_, Arc>, +pub(crate) async fn start_create_pr_pipeline_for_branch( + store: Arc, + registry: Arc, app_handle: tauri::AppHandle, branch_id: String, provider: Option, draft: Option, ) -> Result { - let store = get_store(&store)?; let ctx = resolve_branch_pipeline_context(&store, &branch_id)?; let base_branch = base_branch_name(&ctx.branch); @@ -611,6 +608,27 @@ pub async fn create_pr( ) } +#[tauri::command(rename_all = "camelCase")] +pub async fn create_pr( + store: tauri::State<'_, Mutex>>>, + registry: tauri::State<'_, Arc>, + app_handle: tauri::AppHandle, + branch_id: String, + provider: Option, + draft: Option, +) -> Result { + let store = get_store(&store)?; + start_create_pr_pipeline_for_branch( + store, + Arc::clone(®istry), + app_handle, + branch_id, + provider, + draft, + ) + .await +} + /// Build the GitHub PR URL for a branch. /// /// For fork PRs the stored repo may be the fork (head) repo, but PRs always @@ -716,22 +734,21 @@ pub async fn refresh_pr_status( ) .map_err(|e| e.to_string())?; - app_handle - .emit( - "pr-status-changed", - PrStatusEvent { - branch_id: branch_id.clone(), - pr_state: pr_status.state, - pr_checks_status: pr_status.checks_summary.state, - pr_review_decision: pr_status.review_decision, - pr_mergeable: mergeable, - pr_draft: pr_status.is_draft, - pr_head_sha: pr_status.head_sha, - pr_fetched_at, - failed_checks: pr_status.failed_checks, - }, - ) - .map_err(|e| format!("Failed to emit event: {}", e))?; + crate::web_server::emit_to_all( + &app_handle, + "pr-status-changed", + PrStatusEvent { + branch_id: branch_id.clone(), + pr_state: pr_status.state, + pr_checks_status: pr_status.checks_summary.state, + pr_review_decision: pr_status.review_decision, + pr_mergeable: mergeable, + pr_draft: pr_status.is_draft, + pr_head_sha: pr_status.head_sha, + pr_fetched_at, + failed_checks: pr_status.failed_checks, + }, + ); Ok(()) } @@ -803,7 +820,8 @@ pub async fn refresh_all_pr_statuses( refreshed_count += 1; - if let Err(e) = app_handle.emit( + crate::web_server::emit_to_all( + &app_handle, "pr-status-changed", PrStatusEvent { branch_id: branch.id.clone(), @@ -816,9 +834,7 @@ pub async fn refresh_all_pr_statuses( pr_fetched_at, failed_checks: pr_status.failed_checks, }, - ) { - log::warn!("Failed to emit pr-status-changed event: {}", e); - } + ); } Err(e) => { log::warn!( @@ -831,9 +847,7 @@ pub async fn refresh_all_pr_statuses( } } - app_handle - .emit("pr-statuses-refreshed", &project_id) - .map_err(|e| format!("Failed to emit event: {}", e))?; + crate::web_server::emit_to_all(&app_handle, "pr-statuses-refreshed", &project_id); Ok(refreshed_count) } @@ -855,9 +869,7 @@ pub fn clear_branch_pr_status( .update_branch_pr_status(&branch_id, None, None, None, None, None, None, None, None) .map_err(|e| e.to_string())?; - app_handle - .emit("pr-status-cleared", &branch_id) - .map_err(|e| format!("Failed to emit event: {}", e))?; + crate::web_server::emit_to_all(&app_handle, "pr-status-cleared", &branch_id); Ok(()) } @@ -874,6 +886,13 @@ pub async fn recover_branch_pr( ) -> Result, String> { let store = get_store(&store)?; + recover_branch_pr_impl(store, branch_id).await +} + +pub(crate) async fn recover_branch_pr_impl( + store: Arc, + branch_id: String, +) -> Result, String> { let branch = store .get_branch(&branch_id) .map_err(|e| e.to_string())? @@ -936,7 +955,13 @@ pub async fn has_unpushed_commits( branch_id: String, ) -> Result { let store = get_store(&store)?; + has_unpushed_commits_impl(store, branch_id).await +} +pub(crate) async fn has_unpushed_commits_impl( + store: Arc, + branch_id: String, +) -> Result { let branch = store .get_branch(&branch_id) .map_err(|e| e.to_string())? @@ -989,16 +1014,14 @@ pub async fn has_unpushed_commits( } /// Push a branch to its remote by kicking off an agent session. -#[tauri::command(rename_all = "camelCase")] -pub async fn push_branch( - store: tauri::State<'_, Mutex>>>, - registry: tauri::State<'_, Arc>, +pub(crate) async fn start_push_branch_pipeline_for_branch( + store: Arc, + registry: Arc, app_handle: tauri::AppHandle, branch_id: String, provider: Option, force: Option, ) -> Result { - let store = get_store(&store)?; let ctx = resolve_branch_pipeline_context(&store, &branch_id)?; let force = force.unwrap_or(false); @@ -1055,7 +1078,28 @@ pub async fn push_branch( ) } -/// Rebase a branch via a pipeline. +#[tauri::command(rename_all = "camelCase")] +pub async fn push_branch( + store: tauri::State<'_, Mutex>>>, + registry: tauri::State<'_, Arc>, + app_handle: tauri::AppHandle, + branch_id: String, + provider: Option, + force: Option, +) -> Result { + let store = get_store(&store)?; + start_push_branch_pipeline_for_branch( + store, + Arc::clone(®istry), + app_handle, + branch_id, + provider, + force, + ) + .await +} + +/// Rebase a branch onto its base branch via a pipeline. /// /// When `target` is `None` or `"base"`, rebases onto `origin/{base_branch}` /// (the default behaviour used by the base-moved row and the `…` menu). diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 2e4debd5c..54373bb30 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -20,7 +20,6 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; -use tauri::Emitter; use crate::actions::{ActionExecutor, ActionRegistry}; use crate::agent::{self, AcpProviderInfo}; @@ -42,7 +41,7 @@ fn get_store(store: &tauri::State<'_, Mutex>>>) -> Result, project: &store::Project, branch: &store::Branch, @@ -55,7 +54,7 @@ fn resolve_branch_repo_slug( project.primary_repo().map(|s| s.to_string()) } -async fn run_blox_blocking(op: F) -> Result +pub(crate) async fn run_blox_blocking(op: F) -> Result where T: Send + 'static, F: FnOnce() -> Result + Send + 'static, @@ -322,7 +321,8 @@ pub async fn resume_session( return Err("Session is already running".to_string()); } - let _ = app_handle.emit( + crate::web_server::emit_to_all( + &app_handle, "session-status-changed", session_runner::SessionStatusEvent { session_id: session_id.clone(), @@ -372,7 +372,7 @@ pub async fn resume_session( Ok(()) } -fn infer_branch_resume_session_type(prompt: &str) -> Option<&'static str> { +pub(crate) fn infer_branch_resume_session_type(prompt: &str) -> Option<&'static str> { // Keep these checks aligned with the action prompts built in `prs.rs`. if prompt.contains("Create a draft pull request for the current branch.") || prompt.contains("Create a pull request for the current branch.") @@ -407,7 +407,8 @@ pub fn cancel_session( None, Some(&store::CompletionReason::Interrupted), ); - let _ = app_handle.emit( + crate::web_server::emit_to_all( + &app_handle, "session-status-changed", session_runner::SessionStatusEvent { session_id: session_id.clone(), @@ -1252,7 +1253,8 @@ pub async fn drain_queued_sessions_for_branch( BranchSessionType::Review => "review", }; - let _ = app_handle.emit( + crate::web_server::emit_to_all( + &app_handle, "session-status-changed", session_runner::SessionStatusEvent { session_id: session_id.clone(), @@ -1510,7 +1512,8 @@ pub async fn trigger_auto_review( store.create_review(&review).map_err(|e| e.to_string())?; // Emit session-status-changed with isAutoReview: true - let _ = app_handle.emit( + crate::web_server::emit_to_all( + &app_handle, "session-status-changed", session_runner::SessionStatusEvent { session_id: session.id.clone(), @@ -1605,7 +1608,7 @@ fn latest_git_commit_ms(store: &Arc, branch_id: &str) -> i64 { commits.iter().map(|c| c.timestamp).max().unwrap_or(0) * 1000 } -fn cancel_in_flight_auto_review_for_branch( +pub(crate) fn cancel_in_flight_auto_review_for_branch( store: &Arc, registry: &session_runner::SessionRegistry, branch_id: &str, @@ -1933,7 +1936,7 @@ pub(crate) fn build_project_context( /// /// Includes: project name, all attached repos (with reasons and per-repo /// branch timelines), and existing project notes. -fn build_project_session_context( +pub(crate) fn build_project_session_context( store: &Arc, project: &store::Project, workspace_name: Option<&str>, @@ -2682,7 +2685,7 @@ fn shell_quote_arg(value: &str) -> String { } /// Assemble the full prompt from action instructions + branch context + user prompt. -fn build_full_prompt( +pub(crate) fn build_full_prompt( user_prompt: &str, project_information: &str, branch_context: &str, @@ -2890,7 +2893,7 @@ fn render_launch_context_entry( Some(entry) } -fn embed_launch_context( +pub(crate) fn embed_launch_context( prompt: &str, launch_context: Option<&BranchSessionLaunchContext>, ) -> Result { @@ -2904,7 +2907,7 @@ fn embed_launch_context( )) } -fn extract_launch_context( +pub(crate) fn extract_launch_context( prompt: &str, ) -> Result<(String, Option), String> { const OPEN: &str = ""; diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index 8f98c5ec7..69d988180 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -41,7 +41,7 @@ use std::sync::Arc; use std::time::Duration; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter}; +use tauri::AppHandle; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio_util::sync::CancellationToken; @@ -1509,9 +1509,7 @@ fn emit_pipeline_step( started_at: step.started_at, completed_at: step.completed_at, }; - if let Err(e) = app_handle.emit("pipeline-step-changed", &event) { - log::warn!("Failed to emit pipeline-step-changed: {e}"); - } + crate::web_server::emit_to_all(app_handle, "pipeline-step-changed", &event); } // ============================================================================= @@ -2282,9 +2280,7 @@ fn emit_status( session_type: None, is_auto_review: false, }; - if let Err(e) = app_handle.emit("session-status-changed", &event) { - log::warn!("Failed to emit session-status-changed: {e}"); - } + crate::web_server::emit_to_all(app_handle, "session-status-changed", &event); } /// Emit a `session-status-changed` event with `"running"` status and branch/project @@ -2308,9 +2304,7 @@ pub fn emit_session_running( session_type: Some(session_type.to_string()), is_auto_review: false, }; - if let Err(e) = app_handle.emit("session-status-changed", &event) { - log::warn!("Failed to emit session-status-changed (running): {e}"); - } + crate::web_server::emit_to_all(app_handle, "session-status-changed", &event); } #[cfg(test)] diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index 085c487c0..20f57f2f7 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -120,6 +120,14 @@ fn map_local_commits( .collect() } +/// Public wrapper for `build_branch_timeline` for use by the web server. +pub fn build_branch_timeline_public( + store: &Arc, + branch_id: &str, +) -> Result { + build_branch_timeline(store, branch_id) +} + fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result { // Get the branch and its workdir for git operations let branch = store @@ -772,7 +780,16 @@ pub async fn delete_commit( ) -> Result<(), String> { let store = crate::get_store(&store)?; let registry = Arc::clone(®istry); + delete_commit_impl(registry, store, branch_id, commit_sha, delete_session).await +} +pub(crate) async fn delete_commit_impl( + registry: Arc, + store: Arc, + branch_id: String, + commit_sha: String, + delete_session: Option, +) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { let branch = store .get_branch(&branch_id) diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs new file mode 100644 index 000000000..adaaba15a --- /dev/null +++ b/apps/staged/src-tauri/src/web_server.rs @@ -0,0 +1,3502 @@ +//! Axum HTTPS/WebSocket server for browser-based access. +//! +//! Runs alongside the Tauri desktop app on `0.0.0.0:5175` with a provided TLS +//! certificate and key, serving: +//! +//! - `POST /api/invoke/{command}` — dispatches to the same logic as Tauri commands +//! - `GET /api/events` — WebSocket that broadcasts Tauri events as JSON +//! - `POST /api/auth` — accepts bearer token and sets session cookie +//! - `GET /*` — static files from `../dist` (the built Svelte frontend) +//! +//! All `/api/*` routes (except `/api/auth`) require authentication via either +//! an `Authorization: Bearer ` header or a valid `staged_session` cookie. + +use std::collections::HashSet; +use std::io; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::extract::ws::{Message, WebSocket}; +use axum::extract::{Path, Request, State, WebSocketUpgrade}; +use axum::http::StatusCode; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Json, Response}; +use axum::routing::{get, post}; +use axum::serve::Listener; +use axum::Router; +use axum_extra::extract::cookie::{Cookie, CookieJar}; +use rand::Rng; +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use serde_json::Value; +use subtle::ConstantTimeEq; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::broadcast; +use tokio_rustls::server::TlsStream; +use tokio_rustls::TlsAcceptor; +use tower_http::cors::CorsLayer; +use tower_http::services::ServeDir; + +use crate::actions::{ActionExecutor, ActionRegistry}; +use crate::session_commands; +use crate::session_runner::{self, SessionConfig, SessionRegistry}; +use crate::store::{self, Store}; + +// ============================================================================= +// Shared state +// ============================================================================= + +/// State shared between the Axum web server and the Tauri app. +/// +/// Holds the Tauri `AppHandle` which provides access to all managed state +/// (Store, SessionRegistry, etc.) via `app_handle.state()`. This ensures +/// both Tauri commands and web endpoints operate on the exact same objects. +#[derive(Clone)] +pub struct WebAppState { + pub app_handle: tauri::AppHandle, + pub event_tx: broadcast::Sender, + /// Hex-encoded 256-bit token required to authenticate web clients. + pub auth_token: String, + /// Set of valid session IDs, one per authenticated client. + pub sessions: Arc>>, +} + +/// A serialized event for WebSocket broadcast. +#[derive(Clone, Debug)] +pub struct WebEvent { + pub event_name: String, + pub payload: String, +} + +// ============================================================================= +// Event broadcast helper +// ============================================================================= + +/// Emit an event both to Tauri (for the desktop window) and to the web +/// broadcast channel (for browser clients over WebSocket). +/// +/// Extracts the broadcast sender from the `AppHandle`'s managed state so +/// callers don't need to pass it explicitly. This is the preferred way to +/// emit events — it replaces direct `app_handle.emit()` calls so that web +/// browser clients connected via WebSocket also receive the event. +pub fn emit_to_all( + app_handle: &tauri::AppHandle, + event_name: &str, + payload: S, +) { + use tauri::{Emitter, Manager}; + let _ = app_handle.emit(event_name, payload.clone()); + if let Some(tx) = app_handle.try_state::>() { + if let Ok(json) = serde_json::to_string(&serde_json::json!({ + "event": event_name, + "payload": payload, + })) { + let _ = tx.send(WebEvent { + event_name: event_name.to_string(), + payload: json, + }); + } + } +} + +// ============================================================================= +// Server startup +// ============================================================================= + +/// Generate a cryptographically random hex-encoded token (256-bit). +pub fn generate_token() -> String { + let bytes: [u8; 32] = rand::rng().random(); + hex::encode(bytes) +} + +const CERT_PATH_ENV: &str = "STAGED_WEB_CERT_PATH"; +const KEY_PATH_ENV: &str = "STAGED_WEB_KEY_PATH"; + +fn load_tls_acceptor() -> Result { + let cert_path = std::env::var(CERT_PATH_ENV) + .map_err(|_| format!("{CERT_PATH_ENV} must point to a PEM certificate file"))?; + let key_path = std::env::var(KEY_PATH_ENV) + .map_err(|_| format!("{KEY_PATH_ENV} must point to a PEM private key file"))?; + + let certs = CertificateDer::pem_file_iter(&cert_path) + .map_err(|e| format!("failed to open TLS certificate {cert_path}: {e}"))? + .collect::, _>>() + .map_err(|e| format!("failed to read TLS certificate {cert_path}: {e}"))?; + if certs.is_empty() { + return Err(format!( + "TLS certificate {cert_path} did not contain any certificates" + )); + } + + let key = PrivateKeyDer::from_pem_file(&key_path) + .map_err(|e| format!("failed to read TLS private key {key_path}: {e}"))?; + let config = rustls::ServerConfig::builder_with_provider( + rustls::crypto::aws_lc_rs::default_provider().into(), + ) + .with_safe_default_protocol_versions() + .map_err(|e| format!("failed to configure TLS protocol versions: {e}"))? + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| format!("failed to configure TLS certificate/key pair: {e}"))?; + + Ok(TlsAcceptor::from(Arc::new(config))) +} + +struct TlsListener { + inner: TcpListener, + acceptor: TlsAcceptor, +} + +impl TlsListener { + fn new(inner: TcpListener, acceptor: TlsAcceptor) -> Self { + Self { inner, acceptor } + } +} + +impl Listener for TlsListener { + type Io = TlsStream; + type Addr = SocketAddr; + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + loop { + let (stream, addr) = match self.inner.accept().await { + Ok(connection) => connection, + Err(e) => { + log::error!("[web_server] accept error: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + }; + + match self.acceptor.accept(stream).await { + Ok(tls_stream) => return (tls_stream, addr), + Err(e) => log::warn!("[web_server] TLS handshake failed from {addr}: {e}"), + } + } + } + + fn local_addr(&self) -> io::Result { + self.inner.local_addr() + } +} + +/// Start the Axum web server in a background tokio task. +/// +/// 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}"); + } + }); +} + +// ============================================================================= +// Authentication +// ============================================================================= + +const SESSION_COOKIE_NAME: &str = "staged_session"; +const SESSION_MAX_AGE_DAYS: i64 = 7; + +/// Constant-time string comparison to prevent timing side-channel attacks. +fn constant_time_eq(a: &str, b: &str) -> bool { + a.as_bytes().ct_eq(b.as_bytes()).into() +} + +/// Middleware that rejects unauthenticated requests to protected routes. +/// +/// Accepts either: +/// - `Authorization: Bearer ` header matching the server's auth token +/// - `staged_session` cookie matching the server's session ID +async fn require_auth( + State(state): State, + jar: CookieJar, + request: Request, + next: Next, +) -> Response { + // Check Authorization header (constant-time comparison to prevent timing attacks) + if let Some(auth_header) = request.headers().get("authorization") { + if let Ok(value) = auth_header.to_str() { + if let Some(token) = value.strip_prefix("Bearer ") { + if constant_time_eq(token, &state.auth_token) { + return next.run(request).await; + } + } + } + } + + // Check session cookie against the set of valid sessions + if let Some(cookie) = jar.get(SESSION_COOKIE_NAME) { + let is_valid = { + let sessions = state.sessions.lock().unwrap_or_else(|e| e.into_inner()); + let cookie_val = cookie.value(); + sessions.iter().any(|s| constant_time_eq(cookie_val, s)) + }; + if is_valid { + return next.run(request).await; + } + } + + (StatusCode::UNAUTHORIZED, "Authentication required").into_response() +} + +/// POST /api/auth — validate the bearer token and issue a session cookie. +/// +/// Expects JSON body: `{ "token": "" }` +async fn authenticate( + State(state): State, + jar: CookieJar, + Json(body): Json, +) -> Response { + let token = body + .get("token") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + if !constant_time_eq(token, &state.auth_token) { + return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); + } + + // Generate a unique session ID for this client and register it. + let new_session_id = generate_token(); + state + .sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(new_session_id.clone()); + + let cookie = Cookie::build((SESSION_COOKIE_NAME, new_session_id)) + .path("/") + .http_only(true) + .secure(true) + .max_age(time::Duration::days(SESSION_MAX_AGE_DAYS)) + .same_site(axum_extra::extract::cookie::SameSite::Lax) + .build(); + + (jar.add(cookie), Json(serde_json::json!({ "ok": true }))).into_response() +} + +// ============================================================================= +// WebSocket endpoint — /api/events +// ============================================================================= + +async fn ws_events(ws: WebSocketUpgrade, State(state): State) -> Response { + ws.on_upgrade(move |socket| handle_ws(socket, state)) +} + +async fn handle_ws(mut socket: WebSocket, state: WebAppState) { + let mut rx = state.event_tx.subscribe(); + loop { + tokio::select! { + // Forward broadcast events to the WebSocket client + msg = rx.recv() => { + match msg { + Ok(event) => { + if socket.send(Message::Text(event.payload.into())).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + log::warn!("[web_server] WebSocket client lagged, dropped {n} events"); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + // Handle incoming messages (ping/pong, close) + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Ping(data))) => { + if socket.send(Message::Pong(data)).await.is_err() { + break; + } + } + _ => {} + } + } + } + } +} + +// ============================================================================= +// Command dispatch — POST /api/invoke/{command} +// ============================================================================= + +async fn invoke_command( + Path(command): Path, + State(state): State, + Json(args): Json, +) -> Response { + match dispatch(&command, args, &state).await { + Ok(value) => Json(value).into_response(), + Err(msg) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": msg })), + ) + .into_response(), + } +} + +/// Helper to extract a typed field from the JSON args. +fn arg(args: &Value, key: &str) -> Result { + args.get(key) + .ok_or_else(|| format!("missing argument: {key}")) + .and_then(|v| { + serde_json::from_value(v.clone()).map_err(|e| format!("invalid argument '{key}': {e}")) + }) +} + +/// Helper to extract an optional typed field. +fn opt_arg(args: &Value, key: &str) -> Result, String> { + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => serde_json::from_value(v.clone()) + .map(Some) + .map_err(|e| format!("invalid argument '{key}': {e}")), + } +} + +/// Helper to get the Store Arc from the shared mutex slot. +fn get_store(store: &Mutex>>) -> Result, String> { + store + .lock() + .unwrap() + .clone() + .ok_or_else(|| "Database not initialized".to_string()) +} + +/// The big dispatcher. Maps command names to handler logic. +/// +/// Each arm extracts arguments from the JSON body and calls the same +/// underlying logic as the corresponding Tauri command. Return values +/// are serialized to `serde_json::Value`. +async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + use tauri::Manager; + + // Convenience aliases — pull shared state from the Tauri AppHandle + let app_handle = &state.app_handle; + let store_mutex: &Mutex>> = &app_handle.state::>>>(); + let session_registry: &Arc = &app_handle.state::>(); + let action_executor: &Arc = &app_handle.state::>(); + let action_registry: &Arc = &app_handle.state::>(); + + match command { + // ===================================================================== + // Store status + // ===================================================================== + "get_store_status" => { + // We don't have DbState in web context — return null (store ready) + Ok(Value::Null) + } + "confirm_reset_store" => { + // Not applicable in web context + Err("confirm_reset_store is not supported in web mode".to_string()) + } + + // ===================================================================== + // Projects + // ===================================================================== + "list_projects" => { + let store = get_store(store_mutex)?; + let projects = store.list_projects().map_err(|e| e.to_string())?; + Ok(serde_json::to_value(projects).unwrap()) + } + "create_project" => { + let store = get_store(store_mutex)?; + let name: String = arg(&args, "name")?; + let github_repo: Option = opt_arg(&args, "githubRepo")?; + let location: Option = opt_arg(&args, "location")?; + let subpath: Option = opt_arg(&args, "subpath")?; + let branch_name: Option = opt_arg(&args, "branchName")?; + let pr_number: Option = opt_arg(&args, "prNumber")?; + let default_branch: Option = opt_arg(&args, "defaultBranch")?; + let head_repo: Option = opt_arg(&args, "headRepo")?; + + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Project name is required".to_string()); + } + + let project_location = match location.as_deref() { + Some("remote") => crate::store::ProjectLocation::Remote, + _ => crate::store::ProjectLocation::Local, + }; + let inferred_branch_name = branch_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| crate::branches::infer_branch_name(trimmed)); + + let mut project = crate::store::Project::named(trimmed); + project.location = project_location; + if let Some(ref repo) = github_repo { + project = project.with_primary_repo(repo); + } + if let Some(ref sub) = subpath { + project = project.with_subpath(sub.clone()); + } + store.create_project(&project).map_err(|e| e.to_string())?; + + if let Ok(project_dir) = crate::git::project_worktree_root_for(&project.id) { + let _ = std::fs::create_dir_all(&project_dir); + } + + if let Some(repo) = project.primary_repo() { + store + .get_or_create_action_context(repo, project.subpath.as_deref()) + .map_err(|e| e.to_string())?; + } + + if let Some(ref repo) = github_repo { + store + .record_recent_repo(repo, subpath.clone()) + .map_err(|e| e.to_string())?; + } + + if let Some(repo) = github_repo { + let mut project_repo = crate::store::ProjectRepo::new( + &project.id, + &repo, + &inferred_branch_name, + subpath, + ) + .primary(); + project_repo.head_repo = head_repo; + store + .create_project_repo(&project_repo) + .map_err(|e| e.to_string())?; + + let effective_base = crate::git::resolve_default_branch(default_branch, &repo); + + match project.location { + crate::store::ProjectLocation::Local => { + let mut branch = crate::store::Branch::new( + &project.id, + &inferred_branch_name, + &effective_base, + ) + .with_project_repo(&project_repo.id); + if let Some(pr) = pr_number { + branch = branch.with_pr(pr); + } + store.create_branch(&branch).map_err(|e| e.to_string())?; + + // Fire-and-forget background setup + let store_bg = Arc::clone(&store); + let app_handle = app_handle.clone(); + let branch_id = branch.id.clone(); + let project_id = project.id.clone(); + tauri::async_runtime::spawn(async move { + emit_to_all(&app_handle, "project-setup-progress", project_id.clone()); + + let store_clone = Arc::clone(&store_bg); + let branch_id_clone = branch_id.clone(); + let app_handle_clone = app_handle.clone(); + let worktree_result = tauri::async_runtime::spawn_blocking(move || { + crate::branches::setup_worktree_sync( + &store_clone, + &branch_id_clone, + Some(&app_handle_clone), + ) + }) + .await; + + match worktree_result { + Ok(Ok(path)) => { + log::info!("[web_server] worktree ready at {path}"); + emit_to_all(&app_handle, "project-setup-progress", project_id); + } + Ok(Err(e)) => log::warn!("[web_server] worktree setup failed: {e}"), + Err(e) => log::warn!("[web_server] worktree task panicked: {e}"), + } + }); + } + crate::store::ProjectLocation::Remote => { + let workspace_name = + crate::branches::infer_workspace_name(&inferred_branch_name); + let mut branch = crate::store::Branch::new_remote( + &project.id, + &inferred_branch_name, + &effective_base, + &workspace_name, + ) + .with_project_repo(&project_repo.id); + if let Some(pr) = pr_number { + branch = branch.with_pr(pr); + } + store.create_branch(&branch).map_err(|e| e.to_string())?; + } + }; + } + + Ok(serde_json::to_value(project).unwrap()) + } + "list_project_repos" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let repos = store + .list_project_repos(&project_id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(repos).unwrap()) + } + "list_recent_repos" => { + let store = get_store(store_mutex)?; + let limit: Option = opt_arg(&args, "limit")?; + let repos = store + .list_recent_repos(limit.unwrap_or(10)) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(repos).unwrap()) + } + "get_suggested_repos" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let limit: Option = opt_arg(&args, "limit")?; + let repos = store + .get_suggested_repos_for_project(&project_id, limit.unwrap_or(5)) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(repos).unwrap()) + } + "add_project_repo" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let github_repo: String = arg(&args, "githubRepo")?; + let branch_name: Option = opt_arg(&args, "branchName")?; + let subpath: Option = opt_arg(&args, "subpath")?; + let set_as_primary: Option = opt_arg(&args, "setAsPrimary")?; + let pr_number: Option = opt_arg(&args, "prNumber")?; + let default_branch: Option = opt_arg(&args, "defaultBranch")?; + let head_repo: Option = opt_arg(&args, "headRepo")?; + + let repo = crate::project_commands::add_project_repo_impl( + Arc::clone(&store), + project_id, + github_repo, + branch_name, + subpath, + set_as_primary, + None, + pr_number, + default_branch, + head_repo, + ) + .await?; + Ok(serde_json::to_value(repo).unwrap()) + } + "update_project_repo_branch_name" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let project_repo_id: String = arg(&args, "projectRepoId")?; + let branch_name: String = arg(&args, "branchName")?; + let trimmed = branch_name.trim(); + if trimmed.is_empty() { + return Err("Branch name is required".to_string()); + } + store + .update_project_repo_branch_name(&project_id, &project_repo_id, trimmed) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "clear_project_repo_reason" => { + let store = get_store(store_mutex)?; + let project_repo_id: String = arg(&args, "projectRepoId")?; + store + .clear_project_repo_reason(&project_repo_id) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "remove_project_repo" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let project_repo_id: String = arg(&args, "projectRepoId")?; + + let removed = store + .get_project_repo(&project_repo_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project repo not found: {project_repo_id}"))?; + let branches = store + .list_branches_for_project(&project_id) + .map_err(|e| e.to_string())? + .into_iter() + .filter(|b| b.project_repo_id.as_deref() == Some(project_repo_id.as_str())) + .collect::>(); + + let branch_ids: Vec<&str> = branches.iter().map(|b| b.id.as_str()).collect(); + crate::actions::commands::stop_actions_for_branches( + action_executor, + action_registry, + &branch_ids, + ); + + tauri::async_runtime::spawn_blocking({ + let store = Arc::clone(&store); + let branches = branches.clone(); + move || -> Result<(), String> { + for branch in &branches { + crate::branches::cleanup_branch_resources(&store, branch)?; + } + Ok(()) + } + }) + .await + .map_err(|e| format!("Failed to clean up branch resources: {e}"))??; + + for branch in &branches { + store.delete_branch(&branch.id).map_err(|e| e.to_string())?; + } + store + .delete_project_repo(&project_repo_id) + .map_err(|e| e.to_string())?; + + if removed.is_primary { + let next_primary = store + .list_project_repos(&project_id) + .map_err(|e| e.to_string())? + .into_iter() + .next(); + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + if let Some(next) = next_primary { + store + .set_primary_project_repo(&project_id, &next.id) + .map_err(|e| e.to_string())?; + store + .update_project( + &project_id, + &project.name, + Some(&next.github_repo), + &project.location, + next.subpath.as_deref(), + ) + .map_err(|e| e.to_string())?; + } else { + store + .update_project(&project_id, &project.name, None, &project.location, None) + .map_err(|e| e.to_string())?; + } + } + Ok(Value::Null) + } + "set_primary_project_repo" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let project_repo_id: String = arg(&args, "projectRepoId")?; + let repo = store + .get_project_repo(&project_repo_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project repo not found: {project_repo_id}"))?; + store + .set_primary_project_repo(&project_id, &project_repo_id) + .map_err(|e| e.to_string())?; + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + store + .update_project( + &project_id, + &project.name, + Some(&repo.github_repo), + &project.location, + repo.subpath.as_deref(), + ) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "delete_project" => { + let store = get_store(store_mutex)?; + let id: String = arg(&args, "id")?; + let branches = store + .list_branches_for_project(&id) + .map_err(|e| e.to_string())?; + let branch_ids: Vec<&str> = branches.iter().map(|b| b.id.as_str()).collect(); + crate::actions::commands::stop_actions_for_branches( + action_executor, + action_registry, + &branch_ids, + ); + + tauri::async_runtime::spawn_blocking({ + let store = Arc::clone(&store); + let id = id.clone(); + let branches = branches.clone(); + move || { + for branch in &branches { + crate::branches::cleanup_branch_resources_best_effort(&store, branch); + let _ = store.delete_branch(&branch.id); + } + if let Ok(project_root) = crate::git::project_worktree_root_for(&id) { + if project_root.exists() { + let _ = std::fs::remove_dir_all(&project_root); + } + } + } + }) + .await + .map_err(|e| format!("Failed to clean up project resources: {e}"))?; + + store.delete_project(&id).map_err(|e| e.to_string())?; + Ok(Value::Null) + } + + // ===================================================================== + // Repo badges + // ===================================================================== + "get_all_repo_badges" => { + let store = get_store(store_mutex)?; + let badges = store.list_repo_badges().map_err(|e| e.to_string())?; + Ok(serde_json::to_value(badges).unwrap()) + } + "ensure_repo_badges" => { + // Simplified version — just returns existing badges without AI generation + let store = get_store(store_mutex)?; + let repos: Vec<(String, String)> = arg(&args, "repos")?; + let mut result = Vec::new(); + for (github_repo, subpath) in &repos { + if let Some(badge) = store + .get_repo_badge(github_repo, subpath) + .map_err(|e| e.to_string())? + { + result.push(badge); + } else { + let existing_badges = store.list_repo_badges().map_err(|e| e.to_string())?; + let taken: Vec = existing_badges + .iter() + .map(|b| b.short_name.clone()) + .collect(); + let short_name = + crate::store::fallback_short_name(github_repo, subpath, &taken); + let existing_hues = store.list_badge_hues().map_err(|e| e.to_string())?; + let hue = crate::store::next_hue(&existing_hues); + let badge = crate::store::RepoBadge { + github_repo: github_repo.clone(), + subpath: subpath.to_string(), + short_name, + hue, + created_at: crate::store::now_timestamp(), + }; + let _ = store.create_repo_badge(&badge); + if let Some(b) = store + .get_repo_badge(github_repo, subpath) + .map_err(|e| e.to_string())? + { + result.push(b); + } + } + } + Ok(serde_json::to_value(result).unwrap()) + } + "update_repo_badge" => { + let store = get_store(store_mutex)?; + let github_repo: String = arg(&args, "githubRepo")?; + let subpath: String = arg(&args, "subpath")?; + let short_name: String = arg(&args, "shortName")?; + let hue: f64 = arg(&args, "hue")?; + store + .update_repo_badge(&github_repo, &subpath, &short_name, hue) + .map_err(|e| e.to_string())?; + let badge = store + .get_repo_badge(&github_repo, &subpath) + .map_err(|e| e.to_string())? + .ok_or("Badge not found after update")?; + Ok(serde_json::to_value(badge).unwrap()) + } + "delete_repo_badge" => { + let store = get_store(store_mutex)?; + let github_repo: String = arg(&args, "githubRepo")?; + let subpath: String = arg(&args, "subpath")?; + store + .delete_repo_badge(&github_repo, &subpath) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + + // ===================================================================== + // GitHub commands + // ===================================================================== + "list_github_orgs" => { + let orgs = crate::git::list_github_orgs().map_err(|e| e.to_string())?; + Ok(serde_json::to_value(orgs).unwrap()) + } + "list_github_repos" => { + let owner: Option = opt_arg(&args, "owner")?; + let repos = + crate::git::list_github_repos(owner.as_deref()).map_err(|e| e.to_string())?; + Ok(serde_json::to_value(repos).unwrap()) + } + "list_user_repos" => { + let limit: Option = opt_arg(&args, "limit")?; + let repos = + crate::git::list_user_repos(limit.unwrap_or(30)).map_err(|e| e.to_string())?; + Ok(serde_json::to_value(repos).unwrap()) + } + "get_github_repo" => { + let owner: String = arg(&args, "owner")?; + let repo: String = arg(&args, "repo")?; + let result = crate::git::fetch_github_repo(&owner, &repo).map_err(|e| e.to_string())?; + Ok(serde_json::to_value(result).unwrap()) + } + "search_github_repos" => { + let query: String = arg(&args, "query")?; + let owner: Option = opt_arg(&args, "owner")?; + let repos = crate::git::search_github_repos(&query, owner.as_deref()) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(repos).unwrap()) + } + "check_monorepo_modules" => { + let github_repo: String = arg(&args, "githubRepo")?; + let count = + crate::git::check_monorepo_modules(&github_repo).map_err(|e| e.to_string())?; + Ok(serde_json::to_value(count).unwrap()) + } + "validate_subpath" => { + let github_repo: String = arg(&args, "githubRepo")?; + let subpath: String = arg(&args, "subpath")?; + crate::git::validate_subpath_in_repo(&github_repo, &subpath) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "list_repo_directories" => { + let github_repo: String = arg(&args, "githubRepo")?; + let path: String = arg(&args, "path")?; + let dirs = crate::git::list_repo_directories(&github_repo, &path) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(dirs).unwrap()) + } + "list_git_branches" => { + let github_repo: String = arg(&args, "githubRepo")?; + let branches = + crate::git::list_branches_for_repo(&github_repo).map_err(|e| e.to_string())?; + Ok(serde_json::to_value(branches).unwrap()) + } + "detect_default_branch_cmd" => { + let github_repo: String = arg(&args, "githubRepo")?; + let branch = crate::git::detect_default_branch_for_repo(&github_repo) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(branch).unwrap()) + } + "prune_remote_refs" => { + let github_repo: String = arg(&args, "githubRepo")?; + crate::git::prune_remote_for_repo(&github_repo).map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "check_existing_local_branch" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let branch_name: String = arg(&args, "branchName")?; + let trimmed = branch_name.trim(); + if trimmed.is_empty() { + return Ok(serde_json::to_value(false).unwrap()); + } + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + let result = project + .primary_repo() + .and_then(crate::paths::clone_path_for) + .filter(|p| p.exists()) + .map(|repo_path| crate::git::branch_exists(&repo_path, trimmed).unwrap_or(false)) + .unwrap_or(false); + Ok(serde_json::to_value(result).unwrap()) + } + "get_pr_for_repo" => { + let github_repo: String = arg(&args, "githubRepo")?; + let pr_number: u64 = arg(&args, "prNumber")?; + let pr = tauri::async_runtime::spawn_blocking(move || { + crate::git::github::get_pr_for_repo(&github_repo, pr_number) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(serde_json::to_value(pr).unwrap()) + } + "get_pr_for_branch" => { + let github_repo: String = arg(&args, "githubRepo")?; + let branch_name: String = arg(&args, "branchName")?; + let pr = tauri::async_runtime::spawn_blocking(move || { + crate::git::github::get_pr_for_branch_for_repo(&github_repo, &branch_name) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(serde_json::to_value(pr).unwrap()) + } + "list_pull_requests" => { + let github_repo: String = arg(&args, "githubRepo")?; + let prs = tauri::async_runtime::spawn_blocking(move || { + crate::git::list_pull_requests_for_repo(&github_repo).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(serde_json::to_value(prs).unwrap()) + } + "get_parent_repo" => { + let github_repo: String = arg(&args, "githubRepo")?; + let parent = tauri::async_runtime::spawn_blocking(move || { + crate::git::github::get_parent_repo(&github_repo).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(serde_json::to_value(parent).unwrap()) + } + "list_issues" => { + let github_repo: String = arg(&args, "githubRepo")?; + let issues = tauri::async_runtime::spawn_blocking(move || { + crate::git::list_issues_for_repo(&github_repo).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(serde_json::to_value(issues).unwrap()) + } + "post_comment_to_github" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let pr_number: u64 = arg(&args, "prNumber")?; + let comment: store::Comment = arg(&args, "comment")?; + let result = crate::github_commands::post_comment_to_github_impl( + store, branch_id, pr_number, comment, + ) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + + // ===================================================================== + // Branches + // ===================================================================== + "get_branch" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let branch = store.get_branch(&branch_id).map_err(|e| e.to_string())?; + let enriched = match branch { + Some(branch) => { + let workdir = store + .get_workdir_for_branch(&branch.id) + .map_err(|e| e.to_string())?; + Some(crate::branches::to_branch_with_workdir_public( + branch, + workdir.map(|w| w.path), + )) + } + None => None, + }; + Ok(serde_json::to_value(enriched).unwrap()) + } + "list_branches_for_project" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let branches = store + .list_branches_for_project(&project_id) + .map_err(|e| e.to_string())?; + let enriched: Vec = branches + .into_iter() + .map(|b| { + let workdir = store.get_workdir_for_branch(&b.id).ok().flatten(); + crate::branches::to_branch_with_workdir_public(b, workdir.map(|w| w.path)) + }) + .collect(); + Ok(serde_json::to_value(enriched).unwrap()) + } + "create_branch" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let branch_name: String = arg(&args, "branchName")?; + let base_branch: String = arg(&args, "baseBranch")?; + let pr_number: Option = opt_arg(&args, "prNumber")?; + let project_repo_id: Option = opt_arg(&args, "projectRepoId")?; + + let mut branch = crate::store::Branch::new(&project_id, &branch_name, &base_branch); + if let Some(pr) = pr_number { + branch = branch.with_pr(pr); + } + if let Some(ref repo_id) = project_repo_id { + branch = branch.with_project_repo(repo_id); + } + store.create_branch(&branch).map_err(|e| e.to_string())?; + let workdir = store.get_workdir_for_branch(&branch.id).ok().flatten(); + let enriched = + crate::branches::to_branch_with_workdir_public(branch, workdir.map(|w| w.path)); + Ok(serde_json::to_value(enriched).unwrap()) + } + "delete_branch" => { + let store = get_store(store_mutex)?; + let id: String = arg(&args, "branchId")?; + let branch = store + .get_branch(&id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {id}"))?; + let branch_ids = vec![id.as_str()]; + crate::actions::commands::stop_actions_for_branches( + action_executor, + action_registry, + &branch_ids, + ); + + tauri::async_runtime::spawn_blocking({ + let store = Arc::clone(&store); + let branch = branch.clone(); + move || crate::branches::cleanup_branch_resources(&store, &branch) + }) + .await + .map_err(|e| format!("Failed: {e}"))??; + store.delete_branch(&id).map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "rename_branch" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let branch_name: String = arg(&args, "branchName")?; + store + .update_branch_name(&branch_id, &branch_name) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "get_blox_env" => Ok(serde_json::to_value(std::env::var("BLOX_ENV").ok()).unwrap()), + // ===================================================================== + // Workspace / Blox commands (Tier 3) + // ===================================================================== + "get_workspace_info" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + let ws_name = branch + .workspace_name + .ok_or("Branch is not a remote workspace branch")?; + let info = crate::branches::run_blox_blocking(move || crate::blox::ws_info(&ws_name)) + .await + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(info).unwrap()) + } + "poll_workspace_status" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + let ws_name = branch + .workspace_name + .as_deref() + .ok_or("Branch is not a remote workspace branch")?; + + // Secondary clone setup: hold at Starting until setup marks it Running. + let is_secondary_clone_setup = if branch.workspace_status + == Some(store::WorkspaceStatus::Starting) + && crate::branches::resolve_branch_workspace_subpath(&store, &branch)?.is_some() + { + if let Some(ws_name) = branch.workspace_name.as_deref() { + let peers = store + .list_branches_for_project(&branch.project_id) + .map_err(|e| e.to_string())?; + peers.into_iter().any(|peer| { + peer.id != branch.id + && peer.branch_type == store::BranchType::Remote + && peer.workspace_name.as_deref() == Some(ws_name) + && peer.workspace_status == Some(store::WorkspaceStatus::Running) + }) + } else { + false + } + } else { + false + }; + + if is_secondary_clone_setup { + return Ok(serde_json::to_value(crate::PollWorkspaceResult { + status: store::WorkspaceStatus::Starting.as_str().to_string(), + workstation_id: crate::branches::cached_workstation_id(ws_name), + }) + .unwrap()); + } + + let info = match crate::branches::run_blox_blocking({ + let ws_name = ws_name.to_string(); + move || crate::blox::ws_info(&ws_name) + }) + .await + { + Ok(info) => info, + Err(crate::blox::BloxError::NotAuthenticated) => { + store + .update_branch_workspace_status(&branch_id, &store::WorkspaceStatus::Error) + .ok(); + return Err("Not authenticated with Blox. Run: sq login".to_string()); + } + Err(e) => { + let is_not_found = matches!(&e, crate::blox::BloxError::CommandFailed(msg) if msg.to_lowercase().contains("not found")); + if branch.workspace_status == Some(store::WorkspaceStatus::Starting) { + return Ok(serde_json::to_value(crate::PollWorkspaceResult { + status: store::WorkspaceStatus::Starting.as_str().to_string(), + workstation_id: crate::branches::cached_workstation_id(ws_name), + }) + .unwrap()); + } + if branch.workspace_status == Some(store::WorkspaceStatus::Running) { + if is_not_found { + store + .update_branch_workspace_status( + &branch_id, + &store::WorkspaceStatus::Stopped, + ) + .ok(); + return Ok(serde_json::to_value(crate::PollWorkspaceResult { + status: store::WorkspaceStatus::Stopped.as_str().to_string(), + workstation_id: crate::branches::cached_workstation_id(ws_name), + }) + .unwrap()); + } + return Ok(serde_json::to_value(crate::PollWorkspaceResult { + status: store::WorkspaceStatus::Running.as_str().to_string(), + workstation_id: crate::branches::cached_workstation_id(ws_name), + }) + .unwrap()); + } + return Err(e.to_string()); + } + }; + + let new_status = crate::branches::map_blox_status_to_workspace_status( + info.status.as_deref(), + branch.workspace_status.as_ref(), + ); + if let Some(ws_id) = info.workstation_id { + if let Some(name) = branch.workspace_name.as_deref() { + if let Ok(mut cache) = crate::branches::workstation_id_cache().lock() { + cache.insert(name.to_string(), ws_id); + } + } + } + store + .update_branch_workspace_status(&branch_id, &new_status) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(crate::PollWorkspaceResult { + status: new_status.as_str().to_string(), + workstation_id: crate::branches::cached_workstation_id(ws_name), + }) + .unwrap()) + } + "poll_all_workspace_statuses" => { + let store = get_store(store_mutex)?; + let branch_ids: Vec = arg(&args, "branchIds")?; + + let entries = crate::branches::run_blox_blocking(crate::blox::ws_list) + .await + .map_err(|e| { + if matches!(e, crate::blox::BloxError::NotAuthenticated) { + "Not authenticated with Blox. Run: sq login".to_string() + } else { + e.to_string() + } + })?; + + let ws_map: std::collections::HashMap = + entries.iter().map(|e| (e.name.clone(), e)).collect(); + + let mut starting_workspaces: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut results = std::collections::HashMap::new(); + + for branch_id in &branch_ids { + let branch = match store.get_branch(branch_id) { + Ok(Some(b)) => b, + _ => continue, + }; + let ws_name = match branch.workspace_name.as_deref() { + Some(n) => n, + None => continue, + }; + + // Secondary clone setup check + if branch.workspace_status == Some(store::WorkspaceStatus::Starting) + && crate::branches::resolve_branch_workspace_subpath(&store, &branch) + .unwrap_or(None) + .is_some() + { + let is_secondary = if let Some(ws) = branch.workspace_name.as_deref() { + store + .list_branches_for_project(&branch.project_id) + .unwrap_or_default() + .into_iter() + .any(|peer| { + peer.id != *branch_id + && peer.branch_type == store::BranchType::Remote + && peer.workspace_name.as_deref() == Some(ws) + && peer.workspace_status + == Some(store::WorkspaceStatus::Running) + }) + } else { + false + }; + if is_secondary { + results.insert( + branch_id.clone(), + crate::PollWorkspaceResult { + status: store::WorkspaceStatus::Starting.as_str().to_string(), + workstation_id: crate::branches::cached_workstation_id(ws_name), + }, + ); + continue; + } + } + + match ws_map.get(ws_name) { + Some(entry) => { + let new_status = crate::branches::map_blox_status_to_workspace_status( + entry.status.as_deref(), + branch.workspace_status.as_ref(), + ); + if let Some(ws_id) = entry.workstation_id { + if let Ok(mut cache) = crate::branches::workstation_id_cache().lock() { + cache.insert(ws_name.to_string(), ws_id); + } + } + store + .update_branch_workspace_status(branch_id, &new_status) + .ok(); + if new_status == store::WorkspaceStatus::Starting { + starting_workspaces + .entry(ws_name.to_string()) + .or_default() + .push(branch_id.clone()); + } + results.insert( + branch_id.clone(), + crate::PollWorkspaceResult { + status: new_status.as_str().to_string(), + workstation_id: crate::branches::cached_workstation_id(ws_name), + }, + ); + } + None => { + if branch.workspace_status == Some(store::WorkspaceStatus::Starting) { + starting_workspaces + .entry(ws_name.to_string()) + .or_default() + .push(branch_id.clone()); + results.insert( + branch_id.clone(), + crate::PollWorkspaceResult { + status: store::WorkspaceStatus::Starting.as_str().to_string(), + workstation_id: crate::branches::cached_workstation_id(ws_name), + }, + ); + } else if branch.workspace_status == Some(store::WorkspaceStatus::Running) { + store + .update_branch_workspace_status( + branch_id, + &store::WorkspaceStatus::Stopped, + ) + .ok(); + results.insert( + branch_id.clone(), + crate::PollWorkspaceResult { + status: store::WorkspaceStatus::Stopped.as_str().to_string(), + workstation_id: crate::branches::cached_workstation_id(ws_name), + }, + ); + } + } + } + } + + // Fetch bootstrap progress for starting workspaces in background. + if !starting_workspaces.is_empty() { + let app_handle_clone = app_handle.clone(); + tokio::task::spawn_blocking(move || { + for (ws_name, branch_ids) in &starting_workspaces { + match crate::blox::ws_commands(ws_name) { + Ok(cmds) => { + crate::branches::emit_workspace_setup_progress( + &app_handle_clone, + branch_ids, + &cmds, + ); + } + Err(e) => { + log::debug!( + "[poll_all_workspace_statuses] ws_commands({}) failed: {}", + ws_name, + e + ); + } + } + } + }); + } + + Ok(serde_json::to_value(results).unwrap()) + } + "setup_worktree" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + let project = store + .get_project(&branch.project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {}", branch.project_id))?; + + // Fast-path: already has a workdir + if let Some(existing) = store + .get_workdir_for_branch(&branch.id) + .map_err(|e| e.to_string())? + { + return Ok( + serde_json::to_value(crate::branches::to_branch_with_workdir( + branch, + Some(existing.path), + )) + .unwrap(), + ); + } + + let repo_slug = crate::branches::resolve_branch_repo_slug(&store, &project, &branch)?; + let repo_path = + crate::git::ensure_local_clone(&repo_slug).map_err(|e| e.to_string())?; + crate::git::fetch_for_worktree( + &repo_path, + &repo_slug, + &branch.branch_name, + &branch.base_branch, + ) + .map_err(|e| e.to_string())?; + let desired_worktree_path = crate::git::project_worktree_path_for( + &branch.project_id, + &repo_slug, + &branch.branch_name, + ) + .map_err(|e| e.to_string())?; + + let worktree_str = crate::branches::create_and_link_worktree( + &store, + &branch, + &repo_path, + &desired_worktree_path, + )?; + + Ok( + serde_json::to_value(crate::branches::to_branch_with_workdir( + branch, + Some(worktree_str), + )) + .unwrap(), + ) + } + "setup_worktree_and_run_prerun" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + + // Reuse setup_worktree logic inline + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + let project = store + .get_project(&branch.project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {}", branch.project_id))?; + + let worktree_str = if let Some(existing) = store + .get_workdir_for_branch(&branch.id) + .map_err(|e| e.to_string())? + { + existing.path + } else { + let repo_slug = + crate::branches::resolve_branch_repo_slug(&store, &project, &branch)?; + let repo_path = + crate::git::ensure_local_clone(&repo_slug).map_err(|e| e.to_string())?; + crate::git::fetch_for_worktree( + &repo_path, + &repo_slug, + &branch.branch_name, + &branch.base_branch, + ) + .map_err(|e| e.to_string())?; + let desired_worktree_path = crate::git::project_worktree_path_for( + &branch.project_id, + &repo_slug, + &branch.branch_name, + ) + .map_err(|e| e.to_string())?; + crate::branches::create_and_link_worktree( + &store, + &branch, + &repo_path, + &desired_worktree_path, + )? + }; + + let result = crate::branches::to_branch_with_workdir(branch, Some(worktree_str)); + + // Run prerun actions if we win the setup-complete race + match store.mark_branch_setup_complete(&branch_id) { + Ok(true) => { + match crate::branches::run_prerun_actions_for_branch( + &store, + app_handle, + &branch_id, + action_executor, + action_registry, + ) + .await + { + Ok(count) => { + log::info!("[setup_worktree_and_run_prerun] ran {count} prerun actions") + } + Err(e) => { + log::warn!("[setup_worktree_and_run_prerun] prerun actions failed: {e}") + } + } + } + Ok(false) => { + log::info!("[setup_worktree_and_run_prerun] branch {} already setup complete, skipping prerun", branch_id); + } + Err(e) => { + log::warn!( + "[setup_worktree_and_run_prerun] failed to mark setup complete: {e}" + ); + } + } + + Ok(serde_json::to_value(result).unwrap()) + } + "setup_worktree_from_pr" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let pr_number: u64 = arg(&args, "prNumber")?; + let head_ref: String = arg(&args, "headRef")?; + let base_ref: String = arg(&args, "baseRef")?; + let project_repo_id: Option = opt_arg(&args, "projectRepoId")?; + + let target_repo = match project_repo_id { + Some(repo_id) => store + .get_project_repo(&repo_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project repo not found: {repo_id}"))?, + None => store + .get_primary_project_repo(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project '{project_id}' has no repository attached"))?, + }; + + let repo_path = crate::git::ensure_local_clone(&target_repo.github_repo) + .map_err(|e| e.to_string())?; + let desired_worktree_path = crate::git::project_worktree_path_for( + &project_id, + &target_repo.github_repo, + &head_ref, + ) + .map_err(|e| e.to_string())?; + + let (worktree_path, branch_name, base_branch) = + crate::git::create_worktree_from_pr_at_path( + &repo_path, + pr_number, + &head_ref, + &base_ref, + &desired_worktree_path, + ) + .map_err(|e| e.to_string())?; + + let worktree_str = worktree_path + .to_str() + .ok_or("Invalid worktree path")? + .to_string(); + + let branch = store::Branch::new(&project_id, &branch_name, &base_branch) + .with_project_repo(&target_repo.id) + .with_pr(pr_number); + store.create_branch(&branch).map_err(|e| e.to_string())?; + + let workdir = store::Workdir::new(&project_id, &worktree_str).with_branch(&branch.id); + store.create_workdir(&workdir).map_err(|e| e.to_string())?; + + Ok( + serde_json::to_value(crate::branches::to_branch_with_workdir( + branch, + Some(worktree_str), + )) + .unwrap(), + ) + } + "create_remote_branch" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let branch_name: String = arg(&args, "branchName")?; + let base_branch: Option = opt_arg(&args, "baseBranch")?; + let workspace_name: String = arg(&args, "workspaceName")?; + let project_repo_id: Option = opt_arg(&args, "projectRepoId")?; + + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + let resolved_workspace_name = crate::branches::resolve_project_workspace_name( + &store, + &project, + Some(&workspace_name), + )?; + + let target_repo = match project_repo_id { + Some(repo_id) => store + .get_project_repo(&repo_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project repo not found: {repo_id}"))?, + None => store + .get_primary_project_repo(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project '{project_id}' has no repository attached"))?, + }; + let effective_base = match base_branch { + Some(b) => b, + None => crate::git::detect_default_branch_for_repo(&target_repo.github_repo) + .map_err(|e| e.to_string())?, + }; + let effective_base = if effective_base.starts_with("origin/") { + effective_base + } else { + format!("origin/{effective_base}") + }; + + let branch = store::Branch::new_remote( + &project_id, + &branch_name, + &effective_base, + &resolved_workspace_name, + ) + .with_project_repo(&target_repo.id); + store.create_branch(&branch).map_err(|e| e.to_string())?; + + Ok( + serde_json::to_value(crate::branches::to_branch_with_workdir(branch, None)) + .unwrap(), + ) + } + "start_workspace" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + let is_first_start = branch.workspace_status == Some(store::WorkspaceStatus::Starting); + + let project = store + .get_project(&branch.project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {}", branch.project_id))?; + + let ws_name = branch + .workspace_name + .as_deref() + .ok_or("Branch has no workspace name")?; + let repo_subpath = crate::branches::resolve_branch_workspace_subpath(&store, &branch)?; + let ref_name = crate::branches::normalize_branch_ref(&branch.base_branch); + let repo_slug = crate::branches::resolve_branch_repo_slug(&store, &project, &branch)?; + + // Pre-flight auth check + if let Err(e) = crate::branches::run_blox_blocking(crate::blox::check_auth).await { + store + .update_branch_workspace_status(&branch_id, &store::WorkspaceStatus::Error) + .ok(); + return Err(e.to_string()); + } + + // Secondary repo setup in an already-running shared workspace + if let Some(repo_subpath) = repo_subpath.as_deref() { + if let Ok(info) = crate::branches::run_blox_blocking({ + let ws_name = ws_name.to_string(); + move || crate::blox::ws_info(&ws_name) + }) + .await + { + let ws_status = info + .status + .as_deref() + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_default(); + if let Some(ws_id) = info.workstation_id { + if let Ok(mut cache) = crate::branches::workstation_id_cache().lock() { + cache.insert(ws_name.to_string(), ws_id); + } + } + if ws_status == "running" { + crate::branches::clone_repo_into_workspace( + ws_name, + repo_subpath, + &repo_slug, + &ref_name, + &branch.branch_name, + ) + .await?; + store + .update_branch_workspace_status( + &branch_id, + &store::WorkspaceStatus::Running, + ) + .map_err(|e| e.to_string())?; + if is_first_start { + let store_bg = Arc::clone(&store); + let app_handle_bg = app_handle.clone(); + let branch_id_bg = branch_id.clone(); + tokio::spawn(async move { + crate::maybe_trigger_auto_review_for_new_repo( + &store_bg, + &app_handle_bg, + &branch_id_bg, + None, + ) + .await; + }); + } + return Ok(Value::Null); + } + } + } + + let resolved_source = Some(format!( + "https://github.com/{}.git?ref={}", + repo_slug, ref_name + )); + log::info!( + "[start_workspace] branch={} workspace={} starting", + branch_id, + ws_name + ); + let ws_start_started_at = std::time::Instant::now(); + + match crate::branches::run_blox_blocking({ + let ws_name = ws_name.to_string(); + let source = resolved_source.clone(); + move || { + crate::blox::ws_start( + &ws_name, + source.as_deref(), + Some(crate::branches::WORKSPACE_IDLE_TIMEOUT_MINUTES), + ) + } + }) + .await + { + Ok(_) => { + log::info!( + "[start_workspace] branch={} workspace={} ws_start completed elapsed_ms={}", + branch_id, + ws_name, + ws_start_started_at.elapsed().as_millis() + ); + let has_remote_branch = crate::branches::run_workspace_git_async( + ws_name, + repo_subpath.as_deref(), + &["fetch", "origin", &branch.branch_name], + ) + .await + .is_ok(); + + let remote_ref = format!("origin/{}", branch.branch_name); + let checkout_result = if has_remote_branch { + crate::branches::run_workspace_git_async( + ws_name, + repo_subpath.as_deref(), + &["checkout", "-B", &branch.branch_name, &remote_ref], + ) + .await + } else { + crate::branches::run_workspace_git_async( + ws_name, + repo_subpath.as_deref(), + &["checkout", "-b", &branch.branch_name], + ) + .await + }; + if let Err(e) = checkout_result { + log::warn!( + "failed to create branch '{}' in workspace '{}': {e}", + branch.branch_name, + ws_name + ); + } + + if is_first_start { + let store_bg = Arc::clone(&store); + let app_handle_bg = app_handle.clone(); + let branch_id_bg = branch_id.clone(); + tokio::spawn(async move { + crate::maybe_trigger_auto_review_for_new_repo( + &store_bg, + &app_handle_bg, + &branch_id_bg, + None, + ) + .await; + }); + } + Ok(Value::Null) + } + Err(crate::blox::BloxError::NotAuthenticated) => { + store + .update_branch_workspace_status(&branch_id, &store::WorkspaceStatus::Error) + .ok(); + Err("Not authenticated with Blox. Run: sq login".to_string()) + } + Err(e) => { + if crate::branches::is_blox_onboarding_precondition_error(&e) { + store + .update_branch_workspace_status( + &branch_id, + &store::WorkspaceStatus::Error, + ) + .ok(); + return Err(e.to_string()); + } + log::warn!( + "blox ws start failed for '{}', leaving status as Starting for polling to resolve: {e}", + ws_name + ); + Ok(Value::Null) + } + } + } + "resume_workspace" => { + let store = get_store(store_mutex)?; + let workspace_name: String = arg(&args, "workspaceName")?; + + let branch_ids = store + .update_workspace_status_by_workspace_name( + &workspace_name, + &store::WorkspaceStatus::Starting, + ) + .map_err(|e| e.to_string())?; + + if branch_ids.is_empty() { + return Err(format!("No branches found for workspace: {workspace_name}")); + } + + let ws = workspace_name.clone(); + if let Err(e) = + crate::branches::run_blox_blocking(move || crate::blox::ws_resume(&ws)).await + { + log::warn!( + "[resume_workspace] workspace={} resume failed: {}", + workspace_name, + e + ); + store + .update_workspace_status_by_workspace_name( + &workspace_name, + &store::WorkspaceStatus::Error, + ) + .ok(); + return Err(e.to_string()); + } + + Ok(serde_json::to_value(branch_ids).unwrap()) + } + + // ===================================================================== + // Actions (repo actions CRUD) + // ===================================================================== + "list_project_actions" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let project_repo_id: Option = opt_arg(&args, "projectRepoId")?; + let context = if let Some(repo_id) = project_repo_id { + let repo = store + .get_project_repo(&repo_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project repo not found: {repo_id}"))?; + store + .get_or_create_action_context(&repo.github_repo, repo.subpath.as_deref()) + .map_err(|e| e.to_string())? + } else { + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + let repo = project + .primary_repo() + .ok_or("Project has no repository attached")?; + store + .get_or_create_action_context(repo, project.subpath.as_deref()) + .map_err(|e| e.to_string())? + }; + let actions = store + .list_repo_actions(&context.id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(actions).unwrap()) + } + "update_project_action" => { + let store = get_store(store_mutex)?; + let action_id: String = arg(&args, "actionId")?; + let name: String = arg(&args, "name")?; + let command_str: String = arg(&args, "command")?; + let action_type: String = arg(&args, "actionType")?; + let sort_order: i32 = arg(&args, "sortOrder")?; + let auto_commit: bool = arg(&args, "autoCommit")?; + let action = store + .get_repo_action(&action_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Action not found: {action_id}"))?; + let updated = crate::store::models::RepoAction { + id: action.id, + context_id: action.context_id, + name, + command: command_str, + action_type: builderbot_actions::ActionType::parse(&action_type) + .ok_or_else(|| format!("Invalid action type: {action_type}"))?, + sort_order, + auto_commit, + run_detection_mode: action.run_detection_mode, + created_at: action.created_at, + updated_at: crate::store::now_timestamp(), + }; + store + .update_repo_action(&updated) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "delete_project_action" => { + let store = get_store(store_mutex)?; + let action_id: String = arg(&args, "actionId")?; + store + .delete_repo_action(&action_id) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "list_action_contexts" => { + let store = get_store(store_mutex)?; + let contexts = store.list_action_contexts().map_err(|e| e.to_string())?; + Ok(serde_json::to_value(contexts).unwrap()) + } + "list_repo_actions" => { + let store = get_store(store_mutex)?; + let github_repo: String = arg(&args, "githubRepo")?; + let subpath: Option = opt_arg(&args, "subpath")?; + let context = store + .get_or_create_action_context(&github_repo, subpath.as_deref()) + .map_err(|e| e.to_string())?; + let actions = store + .list_repo_actions(&context.id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(actions).unwrap()) + } + "create_repo_action" => { + let store = get_store(store_mutex)?; + let github_repo: String = arg(&args, "githubRepo")?; + let subpath: Option = opt_arg(&args, "subpath")?; + let name: String = arg(&args, "name")?; + let command_str: String = arg(&args, "command")?; + let action_type: String = arg(&args, "actionType")?; + let sort_order: i32 = arg(&args, "sortOrder")?; + let auto_commit: bool = arg(&args, "autoCommit")?; + let context = store + .get_or_create_action_context(&github_repo, subpath.as_deref()) + .map_err(|e| e.to_string())?; + let parsed_type = builderbot_actions::ActionType::parse(&action_type) + .ok_or_else(|| format!("Invalid action type: {action_type}"))?; + let action = crate::store::models::RepoAction::new( + context.id, + name, + command_str, + parsed_type, + sort_order, + ) + .with_auto_commit(auto_commit); + store + .create_repo_action(&action) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(action).unwrap()) + } + "delete_all_repo_actions" => { + let store = get_store(store_mutex)?; + let context_id: String = arg(&args, "contextId")?; + store + .delete_all_repo_actions(&context_id) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "delete_action_context" => { + let store = get_store(store_mutex)?; + let context_id: String = arg(&args, "contextId")?; + store + .delete_action_context(&context_id) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + + // ===================================================================== + // Action execution commands + // ===================================================================== + "detect_repo_actions" => { + let store = get_store(store_mutex)?; + let github_repo: String = arg(&args, "githubRepo")?; + let subpath: Option = opt_arg(&args, "subpath")?; + let actions = crate::actions::commands::detect_repo_actions_impl( + github_repo, + subpath, + app_handle.clone(), + store, + ) + .await?; + Ok(serde_json::to_value(actions).unwrap()) + } + "run_branch_action" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let action_id: String = arg(&args, "actionId")?; + let execution_id = crate::actions::commands::run_branch_action_impl( + branch_id, + action_id, + app_handle.clone(), + store, + Arc::clone(action_executor), + Arc::clone(action_registry), + ) + .await?; + Ok(serde_json::to_value(execution_id).unwrap()) + } + "stop_branch_action" => { + let execution_id: String = arg(&args, "executionId")?; + crate::actions::commands::stop_branch_action_impl(execution_id, action_executor)?; + Ok(Value::Null) + } + "get_running_branch_actions" => { + let branch_id: String = arg(&args, "branchId")?; + let actions = crate::actions::commands::get_running_branch_actions_impl( + branch_id, + action_executor, + action_registry, + )?; + Ok(serde_json::to_value(actions).unwrap()) + } + "get_action_output_buffer" => { + let execution_id: String = arg(&args, "executionId")?; + let output = crate::actions::commands::get_action_output_buffer_impl( + execution_id, + action_executor, + )?; + Ok(serde_json::to_value(output).unwrap()) + } + "clear_action_execution" => { + let execution_id: String = arg(&args, "executionId")?; + let cleared = crate::actions::commands::clear_action_execution_impl( + execution_id, + action_executor, + )?; + Ok(serde_json::to_value(cleared).unwrap()) + } + "run_prerun_actions" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let execution_ids = crate::actions::commands::run_prerun_actions_impl( + branch_id, + app_handle.clone(), + store, + Arc::clone(action_executor), + Arc::clone(action_registry), + ) + .await?; + Ok(serde_json::to_value(execution_ids).unwrap()) + } + "get_run_phase" => { + let execution_id: String = arg(&args, "executionId")?; + let phase = + crate::actions::commands::get_run_phase_impl(action_registry, execution_id)?; + Ok(serde_json::to_value(phase).unwrap()) + } + "update_run_detection_mode" => { + let store = get_store(store_mutex)?; + let action_id: String = arg(&args, "actionId")?; + let mode: builderbot_actions::RunDetectionMode = arg(&args, "mode")?; + crate::actions::commands::update_run_detection_mode_impl(store, action_id, mode)?; + Ok(Value::Null) + } + + // ===================================================================== + // Timeline + // ===================================================================== + "get_branch_timeline" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let timeline = tauri::async_runtime::spawn_blocking(move || { + crate::timeline::build_branch_timeline_public(&store, &branch_id) + }) + .await + .map_err(|e| format!("Timeline task failed: {e}"))??; + Ok(serde_json::to_value(timeline).unwrap()) + } + + // ===================================================================== + // Notes + // ===================================================================== + "create_note" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let title: String = arg(&args, "title")?; + let content: String = arg(&args, "content")?; + let note = crate::store::models::Note::new(&branch_id, &title, &content); + store.create_note(¬e).map_err(|e| e.to_string())?; + let item = crate::NoteTimelineItem { + id: note.id, + title: note.title, + content: note.content, + session_id: None, + session_status: None, + completion_reason: None, + created_at: note.created_at, + updated_at: note.updated_at, + completed_at: note.completed_at, + suggested_next_commit_step: None, + suggested_next_note_step: None, + }; + Ok(serde_json::to_value(item).unwrap()) + } + "delete_note" => { + let store = get_store(store_mutex)?; + let note_id: String = arg(&args, "noteId")?; + let delete_session_flag: Option = opt_arg(&args, "deleteSession")?; + let note = store + .get_note(¬e_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Note not found: {note_id}"))?; + store.delete_note(¬e_id).map_err(|e| e.to_string())?; + if delete_session_flag.unwrap_or(false) { + if let Some(sid) = note.session_id { + let _ = store.delete_session(&sid); + } + } + Ok(Value::Null) + } + "create_project_note" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let title: String = arg(&args, "title")?; + let content: String = arg(&args, "content")?; + let note = crate::store::ProjectNote::new(&project_id, &title, &content); + store + .create_project_note(¬e) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(note).unwrap()) + } + "list_project_notes" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let notes = store + .list_project_notes(&project_id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(notes).unwrap()) + } + "delete_project_note" => { + let store = get_store(store_mutex)?; + let note_id: String = arg(&args, "noteId")?; + store + .delete_project_note(¬e_id) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + + // ===================================================================== + // Images + // ===================================================================== + "create_image" => Err("create_image is not available in web mode".to_string()), + "create_image_from_data" => { + let store = get_store(store_mutex)?; + let branch_id: Option = opt_arg(&args, "branchId")?; + let project_id: String = arg(&args, "projectId")?; + let filename: String = arg(&args, "filename")?; + let mime_type: String = arg(&args, "mimeType")?; + let data: String = arg(&args, "data")?; + let pending: Option = opt_arg(&args, "pending")?; + let image = crate::image_commands::create_image_from_data_impl( + store, branch_id, project_id, filename, mime_type, data, pending, + )?; + Ok(serde_json::to_value(image).unwrap()) + } + "get_image_path" => { + let store = get_store(store_mutex)?; + let image_id: String = arg(&args, "imageId")?; + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = crate::store::images::image_file_path( + &image.project_id, + &image.id, + &image.filename, + ) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(path.to_string_lossy().to_string()).unwrap()) + } + "get_image_data" => { + let store = get_store(store_mutex)?; + let image_id: String = arg(&args, "imageId")?; + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = crate::store::images::image_file_path( + &image.project_id, + &image.id, + &image.filename, + ) + .map_err(|e| e.to_string())?; + let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {e}"))?; + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + let data_url = format!("data:{};base64,{}", image.mime_type, encoded); + Ok(serde_json::to_value(data_url).unwrap()) + } + "delete_image" => { + let store = get_store(store_mutex)?; + let image_id: String = arg(&args, "imageId")?; + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + store.delete_image(&image_id).map_err(|e| e.to_string())?; + if let Ok(path) = + crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename) + { + let _ = std::fs::remove_file(&path); + } + Ok(Value::Null) + } + "list_branch_images" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let images = store + .list_images_for_branch(&branch_id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(images).unwrap()) + } + + // ===================================================================== + // Timeline delete commands + // ===================================================================== + "delete_review" => { + let store = get_store(store_mutex)?; + let review_id: String = arg(&args, "reviewId")?; + let delete_session_flag: Option = opt_arg(&args, "deleteSession")?; + let review = store + .get_review(&review_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Review not found: {review_id}"))?; + store.delete_review(&review_id).map_err(|e| e.to_string())?; + if delete_session_flag.unwrap_or(false) { + if let Some(sid) = review.session_id { + let _ = store.delete_session(&sid); + } + } + Ok(Value::Null) + } + "delete_pending_commit" => { + let store = get_store(store_mutex)?; + let commit_id: String = arg(&args, "commitId")?; + let delete_session_flag: Option = opt_arg(&args, "deleteSession")?; + let commit = store + .get_commit(&commit_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Commit not found: {commit_id}"))?; + if commit.sha.is_some() { + return Err("Cannot use delete_pending_commit for commits with a SHA".to_string()); + } + crate::timeline::cleanup_reviews_after_commit(&store, session_registry, &commit); + store.delete_commit(&commit_id).map_err(|e| e.to_string())?; + if delete_session_flag.unwrap_or(false) { + if let Some(sid) = commit.session_id { + let _ = store.delete_session(&sid); + } + } + Ok(Value::Null) + } + "delete_commit" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let commit_sha: String = arg(&args, "commitSha")?; + let delete_session_flag: Option = opt_arg(&args, "deleteSession")?; + let registry = Arc::clone(session_registry); + crate::timeline::delete_commit_impl( + registry, + store, + branch_id, + commit_sha, + delete_session_flag, + ) + .await?; + Ok(Value::Null) + } + + // ===================================================================== + // Diff commands + // ===================================================================== + "get_diff_files" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let commit_sha: Option = opt_arg(&args, "commitSha")?; + let scope: String = arg(&args, "scope")?; + let response = + crate::diff_commands::get_diff_files_impl(store, branch_id, commit_sha, scope)?; + Ok(serde_json::to_value(response).unwrap()) + } + "get_file_diff" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let commit_sha: String = arg(&args, "commitSha")?; + let scope: String = arg(&args, "scope")?; + let path: String = arg(&args, "path")?; + let result = crate::diff_commands::get_file_diff_impl( + store, branch_id, commit_sha, scope, path, + )?; + Ok(serde_json::to_value(result).unwrap()) + } + "get_file_at_ref" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let ref_name: String = arg(&args, "refName")?; + let path: String = arg(&args, "path")?; + let file = + crate::diff_commands::get_file_at_ref_impl(store, branch_id, ref_name, path)?; + Ok(serde_json::to_value(file).unwrap()) + } + + // ===================================================================== + // Review commands + // ===================================================================== + "ensure_review" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let commit_sha: String = arg(&args, "commitSha")?; + let scope: String = arg(&args, "scope")?; + let review_scope = crate::store::ReviewScope::parse(&scope) + .ok_or_else(|| format!("Invalid scope: {scope}"))?; + let review = store + .ensure_review(&branch_id, &commit_sha, review_scope) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(review).unwrap()) + } + "find_review" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let commit_sha: String = arg(&args, "commitSha")?; + let scope: String = arg(&args, "scope")?; + let review_scope = crate::store::ReviewScope::parse(&scope) + .ok_or_else(|| format!("Invalid scope: {scope}"))?; + let review = store + .find_review(&branch_id, &commit_sha, review_scope) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(review).unwrap()) + } + "get_review" => { + let store = get_store(store_mutex)?; + let review_id: String = arg(&args, "reviewId")?; + let review = store.get_review(&review_id).map_err(|e| e.to_string())?; + Ok(serde_json::to_value(review).unwrap()) + } + "mark_reviewed" => { + let store = get_store(store_mutex)?; + let review_id: String = arg(&args, "reviewId")?; + let path: String = arg(&args, "path")?; + store + .mark_reviewed(&review_id, &path) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "unmark_reviewed" => { + let store = get_store(store_mutex)?; + let review_id: String = arg(&args, "reviewId")?; + let path: String = arg(&args, "path")?; + store + .unmark_reviewed(&review_id, &path) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "add_comment" => { + let store = get_store(store_mutex)?; + let review_id: String = arg(&args, "reviewId")?; + let path: String = arg(&args, "path")?; + let span_start: u32 = arg(&args, "spanStart")?; + let span_end: u32 = arg(&args, "spanEnd")?; + let content: String = arg(&args, "content")?; + let comment = crate::store::Comment::new( + &path, + crate::git::Span::new(span_start, span_end), + &content, + ); + store + .add_comment(&review_id, &comment) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(comment).unwrap()) + } + "update_comment" => { + let store = get_store(store_mutex)?; + let comment_id: String = arg(&args, "commentId")?; + let content: String = arg(&args, "content")?; + store + .update_comment(&comment_id, &content) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "delete_comment" => { + let store = get_store(store_mutex)?; + let comment_id: String = arg(&args, "commentId")?; + store + .delete_comment(&comment_id) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "delete_all_comments" => { + let store = get_store(store_mutex)?; + let review_id: String = arg(&args, "reviewId")?; + store + .delete_all_comments(&review_id) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "restore_comment" => { + let store = get_store(store_mutex)?; + let comment_id: String = arg(&args, "commentId")?; + store + .restore_comment(&comment_id) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "get_deleted_comments" => { + let store = get_store(store_mutex)?; + let review_id: String = arg(&args, "reviewId")?; + let comments = store + .get_deleted_comments(&review_id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(comments).unwrap()) + } + "add_reference_file" => { + let store = get_store(store_mutex)?; + let review_id: String = arg(&args, "reviewId")?; + let path: String = arg(&args, "path")?; + store + .add_reference_file(&review_id, &path) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "remove_reference_file" => { + let store = get_store(store_mutex)?; + let review_id: String = arg(&args, "reviewId")?; + let path: String = arg(&args, "path")?; + store + .remove_reference_file(&review_id, &path) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + + // ===================================================================== + // Session commands + // ===================================================================== + "discover_acp_providers" => { + let providers = tokio::task::spawn_blocking(crate::agent::discover_providers) + .await + .unwrap_or_default(); + Ok(serde_json::to_value(providers).unwrap()) + } + "get_session" => { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + let session = store.get_session(&session_id).map_err(|e| e.to_string())?; + Ok(serde_json::to_value(session).unwrap()) + } + "get_session_messages" => { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + let messages = store + .get_session_messages(&session_id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(messages).unwrap()) + } + "get_session_messages_since" => { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + let since_id: i64 = arg(&args, "sinceId")?; + let messages = store + .get_session_messages_since(&session_id, since_id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(messages).unwrap()) + } + "start_session" => { + let store = get_store(store_mutex)?; + let prompt: String = arg(&args, "prompt")?; + let working_dir: String = arg(&args, "workingDir")?; + let provider: Option = opt_arg(&args, "provider")?; + + let working_dir = std::path::PathBuf::from(working_dir); + let mut session = store::Session::new_running(&prompt, &working_dir); + if let Some(ref p) = provider { + session = session.with_provider(p); + } + store.create_session(&session).map_err(|e| e.to_string())?; + + session_runner::start_session( + SessionConfig { + session_id: session.id.clone(), + prompt, + working_dir, + agent_session_id: None, + pre_head_sha: None, + provider, + workspace_name: None, + extra_env: vec![], + mcp_project_id: None, + action_executor: None, + action_registry: None, + remote_working_dir: None, + image_ids: vec![], + }, + store, + app_handle.clone(), + Arc::clone(session_registry), + )?; + + Ok(serde_json::to_value(session).unwrap()) + } + "resume_session" => { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + let prompt: String = arg(&args, "prompt")?; + let image_ids: Option> = opt_arg(&args, "imageIds")?; + let branch_id: Option = opt_arg(&args, "branchId")?; + + let session = store + .get_session(&session_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Session not found: {session_id}"))?; + + let provider = session.provider.clone(); + let agent_session_id = session.agent_id.clone(); + let working_dir = std::path::PathBuf::from(&session.working_dir); + + let project_note = store + .get_project_note_by_session(&session_id) + .ok() + .flatten(); + let mcp_project_id = project_note.as_ref().map(|note| note.project_id.clone()); + let linked_commit = store.get_commit_by_session(&session_id).ok().flatten(); + let linked_note = store.get_note_by_session(&session_id).ok().flatten(); + let linked_review = store.get_review_by_session(&session_id).ok().flatten(); + + let branch_from_id = branch_id + .as_deref() + .and_then(|bid| store.get_branch(bid).ok().flatten()); + + let linked_branch = if branch_from_id.is_some() { + branch_from_id.clone() + } else if let Some(commit) = &linked_commit { + store.get_branch(&commit.branch_id).ok().flatten() + } else if let Some(note) = &linked_note { + store.get_branch(¬e.branch_id).ok().flatten() + } else if let Some(review) = &linked_review { + store.get_branch(&review.branch_id).ok().flatten() + } else { + None + }; + + let session_type = if project_note.is_some() { + Some("note".to_string()) + } else if linked_commit.is_some() { + Some("commit".to_string()) + } else if linked_note.is_some() { + Some("note".to_string()) + } else if linked_review.is_some() { + Some("review".to_string()) + } else { + session_commands::infer_branch_resume_session_type(&session.prompt) + .map(str::to_string) + }; + let event_branch_id = linked_branch.as_ref().map(|branch| branch.id.clone()); + let event_project_id = if let Some(note) = &project_note { + Some(note.project_id.clone()) + } else { + linked_branch + .as_ref() + .map(|branch| branch.project_id.clone()) + }; + + let (pre_head_sha, workspace_name) = { + if let Some(ref branch) = linked_branch { + let ws_name = branch.workspace_name.clone(); + let head = if linked_commit.is_some() { + if let Some(ref ws) = ws_name { + let ws = ws.clone(); + session_commands::run_blox_blocking(move || { + crate::blox::ws_exec(&ws, &["git", "rev-parse", "HEAD"]) + }) + .await + .map(|s| s.trim().to_string()) + .ok() + } else { + crate::git::get_head_sha(&working_dir).ok() + } + } else { + None + }; + (head, ws_name) + } else { + (None, None) + } + }; + + let remote_working_dir = if let Some(ref branch) = branch_from_id { + if branch.workspace_name.is_some() { + let ws_name = branch.workspace_name.as_deref().unwrap().to_string(); + let store_for_resolve = Arc::clone(&store); + let branch_for_resolve = branch.clone(); + match tokio::task::spawn_blocking(move || { + crate::branches::resolve_branch_workspace_subpath( + &store_for_resolve, + &branch_for_resolve, + ) + .ok() + .flatten() + .and_then(|subpath| { + crate::branches::resolve_workspace_repo_path(&ws_name, &subpath).ok() + }) + }) + .await + { + Ok(Some(path)) => Some(std::path::PathBuf::from(path)), + _ => None, + } + } else { + None + } + } else { + None + }; + + let transitioned = store + .transition_to_running(&session_id) + .map_err(|e| e.to_string())?; + if !transitioned { + return Err("Session is already running".to_string()); + } + + emit_to_all( + app_handle, + "session-status-changed", + session_runner::SessionStatusEvent { + session_id: session_id.clone(), + status: "running".to_string(), + error_message: None, + completion_reason: None, + branch_id: event_branch_id, + project_id: event_project_id.or(mcp_project_id.clone()), + session_type, + is_auto_review: false, + }, + ); + + session_runner::start_session( + SessionConfig { + session_id, + prompt, + working_dir, + agent_session_id, + pre_head_sha, + provider, + workspace_name, + extra_env: vec![], + mcp_project_id: mcp_project_id.clone(), + action_executor: if mcp_project_id.is_some() { + Some(Arc::clone(action_executor)) + } else { + None + }, + action_registry: if mcp_project_id.is_some() { + Some(Arc::clone(action_registry)) + } else { + None + }, + remote_working_dir, + image_ids: image_ids.unwrap_or_default(), + }, + store, + app_handle.clone(), + Arc::clone(session_registry), + )?; + + Ok(Value::Null) + } + "start_branch_session" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let prompt: String = arg(&args, "prompt")?; + let session_type: session_commands::BranchSessionType = arg(&args, "sessionType")?; + let provider: Option = opt_arg(&args, "provider")?; + let image_ids: Option> = opt_arg(&args, "imageIds")?; + let launch_context: Option = + opt_arg(&args, "launchContext")?; + + if matches!( + session_type, + session_commands::BranchSessionType::Commit + | session_commands::BranchSessionType::Review + ) { + session_commands::cancel_in_flight_auto_review_for_branch( + &store, + session_registry, + &branch_id, + )?; + } + + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + + let project = store + .get_project(&branch.project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {}", branch.project_id))?; + + let is_remote = branch.workspace_name.is_some(); + + let (working_dir, branch_context) = if is_remote { + let fallback_dir = + session_commands::resolve_branch_repo_slug(&store, &project, &branch) + .and_then(|repo| crate::paths::repos_dir().map(|d| d.join(repo))) + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + let workspace_name = branch.workspace_name.as_deref().unwrap().to_string(); + let base_branch = branch.base_branch.clone(); + let store_for_context = Arc::clone(&store); + let branch_id_for_context = branch_id.clone(); + let project_id_for_context = branch.project_id.clone(); + let ctx = tokio::task::spawn_blocking(move || { + session_commands::build_remote_branch_context( + &workspace_name, + &base_branch, + &store_for_context, + &branch_id_for_context, + &project_id_for_context, + ) + }) + .await + .map_err(|e| format!("Failed to build remote branch context: {e}"))?; + (fallback_dir, ctx) + } else { + let workdir = store + .get_workdir_for_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; + + let mut worktree_path = std::path::PathBuf::from(&workdir.path); + let effective_subpath = if let Some(repo_id) = branch.project_repo_id.as_deref() { + store + .get_project_repo(repo_id) + .ok() + .flatten() + .and_then(|repo| repo.subpath) + } else { + project.subpath.clone() + }; + if let Some(ref subpath) = effective_subpath { + worktree_path = worktree_path.join(subpath); + } + + let ctx = session_commands::build_branch_context( + &worktree_path, + &branch.base_branch, + &store, + &branch_id, + &branch.project_id, + ); + (worktree_path, ctx) + }; + + let project_information = + session_commands::build_project_context(&store, &project, &branch); + let full_prompt = session_commands::build_full_prompt( + &prompt, + &project_information, + &branch_context, + &session_type, + launch_context.as_ref(), + Some(&branch.base_branch), + ); + + let mut session = store::Session::new_running(&full_prompt, &working_dir); + if let Some(ref p) = provider { + session = session.with_provider(p); + } + store.create_session(&session).map_err(|e| e.to_string())?; + + let session_type_str = match session_type { + session_commands::BranchSessionType::Commit => "commit", + session_commands::BranchSessionType::Note => "note", + session_commands::BranchSessionType::Review => "review", + }; + session_runner::emit_session_running( + app_handle, + &session.id, + &branch_id, + &branch.project_id, + session_type_str, + ); + + let (artifact_id, pre_head_sha) = match session_type { + session_commands::BranchSessionType::Note => { + let note = store::Note::new(&branch_id, &prompt, "").with_session(&session.id); + store.create_note(¬e).map_err(|e| e.to_string())?; + (note.id, None) + } + session_commands::BranchSessionType::Commit => { + let commit = store::Commit::new_pending(&branch_id).with_session(&session.id); + store.create_commit(&commit).map_err(|e| e.to_string())?; + let head_sha = if is_remote { + let workspace_name = branch.workspace_name.as_deref().unwrap().to_string(); + match session_commands::run_blox_blocking(move || { + crate::blox::ws_exec(&workspace_name, &["git", "rev-parse", "HEAD"]) + }) + .await + { + Ok(sha) => Some(sha.trim().to_string()), + Err(e) => { + log::warn!("Failed to get remote HEAD SHA via ws_exec: {e}"); + None + } + } + } else { + Some( + crate::git::get_head_sha(&working_dir) + .map_err(|e| format!("Failed to get HEAD SHA: {e}"))?, + ) + }; + (commit.id, head_sha) + } + session_commands::BranchSessionType::Review => { + let tip_sha = if is_remote { + let workspace_name = branch.workspace_name.as_deref().unwrap().to_string(); + session_commands::run_blox_blocking(move || { + crate::blox::ws_exec(&workspace_name, &["git", "rev-parse", "HEAD"]) + }) + .await + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) + } else { + crate::git::get_head_sha(&working_dir) + .map_err(|e| format!("Failed to get HEAD SHA: {e}"))? + }; + + let review = + store::Review::new(&branch_id, &tip_sha, store::ReviewScope::Branch) + .with_session(&session.id); + store.create_review(&review).map_err(|e| e.to_string())?; + (review.id, None) + } + }; + + let effective_provider = provider; + + let remote_working_dir = if is_remote { + let ws_name = branch.workspace_name.as_deref().unwrap().to_string(); + let store_for_resolve = Arc::clone(&store); + let branch_for_resolve = branch.clone(); + match tokio::task::spawn_blocking(move || { + crate::branches::resolve_branch_workspace_subpath( + &store_for_resolve, + &branch_for_resolve, + ) + .ok() + .flatten() + .and_then(|subpath| { + crate::branches::resolve_workspace_repo_path(&ws_name, &subpath).ok() + }) + }) + .await + { + Ok(Some(path)) => Some(std::path::PathBuf::from(path)), + _ => None, + } + } else { + None + }; + + session_runner::start_session( + SessionConfig { + session_id: session.id.clone(), + prompt: full_prompt, + working_dir, + agent_session_id: None, + pre_head_sha, + provider: effective_provider, + workspace_name: branch.workspace_name.clone(), + extra_env: vec![], + mcp_project_id: None, + action_executor: None, + action_registry: None, + remote_working_dir, + image_ids: image_ids.unwrap_or_default(), + }, + store, + app_handle.clone(), + Arc::clone(session_registry), + )?; + + Ok( + serde_json::to_value(session_commands::BranchSessionResponse { + session_id: session.id, + artifact_id, + }) + .unwrap(), + ) + } + "start_project_session" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let prompt: String = arg(&args, "prompt")?; + let provider: Option = opt_arg(&args, "provider")?; + let image_ids: Option> = opt_arg(&args, "imageIds")?; + + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + + let project_context = + session_commands::build_project_session_context(&store, &project, None); + + let is_remote = project.location == store::ProjectLocation::Remote; + + let preamble = if is_remote { + "This top-level project session runs locally and acts as a coordinator. \ +For repository-specific execution, use MCP subagent tools.\n\n\ +This is a remote-workspace project. Use the project MCP tools to orchestrate work:" + } else { + "You have access to the following tools:" + }; + + let start_repo_session_desc = if is_remote { + "- start_repo_session: Use this to make changes or run tasks in one of the project's \ +repositories. It enqueues work and returns a `repo_session_id` immediately. Use \ +`expected_outcome=\"note_in_repo\"` for repo notes and `expected_outcome=\"commit\"` for \ +code changes/commits. For remote branches this subagent runs on the remote workspace, where \ +file access, notes, and commits must happen.\n\ +- wait_for_repo_session: Use this to wait on a previously started repo session by passing the \ +`repo_session_id`. It returns the queue state (`queued`, `running`, `completed`, `cancelled`, \ +or `failed`) and any available artifacts.\n\ +- cancel_repo_session: Use this to cancel a queued or running repo session by `repo_session_id`." + } else { + "- start_repo_session: Use this to make changes or run tasks in one of the project's \ +repositories. It enqueues work and returns a `repo_session_id` immediately. Use \ +`expected_outcome=\"note_in_repo\"` for repo notes and `expected_outcome=\"commit\"` for \ +code changes/commits. Do not ask for both a note and a commit in a single start_repo_session \ +request — choose one outcome per call. All reasoning specific to a repo must be done within a \ +repo session rather than in this project-wide context. You MUST NOT write files directly — all \ +file writes MUST go through start_repo_session with expected_outcome=\"commit\".\n\ +- wait_for_repo_session: Use this to wait on a previously started repo session by passing the \ +`repo_session_id`. It returns the queue state (`queued`, `running`, `completed`, `cancelled`, \ +or `failed`) and any available artifacts.\n\ +- cancel_repo_session: Use this to cancel a queued or running repo session by `repo_session_id`." + }; + + let coordinator_reminder = if is_remote { + "\n\nKeep this project session focused on coordination and synthesis. Do not perform \ +repository edits directly here; use `start_repo_session` for implementation work." + } else { + "" + }; + + let action_instructions = format!( + "The user is requesting work at the project level. Investigate and \ +fulfill the request below, then produce a project note summarizing what you found and any \ +actions taken.\n\n\ +{preamble}\n\n\ +{start_repo_session_desc}\n\n\ +- add_project_repo: Use this when the task requires a repository that isn't yet in the \ +project. Pass the GitHub repo slug to add it.\n\n\ +IMPORTANT: `add_project_repo` and `start_repo_session` are MCP tools, not shell commands. \ +Do not run `which`/`type` for these names and do not ask the user to add repos manually \ +unless the MCP tool call itself returns an error. If the tool call fails, report the exact \ +error and the next action needed.\ +{coordinator_reminder}\n\n\ +To discover repositories that might be relevant, use `gh` to explore repos in the user's \ +GitHub organizations. Only add repos from organizations the user already belongs to.\n\n\ +To return the note, include a horizontal rule (---) followed by the note content. \ +Begin the note with a markdown H1 heading as the title.\n\n" + ); + + let full_prompt = format!( + "\n{action_instructions}\n\nProject information:\n{project_context}\n\n\n{prompt}" + ); + + let working_dir = crate::git::project_worktree_root_for(&project.id) + .unwrap_or_else(|_| std::path::PathBuf::from("/tmp")); + + let mut session = store::Session::new_running(&full_prompt, &working_dir); + if let Some(ref p) = provider { + session = session.with_provider(p); + } + store.create_session(&session).map_err(|e| e.to_string())?; + + let note = store::ProjectNote::new(&project_id, "", "").with_session(&session.id); + store + .create_project_note(¬e) + .map_err(|e| e.to_string())?; + let note_id = note.id.clone(); + + session_runner::start_session( + SessionConfig { + session_id: session.id.clone(), + prompt: full_prompt, + working_dir, + agent_session_id: None, + pre_head_sha: None, + provider, + workspace_name: None, + extra_env: vec![], + mcp_project_id: Some(project_id.clone()), + action_executor: Some(Arc::clone(action_executor)), + action_registry: Some(Arc::clone(action_registry)), + remote_working_dir: None, + image_ids: image_ids.unwrap_or_default(), + }, + store, + app_handle.clone(), + Arc::clone(session_registry), + )?; + + Ok( + serde_json::to_value(session_commands::ProjectSessionResponse { + session_id: session.id, + note_id, + }) + .unwrap(), + ) + } + "queue_branch_session" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let prompt: String = arg(&args, "prompt")?; + let session_type: session_commands::BranchSessionType = arg(&args, "sessionType")?; + let provider: Option = opt_arg(&args, "provider")?; + let image_ids: Option> = opt_arg(&args, "imageIds")?; + let launch_context: Option = + opt_arg(&args, "launchContext")?; + + if matches!( + session_type, + session_commands::BranchSessionType::Commit + | session_commands::BranchSessionType::Review + ) { + session_commands::cancel_in_flight_auto_review_for_branch( + &store, + session_registry, + &branch_id, + )?; + } + + let _branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + + let queued_prompt = + session_commands::embed_launch_context(&prompt, launch_context.as_ref())?; + let mut session = store::Session::new_queued(&queued_prompt); + if let Some(ref p) = provider { + session = session.with_provider(p); + } + store.create_session(&session).map_err(|e| e.to_string())?; + + if let Some(ref ids) = image_ids { + store + .set_images_session_id(ids, &session.id) + .map_err(|e| e.to_string())?; + } + + let artifact_id = match session_type { + session_commands::BranchSessionType::Note => { + let note = store::Note::new(&branch_id, &prompt, "").with_session(&session.id); + store.create_note(¬e).map_err(|e| e.to_string())?; + note.id + } + session_commands::BranchSessionType::Commit => { + let commit = store::Commit::new_pending(&branch_id).with_session(&session.id); + store.create_commit(&commit).map_err(|e| e.to_string())?; + commit.id + } + session_commands::BranchSessionType::Review => { + let review = store::Review::new(&branch_id, "", store::ReviewScope::Branch) + .with_session(&session.id); + store.create_review(&review).map_err(|e| e.to_string())?; + review.id + } + }; + + Ok( + serde_json::to_value(session_commands::BranchSessionResponse { + session_id: session.id, + artifact_id, + }) + .unwrap(), + ) + } + "drain_queued_sessions" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let provider: Option = opt_arg(&args, "provider")?; + + let result = session_commands::drain_queued_sessions_for_branch( + store, + Arc::clone(session_registry), + app_handle.clone(), + branch_id, + provider, + ) + .await?; + + Ok(serde_json::to_value(result).unwrap()) + } + "cancel_session" => { + let session_id: String = arg(&args, "sessionId")?; + session_registry.cancel(&session_id); + Ok(Value::Null) + } + "delete_session" => { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + session_registry.cancel(&session_id); + store + .delete_session(&session_id) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "find_fresh_auto_review" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let review = tauri::async_runtime::spawn_blocking(move || { + store + .find_fresh_auto_review(&branch_id, 0) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(serde_json::to_value(review).unwrap()) + } + "set_review_auto" => { + let store = get_store(store_mutex)?; + let review_id: String = arg(&args, "reviewId")?; + let is_auto: bool = arg(&args, "isAuto")?; + store + .set_review_auto(&review_id, is_auto) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + + // ===================================================================== + // PRs + // ===================================================================== + "create_pr" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let provider: Option = opt_arg(&args, "provider")?; + let draft: Option = opt_arg(&args, "draft")?; + let session_id = crate::prs::start_create_pr_pipeline_for_branch( + store, + Arc::clone(session_registry), + app_handle.clone(), + branch_id, + provider, + draft, + ) + .await?; + Ok(serde_json::to_value(session_id).unwrap()) + } + "push_branch" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let provider: Option = opt_arg(&args, "provider")?; + let force: Option = opt_arg(&args, "force")?; + let session_id = crate::prs::start_push_branch_pipeline_for_branch( + store, + Arc::clone(session_registry), + app_handle.clone(), + branch_id, + provider, + force, + ) + .await?; + Ok(serde_json::to_value(session_id).unwrap()) + } + "rebase_branch" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let provider: Option = opt_arg(&args, "provider")?; + let session_id = crate::prs::start_or_queue_commit_pipeline_for_branch( + store, + Arc::clone(session_registry), + app_handle.clone(), + branch_id, + store::PipelineKind::Rebase, + provider, + None, + ) + .await?; + Ok(serde_json::to_value(session_id).unwrap()) + } + "squash_commits" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let provider: Option = opt_arg(&args, "provider")?; + let session_id = crate::prs::start_or_queue_commit_pipeline_for_branch( + store, + Arc::clone(session_registry), + app_handle.clone(), + branch_id, + store::PipelineKind::Squash, + provider, + None, + ) + .await?; + Ok(serde_json::to_value(session_id).unwrap()) + } + "get_pr_url" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let pr_number: u64 = arg(&args, "prNumber")?; + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + let project = store + .get_project(&branch.project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {}", branch.project_id))?; + let repo_slug = crate::branches::resolve_branch_repo_slug(&store, &project, &branch)?; + let url = tauri::async_runtime::spawn_blocking(move || { + crate::git::fetch_pr_url(&repo_slug, pr_number) + }) + .await + .map_err(|e| format!("Task failed: {e}"))? + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(url).unwrap()) + } + "update_branch_pr" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let pr_number: Option = opt_arg(&args, "prNumber")?; + store + .update_branch_pr_number(&branch_id, pr_number) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "recover_branch_pr" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let pr_number = crate::prs::recover_branch_pr_impl(store, branch_id).await?; + Ok(serde_json::to_value(pr_number).unwrap()) + } + "refresh_pr_status" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + let pr_number = branch + .pr_number + .ok_or_else(|| "Branch does not have an associated PR".to_string())?; + let project = store + .get_project(&branch.project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {}", branch.project_id))?; + let (github_repo, _) = + crate::prs::resolve_branch_repo_and_subpath(&store, &project, &branch)?; + + let pr_status = { + let github_repo = github_repo.clone(); + tokio::task::spawn_blocking(move || { + crate::git::fetch_pr_status_for_repo(&github_repo, pr_number) + }) + .await + .map_err(|e| format!("refresh_pr_status task failed: {e}"))? + }; + let pr_status = match pr_status { + Ok(status) => status, + Err(e) => { + log::error!( + "refresh_pr_status failed for branch_id={}, pr_number={}: {}", + branch_id, + pr_number, + e + ); + return Err(e.to_string()); + } + }; + let mergeable = pr_status.mergeable == "MERGEABLE"; + let pr_fetched_at = store::now_timestamp(); + + store + .update_branch_pr_status( + &branch_id, + Some(pr_status.state.clone()), + Some(pr_status.checks_summary.state.clone()), + pr_status.review_decision.clone(), + Some(mergeable), + Some(pr_status.is_draft), + None, + None, + pr_status.head_sha.clone(), + ) + .map_err(|e| e.to_string())?; + + emit_to_all( + app_handle, + "pr-status-changed", + crate::prs::PrStatusEvent { + branch_id: branch_id.clone(), + pr_state: pr_status.state, + pr_checks_status: pr_status.checks_summary.state, + pr_review_decision: pr_status.review_decision, + pr_mergeable: mergeable, + pr_draft: pr_status.is_draft, + pr_head_sha: pr_status.head_sha, + pr_fetched_at, + failed_checks: pr_status.failed_checks, + }, + ); + + Ok(Value::Null) + } + "refresh_all_pr_statuses" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + let branches = store + .list_branches_for_project(&project_id) + .map_err(|e| e.to_string())?; + let branches_with_prs: Vec<_> = branches + .into_iter() + .filter(|b| b.pr_number.is_some()) + .collect(); + + let mut refreshed_count = 0u32; + + for branch in branches_with_prs { + let pr_number = branch.pr_number.unwrap(); + let github_repo = + match crate::prs::resolve_branch_repo_and_subpath(&store, &project, &branch) { + Ok((repo, _)) => repo, + Err(e) => { + log::warn!( + "Failed to resolve repo for branch {} (PR #{}): {}", + branch.id, + pr_number, + e + ); + continue; + } + }; + + let pr_result = { + let github_repo = github_repo.clone(); + tokio::task::spawn_blocking(move || { + crate::git::fetch_pr_status_for_repo(&github_repo, pr_number) + }) + .await + .map_err(|e| format!("refresh_all_pr_statuses task failed: {e}"))? + }; + match pr_result { + Ok(pr_status) => { + let mergeable = pr_status.mergeable == "MERGEABLE"; + let pr_fetched_at = store::now_timestamp(); + if let Err(e) = store.update_branch_pr_status( + &branch.id, + Some(pr_status.state.clone()), + Some(pr_status.checks_summary.state.clone()), + pr_status.review_decision.clone(), + Some(mergeable), + Some(pr_status.is_draft), + None, + None, + pr_status.head_sha.clone(), + ) { + log::warn!( + "Failed to update PR status for branch {}: {}", + branch.id, + e + ); + continue; + } + + refreshed_count += 1; + + emit_to_all( + app_handle, + "pr-status-changed", + crate::prs::PrStatusEvent { + branch_id: branch.id.clone(), + pr_state: pr_status.state, + pr_checks_status: pr_status.checks_summary.state, + pr_review_decision: pr_status.review_decision, + pr_mergeable: mergeable, + pr_draft: pr_status.is_draft, + pr_head_sha: pr_status.head_sha, + pr_fetched_at, + failed_checks: pr_status.failed_checks, + }, + ); + } + Err(e) => { + log::warn!( + "Failed to fetch PR status for branch {} (PR #{}): {}", + branch.id, + pr_number, + e + ); + } + } + } + + emit_to_all(app_handle, "pr-statuses-refreshed", &project_id); + + Ok(serde_json::to_value(refreshed_count).unwrap()) + } + "has_unpushed_commits" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let result = crate::prs::has_unpushed_commits_impl(store, branch_id).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "clear_branch_pr_status" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + store + .update_branch_pr_status(&branch_id, None, None, None, None, None, None, None, None) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + + // ===================================================================== + // Utilities + // ===================================================================== + "open_url" => { + // In web mode, the browser handles URL opening + Err("open_url is handled by the browser in web mode".to_string()) + } + "is_sq_available" => Ok(serde_json::to_value(crate::blox::is_sq_available()).unwrap()), + "read_text_file" => { + let file_path: String = arg(&args, "filePath")?; + let path = std::path::Path::new(&file_path); + if !path.exists() { + return Err(format!("File does not exist: {file_path}")); + } + if !path.is_file() { + return Err(format!("Not a file: {file_path}")); + } + let content = + std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}"))?; + Ok(serde_json::to_value(content).unwrap()) + } + "preferences_store_path" => { + let path = crate::preferences_store_path_buf() + .map(|p| p.to_string_lossy().to_string()) + .ok_or("Cannot determine preferences store path")?; + Ok(serde_json::to_value(path).unwrap()) + } + "check_blox_auth" => { + tauri::async_runtime::spawn_blocking(crate::blox::check_auth) + .await + .map_err(|e| format!("Failed to run blox auth check: {e}"))? + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "get_available_openers" => { + // In web mode, openers are not relevant + Ok(serde_json::to_value(Vec::<()>::new()).unwrap()) + } + "open_in_app" => Err("open_in_app is not available in web mode".to_string()), + + // ===================================================================== + // Doctor + // ===================================================================== + "run_doctor" => { + let report = doctor::run_checks().await; + Ok(serde_json::to_value(report).unwrap()) + } + "run_doctor_fix" => { + let check_id: String = arg(&args, "checkId")?; + let fix_type: doctor::FixType = arg(&args, "fixType")?; + doctor::execute_fix(check_id, fix_type).await?; + Ok(Value::Null) + } + + _ => Err(format!("Unknown command: {command}")), + } +} diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 248cfe539..a57db2568 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -6,8 +6,8 @@ --> -{#if preferences.loaded} +{#if showLogin} + +{:else if preferences.loaded} {#if storeIncompat && storeIncompat.kind === 'needs_reset'}
diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts new file mode 100644 index 000000000..7789947c4 --- /dev/null +++ b/apps/staged/src/lib/commands.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; + +describe('browser-native command wrappers', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('opens URLs with browser navigation in web mode', async () => { + const opened = { opener: {} } as Window; + const open = vi.fn(() => opened); + const assign = vi.fn(); + vi.stubGlobal('window', { open, location: { assign } }); + + const { openUrl } = await import('./commands'); + + await openUrl('https://example.com/pull/1'); + + expect(open).toHaveBeenCalledWith('https://example.com/pull/1', '_blank'); + expect(opened.opener).toBeNull(); + expect(assign).not.toHaveBeenCalled(); + }); + + it('falls back to current-tab navigation when a new window cannot be opened', async () => { + const open = vi.fn(() => null); + const assign = vi.fn(); + vi.stubGlobal('window', { open, location: { assign } }); + + const { openUrl } = await import('./commands'); + + await openUrl('https://example.com/pull/2'); + + expect(open).toHaveBeenCalledWith('https://example.com/pull/2', '_blank'); + expect(assign).toHaveBeenCalledWith('https://example.com/pull/2'); + }); + + it('keeps path-based image uploads desktop-only in web mode', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + const { createImage } = await import('./commands'); + + await expect(createImage('branch-1', 'project-1', '/tmp/image.png')).rejects.toThrow( + 'desktop file paths' + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('keeps opener discovery desktop-only in web mode', async () => { + const fetch = vi.fn(); + vi.stubGlobal('window', {}); + vi.stubGlobal('fetch', fetch); + + const { getAvailableOpeners, openInApp } = await import('./features/branches/branch'); + + await expect(getAvailableOpeners()).resolves.toEqual([]); + await expect(openInApp('/tmp/repo', 'finder')).rejects.toThrow('web mode'); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 5e57b990c..4d0b1dbdf 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -4,7 +4,7 @@ * One function per command. Each returns a typed promise. */ -import { invoke } from '@tauri-apps/api/core'; +import { invokeCommand, isTauri } from './transport'; import type { Project, ProjectRepo, @@ -41,18 +41,27 @@ 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 // ============================================================================= /** Returns null if the store is ready, or version info if a reset is needed. */ export function getStoreStatus(): Promise { - return invoke('get_store_status'); + return invokeCommand('get_store_status'); } /** Delete the old database and create a fresh store. Called after user confirms. */ export function confirmResetStore(): Promise { - return invoke('confirm_reset_store'); + return invokeCommand('confirm_reset_store'); } // ============================================================================= @@ -60,7 +69,7 @@ export function confirmResetStore(): Promise { // ============================================================================= export function listProjects(): Promise { - return invoke('list_projects'); + return invokeCommand('list_projects'); } export function createProject( @@ -73,7 +82,7 @@ export function createProject( defaultBranch?: string, headRepo?: string ): Promise { - return invoke('create_project', { + return invokeCommand('create_project', { name, location, githubRepo: githubRepo ?? null, @@ -86,15 +95,15 @@ export function createProject( } export function deleteProject(id: string): Promise { - return invoke('delete_project', { id }); + return invokeCommand('delete_project', { id }); } export function listProjectRepos(projectId: string): Promise { - return invoke('list_project_repos', { projectId }); + return invokeCommand('list_project_repos', { projectId }); } export function listRecentRepos(limit?: number): Promise { - return invoke('list_recent_repos', { limit: limit ?? 10 }); + return invokeCommand('list_recent_repos', { limit: limit ?? 10 }); } export function addProjectRepo( @@ -107,7 +116,7 @@ export function addProjectRepo( defaultBranch?: string, headRepo?: string ): Promise { - return invoke('add_project_repo', { + return invokeCommand('add_project_repo', { projectId, githubRepo, branchName: branchName ?? null, @@ -124,66 +133,66 @@ export function updateProjectRepoBranchName( projectRepoId: string, branchName: string ): Promise { - return invoke('update_project_repo_branch_name', { projectId, projectRepoId, branchName }); + return invokeCommand('update_project_repo_branch_name', { projectId, projectRepoId, branchName }); } export function removeProjectRepo(projectId: string, projectRepoId: string): Promise { - return invoke('remove_project_repo', { projectId, projectRepoId }); + return invokeCommand('remove_project_repo', { projectId, projectRepoId }); } export function setPrimaryProjectRepo(projectId: string, projectRepoId: string): Promise { - return invoke('set_primary_project_repo', { projectId, projectRepoId }); + return invokeCommand('set_primary_project_repo', { projectId, projectRepoId }); } export function clearProjectRepoReason(projectRepoId: string): Promise { - return invoke('clear_project_repo_reason', { projectRepoId }); + return invokeCommand('clear_project_repo_reason', { projectRepoId }); } export function getSuggestedRepos(projectId: string, limit?: number): Promise { - return invoke('get_suggested_repos', { projectId, limit: limit ?? null }); + return invokeCommand('get_suggested_repos', { projectId, limit: limit ?? null }); } /** List the authenticated user's GitHub organization memberships. */ export function listGithubOrgs(): Promise { - return invoke('list_github_orgs'); + return invokeCommand('list_github_orgs'); } /** List GitHub repositories for the authenticated user or a specific owner. */ export function listGithubRepos(owner?: string): Promise { - return invoke('list_github_repos', { owner: owner ?? null }); + return invokeCommand('list_github_repos', { owner: owner ?? null }); } /** List repositories the authenticated user has recently pushed to. * Returns repos across all orgs, sorted by most recently pushed. */ export function listUserRepos(limit?: number): Promise { - return invoke('list_user_repos', { limit: limit ?? null }); + return invokeCommand('list_user_repos', { limit: limit ?? null }); } /** Fetch a single GitHub repository by owner/repo. * Returns null if the repo doesn't exist or user lacks access. */ export function getGithubRepo(owner: string, repo: string): Promise { - return invoke('get_github_repo', { owner, repo }); + return invokeCommand('get_github_repo', { owner, repo }); } /** Search GitHub repositories for the authenticated user or a specific owner. */ export function searchGithubRepos(query: string, owner?: string): Promise { - return invoke('search_github_repos', { query, owner: owner ?? null }); + return invokeCommand('search_github_repos', { query, owner: owner ?? null }); } /** Check if a repository is likely a monorepo by counting modules in MODULES.yaml. * Returns the module count (0 if file doesn't exist). */ export function checkMonorepoModules(githubRepo: string): Promise { - return invoke('check_monorepo_modules', { githubRepo }); + return invokeCommand('check_monorepo_modules', { githubRepo }); } /** Validate that a subpath exists as a directory in a GitHub repository. */ export function validateSubpath(githubRepo: string, subpath: string): Promise { - return invoke('validate_subpath', { githubRepo, subpath }); + return invokeCommand('validate_subpath', { githubRepo, subpath }); } /** List directories at a given path in a GitHub repository. */ export function listRepoDirectories(githubRepo: string, path: string): Promise { - return invoke('list_repo_directories', { githubRepo, path }); + return invokeCommand('list_repo_directories', { githubRepo, path }); } // ============================================================================= @@ -191,7 +200,7 @@ export function listRepoDirectories(githubRepo: string, path: string): Promise { - return invoke('list_project_notes', { projectId }); + return invokeCommand('list_project_notes', { projectId }); } export function createProjectNote( @@ -199,11 +208,11 @@ export function createProjectNote( title: string, content: string ): Promise { - return invoke('create_project_note', { projectId, title, content }); + return invokeCommand('create_project_note', { projectId, title, content }); } export function deleteProjectNote(noteId: string): Promise { - return invoke('delete_project_note', { noteId }); + return invokeCommand('delete_project_note', { noteId }); } export function startProjectSession( @@ -212,7 +221,7 @@ export function startProjectSession( provider?: string, imageIds?: string[] ): Promise { - return invoke('start_project_session', { + return invokeCommand('start_project_session', { projectId, prompt, provider: provider ?? null, @@ -225,12 +234,12 @@ export function startProjectSession( // ============================================================================= export function listBranchesForProject(projectId: string): Promise { - return invoke('list_branches_for_project', { projectId }); + return invokeCommand('list_branches_for_project', { projectId }); } /** Get a single branch by ID. */ export function getBranch(branchId: string): Promise { - return invoke('get_branch', { branchId }); + return invokeCommand('get_branch', { branchId }); } /** Create a local branch record (DB only — no git worktree yet). @@ -242,19 +251,19 @@ export function createBranch( baseBranch?: string, projectRepoId?: string ): Promise { - return invoke('create_branch', { projectId, branchName, baseBranch, projectRepoId }); + return invokeCommand('create_branch', { projectId, branchName, baseBranch, projectRepoId }); } /** Create the git worktree for a local branch and record its workdir. * Returns the updated branch with worktreePath populated. */ export function setupWorktree(branchId: string): Promise { - return invoke('setup_worktree', { branchId }); + return invokeCommand('setup_worktree', { branchId }); } /** Like setupWorktree, but also runs prerun actions after the worktree is ready. * Used by the retry path so prerun actions aren't skipped on recovery. */ export function setupWorktreeAndRunPrerun(branchId: string): Promise { - return invoke('setup_worktree_and_run_prerun', { branchId }); + return invokeCommand('setup_worktree_and_run_prerun', { branchId }); } /** Import a GitHub PR: fetch its head ref, create a local branch + worktree, @@ -267,7 +276,13 @@ export function setupWorktreeFromPr( baseRef: string, projectRepoId?: string ): Promise { - return invoke('setup_worktree_from_pr', { projectId, prNumber, headRef, baseRef, projectRepoId }); + return invokeCommand('setup_worktree_from_pr', { + projectId, + prNumber, + headRef, + baseRef, + projectRepoId, + }); } /** Create a remote branch record (does not start the workspace). */ @@ -278,7 +293,7 @@ export function createRemoteBranch( baseBranch?: string, projectRepoId?: string ): Promise { - return invoke('create_remote_branch', { + return invokeCommand('create_remote_branch', { projectId, branchName, baseBranch, @@ -289,42 +304,42 @@ export function createRemoteBranch( /** Start the Blox workspace for a remote branch. */ export function startWorkspace(branchId: string): Promise { - return invoke('start_workspace', { branchId }); + return invokeCommand('start_workspace', { branchId }); } /** Resume a suspended Blox workspace. Returns IDs of all affected branches. */ export function resumeWorkspace(workspaceName: string): Promise { - return invoke('resume_workspace', { workspaceName }); + return invokeCommand('resume_workspace', { workspaceName }); } export function deleteBranch(branchId: string): Promise { - return invoke('delete_branch', { branchId }); + return invokeCommand('delete_branch', { branchId }); } export function renameBranch(branchId: string, branchName: string): Promise { - return invoke('rename_branch', { branchId, branchName }); + return invokeCommand('rename_branch', { branchId, branchName }); } /** Return the BLOX_ENV environment variable value, or null if unset. */ export function getBloxEnv(): Promise { - return invoke('get_blox_env'); + return invokeCommand('get_blox_env'); } /** Get info about a remote branch's Blox workspace. */ export function getWorkspaceInfo(branchId: string): Promise { - return invoke('get_workspace_info', { branchId }); + return invokeCommand('get_workspace_info', { branchId }); } /** Poll a remote branch's workspace status and return updated status + workstation ID. */ export function pollWorkspaceStatus(branchId: string): Promise { - return invoke('poll_workspace_status', { branchId }); + return invokeCommand('poll_workspace_status', { branchId }); } /** Poll workspace statuses for multiple branches in a single `sq blox ws list` call. */ export function pollAllWorkspaceStatuses( branchIds: string[] ): Promise> { - return invoke('poll_all_workspace_statuses', { branchIds }); + return invokeCommand('poll_all_workspace_statuses', { branchIds }); } // ============================================================================= @@ -363,9 +378,7 @@ export function getBranchTimeline( } } - const request = invoke('get_branch_timeline', { - branchId, - }) + const request = invokeCommand('get_branch_timeline', { branchId }) .then((timeline) => { if (inFlightTimelines.get(branchId) === request) { timelineCache.set(branchId, { timeline, fetchedAt: Date.now() }); @@ -395,7 +408,7 @@ export function getBranchTimelineWithRevalidation(branchId: string): { } export function refreshBranchGitState(branchId: string): Promise { - return invoke('refresh_branch_git_state', { branchId }); + return invokeCommand('refresh_branch_git_state', { branchId }); } export function invalidateProjectBranchTimelines(branchIds: string[]): void { @@ -407,7 +420,7 @@ export function invalidateProjectBranchTimelines(branchIds: string[]): void { } export function pullBranchFastForward(branchId: string): Promise { - return invoke('pull_branch_ff_only', { branchId }); + return invokeCommand('pull_branch_ff_only', { branchId }); } // ============================================================================= @@ -430,7 +443,7 @@ export function listProjectActions( projectId: string, projectRepoId?: string | null ): Promise { - return invoke('list_project_actions', { projectId, projectRepoId: projectRepoId ?? null }); + return invokeCommand('list_project_actions', { projectId, projectRepoId: projectRepoId ?? null }); } export function updateProjectAction( @@ -441,7 +454,7 @@ export function updateProjectAction( sortOrder: number, autoCommit: boolean ): Promise { - return invoke('update_project_action', { + return invokeCommand('update_project_action', { actionId, name, command, @@ -452,7 +465,7 @@ export function updateProjectAction( } export function deleteProjectAction(actionId: string): Promise { - return invoke('delete_project_action', { actionId }); + return invokeCommand('delete_project_action', { actionId }); } export interface ActionContext { @@ -466,11 +479,11 @@ export interface ActionContext { } export function listActionContexts(): Promise { - return invoke('list_action_contexts'); + return invokeCommand('list_action_contexts'); } export function listRepoActions(githubRepo: string, subpath?: string): Promise { - return invoke('list_repo_actions', { githubRepo, subpath: subpath ?? null }); + return invokeCommand('list_repo_actions', { githubRepo, subpath: subpath ?? null }); } export function createRepoAction( @@ -482,7 +495,7 @@ export function createRepoAction( sortOrder: number, autoCommit: boolean ): Promise { - return invoke('create_repo_action', { + return invokeCommand('create_repo_action', { githubRepo, subpath: subpath ?? null, name, @@ -494,11 +507,11 @@ export function createRepoAction( } export function deleteAllRepoActions(contextId: string): Promise { - return invoke('delete_all_repo_actions', { contextId }); + return invokeCommand('delete_all_repo_actions', { contextId }); } export function deleteActionContext(contextId: string): Promise { - return invoke('delete_action_context', { contextId }); + return invokeCommand('delete_action_context', { contextId }); } // ============================================================================= @@ -507,12 +520,22 @@ export function deleteActionContext(contextId: string): Promise { /** Open a URL in the user's default browser. */ export function openUrl(url: string): Promise { - return invoke('open_url', { url }); + if (!isTauri) { + const opened = window.open(url, '_blank'); + if (!opened) { + window.location.assign(url); + } else { + opened.opener = null; + } + return Promise.resolve(); + } + + return invokeCommand('open_url', { url }); } /** Read a text file from an absolute path (used for Tauri native drag-and-drop). */ export function readTextFile(filePath: string): Promise { - return invoke('read_text_file', { filePath }); + return invokeCommand('read_text_file', { filePath }); } /** Intercept link clicks so they open in the system browser, not the webview. */ @@ -537,7 +560,7 @@ export interface AcpProviderInfo { /** Scan the system for installed ACP-compatible agents. */ export function discoverAcpProviders(): Promise { - return invoke('discover_acp_providers'); + return invokeCommand('discover_acp_providers'); } // ============================================================================= @@ -545,18 +568,18 @@ export function discoverAcpProviders(): Promise { // ============================================================================= export function getSession(sessionId: string): Promise { - return invoke('get_session', { sessionId }); + return invokeCommand('get_session', { sessionId }); } export function getSessionMessages(sessionId: string): Promise { - return invoke('get_session_messages', { sessionId }); + return invokeCommand('get_session_messages', { sessionId }); } export function getSessionMessagesSince( sessionId: string, sinceId: number ): Promise { - return invoke('get_session_messages_since', { sessionId, sinceId }); + return invokeCommand('get_session_messages_since', { sessionId, sinceId }); } /** Create a session and immediately start the agent. */ @@ -565,7 +588,7 @@ export function startSession( workingDir: string, provider?: string ): Promise { - return invoke('start_session', { prompt, workingDir, provider: provider ?? null }); + return invokeCommand('start_session', { prompt, workingDir, provider: provider ?? null }); } /** Send a follow-up message to an existing session. @@ -576,7 +599,7 @@ export function resumeSession( imageIds?: string[], branchId?: string | null ): Promise { - return invoke('resume_session', { + return invokeCommand('resume_session', { sessionId, prompt, imageIds: imageIds ?? null, @@ -585,11 +608,11 @@ export function resumeSession( } export function cancelSession(sessionId: string): Promise { - return invoke('cancel_session', { sessionId }); + return invokeCommand('cancel_session', { sessionId }); } export function deleteSession(sessionId: string): Promise { - return invoke('delete_session', { sessionId }); + return invokeCommand('delete_session', { sessionId }); } /** Start a branch-scoped session (note or commit). */ @@ -601,7 +624,7 @@ export function startBranchSession( imageIds?: string[], launchContext?: BranchSessionLaunchContext ): Promise { - return invoke('start_branch_session', { + return invokeCommand('start_branch_session', { branchId, prompt, sessionType, @@ -620,7 +643,7 @@ export function queueBranchSession( imageIds?: string[], launchContext?: BranchSessionLaunchContext ): Promise { - return invoke('queue_branch_session', { + return invokeCommand('queue_branch_session', { branchId, prompt, sessionType, @@ -633,7 +656,7 @@ export function queueBranchSession( /** Drain queued sessions for a branch — starts the next queued session if any. * Returns true if a session was started, false if the queue was empty. */ export function drainQueuedSessions(branchId: string): Promise { - return invoke('drain_queued_sessions', { + return invokeCommand('drain_queued_sessions', { branchId, provider: null, }); @@ -649,12 +672,12 @@ export function createNote( title: string, content: string ): Promise<{ id: string; title: string; content: string; createdAt: number; updatedAt: number }> { - return invoke('create_note', { branchId, title, content }); + return invokeCommand('create_note', { branchId, title, content }); } /** Delete a note and optionally its linked session. */ export function deleteNote(noteId: string, deleteSession = true): Promise { - return invoke('delete_note', { noteId, deleteSession }); + return invokeCommand('delete_note', { noteId, deleteSession }); } /** Delete a commit (git reset --hard to parent) and optionally its session. @@ -664,17 +687,17 @@ export function deleteCommit( commitSha: string, deleteSession = true ): Promise { - return invoke('delete_commit', { branchId, commitSha, deleteSession }); + return invokeCommand('delete_commit', { branchId, commitSha, deleteSession }); } /** Delete a pending commit (no SHA) by its DB id, optionally its session. */ export function deletePendingCommit(commitId: string, deleteSession = true): Promise { - return invoke('delete_pending_commit', { commitId, deleteSession }); + return invokeCommand('delete_pending_commit', { commitId, deleteSession }); } /** Preview the exact worktree paths that would be reverted or removed. */ export function getWorktreeChangesPreview(branchId: string): Promise { - return invoke('get_worktree_changes_preview', { branchId }); + return invokeCommand('get_worktree_changes_preview', { branchId }); } /** Discard all uncommitted worktree changes after backend safety checks. */ @@ -682,12 +705,12 @@ export function discardWorktreeChanges( branchId: string, expectedPreview?: WorktreeChangesPreview ): Promise { - return invoke('discard_worktree_changes', { branchId, expectedPreview }); + return invokeCommand('discard_worktree_changes', { branchId, expectedPreview }); } /** Delete a review and all its comments, optionally its linked session. */ export function deleteReview(reviewId: string, deleteSession = true): Promise { - return invoke('delete_review', { reviewId, deleteSession }); + return invokeCommand('delete_review', { reviewId, deleteSession }); } // ============================================================================= @@ -708,7 +731,7 @@ export function getDiffFiles( commitSha?: string, scope: DiffScope = 'branch' ): Promise { - return invoke('get_diff_files', { branchId, commitSha, scope }); + return invokeCommand('get_diff_files', { branchId, commitSha, scope }); } /** Get the full diff content for a single file. */ @@ -718,12 +741,12 @@ export function getFileDiff( scope: DiffScope, path: string ): Promise { - return invoke('get_file_diff', { branchId, commitSha, scope, path }); + return invokeCommand('get_file_diff', { branchId, commitSha, scope, path }); } /** Get file content at a specific ref (for reference files). */ export function getFileAtRef(branchId: string, refName: string, path: string): Promise { - return invoke('get_file_at_ref', { branchId, refName, path }); + return invokeCommand('get_file_at_ref', { branchId, refName, path }); } // ============================================================================= @@ -739,7 +762,7 @@ export function ensureReview( commitSha: string, scope: 'branch' | 'commit' ): Promise { - return invoke('ensure_review', { branchId, commitSha, scope }); + return invokeCommand('ensure_review', { branchId, commitSha, scope }); } /** Find an existing review by (branch, commit, scope) without creating one. */ @@ -748,22 +771,22 @@ export function findReview( commitSha: string, scope: 'branch' | 'commit' ): Promise { - return invoke('find_review', { branchId, commitSha, scope }); + return invokeCommand('find_review', { branchId, commitSha, scope }); } /** Get a review by ID with all child data. */ export function getReview(reviewId: string): Promise { - return invoke('get_review', { reviewId }); + return invokeCommand('get_review', { reviewId }); } /** Mark a file as reviewed. */ export function markReviewed(reviewId: string, path: string): Promise { - return invoke('mark_reviewed', { reviewId, path }); + return invokeCommand('mark_reviewed', { reviewId, path }); } /** Unmark a file as reviewed. */ export function unmarkReviewed(reviewId: string, path: string): Promise { - return invoke('unmark_reviewed', { reviewId, path }); + return invokeCommand('unmark_reviewed', { reviewId, path }); } /** Add a comment to a review. */ @@ -774,42 +797,42 @@ export function addComment( spanEnd: number, content: string ): Promise { - return invoke('add_comment', { reviewId, path, spanStart, spanEnd, content }); + return invokeCommand('add_comment', { reviewId, path, spanStart, spanEnd, content }); } /** Update a comment's content. */ export function updateComment(commentId: string, content: string): Promise { - return invoke('update_comment', { commentId, content }); + return invokeCommand('update_comment', { commentId, content }); } /** Delete a comment (soft delete). */ export function deleteComment(commentId: string): Promise { - return invoke('delete_comment', { commentId }); + return invokeCommand('delete_comment', { commentId }); } /** Soft-delete all active comments for a review in one atomic operation. */ export function deleteAllComments(reviewId: string): Promise { - return invoke('delete_all_comments', { reviewId }); + return invokeCommand('delete_all_comments', { reviewId }); } /** Restore a soft-deleted comment. */ export function restoreComment(commentId: string): Promise { - return invoke('restore_comment', { commentId }); + return invokeCommand('restore_comment', { commentId }); } /** Get soft-deleted comments for a review. */ export function getDeletedComments(reviewId: string): Promise { - return invoke('get_deleted_comments', { reviewId }); + return invokeCommand('get_deleted_comments', { reviewId }); } /** Add a reference file to a review. */ export function addReferenceFile(reviewId: string, path: string): Promise { - return invoke('add_reference_file', { reviewId, path }); + return invokeCommand('add_reference_file', { reviewId, path }); } /** Remove a reference file from a review. */ export function removeReferenceFile(reviewId: string, path: string): Promise { - return invoke('remove_reference_file', { reviewId, path }); + return invokeCommand('remove_reference_file', { reviewId, path }); } // ============================================================================= @@ -818,12 +841,12 @@ export function removeReferenceFile(reviewId: string, path: string): Promise { - return invoke('find_fresh_auto_review', { branchId }); + return invokeCommand('find_fresh_auto_review', { branchId }); } /** Mark or unmark a review as auto-generated. */ export function setReviewAuto(reviewId: string, isAuto: boolean): Promise { - return invoke('set_review_auto', { reviewId, isAuto }); + return invokeCommand('set_review_auto', { reviewId, isAuto }); } // ============================================================================= @@ -832,37 +855,37 @@ export function setReviewAuto(reviewId: string, isAuto: boolean): Promise /** Check whether the `sq` CLI is available on this system. */ export function isSqAvailable(): Promise { - return invoke('is_sq_available'); + return invokeCommand('is_sq_available'); } /** Check whether the user is authenticated with Blox. * Resolves if authenticated, rejects with an error message if not. */ export function checkBloxAuth(): Promise { - return invoke('check_blox_auth'); + return invokeCommand('check_blox_auth'); } export function listGitBranches(githubRepo: string): Promise { - return invoke('list_git_branches', { githubRepo }); + return invokeCommand('list_git_branches', { githubRepo }); } export function detectDefaultBranch(githubRepo: string): Promise { - return invoke('detect_default_branch_cmd', { githubRepo }); + return invokeCommand('detect_default_branch_cmd', { githubRepo }); } /** Prune stale remote-tracking refs in the background. * With GitHub-repo-based projects, this is a no-op. */ export function pruneRemoteRefs(githubRepo: string): Promise { - return invoke('prune_remote_refs', { githubRepo }); + return invokeCommand('prune_remote_refs', { githubRepo }); } /** Check whether a branch already exists locally for this project. */ export function checkExistingLocalBranch(projectId: string, branchName: string): Promise { - return invoke('check_existing_local_branch', { projectId, branchName }); + return invokeCommand('check_existing_local_branch', { projectId, branchName }); } /** Fetch a single PR by number. Throws if not found. */ export function getPrForRepo(githubRepo: string, prNumber: number): Promise { - return invoke('get_pr_for_repo', { githubRepo, prNumber }); + return invokeCommand('get_pr_for_repo', { githubRepo, prNumber }); } /** Find the open PR (if any) whose head branch matches `branchName`. */ @@ -870,20 +893,20 @@ export function getPrForBranch( githubRepo: string, branchName: string ): Promise { - return invoke('get_pr_for_branch', { githubRepo, branchName }); + return invokeCommand('get_pr_for_branch', { githubRepo, branchName }); } export function listPullRequests(githubRepo: string): Promise { - return invoke('list_pull_requests', { githubRepo }); + return invokeCommand('list_pull_requests', { githubRepo }); } /** If the repo is a fork, return the parent repo slug (e.g. `"base-owner/repo"`). */ export function getParentRepo(githubRepo: string): Promise { - return invoke('get_parent_repo', { githubRepo }); + return invokeCommand('get_parent_repo', { githubRepo }); } export function listIssues(githubRepo: string): Promise { - return invoke('list_issues', { githubRepo }); + return invokeCommand('list_issues', { githubRepo }); } // ============================================================================= @@ -894,35 +917,35 @@ export function listIssues(githubRepo: string): Promise { * Returns the session ID so the frontend can track progress. * When `draft` is true the PR is created with `--draft`. */ export function createPr(branchId: string, provider?: string, draft?: boolean): Promise { - return invoke('create_pr', { branchId, provider: provider ?? null, draft: draft ?? null }); + return invokeCommand('create_pr', { branchId, provider: provider ?? null, draft: draft ?? null }); } /** Build the GitHub PR URL from the repo's origin remote and a PR number. */ export function getPrUrl(branchId: string, prNumber: number): Promise { - return invoke('get_pr_url', { branchId, prNumber }); + return invokeCommand('get_pr_url', { branchId, prNumber }); } /** Update the PR number stored for a branch. */ export function updateBranchPr(branchId: string, prNumber: number | null): Promise { - return invoke('update_branch_pr', { branchId, prNumber }); + return invokeCommand('update_branch_pr', { branchId, prNumber }); } /** Look up an existing open PR for a branch on GitHub and persist it. * Returns the recovered PR number, or null if no PR exists. */ export function recoverBranchPr(branchId: string): Promise { - return invoke('recover_branch_pr', { branchId }); + return invokeCommand('recover_branch_pr', { branchId }); } /** Check whether a branch has local commits not yet pushed to the remote. */ export function hasUnpushedCommits(branchId: string): Promise { - return invoke('has_unpushed_commits', { branchId }); + return invokeCommand('has_unpushed_commits', { branchId }); } /** Push a branch to its remote via an agent session. * The agent runs git push and can fix pre-push hook failures. * Returns the session ID so the frontend can track progress. */ export function pushBranch(branchId: string, provider?: string, force?: boolean): Promise { - return invoke('push_branch', { + return invokeCommand('push_branch', { branchId, provider: provider ?? null, force: force ?? null, @@ -935,7 +958,7 @@ export function postCommentToGithub( prNumber: number, comment: Comment ): Promise { - return invoke('post_comment_to_github', { branchId, prNumber, comment }); + return invokeCommand('post_comment_to_github', { branchId, prNumber, comment }); } export interface GitHubCommentResult { @@ -953,7 +976,7 @@ export function rebaseBranch( provider?: string, target?: 'base' | 'origin' ): Promise { - return invoke('rebase_branch', { + return invokeCommand('rebase_branch', { branchId, provider: provider ?? null, target: target ?? null, @@ -964,7 +987,7 @@ export function rebaseBranch( * Uses git reset --soft then hands off to AI to write the commit message. * Returns the session ID so the frontend can track progress. */ export function squashCommits(branchId: string, provider?: string): Promise { - return invoke('squash_commits', { + return invokeCommand('squash_commits', { branchId, provider: provider ?? null, }); @@ -993,12 +1016,12 @@ export interface DoctorReport { /** Run all system health checks. */ export function runDoctor(): Promise { - return invoke('run_doctor'); + return invokeCommand('run_doctor'); } /** Run a fix for a doctor check, identified by check ID and fix type. */ export function runDoctorFix(checkId: string, fixType: 'command' | 'bridge'): Promise { - return invoke('run_doctor_fix', { checkId, fixType }); + return invokeCommand('run_doctor_fix', { checkId, fixType }); } // ============================================================================= @@ -1008,17 +1031,17 @@ export function runDoctorFix(checkId: string, fixType: 'command' | 'bridge'): Pr /** Clear stale PR status fields for a branch (e.g. after a push invalidates them). * Emits 'pr-status-cleared' event so the UI drops stale indicators immediately. */ export function clearBranchPrStatus(branchId: string): Promise { - return invoke('clear_branch_pr_status', { branchId }); + return invokeCommand('clear_branch_pr_status', { branchId }); } /** Refresh PR status for a specific branch. Emits 'pr-status-changed' event. */ export function refreshPrStatus(branchId: string): Promise { - return invoke('refresh_pr_status', { branchId }); + return invokeCommand('refresh_pr_status', { branchId }); } /** Refresh PR status for all branches with PRs. */ export function refreshAllPrStatuses(projectId: string): Promise { - return invoke('refresh_all_pr_statuses', { projectId }); + return invokeCommand('refresh_all_pr_statuses', { projectId }); } // ============================================================================= @@ -1032,27 +1055,33 @@ export function createImage( filePath: string, pending?: boolean ): Promise { - return invoke('create_image', { branchId, projectId, filePath, pending }); + if (!isTauri) { + return Promise.reject( + new Error('create_image is only available for desktop file paths; use createImageFromData') + ); + } + + return invokeCommand('create_image', { branchId, projectId, filePath, pending }); } /** Get the filesystem path for a stored image. */ export function getImagePath(imageId: string): Promise { - return invoke('get_image_path', { imageId }); + return invokeCommand('get_image_path', { imageId }); } /** Delete an image record and its stored file. */ export function deleteImage(imageId: string): Promise { - return invoke('delete_image', { imageId }); + return invokeCommand('delete_image', { imageId }); } /** List all images for a branch. */ export function listBranchImages(branchId: string): Promise { - return invoke('list_branch_images', { branchId }); + return invokeCommand('list_branch_images', { branchId }); } /** Get the base64-encoded data URL for an image. */ export function getImageData(imageId: string): Promise { - return invoke('get_image_data', { imageId }); + return invokeCommand('get_image_data', { imageId }); } /** Create an image from base64-encoded data (browser file input / clipboard paste). */ @@ -1064,7 +1093,7 @@ export function createImageFromData( data: string, pending?: boolean ): Promise { - return invoke('create_image_from_data', { + return invokeCommand('create_image_from_data', { branchId, projectId, filename, @@ -1080,7 +1109,7 @@ export function createImageFromData( /** Fetch all repo badges from the store. */ export function getAllRepoBadges(): Promise { - return invoke('get_all_repo_badges'); + return invokeCommand('get_all_repo_badges'); } /** Ensure badges exist for the given (githubRepo, subpath) pairs. @@ -1088,7 +1117,7 @@ export function getAllRepoBadges(): Promise { export function ensureRepoBadges( repos: [string, string][] ): Promise { - return invoke('ensure_repo_badges', { repos }); + return invokeCommand('ensure_repo_badges', { repos }); } /** Update the short name and hue of an existing repo badge. */ @@ -1098,9 +1127,9 @@ export function updateRepoBadge( shortName: string, hue: number ): Promise { - return invoke('update_repo_badge', { githubRepo, subpath, shortName, hue }); + return invokeCommand('update_repo_badge', { githubRepo, subpath, shortName, hue }); } export function deleteRepoBadge(githubRepo: string, subpath: string): Promise { - return invoke('delete_repo_badge', { githubRepo, subpath }); + return invokeCommand('delete_repo_badge', { githubRepo, subpath }); } diff --git a/apps/staged/src/lib/features/actions/actions.ts b/apps/staged/src/lib/features/actions/actions.ts index 5c7baf309..f23358746 100644 --- a/apps/staged/src/lib/features/actions/actions.ts +++ b/apps/staged/src/lib/features/actions/actions.ts @@ -6,8 +6,7 @@ * that can be run in branch worktrees with real-time output streaming. */ -import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { invokeCommand, listenToEvent, type UnlistenFn } from '../../transport'; /** Action types available for project actions. */ export type ActionType = 'build' | 'test' | 'format' | 'check' | 'prerun' | 'run' | 'cleanUp'; @@ -116,7 +115,10 @@ export function detectRepoActions( githubRepo: string, subpath?: string ): Promise { - return invoke('detect_repo_actions', { githubRepo, subpath: subpath ?? null }); + return invokeCommand('detect_repo_actions', { + githubRepo, + subpath: subpath ?? null, + }); } /** @@ -124,14 +126,14 @@ export function detectRepoActions( * Returns an execution ID that can be used to track status and stop the action. */ export function runBranchAction(branchId: string, actionId: string): Promise { - return invoke('run_branch_action', { branchId, actionId }); + return invokeCommand('run_branch_action', { branchId, actionId }); } /** * Stop a running action by execution ID. */ export function stopBranchAction(executionId: string): Promise { - return invoke('stop_branch_action', { executionId }); + return invokeCommand('stop_branch_action', { executionId }); } /** @@ -174,7 +176,7 @@ export async function stopBranchActionWithState( * Returns an array of running action info with execution IDs, action details, and timestamps. */ export function getRunningBranchActions(branchId: string): Promise { - return invoke('get_running_branch_actions', { branchId }); + return invokeCommand('get_running_branch_actions', { branchId }); } /** @@ -182,7 +184,7 @@ export function getRunningBranchActions(branchId: string): Promise { - return invoke('get_action_output_buffer', { executionId }); + return invokeCommand('get_action_output_buffer', { executionId }); } /** @@ -190,7 +192,7 @@ export function getActionOutputBuffer(executionId: string): Promise { - return invoke('clear_action_execution', { executionId }); + return invokeCommand('clear_action_execution', { executionId }); } /** @@ -198,7 +200,7 @@ export function clearActionExecution(executionId: string): Promise { * Returns an array of execution IDs for the started actions. */ export function runPrerunActions(branchId: string): Promise { - return invoke('run_prerun_actions', { branchId }); + return invokeCommand('run_prerun_actions', { branchId }); } /** @@ -208,9 +210,7 @@ export function runPrerunActions(branchId: string): Promise { export function listenToActionOutput( callback: (event: ActionOutputEvent) => void ): Promise { - return listen('action_output', (event) => { - callback(event.payload); - }); + return listenToEvent('action_output', callback); } /** @@ -220,9 +220,7 @@ export function listenToActionOutput( export function listenToActionStatus( callback: (event: ActionStatusEvent) => void ): Promise { - return listen('action_status', (event) => { - callback(event.payload); - }); + return listenToEvent('action_status', callback); } /** @@ -232,18 +230,14 @@ export function listenToActionStatus( export function listenToActionAutoCommit( callback: (event: ActionAutoCommitEvent) => void ): Promise { - return listen('action_auto_commit', (event) => { - callback(event.payload); - }); + return listenToEvent('action_auto_commit', callback); } /** Listen for repo action detection start/stop updates. */ export function listenToRepoActionsDetection( callback: (event: RepoActionsDetectionEvent) => void ): Promise { - return listen('repo-actions-detection', (event) => { - callback(event.payload); - }); + return listenToEvent('repo-actions-detection', callback); } /** @@ -251,14 +245,14 @@ export function listenToRepoActionsDetection( * Returns null if the execution is not found or has no phase. */ export function getRunPhase(executionId: string): Promise { - return invoke('get_run_phase', { executionId }); + return invokeCommand('get_run_phase', { executionId }); } /** * Update the run detection mode for an action. */ export function updateRunDetectionMode(actionId: string, mode: RunDetectionMode): Promise { - return invoke('update_run_detection_mode', { actionId, mode }); + return invokeCommand('update_run_detection_mode', { actionId, mode }); } /** @@ -268,7 +262,5 @@ export function updateRunDetectionMode(actionId: string, mode: RunDetectionMode) export function listenToRunPhaseChanged( callback: (event: RunPhaseChangedEvent) => void ): Promise { - return listen('action:run-phase-changed', (event) => { - callback(event.payload); - }); + return listenToEvent('action:run-phase-changed', callback); } diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index cccfad462..af513d8e4 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -22,7 +22,7 @@ GitPullRequestDraft, } from 'lucide-svelte'; import Spinner from '../../shared/Spinner.svelte'; - import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + import { listenToEvent, type UnlistenFn } from '../../transport'; import { subscribeDragDrop } from './dragDrop'; import type { Branch, @@ -207,12 +207,15 @@ const unlisteners: (() => void)[] = []; for (const eventName of eventNames) { - listen<{ branchId: string; phase: string; detail: string | null }>(eventName, (event) => { - if (event.payload.branchId === branch.id) { - setupPhase = event.payload.phase; - setupDetail = event.payload.detail; + listenToEvent<{ branchId: string; phase: string; detail: string | null }>( + eventName, + (payload) => { + if (payload.branchId === branch.id) { + setupPhase = payload.phase; + setupDetail = payload.detail; + } } - }).then((fn) => { + ).then((fn) => { if (cancelled) fn(); else unlisteners.push(fn); }); @@ -597,13 +600,8 @@ $effect(() => { const branchId = branch.id; - listen('session-status-changed', (event) => { - const { - sessionId: eventSessionId, - status, - branchId: eventBranchId, - isAutoReview, - } = event.payload; + listenToEvent('session-status-changed', (payload) => { + const { sessionId: eventSessionId, status, branchId: eventBranchId, isAutoReview } = payload; if (status === 'completed' || status === 'error' || status === 'cancelled') { // If this is the auto review session completing, just clear tracking if (eventSessionId === sessionMgr.autoReviewSessionId) { @@ -679,13 +677,16 @@ $effect(() => { const branchId = branch.id; - listen<{ branchId: string; gitState: BranchGitState }>('git-state-updated', (event) => { - if (event.payload.branchId !== branchId) return; - if (timeline) { - timeline = { ...timeline, gitState: event.payload.gitState }; + listenToEvent<{ branchId: string; gitState: BranchGitState }>( + 'git-state-updated', + (payload) => { + if (payload.branchId !== branchId) return; + if (timeline) { + timeline = { ...timeline, gitState: payload.gitState }; + } + refreshingGitState = false; } - refreshingGitState = false; - }).then((unlisten) => { + ).then((unlisten) => { unlistenGitState = unlisten; }); diff --git a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte index 7e296e64a..7b696743f 100644 --- a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte @@ -27,7 +27,7 @@ import Spinner from '../../shared/Spinner.svelte'; import SineWave from '../../shared/SineWave.svelte'; import ActionOutputModal from '../actions/ActionOutputModal.svelte'; - import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + import { listenToEvent, type UnlistenFn } from '../../transport'; import type { Branch, ProjectRepo } from '../../types'; import * as commands from '../../api/commands'; import type { ProjectAction } from '../../api/commands'; @@ -208,9 +208,7 @@ $effect(() => { const branchId = branch.id; - listen('action_status', (event) => { - const payload = event.payload; - + listenToEvent('action_status', (payload) => { // Only process events for this branch if (payload.branchId !== branchId) { return; diff --git a/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte b/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte index ebef5e5aa..fc4c3c72f 100644 --- a/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte @@ -16,7 +16,7 @@ import Spinner from '../../shared/Spinner.svelte'; import ConfirmDialog from '../../shared/ConfirmDialog.svelte'; import { minuteNow, secondNow } from '../../shared/relativeTime.svelte'; - import { listen } from '@tauri-apps/api/event'; + import { listenToEvent } from '../../transport'; import type { Branch, BranchTimeline as BranchTimelineData, @@ -158,29 +158,31 @@ $effect(() => { const branchId = branch.id; - const unlistenStatusPromise = listen('pr-status-changed', (event) => { - const payload = event.payload; - if (payload.branchId === branchId) { - prStatusState = payload.prState; - prStatusChecks = payload.prChecksStatus; - prStatusReviewDecision = payload.prReviewDecision; - prStatusMergeable = payload.prMergeable; - prStatusDraft = payload.prDraft; - prHeadSha = payload.prHeadSha; - prFetchedAt = payload.prFetchedAt; - failedChecks = payload.failedChecks ?? []; - prStatusCleared = false; - // Update the polling service with the new checks status - prPollingService.updateChecksStatus( - branchId, - branch.projectId, - payload.prChecksStatus === 'PENDING' - ); + const unlistenStatusPromise = listenToEvent( + 'pr-status-changed', + (payload) => { + if (payload.branchId === branchId) { + prStatusState = payload.prState; + prStatusChecks = payload.prChecksStatus; + prStatusReviewDecision = payload.prReviewDecision; + prStatusMergeable = payload.prMergeable; + prStatusDraft = payload.prDraft; + prHeadSha = payload.prHeadSha; + prFetchedAt = payload.prFetchedAt; + failedChecks = payload.failedChecks ?? []; + prStatusCleared = false; + // Update the polling service with the new checks status + prPollingService.updateChecksStatus( + branchId, + branch.projectId, + payload.prChecksStatus === 'PENDING' + ); + } } - }); + ); - const unlistenClearedPromise = listen('pr-status-cleared', (event) => { - if (event.payload === branchId) { + const unlistenClearedPromise = listenToEvent('pr-status-cleared', (clearedBranchId) => { + if (clearedBranchId === branchId) { prStatusState = null; prStatusChecks = null; prStatusReviewDecision = null; diff --git a/apps/staged/src/lib/features/branches/branch.ts b/apps/staged/src/lib/features/branches/branch.ts index 7f570ac8b..04a052a0a 100644 --- a/apps/staged/src/lib/features/branches/branch.ts +++ b/apps/staged/src/lib/features/branches/branch.ts @@ -1,5 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; -import { writeText } from '@tauri-apps/plugin-clipboard-manager'; +import { invokeCommand, isTauri, writeClipboardText } from '../../transport'; /** An application that can open a directory */ export interface OpenerApp { @@ -17,7 +16,12 @@ let cachedOpeners: OpenerApp[] | null = null; */ export async function getAvailableOpeners(): Promise { if (cachedOpeners !== null) return cachedOpeners; - cachedOpeners = await invoke('get_available_openers'); + if (!isTauri) { + cachedOpeners = []; + return cachedOpeners; + } + + cachedOpeners = await invokeCommand('get_available_openers'); return cachedOpeners; } @@ -25,12 +29,16 @@ export async function getAvailableOpeners(): Promise { * Open a directory in a specific application. */ export async function openInApp(path: string, appId: string): Promise { - return invoke('open_in_app', { path, appId }); + if (!isTauri) { + throw new Error('open_in_app is not available in web mode'); + } + + return invokeCommand('open_in_app', { path, appId }); } /** * Copy a path to the clipboard. */ export async function copyPathToClipboard(path: string): Promise { - await writeText(path); + await writeClipboardText(path); } diff --git a/apps/staged/src/lib/features/branches/dragDrop.ts b/apps/staged/src/lib/features/branches/dragDrop.ts index 4a63c50ee..fa3637d82 100644 --- a/apps/staged/src/lib/features/branches/dragDrop.ts +++ b/apps/staged/src/lib/features/branches/dragDrop.ts @@ -11,7 +11,8 @@ * multiple branch cards were rendered. */ -import type { UnlistenFn } from '@tauri-apps/api/event'; +import type { UnlistenFn } from '../../transport'; +import { isTauri } from '../../transport'; export type DragDropSubscription = { /** The card's root DOM element, used for hit-testing. */ @@ -99,6 +100,12 @@ function handleEvent(type: string, x: number, y: number, paths?: string[]) { function ensureGlobalListener(): Promise { if (initPromise) return initPromise; + if (!isTauri) { + // Native drag-drop is a Tauri-only feature; no-op in web mode + initPromise = Promise.resolve(); + return initPromise; + } + initPromise = import('@tauri-apps/api/webview').then(({ getCurrentWebview }) => { return getCurrentWebview() .onDragDropEvent((event) => { diff --git a/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte b/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte index a9cad3903..63e6621ac 100644 --- a/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte +++ b/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte @@ -1,6 +1,6 @@ + + + + diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index 92952e5e2..d69a41a27 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -6,8 +6,7 @@ -->