- 57
entries - 58
.iter() - 59
.filter_map(|e| { - 60
Some(ChannelEntry { - 61
key: e["key"].as_str()?.to_string(), - 62
status: e["status"].as_str()?.to_string(), - 63
workspace: e["workspace"].as_str().map(PathBuf::from), - 64
added_at: e["added_at"].as_str().unwrap_or_default().to_string(), - 65
}) - 66
}) - 67
.collect() - 68
}) - 69
.unwrap_or_default() - 70
} - 71
- 72
/// `gateway channels` (docs/design/34 "Lifecycle completeness"): an - 73
/// approved channel whose workspace has moved or been deleted will fail - 74
/// to start a Core on the next inbound message, and a pending request - 75
/// nobody acted on is an onboarding request quietly rotting. Both are - 76
/// caught here, before a user hits them. - 77
pub fn gateway_channels_check(sessions_home: &Path, expiry_days: u64) -> HealthCheck { - 78
let label = GATEWAY_CHANNELS_LABEL.to_string(); - 79
let entries = read_channel_entries(sessions_home); - 80
if entries.is_empty() { - 81
return HealthCheck { - 82
label, - 83
detail: Ok("no channels onboarded".into()), - 84
}; - 85
} - 86
let cutoff = chrono::Utc::now() - chrono::Duration::days(expiry_days as i64); - 87
let mut unreachable = Vec::new(); - 88
let mut expired = Vec::new(); - 89
let mut allowed = 0usize; - 90
let mut pending = 0usize; - 91
let mut denied = 0usize; - 92
for entry in &entries { - 93
match entry.status.as_str() { - 94
"allowed" => { - 95
allowed += 1; - 96
// No workspace = inherits the gateway's own cwd, which the - 97
// "sessions home"/provider checks already cover. - 98
if let Some(ws) = &entry.workspace - 99
&& std::fs::read_dir(ws).is_err() - 100
{ - 101
unreachable.push(format!("{} → {}", entry.key, ws.display())); - 102
} - 103
} - 104
"pending" => { - 105
pending += 1; - 106
if chrono::DateTime::parse_from_rfc3339(&entry.added_at) - 107
.is_ok_and(|ts| ts.with_timezone(&chrono::Utc) < cutoff) - 108
{ - 109
expired.push(entry.key.clone()); - 110
} - 111
} - 112
_ => denied += 1, - 113
} - 114
} - 115
if unreachable.is_empty() && expired.is_empty() { - 116
return HealthCheck { - 117
label, - 118
detail: Ok(format!( - 119
"{allowed} allowed · {pending} pending · {denied} denied" - 120
)), - 121
}; - 122
} - 123
let mut parts = Vec::new(); - 124
if !unreachable.is_empty() { - 125
parts.push(format!("workspace unreachable: {}", unreachable.join(", "))); - 126
} - 127
if !expired.is_empty() { - 128
parts.push(format!("pending >{expiry_days}d: {}", expired.join(", "))); - 129
} - 130
HealthCheck { - 131
label, - 132
detail: Err(parts.join(" · ")), - 133
} - 134
} - 135
- 136
/// `--repair` half of [`gateway_channels_check`]: auto-deny every - 137
/// `pending` entry older than `expiry_days`, stamping `added_by = - 138
/// "expiry"` so it stays visibly distinct from an operator's own deny - 139
/// (never deleted — "why did this stop working" must have an answer). - 140
/// - 141
/// Entries are edited as raw JSON so any field a newer schema adds - 142
/// survives the rewrite untouched. An `allowed` entry with an unreachable - 143
/// workspace is deliberately NOT repaired: re-pointing it is a judgment - 144
/// call, and doctor never guesses at those. - 145
pub fn expire_pending_entries(sessions_home: &Path, expiry_days: u64) -> Vec<String> { - 146
let path = allowlist_path(sessions_home); - 147
let Ok(raw) = std::fs::read_to_string(&path) else { - 148
return Vec::new(); - 149
}; - 150
let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(&raw) else { - 151
return Vec::new(); - 152
}; - 153
let cutoff = chrono::Utc::now() - chrono::Duration::days(expiry_days as i64); - 154
let now = chrono::Utc::now().to_rfc3339(); - 155
let mut denied = Vec::new(); - 156
let Some(entries) = doc["entries"].as_array_mut() else { - 157
return Vec::new(); - 158
}; - 159
for entry in entries.iter_mut() { - 160
let expired = entry["status"].as_str() == Some("pending") - 161
&& entry["added_at"] - 162
.as_str() - 163
.and_then(|ts| chrono::DateTime::parse_from_rfc3339(ts).ok()) - 164
.is_some_and(|ts| ts.with_timezone(&chrono::Utc) < cutoff); - 165
if !expired { - 166
continue; - 167
} - 168
if let Some(key) = entry["key"].as_str() { - 169
denied.push(key.to_string()); - 170
} - 171
entry["status"] = serde_json::Value::String("denied".into()); - 172
entry["added_by"] = serde_json::Value::String("expiry".into()); - 173
entry["added_at"] = serde_json::Value::String(now.clone()); - 174
if let Some(map) = entry.as_object_mut() { - 175
map.remove("first_seen_text"); - 176
map.remove("workspace"); - 177
map.remove("route"); - 178
} - 179
} - 180
if denied.is_empty() { - 181
return denied; - 182
} - 183
// Same temp-file+rename write the gateway itself uses, so a crash - 184
// mid-repair can never leave a half-written allowlist. - 185
if let Ok(json) = serde_json::to_string_pretty(&doc) { - 186
let temp = path.with_extension(format!("json.{}.tmp", std::process::id())); - 187
if std::fs::write(&temp, json).is_ok() { - 188
let _ = std::fs::rename(temp, &path); - 189
} - 190
} - 191
denied - 192
} - 193
- 194
#[derive(Debug, Clone)] - 195
pub struct HealthCheck { - 196
pub label: String, - 197
/// Ok = pass with detail, Err = failure with detail. - 198
pub detail: Result<String, String>, - 199
} - 200
- 201
impl HealthCheck { - 202
fn failed(&self) -> bool { - 203
self.detail.is_err() - 204
} - 205
} - 206
- 207
/// Frozen-ladder section of a session header (Phase B/R), mirrored for - 208
/// surfaces that render doctor output. - 209
#[derive(Debug, Clone)] - 210
pub struct LadderReport { - 211
/// "provider/model" per leg, frozen order. - 212
pub legs: Vec<String>, - 213
/// Human-rendered chain; falls back to the bare model id on legacy - 214
/// headers without a ladder — exactly what the TUI prints today. - 215
pub rendered: String, - 216
pub objective: String, - 217
pub fallback_legs: usize, - 218
pub annotations: Vec<String>, - 219
} - 220
- 221
#[derive(Debug, Clone, Default)] - 222
pub struct HealthReport { - 223
pub checks: Vec<HealthCheck>, - 224
/// Informational lines (model/mode/sandbox, limits, extensions, - 225
/// breaker, finops) that today render as dim status rows. - 226
pub facts: Vec<String>, - 227
pub ladder: Option<LadderReport>, - 228
pub failures: usize, - 229
} - 230
- 231
/// "Self version parity" (docs/design/32-release-engineering.md): the - 232
/// running build vs the installed-release manifest. A missing manifest - 233
/// passes — nothing is managed yet, so nothing can drift. `manifest` is - 234
/// the resolved install manifest path (see [`install::resolve_manifest_path`]), - 235
/// which — unlike a bare `$HOME`-relative join — accounts for the macOS - 236
/// app-bundle layout that `self install` actually writes to. - 237
pub fn version_parity_check(manifest: &Path) -> HealthCheck { - 238
let label = "self version parity".to_string(); - 239
let Ok(text) = std::fs::read_to_string(manifest) else { - 240
return HealthCheck { - 241
label, - 242
detail: Ok("no installed release manifest".into()), - 243
}; - 244
}; - 245
let reported = serde_json::from_str::<serde_json::Value>(&text) - 246
.ok() - 247
.and_then(|v| { - 248
v.get("version") - 249
.and_then(|v| v.as_str()) - 250
.map(str::to_string) - 251
}); - 252
match reported { - 253
Some(v) if v == APP_VERSION => HealthCheck { - 254
label, - 255
detail: Ok(format!("build matches installed {v}")), - 256
}, - 257
Some(v) => HealthCheck { - 258
label, - 259
detail: Err(format!("build {APP_VERSION} != installed {v}")), - 260
}, - 261
None => HealthCheck { - 262
label, - 263
detail: Err(format!("unreadable manifest at {}", manifest.display())), - 264
}, - 265
} - 266
} - 267
- 268
/// `core.provider()` failing only ever means the *effective* provider - 269
/// (whatever `Config::default()` or workspace config currently names — - 270
/// `anthropic` out of the box) lacks a credential. Left as-is, that reads - 271
/// as "you must use Anthropic," which is false: it says so because that - 272
/// happens to be today's built-in default, not because it's the only - 273
/// supported option. This surfaces what else is actually usable right - 274
/// now — any other provider with a real credential already set, plus - 275
/// Ollama, which needs none — so the fix on offer is "point config at - 276
/// what you already have" as often as it is "set a key." - 277
fn missing_provider_detail(core: &Core, base: &str) -> String { - 278
let effective = core.effective_provider(); - 279
let mut usable: Vec<String> = core - 280
.provider_names() - 281
.into_iter() - 282
.filter(|p| p != &effective && core.provider_configured(p)) - 283
.collect(); - 284
usable.sort(); - 285
if usable.is_empty() { - 286
format!( - 287
"{base} — no other provider is configured either; set a credential \ - 288
(ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, \ - 289
OPENCODE_API_KEY) or point config at a local, keyless provider \ - 290
(provider = \"ollama\")" - 291
) - 292
} else { - 293
format!( - 294
"{base} — already usable without changes: {} (switch via `vak config` \ - 295
or the admin console instead of setting a credential for '{effective}')", - 296
usable.join(", ") - 297
) - 298
} - 299
} - 300
- 301
/// Configured integrations the composed policy will refuse. - 302
/// - 303
/// The failure this exists to make visible is a quiet one: an operator - 304
/// configures an MCP server, sees it accepted, and every turn that reaches - 305
/// for it is denied by a layer they were not thinking about — an - 306
/// unattended surface with no approver, a read-only cap, a channel deny. - 307
/// Nothing failed loudly, so the only evidence was a tool call buried in a - 308
/// transcript. `doctor` is where "you configured this and it does not - 309
/// work" belongs. - 310
/// - 311
/// No mechanical repair (AGENTS.md invariant 19): every fix here is a - 312
/// deliberate access decision — widen a rule, or give the surface an - 313
/// approver — and `--repair` must not make either on an operator's behalf. - 314
fn capability_reach_check(core: &Core) -> HealthCheck { - 315
let standings = core.capability_standings(); - 316
let blocked: Vec<&crate::reach::Standing> = standings - 317
.iter() - 318
.filter(|standing| standing.reach.is_blocked()) - 319
.collect(); - 320
HealthCheck { - 321
label: "capability reach".into(), - 322
detail: if blocked.is_empty() { - 323
Ok(format!("{} configured, all reachable", standings.len())) - 324
} else { - 325
Err(blocked - 326
.iter() - 327
.map(|standing| { - 328
format!( - 329
"{} unreachable ({}); fix: {}", - 330
standing.label, standing.reason, standing.remedy - 331
) - 332
}) - 333
.collect::<Vec<_>>() - 334
.join("; ")) - 335
}, - 336
} - 337
} - 338
- 339
/// Every capability that is configured but cannot currently be used, with - 340
/// the reason and the fix. - 341
/// - 342
/// This closes the gap that hid the original defect. `capability_diagnostics` - 343
/// already knew a server was unreachable, but it was rendered only into the - 344
/// system prompt — so the model was told, and the person who could actually - 345
/// repair the configuration was not. An agent answering "I do not have - 346
/// access to live data" while a misconfigured search server sat in - 347
/// `[mcp.servers]` produced no error, no failing check, and nothing in - 348
/// `doctor`. Now the same diagnostics reach both audiences. - 349
/// - 350
/// No mechanical repair (AGENTS.md invariant 19): the fixes here are - 351
/// operator decisions — start a server, correct a command, grant an env var. - 352
fn capability_health_check(core: &Core) -> HealthCheck { - 353
let diagnostics = core.capability_diagnostics(); - 354
// Only genuine breakage fails the check. A hook the operator disabled and - 355
// a skill a channel policy excludes are deliberate, and reporting them as - 356
// failures trains people to scroll past this check — which would cost far - 357
// more than the noise saves, since a silently unreachable MCP server is - 358
// exactly what this exists to surface. Deliberate states are still - 359
// counted, so they are visible without being alarming. - 360
let (broken, chosen): (Vec<_>, Vec<_>) = diagnostics.iter().partition(|d| !d.deliberate); - 361
let describe = |d: &&crate::CapabilityDiagnostic| { - 362
let mut line = format!("{} `{}`: {}", d.kind, d.name, d.reason); - 363
if !d.remedy.is_empty() { - 364
line.push_str(&format!("; fix: {}", d.remedy)); - 365
} - 366
line - 367
}; - 368
HealthCheck { - 369
label: "capability health".into(), - 370
detail: if broken.is_empty() { - 371
Ok(if chosen.is_empty() { - 372
"all configured capabilities usable".into() - 373
} else { - 374
format!( - 375
"all configured capabilities usable ({} deliberately off)", - 376
chosen.len() - 377
) - 378
}) - 379
} else { - 380
Err(broken.iter().map(describe).collect::<Vec<_>>().join("; ")) - 381
}, - 382
} - 383
} - 384
- 385
/// Check whether any installed plugins reference retired tool names - 386
/// (e.g. `python_eval`, `react_preview`). These plugins survived a tool - 387
/// retirement and cause `unknown_capability` errors / model hallucinations. - 388
/// A failed check carries the repair instruction: run `vak setup seed` - 389
/// to auto-remove them, or `vak plugins remove <name>` to target one. - 390
pub fn retired_plugins_check(core: &Core) -> HealthCheck { - 391
let flagged = core.check_retired_plugins(); - 392
let label = "retired plugins".to_string(); - 393
if flagged.is_empty() { - 394
HealthCheck { - 395
label, - 396
detail: Ok("none".into()), - 397
} - 398
} else { - 399
let names: Vec<_> = flagged.iter().map(|(name, _)| name.as_str()).collect(); - 400
let detail = format!( - 401
"{} plugin(s) reference retired tools: {}. Run `vak setup seed` to remove.", - 402
names.len(), - 403
names.join(", ") - 404
); - 405
HealthCheck { - 406
label, - 407
detail: Err(detail), - 408
} - 409
} - 410
} - 411
- 412
/// Check whether agent workspaces are valid and readable. - 413
pub fn agent_roster_check(core: &Core) -> HealthCheck { - 414
let agents_dir = core.shared_data_home().join("agents"); - 415
if !agents_dir.exists() { - 416
return HealthCheck { - 417
label: "agent roster".into(), - 418
detail: Ok("1 agent active (default: vak)".into()), - 419
}; - 420
} - 421
match std::fs::read_dir(&agents_dir) { - 422
Ok(read) => { - 423
let mut names = Vec::new(); - 424
for entry in read.flatten() { - 425
if entry.path().is_dir() - 426
&& let Some(name) = entry.file_name().to_str() - 427
{ - 428
names.push(name.to_string()); - 429
} - 430
} - 431
names.sort(); - 432
if names.is_empty() { - 433
HealthCheck { - 434
label: "agent roster".into(), - 435
detail: Ok("1 agent active (default: vak)".into()), - 436
} - 437
} else { - 438
HealthCheck { - 439
label: "agent roster".into(), - 440
detail: Ok(format!( - 441
"{} agent workspace(s) verified ({})", - 442
names.len(), - 443
names.join(", ") - 444
)), - 445
} - 446
} - 447
} - 448
Err(e) => HealthCheck { - 449
label: "agent roster".into(), - 450
detail: Err(format!("failed to read agents directory: {e}")), - 451
}, - 452
} - 453
} - 454
- 455
/// Collect everything `/doctor` reports. `session` optionally adds the - 456
/// frozen-ladder section for the active session. Never panics; every - 457
/// failure mode lands as a failed check or an empty fact. - 458
pub fn collect(core: &Core, session: Option<&SessionLog>) -> HealthReport { - 459
let mut checks = Vec::new(); - 460
- 461
let provider_detail = match core.provider() { - 462
Ok(p) => Ok(format!("{} ready", p.name())), - 463
Err(e) => Err(missing_provider_detail(core, &e.to_string())), - 464
}; - 465
checks.push(HealthCheck { - 466
label: "provider".into(), - 467
detail: provider_detail, - 468
}); - 469
- 470
let home_ok = std::fs::create_dir_all(core.sessions_home()).is_ok(); - 471
checks.push(HealthCheck { - 472
label: "sessions home".into(), - 473
detail: if home_ok { - 474
Ok(core.sessions_home().display().to_string()) - 475
} else { - 476
Err("not writable".into()) - 477
}, - 478
}); - 479
- 480
let warnings = core.config().warnings.clone(); - 481
checks.push(HealthCheck { - 482
label: "config warnings".into(), - 483
detail: if warnings.is_empty() { - 484
Ok("none".into()) - 485
} else { - 486
Err(warnings.join("; ")) - 487
}, - 488
}); - 489
checks.push(capability_reach_check(core)); - 490
let voice = core.effective_voice(); - 491
checks.push(HealthCheck { - 492
label: "voice configuration".into(), - 493
detail: voice_check(&voice), - 494
}); - 495
for (label, var) in [ - 496
("local transcriber", vak_voice::TRANSCRIBER_VAR), - 497
("local TTS backend", vak_voice::TTS_VAR), - 498
] { - 499
let engine = vak_voice::engine_readiness( - 500
vak_config::get_var(var) - 501
.map(std::path::PathBuf::from) - 502
.as_deref(), - 503
); - 504
checks.push(HealthCheck { - 505
label: label.into(), - 506
// Optional: only a configured engine that cannot run is a failure. - 507
detail: if engine.ready || !engine.configured { - 508
Ok(engine.detail) - 509
} else { - 510
Err(engine.detail) - 511
}, - 512
}); - 513
} - 514
checks.push(capability_health_check(core)); - 515
checks.push(gateway_channels_check( - 516
&core.shared_data_home(), - 517
core.config().gateway.pending_expiry_days, - 518
)); - 519
checks.push(layout_check()); - 520
checks.push(version_parity_check(&install::resolve_manifest_path(None))); - 521
checks.push(retired_plugins_check(core)); - 522
checks.push(agent_roster_check(core)); - 523
let failures = checks.iter().filter(|c| c.failed()).count(); - 524
- 525
let mut facts = vec![ - 526
format!( - 527
"model {} via {} · mode {:?} · sandbox {}", - 528
core.effective_model(), - 529
core.effective_provider(), - 530
core.effective_permission_mode(), - 531
core.effective_sandbox_name(), - 532
), - 533
format!( - 534
"context window {} tokens · max turns {} · retries {} (+{})", - 535
core.config().context_window, - 536
core.effective_max_turns(), - 537
core.config().max_retries, - 538
core.config().run_retry_attempts, - 539
), - 540
format!( - 541
"voice: {} · provider {} · transcription {} · synthesis {} · {}s/session · {} concurrent · {} MiB inbound budget", - 542
if voice.enabled { "on" } else { "off" }, - 543
voice.provider.as_deref().unwrap_or("unset"), - 544
voice.transcription_model.as_deref().unwrap_or("unset"), - 545
voice.synthesis_model.as_deref().unwrap_or("unset"), - 546
voice.max_session_secs, - 547
voice.max_concurrent, - 548
voice.max_audio_bytes / (1024 * 1024), - 549
), - 550
// Counted from the *effective* set, not raw config. - 551
// - 552
// These three used to disagree: skills came from `core.skills()` - 553
// (effective, so plugin contributions counted) while hooks and MCP - 554
// servers came from `core.config()` (raw, so plugin contributions - 555
// and every runtime override were invisible). An operator reading - 556
// `doctor` saw a different world from the one the model was told - 557
// about, which is precisely how a broken integration stayed - 558
// invisible. - 559
format!( - 560
"extensions: {} skills · {} hooks · {} mcp servers · workers {}", - 561
core.skills().len(), - 562
core.effective_hooks().iter().filter(|h| h.enabled).count(), - 563
core.effective_mcp().servers.len(), - 564
if core.config().workers { "on" } else { "off" }, - 565
), - 566
]; - 567
- 568
facts.push(format!( - 569
"circuit breaker: {}", - 570
match core.breaker().check() { - 571
Ok(()) => "closed (provider healthy)".to_string(), - 572
Err(open) => format!( - 573
"OPEN — cooling down {}s after {} failure(s)", - 574
open.remaining_secs, open.failures - 575
), - 576
} - 577
)); - 578
- 579
let cap_suffix = core - 580
.config() - 581
.finops - 582
.max_day_usd - 583
.map(|c| format!(" of ${c:.2} day cap")) - 584
.unwrap_or_default(); - 585
facts.push(format!( - 586
"finops: today ~${:.2}{}", - 587
core.spend_day_usd(), - 588
cap_suffix - 589
)); - 590
- 591
let ladder = session.and_then(|s| s.header()).map(|header| { - 592
let contract = &header.contract; - 593
let legs: Vec<String> = contract - 594
.route_ladder - 595
.iter() - 596
.map(|leg| format!("{}/{}", leg.provider, leg.model)) - 597
.collect(); - 598
let rendered = if legs.is_empty() { - 599
contract.model.clone() - 600
} else { - 601
legs.join(" → ") - 602
}; - 603
LadderReport { - 604
rendered, - 605
objective: contract.route_objective.clone(), - 606
fallback_legs: legs.len().saturating_sub(1), - 607
annotations: contract.route_annotations.clone(), - 608
legs, - 609
} - 610
}); - 611
- 612
HealthReport { - 613
checks, - 614
facts, - 615
ladder, - 616
failures, - 617
} - 618
} - 619
- 620
/// Whether the effective voice route could serve a request right now: - 621
/// valid limits, a known provider, its credential, and its model pins. - 622
fn voice_check(voice: &vak_config::VoiceSettings) -> Result<String, String> { - 623
voice.validate()?; - 624
if !voice.enabled { - 625
return Ok("disabled".into()); - 626
} - 627
let provider = vak_voice::VoiceProvider::resolve(voice.provider.as_deref())?; - 628
if provider == vak_voice::VoiceProvider::Local { - 629
return if vak_config::get_var(vak_voice::TRANSCRIBER_VAR).is_some() { - 630
Ok("enabled · local".into()) - 631
} else { - 632
Err(format!( - 633
"enabled with the local provider, but {} is not set", - 634
vak_voice::TRANSCRIBER_VAR - 635
)) - 636
}; - 637
} - 638
let has_credential = provider - 639
.credential_vars() - 640
.iter() - 641
.any(|name| vak_config::get_var(name).is_some_and(|value| !value.trim().is_empty())); - 642
if !has_credential { - 643
return Err(format!( - 644
"enabled with {provider}, but no credential ({}) is configured; add the key in Settings", - 645
provider.credential_vars().join(" or ") - 646
)); - 647
} - 648
let missing: Vec<_> = [ - 649
("transcription", &voice.transcription_model), - 650
("synthesis", &voice.synthesis_model), - 651
] - 652
.into_iter() - 653
.filter(|(_, model)| model.as_deref().is_none_or(|m| m.trim().is_empty())) - 654
.map(|(operation, _)| operation) - 655
.collect(); - 656
if !missing.is_empty() { - 657
return Err(format!( - 658
"enabled with {provider}, but no {} model is chosen; pick one in Voice settings", - 659
missing.join(" or ") - 660
)); - 661
} - 662
Ok(format!("enabled · {provider}")) - 663
} - 664
- 665
#[cfg(test)] - 666
mod tests { - 667
#![allow(clippy::unwrap_used, clippy::expect_used)] - 668
use super::*; - 669
- 670
fn write_allowlist(home: &Path, entries: serde_json::Value) { - 671
let path = allowlist_path(home); - 672
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 673
std::fs::write( - 674
path, - 675
serde_json::json!({ "schema": 1, "entries": entries }).to_string(), - 676
) - 677
.unwrap(); - 678
} - 679
- 680
fn ago(days: i64) -> String { - 681
(chrono::Utc::now() - chrono::Duration::days(days)).to_rfc3339() - 682
} - 683
- 684
#[test] - 685
fn gateway_channels_passes_with_no_store_at_all() { - 686
let home = tempfile::tempdir().unwrap(); - 687
let check = gateway_channels_check(home.path(), 7); - 688
assert_eq!(check.detail.unwrap(), "no channels onboarded"); - 689
} - 690
- 691
#[test] - 692
fn gateway_channels_pass_detail_is_counts() { - 693
let home = tempfile::tempdir().unwrap(); - 694
let workspace = tempfile::tempdir().unwrap(); - 695
write_allowlist( - 696
home.path(), - 697
serde_json::json!([ - 698
{ "key": "telegram:1", "status": "allowed", "workspace": workspace.path(), "added_at": ago(1), "added_by": "admin" }, - 699
{ "key": "slack:C1", "status": "pending", "added_at": ago(1), "added_by": "gateway" }, - 700
{ "key": "discord:9", "status": "denied", "added_at": ago(1), "added_by": "admin" }, - 701
]), - 702
); - 703
let detail = gateway_channels_check(home.path(), 7).detail.unwrap(); - 704
assert_eq!(detail, "1 allowed \u{b7} 1 pending \u{b7} 1 denied"); - 705
} - 706
- 707
#[test] - 708
fn gateway_channels_fails_and_names_an_unreachable_workspace() { - 709
let home = tempfile::tempdir().unwrap(); - 710
write_allowlist( - 711
home.path(), - 712
serde_json::json!([ - 713
{ "key": "telegram:1", "status": "allowed", "workspace": "/definitely/not/here", "added_at": ago(1), "added_by": "admin" }, - 714
]), - 715
); - 716
let detail = gateway_channels_check(home.path(), 7).detail.unwrap_err(); - 717
// Naming the key is the point: doctor output has to be actionable - 718
// without a separate admin-console trip. - 719
assert!(detail.contains("telegram:1"), "{detail}"); - 720
assert!(detail.contains("/definitely/not/here"), "{detail}"); - 721
} - 722
- 723
#[test] - 724
fn gateway_channels_fails_on_a_pending_entry_past_the_expiry_window() { - 725
let home = tempfile::tempdir().unwrap(); - 726
write_allowlist( - 727
home.path(), - 728
serde_json::json!([ - 729
{ "key": "slack:C9", "status": "pending", "added_at": ago(30), "added_by": "gateway" }, - 730
]), - 731
); - 732
let detail = gateway_channels_check(home.path(), 7).detail.unwrap_err(); - 733
assert!(detail.contains("slack:C9"), "{detail}"); - 734
// A longer window makes the same entry healthy - the window is - 735
// config, not a hardcoded 7. - 736
assert!(gateway_channels_check(home.path(), 90).detail.is_ok()); - 737
} - 738
- 739
#[test] - 740
fn a_corrupt_timestamp_never_counts_as_expired() { - 741
let home = tempfile::tempdir().unwrap(); - 742
write_allowlist( - 743
home.path(), - 744
serde_json::json!([ - 745
{ "key": "slack:C9", "status": "pending", "added_at": "not-a-date", "added_by": "gateway" }, - 746
]), - 747
); - 748
assert!(gateway_channels_check(home.path(), 7).detail.is_ok()); - 749
assert!(expire_pending_entries(home.path(), 7).is_empty()); - 750
} - 751
- 752
#[test] - 753
fn repair_auto_denies_expired_pending_entries_visibly() { - 754
let home = tempfile::tempdir().unwrap(); - 755
write_allowlist( - 756
home.path(), - 757
serde_json::json!([ - 758
{ "key": "slack:C9", "status": "pending", "added_at": ago(30), "added_by": "gateway", "first_seen_text": "hi" }, - 759
{ "key": "slack:C1", "status": "pending", "added_at": ago(1), "added_by": "gateway" }, - 760
]), - 761
); - 762
let denied = expire_pending_entries(home.path(), 7); - 763
assert_eq!(denied, vec!["slack:C9".to_string()]); - 764
- 765
let entries = read_channel_entries(home.path()); - 766
let expired = entries.iter().find(|e| e.key == "slack:C9").unwrap(); - 767
// Denied, not deleted: "why did this stop working" must have an - 768
// answer, and `added_by` distinguishes it from an operator's deny. - 769
assert_eq!(expired.status, "denied"); - 770
let raw = std::fs::read_to_string(allowlist_path(home.path())).unwrap(); - 771
assert!(raw.contains("\"added_by\": \"expiry\""), "{raw}"); - 772
// The fresh request is untouched. - 773
let fresh = entries.iter().find(|e| e.key == "slack:C1").unwrap(); - 774
assert_eq!(fresh.status, "pending"); - 775
// And the check now passes. - 776
assert!(gateway_channels_check(home.path(), 7).detail.is_ok()); - 777
} - 778
- 779
#[test] - 780
fn repair_leaves_an_unreachable_workspace_for_the_operator() { - 781
let home = tempfile::tempdir().unwrap(); - 782
write_allowlist( - 783
home.path(), - 784
serde_json::json!([ - 785
{ "key": "telegram:1", "status": "allowed", "workspace": "/definitely/not/here", "added_at": ago(1), "added_by": "admin" }, - 786
]), - 787
); - 788
assert!(expire_pending_entries(home.path(), 7).is_empty()); - 789
// Re-pointing is a judgment call; the failure must survive repair. - 790
assert!(gateway_channels_check(home.path(), 7).detail.is_err()); - 791
} - 792
- 793
#[test] - 794
fn report_mirrors_tui_doctor_shape() { - 795
let dir = tempfile::tempdir().unwrap(); - 796
let home = tempfile::tempdir().unwrap(); - 797
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 798
core.set_sessions_home(home.path().to_path_buf()); - 799
- 800
let report = collect(&core, None); - 801
- 802
assert_eq!( - 803
report - 804
.checks - 805
.iter() - 806
.map(|c| c.label.as_str()) - 807
.collect::<Vec<_>>(), - 808
vec![ - 809
"provider", - 810
"sessions home", - 811
"config warnings", - 812
"capability reach", - 813
"voice configuration", - 814
"local transcriber", - 815
"local TTS backend", - 816
"capability health", - 817
"gateway channels", - 818
"install layout", - 819
"self version parity", - 820
"retired plugins", - 821
"agent roster", - 822
] - 823
); - 824
assert_eq!( - 825
report.failures, - 826
report.checks.iter().filter(|c| c.failed()).count() - 827
); - 828
// Sessions home points at the override and is writable. - 829
let home_check = &report.checks[1]; - 830
assert!(home_check.detail.is_ok()); - 831
assert_eq!( - 832
home_check.detail.as_ref().ok(), - 833
Some(&core.sessions_home().display().to_string()) - 834
); - 835
// Default config carries no warnings and no active session ladder. - 836
assert!(report.checks.iter().any(|c| c.label == "config warnings")); - 837
assert!(report.ladder.is_none()); - 838
- 839
// Facts cover the five dim lines the TUI prints. - 840
assert!(report.facts.iter().any(|f| f.starts_with("model "))); - 841
assert!( - 842
report - 843
.facts - 844
.iter() - 845
.any(|f| f.starts_with("context window ")) - 846
); - 847
assert!(report.facts.iter().any(|f| f.starts_with("extensions: "))); - 848
assert!( - 849
report - 850
.facts - 851
.iter() - 852
.any(|f| f.starts_with("circuit breaker: closed")) - 853
); - 854
assert!( - 855
report - 856
.facts - 857
.iter() - 858
.any(|f| f.starts_with("finops: today ~$")) - 859
); - 860
- 861
// webfetch registration is part of the tool surface doctor implies. - 862
assert!(core.tool_names().contains(&"webfetch".to_string())); - 863
} - 864
- 865
#[test] - 866
fn doctor_reports_voice_disabled_without_treating_it_as_failure() { - 867
let workspace = tempfile::tempdir().unwrap(); - 868
let home = tempfile::tempdir().unwrap(); - 869
let core = Core::new(workspace.path().to_path_buf()).unwrap(); - 870
core.set_sessions_home(home.path().to_path_buf()); - 871
let mut voice = core.effective_voice(); - 872
voice.enabled = false; - 873
core.apply_persisted_voice(voice); - 874
- 875
let report = collect(&core, None); - 876
let check = report - 877
.checks - 878
.iter() - 879
.find(|check| check.label == "voice configuration") - 880
.expect("voice doctor check"); - 881
assert_eq!(check.detail.as_deref(), Ok("disabled")); - 882
assert!( - 883
report - 884
.facts - 885
.iter() - 886
.any(|fact| fact.starts_with("voice: off")) - 887
); - 888
} - 889
- 890
#[test] - 891
fn enabled_voice_without_a_route_is_a_failure_with_a_remedy() { - 892
let mut voice = vak_config::VoiceSettings { - 893
enabled: true, - 894
..Default::default() - 895
}; - 896
assert!( - 897
voice_check(&voice) - 898
.unwrap_err() - 899
.contains("Choose a voice provider") - 900
); - 901
voice.provider = Some("google".into()); - 902
assert!( - 903
voice_check(&voice) - 904
.unwrap_err() - 905
.contains("unknown voice provider") - 906
); - 907
voice.max_concurrent = 0; - 908
assert!(voice_check(&voice).is_err()); - 909
} - 910
- 911
#[test] - 912
fn parity_passes_without_manifest() { - 913
let home = tempfile::tempdir().unwrap(); - 914
let manifest = home.path().join("install.json"); - 915
let check = version_parity_check(&manifest); - 916
assert_eq!(check.label, "self version parity"); - 917
assert!(check.detail.is_ok()); - 918
} - 919
- 920
#[test] - 921
fn parity_matches_installed_and_flags_drift() { - 922
let home = tempfile::tempdir().unwrap(); - 923
let manifest = home.path().join("install.json"); - 924
- 925
std::fs::write( - 926
&manifest, - 927
format!(r#"{{"version":"{APP_VERSION}","git_sha":"deadbeef"}}"#), - 928
) - 929
.unwrap(); - 930
let ok = version_parity_check(&manifest); - 931
assert!(ok.detail.is_ok()); - 932
- 933
std::fs::write(&manifest, r#"{"version":"0.0.9-legacy"}"#).unwrap(); - 934
let drifted = version_parity_check(&manifest); - 935
assert_eq!( - 936
drifted.detail.as_ref().err(), - 937
Some(&format!("build {APP_VERSION} != installed 0.0.9-legacy")) - 938
); - 939
- 940
std::fs::write(&manifest, "not json").unwrap(); - 941
assert!(version_parity_check(&manifest).detail.is_err()); - 942
} - 943
- 944
#[test] - 945
fn resolve_manifest_path_uses_bundle_layout_on_macos() { - 946
// Guards the bug this module exists to fix: health::collect must - 947
// resolve the same manifest path `self install` actually writes - 948
// to, not a hardcoded Linux-style join. - 949
let manifest = - 950
install::resolve_manifest_path(Some(PathBuf::from("/Applications/Vakyartha.app"))); - 951
assert_eq!( - 952
manifest, - 953
PathBuf::from("/Applications/Vakyartha.app/Contents/Resources/install.json") - 954
); - 955
} - 956
- 957
#[test] - 958
fn ladder_section_reflects_frozen_contract() { - 959
use vak_session::types::{FrozenContract, SessionHeader}; - 960
let dir = tempfile::tempdir().unwrap(); - 961
let home = tempfile::tempdir().unwrap(); - 962
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 963
core.set_sessions_home(home.path().to_path_buf()); - 964
- 965
let header = SessionHeader { - 966
agent: None, - 967
session_id: "s-health".into(), - 968
created_at: chrono::Utc::now(), - 969
cwd: dir.path().to_path_buf(), - 970
parent_session_id: None, - 971
contract_id: None, - 972
work_item_id: None, - 973
conversation: None, - 974
contract: FrozenContract { - 975
app_version: "0.0.0-test".into(), - 976
provider: "anthropic".into(), - 977
model: "claude-sonnet-4-5".into(), - 978
route_ladder: vec![ - 979
vak_llm::RouteLeg { - 980
provider: "anthropic".into(), - 981
model: "claude-sonnet-4-5".into(), - 982
dialect: vak_llm::EndpointDialect::AnthropicMessages, - 983
credential_id: None, - 984
}, - 985
vak_llm::RouteLeg { - 986
provider: "openai".into(), - 987
model: "gpt-fallback".into(), - 988
dialect: vak_llm::EndpointDialect::Responses, - 989
credential_id: None, - 990
}, - 991
], - 992
route_objective: "balanced".into(), - 993
route_annotations: vec!["thin primary evidence".into()], - 994
system_prompt: String::new(), - 995
permission_mode: "workspace-write".into(), - 996
capabilities: Vec::new(), - 997
prompt_layers: Vec::new(), - 998
}, - 999
}; - 1000
let log = vak_session::SessionLog::create( - 1001
vak_session::SessionPath::new_session_file(home.path(), dir.path(), &header.session_id), - 1002
header, - 1003
) - 1004
.unwrap(); - 1005
- 1006
let report = collect(&core, Some(&log)); - 1007
let ladder = report.ladder.expect("session header must yield a ladder"); - 1008
assert_eq!( - 1009
ladder.rendered, - 1010
"anthropic/claude-sonnet-4-5 → openai/gpt-fallback" - 1011
); - 1012
assert_eq!(ladder.objective, "balanced"); - 1013
assert_eq!(ladder.fallback_legs, 1); - 1014
assert_eq!( - 1015
ladder.annotations, - 1016
vec!["thin primary evidence".to_string()] - 1017
); - 1018
assert_eq!( - 1019
report.failures, - 1020
report.checks.iter().filter(|c| c.failed()).count() - 1021
); - 1022
} - 1023
- 1024
#[test] - 1025
fn agent_roster_check_discovers_configured_agents() { - 1026
let dir = tempfile::tempdir().unwrap(); - 1027
let home = tempfile::tempdir().unwrap(); - 1028
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 1029
core.set_sessions_home(home.path().to_path_buf()); - 1030
- 1031
let agent1 = home.path().join("agents").join("researcher"); - 1032
let agent2 = home.path().join("agents").join("writer"); - 1033
std::fs::create_dir_all(&agent1).unwrap(); - 1034
std::fs::create_dir_all(&agent2).unwrap(); - 1035
- 1036
let check = agent_roster_check(&core); - 1037
assert!(!check.failed()); - 1038
let detail = check.detail.expect("should pass"); - 1039
assert!(detail.contains("2 agent workspace(s) verified")); - 1040
assert!(detail.contains("researcher")); - 1041
assert!(detail.contains("writer")); - 1042
} - 1043
} - 1044
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.