diff --git a/src/activity_core/automation_status.py b/src/activity_core/automation_status.py index dce4b8c..b0db676 100644 --- a/src/activity_core/automation_status.py +++ b/src/activity_core/automation_status.py @@ -654,6 +654,11 @@ def classify_activity(definition: dict[str, Any], window: dict[str, Any], runs: else: status = "no_due" + last_run = latest_run_summary(runs) + temporal_last = None + if temporal and temporal.get("last_fired_at"): + temporal_last = temporal.get("last_fired_at") + return { "id": definition["id"], "name": definition["name"], @@ -666,6 +671,9 @@ def classify_activity(definition: dict[str, Any], window: dict[str, Any], runs: "expected_fires": expected, "expected_fire_count": len(expected), "observed_run_count": len(runs), + "last_run_at": last_run.get("fired_at") if last_run else None, + "last_run": last_run, + "temporal_last_fired_at": temporal_last, "runs": runs, "evidence": evidence, "temporal": temporal, @@ -674,6 +682,26 @@ def classify_activity(definition: dict[str, Any], window: dict[str, Any], runs: } +def latest_run_summary(runs: list[dict[str, Any]]) -> dict[str, Any] | None: + """Return the newest run by fired_at (then scheduled_for) within the provided list.""" + best: dict[str, Any] | None = None + best_ts: datetime | None = None + for run in runs: + ts = coerce_datetime(run.get("fired_at")) or coerce_datetime(run.get("scheduled_for")) + if ts is None: + continue + if best_ts is None or ts > best_ts: + best_ts = ts + best = { + "run_id": run.get("run_id"), + "fired_at": run.get("fired_at") or iso(ts), + "scheduled_for": run.get("scheduled_for"), + "tasks_spawned": run.get("tasks_spawned"), + "version_used": run.get("version_used"), + } + return best + + def workflow_status_matches(workflow: dict[str, Any], names: set[str]) -> bool: value = str(workflow.get("status") or "").upper() return any(name in value for name in names) diff --git a/src/activity_core/ops_api.py b/src/activity_core/ops_api.py index 2d1b476..8e7c765 100644 --- a/src/activity_core/ops_api.py +++ b/src/activity_core/ops_api.py @@ -402,11 +402,27 @@ async def ui_status(since: str = Query(default="sunday")) -> HTMLResponse: "status-bad" if st in {"missed", "validation_failed", "sink_failed"} else "status-warn" ) aid = html.escape(str(a.get("id") or "")) + last_run = a.get("last_run") or {} + last_at = ( + a.get("last_run_at") + or last_run.get("fired_at") + or a.get("temporal_last_fired_at") + or "—" + ) + last_tasks = last_run.get("tasks_spawned") + last_tasks_s = "" if last_tasks is None else str(last_tasks) + run_count = a.get("observed_run_count", a.get("run_count", a.get("runs_count", ""))) + expected_n = a.get("expected_fire_count") + if expected_n is None: + expected_n = len(a.get("expected_fires") or a.get("expected") or []) rows.append( f"{html.escape(str(a.get('name')))}" f"{html.escape(st)}" - f"{html.escape(str(a.get('run_count', a.get('runs_count', ''))))}" - f"{html.escape(str(len(a.get('expected_fires') or a.get('expected') or [])))}" + f"" + f"{html.escape(str(last_at))}" + f"{html.escape(last_tasks_s)}" + f"{html.escape(str(run_count))}" + f"{html.escape(str(expected_n))}" f"{html.escape(str(a.get('enabled')))}" ) window = html.escape(json.dumps(report.get("window") or {}, indent=2)) @@ -418,11 +434,16 @@ async def ui_status(since: str = Query(default="sunday")) -> HTMLResponse: +

Last run = newest fired_at in the selected window + (falls back to Temporal schedule last action when no DB run is in-window).

window: {window}
summary: {summary}
- - {''.join(rows) or ''} + + + + + {''.join(rows) or ''}
NameStatusRunsExpectedEnabled
No activities
NameStatusLast runTasksRunsExpectedEnabled
No activities
""" return _page("Status", body) diff --git a/tests/test_automation_status.py b/tests/test_automation_status.py index 7b8008d..ac0bd43 100644 --- a/tests/test_automation_status.py +++ b/tests/test_automation_status.py @@ -75,6 +75,28 @@ def test_completed_when_expected_run_exists() -> None: ) assert report["status"] == "completed" + assert report["last_run_at"] == "2026-06-26T07:00:10+00:00" + assert report["last_run"]["run_id"] == "run-1" + assert report["last_run"]["tasks_spawned"] == 1 + + +def test_last_run_picks_newest_fired_at() -> None: + runs = [ + { + "run_id": "older", + "fired_at": "2026-06-26T07:00:00+00:00", + "tasks_spawned": 0, + }, + { + "run_id": "newer", + "fired_at": "2026-06-28T07:00:00+00:00", + "tasks_spawned": 2, + }, + ] + summary = status.latest_run_summary(runs) + assert summary is not None + assert summary["run_id"] == "newer" + assert summary["tasks_spawned"] == 2 def test_validation_failure_wins_over_completed_run() -> None: