diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index 9a3ffaf9..f4b0a3c0 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -980,3 +980,35 @@ impl ResponseEntity for tower_api::apis::default_api::DeleteScheduleSuccess { } } } + +pub async fn cancel_run( + config: &Config, + name: &str, + seq: i64, +) -> Result< + tower_api::models::CancelRunResponse, + Error, +> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::CancelRunParams { + name: name.to_string(), + seq, + }; + + unwrap_api_response(tower_api::apis::default_api::cancel_run( + api_config, params, + )) + .await +} + +impl ResponseEntity for tower_api::apis::default_api::CancelRunSuccess { + type Data = tower_api::models::CancelRunResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} diff --git a/crates/tower-cmd/src/apps.rs b/crates/tower-cmd/src/apps.rs index 319ded8a..1fd816f0 100644 --- a/crates/tower-cmd/src/apps.rs +++ b/crates/tower-cmd/src/apps.rs @@ -76,6 +76,24 @@ pub fn apps_cmd() -> Command { ) .about("Delete an app in Tower"), ) + .subcommand( + Command::new("cancel") + .arg( + Arg::new("app_name") + .value_parser(value_parser!(String)) + .index(1) + .required(true) + .help("Name of the app"), + ) + .arg( + Arg::new("run_number") + .value_parser(value_parser!(i64)) + .index(2) + .required(true) + .help("Run number to cancel"), + ) + .about("Cancel a running app run"), + ) } pub async fn do_logs(config: Config, cmd: &ArgMatches) { @@ -223,6 +241,26 @@ pub async fn do_delete(config: Config, cmd: &ArgMatches) { output::with_spinner("Deleting app", api::delete_app(&config, name)).await; } +pub async fn do_cancel(config: Config, cmd: &ArgMatches) { + let name = cmd + .get_one::("app_name") + .expect("app_name should be required"); + let seq = cmd + .get_one::("run_number") + .copied() + .expect("run_number should be required"); + + let response = + output::with_spinner("Cancelling run", api::cancel_run(&config, name, seq)).await; + + let run = &response.run; + let status = format!("{:?}", run.status); + output::success_with_data( + &format!("Run #{} for '{}' cancelled (status: {})", seq, name, status), + Some(response), + ); +} + async fn latest_run_number(config: &Config, name: &str) -> i64 { match api::describe_app(config, name).await { Ok(resp) => resp.runs @@ -715,4 +753,30 @@ mod tests { assert!(should_emit_line(&mut last_line_num, 4)); assert_eq!(last_line_num, Some(4)); } + + #[test] + fn test_cancel_args_parsing() { + let matches = apps_cmd() + .try_get_matches_from(["apps", "cancel", "my-app", "42"]) + .unwrap(); + let (cmd, sub_matches) = matches.subcommand().unwrap(); + + assert_eq!(cmd, "cancel"); + assert_eq!( + sub_matches + .get_one::("app_name") + .map(|s| s.as_str()), + Some("my-app") + ); + assert_eq!(sub_matches.get_one::("run_number"), Some(&42)); + } + + #[test] + fn test_cancel_requires_both_args() { + let result = apps_cmd().try_get_matches_from(["apps", "cancel", "my-app"]); + assert!(result.is_err()); + + let result = apps_cmd().try_get_matches_from(["apps", "cancel"]); + assert!(result.is_err()); + } } diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index a1086e28..43de9c31 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -124,6 +124,7 @@ impl App { Some(("show", args)) => apps::do_show(sessionized_config, args).await, Some(("logs", args)) => apps::do_logs(sessionized_config, args).await, Some(("delete", args)) => apps::do_delete(sessionized_config, args).await, + Some(("cancel", args)) => apps::do_cancel(sessionized_config, args).await, _ => { apps::apps_cmd().print_help().unwrap(); std::process::exit(2); diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index ad6043ae..c2c18e47 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -98,6 +98,12 @@ struct GenerateTowerfileRequest { script_path: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +struct CancelRunRequest { + name: String, + run_number: String, +} + #[derive(Debug, Deserialize, JsonSchema)] struct ScheduleRequest { app_name: String, @@ -481,6 +487,28 @@ impl TowerService { } } + #[tool(description = "Cancel a running Tower app run")] + async fn tower_apps_cancel( + &self, + Parameters(request): Parameters, + ) -> Result { + let seq: i64 = request + .run_number + .parse() + .map_err(|_| McpError::invalid_params("run_number must be a number", None))?; + + match api::cancel_run(&self.config, &request.name, seq).await { + Ok(response) => { + let status = format!("{:?}", response.run.status); + Self::text_success(format!( + "Cancelled run #{} for '{}' (status: {})", + seq, request.name, status + )) + } + Err(e) => Self::error_result("Failed to cancel run", e), + } + } + #[tool(description = "List secrets in your Tower account (shows only previews for security)")] async fn tower_secrets_list( &self, diff --git a/tests/integration/features/cli_runs.feature b/tests/integration/features/cli_runs.feature index 27f272b9..b2e4c384 100644 --- a/tests/integration/features/cli_runs.feature +++ b/tests/integration/features/cli_runs.feature @@ -45,6 +45,13 @@ Feature: CLI Run Commands And I run "tower apps logs --follow {app_name}#{run_number}" via CLI using created app name and run number Then the output should show "Hello, World!" + Scenario: CLI apps cancel should cancel a running run + Given I have a valid Towerfile in the current directory + When I run "tower deploy --create" via CLI + And I run "tower run --detached" via CLI and capture run number + And I run "tower apps cancel {app_name} {run_number}" via CLI using created app name and run number + Then the output should show "cancelled" + Scenario: CLI apps logs follow should display warnings Given I have a simple hello world application named "app-logs-warning" When I run "tower deploy --create" via CLI diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index 11f2e2ba..b8ea7762 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -338,6 +338,24 @@ async def describe_run(name: str, seq: int): ) +@app.post("/v1/apps/{name}/runs/{seq}") +async def cancel_run(name: str, seq: int): + """Mock endpoint for cancelling a run.""" + if name not in mock_apps_db: + raise HTTPException(status_code=404, detail=f"App '{name}' not found") + + for run_id, run_data in mock_runs_db.items(): + if run_data["app_name"] == name and run_data["number"] == seq: + run_data["status"] = "cancelled" + run_data["status_group"] = "successful" + run_data["cancelled_at"] = now_iso() + return {"run": run_data} + + raise HTTPException( + status_code=404, detail=f"Run sequence {seq} not found for app '{name}'" + ) + + # Placeholder for /secrets endpoints @app.get("/v1/secrets") async def list_secrets():