forked from Rockachopa/Timmy-time-dashboard
Compare commits
5 Commits
kimi/issue
...
fix/router
| Author | SHA1 | Date | |
|---|---|---|---|
| 9732c80892 | |||
| f3b3d1e648 | |||
| 4ba8d25749 | |||
| 2622f0a0fb | |||
| e3d60b89a9 |
@@ -54,6 +54,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
|||||||
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
||||||
SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json"
|
SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json"
|
||||||
EPOCH_COUNTER_FILE = REPO_ROOT / ".loop" / "retro" / ".epoch_counter"
|
EPOCH_COUNTER_FILE = REPO_ROOT / ".loop" / "retro" / ".epoch_counter"
|
||||||
|
CYCLE_RESULT_FILE = REPO_ROOT / ".loop" / "cycle_result.json"
|
||||||
|
|
||||||
# How many recent entries to include in rolling summary
|
# How many recent entries to include in rolling summary
|
||||||
SUMMARY_WINDOW = 50
|
SUMMARY_WINDOW = 50
|
||||||
@@ -246,9 +247,37 @@ def update_summary() -> None:
|
|||||||
SUMMARY_FILE.write_text(json.dumps(summary, indent=2) + "\n")
|
SUMMARY_FILE.write_text(json.dumps(summary, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cycle_result() -> dict:
|
||||||
|
"""Read .loop/cycle_result.json if it exists; return empty dict on failure."""
|
||||||
|
if not CYCLE_RESULT_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = CYCLE_RESULT_FILE.read_text().strip()
|
||||||
|
# Strip hermes fence markers (```json ... ```) if present
|
||||||
|
if raw.startswith("```"):
|
||||||
|
lines = raw.splitlines()
|
||||||
|
lines = [l for l in lines if not l.startswith("```")]
|
||||||
|
raw = "\n".join(lines)
|
||||||
|
return json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
|
# Backfill from cycle_result.json when CLI args have defaults
|
||||||
|
cr = _load_cycle_result()
|
||||||
|
if cr:
|
||||||
|
if args.issue is None and cr.get("issue"):
|
||||||
|
args.issue = int(cr["issue"])
|
||||||
|
if args.type == "unknown" and cr.get("type"):
|
||||||
|
args.type = cr["type"]
|
||||||
|
if args.tests_passed == 0 and cr.get("tests_passed"):
|
||||||
|
args.tests_passed = int(cr["tests_passed"])
|
||||||
|
if not args.notes and cr.get("notes"):
|
||||||
|
args.notes = cr["notes"]
|
||||||
|
|
||||||
# Auto-detect issue from branch when not explicitly provided
|
# Auto-detect issue from branch when not explicitly provided
|
||||||
if args.issue is None:
|
if args.issue is None:
|
||||||
args.issue = detect_issue_from_branch()
|
args.issue = detect_issue_from_branch()
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from dashboard.routes.tasks import router as tasks_router
|
|||||||
from dashboard.routes.telegram import router as telegram_router
|
from dashboard.routes.telegram import router as telegram_router
|
||||||
from dashboard.routes.thinking import router as thinking_router
|
from dashboard.routes.thinking import router as thinking_router
|
||||||
from dashboard.routes.tools import router as tools_router
|
from dashboard.routes.tools import router as tools_router
|
||||||
|
from dashboard.routes.tower import router as tower_router
|
||||||
from dashboard.routes.voice import router as voice_router
|
from dashboard.routes.voice import router as voice_router
|
||||||
from dashboard.routes.work_orders import router as work_orders_router
|
from dashboard.routes.work_orders import router as work_orders_router
|
||||||
from dashboard.routes.world import router as world_router
|
from dashboard.routes.world import router as world_router
|
||||||
@@ -583,6 +584,7 @@ app.include_router(system_router)
|
|||||||
app.include_router(experiments_router)
|
app.include_router(experiments_router)
|
||||||
app.include_router(db_explorer_router)
|
app.include_router(db_explorer_router)
|
||||||
app.include_router(world_router)
|
app.include_router(world_router)
|
||||||
|
app.include_router(tower_router)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
|
|||||||
@@ -16,52 +16,11 @@ router = APIRouter(tags=["system"])
|
|||||||
|
|
||||||
@router.get("/lightning/ledger", response_class=HTMLResponse)
|
@router.get("/lightning/ledger", response_class=HTMLResponse)
|
||||||
async def lightning_ledger(request: Request):
|
async def lightning_ledger(request: Request):
|
||||||
"""Ledger and balance page."""
|
"""Ledger and balance page backed by the in-memory Lightning ledger."""
|
||||||
# Mock data for now, as this seems to be a UI-first feature
|
from lightning.ledger import get_balance, get_transactions
|
||||||
balance = {
|
|
||||||
"available_sats": 1337,
|
|
||||||
"incoming_total_sats": 2000,
|
|
||||||
"outgoing_total_sats": 663,
|
|
||||||
"fees_paid_sats": 5,
|
|
||||||
"net_sats": 1337,
|
|
||||||
"pending_incoming_sats": 0,
|
|
||||||
"pending_outgoing_sats": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Mock transactions
|
balance = get_balance()
|
||||||
from collections import namedtuple
|
transactions = get_transactions()
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
class TxType(Enum):
|
|
||||||
incoming = "incoming"
|
|
||||||
outgoing = "outgoing"
|
|
||||||
|
|
||||||
class TxStatus(Enum):
|
|
||||||
completed = "completed"
|
|
||||||
pending = "pending"
|
|
||||||
|
|
||||||
Tx = namedtuple(
|
|
||||||
"Tx", ["tx_type", "status", "amount_sats", "payment_hash", "memo", "created_at"]
|
|
||||||
)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Tx(
|
|
||||||
TxType.outgoing,
|
|
||||||
TxStatus.completed,
|
|
||||||
50,
|
|
||||||
"hash1",
|
|
||||||
"Model inference",
|
|
||||||
"2026-03-04 10:00:00",
|
|
||||||
),
|
|
||||||
Tx(
|
|
||||||
TxType.incoming,
|
|
||||||
TxStatus.completed,
|
|
||||||
1000,
|
|
||||||
"hash2",
|
|
||||||
"Manual deposit",
|
|
||||||
"2026-03-03 15:00:00",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -70,7 +29,7 @@ async def lightning_ledger(request: Request):
|
|||||||
"balance": balance,
|
"balance": balance,
|
||||||
"transactions": transactions,
|
"transactions": transactions,
|
||||||
"tx_types": ["incoming", "outgoing"],
|
"tx_types": ["incoming", "outgoing"],
|
||||||
"tx_statuses": ["completed", "pending"],
|
"tx_statuses": ["pending", "settled", "failed", "expired"],
|
||||||
"filter_type": None,
|
"filter_type": None,
|
||||||
"filter_status": None,
|
"filter_status": None,
|
||||||
"stats": {},
|
"stats": {},
|
||||||
|
|||||||
108
src/dashboard/routes/tower.py
Normal file
108
src/dashboard/routes/tower.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tower dashboard — real-time Spark visualization via WebSocket.
|
||||||
|
|
||||||
|
GET /tower — HTML Tower dashboard (Thinking / Predicting / Advising)
|
||||||
|
WS /tower/ws — WebSocket stream of Spark engine state updates
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, WebSocket
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from dashboard.templating import templates
|
||||||
|
from spark.engine import spark_engine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tower", tags=["tower"])
|
||||||
|
|
||||||
|
_PUSH_INTERVAL = 5 # seconds between state broadcasts
|
||||||
|
|
||||||
|
|
||||||
|
def _spark_snapshot() -> dict:
|
||||||
|
"""Build a JSON-serialisable snapshot of Spark state."""
|
||||||
|
status = spark_engine.status()
|
||||||
|
|
||||||
|
timeline = spark_engine.get_timeline(limit=10)
|
||||||
|
events = []
|
||||||
|
for ev in timeline:
|
||||||
|
entry = {
|
||||||
|
"event_type": ev.event_type,
|
||||||
|
"description": ev.description,
|
||||||
|
"importance": ev.importance,
|
||||||
|
"created_at": ev.created_at,
|
||||||
|
}
|
||||||
|
if ev.agent_id:
|
||||||
|
entry["agent_id"] = ev.agent_id[:8]
|
||||||
|
if ev.task_id:
|
||||||
|
entry["task_id"] = ev.task_id[:8]
|
||||||
|
try:
|
||||||
|
entry["data"] = json.loads(ev.data)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
entry["data"] = {}
|
||||||
|
events.append(entry)
|
||||||
|
|
||||||
|
predictions = spark_engine.get_predictions(limit=5)
|
||||||
|
preds = []
|
||||||
|
for p in predictions:
|
||||||
|
pred = {
|
||||||
|
"task_id": p.task_id[:8] if p.task_id else "?",
|
||||||
|
"accuracy": p.accuracy,
|
||||||
|
"evaluated": p.evaluated_at is not None,
|
||||||
|
"created_at": p.created_at,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
pred["predicted"] = json.loads(p.predicted_value)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pred["predicted"] = {}
|
||||||
|
preds.append(pred)
|
||||||
|
|
||||||
|
advisories = spark_engine.get_advisories()
|
||||||
|
advs = [
|
||||||
|
{
|
||||||
|
"category": a.category,
|
||||||
|
"priority": a.priority,
|
||||||
|
"title": a.title,
|
||||||
|
"detail": a.detail,
|
||||||
|
"suggested_action": a.suggested_action,
|
||||||
|
}
|
||||||
|
for a in advisories
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "spark_state",
|
||||||
|
"status": status,
|
||||||
|
"events": events,
|
||||||
|
"predictions": preds,
|
||||||
|
"advisories": advs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def tower_ui(request: Request):
|
||||||
|
"""Render the Tower dashboard page."""
|
||||||
|
snapshot = _spark_snapshot()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"tower.html",
|
||||||
|
{"snapshot": snapshot},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws")
|
||||||
|
async def tower_ws(websocket: WebSocket) -> None:
|
||||||
|
"""Stream Spark state snapshots to the Tower dashboard."""
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info("Tower WS connected")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send initial snapshot
|
||||||
|
await websocket.send_text(json.dumps(_spark_snapshot()))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(_PUSH_INTERVAL)
|
||||||
|
await websocket.send_text(json.dumps(_spark_snapshot()))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Tower WS disconnected")
|
||||||
180
src/dashboard/templates/tower.html
Normal file
180
src/dashboard/templates/tower.html
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Timmy Time — Tower{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_styles %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid tower-container py-3">
|
||||||
|
|
||||||
|
<div class="tower-header">
|
||||||
|
<div class="tower-title">TOWER</div>
|
||||||
|
<div class="tower-subtitle">
|
||||||
|
Real-time Spark visualization —
|
||||||
|
<span id="tower-conn" class="tower-conn-badge tower-conn-connecting">CONNECTING</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
|
||||||
|
<!-- Left: THINKING (events) -->
|
||||||
|
<div class="col-12 col-lg-4 d-flex flex-column gap-3">
|
||||||
|
<div class="card mc-panel tower-phase-card">
|
||||||
|
<div class="card-header mc-panel-header tower-phase-thinking">// THINKING</div>
|
||||||
|
<div class="card-body p-3 tower-scroll" id="tower-events">
|
||||||
|
<div class="tower-empty">Waiting for Spark data…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Middle: PREDICTING (EIDOS) -->
|
||||||
|
<div class="col-12 col-lg-4 d-flex flex-column gap-3">
|
||||||
|
<div class="card mc-panel tower-phase-card">
|
||||||
|
<div class="card-header mc-panel-header tower-phase-predicting">// PREDICTING</div>
|
||||||
|
<div class="card-body p-3" id="tower-predictions">
|
||||||
|
<div class="tower-empty">Waiting for Spark data…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mc-panel">
|
||||||
|
<div class="card-header mc-panel-header">// EIDOS STATS</div>
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="tower-stat-grid" id="tower-stats">
|
||||||
|
<div class="tower-stat"><span class="tower-stat-label">EVENTS</span><span class="tower-stat-value" id="ts-events">0</span></div>
|
||||||
|
<div class="tower-stat"><span class="tower-stat-label">MEMORIES</span><span class="tower-stat-value" id="ts-memories">0</span></div>
|
||||||
|
<div class="tower-stat"><span class="tower-stat-label">PREDICTIONS</span><span class="tower-stat-value" id="ts-preds">0</span></div>
|
||||||
|
<div class="tower-stat"><span class="tower-stat-label">ACCURACY</span><span class="tower-stat-value" id="ts-accuracy">—</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: ADVISING -->
|
||||||
|
<div class="col-12 col-lg-4 d-flex flex-column gap-3">
|
||||||
|
<div class="card mc-panel tower-phase-card">
|
||||||
|
<div class="card-header mc-panel-header tower-phase-advising">// ADVISING</div>
|
||||||
|
<div class="card-body p-3 tower-scroll" id="tower-advisories">
|
||||||
|
<div class="tower-empty">Waiting for Spark data…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var ws = null;
|
||||||
|
var badge = document.getElementById('tower-conn');
|
||||||
|
|
||||||
|
function setConn(state) {
|
||||||
|
badge.textContent = state.toUpperCase();
|
||||||
|
badge.className = 'tower-conn-badge tower-conn-' + state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
|
function renderEvents(events) {
|
||||||
|
var el = document.getElementById('tower-events');
|
||||||
|
if (!events || !events.length) { el.innerHTML = '<div class="tower-empty">No events captured yet.</div>'; return; }
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < events.length; i++) {
|
||||||
|
var ev = events[i];
|
||||||
|
var dots = ev.importance >= 0.8 ? '\u25cf\u25cf\u25cf' : ev.importance >= 0.5 ? '\u25cf\u25cf' : '\u25cf';
|
||||||
|
html += '<div class="tower-event tower-etype-' + esc(ev.event_type) + '">'
|
||||||
|
+ '<div class="tower-ev-head">'
|
||||||
|
+ '<span class="tower-ev-badge">' + esc(ev.event_type.replace(/_/g, ' ').toUpperCase()) + '</span>'
|
||||||
|
+ '<span class="tower-ev-dots">' + dots + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="tower-ev-desc">' + esc(ev.description) + '</div>'
|
||||||
|
+ '<div class="tower-ev-time">' + esc((ev.created_at || '').slice(0, 19)) + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPredictions(preds) {
|
||||||
|
var el = document.getElementById('tower-predictions');
|
||||||
|
if (!preds || !preds.length) { el.innerHTML = '<div class="tower-empty">No predictions yet.</div>'; return; }
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < preds.length; i++) {
|
||||||
|
var p = preds[i];
|
||||||
|
var cls = p.evaluated ? 'tower-pred-done' : 'tower-pred-pending';
|
||||||
|
var accTxt = p.accuracy != null ? Math.round(p.accuracy * 100) + '%' : 'PENDING';
|
||||||
|
var accCls = p.accuracy != null ? (p.accuracy >= 0.7 ? 'text-success' : p.accuracy < 0.4 ? 'text-danger' : 'text-warning') : '';
|
||||||
|
html += '<div class="tower-pred ' + cls + '">'
|
||||||
|
+ '<div class="tower-pred-head">'
|
||||||
|
+ '<span class="tower-pred-task">' + esc(p.task_id) + '</span>'
|
||||||
|
+ '<span class="tower-pred-acc ' + accCls + '">' + accTxt + '</span>'
|
||||||
|
+ '</div>';
|
||||||
|
if (p.predicted) {
|
||||||
|
var pr = p.predicted;
|
||||||
|
html += '<div class="tower-pred-detail">';
|
||||||
|
if (pr.likely_winner) html += '<span>Winner: ' + esc(pr.likely_winner.slice(0, 8)) + '</span> ';
|
||||||
|
if (pr.success_probability != null) html += '<span>Success: ' + Math.round(pr.success_probability * 100) + '%</span> ';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '<div class="tower-ev-time">' + esc((p.created_at || '').slice(0, 19)) + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdvisories(advs) {
|
||||||
|
var el = document.getElementById('tower-advisories');
|
||||||
|
if (!advs || !advs.length) { el.innerHTML = '<div class="tower-empty">No advisories yet.</div>'; return; }
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < advs.length; i++) {
|
||||||
|
var a = advs[i];
|
||||||
|
var prio = a.priority >= 0.7 ? 'high' : a.priority >= 0.4 ? 'medium' : 'low';
|
||||||
|
html += '<div class="tower-advisory tower-adv-' + prio + '">'
|
||||||
|
+ '<div class="tower-adv-head">'
|
||||||
|
+ '<span class="tower-adv-cat">' + esc(a.category.replace(/_/g, ' ').toUpperCase()) + '</span>'
|
||||||
|
+ '<span class="tower-adv-prio">' + Math.round(a.priority * 100) + '%</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="tower-adv-title">' + esc(a.title) + '</div>'
|
||||||
|
+ '<div class="tower-adv-detail">' + esc(a.detail) + '</div>'
|
||||||
|
+ '<div class="tower-adv-action">' + esc(a.suggested_action) + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats(status) {
|
||||||
|
if (!status) return;
|
||||||
|
document.getElementById('ts-events').textContent = status.events_captured || 0;
|
||||||
|
document.getElementById('ts-memories').textContent = status.memories_stored || 0;
|
||||||
|
var p = status.predictions || {};
|
||||||
|
document.getElementById('ts-preds').textContent = p.total_predictions || 0;
|
||||||
|
var acc = p.avg_accuracy;
|
||||||
|
var accEl = document.getElementById('ts-accuracy');
|
||||||
|
if (acc != null) {
|
||||||
|
accEl.textContent = Math.round(acc * 100) + '%';
|
||||||
|
accEl.className = 'tower-stat-value ' + (acc >= 0.7 ? 'text-success' : acc < 0.4 ? 'text-danger' : 'text-warning');
|
||||||
|
} else {
|
||||||
|
accEl.textContent = '\u2014';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMsg(data) {
|
||||||
|
if (data.type !== 'spark_state') return;
|
||||||
|
renderEvents(data.events);
|
||||||
|
renderPredictions(data.predictions);
|
||||||
|
renderAdvisories(data.advisories);
|
||||||
|
renderStats(data.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(proto + '//' + location.host + '/tower/ws');
|
||||||
|
ws.onopen = function() { setConn('live'); };
|
||||||
|
ws.onclose = function() { setConn('offline'); setTimeout(connect, 3000); };
|
||||||
|
ws.onerror = function() { setConn('offline'); };
|
||||||
|
ws.onmessage = function(e) {
|
||||||
|
try { handleMsg(JSON.parse(e.data)); } catch(err) { console.error('Tower WS parse error', err); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from .api import router
|
from .api import router
|
||||||
from .cascade import CascadeRouter, Provider, ProviderStatus, get_router
|
from .cascade import CascadeRouter, Provider, ProviderStatus, get_router
|
||||||
|
from .history import HealthHistoryStore, get_history_store
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CascadeRouter",
|
"CascadeRouter",
|
||||||
@@ -9,4 +10,6 @@ __all__ = [
|
|||||||
"ProviderStatus",
|
"ProviderStatus",
|
||||||
"get_router",
|
"get_router",
|
||||||
"router",
|
"router",
|
||||||
|
"HealthHistoryStore",
|
||||||
|
"get_history_store",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .cascade import CascadeRouter, get_router
|
from .cascade import CascadeRouter, get_router
|
||||||
|
from .history import HealthHistoryStore, get_history_store
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/v1/router", tags=["router"])
|
router = APIRouter(prefix="/api/v1/router", tags=["router"])
|
||||||
@@ -199,6 +200,17 @@ async def reload_config(
|
|||||||
raise HTTPException(status_code=500, detail=f"Reload failed: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Reload failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_history(
|
||||||
|
hours: int = 24,
|
||||||
|
store: Annotated[HealthHistoryStore, Depends(get_history_store)] = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get provider health history for the last N hours."""
|
||||||
|
if store is None:
|
||||||
|
store = get_history_store()
|
||||||
|
return store.get_history(hours=hours)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config")
|
@router.get("/config")
|
||||||
async def get_config(
|
async def get_config(
|
||||||
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
|
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
|
||||||
|
|||||||
152
src/infrastructure/router/history.py
Normal file
152
src/infrastructure/router/history.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Provider health history — time-series snapshots for dashboard visualization."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_store: "HealthHistoryStore | None" = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthHistoryStore:
|
||||||
|
"""Stores timestamped provider health snapshots in SQLite."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "data/router_history.db") -> None:
|
||||||
|
self.db_path = db_path
|
||||||
|
if db_path != ":memory:":
|
||||||
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._init_schema()
|
||||||
|
self._bg_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
def _init_schema(self) -> None:
|
||||||
|
self._conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
provider_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error_rate REAL NOT NULL,
|
||||||
|
avg_latency_ms REAL NOT NULL,
|
||||||
|
circuit_state TEXT NOT NULL,
|
||||||
|
total_requests INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self._conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snapshots_ts
|
||||||
|
ON snapshots(timestamp)
|
||||||
|
""")
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def record_snapshot(self, providers: list[dict]) -> None:
|
||||||
|
"""Record a health snapshot for all providers."""
|
||||||
|
ts = datetime.now(UTC).isoformat()
|
||||||
|
rows = [
|
||||||
|
(
|
||||||
|
ts,
|
||||||
|
p["name"],
|
||||||
|
p["status"],
|
||||||
|
p["error_rate"],
|
||||||
|
p["avg_latency_ms"],
|
||||||
|
p["circuit_state"],
|
||||||
|
p["total_requests"],
|
||||||
|
)
|
||||||
|
for p in providers
|
||||||
|
]
|
||||||
|
self._conn.executemany(
|
||||||
|
"""INSERT INTO snapshots
|
||||||
|
(timestamp, provider_name, status, error_rate,
|
||||||
|
avg_latency_ms, circuit_state, total_requests)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def get_history(self, hours: int = 24) -> list[dict]:
|
||||||
|
"""Return snapshots from the last N hours, grouped by timestamp."""
|
||||||
|
cutoff = (datetime.now(UTC) - timedelta(hours=hours)).isoformat()
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"""SELECT timestamp, provider_name, status, error_rate,
|
||||||
|
avg_latency_ms, circuit_state, total_requests
|
||||||
|
FROM snapshots WHERE timestamp >= ? ORDER BY timestamp""",
|
||||||
|
(cutoff,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Group by timestamp
|
||||||
|
snapshots: dict[str, list[dict]] = {}
|
||||||
|
for row in rows:
|
||||||
|
ts = row["timestamp"]
|
||||||
|
if ts not in snapshots:
|
||||||
|
snapshots[ts] = []
|
||||||
|
snapshots[ts].append(
|
||||||
|
{
|
||||||
|
"name": row["provider_name"],
|
||||||
|
"status": row["status"],
|
||||||
|
"error_rate": row["error_rate"],
|
||||||
|
"avg_latency_ms": row["avg_latency_ms"],
|
||||||
|
"circuit_state": row["circuit_state"],
|
||||||
|
"total_requests": row["total_requests"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return [{"timestamp": ts, "providers": providers} for ts, providers in snapshots.items()]
|
||||||
|
|
||||||
|
def prune(self, keep_hours: int = 168) -> int:
|
||||||
|
"""Remove snapshots older than keep_hours. Returns rows deleted."""
|
||||||
|
cutoff = (datetime.now(UTC) - timedelta(hours=keep_hours)).isoformat()
|
||||||
|
cursor = self._conn.execute("DELETE FROM snapshots WHERE timestamp < ?", (cutoff,))
|
||||||
|
self._conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the database connection."""
|
||||||
|
if self._bg_task and not self._bg_task.done():
|
||||||
|
self._bg_task.cancel()
|
||||||
|
self._conn.close()
|
||||||
|
|
||||||
|
def _capture_snapshot(self, cascade_router) -> None: # noqa: ANN001
|
||||||
|
"""Capture current provider state as a snapshot."""
|
||||||
|
providers = []
|
||||||
|
for p in cascade_router.providers:
|
||||||
|
providers.append(
|
||||||
|
{
|
||||||
|
"name": p.name,
|
||||||
|
"status": p.status.value,
|
||||||
|
"error_rate": round(p.metrics.error_rate, 4),
|
||||||
|
"avg_latency_ms": round(p.metrics.avg_latency_ms, 2),
|
||||||
|
"circuit_state": p.circuit_state.value,
|
||||||
|
"total_requests": p.metrics.total_requests,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.record_snapshot(providers)
|
||||||
|
|
||||||
|
async def start_background_task(
|
||||||
|
self,
|
||||||
|
cascade_router,
|
||||||
|
interval_seconds: int = 60, # noqa: ANN001
|
||||||
|
) -> None:
|
||||||
|
"""Start periodic snapshot capture."""
|
||||||
|
|
||||||
|
async def _loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self._capture_snapshot(cascade_router)
|
||||||
|
logger.debug("Recorded health snapshot")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to record health snapshot")
|
||||||
|
await asyncio.sleep(interval_seconds)
|
||||||
|
|
||||||
|
self._bg_task = asyncio.create_task(_loop())
|
||||||
|
logger.info("Health history background task started (interval=%ds)", interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def get_history_store() -> HealthHistoryStore:
|
||||||
|
"""Get or create the singleton history store."""
|
||||||
|
global _store # noqa: PLW0603
|
||||||
|
if _store is None:
|
||||||
|
_store = HealthHistoryStore()
|
||||||
|
return _store
|
||||||
1
src/lightning/__init__.py
Normal file
1
src/lightning/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Lightning Network integration for tool-usage micro-payments."""
|
||||||
69
src/lightning/factory.py
Normal file
69
src/lightning/factory.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Lightning backend factory.
|
||||||
|
|
||||||
|
Returns a mock or real LND backend based on ``settings.lightning_backend``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Invoice:
|
||||||
|
"""Minimal Lightning invoice representation."""
|
||||||
|
|
||||||
|
payment_hash: str
|
||||||
|
payment_request: str
|
||||||
|
amount_sats: int
|
||||||
|
memo: str
|
||||||
|
|
||||||
|
|
||||||
|
class MockBackend:
|
||||||
|
"""In-memory mock Lightning backend for development and testing."""
|
||||||
|
|
||||||
|
def create_invoice(self, amount_sats: int, memo: str = "") -> Invoice:
|
||||||
|
"""Create a fake invoice with a random payment hash."""
|
||||||
|
raw = secrets.token_bytes(32)
|
||||||
|
payment_hash = hashlib.sha256(raw).hexdigest()
|
||||||
|
payment_request = f"lnbc{amount_sats}mock{payment_hash[:20]}"
|
||||||
|
logger.debug("Mock invoice: %s sats — %s", amount_sats, payment_hash[:12])
|
||||||
|
return Invoice(
|
||||||
|
payment_hash=payment_hash,
|
||||||
|
payment_request=payment_request,
|
||||||
|
amount_sats=amount_sats,
|
||||||
|
memo=memo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton — lazily created
|
||||||
|
_backend: MockBackend | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_backend() -> MockBackend:
|
||||||
|
"""Return the configured Lightning backend (currently mock-only).
|
||||||
|
|
||||||
|
Raises ``ValueError`` if an unsupported backend is requested.
|
||||||
|
"""
|
||||||
|
global _backend # noqa: PLW0603
|
||||||
|
if _backend is not None:
|
||||||
|
return _backend
|
||||||
|
|
||||||
|
kind = settings.lightning_backend
|
||||||
|
if kind == "mock":
|
||||||
|
_backend = MockBackend()
|
||||||
|
elif kind == "lnd":
|
||||||
|
# LND gRPC integration is on the roadmap — for now fall back to mock.
|
||||||
|
logger.warning("LND backend not yet implemented — using mock")
|
||||||
|
_backend = MockBackend()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown lightning_backend: {kind!r}")
|
||||||
|
|
||||||
|
logger.info("Lightning backend: %s", kind)
|
||||||
|
return _backend
|
||||||
146
src/lightning/ledger.py
Normal file
146
src/lightning/ledger.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""In-memory Lightning transaction ledger.
|
||||||
|
|
||||||
|
Tracks invoices, settlements, and balances per the schema in
|
||||||
|
``docs/adr/018-lightning-ledger.md``. Uses a simple in-memory list so the
|
||||||
|
dashboard can display real (ephemeral) data without requiring SQLite yet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TxType(StrEnum):
|
||||||
|
incoming = "incoming"
|
||||||
|
outgoing = "outgoing"
|
||||||
|
|
||||||
|
|
||||||
|
class TxStatus(StrEnum):
|
||||||
|
pending = "pending"
|
||||||
|
settled = "settled"
|
||||||
|
failed = "failed"
|
||||||
|
expired = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LedgerEntry:
|
||||||
|
"""Single ledger row matching the ADR-018 schema."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tx_type: TxType
|
||||||
|
status: TxStatus
|
||||||
|
payment_hash: str
|
||||||
|
amount_sats: int
|
||||||
|
memo: str
|
||||||
|
source: str
|
||||||
|
created_at: str
|
||||||
|
invoice: str = ""
|
||||||
|
preimage: str = ""
|
||||||
|
task_id: str = ""
|
||||||
|
agent_id: str = ""
|
||||||
|
settled_at: str = ""
|
||||||
|
fee_sats: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── In-memory store ──────────────────────────────────────────────────
|
||||||
|
_entries: list[LedgerEntry] = []
|
||||||
|
|
||||||
|
|
||||||
|
def create_invoice_entry(
|
||||||
|
payment_hash: str,
|
||||||
|
amount_sats: int,
|
||||||
|
memo: str = "",
|
||||||
|
source: str = "tool_usage",
|
||||||
|
task_id: str = "",
|
||||||
|
agent_id: str = "",
|
||||||
|
invoice: str = "",
|
||||||
|
) -> LedgerEntry:
|
||||||
|
"""Record a new incoming invoice in the ledger."""
|
||||||
|
entry = LedgerEntry(
|
||||||
|
id=uuid.uuid4().hex[:16],
|
||||||
|
tx_type=TxType.incoming,
|
||||||
|
status=TxStatus.pending,
|
||||||
|
payment_hash=payment_hash,
|
||||||
|
amount_sats=amount_sats,
|
||||||
|
memo=memo,
|
||||||
|
source=source,
|
||||||
|
task_id=task_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
invoice=invoice,
|
||||||
|
created_at=datetime.now(UTC).isoformat(),
|
||||||
|
)
|
||||||
|
_entries.append(entry)
|
||||||
|
logger.debug("Ledger entry created: %s (%s sats)", entry.id, amount_sats)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def mark_settled(payment_hash: str, preimage: str = "") -> LedgerEntry | None:
|
||||||
|
"""Mark a pending entry as settled by payment hash."""
|
||||||
|
for entry in _entries:
|
||||||
|
if entry.payment_hash == payment_hash and entry.status == TxStatus.pending:
|
||||||
|
entry.status = TxStatus.settled
|
||||||
|
entry.preimage = preimage
|
||||||
|
entry.settled_at = datetime.now(UTC).isoformat()
|
||||||
|
logger.debug("Ledger settled: %s", payment_hash[:12])
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_balance() -> dict:
|
||||||
|
"""Compute the current balance from settled and pending entries."""
|
||||||
|
incoming_total = sum(
|
||||||
|
e.amount_sats
|
||||||
|
for e in _entries
|
||||||
|
if e.tx_type == TxType.incoming and e.status == TxStatus.settled
|
||||||
|
)
|
||||||
|
outgoing_total = sum(
|
||||||
|
e.amount_sats
|
||||||
|
for e in _entries
|
||||||
|
if e.tx_type == TxType.outgoing and e.status == TxStatus.settled
|
||||||
|
)
|
||||||
|
fees = sum(e.fee_sats for e in _entries if e.status == TxStatus.settled)
|
||||||
|
pending_in = sum(
|
||||||
|
e.amount_sats
|
||||||
|
for e in _entries
|
||||||
|
if e.tx_type == TxType.incoming and e.status == TxStatus.pending
|
||||||
|
)
|
||||||
|
pending_out = sum(
|
||||||
|
e.amount_sats
|
||||||
|
for e in _entries
|
||||||
|
if e.tx_type == TxType.outgoing and e.status == TxStatus.pending
|
||||||
|
)
|
||||||
|
net = incoming_total - outgoing_total - fees
|
||||||
|
return {
|
||||||
|
"incoming_total_sats": incoming_total,
|
||||||
|
"outgoing_total_sats": outgoing_total,
|
||||||
|
"fees_paid_sats": fees,
|
||||||
|
"net_sats": net,
|
||||||
|
"pending_incoming_sats": pending_in,
|
||||||
|
"pending_outgoing_sats": pending_out,
|
||||||
|
"available_sats": net - pending_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_transactions(
|
||||||
|
tx_type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[LedgerEntry]:
|
||||||
|
"""Return ledger entries, optionally filtered."""
|
||||||
|
result = _entries
|
||||||
|
if tx_type:
|
||||||
|
result = [e for e in result if e.tx_type.value == tx_type]
|
||||||
|
if status:
|
||||||
|
result = [e for e in result if e.status.value == status]
|
||||||
|
return list(reversed(result))[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def clear() -> None:
|
||||||
|
"""Reset the ledger (for testing)."""
|
||||||
|
_entries.clear()
|
||||||
@@ -2493,3 +2493,57 @@
|
|||||||
.db-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.db-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.db-cell:hover { white-space: normal; word-break: break-all; }
|
.db-cell:hover { white-space: normal; word-break: break-all; }
|
||||||
.db-truncated { font-size: 0.7rem; color: var(--amber); padding: 0.3rem 0; }
|
.db-truncated { font-size: 0.7rem; color: var(--amber); padding: 0.3rem 0; }
|
||||||
|
|
||||||
|
/* ── Tower ────────────────────────────────────────────────────────────── */
|
||||||
|
.tower-container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
.tower-header { margin-bottom: 1rem; }
|
||||||
|
.tower-title { font-size: 1.6rem; font-weight: 700; color: var(--green); letter-spacing: 0.15em; }
|
||||||
|
.tower-subtitle { font-size: 0.85rem; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.tower-conn-badge { font-size: 0.7rem; font-weight: 600; padding: 2px 8px; border-radius: 3px; letter-spacing: 0.08em; }
|
||||||
|
.tower-conn-live { color: var(--green); border: 1px solid var(--green); }
|
||||||
|
.tower-conn-offline { color: var(--red); border: 1px solid var(--red); }
|
||||||
|
.tower-conn-connecting { color: var(--amber); border: 1px solid var(--amber); }
|
||||||
|
|
||||||
|
.tower-phase-card { min-height: 300px; }
|
||||||
|
.tower-phase-thinking { border-left: 3px solid var(--purple); }
|
||||||
|
.tower-phase-predicting { border-left: 3px solid var(--orange); }
|
||||||
|
.tower-phase-advising { border-left: 3px solid var(--green); }
|
||||||
|
.tower-scroll { max-height: 50vh; overflow-y: auto; }
|
||||||
|
.tower-empty { text-align: center; color: var(--text-dim); padding: 16px; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.tower-stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; text-align: center; }
|
||||||
|
.tower-stat-label { display: block; font-size: 0.65rem; color: var(--text-dim); letter-spacing: 0.1em; }
|
||||||
|
.tower-stat-value { display: block; font-size: 1.1rem; font-weight: 700; color: var(--text-bright); }
|
||||||
|
|
||||||
|
.tower-event { padding: 8px; margin-bottom: 6px; border-left: 3px solid var(--border); border-radius: 3px; background: var(--bg-card); }
|
||||||
|
.tower-etype-task_posted { border-left-color: var(--purple); }
|
||||||
|
.tower-etype-bid_submitted { border-left-color: var(--orange); }
|
||||||
|
.tower-etype-task_completed { border-left-color: var(--green); }
|
||||||
|
.tower-etype-task_failed { border-left-color: var(--red); }
|
||||||
|
.tower-etype-agent_joined { border-left-color: var(--purple); }
|
||||||
|
.tower-etype-tool_executed { border-left-color: var(--amber); }
|
||||||
|
.tower-ev-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||||
|
.tower-ev-badge { font-size: 0.65rem; font-weight: 600; color: var(--text-bright); letter-spacing: 0.08em; }
|
||||||
|
.tower-ev-dots { font-size: 0.6rem; color: var(--amber); }
|
||||||
|
.tower-ev-desc { font-size: 0.8rem; color: var(--text); }
|
||||||
|
.tower-ev-time { font-size: 0.65rem; color: var(--text-dim); margin-top: 2px; }
|
||||||
|
|
||||||
|
.tower-pred { padding: 8px; margin-bottom: 6px; border-radius: 3px; background: var(--bg-card); border-left: 3px solid var(--orange); }
|
||||||
|
.tower-pred-done { border-left-color: var(--green); }
|
||||||
|
.tower-pred-pending { border-left-color: var(--amber); }
|
||||||
|
.tower-pred-head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.tower-pred-task { font-size: 0.75rem; font-weight: 600; color: var(--text-bright); font-family: monospace; }
|
||||||
|
.tower-pred-acc { font-size: 0.75rem; font-weight: 700; }
|
||||||
|
.tower-pred-detail { font-size: 0.75rem; color: var(--text-dim); margin-top: 4px; }
|
||||||
|
|
||||||
|
.tower-advisory { padding: 8px; margin-bottom: 6px; border-radius: 3px; background: var(--bg-card); border-left: 3px solid var(--border); }
|
||||||
|
.tower-adv-high { border-left-color: var(--red); }
|
||||||
|
.tower-adv-medium { border-left-color: var(--orange); }
|
||||||
|
.tower-adv-low { border-left-color: var(--green); }
|
||||||
|
.tower-adv-head { display: flex; justify-content: space-between; font-size: 0.65rem; margin-bottom: 4px; }
|
||||||
|
.tower-adv-cat { font-weight: 600; color: var(--text-dim); letter-spacing: 0.08em; }
|
||||||
|
.tower-adv-prio { font-weight: 700; color: var(--amber); }
|
||||||
|
.tower-adv-title { font-size: 0.85rem; font-weight: 600; color: var(--text-bright); }
|
||||||
|
.tower-adv-detail { font-size: 0.8rem; color: var(--text); margin-top: 2px; }
|
||||||
|
.tower-adv-action { font-size: 0.75rem; color: var(--green); margin-top: 4px; font-style: italic; }
|
||||||
|
|||||||
149
tests/infrastructure/test_router_history.py
Normal file
149
tests/infrastructure/test_router_history.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Tests for provider health history store and API endpoint."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.infrastructure.router.history import HealthHistoryStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store():
|
||||||
|
"""In-memory history store for testing."""
|
||||||
|
s = HealthHistoryStore(db_path=":memory:")
|
||||||
|
yield s
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_providers():
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "anthropic",
|
||||||
|
"status": "healthy",
|
||||||
|
"error_rate": 0.01,
|
||||||
|
"avg_latency_ms": 250.5,
|
||||||
|
"circuit_state": "closed",
|
||||||
|
"total_requests": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "local",
|
||||||
|
"status": "degraded",
|
||||||
|
"error_rate": 0.15,
|
||||||
|
"avg_latency_ms": 80.0,
|
||||||
|
"circuit_state": "closed",
|
||||||
|
"total_requests": 50,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_and_retrieve(store, sample_providers):
|
||||||
|
store.record_snapshot(sample_providers)
|
||||||
|
history = store.get_history(hours=1)
|
||||||
|
assert len(history) == 1
|
||||||
|
assert len(history[0]["providers"]) == 2
|
||||||
|
assert history[0]["providers"][0]["name"] == "anthropic"
|
||||||
|
assert history[0]["providers"][1]["name"] == "local"
|
||||||
|
assert "timestamp" in history[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_snapshots(store, sample_providers):
|
||||||
|
store.record_snapshot(sample_providers)
|
||||||
|
time.sleep(0.01)
|
||||||
|
store.record_snapshot(sample_providers)
|
||||||
|
history = store.get_history(hours=1)
|
||||||
|
assert len(history) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_hours_filtering(store, sample_providers):
|
||||||
|
old_ts = (datetime.now(UTC) - timedelta(hours=48)).isoformat()
|
||||||
|
store._conn.execute(
|
||||||
|
"""INSERT INTO snapshots
|
||||||
|
(timestamp, provider_name, status, error_rate,
|
||||||
|
avg_latency_ms, circuit_state, total_requests)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(old_ts, "anthropic", "healthy", 0.0, 100.0, "closed", 10),
|
||||||
|
)
|
||||||
|
store._conn.commit()
|
||||||
|
store.record_snapshot(sample_providers)
|
||||||
|
|
||||||
|
history = store.get_history(hours=24)
|
||||||
|
assert len(history) == 1
|
||||||
|
|
||||||
|
history = store.get_history(hours=72)
|
||||||
|
assert len(history) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune(store, sample_providers):
|
||||||
|
old_ts = (datetime.now(UTC) - timedelta(hours=200)).isoformat()
|
||||||
|
store._conn.execute(
|
||||||
|
"""INSERT INTO snapshots
|
||||||
|
(timestamp, provider_name, status, error_rate,
|
||||||
|
avg_latency_ms, circuit_state, total_requests)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(old_ts, "anthropic", "healthy", 0.0, 100.0, "closed", 10),
|
||||||
|
)
|
||||||
|
store._conn.commit()
|
||||||
|
store.record_snapshot(sample_providers)
|
||||||
|
|
||||||
|
deleted = store.prune(keep_hours=168)
|
||||||
|
assert deleted == 1
|
||||||
|
history = store.get_history(hours=999)
|
||||||
|
assert len(history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_history(store):
|
||||||
|
assert store.get_history(hours=24) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_snapshot_from_router(store):
|
||||||
|
mock_metrics = MagicMock()
|
||||||
|
mock_metrics.error_rate = 0.05
|
||||||
|
mock_metrics.avg_latency_ms = 200.0
|
||||||
|
mock_metrics.total_requests = 42
|
||||||
|
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.name = "test-provider"
|
||||||
|
mock_provider.status.value = "healthy"
|
||||||
|
mock_provider.metrics = mock_metrics
|
||||||
|
mock_provider.circuit_state.value = "closed"
|
||||||
|
|
||||||
|
mock_router = MagicMock()
|
||||||
|
mock_router.providers = [mock_provider]
|
||||||
|
|
||||||
|
store._capture_snapshot(mock_router)
|
||||||
|
history = store.get_history(hours=1)
|
||||||
|
assert len(history) == 1
|
||||||
|
p = history[0]["providers"][0]
|
||||||
|
assert p["name"] == "test-provider"
|
||||||
|
assert p["status"] == "healthy"
|
||||||
|
assert p["error_rate"] == 0.05
|
||||||
|
assert p["total_requests"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_api_endpoint(store, sample_providers):
|
||||||
|
"""GET /api/v1/router/history returns snapshot data."""
|
||||||
|
store.record_snapshot(sample_providers)
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from src.infrastructure.router.api import get_cascade_router
|
||||||
|
from src.infrastructure.router.api import router as api_router
|
||||||
|
from src.infrastructure.router.history import get_history_store
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
app.dependency_overrides[get_history_store] = lambda: store
|
||||||
|
app.dependency_overrides[get_cascade_router] = lambda: MagicMock()
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/v1/router/history?hours=1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert len(data[0]["providers"]) == 2
|
||||||
|
assert data[0]["providers"][0]["name"] == "anthropic"
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
@@ -56,7 +56,7 @@ def test_think_sends_topic_to_agent():
|
|||||||
|
|
||||||
|
|
||||||
def test_think_ignores_model_size_option():
|
def test_think_ignores_model_size_option():
|
||||||
"""think --model-size is accepted but not forwarded to create_timmy."""
|
"""think --model-size 70b must not forward model_size to create_timmy."""
|
||||||
mock_timmy = MagicMock()
|
mock_timmy = MagicMock()
|
||||||
|
|
||||||
with patch("timmy.cli.create_timmy", return_value=mock_timmy) as mock_create:
|
with patch("timmy.cli.create_timmy", return_value=mock_timmy) as mock_create:
|
||||||
|
|||||||
109
tests/unit/test_lightning.py
Normal file
109
tests/unit/test_lightning.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Unit tests for the lightning package (factory + ledger)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lightning.factory import Invoice, MockBackend, get_backend
|
||||||
|
from lightning.ledger import (
|
||||||
|
TxStatus,
|
||||||
|
TxType,
|
||||||
|
clear,
|
||||||
|
create_invoice_entry,
|
||||||
|
get_balance,
|
||||||
|
get_transactions,
|
||||||
|
mark_settled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_ledger():
|
||||||
|
"""Reset the in-memory ledger between tests."""
|
||||||
|
clear()
|
||||||
|
yield
|
||||||
|
clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Factory tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMockBackend:
|
||||||
|
def test_create_invoice_returns_invoice(self):
|
||||||
|
backend = MockBackend()
|
||||||
|
inv = backend.create_invoice(100, "test memo")
|
||||||
|
assert isinstance(inv, Invoice)
|
||||||
|
assert inv.amount_sats == 100
|
||||||
|
assert inv.memo == "test memo"
|
||||||
|
assert len(inv.payment_hash) == 64 # SHA-256 hex
|
||||||
|
assert inv.payment_request.startswith("lnbc")
|
||||||
|
|
||||||
|
def test_invoices_have_unique_hashes(self):
|
||||||
|
backend = MockBackend()
|
||||||
|
a = backend.create_invoice(10)
|
||||||
|
b = backend.create_invoice(10)
|
||||||
|
assert a.payment_hash != b.payment_hash
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetBackend:
|
||||||
|
def test_returns_mock_backend(self):
|
||||||
|
backend = get_backend()
|
||||||
|
assert isinstance(backend, MockBackend)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ledger tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestLedger:
|
||||||
|
def test_create_invoice_entry(self):
|
||||||
|
entry = create_invoice_entry(
|
||||||
|
payment_hash="abc123",
|
||||||
|
amount_sats=500,
|
||||||
|
memo="test",
|
||||||
|
source="unit_test",
|
||||||
|
)
|
||||||
|
assert entry.tx_type == TxType.incoming
|
||||||
|
assert entry.status == TxStatus.pending
|
||||||
|
assert entry.amount_sats == 500
|
||||||
|
|
||||||
|
def test_mark_settled(self):
|
||||||
|
create_invoice_entry(payment_hash="hash1", amount_sats=100)
|
||||||
|
result = mark_settled("hash1", preimage="secret")
|
||||||
|
assert result is not None
|
||||||
|
assert result.status == TxStatus.settled
|
||||||
|
assert result.preimage == "secret"
|
||||||
|
assert result.settled_at != ""
|
||||||
|
|
||||||
|
def test_mark_settled_unknown_hash(self):
|
||||||
|
assert mark_settled("nonexistent") is None
|
||||||
|
|
||||||
|
def test_get_balance_empty(self):
|
||||||
|
bal = get_balance()
|
||||||
|
assert bal["net_sats"] == 0
|
||||||
|
assert bal["available_sats"] == 0
|
||||||
|
|
||||||
|
def test_get_balance_with_settled(self):
|
||||||
|
create_invoice_entry(payment_hash="h1", amount_sats=1000)
|
||||||
|
mark_settled("h1")
|
||||||
|
bal = get_balance()
|
||||||
|
assert bal["incoming_total_sats"] == 1000
|
||||||
|
assert bal["net_sats"] == 1000
|
||||||
|
assert bal["available_sats"] == 1000
|
||||||
|
|
||||||
|
def test_get_balance_pending_not_counted(self):
|
||||||
|
create_invoice_entry(payment_hash="h2", amount_sats=500)
|
||||||
|
bal = get_balance()
|
||||||
|
assert bal["incoming_total_sats"] == 0
|
||||||
|
assert bal["pending_incoming_sats"] == 500
|
||||||
|
|
||||||
|
def test_get_transactions_returns_entries(self):
|
||||||
|
create_invoice_entry(payment_hash="t1", amount_sats=10)
|
||||||
|
create_invoice_entry(payment_hash="t2", amount_sats=20)
|
||||||
|
txs = get_transactions()
|
||||||
|
assert len(txs) == 2
|
||||||
|
|
||||||
|
def test_get_transactions_filter_by_status(self):
|
||||||
|
create_invoice_entry(payment_hash="f1", amount_sats=10)
|
||||||
|
create_invoice_entry(payment_hash="f2", amount_sats=20)
|
||||||
|
mark_settled("f1")
|
||||||
|
assert len(get_transactions(status="settled")) == 1
|
||||||
|
assert len(get_transactions(status="pending")) == 1
|
||||||
Reference in New Issue
Block a user