- 1348
let health = health_projection(&state); - 1349
let mut service_cfg = vak_ops::OpsConfig::detect(); - 1350
service_cfg.port = state.ops_port; - 1351
let ops_port = service_cfg.port; - 1352
let services = tokio::task::spawn_blocking(move || operation_services(&service_cfg)) - 1353
.await - 1354
.unwrap_or_else(|_| { - 1355
serde_json::json!({ - 1356
"gateway": { "state": "unknown" }, - 1357
"telegram": { "state": "unknown" }, - 1358
"gateway_healthy": false, - 1359
}) - 1360
}); - 1361
let default_route = state.core.effective_route(); - 1362
let gateway = state.gateway.snapshot(); - 1363
let pool = state - 1364
.gateway - 1365
.core_pool - 1366
.snapshot_at(Instant::now()) - 1367
.into_iter() - 1368
.map(|entry| { - 1369
serde_json::json!({ - 1370
"workspace": entry.workspace, - 1371
"is_default": entry.is_default, - 1372
"state": "warm", - 1373
"idle_secs": entry.idle_secs, - 1374
"permission_override": entry.permission_override, - 1375
"effective_permission_mode": entry.effective_permission_mode, - 1376
}) - 1377
}) - 1378
.collect::<Vec<_>>(); - 1379
let allowlist_snapshot = state.gateway.allowlist_snapshot(); - 1380
let allowlist_map: HashMap<String, String> = allowlist_snapshot - 1381
.iter() - 1382
.map(|e| { - 1383
( - 1384
e.key.clone(), - 1385
e.agent_id.clone().unwrap_or_else(|| "vak".to_string()), - 1386
) - 1387
}) - 1388
.collect(); - 1389
let mut bound_targets = std::collections::HashSet::new(); - 1390
let mut bindings = gateway - 1391
.into_iter() - 1392
.map(|(target, binding)| { - 1393
bound_targets.insert(target.clone()); - 1394
let agent_id = allowlist_map - 1395
.get(&target) - 1396
.cloned() - 1397
.unwrap_or_else(|| "vak".to_string()); - 1398
serde_json::json!({ - 1399
"target": target, - 1400
"session_id": binding.session_id, - 1401
"workspace": binding.workspace, - 1402
"agent_id": agent_id, - 1403
"provider": binding - 1404
.provider - 1405
.unwrap_or_else(|| default_route.provider.clone()), - 1406
"model": binding.model.unwrap_or_else(|| default_route.model.clone()), - 1407
"route_revision": binding - 1408
.route_revision - 1409
.unwrap_or_else(|| default_route.revision.clone()), - 1410
}) - 1411
}) - 1412
.collect::<Vec<_>>(); - 1413
for entry in allowlist_snapshot { - 1414
if entry.status != gateway::AllowlistStatus::Allowed || bound_targets.contains(&entry.key) { - 1415
continue; - 1416
} - 1417
bindings.push(serde_json::json!({ - 1418
"target": entry.key, - 1419
"session_id": null, - 1420
"workspace": state.gateway.workspace_for_entry(&state.core, &entry.key), - 1421
"agent_id": entry.agent_id.as_deref().unwrap_or("vak"), - 1422
"provider": entry.route.as_ref().map(|route| route.provider.clone()).unwrap_or_else(|| state.core.effective_provider()), - 1423
"model": entry.route.as_ref().map(|route| route.model.clone()).unwrap_or_else(|| state.core.effective_model()), - 1424
"route_revision": entry.route.as_ref().map(|route| format!("channel:{}:{}", route.provider, route.model)).unwrap_or_else(|| state.core.effective_route().revision), - 1425
"cold": true, - 1426
})); - 1427
} - 1428
let (outbox, outbox_pending, outbox_dead, outbox_error) = match operation_outbox(&state) { - 1429
Ok((rows, pending, dead)) => (rows, pending, dead, None), - 1430
Err(error) => (Vec::new(), 0, 0, Some(error)), - 1431
}; - 1432
let runs = operation_runs(&state); - 1433
let approvals = state - 1434
.live_handles() - 1435
.into_iter() - 1436
.map(|handle| { - 1437
handle - 1438
.pending - 1439
.lock() - 1440
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1441
.len() - 1442
}) - 1443
.sum::<usize>(); - 1444
let security = vak_core::security_events::list(&state.core.sessions_home(), 30) - 1445
.into_iter() - 1446
.map(|event| serde_json::to_value(event).unwrap_or_else(|_| serde_json::json!({}))) - 1447
.collect::<Vec<_>>(); - 1448
let workspace = Some(state.core.cwd().to_string_lossy().into_owned()); - 1449
let mut candidates = Vec::new(); - 1450
if health["failures"].as_u64().unwrap_or(0) > 0 { - 1451
let evidence = health["checks"] - 1452
.as_array() - 1453
.into_iter() - 1454
.flatten() - 1455
.filter(|check| check["status"] == "fail") - 1456
.filter_map(|check| check["label"].as_str().map(str::to_string)) - 1457
.collect(); - 1458
candidates.push(operations::IncidentCandidate { - 1459
fingerprint: "doctor:health-checks".to_string(), - 1460
severity: "critical".to_string(), - 1461
source: "doctor".to_string(), - 1462
title: "Health checks need attention".to_string(), - 1463
detail: format!("{} check(s) failed", health["failures"]), - 1464
workspace: workspace.clone(), - 1465
evidence, - 1466
}); - 1467
} - 1468
if approvals > 0 { - 1469
candidates.push(operations::IncidentCandidate { - 1470
fingerprint: "permission:pending-approvals".to_string(), - 1471
severity: "warning".to_string(), - 1472
source: "permission-engine".to_string(), - 1473
title: "Runs are waiting for approval".to_string(), - 1474
detail: format!("{approvals} approval gate(s) are blocking work"), - 1475
workspace: workspace.clone(), - 1476
evidence: runs - 1477
.iter() - 1478
.filter_map(|run| run["session_id"].as_str().map(|id| format!("session:{id}"))) - 1479
.collect(), - 1480
}); - 1481
} - 1482
if outbox_pending > 0 || outbox_dead > 0 { - 1483
candidates.push(operations::IncidentCandidate { - 1484
fingerprint: "delivery:outbox".to_string(), - 1485
severity: if outbox_dead > 0 { - 1486
"critical" - 1487
} else { - 1488
"warning" - 1489
} - 1490
.to_string(), - 1491
source: "delivery".to_string(), - 1492
title: "Outbound delivery needs attention".to_string(), - 1493
detail: format!("{outbox_pending} pending, {outbox_dead} dead-lettered"), - 1494
workspace: workspace.clone(), - 1495
evidence: outbox - 1496
.iter() - 1497
.filter(|row| row["state"] != "delivered") - 1498
.filter_map(|row| row["job_id"].as_str().map(|id| format!("outbox:{id}"))) - 1499
.collect(), - 1500
}); - 1501
} - 1502
if let Some(error) = &outbox_error { - 1503
candidates.push(operations::IncidentCandidate { - 1504
fingerprint: "delivery:outbox-read".to_string(), - 1505
severity: "critical".to_string(), - 1506
source: "delivery".to_string(), - 1507
title: "Delivery evidence is unavailable".to_string(), - 1508
detail: error.clone(), - 1509
workspace: workspace.clone(), - 1510
evidence: vec!["outbox:read".to_string()], - 1511
}); - 1512
} - 1513
if services["gateway"]["state"] != "running" && state.gateway.enabled { - 1514
candidates.push(operations::IncidentCandidate { - 1515
fingerprint: "service:gateway".to_string(), - 1516
severity: "warning".to_string(), - 1517
source: "service-manager".to_string(), - 1518
title: "Gateway service is not running".to_string(), - 1519
detail: services["gateway"]["state"] - 1520
.as_str() - 1521
.unwrap_or("unknown") - 1522
.to_string(), - 1523
workspace: workspace.clone(), - 1524
evidence: vec!["service:gateway".to_string()], - 1525
}); - 1526
} - 1527
let incidents = operations::reconcile(&state.core.sessions_home(), candidates) - 1528
.into_iter() - 1529
.map(|incident| serde_json::to_value(incident).unwrap_or_else(|_| serde_json::json!({}))) - 1530
.collect::<Vec<_>>(); - 1531
let mut all_agents = vec![serde_json::json!({ - 1532
"id": "vak", - 1533
"name": "Vakyartha", - 1534
"personality": "Codex-grade safety, pi-grade transparency, Claude Code-grade extensibility, opencode-grade simplicity.", - 1535
"lifecycle": "active", - 1536
})]; - 1537
if let Ok(custom) = agents::effective(&state.core) { - 1538
for a in custom { - 1539
all_agents.push(serde_json::json!({ - 1540
"id": a.id, - 1541
"name": a.name, - 1542
"personality": a.personality, - 1543
"lifecycle": a.lifecycle, - 1544
})); - 1545
} - 1546
} - 1547
Json(serde_json::json!({ - 1548
"generated_at": Utc::now(), - 1549
"server": { - 1550
"pid": std::process::id(), - 1551
"version": env!("CARGO_PKG_VERSION"), - 1552
"uptime_secs": state.started_at.elapsed().as_secs(), - 1553
"cwd": state.core.cwd(), - 1554
"posture": health["posture"], - 1555
}, - 1556
"health": health, - 1557
"services": services, - 1558
"agents": all_agents, - 1559
"gateway": { - 1560
"enabled": state.gateway.enabled, - 1561
"approvals": { "pending": approvals, "mode": state.gateway.approvals_mode(), "approver": state.gateway.approver_target() }, - 1562
"bindings": bindings, - 1563
"workspace_catalog": crate::admin::workspace_catalog(&state), - 1564
}, - 1565
"pool": { - 1566
"max": state.core.config().gateway.core_pool_max, - 1567
"idle_secs": state.core.config().gateway.core_pool_idle_secs, - 1568
"entries": pool, - 1569
}, - 1570
"runs": runs, - 1571
"tasks": operation_tasks(&state), - 1572
"outbox": { "pending": outbox_pending, "dead_letter": outbox_dead, "records": outbox, "error": outbox_error }, - 1573
"bus": state.hub.bus_status(), - 1574
"security": security, - 1575
"incidents": incidents, - 1576
"actions": operations::recent_actions(&state.core.sessions_home(), 50), - 1577
"ops_port": ops_port, - 1578
})) - 1579
} - 1580
- 1581
async fn operations_actions(State(state): State<AppState>) -> Json<serde_json::Value> { - 1582
Json(serde_json::json!({ - 1583
"actions": operations::recent_actions(&state.core.sessions_home(), 200), - 1584
})) - 1585
} - 1586
- 1587
async fn operations_incidents(State(state): State<AppState>) -> Json<serde_json::Value> { - 1588
Json(serde_json::json!({ - 1589
"incidents": operations::list(&state.core.sessions_home()), - 1590
})) - 1591
} - 1592
- 1593
async fn operations_outbox(State(state): State<AppState>) -> axum::response::Response { - 1594
match delivery::outbox_records(&state.core) { - 1595
Ok(records) => Json(serde_json::json!({ - 1596
"records": records.into_iter().take(500).map(|record| serde_json::json!({ - 1597
"job_id": record.job.job_id, - 1598
"target": record.job.target, - 1599
"kind": record.job.kind, - 1600
"state": record.state, - 1601
"attempts": record.attempts, - 1602
"created_at_ms": record.created_at_ms, - 1603
"updated_at_ms": record.updated_at_ms, - 1604
"last_error": record.last_error, - 1605
})).collect::<Vec<_>>(), - 1606
})) - 1607
.into_response(), - 1608
Err(error) => ( - 1609
StatusCode::INTERNAL_SERVER_ERROR, - 1610
Json(serde_json::json!({ "error": error })), - 1611
) - 1612
.into_response(), - 1613
} - 1614
} - 1615
- 1616
async fn replay_operations_outbox( - 1617
State(state): State<AppState>, - 1618
Path(job_id): Path<String>, - 1619
) -> axum::response::Response { - 1620
let state_label = |state: vak_delivery::outbox::OutboxState| match state { - 1621
vak_delivery::outbox::OutboxState::Pending => "pending", - 1622
vak_delivery::outbox::OutboxState::Delivered => "delivered", - 1623
vak_delivery::outbox::OutboxState::DeadLetter => "dead_letter", - 1624
}; - 1625
let before = delivery::outbox_records(&state.core) - 1626
.ok() - 1627
.and_then(|records| { - 1628
records - 1629
.into_iter() - 1630
.find(|record| record.job.job_id == job_id) - 1631
}) - 1632
.map(|record| state_label(record.state).to_string()) - 1633
.unwrap_or_else(|| "not found".to_string()); - 1634
let requested_at = Utc::now(); - 1635
match delivery::replay_outbox_job(&state.core, &job_id).await { - 1636
Ok(()) => { - 1637
let after_record = delivery::outbox_records(&state.core) - 1638
.ok() - 1639
.and_then(|records| { - 1640
records - 1641
.into_iter() - 1642
.find(|record| record.job.job_id == job_id) - 1643
}); - 1644
let after = after_record - 1645
.as_ref() - 1646
.map(|record| state_label(record.state).to_string()) - 1647
.unwrap_or_else(|| "not found".to_string()); - 1648
if after == "delivered" - 1649
&& let Some(record) = after_record.as_ref() - 1650
&& let vak_delivery::DeliveryContent::Answer(answer) = &record.job.content - 1651
&& let Some(task_id) = answer.metadata.get("vak_task_id") - 1652
{ - 1653
update_tasks(&state, |tasks| { - 1654
if let Some(task) = tasks.get_mut(task_id) { - 1655
task.last_delivery_state = Some("delivered".into()); - 1656
} - 1657
}); - 1658
} - 1659
let verification_status = if after == "delivered" { - 1660
"verified" - 1661
} else { - 1662
"pending" - 1663
}; - 1664
let mut receipt = operations::ActionReceipt { - 1665
receipt_id: format!("OP-{}", uuid::Uuid::now_v7().simple()), - 1666
service: format!("outbox:{job_id}"), - 1667
action: "replay".to_string(), - 1668
requested_at, - 1669
completed_at: Utc::now(), - 1670
succeeded: true, - 1671
verification: operations::ActionVerification { - 1672
status: verification_status.to_string(), - 1673
before, - 1674
after, - 1675
detail: if verification_status == "verified" { - 1676
"The adapter delivered the replayed job during the verification probe." - 1677
} else { - 1678
"Replay was accepted; the durable outbox record remains pending until the adapter confirms delivery." - 1679
} - 1680
.to_string(), - 1681
}, - 1682
persisted: false, - 1683
}; - 1684
receipt.persisted = - 1685
operations::record_action(&state.core.sessions_home(), &receipt).is_ok(); - 1686
Json(serde_json::json!({ - 1687
"ok": true, - 1688
"job_id": job_id, - 1689
"receipt_id": receipt.receipt_id, - 1690
"receipt_persisted": receipt.persisted, - 1691
"verification": receipt.verification, - 1692
})) - 1693
.into_response() - 1694
} - 1695
Err(error) => ( - 1696
StatusCode::CONFLICT, - 1697
Json(serde_json::json!({ "error": error })), - 1698
) - 1699
.into_response(), - 1700
} - 1701
} - 1702
- 1703
/// Trailing window for the admin console's spend trend chart — long enough - 1704
/// to show a real shape, short enough that a fixed-length zero-filled - 1705
/// series is cheap to compute on every request. - 1706
const FINOPS_TREND_DAYS: u32 = 14; - 1707
- 1708
/// FinOps projection from the append-only cost ledger. Unknown-priced rows - 1709
/// are retained as `unknown_rows`; they are never reported as zero spend. - 1710
/// Caps are read through the live-effective accessors, not `Core::config()` - 1711
/// directly, so a PATCH from `patch_finops` (below) is reflected - 1712
/// immediately rather than only after a restart. - 1713
async fn finops_status( - 1714
State(state): State<AppState>, - 1715
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 1716
) -> axum::response::Response { - 1717
use axum::response::IntoResponse; - 1718
let core = scoped_core!(&state, None, q.agent.as_deref()); - 1719
let mut homes = vec![core.shared_data_home()]; - 1720
if core.sessions_home() != core.shared_data_home() { - 1721
homes.push(core.sessions_home()); - 1722
} - 1723
let mut rows: Vec<vak_core::finops::CostRow> = Vec::new(); - 1724
let mut activity: Vec<vak_core::finops::ActivityRow> = Vec::new(); - 1725
for home in &homes { - 1726
rows.extend(vak_core::finops::FinOpsLedger::new(home).all_rows()); - 1727
activity.extend(vak_core::finops::ActivityLedger::new(home).all_rows()); - 1728
} - 1729
let ledger = vak_core::finops::FinOpsLedger::new(&core.shared_data_home()); - 1730
let now = chrono::Utc::now(); - 1731
let day_start = now - 1732
.date_naive() - 1733
.and_hms_opt(0, 0, 0) - 1734
.and_then(|t| t.and_local_timezone(chrono::Utc).single()); - 1735
let day_rows: Vec<&vak_core::finops::CostRow> = rows - 1736
.iter() - 1737
.filter(|r| day_start.is_some_and(|start| r.ts >= start)) - 1738
.collect(); - 1739
let day_activity = activity - 1740
.iter() - 1741
.filter(|r| day_start.is_some_and(|start| r.ts >= start)); - 1742
let mut activity_by_name = - 1743
std::collections::BTreeMap::<(String, String, Option<String>), (u64, u64, u64)>::new(); - 1744
for row in day_activity { - 1745
let entry = activity_by_name - 1746
.entry((row.kind.clone(), row.name.clone(), row.plugin.clone())) - 1747
.or_default(); - 1748
entry.0 += 1; - 1749
entry.1 += u64::from(row.success); - 1750
if let Some(duration) = row.duration_ms { - 1751
entry.2 += duration; - 1752
} - 1753
} - 1754
let day_usd: f64 = day_rows.iter().filter_map(|r| r.usd).sum(); - 1755
let unknown_rows = day_rows.iter().filter(|r| r.usd.is_none()).count(); - 1756
let mut by_provider = std::collections::BTreeMap::<String, (f64, u64, u64, u64, u64)>::new(); - 1757
let mut by_model = std::collections::BTreeMap::<String, (f64, u64, u64, u64, u64)>::new(); - 1758
for row in &day_rows { - 1759
let usd = row.usd.unwrap_or(0.0); - 1760
let p = by_provider.entry(row.provider.clone()).or_default(); - 1761
p.0 += usd; - 1762
p.1 += 1; - 1763
p.2 += row.input_tokens; - 1764
p.3 += row.output_tokens; - 1765
p.4 += row.cache_read_input_tokens.unwrap_or(0); - 1766
let m = by_model.entry(row.model.clone()).or_default(); - 1767
m.0 += usd; - 1768
m.1 += 1; - 1769
m.2 += row.input_tokens; - 1770
m.3 += row.output_tokens; - 1771
m.4 += row.cache_read_input_tokens.unwrap_or(0); - 1772
} - 1773
let rollup = |source: std::collections::BTreeMap<String, (f64, u64, u64, u64, u64)>| -> Vec<serde_json::Value> { - 1774
source.into_iter().map(|(name, (usd, calls, input_tokens, output_tokens, cache_read_tokens))| serde_json::json!({ "name": name, "usd": usd, "calls": calls, "input_tokens": input_tokens, "output_tokens": output_tokens, "cache_read_tokens": cache_read_tokens })).collect() - 1775
}; - 1776
let daily: Vec<serde_json::Value> = ledger - 1777
.daily_totals(now, FINOPS_TREND_DAYS) - 1778
.into_iter() - 1779
.map(|(date, usd)| serde_json::json!({ "date": date.to_string(), "usd": usd })) - 1780
.collect(); - 1781
let mut alerts = recent_budget_alerts(&core.shared_data_home(), 10); - 1782
if alerts.is_empty() && core.sessions_home() != core.shared_data_home() { - 1783
alerts = recent_budget_alerts(&core.sessions_home(), 10); - 1784
} - 1785
Json(serde_json::json!({ - 1786
"day_usd": day_usd, - 1787
"run_cap_usd": core.effective_finops_max_run_usd(), - 1788
"day_cap_usd": core.effective_finops_max_day_usd(), - 1789
"unknown_rows": unknown_rows, - 1790
"total_rows": rows.len(), - 1791
"day_input_tokens": day_rows.iter().map(|r| r.input_tokens).sum::<u64>(), - 1792
"day_output_tokens": day_rows.iter().map(|r| r.output_tokens).sum::<u64>(), - 1793
"day_cache_read_tokens": day_rows.iter().map(|r| r.cache_read_input_tokens.unwrap_or(0)).sum::<u64>(), - 1794
"activity": activity_by_name.into_iter().map(|((kind, name, plugin), (calls, successes, duration_ms))| serde_json::json!({"kind": kind, "name": name, "plugin": plugin, "calls": calls, "successes": successes, "duration_ms": duration_ms})).collect::<Vec<_>>(), - 1795
"by_provider": rollup(by_provider), - 1796
"by_model": rollup(by_model), - 1797
"daily": daily, - 1798
"recent_alerts": alerts, - 1799
})) - 1800
.into_response() - 1801
} - 1802
- 1803
/// Most recent budget-alert rows, newest first, tolerant of corrupt or - 1804
/// foreign lines exactly like [`vak_core::finops::last_alert`] is. - 1805
fn recent_budget_alerts(home: &std::path::Path, limit: usize) -> Vec<serde_json::Value> { - 1806
let Ok(body) = std::fs::read_to_string(home.join("budget-alerts.jsonl")) else { - 1807
return Vec::new(); - 1808
}; - 1809
let mut rows: Vec<serde_json::Value> = body - 1810
.lines() - 1811
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok()) - 1812
.filter(|row| row.get("kind").and_then(|k| k.as_str()) == Some("budget_alert")) - 1813
.collect(); - 1814
rows.reverse(); - 1815
rows.truncate(limit); - 1816
rows - 1817
} - 1818
- 1819
#[derive(serde::Deserialize, Default)] - 1820
struct FinopsPatch { - 1821
/// Absent = leave alone; explicit `null` = clear the cap; a number = - 1822
/// set it. Same [`gateway::deserialize_present`] shape as - 1823
/// `UpdateBotBody`'s fields, for the same reason: a plain - 1824
/// `Option<Option<f64>>` can't tell "not sent" from "sent as null" - 1825
/// apart otherwise. - 1826
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 1827
max_run_usd: Option<Option<f64>>, - 1828
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 1829
max_day_usd: Option<Option<f64>>, - 1830
#[serde(default)] - 1831
agent: Option<String>, - 1832
} - 1833
- 1834
/// `PATCH /finops` — set or clear the run/day budget caps, applied live - 1835
/// (no restart) and persisted to `.vak/config.toml`'s `[finops]` table. - 1836
async fn patch_finops( - 1837
State(state): State<AppState>, - 1838
Json(body): Json<FinopsPatch>, - 1839
) -> axum::response::Response { - 1840
use axum::response::IntoResponse; - 1841
if body - 1842
.max_run_usd - 1843
.flatten() - 1844
.is_some_and(|v| !v.is_finite() || v < 0.0) - 1845
|| body - 1846
.max_day_usd - 1847
.flatten() - 1848
.is_some_and(|v| !v.is_finite() || v < 0.0) - 1849
{ - 1850
return ( - 1851
StatusCode::BAD_REQUEST, - 1852
Json(serde_json::json!({ "error": "a budget cap must be a non-negative number" })), - 1853
) - 1854
.into_response(); - 1855
} - 1856
if body.max_run_usd.is_none() && body.max_day_usd.is_none() { - 1857
return StatusCode::OK.into_response(); - 1858
} - 1859
let core = scoped_core!(&state, None, body.agent.as_deref()); - 1860
if vak_config::persist_project_finops_caps(core.cwd(), body.max_run_usd, body.max_day_usd) - 1861
.is_err() - 1862
{ - 1863
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 1864
} - 1865
core.apply_persisted_finops_caps(body.max_run_usd, body.max_day_usd); - 1866
vak_core::security_events::record( - 1867
&core.sessions_home(), - 1868
vak_core::security_events::EventKind::ConfigChange, - 1869
"finops_caps_patched", - 1870
&format!( - 1871
"run={:?} day={:?}", - 1872
core.effective_finops_max_run_usd(), - 1873
core.effective_finops_max_day_usd() - 1874
), - 1875
None, - 1876
); - 1877
state.hub.emit_config_changed("finops_caps_patched", ""); - 1878
StatusCode::OK.into_response() - 1879
} - 1880
- 1881
#[derive(serde::Deserialize)] - 1882
struct OpsActionQuery { - 1883
#[serde(default)] - 1884
port: Option<u16>, - 1885
} - 1886
- 1887
/// `POST /ops/services/activate` — reconcile the service manager with - 1888
/// configuration. - 1889
/// - 1890
/// Creating a bot, renaming it, or storing its token writes `bots.json` - 1891
/// and stops there; this is the deliberate act that turns those records - 1892
/// into running bridges. Same contract as `vak self services-sync`, and - 1893
/// the same one `vak setup` runs at its activation step: install and - 1894
/// configuration place things, activation starts them - 1895
/// (`docs/design/46-stabilization-install-and-onboarding.md` D6). - 1896
async fn activate_services(State(state): State<AppState>) -> axum::response::Response { - 1897
use axum::response::IntoResponse; - 1898
match service_control::reconcile(&state.core, state.ops_port).await { - 1899
Ok(outcomes) => { - 1900
state.hub.emit_config_changed("services_activated", ""); - 1901
let failed: Vec<&service_control::UnitOutcome> = - 1902
outcomes.iter().filter(|o| o.error.is_some()).collect(); - 1903
Json(serde_json::json!({ - 1904
"ok": failed.is_empty(), - 1905
"units": outcomes, - 1906
})) - 1907
.into_response() - 1908
} - 1909
Err(e) => ( - 1910
StatusCode::INTERNAL_SERVER_ERROR, - 1911
Json(serde_json::json!({ "error": e })), - 1912
) - 1913
.into_response(), - 1914
} - 1915
} - 1916
- 1917
async fn ops_action( - 1918
State(state): State<AppState>, - 1919
Path((service, action)): Path<(String, String)>, - 1920
axum::extract::Query(q): axum::extract::Query<OpsActionQuery>, - 1921
) -> axum::response::Response { - 1922
use axum::response::IntoResponse; - 1923
let svc = match service.as_str() { - 1924
"gateway" => Some(vak_ops::Service::Gateway), - 1925
"bridges" => Some(vak_ops::Service::Bridges), - 1926
_ => None, - 1927
}; - 1928
let Some(svc) = svc else { - 1929
return ( - 1930
StatusCode::BAD_REQUEST, - 1931
Json(serde_json::json!({ "error": format!("unknown service '{service}'") })), - 1932
) - 1933
.into_response(); - 1934
}; - 1935
let mut cfg = vak_ops::OpsConfig::detect(); - 1936
cfg.port = state.ops_port; - 1937
if let Some(port) = q.port { - 1938
cfg.port = port; - 1939
} - 1940
// Every service-manager call goes through service_control, which runs - 1941
// it on a blocking worker: launchctl and systemctl are subprocesses, - 1942
// and `launchctl bootstrap` can block indefinitely. Calling them from - 1943
// this async handler stalled a tokio worker for as long as the manager - 1944
// took to answer (AGENTS.md invariant 26). - 1945
let before = service_control::status(svc, cfg.clone()) - 1946
.await - 1947
.map(|s| s.to_string()) - 1948
.unwrap_or_else(|e| e); - 1949
let requested_at = Utc::now(); - 1950
let (result, succeeded) = match action.as_str() { - 1951
"start" => { - 1952
let ok = service_control::act(svc, service_control::Action::Start, cfg.clone()) - 1953
.await - 1954
.unwrap_or(false); - 1955
( - 1956
if ok { - 1957
serde_json::json!({ "ok": true, "action": "start" }) - 1958
} else { - 1959
serde_json::json!({ - 1960
"ok": false, - 1961
"action": "start", - 1962
"error": format!("service manager failed to start {service}"), - 1963
}) - 1964
}, - 1965
ok, - 1966
) - 1967
} - 1968
"stop" => { - 1969
let ok = service_control::act(svc, service_control::Action::Stop, cfg.clone()) - 1970
.await - 1971
.unwrap_or(false); - 1972
( - 1973
if ok { - 1974
serde_json::json!({ "ok": true, "action": "stop" }) - 1975
} else { - 1976
serde_json::json!({ - 1977
"ok": false, - 1978
"action": "stop", - 1979
"error": format!("service manager failed to stop {service}"), - 1980
}) - 1981
}, - 1982
ok, - 1983
) - 1984
} - 1985
"restart" => { - 1986
let ok = service_control::act(svc, service_control::Action::Restart, cfg.clone()) - 1987
.await - 1988
.unwrap_or(false); - 1989
( - 1990
if ok { - 1991
serde_json::json!({ "ok": true, "action": "restart" }) - 1992
} else { - 1993
serde_json::json!({ - 1994
"ok": false, - 1995
"action": "restart", - 1996
"error": format!("service manager failed to restart {service}"), - 1997
}) - 1998
}, - 1999
ok, - 2000
) - 2001
} - 2002
"install" => match vak_ops::install(svc, &cfg) { - 2003
Ok(()) => (serde_json::json!({ "ok": true, "action": "install" }), true), - 2004
Err(e) => (serde_json::json!({ "ok": false, "error": e }), false), - 2005
}, - 2006
"uninstall" => match vak_ops::uninstall(svc, &cfg) { - 2007
Ok(()) => ( - 2008
serde_json::json!({ "ok": true, "action": "uninstall" }), - 2009
true, - 2010
), - 2011
Err(e) => (serde_json::json!({ "ok": false, "error": e }), false), - 2012
}, - 2013
other => { - 2014
return ( - 2015
StatusCode::BAD_REQUEST, - 2016
Json(serde_json::json!({ "error": format!("unknown action '{other}'") })), - 2017
) - 2018
.into_response(); - 2019
} - 2020
}; - 2021
let after = service_control::status(svc, cfg.clone()) - 2022
.await - 2023
.map(|s| s.to_string()) - 2024
.unwrap_or_else(|e| e); - 2025
let desired_reached = match action.as_str() { - 2026
"start" | "restart" | "install" => after == "running", - 2027
"stop" => matches!(after.as_str(), "stopped" | "not installed"), - 2028
"uninstall" => after == "not installed", - 2029
_ => false, - 2030
}; - 2031
let verification_status = if !succeeded { - 2032
"failed" - 2033
} else if desired_reached { - 2034
"verified" - 2035
} else { - 2036
"pending" - 2037
}; - 2038
let verification_detail = if !succeeded { - 2039
"The service manager rejected the requested operation; the post-action probe is authoritative." - 2040
} else if desired_reached { - 2041
"The post-action service-manager probe reached the requested state." - 2042
} else { - 2043
"The manager accepted the request but the desired state is not visible yet; keep the receipt and re-probe." - 2044
}; - 2045
let mut receipt = operations::ActionReceipt { - 2046
receipt_id: format!("OP-{}", uuid::Uuid::now_v7().simple()), - 2047
service: service.clone(), - 2048
action: action.clone(), - 2049
requested_at, - 2050
completed_at: Utc::now(), - 2051
succeeded, - 2052
verification: operations::ActionVerification { - 2053
status: verification_status.to_string(), - 2054
before, - 2055
after, - 2056
detail: verification_detail.to_string(), - 2057
}, - 2058
persisted: false, - 2059
}; - 2060
receipt.persisted = operations::record_action(&state.core.sessions_home(), &receipt).is_ok(); - 2061
let receipt_json = serde_json::to_value(&receipt).unwrap_or_else(|_| serde_json::json!({})); - 2062
let mut result = result; - 2063
if let Some(object) = result.as_object_mut() { - 2064
object.insert( - 2065
"receipt_id".to_string(), - 2066
serde_json::json!(receipt.receipt_id), - 2067
); - 2068
object.insert( - 2069
"verification".to_string(), - 2070
receipt_json["verification"].clone(), - 2071
); - 2072
object.insert( - 2073
"receipt_persisted".to_string(), - 2074
serde_json::json!(receipt.persisted), - 2075
); - 2076
} - 2077
if succeeded { - 2078
vak_core::security_events::record( - 2079
&state.core.sessions_home(), - 2080
vak_core::security_events::EventKind::ConfigChange, - 2081
"service_action", - 2082
&format!("service={service} action={action}"), - 2083
None, - 2084
); - 2085
state - 2086
.hub - 2087
.emit_config_changed("service_action", &format!("{service}:{action}")); - 2088
(StatusCode::OK, Json(result)).into_response() - 2089
} else { - 2090
(StatusCode::CONFLICT, Json(result)).into_response() - 2091
} - 2092
} - 2093
- 2094
fn note_payload(n: &vak_core::memory::NoteBlock, scope: &str) -> serde_json::Value { - 2095
serde_json::json!({ - 2096
"id": n.id, - 2097
"ts": n.ts.to_rfc3339(), - 2098
"kind": n.kind, - 2099
"tag": n.tag, - 2100
"session_id": n.session_id, - 2101
"text": n.text, - 2102
"scope": scope, - 2103
}) - 2104
} - 2105
- 2106
/// Which Agent an endpoint scoped to "the currently open Agent's own data" - 2107
/// (memory, learning proposals) should resolve against. Absent means the - 2108
/// built-in "vak" Agent — the same default `agent_chats::open` uses. - 2109
#[derive(serde::Deserialize, Default)] - 2110
struct AgentScopeQuery { - 2111
#[serde(default)] - 2112
agent: Option<String>, - 2113
} - 2114
- 2115
/// Resolve the `Core` an Agent-scoped endpoint should read/write through. - 2116
/// - 2117
/// A registered session already carries the exact `Core` it was opened - 2118
/// under (agent identity, isolated workspace, and now-agent-scoped - 2119
/// `sessions_home` all resolved once at `agent_chats::open` time) — reusing - 2120
/// it is cheaper and more precise than re-deriving identity from an id, so - 2121
/// `session_id` (when the caller already has one, e.g. `AppendMemoryBody`) - 2122
/// takes precedence over an explicit `agent` id. - 2123
/// - 2124
/// This is the single place "which Agent's data does this endpoint mean" - 2125
/// gets decided, so a future endpoint scoped the same way calls this - 2126
/// instead of reading `state.core` directly and drifting out of sync with - 2127
/// `agent_chats::open` the way `list_sessions` once did (see commit - 2128
/// 7e6713c0 and its follow-up). - 2129
#[allow(clippy::result_large_err)] - 2130
fn resolve_scoped_core( - 2131
state: &AppState, - 2132
session_id: Option<&str>, - 2133
agent: Option<&str>, - 2134
) -> Result<vak_core::Core, axum::response::Response> { - 2135
if let Some(sid) = session_id - 2136
&& let Some(handle) = state.get(sid) - 2137
{ - 2138
return Ok(handle.core.clone()); - 2139
} - 2140
let id = agent.unwrap_or("vak"); - 2141
agent_chats::resolve_agent_core(state, id).map(|(_, core)| core) - 2142
} - 2143
- 2144
async fn list_memory( - 2145
State(state): State<AppState>, - 2146
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 2147
) -> axum::response::Response { - 2148
use axum::response::IntoResponse; - 2149
let core = scoped_core!(&state, None, q.agent.as_deref()); - 2150
// The resolved Agent's own (now agent-scoped) sessions_home is primary; - 2151
// `state.core`'s plain, un-scoped home is kept as a fallback merge so - 2152
// notes written before Agents carried their own sessions_home (or by an - 2153
// older build) are not silently hidden. - 2154
let mut homes = vec![core.sessions_home(), state.core.sessions_home()]; - 2155
let shared = state.core.shared_data_home(); - 2156
if !homes.contains(&shared) { - 2157
homes.push(shared); - 2158
} - 2159
let mut blocks: Vec<serde_json::Value> = Vec::new(); - 2160
let mut seen = std::collections::HashSet::new(); - 2161
let mut seen_home = std::collections::HashSet::new(); - 2162
for home in homes { - 2163
if !seen_home.insert(home.clone()) { - 2164
continue; - 2165
} - 2166
for n in vak_core::memory::list_notes(&home, core.cwd()) { - 2167
if seen.insert((n.kind.clone(), n.tag.clone(), n.text.clone())) { - 2168
blocks.push(note_payload(&n, "workspace")); - 2169
} - 2170
} - 2171
for n in vak_core::memory::list_profile_notes(&home) { - 2172
if seen.insert((n.kind.clone(), n.tag.clone(), n.text.clone())) { - 2173
blocks.push(note_payload(&n, "profile")); - 2174
} - 2175
} - 2176
} - 2177
Json(serde_json::json!({ "notes": blocks })).into_response() - 2178
} - 2179
- 2180
async fn cleanup_memory( - 2181
State(state): State<AppState>, - 2182
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 2183
) -> axum::response::Response { - 2184
use axum::response::IntoResponse; - 2185
let core = scoped_core!(&state, None, q.agent.as_deref()); - 2186
let mut homes = vec![core.sessions_home(), state.core.sessions_home()]; - 2187
let shared = state.core.shared_data_home(); - 2188
if !homes.contains(&shared) { - 2189
homes.push(shared); - 2190
} - 2191
let mut report = vak_core::memory::CleanupReport::default(); - 2192
let mut seen_home = std::collections::HashSet::new(); - 2193
for home in homes { - 2194
if !seen_home.insert(home.clone()) { - 2195
continue; - 2196
} - 2197
let home_report = - 2198
vak_core::memory::cleanup_artifacts(&home, std::time::Duration::from_secs(86_400)); - 2199
report.removed_locks += home_report.removed_locks; - 2200
report.removed_temps += home_report.removed_temps; - 2201
report.removed_empty_dirs += home_report.removed_empty_dirs; - 2202
} - 2203
vak_core::security_events::record( - 2204
&core.sessions_home(), - 2205
vak_core::security_events::EventKind::ConfigChange, - 2206
"memory_cleanup", - 2207
&format!( - 2208
"locks={} temps={} empty_dirs={}", - 2209
report.removed_locks, report.removed_temps, report.removed_empty_dirs - 2210
), - 2211
None, - 2212
); - 2213
Json(serde_json::json!({ - 2214
"removed_locks": report.removed_locks, - 2215
"removed_temps": report.removed_temps, - 2216
"removed_empty_dirs": report.removed_empty_dirs, - 2217
})) - 2218
.into_response() - 2219
} - 2220
- 2221
async fn consolidate_memory_route( - 2222
State(state): State<AppState>, - 2223
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 2224
) -> axum::response::Response { - 2225
use axum::response::IntoResponse; - 2226
let core = scoped_core!(&state, None, q.agent.as_deref()); - 2227
match core.consolidate_memory() { - 2228
Ok(report) => ( - 2229
StatusCode::OK, - 2230
Json(serde_json::to_value(&report).unwrap_or_default()), - 2231
) - 2232
.into_response(), - 2233
Err(err) => ( - 2234
StatusCode::INTERNAL_SERVER_ERROR, - 2235
Json(serde_json::json!({ "error": err })), - 2236
) - 2237
.into_response(), - 2238
} - 2239
} - 2240
- 2241
#[derive(serde::Deserialize)] - 2242
struct ListEntitiesQuery { - 2243
#[serde(default)] - 2244
q: Option<String>, - 2245
#[serde(default)] - 2246
scope: Option<String>, - 2247
} - 2248
- 2249
async fn list_entities_route( - 2250
State(state): State<AppState>, - 2251
axum::extract::Query(query): axum::extract::Query<ListEntitiesQuery>, - 2252
) -> axum::response::Response { - 2253
use axum::response::IntoResponse; - 2254
let home = state.core.sessions_home(); - 2255
let is_global = query.scope.as_deref() == Some("global"); - 2256
let cwd_buf = state.core.cwd(); - 2257
let cwd = if is_global { - 2258
None - 2259
} else { - 2260
Some(cwd_buf.as_path()) - 2261
}; - 2262
let entities = if let Some(ref q) = query.q { - 2263
vak_core::entities::search_entities(&home, cwd, q) - 2264
} else { - 2265
vak_core::entities::list_entities(&home, cwd) - 2266
}; - 2267
( - 2268
StatusCode::OK, - 2269
Json(serde_json::json!({ "entities": entities })), - 2270
) - 2271
.into_response() - 2272
} - 2273
- 2274
async fn get_entity_route( - 2275
State(state): State<AppState>, - 2276
Path(id): Path<String>, - 2277
axum::extract::Query(query): axum::extract::Query<ListEntitiesQuery>, - 2278
) -> axum::response::Response { - 2279
use axum::response::IntoResponse; - 2280
let home = state.core.sessions_home(); - 2281
let is_global = query.scope.as_deref() == Some("global"); - 2282
let cwd_buf = state.core.cwd(); - 2283
let cwd = if is_global { - 2284
None - 2285
} else { - 2286
Some(cwd_buf.as_path()) - 2287
}; - 2288
if let Some(entity) = vak_core::entities::get_entity(&home, cwd, &id) { - 2289
( - 2290
StatusCode::OK, - 2291
Json(serde_json::to_value(&entity).unwrap_or_default()), - 2292
) - 2293
.into_response() - 2294
} else { - 2295
( - 2296
StatusCode::NOT_FOUND, - 2297
Json(serde_json::json!({ "error": "entity not found" })), - 2298
) - 2299
.into_response() - 2300
} - 2301
} - 2302
- 2303
#[derive(serde::Deserialize)] - 2304
struct UpsertEntityBody { - 2305
#[serde(default)] - 2306
id: Option<String>, - 2307
name: String, - 2308
entity_type: String, - 2309
#[serde(default)] - 2310
summary: String, - 2311
#[serde(default)] - 2312
attributes: std::collections::BTreeMap<String, String>, - 2313
#[serde(default)] - 2314
relations: Vec<vak_core::entities::EntityRelation>, - 2315
#[serde(default)] - 2316
scope: Option<String>, - 2317
} - 2318
- 2319
async fn upsert_entity_route( - 2320
State(state): State<AppState>, - 2321
Json(body): Json<UpsertEntityBody>, - 2322
) -> axum::response::Response { - 2323
use axum::response::IntoResponse; - 2324
let home = state.core.sessions_home(); - 2325
let is_global = body.scope.as_deref() == Some("global"); - 2326
let cwd_buf = state.core.cwd(); - 2327
let cwd = if is_global { - 2328
None - 2329
} else { - 2330
Some(cwd_buf.as_path()) - 2331
}; - 2332
let id = body.id.unwrap_or_else(|| { - 2333
let slug = body - 2334
.name - 2335
.to_ascii_lowercase() - 2336
.chars() - 2337
.map(|c| if c.is_alphanumeric() { c } else { '-' }) - 2338
.collect::<String>() - 2339
.trim_matches('-') - 2340
.to_string(); - 2341
if slug.is_empty() { - 2342
uuid::Uuid::now_v7().to_string() - 2343
} else { - 2344
slug - 2345
} - 2346
}); - 2347
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.