import asyncio import logging import uuid import socket import time from pathlib import Path from typing import Any import yaml from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.events import EventEnvelope, publish_event from api.models.managed_repo import ManagedRepo from api.models.workstream import Workstream from api.schemas.workstream import ( WorkstreamCreate, WorkstreamRead, WorkstreamUpdate, ) from api.services.lifecycle import transition_workstream_status from api.services.legacy_meter import ( LegacyUsageIdentity, identity_from_request, record_legacy_usage, ) from api.workplan_status import ( is_supported_workstream_status, normalize_workstream_status, ready_review_status, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/workstreams", tags=["workstreams"]) workplan_router = APIRouter(prefix="/workplans", tags=["workplans"]) _INDEX_CACHE: dict[str, Any] | None = None _INDEX_CACHE_AT: float = 0.0 _INDEX_TTL = 30.0 _LEGACY_OWNER = "state-hub.api" _COMPLETED_WORKSTREAM_EVENT = "org.statehub.workstream.completed" _COMPLETED_WORKPLAN_EVENT = "org.statehub.workplan.completed" def _repo_path(repo: ManagedRepo) -> Path | None: hostname = socket.gethostname() candidates = [] host_paths = repo.host_paths or {} if host_paths.get(hostname): candidates.append(host_paths[hostname]) if repo.local_path: candidates.append(repo.local_path) for raw in candidates: path = Path(raw).expanduser() if path.is_dir(): return path return None def _frontmatter(path: Path) -> dict[str, Any]: try: text = path.read_text(encoding="utf-8") except OSError: return {} if not text.startswith("---\n"): return {} end = text.find("\n---", 4) if end == -1: return {} try: return yaml.safe_load(text[4:end].strip()) or {} except yaml.YAMLError: return {} def _legacy_key(method: str, route: str) -> str: return f"rest_api:{method} {route}" def _mark_legacy_response(response: Response | None, replacement_ref: str) -> None: if response is None: return response.headers["Deprecation"] = "true" response.headers["X-StateHub-Replacement"] = replacement_ref response.headers.append("Link", f"<{replacement_ref}>; rel=\"successor-version\"") async def _meter_legacy_route( *, session: AsyncSession, request: Request | None, response: Response | None, interface_key: str, replacement_ref: str, ) -> None: _mark_legacy_response(response, replacement_ref) try: await record_legacy_usage( session, interface_key=interface_key, interface_kind="rest_api", replacement_ref=replacement_ref, owner_component=_LEGACY_OWNER, replacement_verified=True, identity=identity_from_request(request), ) except Exception: await session.rollback() logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True) async def _meter_legacy_event( *, session: AsyncSession, subject: str, replacement_ref: str, ) -> None: try: await record_legacy_usage( session, interface_key=f"event_subject:{subject}", interface_kind="event_subject", replacement_ref=replacement_ref, owner_component="state-hub.events", replacement_verified=True, identity=LegacyUsageIdentity(component_key="state-hub.events"), ) except Exception: await session.rollback() logger.warning("legacy-meter failed to record event subject %s", subject, exc_info=True) async def _list_workstreams( *, topic_id: uuid.UUID | None, repo_id: uuid.UUID | None, repo_goal_id: uuid.UUID | None, status_filter: str | None, owner: str | None, slug: str | None, session: AsyncSession, ) -> list[Workstream]: q = select(Workstream) if topic_id: q = q.where(Workstream.topic_id == topic_id) if repo_id: q = q.where(Workstream.repo_id == repo_id) if repo_goal_id: q = q.where(Workstream.repo_goal_id == repo_goal_id) if status_filter: normalised_status = normalize_workstream_status(status_filter) if not is_supported_workstream_status(status_filter): raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'") q = q.where(Workstream.status == normalised_status) if owner: q = q.where(Workstream.owner == owner) if slug: q = q.where(Workstream.slug == slug) q = q.order_by( Workstream.planning_priority.asc().nullslast(), Workstream.planning_order.asc().nullslast(), Workstream.updated_at.desc(), ) result = await session.execute(q) return list(result.scalars().all()) async def _workplan_index( *, refresh: bool, session: AsyncSession, ) -> dict[str, Any]: """Map file-backed workplan ids to their local workplan filenames.""" global _INDEX_CACHE, _INDEX_CACHE_AT if not refresh and _INDEX_CACHE is not None and (time.monotonic() - _INDEX_CACHE_AT) < _INDEX_TTL: return _INDEX_CACHE result = await session.execute( select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug) ) index: dict[str, Any] = {} for repo in result.scalars().all(): root = _repo_path(repo) if root is None: continue for directory, archived in ( (root / "workplans", False), (root / "workplans" / "archived", True), ): if not directory.is_dir(): continue for path in sorted(directory.glob("*.md")): data = _frontmatter(path) workstream_id = data.get("state_hub_workstream_id") if not workstream_id: continue file_status = normalize_workstream_status(data.get("status", "")) review = ( ready_review_status( root, data.get("reviewed_against_commit"), data.get("context_paths"), ) if file_status == "ready" else None ) index[str(workstream_id)] = { "filename": path.name, "relative_path": str(path.relative_to(root)), "repo_slug": repo.slug, "archived": archived, "status": file_status or None, "needs_review": bool(review and review.needs_review), "health_labels": ["needs_review"] if review and review.needs_review else [], } _INDEX_CACHE = {"workplans": index, "workstreams": index} _INDEX_CACHE_AT = time.monotonic() return _INDEX_CACHE async def _create_workstream( *, body: WorkstreamCreate, session: AsyncSession, ) -> Workstream: ws = Workstream(**body.model_dump()) session.add(ws) await session.commit() await session.refresh(ws) return ws async def _get_workstream( *, workstream_id: uuid.UUID, session: AsyncSession, ) -> Workstream: ws = await session.get(Workstream, workstream_id) if ws is None: raise HTTPException(status_code=404, detail="Workplan not found") return ws async def _update_workstream( *, workstream_id: uuid.UUID, body: WorkstreamUpdate, session: AsyncSession, ) -> Workstream: ws = await session.get(Workstream, workstream_id) if ws is None: raise HTTPException(status_code=404, detail="Workplan not found") update_data = body.model_dump(exclude_unset=True) status_update = update_data.pop("status", None) prev_status = ws.status for field, value in update_data.items(): setattr(ws, field, value) if status_update is not None: transition_workstream_status(ws, status_update) await session.commit() await session.refresh(ws) if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished": await _publish_completion_events(ws, session) return ws async def _archive_workstream( *, workstream_id: uuid.UUID, session: AsyncSession, ) -> Workstream: ws = await session.get(Workstream, workstream_id) if ws is None: raise HTTPException(status_code=404, detail="Workplan not found") transition_workstream_status(ws, "archived") await session.commit() await session.refresh(ws) return ws async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> None: workplan_envelope = EventEnvelope.new( _COMPLETED_WORKPLAN_EVENT, attributes={ "workplan_id": str(ws.id), "legacy_workstream_id": str(ws.id), "slug": ws.slug, "title": ws.title, "topic_id": str(ws.topic_id), "repo_id": str(ws.repo_id) if ws.repo_id else None, "repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None, }, ) asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope)) await _meter_legacy_event( session=session, subject=_COMPLETED_WORKSTREAM_EVENT, replacement_ref=_COMPLETED_WORKPLAN_EVENT, ) legacy_envelope = EventEnvelope.new( _COMPLETED_WORKSTREAM_EVENT, attributes={ "workstream_id": str(ws.id), "slug": ws.slug, "title": ws.title, "topic_id": str(ws.topic_id), "repo_id": str(ws.repo_id) if ws.repo_id else None, "repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None, }, ) asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope)) @router.get("/", response_model=list[WorkstreamRead]) async def list_workstreams( request: Request, response: Response, topic_id: uuid.UUID | None = None, repo_id: uuid.UUID | None = None, repo_goal_id: uuid.UUID | None = None, status: str | None = None, owner: str | None = None, slug: str | None = None, session: AsyncSession = Depends(get_session), ) -> list[Workstream]: await _meter_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("GET", "/workstreams/"), replacement_ref="/workplans/", ) return await _list_workstreams( topic_id=topic_id, repo_id=repo_id, repo_goal_id=repo_goal_id, status_filter=status, owner=owner, slug=slug, session=session, ) @workplan_router.get("/", response_model=list[WorkstreamRead]) async def list_workplans( topic_id: uuid.UUID | None = None, repo_id: uuid.UUID | None = None, repo_goal_id: uuid.UUID | None = None, status: str | None = None, owner: str | None = None, slug: str | None = None, session: AsyncSession = Depends(get_session), ) -> list[Workstream]: return await _list_workstreams( topic_id=topic_id, repo_id=repo_id, repo_goal_id=repo_goal_id, status_filter=status, owner=owner, slug=slug, session=session, ) @router.get("/workplan-index") async def workplan_index( request: Request, response: Response, refresh: bool = Query(False, description="Force cache invalidation"), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: await _meter_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("GET", "/workstreams/workplan-index"), replacement_ref="/workplans/index", ) return await _workplan_index(refresh=refresh, session=session) @workplan_router.get("/index") async def workplan_index_preferred( refresh: bool = Query(False, description="Force cache invalidation"), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: return await _workplan_index(refresh=refresh, session=session) @router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED) async def create_workstream( request: Request, response: Response, body: WorkstreamCreate, session: AsyncSession = Depends(get_session), ) -> Workstream: await _meter_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("POST", "/workstreams/"), replacement_ref="/workplans/", ) return await _create_workstream(body=body, session=session) @workplan_router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED) async def create_workplan( body: WorkstreamCreate, session: AsyncSession = Depends(get_session), ) -> Workstream: return await _create_workstream(body=body, session=session) @router.get("/{workstream_id}", response_model=WorkstreamRead) async def get_workstream( request: Request, response: Response, workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Workstream: await _meter_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"), replacement_ref="/workplans/{workplan_id}", ) return await _get_workstream(workstream_id=workstream_id, session=session) @workplan_router.get("/{workplan_id}", response_model=WorkstreamRead) async def get_workplan( workplan_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Workstream: return await _get_workstream(workstream_id=workplan_id, session=session) @router.patch("/{workstream_id}", response_model=WorkstreamRead) async def update_workstream( request: Request, response: Response, workstream_id: uuid.UUID, body: WorkstreamUpdate, session: AsyncSession = Depends(get_session), ) -> Workstream: await _meter_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"), replacement_ref="/workplans/{workplan_id}", ) return await _update_workstream(workstream_id=workstream_id, body=body, session=session) @workplan_router.patch("/{workplan_id}", response_model=WorkstreamRead) async def update_workplan( workplan_id: uuid.UUID, body: WorkstreamUpdate, session: AsyncSession = Depends(get_session), ) -> Workstream: return await _update_workstream(workstream_id=workplan_id, body=body, session=session) @router.delete("/{workstream_id}", response_model=WorkstreamRead) async def archive_workstream( request: Request, response: Response, workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Workstream: await _meter_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"), replacement_ref="/workplans/{workplan_id}", ) return await _archive_workstream(workstream_id=workstream_id, session=session) @workplan_router.delete("/{workplan_id}", response_model=WorkstreamRead) async def archive_workplan( workplan_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Workstream: return await _archive_workstream(workstream_id=workplan_id, session=session)