STATE-WP-0069 T04: meter workstream_id query-param aliases
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s

Add legacy_compat helper and Deprecation headers when callers filter
/tasks, /decisions, /token-events, or /execution/launch-requests with
workstream_id. Preferred workplan_id filters are unchanged. Route removal
remains gated on legacy-meter zero-usage windows.
This commit is contained in:
tegwick 2026-07-08 21:32:14 +02:00
parent b2b72b7327
commit 793a39a1a4
8 changed files with 181 additions and 15 deletions

View file

@ -4,7 +4,7 @@ import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
logger = logging.getLogger(__name__)
from sqlalchemy import select
@ -15,6 +15,7 @@ from api.events import EventEnvelope, publish_event
from api.models.decision import Decision, DecisionStatus, DecisionType
from api.models.progress_event import ProgressEvent
from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate
from api.services.legacy_compat import meter_legacy_query_param
router = APIRouter(prefix="/decisions", tags=["decisions"])
@ -39,6 +40,8 @@ def _needs_escalation(body: DecisionCreate) -> str | None:
@router.get("/", response_model=list[DecisionRead])
async def list_decisions(
request: Request,
response: Response,
topic_id: uuid.UUID | None = None,
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
@ -46,6 +49,15 @@ async def list_decisions(
decision_type: DecisionType | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Decision]:
if workstream_id is not None and workplan_id is None:
await meter_legacy_query_param(
session=session,
request=request,
response=response,
method="GET",
route="/decisions/",
replacement_ref="/decisions/?workplan_id=<workplan_id>",
)
q = select(Decision)
if topic_id:
q = q.where(Decision.topic_id == topic_id)

View file

@ -28,6 +28,7 @@ from api.services.execution_queue import (
workplan_blockers,
)
from api.routers.workstreams import _legacy_key, _meter_legacy_route
from api.services.legacy_compat import meter_legacy_query_param
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
router = APIRouter(prefix="/execution", tags=["execution"])
@ -193,13 +194,26 @@ async def create_launch_request(
@router.get("/launch-requests", response_model=list[LaunchRequestRead])
async def list_launch_requests(
request: Request,
response: Response,
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
request_status: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[WorkplanLaunchRequest]:
if workstream_id is not None and workplan_id is None:
await meter_legacy_query_param(
session=session,
request=request,
response=response,
method="GET",
route="/execution/launch-requests",
replacement_ref="/execution/launch-requests?workplan_id=<workplan_id>",
)
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
if workstream_id:
q = q.where(WorkplanLaunchRequest.workplan_id == workstream_id)
scope_id = workplan_id or workstream_id
if scope_id:
q = q.where(WorkplanLaunchRequest.workplan_id == scope_id)
if request_status:
q = q.where(WorkplanLaunchRequest.status == request_status)
result = await session.execute(q)

View file

@ -1,7 +1,7 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -18,6 +18,7 @@ from api.schemas.task import (
TaskStatusBulkSyncRead,
TaskUpdate,
)
from api.services.legacy_compat import meter_legacy_query_param
from api.services.lifecycle import status_value, transition_task_status
from api.task_status import normalize_task_status
@ -26,6 +27,8 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("/", response_model=list[TaskRead])
async def list_tasks(
request: Request,
response: Response,
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
status: str | None = None,
@ -37,6 +40,15 @@ async def list_tasks(
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
) -> list[Task]:
if workstream_id is not None and workplan_id is None:
await meter_legacy_query_param(
session=session,
request=request,
response=response,
method="GET",
route="/tasks/",
replacement_ref="/tasks/?workplan_id=<workplan_id>",
)
q = select(Task)
scope_id = workplan_id or workstream_id
if scope_id:
@ -62,11 +74,22 @@ async def list_tasks(
@router.get("/counts", response_model=list[TaskCountRead])
async def count_tasks(
request: Request,
response: Response,
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
status: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[TaskCountRead]:
if workstream_id is not None and workplan_id is None:
await meter_legacy_query_param(
session=session,
request=request,
response=response,
method="GET",
route="/tasks/counts",
replacement_ref="/tasks/counts?workplan_id=<workplan_id>",
)
q = select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
scope_id = workplan_id or workstream_id
if scope_id:

View file

@ -3,7 +3,7 @@ from collections import defaultdict
from datetime import datetime
from typing import Any
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
@ -12,6 +12,7 @@ from api.models.managed_repo import ManagedRepo
from api.models.task import Task
from api.models.token_event import TokenEvent
from api.models.workplan import Workplan
from api.services.legacy_compat import meter_legacy_query_param
from api.schemas.token_event import (
RepoTokenSummary,
TokenAggregateRow,
@ -152,6 +153,7 @@ def _filter_query(
q,
*,
task_id: uuid.UUID | None = None,
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
ref_type: str | None = None,
@ -168,8 +170,9 @@ def _filter_query(
):
if task_id:
q = q.where(TokenEvent.task_id == task_id)
if workstream_id:
q = q.where(TokenEvent.workplan_id == workstream_id)
scope_id = workplan_id or workstream_id
if scope_id:
q = q.where(TokenEvent.workplan_id == scope_id)
if repo_id:
q = q.where(TokenEvent.repo_id == repo_id)
if ref_type:
@ -600,7 +603,10 @@ async def get_token_event(
@router.get("/", response_model=list[TokenEventRead])
async def list_token_events(
request: Request,
response: Response,
task_id: uuid.UUID | None = None,
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
ref_type: str | None = None,
@ -618,9 +624,19 @@ async def list_token_events(
limit: int = Query(100, le=1000),
session: AsyncSession = Depends(get_session),
) -> list[TokenEvent]:
if workstream_id is not None and workplan_id is None:
await meter_legacy_query_param(
session=session,
request=request,
response=response,
method="GET",
route="/token-events/",
replacement_ref="/token-events/?workplan_id=<workplan_id>",
)
q = _filter_query(
select(TokenEvent),
task_id=task_id,
workplan_id=workplan_id,
workstream_id=workstream_id,
repo_id=repo_id,
ref_type=ref_type,

View file

@ -0,0 +1,52 @@
"""Shared legacy REST compat metering (routes and query params)."""
from __future__ import annotations
import logging
from fastapi import Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from api.services.legacy_meter import identity_from_request, record_legacy_usage
logger = logging.getLogger(__name__)
_LEGACY_OWNER = "state-hub.api"
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"')
def legacy_query_param_key(method: str, route: str, param: str = "workstream_id") -> str:
return f"rest_api:{method} {route}?{param}"
async def meter_legacy_query_param(
*,
session: AsyncSession,
request: Request | None,
response: Response | None,
method: str,
route: str,
replacement_ref: str,
param: str = "workstream_id",
) -> None:
interface_key = legacy_query_param_key(method, route, param)
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)