- 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
} - 2646
- 2647
async fn reject_proposal( - 2648
State(state): State<AppState>, - 2649
Path(id): Path<String>, - 2650
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 2651
) -> axum::response::Response { - 2652
use axum::response::IntoResponse; - 2653
let core = scoped_core!(&state, None, q.agent.as_deref()); - 2654
let res = vak_core::learning::reject(&core.sessions_home(), core.cwd(), &id); - 2655
let res = match res { - 2656
Err(_) if state.core.shared_data_home() != core.sessions_home() => { - 2657
vak_core::learning::reject(&state.core.shared_data_home(), core.cwd(), &id) - 2658
} - 2659
other => other, - 2660
}; - 2661
match res { - 2662
Ok(()) => (StatusCode::OK, Json(serde_json::json!({ "rejected": id }))).into_response(), - 2663
Err(e) => ( - 2664
StatusCode::NOT_FOUND, - 2665
Json(serde_json::json!({ "error": e })), - 2666
) - 2667
.into_response(), - 2668
} - 2669
} - 2670
- 2671
#[derive(serde::Deserialize)] - 2672
struct SearchQuery { - 2673
q: String, - 2674
#[serde(default)] - 2675
limit: Option<usize>, - 2676
/// Session id whose (already-in-context) content should be skipped. - 2677
#[serde(default)] - 2678
exclude: Option<String>, - 2679
/// Cross-project recall: search every project's ledgers under the - 2680
/// sessions home (docs/design/29-personal-os.md P1), not just this cwd. - 2681
#[serde(default)] - 2682
all: bool, - 2683
/// The Agent whose ledgers and memory are searched (default `vak`). - 2684
/// Each Agent's memory is private (AGENTS.md invariant 37), so a search - 2685
/// resolves one Agent the way `/memory` does and reads only its home. - 2686
#[serde(default)] - 2687
agent: Option<String>, - 2688
} - 2689
- 2690
async fn search_sessions( - 2691
State(state): State<AppState>, - 2692
axum::extract::Query(q): axum::extract::Query<SearchQuery>, - 2693
) -> axum::response::Response { - 2694
use axum::response::IntoResponse; - 2695
let core = scoped_core!(&state, None, q.agent.as_deref()); - 2696
let home = core.sessions_home(); - 2697
let cwd = core.cwd().clone(); - 2698
let query = q.q.clone(); - 2699
let limit = q.limit.unwrap_or(vak_session::DEFAULT_LIMIT); - 2700
let excluded = - 2701
vak_core::trash::search_exclusions(&core.shared_data_home(), q.exclude.as_deref()); - 2702
let all = q.all; - 2703
let mut extras = Vec::new(); - 2704
let mut workspace_notes = vak_core::memory::list_notes(&home, &cwd); - 2705
if all { - 2706
let root = home.join("memory"); - 2707
if let Ok(entries) = std::fs::read_dir(&root) { - 2708
workspace_notes.clear(); - 2709
for entry in entries.flatten() { - 2710
let path = entry.path().join("MEMORY.md"); - 2711
if let Ok(raw) = std::fs::read_to_string(path) { - 2712
workspace_notes.extend(vak_core::memory::parse_blocks(&raw)); - 2713
} - 2714
} - 2715
} - 2716
} - 2717
for note in workspace_notes { - 2718
let id = if note.tag.is_empty() { - 2719
note.id.clone() - 2720
} else { - 2721
note.tag.clone() - 2722
}; - 2723
extras.push(vak_session::ExternalDoc { - 2724
id, - 2725
text: format!("[{}] {}", note.kind, note.text), - 2726
ts: Some(note.ts), - 2727
role: Some("memory".into()), - 2728
}); - 2729
} - 2730
for note in vak_core::memory::list_profile_notes(&home) { - 2731
let id = format!( - 2732
"profile/{}", - 2733
if note.tag.is_empty() { - 2734
note.id.clone() - 2735
} else { - 2736
note.tag.clone() - 2737
} - 2738
); - 2739
extras.push(vak_session::ExternalDoc { - 2740
id, - 2741
text: format!("[{}] {}", note.kind, note.text), - 2742
ts: Some(note.ts), - 2743
role: Some("profile".into()), - 2744
}); - 2745
} - 2746
match tokio::task::spawn_blocking(move || { - 2747
// Both hit shapes are Serialize; the workspace path keeps its flat - 2748
// SessionHit wire shape, cross-project adds the project_hash wrapper. - 2749
let searched = if all { - 2750
vak_session::search_all_extended(&home, &query, limit, &excluded, &extras) - 2751
.map(|hits| serde_json::to_value(&hits).map_err(|e| e.to_string())) - 2752
} else { - 2753
vak_session::search_extended(&home, &cwd, &query, limit, &excluded, &extras) - 2754
.map(|hits| serde_json::to_value(&hits).map_err(|e| e.to_string())) - 2755
}; - 2756
match searched { - 2757
Ok(inner) => inner, - 2758
Err(e) => Err(e.to_string()), - 2759
} - 2760
}) - 2761
.await - 2762
{ - 2763
Ok(Ok(hits)) => Json(serde_json::json!({ "all": all, "hits": hits })).into_response(), - 2764
Ok(Err(e)) => ( - 2765
StatusCode::INTERNAL_SERVER_ERROR, - 2766
Json(serde_json::json!({ "error": e })), - 2767
) - 2768
.into_response(), - 2769
Err(e) => ( - 2770
StatusCode::INTERNAL_SERVER_ERROR, - 2771
Json(serde_json::json!({ "error": e.to_string() })), - 2772
) - 2773
.into_response(), - 2774
} - 2775
} - 2776
- 2777
/// The bearer token lives for the life of the process; embedders (desktop - 2778
/// shell, tests) need it to hand to their webview, so build the secured - 2779
/// stack here instead of inside `serve()`. - 2780
pub fn secured_router(core: Core) -> (Router, String) { - 2781
secured_router_with(core, false) - 2782
} - 2783
- 2784
/// Same stack with a CLI-level gateway override (`serve --gateway`). - 2785
/// - 2786
/// Token selection: when `VAK_GATEWAY_TOKEN` is set in the - 2787
/// environment, it is used verbatim so service-managed bridges and other - 2788
/// long-lived clients can survive process restarts. Otherwise a fresh - 2789
/// per-process token is minted as before. The variable is never logged. - 2790
pub fn secured_router_with(core: Core, force_gateway: bool) -> (Router, String) { - 2791
secured_router_with_port(core, force_gateway, vak_ops::OpsConfig::detect().port) - 2792
} - 2793
- 2794
/// Same secured stack with the actual listener port carried into operational - 2795
/// probes. `serve_with` uses this so a non-default `--port` cannot make the - 2796
/// console probe a different process. - 2797
pub fn secured_router_with_port(core: Core, force_gateway: bool, port: u16) -> (Router, String) { - 2798
// Tauri can use either its custom scheme or the loopback-style origin, - 2799
// depending on the platform and WebView runtime, plus vite dev servers. - 2800
let origins = [ - 2801
"tauri://localhost", - 2802
"http://tauri.localhost", - 2803
"https://tauri.localhost", - 2804
"http://localhost:1420", - 2805
"http://127.0.0.1:1420", - 2806
"http://localhost:5173", - 2807
"http://127.0.0.1:5173", - 2808
] - 2809
.into_iter() - 2810
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok()) - 2811
.collect::<Vec<_>>(); - 2812
let cors = tower_http::cors::CorsLayer::new() - 2813
.allow_origin(origins) - 2814
// Must cover every method the router exposes: PATCH (/config, - 2815
// /sessions/:id/config) and DELETE are preflighted, so omitting them - 2816
// makes the browser reject the request before it is ever sent. - 2817
.allow_methods([ - 2818
axum::http::Method::GET, - 2819
axum::http::Method::POST, - 2820
axum::http::Method::PUT, - 2821
axum::http::Method::PATCH, - 2822
axum::http::Method::DELETE, - 2823
]) - 2824
.allow_headers([ - 2825
axum::http::header::AUTHORIZATION, - 2826
axum::http::header::CONTENT_TYPE, - 2827
]); - 2828
let mut state = AppState::new(core); - 2829
state.ops_port = port; - 2830
if force_gateway { - 2831
state.enable_gateway(); - 2832
} - 2833
let token = (*state.auth_token).clone(); - 2834
let rl_settings = state.core.config().gateway.rate_limit.clone(); - 2835
let rl_config = rate_limit::RateLimitConfig::from_settings(rl_settings); - 2836
let limiter = rate_limit::RateLimiter::new(rl_config, state.core.sessions_home()); - 2837
let app = router_with_state(state.clone()) - 2838
.layer(axum::middleware::from_fn_with_state( - 2839
limiter, - 2840
rate_limit::rate_limit_layer, - 2841
)) - 2842
.layer(axum::middleware::from_fn_with_state( - 2843
state.clone(), - 2844
enforce_participant_audience, - 2845
)) - 2846
.layer(axum::middleware::from_fn_with_state( - 2847
AuthPolicy { - 2848
token: token.clone(), - 2849
home: state.core.sessions_home(), - 2850
trusted_hosts: state.core.config().server.trusted_hosts.clone(), - 2851
}, - 2852
require_bearer, - 2853
)) - 2854
.layer(cors); - 2855
#[cfg(unix)] - 2856
{ - 2857
let broker = state.core.agent_network_broker(); - 2858
let policy_file = state - 2859
.core - 2860
.sessions_home() - 2861
.join("agent-network/policies.json"); - 2862
if let Err(error) = broker.load_policies(&policy_file) { - 2863
eprintln!("[agent-network] policy load failed: {error}"); - 2864
} - 2865
let socket = - 2866
vak_core::agent_network::AgentNetworkBroker::socket_path(&state.core.sessions_home()); - 2867
tokio::spawn(async move { - 2868
if let Err(error) = broker.serve_unix(&socket).await { - 2869
eprintln!("[agent-network] broker stopped: {error}"); - 2870
} - 2871
}); - 2872
} - 2873
// Local routines: fires due scheduled tasks while this server lives. - 2874
start_scheduler(&state); - 2875
delivery::start_replay(&state.core); - 2876
// Capability discovery is NOT started here. - 2877
// - 2878
// It used to be, and the CLI did its own bounded wait, and the desktop - 2879
// did neither — three surfaces answering "when may a prompt be frozen?" - 2880
// three different ways, which is how an admitted, working MCP server - 2881
// still produced a session that had never seen its catalog. - 2882
// `Core::admitted_capabilities` owns that decision now, so every surface - 2883
// gets the same packet whether it was reached from a terminal, this - 2884
// server, or the desktop app. - 2885
// Background index sync: keeps the admin console populated from the - 2886
// very first boot. Idempotent; never blocks request handling. - 2887
if let Some(store) = state.store.clone() { - 2888
let home = state.core.sessions_home(); - 2889
tokio::spawn(async move { - 2890
match store.rebuild(&home) { - 2891
Ok(s) if s.files_scanned > 0 => eprintln!( - 2892
"[store] indexed {} files / {} entries", - 2893
s.files_scanned, s.entries_indexed - 2894
), - 2895
Ok(_) => {} - 2896
Err(e) => eprintln!("[store] startup rebuild failed: {e}"), - 2897
} - 2898
}); - 2899
} - 2900
(app, token) - 2901
} - 2902
- 2903
fn participant_matches_conversation_audience( - 2904
participant: &coworking::VerifiedPrincipal, - 2905
conversation_id: &str, - 2906
observed_audience: Option<&str>, - 2907
) -> bool { - 2908
participant.conversation_id == conversation_id - 2909
&& observed_audience == Some(participant.audience_id.as_str()) - 2910
} - 2911
- 2912
/// Recheck the live conversation audience after bearer authentication and - 2913
/// before any scoped handler runs. The route whitelist limits *where* a - 2914
/// participant may go; this boundary also proves the durable grant still - 2915
/// names the audience owned by that conversation. - 2916
async fn enforce_participant_audience( - 2917
State(state): State<AppState>, - 2918
req: axum::extract::Request, - 2919
next: axum::middleware::Next, - 2920
) -> axum::response::Response { - 2921
if let Some(AuthenticatedPrincipal::Participant(participant)) = - 2922
req.extensions().get::<AuthenticatedPrincipal>() - 2923
{ - 2924
let conversation_id = req - 2925
.uri() - 2926
.path() - 2927
.trim_matches('/') - 2928
.split('/') - 2929
.nth(1) - 2930
.unwrap_or_default(); - 2931
let audience = conversation_audience(&state, conversation_id); - 2932
if !participant_matches_conversation_audience( - 2933
participant, - 2934
conversation_id, - 2935
audience.as_deref(), - 2936
) { - 2937
return StatusCode::FORBIDDEN.into_response(); - 2938
} - 2939
} - 2940
next.run(req).await - 2941
} - 2942
- 2943
/// Initialize the distributed event bus from config and attach it to the - 2944
/// global `EventHub`. Called from `serve_with` (async) so NATS connection - 2945
/// retries don't block request handling. - 2946
pub async fn init_server_bus(core: &Core) { - 2947
let config = core.config().server.bus.clone(); - 2948
if config.nats_url.is_none() { - 2949
return; - 2950
} - 2951
install_server_bus(core, &config).await; - 2952
} - 2953
- 2954
/// Builds the bus `config` describes, with its secrets from the secrets - 2955
/// chain, and makes it the live one. - 2956
async fn install_server_bus(core: &Core, config: &vak_config::BusResolved) { - 2957
let sessions_home = core.sessions_home(); - 2958
let workspace_id = { - 2959
use sha2::{Digest, Sha256}; - 2960
let mut hasher = Sha256::new(); - 2961
hasher.update(sessions_home.to_string_lossy().as_bytes()); - 2962
let result = hasher.finalize(); - 2963
// First 6 bytes → 12 hex chars, a compact workspace-scoped subject prefix. - 2964
let prefix = &result[..6]; - 2965
let hex: String = prefix.iter().map(|b| format!("{b:02x}")).collect(); - 2966
format!("ws_{}", hex) - 2967
}; - 2968
let bus = crate::bus::ServerBus::from_resolved( - 2969
&workspace_id, - 2970
config, - 2971
&crate::bus::BusCredentials::of(core), - 2972
) - 2973
.await; - 2974
if let Some(mut hub) = crate::events::global() { - 2975
hub.set_server_bus(std::sync::Arc::new(bus)); - 2976
} - 2977
} - 2978
- 2979
pub async fn serve(core: Core, addr: std::net::SocketAddr) -> std::io::Result<()> { - 2980
serve_with(core, addr, false).await - 2981
} - 2982
- 2983
/// `force_gateway` mirrors `serve --gateway`: enable routing regardless of - 2984
/// the (untrusted-stripped) project config. - 2985
/// Serve an already-bound listener with an already-built router. - 2986
/// - 2987
/// The pieces `secured_router` returns, joined. `vak setup` binds its own - 2988
/// ephemeral loopback port so it can print the URL *before* serving, and - 2989
/// needs the token from the same call — which `serve_with` cannot give it, - 2990
/// because that mints and consumes the token internally. Exposed here so - 2991
/// axum stays a dependency of this crate rather than leaking into the CLI. - 2992
pub async fn serve_router(listener: tokio::net::TcpListener, app: Router) -> std::io::Result<()> { - 2993
axum::serve(listener, app).await - 2994
} - 2995
- 2996
pub async fn serve_with( - 2997
core: Core, - 2998
addr: std::net::SocketAddr, - 2999
force_gateway: bool, - 3000
) -> std::io::Result<()> { - 3001
// Presentation seeds are versioned, additive previews. Reconcile them at - 3002
// process startup so a newly installed binary reaches existing workspaces - 3003
// even when the admin presentation list is never opened. User revisions - 3004
// and activations remain untouched by register(). - 3005
reconcile_builtin_presentations(&core) - 3006
.map_err(|error| std::io::Error::other(format!("presentation seed failed: {error}")))?; - 3007
// Local-only does not mean safe-by-default: any local process could - 3008
// reach an unauthenticated agent and drive arbitrary tool execution - 3009
// plus self-approval. Every serve() instance gets a per-process - 3010
// bearer token; /health stays open. - 3011
let listener = tokio::net::TcpListener::bind(addr).await?; - 3012
let actual_addr = listener.local_addr()?; - 3013
let (app, token) = secured_router_with_port(core.clone(), force_gateway, actual_addr.port()); - 3014
// Initialize the distributed event bus (vak-bus, docs/design/53). - 3015
// Falls back to InMemoryBus when NATS is absent or unreachable. - 3016
init_server_bus(&core).await; - 3017
eprintln!("Vakyartha server listening on http://{actual_addr}"); - 3018
// Same source the real token-selection logic above (auth_token, in - 3019
// AppState::new) already checks: `vak_config::get_var` also sees a - 3020
// value that only reached the process through the credential store - 3021
// (never a real `std::env` var), so a plain `std::env::var` check - 3022
// here — which is all this ever did — reported "generated" for - 3023
// every service-managed deployment, since none of them export - 3024
// VAK_GATEWAY_TOKEN into the actual process environment; they rely - 3025
// on the Shared secret scope main() already loads unconditionally at - 3026
// startup. The token itself was always correctly pinned; only this - 3027
// log line was wrong, in exactly the deployment shape (a durable - 3028
// service reading from the credential store) where getting it right - 3029
// matters most for debugging a stale-cookie/token mismatch after a - 3030
// restart. - 3031
if vak_config::get_var("VAK_GATEWAY_TOKEN").is_some_and(|t| !t.trim().is_empty()) { - 3032
eprintln!("auth token: (pinned via VAK_GATEWAY_TOKEN)"); - 3033
} else if std::io::IsTerminal::is_terminal(&std::io::stderr()) { - 3034
eprintln!("auth token: {token}"); - 3035
eprintln!("clients must send 'Authorization: Bearer {token}' (or ?token=)"); - 3036
} else { - 3037
eprintln!("auth token: generated for this process (suppressed in non-interactive output)"); - 3038
} - 3039
if force_gateway { - 3040
eprintln!("gateway: ENABLED (--gateway overrides config)"); - 3041
} - 3042
let (draining_tx, draining_rx) = oneshot::channel(); - 3043
let server = std::future::IntoFuture::into_future( - 3044
axum::serve(listener, app).with_graceful_shutdown(async { - 3045
let _ = tokio::signal::ctrl_c().await; - 3046
eprintln!("\n[shutting down: draining connections]"); - 3047
let _ = draining_tx.send(()); - 3048
}), - 3049
); - 3050
tokio::pin!(server); - 3051
tokio::select! { - 3052
result = &mut server => result, - 3053
_ = draining_rx => { - 3054
// EventSource streams can remain open indefinitely. A restart - 3055
// must release session-ledger locks even when a browser keeps - 3056
// those connections alive, or the replacement server can only - 3057
// attach read-only and every follow-up conflicts. Give ordinary - 3058
// requests a short drain window, then end this process. - 3059
match tokio::time::timeout(Duration::from_secs(5), &mut server).await { - 3060
Ok(result) => result, - 3061
Err(_) => { - 3062
eprintln!("[shutdown: closing long-lived connections]"); - 3063
Ok(()) - 3064
} - 3065
} - 3066
} - 3067
} - 3068
} - 3069
- 3070
fn reconcile_builtin_presentations(core: &Core) -> Result<(), String> { - 3071
let store = vak_store::presentation::PresentationStore::new( - 3072
core.sessions_home().join("presentations.json"), - 3073
); - 3074
let mut library = store.load().map_err(|error| error.to_string())?; - 3075
let before = library.definitions().count(); - 3076
let mut changed = false; - 3077
for seed in vak_presentation::seeds::built_in_seed_pack() { - 3078
if let Some(existing) = library.get(&seed.spec.id, seed.spec.revision) - 3079
&& existing.digest != seed.digest - 3080
{ - 3081
changed = true; - 3082
} - 3083
library.register(seed).map_err(|error| error.to_string())?; - 3084
} - 3085
if changed || library.definitions().count() != before { - 3086
store.save(&library).map_err(|error| error.to_string())?; - 3087
} - 3088
Ok(()) - 3089
} - 3090
- 3091
/// Built-in recipes are available in every build without writing a standing - 3092
/// user preference. An explicit user or workspace activation still wins. - 3093
fn effective_presentation_library( - 3094
library: &vak_presentation::PresentationLibrary, - 3095
workspace_owner: &str, - 3096
) -> vak_presentation::PresentationLibrary { - 3097
let mut effective = library.clone(); - 3098
for seed in vak_presentation::seeds::built_in_seed_pack() { - 3099
let id = seed.spec.id.clone(); - 3100
let revision = seed.spec.revision; - 3101
let accepts = seed.spec.accepts.clone(); - 3102
if library.is_suppressed(&id, vak_presentation::LibraryScope::User, "user") - 3103
|| library.is_suppressed( - 3104
&id, - 3105
vak_presentation::LibraryScope::Workspace, - 3106
workspace_owner, - 3107
) - 3108
{ - 3109
continue; - 3110
} - 3111
if let Err(error) = effective.register(seed) { - 3112
eprintln!("[presentation] built-in pack {id} unavailable: {error}"); - 3113
continue; - 3114
} - 3115
if accepts.iter().any(|semantic_type| { - 3116
effective - 3117
.select_preferred(semantic_type, "user", workspace_owner) - 3118
.is_some_and(|selected| { - 3119
selected.spec.metadata.get("seed").map(String::as_str) != Some("true") - 3120
}) - 3121
}) { - 3122
continue; - 3123
} - 3124
if let Err(error) = effective.activate( - 3125
&id, - 3126
revision, - 3127
vak_presentation::LibraryScope::Workspace, - 3128
workspace_owner, - 3129
) { - 3130
eprintln!("[presentation] built-in pack {id} could not activate: {error}"); - 3131
} - 3132
} - 3133
effective - 3134
} - 3135
- 3136
#[cfg(test)] - 3137
#[allow(clippy::expect_used)] - 3138
mod built_in_presentation_tests { - 3139
#[test] - 3140
fn every_built_in_pack_is_selected_without_a_saved_activation() { - 3141
let library = super::effective_presentation_library( - 3142
&vak_presentation::PresentationLibrary::default(), - 3143
"/tmp/presentation-selection", - 3144
); - 3145
for seed in vak_presentation::seeds::built_in_seed_pack() { - 3146
let selected = library - 3147
.select_preferred(&seed.spec.accepts[0], "user", "/tmp/presentation-selection") - 3148
.expect("built-in pack is selected"); - 3149
assert_eq!(selected.spec.id, seed.spec.id, "{}", seed.spec.accepts[0]); - 3150
} - 3151
} - 3152
- 3153
#[test] - 3154
fn deactivated_builtin_stays_off_until_explicitly_activated() { - 3155
let owner = "/tmp/presentation-selection"; - 3156
let mut library = vak_presentation::PresentationLibrary::default(); - 3157
library.deactivate( - 3158
"seed.timeline", - 3159
vak_presentation::LibraryScope::Workspace, - 3160
owner, - 3161
); - 3162
let effective = super::effective_presentation_library(&library, owner); - 3163
assert!(
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.