diff --git a/apps/mark/src-tauri/Cargo.lock b/apps/mark/src-tauri/Cargo.lock index c6dc3564..645d96c4 100644 --- a/apps/mark/src-tauri/Cargo.lock +++ b/apps/mark/src-tauri/Cargo.lock @@ -117,9 +117,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "arboard" @@ -336,6 +336,58 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -666,8 +718,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -1958,6 +2012,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1972,6 +2032,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2528,6 +2589,7 @@ dependencies = [ "acp-client", "anyhow", "async-trait", + "axum", "base64 0.22.1", "blox-cli", "builderbot-actions", @@ -2537,6 +2599,7 @@ dependencies = [ "git2", "log", "reqwest", + "rmcp", "rusqlite", "serde", "serde_json", @@ -2587,6 +2650,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -3091,6 +3160,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3863,6 +3938,50 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmcp" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa07b85b779d1e1df52dd79f6c6bffbe005b191f07290136cc42a142da3409a" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "paste", + "pin-project-lite", + "rand 0.9.2", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f6fa09933cac0d0204c8a5d647f558425538ed6a0134b1ebb1ae4dc00c96db3" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.116", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -4013,6 +4132,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4064,6 +4189,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive 1.2.1", @@ -4224,6 +4350,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4253,6 +4390,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.16.1" @@ -4449,6 +4598,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5198,6 +5360,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5321,6 +5494,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5359,6 +5533,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/apps/mark/src-tauri/Cargo.toml b/apps/mark/src-tauri/Cargo.toml index 39d1e109..b5ea4289 100644 --- a/apps/mark/src-tauri/Cargo.toml +++ b/apps/mark/src-tauri/Cargo.toml @@ -55,6 +55,10 @@ tokio-util = { version = "0.7", features = ["compat"] } futures = "0.3" tauri-plugin-store = "2.4.2" +# MCP server for project sessions +rmcp = { version = "0.9", features = ["server", "transport-streamable-http-server"] } +axum = { version = "0.8" } + # Debug binaries archived — uncomment when needed # [[bin]] # name = "debug_diff" diff --git a/apps/mark/src-tauri/src/actions/commands.rs b/apps/mark/src-tauri/src/actions/commands.rs index 0c27ecaf..8862d08b 100644 --- a/apps/mark/src-tauri/src/actions/commands.rs +++ b/apps/mark/src-tauri/src/actions/commands.rs @@ -31,7 +31,7 @@ struct DetectingActionsEvent { detecting: bool, } -async fn detect_actions_for_repo_context( +pub(crate) async fn detect_actions_for_repo_context( github_repo: &str, subpath: Option<&str>, ) -> Result, String> { diff --git a/apps/mark/src-tauri/src/branches.rs b/apps/mark/src-tauri/src/branches.rs index d3234f11..59ff7ea6 100644 --- a/apps/mark/src-tauri/src/branches.rs +++ b/apps/mark/src-tauri/src/branches.rs @@ -1,6 +1,10 @@ use std::path::Path; use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter}; + +use crate::actions::events::TauriExecutionListener; +use crate::actions::{ActionExecutor, ActionMetadata, ActionRegistry, ActionType}; use crate::blox; use crate::git; use crate::store::{self, Store}; @@ -484,9 +488,16 @@ pub async fn setup_worktree( return Ok(to_branch_with_workdir(branch, Some(existing.path))); } - // Ensure we have a local clone (clones on first use, fetches on subsequent) + // Ensure we have a local clone, then fetch the specific refs we need. let repo_slug = resolve_branch_repo_slug(&store, &project, &branch)?; let repo_path = git::ensure_local_clone(&repo_slug).map_err(|e| e.to_string())?; + git::fetch_for_worktree( + &repo_path, + &repo_slug, + &branch.branch_name, + &branch.base_branch, + ) + .map_err(|e| e.to_string())?; let desired_worktree_path = git::project_worktree_path_for(&branch.project_id, &repo_slug, &branch.branch_name) .map_err(|e| e.to_string())?; @@ -1077,3 +1088,311 @@ pub async fn rename_branch( .map(|w| w.path); Ok(to_branch_with_workdir(updated, workdir)) } + +/// Set up a git worktree for a branch synchronously. +/// +/// This replicates the core logic from `branches::setup_worktree` without +/// requiring Tauri state, so it can be called from the MCP server. +pub(crate) fn setup_worktree_sync(store: &Arc, branch_id: &str) -> Result { + 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))?; + + // Idempotent fast-path: if the branch already has a workdir, reuse it. + if let Some(existing) = store + .get_workdir_for_branch(&branch.id) + .map_err(|e| e.to_string())? + { + return Ok(existing.path); + } + + // Resolve the repo slug for this branch + let repo_slug = 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())?; + + // Reuse any existing worktree for this branch; otherwise create one. + let existing_worktree_path = crate::git::list_worktrees(&repo_path) + .map_err(|e| e.to_string())? + .into_iter() + .find_map(|(path, wt_branch)| match wt_branch.as_deref() { + Some(name) if name == branch.branch_name => Some(path), + _ => None, + }); + + let worktree_path = if let Some(path) = existing_worktree_path { + path + } else if crate::git::branch_exists(&repo_path, &branch.branch_name) + .map_err(|e| e.to_string())? + { + crate::git::create_worktree_for_existing_branch_at_path( + &repo_path, + &branch.branch_name, + &desired_worktree_path, + ) + .map_err(|e| e.to_string())? + } else { + match crate::git::create_worktree_at_path( + &repo_path, + &branch.branch_name, + &branch.base_branch, + &desired_worktree_path, + ) { + Ok(path) => path, + Err(create_err) => { + if crate::git::branch_exists(&repo_path, &branch.branch_name) + .map_err(|e| e.to_string())? + { + log::warn!( + "[project_mcp] Branch '{}' already exists after create attempt; retrying with existing branch", + branch.branch_name + ); + crate::git::create_worktree_for_existing_branch_at_path( + &repo_path, + &branch.branch_name, + &desired_worktree_path, + ) + .map_err(|e| e.to_string())? + } else { + return Err(create_err.to_string()); + } + } + } + }; + + let worktree_str = worktree_path + .to_str() + .ok_or("Invalid worktree path")? + .to_string(); + + // Link this path to the branch in DB (create or assign existing record). + let tracked_workdir = store + .list_workdirs_for_project(&branch.project_id) + .map_err(|e| e.to_string())? + .into_iter() + .find(|wd| wd.path == worktree_str); + + match tracked_workdir { + Some(wd) => match wd.branch_id.as_deref() { + Some(existing_branch_id) if existing_branch_id != branch.id => { + return Err(format!( + "Worktree '{}' is already assigned to another branch", + wd.path + )); + } + Some(_) => {} + None => { + store + .assign_workdir(&wd.id, &branch.id) + .map_err(|e| e.to_string())?; + } + }, + None => { + let workdir = crate::store::Workdir::new(&branch.project_id, &worktree_str) + .with_branch(&branch.id); + store.create_workdir(&workdir).map_err(|e| e.to_string())?; + } + } + + Ok(worktree_str) +} + +/// Run detect_actions (if needed) and all prerun actions for a branch. +/// +/// This replicates the core logic from `actions::commands::run_prerun_actions` +/// without requiring Tauri state. +pub(crate) async fn run_prerun_actions_for_branch( + store: &Arc, + app_handle: &AppHandle, + branch_id: &str, + executor: &Arc, + act_registry: &Arc, +) -> Result { + let branch = store + .get_branch(branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "Branch not found".to_string())?; + + let project = store + .get_project(&branch.project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "Project not found".to_string())?; + + // Resolve the repo/subpath for this branch + let (github_repo, subpath) = if let Some(project_repo_id) = &branch.project_repo_id { + let project_repo = store + .get_project_repo(project_repo_id) + .map_err(|e| format!("Failed to get project repo: {e}"))? + .ok_or_else(|| format!("Project repo not found: {project_repo_id}"))?; + (project_repo.github_repo, project_repo.subpath) + } else { + let repo = project + .primary_repo() + .ok_or_else(|| "Project has no repository attached".to_string())?; + (repo.to_string(), project.subpath.clone()) + }; + + let context = store + .get_or_create_action_context(&github_repo, subpath.as_deref()) + .map_err(|e| format!("Failed to get action context: {e}"))?; + + // If actions haven't been detected yet for this repo+subpath, detect now + if !context.has_detected_actions { + log::info!( + "[project_mcp] detecting actions for repo {} (subpath: {:?})", + github_repo, + subpath + ); + store + .set_action_context_detecting(&context.id, true) + .map_err(|e| format!("Failed to set detection status: {e}"))?; + + let _ = app_handle.emit( + "repo-actions-detection", + serde_json::json!({ + "githubRepo": github_repo, + "subpath": subpath, + "detecting": true, + }), + ); + + // Run detection (may call out to AI) + let detected = crate::actions::commands::detect_actions_for_repo_context( + &github_repo, + subpath.as_deref(), + ) + .await + .unwrap_or_default(); + + // Persist detected actions (skip duplicates) + let existing_actions = store + .list_repo_actions(&context.id) + .map_err(|e| format!("Failed to list actions: {e}"))?; + let mut existing_commands: std::collections::HashSet = + existing_actions.iter().map(|a| a.command.clone()).collect(); + let mut next_sort_order = existing_actions + .iter() + .map(|a| a.sort_order) + .max() + .unwrap_or(-1) + + 1; + + for suggestion in detected { + if existing_commands.contains(&suggestion.command) { + continue; + } + existing_commands.insert(suggestion.command.clone()); + let action = crate::store::RepoAction::new( + context.id.clone(), + suggestion.name, + suggestion.command, + suggestion.action_type, + next_sort_order, + ) + .with_auto_commit(suggestion.auto_commit); + store + .create_repo_action(&action) + .map_err(|e| format!("Failed to create detected action: {e}"))?; + next_sort_order += 1; + } + + store + .mark_action_context_detected(&context.id) + .map_err(|e| format!("Failed to update detection status: {e}"))?; + + let _ = app_handle.emit( + "repo-actions-detection", + serde_json::json!({ + "githubRepo": github_repo, + "subpath": subpath, + "detecting": false, + }), + ); + } + + // Get all prerun actions for this context + let actions = store + .list_repo_actions(&context.id) + .map_err(|e| format!("Failed to list actions: {e}"))?; + let prerun_actions: Vec<_> = actions + .into_iter() + .filter(|a| matches!(a.action_type, ActionType::Prerun)) + .collect(); + + if prerun_actions.is_empty() { + return Ok(0); + } + + // Get the worktree path for this branch + let workdir = store + .get_workdir_for_branch(branch_id) + .map_err(|e| format!("Failed to get workdir: {e}"))? + .ok_or_else(|| "No worktree found for branch".to_string())?; + + let working_dir = if let Some(ref sp) = subpath { + std::path::PathBuf::from(&workdir.path) + .join(sp) + .to_string_lossy() + .to_string() + } else { + workdir.path + }; + + // Execute each prerun action, waiting for each to complete + let mut count = 0; + for action in prerun_actions { + let listener = Arc::new(TauriExecutionListener::new( + app_handle.clone(), + branch_id.to_string(), + action.id.clone(), + action.name.clone(), + Arc::clone(act_registry), + )); + + let metadata = ActionMetadata { + action_id: action.id.clone(), + action_name: action.name.clone(), + auto_commit: action.auto_commit, + }; + + // execute_and_wait runs the action and waits for it to finish, + // regardless of success or failure (task requirement) + match executor + .execute_and_wait(action.command, working_dir.clone(), metadata, listener) + .await + { + Ok(_execution_id) => { + count += 1; + log::info!( + "[project_mcp] prerun action '{}' completed for branch {}", + action.id, + branch_id + ); + } + Err(e) => { + log::warn!( + "[project_mcp] prerun action '{}' failed (continuing): {e}", + action.id + ); + count += 1; // count even if failed — we waited for it + } + } + } + + Ok(count) +} diff --git a/apps/mark/src-tauri/src/git/github.rs b/apps/mark/src-tauri/src/git/github.rs index 85bfc5dd..df20dc75 100644 --- a/apps/mark/src-tauri/src/git/github.rs +++ b/apps/mark/src-tauri/src/git/github.rs @@ -594,8 +594,10 @@ fn remove_stale_clone_dir(clone_path: &Path) -> Result<(), GitError> { /// Ensure a local clone exists at `///`. /// -/// If the directory already exists, runs `git fetch origin` to update. -/// If not, clones the repo. Returns the path to the local clone. +/// If the directory already exists, returns the path immediately without +/// fetching. Callers that need fresh refs should call [`fetch_for_worktree`] +/// afterwards. If not cloned yet, clones the repo. Returns the path to the +/// local clone. pub fn ensure_local_clone(github_repo: &str) -> Result { let repos = crate::paths::repos_dir() .ok_or_else(|| GitError::CommandFailed("Cannot determine data directory".to_string()))?; @@ -604,17 +606,6 @@ pub fn ensure_local_clone(github_repo: &str) -> Result Result Result<(), GitError> { + let https_url = format!("https://github.com/{github_repo}.git"); + + // Strip any "origin/" prefix — base_branch is stored in the DB with this + // prefix (normalised at creation time) but the remote tracks the bare ref. + let base_ref = base_branch.strip_prefix("origin/").unwrap_or(base_branch); + let branch_ref = branch_name.strip_prefix("origin/").unwrap_or(branch_name); + + // Fetch the base branch — always needed, always exists on the remote. + if let Err(e) = super::cli::run(repo_path, &["fetch", "origin", base_ref]) { + let err_str = e.to_string(); + if err_str.contains("incorrect old value provided") { + // Ref-update CAS race: a concurrent fetch already updated + // refs/remotes/origin/ between when git read the old + // value and when it tried to write the new one. The downloaded + // data and FETCH_HEAD are valid; the tracking ref is at least as + // up-to-date as we need. + log::warn!( + "fetch origin {} for '{}' hit a ref-update race (non-fatal): {}", + base_ref, + github_repo, + e + ); + } else { + log::warn!( + "fetch origin {} failed for '{}': {}. Retrying with HTTPS origin.", + base_ref, + github_repo, + e + ); + super::cli::run(repo_path, &["remote", "set-url", "origin", &https_url])?; + super::cli::run(repo_path, &["fetch", "origin", base_ref])?; + } + } + + // Best-effort fetch of the branch itself — it may not exist on the remote + // yet for new local branches. + if branch_ref != base_ref { + if let Err(e) = super::cli::run(repo_path, &["fetch", "origin", branch_ref]) { + let err_str = e.to_string(); + if !err_str.contains("couldn't find remote ref") { + log::warn!( + "fetch origin {} for '{}' failed (non-fatal): {}", + branch_ref, + github_repo, + e + ); + } + } + } + + Ok(()) +} + // ============================================================================= // PR and Issue listing (local-dir-based, legacy) // ============================================================================= diff --git a/apps/mark/src-tauri/src/git/mod.rs b/apps/mark/src-tauri/src/git/mod.rs index f66ce5f8..76c802b9 100644 --- a/apps/mark/src-tauri/src/git/mod.rs +++ b/apps/mark/src-tauri/src/git/mod.rs @@ -13,11 +13,11 @@ pub use diff::{get_file_diff, get_unified_diff, list_diff_files}; pub use files::{get_file_at_ref, search_files}; pub use github::{ check_github_auth, check_monorepo_modules, create_pull_request, detect_default_branch_for_repo, - ensure_local_clone, fetch_github_repo, fetch_pr, fetch_pr_status, fetch_pr_status_for_repo, - get_pr_for_branch, invalidate_cache as invalidate_pr_cache, list_branches_for_repo, - list_github_orgs, list_github_repos, list_issues, list_issues_for_repo, list_pull_requests, - list_pull_requests_for_repo, list_user_repos, prune_remote_for_repo, push_branch, - search_github_repos, search_issues, search_pull_requests, sync_review_to_github, + ensure_local_clone, fetch_for_worktree, fetch_github_repo, fetch_pr, fetch_pr_status, + fetch_pr_status_for_repo, get_pr_for_branch, invalidate_cache as invalidate_pr_cache, + list_branches_for_repo, list_github_orgs, list_github_repos, list_issues, list_issues_for_repo, + list_pull_requests, list_pull_requests_for_repo, list_user_repos, prune_remote_for_repo, + push_branch, search_github_repos, search_issues, search_pull_requests, sync_review_to_github, update_pull_request, ChecksSummary, CreatePrResult, GitHubAuthStatus, GitHubRepo, GitHubSyncResult, Issue, PrStatus, PullRequest, PullRequestInfo, }; diff --git a/apps/mark/src-tauri/src/git/worktree.rs b/apps/mark/src-tauri/src/git/worktree.rs index 8524b912..b0fe43d6 100644 --- a/apps/mark/src-tauri/src/git/worktree.rs +++ b/apps/mark/src-tauri/src/git/worktree.rs @@ -122,19 +122,14 @@ pub fn create_worktree_at_path( ensure_worktree_parent_exists(worktree_path)?; ensure_worktree_absent(worktree_path)?; - // Always branch from the remote tip on origin, not from a (potentially - // stale) local branch. Normalise the start point to "origin/" - // form and fetch the latest before creating the worktree. - let (remote_start, fetch_ref) = if let Some(rest) = start_point.strip_prefix("origin/") { - (start_point.to_string(), rest.to_string()) + // Normalise the start point to "origin/" form. + // The caller (setup_worktree) already fetched via fetch_for_worktree. + let remote_start = if start_point.starts_with("origin/") { + start_point.to_string() } else { - (format!("origin/{start_point}"), start_point.to_string()) + format!("origin/{start_point}") }; - // Best-effort fetch — if it fails (e.g. offline) we still use whatever - // the local remote-tracking ref currently points to. - let _ = cli::run(repo, &["fetch", "origin", &fetch_ref]); - let worktree_str = worktree_path .to_str() .ok_or_else(|| GitError::InvalidPath(worktree_path.display().to_string()))?; diff --git a/apps/mark/src-tauri/src/lib.rs b/apps/mark/src-tauri/src/lib.rs index 495c5074..c4e0b34e 100644 --- a/apps/mark/src-tauri/src/lib.rs +++ b/apps/mark/src-tauri/src/lib.rs @@ -10,6 +10,8 @@ pub mod branches; pub mod doctor; pub mod git; pub mod paths; +pub mod project_commands; +pub mod project_mcp; pub mod prs; pub mod session_commands; pub mod session_runner; @@ -240,6 +242,7 @@ fn list_projects( #[tauri::command(rename_all = "camelCase")] fn create_project( store: tauri::State<'_, Mutex>>>, + app_handle: tauri::AppHandle, name: String, github_repo: Option, location: Option, @@ -264,6 +267,13 @@ fn create_project( project = project.with_subpath(sub); } store.create_project(&project).map_err(|e| e.to_string())?; + + // Create the project-scoped worktree root so project sessions always + // have a real directory to run in, even before any repos are attached. + if let Ok(project_dir) = 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()) @@ -286,7 +296,6 @@ fn create_project( // Create the initial branch record for the first repo so each new // project starts with exactly one branch tracked for that repository. - // Worktree/workspace setup runs asynchronously from the frontend. let detected_base = git::detect_default_branch_for_repo(&repo).unwrap_or_else(|_| "main".to_string()); let effective_base = if detected_base.starts_with("origin/") { @@ -295,12 +304,13 @@ fn create_project( format!("origin/{detected_base}") }; - match project.location { + let branch_id = match project.location { store::ProjectLocation::Local => { let branch = store::Branch::new(&project.id, &inferred_branch_name, &effective_base) .with_project_repo(&project_repo.id); store.create_branch(&branch).map_err(|e| e.to_string())?; + Some(branch.id) } store::ProjectLocation::Remote => { let workspace_name = branches::infer_workspace_name(&inferred_branch_name); @@ -312,7 +322,59 @@ fn create_project( ) .with_project_repo(&project_repo.id); store.create_branch(&branch).map_err(|e| e.to_string())?; + None // remote branches don't need worktree setup } + }; + + // Spawn background worktree + prerun-actions setup for local branches. + if let Some(branch_id) = branch_id { + 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()); + + let store_clone = Arc::clone(&store_bg); + let branch_id_clone = branch_id.clone(); + let worktree_result = tauri::async_runtime::spawn_blocking(move || { + branches::setup_worktree_sync(&store_clone, &branch_id_clone) + }) + .await; + + match worktree_result { + Ok(Ok(path)) => { + log::info!("[create_project] worktree ready at {path}"); + let _ = app_handle.emit("project-setup-progress", project_id.clone()); + } + Ok(Err(e)) => { + log::warn!("[create_project] worktree setup failed: {e}"); + return; + } + Err(e) => { + log::warn!("[create_project] worktree task panicked: {e}"); + return; + } + } + + let executor = app_handle.state::>(); + let act_registry = app_handle.state::>(); + match branches::run_prerun_actions_for_branch( + &store_bg, + &app_handle, + &branch_id, + &executor, + &act_registry, + ) + .await + { + Ok(count) => { + log::info!("[create_project] ran {count} prerun actions"); + let _ = app_handle.emit("project-setup-progress", project_id); + } + Err(e) => { + log::warn!("[create_project] prerun actions failed: {e}"); + } + } + }); } } @@ -345,6 +407,7 @@ fn list_recent_repos( #[tauri::command(rename_all = "camelCase")] async fn add_project_repo( store: tauri::State<'_, Mutex>>>, + app_handle: tauri::AppHandle, project_id: String, github_repo: String, branch_name: Option, @@ -352,125 +415,100 @@ async fn add_project_repo( set_as_primary: Option, ) -> Result { let store = get_store(&store)?; - let project = store - .get_project(&project_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project not found: {project_id}"))?; - let resolved_branch_name = branch_name - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| branches::infer_branch_name(&project.name)); - let repo_subpath = if project.location == store::ProjectLocation::Remote { - let requested = subpath - .as_deref() - .map(branches::validate_workspace_subpath) - .transpose()?; - Some( - requested - .map(|s| { - if s.starts_with("repo:") || s.starts_with("repos/") { - s - } else { - format!("repo:{s}") - } - }) - .unwrap_or_else(|| branches::infer_remote_repo_subpath(&github_repo)), - ) - } else { - subpath.clone() - }; - let mut repo = store::ProjectRepo::new( - &project_id, - &github_repo, - &resolved_branch_name, - repo_subpath, - ); - if set_as_primary.unwrap_or(false) { - repo = repo.primary(); - } - if project.location == store::ProjectLocation::Remote { - let ws_name = branches::resolve_project_workspace_name(&store, &project, None)?; - let ws_info = tauri::async_runtime::spawn_blocking({ - let ws_name = ws_name.clone(); - move || blox::ws_info(&ws_name) - }) - .await - .map_err(|e| format!("Failed to query workspace '{ws_name}': {e}"))? - .map_err(|e| { - format!("Workspace '{ws_name}' must be running before adding another repo: {e}") - })?; - let ws_status = ws_info - .status - .as_deref() - .map(|s| s.to_ascii_lowercase()) - .unwrap_or_default(); - if ws_status != "running" { - return Err(format!( - "Workspace '{ws_name}' is not ready (status: {}). Wait until it is running, then retry.", - if ws_status.is_empty() { - "unknown" - } else { - ws_status.as_str() + let repo = project_commands::add_project_repo_impl( + Arc::clone(&store), + project_id.clone(), + github_repo, + branch_name, + subpath, + set_as_primary, + None, + ) + .await?; + + // Spawn background worktree + prerun-actions setup — fire and forget. + tauri::async_runtime::spawn({ + let repo_id = repo.id.clone(); + async move { + let _ = app_handle.emit("project-setup-progress", project_id.clone()); + + let branch = match store.list_branches_for_project(&project_id) { + Ok(branches) => branches + .into_iter() + .find(|b| b.project_repo_id.as_deref() == Some(repo_id.as_str())), + Err(e) => { + log::warn!("[add_project_repo] failed to list branches: {e}"); + return; } - )); - } - } - - store - .create_project_repo(&repo) - .map_err(|e| e.to_string())?; + }; + let branch = match branch { + Some(b) => b, + None => { + log::warn!("[add_project_repo] no branch found for repo {repo_id}"); + return; + } + }; - // Record this repo as recently used - store - .record_recent_repo(&github_repo, subpath.clone()) - .map_err(|e| e.to_string())?; + if branch.workspace_name.is_none() { + let branch_id = branch.id.clone(); + let store_clone = Arc::clone(&store); + let worktree_result = tauri::async_runtime::spawn_blocking(move || { + branches::setup_worktree_sync(&store_clone, &branch_id) + }) + .await; - let should_be_primary = repo.is_primary - || store - .get_primary_project_repo(&project_id) - .map_err(|e| e.to_string())? - .is_none(); - if should_be_primary { - store - .set_primary_project_repo(&project_id, &repo.id) - .map_err(|e| e.to_string())?; - store - .update_project( - &project_id, - &project.name, - Some(&repo.github_repo), - &project.location, - repo.subpath.as_deref(), - ) - .map_err(|e| e.to_string())?; - repo.is_primary = true; - } + 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()); + } + Ok(Err(e)) => { + log::warn!("[add_project_repo] worktree setup failed: {e}"); + return; + } + Err(e) => { + log::warn!("[add_project_repo] worktree task panicked: {e}"); + return; + } + } - // Ensure each repo has one tracked branch record. - let detected_base = git::detect_default_branch_for_repo(&repo.github_repo) - .unwrap_or_else(|_| "main".to_string()); - let effective_base = if detected_base.starts_with("origin/") { - detected_base - } else { - format!("origin/{detected_base}") - }; - let branch = match project.location { - store::ProjectLocation::Local => { - store::Branch::new(&project_id, &repo.branch_name, &effective_base) - .with_project_repo(&repo.id) - } - store::ProjectLocation::Remote => { - let ws_name = branches::resolve_project_workspace_name(&store, &project, None)?; - store::Branch::new_remote(&project_id, &repo.branch_name, &effective_base, &ws_name) - .with_project_repo(&repo.id) + let executor = app_handle.state::>(); + let act_registry = app_handle.state::>(); + match branches::run_prerun_actions_for_branch( + &store, + &app_handle, + &branch.id, + &executor, + &act_registry, + ) + .await + { + Ok(count) => { + log::info!("[add_project_repo] ran {count} prerun actions"); + let _ = app_handle.emit("project-setup-progress", project_id); + } + Err(e) => { + log::warn!("[add_project_repo] prerun actions failed: {e}"); + } + } + } } - }; - store.create_branch(&branch).map_err(|e| e.to_string())?; + }); + Ok(repo) } +#[tauri::command(rename_all = "camelCase")] +fn clear_project_repo_reason( + store: tauri::State<'_, Mutex>>>, + project_repo_id: String, +) -> Result<(), String> { + let store = get_store(&store)?; + store + .clear_project_repo_reason(&project_repo_id) + .map_err(|e| e.to_string()) +} + #[tauri::command(rename_all = "camelCase")] async fn remove_project_repo( store: tauri::State<'_, Mutex>>>, @@ -863,6 +901,45 @@ fn delete_note( Ok(()) } +// ============================================================================= +// Project note commands +// ============================================================================= + +#[tauri::command] +fn create_project_note( + store: tauri::State<'_, Mutex>>>, + project_id: String, + title: String, + content: String, +) -> Result { + let store = get_store(&store)?; + let note = store::ProjectNote::new(&project_id, &title, &content); + store + .create_project_note(¬e) + .map_err(|e| e.to_string())?; + Ok(note) +} + +#[tauri::command] +fn list_project_notes( + store: tauri::State<'_, Mutex>>>, + project_id: String, +) -> Result, String> { + get_store(&store)? + .list_project_notes(&project_id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn delete_project_note( + store: tauri::State<'_, Mutex>>>, + note_id: String, +) -> Result<(), String> { + get_store(&store)? + .delete_project_note(¬e_id) + .map_err(|e| e.to_string()) +} + /// Delete a review and all its comments, optionally deleting its linked session. #[tauri::command(rename_all = "camelCase")] fn delete_review( @@ -2310,6 +2387,7 @@ pub fn run() { list_recent_repos, add_project_repo, update_project_repo_branch_name, + clear_project_repo_reason, remove_project_repo, set_primary_project_repo, delete_project, @@ -2338,6 +2416,9 @@ pub fn run() { get_branch_timeline, create_note, delete_note, + create_project_note, + list_project_notes, + delete_project_note, delete_review, delete_commit, delete_pending_commit, @@ -2369,6 +2450,7 @@ pub fn run() { session_commands::cancel_session, session_commands::delete_session, session_commands::start_branch_session, + session_commands::start_project_session, // Actions actions::commands::detect_repo_actions, actions::commands::run_branch_action, diff --git a/apps/mark/src-tauri/src/project_commands.rs b/apps/mark/src-tauri/src/project_commands.rs new file mode 100644 index 00000000..3ba3e206 --- /dev/null +++ b/apps/mark/src-tauri/src/project_commands.rs @@ -0,0 +1,136 @@ +//! Shared project command implementations used by both Tauri commands and MCP tools. + +use std::sync::Arc; + +use crate::store::{self, Store}; +use crate::{blox, branches, git}; + +/// Core logic for adding a GitHub repository to a project. +/// +/// Called by both the `add_project_repo` Tauri command and the MCP tool. +pub(crate) async fn add_project_repo_impl( + store: Arc, + project_id: String, + github_repo: String, + branch_name: Option, + subpath: Option, + set_as_primary: Option, + reason: Option, +) -> Result { + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + let resolved_branch_name = branch_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| branches::infer_branch_name(&project.name)); + let repo_subpath = if project.location == store::ProjectLocation::Remote { + let requested = subpath + .as_deref() + .map(branches::validate_workspace_subpath) + .transpose()?; + Some( + requested + .map(|s| { + if s.starts_with("repo:") || s.starts_with("repos/") { + s + } else { + format!("repo:{s}") + } + }) + .unwrap_or_else(|| branches::infer_remote_repo_subpath(&github_repo)), + ) + } else { + subpath.clone() + }; + let mut repo = store::ProjectRepo::new( + &project_id, + &github_repo, + &resolved_branch_name, + repo_subpath, + ); + if set_as_primary.unwrap_or(false) { + repo = repo.primary(); + } + repo.reason = reason; + if project.location == store::ProjectLocation::Remote { + let ws_name = branches::resolve_project_workspace_name(&store, &project, None)?; + let ws_info = tauri::async_runtime::spawn_blocking({ + let ws_name = ws_name.clone(); + move || blox::ws_info(&ws_name) + }) + .await + .map_err(|e| format!("Failed to query workspace '{ws_name}': {e}"))? + .map_err(|e| { + format!("Workspace '{ws_name}' must be running before adding another repo: {e}") + })?; + let ws_status = ws_info + .status + .as_deref() + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_default(); + if ws_status != "running" { + return Err(format!( + "Workspace '{ws_name}' is not ready (status: {}). Wait until it is running, then retry.", + if ws_status.is_empty() { + "unknown" + } else { + ws_status.as_str() + } + )); + } + } + + store + .create_project_repo(&repo) + .map_err(|e| e.to_string())?; + + store + .record_recent_repo(&github_repo, subpath.clone()) + .map_err(|e| e.to_string())?; + + let should_be_primary = repo.is_primary + || store + .get_primary_project_repo(&project_id) + .map_err(|e| e.to_string())? + .is_none(); + if should_be_primary { + store + .set_primary_project_repo(&project_id, &repo.id) + .map_err(|e| e.to_string())?; + store + .update_project( + &project_id, + &project.name, + Some(&repo.github_repo), + &project.location, + repo.subpath.as_deref(), + ) + .map_err(|e| e.to_string())?; + repo.is_primary = true; + } + + let detected_base = git::detect_default_branch_for_repo(&repo.github_repo) + .unwrap_or_else(|_| "main".to_string()); + let effective_base = if detected_base.starts_with("origin/") { + detected_base + } else { + format!("origin/{detected_base}") + }; + let branch = match project.location { + store::ProjectLocation::Local => { + store::Branch::new(&project_id, &repo.branch_name, &effective_base) + .with_project_repo(&repo.id) + } + store::ProjectLocation::Remote => { + let ws_name = branches::resolve_project_workspace_name(&store, &project, None)?; + store::Branch::new_remote(&project_id, &repo.branch_name, &effective_base, &ws_name) + .with_project_repo(&repo.id) + } + }; + store.create_branch(&branch).map_err(|e| e.to_string())?; + Ok(repo) +} diff --git a/apps/mark/src-tauri/src/project_mcp.rs b/apps/mark/src-tauri/src/project_mcp.rs new file mode 100644 index 00000000..26ec91ae --- /dev/null +++ b/apps/mark/src-tauri/src/project_mcp.rs @@ -0,0 +1,770 @@ +//! MCP server for project sessions. +//! Exposes `start_repo_session` and `add_project_repo` tools to the agent. + +use std::sync::Arc; +use std::time::Duration; +use tokio::task::JoinHandle; + +use axum::Router; +use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters}; +use rmcp::model::{ServerCapabilities, ServerInfo}; +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 crate::actions::{ActionExecutor, ActionRegistry}; +use crate::session_runner::{SessionConfig, SessionRegistry}; +use crate::store::{Session, SessionStatus, Store}; +use tokio_util::sync::CancellationToken; + +/// What outcome the caller expects from a `start_repo_session` call. +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum RepoSessionOutcome { + /// The session should only return output to the caller. No artifact is created. + ReturnOutputOnly, + /// The session should produce a note in the repository. A note stub is created and + /// the agent is instructed to output note content after a horizontal rule (---). + NoteInRepo, + /// The session should make code changes and create a commit. A pending commit record + /// is created and the agent is instructed to commit with a conventional commit message. + Commit, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct StartRepoSessionParams { + /// GitHub repo slug present in the project, e.g. "org/repo". + pub repo: String, + /// Subpath within the repository (for monorepos), e.g. "packages/api". + /// Must match exactly the subpath used when the repo was added to the project. + /// Use `null` / omit if the repo was added without a subpath (whole-repo). + pub subpath: Option, + /// Instructions to give the agent. Notes previously created for this repo are available + /// to the session, so you can refer to them by name (e.g. "refer to the architecture + /// overview note"). + pub instructions: String, + /// What the session should produce. Controls the prompt given to the agent and what + /// artifact (if any) is created in the database. + /// + /// - `"return_output_only"`: Agent returns output only; use `return_info` to + /// specify exactly what you want back. + /// - `"note_in_repo"`: Use this for generating notes that can be referred to again + /// later by other sessions or by the user. Useful for architecture overviews, plans, + /// research, reviews. + /// - `"commit"`: Use this to request code changes. Agent makes code changes and + /// creates a commit with a conventional commit message. + pub expected_outcome: RepoSessionOutcome, + /// Only used when `expected_outcome` is `"return_output_only"`. + /// Describe what information you want the session to return to you when it finishes. + /// Example: "a summary of all changes made and any errors encountered". + pub return_info: Option, + /// Optional ACP provider ID (e.g. "claude", "goose"). + pub provider: Option, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct AddProjectRepoParams { + /// GitHub repo slug to add, e.g. "org/repo". + pub github_repo: String, + /// Optional branch name (defaults to project's inferred name). + pub branch_name: Option, + /// Subpath to the specific service or project within the repository. + /// Required for monorepos — you must provide the path to the root of the + /// relevant service or package (e.g. "packages/api" or "services/auth"). + /// If omitted for a regular repo, the whole repository is used. + pub subpath: Option, + /// Reason this repository is being added to the project. Shown to the user + /// in the branch card timeline so they understand why it was added. Describe what + /// the repo is and how it relates to the project — do not include todos or details + /// about what needs to change. + pub reason: Option, +} + +#[derive(Clone)] +struct ProjectToolsHandler { + tool_router: ToolRouter, + project_id: String, + store: Arc, + registry: Arc, + app_handle: AppHandle, + action_executor: Option>, + action_registry: Option>, + /// Cancellation token for the parent project session. + /// Signalled when the user cancels the project session. + cancel_token: CancellationToken, + /// Whether the parent project session is running in a remote workspace. + /// Controls whether note content is inlined or referenced by file path. + is_remote: bool, +} + +impl ProjectToolsHandler { + #[allow(clippy::too_many_arguments)] + fn new( + project_id: String, + store: Arc, + registry: Arc, + app_handle: AppHandle, + action_executor: Option>, + action_registry: Option>, + cancel_token: CancellationToken, + is_remote: bool, + ) -> Self { + Self { + tool_router: Self::tool_router(), + project_id, + store, + registry, + app_handle, + action_executor, + action_registry, + cancel_token, + is_remote, + } + } +} + +#[tool_router] +impl ProjectToolsHandler { + #[tool( + description = "Start an agent session in one of the project's repositories. Waits for completion and returns the outcome. Use `expected_outcome` to control what the session produces: `\"return_output_only\"` (use `return_info` to describe what to return), `\"note_in_repo\"` (agent researches and writes a note visible in the branch card), or `\"commit\"` (agent makes code changes and creates a commit visible in the branch card). The `repo` + `subpath` combination must exactly match an entry already in the project — call will fail immediately otherwise." + )] + async fn start_repo_session( + &self, + Parameters(p): Parameters, + ) -> String { + log::info!( + "[project_mcp] start_repo_session called: repo={:?} subpath={:?} expected_outcome={:?} return_info={:?} provider={:?} instructions={:?}", + p.repo, + p.subpath, + p.expected_outcome, + p.return_info, + p.provider, + p.instructions, + ); + // Find the matching project repo — must match both github_repo and subpath exactly. + let repos = match self.store.list_project_repos(&self.project_id) { + Ok(r) => r, + Err(e) => return format!("Error listing repos: {e}"), + }; + let repo = match repos + .iter() + .find(|r| r.github_repo == p.repo && r.subpath.as_deref() == p.subpath.as_deref()) + { + Some(r) => r.clone(), + None => { + let available = repos + .iter() + .map(|r| match r.subpath.as_deref() { + Some(sp) => format!("{} (subpath: {sp})", r.github_repo), + None => r.github_repo.clone(), + }) + .collect::>() + .join(", "); + return format!( + "Repository '{}' with subpath {:?} not found in project. Available repos: {}", + p.repo, p.subpath, available + ); + } + }; + + // Find the branch for this repo — capture the full struct for context building. + let branches = match self.store.list_branches_for_project(&self.project_id) { + Ok(b) => b, + Err(e) => return format!("Error listing branches: {e}"), + }; + let branch = branches + .into_iter() + .find(|b| b.project_repo_id.as_deref() == Some(repo.id.as_str())); + let workspace_name = branch.as_ref().and_then(|b| b.workspace_name.clone()); + let branch_id = branch.as_ref().map(|b| b.id.clone()); + + // Determine working directory — include subpath when the repo was added with one. + // For local branches, use the project worktree path so the agent operates on the + // correct branch; error if no worktree is recorded (repo not fully set up yet). + let clone_dir = crate::paths::repos_dir() + .map(|d| { + let base = d.join(&repo.github_repo); + if let Some(ref sp) = repo.subpath { + base.join(sp) + } else { + base + } + }) + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + let working_dir = if workspace_name.is_none() { + match branch.as_ref().and_then(|br| { + self.store + .get_workdir_for_branch(&br.id) + .ok() + .flatten() + .map(|wd| { + let mut path = std::path::PathBuf::from(&wd.path); + if let Some(ref sp) = repo.subpath { + path = path.join(sp); + } + path + }) + }) { + Some(path) => path, + None => return format!( + "No worktree found for repo '{}'. Ensure the repo has been fully set up via add_project_repo before starting a session.", + repo.github_repo + ), + } + } else { + clone_dir + }; + + // Build branch history context (commits + notes) and project context, mirroring + // what start_branch_session does for user-triggered sessions. + let project = self.store.get_project(&self.project_id).ok().flatten(); + let (branch_context, project_information) = if let Some(ref br) = branch { + let store_clone = Arc::clone(&self.store); + let branch_id_str = br.id.clone(); + let base_branch = br.base_branch.clone(); + let project_id_str = self.project_id.clone(); + let ws_name = workspace_name.clone(); + + // working_dir is already the worktree path for local branches. + let context_dir = working_dir.clone(); + + let ctx = tokio::task::spawn_blocking(move || { + if let Some(ws) = ws_name { + crate::session_commands::build_remote_branch_context( + &ws, + &base_branch, + &store_clone, + &branch_id_str, + &project_id_str, + ) + } else { + crate::session_commands::build_branch_context( + &context_dir, + &base_branch, + &store_clone, + &branch_id_str, + &project_id_str, + ) + } + }) + .await + .unwrap_or_else(|e| { + log::warn!("[project_mcp] context build task panicked: {e}"); + String::new() + }); + + let proj_info = if let (Some(proj), Some(br_ref)) = (project.as_ref(), Some(br)) { + crate::session_commands::build_project_context(&self.store, proj, br_ref) + } else { + String::new() + }; + + (ctx, proj_info) + } else { + (String::new(), String::new()) + }; + + // Build the prompt — action instructions + project info + branch history + user instructions, + // matching the structure produced by build_full_prompt for user-triggered sessions. + let expected_outcome = &p.expected_outcome; + let action_instructions = match expected_outcome { + RepoSessionOutcome::ReturnOutputOnly => None, + RepoSessionOutcome::NoteInRepo => Some( + "The user is requesting a note. Generate a note based on their instructions below.\n\n\ + You may use any tools needed to research and gather information, but do NOT create \ + any commits.\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." + ), + RepoSessionOutcome::Commit => Some( + "The user is requesting you make a commit based on the instructions below. Make the necessary \ + code changes, following any verification or formatting steps as instructed, and then \ + create a commit with a conventional commit message. This commit should describe what \ + was requested and how it was fulfilled." + ), + }; + let user_instructions = match (expected_outcome, p.return_info.as_ref()) { + (RepoSessionOutcome::ReturnOutputOnly, Some(return_info)) => format!( + "{}\n\nIMPORTANT: When you are done, your final message must contain: {return_info}", + p.instructions + ), + _ => p.instructions.clone(), + }; + let prompt = { + let action_block = match action_instructions { + Some(instr) if !project_information.is_empty() => format!( + "\n{instr}\n\nProject information:\n{project_information}\n" + ), + Some(instr) => format!("\n{instr}\n"), + None if !project_information.is_empty() => { + format!("\nProject information:\n{project_information}\n") + } + None => String::new(), + }; + let history_block = if !branch_context.is_empty() { + format!("\n{branch_context}\n") + } else { + String::new() + }; + [action_block, history_block, user_instructions] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n\n") + }; + + // Create the session record. + let mut session = Session::new_running(&prompt, &working_dir); + if let Some(ref prov) = p.provider { + session = session.with_provider(prov); + } + if let Err(e) = self.store.create_session(&session) { + return format!("Error creating session: {e}"); + } + let session_id = session.id.clone(); + + // Create artifact stub and capture pre_head_sha (for commit sessions). + let (artifact_id, pre_head_sha) = match expected_outcome { + RepoSessionOutcome::NoteInRepo => match branch_id.as_deref() { + Some(bid) => { + let note = crate::store::Note::new(bid, "", "").with_session(&session_id); + let note_id = note.id.clone(); + if let Err(e) = self.store.create_note(¬e) { + log::error!("[project_mcp] failed to create note stub: {e}"); + } + (Some(note_id), None) + } + None => { + log::warn!( + "[project_mcp] expected_outcome=note_in_repo but no branch found for repo {}", + repo.github_repo + ); + (None, None) + } + }, + RepoSessionOutcome::Commit => { + match branch_id.as_deref() { + Some(bid) => { + let commit = + crate::store::Commit::new_pending(bid).with_session(&session_id); + let commit_id = commit.id.clone(); + if let Err(e) = self.store.create_commit(&commit) { + log::error!("[project_mcp] failed to create commit stub: {e}"); + } + // Capture HEAD SHA before the session runs so post-completion + // hooks can detect whether a new commit was created. + let wd = working_dir.clone(); + let ws = workspace_name.clone(); + let sha = if let Some(ws_name) = ws { + tokio::task::spawn_blocking(move || { + crate::blox::ws_exec(&ws_name, &["git", "rev-parse", "HEAD"]) + .map(|s| s.trim().to_string()) + }) + .await + .ok() + .and_then(|r| r.ok()) + } else { + crate::git::get_head_sha(&wd).ok() + }; + (Some(commit_id), sha) + } + None => { + log::warn!( + "[project_mcp] expected_outcome=commit but no branch found for repo {}", + repo.github_repo + ); + (None, None) + } + } + } + RepoSessionOutcome::ReturnOutputOnly => (None, None), + }; + + // Start the agent (returns immediately; work happens on background thread). + let start_result = crate::session_runner::start_session( + SessionConfig { + session_id: session_id.clone(), + prompt, + working_dir, + agent_session_id: None, + pre_head_sha, + provider: p.provider, + workspace_name, + extra_env: vec![], + mcp_project_id: None, + action_executor: None, + action_registry: None, + }, + Arc::clone(&self.store), + self.app_handle.clone(), + Arc::clone(&self.registry), + ); + if let Err(e) = start_result { + return format!("Error starting session: {e}"); + } + + // Notify the frontend that a new session is running in this branch so it + // can register the session in its state stores and refresh the branch card + // timeline immediately (same pattern as `project-repo-added`). + if let Some(ref bid) = branch_id { + let session_type = match expected_outcome { + RepoSessionOutcome::NoteInRepo => Some("note"), + RepoSessionOutcome::Commit => Some("commit"), + RepoSessionOutcome::ReturnOutputOnly => None, + }; + if let Some(stype) = session_type { + crate::session_runner::emit_session_running( + &self.app_handle, + &session_id, + bid, + &self.project_id, + stype, + ); + } + } + + // Poll until the session reaches a terminal state. + // Also watch the parent project session's cancellation token so we + // don't loop forever if the project session is cancelled while waiting. + loop { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(2)) => {} + _ = self.cancel_token.cancelled() => { + // Cancel the child session so it doesn't run as an orphan + // after the parent project session has been cancelled. + self.registry.cancel(&session_id); + return serde_json::json!({ + "session_id": session_id, + "outcome": "cancelled", + "output": "", + }) + .to_string(); + } + } + match self.store.get_session(&session_id) { + Ok(Some(s)) if s.status != SessionStatus::Running => { + let outcome = match s.status { + SessionStatus::Completed => "completed", + SessionStatus::Cancelled => "cancelled", + _ => "failed", + }; + // Return the last assistant message as the session output so the + // parent agent receives the result the child was asked to produce. + let output = self + .store + .get_session_messages(&session_id) + .ok() + .and_then(|msgs| { + msgs.into_iter() + .rfind(|m| m.role == crate::store::MessageRole::Assistant) + .map(|m| m.content) + }) + .unwrap_or_default(); + // For note sessions, strip the note content (everything from the + // first --- separator onwards) — it's provided separately in `note`. + let output = if matches!(expected_outcome, RepoSessionOutcome::NoteInRepo) { + let mut sep_line = None; + for (i, line) in output.lines().enumerate() { + let t = line.trim(); + if t == "---" || t == "***" || t == "___" { + sep_line = Some(i); + break; + } + } + match sep_line { + Some(i) => output.lines().take(i).collect::>().join("\n"), + None => output, + } + } else { + output + }; + // If this was a note session, include the note info in the same format + // provided at session start for available notes. + let note_info: Option = + if matches!(expected_outcome, RepoSessionOutcome::NoteInRepo) { + artifact_id.as_deref().and_then(|note_id| { + match self.store.get_note(note_id) { + Ok(Some(note)) if !note.content.is_empty() => { + crate::session_commands::format_note_for_context( + ¬e.id, + ¬e.title, + ¬e.content, + self.is_remote, + ) + } + _ => None, + } + }) + } else { + None + }; + let mut result = serde_json::json!({ + "session_id": session_id, + "outcome": outcome, + "output": output, + }); + if let Some(aid) = artifact_id.as_deref() { + result["artifact_id"] = serde_json::Value::String(aid.to_string()); + } + if let Some(note) = note_info { + result["note"] = serde_json::Value::String(note); + } + return result.to_string(); + } + Ok(Some(_)) => continue, // still running + Ok(None) => return format!("Session {session_id} was deleted while running"), + Err(e) => return format!("Error polling session status: {e}"), + } + } + } + + #[tool( + description = "Add a GitHub repository to the current project. Use this when the task requires a repository that isn't yet in the project. Waits until the repository worktree is ready and setup actions have completed before returning." + )] + async fn add_project_repo(&self, Parameters(p): Parameters) -> String { + log::info!( + "[project_mcp] add_project_repo called: github_repo={:?} branch_name={:?} subpath={:?}", + p.github_repo, + p.branch_name, + p.subpath, + ); + + // If no subpath was provided, check whether the repo is a monorepo. + // Monorepos require a subpath to identify which service/package to use. + if p.subpath.is_none() { + let repo_slug = p.github_repo.clone(); + let monorepo_result = tauri::async_runtime::spawn_blocking(move || { + crate::git::check_monorepo_modules(&repo_slug) + }) + .await; + + match monorepo_result { + Ok(Ok(score)) if score >= 20 => { + return format!( + "Error: '{}' appears to be a monorepo (score: {}). \ + You must provide a `subpath` pointing to the root of the specific \ + service or package you want to add (e.g. \"packages/api\" or \ + \"services/auth\"). Re-call this tool with the appropriate subpath.", + p.github_repo, score + ); + } + Ok(Err(e)) => { + log::warn!( + "[project_mcp] monorepo check failed for {}: {e}", + p.github_repo + ); + } + Err(e) => { + log::warn!("[project_mcp] monorepo check task panicked: {e}"); + } + Ok(Ok(_)) => {} // score < 20, not a monorepo + } + } + + let github_repo = p.github_repo.clone(); + let repo = match crate::project_commands::add_project_repo_impl( + Arc::clone(&self.store), + self.project_id.clone(), + p.github_repo, + p.branch_name, + p.subpath, + None, + p.reason, + ) + .await + { + Ok(repo) => repo, + Err(e) => return format!("Error adding repo: {e}"), + }; + + // Notify the UI so the repo appears immediately + let _ = self + .app_handle + .emit("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) { + Ok(branches) => branches + .into_iter() + .find(|b| b.project_repo_id.as_deref() == Some(repo.id.as_str())), + Err(e) => return format!("Error listing branches after adding repo: {e}"), + }; + + let branch = match branch { + Some(b) => b, + None => { + // Repo was added but no branch found — return partial success + log::warn!( + "[project_mcp] add_project_repo: no branch found for repo {} after creation", + github_repo + ); + return format!( + r#"{{"repo_id": "{}", "message": "Added repository {} to project (no branch found)"}}"#, + repo.id, github_repo + ); + } + }; + + // For local branches, set up the git worktree + if branch.workspace_name.is_none() { + log::info!( + "[project_mcp] add_project_repo: setting up worktree for branch {}", + branch.branch_name + ); + let branch_id = branch.id.clone(); + let store = Arc::clone(&self.store); + let worktree_result = tauri::async_runtime::spawn_blocking(move || { + // We need to run the worktree setup synchronously + // Reuse the core logic from branches::setup_worktree + crate::branches::setup_worktree_sync(&store, &branch_id) + }) + .await; + + match worktree_result { + Ok(Ok(worktree_path)) => { + log::info!( + "[project_mcp] add_project_repo: worktree ready at {}", + worktree_path + ); + // Notify UI that the worktree is ready so branch state updates + let _ = self + .app_handle + .emit("project-setup-progress", self.project_id.clone()); + } + Ok(Err(e)) => { + log::warn!( + "[project_mcp] add_project_repo: worktree setup failed (continuing): {e}" + ); + // Don't abort — return the repo even if worktree setup failed + return serde_json::json!({ + "repo_id": repo.id, + "message": format!("Added repository {github_repo} to project (worktree setup failed: {e})"), + }) + .to_string(); + } + Err(e) => { + log::warn!( + "[project_mcp] add_project_repo: worktree task panicked (continuing): {e}" + ); + return serde_json::json!({ + "repo_id": repo.id, + "message": format!("Added repository {github_repo} to project (worktree task error: {e})"), + }) + .to_string(); + } + } + + // Run detect_actions + prerun actions if we have an executor + if let (Some(executor), Some(act_registry)) = + (self.action_executor.as_ref(), self.action_registry.as_ref()) + { + log::info!( + "[project_mcp] add_project_repo: running prerun actions for branch {}", + branch.id + ); + let prerun_result = crate::branches::run_prerun_actions_for_branch( + &self.store, + &self.app_handle, + &branch.id, + executor, + act_registry, + ) + .await; + match prerun_result { + Ok(count) => { + log::info!( + "[project_mcp] add_project_repo: ran {count} prerun actions for branch {}", + branch.id + ); + // Notify UI that prerun actions finished + let _ = self + .app_handle + .emit("project-setup-progress", self.project_id.clone()); + } + Err(e) => { + log::warn!( + "[project_mcp] add_project_repo: prerun actions failed (continuing): {e}" + ); + } + } + } else { + log::info!( + "[project_mcp] add_project_repo: no action executor available, skipping prerun actions" + ); + } + } + + serde_json::json!({ + "repo_id": repo.id, + "message": format!("Added repository {github_repo} to project"), + }) + .to_string() + } +} + +#[tool_handler] +impl ServerHandler for ProjectToolsHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..Default::default() + } + } +} + +/// Start a local MCP SSE server for a project session. +/// +/// Returns the bound port and a JoinHandle. The server runs until +/// the handle (and its parent LocalSet) is dropped. +#[allow(clippy::too_many_arguments)] +pub async fn start_project_mcp_server( + project_id: String, + store: Arc, + registry: Arc, + app_handle: AppHandle, + action_executor: Option>, + action_registry: Option>, + cancel_token: CancellationToken, + is_remote: bool, +) -> Result<(u16, JoinHandle<()>), String> { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("Failed to bind MCP listener: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("Failed to get local address: {e}"))? + .port(); + + let handler = ProjectToolsHandler::new( + project_id, + store, + registry, + app_handle, + action_executor, + action_registry, + cancel_token, + is_remote, + ); + log::info!( + "[project_mcp] HTTP server bound on port {port} for project {}", + handler.project_id + ); + + let service = StreamableHttpService::new( + move || Ok(handler.clone()), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + + let router = Router::new().route_service("/mcp", service); + + let handle = tokio::task::spawn(async move { + if let Err(e) = axum::serve(listener, router).await { + log::error!("[project_mcp] HTTP server error: {e}"); + } + }); + + Ok((port, handle)) +} diff --git a/apps/mark/src-tauri/src/prs.rs b/apps/mark/src-tauri/src/prs.rs index 2b84f894..7e349e52 100644 --- a/apps/mark/src-tauri/src/prs.rs +++ b/apps/mark/src-tauri/src/prs.rs @@ -132,6 +132,10 @@ This is critical - the application parses this to link the PR. pre_head_sha: None, provider, workspace_name: None, + extra_env: vec![], + mcp_project_id: None, + action_executor: None, + action_registry: None, }, store, app_handle, @@ -446,6 +450,10 @@ The push must succeed before you finish (unless you output the non-fast-forward pre_head_sha: None, provider, workspace_name, + extra_env: vec![], + mcp_project_id: None, + action_executor: None, + action_registry: None, }, store, app_handle, diff --git a/apps/mark/src-tauri/src/session_commands.rs b/apps/mark/src-tauri/src/session_commands.rs index 7beaadc1..e3ddc3f6 100644 --- a/apps/mark/src-tauri/src/session_commands.rs +++ b/apps/mark/src-tauri/src/session_commands.rs @@ -22,6 +22,7 @@ use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; use tauri::Emitter; +use crate::actions::{ActionExecutor, ActionRegistry}; use crate::agent::{self, AcpProviderInfo}; use crate::blox; use crate::git; @@ -154,6 +155,10 @@ pub fn start_session( pre_head_sha: None, provider, workspace_name: None, + extra_env: vec![], + mcp_project_id: None, + action_executor: None, + action_registry: None, }, store, app_handle, @@ -206,6 +211,9 @@ pub fn resume_session( session_id: session_id.clone(), status: "running".to_string(), error_message: None, + branch_id: None, + project_id: None, + session_type: None, }, ); @@ -218,6 +226,10 @@ pub fn resume_session( pre_head_sha: None, provider, workspace_name: None, + extra_env: vec![], + mcp_project_id: None, + action_executor: None, + action_registry: None, }, store, app_handle, @@ -247,6 +259,9 @@ pub fn cancel_session( session_id: session_id.clone(), status: "cancelled".to_string(), error_message: None, + branch_id: None, + project_id: None, + session_type: None, }, ); } @@ -290,6 +305,113 @@ pub struct BranchSessionResponse { pub artifact_id: String, } +/// Response from starting a project session. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSessionResponse { + pub session_id: String, + /// The ID of the project note created for this session. + pub note_id: String, +} + +/// Start a project-level session. +/// +/// Project sessions operate at the project level rather than a specific branch. +/// The agent receives project context (all repos, existing project notes), +/// and an MCP server with tools to start repo subagent sessions and add repos. +/// Always creates a ProjectNote stub that is populated when the session completes. +#[tauri::command(rename_all = "camelCase")] +#[allow(clippy::too_many_arguments)] +pub async fn start_project_session( + store: tauri::State<'_, Mutex>>>, + registry: tauri::State<'_, Arc>, + action_executor: tauri::State<'_, Arc>, + action_registry: tauri::State<'_, Arc>, + app_handle: tauri::AppHandle, + project_id: String, + prompt: String, + provider: Option, +) -> Result { + let store = get_store(&store)?; + + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + + // Build project context for the prompt + let project_context = build_project_session_context(&store, &project); + + // Build the full prompt + let action_instructions = "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\ + You have access to the following tools:\n\n\ + - start_repo_session: Use this to make changes or run tasks within one of the project's \ + repositories. Pass the repo slug (e.g. \"org/repo\") and clear instructions for what to \ + do there. This tool starts a subagent session and waits for it to complete before \ + returning the outcome. 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 should be done within \ + a repo session rather in this project wide context.\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\ + 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}" + ); + + // Resolve working directory — use the primary repo's clone path, then the + // project-scoped worktree root (created at project creation time), then /tmp. + let working_dir = project.clone_path().unwrap_or_else(|| { + crate::git::project_worktree_root_for(&project.id) + .unwrap_or_else(|_| std::path::PathBuf::from("/tmp")) + }); + + // Create the session + 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())?; + + // Always create a project note stub with empty title and content so that the + // frontend can detect it as "generating" via the !title && !content check. + 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)), + }, + store, + app_handle, + Arc::clone(®istry), + )?; + + Ok(ProjectSessionResponse { + session_id: session.id, + note_id, + }) +} + /// Start a branch-scoped session (note or commit). /// /// This builds the full prompt (action tag + branch history + user prompt), @@ -335,12 +457,14 @@ pub async fn start_branch_session( 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 = tauri::async_runtime::spawn_blocking(move || { build_remote_branch_context( &workspace_name, &base_branch, &store_for_context, &branch_id_for_context, + &project_id_for_context, ) }) .await @@ -369,7 +493,13 @@ pub async fn start_branch_session( worktree_path = worktree_path.join(subpath); } - let ctx = build_branch_context(&worktree_path, &branch.base_branch, &store, &branch_id); + let ctx = build_branch_context( + &worktree_path, + &branch.base_branch, + &store, + &branch_id, + &branch.project_id, + ); (worktree_path, ctx) }; @@ -455,6 +585,10 @@ pub async fn start_branch_session( 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, }, store, app_handle, @@ -472,11 +606,12 @@ pub async fn start_branch_session( // ============================================================================= /// Build the branch history context block for a local branch. -fn build_branch_context( +pub(crate) fn build_branch_context( worktree: &Path, base_branch: &str, store: &Arc, branch_id: &str, + project_id: &str, ) -> String { let mut parts = vec![context_preamble()]; let mut timeline: Vec = Vec::new(); @@ -498,6 +633,9 @@ fn build_branch_context( timeline.extend(note_timeline_entries(store, branch_id, false)); timeline.extend(review_timeline_entries(store, branch_id)); + // Project-level notes + timeline.extend(project_note_timeline_entries(store, project_id, false)); + parts.push(render_timeline(timeline, commit_error)); parts.join("\n\n") } @@ -506,11 +644,12 @@ fn build_branch_context( /// /// Uses `blox ws_exec` to run git commands inside the remote workspace, /// and reads notes from the DB (which works regardless of worktree location). -fn build_remote_branch_context( +pub(crate) fn build_remote_branch_context( workspace_name: &str, base_branch: &str, store: &Arc, branch_id: &str, + project_id: &str, ) -> String { let mut parts = vec![context_preamble()]; let mut timeline: Vec = Vec::new(); @@ -550,6 +689,9 @@ fn build_remote_branch_context( timeline.extend(note_timeline_entries(store, branch_id, true)); timeline.extend(review_timeline_entries(store, branch_id)); + // Project-level notes + timeline.extend(project_note_timeline_entries(store, project_id, true)); + parts.push(render_timeline(timeline, None)); parts.join("\n\n") } @@ -578,7 +720,7 @@ fn format_repo_label(repo_slug: &str, subpath: Option<&str>) -> String { } } -fn build_project_context( +pub(crate) fn build_project_context( store: &Arc, project: &store::Project, branch: &store::Branch, @@ -672,6 +814,178 @@ fn build_project_context( lines.join("\n") } +/// Build the context block for a project-level session. +/// +/// Includes: project name, all attached repos (with reasons and per-repo +/// branch timelines), and existing project notes. +fn build_project_session_context(store: &Arc, project: &store::Project) -> String { + let project_name = project.name.trim(); + let project_name = if project_name.is_empty() { + "Unnamed Project" + } else { + project_name + }; + + let mut lines = vec![format!("You are working in project \"{project_name}\".")]; + + // List all repos + let repos = store.list_project_repos(&project.id).unwrap_or_default(); + if repos.is_empty() { + if let Some(ref repo) = project.github_repo { + lines.push(format!("Primary repository: `{repo}`")); + } else { + lines.push("No repositories are attached to this project.".to_string()); + } + } else { + lines.push("Repositories in this project:".to_string()); + for repo in &repos { + let label = format_repo_label(&repo.github_repo, repo.subpath.as_deref()); + let primary_tag = if repo.is_primary { " (primary)" } else { "" }; + let reason_tag = repo + .reason + .as_deref() + .map(|r| format!(" — {r}")) + .unwrap_or_default(); + lines.push(format!("- `{label}`{primary_tag}{reason_tag}")); + } + } + + // Per-repo branch timelines — gives the project-level agent the same + // awareness of branch activity that branch-level agents receive. + let all_branches = store + .list_branches_for_project(&project.id) + .unwrap_or_default(); + + for repo in &repos { + let repo_branches: Vec<_> = all_branches + .iter() + .filter(|b| b.project_repo_id.as_deref() == Some(&repo.id)) + .collect(); + + if repo_branches.is_empty() { + continue; + } + + let repo_label = format_repo_label(&repo.github_repo, repo.subpath.as_deref()); + lines.push(String::new()); + lines.push(format!("## Repository: {repo_label}")); + + for branch in &repo_branches { + lines.push(String::new()); + lines.push(format!("### Branch: {}", branch.branch_name)); + + let timeline = build_branch_timeline_summary(store, branch); + if timeline.is_empty() { + lines.push("No activity on this branch yet.".to_string()); + } else { + lines.push(timeline); + } + } + } + + // Also include branches not associated with any repo (legacy or unlinked) + let unlinked_branches: Vec<_> = all_branches + .iter() + .filter(|b| b.project_repo_id.is_none()) + .collect(); + if !unlinked_branches.is_empty() { + lines.push(String::new()); + lines.push("## Branches (no repo association)".to_string()); + for branch in &unlinked_branches { + lines.push(String::new()); + lines.push(format!("### Branch: {}", branch.branch_name)); + + let timeline = build_branch_timeline_summary(store, branch); + if timeline.is_empty() { + lines.push("No activity on this branch yet.".to_string()); + } else { + lines.push(timeline); + } + } + } + + // Include existing project notes + let notes = store.list_project_notes(&project.id).unwrap_or_default(); + let non_empty_notes: Vec<_> = notes.iter().filter(|n| !n.content.is_empty()).collect(); + if !non_empty_notes.is_empty() { + lines.push(String::new()); + lines.push("## Existing Project Notes".to_string()); + for note in &non_empty_notes { + let note_path = std::env::temp_dir().join(format!("mark-note-{}.md", note.id)); + let formatted = match std::fs::write(¬e_path, ¬e.content) { + Ok(()) => format!( + "### Project Note: {}\n\nSee: `{}`", + note.title, + note_path.display() + ), + Err(e) => { + log::warn!("Failed to write project note to temp file, inlining: {e}"); + format!("### Project Note: {}\n\n{}", note.title, note.content) + } + }; + lines.push(formatted); + } + } + + lines.join("\n") +} + +/// Build a compact timeline summary for a single branch, suitable for +/// inclusion in project-level context. +/// +/// Includes commit log (when a local worktree is available), notes, and +/// reviews — but omits project-level notes (those are rendered separately +/// at the project level to avoid duplication). +fn build_branch_timeline_summary(store: &Arc, branch: &store::Branch) -> String { + let mut timeline: Vec = Vec::new(); + let mut commit_error = None; + + // Attempt to include commit log if we can resolve a local worktree + if let Ok(Some(workdir)) = store.get_workdir_for_branch(&branch.id) { + let worktree = std::path::Path::new(&workdir.path); + if worktree.exists() { + match git::get_full_commit_log(worktree, &branch.base_branch) { + Ok(log) if !log.trim().is_empty() => { + timeline.extend(parse_timestamped_log(&log)); + } + Ok(_) => {} + Err(e) => { + log::warn!( + "Failed to get commit log for branch {} in project context: {e}", + branch.branch_name + ); + commit_error = Some(format!("(Error retrieving commit log: {e})")); + } + } + } + } + + // Notes (inlined for project context — the project agent may not have + // access to the branch's local temp files) + timeline.extend(note_timeline_entries(store, &branch.id, true)); + timeline.extend(review_timeline_entries(store, &branch.id)); + + if timeline.is_empty() { + if let Some(err) = commit_error { + return err; + } + return String::new(); + } + + timeline.sort_by_key(|e| e.timestamp); + + let mut section = String::new(); + if let Some(err) = commit_error { + section.push_str(&err); + section.push('\n'); + } + for entry in &timeline { + section.push_str(&entry.content); + section.push('\n'); + } + section.trim_end().to_string() +} + // ============================================================================= // Chronological timeline helpers // ============================================================================= @@ -731,6 +1045,34 @@ fn parse_timestamped_log(output: &str) -> Vec { entries } +/// Format a single note's content for inclusion in an agent context string. +/// +/// When `is_remote` is true, content is inlined directly because the remote +/// agent cannot access local temp files. For local sessions, the content is +/// written to a temp file and referenced by path so large notes don't bloat +/// the context; falls back to inline if the write fails. +pub(crate) fn format_note_for_context( + id: &str, + title: &str, + content: &str, + is_remote: bool, +) -> Option { + if is_remote { + Some(format!("### Note: {title}\n\n{content}")) + } else { + let note_path = std::env::temp_dir().join(format!("mark-note-{id}.md")); + if let Err(e) = std::fs::write(¬e_path, content) { + log::warn!("Failed to write note to temp file: {e}"); + None + } else { + Some(format!( + "### Note: {title}\n\nSee: `{}`", + note_path.display() + )) + } + } +} + /// Convert notes from the DB into timeline entries. /// /// When `is_remote` is true, note content is inlined directly because the @@ -749,20 +1091,57 @@ fn note_timeline_entries( } }; + let mut entries = Vec::new(); + for note in ¬es { + if note.content.is_empty() { + continue; // skip notes still generating + } + if let Some(content) = + format_note_for_context(¬e.id, ¬e.title, ¬e.content, is_remote) + { + entries.push(TimelineEntry { + timestamp: note.created_at, + content, + }); + } + } + entries +} + +/// Convert project notes from the DB into timeline entries. +fn project_note_timeline_entries( + store: &Arc, + project_id: &str, + is_remote: bool, +) -> Vec { + let notes = match store.list_project_notes(project_id) { + Ok(n) => n, + Err(e) => { + log::warn!("Failed to list project notes for branch context: {e}"); + return Vec::new(); + } + }; + let mut entries = Vec::new(); for note in ¬es { if note.content.is_empty() { continue; // skip notes still generating } let content = if is_remote { - format!("### Note: {}\n\n{}", note.title, note.content) + format!("### Project Note: {}\n\n{}", note.title, note.content) } else { let note_path = std::env::temp_dir().join(format!("mark-note-{}.md", note.id)); - if let Err(e) = std::fs::write(¬e_path, ¬e.content) { - log::warn!("Failed to write note to temp file: {e}"); - continue; + match std::fs::write(¬e_path, ¬e.content) { + Ok(()) => format!( + "### Project Note: {}\n\nSee: `{}`", + note.title, + note_path.display() + ), + Err(e) => { + log::warn!("Failed to write project note to temp file, inlining: {e}"); + format!("### Project Note: {}\n\n{}", note.title, note.content) + } } - format!("### Note: {}\n\nSee: `{}`", note.title, note_path.display()) }; entries.push(TimelineEntry { timestamp: note.created_at, diff --git a/apps/mark/src-tauri/src/session_runner.rs b/apps/mark/src-tauri/src/session_runner.rs index 0d664237..cf6560a6 100644 --- a/apps/mark/src-tauri/src/session_runner.rs +++ b/apps/mark/src-tauri/src/session_runner.rs @@ -41,6 +41,9 @@ use serde::Serialize; use tauri::{AppHandle, Emitter}; use tokio_util::sync::CancellationToken; +use acp_client::{McpServer, McpServerHttp}; + +use crate::actions::{ActionExecutor, ActionRegistry}; use crate::agent::{AcpDriver, AgentDriver, MessageWriter}; use crate::git::Span; use crate::store::{Comment, CommentAuthor, CommentType, MessageRole, SessionStatus, Store}; @@ -56,6 +59,11 @@ pub struct SessionStatusEvent { pub session_id: String, pub status: String, pub error_message: Option, + /// Set on `"running"` events emitted when an MCP tool starts a repo session, + /// so the frontend can register the session and refresh the branch timeline. + pub branch_id: Option, + pub project_id: Option, + pub session_type: Option, } // ============================================================================= @@ -135,6 +143,19 @@ pub struct SessionConfig { /// When set, the session runs via `blox acp ` instead /// of a local agent binary. Commit detection is skipped (no local git). pub workspace_name: Option, + /// Extra environment variables to pass to the agent process. + pub extra_env: Vec<(String, String)>, + /// Project ID for MCP tool server (project sessions only). + /// When set, an MCP server is started and the agent is given access to + /// `start_repo_session` and `add_project_repo` tools. The MCP server URL + /// is injected into the ACP session via `NewSessionRequest`. + pub mcp_project_id: Option, + /// Action executor for running setup actions in the MCP add_project_repo tool. + /// Required when `mcp_project_id` is set so the MCP server can run prerun actions. + pub action_executor: Option>, + /// Action registry for tracking running actions in the MCP add_project_repo tool. + /// Required when `mcp_project_id` is set. + pub action_registry: Option>, } /// Start a session: persist the user message, spawn the agent, stream to DB. @@ -181,6 +202,44 @@ pub fn start_session( let local = tokio::task::LocalSet::new(); let result = local.block_on(&rt, async { + // Start MCP server for project sessions, injecting it via the ACP NewSessionRequest. + let (driver, _mcp_handle) = if let Some(ref proj_id) = config.mcp_project_id { + match crate::project_mcp::start_project_mcp_server( + proj_id.clone(), + Arc::clone(&store), + Arc::clone(®istry), + app_handle.clone(), + config.action_executor.clone(), + config.action_registry.clone(), + cancel_token.clone(), + config.workspace_name.is_some(), + ) + .await + { + Ok((port, handle)) => { + log::info!( + "Session {}: MCP server started on port {port}", + config.session_id + ); + let mcp_server = McpServer::Http(McpServerHttp::new( + "builderbot", + format!("http://127.0.0.1:{port}/mcp"), + )); + let driver = driver + .with_extra_env(config.extra_env.clone()) + .with_mcp_servers(vec![mcp_server]); + (driver, Some(handle)) + } + Err(e) => { + log::error!("Failed to start MCP server: {e}"); + return Err(format!("Failed to start MCP server: {e}")); + } + } + } else { + let env = config.extra_env.clone(); + (driver.with_extra_env(env), None) + }; + let writer = Arc::new(MessageWriter::new( config.session_id.clone(), Arc::clone(&store), @@ -408,6 +467,47 @@ fn run_post_completion_hooks( } } + // --- Project note extraction --- + if let Ok(Some(empty_note)) = store.get_empty_project_note_by_session(session_id) { + if let Ok(messages) = store.get_session_messages(session_id) { + let full_text: String = messages + .iter() + .filter(|m| m.role == MessageRole::Assistant) + .map(|m| m.content.as_str()) + .collect::>() + .join("\n"); + + if let Some(note_content) = extract_note_content(&full_text) { + let (title, body) = extract_note_title(¬e_content); + let final_title = if title.is_empty() { + store + .get_session(session_id) + .ok() + .flatten() + .map(|s| { + let t: String = s.prompt.chars().take(80).collect(); + if s.prompt.len() > 80 { + format!("{t}…") + } else { + t + } + }) + .unwrap_or_else(|| "Untitled Note".to_string()) + } else { + title + }; + log::info!("Session {session_id}: extracted project note \"{final_title}\""); + if let Err(e) = + store.update_project_note_title_and_content(&empty_note.id, &final_title, &body) + { + log::error!("Failed to update project note content: {e}"); + } + } else { + log::warn!("Session {session_id}: project note session completed but no --- found in assistant output"); + } + } + } + // --- Review comment extraction --- if let Ok(Some(review)) = store.get_review_by_session(session_id) { if review.comments.is_empty() { @@ -588,12 +688,39 @@ fn emit_status(app_handle: &AppHandle, session_id: &str, status: &str, error: Op session_id: session_id.to_string(), status: status.to_string(), error_message: error, + branch_id: None, + project_id: None, + session_type: None, }; if let Err(e) = app_handle.emit("session-status-changed", &event) { log::warn!("Failed to emit session-status-changed: {e}"); } } +/// Emit a `session-status-changed` event with `"running"` status and branch/project +/// context. Called by the MCP tool when it starts a repo session on behalf of a project +/// session, so the frontend can register the session in its state stores and refresh +/// the branch card timeline immediately (without waiting for completion). +pub fn emit_session_running( + app_handle: &AppHandle, + session_id: &str, + branch_id: &str, + project_id: &str, + session_type: &str, +) { + let event = SessionStatusEvent { + session_id: session_id.to_string(), + status: "running".to_string(), + error_message: None, + branch_id: Some(branch_id.to_string()), + project_id: Some(project_id.to_string()), + session_type: Some(session_type.to_string()), + }; + if let Err(e) = app_handle.emit("session-status-changed", &event) { + log::warn!("Failed to emit session-status-changed (running): {e}"); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/mark/src-tauri/src/store/mod.rs b/apps/mark/src-tauri/src/store/mod.rs index 3b7cfbab..6355f5ce 100644 --- a/apps/mark/src-tauri/src/store/mod.rs +++ b/apps/mark/src-tauri/src/store/mod.rs @@ -6,7 +6,7 @@ //! confirmation. //! //! Tables: schema_version, projects, project_repos, branches, workdirs, commits, -//! sessions, session_messages, notes, reviews, action_contexts, repo_actions. +//! sessions, session_messages, notes, project_notes, reviews, action_contexts, repo_actions. pub mod models; @@ -15,6 +15,7 @@ mod branches; mod commits; mod messages; mod notes; +mod project_notes; mod project_repos; mod projects; mod recent_repos; @@ -61,7 +62,7 @@ impl From for StoreError { /// /// Bump this whenever the schema changes in an incompatible way. /// Many app versions may share the same schema version. -pub const SCHEMA_VERSION: i64 = 13; +pub const SCHEMA_VERSION: i64 = 14; /// The app version of this build, pulled from Cargo.toml at compile time. pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -247,6 +248,7 @@ impl Store { branch_name TEXT NOT NULL, subpath TEXT, is_primary INTEGER NOT NULL DEFAULT 0, + reason TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -337,6 +339,17 @@ impl Store { ); CREATE INDEX IF NOT EXISTS idx_notes_branch ON notes(branch_id); + CREATE TABLE IF NOT EXISTS project_notes ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + session_id TEXT, + title TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_project_notes_project ON project_notes(project_id); + CREATE TABLE IF NOT EXISTS reviews ( id TEXT PRIMARY KEY, branch_id TEXT NOT NULL REFERENCES branches(id) ON DELETE CASCADE, @@ -410,11 +423,11 @@ impl Store { CREATE INDEX IF NOT EXISTS idx_recent_repos_last_used ON recent_repos(last_used_at DESC); - -- Session cleanup triggers: when a commit, note, or review is - -- deleted (directly or via cascade from branch/project deletion), - -- delete the referenced session if no other row still points at - -- it. Only non-running sessions are cleaned up — a running - -- session may legitimately have no artifacts yet. + -- Session cleanup triggers: when a commit, note, project note, or + -- review is deleted (directly or via cascade from branch/project + -- deletion), delete the referenced session if no other row still + -- points at it. Only non-running sessions are cleaned up — a + -- running session may legitimately have no artifacts yet. CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_commit_delete AFTER DELETE ON commits WHEN OLD.session_id IS NOT NULL @@ -422,9 +435,10 @@ impl Store { DELETE FROM sessions WHERE id = OLD.session_id AND status != 'running' - AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id); + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); END; CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_note_delete @@ -434,9 +448,10 @@ impl Store { DELETE FROM sessions WHERE id = OLD.session_id AND status != 'running' - AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id); + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); END; CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_review_delete @@ -446,9 +461,23 @@ impl Store { DELETE FROM sessions WHERE id = OLD.session_id AND status != 'running' - AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id); + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_project_note_delete + AFTER DELETE ON project_notes + WHEN OLD.session_id IS NOT NULL + BEGIN + DELETE FROM sessions + WHERE id = OLD.session_id + AND status != 'running' + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); END; ", )?; diff --git a/apps/mark/src-tauri/src/store/models.rs b/apps/mark/src-tauri/src/store/models.rs index 21d3afe1..cbf759d6 100644 --- a/apps/mark/src-tauri/src/store/models.rs +++ b/apps/mark/src-tauri/src/store/models.rs @@ -131,6 +131,7 @@ pub struct ProjectRepo { pub branch_name: String, pub subpath: Option, pub is_primary: bool, + pub reason: Option, pub created_at: i64, pub updated_at: i64, } @@ -150,6 +151,7 @@ impl ProjectRepo { branch_name: branch_name.to_string(), subpath, is_primary: false, + reason: None, created_at: now, updated_at: now, } @@ -628,6 +630,47 @@ impl Note { } } +// ============================================================================= +// Project Notes +// ============================================================================= + +/// A note scoped to a project (not a specific branch). +/// +/// Project notes capture cross-cutting context, research, or decisions +/// that apply to the project as a whole. They are injected into every +/// branch session's context so the agent has project-level awareness. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectNote { + pub id: String, + pub project_id: String, + pub session_id: Option, + pub title: String, + pub content: String, + pub created_at: i64, + pub updated_at: i64, +} + +impl ProjectNote { + pub fn new(project_id: &str, title: &str, content: &str) -> Self { + let now = now_timestamp(); + Self { + id: Uuid::new_v4().to_string(), + project_id: project_id.to_string(), + session_id: None, + title: title.to_string(), + content: content.to_string(), + created_at: now, + updated_at: now, + } + } + + pub fn with_session(mut self, session_id: &str) -> Self { + self.session_id = Some(session_id.to_string()); + self + } +} + // ============================================================================= // Recent Repos // ============================================================================= diff --git a/apps/mark/src-tauri/src/store/project_notes.rs b/apps/mark/src-tauri/src/store/project_notes.rs new file mode 100644 index 00000000..da83ea7f --- /dev/null +++ b/apps/mark/src-tauri/src/store/project_notes.rs @@ -0,0 +1,96 @@ +//! Project note CRUD operations. + +use rusqlite::{params, OptionalExtension}; + +use super::models::ProjectNote; +use super::{now_timestamp, Store, StoreError}; + +impl Store { + pub fn create_project_note(&self, note: &ProjectNote) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO project_notes (id, project_id, session_id, title, content, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + note.id, + note.project_id, + note.session_id, + note.title, + note.content, + note.created_at, + note.updated_at, + ], + )?; + Ok(()) + } + + pub fn get_project_note(&self, id: &str) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id, project_id, session_id, title, content, created_at, updated_at + FROM project_notes WHERE id = ?1", + params![id], + Self::row_to_project_note, + ) + .optional() + .map_err(Into::into) + } + + pub fn list_project_notes(&self, project_id: &str) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, project_id, session_id, title, content, created_at, updated_at + FROM project_notes WHERE project_id = ?1 ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![project_id], Self::row_to_project_note)?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Find an empty project note (content = '') linked to a given session. + pub fn get_empty_project_note_by_session( + &self, + session_id: &str, + ) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id, project_id, session_id, title, content, created_at, updated_at + FROM project_notes WHERE session_id = ?1 AND content = ''", + params![session_id], + Self::row_to_project_note, + ) + .optional() + .map_err(Into::into) + } + + pub fn update_project_note_title_and_content( + &self, + id: &str, + title: &str, + content: &str, + ) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE project_notes SET title = ?1, content = ?2, updated_at = ?3 WHERE id = ?4", + params![title, content, now_timestamp(), id], + )?; + Ok(()) + } + + pub fn delete_project_note(&self, id: &str) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM project_notes WHERE id = ?1", params![id])?; + Ok(()) + } + + fn row_to_project_note(row: &rusqlite::Row) -> rusqlite::Result { + Ok(ProjectNote { + id: row.get(0)?, + project_id: row.get(1)?, + session_id: row.get(2)?, + title: row.get(3)?, + content: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + } +} diff --git a/apps/mark/src-tauri/src/store/project_repos.rs b/apps/mark/src-tauri/src/store/project_repos.rs index d076f80a..33ca2dc5 100644 --- a/apps/mark/src-tauri/src/store/project_repos.rs +++ b/apps/mark/src-tauri/src/store/project_repos.rs @@ -9,8 +9,8 @@ impl Store { pub fn create_project_repo(&self, repo: &ProjectRepo) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); if let Err(e) = conn.execute( - "INSERT INTO project_repos (id, project_id, github_repo, branch_name, subpath, is_primary, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + "INSERT INTO project_repos (id, project_id, github_repo, branch_name, subpath, is_primary, reason, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ repo.id, repo.project_id, @@ -18,6 +18,7 @@ impl Store { repo.branch_name, repo.subpath, if repo.is_primary { 1 } else { 0 }, + repo.reason, repo.created_at, repo.updated_at, ], @@ -43,7 +44,7 @@ impl Store { pub fn get_project_repo(&self, id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, created_at, updated_at + "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, reason, created_at, updated_at FROM project_repos WHERE id = ?1", params![id], Self::row_to_project_repo, @@ -55,7 +56,7 @@ impl Store { pub fn list_project_repos(&self, project_id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, created_at, updated_at + "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, reason, created_at, updated_at FROM project_repos WHERE project_id = ?1 ORDER BY is_primary DESC, created_at ASC", )?; @@ -69,7 +70,7 @@ impl Store { ) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, created_at, updated_at + "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, reason, created_at, updated_at FROM project_repos WHERE project_id = ?1 AND is_primary = 1 ORDER BY created_at ASC LIMIT 1", params![project_id], @@ -111,6 +112,15 @@ impl Store { Ok(()) } + pub fn clear_project_repo_reason(&self, repo_id: &str) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE project_repos SET reason = NULL, updated_at = ?1 WHERE id = ?2", + params![now_timestamp(), repo_id], + )?; + Ok(()) + } + pub fn delete_project_repo(&self, repo_id: &str) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM project_repos WHERE id = ?1", params![repo_id])?; @@ -126,8 +136,9 @@ impl Store { branch_name: row.get(3)?, subpath: row.get(4)?, is_primary: is_primary_i64 == 1, - created_at: row.get(6)?, - updated_at: row.get(7)?, + reason: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, }) } } diff --git a/apps/mark/src/App.svelte b/apps/mark/src/App.svelte index fc19659c..0e6ba54b 100644 --- a/apps/mark/src/App.svelte +++ b/apps/mark/src/App.svelte @@ -94,8 +94,31 @@ unlistenSessionStatus = await listen<{ sessionId: string; status: string; + branchId?: string; + projectId?: string; + sessionType?: string; }>('session-status-changed', async (event) => { - const { sessionId, status } = event.payload; + const { + sessionId, + status, + branchId: eventBranchId, + projectId: eventProjectId, + sessionType, + } = event.payload; + + // MCP-initiated repo session just started — register it so the project + // spinner shows and the completion handler can clean it up correctly. + if (status === 'running' && eventProjectId) { + sessionRegistry.register( + sessionId, + eventProjectId, + (sessionType as import('./lib/stores/sessionRegistry.svelte').SessionType) ?? 'other', + eventBranchId + ); + projectStateStore.addRunningSession(eventProjectId, sessionId); + return; + } + if (status === 'completed' || status === 'error' || status === 'cancelled') { // Get session metadata from the unified registry const sessionProjectId = sessionRegistry.getProjectId(sessionId); diff --git a/apps/mark/src/lib/commands.ts b/apps/mark/src/lib/commands.ts index f1bc82e1..aa1dbd9c 100644 --- a/apps/mark/src/lib/commands.ts +++ b/apps/mark/src/lib/commands.ts @@ -104,6 +104,10 @@ export function setPrimaryProjectRepo(projectId: string, projectRepoId: string): return invoke('set_primary_project_repo', { projectId, projectRepoId }); } +export function clearProjectRepoReason(projectRepoId: string): Promise { + return invoke('clear_project_repo_reason', { projectRepoId }); +} + /** List the authenticated user's GitHub organization memberships. */ export function listGithubOrgs(): Promise { return invoke('list_github_orgs'); @@ -137,6 +141,38 @@ export function checkMonorepoModules(githubRepo: string): Promise { return invoke('check_monorepo_modules', { githubRepo }); } +// ============================================================================= +// Project notes & sessions +// ============================================================================= + +export function listProjectNotes(projectId: string): Promise { + return invoke('list_project_notes', { projectId }); +} + +export function createProjectNote( + projectId: string, + title: string, + content: string +): Promise { + return invoke('create_project_note', { projectId, title, content }); +} + +export function deleteProjectNote(noteId: string): Promise { + return invoke('delete_project_note', { noteId }); +} + +export function startProjectSession( + projectId: string, + prompt: string, + provider?: string +): Promise { + return invoke('start_project_session', { + projectId, + prompt, + provider: provider ?? null, + }); +} + // ============================================================================= // Branches // ============================================================================= diff --git a/apps/mark/src/lib/features/branches/BranchCard.svelte b/apps/mark/src/lib/features/branches/BranchCard.svelte index 8d47adfb..433084fd 100644 --- a/apps/mark/src/lib/features/branches/BranchCard.svelte +++ b/apps/mark/src/lib/features/branches/BranchCard.svelte @@ -82,13 +82,14 @@ import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte'; import { prStateStore, type PrState } from '../../stores/prState.svelte'; import BranchCardHeaderInfo from './BranchCardHeaderInfo.svelte'; + import ReasonBanner from './ReasonBanner.svelte'; import { alerts } from '../../shared/alerts.svelte'; import { projectStateStore } from '../../stores/projectState.svelte'; import { sessionRegistry } from '../../stores/sessionRegistry.svelte'; interface Props { branch: Branch; - repoLabel?: { githubRepo: string; subpath: string | null } | null; + repoLabel?: { githubRepo: string; subpath: string | null; reason?: string | null } | null; deleting?: boolean; worktreeError?: string; onDelete?: () => void; @@ -267,8 +268,9 @@ listen<{ sessionId: string; status: string; + branchId?: string; }>('session-status-changed', (event) => { - const { sessionId: eventSessionId, status } = event.payload; + const { sessionId: eventSessionId, status, branchId: eventBranchId } = event.payload; if (status === 'completed' || status === 'error' || status === 'cancelled') { loadTimeline(); // Handle PR session completion @@ -279,6 +281,10 @@ if (eventSessionId === pushSessionId) { handlePushSessionComplete(status); } + } else if (status === 'running' && eventBranchId === branchId) { + // An MCP-initiated session just started in this branch — refresh the + // timeline so the pending note/commit stub appears immediately. + loadTimeline(); } }).then((unlisten) => { unlistenStatus = unlisten; @@ -1216,6 +1222,20 @@ prStateStore.clearPrState(branch.id); } + // ========================================================================= + // Repo reason banner + // ========================================================================= + + async function handleDismissReason() { + if (branch.projectRepoId) { + try { + await commands.clearProjectRepoReason(branch.projectRepoId); + } catch (e) { + console.error('Failed to clear repo reason:', e); + } + } + } + // ========================================================================= // Drag-and-drop text files → notes (via Tauri native drag-drop events) // ========================================================================= @@ -1508,6 +1528,7 @@
+ {#if loading}
diff --git a/apps/mark/src/lib/features/branches/ReasonBanner.svelte b/apps/mark/src/lib/features/branches/ReasonBanner.svelte new file mode 100644 index 00000000..eed878e7 --- /dev/null +++ b/apps/mark/src/lib/features/branches/ReasonBanner.svelte @@ -0,0 +1,83 @@ + + +{#if reason && !dismissed} +
+ + {reason} + +
+{/if} + + diff --git a/apps/mark/src/lib/features/branches/RemoteBranchCard.svelte b/apps/mark/src/lib/features/branches/RemoteBranchCard.svelte index 8dd2ba0f..d457aa71 100644 --- a/apps/mark/src/lib/features/branches/RemoteBranchCard.svelte +++ b/apps/mark/src/lib/features/branches/RemoteBranchCard.svelte @@ -40,6 +40,7 @@ import NoteModal from '../notes/NoteModal.svelte'; import ConfirmDialog from '../../shared/ConfirmDialog.svelte'; import BranchCardHeaderInfo from './BranchCardHeaderInfo.svelte'; + import ReasonBanner from './ReasonBanner.svelte'; import { formatBaseBranch } from './branchCardHelpers'; import { alerts } from '../../shared/alerts.svelte'; import { projectStateStore } from '../../stores/projectState.svelte'; @@ -47,7 +48,7 @@ interface Props { branch: Branch; - repoLabel?: { githubRepo: string; subpath: string | null } | null; + repoLabel?: { githubRepo: string; subpath: string | null; reason?: string | null } | null; deleting?: boolean; workspaceError?: string; onDelete?: () => void; @@ -176,6 +177,20 @@ } } + // ========================================================================= + // Repo reason banner + // ========================================================================= + + async function handleDismissReason() { + if (branch.projectRepoId) { + try { + await commands.clearProjectRepoReason(branch.projectRepoId); + } catch (e) { + console.error('Failed to clear repo reason:', e); + } + } + } + // ========================================================================= // Status polling // ========================================================================= @@ -656,6 +671,7 @@
+ {#if status === 'starting'}
diff --git a/apps/mark/src/lib/features/projects/ProjectHome.svelte b/apps/mark/src/lib/features/projects/ProjectHome.svelte index 84881e23..a9d5fa55 100644 --- a/apps/mark/src/lib/features/projects/ProjectHome.svelte +++ b/apps/mark/src/lib/features/projects/ProjectHome.svelte @@ -31,7 +31,7 @@ let projects = $state([]); let branchesByProject = $state>(new Map()); let repoLabelsByProject = $state< - Map> + Map> >(new Map()); let loading = $state(true); let error = $state(null); @@ -90,6 +90,44 @@ unlistenDetection = unlisten; }); + // Listen for backend-driven setup progress events. The backend emits this + // after repo creation, after worktree setup, and after prerun actions. + // We only refresh display state here — setup itself is owned by the backend. + let unlistenProjectRepoAdded: UnlistenFn | undefined; + listen('project-setup-progress', async (event) => { + const projectId = event.payload; + console.log('[ProjectHome] project-setup-progress event for project', projectId); + try { + const [projectsList, branches, repos] = await Promise.all([ + commands.listProjects(), + commands.listBranchesForProject(projectId), + commands.listProjectRepos(projectId), + ]); + projects = projectsList; + branchesByProject = new Map(branchesByProject).set(projectId, branches); + repoLabelsByProject = new Map(repoLabelsByProject).set( + projectId, + new Map( + repos.map( + (repo) => + [ + repo.id, + { + githubRepo: repo.githubRepo, + subpath: repo.subpath ?? null, + reason: repo.reason ?? null, + }, + ] as const + ) + ) + ); + } catch (e) { + console.error('[ProjectHome] Failed to refresh project after setup progress:', e); + } + }).then((unlisten) => { + unlistenProjectRepoAdded = unlisten; + }); + // Listen for PR status changes to update branch state let unlistenPrStatus: UnlistenFn | undefined; listen<{ @@ -126,6 +164,7 @@ return () => { window.removeEventListener('mark:new-project', onNewProject); unlistenDetection?.(); + unlistenProjectRepoAdded?.(); unlistenPrStatus?.(); if (kickoffTimer) { clearTimeout(kickoffTimer); @@ -184,7 +223,7 @@ const branchMap = new Map(); const repoLabelMap = new Map< string, - Map + Map >(); for (const project of projectList) { branchMap.set(project.id, branchesByProject.get(project.id) || []); @@ -209,7 +248,11 @@ (repo) => [ repo.id, - { githubRepo: repo.githubRepo, subpath: repo.subpath ?? null }, + { + githubRepo: repo.githubRepo, + subpath: repo.subpath ?? null, + reason: repo.reason ?? null, + }, ] as const ) ) @@ -341,11 +384,17 @@ new Map( repos.map( (repo) => - [repo.id, { githubRepo: repo.githubRepo, subpath: repo.subpath ?? null }] as const + [ + repo.id, + { + githubRepo: repo.githubRepo, + subpath: repo.subpath ?? null, + reason: repo.reason ?? null, + }, + ] as const ) ) ); - startInitialBranchSetup(project.id, branches); showNewProjectModal = false; selectProject(project.id); } @@ -450,11 +499,17 @@ new Map( repos.map( (repo) => - [repo.id, { githubRepo: repo.githubRepo, subpath: repo.subpath ?? null }] as const + [ + repo.id, + { + githubRepo: repo.githubRepo, + subpath: repo.subpath ?? null, + reason: repo.reason ?? null, + }, + ] as const ) ) ); - startInitialBranchSetup(projectId, branches); } catch (e) { console.error('Failed to add repo:', e); const message = e instanceof Error ? e.message : String(e); @@ -676,7 +731,14 @@ new Map( repos.map( (repo) => - [repo.id, { githubRepo: repo.githubRepo, subpath: repo.subpath ?? null }] as const + [ + repo.id, + { + githubRepo: repo.githubRepo, + subpath: repo.subpath ?? null, + reason: repo.reason ?? null, + }, + ] as const ) ) ); diff --git a/apps/mark/src/lib/features/projects/ProjectSection.svelte b/apps/mark/src/lib/features/projects/ProjectSection.svelte index 5f18e001..59bafa28 100644 --- a/apps/mark/src/lib/features/projects/ProjectSection.svelte +++ b/apps/mark/src/lib/features/projects/ProjectSection.svelte @@ -1,22 +1,34 @@
@@ -162,6 +298,68 @@
{/if}
+ + +
+
+ + +
+
+ + + {#if projectNotes.length > 0} +
+
+ + Project Notes +
+
+ {#each timelineNotes as note, index (note.id)} + {@const isRunning = !note.title.trim() && !note.content.trim()} + {@const isFailed = !isRunning && !!note.sessionId && !note.content.trim()} + {@const noteType = isRunning ? 'generating-note' : isFailed ? 'failed-note' : 'note'} + { + openNote = { title: note.title, content: note.content }; + }} + onSessionClick={(sid) => { + openSessionId = sid; + }} + onDeleteClick={() => handleDeleteNote(note.id)} + /> + {/each} +
+
+ {/if} +
{#each sortedBranches as branch (branch.id)} {#if branch.branchType === 'remote'} @@ -189,6 +387,20 @@
+{#if openNote} + (openNote = null)} /> +{/if} + +{#if openSessionId} + { + openSessionId = null; + loadProjectNotes(); + }} + /> +{/if} +