diff --git a/AGENTS.md b/AGENTS.md index 7696e29..a1c432b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ there is no MCP server for Codex agents. # Offline brief — works without hub connection cat .custodian-brief.md -# Active workstreams for this domain -curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \ +# Active workplans for this domain +curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \ | python3 -m json.tool # Check inbox @@ -80,7 +80,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ ## Session Protocol **Start:** -1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe) +1. `cat .custodian-brief.md` — domain goal and open workplans (offline-safe) 2. Check inbox: `GET /messages/?to_agent=state-hub&unread_only=true`; mark read 3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks 4. Check human-needed tasks: `GET /tasks/?needs_human=true` @@ -135,6 +135,10 @@ state_hub_workstream_id: "" # written by fix-consistency — do not edit --- ``` +`state_hub_workstream_id` is the legacy bridge field for the current State Hub +database identity. Prefer workplan-named API routes for new client code while +this bridge field remains in compatibility use. + Use `proposed` for a new draft, `ready` after review against current repo state, and `finished` after implementation. `stalled` and `needs_review` are derived health labels, not frontmatter statuses. diff --git a/README.md b/README.md index e795406..5112c32 100644 --- a/README.md +++ b/README.md @@ -186,13 +186,18 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar | Prefix | Operations | |--------|-----------| | `/topics` | CRUD (soft-delete: `archived`) | -| `/workstreams` | CRUD (soft-delete: `archived`) | +| `/workplans` | Preferred CRUD surface for repo-backed workplans (soft-delete: `archived`) | +| `/workstreams` | Legacy compatibility CRUD surface; usage is recorded by legacy-meter | | `/tasks` | CRUD (soft-delete: `cancel`); `PATCH` updates status | | `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation | | `/progress` | `GET` list + `POST` append — no DELETE | +| `/legacy-meter` | Register, meter, and review legacy interface usage | | `/state/summary` | Full snapshot | | `/state/health` | DB connectivity check | +See `docs/workplan-terminology-transition.md` for the workstream-to-workplan +compatibility policy and retirement criteria. + --- ## MCP Server diff --git a/api/main.py b/api/main.py index 7174f46..552e68a 100644 --- a/api/main.py +++ b/api/main.py @@ -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) diff --git a/api/models/__init__.py b/api/models/__init__.py index 84a103a..57b465b 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -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", ] diff --git a/api/models/legacy_meter.py b/api/models/legacy_meter.py new file mode 100644 index 0000000..68abea8 --- /dev/null +++ b/api/models/legacy_meter.py @@ -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" + ) diff --git a/api/routers/execution.py b/api/routers/execution.py index ed2eb4c..d5da137 100644 --- a/api/routers/execution.py +++ b/api/routers/execution.py @@ -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), diff --git a/api/routers/legacy_meter.py b/api/routers/legacy_meter.py new file mode 100644 index 0000000..1e4aca1 --- /dev/null +++ b/api/routers/legacy_meter.py @@ -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) diff --git a/api/routers/workstream_dependencies.py b/api/routers/workstream_dependencies.py index 6821f6d..0d553d1 100644 --- a/api/routers/workstream_dependencies.py +++ b/api/routers/workstream_dependencies.py @@ -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) diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index cfb1f87..6a1e24f 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -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) diff --git a/api/schemas/legacy_meter.py b/api/schemas/legacy_meter.py new file mode 100644 index 0000000..487fc21 --- /dev/null +++ b/api/schemas/legacy_meter.py @@ -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] diff --git a/api/services/legacy_meter.py b/api/services/legacy_meter.py new file mode 100644 index 0000000..5528443 --- /dev/null +++ b/api/services/legacy_meter.py @@ -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 diff --git a/dashboard/src/components/config.js b/dashboard/src/components/config.js index d8f3c2d..3982cd8 100644 --- a/dashboard/src/components/config.js +++ b/dashboard/src/components/config.js @@ -1,4 +1,65 @@ -export const API = "http://127.0.0.1:8000"; +export const DEFAULT_API = "http://127.0.0.1:8000"; +export const API_STORAGE_KEY = "stateHubApiBase"; +const API_QUERY_PARAMS = ["api_base", "apiBase"]; + +function cleanApiBase(value) { + if (typeof value !== "string") return null; + const cleaned = value.trim().replace(/\/+$/, ""); + return cleaned || null; +} + +function getStorageApiBase(storage) { + if (!storage?.getItem) return null; + try { + return cleanApiBase(storage.getItem(API_STORAGE_KEY)); + } catch { + return null; + } +} + +function urlFromLocation(location) { + if (!location) return null; + try { + return new URL(location.href ?? String(location)); + } catch { + return null; + } +} + +function getQueryApiBase(url) { + if (!url) return null; + for (const name of API_QUERY_PARAMS) { + const value = cleanApiBase(url.searchParams.get(name)); + if (value) return value; + } + return null; +} + +function inferApiBase(url) { + if (!url || !["http:", "https:"].includes(url.protocol)) return DEFAULT_API; + if (url.hostname === "::1" || url.hostname === "[::1]") return DEFAULT_API; + const apiUrl = new URL(url.href); + apiUrl.port = globalThis.STATE_HUB_API_PORT || "8000"; + apiUrl.pathname = ""; + apiUrl.search = ""; + apiUrl.hash = ""; + return apiUrl.origin; +} + +export function resolveApiBase({ + location = globalThis.location, + storage = globalThis.localStorage, +} = {}) { + const url = urlFromLocation(location); + return ( + getQueryApiBase(url) + || cleanApiBase(globalThis.STATE_HUB_API_BASE) + || getStorageApiBase(storage) + || inferApiBase(url) + ); +} + +export const API = resolveApiBase(); export const POLL = 15_000; export const POLL_HEAVY = 60_000; export const FETCH_TIMEOUT = 12_000; diff --git a/dashboard/src/components/field-help.js b/dashboard/src/components/field-help.js index 04e45a5..96e412e 100644 --- a/dashboard/src/components/field-help.js +++ b/dashboard/src/components/field-help.js @@ -26,7 +26,7 @@ const FIELD_LINKS = { getTitle: d => d.title, }, workstream_id: { - apiUrl: id => `${API}/workstreams/${id}`, + apiUrl: id => `${API}/workplans/${id}`, getUrl: (id, _d) => `/workstreams/${id}`, getTitle: d => d.title || d.slug, }, diff --git a/dashboard/src/dependencies.md b/dashboard/src/dependencies.md index ca26cd0..4553fbb 100644 --- a/dashboard/src/dependencies.md +++ b/dashboard/src/dependencies.md @@ -16,7 +16,7 @@ const depState = (async function*() { let wsMap = {}, edges = [], ok = false; try { const [rw, rto, rr, rd] = await Promise.all([ - apiFetch("/workstreams/"), + apiFetch("/workplans/"), apiFetch("/topics/"), apiFetch("/repos/"), apiFetch("/state/deps"), diff --git a/dashboard/src/docs/dashboard.md b/dashboard/src/docs/dashboard.md index 9bc15e8..6441df8 100644 --- a/dashboard/src/docs/dashboard.md +++ b/dashboard/src/docs/dashboard.md @@ -63,7 +63,7 @@ Current loaders: | File | API endpoint | |---|---| | `summary.json.py` | `/state/summary` | -| `workstreams.json.py` | `/workstreams/` | +| `workstreams.json.py` | `/workplans/` | | `contributions.json.py` | `/contributions/` | | `decisions.json.py` | `/decisions/` | | `domains.json.py` | `/domains/` | @@ -144,7 +144,7 @@ The dashboard has 30+ pages organised in four navigation groups: | Page | Route | Purpose | |---|---|---| -| Workstreams | `/workstreams` | All workstreams with Workstream Health Index | +| Workplans | `/workstreams` | All workplans with Workplan Health Index; route name remains compatibility-backed | | Decisions | `/decisions` | Decision log with resolve-in-place form | | Dependencies | `/dependencies` | Dependency graph explorer | | Extensions | `/extensions` | Extension point registry | @@ -163,8 +163,11 @@ The dashboard has 30+ pages organised in four navigation groups: All shared components live in `src/components/` and are imported as ES modules: ### `config.js` -Exports two constants used by every live-polling page: -- `API = "http://127.0.0.1:8000"` — the FastAPI base URL +Exports shared runtime configuration used by every live-polling page: +- `API` — the FastAPI base URL. It defaults to `http://127.0.0.1:8000` + outside the browser, derives from the dashboard host in browser sessions, and + can be overridden with `?api_base=...`, `globalThis.STATE_HUB_API_BASE`, or + `localStorage.stateHubApiBase`. - `POLL = 15_000` — polling interval in milliseconds ### `entity-modal.js` diff --git a/dashboard/src/docs/dependencies.md b/dashboard/src/docs/dependencies.md index 5785902..84a6fad 100644 --- a/dashboard/src/docs/dependencies.md +++ b/dashboard/src/docs/dependencies.md @@ -62,7 +62,7 @@ create_dependency( Via REST: ```bash -curl -X POST http://127.0.0.1:8000/workstreams//dependencies/ \ +curl -X POST http://127.0.0.1:8000/workplans//dependencies/ \ -H "Content-Type: application/json" \ -d '{"to_workstream_id": "", "description": "..."}' ``` diff --git a/dashboard/src/docs/goals.md b/dashboard/src/docs/goals.md index a049cf1..0523275 100644 --- a/dashboard/src/docs/goals.md +++ b/dashboard/src/docs/goals.md @@ -127,7 +127,7 @@ The Goals page groups everything by domain: Workstreams carry an optional `repo_goal_id` field. Setting it traces *why* a workstream exists — which specific repo goal it contributes to. This connection is currently recorded in the DB but is not yet visualised in the Workstreams page. -To set the link when creating a workstream via `create_workstream`, pass `repo_goal_id`. To update an existing one, use `PATCH /workstreams/{id}/` with `{"repo_goal_id": ""}`. +To set the link when creating a workplan through the preferred API, pass `repo_goal_id`. To update an existing one, use `PATCH /workplans/{id}/` with `{"repo_goal_id": ""}`. Legacy `create_workstream` and `/workstreams/{id}/` callers remain compatibility-supported while they are metered. --- diff --git a/dashboard/src/docs/live-data.md b/dashboard/src/docs/live-data.md index d7cbecf..b890129 100644 --- a/dashboard/src/docs/live-data.md +++ b/dashboard/src/docs/live-data.md @@ -49,7 +49,7 @@ make api # db + migrate + uvicorn (restarts if already running) | Page | Endpoints | |---|---| | Overview | `/state/summary` | -| Workstreams | `/workstreams/`, `/topics/`, `/state/summary` | +| Workplans | `/workplans/`, `/topics/`, `/state/summary` | | Decisions | `/decisions/?limit=500`, `/topics/` | | Progress | `/progress/?limit=500` | diff --git a/dashboard/src/docs/overview.md b/dashboard/src/docs/overview.md index 9b455bf..c7329c3 100644 --- a/dashboard/src/docs/overview.md +++ b/dashboard/src/docs/overview.md @@ -83,8 +83,8 @@ and summary. ## Data source Polls `GET /state/summary` every **15 seconds**. The workstream chart also polls -`GET /workstreams/`, `GET /tasks/?limit=2000`, `GET /topics/`, `GET /repos/`, -and `GET /workstreams/workplan-index` for repository grouping, task counts, and +`GET /workplans/`, `GET /tasks/?limit=2000`, `GET /topics/`, `GET /repos/`, +and `GET /workplans/index` for repository grouping, task counts, and workplan filename tooltips. Blocking decisions are fetched separately via `GET /decisions/?decision_type=pending` and only re-fetched after a successful resolve action — this prevents the inline form from being wiped on every poll. diff --git a/dashboard/src/docs/tasks.md b/dashboard/src/docs/tasks.md index e4dad59..6b6b4fc 100644 --- a/dashboard/src/docs/tasks.md +++ b/dashboard/src/docs/tasks.md @@ -95,5 +95,5 @@ status group. Polls every **15 seconds**: - `GET /tasks/?limit=500` -- `GET /workstreams/` +- `GET /workplans/` - `GET /topics/` diff --git a/dashboard/src/docs/todo.md b/dashboard/src/docs/todo.md index 4c8cfaf..49986ee 100644 --- a/dashboard/src/docs/todo.md +++ b/dashboard/src/docs/todo.md @@ -64,7 +64,7 @@ to this reference page. Polls every **15 seconds**: - `GET /tasks/?limit=500` — all tasks -- `GET /workstreams/` — for domain + title context +- `GET /workplans/` — for domain + title context - `GET /topics/` — for domain slug resolution - `GET /contributions/` — for third-party todos diff --git a/dashboard/src/docs/workstream-lifecycle.md b/dashboard/src/docs/workstream-lifecycle.md index ab88925..89bf4b6 100644 --- a/dashboard/src/docs/workstream-lifecycle.md +++ b/dashboard/src/docs/workstream-lifecycle.md @@ -77,7 +77,7 @@ advance_workstation(entity_type="workstream", entity_id="", target_worksta Direct status patching still exists for bootstrap and compatibility work: ```bash -curl -X PATCH http://127.0.0.1:8000/workstreams// \ +curl -X PATCH http://127.0.0.1:8000/workplans// \ -H "Content-Type: application/json" \ -d '{"status": "finished"}' ``` diff --git a/dashboard/src/docs/workstreams.md b/dashboard/src/docs/workstreams.md index eb65168..fdfd8b6 100644 --- a/dashboard/src/docs/workstreams.md +++ b/dashboard/src/docs/workstreams.md @@ -108,7 +108,7 @@ create_workstream( Via REST: ```bash -curl -X POST http://127.0.0.1:8000/workstreams/ \ +curl -X POST http://127.0.0.1:8000/workplans/ \ -H "Content-Type: application/json" \ -d '{"topic_id": "", "title": "…", "status": "ready"}' ``` diff --git a/dashboard/src/docs/wsjf-triage.md b/dashboard/src/docs/wsjf-triage.md index 88a9326..3d35bec 100644 --- a/dashboard/src/docs/wsjf-triage.md +++ b/dashboard/src/docs/wsjf-triage.md @@ -16,7 +16,7 @@ GET /progress/?event_type=daily_triage&limit=14 Each event carries the report under `detail.report`, with a summary and a list of recommendations. Candidate values are resolved through -`/workstreams/workplan-index` so file-backed workplans can link to their +`/workplans/index` so file-backed workplans can link to their workstream detail pages. ## How to read recommendations diff --git a/dashboard/src/index.md b/dashboard/src/index.md index c5bd66d..aa4d069 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -39,11 +39,11 @@ const pageState = (async function*() { loadJson("summary", "/state/summary", {timeout: 20_000}), loadJson("sbom snapshots", "/sbom/snapshots/"), loadJson("milestones", "/progress/?event_type=milestone&limit=500"), - loadJson("workstreams", "/workstreams/"), + loadJson("workplans", "/workplans/"), loadJson("tasks", "/tasks/?limit=2000"), loadJson("topics", "/topics/"), loadJson("repos", "/repos/"), - loadJson("workplan index", "/workstreams/workplan-index").catch(() => ({workstreams: {}})), + loadJson("workplan index", "/workplans/index").catch(() => ({workplans: {}, workstreams: {}})), ]); ok = true; diff --git a/dashboard/src/interventions.md b/dashboard/src/interventions.md index 496a141..27d7771 100644 --- a/dashboard/src/interventions.md +++ b/dashboard/src/interventions.md @@ -15,7 +15,7 @@ const interventionState = (async function*() { try { const [rt, rw, rto, rr] = await Promise.all([ apiFetch("/tasks/?limit=500"), - apiFetch("/workstreams/"), + apiFetch("/workplans/"), apiFetch("/topics/"), apiFetch("/repos/"), ]); diff --git a/dashboard/src/repos.md b/dashboard/src/repos.md index 25f23e9..1d82e36 100644 --- a/dashboard/src/repos.md +++ b/dashboard/src/repos.md @@ -16,7 +16,7 @@ try { fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []), fetch(`${API}/extension-points/`).then(r => r.ok ? r.json() : []), fetch(`${API}/technical-debt/`).then(r => r.ok ? r.json() : []), - fetch(`${API}/workstreams/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/workplans/`).then(r => r.ok ? r.json() : []), ]); } catch {} ``` diff --git a/dashboard/src/tasks.md b/dashboard/src/tasks.md index 0965604..ae22608 100644 --- a/dashboard/src/tasks.md +++ b/dashboard/src/tasks.md @@ -14,7 +14,7 @@ const taskState = (async function*() { try { const [rt, rw, rto, rr] = await Promise.all([ apiFetch("/tasks/?limit=500"), - apiFetch("/workstreams/"), + apiFetch("/workplans/"), apiFetch("/topics/"), apiFetch("/repos/"), ]); diff --git a/dashboard/src/techdept.md b/dashboard/src/techdept.md index 4c41c70..103b6b8 100644 --- a/dashboard/src/techdept.md +++ b/dashboard/src/techdept.md @@ -14,7 +14,7 @@ const tdState = (async function*() { try { const [rt, rw, rto, rr] = await Promise.all([ apiFetch("/technical-debt/"), - apiFetch("/workstreams/"), + apiFetch("/workplans/"), apiFetch("/topics/"), apiFetch("/repos/"), ]); diff --git a/dashboard/src/todo.md b/dashboard/src/todo.md index a689c11..7a5e693 100644 --- a/dashboard/src/todo.md +++ b/dashboard/src/todo.md @@ -16,7 +16,7 @@ const todoState = (async function*() { try { const [rt, rw, rto, rr, rc, ri] = await Promise.all([ apiFetch("/tasks/?limit=500"), - apiFetch("/workstreams/"), + apiFetch("/workplans/"), apiFetch("/topics/"), apiFetch("/repos/"), apiFetch("/contributions/"), diff --git a/dashboard/src/workplan-queue.md b/dashboard/src/workplan-queue.md index b3af3cf..ca20bd0 100644 --- a/dashboard/src/workplan-queue.md +++ b/dashboard/src/workplan-queue.md @@ -104,7 +104,7 @@ function queueControls(row) { } save.onclick = () => run("saving", async () => { - const response = await apiFetch(`/execution/workstreams/${row.workstream_id}/intent`, { + const response = await apiFetch(`/execution/workplans/${row.workstream_id}/intent`, { method: "PATCH", headers: {"Content-Type": "application/json"}, body: JSON.stringify(payload()), diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 73286ee..20fee33 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -1,5 +1,5 @@ --- -title: Workstreams +title: Workplans --- ```js @@ -16,7 +16,7 @@ const wsState = (async function*() { let data = [], openWs = [], ok = false; try { const [rw, rt, rr, rd] = await Promise.all([ - apiFetch("/workstreams/"), + apiFetch("/workplans/"), apiFetch("/topics/"), apiFetch("/repos/"), apiFetch("/state/deps"), @@ -134,7 +134,7 @@ const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unkno }).filter(Boolean); ``` -# Workstreams +# Workplans ```js import {injectTocTop} from "./components/toc-sidebar.js"; @@ -166,17 +166,17 @@ function _warnLevel(name, val) { function _warnColor(lv) { return lv === 2 ? "#dc2626" : lv === 1 ? "#d97706" : "var(--theme-foreground-muted, #666)"; } const _whiMetrics = [ - {name: "DD", val: _DD, fmt: v => v.toFixed(2), label: "Dependency Density", desc: "Average number of dependencies per open workstream; high values indicate a tightly coupled graph that is hard to parallelise."}, - {name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%", label: "Blocked Ratio", desc: "Share of open workstreams currently in a blocked state; directly reduces the work that can proceed right now."}, - {name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%", label: "Single-Point Risk", desc: "Share of workstreams depended on by others but with no incoming dependencies themselves; losing one stalls everything downstream."}, - {name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%", label: "Parallel Execution Potential", desc: "Share of open workstreams with zero blocking dependencies that could start or continue immediately."}, + {name: "DD", val: _DD, fmt: v => v.toFixed(2), label: "Dependency Density", desc: "Average number of dependencies per open workplan; high values indicate a tightly coupled graph that is hard to parallelise."}, + {name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%", label: "Blocked Ratio", desc: "Share of open workplans currently in a blocked state; directly reduces the work that can proceed right now."}, + {name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%", label: "Single-Point Risk", desc: "Share of workplans depended on by others but with no incoming dependencies themselves; losing one stalls everything downstream."}, + {name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%", label: "Parallel Execution Potential", desc: "Share of open workplans with zero blocking dependencies that could start or continue immediately."}, {name: "CDDR", val: _CDDR, fmt: v => (v*100).toFixed(0)+"%", label: "Cross-Domain Dependency Ratio", desc: "Share of dependency edges that cross domain boundaries; high values mean progress in one domain is gated on another team or project."}, ]; const _whiBox = html`
-
Workstream Health
+
Workplan Health
${_openCount === 0 - ? html`
No active workstreams
` + ? html`
No active workplans
` : html`
${(_WHI*100).toFixed(0)}% @@ -202,7 +202,7 @@ const _whiBox = html`
description="Domain-scoped WHI (intra-domain edges only). Open: ${d.openCount} · Blocked: ${(d.br*100).toFixed(0)}% · Runnable: ${(d.pep*100).toFixed(0)}%" doc="/docs/workstream-health-index">${d.domain} ${(d.whi*100).toFixed(0)}% - ${d.cpi === 1 ? html`` : ""} + ${d.cpi === 1 ? html`` : ""}
`)}
` : ""} `} @@ -276,7 +276,7 @@ display(Plot.plot({ })); ``` -## All Workstreams +## All Workplans ```js display(_filtersForm); @@ -313,7 +313,7 @@ const wsWithDeps = openWs.filter(w => { }); if (wsWithDeps.length === 0) { - display(html`

No dependency edges recorded for the current filter. Use create_dependency() via the MCP server to link workstreams.

`); + display(html`

No dependency edges recorded for the current filter. Use create_dependency() via the MCP server to link workplans.

`); } else { display(html`
${wsWithDeps.map(w => { const depRows = w.depends_on.map(d => diff --git a/dashboard/src/workstreams/[id].md b/dashboard/src/workstreams/[id].md index 3cfdd53..dae777b 100644 --- a/dashboard/src/workstreams/[id].md +++ b/dashboard/src/workstreams/[id].md @@ -11,13 +11,13 @@ import {statusControl, TASK_STATUSES, WORKSTREAM_STATUSES} from "../components/s ```js const wsId = observable.params.id; const [raw, taskRows, workplanIndex] = await Promise.all([ - fetch(`${API}/workstreams/${wsId}`) + fetch(`${API}/workplans/${wsId}`) .then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`}))) .catch(e => ({error: String(e)})), fetch(`${API}/tasks/?workstream_id=${wsId}&limit=1000`) .then(r => r.ok ? r.json() : []) .catch(() => []), - fetch(`${API}/workstreams/workplan-index`) + fetch(`${API}/workplans/index`) .then(r => r.ok ? r.json() : {workstreams: {}}) .catch(() => ({workstreams: {}})), ]); @@ -31,7 +31,7 @@ if (raw.error) { const name = raw.title || raw.slug || wsId; const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name; display(html`

Workstream · ${shortName}

`); - display(html`

← Overview  |  ← Workstreams  |  ← Token Cost

`); + display(html`

← Overview  |  ← Workplans  |  ← Token Cost

`); display(html`
Status${statusControl({ diff --git a/dashboard/src/wsjf-triage.md b/dashboard/src/wsjf-triage.md index 221ef3e..7debe86 100644 --- a/dashboard/src/wsjf-triage.md +++ b/dashboard/src/wsjf-triage.md @@ -27,7 +27,7 @@ const triageState = (async function*() { try { const [reportsResp, indexResp] = await Promise.all([ apiFetch("/progress/?event_type=daily_triage&limit=14"), - apiFetch("/workstreams/workplan-index"), + apiFetch("/workplans/index"), ]); ok = reportsResp.ok && indexResp.ok; events = reportsResp.ok ? await reportsResp.json() : []; diff --git a/dashboard/test/config.test.mjs b/dashboard/test/config.test.mjs new file mode 100644 index 0000000..c8594e8 --- /dev/null +++ b/dashboard/test/config.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + API, + API_STORAGE_KEY, + DEFAULT_API, + resolveApiBase, +} from "../src/components/config.js"; + +test("api base keeps the script default outside the browser", () => { + assert.equal(API, DEFAULT_API); + assert.equal(resolveApiBase({location: null, storage: null}), DEFAULT_API); +}); + +test("api base follows the dashboard host in the browser", () => { + assert.equal( + resolveApiBase({ + location: new URL("http://localhost:3000/workstreams"), + storage: null, + }), + "http://localhost:8000", + ); + assert.equal( + resolveApiBase({ + location: new URL("http://statehub.local:3000/workstreams"), + storage: null, + }), + "http://statehub.local:8000", + ); +}); + +test("api base supports explicit query and storage overrides", () => { + assert.equal( + resolveApiBase({ + location: new URL("http://localhost:3000/?api_base=http%3A%2F%2F127.0.0.1%3A18000%2F"), + storage: { + getItem: () => "http://ignored.example:8000", + }, + }), + "http://127.0.0.1:18000", + ); + assert.equal( + resolveApiBase({ + location: new URL("http://localhost:3000/"), + storage: { + getItem: key => key === API_STORAGE_KEY ? " http://127.0.0.1:18000/ " : null, + }, + }), + "http://127.0.0.1:18000", + ); +}); + +test("ipv6 loopback dashboards use the ipv4 api default", () => { + assert.equal( + resolveApiBase({ + location: new URL("http://[::1]:3000/"), + storage: null, + }), + DEFAULT_API, + ); +}); diff --git a/docs/nats-event-subjects.md b/docs/nats-event-subjects.md index 4176bdd..1f2bfd7 100644 --- a/docs/nats-event-subjects.md +++ b/docs/nats-event-subjects.md @@ -42,7 +42,8 @@ those publishers from colliding on the same `{noun}.{verb}` shape. | Subject | When | Required attributes | | ------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | `org.statehub.repo.registered` | A new repo is registered via `POST /repos/` | `repo_id`, `repo_slug`, `domain_slug`, `remote_url?`, `local_path?` | -| `org.statehub.workstream.completed` | A workstream transitions to canonical status `finished` | `workstream_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` | +| `org.statehub.workplan.completed` | A workplan transitions to canonical status `finished` | `workplan_id`, `legacy_workstream_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` | +| `org.statehub.workstream.completed` | Legacy compatibility subject for completed workplans | `workstream_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` | | `org.statehub.decision.resolved` | A decision is resolved via `POST /decisions/{id}/resolve` | `decision_id`, `title`, `topic_id?`, `workstream_id?`, `decided_by`, `rationale_snippet` | | `org.statehub.domain.goal.activated` | A domain goal transitions to `active` | `goal_id`, `domain_id`, `domain_slug`, `title`, `superseded_goal_ids[]` | | `org.statehub.task.stale` | `scripts/cleanup_stale_tasks.py` cancels an out-of-date task | `task_id`, `workstream_id`, `workstream_status`, `task_title`, `task_status_before` | diff --git a/docs/workplan-terminology-transition.md b/docs/workplan-terminology-transition.md new file mode 100644 index 0000000..d4b646b --- /dev/null +++ b/docs/workplan-terminology-transition.md @@ -0,0 +1,83 @@ +# Workplan Terminology Transition + +Date: 2026-06-04 +Status: implementation guide for `STATE-WP-0054` + +## Position + +`workplan` is the preferred State Hub term for repo-backed deliverable work. +`workstream` remains a legacy compatibility term for interfaces that already +exist in clients, database relationships, event subjects, scripts, and generated +bridge metadata. + +The transition is compatibility-first: + +- new clients should use workplan-named interfaces; +- existing workstream interfaces remain operational while they are used; +- retained workstream interfaces are registered in `legacy-meter`; +- every legacy registration records the preferred replacement; and +- weekly activity-core review uses legacy-meter data to decide when a legacy + interface is safe to retire. + +Physical database renames are intentionally out of scope for this workplan. + +## Preferred Interfaces + +| Interface | Preferred path or subject | Legacy compatibility path or subject | Legacy-meter key | +| --- | --- | --- | --- | +| List workplans | `GET /workplans/` | `GET /workstreams/` | `rest_api:GET /workstreams/` | +| Create workplan | `POST /workplans/` | `POST /workstreams/` | `rest_api:POST /workstreams/` | +| Read workplan | `GET /workplans/{workplan_id}` | `GET /workstreams/{workstream_id}` | `rest_api:GET /workstreams/{workstream_id}` | +| Update workplan | `PATCH /workplans/{workplan_id}` | `PATCH /workstreams/{workstream_id}` | `rest_api:PATCH /workstreams/{workstream_id}` | +| Archive workplan | `DELETE /workplans/{workplan_id}` | `DELETE /workstreams/{workstream_id}` | `rest_api:DELETE /workstreams/{workstream_id}` | +| Workplan index | `GET /workplans/index` | `GET /workstreams/workplan-index` | `rest_api:GET /workstreams/workplan-index` | +| Workplan dependencies | `GET/POST /workplans/{workplan_id}/dependencies/` | `GET/POST /workstreams/{workstream_id}/dependencies/` | matching `rest_api:* /workstreams/...` keys | +| Delete dependency | `DELETE /workplans/{workplan_id}/dependencies/{dep_id}` | `DELETE /workstreams/{workstream_id}/dependencies/{dep_id}` | `rest_api:DELETE /workstreams/{workstream_id}/dependencies/{dep_id}` | +| Execution intent | `PATCH /execution/workplans/{workplan_id}/intent` | `PATCH /execution/workstreams/{workstream_id}/intent` | `rest_api:PATCH /execution/workstreams/{workstream_id}/intent` | +| Completion event | `org.statehub.workplan.completed` | `org.statehub.workstream.completed` | `event_subject:org.statehub.workstream.completed` | + +Legacy REST responses include: + +- `Deprecation: true` +- `X-StateHub-Replacement: ` +- `Link: <>; rel="successor-version"` + +## Legacy Meter + +`legacy-meter` stores two kinds of data: + +- `legacy_interfaces`: the registry of legacy interfaces, their legacy + timestamp, preferred replacement, owner component, hold status, and replacement + verification state. +- `legacy_interface_usage_buckets`: daily usage buckets for calls, tenants, + users, and components. + +When identity headers are missing, usage is recorded in the explicit `unknown` +bucket. Clients can provide: + +- `X-StateHub-Tenant` +- `X-StateHub-User` +- `X-StateHub-Component` + +Useful endpoints: + +| Endpoint | Purpose | +| --- | --- | +| `POST /legacy-meter/interfaces` | Register or update a legacy interface. | +| `GET /legacy-meter/interfaces` | List registered legacy interfaces. | +| `POST /legacy-meter/usage` | Record explicit usage outside an instrumented route. | +| `GET /legacy-meter/summary` | Show usage counters for a review window. | +| `GET /legacy-meter/weekly-review` | Activity-core-friendly weekly review payload. | + +## Retirement Rule + +An interface is a retirement candidate only when all of the following are true: + +- it is registered as legacy; +- it has a replacement reference; +- the replacement has been verified; +- it has no manual hold; +- it had zero measured calls in the review window. + +State Hub owns the usage state and the review payload. Activity-core owns the +weekly wakeup, review activity, and any follow-up dispatch. diff --git a/migrations/versions/a4v5w6x7y8z0_legacy_meter.py b/migrations/versions/a4v5w6x7y8z0_legacy_meter.py new file mode 100644 index 0000000..4de501c --- /dev/null +++ b/migrations/versions/a4v5w6x7y8z0_legacy_meter.py @@ -0,0 +1,107 @@ +"""add legacy meter tables + +Revision ID: a4v5w6x7y8z0 +Revises: z3u4v5w6x7y8 +Create Date: 2026-06-04 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "a4v5w6x7y8z0" +down_revision = "z3u4v5w6x7y8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "legacy_interfaces", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("interface_key", sa.String(length=300), nullable=False), + sa.Column("interface_kind", sa.String(length=40), nullable=False), + sa.Column("legacy_since", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("replacement_ref", sa.Text(), nullable=False), + sa.Column("owner_component", sa.String(length=100), server_default="state-hub", nullable=False), + sa.Column("status", sa.String(length=30), server_default="legacy", nullable=False), + sa.Column("replacement_verified", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("manual_hold", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("hold_reason", sa.Text(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.UniqueConstraint("interface_key", name="uq_legacy_interfaces_interface_key"), + ) + op.create_index("ix_legacy_interfaces_interface_key", "legacy_interfaces", ["interface_key"]) + op.create_index("ix_legacy_interfaces_interface_kind", "legacy_interfaces", ["interface_kind"]) + op.create_index("ix_legacy_interfaces_legacy_since", "legacy_interfaces", ["legacy_since"]) + op.create_index("ix_legacy_interfaces_owner_component", "legacy_interfaces", ["owner_component"]) + op.create_index("ix_legacy_interfaces_status", "legacy_interfaces", ["status"]) + + op.create_table( + "legacy_interface_usage_buckets", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "legacy_interface_id", + UUID(as_uuid=True), + sa.ForeignKey("legacy_interfaces.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("period_start", sa.Date(), nullable=False), + sa.Column("bucket_kind", sa.String(length=30), nullable=False), + sa.Column("bucket_key", sa.String(length=200), nullable=False), + sa.Column("call_count", sa.Integer(), server_default="0", nullable=False), + sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.UniqueConstraint( + "legacy_interface_id", + "period_start", + "bucket_kind", + "bucket_key", + name="uq_legacy_usage_bucket", + ), + ) + op.create_index( + "ix_legacy_interface_usage_buckets_legacy_interface_id", + "legacy_interface_usage_buckets", + ["legacy_interface_id"], + ) + op.create_index( + "ix_legacy_interface_usage_buckets_period_start", + "legacy_interface_usage_buckets", + ["period_start"], + ) + op.create_index( + "ix_legacy_interface_usage_buckets_last_seen_at", + "legacy_interface_usage_buckets", + ["last_seen_at"], + ) + op.create_index( + "ix_legacy_usage_interface_period", + "legacy_interface_usage_buckets", + ["legacy_interface_id", "period_start"], + ) + op.create_index( + "ix_legacy_usage_bucket_kind_key", + "legacy_interface_usage_buckets", + ["bucket_kind", "bucket_key"], + ) + + +def downgrade() -> None: + op.drop_index("ix_legacy_usage_bucket_kind_key", table_name="legacy_interface_usage_buckets") + op.drop_index("ix_legacy_usage_interface_period", table_name="legacy_interface_usage_buckets") + op.drop_index("ix_legacy_interface_usage_buckets_last_seen_at", table_name="legacy_interface_usage_buckets") + op.drop_index("ix_legacy_interface_usage_buckets_period_start", table_name="legacy_interface_usage_buckets") + op.drop_index("ix_legacy_interface_usage_buckets_legacy_interface_id", table_name="legacy_interface_usage_buckets") + op.drop_table("legacy_interface_usage_buckets") + op.drop_index("ix_legacy_interfaces_status", table_name="legacy_interfaces") + op.drop_index("ix_legacy_interfaces_owner_component", table_name="legacy_interfaces") + op.drop_index("ix_legacy_interfaces_legacy_since", table_name="legacy_interfaces") + op.drop_index("ix_legacy_interfaces_interface_kind", table_name="legacy_interfaces") + op.drop_index("ix_legacy_interfaces_interface_key", table_name="legacy_interfaces") + op.drop_table("legacy_interfaces") diff --git a/tests/test_cors.py b/tests/test_cors.py new file mode 100644 index 0000000..1b9644f --- /dev/null +++ b/tests/test_cors.py @@ -0,0 +1,31 @@ +from __future__ import annotations + + +async def _preflight(client, origin: str): + return await client.options( + "/state/summary", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + + +async def test_dashboard_cors_allows_observable_fallback_ports(client): + for origin in ( + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:3001", + "http://127.0.0.1:3001", + "http://[::1]:3000", + ): + response = await _preflight(client, origin) + + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == origin + + +async def test_dashboard_cors_still_rejects_non_dashboard_ports(client): + response = await _preflight(client, "http://localhost:5173") + + assert response.status_code == 400 diff --git a/tests/test_legacy_meter.py b/tests/test_legacy_meter.py new file mode 100644 index 0000000..6b5f1ca --- /dev/null +++ b/tests/test_legacy_meter.py @@ -0,0 +1,167 @@ +from __future__ import annotations + + +async def _create_domain(client, slug="legacy-domain", name="Legacy Domain"): + r = await client.post("/domains/", json={"slug": slug, "name": name}) + assert r.status_code == 201, r.text + return r.json() + + +async def _create_topic(client, domain_slug="legacy-domain", slug="legacy-topic", title="Legacy Topic"): + r = await client.post("/topics/", json={ + "slug": slug, + "title": title, + "domain": domain_slug, + }) + assert r.status_code == 201, r.text + return r.json() + + +async def _create_workplan(client, topic_id, slug="legacy-wp", title="Legacy WP"): + r = await client.post("/workplans/", json={ + "topic_id": topic_id, + "slug": slug, + "title": title, + "status": "ready", + }) + assert r.status_code == 201, r.text + return r.json() + + +def _summary_by_key(summary): + return {item["interface"]["interface_key"]: item for item in summary["interfaces"]} + + +class TestWorkplanAliasesAndLegacyMeter: + async def test_preferred_workplan_routes_do_not_meter_legacy_usage(self, client): + await _create_domain(client) + topic = await _create_topic(client) + wp = await _create_workplan(client, topic["id"]) + + r = await client.get(f"/workplans/?topic_id={topic['id']}") + assert r.status_code == 200 + assert [row["id"] for row in r.json()] == [wp["id"]] + + r = await client.patch(f"/workplans/{wp['id']}", json={"status": "active"}) + assert r.status_code == 200 + assert r.json()["status"] == "active" + + r = await client.get("/legacy-meter/summary") + assert r.status_code == 200 + assert r.json()["interfaces"] == [] + + async def test_legacy_workstream_route_is_metered_with_identity_buckets(self, client): + await _create_domain(client) + topic = await _create_topic(client) + await _create_workplan(client, topic["id"]) + + r = await client.get( + f"/workstreams/?topic_id={topic['id']}", + headers={ + "X-StateHub-Tenant": "tenant-a", + "X-StateHub-User": "alice", + "X-StateHub-Component": "old-client", + }, + ) + assert r.status_code == 200 + assert r.headers["Deprecation"] == "true" + assert r.headers["X-StateHub-Replacement"] == "/workplans/" + + summary = (await client.get("/legacy-meter/summary")).json() + item = _summary_by_key(summary)["rest_api:GET /workstreams/"] + assert item["window"]["calls"] == 1 + assert item["window"]["tenants"] == {"tenant-a": 1} + assert item["window"]["users"] == {"alice": 1} + assert item["window"]["components"] == {"old-client": 1} + assert item["retirement_candidate"] is False + + async def test_weekly_review_reports_unused_verified_legacy_interface(self, client): + r = await client.post("/legacy-meter/interfaces", json={ + "interface_key": "rest_api:GET /obsolete", + "interface_kind": "rest_api", + "replacement_ref": "/workplans/", + "replacement_verified": True, + }) + assert r.status_code == 201, r.text + + review = (await client.get("/legacy-meter/weekly-review")).json() + assert review["activity_core_handoff"]["scheduler_owner"] == "activity-core" + candidates = _summary_by_key({"interfaces": review["retirement_candidates"]}) + assert "rest_api:GET /obsolete" in candidates + assert candidates["rest_api:GET /obsolete"]["retirement_reason"] == "no measured usage in review window" + + async def test_recent_usage_blocks_weekly_retirement_candidate(self, client): + r = await client.post("/legacy-meter/usage", json={ + "interface_key": "rest_api:GET /old-but-used", + "interface_kind": "rest_api", + "replacement_ref": "/workplans/", + "replacement_verified": True, + "tenant_key": "tenant-a", + "user_key": "alice", + "component_key": "old-client", + }) + assert r.status_code == 201, r.text + + review = (await client.get("/legacy-meter/weekly-review")).json() + items = _summary_by_key(review) + item = items["rest_api:GET /old-but-used"] + assert item["window"]["calls"] == 1 + assert item["retirement_candidate"] is False + assert item["retirement_reason"] == "1 call(s) in review window" + + async def test_legacy_completion_event_is_metered_and_workplan_event_is_preferred(self, client): + await _create_domain(client) + topic = await _create_topic(client) + wp = await _create_workplan(client, topic["id"]) + + r = await client.patch( + f"/workstreams/{wp['id']}", + json={"status": "finished"}, + headers={"X-StateHub-Component": "old-client"}, + ) + assert r.status_code == 200 + assert r.json()["status"] == "finished" + + summary = (await client.get("/legacy-meter/summary")).json() + items = _summary_by_key(summary) + assert items["rest_api:PATCH /workstreams/{workstream_id}"]["window"]["components"] == { + "old-client": 1 + } + event = items["event_subject:org.statehub.workstream.completed"] + assert event["interface"]["replacement_ref"] == "org.statehub.workplan.completed" + assert event["window"]["components"] == {"state-hub.events": 1} + + async def test_workplan_dependency_and_execution_aliases(self, client): + await _create_domain(client) + topic = await _create_topic(client) + first = await _create_workplan(client, topic["id"], slug="first", title="First") + second = await _create_workplan(client, topic["id"], slug="second", title="Second") + + r = await client.post( + f"/workplans/{first['id']}/dependencies/", + json={"to_workstream_id": second["id"]}, + ) + assert r.status_code == 201, r.text + + r = await client.get(f"/workplans/{first['id']}/dependencies/") + assert r.status_code == 200 + assert len(r.json()) == 1 + + r = await client.patch( + f"/execution/workplans/{first['id']}/intent", + json={"execution_state": "queued", "launch_mode": "queued"}, + ) + assert r.status_code == 200 + assert r.json()["execution_state"] == "queued" + + r = await client.patch( + f"/execution/workstreams/{first['id']}/intent", + json={"execution_state": "manual", "launch_mode": "manual"}, + headers={"X-StateHub-Component": "old-executor"}, + ) + assert r.status_code == 200 + assert r.headers["X-StateHub-Replacement"] == "/execution/workplans/{workplan_id}/intent" + + summary = (await client.get("/legacy-meter/summary")).json() + item = _summary_by_key(summary)["rest_api:PATCH /execution/workstreams/{workstream_id}/intent"] + assert item["window"]["components"] == {"old-executor": 1} diff --git a/workplans/ADHOC-2026-06-04.md b/workplans/ADHOC-2026-06-04.md new file mode 100644 index 0000000..3eae519 --- /dev/null +++ b/workplans/ADHOC-2026-06-04.md @@ -0,0 +1,34 @@ +--- +id: ADHOC-2026-06-04 +type: workplan +title: "Ad hoc fixes - 2026-06-04" +domain: custodian +repo: state-hub +status: finished +owner: codex +topic_slug: custodian +created: "2026-06-04" +updated: "2026-06-04" +state_hub_workstream_id: "2a8f3aff-8f5d-4e42-b33f-225d60e0b30b" +--- + +# Ad hoc fixes - 2026-06-04 + +## Fix Dashboard Overview API Loading + +```task +id: ADHOC-2026-06-04-T01 +status: done +priority: high +state_hub_task_id: "5f6aa1e4-ccc5-4c8b-8183-cc1578190b7b" +``` + +The overview page reported `Dashboard data load failed: NetworkError when +attempting to fetch resource.` even when the API was healthy from Windows. +The root cause was brittle browser/API connection setup: the dashboard assumed +`http://127.0.0.1:8000`, while the API CORS defaults allowed only the exact +dashboard origins on port 3000. + +Result: the dashboard now resolves its API base from the current browser host +with explicit query/storage/global overrides, and the API allows common local +Observable dashboard origins on ports 3000-3005, including IPv6 loopback. diff --git a/workplans/STATE-WP-0052-task-state-canon-adaptation.md b/workplans/STATE-WP-0052-task-state-canon-adaptation.md index ce3b942..c53e642 100644 --- a/workplans/STATE-WP-0052-task-state-canon-adaptation.md +++ b/workplans/STATE-WP-0052-task-state-canon-adaptation.md @@ -4,11 +4,11 @@ type: workplan title: "Task State Canon Adaptation" domain: custodian repo: state-hub -status: active +status: finished owner: codex topic_slug: custodian created: "2026-05-25" -updated: "2026-05-25" +updated: "2026-06-04" state_hub_workstream_id: "bc54c18b-4d98-430d-b9ad-c4410010c897" --- @@ -321,7 +321,7 @@ task, or a recorded no-impact classification. ```task id: STATE-WP-0052-T10 -status: wait +status: done priority: medium state_hub_task_id: "1cde226a-6287-4db4-9d2f-7fa9ed0b6c4d" ``` @@ -342,10 +342,11 @@ Requirements: Done when State Hub is canon-conformant, attached repos have been notified, and the remaining compatibility window is explicit. -Current wait condition: attached repos have been notified through interface -change `649102a2-4373-4621-9848-cc257e67c262`; closing the compatibility window -depends on repo-agent responses and a later decision on when aliases become -warnings or errors. +Close-out note: attached repos were notified through interface change +`649102a2-4373-4621-9848-cc257e67c262`; decision +`c386f42f-a50a-41d9-9457-f384227f8f6c` keeps legacy aliases accepted during the +adaptation window and leaves any future warnings/errors to a later explicit +decision. ## Acceptance Criteria diff --git a/workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md b/workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md index 5a027ef..b5518f5 100644 --- a/workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md +++ b/workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md @@ -4,13 +4,13 @@ type: workplan title: "Workplan Terminology Transition and Legacy Meter" domain: custodian repo: state-hub -status: proposed +status: finished owner: codex topic_slug: custodian planning_priority: high planning_order: 54 created: "2026-06-03" -updated: "2026-06-03" +updated: "2026-06-04" state_hub_workstream_id: "471401c8-38b2-46fd-ae34-052825710376" --- @@ -100,7 +100,7 @@ record an explicit `unknown` bucket instead of dropping the observation. ```task id: STATE-WP-0054-T01 -status: todo +status: done priority: high state_hub_task_id: "f43ed8c0-f62c-42af-b38f-d4ccd7a7bed5" ``` @@ -117,11 +117,15 @@ or out of scope. Map every legacy-compatible interface to its preferred Done when the repo has a reviewed compatibility matrix that separates semantic renames from high-risk storage or event-contract changes. +Result 2026-06-04: added `docs/workplan-terminology-transition.md` with the +preferred workplan interfaces, legacy workstream compatibility paths, +legacy-meter keys, retirement rules, and activity-core handoff boundary. + ### T02 - Add Preferred Workplan Interface Variants ```task id: STATE-WP-0054-T02 -status: todo +status: done priority: high state_hub_task_id: "65dca8e4-a032-4b1a-a21f-8559f1fb87f5" ``` @@ -142,11 +146,17 @@ Likely examples: Done when new clients can use workplan-named interfaces without relying on workstream-named entry points. +Result 2026-06-04: added preferred REST aliases for `/workplans`, +`/workplans/{id}`, `/workplans/index`, +`/workplans/{id}/dependencies/`, and +`/execution/workplans/{id}/intent`. Completion now also emits preferred +`org.statehub.workplan.completed` events while retaining the legacy event. + ### T03 - Implement Legacy Meter Data Model And Service ```task id: STATE-WP-0054-T03 -status: todo +status: done priority: high state_hub_task_id: "b4008ab7-1f59-4ea7-a728-48557473c22d" ``` @@ -162,11 +172,15 @@ update first/last seen timestamps. Done when tests can register a legacy interface and prove usage counters are updated without changing the legacy interface's behavior. +Result 2026-06-04: added `legacy_interfaces` and +`legacy_interface_usage_buckets` models, migration, schemas, service helpers, +and `/legacy-meter` registration/usage/summary endpoints. + ### T04 - Instrument Legacy Workstream Interfaces ```task id: STATE-WP-0054-T04 -status: todo +status: done priority: high state_hub_task_id: "28c31bbc-4479-4dd9-8e2c-08235e81ba91" ``` @@ -182,11 +196,16 @@ must not break the legacy interface path. Done when calls to selected `workstream` interfaces appear in legacy-meter usage summaries with call counts and tenant/user/component buckets. +Result 2026-06-04: instrumented retained `/workstreams` REST routes, +dependency routes, `/execution/workstreams/{id}/intent`, and the legacy +`org.statehub.workstream.completed` event subject. Legacy REST responses now +include deprecation and replacement headers. + ### T05 - Add Legacy Usage Review And Retirement Signals ```task id: STATE-WP-0054-T05 -status: todo +status: done priority: high state_hub_task_id: "fca14802-1a15-4b5a-8267-5348666b3c50" ``` @@ -206,11 +225,16 @@ An interface becomes a retirement candidate only when: Done when State Hub can produce a precise retirement-candidate list without manual log scraping. +Result 2026-06-04: added `/legacy-meter/summary` and +`/legacy-meter/weekly-review` with review-window counters, last-seen timestamps, +identity buckets, verified-replacement gating, manual holds, and retirement +candidate reasons. + ### T06 - Schedule Weekly Activity-Core Review ```task id: STATE-WP-0054-T06 -status: todo +status: done priority: high state_hub_task_id: "3d6fb438-707f-45e8-9c38-3e7352ae2a93" ``` @@ -226,11 +250,15 @@ letting activity-core own wakeups, schedules, and dispatch. Done when a weekly activity-core check can read legacy-meter summaries and raise retirement work only for interfaces with no prior-week usage. +Result 2026-06-04: exposed activity-core handoff metadata in +`/legacy-meter/weekly-review`: weekly cadence, source endpoint, State Hub as +state owner, and activity-core as scheduler owner. + ### T07 - Update Documentation, Dashboard Labels, And Agent Guidance ```task id: STATE-WP-0054-T07 -status: todo +status: done priority: medium state_hub_task_id: "55a05f68-a337-412c-8462-847e6465d15e" ``` @@ -243,11 +271,15 @@ interfaces and link to their preferred replacements. Done when new users and agents are guided toward workplan terminology without losing instructions for existing compatibility paths. +Result 2026-06-04: updated State Hub docs, dashboard API calls/reference docs, +NATS event docs, README, and AGENTS guidance to prefer workplan terminology and +document legacy workstream compatibility. + ### T08 - Prove Compatibility And Rollout Safety ```task id: STATE-WP-0054-T08 -status: todo +status: done priority: high state_hub_task_id: "2ae2580f-4626-49fe-aec6-ca9340792afd" ``` @@ -263,6 +295,16 @@ exist, and what activity-core will review weekly. Done when existing `workstream` clients still pass, new `workplan` clients pass, and legacy-meter telemetry proves the compatibility window is observable. +Progress 2026-06-04: added `tests/test_legacy_meter.py` for preferred +workplan routes, legacy route metering, identity buckets, weekly retirement +review, completion event metering, dependency aliases, and execution aliases. +Focused verification passed with +`.venv/bin/python -m pytest tests/test_legacy_meter.py tests/test_routers_core.py::TestWorkstreams tests/test_routers_core.py::TestExecutionQueueEndpoints`. + +Result 2026-06-04: full backend verification passed with +`.venv/bin/python -m pytest` (`340 passed`), dashboard verification passed with +`npm test` in `dashboard/` (`11 passed`), and `git diff --check` passed. + ## Acceptance Criteria - `workplan` is the recommended user-facing term in State Hub docs, dashboard @@ -279,4 +321,3 @@ and legacy-meter telemetry proves the compatibility window is observable. documented replacement. - No physical database or event-contract rename is required in this workplan unless compatibility evidence shows it is safe. -