Files
state-hub/api/routers/workstreams.py
tegwick 9dd71af8f9 feat(state-hub): CUST-WP-0040 — NATS lifecycle event publishing for activity-core
Makes the state hub an event publisher so activity-core can drive
maintenance automation declaratively via ActivityDefinitions, rather
than the hub creating tasks itself.

- api/events/: lazy JetStream publisher + EventEnvelope mirroring
  activity-core's contract; no-op when NATS_URL unset, fire-and-forget
  with logged failures so publishing never breaks an API request.
- Wired publishers on the five v1.0 lifecycle events:
    org.statehub.repo.registered        (POST /repos/)
    org.statehub.workstream.completed   (PATCH /workstreams/* on transition)
    org.statehub.decision.resolved      (POST /decisions/*/resolve)
    org.statehub.domain.goal.activated  (POST /domain-goals/*/activate)
    org.statehub.task.stale             (scripts/cleanup_stale_tasks.py)
- docs/nats-event-subjects.md: subject naming convention + catalog.
- docs/cron-migration.md: design stub for replacing custodian-sync
  systemd timer and cleanup-stale cron with ActivityDefinitions
  (depends on activity-core WP-0003).
- docs/activity-core-delegation.md: protocol, invariants, cutover plan.
- SCOPE.md: declares activity-core as downstream event consumer and
  restates that the state hub stays a read model, not a task factory.

Workplan: workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md
242 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 05:49:29 +02:00

209 lines
6.6 KiB
Python

import asyncio
import uuid
import socket
import time
from pathlib import Path
from typing import Any
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,
WorkstreamStatus,
WorkstreamUpdate,
)
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 {}
data: dict[str, Any] = {}
for raw_line in text[4:end].splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or ":" not in line:
continue
key, value = line.split(":", 1)
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
data[key.strip()] = value
return data
@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: WorkstreamStatus | 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:
q = q.where(Workstream.status == 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
index[str(workstream_id)] = {
"filename": path.name,
"relative_path": str(path.relative_to(root)),
"repo_slug": repo.slug,
"archived": archived,
}
_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")
prev_status = ws.status
for field, value in body.model_dump(exclude_unset=True).items():
setattr(ws, field, value)
await session.commit()
await session.refresh(ws)
if prev_status != "completed" and ws.status == "completed":
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")
ws.status = "archived"
await session.commit()
await session.refresh(ws)
return ws