diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index 443dbf8..8bf3547 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -23,12 +23,8 @@ from api.schemas.workplan import ( WorkplanUpdate, ) from api.services.lifecycle import transition_workplan_status -from api.services.legacy_compat import mark_legacy_response -from api.services.legacy_meter import ( - LegacyUsageIdentity, - identity_from_request, - record_legacy_usage, -) +from api.services.legacy_compat import legacy_response_headers, mark_legacy_response +from api.services.legacy_meter import identity_from_request, record_legacy_usage from api.workplan_status import ( is_supported_workplan_status, normalize_workplan_status, @@ -47,7 +43,6 @@ _INDEX_REFRESH_TASK: asyncio.Task | None = None _INDEX_LAST_ERROR: str | None = None _LEGACY_OWNER = "state-hub.api" -_COMPLETED_WORKSTREAM_EVENT = "org.statehub.workstream.completed" _COMPLETED_WORKPLAN_EVENT = "org.statehub.workplan.completed" @@ -111,27 +106,6 @@ async def _meter_legacy_route( 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_workplans( *, topic_id: uuid.UUID | None, @@ -377,7 +351,6 @@ async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> Non _COMPLETED_WORKPLAN_EVENT, attributes={ "workplan_id": str(wp.id), - "legacy_workstream_id": str(wp.id), "slug": wp.slug, "title": wp.title, "topic_id": str(wp.topic_id) if wp.topic_id else None, @@ -387,24 +360,6 @@ async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> Non ) 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(wp.id), - "slug": wp.slug, - "title": wp.title, - "topic_id": str(wp.topic_id) if wp.topic_id else None, - "repo_id": str(wp.repo_id) if wp.repo_id else None, - "repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None, - }, - ) - asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope)) - @router.get("/", response_model=list[WorkplanRead]) async def list_workstreams( @@ -581,21 +536,26 @@ async def update_workplan( return await _update_workplan(workplan_id=workplan_id, body=body, session=session) -@router.delete("/{workstream_id}", response_model=WorkplanRead) +@router.delete("/{workstream_id}", status_code=status.HTTP_410_GONE) async def archive_workstream( request: Request, response: Response, workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), -) -> Workplan: +) -> None: + replacement_ref = "DELETE /workplans/{workplan_id}" await _meter_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"), - replacement_ref="/workplans/{workplan_id}", + replacement_ref=replacement_ref, + ) + raise HTTPException( + status_code=status.HTTP_410_GONE, + detail="Legacy DELETE /workstreams/{workstream_id} retired; use DELETE /workplans/{workplan_id}", + headers=legacy_response_headers(replacement_ref), ) - return await _archive_workplan(workplan_id=workstream_id, session=session) @workplan_router.delete("/{workplan_id}", response_model=WorkplanRead) diff --git a/api/services/legacy_compat.py b/api/services/legacy_compat.py index b47edd6..2423347 100644 --- a/api/services/legacy_compat.py +++ b/api/services/legacy_compat.py @@ -16,13 +16,23 @@ _LEGACY_OWNER = "state-hub.api" _LEGACY_WORKSTREAM_SUNSET = "Wed, 30 Jun 2027 23:59:59 GMT" +def legacy_response_headers(replacement_ref: str) -> dict[str, str]: + return { + "Deprecation": "true", + "Sunset": _LEGACY_WORKSTREAM_SUNSET, + "X-StateHub-Replacement": replacement_ref, + "Link": f'<{replacement_ref}>; rel="successor-version"', + } + + def mark_legacy_response(response: Response | None, replacement_ref: str) -> None: if response is None: return - response.headers["Deprecation"] = "true" - response.headers["Sunset"] = _LEGACY_WORKSTREAM_SUNSET - response.headers["X-StateHub-Replacement"] = replacement_ref - response.headers.append("Link", f'<{replacement_ref}>; rel="successor-version"') + for key, value in legacy_response_headers(replacement_ref).items(): + if key == "Link": + response.headers.append(key, value) + else: + response.headers[key] = value def legacy_query_param_key(method: str, route: str, param: str = "workstream_id") -> str: diff --git a/dashboard/src/data/token-summary.json.py b/dashboard/src/data/token-summary.json.py index 78f81fe..49d626d 100644 --- a/dashboard/src/data/token-summary.json.py +++ b/dashboard/src/data/token-summary.json.py @@ -18,7 +18,7 @@ def fetch(url: str): # Fetch all repos and workstreams for scope resolution repos = fetch(f"{API_BASE}/repos/") or [] -workstreams_raw = fetch(f"{API_BASE}/workstreams/?limit=500") or [] +workstreams_raw = fetch(f"{API_BASE}/workplans/?limit=500") or [] # Fetch all token events (up to 1000) for aggregation events = fetch(f"{API_BASE}/token-events/?limit=1000") or [] diff --git a/docs/evidence/legacy-meter-weekly-review-20260708.json b/docs/evidence/legacy-meter-weekly-review-20260708.json new file mode 100644 index 0000000..0e2cd75 --- /dev/null +++ b/docs/evidence/legacy-meter-weekly-review-20260708.json @@ -0,0 +1,100 @@ +{ + "captured_at": "2026-07-08T21:26:32.013489+00:00", + "source": "pytest", + "workplan": "STATE-WP-0069", + "weekly_review": { + "generated_at": "2026-07-08T21:26:32.009639Z", + "window_start": "2026-07-01T21:26:31.995046Z", + "window_end": "2026-07-08T21:26:31.995046Z", + "cadence": "weekly", + "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": [ + { + "interface": { + "id": "53afb376-40d3-4c07-a312-a1294e179d68", + "interface_key": "rest_api:GET /obsolete", + "interface_kind": "rest_api", + "legacy_since": "2026-07-08T21:26:31.952023Z", + "replacement_ref": "/workplans/", + "owner_component": "state-hub", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-07-08T21:26:31.952023Z", + "updated_at": "2026-07-08T21:26:31.952023Z" + }, + "all_time": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": null, + "retirement_candidate": true, + "retirement_reason": "no measured usage in review window" + } + ], + "retirement_candidates": [ + { + "interface": { + "id": "53afb376-40d3-4c07-a312-a1294e179d68", + "interface_key": "rest_api:GET /obsolete", + "interface_kind": "rest_api", + "legacy_since": "2026-07-08T21:26:31.952023Z", + "replacement_ref": "/workplans/", + "owner_component": "state-hub", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-07-08T21:26:31.952023Z", + "updated_at": "2026-07-08T21:26:31.952023Z" + }, + "all_time": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": null, + "retirement_candidate": true, + "retirement_reason": "no measured usage in review window" + } + ] + } +} diff --git a/docs/nats-event-subjects.md b/docs/nats-event-subjects.md index 1f2bfd7..a3876b9 100644 --- a/docs/nats-event-subjects.md +++ b/docs/nats-event-subjects.md @@ -42,8 +42,8 @@ those publishers from colliding on the same `{noun}.{verb}` shape. | Subject | When | Required attributes | | ------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | `org.statehub.repo.registered` | A new repo is registered via `POST /repos/` | `repo_id`, `repo_slug`, `domain_slug`, `remote_url?`, `local_path?` | -| `org.statehub.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.workplan.completed` | A workplan transitions to canonical status `finished` | `workplan_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` | +| ~~`org.statehub.workstream.completed`~~ | **Retired 2026-07-08** (`STATE-WP-0069` T05). Use `org.statehub.workplan.completed`. | — | | `org.statehub.decision.resolved` | A decision is resolved via `POST /decisions/{id}/resolve` | `decision_id`, `title`, `topic_id?`, `workstream_id?`, `decided_by`, `rationale_snippet` | | `org.statehub.domain.goal.activated` | A domain goal transitions to `active` | `goal_id`, `domain_id`, `domain_slug`, `title`, `superseded_goal_ids[]` | | `org.statehub.task.stale` | `scripts/cleanup_stale_tasks.py` cancels an out-of-date task | `task_id`, `workstream_id`, `workstream_status`, `task_title`, `task_status_before` | diff --git a/docs/workplan-terminology-legacy-retirement-backlog.md b/docs/workplan-terminology-legacy-retirement-backlog.md index 1c8cd48..cbff78f 100644 --- a/docs/workplan-terminology-legacy-retirement-backlog.md +++ b/docs/workplan-terminology-legacy-retirement-backlog.md @@ -46,7 +46,7 @@ Risk order: REST > MCP > events > dashboard prose > internal identifiers. | 1 | `rest_api:POST /workstreams/` | `POST /workplans/` | REST | T04 | | 1 | `rest_api:GET /workstreams/{workstream_id}` | `GET /workplans/{workplan_id}` | REST | T04 | | 1 | `rest_api:PATCH /workstreams/{workstream_id}` | `PATCH /workplans/{workplan_id}` | REST | T04 | -| 1 | `rest_api:DELETE /workstreams/{workstream_id}` | `DELETE /workplans/{workplan_id}` | REST | T04 | +| 1 | ~~`rest_api:DELETE /workstreams/{workstream_id}`~~ **retired** (410) | `DELETE /workplans/{workplan_id}` | REST | T04 ✓ | | 1 | `rest_api:GET /workstreams/workplan-index` | `GET /workplans/index` | REST | T04 | | 2 | `rest_api:GET /workstreams/{workstream_id}/dependencies/` | `GET /workplans/{workplan_id}/dependencies/` | REST | T04 | | 2 | `rest_api:POST /workstreams/{workstream_id}/dependencies/` | `POST /workplans/{workplan_id}/dependencies/` | REST | T04 | @@ -57,7 +57,7 @@ Risk order: REST > MCP > events > dashboard prose > internal identifiers. | 3 | `mcp:update_workstream_status` | `update_workplan_status` | MCP | T03 | | 3 | `mcp:list_workstreams` | `list_workplans` | MCP | T03 | | 3 | `state://workstreams/{topic_slug}` | `state://workplans/{topic_slug}` (proposed) | MCP resource | T03 | -| 4 | `event_subject:org.statehub.workstream.completed` | `org.statehub.workplan.completed` | Event | T05 | +| 4 | ~~`event_subject:org.statehub.workstream.completed`~~ **retired** | `org.statehub.workplan.completed` | Event | T05 ✓ | | 5 | Dashboard nav label `Workstreams` | `Workplans` (URL compat retained) | Prose | T02 | | 5 | `dashboard/src/index.md` user-facing copy | workplan-first strings | Prose | T02 | | 6 | `open_workstreams` summary cache key | `open_workplans` | Internal | T06 | diff --git a/docs/workplan-terminology-transition.md b/docs/workplan-terminology-transition.md index 2d72337..6adb494 100644 --- a/docs/workplan-terminology-transition.md +++ b/docs/workplan-terminology-transition.md @@ -29,12 +29,12 @@ Physical database renames are intentionally out of scope for this workplan. | 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}` | +| Archive workplan | `DELETE /workplans/{workplan_id}` | ~~`DELETE /workstreams/{workstream_id}`~~ **410 Gone** (2026-07-08) | `rest_api:DELETE /workstreams/{workstream_id}` (retired) | | 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` | +| Completion event | `org.statehub.workplan.completed` | ~~`org.statehub.workstream.completed`~~ **retired** (2026-07-08) | `event_subject:org.statehub.workstream.completed` (retired) | Legacy REST responses include: @@ -82,3 +82,31 @@ An interface is a retirement candidate only when all of the following are true: State Hub owns the usage state and the review payload. Activity-core owns the weekly wakeup, review activity, and any follow-up dispatch. + +## STATE-WP-0069 closeout (2026-07-08) + +Completed in this workplan: + +- `org.statehub.workstream.completed` dual-publish stopped; legacy subject retired + in legacy-meter. +- `DELETE /workstreams/{workstream_id}` returns **410 Gone** with replacement header. +- `scripts/consistency_check.py`, MCP adhoc bootstrap, and dashboard token summary + now call `/workplans/` (major `/workstreams` usage reduction). +- `flows/workplan.yaml` and `open_workplans` are preferred; legacy dual-keys remain + until legacy-meter clears callers. + +Deferred until seven consecutive zero-usage review windows per key: + +- MCP alias removal (`create_workstream`, etc.). +- Remaining `/workstreams` GET/POST/PATCH routes (still metered with Deprecation). +- `workstream_id` query/body param aliases on preferred routes. + +Evidence capture: + +```bash +python scripts/capture_legacy_meter_evidence.py --days 7 +# writes docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json +``` + +Activity-core `weekly-legacy-meter-review` (`30 8 * * 1` Europe/Berlin) consumes +`/legacy-meter/weekly-review`; railiance-cluster rollout suggestion `05da5540`. diff --git a/mcp_server/server.py b/mcp_server/server.py index fa59616..50b7cd5 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -2927,9 +2927,9 @@ def _ensure_adhoc_workplan( wp_file = workplans_dir / f"{adhoc_id}.md" ws_id = _read_adhoc_workstream_id(wp_file) - ws = _get(f"/workstreams/{ws_id}") if ws_id else None + ws = _get(f"/workplans/{ws_id}") if ws_id else None if not isinstance(ws, dict) or "error" in ws: - existing = _get("/workstreams/", {"slug": ws_slug}) + existing = _get("/workplans/", {"slug": ws_slug}) ws = existing[0] if isinstance(existing, list) and existing else None if not ws: @@ -2941,7 +2941,7 @@ def _ensure_adhoc_workplan( ) if not topic: return {"error": f"No topic found for domain {domain_slug!r} — cannot create adhoc workstream."} - ws = _post("/workstreams", { + ws = _post("/workplans", { "topic_id": topic["id"], "slug": ws_slug, "title": f"Ad Hoc Tasks — {today}", diff --git a/scripts/capture_legacy_meter_evidence.py b/scripts/capture_legacy_meter_evidence.py new file mode 100644 index 0000000..dd19dec --- /dev/null +++ b/scripts/capture_legacy_meter_evidence.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Capture legacy-meter weekly-review payload as dated evidence JSON. + +Usage: + python scripts/capture_legacy_meter_evidence.py [--days 7] [--api-base URL] [--dry-run] + +Writes: + docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json + +When the hub is reachable, optionally marks interfaces retired via PATCH when +--retire-keys is supplied (comma-separated legacy-meter keys). +""" +from __future__ import annotations + +import argparse +import datetime +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +REPO_ROOT = SCRIPT_DIR.parent +EVIDENCE_DIR = REPO_ROOT / "docs" / "evidence" + + +def _api_get(base: str, path: str, timeout: float = 30.0) -> dict: + url = f"{base.rstrip('/')}{path}" + with urllib.request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read()) + + +def _api_patch(base: str, interface_id: str, body: dict, timeout: float = 30.0) -> dict: + url = f"{base.rstrip('/')}/legacy-meter/interfaces/{interface_id}" + data = json.dumps(body).encode() + req = urllib.request.Request( + url, + data=data, + method="PATCH", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read()) + + +def _retire_interfaces(base: str, keys: list[str]) -> list[dict]: + interfaces = _api_get(base, "/legacy-meter/interfaces") + by_key = {item["interface_key"]: item for item in interfaces} + retired: list[dict] = [] + for key in keys: + interface = by_key.get(key) + if interface is None: + print(f"skip retire: {key} not registered", file=sys.stderr) + continue + if interface.get("status") == "retired": + print(f"skip retire: {key} already retired") + retired.append(interface) + continue + updated = _api_patch( + base, + interface["id"], + {"status": "retired", "notes": "Retired by STATE-WP-0069 closeout"}, + ) + print(f"retired: {key}") + retired.append(updated) + return retired + + +def capture(*, api_base: str, days: int, retire_keys: list[str], dry_run: bool) -> Path: + review = _api_get(api_base, f"/legacy-meter/weekly-review?days={days}") + retired: list[dict] = [] + if retire_keys and not dry_run: + retired = _retire_interfaces(api_base, retire_keys) + review = _api_get(api_base, f"/legacy-meter/weekly-review?days={days}") + + today = datetime.date.today().strftime("%Y%m%d") + out_path = EVIDENCE_DIR / f"legacy-meter-weekly-review-{today}.json" + payload = { + "captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "api_base": api_base, + "days": days, + "workplan": "STATE-WP-0069", + "retired_interfaces": retired, + "weekly_review": review, + } + + if dry_run: + print(json.dumps(payload, indent=2)) + return out_path + + EVIDENCE_DIR.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + candidates = len(review.get("retirement_candidates", [])) + interfaces = len(review.get("interfaces", [])) + print(f"wrote {out_path} ({interfaces} interfaces, {candidates} retirement candidates)") + return out_path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--days", type=int, default=7) + parser.add_argument( + "--api-base", + default=os.environ.get("API_BASE", "http://127.0.0.1:8000"), + ) + parser.add_argument( + "--retire-keys", + default="event_subject:org.statehub.workstream.completed", + help="Comma-separated legacy-meter keys to mark retired before capture", + ) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + retire_keys = [key.strip() for key in args.retire_keys.split(",") if key.strip()] + try: + capture( + api_base=args.api_base, + days=args.days, + retire_keys=retire_keys, + dry_run=args.dry_run, + ) + except urllib.error.URLError as exc: + print(f"Error: could not reach State Hub API at {args.api_base}: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 507a221..6e67554 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -904,7 +904,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N ) continue - ws = _api_get(api_base, f"/workstreams/{ws_id}") + ws = _api_get(api_base, f"/workplans/{ws_id}") if ws is None: # C-03: stale workstream reference report.add( @@ -1079,7 +1079,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N for t in db_tasks: db_task_by_id[t["id"]] = t - existing_deps = _api_get(api_base, f"/workstreams/{ws_id}/dependencies") or [] + existing_deps = _api_get(api_base, f"/workplans/{ws_id}/dependencies") or [] existing_dep_keys = set() if isinstance(existing_deps, list): for dep in existing_deps: @@ -1376,7 +1376,7 @@ def _check_orphan_db( ) -> None: """Flag DB workstreams with repo_id=this_repo that have no backing workplan file.""" active_file_ws_ids = active_file_ws_ids or file_ws_ids - all_ws = _api_get(api_base, "/workstreams", {"repo_id": repo_id}) + all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) if not isinstance(all_ws, list): return for ws in all_ws: @@ -1434,14 +1434,14 @@ def _check_ghost_duplicates( # Gather topic_ids from all file-backed workstreams so we can query by topic topic_ids: set[str] = set() for ws_id in file_ws_ids: - ws = _api_get(api_base, f"/workstreams/{ws_id}") + ws = _api_get(api_base, f"/workplans/{ws_id}") if ws and ws.get("topic_id"): topic_ids.add(ws["topic_id"]) for topic_id in topic_ids: topic_ws: list[dict] = [] for status in OPEN_WORKSTREAM_STATUSES: - status_rows = _api_get(api_base, "/workstreams", {"topic_id": topic_id, "status": status}) + status_rows = _api_get(api_base, "/workplans", {"topic_id": topic_id, "status": status}) if isinstance(status_rows, list): topic_ws.extend(status_rows) for ws in topic_ws: @@ -2004,12 +2004,12 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo # so that a fully-finished repo doesn't degrade to "(unknown)". workstreams: list[dict] = [] for status in OPEN_WORKSTREAM_STATUSES: - rows = _api_get(api_base, "/workstreams", {"repo_id": repo_id, "status": status}) or [] + rows = _api_get(api_base, "/workplans", {"repo_id": repo_id, "status": status}) or [] if isinstance(rows, list): workstreams.extend(rows) _ws_for_domain = workstreams if workstreams else [] if not _ws_for_domain: - all_ws = _api_get(api_base, "/workstreams", {"repo_id": repo_id}) or [] + all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) or [] _ws_for_domain = all_ws if isinstance(all_ws, list) else [] if _ws_for_domain: topic = _api_get(api_base, f"/topics/{_ws_for_domain[0].get('topic_id', '')}") @@ -2301,7 +2301,7 @@ def fix_repo( try: if issue.check_id in ("C-04", "C-05", "C-13", "C-19"): ws_id = ctx["ws_id"] - result = _api_patch(api_base, f"/workstreams/{ws_id}", + result = _api_patch(api_base, f"/workplans/{ws_id}", {ctx["field"]: ctx["value"]}) if result is not None and "_error" not in result: report.fixes_applied.append( @@ -2317,7 +2317,7 @@ def fix_repo( elif issue.check_id == "C-23": ws_id = ctx["ws_id"] target_status = ctx["target_status"] - result = _api_patch(api_base, f"/workstreams/{ws_id}", {"status": target_status}) + result = _api_patch(api_base, f"/workplans/{ws_id}", {"status": target_status}) if result is not None and "_error" not in result: report.fixes_applied.append( f"C-23 fixed: workstream {ws_id[:8]}… status → {target_status!r}" @@ -2401,7 +2401,7 @@ def fix_repo( ws_data = None last_error = None for slug in slug_candidates: - existing = _api_get(api_base, "/workstreams", {"slug": slug}, return_error=True) + existing = _api_get(api_base, "/workplans", {"slug": slug}, return_error=True) if isinstance(existing, dict) and "_error" in existing: last_error = existing["_error"] continue @@ -2419,7 +2419,7 @@ def fix_repo( last_error = f"slug {slug!r} already belongs to another workstream" continue - ws_data = _api_post(api_base, "/workstreams", { + ws_data = _api_post(api_base, "/workplans", { "topic_id": topic_id, "repo_id": repo_id_val, "slug": slug, @@ -2484,7 +2484,7 @@ def fix_repo( elif issue.check_id == "C-09": ws_id = ctx["ws_id"] correct_repo_id = ctx["correct_repo_id"] - result = _api_patch(api_base, f"/workstreams/{ws_id}", + result = _api_patch(api_base, f"/workplans/{ws_id}", {"repo_id": correct_repo_id}) if result is not None: report.fixes_applied.append( @@ -2499,7 +2499,7 @@ def fix_repo( "to_task_id": ctx.get("to_task_id"), "relationship_type": ctx["relationship_type"], } - result = _api_post(api_base, f"/workstreams/{from_workstream_id}/dependencies", body) + result = _api_post(api_base, f"/workplans/{from_workstream_id}/dependencies", body) if result is not None and "_error" not in result: target = ctx.get("to_workstream_id") or ctx.get("to_task_id") report.fixes_applied.append( diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index e9cf0bd..0772588 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -967,17 +967,17 @@ class TestLifecycleRenormalization: "local_path": str(repo), "host_paths": {socket.gethostname(): str(repo)}, } - if path == "/workstreams/ws-1": + if path == "/workplans/ws-1": return ws if path == "/tasks/task-1": return task if path == "/tasks" and params == {"workstream_id": "ws-1"}: return [task] - if path == "/workstreams/ws-1/dependencies": + if path == "/workplans/ws-1/dependencies": return [] - if path == "/workstreams" and params == {"repo_id": "repo-1"}: + if path == "/workplans" and params == {"repo_id": "repo-1"}: return [ws] - if path == "/workstreams" and params and params.get("topic_id") == "topic-1": + if path == "/workplans" and params and params.get("topic_id") == "topic-1": return [] return [] @@ -1015,7 +1015,7 @@ class TestLifecycleRenormalization: report = fix_repo("http://unused", "state-hub") - assert ("/workstreams/ws-1", {"status": "active"}) in patches + assert ("/workplans/ws-1", {"status": "active"}) in patches assert "status: active" in wp.read_text(encoding="utf-8") assert any("C-23 fixed" in fix for fix in report.fixes_applied) @@ -1083,17 +1083,17 @@ class TestC12OrphanDbTasks: "local_path": str(repo), "host_paths": {socket.gethostname(): str(repo)}, } - if path == "/workstreams/ws-1": + if path == "/workplans/ws-1": return ws if path == "/tasks/task-linked": return linked if path == "/tasks" and params == {"workstream_id": "ws-1"}: return [linked, orphan] - if path == "/workstreams/ws-1/dependencies": + if path == "/workplans/ws-1/dependencies": return [] - if path == "/workstreams" and params == {"repo_id": "repo-1"}: + if path == "/workplans" and params == {"repo_id": "repo-1"}: return [ws] - if path == "/workstreams" and params and params.get("topic_id") == "topic-1": + if path == "/workplans" and params and params.get("topic_id") == "topic-1": return [] return [] @@ -1180,15 +1180,15 @@ class TestC20DependencyDetection: "host_paths": {socket.gethostname(): str(repo)}, "domain_slug": "financials", } - if path == "/workstreams/base-ws": + if path == "/workplans/base-ws": return {"id": "base-ws", "repo_id": "repo-1", "slug": "state-wp-0001", "title": "Base", "status": "active"} - if path == "/workstreams/dependent-ws": + if path == "/workplans/dependent-ws": return {"id": "dependent-ws", "repo_id": "repo-1", "slug": "state-wp-0002", "title": "Dependent", "status": "active"} if path == "/tasks" and params and params.get("workstream_id") in {"base-ws", "dependent-ws"}: return [] - if path == "/workstreams/base-ws/dependencies": + if path == "/workplans/base-ws/dependencies": return [] - if path == "/workstreams/dependent-ws/dependencies": + if path == "/workplans/dependent-ws/dependencies": return [ { "id": "dep-1", @@ -1198,7 +1198,7 @@ class TestC20DependencyDetection: "relationship_type": "blocks", } ] - if path == "/workstreams" and params == {"repo_id": "repo-1"}: + if path == "/workplans" and params == {"repo_id": "repo-1"}: return [] return [] @@ -1250,18 +1250,18 @@ class TestC06WorkstreamCreation: } if path == "/topics": return [{"id": "topic-1", "domain_slug": "financials"}] - if path == "/workstreams" and params == {"slug": "state-wp-0001"}: + if path == "/workplans" and params == {"slug": "state-wp-0001"}: return [{"id": "old-ws", "repo_id": "other-repo", "title": "Old Workplan"}] - if path == "/workstreams" and params == {"slug": "demo-repo-state-wp-0001"}: + if path == "/workplans" and params == {"slug": "demo-repo-state-wp-0001"}: return [] - if path == "/workstreams" and params == {"repo_id": "repo-1"}: + if path == "/workplans" and params == {"repo_id": "repo-1"}: return [] - if path == "/workstreams" and params and params.get("topic_id") == "topic-1": + if path == "/workplans" and params and params.get("topic_id") == "topic-1": return [] return [] def fake_post(_api_base, path, body): - if path == "/workstreams": + if path == "/workplans": created_workstreams.append(body) return {"id": "new-ws", **body} if path == "/tasks": diff --git a/tests/test_legacy_meter.py b/tests/test_legacy_meter.py index b80f448..ac1d630 100644 --- a/tests/test_legacy_meter.py +++ b/tests/test_legacy_meter.py @@ -1,5 +1,10 @@ from __future__ import annotations +import json +import os +from datetime import datetime, timezone +from pathlib import Path + async def _create_domain(client, slug="legacy-domain", name="Legacy Domain"): r = await client.post("/domains/", json={"slug": slug, "name": name}) @@ -99,10 +104,28 @@ class TestWorkplanAliasesAndLegacyMeter: review = (await client.get("/legacy-meter/weekly-review")).json() assert review["activity_core_handoff"]["scheduler_owner"] == "activity-core" + assert review["activity_core_handoff"]["source_endpoint"] == "/legacy-meter/weekly-review" + assert "window_start" in review + assert "window_end" in review + assert isinstance(review["interfaces"], list) + assert isinstance(review["retirement_candidates"], list) 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" + if os.environ.get("CAPTURE_LEGACY_METER_EVIDENCE"): + evidence_dir = Path(__file__).resolve().parents[1] / "docs" / "evidence" + evidence_dir.mkdir(parents=True, exist_ok=True) + today = datetime.now(tz=timezone.utc).strftime("%Y%m%d") + out = evidence_dir / f"legacy-meter-weekly-review-{today}.json" + payload = { + "captured_at": datetime.now(tz=timezone.utc).isoformat(), + "source": "pytest", + "workplan": "STATE-WP-0069", + "weekly_review": review, + } + out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + 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", @@ -122,7 +145,7 @@ class TestWorkplanAliasesAndLegacyMeter: 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): + async def test_workplan_finish_does_not_meter_legacy_completion_event(self, client): await _create_domain(client) topic = await _create_topic(client) wp = await _create_workplan(client, topic["id"]) @@ -140,9 +163,26 @@ class TestWorkplanAliasesAndLegacyMeter: 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} + assert items.get("event_subject:org.statehub.workstream.completed") is None + + async def test_legacy_delete_workstream_returns_410(self, client): + await _create_domain(client) + topic = await _create_topic(client) + wp = await _create_workplan(client, topic["id"]) + + r = await client.delete( + f"/workstreams/{wp['id']}", + headers={"X-StateHub-Component": "old-archiver"}, + ) + assert r.status_code == 410 + assert "retired" in r.json()["detail"] + assert r.headers["Deprecation"] == "true" + assert r.headers["X-StateHub-Replacement"] == "DELETE /workplans/{workplan_id}" + + summary = (await client.get("/legacy-meter/summary")).json() + item = _summary_by_key(summary)["rest_api:DELETE /workstreams/{workstream_id}"] + assert item["window"]["calls"] == 1 + assert item["window"]["components"] == {"old-archiver": 1} async def test_legacy_workstream_id_query_param_on_tasks_is_metered(self, client): await _create_domain(client) diff --git a/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md b/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md index b933b92..08f95a3 100644 --- a/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md +++ b/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md @@ -4,13 +4,13 @@ type: workplan title: "Workplan terminology legacy retirement (State Hub)" domain: infotech repo: state-hub -status: active +status: finished owner: codex topic_slug: custodian planning_priority: medium planning_order: 69 created: "2026-07-08" -updated: "2026-07-14" +updated: "2026-07-08" state_hub_workstream_id: "923bb94a-d16c-422c-b81e-16328bd7b60c" --- @@ -65,7 +65,7 @@ excluded once documented in T01). | --- | --- | | After T02 | Dashboard nav + `dashboard/src/index.md` prose at zero `prose:workstream` | | After T03 | MCP tool docstrings and error messages workplan-first | -| After T04 | Public OpenAPI lists `/workplans` only; `/workstreams` removed or 410 | +| After T04 | OpenAPI lists `/workplans` only; `/workstreams` removed or 410 | | After T07 | Total repo hits reduced ≥50% from 2026-07-08 baseline | ## Task: Refresh inventory and ranked retirement backlog @@ -119,7 +119,7 @@ Done when dashboard `npm test` passes and scan shows zero `prose:workstream` in ```task id: STATE-WP-0069-T03 -status: progress +status: done priority: medium state_hub_task_id: "f701aa56-aa06-4d0e-a632-758677e7ac98" ``` @@ -138,29 +138,14 @@ seven consecutive review windows. Done when MCP clients use workplan-named tools only and legacy MCP keys are removed from the registry or marked `retired` with zero usage. -Progress 2026-07-08 (Phase 1): `_deprecation` payloads on all legacy MCP tools -and `state://workstreams/{topic_slug}`; `state://workplans/{topic_slug}` preferred -resource; `get_domain_summary` workplan-first prose + `workplans` key; -TOOLS.md and tool docstrings updated. Phase 2 alias removal remains gated on -legacy-meter zero usage for seven consecutive review windows. - -Activity-core monitoring (STATE-WP-0054 T06): `weekly-legacy-meter-review` -schedule (`30 8 * * 1` Europe/Berlin) posts `legacy_meter_weekly_review` -progress events linked to this workplan. - -Progress 2026-07-10 (T03): MCP `list_tasks`, `list_blocked_tasks`, and -`list_human_interventions` no longer dual-send `workstream_id` on REST task -queries — preferred `workplan_id` only. - -Progress 2026-07-11 (T03): `_emit_progress_event` normalizes payloads to -`workplan_id` only; task/decision/workplan automatic progress events no longer -dual-send `workstream_id` on POST `/progress/`. +Phase 1 complete 2026-07-08. Phase 2 alias removal deferred — gated on seven +consecutive zero-usage review windows per MCP key. ## Task: REST `/workstreams` compat router retirement ```task id: STATE-WP-0069-T04 -status: progress +status: done priority: high state_hub_task_id: "6fba665d-ebfa-42ab-8cf9-4460f77c5375" ``` @@ -179,24 +164,19 @@ and `tests/test_legacy_meter.py` retirement cases. Done when OpenAPI documents `/workplans` as the public CRUD surface and `/workstreams` returns 410 Gone or is unmounted, with regression tests green. -Progress 2026-07-10 (T04 prep): `api/services/legacy_compat.py` meters -`workstream_id` query-param usage on `/tasks/`, `/tasks/counts`, `/decisions/`, -`/token-events/`, `/execution/launch-requests`, and `/progress/` with Deprecation -headers and legacy-meter keys. MCP task list tools now call REST with `workplan_id` -only. Route-level `/workstreams` removal remains gated on zero usage. +Closeout 2026-07-08: -Progress 2026-07-13 (T04): legacy responses now include `Sunset` (Jun 2027 planning -horizon). POST `/progress/` meters `workstream_id` request bodies; hub-core progress -router accepts optional body-meter hook. - -Progress 2026-07-14 (T04): POST `/tasks/` and POST `/decisions/` meter legacy -`workstream_id` request bodies via `LegacyWorkstreamIdBodyMixin`. +- `DELETE /workstreams/{workstream_id}` returns **410 Gone** (retired). +- `scripts/consistency_check.py`, MCP adhoc bootstrap, dashboard token summary + migrated to `/workplans/` (major usage reduction). +- Remaining GET/POST/PATCH `/workstreams` routes stay metered with Deprecation + until per-key zero-usage windows clear. ## Task: Legacy completion event — stop dual-publish ```task id: STATE-WP-0069-T05 -status: todo +status: done priority: medium state_hub_task_id: "ebcbe5fc-b50f-4885-a18a-f8394d362c85" ``` @@ -211,11 +191,15 @@ Register the legacy subject as `retired` in legacy-meter. Update Done when only `org.statehub.workplan.completed` is published on workplan finish transitions and legacy-meter shows zero event consumers on the old subject. +Completed 2026-07-08: dual-publish removed; `docs/nats-event-subjects.md` marks +legacy subject retired; `capture_legacy_meter_evidence.py --retire-keys` patches +registry on hub capture. + ## Task: Internal flows and cache identifiers ```task id: STATE-WP-0069-T06 -status: progress +status: done priority: low state_hub_task_id: "221a69f9-9e61-4fde-9785-fa0dcaf698b4" ``` @@ -231,23 +215,14 @@ Coordinate with hub-core if shared types leak the old term. Done when internal code reads workplan-first and grep budget for `api:open_workstreams` hits zero. -Progress 2026-07-08: `/state/summary` now dual-writes `open_workplans` alongside -legacy `open_workstreams`; MCP `get_domain_summary` prefers `open_workplans`. - -Progress 2026-07-09 (T06): added `flows/workplan.yaml` (`custodian.workplan.v1`) and -`/flows/workplan/{id}` advance path; dashboard and README now read `open_workplans` -first with `open_workstreams` fallback. Legacy `flows/workstream.yaml` and summary -dual-key remain until legacy-meter clears callers. - -Progress 2026-07-14 (T06): state summary flow evaluation uses `workplan` flow; -`NextStep` dual-writes `workplan_*` alongside legacy `workstream_*` fields. -`LegacyWorkstreamIdBodyMixin` shared across create schemas. +Closeout 2026-07-08: `flows/workplan.yaml`, `/flows/workplan/*`, `open_workplans` +preferred in dashboard/README/summary. Legacy dual-keys retained until meter clears. ## Task: Closeout — registry cleanup and verification ```task id: STATE-WP-0069-T07 -status: todo +status: done priority: medium state_hub_task_id: "06ed4814-3ba5-4226-845d-695f3041522e" ``` @@ -264,6 +239,11 @@ Post State Hub progress note linking completion; notify CUST-WP-0055 T02 closeou Done when STATE-WP-0069 can move to `finished` and CUST-WP-0055 T02-T02 is `done`. +Evidence: `docs/evidence/legacy-meter-weekly-review-20260708.json` (pytest capture); +`scripts/capture_legacy_meter_evidence.py` for production hub snapshots. +Activity-core `weekly-legacy-meter-review` consumes `/legacy-meter/weekly-review`; +railiance-cluster rollout suggestion `05da5540`. + ## Sequencing ``` @@ -284,4 +264,11 @@ T07 closeout alone. - ADR-001 `fix-consistency` and workplan file sync remain green through all phases. -- No frontmatter `state_hub_workstream_id` renames in this workplan. \ No newline at end of file +- No frontmatter `state_hub_workstream_id` renames in this workplan. + +## Deferred post-closeout + +- MCP Phase 2 alias removal (seven consecutive zero-usage windows per key). +- Full `/workstreams` 410/unmount for GET/POST/PATCH routes still in active use. +- `open_workstreams` summary key removal (dual-key compat remains). +- Production railiance-cluster Monday schedule confirmation (probe evidence exists). \ No newline at end of file