Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion backend/app/api/v1/generation_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@
_multi_workspace_result_to_store,
_handle_workflow_exception,
)
from app.core.artifact_files import MULTI_WORKSPACE_REPORT_HTML_FILE
from app.core.artifact_subdirs import REPORT_SUBDIR
from app.services.workspace_pool import WorkspacePoolService
from app.services.artifact_store import ArtifactStore
from app.services.artifact_store import ARTIFACTS_BASE, ArtifactStore
from app.services.agent_stream_broker import get_agent_stream_broker
from app.services.mcp_prune import resolve_enabled_mcps_and_set_telemetry
from app.schemas.model_token_usage import ModelTokenUsage
Expand Down Expand Up @@ -1662,3 +1664,29 @@ async def download_generation_session_outputs(
detail=f"Failed to build outputs tarball: {e}",
)


@router.get("/{generation_id}/report.html")
@track_event(event_name="download_report_html_triggered")
async def download_generation_session_report_html(
generation_id: str,
request: Request,
_: None = Depends(require_generation_session_owner),
):
"""
Serve the P10Y multi-workspace estimation report as raw HTML.

Lightweight alternative to ``/outputs`` — a few KB rather than a full
tarball of workspace code, so it's fast enough to fetch on a single local
TUI keypress. Raises 404 when the report hasn't been written yet (P10Y
estimation not complete, or this is an older run predating the report).
"""
report_path = (
ARTIFACTS_BASE / generation_id / REPORT_SUBDIR / MULTI_WORKSPACE_REPORT_HTML_FILE
)
if not report_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No HTML report found for generation session {generation_id}.",
)
return Response(content=report_path.read_bytes(), media_type="text/html")

6 changes: 6 additions & 0 deletions backend/app/core/artifact_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@
SPEC_COMPLETENESS_FILE = "specification_completeness.md"
IMPLEMENTATION_PLAN_FILE = "IMPLEMENTATION_PLAN.md"
E2E_TEST_PLAN_FILE = "e2e-test-plan.md"

# P10Y multi-workspace estimation report — written by the estimation workflow
# under ARTIFACTS_BASE/{generation_id}/report/, read back by the report-html
# API endpoint and the local TUI.
MULTI_WORKSPACE_REPORT_MD_FILE = "multi-workspace-estimation-report.md"
MULTI_WORKSPACE_REPORT_HTML_FILE = "multi-workspace-estimation-report.html"
778 changes: 402 additions & 376 deletions backend/app/core/notifications.py

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion backend/app/database/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"""

from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, TypeVar, runtime_checkable

# Type for query filter: (field, operator, value)
FilterTuple = Tuple[str, str, Any]
Expand All @@ -16,6 +16,22 @@
T = TypeVar("T")


@runtime_checkable
class ReadOnlyDatabase(Protocol):
"""Read-only view over a database — the narrow subset a document reader needs.

Consumers that must never write status/checkpoint/workspace_phases (e.g. the
notifications report renderer) depend on this protocol instead of the full
``IDatabase``, so those writes are impossible through the handle they hold.
Any ``IDatabase`` satisfies it structurally; ``StateMachineDBAdapter`` hands
out a view that exposes *only* ``get`` — keeping Commandment VII enforced,
not merely documented.
"""

def get(self, collection: str, doc_id: str) -> Optional[Dict[str, Any]]:
...


class ITransactionContext(ABC):
"""
Transaction context interface for atomic database operations.
Expand Down
34 changes: 32 additions & 2 deletions backend/app/state/db_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,35 @@
from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Dict, Optional

if TYPE_CHECKING:
from app.database.interface import IDatabase
from app.database.interface import IDatabase, ReadOnlyDatabase

# Firestore collection names — use these constants instead of repeating string literals
COL_WORKSPACES = "workspaces"
COL_GENERATION_SESSIONS = "generation_sessions"
COL_API_KEYS = "api_keys"


class _ReadOnlyDatabaseView:
"""Read-only façade over ``IDatabase`` exposing only ``get``.

Handed to read-only consumers (the notifications report renderer) so they
physically cannot perform status/checkpoint/workspace_phases writes — the
encapsulation of Commandment VII is enforced by construction, not by a
docstring warning. Satisfies the ``ReadOnlyDatabase`` protocol structurally.
"""

__slots__ = ("_db",)

def __init__(self, db: "IDatabase") -> None:
self._db = db

def get(self, collection: str, doc_id: str) -> Optional[Dict[str, Any]]:
return self._db.get(collection, doc_id)


class StateMachineDBAdapter:
"""
Adapts the synchronous IDatabase interface to the async interface
Expand All @@ -45,6 +63,18 @@ class StateMachineDBAdapter:
def __init__(self, db: "IDatabase") -> None:
self._db = db

@property
def read_only_db(self) -> "ReadOnlyDatabase":
"""A read-only view over the underlying database.

For consumers that only read (e.g. the notifications report renderer,
which does a plain ``db.get("workspaces", id)``). The returned view
exposes *only* ``get`` — writes are unreachable through it, so
status/checkpoint/workspace_phases writes still have to go through this
adapter's async methods.
"""
return _ReadOnlyDatabaseView(self._db)

# ------------------------------------------------------------------
# Generation methods
# ------------------------------------------------------------------
Expand Down
61 changes: 44 additions & 17 deletions backend/app/workflows/multi_workspace_estimation_p10y.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
from app.schemas.specification import GenerationWorkflowRequest
from app.schemas.telemetry_workflow import TelemetryWorkflowLabel
from app.schemas.workspace import WorkspaceSettings
from app.core.artifact_files import MULTI_WORKSPACE_REPORT_HTML_FILE, MULTI_WORKSPACE_REPORT_MD_FILE
from app.core.notifications import render_generation_session_report_html
from app.services.artifact_store import ArtifactStore
from app.services.claude_code import agent_query
from app.services.p10y.estimation_report_generator import format_multi_workspace_report
Expand Down Expand Up @@ -642,7 +644,19 @@ async def multi_workspace_estimation_p10y_workflow(
f"buffer={risk_assessment.total_buffer_pct*100:.1f}%, "
f"final_estimate={risk_assessment.final_estimate:.1f}h"
)


# Build response now — every field is resolved, and both the markdown and
# HTML reports below render from it.
response = MultiWorkspaceEstimationResponse(
summary=summary,
workspace_estimations=workspace_estimations,
comparative_analysis=comparative_analysis,
timestamp=datetime.now(timezone.utc).isoformat(),
skipped_workspaces=skipped_workspaces,
aggregate_p10y_commit_coverage_pct=aggregate_cov,
total_usd_cost=session_llm_cost_total,
)

# Generate structured markdown report using report generator
logger.info("Generating structured markdown report...")
structured_report = format_multi_workspace_report(
Expand All @@ -652,13 +666,37 @@ async def multi_workspace_estimation_p10y_workflow(
skipped_workspaces=skipped_workspaces,
aggregate_p10y_commit_coverage_pct=aggregate_cov,
)

# Save structured report to file in primary workspace
primary_workspace_path = workspaces[0].workspace_path
full_outputs_dir = f"{primary_workspace_path}/{request.outputs_dir}"
structured_report_path = f"{full_outputs_dir}/multi-workspace-estimation-report.md"
structured_report_path = f"{full_outputs_dir}/{MULTI_WORKSPACE_REPORT_MD_FILE}"
_write_structured_report(structured_report, structured_report_path, logger)


# Save the same report as HTML, regardless of whether email/Slack notifiers
# are configured — local quickstart users have neither, so this is the only
# place the HTML report is produced. Non-essential observability output: a
# failure here must never abort an estimation whose work is already done.
# The renderer only reads (a plain db.get("workspaces", id)); db_adapter is
# the async state-machine wrapper, so pass its read-only view — without a
# database handle the Variants section silently drops out.
logger.info("Generating HTML report...")
try:
html_content, _plain_content = render_generation_session_report_html(
generation_id=request.generation_id,
workspace_ids=workspace_ids,
result=response,
spec_path=request.spec_path,
db=db_adapter.read_only_db if db_adapter else None,
)
html_report_path = f"{full_outputs_dir}/{MULTI_WORKSPACE_REPORT_HTML_FILE}"
_write_structured_report(html_content, html_report_path, logger)
except Exception as e:
logger.warning(
"Failed to render/write HTML report — continuing without it: %s",
e, exc_info=True,
)

# Generate AI-powered comprehensive report (optional, additional analysis)
logger.info("Generating AI-powered comprehensive report...")
report_summary = await _generate_ai_report(
Expand Down Expand Up @@ -691,18 +729,7 @@ async def multi_workspace_estimation_p10y_workflow(
f"Failed to archive combined report for estimation {generation_id}: {e}",
exc_info=True,
)

# Build response
response = MultiWorkspaceEstimationResponse(
summary=summary,
workspace_estimations=workspace_estimations,
comparative_analysis=comparative_analysis,
timestamp=datetime.now(timezone.utc).isoformat(),
skipped_workspaces=skipped_workspaces,
aggregate_p10y_commit_coverage_pct=aggregate_cov,
total_usd_cost=session_llm_cost_total,
)


logger.info("Multi-workspace estimation completed successfully")

return response
Loading