From ba98ff39b5f1673be15ab75e9b4e195c401c4488 Mon Sep 17 00:00:00 2001 From: Ben Lovell Date: Tue, 18 Aug 2026 15:25:32 +0200 Subject: [PATCH 1/5] refactor: show beta notices whenever stderr is a terminal (#336) --- crates/tower-cmd/src/beta.rs | 45 +++++--------------------------- crates/tower-cmd/src/catalogs.rs | 16 ++++++------ crates/tower-cmd/src/output.rs | 17 +++++++++++- 3 files changed, 30 insertions(+), 48 deletions(-) diff --git a/crates/tower-cmd/src/beta.rs b/crates/tower-cmd/src/beta.rs index 09e746c4..83b9ab9d 100644 --- a/crates/tower-cmd/src/beta.rs +++ b/crates/tower-cmd/src/beta.rs @@ -1,8 +1,4 @@ -use std::io::{self, IsTerminal}; - -use tower_telemetry::debug; - -use crate::output::{self, Out}; +use crate::output; pub(crate) struct BetaFeature { id: &'static str, @@ -21,6 +17,10 @@ impl BetaFeature { None => self.message.to_string(), } } + + pub fn notify_once(&self) { + output::notice_once(self.id, "Beta:", &self.notice()); + } } pub(crate) const STORAGE_BETA_MESSAGE: &str = "Tower Storage is in beta. Core functionality is stable, but some featues and interfaces might change before general availability."; @@ -31,30 +31,9 @@ pub(crate) const STORAGE: BetaFeature = BetaFeature { docs_url: None, }; -pub(crate) fn notify_once(out: &Out, feature: &BetaFeature) { - let stderr_is_terminal = io::stderr().is_terminal(); - - if !should_notify(out.interactive(), out.foreground(), stderr_is_terminal) { - return; - } - - match config::claim_notice(feature.id) { - Ok(true) => output::notice_to_stderr("Beta:", &feature.notice()), - Ok(false) => {} - Err(err) => debug!("Failed to persist CLI notice {}: {}", feature.id, err), - } -} - -/// The notice only goes out for a foreground CLI driving an interactive terminal: -/// human output on a stdout TTY (never JSON or MCP capture), with stderr also a -/// TTY so the notice itself is seen. -fn should_notify(interactive: bool, foreground: bool, stderr_is_terminal: bool) -> bool { - interactive && foreground && stderr_is_terminal -} - #[cfg(test)] mod tests { - use super::{should_notify, BetaFeature, STORAGE, STORAGE_BETA_MESSAGE}; + use super::{BetaFeature, STORAGE, STORAGE_BETA_MESSAGE}; #[test] fn short_about_has_one_beta_suffix() { @@ -85,16 +64,4 @@ mod tests { "Example is in beta. Its interface may change. Learn more: https://example.com/beta" ); } - - #[test] - fn notice_requires_interactive_foreground_and_stderr_terminal() { - assert!(should_notify(true, true, true)); - // stdout not an interactive terminal (redirected, JSON, or MCP capture) - assert!(!should_notify(false, true, true)); - // not a foreground CLI (MCP or discarded output) - assert!(!should_notify(true, false, true)); - // stderr not a terminal - assert!(!should_notify(true, true, false)); - assert!(!should_notify(false, false, false)); - } } diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index 1bc6ea19..f829069f 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -186,7 +186,7 @@ pub async fn do_list(out: &output::Out, config: Config, args: &ArgMatches) { }; if is_storage_catalog_type(catalog_type) { - beta::notify_once(out, &beta::STORAGE); + beta::STORAGE.notify_once(); } let catalogs = out @@ -214,7 +214,7 @@ pub async fn do_list(out: &output::Out, config: Config, args: &ArgMatches) { } pub async fn do_credentials(out: &output::Out, config: Config, args: &ArgMatches) { - beta::notify_once(out, &beta::STORAGE); + beta::STORAGE.notify_once(); let name = args .get_one::("catalog_name") @@ -263,7 +263,7 @@ pub async fn do_show(out: &output::Out, config: Config, args: &ArgMatches) { let is_storage = is_storage_catalog_type(Some(&response.catalog.r#type)); if is_storage { - beta::notify_once(out, &beta::STORAGE); + beta::STORAGE.notify_once(); } let tables = if is_storage { @@ -464,7 +464,7 @@ fn redact_token(message: &str, token: &str) -> String { } pub async fn do_query(out: &output::Out, config: Config, args: &ArgMatches) { - beta::notify_once(out, &beta::STORAGE); + beta::STORAGE.notify_once(); let name = args .get_one::("catalog_name") @@ -1550,7 +1550,7 @@ fn knowledge_cmd() -> Command { } pub async fn do_knowledge_list(out: &output::Out, config: Config, args: &ArgMatches) { - beta::notify_once(out, &beta::STORAGE); + beta::STORAGE.notify_once(); let catalog = args .get_one::("catalog_name") @@ -1587,7 +1587,7 @@ pub async fn do_knowledge_list(out: &output::Out, config: Config, args: &ArgMatc } pub async fn do_knowledge_show(out: &output::Out, config: Config, args: &ArgMatches) { - beta::notify_once(out, &beta::STORAGE); + beta::STORAGE.notify_once(); let catalog = args .get_one::("catalog_name") @@ -1609,7 +1609,7 @@ pub async fn do_knowledge_show(out: &output::Out, config: Config, args: &ArgMatc } pub async fn do_knowledge_set(out: &output::Out, config: Config, args: &ArgMatches) { - beta::notify_once(out, &beta::STORAGE); + beta::STORAGE.notify_once(); let catalog = args .get_one::("catalog_name") @@ -1657,7 +1657,7 @@ pub async fn do_knowledge_set(out: &output::Out, config: Config, args: &ArgMatch } pub async fn do_knowledge_delete(out: &output::Out, config: Config, args: &ArgMatches) { - beta::notify_once(out, &beta::STORAGE); + beta::STORAGE.notify_once(); let catalog = args .get_one::("catalog_name") diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index cb395739..2758e8f8 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -616,8 +616,23 @@ pub fn background_error(msg: &str) { write_to_stderr(&format!("{} {}\n", "Oh no!".red(), msg)); } +/// Writes a labelled notice to stderr once per user, ever. The once-per-user +/// claim is only spent when stderr is a terminal, so a script, MCP capture, or +/// CI run can't use it up on a notice nobody saw. +pub(crate) fn notice_once(id: &str, label: &str, msg: &str) { + if !io::stderr().is_terminal() { + return; + } + + match config::claim_notice(id) { + Ok(true) => notice_to_stderr(label, msg), + Ok(false) => {} + Err(err) => debug!("Failed to persist CLI notice {}: {}", id, err), + } +} + /// Writes a labelled notice to stderr, keeping stdout clean for command output. -pub(crate) fn notice_to_stderr(label: &str, msg: &str) { +fn notice_to_stderr(label: &str, msg: &str) { let line = format!("{} {}\n", label.bold().yellow(), msg); write_to_stderr(&line); } From 97a956cce42fb4c335b2c21cffb8aaca1bcfa788 Mon Sep 17 00:00:00 2001 From: "tower-cla-app[bot]" <316933833+tower-cla-app[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:23:19 +0000 Subject: [PATCH 2/5] Creating file for storing CLA Signatures --- signatures/version1/cla.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 signatures/version1/cla.json diff --git a/signatures/version1/cla.json b/signatures/version1/cla.json new file mode 100644 index 00000000..18d5487f --- /dev/null +++ b/signatures/version1/cla.json @@ -0,0 +1,3 @@ +{ + "signedContributors": [] +} \ No newline at end of file From 638f2323d849069ac959912621ddcf7a7a28bbb9 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Wed, 19 Aug 2026 11:45:38 +0100 Subject: [PATCH 3/5] test: make run-log scenarios API-safe and stop mock drift (#357) The two run-log scenarios asserted on strings only the mock API server ever emitted, so they passed locally and failed in the monorepo's CLI regression job, which runs this suite against a real Tower server. The post-completion drain scenario now deploys a fixture app that prints the two lines itself, and the follow scenario asserts a warning the server actually sends. The mock's log stream is aligned with the server: it closes with the idle warning and a terminal end-of-stream event, and its stored log lines match what the hello-world fixture prints. --- tests/integration/features/cli_runs.feature | 4 +-- tests/integration/features/steps/mcp_steps.py | 5 +++ .../templates/logs_after_completion.py | 8 +++++ tests/mock-api-server/main.py | 32 +++++++++++++++---- 4 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 tests/integration/templates/logs_after_completion.py diff --git a/tests/integration/features/cli_runs.feature b/tests/integration/features/cli_runs.feature index 630ef2a6..1a3c91f7 100644 --- a/tests/integration/features/cli_runs.feature +++ b/tests/integration/features/cli_runs.feature @@ -33,7 +33,7 @@ Feature: CLI Run Commands And both spinners should complete successfully Scenario: CLI run should show logs that arrive after run completes - Given I have a simple hello world application named "app-logs-after-completion" + Given I have an application named "app-logs-after-completion" that logs either side of completion When I run "tower deploy --create" via CLI And I run "tower run" via CLI Then the output should show "First log before run completes" @@ -53,7 +53,7 @@ Feature: CLI Run Commands And I run "tower apps logs {app_name}#{run_number} --follow" via CLI with the created app name and run number Then the output should show "Hello, World!" And the output should contain "Hello, World!" exactly once - And the output should show "Warning: This run is using a deprecated runtime" + And the output should show "Warning: No new logs available" Scenario: CLI apps logs --follow on a finished run prints stored logs exactly once Given I have a simple hello world application named "app-logs-after-completion" diff --git a/tests/integration/features/steps/mcp_steps.py b/tests/integration/features/steps/mcp_steps.py index 5b1f3b75..b9afa60e 100644 --- a/tests/integration/features/steps/mcp_steps.py +++ b/tests/integration/features/steps/mcp_steps.py @@ -908,6 +908,11 @@ def step_create_hello_world_app_named(context, app_name): create_towerfile(context, app_name=app_name) +@given('I have an application named "{app_name}" that logs either side of completion') +def step_create_logs_after_completion_app(context, app_name): + create_towerfile(context, app_name=app_name, script_name="logs_after_completion.py") + + # --- Catalog querying (gated on TOWER_TEST_CATALOG; see environment.py) ------- diff --git a/tests/integration/templates/logs_after_completion.py b/tests/integration/templates/logs_after_completion.py new file mode 100644 index 00000000..afc2d826 --- /dev/null +++ b/tests/integration/templates/logs_after_completion.py @@ -0,0 +1,8 @@ +import time + +# The second line is printed immediately before exit so it races the run's +# transition to a terminal status, which is what exercises the CLI's +# post-completion log drain. +print("First log before run completes", flush=True) +time.sleep(2) +print("Second log after run completes", flush=True) diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index f16d829c..421f58b4 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -644,10 +644,11 @@ async def refresh_session(refresh_params: Dict[str, Any] = None): } +# What the integration suite's hello-world fixture app actually prints. The +# mock only ever relays program output, so anything here that the fixture +# doesn't print is content no real run could produce. NORMAL_LOG_ENTRIES = [ - (1, "Starting application...", "2025-08-22T12:00:00Z"), - (2, "Hello, World!", "2025-08-22T12:00:01Z"), - (3, "Application completed successfully", "2025-08-22T12:00:02Z"), + (1, "Hello, World!", "2025-08-22T12:00:01Z"), ] @@ -666,13 +667,24 @@ def make_log_event(seq: int, line_num: int, content: str, timestamp: str): return f"event: log\ndata: {json.dumps(make_log_data(seq, line_num, content, timestamp))}\n\n" -def make_warning_event(content: str, timestamp: str): +def make_warning_event(content: str, timestamp: str, end_of_stream: bool = False): """A warning SSE event. Matching the real server, the data field carries - the bare warning payload (not an enveloped {event, data, ...} object).""" + the bare warning payload (not an enveloped {event, data, ...} object) and + omits end_of_stream unless it is set.""" data = {"content": content, "reported_at": timestamp} + if end_of_stream: + data["end_of_stream"] = True return f"event: warning\ndata: {json.dumps(data)}\n\n" +# The only warnings the real API emits on a run log stream (see +# sendRunLogNotifications in tower-services). Warnings the server cannot send +# do not belong here: the suite runs these same features against the real API, +# where anything invented here fails. +NO_NEW_LOGS_WARNING = "No new logs available" +STREAM_COMPLETE_WARNING = "stream complete" + + @app.get("/v1/apps/{name}/runs/{seq}/logs") async def describe_run_logs(name: str, seq: int): """Mock endpoint for getting run logs.""" @@ -694,6 +706,10 @@ async def generate_logs_after_completion_test_stream(seq: int): after about 1 second (see describe_run), so the second line arrives after the CLI has already observed completion — exercising the post-completion log drain. + + These two lines are the program output of the suite's + templates/logs_after_completion.py fixture, so the same scenario asserts + the same content whether it runs against this mock or the real API. """ yield make_log_event( seq, 1, "First log before run completes", "2025-08-22T12:00:00Z" @@ -705,12 +721,14 @@ async def generate_logs_after_completion_test_stream(seq: int): async def generate_normal_log_stream(seq: int): - """Normal log stream for regular tests, including a warning event.""" + """Normal log stream for regular tests, closing the way the server does: + the log lines, then the idle warning, then the terminal end-of-stream.""" for line_num, content, timestamp in NORMAL_LOG_ENTRIES: yield make_log_event(seq, line_num, content, timestamp) await asyncio.sleep(0.1) + yield make_warning_event(NO_NEW_LOGS_WARNING, "2025-08-22T12:00:03Z") yield make_warning_event( - "This run is using a deprecated runtime", "2025-08-22T12:00:03Z" + STREAM_COMPLETE_WARNING, "2025-08-22T12:00:03Z", end_of_stream=True ) From 38e25b86a0ccad292912f3a1774c18cc72daa679 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Wed, 19 Aug 2026 11:47:59 +0100 Subject: [PATCH 4/5] chore: Bump version to v0.3.72 --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3eb3398..d81ddbf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -841,7 +841,7 @@ dependencies = [ [[package]] name = "config" -version = "0.3.71" +version = "0.3.72" dependencies = [ "base64", "chrono", @@ -1033,7 +1033,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto" -version = "0.3.71" +version = "0.3.72" dependencies = [ "aes-gcm", "base64", @@ -4456,7 +4456,7 @@ dependencies = [ [[package]] name = "testutils" -version = "0.3.71" +version = "0.3.72" dependencies = [ "pem", "rsa", @@ -4765,7 +4765,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.3.71" +version = "0.3.72" dependencies = [ "config", "pyo3", @@ -4793,7 +4793,7 @@ dependencies = [ [[package]] name = "tower-api" -version = "0.3.71" +version = "0.3.72" dependencies = [ "reqwest", "serde", @@ -4805,7 +4805,7 @@ dependencies = [ [[package]] name = "tower-cmd" -version = "0.3.71" +version = "0.3.72" dependencies = [ "axum", "bytes", @@ -4854,7 +4854,7 @@ dependencies = [ [[package]] name = "tower-duckdb" -version = "0.3.71" +version = "0.3.72" dependencies = [ "chrono", "duckdb", @@ -4889,7 +4889,7 @@ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-package" -version = "0.3.71" +version = "0.3.72" dependencies = [ "async-compression", "flate2", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "tower-runtime" -version = "0.3.71" +version = "0.3.72" dependencies = [ "async-trait", "chrono", @@ -4938,7 +4938,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tower-telemetry" -version = "0.3.71" +version = "0.3.72" dependencies = [ "tracing", "tracing-appender", @@ -4947,7 +4947,7 @@ dependencies = [ [[package]] name = "tower-uv" -version = "0.3.71" +version = "0.3.72" dependencies = [ "async-compression", "async_zip", @@ -4967,7 +4967,7 @@ dependencies = [ [[package]] name = "tower-version" -version = "0.3.71" +version = "0.3.72" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 8a369227..70db28b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.3.71" +version = "0.3.72" description = "Tower is the best way to host Python data apps in production" # Matches rust-toolchain.toml. The two had drifted: the toolchain has been 1.88 # for a while, and the dependency tree (testcontainers and its transitive deps, diff --git a/pyproject.toml b/pyproject.toml index fdf331b4..ae73132f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "tower" -version = "0.3.71" +version = "0.3.72" description = "Tower CLI and runtime environment for Tower." authors = [{ name = "Tower Computing GmbH", email = "brad@tower.dev" }] readme = "README.md" diff --git a/uv.lock b/uv.lock index 9b74ba24..e88fc41c 100644 --- a/uv.lock +++ b/uv.lock @@ -2264,7 +2264,7 @@ wheels = [ [[package]] name = "tower" -version = "0.3.71" +version = "0.3.72" source = { editable = "." } dependencies = [ { name = "attrs" }, From 0d803290dd5aa47a39314f7992f2fab7c25b67c8 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Wed, 19 Aug 2026 11:49:34 +0100 Subject: [PATCH 5/5] fix(ci): exempt the CLA signature app from the CLA check The GitHub App that writes signatures/version1/cla.json authors its own commits on develop. Those commits are swept into every develop -> main release PR, so the CLA check flagged release PR #358 over a bot that cannot sign. --- .github/cla-allowlist.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/cla-allowlist.txt b/.github/cla-allowlist.txt index c3c08db3..94e57856 100644 --- a/.github/cla-allowlist.txt +++ b/.github/cla-allowlist.txt @@ -8,6 +8,9 @@ # Bots: dependabot[bot] renovate[bot] +# The app that persists signatures to develop; its own commits land in +# release PRs, where the check would otherwise demand a CLA from it. +tower-cla-app[bot] # Tower employees: exempt because they have already signed equivalent # agreements as part of employment; the CLA record for them lives in their