- 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
- 2348
let record = vak_core::entities::EntityRecord { - 2349
id, - 2350
name: body.name, - 2351
entity_type: body.entity_type, - 2352
summary: body.summary, - 2353
attributes: body.attributes, - 2354
relations: body.relations, - 2355
updated_at: chrono::Utc::now(), - 2356
}; - 2357
- 2358
match vak_core::entities::upsert_entity(&home, cwd, record) { - 2359
Ok(saved) => ( - 2360
StatusCode::OK, - 2361
Json(serde_json::to_value(&saved).unwrap_or_default()), - 2362
) - 2363
.into_response(), - 2364
Err(err) => ( - 2365
StatusCode::INTERNAL_SERVER_ERROR, - 2366
Json(serde_json::json!({ "error": err.to_string() })), - 2367
) - 2368
.into_response(), - 2369
} - 2370
} - 2371
- 2372
async fn delete_entity_route( - 2373
State(state): State<AppState>, - 2374
Path(id): Path<String>, - 2375
axum::extract::Query(query): axum::extract::Query<ListEntitiesQuery>, - 2376
) -> axum::response::Response { - 2377
use axum::response::IntoResponse; - 2378
let home = state.core.sessions_home(); - 2379
let is_global = query.scope.as_deref() == Some("global"); - 2380
let cwd_buf = state.core.cwd(); - 2381
let cwd = if is_global { - 2382
None - 2383
} else { - 2384
Some(cwd_buf.as_path()) - 2385
}; - 2386
match vak_core::entities::delete_entity(&home, cwd, &id) { - 2387
Ok(true) => (StatusCode::OK, Json(serde_json::json!({ "deleted": true }))).into_response(), - 2388
Ok(false) => ( - 2389
StatusCode::NOT_FOUND, - 2390
Json(serde_json::json!({ "deleted": false, "error": "entity not found" })), - 2391
) - 2392
.into_response(), - 2393
Err(err) => ( - 2394
StatusCode::INTERNAL_SERVER_ERROR, - 2395
Json(serde_json::json!({ "error": err.to_string() })), - 2396
) - 2397
.into_response(), - 2398
} - 2399
} - 2400
- 2401
/// Resolve a note id to the markdown store it lives in. The workspace tier/// is per-cwd; the profile tier is global (`<home>/memory/user/USER.md`). - 2402
#[derive(serde::Deserialize)] - 2403
struct AppendMemoryBody { - 2404
text: String, - 2405
#[serde(default)] - 2406
kind: Option<String>, - 2407
#[serde(default)] - 2408
tag: Option<String>, - 2409
#[serde(default)] - 2410
scope: Option<MemoryScope>, - 2411
#[serde(default)] - 2412
session_id: Option<String>, - 2413
/// Which Agent this note belongs to; see `AgentScopeQuery`. Absent - 2414
/// means "vak", unless `session_id` names a currently-registered - 2415
/// session, whose own Agent takes precedence (see `resolve_scoped_core`). - 2416
#[serde(default)] - 2417
agent: Option<String>, - 2418
} - 2419
- 2420
/// Append a note to either tier. Keeps gateway/desktop/CLI symmetric — - 2421
/// every surface writes through the same validated core API. - 2422
async fn append_memory( - 2423
State(state): State<AppState>, - 2424
Json(body): Json<AppendMemoryBody>, - 2425
) -> axum::response::Response { - 2426
use axum::response::IntoResponse; - 2427
let core = scoped_core!(&state, body.session_id.as_deref(), body.agent.as_deref()); - 2428
let home = core.sessions_home(); - 2429
let scope = body.scope.unwrap_or(MemoryScope::Workspace); - 2430
let kind = body.kind.unwrap_or_else(|| "fact".to_string()); - 2431
let tag = body.tag.unwrap_or_default(); - 2432
let session = body.session_id.unwrap_or_else(|| "http".to_string()); - 2433
let result = match scope { - 2434
MemoryScope::Workspace => { - 2435
vak_core::memory::append_note(&home, core.cwd(), &kind, &tag, &session, &body.text) - 2436
} - 2437
MemoryScope::Profile => { - 2438
vak_core::memory::append_profile_note(&home, &kind, &tag, &body.text, &session) - 2439
} - 2440
}; - 2441
match result { - 2442
Ok(note) => { - 2443
let scope_str = match scope { - 2444
MemoryScope::Workspace => "workspace", - 2445
MemoryScope::Profile => "profile", - 2446
}; - 2447
vak_core::security_events::record( - 2448
&home, - 2449
vak_core::security_events::EventKind::ConfigChange, - 2450
"memory_append", - 2451
&format!("scope={scope_str} note_id={}", note.id), - 2452
None, - 2453
); - 2454
(StatusCode::CREATED, Json(note_payload(¬e, scope_str))).into_response() - 2455
} - 2456
Err(e) => ( - 2457
StatusCode::BAD_REQUEST, - 2458
Json(serde_json::json!({ "error": e })), - 2459
) - 2460
.into_response(), - 2461
} - 2462
} - 2463
- 2464
fn memory_store_path(core: &vak_core::Core, scope: MemoryScope) -> PathBuf { - 2465
let home = core.sessions_home(); - 2466
match scope { - 2467
MemoryScope::Workspace => home - 2468
.join("memory") - 2469
.join(vak_core::memory::hash_cwd(core.cwd())) - 2470
.join("MEMORY.md"), - 2471
MemoryScope::Profile => vak_core::memory::profile_path(&home), - 2472
} - 2473
} - 2474
- 2475
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, Default)] - 2476
#[serde(rename_all = "lowercase")] - 2477
enum MemoryScope { - 2478
#[default] - 2479
Workspace, - 2480
Profile, - 2481
} - 2482
- 2483
async fn forget_memory_note( - 2484
State(state): State<AppState>, - 2485
Path(note_id): Path<String>, - 2486
axum::extract::Query(q): axum::extract::Query<MemoryScopeQuery>, - 2487
) -> axum::response::Response { - 2488
use axum::response::IntoResponse; - 2489
let core = scoped_core!(&state, None, q.agent.as_deref()); - 2490
let path = memory_store_path(&core, q.scope.unwrap_or_default()); - 2491
let result = vak_core::memory::forget_note(&path, ¬e_id); - 2492
// A note written before this Agent's data moved to its own sessions_home - 2493
// subfolder still lives at the legacy, un-scoped path — fall back to it - 2494
// the same way `promote_proposal`/`reject_proposal` already do, so an - 2495
// old note surfaced by `list_memory`'s merged view can still be forgotten. - 2496
let legacy_path = memory_store_path(&state.core, q.scope.unwrap_or_default()); - 2497
let result = match result { - 2498
Err(_) if legacy_path != path => vak_core::memory::forget_note(&legacy_path, ¬e_id), - 2499
other => other, - 2500
}; - 2501
match result { - 2502
Ok(bytes) => { - 2503
vak_core::security_events::record( - 2504
&core.sessions_home(), - 2505
vak_core::security_events::EventKind::ConfigChange, - 2506
"memory_forget", - 2507
&format!("scope={:?} note_id={note_id}", q.scope.unwrap_or_default()), - 2508
None, - 2509
); - 2510
( - 2511
StatusCode::OK, - 2512
Json(serde_json::json!({ "forgotten": note_id, "bytes": bytes })), - 2513
) - 2514
.into_response() - 2515
} - 2516
Err(e) => ( - 2517
StatusCode::NOT_FOUND, - 2518
Json(serde_json::json!({ "error": e })), - 2519
) - 2520
.into_response(), - 2521
} - 2522
} - 2523
- 2524
#[derive(serde::Deserialize)] - 2525
struct MemoryAmendBody { - 2526
text: String, - 2527
#[serde(default)] - 2528
scope: Option<MemoryScope>, - 2529
/// See `AgentScopeQuery`. - 2530
#[serde(default)] - 2531
agent: Option<String>, - 2532
} - 2533
- 2534
#[derive(serde::Deserialize, Default)] - 2535
struct MemoryScopeQuery { - 2536
#[serde(default)] - 2537
scope: Option<MemoryScope>, - 2538
/// See `AgentScopeQuery`. - 2539
#[serde(default)] - 2540
agent: Option<String>, - 2541
} - 2542
- 2543
async fn amend_memory_note( - 2544
State(state): State<AppState>, - 2545
Path(note_id): Path<String>, - 2546
Json(body): Json<MemoryAmendBody>, - 2547
) -> axum::response::Response { - 2548
use axum::response::IntoResponse; - 2549
if body.text.trim().is_empty() { - 2550
return ( - 2551
StatusCode::BAD_REQUEST, - 2552
Json(serde_json::json!({ "error": "note must not be empty" })), - 2553
) - 2554
.into_response(); - 2555
} - 2556
let core = scoped_core!(&state, None, body.agent.as_deref()); - 2557
let path = memory_store_path(&core, body.scope.unwrap_or_default()); - 2558
let result = vak_core::memory::amend_note(&path, ¬e_id, &body.text); - 2559
// See the identical fallback in `forget_memory_note`. - 2560
let legacy_path = memory_store_path(&state.core, body.scope.unwrap_or_default()); - 2561
let result = match result { - 2562
Err(_) if legacy_path != path => { - 2563
vak_core::memory::amend_note(&legacy_path, ¬e_id, &body.text) - 2564
} - 2565
other => other, - 2566
}; - 2567
match result { - 2568
Ok(()) => { - 2569
vak_core::security_events::record( - 2570
&core.sessions_home(), - 2571
vak_core::security_events::EventKind::ConfigChange, - 2572
"memory_amend", - 2573
&format!( - 2574
"scope={:?} note_id={note_id}", - 2575
body.scope.unwrap_or_default() - 2576
), - 2577
None, - 2578
); - 2579
( - 2580
StatusCode::OK, - 2581
Json(serde_json::json!({ "amended": note_id })), - 2582
) - 2583
.into_response() - 2584
} - 2585
Err(e) => ( - 2586
StatusCode::NOT_FOUND, - 2587
Json(serde_json::json!({ "error": e })), - 2588
) - 2589
.into_response(), - 2590
} - 2591
} - 2592
- 2593
fn proposals_payload(core: &Core) -> Vec<serde_json::Value> { - 2594
let mut proposals = vak_core::learning::list_proposals(&core.sessions_home(), core.cwd()); - 2595
if proposals.is_empty() && core.shared_data_home() != core.sessions_home() { - 2596
proposals = vak_core::learning::list_proposals(&core.shared_data_home(), core.cwd()); - 2597
} - 2598
proposals - 2599
.iter() - 2600
.map(|p| { - 2601
serde_json::json!({ - 2602
"id": p.id, - 2603
"name": p.name, - 2604
"description": p.description, - 2605
}) - 2606
}) - 2607
.collect() - 2608
} - 2609
- 2610
async fn list_proposals_route( - 2611
State(state): State<AppState>, - 2612
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 2613
) -> axum::response::Response { - 2614
use axum::response::IntoResponse; - 2615
let core = scoped_core!(&state, None, q.agent.as_deref()); - 2616
Json(serde_json::json!({ "proposals": proposals_payload(&core) })).into_response() - 2617
} - 2618
- 2619
async fn promote_proposal( - 2620
State(state): State<AppState>, - 2621
Path(id): Path<String>, - 2622
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 2623
) -> axum::response::Response { - 2624
use axum::response::IntoResponse; - 2625
let core = scoped_core!(&state, None, q.agent.as_deref()); - 2626
let res = vak_core::learning::promote(&core.sessions_home(), core.cwd(), &id); - 2627
let res = match res { - 2628
Err(_) if state.core.shared_data_home() != core.sessions_home() => { - 2629
vak_core::learning::promote(&state.core.shared_data_home(), core.cwd(), &id) - 2630
} - 2631
other => other, - 2632
}; - 2633
match res { - 2634
Ok(name) => ( - 2635
StatusCode::OK, - 2636
Json(serde_json::json!({ "promoted": name })), - 2637
) - 2638
.into_response(), - 2639
Err(e) => ( - 2640
StatusCode::NOT_FOUND, - 2641
Json(serde_json::json!({ "error": e })), - 2642
) - 2643
.into_response(), - 2644
} - 2645
}
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.