Implement fin-hub T24-T26: ingest, runway, coupling, RaaS packaging
Add CSV importers, runway calculator, budget alerts, cross-hub coupling emitters, finhub CLI, and raas-mvp-packaging docs with full test coverage.
This commit is contained in:
parent
b6993d4a05
commit
1abbbe85e4
27 changed files with 835 additions and 3 deletions
11
src/fin_hub/coupling/__init__.py
Normal file
11
src/fin_hub/coupling/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""FOS §9 cross-hub coupling emitters."""
|
||||
|
||||
from fin_hub.coupling.canon import emit_viability_alert
|
||||
from fin_hub.coupling.dev_hub import emit_resource_pressure
|
||||
from fin_hub.coupling.ops_hub import build_service_cost_report
|
||||
|
||||
__all__ = [
|
||||
"build_service_cost_report",
|
||||
"emit_resource_pressure",
|
||||
"emit_viability_alert",
|
||||
]
|
||||
38
src/fin_hub/coupling/canon.py
Normal file
38
src/fin_hub/coupling/canon.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""fin→canon: viability alerts when runway breaches System 5 threshold."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hub_core.events import RISK_ESCALATED
|
||||
|
||||
from fin_hub.coupling.common import default_dev_hub_api_base, post_progress_event
|
||||
from fin_hub.services.runway import RunwayResult
|
||||
|
||||
|
||||
def emit_viability_alert(
|
||||
runway: RunwayResult,
|
||||
*,
|
||||
api_base: str | None = None,
|
||||
canon_workplan_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not runway.below_threshold or runway.monthly_burn <= 0:
|
||||
return {"ok": True, "skipped": True, "reason": "runway above threshold"}
|
||||
base = api_base or default_dev_hub_api_base()
|
||||
return post_progress_event(
|
||||
api_base=base,
|
||||
event_type=RISK_ESCALATED,
|
||||
summary=(
|
||||
f"[fin→canon] Runway {runway.months_remaining:.1f} months below "
|
||||
f"{runway.alert_threshold_months:.1f}-month viability threshold"
|
||||
),
|
||||
detail={
|
||||
"source_hub": "fin-hub",
|
||||
"target_hub": "canon",
|
||||
"signal": "viability_alert",
|
||||
"runway": runway.as_dict(),
|
||||
"escalation_target": "system_5",
|
||||
},
|
||||
author="fin-hub",
|
||||
workplan_id=canon_workplan_id,
|
||||
)
|
||||
46
src/fin_hub/coupling/common.py
Normal file
46
src/fin_hub/coupling/common.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""HTTP helpers for cross-hub progress emission."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
def post_progress_event(
|
||||
*,
|
||||
api_base: str,
|
||||
event_type: str,
|
||||
summary: str,
|
||||
detail: dict[str, Any] | None = None,
|
||||
author: str = "fin-hub",
|
||||
workplan_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"event_type": event_type,
|
||||
"summary": summary,
|
||||
"author": author,
|
||||
}
|
||||
if detail:
|
||||
payload["detail"] = detail
|
||||
if workplan_id:
|
||||
payload["workplan_id"] = workplan_id
|
||||
url = f"{api_base.rstrip('/')}/progress/"
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.URLError as exc:
|
||||
return {"ok": False, "error": str(exc), "queued": False}
|
||||
|
||||
|
||||
def default_dev_hub_api_base() -> str:
|
||||
return os.environ.get("STATE_HUB_API", os.environ.get("API_BASE", "http://127.0.0.1:8000"))
|
||||
37
src/fin_hub/coupling/dev_hub.py
Normal file
37
src/fin_hub/coupling/dev_hub.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""fin→dev: resource pressure signals to dev-hub (State Hub progress)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hub_core.events import ALERT_RAISED
|
||||
|
||||
from fin_hub.coupling.common import default_dev_hub_api_base, post_progress_event
|
||||
from fin_hub.services.alerts import BudgetAlert
|
||||
|
||||
|
||||
def emit_resource_pressure(
|
||||
alerts: list[BudgetAlert],
|
||||
*,
|
||||
api_base: str | None = None,
|
||||
domain_slug: str = "infotech",
|
||||
) -> list[dict[str, Any]]:
|
||||
base = api_base or default_dev_hub_api_base()
|
||||
emitted: list[dict[str, Any]] = []
|
||||
for alert in alerts:
|
||||
if alert.code not in {"budget_pressure", "runway_below_threshold"}:
|
||||
continue
|
||||
result = post_progress_event(
|
||||
api_base=base,
|
||||
event_type=ALERT_RAISED,
|
||||
summary=f"[fin→dev:{domain_slug}] {alert.summary}",
|
||||
detail={
|
||||
"source_hub": "fin-hub",
|
||||
"target_hub": "dev-hub",
|
||||
"signal": "resource_pressure",
|
||||
"alert": alert.as_dict(),
|
||||
},
|
||||
author="fin-hub",
|
||||
)
|
||||
emitted.append(result)
|
||||
return emitted
|
||||
47
src/fin_hub/coupling/ops_hub.py
Normal file
47
src/fin_hub/coupling/ops_hub.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""fin→ops: infrastructure cost attribution per service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceCostLine:
|
||||
service_id: str
|
||||
environment: str
|
||||
period_month: str
|
||||
amount: float
|
||||
currency: str
|
||||
source: str
|
||||
|
||||
|
||||
def build_service_cost_report(
|
||||
lines: Iterable[ServiceCostLine],
|
||||
) -> dict:
|
||||
by_service: dict[str, dict] = {}
|
||||
totals_by_month: dict[str, float] = defaultdict(float)
|
||||
for line in lines:
|
||||
bucket = by_service.setdefault(
|
||||
line.service_id,
|
||||
{
|
||||
"service_id": line.service_id,
|
||||
"environment": line.environment,
|
||||
"currency": line.currency,
|
||||
"months": {},
|
||||
"total": 0.0,
|
||||
},
|
||||
)
|
||||
month_total = bucket["months"].get(line.period_month, 0.0) + line.amount
|
||||
bucket["months"][line.period_month] = month_total
|
||||
bucket["total"] += line.amount
|
||||
totals_by_month[line.period_month] += line.amount
|
||||
services = sorted(by_service.values(), key=lambda item: item["total"], reverse=True)
|
||||
return {
|
||||
"source_hub": "fin-hub",
|
||||
"target_hub": "ops-hub",
|
||||
"signal": "service_cost_attribution",
|
||||
"services": services,
|
||||
"totals_by_month": dict(sorted(totals_by_month.items())),
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue