feat: add workplan aliases and legacy meter

Adds preferred workplan REST/event surfaces, legacy-meter telemetry and weekly review summaries, documentation/dashboard terminology updates, dashboard API loading fixes, and close-out sync for STATE-WP-0052 and STATE-WP-0054.
This commit is contained in:
2026-06-04 08:25:31 +02:00
parent 355f80b078
commit 166aedfa8d
43 changed files with 1851 additions and 145 deletions

View File

@@ -19,6 +19,7 @@ from api.routers import recently_on_scope
from api.routers import reconciliation
from api.routers import execution
from api.routers import fabric
from api.routers import legacy_meter
class ETagMiddleware(BaseHTTPMiddleware):
@@ -69,7 +70,12 @@ app = FastAPI(
lifespan=lifespan,
)
_cors_env = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
_default_dashboard_origins = [
*(f"http://localhost:{port}" for port in range(3000, 3006)),
*(f"http://127.0.0.1:{port}" for port in range(3000, 3006)),
*(f"http://[::1]:{port}" for port in range(3000, 3006)),
]
_cors_env = os.getenv("CORS_ORIGINS", ",".join(_default_dashboard_origins))
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
app.add_middleware(ETagMiddleware)
@@ -87,7 +93,9 @@ app.include_router(recently_on_scope.router)
app.include_router(repos.router)
app.include_router(topics.router)
app.include_router(workstreams.router)
app.include_router(workstreams.workplan_router)
app.include_router(workstream_dependencies.router)
app.include_router(workstream_dependencies.workplan_router)
app.include_router(tasks.router)
app.include_router(decisions.router)
app.include_router(extension_points.router)
@@ -106,6 +114,7 @@ app.include_router(flows.router)
app.include_router(reconciliation.router)
app.include_router(execution.router)
app.include_router(fabric.router)
app.include_router(legacy_meter.router)
app.include_router(state.router)
app.include_router(policy.router)

View File

@@ -23,6 +23,7 @@ from api.models.token_event import TokenEvent
from api.models.interface_change import InterfaceChange
from api.models.workplan_launch_request import WorkplanLaunchRequest
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
__all__ = [
"Base",
@@ -50,4 +51,5 @@ __all__ = [
"InterfaceChange",
"WorkplanLaunchRequest",
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
"LegacyInterface", "LegacyInterfaceUsageBucket",
]

View File

@@ -0,0 +1,79 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class LegacyInterface(Base, TimestampMixin):
__tablename__ = "legacy_interfaces"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
interface_key: Mapped[str] = mapped_column(String(300), nullable=False, unique=True, index=True)
interface_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
legacy_since: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
replacement_ref: Mapped[str] = mapped_column(Text, nullable=False)
owner_component: Mapped[str] = mapped_column(
String(100), nullable=False, default="state-hub", server_default="state-hub", index=True
)
status: Mapped[str] = mapped_column(
String(30), nullable=False, default="legacy", server_default="legacy", index=True
)
replacement_verified: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
manual_hold: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
hold_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
retired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
usage_buckets: Mapped[list["LegacyInterfaceUsageBucket"]] = relationship( # noqa: F821
"LegacyInterfaceUsageBucket",
back_populates="legacy_interface",
cascade="all, delete-orphan",
lazy="selectin",
)
class LegacyInterfaceUsageBucket(Base, TimestampMixin):
__tablename__ = "legacy_interface_usage_buckets"
__table_args__ = (
UniqueConstraint(
"legacy_interface_id",
"period_start",
"bucket_kind",
"bucket_key",
name="uq_legacy_usage_bucket",
),
Index("ix_legacy_usage_interface_period", "legacy_interface_id", "period_start"),
Index("ix_legacy_usage_bucket_kind_key", "bucket_kind", "bucket_key"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
legacy_interface_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("legacy_interfaces.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
period_start: Mapped[date] = mapped_column(Date, nullable=False, index=True)
bucket_kind: Mapped[str] = mapped_column(String(30), nullable=False)
bucket_key: Mapped[str] = mapped_column(String(200), nullable=False)
call_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
legacy_interface: Mapped["LegacyInterface"] = relationship( # noqa: F821
"LegacyInterface", back_populates="usage_buckets"
)

View File

@@ -1,6 +1,6 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,6 +27,7 @@ from api.services.execution_queue import (
queue_sort_key,
workstream_blockers,
)
from api.routers.workstreams import _legacy_key, _meter_legacy_route
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
router = APIRouter(prefix="/execution", tags=["execution"])
@@ -43,15 +44,15 @@ async def execution_semantics() -> ExecutionSemantics:
)
@router.patch("/workstreams/{workstream_id}/intent", response_model=ExecutionIntentRead)
async def update_execution_intent(
async def _update_execution_intent(
*,
workstream_id: uuid.UUID,
body: ExecutionIntentUpdate,
session: AsyncSession = Depends(get_session),
session: AsyncSession,
) -> ExecutionIntentRead:
ws = await session.get(Workstream, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
raise HTTPException(status_code=404, detail="Workplan not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(ws, field, value)
@@ -60,6 +61,33 @@ async def update_execution_intent(
return _intent_read(ws)
@router.patch("/workstreams/{workstream_id}/intent", response_model=ExecutionIntentRead)
async def update_execution_intent(
request: Request,
response: Response,
workstream_id: uuid.UUID,
body: ExecutionIntentUpdate,
session: AsyncSession = Depends(get_session),
) -> ExecutionIntentRead:
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("PATCH", "/execution/workstreams/{workstream_id}/intent"),
replacement_ref="/execution/workplans/{workplan_id}/intent",
)
return await _update_execution_intent(workstream_id=workstream_id, body=body, session=session)
@router.patch("/workplans/{workplan_id}/intent", response_model=ExecutionIntentRead)
async def update_workplan_execution_intent(
workplan_id: uuid.UUID,
body: ExecutionIntentUpdate,
session: AsyncSession = Depends(get_session),
) -> ExecutionIntentRead:
return await _update_execution_intent(workstream_id=workplan_id, body=body, session=session)
@router.get("/workplan-stack", response_model=list[WorkplanQueueItem])
async def workplan_stack(
include_manual: bool = Query(True),

129
api/routers/legacy_meter.py Normal file
View File

@@ -0,0 +1,129 @@
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.legacy_meter import LegacyInterface
from api.schemas.legacy_meter import (
LegacyInterfacePatch,
LegacyInterfaceRead,
LegacyInterfaceRegister,
LegacyUsageRecord,
LegacyUsageSummary,
LegacyWeeklyReview,
)
from api.services.legacy_meter import (
LegacyUsageIdentity,
get_legacy_interface_by_key,
legacy_usage_summary,
legacy_weekly_review,
list_legacy_interfaces,
patch_legacy_interface,
record_legacy_usage,
register_legacy_interface,
)
router = APIRouter(prefix="/legacy-meter", tags=["legacy-meter"])
@router.post(
"/interfaces",
response_model=LegacyInterfaceRead,
status_code=status.HTTP_201_CREATED,
)
async def register_interface(
body: LegacyInterfaceRegister,
session: AsyncSession = Depends(get_session),
) -> LegacyInterface:
try:
return await register_legacy_interface(session, **body.model_dump())
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.get("/interfaces", response_model=list[LegacyInterfaceRead])
async def list_interfaces(
session: AsyncSession = Depends(get_session),
) -> list[LegacyInterface]:
return await list_legacy_interfaces(session)
@router.get("/interfaces/by-key", response_model=LegacyInterfaceRead)
async def get_interface_by_key(
interface_key: str = Query(...),
session: AsyncSession = Depends(get_session),
) -> LegacyInterface:
interface = await get_legacy_interface_by_key(session, interface_key)
if interface is None:
raise HTTPException(status_code=404, detail=f"Legacy interface '{interface_key}' not found")
return interface
@router.patch("/interfaces/{interface_id}", response_model=LegacyInterfaceRead)
async def patch_interface(
interface_id: uuid.UUID,
body: LegacyInterfacePatch,
session: AsyncSession = Depends(get_session),
) -> LegacyInterface:
interface = await session.get(LegacyInterface, interface_id)
if interface is None:
raise HTTPException(status_code=404, detail=f"Legacy interface '{interface_id}' not found")
try:
return await patch_legacy_interface(
session,
interface,
body.model_dump(exclude_unset=True),
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/usage", response_model=LegacyInterfaceRead, status_code=status.HTTP_201_CREATED)
async def record_usage(
body: LegacyUsageRecord,
session: AsyncSession = Depends(get_session),
) -> LegacyInterface:
try:
return await record_legacy_usage(
session,
interface_key=body.interface_key,
interface_kind=body.interface_kind,
replacement_ref=body.replacement_ref,
owner_component=body.owner_component,
replacement_verified=body.replacement_verified,
identity=LegacyUsageIdentity(
tenant_key=body.tenant_key or "unknown",
user_key=body.user_key or "unknown",
component_key=body.component_key or "unknown",
),
observed_at=body.observed_at,
call_count=body.call_count,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.get("/summary", response_model=LegacyUsageSummary)
async def usage_summary(
days: int = Query(7, ge=1, le=366),
window_start: datetime | None = None,
window_end: datetime | None = None,
session: AsyncSession = Depends(get_session),
) -> LegacyUsageSummary:
end = window_end or datetime.now(tz=timezone.utc)
start = window_start or (end - timedelta(days=days))
return await legacy_usage_summary(session, window_start=start, window_end=end)
@router.get("/weekly-review", response_model=LegacyWeeklyReview)
async def weekly_review(
days: int = Query(7, ge=1, le=366),
window_start: datetime | None = None,
window_end: datetime | None = None,
session: AsyncSession = Depends(get_session),
) -> LegacyWeeklyReview:
end = window_end or datetime.now(tz=timezone.utc)
start = window_start or (end - timedelta(days=days))
return await legacy_weekly_review(session, window_start=start, window_end=end)

View File

@@ -1,6 +1,6 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,23 +9,20 @@ from api.models.task import Task
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
from api.routers.workstreams import _legacy_key, _meter_legacy_route
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"])
@router.post(
"/{workstream_id}/dependencies/",
response_model=WorkstreamDependencyRead,
status_code=status.HTTP_201_CREATED,
)
async def create_dependency(
async def _create_dependency(
*,
workstream_id: uuid.UUID,
body: WorkstreamDependencyCreate,
session: AsyncSession = Depends(get_session),
session: AsyncSession,
) -> WorkstreamDependency:
"""Record that workstream_id depends on another workstream or a task."""
if await session.get(Workstream, workstream_id) is None:
raise HTTPException(status_code=404, detail="from workstream not found")
raise HTTPException(status_code=404, detail="from workplan not found")
has_workstream_target = body.to_workstream_id is not None
has_task_target = body.to_task_id is not None
@@ -33,11 +30,11 @@ async def create_dependency(
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
if body.to_workstream_id and await session.get(Workstream, body.to_workstream_id) is None:
raise HTTPException(status_code=404, detail="target workstream not found")
raise HTTPException(status_code=404, detail="target workplan not found")
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
raise HTTPException(status_code=404, detail="target task not found")
if workstream_id == body.to_workstream_id:
raise HTTPException(status_code=422, detail="a workstream cannot depend on itself")
raise HTTPException(status_code=422, detail="a workplan cannot depend on itself")
dep = WorkstreamDependency(
from_workstream_id=workstream_id,
@@ -52,17 +49,13 @@ async def create_dependency(
return dep
@router.get(
"/{workstream_id}/dependencies/",
response_model=list[WorkstreamDependencyRead],
)
async def list_dependencies(
async def _list_dependencies(
*,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
session: AsyncSession,
) -> list[WorkstreamDependency]:
"""Return all dependency edges touching this workstream (both directions)."""
if await session.get(Workstream, workstream_id) is None:
raise HTTPException(status_code=404, detail="workstream not found")
raise HTTPException(status_code=404, detail="workplan not found")
rows = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id == workstream_id)
@@ -72,20 +65,118 @@ async def list_dependencies(
return list(rows.scalars().all())
async def _delete_dependency(
*,
workstream_id: uuid.UUID,
dep_id: uuid.UUID,
session: AsyncSession,
) -> None:
dep = await session.get(WorkstreamDependency, dep_id)
if dep is None:
raise HTTPException(status_code=404, detail="dependency not found")
if dep.from_workstream_id != workstream_id:
raise HTTPException(status_code=403, detail="dependency does not belong to this workplan")
await session.delete(dep)
await session.commit()
@router.post(
"/{workstream_id}/dependencies/",
response_model=WorkstreamDependencyRead,
status_code=status.HTTP_201_CREATED,
)
async def create_dependency(
request: Request,
response: Response,
workstream_id: uuid.UUID,
body: WorkstreamDependencyCreate,
session: AsyncSession = Depends(get_session),
) -> WorkstreamDependency:
"""Record that workstream_id depends on another workstream or a task."""
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"),
replacement_ref="/workplans/{workplan_id}/dependencies/",
)
return await _create_dependency(workstream_id=workstream_id, body=body, session=session)
@workplan_router.post(
"/{workplan_id}/dependencies/",
response_model=WorkstreamDependencyRead,
status_code=status.HTTP_201_CREATED,
)
async def create_workplan_dependency(
workplan_id: uuid.UUID,
body: WorkstreamDependencyCreate,
session: AsyncSession = Depends(get_session),
) -> WorkstreamDependency:
return await _create_dependency(workstream_id=workplan_id, body=body, session=session)
@router.get(
"/{workstream_id}/dependencies/",
response_model=list[WorkstreamDependencyRead],
)
async def list_dependencies(
request: Request,
response: Response,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[WorkstreamDependency]:
"""Return all dependency edges touching this workstream (both directions)."""
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"),
replacement_ref="/workplans/{workplan_id}/dependencies/",
)
return await _list_dependencies(workstream_id=workstream_id, session=session)
@workplan_router.get(
"/{workplan_id}/dependencies/",
response_model=list[WorkstreamDependencyRead],
)
async def list_workplan_dependencies(
workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[WorkstreamDependency]:
return await _list_dependencies(workstream_id=workplan_id, session=session)
@router.delete(
"/{workstream_id}/dependencies/{dep_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_dependency(
request: Request,
response: Response,
workstream_id: uuid.UUID,
dep_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> None:
"""Hard-delete a dependency edge. Removing a constraint is safe — no information is lost."""
dep = await session.get(WorkstreamDependency, dep_id)
if dep is None:
raise HTTPException(status_code=404, detail="dependency not found")
if dep.from_workstream_id != workstream_id:
raise HTTPException(status_code=403, detail="dependency does not belong to this workstream")
await session.delete(dep)
await session.commit()
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"),
replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}",
)
await _delete_dependency(workstream_id=workstream_id, dep_id=dep_id, session=session)
@workplan_router.delete(
"/{workplan_id}/dependencies/{dep_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_workplan_dependency(
workplan_id: uuid.UUID,
dep_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> None:
await _delete_dependency(workstream_id=workplan_id, dep_id=dep_id, session=session)

View File

@@ -1,4 +1,5 @@
import asyncio
import logging
import uuid
import socket
import time
@@ -6,7 +7,7 @@ from pathlib import Path
from typing import Any
import yaml
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,18 +21,30 @@ from api.schemas.workstream import (
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()
@@ -65,15 +78,72 @@ def _frontmatter(path: Path) -> dict[str, Any]:
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),
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:
@@ -82,10 +152,10 @@ async def list_workstreams(
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}'")
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)
@@ -100,12 +170,12 @@ async def list_workstreams(
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),
async def _workplan_index(
*,
refresh: bool,
session: AsyncSession,
) -> dict[str, Any]:
"""Map file-backed workstream ids to their local workplan filenames."""
"""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
@@ -148,15 +218,15 @@ async def workplan_index(
"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 = {"workplans": index, "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(
async def _create_workstream(
*,
body: WorkstreamCreate,
session: AsyncSession = Depends(get_session),
session: AsyncSession,
) -> Workstream:
ws = Workstream(**body.model_dump())
session.add(ws)
@@ -165,26 +235,26 @@ async def create_workstream(
return ws
@router.get("/{workstream_id}", response_model=WorkstreamRead)
async def get_workstream(
async def _get_workstream(
*,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
session: AsyncSession,
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
raise HTTPException(status_code=404, detail="Workplan not found")
return ws
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
async def update_workstream(
async def _update_workstream(
*,
workstream_id: uuid.UUID,
body: WorkstreamUpdate,
session: AsyncSession = Depends(get_session),
session: AsyncSession,
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
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
@@ -196,32 +266,232 @@ async def update_workstream(
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))
await _publish_completion_events(ws, session)
return ws
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
async def archive_workstream(
async def _archive_workstream(
*,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
session: AsyncSession,
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
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)

106
api/schemas/legacy_meter.py Normal file
View File

@@ -0,0 +1,106 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
LegacyInterfaceKind = Literal[
"rest_api",
"mcp_tool",
"procedure_call",
"event_subject",
"cli",
"dashboard_route",
"schema_field",
]
LegacyInterfaceStatus = Literal["legacy", "retirement_candidate", "retired"]
class LegacyInterfaceRegister(BaseModel):
interface_key: str
interface_kind: LegacyInterfaceKind = "rest_api"
replacement_ref: str
owner_component: str = "state-hub"
replacement_verified: bool = False
manual_hold: bool = False
hold_reason: str | None = None
notes: str | None = None
class LegacyInterfacePatch(BaseModel):
replacement_ref: str | None = None
owner_component: str | None = None
status: LegacyInterfaceStatus | None = None
replacement_verified: bool | None = None
manual_hold: bool | None = None
hold_reason: str | None = None
notes: str | None = None
class LegacyUsageRecord(BaseModel):
interface_key: str
interface_kind: LegacyInterfaceKind = "rest_api"
replacement_ref: str
owner_component: str = "state-hub"
replacement_verified: bool = False
tenant_key: str | None = None
user_key: str | None = None
component_key: str | None = None
observed_at: datetime | None = None
call_count: int = Field(default=1, ge=1)
class LegacyInterfaceRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
interface_key: str
interface_kind: str
legacy_since: datetime
replacement_ref: str
owner_component: str
status: str
replacement_verified: bool
manual_hold: bool
hold_reason: str | None = None
notes: str | None = None
retired_at: datetime | None = None
created_at: datetime
updated_at: datetime
class LegacyUsageCounters(BaseModel):
calls: int = 0
tenant_count: int = 0
user_count: int = 0
component_count: int = 0
tenants: dict[str, int] = Field(default_factory=dict)
users: dict[str, int] = Field(default_factory=dict)
components: dict[str, int] = Field(default_factory=dict)
class LegacyInterfaceSummary(BaseModel):
interface: LegacyInterfaceRead
all_time: LegacyUsageCounters
window: LegacyUsageCounters
last_seen_at: datetime | None = None
retirement_candidate: bool
retirement_reason: str
class LegacyUsageSummary(BaseModel):
generated_at: datetime
window_start: datetime
window_end: datetime
interfaces: list[LegacyInterfaceSummary]
class LegacyWeeklyReview(BaseModel):
generated_at: datetime
window_start: datetime
window_end: datetime
cadence: str = "weekly"
activity_core_handoff: dict[str, str]
interfaces: list[LegacyInterfaceSummary]
retirement_candidates: list[LegacyInterfaceSummary]

View File

@@ -0,0 +1,392 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
from api.schemas.legacy_meter import (
LegacyInterfaceRead,
LegacyInterfaceSummary,
LegacyUsageCounters,
LegacyUsageSummary,
LegacyWeeklyReview,
)
UNKNOWN_BUCKET = "unknown"
VALID_INTERFACE_KINDS = {
"rest_api",
"mcp_tool",
"procedure_call",
"event_subject",
"cli",
"dashboard_route",
"schema_field",
}
VALID_INTERFACE_STATUSES = {"legacy", "retirement_candidate", "retired"}
@dataclass(frozen=True)
class LegacyUsageIdentity:
tenant_key: str = UNKNOWN_BUCKET
user_key: str = UNKNOWN_BUCKET
component_key: str = UNKNOWN_BUCKET
def identity_from_request(request: Request | None) -> LegacyUsageIdentity:
if request is None:
return LegacyUsageIdentity()
headers = request.headers
return LegacyUsageIdentity(
tenant_key=_clean_bucket(headers.get("x-statehub-tenant") or headers.get("x-tenant-id")),
user_key=_clean_bucket(headers.get("x-statehub-user") or headers.get("x-user-id")),
component_key=_clean_bucket(headers.get("x-statehub-component") or headers.get("x-component")),
)
async def register_legacy_interface(
session: AsyncSession,
*,
interface_key: str,
interface_kind: str,
replacement_ref: str,
owner_component: str = "state-hub",
replacement_verified: bool = False,
manual_hold: bool = False,
hold_reason: str | None = None,
notes: str | None = None,
status: str = "legacy",
commit: bool = True,
preserve_controls: bool = False,
) -> LegacyInterface:
interface_key = _required(interface_key, "interface_key")
replacement_ref = _required(replacement_ref, "replacement_ref")
interface_kind = _validate_kind(interface_kind)
status = _validate_status(status)
existing = await _get_by_key(session, interface_key)
if existing is None:
existing = LegacyInterface(
interface_key=interface_key,
interface_kind=interface_kind,
replacement_ref=replacement_ref,
owner_component=_clean_value(owner_component, "state-hub"),
replacement_verified=replacement_verified,
manual_hold=manual_hold,
hold_reason=hold_reason,
notes=notes,
status=status,
)
session.add(existing)
else:
existing.interface_kind = interface_kind
existing.replacement_ref = replacement_ref
existing.owner_component = _clean_value(owner_component, "state-hub")
existing.replacement_verified = existing.replacement_verified or replacement_verified
if not preserve_controls:
existing.manual_hold = manual_hold
existing.hold_reason = hold_reason
existing.notes = notes if notes is not None else existing.notes
existing.status = status
if commit:
await session.commit()
await session.refresh(existing)
else:
await session.flush()
return existing
async def patch_legacy_interface(
session: AsyncSession,
interface: LegacyInterface,
updates: dict[str, Any],
) -> LegacyInterface:
if "interface_kind" in updates:
updates["interface_kind"] = _validate_kind(updates["interface_kind"])
if "status" in updates and updates["status"] is not None:
updates["status"] = _validate_status(updates["status"])
if updates["status"] == "retired" and interface.retired_at is None:
interface.retired_at = datetime.now(tz=timezone.utc)
for field, value in updates.items():
if value is not None or field in {"manual_hold", "replacement_verified", "hold_reason", "notes"}:
setattr(interface, field, value)
await session.commit()
await session.refresh(interface)
return interface
async def record_legacy_usage(
session: AsyncSession,
*,
interface_key: str,
interface_kind: str,
replacement_ref: str,
identity: LegacyUsageIdentity | None = None,
owner_component: str = "state-hub",
replacement_verified: bool = False,
observed_at: datetime | None = None,
call_count: int = 1,
commit: bool = True,
) -> LegacyInterface:
if call_count < 1:
raise ValueError("call_count must be >= 1")
observed_at = observed_at or datetime.now(tz=timezone.utc)
if observed_at.tzinfo is None:
observed_at = observed_at.replace(tzinfo=timezone.utc)
identity = identity or LegacyUsageIdentity()
interface = await register_legacy_interface(
session,
interface_key=interface_key,
interface_kind=interface_kind,
replacement_ref=replacement_ref,
owner_component=owner_component,
replacement_verified=replacement_verified,
preserve_controls=True,
commit=False,
)
for bucket_kind, bucket_key in (
("call", "total"),
("tenant", identity.tenant_key),
("user", identity.user_key),
("component", identity.component_key),
):
await _increment_bucket(
session,
interface,
period_start=observed_at.date(),
bucket_kind=bucket_kind,
bucket_key=_clean_bucket(bucket_key),
observed_at=observed_at,
call_count=call_count,
)
if commit:
await session.commit()
await session.refresh(interface)
else:
await session.flush()
return interface
async def get_legacy_interface_by_key(
session: AsyncSession,
interface_key: str,
) -> LegacyInterface | None:
return await _get_by_key(session, interface_key)
async def list_legacy_interfaces(session: AsyncSession) -> list[LegacyInterface]:
result = await session.execute(
select(LegacyInterface).order_by(LegacyInterface.interface_kind, LegacyInterface.interface_key)
)
return list(result.scalars().all())
async def legacy_usage_summary(
session: AsyncSession,
*,
window_start: datetime | None = None,
window_end: datetime | None = None,
) -> LegacyUsageSummary:
window_end = _ensure_datetime(window_end) or datetime.now(tz=timezone.utc)
window_start = _ensure_datetime(window_start) or (window_end - timedelta(days=7))
interfaces = await list_legacy_interfaces(session)
buckets = await _usage_buckets(session)
by_interface: dict[Any, list[LegacyInterfaceUsageBucket]] = {}
for bucket in buckets:
by_interface.setdefault(bucket.legacy_interface_id, []).append(bucket)
summaries = [
_summarize_interface(interface, by_interface.get(interface.id, []), window_start, window_end)
for interface in interfaces
]
return LegacyUsageSummary(
generated_at=datetime.now(tz=timezone.utc),
window_start=window_start,
window_end=window_end,
interfaces=summaries,
)
async def legacy_weekly_review(
session: AsyncSession,
*,
window_start: datetime | None = None,
window_end: datetime | None = None,
) -> LegacyWeeklyReview:
summary = await legacy_usage_summary(
session,
window_start=window_start,
window_end=window_end,
)
candidates = [item for item in summary.interfaces if item.retirement_candidate]
return LegacyWeeklyReview(
generated_at=summary.generated_at,
window_start=summary.window_start,
window_end=summary.window_end,
activity_core_handoff={
"activity_id": "statehub-legacy-interface-review",
"cadence": "weekly",
"source_endpoint": "/legacy-meter/weekly-review",
"state_owner": "state-hub",
"scheduler_owner": "activity-core",
},
interfaces=summary.interfaces,
retirement_candidates=candidates,
)
async def _increment_bucket(
session: AsyncSession,
interface: LegacyInterface,
*,
period_start: date,
bucket_kind: str,
bucket_key: str,
observed_at: datetime,
call_count: int,
) -> LegacyInterfaceUsageBucket:
result = await session.execute(
select(LegacyInterfaceUsageBucket).where(
LegacyInterfaceUsageBucket.legacy_interface_id == interface.id,
LegacyInterfaceUsageBucket.period_start == period_start,
LegacyInterfaceUsageBucket.bucket_kind == bucket_kind,
LegacyInterfaceUsageBucket.bucket_key == bucket_key,
)
)
bucket = result.scalar_one_or_none()
if bucket is None:
bucket = LegacyInterfaceUsageBucket(
legacy_interface_id=interface.id,
period_start=period_start,
bucket_kind=bucket_kind,
bucket_key=bucket_key,
call_count=call_count,
first_seen_at=observed_at,
last_seen_at=observed_at,
)
session.add(bucket)
return bucket
bucket.call_count += call_count
if observed_at < bucket.first_seen_at:
bucket.first_seen_at = observed_at
if observed_at > bucket.last_seen_at:
bucket.last_seen_at = observed_at
return bucket
async def _get_by_key(session: AsyncSession, interface_key: str) -> LegacyInterface | None:
result = await session.execute(
select(LegacyInterface).where(LegacyInterface.interface_key == interface_key)
)
return result.scalar_one_or_none()
async def _usage_buckets(session: AsyncSession) -> list[LegacyInterfaceUsageBucket]:
result = await session.execute(select(LegacyInterfaceUsageBucket))
return list(result.scalars().all())
def _summarize_interface(
interface: LegacyInterface,
buckets: list[LegacyInterfaceUsageBucket],
window_start: datetime,
window_end: datetime,
) -> LegacyInterfaceSummary:
all_time = _counters(buckets)
window_buckets = [
bucket for bucket in buckets
if bucket.last_seen_at >= window_start and bucket.first_seen_at < window_end
]
window = _counters(window_buckets)
last_seen = max((bucket.last_seen_at for bucket in buckets), default=None)
retirement_candidate, reason = _retirement_state(interface, window.calls)
return LegacyInterfaceSummary(
interface=LegacyInterfaceRead.model_validate(interface),
all_time=all_time,
window=window,
last_seen_at=last_seen,
retirement_candidate=retirement_candidate,
retirement_reason=reason,
)
def _counters(buckets: list[LegacyInterfaceUsageBucket]) -> LegacyUsageCounters:
calls = sum(bucket.call_count for bucket in buckets if bucket.bucket_kind == "call")
tenants = _bucket_counts(buckets, "tenant")
users = _bucket_counts(buckets, "user")
components = _bucket_counts(buckets, "component")
return LegacyUsageCounters(
calls=calls,
tenant_count=len(tenants),
user_count=len(users),
component_count=len(components),
tenants=tenants,
users=users,
components=components,
)
def _bucket_counts(buckets: list[LegacyInterfaceUsageBucket], bucket_kind: str) -> dict[str, int]:
counts: dict[str, int] = {}
for bucket in buckets:
if bucket.bucket_kind == bucket_kind:
counts[bucket.bucket_key] = counts.get(bucket.bucket_key, 0) + bucket.call_count
return counts
def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bool, str]:
if interface.status == "retired":
return False, "already retired"
if interface.manual_hold:
return False, interface.hold_reason or "manual hold"
if not interface.replacement_ref.strip():
return False, "missing replacement reference"
if not interface.replacement_verified:
return False, "replacement not verified"
if window_calls > 0:
return False, f"{window_calls} call(s) in review window"
return True, "no measured usage in review window"
def _ensure_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _required(value: str | None, field: str) -> str:
cleaned = _clean_value(value)
if not cleaned:
raise ValueError(f"{field} is required")
return cleaned
def _clean_value(value: str | None, default: str = "") -> str:
return str(value or default).strip()
def _clean_bucket(value: str | None) -> str:
cleaned = _clean_value(value)
return cleaned or UNKNOWN_BUCKET
def _validate_kind(kind: str) -> str:
cleaned = _required(kind, "interface_kind")
if cleaned not in VALID_INTERFACE_KINDS:
raise ValueError(f"interface_kind must be one of {sorted(VALID_INTERFACE_KINDS)}")
return cleaned
def _validate_status(status: str) -> str:
cleaned = _required(status, "status")
if cleaned not in VALID_INTERFACE_STATUSES:
raise ValueError(f"status must be one of {sorted(VALID_INTERFACE_STATUSES)}")
return cleaned