From 6b5ae43f9963a767f7a3975b5bda8824db3ce921 Mon Sep 17 00:00:00 2001 From: Vim Wickramasinghe Date: Tue, 19 May 2026 16:52:20 +0200 Subject: [PATCH 1/2] [TOW-2007] Runtime as the source of start and end times --- crates/tower-cmd/src/api.rs | 51 +++++++++++++++++++------- crates/tower-cmd/src/apps.rs | 8 ++-- crates/tower-cmd/src/run.rs | 10 ++--- crates/tower-cmd/src/teams.rs | 5 +-- crates/tower-runtime/src/execution.rs | 28 +++++++++++++- crates/tower-runtime/src/lib.rs | 5 +-- crates/tower-runtime/src/local.rs | 10 +++-- crates/tower-runtime/src/subprocess.rs | 12 +++--- 8 files changed, 87 insertions(+), 42 deletions(-) diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index 07f0dca8..7accd413 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -30,38 +30,62 @@ trait PaginatedResponse { impl PaginatedResponse for tower_api::models::ListAppsResponse { type Item = tower_api::models::AppSummary; - fn pagination(&self) -> &Pagination { &self.pages } - fn into_items(self) -> Vec { self.apps } + fn pagination(&self) -> &Pagination { + &self.pages + } + fn into_items(self) -> Vec { + self.apps + } } impl PaginatedResponse for tower_api::models::ListTeamsResponse { type Item = tower_api::models::Team; - fn pagination(&self) -> &Pagination { &self.pages } - fn into_items(self) -> Vec { self.teams } + fn pagination(&self) -> &Pagination { + &self.pages + } + fn into_items(self) -> Vec { + self.teams + } } impl PaginatedResponse for tower_api::models::ListSecretsResponse { type Item = tower_api::models::Secret; - fn pagination(&self) -> &Pagination { &self.pages } - fn into_items(self) -> Vec { self.secrets } + fn pagination(&self) -> &Pagination { + &self.pages + } + fn into_items(self) -> Vec { + self.secrets + } } impl PaginatedResponse for tower_api::models::ListCatalogsResponse { type Item = tower_api::models::Catalog; - fn pagination(&self) -> &Pagination { &self.pages } - fn into_items(self) -> Vec { self.catalogs } + fn pagination(&self) -> &Pagination { + &self.pages + } + fn into_items(self) -> Vec { + self.catalogs + } } impl PaginatedResponse for tower_api::models::ListEnvironmentsResponse { type Item = tower_api::models::Environment; - fn pagination(&self) -> &Pagination { &self.pages } - fn into_items(self) -> Vec { self.environments } + fn pagination(&self) -> &Pagination { + &self.pages + } + fn into_items(self) -> Vec { + self.environments + } } impl PaginatedResponse for tower_api::models::ListSchedulesResponse { type Item = tower_api::models::Schedule; - fn pagination(&self) -> &Pagination { &self.pages } - fn into_items(self) -> Vec { self.schedules } + fn pagination(&self) -> &Pagination { + &self.pages + } + fn into_items(self) -> Vec { + self.schedules + } } /// Fetches pages from a paginated API endpoint, honoring the caller's @@ -375,8 +399,7 @@ pub async fn list_secrets( config: &Config, env: &str, all: bool, -) -> Result, Error> -{ +) -> Result, Error> { let api_config: configuration::Configuration = config.into(); let env = env.to_string(); diff --git a/crates/tower-cmd/src/apps.rs b/crates/tower-cmd/src/apps.rs index e04c3dc9..5fc707c1 100644 --- a/crates/tower-cmd/src/apps.rs +++ b/crates/tower-cmd/src/apps.rs @@ -833,9 +833,7 @@ mod tests { #[test] fn list_defaults_to_no_environment_filter() { - let matches = apps_cmd() - .try_get_matches_from(["apps", "list"]) - .unwrap(); + let matches = apps_cmd().try_get_matches_from(["apps", "list"]).unwrap(); let (_, list_args) = matches.subcommand().unwrap(); assert_eq!(list_args.get_one::("environment"), None); @@ -849,7 +847,9 @@ mod tests { let (_, list_args) = matches.subcommand().unwrap(); assert_eq!( - list_args.get_one::("environment").map(|s| s.as_str()), + list_args + .get_one::("environment") + .map(|s| s.as_str()), Some("production") ); } diff --git a/crates/tower-cmd/src/run.rs b/crates/tower-cmd/src/run.rs index be8050e6..3637fa0a 100644 --- a/crates/tower-cmd/src/run.rs +++ b/crates/tower-cmd/src/run.rs @@ -713,22 +713,22 @@ async fn monitor_cli_status( ); match handle.lock().await.status().await { - Ok(status) => { + Ok(exec_status) => { // We reset the error count to indicate that we can intermittently get statuses. err_count = 0; - match status { + match exec_status.status { Status::Exited => { debug!("Run exited cleanly, stopping status monitoring"); - return status; + return exec_status.status; } Status::Crashed { .. } => { debug!("Run crashed, stopping status monitoring"); - return status; + return exec_status.status; } Status::Failed { .. } => { debug!("Run failed at platform layer, stopping status monitoring"); - return status; + return exec_status.status; } _ => { debug!("Handle status: other, continuing to monitor"); diff --git a/crates/tower-cmd/src/teams.rs b/crates/tower-cmd/src/teams.rs index 36783a9e..59ae9636 100644 --- a/crates/tower-cmd/src/teams.rs +++ b/crates/tower-cmd/src/teams.rs @@ -60,10 +60,7 @@ async fn do_list_via_api(config: &Config) { let headers = vec!["Name".to_string()]; - let teams_data: Vec> = teams - .iter() - .map(|team| vec![team.name.clone()]) - .collect(); + let teams_data: Vec> = teams.iter().map(|team| vec![team.name.clone()]).collect(); output::newline(); output::table(headers, teams_data, None::<&Vec>); diff --git a/crates/tower-runtime/src/execution.rs b/crates/tower-runtime/src/execution.rs index 47d6dd26..f69e51f9 100644 --- a/crates/tower-runtime/src/execution.rs +++ b/crates/tower-runtime/src/execution.rs @@ -5,6 +5,7 @@ //! Kubernetes pods, etc.) through a uniform interface. use async_trait::async_trait; +use chrono::{DateTime, Utc}; use std::collections::HashMap; use std::path::PathBuf; use tokio::io::AsyncRead; @@ -198,14 +199,37 @@ pub struct BackendCapabilities { // Execution Handle Trait // ============================================================================ +/// Result of querying execution status, including optional timing and +/// backend-specific metadata. The metadata map allows backends to surface +/// arbitrary key-value data (e.g. node_type, scheduling_latency_ms) without +/// requiring trait changes. +#[derive(Clone, Debug)] +pub struct ExecutionStatus { + pub status: Status, + pub started_at: Option>, + pub ended_at: Option>, + pub metadata: HashMap, +} + +impl From for ExecutionStatus { + fn from(status: Status) -> Self { + Self { + status, + started_at: None, + ended_at: None, + metadata: HashMap::new(), + } + } +} + /// ExecutionHandle represents a running execution #[async_trait] pub trait ExecutionHandle: Send + Sync { /// Get a unique identifier for this execution fn id(&self) -> &str; - /// Get current execution status - async fn status(&self) -> Result; + /// Get current execution status with optional timing metadata + async fn status(&self) -> Result; /// Subscribe to log stream async fn logs(&self) -> Result; diff --git a/crates/tower-runtime/src/lib.rs b/crates/tower-runtime/src/lib.rs index 446264fe..fffb9447 100644 --- a/crates/tower-runtime/src/lib.rs +++ b/crates/tower-runtime/src/lib.rs @@ -83,10 +83,7 @@ impl Status { pub fn is_terminal(&self) -> bool { matches!( self, - Status::Exited - | Status::Crashed { .. } - | Status::Cancelled - | Status::Failed(_) + Status::Exited | Status::Crashed { .. } | Status::Cancelled | Status::Failed(_) ) } } diff --git a/crates/tower-runtime/src/local.rs b/crates/tower-runtime/src/local.rs index 07b368cf..5f209361 100644 --- a/crates/tower-runtime/src/local.rs +++ b/crates/tower-runtime/src/local.rs @@ -303,7 +303,8 @@ async fn inner_execute_local_app( } } Ok(child) => { - let mut res = run_setup_child(&ctx, &cancel_token, &opts.output_sender, child).await; + let mut res = + run_setup_child(&ctx, &cancel_token, &opts.output_sender, child).await; // If sync was cancelled, don't bother retrying — bail out // cleanly so the receiver sees `Status::Cancelled` instead of @@ -328,7 +329,8 @@ async fn inner_execute_local_app( let retry_child = uv .sync_with_legacy_setuptools_pin(&working_dir, &env_vars) .await?; - res = run_setup_child(&ctx, &cancel_token, &opts.output_sender, retry_child).await; + res = run_setup_child(&ctx, &cancel_token, &opts.output_sender, retry_child) + .await; if cancel_token.is_cancelled() { return Err(Error::Cancelled); } @@ -419,7 +421,9 @@ impl App for LocalApp { Ok(Ok(code)) => AppCompletion::Exit(code), Ok(Err(Error::Cancelled)) => AppCompletion::Cancelled, Ok(Err(e)) => AppCompletion::Failed(AppFailure::Runtime(e)), - Err(panic) => AppCompletion::Failed(AppFailure::Panic(panic_payload_message(&panic))), + Err(panic) => { + AppCompletion::Failed(AppFailure::Panic(panic_payload_message(&panic))) + } }; let _ = sx.send(completion); }); diff --git a/crates/tower-runtime/src/subprocess.rs b/crates/tower-runtime/src/subprocess.rs index decdb1b0..a25ba1ec 100644 --- a/crates/tower-runtime/src/subprocess.rs +++ b/crates/tower-runtime/src/subprocess.rs @@ -4,7 +4,7 @@ use crate::auto_cleanup; use crate::errors::Error; use crate::execution::{ BackendCapabilities, CacheBackend, ExecutionBackend, ExecutionHandle, ExecutionSpec, - ServiceEndpoint, + ExecutionStatus, ServiceEndpoint, }; use crate::local::LocalApp; use crate::{App, OutputReceiver, StartOptions, Status}; @@ -212,9 +212,9 @@ impl ExecutionHandle for SubprocessHandle { &self.id } - async fn status(&self) -> Result { + async fn status(&self) -> Result { let app = self.app.lock().await; - app.status().await + Ok(app.status().await?.into()) } async fn logs(&self) -> Result { @@ -247,12 +247,12 @@ impl ExecutionHandle for SubprocessHandle { async fn wait_for_completion(&self) -> Result { loop { - let status = self.status().await?; - match status { + let exec_status = self.status().await?; + match exec_status.status { Status::None | Status::Running => { tokio::time::sleep(Duration::from_millis(100)).await; } - _ => return Ok(status), + _ => return Ok(exec_status.status), } } } From 6e0c662dd6265fc290ed32d1aba32235a84ac680 Mon Sep 17 00:00:00 2001 From: Vim Wickramasinghe Date: Wed, 20 May 2026 10:32:44 +0200 Subject: [PATCH 2/2] fix: avoid use-after-move on ExecutionStatus.status field Extract status into a local variable before matching to prevent the moved-value error since Status is not Copy. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tower-cmd/src/run.rs | 9 +++++---- crates/tower-runtime/src/subprocess.rs | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/tower-cmd/src/run.rs b/crates/tower-cmd/src/run.rs index 3637fa0a..3a3cfa27 100644 --- a/crates/tower-cmd/src/run.rs +++ b/crates/tower-cmd/src/run.rs @@ -716,19 +716,20 @@ async fn monitor_cli_status( Ok(exec_status) => { // We reset the error count to indicate that we can intermittently get statuses. err_count = 0; + let status = exec_status.status; - match exec_status.status { + match status { Status::Exited => { debug!("Run exited cleanly, stopping status monitoring"); - return exec_status.status; + return status; } Status::Crashed { .. } => { debug!("Run crashed, stopping status monitoring"); - return exec_status.status; + return status; } Status::Failed { .. } => { debug!("Run failed at platform layer, stopping status monitoring"); - return exec_status.status; + return status; } _ => { debug!("Handle status: other, continuing to monitor"); diff --git a/crates/tower-runtime/src/subprocess.rs b/crates/tower-runtime/src/subprocess.rs index a25ba1ec..23c7d4ef 100644 --- a/crates/tower-runtime/src/subprocess.rs +++ b/crates/tower-runtime/src/subprocess.rs @@ -247,12 +247,12 @@ impl ExecutionHandle for SubprocessHandle { async fn wait_for_completion(&self) -> Result { loop { - let exec_status = self.status().await?; - match exec_status.status { + let status = self.status().await?.status; + match status { Status::None | Status::Running => { tokio::time::sleep(Duration::from_millis(100)).await; } - _ => return Ok(exec_status.status), + _ => return Ok(status), } } }