diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 16543d5b3..3abbcce00 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,8 @@ See the CHANGELOG rule in [AGENTS.md](../AGENTS.md). - Kit 14fa729c (core 2.6.0) is the scaffold, tooling and catalog pin, so a new product's bot registry takes a text handler and inline callbacks besides commands. +- `POST /api/runs/{id}/start` stamps `started_at` on the first accepted start, so deploy and QA runs + show a duration on the Journey timeline instead of "not recorded". ## 2026-10-16 diff --git a/services/api/src/routers/runs.py b/services/api/src/routers/runs.py index 86ae74342..c6d3c95a5 100644 --- a/services/api/src/routers/runs.py +++ b/services/api/src/routers/runs.py @@ -957,6 +957,8 @@ async def start_run( Starting a run that is already running is the same answer, so a worker retrying after a lost response is not refused its own start. + + The first accepted start stamps `started_at`; a refused start never does. """ run, fenced = await _lock_launch_context(run_id, db) if fenced or run.status in _TERMINAL_RUN_STATUSES: @@ -965,6 +967,8 @@ async def start_run( return DeployRunStart(run_id=run_id, started=False, run_status=RunStatus(run.status)) run.status = RunStatus.RUNNING.value + if run.started_at is None: + run.started_at = datetime.now(UTC) await db.commit() return DeployRunStart(run_id=run_id, started=True, run_status=RunStatus.RUNNING) diff --git a/services/api/tests/service/test_admin_console.py b/services/api/tests/service/test_admin_console.py index ce77d9896..204c2fff3 100644 --- a/services/api/tests/service/test_admin_console.py +++ b/services/api/tests/service/test_admin_console.py @@ -6,8 +6,9 @@ import pytest from sqlalchemy import delete -from shared.contracts.dto.run_result import DeployRunResult +from shared.contracts.dto.run_result import DeployRunResult, QARunResult from shared.contracts.queues.deploy import DeployOutcome +from shared.contracts.queues.qa import QAOutcome from shared.models import ( Application, Deployment, @@ -272,6 +273,40 @@ async def test_journey_detail_lays_out_the_story_and_its_passport(async_client, ] +async def test_a_started_and_completed_qa_run_shows_its_duration_on_the_journey( + async_client, db_session, product +): + run_id = f"qa-{uuid.uuid4().hex[:8]}" + db_session.add( + Run( + id=run_id, + type="qa", + status="queued", + project_id=product["project"].id, + story_id=product["story"].id, + ) + ) + await db_session.commit() + + started = await async_client.post(f"/api/runs/{run_id}/start") + assert started.json()["started"] is True + completed = await async_client.patch( + f"/api/runs/{run_id}", + json={ + "status": "completed", + "result": QARunResult(qa_outcome=QAOutcome.PASSED).model_dump(mode="json"), + }, + ) + assert completed.status_code == 200, completed.text + + response = await async_client.get(f"/api/admin/v2/journeys/{product['story'].id}") + assert response.status_code == 200, response.text + [qa] = [s for s in response.json()["timeline"]["stages"] if s["kind"] == "qa"] + assert (qa["label"], qa["state"]) == ("QA 1", "done") + assert qa["duration_seconds"] is not None + assert qa["duration_seconds"] >= 0 + + MODULE_PR = "https://github.com/vladmesh/codegen-product-kit/pull/42" diff --git a/services/api/tests/service/test_deploy_dispatch_boundary.py b/services/api/tests/service/test_deploy_dispatch_boundary.py index 4727f1ca5..ef3eb5fe8 100644 --- a/services/api/tests/service/test_deploy_dispatch_boundary.py +++ b/services/api/tests/service/test_deploy_dispatch_boundary.py @@ -83,6 +83,7 @@ async def test_a_cancelled_run_cannot_be_started(async_client: AsyncClient): run = await async_client.get(f"/api/runs/{run_id}") assert run.json()["status"] == "cancelled" assert run.json()["completed_at"] is not None + assert run.json()["started_at"] is None claimed = await async_client.post(f"/api/runs/{run_id}/dispatch-claim") assert claimed.json()["granted"] is False @@ -106,12 +107,26 @@ async def test_starting_a_live_run_twice_is_the_same_answer(async_client: AsyncC run_id = await _deploy_run(async_client, run_status="queued") first = await async_client.post(f"/api/runs/{run_id}/start") + first_stamp = (await async_client.get(f"/api/runs/{run_id}")).json()["started_at"] second = await async_client.post(f"/api/runs/{run_id}/start") assert first.json()["started"] is True assert second.json()["started"] is True run = await async_client.get(f"/api/runs/{run_id}") assert run.json()["status"] == "running" + assert run.json()["started_at"] == first_stamp + + +@pytest.mark.asyncio +async def test_the_first_start_records_when_the_run_started(async_client: AsyncClient): + run_id = await _deploy_run(async_client, run_status="queued") + assert (await async_client.get(f"/api/runs/{run_id}")).json()["started_at"] is None + + before = datetime.now(UTC) + await async_client.post(f"/api/runs/{run_id}/start") + + started_at = (await async_client.get(f"/api/runs/{run_id}")).json()["started_at"] + assert before <= datetime.fromisoformat(started_at) <= datetime.now(UTC) @pytest.mark.asyncio