diff --git a/api/routers/legacy_meter.py b/api/routers/legacy_meter.py index 1e4aca1..d3adf85 100644 --- a/api/routers/legacy_meter.py +++ b/api/routers/legacy_meter.py @@ -28,6 +28,21 @@ from api.services.legacy_meter import ( router = APIRouter(prefix="/legacy-meter", tags=["legacy-meter"]) +def _review_window( + *, + days: int | None, + hours: int | None, + window_start: datetime | None, + window_end: datetime | None, +) -> tuple[datetime, datetime]: + end = window_end or datetime.now(tz=timezone.utc) + if window_start is not None: + return window_start, end + if hours is not None: + return end - timedelta(hours=hours), end + return end - timedelta(days=days or 7), end + + @router.post( "/interfaces", response_model=LegacyInterfaceRead, @@ -107,23 +122,43 @@ async def record_usage( @router.get("/summary", response_model=LegacyUsageSummary) async def usage_summary( - days: int = Query(7, ge=1, le=366), + days: int | None = Query(None, ge=1, le=366), + hours: int | None = Query(None, ge=1, le=8784), window_start: datetime | None = None, window_end: datetime | None = None, session: AsyncSession = Depends(get_session), ) -> LegacyUsageSummary: - end = window_end or datetime.now(tz=timezone.utc) - start = window_start or (end - timedelta(days=days)) + if days is not None and hours is not None: + raise HTTPException( + status_code=422, + detail="Pass either days or hours, not both", + ) + start, end = _review_window( + days=days, + hours=hours, + window_start=window_start, + window_end=window_end, + ) return await legacy_usage_summary(session, window_start=start, window_end=end) @router.get("/weekly-review", response_model=LegacyWeeklyReview) async def weekly_review( - days: int = Query(7, ge=1, le=366), + days: int | None = Query(None, ge=1, le=366), + hours: int | None = Query(None, ge=1, le=8784), window_start: datetime | None = None, window_end: datetime | None = None, session: AsyncSession = Depends(get_session), ) -> LegacyWeeklyReview: - end = window_end or datetime.now(tz=timezone.utc) - start = window_start or (end - timedelta(days=days)) + if days is not None and hours is not None: + raise HTTPException( + status_code=422, + detail="Pass either days or hours, not both", + ) + start, end = _review_window( + days=days, + hours=hours, + window_start=window_start, + window_end=window_end, + ) return await legacy_weekly_review(session, window_start=start, window_end=end) diff --git a/docs/evidence/legacy-meter-weekly-review-20260709.json b/docs/evidence/legacy-meter-weekly-review-20260709.json index 0112d95..d07adfc 100644 --- a/docs/evidence/legacy-meter-weekly-review-20260709.json +++ b/docs/evidence/legacy-meter-weekly-review-20260709.json @@ -1,30 +1,12 @@ { - "captured_at": "2026-07-08T23:08:23.293570+00:00", + "captured_at": "2026-07-08T23:35:18.486286+00:00", "api_base": "http://127.0.0.1:8000", - "days": 7, - "workplan": "STATE-WP-0069", - "retired_interfaces": [ - { - "id": "8051fb40-66c9-4dc4-a413-13e8573e04a3", - "interface_key": "event_subject:org.statehub.workstream.completed", - "interface_kind": "event_subject", - "legacy_since": "2026-06-04T06:09:42.198193Z", - "replacement_ref": "org.statehub.workplan.completed", - "owner_component": "state-hub.events", - "status": "retired", - "replacement_verified": true, - "manual_hold": false, - "hold_reason": null, - "notes": "Retired by STATE-WP-0069 closeout", - "retired_at": "2026-07-08T21:38:52.773601Z", - "created_at": "2026-06-04T06:09:42.198193Z", - "updated_at": "2026-07-08T21:38:52.761243Z" - } - ], + "workplan": "STATE-WP-0070", + "retired_interfaces": [], "weekly_review": { - "generated_at": "2026-07-08T23:08:23.276946Z", - "window_start": "2026-07-01T23:08:22.803913Z", - "window_end": "2026-07-08T23:08:22.803913Z", + "generated_at": "2026-07-08T23:35:18.459774Z", + "window_start": "2026-07-08T15:35:18.219148Z", + "window_end": "2026-07-08T23:35:18.219148Z", "cadence": "weekly", "activity_core_handoff": { "activity_id": "statehub-legacy-interface-review", @@ -67,18 +49,18 @@ } }, "window": { - "calls": 54, + "calls": 17, "tenant_count": 1, "user_count": 1, "component_count": 1, "tenants": { - "unknown": 54 + "unknown": 17 }, "users": { - "unknown": 54 + "unknown": 17 }, "components": { - "state-hub.events": 54 + "state-hub.events": 17 } }, "last_seen_at": "2026-07-08T20:49:12.839383Z", @@ -214,23 +196,23 @@ } }, "window": { - "calls": 24897, + "calls": 1752, "tenant_count": 1, "user_count": 1, "component_count": 1, "tenants": { - "unknown": 24897 + "unknown": 1752 }, "users": { - "unknown": 24897 + "unknown": 1752 }, "components": { - "unknown": 24897 + "unknown": 1752 } }, "last_seen_at": "2026-07-08T21:14:39.652776Z", "retirement_candidate": false, - "retirement_reason": "24897 call(s) in review window" + "retirement_reason": "1752 call(s) in review window" }, { "interface": { @@ -265,23 +247,17 @@ } }, "window": { - "calls": 4, - "tenant_count": 1, - "user_count": 1, - "component_count": 1, - "tenants": { - "unknown": 4 - }, - "users": { - "unknown": 4 - }, - "components": { - "unknown": 4 - } + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} }, "last_seen_at": "2026-07-03T05:20:34.534439Z", - "retirement_candidate": false, - "retirement_reason": "4 call(s) in review window" + "retirement_candidate": true, + "retirement_reason": "no measured usage in review window" }, { "interface": { @@ -316,23 +292,23 @@ } }, "window": { - "calls": 82774, + "calls": 7653, "tenant_count": 1, "user_count": 1, "component_count": 1, "tenants": { - "unknown": 82774 + "unknown": 7653 }, "users": { - "unknown": 82774 + "unknown": 7653 }, "components": { - "unknown": 82774 + "unknown": 7653 } }, "last_seen_at": "2026-07-08T21:14:20.781103Z", "retirement_candidate": false, - "retirement_reason": "82774 call(s) in review window" + "retirement_reason": "7653 call(s) in review window" }, { "interface": { @@ -367,23 +343,23 @@ } }, "window": { - "calls": 41448, + "calls": 3892, "tenant_count": 1, "user_count": 1, "component_count": 1, "tenants": { - "unknown": 41448 + "unknown": 3892 }, "users": { - "unknown": 41448 + "unknown": 3892 }, "components": { - "unknown": 41448 + "unknown": 3892 } }, "last_seen_at": "2026-07-08T21:14:12.747005Z", "retirement_candidate": false, - "retirement_reason": "41448 call(s) in review window" + "retirement_reason": "3892 call(s) in review window" }, { "interface": { @@ -418,23 +394,74 @@ } }, "window": { - "calls": 128, + "calls": 46, "tenant_count": 1, "user_count": 1, "component_count": 1, "tenants": { - "unknown": 128 + "unknown": 46 }, "users": { - "unknown": 128 + "unknown": 46 }, "components": { - "unknown": 128 + "unknown": 46 } }, "last_seen_at": "2026-07-08T20:49:12.335710Z", "retirement_candidate": false, - "retirement_reason": "128 call(s) in review window" + "retirement_reason": "46 call(s) in review window" + }, + { + "interface": { + "id": "4a70583c-1e41-4ec1-99a9-728856a7d79c", + "interface_key": "rest_api:POST /progress/ workstream_id", + "interface_kind": "rest_api", + "legacy_since": "2026-07-08T23:35:09.148333Z", + "replacement_ref": "POST /progress/ with workplan_id", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-07-08T23:35:09.148333Z", + "updated_at": "2026-07-08T23:35:09.148333Z" + }, + "all_time": { + "calls": 1, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 1 + }, + "users": { + "unknown": 1 + }, + "components": { + "unknown": 1 + } + }, + "window": { + "calls": 1, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 1 + }, + "users": { + "unknown": 1 + }, + "components": { + "unknown": 1 + } + }, + "last_seen_at": "2026-07-08T23:35:09.125218Z", + "retirement_candidate": false, + "retirement_reason": "1 call(s) in review window" }, { "interface": { @@ -520,23 +547,23 @@ } }, "window": { - "calls": 40, + "calls": 18, "tenant_count": 1, "user_count": 1, "component_count": 1, "tenants": { - "unknown": 40 + "unknown": 18 }, "users": { - "unknown": 40 + "unknown": 18 }, "components": { - "unknown": 40 + "unknown": 18 } }, "last_seen_at": "2026-07-08T20:38:05.903144Z", "retirement_candidate": false, - "retirement_reason": "40 call(s) in review window" + "retirement_reason": "18 call(s) in review window" }, { "interface": { @@ -630,6 +657,51 @@ "retirement_candidate": true, "retirement_reason": "no measured usage in review window" }, + { + "interface": { + "id": "e4c140ec-7479-46f9-b185-4b533f19d639", + "interface_key": "rest_api:GET /workstreams/workplan-index", + "interface_kind": "rest_api", + "legacy_since": "2026-06-04T05:20:58.321869Z", + "replacement_ref": "/workplans/index", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T05:20:58.321869Z", + "updated_at": "2026-06-04T05:20:58.321869Z" + }, + "all_time": { + "calls": 30, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 30 + }, + "users": { + "unknown": 30 + }, + "components": { + "unknown": 30 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-07-03T05:20:34.534439Z", + "retirement_candidate": true, + "retirement_reason": "no measured usage in review window" + }, { "interface": { "id": "281fd706-b192-4194-8055-2d8733c115f9", @@ -676,5 +748,6 @@ "retirement_reason": "no measured usage in review window" } ] - } + }, + "hours": 8 } diff --git a/docs/evidence/railiance01-state-hub-deploy-20260709.md b/docs/evidence/railiance01-state-hub-deploy-20260709.md index f141cb4..dc48b69 100644 --- a/docs/evidence/railiance01-state-hub-deploy-20260709.md +++ b/docs/evidence/railiance01-state-hub-deploy-20260709.md @@ -35,7 +35,8 @@ git log -1 --oneline # expect 5a92923 or newer statehub fix-consistency --all --remote --api-base http://127.0.0.1:8000 # 4. Verify legacy-meter attribution (from any host with hub API access) -python ~/state-hub/scripts/capture_legacy_meter_evidence.py --days 7 +python ~/state-hub/scripts/capture_legacy_meter_evidence.py +# Default 8-hour window; use --days 7 for weekly cadence # Expect component bucket state-hub.fix-consistency; declining GET /workstreams/* ``` @@ -47,8 +48,8 @@ python ~/state-hub/scripts/capture_legacy_meter_evidence.py --days 7 (`POST /consistency/sweep/remote-all` exit_code 0, 2026-07-08T23:08Z) - [x] Weekly legacy-meter review shows `state-hub.fix-consistency` (not only `unknown`) (`docs/evidence/legacy-meter-weekly-review-20260709.json`, 5 attributed calls) -- [ ] `GET /workstreams/*` 7-day window counts trend down week-over-week - (deploy complete 2026-07-09; re-capture after one full week for ≥50% drop gate) +- [ ] `GET /workstreams/*` counts trend down in 8-hour capture windows post-deploy + (default `capture_legacy_meter_evidence.py`; weekly `--days 7` for retirement gate) ## Rollback diff --git a/docs/evidence/workstream-caller-inventory-20260708.md b/docs/evidence/workstream-caller-inventory-20260708.md index 9db52af..bd152c3 100644 --- a/docs/evidence/workstream-caller-inventory-20260708.md +++ b/docs/evidence/workstream-caller-inventory-20260708.md @@ -43,7 +43,7 @@ on legacy REST keys; most lacked `X-StateHub-Component` identity (`unknown` buck ## Next verification ```bash -python scripts/capture_legacy_meter_evidence.py --days 7 +python scripts/capture_legacy_meter_evidence.py # default 8h; --days 7 for weekly ``` Expect `GET /workstreams/*` window counts to fall after deploy + one full diff --git a/docs/workplan-terminology-transition.md b/docs/workplan-terminology-transition.md index 6a95d1f..2d2c76c 100644 --- a/docs/workplan-terminology-transition.md +++ b/docs/workplan-terminology-transition.md @@ -104,7 +104,8 @@ Deferred until seven consecutive zero-usage review windows per key: Evidence capture: ```bash -python scripts/capture_legacy_meter_evidence.py --days 7 +python scripts/capture_legacy_meter_evidence.py # default 8h post-deploy monitoring +python scripts/capture_legacy_meter_evidence.py --days 7 # weekly retirement gate # writes docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json ``` diff --git a/scripts/capture_legacy_meter_evidence.py b/scripts/capture_legacy_meter_evidence.py index dd19dec..e587a9b 100644 --- a/scripts/capture_legacy_meter_evidence.py +++ b/scripts/capture_legacy_meter_evidence.py @@ -1,8 +1,11 @@ #!/usr/bin/env python3 -"""Capture legacy-meter weekly-review payload as dated evidence JSON. +"""Capture legacy-meter review payload as dated evidence JSON. Usage: - python scripts/capture_legacy_meter_evidence.py [--days 7] [--api-base URL] [--dry-run] + 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 @@ -68,23 +71,40 @@ def _retire_interfaces(base: str, keys: list[str]) -> list[dict]: 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}") +def _review_query(*, days: int | None, hours: int | None) -> str: + if days is not None: + return f"days={days}" + return f"hours={hours or 8}" + + +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?days={days}") + 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 = { + payload: dict = { "captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "api_base": api_base, - "days": days, - "workplan": "STATE-WP-0069", + "workplan": "STATE-WP-0070", "retired_interfaces": retired, "weekly_review": review, } + if days is not None: + payload["days"] = days + else: + payload["hours"] = hours or 8 if dry_run: print(json.dumps(payload, indent=2)) @@ -94,13 +114,25 @@ def capture(*, api_base: str, days: int, retire_keys: list[str], dry_run: bool) 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)") + 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("--days", type=int, default=7) + parser.add_argument( + "--hours", + type=int, + default=8, + help="Review window in hours (default: 8; used when --days is omitted)", + ) + parser.add_argument( + "--days", + type=int, + default=None, + help="Review window in days (weekly cadence; overrides --hours)", + ) parser.add_argument( "--api-base", default=os.environ.get("API_BASE", "http://127.0.0.1:8000"), @@ -118,6 +150,7 @@ def main() -> None: capture( api_base=args.api_base, days=args.days, + hours=args.hours, retire_keys=retire_keys, dry_run=args.dry_run, ) diff --git a/tests/test_legacy_meter.py b/tests/test_legacy_meter.py index bac4456..ba313e3 100644 --- a/tests/test_legacy_meter.py +++ b/tests/test_legacy_meter.py @@ -93,6 +93,25 @@ class TestWorkplanAliasesAndLegacyMeter: assert item["window"]["components"] == {"old-client": 1} assert item["retirement_candidate"] is False + async def test_weekly_review_accepts_hours_window(self, client): + r = await client.post("/legacy-meter/interfaces", json={ + "interface_key": "rest_api:GET /hour-window", + "interface_kind": "rest_api", + "replacement_ref": "/workplans/", + "replacement_verified": True, + }) + assert r.status_code == 201, r.text + + review = (await client.get("/legacy-meter/weekly-review?hours=8")).json() + window_seconds = ( + datetime.fromisoformat(review["window_end"].replace("Z", "+00:00")) + - datetime.fromisoformat(review["window_start"].replace("Z", "+00:00")) + ).total_seconds() + assert 8 * 3600 - 5 <= window_seconds <= 8 * 3600 + 5 + + bad = await client.get("/legacy-meter/weekly-review?days=7&hours=8") + assert bad.status_code == 422 + async def test_weekly_review_reports_unused_verified_legacy_interface(self, client): r = await client.post("/legacy-meter/interfaces", json={ "interface_key": "rest_api:GET /obsolete",