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:
tegwick 2026-07-08 00:59:39 +02:00
parent b6993d4a05
commit 1abbbe85e4
27 changed files with 835 additions and 3 deletions

View 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",
]

View 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,
)

View 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"))

View 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

View 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())),
}