Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2bc93a5
feat(repos): one shared repo card with path labels and full action row
matt2e Aug 4, 2026
e276a9d
feat(repos): pinned/all grid sections and a full-action more menu
matt2e Aug 4, 2026
7a01425
fix(repos): open the new project dialog from repo cards with the repo…
matt2e Aug 4, 2026
1692476
feat(repos): label the repo card's new-project button and right-align…
matt2e Aug 4, 2026
af79625
feat(repos): add a repo-scoped run_repo_action command
matt2e Aug 5, 2026
e656823
refactor(actions): extract the branch card's action-runner machinery …
matt2e Aug 5, 2026
b2ea52a
feat(repos): wire RepoCard to the shared action runner via run_repo_a…
matt2e Aug 5, 2026
6372379
feat(repos): theme the shared action-runner surfaces per host card
matt2e Aug 5, 2026
512257e
perf(repos): coalesce repo-card action hydration into two bulk IPC calls
matt2e Aug 6, 2026
844fd2e
fix(actions): persist detected actions inside the detection window
matt2e Aug 6, 2026
99482f1
fix(actions): tighten the action-runner failure paths flagged in review
matt2e Aug 6, 2026
414776b
fix(actions): one shared detection window so prerun failures can't we…
matt2e Aug 7, 2026
1e03132
fix(actions): make the action-detection claim crash-safe
matt2e Aug 7, 2026
16fdc0d
fix(actions): prerun waits out an in-flight detection window instead …
matt2e Aug 7, 2026
b584c2b
feat(repos): enable the repos UI by default, removing the VITE_REPOS_…
matt2e Aug 7, 2026
b6c5857
fix(actions): take over an orphaned detection claim in one statement
matt2e Aug 7, 2026
505a519
perf(actions): probe a detection window's owner on a clock, not every…
matt2e Aug 7, 2026
d637026
fix(actions): detach prerun on the three paths whose caller is on a c…
matt2e Aug 7, 2026
89828bc
fix(repos): show the sidebar's All Repos entry to anyone with repos
matt2e Aug 10, 2026
c22da5d
fix(mcp): only promise setup actions when add_project_repo can run them
matt2e Aug 10, 2026
f6dba55
perf(repos): mount RepoCard's output modal only while it's showing
matt2e Aug 10, 2026
403c6c2
docs(actions): scope prerun's "a miss is permanent" invariant to the …
matt2e Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,661 changes: 1,456 additions & 205 deletions apps/staged/src-tauri/src/actions/commands.rs

Large diffs are not rendered by default.

280 changes: 149 additions & 131 deletions apps/staged/src-tauri/src/branches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@ pub(crate) struct WorktreeSetupProgress {
pub detail: Option<String>,
}

/// Emit a `worktree-setup-progress` event for a branch and phase.
pub(crate) fn emit_setup_progress(
handle: &AppHandle,
branch_id: &str,
phase: &str,
detail: Option<String>,
) {
crate::web_server::emit_to_all(
handle,
"worktree-setup-progress",
WorktreeSetupProgress {
branch_id: branch_id.to_string(),
phase: phase.to_string(),
detail,
},
);
}

/// Default idle timeout (in minutes) for Staged workstations.
pub(crate) const WORKSPACE_IDLE_TIMEOUT_MINUTES: u32 = 10080;

Expand Down Expand Up @@ -1353,6 +1371,11 @@ pub async fn setup_worktree(
/// Like [`setup_worktree`], but also runs prerun actions after the worktree is
/// ready. Used by the frontend retry path so that a failed initial setup
/// (which skips prerun actions) can be fully recovered by the user.
///
/// Resolves at worktree-ready: the prerun run is detached
/// ([`spawn_prerun_actions`]) because the frontend holds the branch in
/// `pendingSetupBranches` — and the card in "Setting up…" — until this command
/// returns, and nothing in what it returns comes from prerun.
#[tauri::command(rename_all = "camelCase")]
pub async fn setup_worktree_and_run_prerun(
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
Expand All @@ -1363,41 +1386,13 @@ pub async fn setup_worktree_and_run_prerun(
// Delegate to the existing setup_worktree command for worktree creation.
let result = setup_worktree(store.clone(), branch_id.clone()).await?;

let store = get_store(&store)?;

// Atomically claim setup ownership — only run prerun actions if we win.
match store.mark_branch_setup_complete(&branch_id) {
Ok(true) => {
let executor = app_handle.state::<Arc<ActionExecutor>>();
let act_registry = app_handle.state::<Arc<ActionRegistry>>();
match run_prerun_actions_for_branch(
&store,
&app_handle,
&branch_id,
&executor,
&act_registry,
provider.as_deref(),
)
.await
{
Ok(count) => {
log::info!("[setup_worktree_and_run_prerun] ran {count} prerun actions");
}
Err(e) => {
log::warn!("[setup_worktree_and_run_prerun] prerun actions failed: {e}");
}
}
}
Ok(false) => {
log::info!(
"[setup_worktree_and_run_prerun] branch {} already setup complete, skipping prerun",
branch_id
);
}
Err(e) => {
log::warn!("[setup_worktree_and_run_prerun] failed to mark setup complete: {e}");
}
}
spawn_prerun_actions(
get_store(&store)?,
app_handle,
branch_id,
provider,
"setup_worktree_and_run_prerun",
);

Ok(result)
}
Expand Down Expand Up @@ -2469,15 +2464,7 @@ pub(crate) fn setup_worktree_sync(
) -> Result<String, String> {
let emit_progress = |phase: &str, detail: Option<String>| {
if let Some(handle) = app_handle {
crate::web_server::emit_to_all(
handle,
"worktree-setup-progress",
WorktreeSetupProgress {
branch_id: branch_id.to_string(),
phase: phase.to_string(),
detail,
},
);
emit_setup_progress(handle, branch_id, phase, detail);
}
};

Expand Down Expand Up @@ -2546,11 +2533,122 @@ pub(crate) fn setup_worktree_sync(
Ok(worktree_str)
}

/// What [`claim_and_run_prerun_actions`] did.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum PrerunOutcome {
/// This caller won the setup claim and the branch's prerun actions all ran
/// to completion; the count is how many.
Ran(usize),
/// Nothing ran — another caller had already claimed this branch's setup, or
/// the claim or the run failed. Failures are logged, not returned: no
/// caller has anything to do with one.
NotRun,
}

/// Claim setup ownership of a branch and, if this caller wins the claim, run
/// the branch's prerun actions.
///
/// `mark_branch_setup_complete` is a one-shot atomic claim, so the claim and
/// the run belong in one place: a caller that takes the claim and then doesn't
/// run leaves that worktree without its setup actions forever, and nothing
/// retries it. Keeping them together is why this is the only way in to
/// [`run_prerun_actions_for_branch`], which is private for that reason.
///
/// **Never await this on a caller's critical path.** Detection inside it waits
/// out another caller's detection window for up to five minutes
/// ([`crate::actions::commands::ensure_actions_detected`]), and each prerun
/// action then runs to completion in turn — a dependency install alone can
/// outlast any request timeout. Every entry point either already runs in a
/// background task or reaches this through [`spawn_prerun_actions`].
///
/// `tag` names the entry point in this function's log lines.
pub(crate) async fn claim_and_run_prerun_actions(
store: &Arc<Store>,
app_handle: &AppHandle,
branch_id: &str,
executor: &Arc<ActionExecutor>,
act_registry: &Arc<ActionRegistry>,
provider: Option<&str>,
tag: &str,
) -> PrerunOutcome {
match store.mark_branch_setup_complete(branch_id) {
Ok(true) => {
emit_setup_progress(app_handle, branch_id, "running_setup_actions", None);
match run_prerun_actions_for_branch(
store,
app_handle,
branch_id,
executor,
act_registry,
provider,
)
.await
{
Ok(count) => {
log::info!("[{tag}] ran {count} prerun actions");
PrerunOutcome::Ran(count)
}
Err(e) => {
log::warn!("[{tag}] prerun actions failed: {e}");
PrerunOutcome::NotRun
}
}
}
Ok(false) => {
log::info!("[{tag}] branch {branch_id} already setup complete, skipping prerun");
PrerunOutcome::NotRun
}
Err(e) => {
log::warn!("[{tag}] failed to mark setup complete: {e}");
PrerunOutcome::NotRun
}
}
}

/// [`claim_and_run_prerun_actions`], detached from the caller.
///
/// For the entry points whose caller is on a clock — the branch card's Retry
/// button, over Tauri and over HTTP — where prerun's result is discarded
/// anyway. They return as soon as the worktree exists and leave this running:
/// the worktree has been on disk for seconds by then, while prerun can take
/// minutes, and the frontend holds the branch in "Setting up…" until the
/// command resolves.
///
/// The claim goes into the task with the run so it can't be consumed by a task
/// that never runs the prerun; see [`claim_and_run_prerun_actions`].
pub(crate) fn spawn_prerun_actions(
store: Arc<Store>,
app_handle: AppHandle,
branch_id: String,
provider: Option<String>,
tag: &'static str,
) {
tauri::async_runtime::spawn(async move {
let executor = app_handle.state::<Arc<ActionExecutor>>().inner().clone();
let act_registry = app_handle.state::<Arc<ActionRegistry>>().inner().clone();
claim_and_run_prerun_actions(
&store,
&app_handle,
&branch_id,
&executor,
&act_registry,
provider.as_deref(),
tag,
)
.await;
});
}

/// 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(
///
/// Private on purpose: prerun runs exactly once per branch, behind the
/// `mark_branch_setup_complete` claim, so [`claim_and_run_prerun_actions`] is
/// the only caller — the two can't drift apart if there is nowhere else to
/// call this from.
async fn run_prerun_actions_for_branch(
store: &Arc<Store>,
app_handle: &AppHandle,
branch_id: &str,
Expand Down Expand Up @@ -2586,92 +2684,12 @@ pub(crate) async fn run_prerun_actions_for_branch(
.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 {
store
.set_action_context_detecting(&context.id, true)
.map_err(|e| format!("Failed to set detection status: {e}"))?;

crate::web_server::emit_to_all(
app_handle,
"repo-actions-detection",
serde_json::json!({
"githubRepo": github_repo,
"subpath": subpath,
"detecting": true,
}),
);

// Run detection (may call out to AI)
let detected = match crate::actions::commands::detect_actions_for_repo_context(
&github_repo,
subpath.as_deref(),
provider_id,
)
.await
{
Ok(actions) => actions,
Err(e) => {
log::warn!(
"[run_prerun_actions_for_branch] action detection failed for repo {} (subpath: {:?}): {e}",
github_repo,
subpath
);
Vec::new()
}
};

// 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<String> =
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}"))?;

crate::web_server::emit_to_all(
app_handle,
"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}"))?;
// If actions haven't been detected yet for this repo+subpath, detect now —
// waiting out another caller's detection rather than reading a list it
// hasn't finished writing.
let actions =
crate::actions::commands::ensure_actions_detected(app_handle, store, &context, provider_id)
.await?;
let prerun_actions: Vec<_> = actions
.into_iter()
.filter(|a| matches!(a.action_type, ActionType::Prerun))
Expand Down
Loading