- 1
//! `vak tasks` (docs/design/29-personal-os.md P2): CRUD over - 2
//! `vak_core::tasks::TaskStore` (tasks.json in the sessions home) without the - 3
//! server. Validation and cron math live in the library; this module only - 4
//! maps flags onto `TaskDef` and renders the table. - 5
- 6
use std::path::{Path, PathBuf}; - 7
- 8
use vak_core::Core; - 9
use vak_core::tasks::{TaskDef, TaskStore, cron_next_after}; - 10
- 11
pub fn run_tasks(cwd: PathBuf, action: crate::cli::TasksAction) -> i32 { - 12
let core = match Core::new(cwd.clone()) { - 13
Ok(c) => c, - 14
Err(e) => { - 15
eprintln!("error: {e}"); - 16
return 2; - 17
} - 18
}; - 19
let home = core.sessions_home(); - 20
let mut store = match TaskStore::load(&home) { - 21
Ok(s) => s, - 22
Err(e) => { - 23
eprintln!("error: {e}"); - 24
return 2; - 25
} - 26
}; - 27
match action { - 28
crate::cli::TasksAction::List => { - 29
list(&store); - 30
0 - 31
} - 32
crate::cli::TasksAction::Add { - 33
name, - 34
prompt, - 35
script, - 36
every, - 37
cron, - 38
cwd: task_cwd, - 39
deliver, - 40
model, - 41
preset, - 42
} => { - 43
let task_dir = task_cwd.unwrap_or_else(|| cwd.clone()); - 44
let built = match preset.as_deref() { - 45
None => build_task_def( - 46
name.as_deref().unwrap_or_default(), - 47
prompt.as_deref(), - 48
script.as_deref(), - 49
every, - 50
cron.as_deref(), - 51
&task_dir, - 52
deliver.as_deref(), - 53
model.as_deref(), - 54
), - 55
Some(preset_name) => expand_preset(preset_name, deliver.as_deref()).and_then(|x| { - 56
// clap rejects the content/schedule flags with - 57
// --preset; an explicit --name may rename the task. - 58
let task_name = name - 59
.as_deref() - 60
.map(str::trim) - 61
.filter(|n| !n.is_empty()) - 62
.unwrap_or(x.default_name); - 63
build_task_def( - 64
task_name, - 65
None, - 66
Some(&x.script), - 67
None, - 68
Some(x.schedule), - 69
&task_dir, - 70
Some(&x.deliver_to), - 71
model.as_deref(), - 72
) - 73
}), - 74
}; - 75
match built { - 76
Ok(task) => { - 77
if let Err(e) = task.validate() { - 78
eprintln!("error: {e}"); - 79
return 2; - 80
} - 81
let id = task.id.clone(); - 82
store.put(task.clone()); - 83
save_and_report(&mut store, || { - 84
println!("added task {id} · {}", add_detail(&task)); - 85
println!( - 86
" next fire {}", - 87
next_fire_preview(&task, chrono::Local::now()) - 88
); - 89
}) - 90
} - 91
Err(msg) => { - 92
eprintln!("error: {msg}"); - 93
2 - 94
} - 95
} - 96
} - 97
crate::cli::TasksAction::Remove { id } => { - 98
if store.remove(&id) { - 99
save_and_report(&mut store, || println!("removed task {id}")) - 100
} else { - 101
eprintln!("error: no task '{id}'"); - 102
2 - 103
} - 104
} - 105
crate::cli::TasksAction::Enable { id } => set_enabled(&mut store, &id, true), - 106
crate::cli::TasksAction::Disable { id } => set_enabled(&mut store, &id, false), - 107
} - 108
} - 109
- 110
fn set_enabled(store: &mut TaskStore, id: &str, enable: bool) -> i32 { - 111
match store.get(id).cloned() { - 112
Some(mut task) => { - 113
task.enabled = enable; - 114
store.put(task); - 115
save_and_report(store, || { - 116
println!("{} task {id}", if enable { "enabled" } else { "disabled" }) - 117
}) - 118
} - 119
None => { - 120
eprintln!("error: no task '{id}'"); - 121
2 - 122
} - 123
} - 124
} - 125
- 126
fn save_and_report(store: &mut TaskStore, report: impl Fn()) -> i32 { - 127
match store.save() { - 128
Ok(()) => { - 129
report(); - 130
0 - 131
} - 132
Err(e) => { - 133
eprintln!("error: {e}"); - 134
1 - 135
} - 136
} - 137
} - 138
- 139
/// Flag mapping + schedule exclusivity, kept pure for tests. - 140
#[allow(clippy::too_many_arguments)] - 141
pub fn build_task_def( - 142
name: &str, - 143
prompt: Option<&str>, - 144
script: Option<&str>, - 145
every: Option<u64>, - 146
cron: Option<&str>, - 147
cwd: &Path, - 148
deliver: Option<&str>, - 149
model_pin: Option<&str>, - 150
) -> Result<TaskDef, String> { - 151
if name.trim().is_empty() { - 152
return Err("--name must not be empty".into()); - 153
} - 154
if every.is_some() && cron.is_some() { - 155
return Err("pass --every or --cron, not both".into()); - 156
} - 157
let (prompt, script) = match (prompt.map(str::trim), script.map(str::trim)) { - 158
(Some(p), None) if !p.is_empty() => (p.to_string(), None), - 159
(None, Some(s)) if !s.is_empty() => (String::new(), Some(s.to_string())), - 160
(None, None) => { - 161
return Err("exactly one of --prompt or --script is required".into()); - 162
} - 163
_ => return Err("exactly one of --prompt or --script is required".into()), - 164
}; - 165
Ok(TaskDef { - 166
// Nanosecond timestamp ids sort like the server's uuid-v7 ids. - 167
id: format!("cli-{}", timestamp_id()), - 168
name: name.trim().to_string(), - 169
interval_secs: every.unwrap_or(3600), - 170
enabled: true, - 171
cwd: cwd.to_path_buf(), - 172
created_at: chrono::Utc::now(), - 173
last_run_at: None, - 174
last_session_id: None, - 175
last_summary: None, - 176
last_result_id: None, - 177
last_run_status: None, - 178
last_delivery_state: None, - 179
last_wt: None, - 180
deliver_to: deliver - 181
.map(str::trim) - 182
.filter(|d| !d.is_empty()) - 183
.map(str::to_string), - 184
schedule: cron - 185
.map(str::trim) - 186
.filter(|c| !c.is_empty()) - 187
.map(str::to_string), - 188
timezone: None, - 189
due_at: None, - 190
script, - 191
model_pin: model_pin - 192
.map(str::trim) - 193
.filter(|m| !m.is_empty()) - 194
.map(str::to_string), - 195
agent_id: None, - 196
agent_revision: None, - 197
prompt, - 198
}) - 199
} - 200
- 201
fn timestamp_id() -> String { - 202
std::time::SystemTime::now() - 203
.duration_since(std::time::UNIX_EPOCH) - 204
.unwrap_or_default() - 205
.as_nanos() - 206
.to_string() - 207
} - 208
- 209
/// A built-in `tasks add --preset` expansion (docs/design/29-personal-os.md - 210
/// P2/P3). Script presets only: each runs this binary as a shell one-liner, - 211
/// so adding a preset must never require new prompt semantics. - 212
struct TaskPreset { - 213
name: &'static str, - 214
/// Argument vector appended after the current executable. - 215
args: &'static [&'static str], - 216
schedule: &'static str, - 217
deliver_default: &'static str, - 218
} - 219
- 220
const TASK_PRESETS: &[TaskPreset] = &[TaskPreset { - 221
name: "weekly-digest", - 222
args: &["digest", "--days", "7"], - 223
schedule: "0 9 * * 1", - 224
deliver_default: "log:vak", - 225
}]; - 226
- 227
#[derive(Debug)] - 228
pub(crate) struct PresetExpansion { - 229
pub default_name: &'static str, - 230
pub script: String, - 231
pub schedule: &'static str, - 232
pub deliver_to: String, - 233
} - 234
- 235
/// Expand a preset, resolving the running binary so the task invokes this - 236
/// exact build even after upgrades move it. - 237
pub(crate) fn expand_preset( - 238
preset: &str, - 239
deliver: Option<&str>, - 240
) -> Result<PresetExpansion, String> { - 241
let exe = std::env::current_exe().map_err(|e| format!("resolve current executable: {e}"))?; - 242
expand_preset_with(preset, &exe, deliver) - 243
} - 244
- 245
fn expand_preset_with( - 246
preset: &str, - 247
exe: &Path, - 248
deliver: Option<&str>, - 249
) -> Result<PresetExpansion, String> { - 250
let known = TASK_PRESETS - 251
.iter() - 252
.find(|p| p.name == preset) - 253
.ok_or_else(|| { - 254
format!( - 255
"unknown preset '{preset}' (known: {})", - 256
TASK_PRESETS - 257
.iter() - 258
.map(|p| p.name) - 259
.collect::<Vec<_>>() - 260
.join(", ") - 261
) - 262
})?; - 263
Ok(PresetExpansion { - 264
default_name: known.name, - 265
script: format!("{} {}", shell_quote(exe), known.args.join(" ")), - 266
schedule: known.schedule, - 267
deliver_to: deliver - 268
.map(str::trim) - 269
.filter(|d| !d.is_empty()) - 270
.map(str::to_string) - 271
.unwrap_or_else(|| known.deliver_default.to_string()), - 272
}) - 273
} - 274
- 275
/// POSIX single-quote wrapper; embedded quotes become the `'\''` idiom. - 276
fn shell_quote(path: &Path) -> String { - 277
let raw = path.to_string_lossy(); - 278
format!("'{}'", raw.replace('\'', "'\\''")) - 279
} - 280
- 281
fn list(store: &TaskStore) { - 282
let tasks = store.all(); - 283
if tasks.is_empty() { - 284
println!("no scheduled tasks"); - 285
return; - 286
} - 287
let width = tasks.iter().map(|t| t.name.len()).max().unwrap_or(0); - 288
let header = format!( - 289
" {:<width$} {:<3} {:<12} {}", - 290
"name", - 291
"on", - 292
"schedule", - 293
"next fire", - 294
width = width - 295
); - 296
println!("{header}"); - 297
let now = chrono::Local::now(); - 298
for t in &tasks { - 299
let schedule = match (&t.schedule, t.interval_secs) { - 300
(Some(expr), _) => expr.clone(), - 301
(None, secs) => format!("every {secs}s"), - 302
}; - 303
let next = next_fire_preview(t, now); - 304
println!( - 305
" {:<width$} {:<3} {:<12} {}", - 306
t.name, - 307
if t.enabled { "yes" } else { "no" }, - 308
schedule, - 309
next, - 310
width = width - 311
); - 312
println!(" id {} · {}", t.id, kind_line(t)); - 313
} - 314
} - 315
- 316
fn next_fire_preview(task: &TaskDef, now: chrono::DateTime<chrono::Local>) -> String { - 317
let Some(expr) = &task.schedule else { - 318
return "—".into(); - 319
}; - 320
if !task.enabled { - 321
return "(disabled)".into(); - 322
} - 323
match cron_next_after(expr, now) { - 324
Ok(next) => next.format("%Y-%m-%d %H:%M (%a)").to_string(), - 325
Err(reason) => format!("invalid: {reason}"), - 326
} - 327
} - 328
- 329
/// One-line expanded definition shown after every successful add. - 330
fn add_detail(t: &TaskDef) -> String { - 331
let schedule = match &t.schedule { - 332
Some(expr) => format!("schedule {expr}"), - 333
None => format!("every {}s", t.interval_secs), - 334
}; - 335
format!("{schedule} · {}", kind_line(t)) - 336
} - 337
- 338
fn kind_line(t: &TaskDef) -> String { - 339
let mut parts = vec![if t.script.is_some() { - 340
format!("script: {}", t.script.as_deref().unwrap_or("")) - 341
} else { - 342
format!("prompt: {}", first_line(&t.prompt)) - 343
}]; - 344
if let Some(m) = &t.model_pin { - 345
parts.push(format!("model {m}")); - 346
} - 347
if let Some(d) = &t.deliver_to { - 348
parts.push(format!("deliver {d}")); - 349
} - 350
parts.push(format!("cwd {}", t.cwd.display())); - 351
parts.join(" · ") - 352
} - 353
- 354
fn first_line(text: &str) -> String { - 355
text.lines().next().unwrap_or_default().to_string() - 356
} - 357
- 358
#[cfg(test)] - 359
mod tests { - 360
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 361
use super::*; - 362
- 363
fn add_args() -> (&'static str, Option<&'static str>, Option<&'static str>) { - 364
("nightly", Some("tidy the repo"), None) - 365
} - 366
- 367
#[test] - 368
fn build_requires_prompt_xor_script() { - 369
let (name, prompt, _) = add_args(); - 370
assert!( - 371
build_task_def(name, prompt, None, None, None, Path::new("/w"), None, None).is_ok() - 372
); - 373
assert!( - 374
build_task_def( - 375
name, - 376
None, - 377
Some("echo tick"), - 378
None, - 379
None, - 380
Path::new("/w"), - 381
None, - 382
None - 383
) - 384
.is_ok() - 385
); - 386
let both = build_task_def( - 387
name, - 388
prompt, - 389
Some("x"), - 390
None, - 391
None, - 392
Path::new("/w"), - 393
None, - 394
None, - 395
); - 396
assert_eq!( - 397
both.unwrap_err(), - 398
"exactly one of --prompt or --script is required" - 399
); - 400
let neither = build_task_def(name, None, None, None, None, Path::new("/w"), None, None); - 401
assert_eq!( - 402
neither.unwrap_err(), - 403
"exactly one of --prompt or --script is required" - 404
); - 405
} - 406
- 407
#[test] - 408
fn build_rejects_every_and_cron_together() { - 409
let (name, prompt, _) = add_args(); - 410
let err = build_task_def( - 411
name, - 412
prompt, - 413
None, - 414
Some(60), - 415
Some("* * * * *"), - 416
Path::new("/w"), - 417
None, - 418
None, - 419
) - 420
.unwrap_err(); - 421
assert!(err.contains("--every or --cron"), "{err}"); - 422
} - 423
- 424
#[test] - 425
fn build_defaults_interval_and_keeps_pins() { - 426
let task = build_task_def( - 427
"nightly", - 428
None, - 429
Some("curl -sf http://x/health"), - 430
None, - 431
None, - 432
Path::new("/w"), - 433
Some("telegram:42"), - 434
Some(" haiku-fast "), - 435
) - 436
.unwrap(); - 437
assert_eq!(task.interval_secs, 3600); - 438
assert_eq!(task.script.as_deref(), Some("curl -sf http://x/health")); - 439
assert_eq!(task.deliver_to.as_deref(), Some("telegram:42")); - 440
assert_eq!(task.model_pin.as_deref(), Some("haiku-fast")); - 441
assert!(task.schedule.is_none()); - 442
assert!(task.enabled); - 443
assert!(task.validate().is_ok()); - 444
- 445
let with_every = build_task_def( - 446
"nightly", - 447
None, - 448
Some("x"), - 449
Some(120), - 450
None, - 451
Path::new("/w"), - 452
None, - 453
None, - 454
) - 455
.unwrap(); - 456
assert_eq!(with_every.interval_secs, 120); - 457
- 458
let with_cron = build_task_def( - 459
"nightly", - 460
Some("tidy"), - 461
None, - 462
None, - 463
Some("0 7 * * 1-5"), - 464
Path::new("/w"), - 465
None, - 466
None, - 467
) - 468
.unwrap(); - 469
assert_eq!(with_cron.schedule.as_deref(), Some("0 7 * * 1-5")); - 470
} - 471
- 472
#[test] - 473
fn empty_name_rejected() { - 474
assert!( - 475
build_task_def( - 476
" ", - 477
Some("p"), - 478
None, - 479
None, - 480
None, - 481
Path::new("/w"), - 482
None, - 483
None - 484
) - 485
.is_err() - 486
); - 487
} - 488
- 489
#[test] - 490
fn bad_cron_surfaces_through_validate_not_build() { - 491
let (name, _, _) = add_args(); - 492
let task = build_task_def( - 493
name, - 494
None, - 495
Some("x"), - 496
None, - 497
Some("99 * * * *"), - 498
Path::new("/w"), - 499
None, - 500
None, - 501
) - 502
.unwrap(); - 503
assert!(matches!( - 504
task.validate(), - 505
Err(vak_core::tasks::TaskError::BadSchedule { .. }) - 506
)); - 507
} - 508
- 509
#[test] - 510
fn next_fire_preview_states() { - 511
let mut t = base_task(); - 512
t.schedule = Some("0 7 * * 1-5".into()); - 513
t.enabled = false; - 514
assert_eq!(next_fire_preview(&t, chrono::Local::now()), "(disabled)"); - 515
- 516
t.enabled = true; - 517
let fired = next_fire_preview(&t, chrono::Local::now()); - 518
assert!(!fired.starts_with("invalid"), "{fired}"); - 519
- 520
t.schedule = Some("0 0 31 2 *".into()); - 521
assert!(next_fire_preview(&t, chrono::Local::now()).starts_with("invalid")); - 522
- 523
t.schedule = None; - 524
assert_eq!(next_fire_preview(&t, chrono::Local::now()), "—"); - 525
} - 526
- 527
fn base_task() -> TaskDef { - 528
build_task_def( - 529
"watchdog", - 530
None, - 531
Some("true"), - 532
None, - 533
None, - 534
Path::new("/w"), - 535
None, - 536
None, - 537
) - 538
.unwrap() - 539
} - 540
- 541
#[test] - 542
fn list_renders_without_panicking() { - 543
let dir = tempfile::tempdir().unwrap(); - 544
let mut store = TaskStore::load(dir.path()).unwrap(); - 545
store.put(base_task()); - 546
let mut scheduled = base_task(); - 547
scheduled.name = "morning brief".into(); - 548
scheduled.prompt = "brief me".into(); - 549
scheduled.script = None; - 550
scheduled.schedule = Some("0 7 * * 1-5".into()); - 551
assert!(scheduled.validate().is_ok()); - 552
store.put(scheduled); - 553
list(&store); - 554
let empty = TaskStore::load(dir.path()).unwrap(); - 555
list(&empty); - 556
} - 557
- 558
fn weekly_digest_task(exe: &Path) -> TaskDef { - 559
let x = expand_preset_with("weekly-digest", exe, None).unwrap(); - 560
build_task_def( - 561
x.default_name, - 562
None, - 563
Some(&x.script), - 564
None, - 565
Some(x.schedule), - 566
Path::new("/w"), - 567
Some(&x.deliver_to), - 568
None, - 569
) - 570
.unwrap() - 571
} - 572
- 573
#[test] - 574
fn weekly_digest_preset_expands_fully() { - 575
let task = weekly_digest_task(Path::new("/opt/bin/vak")); - 576
assert_eq!(task.name, "weekly-digest"); - 577
assert_eq!(task.schedule.as_deref(), Some("0 9 * * 1")); - 578
assert_eq!(task.deliver_to.as_deref(), Some("log:vak")); - 579
assert_eq!( - 580
task.script.as_deref(), - 581
Some("'/opt/bin/vak' digest --days 7") - 582
); - 583
assert!(task.prompt.is_empty()); - 584
assert!(task.model_pin.is_none()); - 585
assert!(task.validate().is_ok()); - 586
} - 587
- 588
#[test] - 589
fn preset_deliver_override_wins_over_default() { - 590
let x = - 591
expand_preset_with("weekly-digest", Path::new("/v"), Some(" telegram:42 ")).unwrap(); - 592
assert_eq!(x.deliver_to, "telegram:42"); - 593
} - 594
- 595
#[test] - 596
fn unknown_preset_lists_known_names() { - 597
let err = expand_preset_with("inbox-zero", Path::new("/v"), None).unwrap_err(); - 598
assert!(err.contains("unknown preset 'inbox-zero'"), "{err}"); - 599
assert!(err.contains("weekly-digest"), "{err}"); - 600
} - 601
- 602
#[test] - 603
fn exe_quoting_survives_spaces_and_quotes() { - 604
assert_eq!(shell_quote(Path::new("/app dir/vak")), "'/app dir/vak'"); - 605
assert_eq!(shell_quote(Path::new("/o'mal/vak")), "'/o'\\''mal/vak'"); - 606
} - 607
- 608
#[test] - 609
fn expanded_preset_roundtrips_through_store() { - 610
let dir = tempfile::tempdir().unwrap(); - 611
let mut task = weekly_digest_task(Path::new("/opt/bin/vak")); - 612
task.id = "preset-rt".into(); - 613
let mut store = TaskStore::load(dir.path()).unwrap(); - 614
store.put(task.clone()); - 615
store.save().unwrap(); - 616
let reloaded = TaskStore::load(dir.path()) - 617
.unwrap() - 618
.get("preset-rt") - 619
.cloned(); - 620
let same = reloaded.as_ref().is_some_and(|t| { - 621
t.name == task.name - 622
&& t.script == task.script - 623
&& t.schedule == task.schedule - 624
&& t.deliver_to == task.deliver_to - 625
&& t.prompt == task.prompt - 626
&& t.model_pin == task.model_pin - 627
&& t.enabled == task.enabled - 628
}); - 629
assert!(same, "roundtrip lost fields: {reloaded:?}"); - 630
} - 631
- 632
#[test] - 633
fn weekly_digest_next_fire_lands_on_monday() { - 634
let task = weekly_digest_task(Path::new("/opt/bin/vak")); - 635
let preview = next_fire_preview(&task, chrono::Local::now()); - 636
assert!(preview.ends_with("(Mon)"), "{preview}"); - 637
} - 638
- 639
#[test] - 640
fn add_detail_includes_schedule_script_and_delivery() { - 641
let task = weekly_digest_task(Path::new("/opt/bin/vak")); - 642
let detail = add_detail(&task); - 643
assert!(detail.contains("schedule 0 9 * * 1"), "{detail}"); - 644
assert!(detail.contains("digest --days 7"), "{detail}"); - 645
assert!(detail.contains("deliver log:vak"), "{detail}"); - 646
} - 647
} - 648
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.