diff --git a/backend/app/api/v1/generation_sessions.py b/backend/app/api/v1/generation_sessions.py index 9b210da..de89cbb 100644 --- a/backend/app/api/v1/generation_sessions.py +++ b/backend/app/api/v1/generation_sessions.py @@ -652,6 +652,13 @@ async def get_generation_session_status( kb_init_in_progress=kb_init_in_progress, ) + # Deploy & E2E loop progress. + workspace_phases_deployment = session_doc.get("workspace_phases_deployment", {}) + workspace_phases_deployment_view: Dict[str, Any] = { + ws_id: _ws_phase_view_entry(ws_data or {}, usage=None, models=[]) + for ws_id, ws_data in workspace_phases_deployment.items() + } + usage = ModelTokenUsage.from_generation_session_doc(session_doc) tok_used = usage.total_tokens response = { @@ -671,6 +678,8 @@ async def get_generation_session_status( "last_spec_summary": session_doc.get("last_spec_summary"), "workspace_phases": workspace_phases_view, } + if workspace_phases_deployment_view: + response["workspace_phases_deployment"] = workspace_phases_deployment_view # 2.5a: include result fields when COMPLETED (Gap 1 fix) if session_doc.get("status") == GenerationStatus.COMPLETED.value: diff --git a/backend/test/api/test_generation_sessions.py b/backend/test/api/test_generation_sessions.py index dd72d76..a24aaa6 100644 --- a/backend/test/api/test_generation_sessions.py +++ b/backend/test/api/test_generation_sessions.py @@ -334,6 +334,96 @@ def test_status_includes_lean_workspace_phases_with_derived_phase_name( # Heavy planning_data must not leak into the polled response. assert "planning_data" not in wp["ws-01-1"] + def test_status_surfaces_deploy_phases_during_deploy_loop( + self, client, mock_generation_session_service + ): + """Once the deploy & E2E loop starts, its per-workspace progress is + surfaced under ``workspace_phases_deployment`` (same lean shape as the + code-gen view) so the TUI can show deploy progress additively (issue #38).""" + generation_id = "est-deploy-1" + doc = { + "generation_id": generation_id, + "status": "running", + "user_email": "u@example.com", + "workspace_ids": ["ws-01-1"], + "parameters": {"workspace_count": 1}, + "progress": {}, + "error": None, + "workspace_phases": { + "ws-01-1": {"last_completed_phase": 12, "total_phases": 12, "planning_data": {}}, + }, + "workspace_phases_deployment": { + "ws-01-1": { + "last_completed_phase": 1, + "total_phases": 4, + "planning_data": { + "phases": [ + {"number": 1, "name": "Smoke E2E"}, + {"number": 2, "name": "Auth E2E"}, + {"number": 3, "name": "Checkout E2E"}, + {"number": 4, "name": "Regression"}, + ] + }, + }, + }, + } + mock_generation_session_service.get_generation_session_status = AsyncMock(return_value=doc) + mock_generation_session_service.get_checkpoint = Mock( + return_value=GenerationCheckpoint.GENERATION_DONE + ) + mock_generation_session_service.get_current_phase_name = Mock(return_value="Deploy & E2E") + + response = client.get( + f"/api/v1/generation-sessions/{generation_id}/status", + headers={"X-API-Key": "test-key"}, + ) + + assert response.status_code == 200 + body = response.json() + wpd = body["workspace_phases_deployment"] + # 1 deploy phase done → currently on phase index 1 = "Auth E2E". + assert wpd["ws-01-1"] == { + "last_completed_phase": 1, + "total_phases": 4, + "phase_name": "Auth E2E", + } + # Deploy view carries no usage/models (no per-variant deploy drill-in). + assert "usage" not in wpd["ws-01-1"] + assert "models" not in wpd["ws-01-1"] + assert "planning_data" not in wpd["ws-01-1"] + + def test_status_omits_deploy_phases_before_deploy_loop( + self, client, mock_generation_session_service + ): + """No ``workspace_phases_deployment`` key until the deploy loop starts — + keeps the polled payload lean and LOCAL_ONLY runs unaffected.""" + generation_id = "est-no-deploy" + doc = { + "generation_id": generation_id, + "status": "running", + "user_email": "u@example.com", + "workspace_ids": ["ws-01-1"], + "parameters": {"workspace_count": 1}, + "progress": {}, + "error": None, + "workspace_phases": { + "ws-01-1": {"last_completed_phase": 3, "total_phases": 6, "planning_data": {}}, + }, + } + mock_generation_session_service.get_generation_session_status = AsyncMock(return_value=doc) + mock_generation_session_service.get_checkpoint = Mock( + return_value=GenerationCheckpoint.GENERATION_STARTED + ) + mock_generation_session_service.get_current_phase_name = Mock(return_value="Generating") + + response = client.get( + f"/api/v1/generation-sessions/{generation_id}/status", + headers={"X-API-Key": "test-key"}, + ) + + assert response.status_code == 200 + assert "workspace_phases_deployment" not in response.json() + def test_status_phase_name_reports_kb_init_before_kb_init_done( self, client, mock_generation_session_service ): diff --git a/mcp_server/tests/test_tui_render.py b/mcp_server/tests/test_tui_render.py index 34eeb47..5e2d3ba 100644 --- a/mcp_server/tests/test_tui_render.py +++ b/mcp_server/tests/test_tui_render.py @@ -164,6 +164,67 @@ def test_unknown_total_is_safe(self): assert bar.percent == 0 assert bar.phase_label == "Phase 2/?" + def test_no_deploy_map_leaves_deploy_inactive(self): + bar = render.workspace_bars(_running_payload())[0] + assert bar.deploy_active is False + assert bar.active_phase_name == bar.phase_name + + def test_deploy_progress_shown_additively(self): + # Code-gen complete (12/12), deploy loop on its first round (1/4). + payload = { + "workspace_phases": { + "ws-01-1": {"last_completed_phase": 12, "total_phases": 12, "phase_name": "Wrap-up"}, + }, + "workspace_phases_deployment": { + "ws-01-1": {"last_completed_phase": 1, "total_phases": 4, "phase_name": "Smoke E2E"}, + }, + } + bar = render.workspace_bars(payload)[0] + assert bar.deploy_active is True + assert bar.phase_label == "Phase 12/12 · Deploy 1/4" + # The active stage is deploy, so fraction/name reflect the deploy round. + assert abs(bar.fraction - 1 / 4) < 1e-9 + assert bar.percent == 25 + assert bar.active_phase_name == "Smoke E2E" + + def test_deploy_not_started_for_workspace_stays_codegen(self): + # Deployment map present for another workspace but not this one. + payload = { + "workspace_phases": { + "ws-01-1": {"last_completed_phase": 12, "total_phases": 12, "phase_name": "Wrap-up"}, + "ws-01-2": {"last_completed_phase": 12, "total_phases": 12, "phase_name": "Wrap-up"}, + }, + "workspace_phases_deployment": { + "ws-01-1": {"last_completed_phase": 2, "total_phases": 4, "phase_name": "Auth E2E"}, + }, + } + bars = {b.workspace_id: b for b in render.workspace_bars(payload)} + assert bars["ws-01-1"].deploy_active is True + assert bars["ws-01-2"].deploy_active is False + assert bars["ws-01-2"].phase_label == "Phase 12/12" + + def test_deploy_unknown_total_is_safe(self): + payload = { + "workspace_phases": {"ws-1": {"last_completed_phase": 5, "total_phases": 5}}, + "workspace_phases_deployment": {"ws-1": {"last_completed_phase": 1}}, + } + bar = render.workspace_bars(payload)[0] + # total_phases absent → deploy not treated as active (nothing to count against). + assert bar.deploy_active is False + assert bar.phase_label == "Phase 5/5" + + def test_deploy_zero_total_is_safe(self): + # A zero total is as meaningless as a missing one — both mean "no live + # deploy stage to count against", so they must read identically. + payload = { + "workspace_phases": {"ws-1": {"last_completed_phase": 5, "total_phases": 5}}, + "workspace_phases_deployment": {"ws-1": {"last_completed_phase": 0, "total_phases": 0}}, + } + bar = render.workspace_bars(payload)[0] + assert bar.deploy_active is False + assert bar.phase_label == "Phase 5/5" + assert bar.active_phase_name == bar.phase_name + class TestProgressBar: def test_full_and_empty(self): @@ -334,6 +395,29 @@ def test_reads_legacy_progress_nesting(self): stats = render.workspace_stats(payload, "ws-1") assert stats.percent == 50 + def test_drill_in_tracks_active_deploy_stage(self): + # Once deploy starts, the drill-in must reflect the same active stage as + # the dashboard row (issue #38) — not the finished code-gen stage. + payload = { + "workspace_phases": { + "ws-01-1": {"last_completed_phase": 12, "total_phases": 12, "phase_name": "Wrap-up"}, + }, + "workspace_phases_deployment": { + "ws-01-1": {"last_completed_phase": 1, "total_phases": 4, "phase_name": "Auth E2E"}, + }, + } + stats = render.workspace_stats(payload, "ws-01-1") + assert stats.deploy_active is True + assert stats.active_phase_name == "Auth E2E" + assert stats.phase_label == "Phase 12/12 · Deploy 1/4" + assert stats.percent == 25 + + def test_drill_in_without_deploy_stays_codegen(self): + stats = render.workspace_stats(_usage_payload(), "ws-01-1") + assert stats.deploy_active is False + assert stats.active_phase_name == "Auth API" + assert stats.phase_label == "Phase 3/9" + class TestFormatTokens: def test_none_is_dash(self): diff --git a/mcp_server/tui/app.py b/mcp_server/tui/app.py index de695e7..7283830 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -152,7 +152,7 @@ def _workspaces_panel(payload: dict[str, Any], selected_ws_id: str | None = None Text(marker, style="yellow"), Text(bar.workspace_id, style=id_style), Text(bar.phase_label, style="cyan"), - Text(bar.phase_name[:32], style="dim"), + Text(bar.active_phase_name[:32], style="dim"), Text(render.progress_bar(bar.fraction), style="green"), Text(f"{bar.percent}%"), ) @@ -282,8 +282,8 @@ def build_workspace_stats(payload: dict[str, Any] | None, workspace_id: str) -> if stats.models: grid.add_row("Model(s)", ", ".join(stats.models)) phase = f"{stats.phase_label}".strip() - if stats.phase_name: - phase += f" {stats.phase_name}" + if stats.active_phase_name: + phase += f" {stats.active_phase_name}" grid.add_row("Phase", phase) grid.add_row("Progress", f"{render.progress_bar(stats.fraction)} {stats.percent}%") if stats.has_usage: @@ -1860,7 +1860,7 @@ def _plain_status(payload: dict[str, Any] | None, generation_id: str) -> str: if tokens: lines.append(f" Usage: {tokens}") for bar in render.workspace_bars(payload): - lines.append(f" {bar.workspace_id} {bar.phase_label} {bar.percent}% {bar.phase_name}") + lines.append(f" {bar.workspace_id} {bar.phase_label} {bar.percent}% {bar.active_phase_name}") return "\n".join(lines) diff --git a/mcp_server/tui/render.py b/mcp_server/tui/render.py index 3c31b1c..6f87a3a 100644 --- a/mcp_server/tui/render.py +++ b/mcp_server/tui/render.py @@ -40,21 +40,55 @@ def symbol(self) -> str: return STEP_SYMBOLS[self.state] -@dataclass(frozen=True) -class WorkspaceBar: - """Per-workspace progress row.""" +def _phase_fraction(last_completed: int, total: int | None) -> float: + """Completed fraction in [0, 1]; 0 when total is unknown/zero.""" + if not total or total <= 0: + return 0.0 + return max(0.0, min(1.0, last_completed / total)) - workspace_id: str + +class _ActiveStageProgress: + """Stage-aware progress derivations shared by every per-workspace view. + + A workspace runs code-generation phases and then, once the Deploy & E2E loop + starts, deploy phases. Deploy runs *after* code-gen finishes, so when deploy is + active the fraction/name reflect the deploy stage (the live work) while + ``phase_label`` shows both stages additively — e.g. "Phase 12/12 · Deploy 1/4" + (issue #38). + + Concrete consumers (``WorkspaceBar``, ``WorkspaceStats``) declare the fields + below as dataclass fields; this mixin owns the derivations so the two views + can never drift apart. + """ + + # Declared as dataclass fields by each concrete subclass. phase_name: str last_completed_phase: int total_phases: int | None + deploy_phase_name: str + deploy_last_completed_phase: int | None + deploy_total_phases: int | None + + @property + def deploy_active(self) -> bool: + """True once the deploy loop has reported a positive phase total. + + A missing *or* zero total both mean "no live deploy stage to count + against", so they read identically (see ``_phase_fraction``). + """ + return bool(self.deploy_total_phases and self.deploy_total_phases > 0) + + @property + def active_phase_name(self) -> str: + """Name of the phase currently in flight (deploy stage takes precedence).""" + return self.deploy_phase_name if self.deploy_active else self.phase_name @property def fraction(self) -> float: - """Completed fraction in [0, 1]; 0 when total is unknown/zero.""" - if not self.total_phases or self.total_phases <= 0: - return 0.0 - return max(0.0, min(1.0, self.last_completed_phase / self.total_phases)) + """Completed fraction of the active stage in [0, 1].""" + if self.deploy_active: + return _phase_fraction(self.deploy_last_completed_phase or 0, self.deploy_total_phases) + return _phase_fraction(self.last_completed_phase, self.total_phases) @property def percent(self) -> int: @@ -62,9 +96,28 @@ def percent(self) -> int: @property def phase_label(self) -> str: - """e.g. 'Phase 6/9' — '?' for total when unknown.""" + """e.g. 'Phase 6/9', or 'Phase 12/12 · Deploy 1/4' once deploy starts. + + '?' stands in for a total that has not been reported yet. + """ total = self.total_phases if self.total_phases else "?" - return f"Phase {self.last_completed_phase}/{total}" + label = f"Phase {self.last_completed_phase}/{total}" + if self.deploy_active: + label += f" · Deploy {self.deploy_last_completed_phase or 0}/{self.deploy_total_phases}" + return label + + +@dataclass(frozen=True) +class WorkspaceBar(_ActiveStageProgress): + """Per-workspace progress row (see ``_ActiveStageProgress`` for stage rules).""" + + workspace_id: str + phase_name: str + last_completed_phase: int + total_phases: int | None + deploy_phase_name: str = "" + deploy_last_completed_phase: int | None = None + deploy_total_phases: int | None = None @dataclass(frozen=True) @@ -155,20 +208,29 @@ def workspace_bars(payload: dict[str, Any]) -> list[WorkspaceBar]: """Build per-workspace progress rows from ``workspace_phases``. Reads the top-level ``workspace_phases`` the status endpoint returns, with a - fallback to the older ``progress.workspace_phases`` nesting. Returns an empty - list when the backend has not yet reported phases. Rows are ordered by - workspace id for stable rendering. + fallback to the older ``progress.workspace_phases`` nesting. Merges in the + ``workspace_phases_deployment`` map (present once the Deploy & E2E loop starts) + so each row can show deploy progress additively. Returns an empty list when + the backend has not yet reported phases. Rows are ordered by workspace id for + stable rendering. """ workspace_phases = _workspace_phases(payload) + deployment = payload.get("workspace_phases_deployment") or {} bars: list[WorkspaceBar] = [] for ws_id in sorted(workspace_phases): data = workspace_phases[ws_id] or {} + deploy = deployment.get(ws_id) or {} bars.append( WorkspaceBar( workspace_id=ws_id, phase_name=data.get("phase_name") or "", last_completed_phase=int(data.get("last_completed_phase") or 0), total_phases=data.get("total_phases"), + deploy_phase_name=deploy.get("phase_name") or "", + deploy_last_completed_phase=( + int(deploy.get("last_completed_phase") or 0) if deploy else None + ), + deploy_total_phases=deploy.get("total_phases"), ) ) return bars @@ -345,9 +407,13 @@ def stream_row(event: Any) -> StreamRow: @dataclass(frozen=True) -class WorkspaceStats: +class WorkspaceStats(_ActiveStageProgress): """Per-workspace stats for the drill-in panel, flattened for display. + Inherits the same stage-aware progress derivations as ``WorkspaceBar`` (see + ``_ActiveStageProgress``) so the drill-in never disagrees with the row it was + opened from once the deploy loop starts. + Token/turn fields are optional: they come from the status payload's per-workspace ``usage`` block which is only present once the backend has recorded agent usage for that workspace. @@ -358,6 +424,9 @@ class WorkspaceStats: phase_name: str last_completed_phase: int total_phases: int | None + deploy_phase_name: str = "" + deploy_last_completed_phase: int | None = None + deploy_total_phases: int | None = None num_turns: int | None = None input_tokens: int | None = None output_tokens: int | None = None @@ -365,21 +434,6 @@ class WorkspaceStats: cache_read_tokens: int | None = None total_tokens: int | None = None - @property - def fraction(self) -> float: - if not self.total_phases or self.total_phases <= 0: - return 0.0 - return max(0.0, min(1.0, self.last_completed_phase / self.total_phases)) - - @property - def percent(self) -> int: - return round(self.fraction * 100) - - @property - def phase_label(self) -> str: - total = self.total_phases if self.total_phases else "?" - return f"Phase {self.last_completed_phase}/{total}" - @property def has_usage(self) -> bool: """True once any turn/token figure has been recorded for the workspace. @@ -406,14 +460,17 @@ def workspace_stats(payload: dict[str, Any], workspace_id: str) -> WorkspaceStat Reads the same ``workspace_phases`` map the dashboard polls (no extra call), including the optional per-workspace ``usage`` token buckets and ``models`` - list added to the status endpoint. Returns None when the workspace has not - been reported yet so the caller can show a placeholder. + list added to the status endpoint, plus the ``workspace_phases_deployment`` + map (present once the Deploy & E2E loop starts) so the drill-in tracks the + same active stage as the dashboard row. Returns None when the workspace has + not been reported yet so the caller can show a placeholder. """ phases = _workspace_phases(payload) data = phases.get(workspace_id) if data is None: return None data = data or {} + deploy = (payload.get("workspace_phases_deployment") or {}).get(workspace_id) or {} usage = data.get("usage") or {} models = [str(m) for m in (data.get("models") or [])] @@ -427,6 +484,11 @@ def _opt(key: str) -> int | None: phase_name=data.get("phase_name") or "", last_completed_phase=int(data.get("last_completed_phase") or 0), total_phases=data.get("total_phases"), + deploy_phase_name=deploy.get("phase_name") or "", + deploy_last_completed_phase=( + int(deploy.get("last_completed_phase") or 0) if deploy else None + ), + deploy_total_phases=deploy.get("total_phases"), num_turns=_opt("num_turns"), input_tokens=_opt("input_tokens"), output_tokens=_opt("output_tokens"),