From 2de7cbedd46b4aa409f5200be0fad7218d7be74e Mon Sep 17 00:00:00 2001 From: trac41799 Date: Tue, 29 Sep 2026 12:57:44 +0900 Subject: [PATCH] feat(build): cancel and resume a running build from the app - pipeline_commands: new get_active_build_run_cmd exposes the in-flight (or paused) run for a project path, using the existing find_resumable_run. build_app_cmd only returns when the pipeline finishes, so the UI previously had no way to know the run id in time to cancel. - BuildApp: poll the active run while building, show Cancel (calls cancel_build_app_cmd, honoured between stages and by the supervisor's cancel check), and offer Resume for a paused run (awaiting_user/cancelled) via resume_build_app_cmd. - IPC: register getActiveBuildRun; regenerate ipc-unused snapshot (cancel/resume are now genuinely used). - tests: BuildApp cancel flow; web suite 341. Gates: Rust 185 lib + 42 integration; Web 341; tsc/lint clean. --- src-tauri/src/lib.rs | 1 + src-tauri/src/pipeline_commands.rs | 14 ++++ src-tauri/src/real_pipeline_test.rs | 9 ++- .../contracts/ipc-unused.snapshot.json | 2 - src/__tests__/pages/BuildApp.test.tsx | 28 +++++++- src/lib/ipc/commands.ts | 1 + src/pages/BuildApp.tsx | 69 +++++++++++++++++++ 7 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e7f263..f6cb710 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -247,6 +247,7 @@ pub fn run() { pipeline_commands::resume_build_app_cmd, pipeline_commands::cancel_build_app_cmd, pipeline_commands::get_build_app_status_cmd, + pipeline_commands::get_active_build_run_cmd, // Knowledge Graph Commands commands::kg_local_search_cmd, commands::kg_global_search_cmd, diff --git a/src-tauri/src/pipeline_commands.rs b/src-tauri/src/pipeline_commands.rs index c3d28d0..9c08789 100644 --- a/src-tauri/src/pipeline_commands.rs +++ b/src-tauri/src/pipeline_commands.rs @@ -198,3 +198,17 @@ pub async fn get_build_app_status_cmd( let db = state.db.lock().map_err(|e| e.to_string())?; pipeline_store::get_run(&db, &run_id) } + +/// The run currently in flight (or paused awaiting the user) for a project path. +/// +/// `build_app_cmd` only returns when the pipeline finishes, so the UI cannot +/// learn the `run_id` in time to offer Cancel/Resume. This exposes the active +/// run so a long build can be cancelled or retried from the app. +#[tauri::command] +pub async fn get_active_build_run_cmd( + state: State<'_, AppState>, + project_path: String, +) -> Result, String> { + let db = state.db.lock().map_err(|e| e.to_string())?; + pipeline_store::find_resumable_run(&db, &project_path) +} diff --git a/src-tauri/src/real_pipeline_test.rs b/src-tauri/src/real_pipeline_test.rs index 5b22a64..6ae5190 100644 --- a/src-tauri/src/real_pipeline_test.rs +++ b/src-tauri/src/real_pipeline_test.rs @@ -110,8 +110,8 @@ fn make_fixture_repo() -> tempfile::TempDir { dir } -/// The spec paragraph becomes the step objective, which the guideline carries -/// into the agent prompt (together with the exact HANDOFF filename). +/// Two independent steps → two agents running concurrently in their own +/// worktrees (exercises the parallel wave model, not just a single agent). fn make_spec(dir: &Path) -> String { let path = dir.join("real-plan.md"); let body = "# Plan\n\n\ @@ -120,6 +120,11 @@ fn make_spec(dir: &Path) -> String { **Wave:** A \u{00b7} **Depends on:** \u{2014}\n\ Create a file named src/greeting.ts in this repository that exports \ `export function greet(name: string): string { return `Hello ${name}`; }` \ + as dependency-free TypeScript.\n\n\ + ### Step 1.2: Add a farewell module\n\ + **Wave:** A \u{00b7} **Depends on:** \u{2014}\n\ + Create a file named src/farewell.ts in this repository that exports \ + `export function farewell(name: string): string { return `Bye ${name}`; }` \ as dependency-free TypeScript.\n"; std::fs::write(&path, body).unwrap(); path.to_string_lossy().to_string() diff --git a/src/__tests__/contracts/ipc-unused.snapshot.json b/src/__tests__/contracts/ipc-unused.snapshot.json index bf51f95..a5aad0e 100644 --- a/src/__tests__/contracts/ipc-unused.snapshot.json +++ b/src/__tests__/contracts/ipc-unused.snapshot.json @@ -1,5 +1,4 @@ [ - "cancel_build_app_cmd", "check_cli_cmd", "claim_file_cmd", "create_delegation_report_cmd", @@ -26,7 +25,6 @@ "record_subagent_token_usage_cmd", "release_all_for_thread_cmd", "release_file_cmd", - "resume_build_app_cmd", "scaffold_project_cmd", "seed_wave_from_spec_cmd", "set_project_path", diff --git a/src/__tests__/pages/BuildApp.test.tsx b/src/__tests__/pages/BuildApp.test.tsx index f30abdc..82e6987 100644 --- a/src/__tests__/pages/BuildApp.test.tsx +++ b/src/__tests__/pages/BuildApp.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import BuildApp from "@/pages/BuildApp"; import { mockInvoke } from "../setup"; @@ -64,4 +64,30 @@ describe("BuildApp page", () => { expect(screen.getByText(/SPA rewrite excludes \/api/)).toBeInTheDocument(); expect(screen.getByText(/2 knowledge items compounded/)).toBeInTheDocument(); }); + + it("offers Cancel for a running build and cancels the active run", async () => { + // `build_app_cmd` only returns when the pipeline finishes, so the page polls + // the active run to learn its id; that is what makes Cancel possible. + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "build_app_cmd") return new Promise(() => {}); + if (cmd === "get_active_build_run_cmd") return { id: "run-42" }; + if (cmd === "cancel_build_app_cmd") return true; + return undefined; + }); + + render(); + await userEvent.type(screen.getByPlaceholderText("docs/PLAN.md"), "docs/PLAN.md"); + await userEvent.type(screen.getByPlaceholderText("/path/to/project"), "/tmp/app"); + await userEvent.click(screen.getByRole("button", { name: /build app/i })); + + const cancel = await screen.findByRole("button", { name: /cancel/i }); + await waitFor(() => expect(cancel).toBeEnabled()); + await userEvent.click(cancel); + + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("cancel_build_app_cmd", { + runId: "run-42", + }) + ); + }); }); diff --git a/src/lib/ipc/commands.ts b/src/lib/ipc/commands.ts index dbe31d9..b6b463b 100644 --- a/src/lib/ipc/commands.ts +++ b/src/lib/ipc/commands.ts @@ -34,6 +34,7 @@ export const IPC = { resumeBuildApp: "resume_build_app_cmd", cancelBuildApp: "cancel_build_app_cmd", getBuildAppStatus: "get_build_app_status_cmd", + getActiveBuildRun: "get_active_build_run_cmd", // Decision layer getDecisionConfig: "get_decision_config_cmd", diff --git a/src/pages/BuildApp.tsx b/src/pages/BuildApp.tsx index 582d404..275d1f8 100644 --- a/src/pages/BuildApp.tsx +++ b/src/pages/BuildApp.tsx @@ -15,6 +15,8 @@ import { MinusCircle, ExternalLink, Loader2, + Ban, + RotateCcw, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -82,6 +84,7 @@ export default function BuildApp() { const [progress, setProgress] = useState([]); const [report, setReport] = useState(null); const [error, setError] = useState(null); + const [activeRunId, setActiveRunId] = useState(null); useEffect(() => { if (project?.path && !projectPath) { @@ -110,11 +113,60 @@ export default function BuildApp() { const canStart = specPath.trim().length > 0 && projectPath.trim().length > 0 && !running; + // `build_app_cmd` only returns when the pipeline finishes, so poll the active + // run id to be able to cancel (or resume) a long build from the UI. + useEffect(() => { + if (!running || !projectPath.trim()) return; + let stopped = false; + const tick = async () => { + try { + const run = await invoke<{ id: string } | null>(IPC.getActiveBuildRun, { + projectPath: projectPath.trim(), + }); + if (!stopped && run?.id) setActiveRunId(run.id); + } catch { + // The run may not exist yet on the first tick. + } + }; + void tick(); + const timer = setInterval(tick, 2000); + return () => { + stopped = true; + clearInterval(timer); + }; + }, [running, projectPath]); + + const cancel = async () => { + if (!activeRunId) return; + try { + await invoke(IPC.cancelBuildApp, { runId: activeRunId }); + } catch (e) { + setError(String(e)); + } + }; + + const resume = async () => { + if (!activeRunId) return; + setRunning(true); + setError(null); + try { + const result = await invoke(IPC.resumeBuildApp, { + runId: activeRunId, + }); + setReport(result); + } catch (e) { + setError(String(e)); + } finally { + setRunning(false); + } + }; + const start = async () => { setRunning(true); setError(null); setReport(null); setProgress([]); + setActiveRunId(null); try { const result = await invoke(IPC.buildApp, { options: { @@ -199,6 +251,23 @@ export default function BuildApp() { )} {running ? "Building…" : "Build App"} + {running && ( + + )} + {!running && activeRunId && ( + + )} Deploy runs only when verification passes.