- 1
//! Scheduler upgrade behaviors (docs/design/29-personal-os.md P2): cron - 2
//! schedules with startup catch-up, zero-token watchdog script tasks over - 3
//! the brokered bash path, per-task model pinning verified through work - 4
//! receipts, and once-per-window budget alerts. - 5
- 6
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 7
- 8
use std::io::BufRead; - 9
use std::path::{Path, PathBuf}; - 10
use std::sync::{ - 11
Arc, - 12
atomic::{AtomicUsize, Ordering}, - 13
}; - 14
- 15
use tokio_util::sync::CancellationToken; - 16
- 17
use vak_core::Core; - 18
use vak_llm::stream; - 19
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, Usage}; - 20
use vak_llm::{EventStream, LlmError, Provider}; - 21
- 22
struct Counting { - 23
dispatches: Arc<AtomicUsize>, - 24
} - 25
- 26
#[async_trait::async_trait] - 27
impl Provider for Counting { - 28
fn name(&self) -> &str { - 29
"counting" - 30
} - 31
- 32
async fn stream( - 33
&self, - 34
_request: ChatRequest, - 35
_cancel: CancellationToken, - 36
) -> Result<EventStream, LlmError> { - 37
self.dispatches.fetch_add(1, Ordering::SeqCst); - 38
let (mut sink, rx) = stream::channel(8); - 39
let done = AssistantMessage { - 40
content: vec![ContentBlock::text("done")], - 41
stop_reason: vak_llm::types::StopReason::EndTurn, - 42
usage: Usage { - 43
input_tokens: 9, - 44
output_tokens: 1, - 45
..Default::default() - 46
}, - 47
model: "counted-model".into(), - 48
response_id: None, - 49
}; - 50
sink.push(stream::StreamEvent::Start { - 51
partial: done.clone(), - 52
}); - 53
sink.close_message(done).await; - 54
Ok(rx) - 55
} - 56
} - 57
- 58
struct Srv { - 59
base: String, - 60
token: String, - 61
home: PathBuf, - 62
dispatches: Arc<AtomicUsize>, - 63
} - 64
- 65
impl Srv { - 66
fn client(&self) -> reqwest::Client { - 67
reqwest::ClientBuilder::new() - 68
.default_headers({ - 69
let mut h = reqwest::header::HeaderMap::new(); - 70
h.insert( - 71
reqwest::header::AUTHORIZATION, - 72
format!("Bearer {}", self.token).parse().unwrap(), - 73
); - 74
h - 75
}) - 76
.build() - 77
.unwrap() - 78
} - 79
- 80
async fn get_json(&self, path: &str) -> serde_json::Value { - 81
self.client() - 82
.get(format!("{}{path}", self.base)) - 83
.send() - 84
.await - 85
.unwrap() - 86
.json() - 87
.await - 88
.unwrap() - 89
} - 90
} - 91
- 92
fn git_seed(cwd: &Path) { - 93
let run = |args: &[&str]| { - 94
let out = std::process::Command::new("git") - 95
.args(args) - 96
.current_dir(cwd) - 97
.env("GIT_AUTHOR_NAME", "t") - 98
.env("GIT_AUTHOR_EMAIL", "t@t") - 99
.env("GIT_COMMITTER_NAME", "t") - 100
.env("GIT_COMMITTER_EMAIL", "t@t") - 101
.output() - 102
.unwrap(); - 103
assert!(out.status.success(), "git {args:?} failed"); - 104
}; - 105
run(&["init", "-q"]); - 106
std::fs::write(cwd.join("README.md"), "seed\n").unwrap(); - 107
run(&["add", "."]); - 108
run(&["commit", "-q", "-m", "seed"]); - 109
} - 110
- 111
struct Fixture { - 112
srv: Srv, - 113
#[allow(dead_code)] - 114
dir: Arc<tempfile::TempDir>, - 115
} - 116
- 117
/// Build a hermetic git workspace + home and start the FULL stack (bearer - 118
/// auth + scheduler). `seed_tasks` receives `(home, ws)` BEFORE the server - 119
/// starts — writing `<home>/tasks.json` there is the simulated-downtime - 120
/// injection point, since the scheduler loads it once at startup. - 121
async fn spawn_full( - 122
config_toml: &str, - 123
seed_tasks: Option<impl for<'a> FnOnce(&'a Path, &'a Path) -> serde_json::Value>, - 124
) -> Fixture { - 125
let dir = Arc::new(tempfile::tempdir().unwrap()); - 126
let ws = dir.path().join("ws"); - 127
std::fs::create_dir_all(ws.join(".vak")).unwrap(); - 128
std::fs::write( - 129
ws.join(".vak/config.toml"), - 130
format!("[memory]\nreflection = false\n{config_toml}"), - 131
) - 132
.unwrap(); - 133
git_seed(&ws); - 134
- 135
let home = dir.path().join("home"); - 136
if let Some(build) = seed_tasks { - 137
std::fs::create_dir_all(&home).unwrap(); - 138
std::fs::write( - 139
vak_core::tasks::tasks_file(&home), - 140
serde_json::to_string_pretty(&build(&home, &ws)).unwrap(), - 141
) - 142
.unwrap(); - 143
} - 144
- 145
let dispatches = Arc::new(AtomicUsize::new(0)); - 146
vak_config::paths::isolate_home_for_tests(); - 147
let core = Core::new_with_trust(ws.clone(), true).unwrap(); - 148
core.set_sessions_home(home.clone()); - 149
core.set_permission_mode(vak_config::PermissionMode::FullAccess); - 150
// A REAL worker executable: a cargo test harness cannot speak the - 151
// broker protocol. - 152
core.set_tool_worker_exe(PathBuf::from(env!("CARGO_BIN_EXE_vak-tool-worker"))); - 153
core.set_provider_instance(Arc::new(Counting { - 154
dispatches: dispatches.clone(), - 155
})); - 156
- 157
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 158
let addr = listener.local_addr().unwrap(); - 159
let (app, token) = vak_server::secured_router_with(core, false); - 160
tokio::spawn(async move { - 161
axum::serve(listener, app).await.unwrap(); - 162
}); - 163
Fixture { - 164
srv: Srv { - 165
base: format!("http://{addr}"), - 166
token, - 167
home, - 168
dispatches, - 169
}, - 170
dir, - 171
} - 172
} - 173
- 174
fn delivery_lines(home: &Path) -> Vec<(String, String)> { - 175
let Ok(f) = std::fs::File::open(home.join("gateway").join("deliveries.jsonl")) else { - 176
return Vec::new(); - 177
}; - 178
let mut out = Vec::new(); - 179
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) { - 180
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) { - 181
out.push(( - 182
v["target"].as_str().unwrap_or_default().to_string(), - 183
v["text"].as_str().unwrap_or_default().to_string(), - 184
)); - 185
} - 186
} - 187
out - 188
} - 189
- 190
async fn wait_until(secs: u64, mut pred: impl FnMut() -> bool) -> bool { - 191
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs); - 192
while std::time::Instant::now() < deadline { - 193
if pred() { - 194
return true; - 195
} - 196
tokio::time::sleep(std::time::Duration::from_millis(150)).await; - 197
} - 198
pred() - 199
} - 200
- 201
async fn task_field(srv: &Srv, id: &str, field: &str) -> serde_json::Value { - 202
let body = srv.get_json("/tasks").await; - 203
body["tasks"] - 204
.as_array() - 205
.unwrap() - 206
.iter() - 207
.find(|t| t["id"] == id) - 208
.map(|t| t[field].clone()) - 209
.unwrap_or(serde_json::Value::Null) - 210
} - 211
- 212
async fn wait_summary(srv: &Srv, id: &str, secs: u64, pred: impl Fn(&str) -> bool) { - 213
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs); - 214
loop { - 215
assert!( - 216
std::time::Instant::now() < deadline, - 217
"task '{id}' summary never matched" - 218
); - 219
let v = task_field(srv, id, "last_summary").await; - 220
let matched = match v.as_str() { - 221
Some(s) => pred(s), - 222
None => false, - 223
}; - 224
if matched { - 225
return; - 226
} - 227
tokio::time::sleep(std::time::Duration::from_millis(150)).await; - 228
} - 229
} - 230
- 231
fn stale_script_task( - 232
id: &str, - 233
name: &str, - 234
ws: &Path, - 235
deliver_to: &str, - 236
script: &str, - 237
) -> serde_json::Value { - 238
serde_json::json!({ - 239
"id": id, - 240
"name": name, - 241
"prompt": "", - 242
"interval_secs": 3600, - 243
"enabled": true, - 244
"cwd": ws.display().to_string(), - 245
"created_at": chrono::Utc::now().to_rfc3339(), - 246
// Two hours of downtime: every "* * * * *" slot since this instant - 247
// was missed. - 248
"last_run_at": (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339(), - 249
"last_session_id": null, - 250
"last_summary": null, - 251
"last_wt": null, - 252
"deliver_to": deliver_to, - 253
"schedule": "* * * * *", - 254
"script": script, - 255
"model_pin": null - 256
}) - 257
} - 258
- 259
// ---- Catch-up ----------------------------------------------------------------- - 260
- 261
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 262
async fn catch_up_fires_missed_cron_slot_exactly_once_with_zero_dispatch() { - 263
let fx = spawn_full( - 264
"", - 265
Some(|_home: &Path, ws: &Path| { - 266
serde_json::json!([stale_script_task( - 267
"cu-1", - 268
"nightly-watch", - 269
ws, - 270
"log:cu", - 271
"echo caught-up-42" - 272
)]) - 273
}), - 274
) - 275
.await; - 276
- 277
// The missed slot fires immediately at startup, stdout verbatim. - 278
assert!( - 279
wait_until(10, || delivery_lines(&fx.srv.home) - 280
.iter() - 281
.any(|(t, x)| t == "log:cu" && x.contains("caught-up-42"))) - 282
.await, - 283
"catch-up never delivered" - 284
); - 285
// Exactly once: no duplicate delivery after things settle. - 286
tokio::time::sleep(std::time::Duration::from_millis(1200)).await; - 287
let hits = delivery_lines(&fx.srv.home) - 288
.iter() - 289
.filter(|(_, x)| x.contains("caught-up-42")) - 290
.count(); - 291
assert_eq!(hits, 1, "catch-up must fire exactly once"); - 292
- 293
// Watchdogs never touch the LLM: zero provider dispatches overall. - 294
assert_eq!(fx.srv.dispatches.load(Ordering::SeqCst), 0); - 295
- 296
// The record moved forward (no longer two hours stale). - 297
wait_summary(&fx.srv, "cu-1", 5, |s| s.contains("caught-up-42")).await; - 298
let last = task_field(&fx.srv, "cu-1", "last_run_at").await; - 299
let last = chrono::DateTime::parse_from_rfc3339(last.as_str().unwrap()) - 300
.unwrap() - 301
.with_timezone(&chrono::Utc); - 302
assert!( - 303
chrono::Utc::now() - last < chrono::Duration::seconds(30), - 304
"last_run_at should be refreshed by the catch-up fire" - 305
); - 306
} - 307
- 308
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 309
async fn catch_up_disabled_waits_for_next_slot() { - 310
let fx = spawn_full( - 311
"[automation]\ncatch_up_missed = false\n", - 312
Some(|_home: &Path, ws: &Path| { - 313
serde_json::json!([stale_script_task( - 314
"cu-off", - 315
"quiet-watch", - 316
ws, - 317
"log:cuoff", - 318
"echo not-caught-up-99" - 319
)]) - 320
}), - 321
) - 322
.await; - 323
- 324
tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - 325
assert!( - 326
!delivery_lines(&fx.srv.home) - 327
.iter() - 328
.any(|(_, x)| x.contains("not-caught-up-99")), - 329
"disabled catch-up must not fire missed slots" - 330
); - 331
assert_eq!(fx.srv.dispatches.load(Ordering::SeqCst), 0); - 332
} - 333
- 334
// ---- Watchdog script matrix ----------------------------------------------------- - 335
- 336
async fn create_task(srv: &Srv, body: serde_json::Value) -> String { - 337
let res = srv - 338
.client() - 339
.post(format!("{}/tasks", srv.base)) - 340
.json(&body) - 341
.send() - 342
.await - 343
.unwrap(); - 344
assert_eq!(res.status(), 200, "create failed: {}", res.status()); - 345
let list = srv.get_json("/tasks").await; - 346
list["tasks"] - 347
.as_array() - 348
.unwrap() - 349
.iter() - 350
.find(|t| t["name"] == body["name"]) - 351
.map(|t| t["id"].as_str().unwrap().to_string()) - 352
.unwrap() - 353
} - 354
- 355
async fn run_now(srv: &Srv, id: &str) -> reqwest::StatusCode { - 356
srv.client() - 357
.post(format!("{}/tasks/{id}/run-now", srv.base)) - 358
.send() - 359
.await - 360
.unwrap() - 361
.status() - 362
} - 363
- 364
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 365
async fn script_watchdog_matrix_silent_stdout_failure_and_delivery() { - 366
let fx = spawn_full("", None::<fn(&Path, &Path) -> serde_json::Value>).await; - 367
let srv = &fx.srv; - 368
- 369
let ok_id = create_task( - 370
srv, - 371
serde_json::json!({ - 372
"name": "ok-watch", "script": "echo watchdog-hello", - 373
"schedule": "0 0 29 2 *", "deliver_to": "log:w-ok" - 374
}), - 375
) - 376
.await; - 377
let silent_id = create_task( - 378
srv, - 379
serde_json::json!({ - 380
"name": "silent-watch", "script": "true", - 381
"schedule": "0 0 29 2 *", "deliver_to": "log:w-silent" - 382
}), - 383
) - 384
.await; - 385
let fail_id = create_task( - 386
srv, - 387
serde_json::json!({ - 388
"name": "fail-watch", "script": "echo broken >&2; exit 3", - 389
"schedule": "0 0 29 2 *" - 390
}), - 391
) - 392
.await; - 393
- 394
// Success delivers trimmed stdout verbatim. - 395
assert_eq!(run_now(srv, &ok_id).await, 202); - 396
wait_summary(srv, &ok_id, 15, |s| s == "watchdog-hello").await; - 397
if !wait_until(10, || { - 398
delivery_lines(&srv.home) - 399
.iter() - 400
.any(|(t, x)| t == "log:w-ok" && x == "watchdog-hello") - 401
}) - 402
.await - 403
{ - 404
eprintln!( - 405
"[DBG-test] deliveries={:?} raw={:?} summary={:?}", - 406
delivery_lines(&srv.home), - 407
std::fs::read(srv.home.join("gateway").join("deliveries.jsonl")), - 408
task_field(srv, &ok_id, "last_summary").await - 409
); - 410
panic!("delivery never landed"); - 411
} - 412
- 413
// Empty stdout is a silent tick: recorded locally, never delivered. - 414
assert_eq!(run_now(srv, &silent_id).await, 202); - 415
wait_summary(srv, &silent_id, 15, |s| s == "(silent tick)").await; - 416
assert!( - 417
!delivery_lines(&srv.home) - 418
.iter() - 419
.any(|(t, _)| t == "log:w-silent"), - 420
"silent tick must not deliver" - 421
); - 422
- 423
// Failure delivers an error alert EVEN without a configured target - 424
// (fallback log surface), carrying exit-code detail. - 425
assert_eq!(run_now(srv, &fail_id).await, 202); - 426
assert!( - 427
wait_until(15, || delivery_lines(&srv.home).iter().any(|(_, x)| { - 428
x.contains("watchdog 'fail-watch' alert") && x.contains("exit code: 3") - 429
})) - 430
.await, - 431
"failing watchdog must deliver a typed error alert" - 432
); - 433
wait_summary(srv, &fail_id, 10, |s| s.starts_with("script failed:")).await; - 434
- 435
// Zero tokens by construction AND observed: no provider dispatch ever. - 436
assert_eq!(fx.srv.dispatches.load(Ordering::SeqCst), 0); - 437
} - 438
- 439
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 440
async fn missing_worker_delivers_typed_error_not_silence() { - 441
// Same fixture minus the real worker exe: the broker fails closed and - 442
// the failure must land as a DELIVERED alert, never silence. - 443
let dir = tempfile::tempdir().unwrap(); - 444
let cwd = dir.path().join("ws"); - 445
std::fs::create_dir_all(cwd.join(".vak")).unwrap(); - 446
std::fs::write(cwd.join(".vak/config.toml"), "").unwrap(); - 447
let home = dir.path().join("home"); - 448
vak_config::paths::isolate_home_for_tests(); - 449
let core = Core::new_with_trust(cwd, true).unwrap(); - 450
core.set_sessions_home(home.clone()); - 451
core.set_provider_instance(Arc::new(Counting { - 452
dispatches: Arc::new(AtomicUsize::new(0)), - 453
})); - 454
// Deliberately point the worker at something unusable. - 455
core.set_tool_worker_exe(dir.path().join("no-worker-here")); - 456
std::mem::forget(dir); - 457
- 458
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 459
let addr = listener.local_addr().unwrap(); - 460
let app = vak_server::router(core); - 461
tokio::spawn(async move { - 462
axum::serve(listener, app).await.unwrap(); - 463
}); - 464
let base = format!("http://{addr}"); - 465
let client = reqwest::Client::new(); - 466
- 467
client - 468
.post(format!("{base}/tasks")) - 469
.json(&serde_json::json!({ - 470
"name": "brokerless", "script": "echo hi", - 471
"interval_secs": 3600, "deliver_to": "log:bw" - 472
})) - 473
.send() - 474
.await - 475
.unwrap(); - 476
let list: serde_json::Value = client - 477
.get(format!("{base}/tasks")) - 478
.send() - 479
.await - 480
.unwrap() - 481
.json() - 482
.await - 483
.unwrap(); - 484
let id = list["tasks"][0]["id"].as_str().unwrap().to_string(); - 485
let res = client - 486
.post(format!("{base}/tasks/{id}/run-now")) - 487
.send() - 488
.await - 489
.unwrap(); - 490
assert_eq!(res.status(), 202); - 491
- 492
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); - 493
loop { - 494
assert!( - 495
std::time::Instant::now() < deadline, - 496
"broker failure was silent; expected delivered alert" - 497
); - 498
if delivery_lines(&home) - 499
.iter() - 500
.any(|(_, x)| x.contains("watchdog 'brokerless' alert")) - 501
{ - 502
break; - 503
} - 504
tokio::time::sleep(std::time::Duration::from_millis(150)).await; - 505
} - 506
} - 507
- 508
// ---- Model pin ------------------------------------------------------------------- - 509
- 510
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 511
async fn model_pin_receipts_show_pinned_model_only() { - 512
let fx = spawn_full("", None::<fn(&Path, &Path) -> serde_json::Value>).await; - 513
let srv = &fx.srv; - 514
let client = srv.client(); - 515
- 516
let res = client - 517
.post(format!("{}/tasks", srv.base)) - 518
.json(&serde_json::json!({ - 519
"name": "pinned-nightly", - 520
"prompt": "summarize the tree", - 521
"interval_secs": 3600, - 522
"model_pin": "counting/pinned-model-x" - 523
})) - 524
.send() - 525
.await - 526
.unwrap(); - 527
assert_eq!(res.status(), 200); - 528
let list = srv.get_json("/tasks").await; - 529
let tid = list["tasks"][0]["id"].as_str().unwrap().to_string(); - 530
- 531
// A never-run interval task is due immediately, so the SCHEDULER fires - 532
// it on its first tick; wait for the child session id it records. - 533
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(25); - 534
let child = loop { - 535
assert!( - 536
std::time::Instant::now() < deadline, - 537
"task never recorded a child session" - 538
); - 539
if let Some(c) = task_field(srv, &tid, "last_session_id") - 540
.await - 541
.as_str() - 542
.map(String::from) - 543
{ - 544
break c; - 545
} - 546
tokio::time::sleep(std::time::Duration::from_millis(150)).await; - 547
}; - 548
let receipts: serde_json::Value = { - 549
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); - 550
loop { - 551
assert!( - 552
std::time::Instant::now() < deadline, - 553
"receipts never appeared for {child}" - 554
); - 555
match client - 556
.get(format!("{}/sessions/{child}/receipts", srv.base)) - 557
.send() - 558
.await - 559
{ - 560
Ok(res) if res.status() == 200 => { - 561
let body: serde_json::Value = res.json().await.unwrap(); - 562
if body.as_array().is_some_and(|a| !a.is_empty()) { - 563
break body; - 564
} - 565
} - 566
_ => {} - 567
} - 568
tokio::time::sleep(std::time::Duration::from_millis(150)).await; - 569
} - 570
}; - 571
for r in receipts.as_array().unwrap() { - 572
assert_eq!( - 573
r["model"], "pinned-model-x", - 574
"every receipt carries the pin" - 575
); - 576
assert_eq!(r["provider"], "counting"); - 577
} - 578
- 579
// And the run itself completed normally. - 580
wait_summary(srv, &tid, 15, |s| s == "done").await; - 581
} - 582
- 583
// ---- Budget alerts --------------------------------------------------------------- - 584
- 585
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 586
async fn budget_alert_fires_once_per_window_then_stops() { - 587
let fx = spawn_full( - 588
"[finops]\nmax_day_usd = 10.0\n", - 589
None::<fn(&Path, &Path) -> serde_json::Value>, - 590
) - 591
.await; - 592
let srv = &fx.srv; - 593
- 594
// Day spend already past the 80% threshold of the $10 cap. - 595
let ledger = vak_core::finops::FinOpsLedger::new(&srv.home); - 596
ledger - 597
.append(&vak_core::finops::CostRow { - 598
ts: chrono::Utc::now(), - 599
model: "claude-sonnet".into(), - 600
provider: "anthropic".into(), - 601
input_tokens: 1000, - 602
output_tokens: 500, - 603
cache_read_input_tokens: None, - 604
usd: Some(9.0), - 605
source: "estimated".into(), - 606
session_id: "seed".into(), - 607
}) - 608
.unwrap(); - 609
- 610
let alerts_path = srv.home.join("budget-alerts.jsonl"); - 611
- 612
let tid = create_task( - 613
srv, - 614
serde_json::json!({ - 615
"name": "budget-probe", "script": "true", - 616
"schedule": "0 0 29 2 *", "deliver_to": "log:budget" - 617
}), - 618
) - 619
.await; - 620
- 621
// First fire crosses the threshold: one audit row, one delivery. - 622
assert_eq!(run_now(srv, &tid).await, 202); - 623
assert!( - 624
wait_until(15, || std::fs::read_to_string(&alerts_path) - 625
.map(|c| c.lines().count()) - 626
.unwrap_or(0) - 627
>= 1) - 628
.await, - 629
"no budget alert row recorded" - 630
); - 631
let rows = std::fs::read_to_string(&alerts_path).unwrap(); - 632
assert_eq!(rows.lines().count(), 1, "{rows}"); - 633
assert!(rows.contains("\"level\":\"eighty\""), "{rows}"); - 634
assert!( - 635
wait_until(10, || delivery_lines(&srv.home).iter().any(|(t, x)| t - 636
== "log:budget" - 637
&& x.contains("budget alert [eighty]"))) - 638
.await - 639
); - 640
- 641
// Second fire inside the same day window: no new row, no redelivery. - 642
assert_eq!(run_now(srv, &tid).await, 202); - 643
tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - 644
let rows = std::fs::read_to_string(&alerts_path).unwrap(); - 645
assert_eq!(rows.lines().count(), 1, "same-level alert must not refire"); - 646
let deliveries = delivery_lines(&srv.home) - 647
.iter() - 648
.filter(|(_, x)| x.contains("budget alert [eighty]")) - 649
.count(); - 650
assert_eq!(deliveries, 1, "same-level alert must not redeliver"); - 651
} - 652
- 653
// ---- Task CRUD validation --------------------------------------------------------- - 654
- 655
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 656
async fn task_api_validates_schedule_script_and_pin_fields() { - 657
let fx = spawn_full("", None::<fn(&Path, &Path) -> serde_json::Value>).await; - 658
let srv = &fx.srv; - 659
let client = srv.client(); - 660
- 661
// prompt XOR script. - 662
let res = client - 663
.post(format!("{}/tasks", srv.base)) - 664
.json(&serde_json::json!({ - 665
"name": "both", "prompt": "x", "script": "y", "interval_secs": 3600 - 666
})) - 667
.send() - 668
.await - 669
.unwrap(); - 670
assert_eq!(res.status(), 400); - 671
let body: serde_json::Value = res.json().await.unwrap(); - 672
assert!(body["error"].as_str().unwrap().contains("exactly one")); - 673
- 674
let res = client - 675
.post(format!("{}/tasks", srv.base)) - 676
.json(&serde_json::json!({ "name": "neither", "interval_secs": 3600 })) - 677
.send() - 678
.await - 679
.unwrap(); - 680
assert_eq!(res.status(), 400); - 681
- 682
// Bad cron grammar is rejected with a typed message. - 683
let res = client - 684
.post(format!("{}/tasks", srv.base)) - 685
.json(&serde_json::json!({ - 686
"name": "badcron", "script": "true", - 687
"interval_secs": 3600, "schedule": "99 * * * *" - 688
})) - 689
.send() - 690
.await - 691
.unwrap(); - 692
assert_eq!(res.status(), 400); - 693
let body: serde_json::Value = res.json().await.unwrap(); - 694
assert!(body["error"].as_str().unwrap().contains("99"), "{body}"); - 695
- 696
// A valid cron+script watchdog is accepted; prompt stays optional. - 697
let res = client - 698
.post(format!("{}/tasks", srv.base)) - 699
.json(&serde_json::json!({ - 700
"name": "goodcron", "script": "true", - 701
"interval_secs": 3600, "schedule": "*/5 * * * *", - 702
"agent_id": "vak", "agent_revision": 1 - 703
})) - 704
.send() - 705
.await - 706
.unwrap(); - 707
assert_eq!(res.status(), 200); - 708
let list = srv.get_json("/tasks").await; - 709
let good = &list["tasks"][0]; - 710
assert_eq!(good["schedule"], "*/5 * * * *"); - 711
assert_eq!(good["agent_id"], "vak"); - 712
assert_eq!(good["agent_revision"], 1); - 713
- 714
// PATCH to an invalid schedule is rejected and leaves state untouched. - 715
let res = client - 716
.patch(format!( - 717
"{}/tasks/{}", - 718
srv.base, - 719
good["id"].as_str().unwrap() - 720
)) - 721
.json(&serde_json::json!({ "schedule": "* * * *" })) - 722
.send() - 723
.await - 724
.unwrap(); - 725
assert_eq!(res.status(), 400); - 726
let list = srv.get_json("/tasks").await; - 727
assert_eq!( - 728
list["tasks"][0]["schedule"], "*/5 * * * *", - 729
"invalid patch ignored" - 730
); - 731
- 732
// Clearing script then setting prompt keeps validation green. - 733
let res = client - 734
.patch(format!( - 735
"{}/tasks/{}", - 736
srv.base, - 737
good["id"].as_str().unwrap() - 738
)) - 739
.json(&serde_json::json!({ "script": null, "prompt": "now an agent task" })) - 740
.send() - 741
.await - 742
.unwrap(); - 743
assert_eq!(res.status(), 200); - 744
let list = srv.get_json("/tasks").await; - 745
assert_eq!(list["tasks"][0]["prompt"], "now an agent task"); - 746
assert!(list["tasks"][0]["script"].is_null()); - 747
- 748
// Agent ownership is patchable and clearing it also clears its revision. - 749
let res = client - 750
.patch(format!( - 751
"{}/tasks/{}", - 752
srv.base, - 753
good["id"].as_str().unwrap() - 754
)) - 755
.json(&serde_json::json!({ "agent_id": null })) - 756
.send() - 757
.await - 758
.unwrap(); - 759
assert_eq!(res.status(), 200); - 760
let list = srv.get_json("/tasks").await; - 761
assert!(list["tasks"][0]["agent_id"].is_null()); - 762
assert!(list["tasks"][0]["agent_revision"].is_null()); - 763
- 764
// Unknown task id on PATCH is a typed 404. - 765
let res = client - 766
.patch(format!("{}/tasks/nope", srv.base)) - 767
.json(&serde_json::json!({ "name": "x" })) - 768
.send() - 769
.await - 770
.unwrap(); - 771
assert_eq!(res.status(), 404); - 772
} - 773
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.