diff --git a/crates/tower-cmd/src/apps.rs b/crates/tower-cmd/src/apps.rs index bccc33cd..e2cd81be 100644 --- a/crates/tower-cmd/src/apps.rs +++ b/crates/tower-cmd/src/apps.rs @@ -405,30 +405,22 @@ async fn drain_stream_with_grace( .await; } -/// Follows the logs of a run: prints stored logs for a finished run, waits for -/// a not-yet-started run, and otherwise attaches to the live log stream with -/// reconnects, dedup, and independent completion detection. +/// Follows the logs of a run: waits for a not-yet-started run, then attaches +/// to the live log stream. Finished runs take the same path — the stream +/// replays from the start and the server closes it — because stored logs can +/// trail a run's terminal status. The post-stream catch-up covers runs whose +/// stream has expired. async fn follow_run_logs(out: &output::Out, config: &Config, name: &str, seq: i64) { let mut tracker = LineTracker::new(); let run = describe_run_or_die(out, config, name, seq).await; - - match run_phase(&run.status) { - RunPhase::Terminal => { - print_stored_logs(out, config, name, seq, &mut tracker).await; - return; - } - RunPhase::NotStarted => match wait_for_run_start(out, config, name, seq).await { - WaitOutcome::Started => {} - WaitOutcome::Finished => { - print_stored_logs(out, config, name, seq, &mut tracker).await; - return; - } + if run_phase(&run.status) == RunPhase::NotStarted { + match wait_for_run_start(out, config, name, seq).await { + WaitOutcome::Started | WaitOutcome::Finished => {} WaitOutcome::TimedOut => { out.die("Timed out waiting for the run to start. The runner may be unavailable."); } - }, - RunPhase::InProgress => {} + } } stream_logs_with_reconnect(out, config, name, seq, &run.dollar_link, &mut tracker).await; diff --git a/tests/integration/features/cli_runs.feature b/tests/integration/features/cli_runs.feature index 1a3c91f7..d559fe52 100644 --- a/tests/integration/features/cli_runs.feature +++ b/tests/integration/features/cli_runs.feature @@ -56,7 +56,7 @@ Feature: CLI Run Commands 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" + Given I have a simple hello world application named "app-logs-finished" When I run "tower deploy --create" via CLI And I run "tower run --detached" via CLI and capture the run number And I wait for 2 seconds diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index 421f58b4..6e12f486 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -364,13 +364,15 @@ async def describe_run(name: str, seq: int): # For logs-after-completion test apps, complete quickly to test log draining # Use 1 second so CLI has time to start streaming before completion - completion_threshold = 1.0 if "logs-after-completion" in name else 5.0 + quick = "logs-after-completion" in name or "logs-finished" in name + completion_threshold = 1.0 if quick else 5.0 if elapsed > completion_threshold: run_data["status"] = "exited" run_data["status_group"] = "successful" run_data["exit_code"] = 0 - run_data["ended_at"] = now_time.isoformat() + if not run_data.get("ended_at"): + run_data["ended_at"] = now_time.isoformat() return { "run": run_data, @@ -685,12 +687,28 @@ def make_warning_event(content: str, timestamp: str, end_of_stream: bool = False STREAM_COMPLETE_WARNING = "stream complete" +# The real server persists log lines a beat after a run turns terminal; +# fetch too soon and the list is empty. Mirror that window. +STORED_LOGS_PERSIST_LAG = datetime.timedelta(seconds=2.5) + + @app.get("/v1/apps/{name}/runs/{seq}/logs") async def describe_run_logs(name: str, seq: int): """Mock endpoint for getting run logs.""" if name not in mock_apps_db: raise HTTPException(status_code=404, detail=f"App '{name}' not found") + for run_data in mock_runs_db.values(): + if run_data["app_name"] == name and run_data["number"] == seq: + ended = run_data.get("ended_at") + if ( + not ended + or datetime.datetime.now() - datetime.datetime.fromisoformat(ended) + < STORED_LOGS_PERSIST_LAG + ): + return {"log_lines": []} + break + return { "log_lines": [ make_log_data(seq, line_num, content, timestamp)