generated from coulomb/repo-seed
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.
This commit is contained in:
392
api/services/legacy_meter.py
Normal file
392
api/services/legacy_meter.py
Normal file
@@ -0,0 +1,392 @@
|
||||
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
|
||||
Reference in New Issue
Block a user