capture_legacy_meter_evidence.py fell back to hours=8 whenever --days was omitted, and --hours itself defaulted to 8. Every unattended capture sampled 06:00Z-14:00Z while writing a file named weekly-review with cadence: weekly. 39 of 40 captures ran this way; only 2026-07-08 used a true 7-day window. Calls outside the band were never sampled, so interfaces with live callers reported as retirement candidates -- GET /tasks/?workstream_id was flagged on 2026-08-19 despite traffic on 2026-08-18. Default the script to days=7; keep --hours for spot checks, documented as not retirement evidence. Adds corrected capture for 2026-08-20 and records the residual gap (candidate rule ignores last_seen_at) against STATE-WP-0079-T05. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
No EOL
5.5 KiB
Python
174 lines
No EOL
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Capture legacy-meter review payload as dated evidence JSON.
|
|
|
|
Usage:
|
|
python scripts/capture_legacy_meter_evidence.py [--hours 8] [--days N] [--api-base URL] [--dry-run]
|
|
|
|
Default review window is 8 hours (post-deploy monitoring). Pass --days for the
|
|
weekly activity-core cadence (typically 7).
|
|
|
|
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
|
|
|
|
|
|
DEFAULT_REVIEW_DAYS = 7
|
|
|
|
|
|
def _review_query(*, days: int | None, hours: int | None) -> str:
|
|
"""Build the review-window query.
|
|
|
|
Defaults to a 7-day window to match the ``weekly-review`` endpoint and the
|
|
``cadence: weekly`` label in the payload. An hours-scoped window only
|
|
samples part of each day, so an interface called outside that band reads as
|
|
unused and is falsely reported as a retirement candidate.
|
|
"""
|
|
if hours is not None:
|
|
return f"hours={hours}"
|
|
return f"days={days or DEFAULT_REVIEW_DAYS}"
|
|
|
|
|
|
def capture(
|
|
*,
|
|
api_base: str,
|
|
days: int | None,
|
|
hours: int | None,
|
|
retire_keys: list[str],
|
|
dry_run: bool,
|
|
) -> Path:
|
|
query = _review_query(days=days, hours=hours)
|
|
review = _api_get(api_base, f"/legacy-meter/weekly-review?{query}")
|
|
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?{query}")
|
|
|
|
today = datetime.date.today().strftime("%Y%m%d")
|
|
out_path = EVIDENCE_DIR / f"legacy-meter-weekly-review-{today}.json"
|
|
payload: dict = {
|
|
"captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
"api_base": api_base,
|
|
"workplan": "STATE-WP-0070",
|
|
"retired_interfaces": retired,
|
|
"weekly_review": review,
|
|
}
|
|
if hours is not None:
|
|
payload["hours"] = hours
|
|
else:
|
|
payload["days"] = days or DEFAULT_REVIEW_DAYS
|
|
|
|
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", []))
|
|
window = query.replace("=", " ")
|
|
print(f"wrote {out_path} ({interfaces} interfaces, {candidates} retirement candidates, {window})")
|
|
return out_path
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--hours",
|
|
type=int,
|
|
default=None,
|
|
help="Review window in hours; only samples part of each day — use for "
|
|
"spot checks, never as retirement evidence",
|
|
)
|
|
parser.add_argument(
|
|
"--days",
|
|
type=int,
|
|
default=None,
|
|
help=f"Review window in days (default: {DEFAULT_REVIEW_DAYS}, weekly cadence)",
|
|
)
|
|
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,
|
|
hours=args.hours,
|
|
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() |