Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions src-tauri/src/pipeline_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<BuildRun>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
pipeline_store::find_resumable_run(&db, &project_path)
}
9 changes: 7 additions & 2 deletions src-tauri/src/real_pipeline_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\
Expand All @@ -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()
Expand Down
2 changes: 0 additions & 2 deletions src/__tests__/contracts/ipc-unused.snapshot.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
[
"cancel_build_app_cmd",
"check_cli_cmd",
"claim_file_cmd",
"create_delegation_report_cmd",
Expand All @@ -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",
Expand Down
28 changes: 27 additions & 1 deletion src/__tests__/pages/BuildApp.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(<BuildApp />);
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",
})
);
});
});
1 change: 1 addition & 0 deletions src/lib/ipc/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 69 additions & 0 deletions src/pages/BuildApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
MinusCircle,
ExternalLink,
Loader2,
Ban,
RotateCcw,
} from "lucide-react";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -82,6 +84,7 @@ export default function BuildApp() {
const [progress, setProgress] = useState<string[]>([]);
const [report, setReport] = useState<BuildReport | null>(null);
const [error, setError] = useState<string | null>(null);
const [activeRunId, setActiveRunId] = useState<string | null>(null);

useEffect(() => {
if (project?.path && !projectPath) {
Expand Down Expand Up @@ -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<BuildReport>(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<BuildReport>(IPC.buildApp, {
options: {
Expand Down Expand Up @@ -199,6 +251,23 @@ export default function BuildApp() {
)}
{running ? "Building…" : "Build App"}
</Button>
{running && (
<Button
variant="outline"
onClick={cancel}
disabled={!activeRunId}
className="gap-1.5"
>
<Ban className="size-4" />
Cancel
</Button>
)}
{!running && activeRunId && (
<Button variant="outline" onClick={resume} className="gap-1.5">
<RotateCcw className="size-4" />
Resume
</Button>
)}
<span className="text-xs text-muted-foreground">
Deploy runs only when verification passes.
</span>
Expand Down
Loading