generated from coulomb/repo-seed
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.
393 lines
13 KiB
Python
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
|