import asyncio import uuid import socket import time from pathlib import Path from typing import Any import yaml from fastapi import APIRouter, Depends, HTTPException, Query, 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.workplan_status import ( is_supported_workstream_status, normalize_workstream_status, ready_review_status, ) router = APIRouter(prefix="/workstreams", tags=["workstreams"]) _INDEX_CACHE: dict[str, Any] | None = None _INDEX_CACHE_AT: float = 0.0 _INDEX_TTL = 30.0 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 {} @router.get("/", response_model=list[WorkstreamRead]) async def list_workstreams( 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]: 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: normalised_status = normalize_workstream_status(status) if not is_supported_workstream_status(status): raise HTTPException(status_code=422, detail=f"Unsupported workstream status '{status}'") 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()) @router.get("/workplan-index") async def workplan_index( refresh: bool = Query(False, description="Force cache invalidation"), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """Map file-backed workstream 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 = {"workstreams": index} _INDEX_CACHE_AT = time.monotonic() return _INDEX_CACHE @router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED) async def create_workstream( body: WorkstreamCreate, session: AsyncSession = Depends(get_session), ) -> Workstream: ws = Workstream(**body.model_dump()) session.add(ws) await session.commit() await session.refresh(ws) return ws @router.get("/{workstream_id}", response_model=WorkstreamRead) async def get_workstream( workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Workstream: ws = await session.get(Workstream, workstream_id) if ws is None: raise HTTPException(status_code=404, detail="Workstream not found") return ws @router.patch("/{workstream_id}", response_model=WorkstreamRead) async def update_workstream( workstream_id: uuid.UUID, body: WorkstreamUpdate, session: AsyncSession = Depends(get_session), ) -> Workstream: ws = await session.get(Workstream, workstream_id) if ws is None: raise HTTPException(status_code=404, detail="Workstream 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": subject = "org.statehub.workstream.completed" envelope = EventEnvelope.new( subject, 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(subject, envelope)) return ws @router.delete("/{workstream_id}", response_model=WorkstreamRead) async def archive_workstream( workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Workstream: ws = await session.get(Workstream, workstream_id) if ws is None: raise HTTPException(status_code=404, detail="Workstream not found") transition_workstream_status(ws, "archived") await session.commit() await session.refresh(ws) return ws