- 1
//! vak-ops: one service-control layer shared by the tray, TUI and desktop. - 2
//! - 3
//! Wraps platform service managers (launchd on macOS, systemd --user on - 4
//! Linux) behind a tiny API so every surface shows the same truth: - 5
//! - 6
//! ```ignore - 7
//! let st = vak_ops::status(Service::Gateway, &OpsConfig::detect()); - 8
//! ``` - 9
//! - 10
//! Design rules (docs/design/28-operations.md): - 11
//! - No daemon of its own: it shells out to the platform manager, which is - 12
//! already keeping the services alive. - 13
//! - Every command is idempotent from the user's point of view — start on a - 14
//! running service is a no-op, stop on a stopped one too. - 15
//! - Health checks are plain HTTP against the gateway's /health. - 16
- 17
use std::fmt; - 18
use std::path::PathBuf; - 19
use std::process::Command; - 20
#[cfg(target_os = "macos")] - 21
use std::sync::OnceLock; - 22
- 23
pub mod services; - 24
pub use services::{ - 25
CommandRunner, Paths, SERVICES, ServiceDef, ServiceRow, ServiceSpec, SyncAction, SyncOutcome, - 26
SystemRunner, bot_service_name, bot_service_specs, configured_bot_service_names, - 27
configured_bot_service_names_all, default_service_names, is_autostart_configured, - 28
is_desktop_autostart_persisted, is_service_autostart_enabled, persist_desktop_autostart, - 29
render_launchd_plist, render_systemd_unit, resolve_specs, restart_bot_unit, services_status, - 30
services_sync, services_uninstall, set_service_autostart, status_specs, sync_bots, sync_specs, - 31
}; - 32
- 33
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 34
pub enum Service { - 35
Gateway, - 36
/// Every chat bridge, across every transport. - 37
/// - 38
/// Deliberately not one variant per surface. A bridge unit belongs to - 39
/// a *bot* (AGENTS.md invariant 23) and a bot names its own transport, - 40
/// so the set of bridges is data in `bots.json`, not a shape in this - 41
/// enum. The variant this replaces was `Telegram` — one transport with - 42
/// a named service while Discord and Slack had none, which made - 43
/// Telegram read as the real one and the others as extras. They are - 44
/// peers; per-transport detail comes from the bot list. - 45
Bridges, - 46
} - 47
- 48
impl Service { - 49
pub fn label(self) -> &'static str { - 50
match self { - 51
Service::Gateway => "Gateway", - 52
Service::Bridges => "Chat bridges", - 53
} - 54
} - 55
- 56
/// The single unit this service is, where it is one. - 57
/// - 58
/// `Telegram` is **not** a unit: a bot owns its own bridge unit - 59
/// (`com.vak.telegram-<id>`), and the bot-id-less `com.vak.telegram` - 60
/// that used to sit here could only ever describe one bot per - 61
/// transport (AGENTS.md invariant 23). `Telegram` is an aggregate view - 62
/// over whatever per-bot units are configured, so it resolves to a - 63
/// list rather than a name — see [`Service::unit_labels`]. - 64
pub fn launchd_label(self) -> Option<&'static str> { - 65
match self { - 66
Service::Gateway => Some("com.vak.gateway"), - 67
Service::Bridges => None, - 68
} - 69
} - 70
- 71
pub fn systemd_unit(self) -> Option<&'static str> { - 72
match self { - 73
Service::Gateway => Some("vak-gateway.service"), - 74
Service::Bridges => None, - 75
} - 76
} - 77
- 78
/// Every unit this service covers right now. One for the gateway; one - 79
/// per configured bot for `Telegram`, which is why this is resolved - 80
/// from `bots.json` on each call rather than being a constant. - 81
pub fn unit_labels(self) -> Vec<String> { - 82
match self { - 83
Service::Gateway => self - 84
.launchd_label() - 85
.map(|l| vec![l.to_string()]) - 86
.unwrap_or_default(), - 87
Service::Bridges => configured_bot_service_names_all(&vak_config::paths::data_home()), - 88
} - 89
} - 90
- 91
/// Systemd counterpart of [`Service::unit_labels`]. - 92
pub fn systemd_units(self) -> Vec<String> { - 93
match self { - 94
Service::Gateway => self - 95
.systemd_unit() - 96
.map(|u| vec![u.to_string()]) - 97
.unwrap_or_default(), - 98
Service::Bridges => self - 99
.unit_labels() - 100
.into_iter() - 101
.map(|name| format!("{name}.service")) - 102
.collect(), - 103
} - 104
} - 105
} - 106
- 107
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 108
pub enum State { - 109
Running, - 110
Stopped, - 111
NotInstalled, - 112
Unknown, - 113
} - 114
- 115
impl fmt::Display for State { - 116
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - 117
f.write_str(match self { - 118
State::Running => "running", - 119
State::Stopped => "stopped", - 120
State::NotInstalled => "not installed", - 121
State::Unknown => "unknown", - 122
}) - 123
} - 124
} - 125
- 126
/// Where the user's service manager lives. - 127
#[derive(Debug, Clone)] - 128
pub struct OpsConfig { - 129
pub port: u16, - 130
} - 131
- 132
impl Default for OpsConfig { - 133
fn default() -> Self { - 134
OpsConfig { port: 8901 } - 135
} - 136
} - 137
- 138
impl OpsConfig { - 139
pub fn detect() -> Self { - 140
OpsConfig { - 141
port: std::env::var("VAK_PORT") - 142
.ok() - 143
.or_else(|| vak_config::get_var("VAK_PORT")) - 144
.and_then(|p| p.parse().ok()) - 145
.unwrap_or(8901), - 146
} - 147
} - 148
- 149
pub fn base_url(&self) -> String { - 150
format!("http://127.0.0.1:{}", self.port) - 151
} - 152
} - 153
- 154
fn run(cmd: &mut Command) -> bool { - 155
cmd.stdout(std::process::Stdio::null()) - 156
.stderr(std::process::Stdio::null()) - 157
.status() - 158
.map(|s| s.success()) - 159
.unwrap_or(false) - 160
} - 161
- 162
pub(crate) fn home() -> PathBuf { - 163
std::env::var_os("HOME") - 164
.map(PathBuf::from) - 165
.unwrap_or_else(|| PathBuf::from("/")) - 166
} - 167
- 168
/// Register the service with the platform manager and start it. On macOS - 169
/// this requires the plist produced by - 170
/// `scripts/install_gateway_service.sh`; on Linux it enables the unit. - 171
pub fn install(service: Service, #[allow(unused_variables)] cfg: &OpsConfig) -> Result<(), String> { - 172
#[cfg(target_os = "macos")] - 173
{ - 174
let labels = service.unit_labels(); - 175
if labels.is_empty() { - 176
return Err(format!( - 177
"{} has no units configured — create a bot, then run `vak self services-sync`", - 178
service.label() - 179
)); - 180
} - 181
for label in &labels { - 182
let plist = home() - 183
.join("Library/LaunchAgents") - 184
.join(format!("{label}.plist")); - 185
if !plist.is_file() { - 186
return Err(format!( - 187
"no plist at {} — run `vak self services-sync`", - 188
plist.display() - 189
)); - 190
} - 191
// Already bootstrapped is fine. - 192
run(Command::new("launchctl").args([ - 193
"bootstrap", - 194
&format!("gui/{}", uid()), - 195
&plist.display().to_string(), - 196
])); - 197
} - 198
start(service, cfg); - 199
Ok(()) - 200
} - 201
#[cfg(not(target_os = "macos"))] - 202
{ - 203
let units = service.systemd_units(); - 204
if units.is_empty() { - 205
return Err(format!( - 206
"{} has no units configured — create a bot, then run `vak self services-sync`", - 207
service.label() - 208
)); - 209
} - 210
for unit in &units { - 211
if !run(Command::new("systemctl").args(["--user", "enable", "--now", unit])) { - 212
return Err(format!( - 213
"unit {unit} not found — run `vak self services-sync`" - 214
)); - 215
} - 216
} - 217
Ok(()) - 218
} - 219
} - 220
- 221
/// Stop and deregister. Files under ~/.vak are never touched. - 222
pub fn uninstall(service: Service, cfg: &OpsConfig) -> Result<(), String> { - 223
stop(service, cfg); - 224
#[cfg(target_os = "macos")] - 225
{ - 226
for label in service.unit_labels() { - 227
let plist = home() - 228
.join("Library/LaunchAgents") - 229
.join(format!("{label}.plist")); - 230
// A unit that is already gone is the desired end state. - 231
match std::fs::remove_file(&plist) { - 232
Ok(()) => {} - 233
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - 234
Err(e) => return Err(format!("remove plist: {e}")), - 235
} - 236
} - 237
} - 238
#[cfg(not(target_os = "macos"))] - 239
{ - 240
for unit in service.systemd_units() { - 241
run(Command::new("systemctl").args(["--user", "disable", &unit])); - 242
} - 243
} - 244
Ok(()) - 245
} - 246
- 247
/// Platform state probe. Falls back to HTTP liveness when the manager has - 248
/// no opinion (service not installed as such). - 249
pub fn status(service: Service, cfg: &OpsConfig) -> State { - 250
let managed = manager_state(service); - 251
if managed == State::Running { - 252
return managed; - 253
} - 254
if service == Service::Gateway && managed == State::NotInstalled && health_ok(cfg) { - 255
return State::Running; - 256
} - 257
managed - 258
} - 259
- 260
fn manager_state(service: Service) -> State { - 261
#[cfg(target_os = "macos")] - 262
{ - 263
let labels = service.unit_labels(); - 264
if labels.is_empty() { - 265
// No units configured is "nothing installed", not "stopped": - 266
// a transport with no bots has nothing that could be running. - 267
return State::NotInstalled; - 268
} - 269
let states: Vec<State> = labels.iter().map(|label| launchd_state(label)).collect(); - 270
if states.iter().all(|state| *state == State::Running) { - 271
State::Running - 272
} else if states.iter().any(|state| *state != State::NotInstalled) { - 273
State::Stopped - 274
} else { - 275
State::NotInstalled - 276
} - 277
} - 278
#[cfg(not(target_os = "macos"))] - 279
{ - 280
let units = service.systemd_units(); - 281
if units.is_empty() { - 282
return State::NotInstalled; - 283
} - 284
let active = units.iter().all(|unit| { - 285
Command::new("systemctl") - 286
.args(["--user", "is-active", "--quiet", unit]) - 287
.status() - 288
.map(|s| s.success()) - 289
.unwrap_or(false) - 290
}); - 291
if active { - 292
State::Running - 293
} else { - 294
let enabled = units.iter().all(|unit| { - 295
Command::new("systemctl") - 296
.args(["--user", "is-enabled", unit]) - 297
.stdout(std::process::Stdio::null()) - 298
.stderr(std::process::Stdio::null()) - 299
.status() - 300
.map(|s| s.success()) - 301
.unwrap_or(false) - 302
}); - 303
if enabled { - 304
State::Stopped - 305
} else { - 306
State::NotInstalled - 307
} - 308
} - 309
} - 310
} - 311
- 312
#[cfg(target_os = "macos")] - 313
fn launchd_state(label: &str) -> State { - 314
let output = Command::new("launchctl") - 315
.args(["print", &format!("gui/{}/{}", uid(), label)]) - 316
.output(); - 317
let Ok(output) = output else { - 318
return State::Unknown; - 319
}; - 320
if !output.status.success() { - 321
return State::NotInstalled; - 322
} - 323
parse_launchd_state(&String::from_utf8_lossy(&output.stdout)) - 324
} - 325
- 326
#[cfg(target_os = "macos")] - 327
fn parse_launchd_state(text: &str) -> State { - 328
if text.lines().any(|line| { - 329
line.trim() - 330
.strip_prefix("pid = ") - 331
.and_then(|pid| pid.parse::<u32>().ok()) - 332
.is_some_and(|pid| pid > 0) - 333
}) { - 334
State::Running - 335
} else { - 336
State::Stopped - 337
} - 338
} - 339
- 340
#[cfg(all(test, target_os = "macos"))] - 341
mod tests { - 342
use super::{State, parse_launchd_state}; - 343
- 344
#[test] - 345
fn launchd_registration_without_pid_is_stopped() { - 346
assert_eq!( - 347
parse_launchd_state("state = waiting\nruns = 3\n"), - 348
State::Stopped - 349
); - 350
} - 351
- 352
#[test] - 353
fn launchd_live_pid_is_running() { - 354
assert_eq!( - 355
parse_launchd_state("state = running\npid = 123\n"), - 356
State::Running - 357
); - 358
} - 359
} - 360
- 361
/// True when the gateway answers /health. - 362
pub fn health_ok(cfg: &OpsConfig) -> bool { - 363
// Blocking call by design: callers are UI threads that want a quick, - 364
// bounded answer. - 365
let url = format!("{}/health", cfg.base_url()); - 366
reqwest::blocking::Client::builder() - 367
.timeout(std::time::Duration::from_secs(2)) - 368
.build() - 369
.and_then(|c| c.get(&url).send().and_then(|r| r.error_for_status())) - 370
.is_ok() - 371
} - 372
- 373
pub fn start(service: Service, _cfg: &OpsConfig) -> bool { - 374
#[cfg(target_os = "macos")] - 375
{ - 376
let labels = service.unit_labels(); - 377
!labels.is_empty() - 378
&& labels.into_iter().all(|label| { - 379
run(Command::new("launchctl").args([ - 380
"kickstart", - 381
"-k", - 382
&format!("gui/{}/{label}", uid()), - 383
])) - 384
}) - 385
} - 386
#[cfg(not(target_os = "macos"))] - 387
{ - 388
let units = service.systemd_units(); - 389
!units.is_empty() - 390
&& units - 391
.into_iter() - 392
.all(|unit| run(Command::new("systemctl").args(["--user", "start", &unit]))) - 393
} - 394
} - 395
- 396
pub fn stop(service: Service, _cfg: &OpsConfig) -> bool { - 397
#[cfg(target_os = "macos")] - 398
{ - 399
let labels = service.unit_labels(); - 400
!labels.is_empty() - 401
&& labels.into_iter().all(|label| { - 402
run(Command::new("launchctl").args(["bootout", &format!("gui/{}/{label}", uid())])) - 403
}) - 404
} - 405
#[cfg(not(target_os = "macos"))] - 406
{ - 407
let units = service.systemd_units(); - 408
!units.is_empty() - 409
&& units - 410
.into_iter() - 411
.all(|unit| run(Command::new("systemctl").args(["--user", "stop", &unit]))) - 412
} - 413
} - 414
- 415
pub fn restart(service: Service, cfg: &OpsConfig) -> bool { - 416
// bootout+bootstrap would lose KeepAlive semantics; kickstart -k IS the - 417
// restart primitive on launchd. - 418
start(service, cfg) - 419
} - 420
- 421
pub fn open_log(service: Service) { - 422
#[cfg(target_os = "macos")] - 423
{ - 424
// Canonical logs home (doc 32): ~/Library/Logs/vak — - 425
// Console.app-visible. Overridden homes keep self-contained logs. - 426
let log = vak_config::paths::logs_dir().join(match service { - 427
Service::Gateway => "gateway.log", - 428
Service::Bridges => "bridges.log", - 429
}); - 430
if let Some(parent) = log.parent() { - 431
std::fs::create_dir_all(parent).ok(); - 432
} - 433
if !log.exists() { - 434
std::fs::write(&log, "").ok(); - 435
} - 436
run(Command::new("open").arg("-t").arg(&log)); - 437
} - 438
#[cfg(not(target_os = "macos"))] - 439
{ - 440
let unit = match service { - 441
Service::Gateway => "vak-gateway", - 442
Service::Bridges => "vak-bridges", - 443
}; - 444
run(Command::new("sh").arg("-c").arg(format!( - 445
"journalctl --user -u {unit} -n 200 --no-pager 2>/dev/null || true" - 446
))); - 447
} - 448
} - 449
- 450
#[cfg(target_os = "macos")] - 451
fn uid() -> String { - 452
static UID: OnceLock<String> = OnceLock::new(); - 453
UID.get_or_init(|| { - 454
Command::new("id") - 455
.arg("-u") - 456
.output() - 457
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - 458
.unwrap_or_else(|_| "501".into()) - 459
}) - 460
.clone() - 461
} - 462
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.