Skip to content
Open
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
32 changes: 32 additions & 0 deletions crates/tower-cmd/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<tower_api::apis::default_api::CancelRunError>,
> {
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<Self::Data> {
match self {
Self::Status200(data) => Some(data),
Self::UnknownValue(_) => None,
}
}
}
64 changes: 64 additions & 0 deletions crates/tower-cmd/src/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Comment on lines +79 to +95
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want the interface to be cancel {app_name} {seq} or cancel --app-name {name} --seq {seq}?

)
}

pub async fn do_logs(config: Config, cmd: &ArgMatches) {
Expand Down Expand Up @@ -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::<String>("app_name")
.expect("app_name should be required");
let seq = cmd
.get_one::<i64>("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
Expand Down Expand Up @@ -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::<String>("app_name")
.map(|s| s.as_str()),
Some("my-app")
);
assert_eq!(sub_matches.get_one::<i64>("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());
}
}
1 change: 1 addition & 0 deletions crates/tower-cmd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions crates/tower-cmd/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ struct GenerateTowerfileRequest {
script_path: Option<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct CancelRunRequest {
name: String,
run_number: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct ScheduleRequest {
app_name: String,
Expand Down Expand Up @@ -481,6 +487,28 @@ impl TowerService {
}
}

#[tool(description = "Cancel a running Tower app run")]
async fn tower_apps_cancel(
&self,
Parameters(request): Parameters<CancelRunRequest>,
) -> Result<CallToolResult, McpError> {
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,
Expand Down
7 changes: 7 additions & 0 deletions tests/integration/features/cli_runs.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions tests/mock-api-server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Comment on lines +347 to +352
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restrict cancellation to active runs only.

The handler currently marks any matched run as cancelled. This can incorrectly allow cancelling already terminal runs and drifts from the “cancel running runs” behavior.

💡 Proposed fix
-    for run_id, run_data in mock_runs_db.items():
+    for run_id, run_data in mock_runs_db.items():
         if run_data["app_name"] == name and run_data["number"] == seq:
+            if run_data["status"] not in {"running", "pending", "scheduled"}:
+                raise HTTPException(
+                    status_code=409,
+                    detail=f"Run sequence {seq} for app '{name}' is not cancellable from status '{run_data['status']}'",
+                )
             run_data["status"] = "cancelled"
             run_data["status_group"] = "successful"
             run_data["cancelled_at"] = now_iso()
             return {"run": run_data}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/mock-api-server/main.py` around lines 347 - 352, The cancellation loop
in tests/mock-api-server/main.py currently cancels any matched run; change it to
only cancel runs whose run_data indicates they are active (e.g., check
run_data["status_group"] == "running" or that run_data["status"] is not in
terminal states). In the loop that iterates mock_runs_db items (referencing
mock_runs_db, run_data, name, seq, now_iso), add a guard to skip non-active runs
before setting run_data["status"], run_data["status_group"], and
run_data["cancelled_at"], and only return the modified run when the run was
actually running.


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():
Expand Down