Files
state-hub/api/services/legacy_meter.py
tegwick 166aedfa8d feat: add workplan aliases and legacy meter
Adds preferred workplan REST/event surfaces, legacy-meter telemetry and weekly review summaries, documentation/dashboard terminology updates, dashboard API loading fixes, and close-out sync for STATE-WP-0052 and STATE-WP-0054.
2026-06-04 08:25:31 +02:00

393 lines
13 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
from api.schemas.legacy_meter import (
LegacyInterfaceRead,
LegacyInterfaceSummary,
LegacyUsageCounters,
LegacyUsageSummary,
LegacyWeeklyReview,
)
UNKNOWN_BUCKET = "unknown"
VALID_INTERFACE_KINDS = {
"rest_api",
"mcp_tool",
"procedure_call",
"event_subject",
"cli",
"dashboard_route",
"schema_field",
}
VALID_INTERFACE_STATUSES = {"legacy", "retirement_candidate", "retired"}
@dataclass(frozen=True)
class LegacyUsageIdentity:
tenant_key: str = UNKNOWN_BUCKET
user_key: str = UNKNOWN_BUCKET
component_key: str = UNKNOWN_BUCKET
def identity_from_request(request: Request | None) -> LegacyUsageIdentity:
if request is None:
return LegacyUsageIdentity()
headers = request.headers
return LegacyUsageIdentity(
tenant_key=_clean_bucket(headers.get("x-statehub-tenant") or headers.get("x-tenant-id")),
user_key=_clean_bucket(headers.get("x-statehub-user") or headers.get("x-user-id")),
component_key=_clean_bucket(headers.get("x-statehub-component") or headers.get("x-component")),
)
async def register_legacy_interface(
session: AsyncSession,
*,
interface_key: str,
interface_kind: str,
replacement_ref: str,
owner_component: str = "state-hub",
replacement_verified: bool = False,
manual_hold: bool = False,
hold_reason: str | None = None,
notes: str | None = None,
status: str = "legacy",
commit: bool = True,
preserve_controls: bool = False,
) -> LegacyInterface:
interface_key = _required(interface_key, "interface_key")
replacement_ref = _required(replacement_ref, "replacement_ref")
interface_kind = _validate_kind(interface_kind)
status = _validate_status(status)
existing = await _get_by_key(session, interface_key)
if existing is None:
existing = LegacyInterface(
interface_key=interface_key,
interface_kind=interface_kind,
replacement_ref=replacement_ref,
owner_component=_clean_value(owner_component, "state-hub"),
replacement_verified=replacement_verified,
manual_hold=manual_hold,
hold_reason=hold_reason,
notes=notes,
status=status,
)
session.add(existing)
else:
existing.interface_kind = interface_kind
existing.replacement_ref = replacement_ref
existing.owner_component = _clean_value(owner_component, "state-hub")
existing.replacement_verified = existing.replacement_verified or replacement_verified
if not preserve_controls:
existing.manual_hold = manual_hold
existing.hold_reason = hold_reason
existing.notes = notes if notes is not None else existing.notes
existing.status = status
if commit:
await session.commit()
await session.refresh(existing)
else:
await session.flush()
return existing
async def patch_legacy_interface(
session: AsyncSession,
interface: LegacyInterface,
updates: dict[str, Any],
) -> LegacyInterface:
if "interface_kind" in updates:
updates["interface_kind"] = _validate_kind(updates["interface_kind"])
if "status" in updates and updates["status"] is not None:
updates["status"] = _validate_status(updates["status"])
if updates["status"] == "retired" and interface.retired_at is None:
interface.retired_at = datetime.now(tz=timezone.utc)
for field, value in updates.items():
if value is not None or field in {"manual_hold", "replacement_verified", "hold_reason", "notes"}:
setattr(interface, field, value)
await session.commit()
await session.refresh(interface)
return interface
async def record_legacy_usage(
session: AsyncSession,
*,
interface_key: str,
interface_kind: str,
replacement_ref: str,
identity: LegacyUsageIdentity | None = None,
owner_component: str = "state-hub",
replacement_verified: bool = False,
observed_at: datetime | None = None,
call_count: int = 1,
commit: bool = True,
) -> LegacyInterface:
if call_count < 1:
raise ValueError("call_count must be >= 1")
observed_at = observed_at or datetime.now(tz=timezone.utc)
if observed_at.tzinfo is None:
observed_at = observed_at.replace(tzinfo=timezone.utc)
identity = identity or LegacyUsageIdentity()
interface = await register_legacy_interface(
session,
interface_key=interface_key,
interface_kind=interface_kind,
replacement_ref=replacement_ref,
owner_component=owner_component,
replacement_verified=replacement_verified,
preserve_controls=True,
commit=False,
)
for bucket_kind, bucket_key in (
("call", "total"),
("tenant", identity.tenant_key),
("user", identity.user_key),
("component", identity.component_key),
):
await _increment_bucket(
session,
interface,
period_start=observed_at.date(),
bucket_kind=bucket_kind,
bucket_key=_clean_bucket(bucket_key),
observed_at=observed_at,
call_count=call_count,
)
if commit:
await session.commit()
await session.refresh(interface)
else:
await session.flush()
return interface
async def get_legacy_interface_by_key(
session: AsyncSession,
interface_key: str,
) -> LegacyInterface | None:
return await _get_by_key(session, interface_key)
async def list_legacy_interfaces(session: AsyncSession) -> list[LegacyInterface]:
result = await session.execute(
select(LegacyInterface).order_by(LegacyInterface.interface_kind, LegacyInterface.interface_key)
)
return list(result.scalars().all())
async def legacy_usage_summary(
session: AsyncSession,
*,
window_start: datetime | None = None,
window_end: datetime | None = None,
) -> LegacyUsageSummary:
window_end = _ensure_datetime(window_end) or datetime.now(tz=timezone.utc)
window_start = _ensure_datetime(window_start) or (window_end - timedelta(days=7))
interfaces = await list_legacy_interfaces(session)
buckets = await _usage_buckets(session)
by_interface: dict[Any, list[LegacyInterfaceUsageBucket]] = {}
for bucket in buckets:
by_interface.setdefault(bucket.legacy_interface_id, []).append(bucket)
summaries = [
_summarize_interface(interface, by_interface.get(interface.id, []), window_start, window_end)
for interface in interfaces
]
return LegacyUsageSummary(
generated_at=datetime.now(tz=timezone.utc),
window_start=window_start,
window_end=window_end,
interfaces=summaries,
)
async def legacy_weekly_review(
session: AsyncSession,
*,
window_start: datetime | None = None,
window_end: datetime | None = None,
) -> LegacyWeeklyReview:
summary = await legacy_usage_summary(
session,
window_start=window_start,
window_end=window_end,
)
candidates = [item for item in summary.interfaces if item.retirement_candidate]
return LegacyWeeklyReview(
generated_at=summary.generated_at,
window_start=summary.window_start,
window_end=summary.window_end,
activity_core_handoff={
"activity_id": "statehub-legacy-interface-review",
"cadence": "weekly",
"source_endpoint": "/legacy-meter/weekly-review",
"state_owner": "state-hub",
"scheduler_owner": "activity-core",
},
interfaces=summary.interfaces,
retirement_candidates=candidates,
)
async def _increment_bucket(
session: AsyncSession,
interface: LegacyInterface,
*,
period_start: date,
bucket_kind: str,
bucket_key: str,
observed_at: datetime,
call_count: int,
) -> LegacyInterfaceUsageBucket:
result = await session.execute(
select(LegacyInterfaceUsageBucket).where(
LegacyInterfaceUsageBucket.legacy_interface_id == interface.id,
LegacyInterfaceUsageBucket.period_start == period_start,
LegacyInterfaceUsageBucket.bucket_kind == bucket_kind,
LegacyInterfaceUsageBucket.bucket_key == bucket_key,
)
)
bucket = result.scalar_one_or_none()
if bucket is None:
bucket = LegacyInterfaceUsageBucket(
legacy_interface_id=interface.id,
period_start=period_start,
bucket_kind=bucket_kind,
bucket_key=bucket_key,
call_count=call_count,
first_seen_at=observed_at,
last_seen_at=observed_at,
)
session.add(bucket)
return bucket
bucket.call_count += call_count
if observed_at < bucket.first_seen_at:
bucket.first_seen_at = observed_at
if observed_at > bucket.last_seen_at:
bucket.last_seen_at = observed_at
return bucket
async def _get_by_key(session: AsyncSession, interface_key: str) -> LegacyInterface | None:
result = await session.execute(
select(LegacyInterface).where(LegacyInterface.interface_key == interface_key)
)
return result.scalar_one_or_none()
async def _usage_buckets(session: AsyncSession) -> list[LegacyInterfaceUsageBucket]:
result = await session.execute(select(LegacyInterfaceUsageBucket))
return list(result.scalars().all())
def _summarize_interface(
interface: LegacyInterface,
buckets: list[LegacyInterfaceUsageBucket],
window_start: datetime,
window_end: datetime,
) -> LegacyInterfaceSummary:
all_time = _counters(buckets)
window_buckets = [
bucket for bucket in buckets
if bucket.last_seen_at >= window_start and bucket.first_seen_at < window_end
]
window = _counters(window_buckets)
last_seen = max((bucket.last_seen_at for bucket in buckets), default=None)
retirement_candidate, reason = _retirement_state(interface, window.calls)
return LegacyInterfaceSummary(
interface=LegacyInterfaceRead.model_validate(interface),
all_time=all_time,
window=window,
last_seen_at=last_seen,
retirement_candidate=retirement_candidate,
retirement_reason=reason,
)
def _counters(buckets: list[LegacyInterfaceUsageBucket]) -> LegacyUsageCounters:
calls = sum(bucket.call_count for bucket in buckets if bucket.bucket_kind == "call")
tenants = _bucket_counts(buckets, "tenant")
users = _bucket_counts(buckets, "user")
components = _bucket_counts(buckets, "component")
return LegacyUsageCounters(
calls=calls,
tenant_count=len(tenants),
user_count=len(users),
component_count=len(components),
tenants=tenants,
users=users,
components=components,
)
def _bucket_counts(buckets: list[LegacyInterfaceUsageBucket], bucket_kind: str) -> dict[str, int]:
counts: dict[str, int] = {}
for bucket in buckets:
if bucket.bucket_kind == bucket_kind:
counts[bucket.bucket_key] = counts.get(bucket.bucket_key, 0) + bucket.call_count
return counts
def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bool, str]:
if interface.status == "retired":
return False, "already retired"
if interface.manual_hold:
return False, interface.hold_reason or "manual hold"
if not interface.replacement_ref.strip():
return False, "missing replacement reference"
if not interface.replacement_verified:
return False, "replacement not verified"
if window_calls > 0:
return False, f"{window_calls} call(s) in review window"
return True, "no measured usage in review window"
def _ensure_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _required(value: str | None, field: str) -> str:
cleaned = _clean_value(value)
if not cleaned:
raise ValueError(f"{field} is required")
return cleaned
def _clean_value(value: str | None, default: str = "") -> str:
return str(value or default).strip()
def _clean_bucket(value: str | None) -> str:
cleaned = _clean_value(value)
return cleaned or UNKNOWN_BUCKET
def _validate_kind(kind: str) -> str:
cleaned = _required(kind, "interface_kind")
if cleaned not in VALID_INTERFACE_KINDS:
raise ValueError(f"interface_kind must be one of {sorted(VALID_INTERFACE_KINDS)}")
return cleaned
def _validate_status(status: str) -> str:
cleaned = _required(status, "status")
if cleaned not in VALID_INTERFACE_STATUSES:
raise ValueError(f"status must be one of {sorted(VALID_INTERFACE_STATUSES)}")
return cleaned