diff --git a/custodian_cli.py b/custodian_cli.py index 776d060..9498b57 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -25,6 +25,7 @@ from statehub_register import run_register as run_statehub_register STATE_HUB_DIR = Path(__file__).resolve().parent API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000") +_LEGACY_METER_HEADERS = {"X-StateHub-Component": "state-hub.custodian-cli"} TEMPLATE = STATE_HUB_DIR / "scripts" / "project_claude_md.template" PATCH_CWD = STATE_HUB_DIR / "scripts" / "patch_mcp_cwd.py" @@ -81,7 +82,8 @@ _ONBOARDING_TASKS = [ def _api_get(path: str) -> object: url = API_BASE.rstrip("/") + path try: - with urllib.request.urlopen(url, timeout=10) as r: + req = urllib.request.Request(url, headers=_LEGACY_METER_HEADERS) + with urllib.request.urlopen(req, timeout=10) as r: return json.loads(r.read()) except urllib.error.URLError as e: print(f"ERROR: Cannot reach API at {API_BASE}: {e}") @@ -92,7 +94,11 @@ def _api_get(path: str) -> object: def _api_post(path: str, body: dict) -> object: url = API_BASE.rstrip("/") + path data = json.dumps({k: v for k, v in body.items() if v is not None}).encode() - req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json", **_LEGACY_METER_HEADERS}, + ) with urllib.request.urlopen(req, timeout=10) as r: return json.loads(r.read()) @@ -103,7 +109,7 @@ def _api_patch(path: str, body: dict) -> object: req = urllib.request.Request( url, data=data, - headers={"Content-Type": "application/json"}, + headers={"Content-Type": "application/json", **_LEGACY_METER_HEADERS}, method="PATCH", ) with urllib.request.urlopen(req, timeout=10) as r: diff --git a/dashboard/src/data/token-summary.json.py b/dashboard/src/data/token-summary.json.py index 49d626d..041904a 100644 --- a/dashboard/src/data/token-summary.json.py +++ b/dashboard/src/data/token-summary.json.py @@ -6,11 +6,13 @@ import urllib.error import urllib.request API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") +_HEADERS = {"X-StateHub-Component": "state-hub.dashboard.token-summary"} def fetch(url: str): try: - with urllib.request.urlopen(url, timeout=10) as resp: + req = urllib.request.Request(url, headers=_HEADERS) + with urllib.request.urlopen(req, timeout=10) as resp: return json.loads(resp.read()) except urllib.error.URLError: return None diff --git a/dashboard/src/data/workstreams.json.py b/dashboard/src/data/workstreams.json.py index d32608a..0efd386 100644 --- a/dashboard/src/data/workstreams.json.py +++ b/dashboard/src/data/workstreams.json.py @@ -6,9 +6,11 @@ import urllib.request import urllib.error API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") +_HEADERS = {"X-StateHub-Component": "state-hub.dashboard.workstreams-list"} try: - with urllib.request.urlopen(f"{API_BASE}/workplans", timeout=10) as resp: + req = urllib.request.Request(f"{API_BASE}/workplans", headers=_HEADERS) + with urllib.request.urlopen(req, 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 1863314..55f8aac 100644 --- a/dashboard/src/data/workstreams/[id].json.py +++ b/dashboard/src/data/workstreams/[id].json.py @@ -7,6 +7,7 @@ import urllib.request import urllib.error API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") +_HEADERS = {"X-StateHub-Component": "state-hub.dashboard.workplan-detail"} ws_id = sys.argv[1] if len(sys.argv) > 1 else "" @@ -15,7 +16,8 @@ if not ws_id: sys.exit(1) try: - with urllib.request.urlopen(f"{API_BASE}/workplans/{ws_id}", timeout=10) as resp: + req = urllib.request.Request(f"{API_BASE}/workplans/{ws_id}", headers=_HEADERS) + with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read()) print(json.dumps(data)) except urllib.error.HTTPError as e: diff --git a/docs/evidence/workstream-caller-inventory-20260708.md b/docs/evidence/workstream-caller-inventory-20260708.md index 96f1a20..9db52af 100644 --- a/docs/evidence/workstream-caller-inventory-20260708.md +++ b/docs/evidence/workstream-caller-inventory-20260708.md @@ -47,4 +47,22 @@ 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 +fix-consistency sweep cycle on all hosts. + +## Identity headers (2026-07-09) + +Scripts now send `X-StateHub-Component` for legacy-meter attribution: + +| Component key | Caller | +| --- | --- | +| `state-hub.fix-consistency` | `scripts/consistency_check.py` | +| `state-hub.custodian-cli` | `custodian_cli.py` | +| `state-hub.cleanup-stale-tasks` | `scripts/cleanup_stale_tasks.py` | +| `state-hub.validate-repo-adr` | `scripts/validate_repo_adr.py` | +| `state-hub.dashboard.workstreams-list` | dashboard data loader | +| `state-hub.dashboard.workplan-detail` | dashboard detail loader | +| `state-hub.dashboard.token-summary` | token summary loader | + +**Remote deploy note:** railiance01 `~/state-hub` was at `050cbbc` (pre-migration); +`git pull` failed with permission denied on `.git/FETCH_HEAD` — operator action +required to update the sweep host. \ No newline at end of file diff --git a/scripts/cleanup_stale_tasks.py b/scripts/cleanup_stale_tasks.py index d2ebabb..d24ef39 100644 --- a/scripts/cleanup_stale_tasks.py +++ b/scripts/cleanup_stale_tasks.py @@ -32,13 +32,15 @@ except Exception: # pragma: no cover — event publishing is optional publish_event = None # type: ignore[assignment] shutdown_publisher = None # type: ignore[assignment] -API = "http://127.0.0.1:8000" +API = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") +_LEGACY_METER_HEADERS = {"X-StateHub-Component": "state-hub.cleanup-stale-tasks"} STALE_STATUSES = set(OPEN_TASK_STATUSES) CLOSED_WS_STATUS = set(CLOSED_WORKSTREAM_STATUSES) def get(path: str) -> list | dict: - with urllib.request.urlopen(f"{API}{path}") as r: + req = urllib.request.Request(f"{API}{path}", headers=_LEGACY_METER_HEADERS) + with urllib.request.urlopen(req) as r: return json.loads(r.read()) @@ -49,7 +51,7 @@ def _request(method: str, url: str, payload: dict) -> dict: req = urllib.request.Request( url, data=data, - headers={"Content-Type": "application/json"}, + headers={"Content-Type": "application/json", **_LEGACY_METER_HEADERS}, method=method, ) try: diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index bdd72db..625c618 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -122,6 +122,13 @@ except ImportError: # Constants # --------------------------------------------------------------------------- +_LEGACY_METER_COMPONENT = "state-hub.fix-consistency" + + +def _legacy_meter_headers() -> dict[str, str]: + return {"X-StateHub-Component": _LEGACY_METER_COMPONENT} + + _TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) _HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE) _ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$") @@ -566,7 +573,12 @@ def _api_get( last_error: Exception | None = None for attempt in range(_API_GET_RETRIES): try: - with _httpx.Client(base_url=api_base, timeout=10.0, follow_redirects=True) as c: + with _httpx.Client( + base_url=api_base, + timeout=10.0, + follow_redirects=True, + headers=_legacy_meter_headers(), + ) as c: r = c.get(path, params=filtered if filtered else None) r.raise_for_status() return r.json() @@ -604,7 +616,12 @@ def _api_patch(api_base: str, path: str, body: dict) -> Any: if not path.endswith("/"): path += "/" try: - with _httpx.Client(base_url=api_base, timeout=10.0, follow_redirects=True) as c: + with _httpx.Client( + base_url=api_base, + timeout=10.0, + follow_redirects=True, + headers=_legacy_meter_headers(), + ) as c: r = c.patch(path, json=body) r.raise_for_status() return r.json() @@ -620,7 +637,12 @@ def _api_put(api_base: str, path: str, body: dict) -> Any: if not path.endswith("/"): path += "/" try: - with _httpx.Client(base_url=api_base, timeout=30.0, follow_redirects=True) as c: + with _httpx.Client( + base_url=api_base, + timeout=30.0, + follow_redirects=True, + headers=_legacy_meter_headers(), + ) as c: r = c.put(path, json=body) r.raise_for_status() return r.json() @@ -634,7 +656,12 @@ def _api_post(api_base: str, path: str, body: dict) -> Any: if not path.endswith("/"): path += "/" try: - with _httpx.Client(base_url=api_base, timeout=10.0, follow_redirects=True) as c: + with _httpx.Client( + base_url=api_base, + timeout=10.0, + follow_redirects=True, + headers=_legacy_meter_headers(), + ) as c: r = c.post(path, json=body) r.raise_for_status() return r.json() @@ -2461,7 +2488,7 @@ def fix_repo( if t_priority not in VALID_TASK_PRIORITIES: t_priority = "medium" t_data = _api_post(api_base, "/tasks", { - "workstream_id": new_ws_id, + "workplan_id": new_ws_id, "title": str(task.get("title", t_id)).strip() or t_id, "description": task.get("description") or None, "status": t_status, @@ -2545,7 +2572,7 @@ def fix_repo( if t_priority not in VALID_TASK_PRIORITIES: t_priority = "medium" t_data = _api_post(api_base, "/tasks", { - "workstream_id": ws_id, + "workplan_id": ws_id, "title": str(task.get("title", t_id)).strip() or t_id, "description": task.get("description") or None, "status": t_status, diff --git a/scripts/validate_repo_adr.py b/scripts/validate_repo_adr.py index b8254d7..8f7bf5c 100644 --- a/scripts/validate_repo_adr.py +++ b/scripts/validate_repo_adr.py @@ -301,13 +301,22 @@ def check_files(workplans_dir: Path, report: Report) -> list[dict]: # State-hub API checks # --------------------------------------------------------------------------- +def _legacy_meter_headers() -> dict[str, str]: + return {"X-StateHub-Component": "state-hub.validate-repo-adr"} + + def _api_get(api_base: str, path: str, params: dict | None = None) -> Any: if not _HAS_HTTPX: return None if not path.endswith("/"): path += "/" try: - with _httpx.Client(base_url=api_base, timeout=10.0, follow_redirects=True) as c: + with _httpx.Client( + base_url=api_base, + timeout=10.0, + follow_redirects=True, + headers=_legacy_meter_headers(), + ) as c: r = c.get(path, params={k: v for k, v in (params or {}).items() if v is not None}) r.raise_for_status() return r.json() diff --git a/workplans/STATE-WP-0070-workplan-terminology-phase2-retirement.md b/workplans/STATE-WP-0070-workplan-terminology-phase2-retirement.md new file mode 100644 index 0000000..cb7bc97 --- /dev/null +++ b/workplans/STATE-WP-0070-workplan-terminology-phase2-retirement.md @@ -0,0 +1,113 @@ +--- +id: STATE-WP-0070 +type: workplan +title: "Workplan terminology phase-2 legacy retirement" +domain: infotech +repo: state-hub +status: active +owner: codex +topic_slug: custodian +planning_priority: medium +planning_order: 70 +created: "2026-07-09" +updated: "2026-07-09" +state_hub_workstream_id: "9aa92529-6ee8-4b3f-b573-78b0ef8d9788" +--- + +# STATE-WP-0070 — Workplan terminology phase-2 legacy retirement + +**Parent:** `STATE-WP-0069` (finished 2026-07-08, archived). + +**Prerequisite:** Caller migrations shipped (`b05ca2c+`), identity headers on +fix-consistency and dashboard loaders, activity-core progress dual-write stopped. + +## Goal + +Drive legacy-meter counts to zero per key, then retire remaining `/workstreams` +routes, MCP aliases, and internal dual-keys. All removals remain +**meter-gated** (seven consecutive zero-usage windows). + +## Context + +Live meter (2026-07-09) still shows ~150k weekly legacy GET calls — attributed +to `unknown` until identity headers deploy and remote fix-consistency hosts pull +latest `state-hub`. Evidence: `docs/evidence/legacy-meter-weekly-review-20260708.json`, +caller inventory: `docs/evidence/workstream-caller-inventory-20260708.md`. + +## Task: Deploy caller migrations and verify meter attribution + +```task +id: STATE-WP-0070-T01 +status: progress +priority: high +state_hub_task_id: "04cb3a3a-fe52-480b-816b-37c8f8fbe5ee" +``` + +1. Pull `state-hub` ≥ `b05ca2c` on all fix-consistency hosts (railiance01 sweep, + WSL workstation, cluster runners). +2. Run full `statehub fix-consistency --all` (or scheduled sweep). +3. Capture weekly review; confirm `X-StateHub-Component` buckets name + `state-hub.fix-consistency` instead of `unknown`. +4. Expect `GET /workstreams/*` window counts to fall sharply. + +Done when a post-deploy capture shows attributed components and declining legacy +REST counts. + +## Task: Retire remaining REST `/workstreams` routes per key + +```task +id: STATE-WP-0070-T02 +status: todo +priority: high +state_hub_task_id: "13122195-3b7e-4e22-a5e8-4ebe7e9a093f" +``` + +For each key in `docs/workplan-terminology-legacy-retirement-backlog.md` phase 1–2 +still showing usage: + +- `GET/POST/PATCH /workstreams/*` +- dependency and execution intent aliases + +Return **410 Gone** (matching DELETE pattern) when legacy-meter shows seven +consecutive zero-usage windows. Update tests and capture evidence per retirement. + +## Task: MCP alias removal (Phase 2) + +```task +id: STATE-WP-0070-T03 +status: todo +priority: medium +state_hub_task_id: "e2b9015a-d157-4677-b700-165f1cf7e2af" +``` + +Remove `create_workstream`, `update_workstream`, `list_workstreams`, +`update_workstream_status`, and `state://workstreams/{topic_slug}` when MCP +procedure keys hit zero usage for seven windows. Keep `TOOLS.md` migration notes. + +## Task: Internal dual-key and param alias cleanup + +```task +id: STATE-WP-0070-T04 +status: todo +priority: low +state_hub_task_id: "18af98bf-c74e-47c8-948a-e713cff88bb8" +``` + +- Drop `open_workstreams` from `/state/summary` when meter clears. +- Remove `workstream_id` query/body aliases on preferred routes. +- Retire `flows/workstream.yaml` when no callers remain. + +## Task: Closeout and fleet gate + +```task +id: STATE-WP-0070-T05 +status: todo +priority: medium +state_hub_task_id: "dcc81665-ef84-4c0a-a88f-d8afc0d1bbb9" +``` + +Re-run `scan_workstream_terminology.py`, grep budget check, full test suite, +archive this workplan, notify CUST-WP-0055 fleet gate complete. + +Done when legacy-meter weekly review shows no active legacy REST keys and MCP +aliases are removed or marked retired. \ No newline at end of file diff --git a/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md b/workplans/archived/260708-STATE-WP-0069-workplan-terminology-legacy-retirement.md similarity index 100% rename from workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md rename to workplans/archived/260708-STATE-WP-0069-workplan-terminology-legacy-retirement.md