- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
use std::collections::{HashMap, VecDeque}; - 4
use std::sync::{Arc, Mutex}; - 5
- 6
use tokio::sync::mpsc; - 7
use tokio_util::sync::CancellationToken; - 8
- 9
use vak_agent::AutoApprove; - 10
use vak_flow::{Executor, ExecutorDeps, FlowOutcome, FlowState}; - 11
use vak_llm::stream; - 12
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 13
use vak_llm::{EventStream, LlmError, Provider}; - 14
use vak_permission::{Mode, PermissionEngine}; - 15
use vak_tools::bash::BashTool; - 16
- 17
/// Serves a fixed sequence per routing key (the first user text). - 18
struct TaggedScripted { - 19
routes: Mutex<HashMap<String, VecDeque<AssistantMessage>>>, - 20
} - 21
- 22
impl TaggedScripted { - 23
fn route_for(request: &ChatRequest) -> String { - 24
request - 25
.messages - 26
.iter() - 27
.find(|m| m.role == vak_llm::types::Role::User) - 28
.map(|m| m.text_content()) - 29
.unwrap_or_default() - 30
} - 31
} - 32
- 33
#[async_trait::async_trait] - 34
impl Provider for TaggedScripted { - 35
fn name(&self) -> &str { - 36
"scripted" - 37
} - 38
- 39
async fn stream( - 40
&self, - 41
request: ChatRequest, - 42
_cancel: CancellationToken, - 43
) -> Result<EventStream, LlmError> { - 44
let key = Self::route_for(&request); - 45
let next = self - 46
.routes - 47
.lock() - 48
.unwrap() - 49
.get_mut(&key) - 50
.and_then(|d| d.pop_front()); - 51
let (mut sink, rx) = stream::channel(64); - 52
match next { - 53
Some(m) => { - 54
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 55
sink.close_message(m).await; - 56
} - 57
None => { - 58
sink.close_error(LlmError::Parse(format!("exhausted: {key}"))) - 59
.await - 60
} - 61
} - 62
Ok(rx) - 63
} - 64
} - 65
- 66
fn text(t: &str) -> AssistantMessage { - 67
AssistantMessage { - 68
content: vec![ContentBlock::text(t)], - 69
stop_reason: StopReason::EndTurn, - 70
usage: Usage::default(), - 71
model: "test-model".into(), - 72
response_id: None, - 73
} - 74
} - 75
- 76
fn make_executor(provider: Arc<TaggedScripted>, state_path: std::path::PathBuf) -> Executor { - 77
// Bash records workspace file changes. Sharing the system temp directory - 78
// lets concurrent tests' files leak into the output used by prompt substitution. - 79
let workspace = tempfile::tempdir().unwrap().keep(); - 80
make_executor_with_policy( - 81
provider, - 82
state_path, - 83
Mode::FullAccess, - 84
Some(Arc::new(AutoApprove)), - 85
workspace, - 86
) - 87
} - 88
- 89
fn make_executor_with_policy( - 90
provider: Arc<TaggedScripted>, - 91
state_path: std::path::PathBuf, - 92
mode: Mode, - 93
approver: Option<Arc<dyn vak_agent::Approver>>, - 94
cwd: std::path::PathBuf, - 95
) -> Executor { - 96
make_executor_with_outcome(provider, state_path, mode, approver, cwd, None) - 97
} - 98
- 99
fn make_executor_with_outcome( - 100
provider: Arc<TaggedScripted>, - 101
state_path: std::path::PathBuf, - 102
mode: Mode, - 103
approver: Option<Arc<dyn vak_agent::Approver>>, - 104
cwd: std::path::PathBuf, - 105
outcome: Option<vak_intent::OutcomeSpec>, - 106
) -> Executor { - 107
let dir = tempfile::tempdir().unwrap(); - 108
let home = dir.path().join("home"); - 109
std::fs::create_dir_all(&home).unwrap(); - 110
std::mem::forget(dir); - 111
Executor::new(ExecutorDeps { - 112
prompt_layers: Vec::new(), - 113
provider, - 114
system_prompt: "sys".into(), - 115
model: "test-model".into(), - 116
tools: vec![Arc::new(BashTool)], - 117
read_only_tools: vec![], - 118
max_turns: 4, - 119
outcome, - 120
max_retries: 0, - 121
retry_base_backoff_ms: 100, - 122
request_timeout: Some(std::time::Duration::from_secs(600)), - 123
circuit_breaker: None, - 124
run_retry_attempts: 0, - 125
run_retry_base_backoff_ms: 1000, - 126
dispatch_ceiling: 1, - 127
spend_gate: None, - 128
permission: Some(Arc::new(PermissionEngine::default())), - 129
mode, - 130
approval_mode: vak_agent::ApprovalMode::Ask, - 131
approver, - 132
sandbox: None, - 133
cwd, - 134
sessions_home: home, - 135
parent_session_id: "flow-parent".into(), - 136
state_path, - 137
agent_identity: None, - 138
conversation_context: None, - 139
work: None, - 140
}) - 141
} - 142
- 143
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 144
async fn admitted_outcome_is_persisted_into_flow_state() { - 145
let workspace = tempfile::tempdir().unwrap(); - 146
let toml = r#" - 147
[flow] - 148
name = "outcome" - 149
- 150
[[nodes]] - 151
id = "report" - 152
type = "bash" - 153
command = "echo admitted" - 154
"#; - 155
let flow = vak_flow::parse_flow(toml).unwrap(); - 156
let expected = vak_intent::OutcomeSpec { - 157
schema_version: 1, - 158
revision: 4, - 159
objective: "preserve the admitted objective".into(), - 160
assumptions: vec!["the workspace is available".into()], - 161
requirements: Vec::new(), - 162
resolver_version: 1, - 163
evidence_max_age_secs: Some(3600), - 164
acts: Default::default(), - 165
stop: Default::default(), - 166
max_turns: Some(2), - 167
}; - 168
let provider = Arc::new(TaggedScripted { - 169
routes: Mutex::new(HashMap::new()), - 170
}); - 171
let state_path = workspace.path().join("state.json"); - 172
let mut state = FlowState { - 173
run_id: "outcome-run".into(), - 174
flow_name: "outcome".into(), - 175
definition_toml: toml.into(), - 176
started_at: chrono::Utc::now(), - 177
outcome: None, - 178
nodes: Default::default(), - 179
}; - 180
let executor = make_executor_with_outcome( - 181
provider, - 182
state_path.clone(), - 183
Mode::FullAccess, - 184
Some(Arc::new(AutoApprove)), - 185
workspace.path().to_path_buf(), - 186
Some(expected.clone()), - 187
); - 188
- 189
let _ = drain_run(&executor, &flow, &mut state).await; - 190
assert_eq!(state.outcome, Some(expected)); - 191
let persisted = std::fs::read_to_string(state_path).unwrap(); - 192
assert!(persisted.contains("preserve the admitted objective")); - 193
} - 194
- 195
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 196
async fn bash_node_obeys_permission_decision() { - 197
let workspace = tempfile::tempdir().unwrap(); - 198
let toml = r#" - 199
[flow] - 200
name = "permission-gate" - 201
- 202
[[nodes]] - 203
id = "blocked" - 204
type = "bash" - 205
command = "echo escaped > should-not-exist.txt" - 206
"#; - 207
let flow = vak_flow::parse_flow(toml).unwrap(); - 208
let provider = Arc::new(TaggedScripted { - 209
routes: Mutex::new(HashMap::new()), - 210
}); - 211
let state_path = workspace.path().join("state.json"); - 212
let mut state = FlowState { - 213
run_id: "permission-run".into(), - 214
flow_name: "permission-gate".into(), - 215
definition_toml: toml.into(), - 216
started_at: chrono::Utc::now(), - 217
outcome: None, - 218
nodes: Default::default(), - 219
}; - 220
let executor = make_executor_with_policy( - 221
provider, - 222
state_path, - 223
Mode::WorkspaceWrite, - 224
None, - 225
workspace.path().to_path_buf(), - 226
); - 227
- 228
let outcome = drain_run(&executor, &flow, &mut state).await; - 229
match outcome { - 230
FlowOutcome::Failed { node, reason, .. } => { - 231
assert_eq!(node, "blocked"); - 232
assert!(reason.contains("no approver available"), "{reason}"); - 233
} - 234
other => panic!("expected permission failure, got {other:?}"), - 235
} - 236
assert!(!workspace.path().join("should-not-exist.txt").exists()); - 237
} - 238
- 239
async fn drain_run( - 240
executor: &Executor, - 241
flow: &vak_flow::FlowDef, - 242
state: &mut FlowState, - 243
) -> FlowOutcome { - 244
let (tx, mut rx) = mpsc::channel(256); - 245
let drainer = tokio::spawn(async move { while rx.recv().await.is_some() {} }); - 246
let outcome = executor - 247
.run(flow, state, CancellationToken::new(), tx) - 248
.await; - 249
drainer.await.unwrap(); - 250
outcome - 251
} - 252
- 253
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 254
async fn chain_runs_with_substitution_and_merge() { - 255
let toml = r#" - 256
[flow] - 257
name = "chain" - 258
- 259
[[nodes]] - 260
id = "greet" - 261
type = "bash" - 262
command = "echo hello" - 263
- 264
[[nodes]] - 265
id = "shout" - 266
type = "agent" - 267
prompt = "SHOUT {{greet}}" - 268
- 269
[[nodes]] - 270
id = "done" - 271
type = "merge" - 272
deps = ["shout"] - 273
"#; - 274
let flow = vak_flow::parse_flow(toml).unwrap(); - 275
- 276
let mut routes = HashMap::new(); - 277
routes.insert( - 278
"SHOUT [stdout]\nhello\n\n".to_string(), - 279
VecDeque::from(vec![text("HELLO")]), - 280
); - 281
let provider = Arc::new(TaggedScripted { - 282
routes: Mutex::new(routes), - 283
}); - 284
- 285
let state_path = std::env::temp_dir().join(format!("vak-flow-{}.json", uuid_like())); - 286
let mut state = FlowState { - 287
run_id: "r1".into(), - 288
flow_name: "chain".into(), - 289
definition_toml: toml.into(), - 290
started_at: chrono::Utc::now(), - 291
outcome: None, - 292
nodes: Default::default(), - 293
}; - 294
- 295
let executor = make_executor(provider, state_path.clone()); - 296
let outcome = drain_run(&executor, &flow, &mut state).await; - 297
- 298
match outcome { - 299
FlowOutcome::Completed { outputs } => { - 300
assert!(outputs.get("greet").is_some_and(|o| o.contains("hello"))); - 301
assert_eq!(outputs.get("shout").map(String::as_str), Some("HELLO")); - 302
let merge = outputs.get("done").expect("merge output"); - 303
assert!(merge.contains("[shout] ok")); - 304
assert!(merge.contains("HELLO")); - 305
} - 306
other => panic!("expected completed, got {other:?}"), - 307
} - 308
assert_eq!( - 309
state.nodes.get("greet").unwrap().status, - 310
vak_flow::NodeStatus::Completed - 311
); - 312
let _ = std::fs::remove_file(&state_path); - 313
} - 314
- 315
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 316
async fn required_failure_fails_flow_and_skips_downstream() { - 317
let toml = r#" - 318
[flow] - 319
name = "strict" - 320
- 321
[[nodes]] - 322
id = "boom" - 323
type = "bash" - 324
command = "exit 3" - 325
required = true - 326
- 327
[[nodes]] - 328
id = "after" - 329
type = "agent" - 330
prompt = "never runs" - 331
deps = ["boom"] - 332
"#; - 333
let flow = vak_flow::parse_flow(toml).unwrap(); - 334
let provider = Arc::new(TaggedScripted { - 335
routes: Mutex::new(HashMap::new()), - 336
}); - 337
let state_path = std::env::temp_dir().join(format!("vak-flow-{}.json", uuid_like())); - 338
let mut state = FlowState { - 339
run_id: "r2".into(), - 340
flow_name: "strict".into(), - 341
definition_toml: toml.into(), - 342
started_at: chrono::Utc::now(), - 343
outcome: None, - 344
nodes: Default::default(), - 345
}; - 346
let executor = make_executor(provider, state_path.clone()); - 347
let outcome = drain_run(&executor, &flow, &mut state).await; - 348
- 349
match outcome { - 350
FlowOutcome::Failed { node, reason, .. } => { - 351
assert_eq!(node, "boom"); - 352
assert!(reason.contains("exit code: 3")); - 353
} - 354
other => panic!("expected failed, got {other:?}"), - 355
} - 356
assert_eq!( - 357
state.nodes.get("after").unwrap().status, - 358
vak_flow::NodeStatus::Skipped - 359
); - 360
let _ = std::fs::remove_file(&state_path); - 361
} - 362
- 363
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 364
async fn optional_failure_lets_merge_report_partial_outcome() { - 365
let toml = r#" - 366
[flow] - 367
name = "lenient" - 368
- 369
[[nodes]] - 370
id = "flaky" - 371
type = "bash" - 372
command = "exit 9" - 373
required = false - 374
- 375
[[nodes]] - 376
id = "report" - 377
type = "merge" - 378
deps = ["flaky"] - 379
"#; - 380
let flow = vak_flow::parse_flow(toml).unwrap(); - 381
let provider = Arc::new(TaggedScripted { - 382
routes: Mutex::new(HashMap::new()), - 383
}); - 384
let state_path = std::env::temp_dir().join(format!("vak-flow-{}.json", uuid_like())); - 385
let mut state = FlowState { - 386
run_id: "r3".into(), - 387
flow_name: "lenient".into(), - 388
definition_toml: toml.into(), - 389
started_at: chrono::Utc::now(), - 390
outcome: None, - 391
nodes: Default::default(), - 392
}; - 393
let executor = make_executor(provider, state_path.clone()); - 394
let outcome = drain_run(&executor, &flow, &mut state).await; - 395
- 396
match outcome { - 397
FlowOutcome::Completed { outputs } => { - 398
let report = outputs.get("report").expect("merge ran despite failure"); - 399
assert!(report.contains("[flaky] unavailable")); - 400
} - 401
other => panic!("expected completed with partial report, got {other:?}"), - 402
} - 403
assert_eq!( - 404
state.nodes.get("flaky").unwrap().status, - 405
vak_flow::NodeStatus::Failed - 406
); - 407
let _ = std::fs::remove_file(&state_path); - 408
} - 409
- 410
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 411
async fn resume_skips_completed_nodes() { - 412
let toml = r#" - 413
[flow] - 414
name = "resumable" - 415
- 416
[[nodes]] - 417
id = "first" - 418
type = "bash" - 419
command = "echo one" - 420
- 421
[[nodes]] - 422
id = "second" - 423
type = "bash" - 424
command = "echo two" - 425
deps = ["first"] - 426
"#; - 427
let flow = vak_flow::parse_flow(toml).unwrap(); - 428
let provider = Arc::new(TaggedScripted { - 429
routes: Mutex::new(HashMap::new()), - 430
}); - 431
let state_path = std::env::temp_dir().join(format!("vak-flow-{}.json", uuid_like())); - 432
- 433
// Pre-seed state as if "first" already completed in an earlier run. - 434
let mut state = FlowState { - 435
run_id: "r4".into(), - 436
flow_name: "resumable".into(), - 437
definition_toml: toml.into(), - 438
started_at: chrono::Utc::now(), - 439
outcome: None, - 440
nodes: [( - 441
"first".to_string(), - 442
vak_flow::NodeResult { - 443
status: vak_flow::NodeStatus::Completed, - 444
output: "one\n".into(), - 445
}, - 446
)] - 447
.into_iter() - 448
.collect(), - 449
}; - 450
std::fs::write(&state_path, serde_json::to_string_pretty(&state).unwrap()).unwrap(); - 451
- 452
let executor = make_executor(provider.clone(), state_path.clone()); - 453
let outcome = drain_run(&executor, &flow, &mut state).await; - 454
assert!(matches!(outcome, FlowOutcome::Completed { .. })); - 455
- 456
// "second" must have executed; "first" must not have re-executed. - 457
assert_eq!( - 458
state.nodes.get("second").unwrap().status, - 459
vak_flow::NodeStatus::Completed - 460
); - 461
assert!(state.nodes.get("second").unwrap().output.contains("two")); - 462
assert_eq!(state.nodes.get("first").unwrap().output.trim(), "one"); - 463
- 464
// The persisted ledger reflects both. - 465
let persisted: FlowState = - 466
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); - 467
assert_eq!(persisted.nodes.len(), 2); - 468
let _ = std::fs::remove_file(&state_path); - 469
} - 470
- 471
fn uuid_like() -> String { - 472
use std::sync::atomic::{AtomicU32, Ordering}; - 473
static C: AtomicU32 = AtomicU32::new(0); - 474
format!("u{}", C.fetch_add(1, Ordering::Relaxed)) - 475
} - 476
- 477
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 478
async fn done_contract_failure_rejects_node() { - 479
let workspace = tempfile::tempdir().unwrap(); - 480
let toml = r#" - 481
[flow] - 482
name = "contract" - 483
- 484
[[nodes]] - 485
id = "build" - 486
type = "bash" - 487
command = "echo built > built.txt" - 488
accept = ["verify: test -f built.txt", "verify: test -f missing-artifact.txt"] - 489
- 490
[[nodes]] - 491
id = "after" - 492
type = "merge" - 493
deps = ["build"] - 494
"#; - 495
let flow = vak_flow::parse_flow(toml).unwrap(); - 496
let provider = Arc::new(TaggedScripted { - 497
routes: Mutex::new(HashMap::new()), - 498
}); - 499
let state_path = workspace.path().join("state.json"); - 500
let executor = make_executor_with_policy( - 501
provider, - 502
state_path.clone(), - 503
Mode::FullAccess, - 504
None, - 505
workspace.path().to_path_buf(), - 506
); - 507
- 508
let (tx, mut rx) = mpsc::channel(256); - 509
tokio::spawn(async move { while rx.recv().await.is_some() {} }); - 510
let mut state = FlowState { - 511
run_id: "r".into(), - 512
flow_name: "contract".into(), - 513
definition_toml: toml.into(), - 514
started_at: chrono::Utc::now(), - 515
outcome: None, - 516
nodes: Default::default(), - 517
}; - 518
let outcome = executor - 519
.run(&flow, &mut state, CancellationToken::new(), tx) - 520
.await; - 521
match outcome { - 522
FlowOutcome::Failed { node, reason, .. } => { - 523
assert_eq!(node, "build"); - 524
assert!(reason.contains("done-contract failed"), "{reason}"); - 525
assert!(reason.contains("missing-artifact.txt"), "{reason}"); - 526
} - 527
other => panic!("expected contract failure, got {other:?}"), - 528
} - 529
- 530
// Ledger marks the node Failed despite the command itself succeeding. - 531
let st: FlowState = - 532
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); - 533
assert_eq!( - 534
st.nodes.get("build").map(|r| r.status), - 535
Some(vak_flow::NodeStatus::Failed) - 536
); - 537
} - 538
- 539
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 540
async fn done_contract_pass_keeps_node_green() { - 541
let workspace = tempfile::tempdir().unwrap(); - 542
let toml = r#" - 543
[flow] - 544
name = "contract-ok" - 545
- 546
[[nodes]] - 547
id = "make" - 548
type = "bash" - 549
command = "echo v1 > artifact.txt" - 550
accept = ["verify: grep -q v1 artifact.txt"] - 551
"#; - 552
let flow = vak_flow::parse_flow(toml).unwrap(); - 553
let provider = Arc::new(TaggedScripted { - 554
routes: Mutex::new(HashMap::new()), - 555
}); - 556
let state_path = workspace.path().join("state.json"); - 557
let executor = make_executor_with_policy( - 558
provider, - 559
state_path.clone(), - 560
Mode::FullAccess, - 561
None, - 562
workspace.path().to_path_buf(), - 563
); - 564
let (tx, mut rx) = mpsc::channel(256); - 565
tokio::spawn(async move { while rx.recv().await.is_some() {} }); - 566
let mut state = FlowState { - 567
run_id: "r".into(), - 568
flow_name: "contract-ok".into(), - 569
definition_toml: toml.into(), - 570
started_at: chrono::Utc::now(), - 571
outcome: None, - 572
nodes: Default::default(), - 573
}; - 574
let outcome = executor - 575
.run(&flow, &mut state, CancellationToken::new(), tx) - 576
.await; - 577
assert!(matches!(outcome, FlowOutcome::Completed { .. })); - 578
let st: FlowState = - 579
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); - 580
assert_eq!( - 581
st.nodes.get("make").map(|r| r.status), - 582
Some(vak_flow::NodeStatus::Completed) - 583
); - 584
} - 585
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.