- 1
use std::collections::{BTreeMap, BTreeSet, VecDeque}; - 2
- 3
use thiserror::Error; - 4
- 5
use crate::types::{ - 6
CriterionResult, Entry, EntryPayload, WorkContract, WorkContractStatus, WorkEvent, - 7
WorkEventKind, WorkItemDefinition, WorkItemState, WorkItemStatus, - 8
}; - 9
- 10
#[derive(Debug, Clone, PartialEq, serde::Serialize)] - 11
pub struct WorkProjection { - 12
pub contract: WorkContract, - 13
pub status: WorkContractStatus, - 14
pub items: BTreeMap<String, WorkItemState>, - 15
pub criteria: BTreeMap<String, CriterionResult>, - 16
} - 17
- 18
#[derive(Debug, Error, PartialEq, Eq)] - 19
pub enum WorkError { - 20
#[error("work contract is empty")] - 21
EmptyContract, - 22
#[error("work contract has duplicate item '{0}'")] - 23
DuplicateItem(String), - 24
#[error("work item '{0}' has unknown dependency '{1}'")] - 25
UnknownDependency(String, String), - 26
#[error("work contract contains a dependency cycle")] - 27
DependencyCycle, - 28
#[error("work item '{0}' references unknown criterion '{1}'")] - 29
UnknownCriterion(String, String), - 30
#[error("work event belongs to contract '{event}', active contract is '{active}'")] - 31
WrongContract { event: String, active: String }, - 32
#[error("work revision {actual} is not the expected revision {expected}")] - 33
WrongRevision { actual: u32, expected: u32 }, - 34
#[error("work event requires an active contract")] - 35
MissingContract, - 36
#[error("invalid work status transition {from:?} -> {to:?}")] - 37
InvalidContractTransition { - 38
from: WorkContractStatus, - 39
to: WorkContractStatus, - 40
}, - 41
#[error("invalid work item '{item}' transition {from:?} -> {to:?}")] - 42
InvalidItemTransition { - 43
item: String, - 44
from: WorkItemStatus, - 45
to: WorkItemStatus, - 46
}, - 47
#[error("unknown work item '{0}'")] - 48
UnknownItem(String), - 49
#[error("work item '{0}' cannot be marked succeeded by a model event")] - 50
ModelCompletion(String), - 51
#[error("work event is not valid for the current contract state")] - 52
InvalidEvent, - 53
} - 54
- 55
pub fn validate_contract(contract: &WorkContract) -> Result<(), WorkError> { - 56
if contract.objective.trim().is_empty() || contract.items.is_empty() { - 57
return Err(WorkError::EmptyContract); - 58
} - 59
- 60
let item_ids: BTreeSet<&str> = contract - 61
.items - 62
.iter() - 63
.map(|item| item.item_id.as_str()) - 64
.collect(); - 65
if item_ids.len() != contract.items.len() { - 66
let mut seen = BTreeSet::new(); - 67
let duplicate = contract - 68
.items - 69
.iter() - 70
.find(|item| !seen.insert(item.item_id.as_str())) - 71
.map(|item| item.item_id.clone()) - 72
.unwrap_or_default(); - 73
return Err(WorkError::DuplicateItem(duplicate)); - 74
} - 75
- 76
let criterion_ids: BTreeSet<&str> = contract - 77
.criteria - 78
.iter() - 79
.map(|criterion| criterion.criterion_id.as_str()) - 80
.collect(); - 81
if criterion_ids.len() != contract.criteria.len() { - 82
return Err(WorkError::InvalidEvent); - 83
} - 84
let assumption_ids: BTreeSet<&str> = contract - 85
.assumptions - 86
.iter() - 87
.map(|assumption| assumption.assumption_id.as_str()) - 88
.collect(); - 89
if assumption_ids.len() != contract.assumptions.len() { - 90
return Err(WorkError::InvalidEvent); - 91
} - 92
for item in &contract.items { - 93
for dep in &item.dependencies { - 94
if !item_ids.contains(dep.as_str()) { - 95
return Err(WorkError::UnknownDependency( - 96
item.item_id.clone(), - 97
dep.clone(), - 98
)); - 99
} - 100
} - 101
for criterion in &item.criterion_ids { - 102
if !criterion_ids.contains(criterion.as_str()) { - 103
return Err(WorkError::UnknownCriterion( - 104
item.item_id.clone(), - 105
criterion.clone(), - 106
)); - 107
} - 108
} - 109
} - 110
ensure_acyclic(&contract.items) - 111
} - 112
- 113
pub fn validate_contract_for_admission(contract: &WorkContract) -> Result<(), WorkError> { - 114
validate_contract(contract)?; - 115
if contract.items.iter().any(|item| { - 116
item.required - 117
&& !item.criterion_ids.iter().any(|criterion_id| { - 118
contract - 119
.criteria - 120
.iter() - 121
.any(|criterion| criterion.criterion_id == *criterion_id && criterion.required) - 122
}) - 123
}) { - 124
return Err(WorkError::InvalidEvent); - 125
} - 126
Ok(()) - 127
} - 128
- 129
fn ensure_acyclic(items: &[WorkItemDefinition]) -> Result<(), WorkError> { - 130
let mut indegree: BTreeMap<&str, usize> = items - 131
.iter() - 132
.map(|item| (item.item_id.as_str(), item.dependencies.len())) - 133
.collect(); - 134
let mut dependents: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); - 135
for item in items { - 136
for dependency in &item.dependencies { - 137
dependents - 138
.entry(dependency.as_str()) - 139
.or_default() - 140
.push(item.item_id.as_str()); - 141
} - 142
} - 143
let mut ready: VecDeque<&str> = indegree - 144
.iter() - 145
.filter_map(|(id, degree)| (*degree == 0).then_some(*id)) - 146
.collect(); - 147
let mut visited = 0; - 148
while let Some(id) = ready.pop_front() { - 149
visited += 1; - 150
if let Some(children) = dependents.get(id) { - 151
for child in children { - 152
let Some(degree) = indegree.get_mut(child) else { - 153
return Err(WorkError::InvalidEvent); - 154
}; - 155
*degree -= 1; - 156
if *degree == 0 { - 157
ready.push_back(child); - 158
} - 159
} - 160
} - 161
} - 162
if visited != items.len() { - 163
return Err(WorkError::DependencyCycle); - 164
} - 165
Ok(()) - 166
} - 167
- 168
pub fn project_work(entries: &[&Entry]) -> Result<Option<WorkProjection>, WorkError> { - 169
let mut projection = None; - 170
for entry in entries { - 171
let EntryPayload::Work(event) = &entry.payload else { - 172
continue; - 173
}; - 174
apply_event(&mut projection, event)?; - 175
} - 176
Ok(projection) - 177
} - 178
- 179
fn apply_event( - 180
projection: &mut Option<WorkProjection>, - 181
event: &WorkEvent, - 182
) -> Result<(), WorkError> { - 183
if let WorkEventKind::ContractCreated { contract } = &event.kind { - 184
if projection.as_ref().is_some_and(|current| { - 185
!matches!( - 186
current.status, - 187
WorkContractStatus::Completed - 188
| WorkContractStatus::Failed - 189
| WorkContractStatus::Cancelled - 190
| WorkContractStatus::Unverified - 191
) - 192
}) { - 193
return Err(WorkError::InvalidEvent); - 194
} - 195
validate_contract(contract)?; - 196
if event.contract_id != contract.contract_id || event.revision != contract.revision { - 197
return Err(WorkError::WrongRevision { - 198
actual: event.revision, - 199
expected: contract.revision, - 200
}); - 201
} - 202
let items = contract - 203
.items - 204
.iter() - 205
.map(|item| { - 206
( - 207
item.item_id.clone(), - 208
WorkItemState { - 209
item_id: item.item_id.clone(), - 210
owner: item.owner.clone(), - 211
status: WorkItemStatus::Proposed, - 212
attempt: 0, - 213
child_session_id: None, - 214
blocker: None, - 215
evidence: Vec::new(), - 216
}, - 217
) - 218
}) - 219
.collect(); - 220
*projection = Some(WorkProjection { - 221
contract: contract.clone(), - 222
status: WorkContractStatus::Draft, - 223
items, - 224
criteria: BTreeMap::new(), - 225
}); - 226
return Ok(()); - 227
} - 228
- 229
let Some(current) = projection.as_mut() else { - 230
return Err(WorkError::MissingContract); - 231
}; - 232
if event.contract_id != current.contract.contract_id { - 233
return Err(WorkError::WrongContract { - 234
event: event.contract_id.clone(), - 235
active: current.contract.contract_id.clone(), - 236
}); - 237
} - 238
let is_revision = matches!(event.kind, WorkEventKind::ContractRevised { .. }); - 239
let expected_revision = if is_revision { - 240
current.contract.revision + 1 - 241
} else { - 242
current.contract.revision - 243
}; - 244
if event.revision != expected_revision { - 245
return Err(WorkError::WrongRevision { - 246
actual: event.revision, - 247
expected: expected_revision, - 248
}); - 249
} - 250
if matches!( - 251
&event.kind, - 252
WorkEventKind::ItemStatusChanged { .. } - 253
| WorkEventKind::ItemVerified { .. } - 254
| WorkEventKind::ItemAssigned { .. } - 255
| WorkEventKind::EvidenceAttached { .. } - 256
| WorkEventKind::VerificationRecorded { .. } - 257
) && current.status != WorkContractStatus::Active - 258
{ - 259
return Err(WorkError::InvalidEvent); - 260
} - 261
if matches!(&event.kind, WorkEventKind::AssumptionResolved { .. }) - 262
&& matches!( - 263
current.status, - 264
WorkContractStatus::Completed - 265
| WorkContractStatus::Failed - 266
| WorkContractStatus::Cancelled - 267
| WorkContractStatus::Unverified - 268
) - 269
{ - 270
return Err(WorkError::InvalidEvent); - 271
} - 272
- 273
match &event.kind { - 274
WorkEventKind::ContractCreated { .. } => unreachable!(), - 275
WorkEventKind::ContractRevised { - 276
previous_revision, - 277
contract, - 278
.. - 279
} => { - 280
if *previous_revision != current.contract.revision - 281
|| contract.revision != current.contract.revision + 1 - 282
{ - 283
return Err(WorkError::WrongRevision { - 284
actual: contract.revision, - 285
expected: current.contract.revision + 1, - 286
}); - 287
} - 288
validate_contract(contract)?; - 289
current.contract = contract.clone(); - 290
if matches!( - 291
current.status, - 292
WorkContractStatus::Completed - 293
| WorkContractStatus::Failed - 294
| WorkContractStatus::Cancelled - 295
| WorkContractStatus::Unverified - 296
) { - 297
current.status = WorkContractStatus::Draft; - 298
} - 299
current.criteria.clear(); - 300
current.items = contract - 301
.items - 302
.iter() - 303
.map(|item| { - 304
( - 305
item.item_id.clone(), - 306
WorkItemState { - 307
item_id: item.item_id.clone(), - 308
owner: item.owner.clone(), - 309
status: WorkItemStatus::Proposed, - 310
attempt: 0, - 311
child_session_id: None, - 312
blocker: None, - 313
evidence: Vec::new(), - 314
}, - 315
) - 316
}) - 317
.collect(); - 318
} - 319
WorkEventKind::ContractStatusChanged { from, to, .. } => { - 320
if current.status != *from || !valid_contract_transition(from, to) { - 321
return Err(WorkError::InvalidContractTransition { - 322
from: current.status.clone(), - 323
to: to.clone(), - 324
}); - 325
} - 326
if *to == WorkContractStatus::Completed - 327
&& (current.contract.items.iter().any(|item| { - 328
item.required - 329
&& !current - 330
.items - 331
.get(&item.item_id) - 332
.is_some_and(|state| state.status == WorkItemStatus::Succeeded) - 333
}) || current.contract.criteria.iter().any(|criterion| { - 334
criterion.required - 335
&& !matches!( - 336
current.criteria.get(&criterion.criterion_id), - 337
Some(CriterionResult::Passed { .. }) - 338
) - 339
})) - 340
{ - 341
return Err(WorkError::InvalidEvent); - 342
} - 343
current.status = to.clone(); - 344
} - 345
WorkEventKind::ItemStatusChanged { - 346
item_id, - 347
from, - 348
to, - 349
attempt, - 350
reason, - 351
} => { - 352
let current_status = current - 353
.items - 354
.get(item_id) - 355
.ok_or_else(|| WorkError::UnknownItem(item_id.clone()))? - 356
.status - 357
.clone(); - 358
if current_status != *from { - 359
return Err(WorkError::InvalidItemTransition { - 360
item: item_id.clone(), - 361
from: current_status, - 362
to: to.clone(), - 363
}); - 364
} - 365
if matches!(to, WorkItemStatus::Succeeded) { - 366
return Err(WorkError::ModelCompletion(item_id.clone())); - 367
} - 368
if !valid_item_transition(from, to) { - 369
return Err(WorkError::InvalidItemTransition { - 370
item: item_id.clone(), - 371
from: from.clone(), - 372
to: to.clone(), - 373
}); - 374
} - 375
if *to == WorkItemStatus::Running { - 376
let definition = current - 377
.contract - 378
.items - 379
.iter() - 380
.find(|item| item.item_id == *item_id) - 381
.ok_or_else(|| WorkError::UnknownItem(item_id.clone()))?; - 382
if definition.dependencies.iter().any(|dependency| { - 383
!current - 384
.items - 385
.get(dependency) - 386
.is_some_and(|dependency_state| { - 387
matches!( - 388
dependency_state.status, - 389
WorkItemStatus::Succeeded | WorkItemStatus::Skipped - 390
) - 391
}) - 392
}) { - 393
return Err(WorkError::InvalidEvent); - 394
} - 395
} - 396
if *to == WorkItemStatus::ReadyForVerification - 397
&& current.items[item_id].evidence.is_empty() - 398
{ - 399
return Err(WorkError::InvalidEvent); - 400
} - 401
let state = current - 402
.items - 403
.get_mut(item_id) - 404
.ok_or_else(|| WorkError::UnknownItem(item_id.clone()))?; - 405
state.status = to.clone(); - 406
state.attempt = *attempt; - 407
state.blocker = matches!(to, WorkItemStatus::Blocked | WorkItemStatus::Interrupted) - 408
.then_some(reason.clone()) - 409
.filter(|reason| !reason.is_empty()); - 410
} - 411
WorkEventKind::ItemVerified { item_id, attempt } => { - 412
let state = current - 413
.items - 414
.get_mut(item_id) - 415
.ok_or_else(|| WorkError::UnknownItem(item_id.clone()))?; - 416
if state.status != WorkItemStatus::ReadyForVerification || state.attempt != *attempt { - 417
return Err(WorkError::InvalidItemTransition { - 418
item: item_id.clone(), - 419
from: state.status.clone(), - 420
to: WorkItemStatus::Succeeded, - 421
}); - 422
} - 423
let definition = current - 424
.contract - 425
.items - 426
.iter() - 427
.find(|item| item.item_id == *item_id) - 428
.ok_or_else(|| WorkError::UnknownItem(item_id.clone()))?; - 429
if definition.criterion_ids.iter().any(|criterion_id| { - 430
let required = current - 431
.contract - 432
.criteria - 433
.iter() - 434
.find(|criterion| criterion.criterion_id == *criterion_id) - 435
.is_some_and(|criterion| criterion.required); - 436
required - 437
&& !matches!( - 438
current.criteria.get(criterion_id), - 439
Some(CriterionResult::Passed { .. }) - 440
) - 441
}) { - 442
return Err(WorkError::InvalidEvent); - 443
} - 444
if state.evidence.is_empty() { - 445
return Err(WorkError::InvalidEvent); - 446
} - 447
state.status = WorkItemStatus::Succeeded; - 448
} - 449
WorkEventKind::ItemAssigned { - 450
item_id, - 451
owner, - 452
child_session_id, - 453
.. - 454
} => { - 455
let state = current - 456
.items - 457
.get_mut(item_id) - 458
.ok_or_else(|| WorkError::UnknownItem(item_id.clone()))?; - 459
state.child_session_id = child_session_id.clone(); - 460
state.owner = owner.clone(); - 461
} - 462
WorkEventKind::EvidenceAttached { item_id, evidence } => { - 463
let state = current - 464
.items - 465
.get_mut(item_id) - 466
.ok_or_else(|| WorkError::UnknownItem(item_id.clone()))?; - 467
if !state.evidence.contains(evidence) { - 468
state.evidence.push(evidence.clone()); - 469
} - 470
} - 471
WorkEventKind::AssumptionResolved { - 472
assumption_id, - 473
resolution, - 474
} => { - 475
let assumption = current - 476
.contract - 477
.assumptions - 478
.iter_mut() - 479
.find(|assumption| assumption.assumption_id == *assumption_id) - 480
.ok_or(WorkError::InvalidEvent)?; - 481
assumption.resolution = Some(resolution.clone()); - 482
} - 483
WorkEventKind::VerificationRecorded { - 484
criterion_id, - 485
result, - 486
} => { - 487
if !current - 488
.contract - 489
.criteria - 490
.iter() - 491
.any(|criterion| criterion.criterion_id == *criterion_id) - 492
{ - 493
return Err(WorkError::InvalidEvent); - 494
} - 495
current - 496
.criteria - 497
.insert(criterion_id.clone(), result.clone()); - 498
} - 499
} - 500
Ok(()) - 501
} - 502
- 503
fn valid_contract_transition(from: &WorkContractStatus, to: &WorkContractStatus) -> bool { - 504
matches!( - 505
(from, to), - 506
(WorkContractStatus::Draft, WorkContractStatus::AwaitingInput) - 507
| (WorkContractStatus::Draft, WorkContractStatus::Active) - 508
| ( - 509
WorkContractStatus::AwaitingInput, - 510
WorkContractStatus::Active - 511
) - 512
| (WorkContractStatus::Active, WorkContractStatus::Blocked) - 513
| (WorkContractStatus::Active, WorkContractStatus::Verifying) - 514
| (WorkContractStatus::Active, WorkContractStatus::Cancelled) - 515
| (WorkContractStatus::Active, WorkContractStatus::Failed) - 516
| (WorkContractStatus::Blocked, WorkContractStatus::Active) - 517
| (WorkContractStatus::Verifying, WorkContractStatus::Active) - 518
| (WorkContractStatus::Verifying, WorkContractStatus::Completed) - 519
| ( - 520
WorkContractStatus::Verifying, - 521
WorkContractStatus::Unverified - 522
) - 523
) - 524
} - 525
- 526
fn valid_item_transition(from: &WorkItemStatus, to: &WorkItemStatus) -> bool { - 527
matches!( - 528
(from, to), - 529
(WorkItemStatus::Proposed, WorkItemStatus::Ready) - 530
| (WorkItemStatus::Ready, WorkItemStatus::Running) - 531
| (WorkItemStatus::Running, WorkItemStatus::Blocked) - 532
| ( - 533
WorkItemStatus::Running, - 534
WorkItemStatus::ReadyForVerification - 535
) - 536
| (WorkItemStatus::Running, WorkItemStatus::Failed) - 537
| (WorkItemStatus::Running, WorkItemStatus::Interrupted) - 538
| (WorkItemStatus::Blocked, WorkItemStatus::Ready) - 539
| (WorkItemStatus::Failed, WorkItemStatus::Ready) - 540
| (WorkItemStatus::Interrupted, WorkItemStatus::Ready) - 541
| (WorkItemStatus::ReadyForVerification, WorkItemStatus::Failed) - 542
| (WorkItemStatus::Ready, WorkItemStatus::Cancelled) - 543
| (WorkItemStatus::Running, WorkItemStatus::Cancelled) - 544
) - 545
} - 546
- 547
#[cfg(test)] - 548
mod tests { - 549
#![allow(clippy::expect_used, clippy::unwrap_used)] - 550
- 551
use super::*; - 552
use crate::types::{CriterionKind, Entry, EvidenceRef, WorkCriterion, WorkOwner}; - 553
- 554
fn contract(items: Vec<WorkItemDefinition>) -> WorkContract { - 555
WorkContract { - 556
contract_id: "work-1".into(), - 557
revision: 0, - 558
source_entry_id: "user-1".into(), - 559
objective: "ship the change".into(), - 560
constraints: Vec::new(), - 561
assumptions: Vec::new(), - 562
criteria: vec![WorkCriterion { - 563
criterion_id: "tests".into(), - 564
statement: "tests pass".into(), - 565
kind: CriterionKind::Semantic, - 566
required: true, - 567
}], - 568
items, - 569
} - 570
} - 571
- 572
fn item(id: &str, dependencies: Vec<&str>) -> WorkItemDefinition { - 573
WorkItemDefinition { - 574
item_id: id.into(), - 575
title: id.into(), - 576
instructions: "do the work".into(), - 577
dependencies: dependencies.into_iter().map(String::from).collect(), - 578
owner: WorkOwner::ParentAgent, - 579
required: true, - 580
readonly: false, - 581
path_claims: Vec::new(), - 582
criterion_ids: vec!["tests".into()], - 583
} - 584
} - 585
- 586
fn entry(event: WorkEvent) -> Entry { - 587
Entry::new(None, EntryPayload::Work(event)) - 588
} - 589
- 590
#[test] - 591
fn rejects_dependency_cycles() { - 592
let result = validate_contract(&contract(vec![item("a", vec!["b"]), item("b", vec!["a"])])); - 593
assert_eq!(result, Err(WorkError::DependencyCycle)); - 594
} - 595
- 596
#[test] - 597
fn required_items_need_required_criteria() { - 598
let mut c = contract(vec![item("a", Vec::new())]); - 599
c.items[0].criterion_ids.clear(); - 600
assert_eq!( - 601
validate_contract_for_admission(&c), - 602
Err(WorkError::InvalidEvent) - 603
); - 604
} - 605
- 606
#[test] - 607
fn projector_rejects_completion_with_unverified_work() { - 608
let c = contract(vec![item("a", Vec::new())]); - 609
let entries = [ - 610
entry(WorkEvent { - 611
contract_id: "work-1".into(), - 612
revision: 0, - 613
kind: WorkEventKind::ContractCreated { contract: c }, - 614
}), - 615
entry(WorkEvent { - 616
contract_id: "work-1".into(), - 617
revision: 0, - 618
kind: WorkEventKind::ContractStatusChanged { - 619
from: WorkContractStatus::Draft, - 620
to: WorkContractStatus::Active, - 621
reason: String::new(), - 622
}, - 623
}), - 624
entry(WorkEvent { - 625
contract_id: "work-1".into(), - 626
revision: 0, - 627
kind: WorkEventKind::ContractStatusChanged { - 628
from: WorkContractStatus::Active, - 629
to: WorkContractStatus::Verifying, - 630
reason: String::new(), - 631
}, - 632
}), - 633
entry(WorkEvent { - 634
contract_id: "work-1".into(), - 635
revision: 0, - 636
kind: WorkEventKind::ContractStatusChanged { - 637
from: WorkContractStatus::Verifying, - 638
to: WorkContractStatus::Completed, - 639
reason: String::new(), - 640
}, - 641
}), - 642
]; - 643
let refs: Vec<&Entry> = entries.iter().collect(); - 644
assert_eq!(project_work(&refs), Err(WorkError::InvalidEvent)); - 645
} - 646
- 647
#[test] - 648
fn projects_lifecycle_and_rejects_model_completion() { - 649
let c = contract(vec![item("implement", Vec::new())]); - 650
let mut entries = vec![entry(WorkEvent { - 651
contract_id: "work-1".into(), - 652
revision: 0, - 653
kind: WorkEventKind::ContractCreated { contract: c }, - 654
})]; - 655
entries.push(entry(WorkEvent { - 656
contract_id: "work-1".into(), - 657
revision: 0, - 658
kind: WorkEventKind::ContractStatusChanged { - 659
from: WorkContractStatus::Draft, - 660
to: WorkContractStatus::Active, - 661
reason: "confirmed".into(), - 662
}, - 663
})); - 664
entries.push(entry(WorkEvent { - 665
contract_id: "work-1".into(), - 666
revision: 0, - 667
kind: WorkEventKind::ItemStatusChanged { - 668
item_id: "implement".into(), - 669
from: WorkItemStatus::Proposed, - 670
to: WorkItemStatus::Ready, - 671
attempt: 0, - 672
reason: String::new(), - 673
}, - 674
})); - 675
let refs: Vec<&Entry> = entries.iter().collect(); - 676
let projection = project_work(&refs).unwrap().unwrap(); - 677
assert_eq!(projection.status, WorkContractStatus::Active); - 678
assert_eq!(projection.items["implement"].status, WorkItemStatus::Ready); - 679
- 680
entries.push(entry(WorkEvent { - 681
contract_id: "work-1".into(), - 682
revision: 0, - 683
kind: WorkEventKind::ItemStatusChanged { - 684
item_id: "implement".into(), - 685
from: WorkItemStatus::Ready, - 686
to: WorkItemStatus::Succeeded, - 687
attempt: 1, - 688
reason: "done".into(), - 689
}, - 690
})); - 691
let refs: Vec<&Entry> = entries.iter().collect(); - 692
assert!(matches!( - 693
project_work(&refs), - 694
Err(WorkError::ModelCompletion(item)) if item == "implement" - 695
)); - 696
} - 697
- 698
#[test] - 699
fn verifier_can_complete_only_with_evidence_and_passed_criteria() { - 700
let c = contract(vec![item("implement", Vec::new())]); - 701
let mut entries = vec![entry(WorkEvent { - 702
contract_id: "work-1".into(), - 703
revision: 0, - 704
kind: WorkEventKind::ContractCreated { contract: c }, - 705
})]; - 706
entries.push(entry(WorkEvent { - 707
contract_id: "work-1".into(), - 708
revision: 0, - 709
kind: WorkEventKind::ContractStatusChanged { - 710
from: WorkContractStatus::Draft, - 711
to: WorkContractStatus::Active, - 712
reason: "confirmed".into(), - 713
}, - 714
})); - 715
for (from, to) in [ - 716
(WorkItemStatus::Proposed, WorkItemStatus::Ready), - 717
(WorkItemStatus::Ready, WorkItemStatus::Running), - 718
] { - 719
entries.push(entry(WorkEvent { - 720
contract_id: "work-1".into(), - 721
revision: 0, - 722
kind: WorkEventKind::ItemStatusChanged { - 723
item_id: "implement".into(), - 724
from, - 725
to, - 726
attempt: 1, - 727
reason: String::new(), - 728
}, - 729
})); - 730
} - 731
entries.push(entry(WorkEvent { - 732
contract_id: "work-1".into(), - 733
revision: 0, - 734
kind: WorkEventKind::EvidenceAttached { - 735
item_id: "implement".into(), - 736
evidence: EvidenceRef::LedgerEntry { - 737
session_id: "s".into(), - 738
entry_id: "result".into(), - 739
}, - 740
}, - 741
})); - 742
entries.push(entry(WorkEvent { - 743
contract_id: "work-1".into(), - 744
revision: 0, - 745
kind: WorkEventKind::ItemStatusChanged { - 746
item_id: "implement".into(), - 747
from: WorkItemStatus::Running, - 748
to: WorkItemStatus::ReadyForVerification, - 749
attempt: 1, - 750
reason: String::new(), - 751
}, - 752
})); - 753
entries.push(entry(WorkEvent { - 754
contract_id: "work-1".into(), - 755
revision: 0, - 756
kind: WorkEventKind::VerificationRecorded { - 757
criterion_id: "tests".into(), - 758
result: CriterionResult::Passed { - 759
evidence: "result".into(), - 760
}, - 761
}, - 762
})); - 763
entries.push(entry(WorkEvent { - 764
contract_id: "work-1".into(), - 765
revision: 0, - 766
kind: WorkEventKind::ItemVerified { - 767
item_id: "implement".into(), - 768
attempt: 1, - 769
}, - 770
})); - 771
let refs: Vec<&Entry> = entries.iter().collect(); - 772
let projection = project_work(&refs).unwrap().unwrap(); - 773
assert_eq!( - 774
projection.items["implement"].status, - 775
WorkItemStatus::Succeeded - 776
); - 777
} - 778
- 779
#[test] - 780
fn assumption_resolution_is_projected_and_durable() { - 781
let mut c = contract(vec![item("implement", Vec::new())]); - 782
c.assumptions.push(crate::types::WorkAssumption { - 783
assumption_id: "scope".into(), - 784
text: "use the current workspace".into(), - 785
requires_confirmation: true, - 786
resolution: None, - 787
}); - 788
let entries = [ - 789
entry(WorkEvent { - 790
contract_id: "work-1".into(), - 791
revision: 0, - 792
kind: WorkEventKind::ContractCreated { contract: c }, - 793
}), - 794
entry(WorkEvent { - 795
contract_id: "work-1".into(), - 796
revision: 0, - 797
kind: WorkEventKind::AssumptionResolved { - 798
assumption_id: "scope".into(), - 799
resolution: "confirmed".into(), - 800
}, - 801
}), - 802
]; - 803
let refs: Vec<&Entry> = entries.iter().collect(); - 804
assert_eq!( - 805
project_work(&refs).unwrap().unwrap().contract.assumptions[0].resolution, - 806
Some("confirmed".into()) - 807
); - 808
} - 809
- 810
#[test] - 811
fn terminal_contract_rejects_late_assumption_resolution() { - 812
let mut c = contract(vec![item("implement", Vec::new())]); - 813
c.assumptions.push(crate::types::WorkAssumption { - 814
assumption_id: "scope".into(), - 815
text: "use the current workspace".into(), - 816
requires_confirmation: false, - 817
resolution: None, - 818
}); - 819
let entries = [ - 820
entry(WorkEvent { - 821
contract_id: "work-1".into(), - 822
revision: 0, - 823
kind: WorkEventKind::ContractCreated { contract: c }, - 824
}), - 825
entry(WorkEvent { - 826
contract_id: "work-1".into(), - 827
revision: 0, - 828
kind: WorkEventKind::ContractStatusChanged { - 829
from: WorkContractStatus::Draft, - 830
to: WorkContractStatus::Active, - 831
reason: "confirmed".into(), - 832
}, - 833
}), - 834
entry(WorkEvent { - 835
contract_id: "work-1".into(), - 836
revision: 0, - 837
kind: WorkEventKind::ItemStatusChanged { - 838
item_id: "implement".into(), - 839
from: WorkItemStatus::Proposed, - 840
to: WorkItemStatus::Ready, - 841
attempt: 0, - 842
reason: String::new(), - 843
}, - 844
}), - 845
entry(WorkEvent { - 846
contract_id: "work-1".into(), - 847
revision: 0, - 848
kind: WorkEventKind::ItemStatusChanged { - 849
item_id: "implement".into(), - 850
from: WorkItemStatus::Ready, - 851
to: WorkItemStatus::Running, - 852
attempt: 1, - 853
reason: String::new(), - 854
}, - 855
}), - 856
entry(WorkEvent { - 857
contract_id: "work-1".into(), - 858
revision: 0, - 859
kind: WorkEventKind::EvidenceAttached { - 860
item_id: "implement".into(), - 861
evidence: EvidenceRef::LedgerEntry { - 862
session_id: "s".into(), - 863
entry_id: "result".into(), - 864
}, - 865
}, - 866
}), - 867
entry(WorkEvent { - 868
contract_id: "work-1".into(), - 869
revision: 0, - 870
kind: WorkEventKind::ItemStatusChanged { - 871
item_id: "implement".into(), - 872
from: WorkItemStatus::Running, - 873
to: WorkItemStatus::ReadyForVerification, - 874
attempt: 1, - 875
reason: String::new(), - 876
}, - 877
}), - 878
entry(WorkEvent { - 879
contract_id: "work-1".into(), - 880
revision: 0, - 881
kind: WorkEventKind::VerificationRecorded { - 882
criterion_id: "tests".into(), - 883
result: CriterionResult::Passed { - 884
evidence: "result".into(), - 885
}, - 886
}, - 887
}), - 888
entry(WorkEvent { - 889
contract_id: "work-1".into(), - 890
revision: 0, - 891
kind: WorkEventKind::ItemVerified { - 892
item_id: "implement".into(), - 893
attempt: 1, - 894
}, - 895
}), - 896
entry(WorkEvent { - 897
contract_id: "work-1".into(), - 898
revision: 0, - 899
kind: WorkEventKind::ContractStatusChanged { - 900
from: WorkContractStatus::Active, - 901
to: WorkContractStatus::Verifying, - 902
reason: "verification started".into(), - 903
}, - 904
}), - 905
entry(WorkEvent { - 906
contract_id: "work-1".into(), - 907
revision: 0, - 908
kind: WorkEventKind::ContractStatusChanged { - 909
from: WorkContractStatus::Verifying, - 910
to: WorkContractStatus::Completed, - 911
reason: "verified".into(), - 912
}, - 913
}), - 914
]; - 915
let refs: Vec<&Entry> = entries.iter().collect(); - 916
let projection = project_work(&refs).unwrap().unwrap(); - 917
assert_eq!(projection.status, WorkContractStatus::Completed); - 918
- 919
let mut late = entries.to_vec(); - 920
late.push(entry(WorkEvent { - 921
contract_id: "work-1".into(), - 922
revision: 0, - 923
kind: WorkEventKind::AssumptionResolved { - 924
assumption_id: "scope".into(), - 925
resolution: "changed".into(), - 926
}, - 927
})); - 928
let refs: Vec<&Entry> = late.iter().collect(); - 929
assert_eq!(project_work(&refs), Err(WorkError::InvalidEvent)); - 930
} - 931
- 932
#[test] - 933
fn revision_requires_next_revision_and_valid_contract() { - 934
let c = contract(vec![item("a", Vec::new())]); - 935
let replacement = WorkContract { - 936
revision: 1, - 937
..c.clone() - 938
}; - 939
let entries = [ - 940
entry(WorkEvent { - 941
contract_id: "work-1".into(), - 942
revision: 0, - 943
kind: WorkEventKind::ContractCreated { contract: c }, - 944
}), - 945
entry(WorkEvent { - 946
contract_id: "work-1".into(), - 947
revision: 1, - 948
kind: WorkEventKind::ContractRevised { - 949
previous_revision: 0, - 950
contract: replacement, - 951
reason: "discovery".into(), - 952
}, - 953
}), - 954
]; - 955
let refs: Vec<&Entry> = entries.iter().collect(); - 956
assert_eq!(project_work(&refs).unwrap().unwrap().contract.revision, 1); - 957
} - 958
} - 959
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.