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
26 changes: 9 additions & 17 deletions crates/tower-cmd/src/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/features/cli_runs.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions tests/mock-api-server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading