Skip to content
45 changes: 38 additions & 7 deletions avrea_cli/commands/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from avrea_cli.display import truncate as _truncate
from avrea_cli.helpers import ensure_authenticated
from avrea_cli.helpers import ensure_ctx
from avrea_cli.helpers import extract_detail
from avrea_cli.helpers import get_org_id
from avrea_cli.helpers import get_org_slug
from avrea_cli.helpers import handle_http_error
Expand All @@ -49,6 +50,7 @@
from avrea_cli.output import format_timestamp
from avrea_cli.output import short_id
from avrea_cli.repo_context import resolve_repos_or_detect
from avrea_cli.vm import _write_known_hosts
from collections import deque
from datetime import UTC
from datetime import datetime
Expand Down Expand Up @@ -214,7 +216,12 @@ def job(ctx):
help="Filter by state (queued, in_progress, completed) or conclusion (success, failure, ...). Repeatable.",
)
@click.option("--on-avrea/--shadowing", "running_on_avrea", default=None, help="Filter by Avrea-run vs shadowing jobs.")
@click.option("-w", "--workflow", "workflow_ids", multiple=True, help="Filter by workflow ID (wfl-xxx, repeatable).")
@click.option(
"--ci-provider", type=click.Choice(["github_actions", "gitlab_ci", "avrea_ci"]), help="Filter by CI engine."
)
@click.option(
"-w", "--workflow", "workflow_ids", multiple=True, help="Filter by workflow ID (wfl- or awf-, repeatable)."
)
@click.option("--since", default=None, help="Relative time window: '7d', '24h', etc.")
@click.option("-L", "--limit", type=click.IntRange(1, 1000), default=20, show_default=True, help="Max jobs to return.")
@click.option("--cursor", default=None, help="Pagination cursor from a previous response.")
Expand All @@ -241,6 +248,7 @@ def job_list(
status_values,
running_on_avrea,
workflow_ids,
ci_provider,
since,
limit,
cursor,
Expand Down Expand Up @@ -289,6 +297,8 @@ def job_list(
params["conclusions"] = platform_conclusions
if running_on_avrea is not None:
params["running_on_avrea"] = running_on_avrea
if ci_provider:
params["ci_provider"] = ci_provider
if workflow_ids:
params["workflow_ids"] = list(workflow_ids)
if since:
Expand Down Expand Up @@ -445,7 +455,7 @@ def job_ssh(ctx, job_id: str, print_command: bool, show_password: bool):
password = result.get("password")
ssh_keys_installed = result.get("ssh_keys_installed", False)

ssh_opts = ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"]
ssh_opts = ["-o", "StrictHostKeyChecking=accept-new"]
ssh_exec = ["ssh", *ssh_opts, "-p", str(port), f"{username}@{host}"]
ssh_cmd = shlex.join(ssh_exec)

Expand All @@ -470,7 +480,23 @@ def job_ssh(ctx, job_id: str, print_command: bool, show_password: bool):
else:
click.echo("Password: ******** (hidden; re-run with --show-password to reveal)", err=True)

sys.exit(subprocess.run(ssh_exec).returncode)
known_hosts = _write_known_hosts(result.get("host_key"), host, port)
if known_hosts is None:
raise click.ClickException("The VM did not return an SSH host key; refusing an unverified connection.")
try:
ssh_exec = [
"ssh",
"-o",
"StrictHostKeyChecking=yes",
"-o",
f"UserKnownHostsFile={known_hosts}",
"-p",
str(port),
f"{username}@{host}",
]
sys.exit(subprocess.run(ssh_exec).returncode)
finally:
known_hosts.unlink(missing_ok=True)


@job.command("view")
Expand Down Expand Up @@ -785,6 +811,10 @@ def _format_job_status(job_data: dict[str, Any]) -> str:
return f"{indicator} {label} ({duration})"


def _runtime_pending(response: httpx.Response) -> bool:
return response.status_code == 404 and extract_detail(response) == "No execution found for job"


def _fetch_metrics_sources(
client: ApiClient,
org_id: str,
Expand All @@ -796,7 +826,7 @@ def _fetch_metrics_sources(
) -> dict[str, dict[str, Any]]:
"""Fetch metrics for each source.

404 is treated as "no execution yet" (queued job) so the user still sees
A missing execution is treated as pending (queued job) so the user still sees
a partial pane. Other failures propagate — the caller decides whether to
abort (static path) or render the error in the footer (live path)."""
params: dict[str, Any] = {}
Expand All @@ -810,7 +840,7 @@ def _fetch_metrics_sources(
try:
response = client.public_get(f"/orgs/{org_id}/jobs/{job_id}/metrics/{src}", params=params or None)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
if _runtime_pending(exc.response):
# Job has no execution yet (queued) — keep going so partial sources still render.
out[src] = {"series": [], "_error": "no execution"}
continue
Expand Down Expand Up @@ -956,10 +986,11 @@ def _build_frame(fetched: dict[str, dict[str, Any]], log_tail: list[dict[str, An
new_entries, new_cursor = fetch_logs_after(client, job_id, after_line=last_log_line)
except httpx.HTTPStatusError as exc:
code = exc.response.status_code
if 400 <= code < 500 and code != 429:
pending = _runtime_pending(exc.response)
if 400 <= code < 500 and code != 429 and not pending:
handle_http_error(exc, "fetch logs")
new_entries, new_cursor = [], last_log_line
log_error = f"log fetch failed: HTTP {code}"
log_error = None if pending else f"log fetch failed: HTTP {code}"
except (httpx.ConnectError, httpx.TimeoutException) as exc:
# Live mode polls on a tight loop — a transient API blip
# shouldn't kill the watcher. Surface it in the LOGS pane so
Expand Down
84 changes: 54 additions & 30 deletions avrea_cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from avrea_cli.helpers import handle_http_error
from avrea_cli.helpers import is_scoped_token
from avrea_cli.helpers import parse_since
from avrea_cli.helpers import require_external_ci
from avrea_cli.helpers import validate_cursor
from avrea_cli.json_output import emit_json
from avrea_cli.json_output import emit_json_record
Expand Down Expand Up @@ -162,7 +163,12 @@ def run(ctx):
help="Filter by state (queued, in_progress, completed) or conclusion (success, failure, ...). Repeatable.",
)
@click.option("--branch", "head_branches", multiple=True, help="Filter by head branch (repeatable).")
@click.option("-w", "--workflow", "workflow_ids", multiple=True, help="Filter by workflow ID (wfl-xxx, repeatable).")
@click.option(
"--ci-provider", type=click.Choice(["github_actions", "gitlab_ci", "avrea_ci"]), help="Filter by CI engine."
)
@click.option(
"-w", "--workflow", "workflow_ids", multiple=True, help="Filter by workflow ID (wfl- or awf-, repeatable)."
)
@click.option("--since", default=None, help="Relative time window: '7d', '24h', etc. Sugar for --created-after.")
@click.option(
"--from",
Expand Down Expand Up @@ -203,6 +209,7 @@ def run_list(
status_values,
head_branches,
workflow_ids,
ci_provider,
since,
created_after,
created_before,
Expand Down Expand Up @@ -282,6 +289,8 @@ def run_list(
params["conclusions"] = platform_conclusions
if head_branches:
params["head_branches"] = list(head_branches)
if ci_provider:
params["ci_provider"] = ci_provider
if workflow_ids:
params["workflow_ids"] = list(workflow_ids)
if created_after:
Expand Down Expand Up @@ -820,6 +829,7 @@ def run_diagnose(ctx, run: str, org_id: str | None, json_output: bool) -> None:
)

try:
require_external_ci(resolved, "Diagnostics")
report = build_run_diagnostics(client, org_id, resolved)
except httpx.HTTPStatusError as exc:
handle_http_error(exc, "diagnose workflow run")
Expand Down Expand Up @@ -883,11 +893,14 @@ def _emit_ndjson_event(event: dict[str, Any]) -> None:
raise SystemExit(0) from None


def _job_event_payload(event: str, run_id: str, job: dict[str, Any], timestamp: str) -> dict[str, Any]:
def _job_event_payload(
event: str, run_id: str, job: dict[str, Any], timestamp: str, ci_provider: str
) -> dict[str, Any]:
return {
"event": event,
"timestamp": timestamp,
"run_id": run_id,
"ci_provider": ci_provider,
"avrea_job_id": job.get("job_id"),
"platform_job_id": job.get("platform_job_id"),
"job_name": job.get("job_name"),
Expand All @@ -903,12 +916,12 @@ def watch_run_loop_ndjson(client: ApiClient, org_id: str, run_id: str, *, interv
event kinds, with intentionally different payloads:

- ``job_started`` / ``job_completed`` — emitted via ``_job_event_payload``
and share the keys ``event``, ``timestamp``, ``run_id``,
and share the keys ``event``, ``timestamp``, ``run_id``, ``ci_provider``,
``avrea_job_id``, ``platform_job_id``, ``job_name``, ``state``,
``conclusion``. ``conclusion`` is ``null`` until ``job_completed``.

- ``run_completed`` — top-level run summary; carries ``event``,
``timestamp``, ``run_id``, ``status``, ``conclusion``,
``timestamp``, ``run_id``, ``ci_provider``, ``status``, ``conclusion``,
``duration_seconds``. It deliberately omits the per-job fields —
consumers correlate via ``run_id`` to the preceding ``job_*`` events.

Expand Down Expand Up @@ -965,6 +978,7 @@ def record_failure(reason: str, msg: str) -> None:
continue

now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
ci_provider = run_data.get("ci_provider", "github_actions")
for j in run_data.get("jobs") or []:
j_id = j.get("job_id")
if not j_id:
Expand All @@ -974,9 +988,9 @@ def record_failure(reason: str, msg: str) -> None:
if prev != j_state:
last_state[j_id] = j_state
if j_state == "in_progress":
_emit_ndjson_event(_job_event_payload("job_started", run_id, j, now))
_emit_ndjson_event(_job_event_payload("job_started", run_id, j, now, ci_provider))
elif j_state == "completed":
_emit_ndjson_event(_job_event_payload("job_completed", run_id, j, now))
_emit_ndjson_event(_job_event_payload("job_completed", run_id, j, now, ci_provider))

status = run_data.get("status", "unknown")
if status == "completed":
Expand All @@ -985,6 +999,7 @@ def record_failure(reason: str, msg: str) -> None:
"event": "run_completed",
"timestamp": now,
"run_id": run_id,
"ci_provider": ci_provider,
"status": status,
"conclusion": run_data.get("conclusion"),
"duration_seconds": run_data.get("duration_seconds"),
Expand Down Expand Up @@ -1095,38 +1110,42 @@ def watch_run_loop(
now_str = datetime.now(UTC).strftime("%H:%M:%S UTC")
title = run_data.get("display_title", "")
run_number = run_data.get("run_number", "?")
run_label = f"#{run_number}" if run_number is not None else run_id
ci_provider = run_data.get("ci_provider", "github_actions")
status = run_data.get("status", "unknown")
conclusion = run_data.get("conclusion")

header = f"Watching run #{run_number}: {title} -- {now_str}"
header = f"Watching run {run_label}: {title} [{ci_provider}] -- {now_str}"
if console_url and slug:
header = hyperlink(header, run_url(console_url, slug, run_id))
click.echo(header + "\n")

run_jobs = run_data.get("jobs") or []
if run_jobs:
_print_run_jobs(run_jobs, console_url=console_url, slug=slug)
# Hints for drilling into a job. Static lines (job ids
# vary across rows), but the verbs are the demo flow.
click.echo()
click.echo(
click.style(
" → avr job logs --follow <job-id> # tail logs of a running job",
fg=DIM_FG,
)
)
click.echo(
click.style(
" → avr job metrics --watch <job-id> # live CPU/mem/IO gauges",
fg=DIM_FG,
)
)
click.echo(
click.style(
" → avr job ssh <job-id> # ssh into the runner VM",
fg=DIM_FG,
)
)
match ci_provider:
case "avrea_ci" | "github_actions" | "gitlab_ci":
click.echo(
click.style(
" → avr job logs --follow <job-id> # tail logs of a running job",
fg=DIM_FG,
)
)
click.echo(
click.style(
" → avr job metrics --watch <job-id> # live CPU/mem/IO gauges",
fg=DIM_FG,
)
)
click.echo(
click.style(
" → avr job ssh <job-id> # ssh into the runner VM",
fg=DIM_FG,
)
)
case _:
raise ValueError(f"Unsupported CI provider: {ci_provider}")
else:
click.echo(" No jobs yet.")

Expand All @@ -1137,13 +1156,13 @@ def watch_run_loop(
if status == "completed":
dur = format_duration(run_data.get("duration_seconds"))
conclusion_str = format_conclusion_colored(status, conclusion)
click.echo(f"\nRun #{run_number} completed: {conclusion_str} ({dur})")
click.echo(f"\nRun {run_label} completed: {conclusion_str} ({dur})")
# Reset title to bare ``avr ▸ <workflow>`` before notify
# so the conclusion isn't echoed twice (once via Ghostty's
# source line, once in the notification body).
wf_name = ((run_data.get("workflow") or {}).get("name") or "run").strip()
term_title.set(f"avr ▸ {wf_name}")
notify(f"Run #{run_number} {title}: {conclusion or 'completed'}")
notify(f"Run {run_label} {title}: {conclusion or 'completed'}")
if exit_status and conclusion != "success":
sys.exit(1)
return
Expand Down Expand Up @@ -1188,6 +1207,10 @@ def run_watch(
`avr run view`. Without RUN, auto-selects the latest in-progress run.
Pass --repo (repeatable) to scope the auto-select to specific repositories.

NDJSON job and run completion events include ci_provider
(github_actions, gitlab_ci, or avrea_ci). Native jobs have a null
platform_job_id; correlate events using run_id and avrea_job_id.

\b
Examples:
avr run watch
Expand Down Expand Up @@ -1363,6 +1386,7 @@ def run_rerun(ctx, run: str, org_id, failed: bool, yes: bool):
action="look up workflow run before rerun",
)

require_external_ci(existing_data, "Reruns")
if not yes:
scope = "failed jobs only" if failed else "all jobs"
ensure_prompts_allowed("run rerun needs confirmation")
Expand Down Expand Up @@ -1440,7 +1464,7 @@ def run_logs(
show_all_levels: bool,
no_pager: bool,
):
"""Fetch logs for a workflow run's GitHub jobs.
"""Fetch logs for a workflow run's jobs.

RUN accepts the same Avrea IDs, GitHub run IDs, and run URLs as
`avr run view`.
Expand Down
Loading