- 152
/// The gateway already passes its pooled `Core` explicitly into turn - 153
/// execution; this is the same fact, held where every run path can see - 154
/// it, which is what lets a session be capped *below* the workspace - 155
/// mode (doc 46 security invariant 5). - 156
pub(crate) core: Core, - 157
/// Workspace this session's tools/diffs operate in (main cwd, or a - 158
/// best-of-N worktree). - 159
pub(crate) cwd: PathBuf, - 160
pub(crate) session: Arc<Mutex<Option<SessionLog>>>, - 161
/// Latest host-admitted intent, retained while the runner owns the log. - 162
pub(crate) intent: Arc<Mutex<Option<vak_session::types::IntentRecord>>>, - 163
pub(crate) steering: Arc<SteeringQueues>, - 164
/// Cancel for the CURRENT run only; replaced with a fresh token when a - 165
/// run ends so one `/cancel` doesn't poison every later run. - 166
pub(crate) cancel: Arc<std::sync::Mutex<CancellationToken>>, - 167
/// Live events for the MAIN transcript, with replay so a dropped - 168
/// connection can resume rather than lose the gap (events::EventBus). - 169
pub(crate) events_tx: events::EventBus, - 170
/// Content-free wakeup for shared candidate comments. - 171
pub(crate) coworking_comments_tx: tokio::sync::broadcast::Sender<()>, - 172
/// Pending approval gates scoped to THIS session — a client holding - 173
/// session A can never resolve session B's approvals. - 174
pub(crate) pending: Arc<Mutex<HashMap<String, ApprovalRequest>>>, - 175
/// Lifecycle facts produced while the runner owns the SessionLog. They - 176
/// are appended atomically when the runner returns the ledger. - 177
pub(crate) activity_buffer: Arc<Mutex<Vec<vak_session::ActivityRecord>>>, - 178
/// Reconnectable presentation state while the runner owns the ledger. - 179
pub(crate) presentation: Arc<Mutex<vak_delivery::OutputTimeline>>, - 180
/// Notified when an SSE consumer attaches, so runs don't start (and - 181
/// finish) before anyone is listening. - 182
pub(crate) subscribed: Arc<tokio::sync::Notify>, - 183
/// Side-chat stream + cancel: branched turns that read the session - 184
/// context but never land on the main chain. - 185
/// Live events for the `/btw` side branch. Same bus type as the - 186
/// main transcript so both resume identically. - 187
pub(crate) side_events_tx: events::EventBus, - 188
/// Last time a request resolved this handle, for idle eviction. - 189
pub(crate) last_touched: Mutex<std::time::Instant>, - 190
pub(crate) side_cancel: Arc<std::sync::Mutex<CancellationToken>>, - 191
/// Admission identities currently owned by this handle. This closes the - 192
/// retry race while the runner owns the ledger. - 193
pub(crate) admissions: Arc<Mutex<HashSet<String>>>, - 194
} - 195
- 196
#[derive(Clone)] - 197
pub struct AppState { - 198
pub core: Core, - 199
/// Monotonic start point for this server state. Keeping it on the state - 200
/// avoids reporting the first Operations request as process start and - 201
/// keeps embedded/test routers independent from one another. - 202
started_at: Instant, - 203
/// Port used by the bound server. Plain routers use the configured - 204
/// operations default; `serve_with` overwrites this with its actual - 205
/// listener port so health probes and the console never drift from the - 206
/// process being inspected. - 207
ops_port: u16, - 208
sessions: Arc<Mutex<HashMap<String, Arc<SessionHandle>>>>, - 209
/// Ephemeral, observation-based coworking presence. It is deliberately - 210
/// outside the append-only work ledger and expires without disconnect - 211
/// bookkeeping when a browser vanishes. - 212
coworking_presence: Arc<Mutex<HashMap<String, HashMap<String, CoworkingPresence>>>>, - 213
/// Live best-of-N runs keyed by child session id. - 214
pub(crate) best_runs: Arc<Mutex<HashMap<String, BestRunMeta>>>, - 215
/// Scheduled tasks for this workspace (store shape owned by vak-core). - 216
tasks: Arc<Mutex<HashMap<String, TaskDef>>>, - 217
/// In-memory cron markers: task id → next scheduled local fire. Interval - 218
/// tasks keep using `last_run_at`; only `schedule:` tasks appear here. - 219
next_fire: Arc<Mutex<HashMap<String, chrono::DateTime<chrono::Local>>>>, - 220
/// Script tasks currently executing (no child session to inspect, so - 221
/// this stands in for the busy-check that prompt tasks get). - 222
script_inflight: Arc<Mutex<std::collections::HashSet<String>>>, - 223
/// Managed dev servers (preview pane), keyed by session::name. - 224
procs: Arc<Mutex<HashMap<String, ManagedProc>>>, - 225
/// Gateway surface bindings + enable gate (docs/design/22-gateway.md). - 226
pub(crate) gateway: Arc<gateway::GatewayState>, - 227
/// Proactive heartbeat runtime (docs/design/29-personal-os.md P7). - 228
pub(crate) heartbeat: Arc<heartbeat::HeartbeatRuntime>, - 229
/// Global event hub for admin console SSE streaming. - 230
pub(crate) hub: events::EventHub, - 231
/// SQLite FTS5 session index (rebuildable from JSONL). - 232
pub(crate) store: Option<vak_store::Store>, - 233
/// Expected auth token (login endpoint compares against it). - 234
pub(crate) auth_token: Arc<String>, - 235
/// Workspace the browser client currently has open, when it has moved - 236
/// away from the one this process started in (docs/design/48-web-client.md - 237
/// §5). `None` means "the process's own workspace". - 238
/// - 239
/// Only *new* sessions are affected: a session freezes its `Core` at - 240
/// creation (invariant 17), so switching the active workspace never - 241
/// retargets work already under way — it decides where the next task - 242
/// will live, which is exactly what an operator switching projects - 243
/// means by it. - 244
pub(crate) active_core: Arc<Mutex<Option<Core>>>, - 245
/// Number of live voice websocket sessions. Admission is checked against - 246
/// the effective configuration at connection time and released on exit. - 247
pub(crate) voice_active: Arc<std::sync::atomic::AtomicUsize>, - 248
/// Per-minute budget shared by every paid voice request. - 249
pub(crate) voice_requests: Arc<voice::RequestWindow>, - 250
} - 251
- 252
#[derive(Clone)] - 253
pub struct BestRunMeta { - 254
pub repo: PathBuf, - 255
pub wt_path: PathBuf, - 256
pub branch: String, - 257
} - 258
- 259
impl AppState { - 260
pub fn new(core: Core) -> Self { - 261
let gateway = Arc::new(gateway::GatewayState::load(&core, false)); - 262
let hub = events::init_global(); - 263
// Canonical layout (doc 32): the FTS index is a rebuildable cache, - 264
// never user data — it lives under Library/Caches / XDG_CACHE_HOME. - 265
let store = vak_store::Store::open(&core.cache_home()).ok(); - 266
if store.is_none() { - 267
eprintln!("[warn] store open failed, search will use fallback"); - 268
} - 269
// Token selection lives here so every router flavor (plain, - 270
// gateway, secured) shares one identity for auth + login. - 271
let auth_token = Arc::new( - 272
std::env::var("VAK_GATEWAY_TOKEN") - 273
.ok() - 274
.filter(|t| !t.trim().is_empty()) - 275
.or_else(|| vak_config::get_var("VAK_GATEWAY_TOKEN")) - 276
.filter(|t| !t.trim().is_empty()) - 277
.unwrap_or_else(|| format!("vk_{}", uuid::Uuid::now_v7())), - 278
); - 279
AppState { - 280
core, - 281
started_at: Instant::now(), - 282
ops_port: vak_ops::OpsConfig::detect().port, - 283
sessions: Arc::new(Mutex::new(HashMap::new())), - 284
coworking_presence: Arc::new(Mutex::new(HashMap::new())), - 285
best_runs: Arc::new(Mutex::new(HashMap::new())), - 286
tasks: Arc::new(Mutex::new(HashMap::new())), - 287
next_fire: Arc::new(Mutex::new(HashMap::new())), - 288
script_inflight: Arc::new(Mutex::new(std::collections::HashSet::new())), - 289
procs: Arc::new(Mutex::new(HashMap::new())), - 290
gateway, - 291
heartbeat: Arc::new(heartbeat::HeartbeatRuntime::new()), - 292
hub, - 293
store, - 294
auth_token, - 295
active_core: Arc::new(Mutex::new(None)), - 296
voice_active: Arc::new(std::sync::atomic::AtomicUsize::new(0)), - 297
voice_requests: Arc::new(voice::RequestWindow::new()), - 298
} - 299
} - 300
- 301
/// The `Core` new work should run under: the browser's chosen - 302
/// workspace if it has picked one, else this process's own. - 303
pub(crate) fn active_core(&self) -> Core { - 304
self.active_core - 305
.lock() - 306
.unwrap_or_else(std::sync::PoisonError::into_inner) - 307
.clone() - 308
.unwrap_or_else(|| self.core.clone()) - 309
} - 310
- 311
/// Force-enable the gateway (`serve --gateway`) before the state is - 312
/// shared; the config gate alone governs every other entry point. - 313
pub fn enable_gateway(&mut self) { - 314
if let Some(gw) = Arc::get_mut(&mut self.gateway) { - 315
gw.set_enabled(true); - 316
} - 317
} - 318
- 319
fn get(&self, id: &str) -> Option<Arc<SessionHandle>> { - 320
let handle = self - 321
.sessions - 322
.lock() - 323
.unwrap_or_else(std::sync::PoisonError::into_inner) - 324
.get(id) - 325
.cloned(); - 326
if let Some(handle) = &handle - 327
&& let Ok(mut touched) = handle.last_touched.lock() - 328
{ - 329
*touched = std::time::Instant::now(); - 330
} - 331
handle - 332
} - 333
- 334
/// Drop the least-recently-touched idle sessions once the live set exceeds - 335
/// [`MAX_LIVE_SESSIONS`]. - 336
/// - 337
/// The map was insert-only. Each handle pins the whole ledger in memory - 338
/// (`Vec<Entry>` of every message, tool result, and receipt) plus a - 339
/// presentation snapshot and two broadcast channels, so a long-lived - 340
/// gateway process grew without bound — and because `SessionLog::open` - 341
/// holds an exclusive file lock for the handle's lifetime, every session - 342
/// the daemon ever touched stayed locked against the CLI. - 343
/// - 344
/// Eviction is deliberately conservative: a session is only a candidate - 345
/// when nothing else holds a reference, no SSE client is subscribed, and - 346
/// the runner is not holding the ledger. `/sessions/{id}/attach` re-opens - 347
/// an evicted session from disk, so this is a cache bound, not a - 348
/// lifecycle. - 349
fn evict_idle_sessions(&self) { - 350
let cap = max_live_sessions(); - 351
let mut sessions = self - 352
.sessions - 353
.lock() - 354
.unwrap_or_else(std::sync::PoisonError::into_inner); - 355
if sessions.len() <= cap { - 356
return; - 357
} - 358
let mut idle: Vec<(std::time::Instant, String)> = sessions - 359
.iter() - 360
.filter(|(_, handle)| { - 361
// `events_tx` always carries the handle's own internal - 362
// projector (`register_handle`), so `receiver_count()` can - 363
// never read zero; `external_subscribers()` excludes it. - 364
// `side_events_tx` has no such internal subscriber, so a - 365
// plain `receiver_count()` remains correct there. - 366
Arc::strong_count(handle) == 1 - 367
&& handle.events_tx.external_subscribers() == 0 - 368
&& handle.side_events_tx.receiver_count() == 0 - 369
&& handle.session.lock().is_ok_and(|guard| guard.is_some()) - 370
}) - 371
.filter_map(|(id, handle)| { - 372
let touched = *handle.last_touched.lock().ok()?; - 373
Some((touched, id.clone())) - 374
}) - 375
.collect(); - 376
idle.sort_by_key(|(touched, _)| *touched); - 377
let mut over = sessions.len().saturating_sub(cap); - 378
for (_, id) in idle { - 379
if over == 0 { - 380
break; - 381
} - 382
sessions.remove(&id); - 383
over -= 1; - 384
} - 385
} - 386
- 387
/// Drops a session's live handle, so a trashed session is not served - 388
/// from memory after it leaves every list. - 389
fn forget_session(&self, id: &str) { - 390
self.sessions - 391
.lock() - 392
.unwrap_or_else(std::sync::PoisonError::into_inner) - 393
.remove(id); - 394
} - 395
- 396
/// Snapshot of every live session handle (admin surfaces aggregate - 397
/// across sessions; nothing here crosses a session's approval scope — - 398
/// answering still goes through the per-session endpoint). - 399
pub(crate) fn live_handles(&self) -> Vec<Arc<SessionHandle>> { - 400
self.sessions - 401
.lock() - 402
.unwrap_or_else(std::sync::PoisonError::into_inner) - 403
.values() - 404
.cloned() - 405
.collect() - 406
} - 407
} - 408
- 409
#[cfg(test)] - 410
#[allow(clippy::unwrap_used, clippy::expect_used)] - 411
mod eviction_tests { - 412
use super::*; - 413
- 414
/// Restores the process-global test override on drop, so a panic mid-test - 415
/// cannot leave a tiny cap active for an unrelated concurrent test. - 416
struct RestoreCap; - 417
impl Drop for RestoreCap { - 418
fn drop(&mut self) { - 419
MAX_LIVE_SESSIONS_TEST_OVERRIDE.store(0, std::sync::atomic::Ordering::SeqCst); - 420
} - 421
} - 422
- 423
/// Finding 3: every handle's `events_tx` carries the internal projector - 424
/// subscription from `register_handle`, so the old `receiver_count() == - 425
/// 0` eviction guard never held — a long-lived process kept every - 426
/// session's ledger (and its exclusive file lock) in memory forever. - 427
/// `external_subscribers()` fixes the guard; this proves eviction - 428
/// actually runs once the live set exceeds the cap. - 429
#[tokio::test] - 430
async fn idle_sessions_beyond_the_cap_are_evicted() { - 431
vak_config::paths::isolate_home_for_tests(); - 432
MAX_LIVE_SESSIONS_TEST_OVERRIDE.store(3, std::sync::atomic::Ordering::SeqCst); - 433
let _restore = RestoreCap; - 434
- 435
let dir = tempfile::tempdir().unwrap(); - 436
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 437
core.set_sessions_home(dir.path().join("home")); - 438
let state = AppState::new(core.clone()); - 439
- 440
let mut ids = Vec::new(); - 441
for _ in 0..5 { - 442
let session = core.start_session().await.unwrap(); - 443
let id = session.header().unwrap().session_id.clone(); - 444
// No SSE client, no run in progress, and the returned Arc is - 445
// dropped immediately — exactly the "nothing references it" - 446
// shape `evict_idle_sessions` looks for. - 447
let _ = register_handle( - 448
&state, - 449
id.clone(), - 450
session, - 451
core.cwd().clone(), - 452
core.clone(), - 453
); - 454
ids.push(id); - 455
} - 456
- 457
let live: Vec<String> = state.sessions.lock().unwrap().keys().cloned().collect(); - 458
assert_eq!( - 459
live.len(), - 460
3, - 461
"expected eviction down to the test cap, got {live:?}" - 462
); - 463
// Eviction drops the least-recently-touched handles first, so the - 464
// most recently created session must survive. - 465
assert!( - 466
live.contains(ids.last().unwrap()), - 467
"the newest session must not be evicted: {live:?}" - 468
); - 469
assert!( - 470
!live.contains(&ids[0]), - 471
"the oldest session must be evicted first: {live:?}" - 472
); - 473
} - 474
} - 475
- 476
#[derive(Clone)] - 477
struct CoworkingPresence { - 478
display_name: String, - 479
seen_at: Instant, - 480
office_room_id: Option<String>, - 481
office_anchor: Option<String>, - 482
} - 483
- 484
#[derive(Clone)] - 485
pub struct ApprovalRequest { - 486
pub id: String, - 487
pub tool: String, - 488
pub args_json: String, - 489
pub reason: String, - 490
pub requested_at: chrono::DateTime<chrono::Utc>, - 491
respond: Arc<Mutex<Option<oneshot::Sender<bool>>>>, - 492
answered_by: Arc<Mutex<Option<(String, String)>>>, - 493
delegated_to: Arc<Mutex<Option<String>>>, - 494
} - 495
- 496
impl ApprovalRequest { - 497
pub fn respond(&self, approve: bool) { - 498
if let Some(tx) = self - 499
.respond - 500
.lock() - 501
.unwrap_or_else(std::sync::PoisonError::into_inner) - 502
.take() - 503
{ - 504
let _ = tx.send(approve); - 505
} - 506
} - 507
} - 508
- 509
/// How long an HTTP-surfaced gate waits for a console or desktop client to - 510
/// answer before failing closed. - 511
/// - 512
/// There was no bound at all, which was survivable while every run behind - 513
/// this approver had a human watching an SSE stream — and was not, once the - 514
/// scheduler started firing runs through the same path. An unanswered gate - 515
/// held the session handle open forever, so the routine never completed and - 516
/// its slot never freed. Generous, because a person may genuinely be away - 517
/// from the tab, but finite: a run that fails closed can be retried, and one - 518
/// that hangs cannot. - 519
const HTTP_APPROVAL_TIMEOUT: Duration = Duration::from_secs(900); - 520
- 521
struct HttpApprover { - 522
events_tx: events::EventBus, - 523
pending: Arc<Mutex<HashMap<String, ApprovalRequest>>>, - 524
/// Owning session, so admin-console surfaces can attribute gates. - 525
session_id: String, - 526
activity_buffer: Arc<Mutex<Vec<vak_session::ActivityRecord>>>, - 527
/// False when this run has no client watching — a scheduled routine, a - 528
/// best-of-N leg. The gate is then a foregone denial, and saying so - 529
/// through `answerable()` is what lets `vak_core::reach` drop the - 530
/// capability from the turn instead of letting the model discover it by - 531
/// blocking on a question nobody will read. - 532
answerable: bool, - 533
} - 534
- 535
#[async_trait::async_trait] - 536
impl Approver for HttpApprover { - 537
fn answerable(&self) -> bool { - 538
self.answerable - 539
} - 540
- 541
async fn approve(&self, tool: &str, args_json: &str, reason: &str) -> bool { - 542
if !self.answerable { - 543
return false; - 544
} - 545
let id = uuid::Uuid::now_v7().to_string(); - 546
let (respond, rx) = oneshot::channel(); - 547
let answered_by = Arc::new(Mutex::new(None)); - 548
self.pending - 549
.lock() - 550
.unwrap_or_else(std::sync::PoisonError::into_inner) - 551
.insert( - 552
id.clone(), - 553
ApprovalRequest { - 554
id: id.clone(), - 555
tool: tool.to_string(), - 556
args_json: args_json.to_string(), - 557
reason: reason.to_string(), - 558
requested_at: chrono::Utc::now(), - 559
respond: Arc::new(Mutex::new(Some(respond))), - 560
answered_by: answered_by.clone(), - 561
delegated_to: Arc::new(Mutex::new(None)), - 562
}, - 563
); - 564
let _ = self.events_tx.send(AgentEvent::ApprovalRequested { - 565
id: id.clone(), - 566
tool: tool.to_string(), - 567
args_json: args_json.to_string(), - 568
reason: reason.to_string(), - 569
}); - 570
self.activity_buffer - 571
.lock() - 572
.unwrap_or_else(std::sync::PoisonError::into_inner) - 573
.push(vak_session::ActivityRecord { - 574
activity_id: format!("approval-{id}"), - 575
turn: None, - 576
kind: vak_session::ActivityKind::Approval, - 577
status: vak_session::ActivityStatus::Pending, - 578
label: format!("Approval required for {tool}"), - 579
detail: Some(reason.to_string()), - 580
data: [ - 581
("request_id".into(), id.clone()), - 582
("tool".into(), tool.to_string()), - 583
("args_json".into(), args_json.to_string()), - 584
] - 585
.into(), - 586
}); - 587
if let Some(hub) = events::global() { - 588
hub.emit(events::SystemEvent::ApprovalRequested { - 589
id: id.clone(), - 590
session_id: self.session_id.clone(), - 591
tool: tool.to_string(), - 592
reason: reason.to_string(), - 593
}); - 594
} - 595
// Bounded, and failing closed on expiry — the same contract - 596
// `GatewayApprover` already had. Dropping the entry before returning - 597
// means a reply that arrives after the deadline resolves nothing - 598
// rather than answering a gate the run has already moved past. - 599
let approved = match tokio::time::timeout(HTTP_APPROVAL_TIMEOUT, rx).await { - 600
Ok(answer) => answer.unwrap_or(false), - 601
Err(_) => { - 602
eprintln!( - 603
"[approvals] gate {} for `{tool}` expired after {}s; denied", - 604
&id[..8.min(id.len())], - 605
HTTP_APPROVAL_TIMEOUT.as_secs() - 606
); - 607
false - 608
} - 609
}; - 610
self.pending - 611
.lock() - 612
.unwrap_or_else(std::sync::PoisonError::into_inner) - 613
.remove(&id); - 614
let answered_by = answered_by - 615
.lock() - 616
.unwrap_or_else(std::sync::PoisonError::into_inner) - 617
.clone(); - 618
self.activity_buffer - 619
.lock() - 620
.unwrap_or_else(std::sync::PoisonError::into_inner) - 621
.push(vak_session::ActivityRecord { - 622
activity_id: format!("approval-{id}"), - 623
turn: None, - 624
kind: vak_session::ActivityKind::Approval, - 625
status: if approved { - 626
vak_session::ActivityStatus::Succeeded - 627
} else { - 628
vak_session::ActivityStatus::Denied - 629
}, - 630
label: format!( - 631
"Approval {} for {tool}", - 632
if approved { "granted" } else { "denied" } - 633
), - 634
detail: Some(reason.to_string()), - 635
data: [ - 636
("request_id".into(), id.clone()), - 637
("tool".into(), tool.to_string()), - 638
("args_json".into(), args_json.to_string()), - 639
] - 640
.into_iter() - 641
.chain(answered_by.into_iter().flat_map(|(actor_id, actor_name)| { - 642
[ - 643
("actor_id".into(), actor_id), - 644
("actor_name".into(), actor_name), - 645
] - 646
})) - 647
.collect(), - 648
}); - 649
if let Some(hub) = events::global() { - 650
hub.emit(if approved { - 651
events::SystemEvent::ApprovalGranted { - 652
id: id.clone(), - 653
tool: tool.to_string(), - 654
} - 655
} else { - 656
events::SystemEvent::ApprovalDenied { - 657
id, - 658
tool: tool.to_string(), - 659
} - 660
}); - 661
} - 662
approved - 663
} - 664
} - 665
- 666
/// Resolve the Agent-scoped `Core` for the rest of an async handler body, or - 667
/// return early with `resolve_scoped_core`'s own error `Response` (forwarding - 668
/// its status/message unchanged, e.g. 409 CONFLICT for a paused/archived - 669
/// Agent). Every endpoint scoped this way needs the identical - 670
/// match-and-early-return, so it lives here once instead of copy-pasted at - 671
/// each of the 50+ call sites (see `resolve_scoped_core` below). - 672
macro_rules! scoped_core { - 673
($state:expr, $session_id:expr, $agent:expr) => { - 674
match resolve_scoped_core($state, $session_id, $agent) { - 675
Ok(core) => core, - 676
Err(response) => return response, - 677
} - 678
}; - 679
} - 680
- 681
pub fn router(core: Core) -> Router { - 682
router_with_state(AppState::new(core)) - 683
} - 684
- 685
/// Unauthenticated router with the gateway force-enabled and no background - 686
/// scheduler. For embedders that run their own supervision loop and need - 687
/// clean teardown: dropping this router releases every session lock, - 688
/// whereas `secured_router`'s scheduler pins handles until process exit. - 689
pub fn gateway_router(core: Core) -> Router { - 690
let mut state = AppState::new(core); - 691
state.enable_gateway(); - 692
router_with_state(state) - 693
} - 694
- 695
fn router_with_state(state: AppState) -> Router { - 696
Router::new() - 697
.route("/health", get(health)) - 698
.route("/sessions", get(list_sessions).post(create_session)) - 699
.route("/sessions/{id}/attach", post(attach_session)) - 700
.route("/sessions/{id}/diff", get(session_diff)) - 701
.route("/sessions/{id}/receipts", get(session_receipts)) - 702
.route( - 703
"/sessions/{id}/work", - 704
get(session_work).post(session_work_command), - 705
) - 706
.route("/sessions/{id}/work/confirm", post(session_work_confirm)) - 707
.route("/sessions/{id}/work/revise", post(session_work_revise)) - 708
.route( - 709
"/sessions/{id}/work/items/{item_id}/retry", - 710
post(session_work_retry), - 711
) - 712
.route( - 713
"/sessions/{id}/work/items/{item_id}/cancel", - 714
post(session_work_cancel_item), - 715
) - 716
.route( - 717
"/sessions/{id}/work/items/{item_id}/reassign", - 718
post(session_work_reassign), - 719
) - 720
.route("/flows", get(flows_list)) - 721
.route("/flows/{name}/runs", get(flow_runs_list)) - 722
.route("/flows/{name}/runs/{run}/graph", get(flow_run_graph)) - 723
.route("/sessions/{id}/checkpoints", get(list_checkpoints)) - 724
.route( - 725
"/sessions/{id}/checkpoints/{seq}/restore", - 726
post(restore_checkpoint), - 727
) - 728
.route("/sessions/{id}/archive", post(set_archived)) - 729
.route("/sessions/archived", delete(delete_all_archived)) - 730
.route("/sessions/{id}", delete(delete_session)) - 731
.route("/sessions/{id}/restore", post(restore_session)) - 732
.route("/skills", get(list_skills)) - 733
.route("/commands", get(list_commands)) - 734
.route("/plugins", get(list_plugins)) - 735
.route("/plugins/catalog", get(plugin_catalog)) - 736
.route("/plugins/audit", get(plugin_audit)) - 737
.route("/plugins/invocations", get(plugin_invocations)) - 738
.route( - 739
"/presentations", - 740
get(list_presentations).post(register_presentations), - 741
) - 742
.route( - 743
"/presentations/specs/{id}/{revision}", - 744
get(get_presentation_spec), - 745
) - 746
.route( - 747
"/presentations/revisions", - 748
post(propose_presentation_revision), - 749
) - 750
.route( - 751
"/sessions/{id}/presentation/proposals", - 752
post(propose_session_presentation_revision), - 753
) - 754
.route("/presentations/export", get(export_presentations)) - 755
.route("/presentations/import", post(import_presentations)) - 756
.route( - 757
"/presentations/activate-all", - 758
post(activate_all_presentations), - 759
) - 760
.route( - 761
"/presentations/deactivate-all", - 762
post(deactivate_all_presentations), - 763
) - 764
.route( - 765
"/presentations/{id}/{revision}/activate", - 766
post(activate_presentation), - 767
) - 768
.route( - 769
"/presentations/{id}/deactivate", - 770
post(deactivate_presentation), - 771
) - 772
.route("/presentations/{id}/reset", post(reset_presentation)) - 773
.route( - 774
"/presentations/plugins/{plugin_id}", - 775
delete(revoke_presentations_plugin), - 776
) - 777
.route( - 778
"/plugins/retired", - 779
get(list_retired_plugins).delete(remove_retired_plugins), - 780
) - 781
.route( - 782
"/plugins/sources", - 783
get(plugin_sources).post(plugin_register_source), - 784
) - 785
.route("/plugins/sources/{id}/enable", post(plugin_source_enable)) - 786
.route("/plugins/sources/{id}/disable", post(plugin_source_disable)) - 787
.route("/plugins/keys/{id}/revoke", post(plugin_key_revoke)) - 788
.route("/plugins/keys/{id}/restore", post(plugin_key_restore)) - 789
.route("/plugins/install", post(plugin_install)) - 790
.route("/plugins/update", post(plugin_update)) - 791
.route("/plugins/{name}/enable", post(plugin_enable)) - 792
.route("/plugins/{name}/disable", post(plugin_disable)) - 793
.route("/plugins/{name}/rollback", post(plugin_rollback)) - 794
.route("/plugins/{name}", delete(plugin_remove)) - 795
.route("/sessions/{id}/pr", get(session_pr)) - 796
.route("/sessions/{id}/pr/merge", post(pr_merge)) - 797
.route("/tasks", get(list_tasks).post(create_task)) - 798
.route( - 799
"/tasks/{id}", - 800
axum::routing::patch(patch_task).delete(delete_task), - 801
) - 802
.route("/tasks/{id}/run-now", post(run_task_now)) - 803
.route("/tasks/{id}/retry-delivery", post(retry_task_delivery)) - 804
.route("/sessions/{id}/launch", get(get_launch)) - 805
.route("/sessions/{id}/launch/prepare", post(prepare_launch)) - 806
.route("/sessions/{id}/launch/start", post(start_launch)) - 807
.route("/sessions/{id}/launch/stop", post(stop_launch)) - 808
.route("/sessions/{id}/launch/logs", get(launch_logs)) - 809
.route("/sessions/{id}/run", post(run_prompt)) - 810
.route("/sessions/{id}/steering", post(send_steering)) - 811
.route("/sessions/{id}/cancel", post(cancel_run)) - 812
.route("/sessions/{id}/pause", post(pause_run)) - 813
.route("/sessions/{id}/resume", post(resume_run)) - 814
.route("/sessions/{id}/control-state", get(control_state)) - 815
.route("/sessions/{id}/plan-change", post(plan_change)) - 816
.route("/sessions/{id}/workers", get(list_workers)) - 817
.route("/sessions/{id}/workers/{child}/steer", post(steer_worker)) - 818
.route("/sessions/{id}/workers/{child}/stop", post(stop_worker)) - 819
// Backward-compatible aliases for the old `subagents` route names. - 820
.route("/sessions/{id}/subagents", get(list_workers)) - 821
.route("/sessions/{id}/subagents/{child}/steer", post(steer_worker)) - 822
.route("/sessions/{id}/subagents/{child}/stop", post(stop_worker)) - 823
.route("/sessions/{id}/approvals/{req_id}", post(answer_approval)) - 824
.route("/sessions/{id}/outcome-review", post(record_outcome_review)) - 825
.route("/sessions/{id}/events", get(events_sse)) - 826
.route( - 827
"/sessions/{id}/sandbox/executions", - 828
get(session_sandbox_executions), - 829
) - 830
.route( - 831
"/sessions/{id}/sandbox/records", - 832
get(list_session_sandbox_records), - 833
) - 834
.route( - 835
"/sessions/{id}/sandbox/candidates", - 836
post(export_sandbox_candidate), - 837
) - 838
.route( - 839
"/sessions/{id}/sandbox/candidates/{candidate_id}/files", - 840
get(read_sandbox_candidate_file), - 841
) - 842
.route( - 843
"/sessions/{id}/sandbox/candidates/{candidate_id}/files/raw", - 844
get(read_sandbox_candidate_file_raw), - 845
) - 846
.route( - 847
"/sessions/{id}/sandbox/candidates/{candidate_id}/office-review", - 848
get(read_sandbox_candidate_office_review), - 849
) - 850
.route( - 851
"/sessions/{id}/sandbox/candidates/{candidate_id}/office-narrow", - 852
post(narrow_sandbox_candidate_office), - 853
) - 854
.route( - 855
"/sessions/{id}/sandbox/candidates/{candidate_id}/office", - 856
get(read_sandbox_candidate_office_projection), - 857
) - 858
.route( - 859
"/sessions/{id}/sandbox/candidates/{candidate_id}/comments", - 860
get(list_sandbox_candidate_comments).post(comment_on_sandbox_candidate), - 861
) - 862
.route( - 863
"/sessions/{id}/sandbox/candidates/{candidate_id}/comments/{comment_id}/request-revision", - 864
post(request_revision_from_candidate_comment), - 865
) - 866
.route( - 867
"/sessions/{id}/sandbox/promote", - 868
post(promote_sandbox_candidate), - 869
) - 870
.route( - 871
"/sessions/{id}/sandbox/promotions/{candidate_id}/undo", - 872
post(undo_sandbox_promotion), - 873
) - 874
.route( - 875
"/sessions/{id}/sandbox/promotions/{candidate_id}/checks", - 876
post(run_sandbox_workspace_check), - 877
) - 878
.route("/sessions/{id}/presentation", get(presentation_snapshot)) - 879
.route("/sessions/{id}/results/{result_id}", get(session_result)) - 880
.route( - 881
"/sessions/{id}/presentation/feedback", - 882
post(presentation_feedback), - 883
) - 884
.route( - 885
"/sessions/{id}/presentation/select", - 886
post(select_presentation_for_session), - 887
) - 888
.route( - 889
"/sessions/{id}/presentation/events", - 890
get(presentation_events_sse), - 891
) - 892
.route("/sessions/{id}/transcript", get(transcript)) - 893
.route("/sessions/{id}/transcript.md", get(transcript_markdown)) - 894
.route( - 895
"/sessions/{id}/coworking/invitations", - 896
get(list_coworking_invitations).post(create_coworking_invitation), - 897
) - 898
.route("/sessions/{id}/coworking/me", get(coworking_me)) - 899
.route( - 900
"/sessions/{id}/coworking/presence", - 901
get(coworking_presence), - 902
) - 903
.route( - 904
"/sessions/{id}/coworking/messages", - 905
post(create_coworking_message), - 906
) - 907
.route( - 908
"/sessions/{id}/coworking/approvals", - 909
get(list_coworking_approvals), - 910
) - 911
.route( - 912
"/sessions/{id}/coworking/approvals/{req_id}", - 913
post(answer_coworking_approval), - 914
) - 915
.route( - 916
"/sessions/{id}/coworking/approvals/{req_id}/delegate", - 917
post(delegate_coworking_approval), - 918
) - 919
.route("/sessions/{id}/coworking/updates", get(coworking_updates)) - 920
.route("/sessions/{id}/office-workspaces", get(office_workspace::list).post(office_workspace::create)) - 921
.route("/sessions/{id}/office-workspaces/{room_id}", post(office_workspace::mutate)) - 922
.route("/sessions/{id}/office-workspaces/{room_id}/presence", post(office_workspace::focus)) - 923
.route( - 924
"/sessions/{id}/coworking/invitations/{grant_id}/revoke", - 925
post(revoke_coworking_invitation), - 926
) - 927
.route("/sessions/{id}/side", post(side_chat)) - 928
.route("/sessions/{id}/side/cancel", post(side_cancel_run)) - 929
.route("/sessions/{id}/bestofn", post(start_bestofn)) - 930
.route("/sessions/{id}/keep", post(keep_best_run)) - 931
.route("/sessions/{id}/discard", post(discard_best_run)) - 932
.route("/fs/file", get(read_file).put(write_file)) - 933
.route("/fs/file/raw", get(read_file_raw)) - 934
.route("/fs/office", get(read_office_projection)) - 935
.route( - 936
"/fs/inbox", - 937
post(upload_to_inbox).layer(axum::extract::DefaultBodyLimit::max( - 938
inbox::UPLOAD_MAX_BYTES, - 939
)), - 940
) - 941
.route("/fs/preview/{*path}", get(preview_file)) - 942
.route("/sandbox/records", get(list_sandbox_records)) - 943
.route("/fs/tree", get(fs_tree)) - 944
.route("/config", get(get_config).patch(patch_config)) - 945
.route("/config/intent/evidence", post(patch_evidence_policy)) - 946
.route( - 947
"/config/global", - 948
get(get_global_config_layer).patch(patch_global_config), - 949
) - 950
.route("/config/workspace", get(get_workspace_config_layer)) - 951
.route("/config/project", get(get_workspace_config_layer)) - 952
.route("/config/mode", post(set_permission_mode)) - 953
.route( - 954
"/agent-network/capabilities", - 955
post(agent_network_capability), - 956
) - 957
.route( - 958
"/agent-network/messages", - 959
post(agent_network_send).get(agent_network_receive), - 960
) - 961
.route("/config/mcp", get(get_mcp_servers).put(put_mcp_servers)) - 962
.route( - 963
"/config/mcp/global", - 964
get(get_global_mcp_servers).put(put_global_mcp_servers), - 965
) - 966
.route("/config/integrations", get(get_integration_catalog)) - 967
.route( - 968
"/config/integrations/{id}", - 969
get(get_scoped_integration) - 970
.put(put_scoped_integration) - 971
.delete(delete_scoped_integration), - 972
) - 973
.route("/config/hooks", get(get_hooks).put(put_hooks)) - 974
.route( - 975
"/config/prompts", - 976
get(get_prompt_layer).put(put_prompt_block), - 977
) - 978
.route("/config/prompts/effective", get(get_prompt_effective)) - 979
.route("/config/prompts/preview", post(preview_prompt)) - 980
.route("/config/prompts/roles", get(list_prompt_roles)) - 981
.route("/agents", get(agent_chats::list)) - 982
.route("/agents/{agent}/open", post(agent_chats::open)) - 983
.route("/config/agents", get(get_agents).put(put_agents)) - 984
.route( - 985
"/config/hooks/global", - 986
get(get_global_hooks).put(put_global_hooks), - 987
) - 988
.route( - 989
"/config/key", - 990
put(put_provider_key).delete(delete_provider_key), - 991
) - 992
// Distributed event bus status (vak-bus, docs/design/53). - 993
// Credentials are never returned; only the connection state and - 994
// metrics are exposed. - 995
.route( - 996
"/config/bus", - 997
get(get_bus_config) - 998
.put(put_bus_config) - 999
.delete(delete_bus_config), - 1000
) - 1001
// The approval policy: whether an `Ask` raised on an unattended - 1002
// chat surface reaches a human at all. Read-only everywhere until - 1003
// now, which made `vak_core::reach`'s own printed remedy an action - 1004
// no surface could perform. - 1005
.route( - 1006
"/gateway/approvals", - 1007
get(get_gateway_approvals).put(put_gateway_approvals), - 1008
) - 1009
// The three permission rule lists, as the engine evaluates them. - 1010
.route( - 1011
"/config/permissions", - 1012
get(get_permission_rules).put(put_permission_rules), - 1013
) - 1014
// Multi-bot-per-surface (docs/design/34, multi-bot): a `Bot` is an - 1015
// independent identity, so it gets its own id-addressed routes - 1016
// rather than reusing the one-slot-per-surface ones above. - 1017
.route("/gateway/bots", get(list_bots).post(create_bot)) - 1018
.route( - 1019
"/gateway/bots/{id}", - 1020
axum::routing::patch(update_bot).delete(delete_bot), - 1021
) - 1022
.route( - 1023
"/gateway/bots/{id}/token", - 1024
put(put_bot_id_token).delete(delete_bot_id_token), - 1025
) - 1026
.route("/providers", get(list_providers)) - 1027
.route("/providers/{name}/models", get(discover_models)) - 1028
.route( - 1029
"/providers/{name}/models/availability", - 1030
get(model_availability), - 1031
) - 1032
.route("/providers/{name}/status", get(provider_status)) - 1033
.route("/search", get(search_sessions)) - 1034
.route("/ops/status", get(ops_status)) - 1035
.route("/ops/center", get(operations_center)) - 1036
.route("/ops/actions", get(operations_actions)) - 1037
.route("/ops/incidents", get(operations_incidents)) - 1038
.route("/ops/outbox", get(operations_outbox)) - 1039
.route( - 1040
"/ops/outbox/{job_id}/replay", - 1041
post(replay_operations_outbox), - 1042
) - 1043
.route("/ops/{service}/{action}", post(ops_action)) - 1044
.route("/ops/diagnostics", get(ops_diagnostics)) - 1045
// Activation, explicitly (docs/design/46 D6). Configuration writes - 1046
// never register a service; this is the one action that does. - 1047
.route("/ops/services/activate", post(activate_services)) - 1048
.route("/finops", get(finops_status).patch(patch_finops)) - 1049
.route("/voice/speak", post(voice::voice_speak)) - 1050
.route("/voice/providers", get(voice::voice_providers)) - 1051
.route("/memory", get(list_memory).post(append_memory)) - 1052
.route("/memory/cleanup", post(cleanup_memory)) - 1053
.route("/memory/consolidate", post(consolidate_memory_route)) - 1054
.route( - 1055
"/memory/{note_id}", - 1056
axum::routing::patch(amend_memory_note).delete(forget_memory_note), - 1057
) - 1058
.route( - 1059
"/entities", - 1060
get(list_entities_route).post(upsert_entity_route), - 1061
) - 1062
.route( - 1063
"/entities/{id}", - 1064
get(get_entity_route).delete(delete_entity_route), - 1065
) - 1066
.route("/agents/templates", get(list_agent_templates)) - 1067
.route("/agents/instantiate", post(instantiate_agent_template)) - 1068
.route("/canvas/preview", post(canvas_preview)) - 1069
.route("/intent/explain", get(intent_explain)) - 1070
.route("/intent/policy", get(intent_policy)) - 1071
.route("/commitments", get(list_commitments)) - 1072
.route("/commitments/{id}", get(get_commitment)) - 1073
.route("/commitments/{id}/close", post(close_commitment)) - 1074
.route("/doctor", get(doctor_report)) - 1075
.route("/onboarding", get(onboarding_state)) - 1076
// The composition layer setup needs, and nothing more: every other - 1077
// choice reuses the config, provider, integration, and bot APIs - 1078
// that already exist (doc 46, "API and command design"). - 1079
.route("/onboarding/seed", post(onboarding_seed)) - 1080
.route( - 1081
"/onboarding/workspace-review", - 1082
post(onboarding_workspace_review), - 1083
) - 1084
.route("/onboarding/trust", post(onboarding_trust)) - 1085
.route("/onboarding/first-task", post(onboarding_first_task)) - 1086
// ---- browser auth (docs/design/48-web-client.md §4.3) ----------- - 1087
// - 1088
// ONE login for every browser surface — the workspace client and - 1089
// the operations console share this exchange and this cookie. - 1090
// These replace `/admin/login` and `/admin/logout`, which were - 1091
// removed rather than kept alongside: two endpoints against one - 1092
// cookie is two contracts that must agree forever (invariant 30). - 1093
.route("/auth/login", post(web::login)) - 1094
.route("/auth/logout", post(web::logout)) - 1095
.route("/auth/session", get(web::session_status)) - 1096
// ---- the workspace client's own host surface -------------------- - 1097
.route("/host", get(web::host_info)) - 1098
.route("/stream", get(stream::stream)) - 1099
.route("/workspaces", get(web::list_workspaces)) - 1100
.route("/workspaces/open", post(web::open_workspace)) - 1101
.route("/workspaces/forget", post(web::forget_workspace)) - 1102
.route("/fs/dirs", get(web::list_dirs)) - 1103
.route("/pty", get(web::pty_socket)) - 1104
.route("/voice/session", get(voice::voice_socket)) - 1105
.route("/version", get(web::version)) - 1106
.route("/backup/export", post(backup_export)) - 1107
.route("/backup/import", post(backup_import)) - 1108
.route("/digest", get(digest_report)) - 1109
.route("/inbox", get(inbox_list)) - 1110
.route("/inbox/unread_count", get(inbox_unread_count)) - 1111
.route("/inbox/{id}/ack", post(inbox_ack)) - 1112
.route("/skills/proposals", get(list_proposals_route)) - 1113
.route("/skills/proposals/{id}/promote", post(promote_proposal)) - 1114
.route("/skills/proposals/{id}/reject", post(reject_proposal)) - 1115
.merge(gateway::routes()) - 1116
.merge(feeds::routes()) - 1117
.merge(admin::routes()) - 1118
// Compression is scoped to the three STATIC bundles and nowhere - 1119
// else. These are the big, highly compressible responses — a site - 1120
// page is ~78 KB of inlined CSS and markup, the vendored motion - 1121
// build is 141 KB, and the SPA bundles are larger still — and this - 1122
// product is explicitly built to be reached over a tunnel, where - 1123
// that is the whole first-visit cost. - 1124
// - 1125
// It is NOT applied to the API. `/sessions/:id/events` is - 1126
// server-sent events: a compressor sits between the writer and the - 1127
// socket, and a live transcript that arrives in buffer-sized - 1128
// batches instead of per frame is a worse product than an - 1129
// uncompressed one. Scoping it here rather than at the root is the - 1130
// difference between a smaller page and a laggy agent. - 1131
.merge( - 1132
axum::Router::new() - 1133
.merge(admin_ui::routes()) - 1134
.merge(client_ui::routes()) - 1135
.merge(site::routes()) - 1136
.layer(tower_http::compression::CompressionLayer::new().gzip(true)), - 1137
) - 1138
.with_state(state) - 1139
} - 1140
- 1141
/// Service-control plane over vak-ops: lets TUI/desktop/tray agree on the - 1142
/// same truth (docs/design/28-operations.md). - 1143
fn ops_payload(cfg: &vak_ops::OpsConfig) -> serde_json::Value { - 1144
let st = |svc| vak_ops::status(svc, cfg); - 1145
serde_json::json!({ - 1146
"gateway": { "state": st(vak_ops::Service::Gateway).to_string() }, - 1147
"bridges": { "state": st(vak_ops::Service::Bridges).to_string() }, - 1148
"gateway_healthy": vak_ops::health_ok(cfg), - 1149
}) - 1150
} - 1151
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.