diff --git a/src/smallestai/cli/agent_crew.py b/src/smallestai/cli/agent_crew.py index 8c2a8fa7..968d8b65 100644 --- a/src/smallestai/cli/agent_crew.py +++ b/src/smallestai/cli/agent_crew.py @@ -584,6 +584,42 @@ async def async_build_logs(build_id: str | None, agent_id_arg: Optional[str] = N console.print(f"[red]Error streaming build logs: {e}[/red]") raise typer.Exit(1) + @app.command("cancel") + def cancel_build( + build_id: str = typer.Argument(..., help="Build ID to cancel."), + agent_id: Optional[str] = typer.Option( + None, "--agent-id", help="Agent id (defaults to the linked project agent)." + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), + ): + """Cancel a build that is stuck in QUEUED or BUILDING. + + Frees the agent's build queue when a build hangs. A build that has already + finished, failed, or is deploying can't be cancelled. + """ + if not yes and sys.stdin.isatty(): + typer.confirm(f"Cancel build {build_id}?", abort=True) + asyncio.run(async_cancel_build(build_id, agent_id)) + + async def async_cancel_build(build_id: str, agent_id_arg: Optional[str] = None): + agent_id = _resolve_agent_id(agent_id_arg) + + credentials = auth_client.get_credentials() + if not credentials or not credentials.get("access_token"): + console.print("[red]Error: You must be logged in first. Run 'smallestai auth login'[/red]") + raise typer.Exit(1) + access_token = credentials["access_token"] + + try: + message = await atoms_client.cancel_agent_build( + agent_id=agent_id, build_id=build_id, api_key=access_token + ) + except Exception as e: + console.print(f"[red]Could not cancel build {build_id[:12]}...: {e}[/red]") + raise typer.Exit(1) + + console.print(f"[bold green]✓ {message}[/bold green] [dim]({build_id[:12]}...)[/dim]") + @app.command() def doctor( agent_id: Optional[str] = typer.Option( diff --git a/src/smallestai/cli/lib/atoms.py b/src/smallestai/cli/lib/atoms.py index 32bc0866..1b06c7b8 100644 --- a/src/smallestai/cli/lib/atoms.py +++ b/src/smallestai/cli/lib/atoms.py @@ -306,6 +306,41 @@ async def update_agent_build( return update_build_response.data + async def cancel_agent_build( + self, + agent_id: str, + build_id: str, + api_key: str, + ) -> str: + """Cancel a build stuck in QUEUED/BUILDING. Returns the server's message. + + Raises with the API's reason when the build can't be cancelled (e.g. it + is already terminal or deploying, which the backend returns as HTTP 409). + """ + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/atoms/v1/sdk/agents/{agent_id}/builds/{build_id}/cancel", + headers={ + "Authorization": f"Bearer {api_key}", + }, + ) + + body = None + try: + body = response.json() + except Exception: + pass + + if response.status_code >= 400: + errors = body.get("errors") if isinstance(body, dict) else None + if isinstance(errors, list): + errors = "; ".join(str(e) for e in errors) + raise Exception(errors or f"cancel failed (HTTP {response.status_code}).") + + data = body.get("data") if isinstance(body, dict) else None + message = data.get("message") if isinstance(data, dict) else None + return message or "Build cancelled" + async def _stream_sse(self, url: str, access_token: str): """Open an SSE stream and yield each `data:` frame as a parsed dict.