From b05ca2ca8c98de8afe2106322e6a5e09b3768904 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 9 Jul 2026 00:25:45 +0200 Subject: [PATCH] Migrate remaining in-repo workstream REST callers to /workplans Point cleanup_stale_tasks, validate_repo_adr, custodian_cli, dashboard data loaders, extensions poll, MCP legacy resource, and consistency_check task queries at preferred /workplans routes. Add caller inventory evidence doc. --- custodian_cli.py | 10 ++-- dashboard/src/data/workstreams.json.py | 2 +- dashboard/src/data/workstreams/[id].json.py | 2 +- dashboard/src/extensions.md | 2 +- .../workstream-caller-inventory-20260708.md | 50 +++++++++++++++++++ mcp_server/server.py | 2 +- scripts/cleanup_stale_tasks.py | 2 +- scripts/consistency_check.py | 4 +- scripts/validate_repo_adr.py | 4 +- tests/test_consistency_check.py | 4 +- 10 files changed, 66 insertions(+), 16 deletions(-) create mode 100644 docs/evidence/workstream-caller-inventory-20260708.md diff --git a/custodian_cli.py b/custodian_cli.py index de88e2b..776d060 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -241,14 +241,14 @@ def cmd_register(args: argparse.Namespace) -> None: print(f"==> Creating onboarding workstream '{ws_slug}' ...") # Check if it already exists existing_ws = next( - (w for w in _api_get("/workstreams/") if w.get("slug") == ws_slug and w.get("status") == "active"), + (w for w in _api_get("/workplans/") if w.get("slug") == ws_slug and w.get("status") == "active"), None, ) if existing_ws: print(" Onboarding workstream already exists — skipping task creation.") if repo_id and not existing_ws.get("repo_id"): existing_owner = existing_ws.get("owner") - _api_patch(f"/workstreams/{existing_ws['id']}/", { + _api_patch(f"/workplans/{existing_ws['id']}/", { "repo_id": repo_id, "owner": repo_slug if existing_owner in (None, domain) else existing_owner, }) @@ -260,7 +260,7 @@ def cmd_register(args: argparse.Namespace) -> None: ) else: try: - ws = _api_post("/workstreams/", { + ws = _api_post("/workplans/", { "topic_id": topic_id, "title": f"Repo Integration: {repo_slug}", "slug": ws_slug, @@ -430,7 +430,7 @@ def cmd_create_workstream(args: argparse.Namespace) -> None: slug = args.slug or re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-") - ws = _api_post("/workstreams/", { + ws = _api_post("/workplans/", { "topic_id": topic_id, "title": args.title, "slug": slug, @@ -460,7 +460,7 @@ def cmd_create_task(args: argparse.Namespace) -> None: # Resolve workstream: accept UUID or slug workstream_id = args.workstream if not _is_uuid(workstream_id): - wss = _api_get("/workstreams/") + wss = _api_get("/workplans/") match = next((w for w in wss if w.get("slug") == workstream_id), None) if not match: print(f"ERROR: No workstream found with slug '{workstream_id}'.") diff --git a/dashboard/src/data/workstreams.json.py b/dashboard/src/data/workstreams.json.py index d3368cd..d32608a 100644 --- a/dashboard/src/data/workstreams.json.py +++ b/dashboard/src/data/workstreams.json.py @@ -8,7 +8,7 @@ import urllib.error API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") try: - with urllib.request.urlopen(f"{API_BASE}/workstreams", timeout=10) as resp: + with urllib.request.urlopen(f"{API_BASE}/workplans", timeout=10) as resp: data = json.loads(resp.read()) print(json.dumps(data)) except urllib.error.URLError as e: diff --git a/dashboard/src/data/workstreams/[id].json.py b/dashboard/src/data/workstreams/[id].json.py index d4a511b..1863314 100644 --- a/dashboard/src/data/workstreams/[id].json.py +++ b/dashboard/src/data/workstreams/[id].json.py @@ -15,7 +15,7 @@ if not ws_id: sys.exit(1) try: - with urllib.request.urlopen(f"{API_BASE}/workstreams/{ws_id}", timeout=10) as resp: + with urllib.request.urlopen(f"{API_BASE}/workplans/{ws_id}", timeout=10) as resp: data = json.loads(resp.read()) print(json.dumps(data)) except urllib.error.HTTPError as e: diff --git a/dashboard/src/extensions.md b/dashboard/src/extensions.md index 6fa5dce..8ab31ae 100644 --- a/dashboard/src/extensions.md +++ b/dashboard/src/extensions.md @@ -14,7 +14,7 @@ const epState = (async function*() { try { const [re, rw, rt, rr] = await Promise.all([ apiFetch("/extension-points/"), - apiFetch("/workstreams/"), + apiFetch("/workplans/"), apiFetch("/topics/"), apiFetch("/repos/"), ]); diff --git a/docs/evidence/workstream-caller-inventory-20260708.md b/docs/evidence/workstream-caller-inventory-20260708.md new file mode 100644 index 0000000..e31000c --- /dev/null +++ b/docs/evidence/workstream-caller-inventory-20260708.md @@ -0,0 +1,50 @@ +# Workstream caller inventory — 2026-07-08 + +Post `STATE-WP-0069` hunt. Live legacy-meter (7-day window) showed ~150k calls +on legacy REST keys; most lacked `X-StateHub-Component` identity (`unknown` bucket). + +## Migrated in this pass (REST → `/workplans/`) + +| Caller | Repo | Was | Now | +| --- | --- | --- | --- | +| `scripts/consistency_check.py` | state-hub | `/workstreams/*` | `/workplans/*` (prior commit) | +| `scripts/consistency_check.py` task queries | state-hub | `?workstream_id=` | `?workplan_id=` | +| `scripts/cleanup_stale_tasks.py` | state-hub | `GET /workstreams/` | `GET /workplans/` | +| `scripts/validate_repo_adr.py` | state-hub | `/workstreams/{id}`, list | `/workplans/*` | +| `custodian_cli.py` | state-hub | CRUD/list `/workstreams/` | `/workplans/` | +| `dashboard/src/data/workstreams.json.py` | state-hub | `GET /workstreams` | `GET /workplans` | +| `dashboard/src/data/workstreams/[id].json.py` | state-hub | `GET /workstreams/{id}` | `GET /workplans/{id}` | +| `dashboard/src/extensions.md` | state-hub | `apiFetch("/workstreams/")` | `apiFetch("/workplans/")` | +| `mcp_server/server.py` legacy resource | state-hub | `_get("/workstreams")` | `_get("/workplans")` | +| `context_resolvers/state_hub.py` | activity-core | index, detail, tasks | `/workplans/index`, `/workplans/{id}`, `?workplan_id=` | +| `cya/shell_session.py` | can-you-assist | `GET /workstreams/?` | `GET /workplans/?` | + +## Intentional legacy (keep until meter clears) + +| Surface | Notes | +| --- | --- | +| `api/routers/workstreams.py` | Compat router; metered with Deprecation | +| `api/routers/workstream_dependencies.py` | Legacy dependency aliases | +| MCP tool names `create_workstream`, etc. | Aliases call `/workplans/` internally | +| MCP param aliases `workstream_id` | Accepted on tools; REST uses `workplan_id` where updated | +| `activity-core` progress sinks | Dual-write `workplan_id` + `workstream_id` on POST `/progress/` (wire compat) | +| `railiance-platform/scripts/credential.py` | Dual-write scope fields | +| Dashboard **routes** `/workstreams` | Nav URL compat only; `workstreams.md` already polls `/workplans/` | +| `tests/test_legacy_meter.py`, `test_routers_core.py` | Regression coverage for legacy routes | + +## Likely remaining production traffic sources + +1. **Remote fix-consistency** on railiance01 / cluster nodes still on pre-`e0c954d` checkout. +2. **Dashboard data loaders** on deployed hosts before this migration ships. +3. **activity-core** k8s prompts still document `/workstreams/workplan-index` in YAML (docs only until rolled). +4. **External MCP clients** (Claude Code `create_workstream` in `~/.claude.json`) — tool aliases hit `/workplans/` but old REST clients may remain. +5. **POST bodies** with `workstream_id` on `/tasks/`, `/decisions/`, `/progress/` (metered separately). + +## Next verification + +```bash +python scripts/capture_legacy_meter_evidence.py --days 7 +``` + +Expect `GET /workstreams/*` window counts to fall after deploy + one full +fix-consistency sweep cycle on all hosts. \ No newline at end of file diff --git a/mcp_server/server.py b/mcp_server/server.py index 50b7cd5..3ec43d9 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -276,7 +276,7 @@ def resource_workstreams(topic_slug: str) -> str: match = next((t for t in topics if t["slug"] == topic_slug), None) if not match: return json.dumps({"error": f"Topic '{topic_slug}' not found"}) - rows = _get("/workstreams", {"topic_id": match["id"]}) + rows = _get("/workplans", {"topic_id": match["id"]}) return json.dumps( { "_deprecation": _legacy_mcp_deprecation(resource="state://workstreams/{topic_slug}"), diff --git a/scripts/cleanup_stale_tasks.py b/scripts/cleanup_stale_tasks.py index 0e08f86..d2ebabb 100644 --- a/scripts/cleanup_stale_tasks.py +++ b/scripts/cleanup_stale_tasks.py @@ -78,7 +78,7 @@ def main() -> int: try: tasks = get("/tasks/?limit=500") - workstreams = get("/workstreams/") + workstreams = get("/workplans/") except urllib.error.URLError as e: print(f"[cleanup-stale] ERROR: API unreachable — {e}", file=sys.stderr) print("[cleanup-stale] Start the API with: cd ~/state-hub && make api", file=sys.stderr) diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 6e67554..bdd72db 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -936,7 +936,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N # Continue to check drift even with mismatched repo tasks = get_tasks_from_workplan(meta, body) - db_tasks = _api_get(api_base, "/tasks", {"workstream_id": ws_id}) + db_tasks = _api_get(api_base, "/tasks", {"workplan_id": ws_id}) file_task_statuses = [ str(task.get("status", "")).strip() for task in tasks @@ -2046,7 +2046,7 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo for ws in workstreams: ws_title = ws.get("title", ws.get("slug", "?")) ws_id = ws["id"] - tasks = _api_get(api_base, "/tasks", {"workstream_id": ws_id}) or [] + tasks = _api_get(api_base, "/tasks", {"workplan_id": ws_id}) or [] if not isinstance(tasks, list): tasks = [] diff --git a/scripts/validate_repo_adr.py b/scripts/validate_repo_adr.py index a978a84..b8254d7 100644 --- a/scripts/validate_repo_adr.py +++ b/scripts/validate_repo_adr.py @@ -339,7 +339,7 @@ def check_api(api_base: str, metas: list[dict], domain_slug: str | None, file_ws_ids.add(ws_id) if meta.get("_active_file", True): active_file_ws_ids.add(ws_id) - ws = _api_get(api_base, f"/workstreams/{ws_id}") + ws = _api_get(api_base, f"/workplans/{ws_id}") if ws is None: report.add(Level.FAIL, "workstream-ref-exists", f"state_hub_workstream_id {ws_id} not found in DB (stale reference)", @@ -373,7 +373,7 @@ def check_api(api_base: str, metas: list[dict], domain_slug: str | None, if t_domain not in domains_to_check: continue t_id = topic["id"] - workstreams = _api_get(api_base, "/workstreams", {"topic_id": t_id}) + workstreams = _api_get(api_base, "/workplans", {"topic_id": t_id}) if not isinstance(workstreams, list): report.add(Level.WARN, "orphan-detection", f"Could not fetch workstreams for topic {t_id[:8]}… (domain={t_domain})") diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 0772588..a7d9357 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -971,7 +971,7 @@ class TestLifecycleRenormalization: return ws if path == "/tasks/task-1": return task - if path == "/tasks" and params == {"workstream_id": "ws-1"}: + if path == "/tasks" and params == {"workplan_id": "ws-1"}: return [task] if path == "/workplans/ws-1/dependencies": return [] @@ -1087,7 +1087,7 @@ class TestC12OrphanDbTasks: return ws if path == "/tasks/task-linked": return linked - if path == "/tasks" and params == {"workstream_id": "ws-1"}: + if path == "/tasks" and params == {"workplan_id": "ws-1"}: return [linked, orphan] if path == "/workplans/ws-1/dependencies": return []