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

@@ -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)