feat: add workplan aliases and legacy meter

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

View File

@@ -27,8 +27,8 @@ there is no MCP server for Codex agents.
# Offline brief — works without hub connection # Offline brief — works without hub connection
cat .custodian-brief.md cat .custodian-brief.md
# Active workstreams for this domain # Active workplans for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \ curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
| python3 -m json.tool | python3 -m json.tool
# Check inbox # Check inbox
@@ -80,7 +80,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
## Session Protocol ## Session Protocol
**Start:** **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 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 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` 4. Check human-needed tasks: `GET /tasks/?needs_human=true`
@@ -135,6 +135,10 @@ state_hub_workstream_id: "<uuid>" # 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 Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses. derived health labels, not frontmatter statuses.

View File

@@ -186,13 +186,18 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
| Prefix | Operations | | Prefix | Operations |
|--------|-----------| |--------|-----------|
| `/topics` | CRUD (soft-delete: `archived`) | | `/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 | | `/tasks` | CRUD (soft-delete: `cancel`); `PATCH` updates status |
| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation | | `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
| `/progress` | `GET` list + `POST` append — no DELETE | | `/progress` | `GET` list + `POST` append — no DELETE |
| `/legacy-meter` | Register, meter, and review legacy interface usage |
| `/state/summary` | Full snapshot | | `/state/summary` | Full snapshot |
| `/state/health` | DB connectivity check | | `/state/health` | DB connectivity check |
See `docs/workplan-terminology-transition.md` for the workstream-to-workplan
compatibility policy and retirement criteria.
--- ---
## MCP Server ## MCP Server

View File

@@ -19,6 +19,7 @@ from api.routers import recently_on_scope
from api.routers import reconciliation from api.routers import reconciliation
from api.routers import execution from api.routers import execution
from api.routers import fabric from api.routers import fabric
from api.routers import legacy_meter
class ETagMiddleware(BaseHTTPMiddleware): class ETagMiddleware(BaseHTTPMiddleware):
@@ -69,7 +70,12 @@ app = FastAPI(
lifespan=lifespan, 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()] _cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
app.add_middleware(ETagMiddleware) app.add_middleware(ETagMiddleware)
@@ -87,7 +93,9 @@ app.include_router(recently_on_scope.router)
app.include_router(repos.router) app.include_router(repos.router)
app.include_router(topics.router) app.include_router(topics.router)
app.include_router(workstreams.router) app.include_router(workstreams.router)
app.include_router(workstreams.workplan_router)
app.include_router(workstream_dependencies.router) app.include_router(workstream_dependencies.router)
app.include_router(workstream_dependencies.workplan_router)
app.include_router(tasks.router) app.include_router(tasks.router)
app.include_router(decisions.router) app.include_router(decisions.router)
app.include_router(extension_points.router) app.include_router(extension_points.router)
@@ -106,6 +114,7 @@ app.include_router(flows.router)
app.include_router(reconciliation.router) app.include_router(reconciliation.router)
app.include_router(execution.router) app.include_router(execution.router)
app.include_router(fabric.router) app.include_router(fabric.router)
app.include_router(legacy_meter.router)
app.include_router(state.router) app.include_router(state.router)
app.include_router(policy.router) app.include_router(policy.router)

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import uuid 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,6 +27,7 @@ from api.services.execution_queue import (
queue_sort_key, queue_sort_key,
workstream_blockers, workstream_blockers,
) )
from api.routers.workstreams import _legacy_key, _meter_legacy_route
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
router = APIRouter(prefix="/execution", tags=["execution"]) 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, workstream_id: uuid.UUID,
body: ExecutionIntentUpdate, body: ExecutionIntentUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession,
) -> ExecutionIntentRead: ) -> ExecutionIntentRead:
ws = await session.get(Workstream, workstream_id) ws = await session.get(Workstream, workstream_id)
if ws is None: 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(): for field, value in body.model_dump(exclude_unset=True).items():
setattr(ws, field, value) setattr(ws, field, value)
@@ -60,6 +61,33 @@ async def update_execution_intent(
return _intent_read(ws) 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]) @router.get("/workplan-stack", response_model=list[WorkplanQueueItem])
async def workplan_stack( async def workplan_stack(
include_manual: bool = Query(True), include_manual: bool = Query(True),

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

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

View File

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

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import logging
import uuid import uuid
import socket import socket
import time import time
@@ -6,7 +7,7 @@ from pathlib import Path
from typing import Any from typing import Any
import yaml 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,18 +21,30 @@ from api.schemas.workstream import (
WorkstreamUpdate, WorkstreamUpdate,
) )
from api.services.lifecycle import transition_workstream_status 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 ( from api.workplan_status import (
is_supported_workstream_status, is_supported_workstream_status,
normalize_workstream_status, normalize_workstream_status,
ready_review_status, ready_review_status,
) )
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/workstreams", tags=["workstreams"]) router = APIRouter(prefix="/workstreams", tags=["workstreams"])
workplan_router = APIRouter(prefix="/workplans", tags=["workplans"])
_INDEX_CACHE: dict[str, Any] | None = None _INDEX_CACHE: dict[str, Any] | None = None
_INDEX_CACHE_AT: float = 0.0 _INDEX_CACHE_AT: float = 0.0
_INDEX_TTL = 30.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: def _repo_path(repo: ManagedRepo) -> Path | None:
hostname = socket.gethostname() hostname = socket.gethostname()
@@ -65,15 +78,72 @@ def _frontmatter(path: Path) -> dict[str, Any]:
return {} return {}
@router.get("/", response_model=list[WorkstreamRead]) def _legacy_key(method: str, route: str) -> str:
async def list_workstreams( return f"rest_api:{method} {route}"
topic_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
repo_goal_id: uuid.UUID | None = None, def _mark_legacy_response(response: Response | None, replacement_ref: str) -> None:
status: str | None = None, if response is None:
owner: str | None = None, return
slug: str | None = None, response.headers["Deprecation"] = "true"
session: AsyncSession = Depends(get_session), 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]: ) -> list[Workstream]:
q = select(Workstream) q = select(Workstream)
if topic_id: if topic_id:
@@ -82,10 +152,10 @@ async def list_workstreams(
q = q.where(Workstream.repo_id == repo_id) q = q.where(Workstream.repo_id == repo_id)
if repo_goal_id: if repo_goal_id:
q = q.where(Workstream.repo_goal_id == repo_goal_id) q = q.where(Workstream.repo_goal_id == repo_goal_id)
if status: if status_filter:
normalised_status = normalize_workstream_status(status) normalised_status = normalize_workstream_status(status_filter)
if not is_supported_workstream_status(status): if not is_supported_workstream_status(status_filter):
raise HTTPException(status_code=422, detail=f"Unsupported workstream status '{status}'") raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'")
q = q.where(Workstream.status == normalised_status) q = q.where(Workstream.status == normalised_status)
if owner: if owner:
q = q.where(Workstream.owner == owner) q = q.where(Workstream.owner == owner)
@@ -100,12 +170,12 @@ async def list_workstreams(
return list(result.scalars().all()) return list(result.scalars().all())
@router.get("/workplan-index") async def _workplan_index(
async def workplan_index( *,
refresh: bool = Query(False, description="Force cache invalidation"), refresh: bool,
session: AsyncSession = Depends(get_session), session: AsyncSession,
) -> dict[str, Any]: ) -> 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 global _INDEX_CACHE, _INDEX_CACHE_AT
if not refresh and _INDEX_CACHE is not None and (time.monotonic() - _INDEX_CACHE_AT) < _INDEX_TTL: if not refresh and _INDEX_CACHE is not None and (time.monotonic() - _INDEX_CACHE_AT) < _INDEX_TTL:
return _INDEX_CACHE return _INDEX_CACHE
@@ -148,15 +218,15 @@ async def workplan_index(
"needs_review": bool(review and review.needs_review), "needs_review": bool(review and review.needs_review),
"health_labels": ["needs_review"] if review and review.needs_review else [], "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() _INDEX_CACHE_AT = time.monotonic()
return _INDEX_CACHE return _INDEX_CACHE
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED) async def _create_workstream(
async def create_workstream( *,
body: WorkstreamCreate, body: WorkstreamCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession,
) -> Workstream: ) -> Workstream:
ws = Workstream(**body.model_dump()) ws = Workstream(**body.model_dump())
session.add(ws) session.add(ws)
@@ -165,26 +235,26 @@ async def create_workstream(
return ws return ws
@router.get("/{workstream_id}", response_model=WorkstreamRead) async def _get_workstream(
async def get_workstream( *,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession,
) -> Workstream: ) -> Workstream:
ws = await session.get(Workstream, workstream_id) ws = await session.get(Workstream, workstream_id)
if ws is None: if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found") raise HTTPException(status_code=404, detail="Workplan not found")
return ws return ws
@router.patch("/{workstream_id}", response_model=WorkstreamRead) async def _update_workstream(
async def update_workstream( *,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
body: WorkstreamUpdate, body: WorkstreamUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession,
) -> Workstream: ) -> Workstream:
ws = await session.get(Workstream, workstream_id) ws = await session.get(Workstream, workstream_id)
if ws is None: 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) update_data = body.model_dump(exclude_unset=True)
status_update = update_data.pop("status", None) status_update = update_data.pop("status", None)
prev_status = ws.status prev_status = ws.status
@@ -196,32 +266,232 @@ async def update_workstream(
await session.refresh(ws) await session.refresh(ws)
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished": if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished":
subject = "org.statehub.workstream.completed" await _publish_completion_events(ws, session)
envelope = EventEnvelope.new(
subject,
attributes={
"workstream_id": str(ws.id),
"slug": ws.slug,
"title": ws.title,
"topic_id": str(ws.topic_id),
"repo_id": str(ws.repo_id) if ws.repo_id else None,
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
},
)
asyncio.create_task(publish_event(subject, envelope))
return ws return ws
@router.delete("/{workstream_id}", response_model=WorkstreamRead) async def _archive_workstream(
async def archive_workstream( *,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession,
) -> Workstream: ) -> Workstream:
ws = await session.get(Workstream, workstream_id) ws = await session.get(Workstream, workstream_id)
if ws is None: 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") transition_workstream_status(ws, "archived")
await session.commit() await session.commit()
await session.refresh(ws) await session.refresh(ws)
return ws return ws
async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> None:
workplan_envelope = EventEnvelope.new(
_COMPLETED_WORKPLAN_EVENT,
attributes={
"workplan_id": str(ws.id),
"legacy_workstream_id": str(ws.id),
"slug": ws.slug,
"title": ws.title,
"topic_id": str(ws.topic_id),
"repo_id": str(ws.repo_id) if ws.repo_id else None,
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
},
)
asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope))
await _meter_legacy_event(
session=session,
subject=_COMPLETED_WORKSTREAM_EVENT,
replacement_ref=_COMPLETED_WORKPLAN_EVENT,
)
legacy_envelope = EventEnvelope.new(
_COMPLETED_WORKSTREAM_EVENT,
attributes={
"workstream_id": str(ws.id),
"slug": ws.slug,
"title": ws.title,
"topic_id": str(ws.topic_id),
"repo_id": str(ws.repo_id) if ws.repo_id else None,
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
},
)
asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope))
@router.get("/", response_model=list[WorkstreamRead])
async def list_workstreams(
request: Request,
response: Response,
topic_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
repo_goal_id: uuid.UUID | None = None,
status: str | None = None,
owner: str | None = None,
slug: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Workstream]:
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("GET", "/workstreams/"),
replacement_ref="/workplans/",
)
return await _list_workstreams(
topic_id=topic_id,
repo_id=repo_id,
repo_goal_id=repo_goal_id,
status_filter=status,
owner=owner,
slug=slug,
session=session,
)
@workplan_router.get("/", response_model=list[WorkstreamRead])
async def list_workplans(
topic_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
repo_goal_id: uuid.UUID | None = None,
status: str | None = None,
owner: str | None = None,
slug: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Workstream]:
return await _list_workstreams(
topic_id=topic_id,
repo_id=repo_id,
repo_goal_id=repo_goal_id,
status_filter=status,
owner=owner,
slug=slug,
session=session,
)
@router.get("/workplan-index")
async def workplan_index(
request: Request,
response: Response,
refresh: bool = Query(False, description="Force cache invalidation"),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("GET", "/workstreams/workplan-index"),
replacement_ref="/workplans/index",
)
return await _workplan_index(refresh=refresh, session=session)
@workplan_router.get("/index")
async def workplan_index_preferred(
refresh: bool = Query(False, description="Force cache invalidation"),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
return await _workplan_index(refresh=refresh, session=session)
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
async def create_workstream(
request: Request,
response: Response,
body: WorkstreamCreate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("POST", "/workstreams/"),
replacement_ref="/workplans/",
)
return await _create_workstream(body=body, session=session)
@workplan_router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
async def create_workplan(
body: WorkstreamCreate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _create_workstream(body=body, session=session)
@router.get("/{workstream_id}", response_model=WorkstreamRead)
async def get_workstream(
request: Request,
response: Response,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_id}",
)
return await _get_workstream(workstream_id=workstream_id, session=session)
@workplan_router.get("/{workplan_id}", response_model=WorkstreamRead)
async def get_workplan(
workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _get_workstream(workstream_id=workplan_id, session=session)
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
async def update_workstream(
request: Request,
response: Response,
workstream_id: uuid.UUID,
body: WorkstreamUpdate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_id}",
)
return await _update_workstream(workstream_id=workstream_id, body=body, session=session)
@workplan_router.patch("/{workplan_id}", response_model=WorkstreamRead)
async def update_workplan(
workplan_id: uuid.UUID,
body: WorkstreamUpdate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _update_workstream(workstream_id=workplan_id, body=body, session=session)
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
async def archive_workstream(
request: Request,
response: Response,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_id}",
)
return await _archive_workstream(workstream_id=workstream_id, session=session)
@workplan_router.delete("/{workplan_id}", response_model=WorkstreamRead)
async def archive_workplan(
workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _archive_workstream(workstream_id=workplan_id, session=session)

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

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

View File

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

View File

@@ -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 = 15_000;
export const POLL_HEAVY = 60_000; export const POLL_HEAVY = 60_000;
export const FETCH_TIMEOUT = 12_000; export const FETCH_TIMEOUT = 12_000;

View File

@@ -26,7 +26,7 @@ const FIELD_LINKS = {
getTitle: d => d.title, getTitle: d => d.title,
}, },
workstream_id: { workstream_id: {
apiUrl: id => `${API}/workstreams/${id}`, apiUrl: id => `${API}/workplans/${id}`,
getUrl: (id, _d) => `/workstreams/${id}`, getUrl: (id, _d) => `/workstreams/${id}`,
getTitle: d => d.title || d.slug, getTitle: d => d.title || d.slug,
}, },

View File

@@ -16,7 +16,7 @@ const depState = (async function*() {
let wsMap = {}, edges = [], ok = false; let wsMap = {}, edges = [], ok = false;
try { try {
const [rw, rto, rr, rd] = await Promise.all([ const [rw, rto, rr, rd] = await Promise.all([
apiFetch("/workstreams/"), apiFetch("/workplans/"),
apiFetch("/topics/"), apiFetch("/topics/"),
apiFetch("/repos/"), apiFetch("/repos/"),
apiFetch("/state/deps"), apiFetch("/state/deps"),

View File

@@ -63,7 +63,7 @@ Current loaders:
| File | API endpoint | | File | API endpoint |
|---|---| |---|---|
| `summary.json.py` | `/state/summary` | | `summary.json.py` | `/state/summary` |
| `workstreams.json.py` | `/workstreams/` | | `workstreams.json.py` | `/workplans/` |
| `contributions.json.py` | `/contributions/` | | `contributions.json.py` | `/contributions/` |
| `decisions.json.py` | `/decisions/` | | `decisions.json.py` | `/decisions/` |
| `domains.json.py` | `/domains/` | | `domains.json.py` | `/domains/` |
@@ -144,7 +144,7 @@ The dashboard has 30+ pages organised in four navigation groups:
| Page | Route | Purpose | | 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 | | Decisions | `/decisions` | Decision log with resolve-in-place form |
| Dependencies | `/dependencies` | Dependency graph explorer | | Dependencies | `/dependencies` | Dependency graph explorer |
| Extensions | `/extensions` | Extension point registry | | 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: All shared components live in `src/components/` and are imported as ES modules:
### `config.js` ### `config.js`
Exports two constants used by every live-polling page: Exports shared runtime configuration used by every live-polling page:
- `API = "http://127.0.0.1:8000"` — the FastAPI base URL - `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 - `POLL = 15_000` — polling interval in milliseconds
### `entity-modal.js` ### `entity-modal.js`

View File

@@ -62,7 +62,7 @@ create_dependency(
Via REST: Via REST:
```bash ```bash
curl -X POST http://127.0.0.1:8000/workstreams/<from_id>/dependencies/ \ curl -X POST http://127.0.0.1:8000/workplans/<from_id>/dependencies/ \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"to_workstream_id": "<to_id>", "description": "..."}' -d '{"to_workstream_id": "<to_id>", "description": "..."}'
``` ```

View File

@@ -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. 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": "<uuid>"}`. 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": "<uuid>"}`. Legacy `create_workstream` and `/workstreams/{id}/` callers remain compatibility-supported while they are metered.
--- ---

View File

@@ -49,7 +49,7 @@ make api # db + migrate + uvicorn (restarts if already running)
| Page | Endpoints | | Page | Endpoints |
|---|---| |---|---|
| Overview | `/state/summary` | | Overview | `/state/summary` |
| Workstreams | `/workstreams/`, `/topics/`, `/state/summary` | | Workplans | `/workplans/`, `/topics/`, `/state/summary` |
| Decisions | `/decisions/?limit=500`, `/topics/` | | Decisions | `/decisions/?limit=500`, `/topics/` |
| Progress | `/progress/?limit=500` | | Progress | `/progress/?limit=500` |

View File

@@ -83,8 +83,8 @@ and summary.
## Data source ## Data source
Polls `GET /state/summary` every **15 seconds**. The workstream chart also polls Polls `GET /state/summary` every **15 seconds**. The workstream chart also polls
`GET /workstreams/`, `GET /tasks/?limit=2000`, `GET /topics/`, `GET /repos/`, `GET /workplans/`, `GET /tasks/?limit=2000`, `GET /topics/`, `GET /repos/`,
and `GET /workstreams/workplan-index` for repository grouping, task counts, and and `GET /workplans/index` for repository grouping, task counts, and
workplan filename tooltips. Blocking decisions are fetched separately via workplan filename tooltips. Blocking decisions are fetched separately via
`GET /decisions/?decision_type=pending` and only re-fetched after a successful `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. resolve action — this prevents the inline form from being wiped on every poll.

View File

@@ -95,5 +95,5 @@ status group.
Polls every **15 seconds**: Polls every **15 seconds**:
- `GET /tasks/?limit=500` - `GET /tasks/?limit=500`
- `GET /workstreams/` - `GET /workplans/`
- `GET /topics/` - `GET /topics/`

View File

@@ -64,7 +64,7 @@ to this reference page.
Polls every **15 seconds**: Polls every **15 seconds**:
- `GET /tasks/?limit=500` — all tasks - `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 /topics/` — for domain slug resolution
- `GET /contributions/` — for third-party todos - `GET /contributions/` — for third-party todos

View File

@@ -77,7 +77,7 @@ advance_workstation(entity_type="workstream", entity_id="<uuid>", target_worksta
Direct status patching still exists for bootstrap and compatibility work: Direct status patching still exists for bootstrap and compatibility work:
```bash ```bash
curl -X PATCH http://127.0.0.1:8000/workstreams/<uuid>/ \ curl -X PATCH http://127.0.0.1:8000/workplans/<uuid>/ \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"status": "finished"}' -d '{"status": "finished"}'
``` ```

View File

@@ -108,7 +108,7 @@ create_workstream(
Via REST: Via REST:
```bash ```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" \ -H "Content-Type: application/json" \
-d '{"topic_id": "<uuid>", "title": "…", "status": "ready"}' -d '{"topic_id": "<uuid>", "title": "…", "status": "ready"}'
``` ```

View File

@@ -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 Each event carries the report under `detail.report`, with a summary and a list
of recommendations. Candidate values are resolved through 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. workstream detail pages.
## How to read recommendations ## How to read recommendations

View File

@@ -39,11 +39,11 @@ const pageState = (async function*() {
loadJson("summary", "/state/summary", {timeout: 20_000}), loadJson("summary", "/state/summary", {timeout: 20_000}),
loadJson("sbom snapshots", "/sbom/snapshots/"), loadJson("sbom snapshots", "/sbom/snapshots/"),
loadJson("milestones", "/progress/?event_type=milestone&limit=500"), loadJson("milestones", "/progress/?event_type=milestone&limit=500"),
loadJson("workstreams", "/workstreams/"), loadJson("workplans", "/workplans/"),
loadJson("tasks", "/tasks/?limit=2000"), loadJson("tasks", "/tasks/?limit=2000"),
loadJson("topics", "/topics/"), loadJson("topics", "/topics/"),
loadJson("repos", "/repos/"), loadJson("repos", "/repos/"),
loadJson("workplan index", "/workstreams/workplan-index").catch(() => ({workstreams: {}})), loadJson("workplan index", "/workplans/index").catch(() => ({workplans: {}, workstreams: {}})),
]); ]);
ok = true; ok = true;

View File

@@ -15,7 +15,7 @@ const interventionState = (async function*() {
try { try {
const [rt, rw, rto, rr] = await Promise.all([ const [rt, rw, rto, rr] = await Promise.all([
apiFetch("/tasks/?limit=500"), apiFetch("/tasks/?limit=500"),
apiFetch("/workstreams/"), apiFetch("/workplans/"),
apiFetch("/topics/"), apiFetch("/topics/"),
apiFetch("/repos/"), apiFetch("/repos/"),
]); ]);

View File

@@ -16,7 +16,7 @@ try {
fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []), fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/extension-points/`).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}/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 {} } catch {}
``` ```

View File

@@ -14,7 +14,7 @@ const taskState = (async function*() {
try { try {
const [rt, rw, rto, rr] = await Promise.all([ const [rt, rw, rto, rr] = await Promise.all([
apiFetch("/tasks/?limit=500"), apiFetch("/tasks/?limit=500"),
apiFetch("/workstreams/"), apiFetch("/workplans/"),
apiFetch("/topics/"), apiFetch("/topics/"),
apiFetch("/repos/"), apiFetch("/repos/"),
]); ]);

View File

@@ -14,7 +14,7 @@ const tdState = (async function*() {
try { try {
const [rt, rw, rto, rr] = await Promise.all([ const [rt, rw, rto, rr] = await Promise.all([
apiFetch("/technical-debt/"), apiFetch("/technical-debt/"),
apiFetch("/workstreams/"), apiFetch("/workplans/"),
apiFetch("/topics/"), apiFetch("/topics/"),
apiFetch("/repos/"), apiFetch("/repos/"),
]); ]);

View File

@@ -16,7 +16,7 @@ const todoState = (async function*() {
try { try {
const [rt, rw, rto, rr, rc, ri] = await Promise.all([ const [rt, rw, rto, rr, rc, ri] = await Promise.all([
apiFetch("/tasks/?limit=500"), apiFetch("/tasks/?limit=500"),
apiFetch("/workstreams/"), apiFetch("/workplans/"),
apiFetch("/topics/"), apiFetch("/topics/"),
apiFetch("/repos/"), apiFetch("/repos/"),
apiFetch("/contributions/"), apiFetch("/contributions/"),

View File

@@ -104,7 +104,7 @@ function queueControls(row) {
} }
save.onclick = () => run("saving", async () => { 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", method: "PATCH",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload()), body: JSON.stringify(payload()),

View File

@@ -1,5 +1,5 @@
--- ---
title: Workstreams title: Workplans
--- ---
```js ```js
@@ -16,7 +16,7 @@ const wsState = (async function*() {
let data = [], openWs = [], ok = false; let data = [], openWs = [], ok = false;
try { try {
const [rw, rt, rr, rd] = await Promise.all([ const [rw, rt, rr, rd] = await Promise.all([
apiFetch("/workstreams/"), apiFetch("/workplans/"),
apiFetch("/topics/"), apiFetch("/topics/"),
apiFetch("/repos/"), apiFetch("/repos/"),
apiFetch("/state/deps"), apiFetch("/state/deps"),
@@ -134,7 +134,7 @@ const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unkno
}).filter(Boolean); }).filter(Boolean);
``` ```
# Workstreams # Workplans
```js ```js
import {injectTocTop} from "./components/toc-sidebar.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)"; } function _warnColor(lv) { return lv === 2 ? "#dc2626" : lv === 1 ? "#d97706" : "var(--theme-foreground-muted, #666)"; }
const _whiMetrics = [ 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: "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 workstreams currently in a blocked state; directly reduces the work that can proceed right now."}, {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 workstreams depended on by others but with no incoming dependencies themselves; losing one stalls everything downstream."}, {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 workstreams with zero blocking dependencies that could start or continue immediately."}, {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."}, {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`<div class="kpi-infobox whi-box"> const _whiBox = html`<div class="kpi-infobox whi-box">
<div class="kpi-infobox-title">Workstream Health</div> <div class="kpi-infobox-title">Workplan Health</div>
${_openCount === 0 ${_openCount === 0
? html`<div class="kpi-row"><span class="kpi-muted">No active workstreams</span></div>` ? html`<div class="kpi-row"><span class="kpi-muted">No active workplans</span></div>`
: html` : html`
<div class="whi-score-row"> <div class="whi-score-row">
<span class="whi-value" style="color:${_whiColor(_WHI)}">${(_WHI*100).toFixed(0)}<span class="whi-pct">%</span></span> <span class="whi-value" style="color:${_whiColor(_WHI)}">${(_WHI*100).toFixed(0)}<span class="whi-pct">%</span></span>
@@ -202,7 +202,7 @@ const _whiBox = html`<div class="kpi-infobox whi-box">
description="Domain-scoped WHI (intra-domain edges only). Open: ${d.openCount} · Blocked: ${(d.br*100).toFixed(0)}% · Runnable: ${(d.pep*100).toFixed(0)}%" 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}</help-tip> doc="/docs/workstream-health-index">${d.domain}</help-tip>
<span class="whi-domain-score" style="color:${_whiColor(d.whi)}">${(d.whi*100).toFixed(0)}%</span> <span class="whi-domain-score" style="color:${_whiColor(d.whi)}">${(d.whi*100).toFixed(0)}%</span>
${d.cpi === 1 ? html`<help-tip style="color:#d97706;font-size:0.7rem" label="Dependency Cycle" description="A circular dependency exists within this domain — workstreams are waiting on each other and cannot all proceed." doc="/docs/workstream-health-index">⚠</help-tip>` : ""} ${d.cpi === 1 ? html`<help-tip style="color:#d97706;font-size:0.7rem" label="Dependency Cycle" description="A circular dependency exists within this domain — workplans are waiting on each other and cannot all proceed." doc="/docs/workstream-health-index">⚠</help-tip>` : ""}
</div>`)} </div>`)}
</div>` : ""} </div>` : ""}
`} `}
@@ -276,7 +276,7 @@ display(Plot.plot({
})); }));
``` ```
## All Workstreams ## All Workplans
```js ```js
display(_filtersForm); display(_filtersForm);
@@ -313,7 +313,7 @@ const wsWithDeps = openWs.filter(w => {
}); });
if (wsWithDeps.length === 0) { if (wsWithDeps.length === 0) {
display(html`<p class="dim">No dependency edges recorded for the current filter. Use <code>create_dependency()</code> via the MCP server to link workstreams.</p>`); display(html`<p class="dim">No dependency edges recorded for the current filter. Use <code>create_dependency()</code> via the MCP server to link workplans.</p>`);
} else { } else {
display(html`<div class="dep-grid">${wsWithDeps.map(w => { display(html`<div class="dep-grid">${wsWithDeps.map(w => {
const depRows = w.depends_on.map(d => const depRows = w.depends_on.map(d =>

View File

@@ -11,13 +11,13 @@ import {statusControl, TASK_STATUSES, WORKSTREAM_STATUSES} from "../components/s
```js ```js
const wsId = observable.params.id; const wsId = observable.params.id;
const [raw, taskRows, workplanIndex] = await Promise.all([ 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}`}))) .then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
.catch(e => ({error: String(e)})), .catch(e => ({error: String(e)})),
fetch(`${API}/tasks/?workstream_id=${wsId}&limit=1000`) fetch(`${API}/tasks/?workstream_id=${wsId}&limit=1000`)
.then(r => r.ok ? r.json() : []) .then(r => r.ok ? r.json() : [])
.catch(() => []), .catch(() => []),
fetch(`${API}/workstreams/workplan-index`) fetch(`${API}/workplans/index`)
.then(r => r.ok ? r.json() : {workstreams: {}}) .then(r => r.ok ? r.json() : {workstreams: {}})
.catch(() => ({workstreams: {}})), .catch(() => ({workstreams: {}})),
]); ]);
@@ -31,7 +31,7 @@ if (raw.error) {
const name = raw.title || raw.slug || wsId; const name = raw.title || raw.slug || wsId;
const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name; const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name;
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Workstream · <em>${shortName}</em></h1>`); display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Workstream · <em>${shortName}</em></h1>`);
display(html`<p style="margin-top:0"><a href="/">← Overview</a> &nbsp;|&nbsp; <a href="/workstreams">← Workstreams</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`); display(html`<p style="margin-top:0"><a href="/">← Overview</a> &nbsp;|&nbsp; <a href="/workstreams">← Workplans</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`);
display(html`<div class="ws-summary"> display(html`<div class="ws-summary">
<div><span>Status</span>${statusControl({ <div><span>Status</span>${statusControl({

View File

@@ -27,7 +27,7 @@ const triageState = (async function*() {
try { try {
const [reportsResp, indexResp] = await Promise.all([ const [reportsResp, indexResp] = await Promise.all([
apiFetch("/progress/?event_type=daily_triage&limit=14"), apiFetch("/progress/?event_type=daily_triage&limit=14"),
apiFetch("/workstreams/workplan-index"), apiFetch("/workplans/index"),
]); ]);
ok = reportsResp.ok && indexResp.ok; ok = reportsResp.ok && indexResp.ok;
events = reportsResp.ok ? await reportsResp.json() : []; events = reportsResp.ok ? await reportsResp.json() : [];

View File

@@ -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,
);
});

View File

@@ -42,7 +42,8 @@ those publishers from colliding on the same `{noun}.{verb}` shape.
| Subject | When | Required attributes | | 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.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.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.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` | | `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` |

View File

@@ -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: <preferred interface>`
- `Link: <<preferred interface>>; 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.

View File

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

31
tests/test_cors.py Normal file
View File

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

167
tests/test_legacy_meter.py Normal file
View File

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

View File

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

View File

@@ -4,11 +4,11 @@ type: workplan
title: "Task State Canon Adaptation" title: "Task State Canon Adaptation"
domain: custodian domain: custodian
repo: state-hub repo: state-hub
status: active status: finished
owner: codex owner: codex
topic_slug: custodian topic_slug: custodian
created: "2026-05-25" created: "2026-05-25"
updated: "2026-05-25" updated: "2026-06-04"
state_hub_workstream_id: "bc54c18b-4d98-430d-b9ad-c4410010c897" state_hub_workstream_id: "bc54c18b-4d98-430d-b9ad-c4410010c897"
--- ---
@@ -321,7 +321,7 @@ task, or a recorded no-impact classification.
```task ```task
id: STATE-WP-0052-T10 id: STATE-WP-0052-T10
status: wait status: done
priority: medium priority: medium
state_hub_task_id: "1cde226a-6287-4db4-9d2f-7fa9ed0b6c4d" 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 Done when State Hub is canon-conformant, attached repos have been notified, and
the remaining compatibility window is explicit. the remaining compatibility window is explicit.
Current wait condition: attached repos have been notified through interface Close-out note: attached repos were notified through interface change
change `649102a2-4373-4621-9848-cc257e67c262`; closing the compatibility window `649102a2-4373-4621-9848-cc257e67c262`; decision
depends on repo-agent responses and a later decision on when aliases become `c386f42f-a50a-41d9-9457-f384227f8f6c` keeps legacy aliases accepted during the
warnings or errors. adaptation window and leaves any future warnings/errors to a later explicit
decision.
## Acceptance Criteria ## Acceptance Criteria

View File

@@ -4,13 +4,13 @@ type: workplan
title: "Workplan Terminology Transition and Legacy Meter" title: "Workplan Terminology Transition and Legacy Meter"
domain: custodian domain: custodian
repo: state-hub repo: state-hub
status: proposed status: finished
owner: codex owner: codex
topic_slug: custodian topic_slug: custodian
planning_priority: high planning_priority: high
planning_order: 54 planning_order: 54
created: "2026-06-03" created: "2026-06-03"
updated: "2026-06-03" updated: "2026-06-04"
state_hub_workstream_id: "471401c8-38b2-46fd-ae34-052825710376" state_hub_workstream_id: "471401c8-38b2-46fd-ae34-052825710376"
--- ---
@@ -100,7 +100,7 @@ record an explicit `unknown` bucket instead of dropping the observation.
```task ```task
id: STATE-WP-0054-T01 id: STATE-WP-0054-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "f43ed8c0-f62c-42af-b38f-d4ccd7a7bed5" 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 Done when the repo has a reviewed compatibility matrix that separates semantic
renames from high-risk storage or event-contract changes. 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 ### T02 - Add Preferred Workplan Interface Variants
```task ```task
id: STATE-WP-0054-T02 id: STATE-WP-0054-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "65dca8e4-a032-4b1a-a21f-8559f1fb87f5" 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 Done when new clients can use workplan-named interfaces without relying on
workstream-named entry points. 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 ### T03 - Implement Legacy Meter Data Model And Service
```task ```task
id: STATE-WP-0054-T03 id: STATE-WP-0054-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "b4008ab7-1f59-4ea7-a728-48557473c22d" 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 Done when tests can register a legacy interface and prove usage counters are
updated without changing the legacy interface's behavior. 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 ### T04 - Instrument Legacy Workstream Interfaces
```task ```task
id: STATE-WP-0054-T04 id: STATE-WP-0054-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "28c31bbc-4479-4dd9-8e2c-08235e81ba91" 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 Done when calls to selected `workstream` interfaces appear in legacy-meter
usage summaries with call counts and tenant/user/component buckets. 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 ### T05 - Add Legacy Usage Review And Retirement Signals
```task ```task
id: STATE-WP-0054-T05 id: STATE-WP-0054-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "fca14802-1a15-4b5a-8267-5348666b3c50" 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 Done when State Hub can produce a precise retirement-candidate list without
manual log scraping. 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 ### T06 - Schedule Weekly Activity-Core Review
```task ```task
id: STATE-WP-0054-T06 id: STATE-WP-0054-T06
status: todo status: done
priority: high priority: high
state_hub_task_id: "3d6fb438-707f-45e8-9c38-3e7352ae2a93" 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 Done when a weekly activity-core check can read legacy-meter summaries and
raise retirement work only for interfaces with no prior-week usage. 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 ### T07 - Update Documentation, Dashboard Labels, And Agent Guidance
```task ```task
id: STATE-WP-0054-T07 id: STATE-WP-0054-T07
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "55a05f68-a337-412c-8462-847e6465d15e" 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 Done when new users and agents are guided toward workplan terminology without
losing instructions for existing compatibility paths. 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 ### T08 - Prove Compatibility And Rollout Safety
```task ```task
id: STATE-WP-0054-T08 id: STATE-WP-0054-T08
status: todo status: done
priority: high priority: high
state_hub_task_id: "2ae2580f-4626-49fe-aec6-ca9340792afd" 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, Done when existing `workstream` clients still pass, new `workplan` clients pass,
and legacy-meter telemetry proves the compatibility window is observable. 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 ## Acceptance Criteria
- `workplan` is the recommended user-facing term in State Hub docs, dashboard - `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. documented replacement.
- No physical database or event-contract rename is required in this workplan - No physical database or event-contract rename is required in this workplan
unless compatibility evidence shows it is safe. unless compatibility evidence shows it is safe.