Finish STATE-WP-0069: retire legacy completion event and DELETE /workstreams
Stop dual-publishing org.statehub.workstream.completed; return 410 Gone for
legacy DELETE /workstreams/{id}. Migrate fix-consistency, MCP adhoc bootstrap,
and dashboard token summary to /workplans/. Add legacy-meter evidence capture
script and pytest snapshot; update docs and close out the workplan.
This commit is contained in:
parent
b659ff8d13
commit
e0c954d098
13 changed files with 404 additions and 149 deletions
130
scripts/capture_legacy_meter_evidence.py
Normal file
130
scripts/capture_legacy_meter_evidence.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue