From 7b3b7786c723a48593a4d07be5dbf13c1fe57955 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 22 Jul 2026 17:48:43 +0800 Subject: [PATCH] fix(platform): expose run correlation in list --- apps/api/src/api/v1/platform/operator_runs.py | 3 + .../src/services/platform_operator_service.py | 50 ++++++++++----- apps/api/src/services/platform_runtime.py | 18 +++--- apps/api/src/services/runtime_correlation.py | 19 ++++++ .../test_awooop_operator_timeline_labels.py | 63 +++++++++++++++++++ apps/api/tests/test_runtime_correlation.py | 37 +++++++++++ 6 files changed, 165 insertions(+), 25 deletions(-) diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 624a0364b..677ccb079 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -67,6 +67,9 @@ class RunItem(BaseModel): agent_id: str state: str is_shadow: bool + trace_id: str | None + work_item_id: str + correlation_status: Literal["canonical", "legacy_or_invalid_trace"] cost_usd: Decimal step_count: int created_at: datetime diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 7edb8c3a1..57cc322be 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -88,6 +88,7 @@ from src.services.paid_provider_canary_gate5_run import ( paid_provider_canary_approval_contract_errors, ) from src.services.run_state_machine import transition +from src.services.runtime_correlation import correlation_readback_for_run from src.services.snapshot_paths import resolve_repo_root logger = structlog.get_logger(__name__) @@ -1073,6 +1074,35 @@ async def list_contracts( # Runs # ============================================================================= + +def _run_list_item( + run: AwoooPRunState, + *, + remediation_summary: dict[str, Any] | None, + callback_reply_summary: dict[str, Any] | None, +) -> dict[str, Any]: + """Build one list row with the same correlation truth as run detail.""" + + return { + "run_id": run.run_id, + "project_id": run.project_id, + "agent_id": run.agent_id, + "state": run.state, + "is_shadow": run.is_shadow, + **correlation_readback_for_run( + run.project_id, + run.run_id, + run.trace_id, + ), + "cost_usd": run.cost_usd, + "step_count": run.step_count, + "created_at": run.created_at, + "timeout_at": run.timeout_at, + "remediation_summary": remediation_summary, + "callback_reply_summary": callback_reply_summary, + } + + async def list_runs( project_id: str | None, state: str | None, @@ -1163,20 +1193,12 @@ async def list_runs( } runs = [ - { - "run_id": r.run_id, - "project_id": r.project_id, - "agent_id": r.agent_id, - "state": r.state, - "is_shadow": r.is_shadow, - "cost_usd": r.cost_usd, - "step_count": r.step_count, - "created_at": r.created_at, - "timeout_at": r.timeout_at, - "remediation_summary": remediation_summaries.get(r.run_id), - "callback_reply_summary": callback_reply_summaries.get(r.run_id), - } - for r in rows + _run_list_item( + row, + remediation_summary=remediation_summaries.get(row.run_id), + callback_reply_summary=callback_reply_summaries.get(row.run_id), + ) + for row in rows ] return {"runs": runs, "total": total, "page": page, "per_page": per_page} diff --git a/apps/api/src/services/platform_runtime.py b/apps/api/src/services/platform_runtime.py index 29f2c41e6..9b5df8096 100644 --- a/apps/api/src/services/platform_runtime.py +++ b/apps/api/src/services/platform_runtime.py @@ -35,8 +35,7 @@ from src.db.base import get_db_context from src.services.run_state_machine import LEASE_TTL_SECONDS, transition from src.services.runtime_correlation import ( canonical_traceparent, - canonical_work_item_id, - is_canonical_traceparent, + correlation_readback_for_run, new_runtime_correlation, new_uuid7, ) @@ -412,21 +411,18 @@ async def get_run_status(run_id: UUID, project_id: str) -> dict[str, Any] | None if run is None: return None - work_item_id = canonical_work_item_id(run.project_id, run.run_id) - trace_id = str(run.trace_id or "") + correlation = correlation_readback_for_run( + run.project_id, + run.run_id, + run.trace_id, + ) return { "run_id": str(run.run_id), "project_id": run.project_id, "agent_id": run.agent_id, "state": run.state, "is_shadow": run.is_shadow, - "trace_id": run.trace_id, - "work_item_id": work_item_id, - "correlation_status": ( - "canonical" - if is_canonical_traceparent(trace_id, run_id=run.run_id) - else "legacy_or_invalid_trace" - ), + **correlation, "attempt_count": run.attempt_count, "cost_usd": float(run.cost_usd), "step_count": run.step_count, diff --git a/apps/api/src/services/runtime_correlation.py b/apps/api/src/services/runtime_correlation.py index 85a3c2e4d..d0775dfb0 100644 --- a/apps/api/src/services/runtime_correlation.py +++ b/apps/api/src/services/runtime_correlation.py @@ -101,6 +101,25 @@ def canonical_work_item_id(project_id: str, run_id: UUID) -> str: return f"platform-run:{project}:{run_id}" +def correlation_readback_for_run( + project_id: str, + run_id: UUID, + trace_id: str | None, +) -> dict[str, str | None]: + """Project stored trace truth plus its canonical run-bound work item.""" + + stored_trace_id = None if trace_id is None else str(trace_id) + return { + "trace_id": stored_trace_id, + "work_item_id": canonical_work_item_id(project_id, run_id), + "correlation_status": ( + "canonical" + if is_canonical_traceparent(stored_trace_id or "", run_id=run_id) + else "legacy_or_invalid_trace" + ), + } + + def correlation_for_run(project_id: str, run_id: UUID) -> RuntimeCorrelation: """Build the canonical public-safe correlation projection for a run.""" diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index fb5067442..5528bfd9b 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -56,6 +56,7 @@ from src.services.platform_operator_service import ( _remediation_timeline_summary, _repair_candidate_projection_from_metadata, _run_callback_reply_summary, + _run_list_item, _run_remediation_list_summary, _score_source_correlation_event, _source_event_correlation_context, @@ -67,6 +68,7 @@ from src.services.platform_operator_service import ( _validate_cicd_status_filter, _validate_incident_id_filter, ) +from src.services.runtime_correlation import canonical_traceparent def test_repair_candidate_promotion_summary_marks_controlled_runtime() -> None: @@ -500,6 +502,7 @@ def test_run_callback_reply_summary_marks_no_callback() -> None: def test_list_runs_response_preserves_callback_reply_summary() -> None: run_id = UUID("5c0306e0-591a-5445-9a33-80f499426b38") + trace_id = canonical_traceparent(run_id) response = ListRunsResponse.model_validate({ "runs": [ { @@ -508,6 +511,9 @@ def test_list_runs_response_preserves_callback_reply_summary() -> None: "agent_id": "legacy-telegram-gateway", "state": "completed", "is_shadow": True, + "trace_id": trace_id, + "work_item_id": f"platform-run:awoooi:{run_id}", + "correlation_status": "canonical", "cost_usd": Decimal("0.0000"), "step_count": 0, "created_at": datetime(2026, 5, 18, 7, 31, 37), @@ -548,6 +554,11 @@ def test_list_runs_response_preserves_callback_reply_summary() -> None: }) dumped = response.model_dump(mode="json") + assert dumped["runs"][0]["trace_id"] == trace_id + assert dumped["runs"][0]["work_item_id"] == ( + f"platform-run:awoooi:{run_id}" + ) + assert dumped["runs"][0]["correlation_status"] == "canonical" assert dumped["runs"][0]["callback_reply_summary"]["status"] == "failed" assert dumped["runs"][0]["callback_reply_summary"]["needs_human"] is True assert dumped["runs"][0]["callback_reply_summary"]["capture_status"] == ( @@ -555,6 +566,58 @@ def test_list_runs_response_preserves_callback_reply_summary() -> None: ) +def test_run_list_item_projects_canonical_correlation_identity() -> None: + run_id = UUID("019f88d5-f6c9-7a18-b764-df0df0cbe222") + trace_id = canonical_traceparent(run_id) + item = _run_list_item( + SimpleNamespace( + run_id=run_id, + project_id="awoooi", + agent_id="legacy-signal-worker", + state="completed", + is_shadow=True, + trace_id=trace_id, + cost_usd=Decimal("0.0000"), + step_count=4, + created_at=datetime(2026, 7, 22, 8, 0, 0), + timeout_at=None, + ), + remediation_summary=None, + callback_reply_summary=None, + ) + + assert item["trace_id"] == trace_id + assert item["work_item_id"] == f"platform-run:awoooi:{run_id}" + assert item["correlation_status"] == "canonical" + + +@pytest.mark.parametrize("trace_id", ["legacy-trace", None]) +def test_run_list_item_preserves_noncanonical_trace_truth( + trace_id: str | None, +) -> None: + run_id = UUID("019f88d5-f6c9-7a18-b764-df0df0cbe222") + item = _run_list_item( + SimpleNamespace( + run_id=run_id, + project_id="awoooi", + agent_id="legacy-signal-worker", + state="completed", + is_shadow=True, + trace_id=trace_id, + cost_usd=Decimal("0.0000"), + step_count=4, + created_at=datetime(2026, 7, 22, 8, 0, 0), + timeout_at=None, + ), + remediation_summary=None, + callback_reply_summary=None, + ) + + assert item["trace_id"] == trace_id + assert item["work_item_id"] == f"platform-run:awoooi:{run_id}" + assert item["correlation_status"] == "legacy_or_invalid_trace" + + def test_callback_reply_event_item_surfaces_run_link_and_human_flag() -> None: run_id = UUID("5c0306e0-591a-5445-9a33-80f499426b38") message_id = UUID("56cdb6ad-46a4-48f5-9d3b-b1ac9c0b2e92") diff --git a/apps/api/tests/test_runtime_correlation.py b/apps/api/tests/test_runtime_correlation.py index 6b8aaf29a..21d6a91a0 100644 --- a/apps/api/tests/test_runtime_correlation.py +++ b/apps/api/tests/test_runtime_correlation.py @@ -11,10 +11,12 @@ import pytest from src.db.awooop_models import AwoooPRunIdempotency, AwoooPRunState from src.services import platform_runtime +from src.services.platform_operator_service import _run_list_item from src.services.runtime_correlation import ( canonical_traceparent, canonical_work_item_id, correlation_for_run, + correlation_readback_for_run, is_canonical_traceparent, new_uuid7, ) @@ -66,6 +68,28 @@ def test_runtime_correlation_rejects_invalid_project_id() -> None: ) +def test_runtime_correlation_readback_preserves_legacy_trace_truth() -> None: + run_id = UUID("019f88d5-f6c9-7a18-b764-df0df0cbe222") + + canonical = correlation_readback_for_run( + "awoooi", + run_id, + canonical_traceparent(run_id), + ) + legacy = correlation_readback_for_run("awoooi", run_id, "legacy-trace") + + assert canonical == { + "trace_id": canonical_traceparent(run_id), + "work_item_id": f"platform-run:awoooi:{run_id}", + "correlation_status": "canonical", + } + assert legacy == { + "trace_id": "legacy-trace", + "work_item_id": f"platform-run:awoooi:{run_id}", + "correlation_status": "legacy_or_invalid_trace", + } + + @pytest.mark.asyncio async def test_run_status_exposes_canonical_identity_readback( monkeypatch: pytest.MonkeyPatch, @@ -86,6 +110,7 @@ async def test_run_status_exposes_canonical_identity_readback( created_at=datetime(2026, 7, 22, tzinfo=UTC), started_at=datetime(2026, 7, 22, tzinfo=UTC), completed_at=None, + timeout_at=None, ) class _Result: @@ -110,6 +135,18 @@ async def test_run_status_exposes_canonical_identity_readback( assert payload["trace_id"] == trace_id assert payload["work_item_id"] == f"platform-run:awoooi:{run_id}" assert payload["correlation_status"] == "canonical" + list_item = _run_list_item( + row, + remediation_summary=None, + callback_reply_summary=None, + ) + assert { + key: list_item[key] + for key in ("trace_id", "work_item_id", "correlation_status") + } == { + key: payload[key] + for key in ("trace_id", "work_item_id", "correlation_status") + } @pytest.mark.asyncio