1use std::collections::{BTreeMap, BTreeSet};
2
3use chrono::{DateTime, Duration, Utc};
4use serde_json::json;
5
6use crate::WorkGraphError;
7use crate::machines::{work_attention_lifecycle as attention_dsl, workgraph_lifecycle as wg_dsl};
8use crate::types::{
9 AddEvidenceRequest, AttentionDelegatedAuthority, ClaimWorkItemRequest, CloseWorkItemRequest,
10 CreateWorkItemRequest, PolicyEscalateRequest, ProjectedAttentionAuthority,
11 ReleaseWorkItemRequest, UpdateWorkItemRequest, WorkAttentionBinding, WorkAttentionMode,
12 WorkAttentionStatus, WorkClaim, WorkCompletionPolicy, WorkEdge, WorkEdgeKind, WorkGraphEvent,
13 WorkGraphEventKind, WorkGraphMachineState, WorkItem, WorkItemId, WorkNamespace, WorkStatus,
14};
15
16pub use wg_dsl::WorkGraphPublicErrorClass;
22
23pub use wg_dsl::WorkPublicConfirmationAdmissionKind;
31
32pub use wg_dsl::WorkCompletionPolicyMutationAdmissionKind;
41pub use wg_dsl::WorkPolicyEscalationAdmissionKind;
42
43#[derive(Debug, Default, Clone, Copy)]
44pub struct WorkAttentionMachine;
45
46impl WorkAttentionMachine {
47 pub fn pause(
48 mut binding: WorkAttentionBinding,
49 expected_revision: u64,
50 until: Option<DateTime<Utc>>,
51 now: DateTime<Utc>,
52 ) -> Result<WorkAttentionBinding, WorkGraphError> {
53 let input = attention_dsl::WorkAttentionLifecycleInput::Pause {
54 expected_revision,
55 until_utc_ms: until.map(datetime_to_millis),
56 };
57 binding.machine_state = apply_attention_dsl(&binding, input, Some(expected_revision))?;
58 sync_attention_from_machine_state(&mut binding);
59 binding.updated_at = now;
60 Ok(binding)
61 }
62
63 pub fn resume(
64 mut binding: WorkAttentionBinding,
65 expected_revision: u64,
66 now: DateTime<Utc>,
67 ) -> Result<WorkAttentionBinding, WorkGraphError> {
68 let input = attention_dsl::WorkAttentionLifecycleInput::Resume { expected_revision };
69 binding.machine_state = apply_attention_dsl(&binding, input, Some(expected_revision))?;
70 sync_attention_from_machine_state(&mut binding);
71 binding.updated_at = now;
72 Ok(binding)
73 }
74
75 pub fn stop(
76 mut binding: WorkAttentionBinding,
77 expected_revision: u64,
78 now: DateTime<Utc>,
79 ) -> Result<WorkAttentionBinding, WorkGraphError> {
80 let input = attention_dsl::WorkAttentionLifecycleInput::Stop {
81 expected_revision,
82 at_utc_ms: datetime_to_millis(now),
83 };
84 binding.machine_state = apply_attention_dsl(&binding, input, Some(expected_revision))?;
85 sync_attention_from_machine_state(&mut binding);
86 binding.updated_at = now;
87 Ok(binding)
88 }
89
90 pub fn supersede(
91 mut binding: WorkAttentionBinding,
92 expected_revision: u64,
93 superseded_by_binding_id: &crate::types::WorkAttentionBindingId,
94 now: DateTime<Utc>,
95 ) -> Result<WorkAttentionBinding, WorkGraphError> {
96 let input = attention_dsl::WorkAttentionLifecycleInput::Supersede {
97 expected_revision,
98 superseded_by_binding_key: attention_dsl::WorkAttentionBindingKey(
99 superseded_by_binding_id.as_str().to_string(),
100 ),
101 at_utc_ms: datetime_to_millis(now),
102 };
103 binding.machine_state = apply_attention_dsl(&binding, input, Some(expected_revision))?;
104 sync_attention_from_machine_state(&mut binding);
105 binding.updated_at = now;
106 Ok(binding)
107 }
108
109 pub fn classify_eligibility_at(
120 binding: &WorkAttentionBinding,
121 now: DateTime<Utc>,
122 ) -> Result<bool, WorkGraphError> {
123 let mut dsl_auth =
124 attention_dsl::WorkAttentionLifecycleMachineAuthority::recover_from_state(
125 binding.machine_state.clone(),
126 )
127 .map_err(|error| {
128 WorkGraphError::InvalidTransition(format!(
129 "attention binding {} refused eligibility recovery: {error:?}",
130 binding.binding_id
131 ))
132 })?;
133 let transition = attention_dsl::WorkAttentionLifecycleMachineMutator::apply(
134 &mut dsl_auth,
135 attention_dsl::WorkAttentionLifecycleInput::ClassifyAttentionEligibility {
136 now_utc_ms: datetime_to_millis(now),
137 },
138 )
139 .map_err(|error| {
140 WorkGraphError::InvalidTransition(format!(
141 "attention binding {} refused eligibility classification: {error:?}",
142 binding.binding_id
143 ))
144 })?;
145
146 let mut classified = None;
147 for effect in transition.effects() {
148 if let attention_dsl::WorkAttentionLifecycleEffect::AttentionEligibilityClassified {
149 eligible,
150 } = effect
151 && classified.replace(*eligible).is_some()
152 {
153 return Err(WorkGraphError::Store(format!(
154 "attention binding {} emitted multiple eligibility verdicts",
155 binding.binding_id
156 )));
157 }
158 }
159
160 classified.ok_or_else(|| {
161 WorkGraphError::Store(format!(
162 "attention binding {} emitted no eligibility verdict",
163 binding.binding_id
164 ))
165 })
166 }
167
168 pub fn classify_authority(
180 binding: &WorkAttentionBinding,
181 ) -> Result<ProjectedAttentionAuthority, WorkGraphError> {
182 let mode = attention_mode_to_dsl(binding.mode);
183 let delegated_authority = attention_delegated_authority_to_dsl(binding.delegated_authority);
184 let mut dsl_auth =
185 attention_dsl::WorkAttentionLifecycleMachineAuthority::recover_from_state(
186 binding.machine_state.clone(),
187 )
188 .map_err(|error| {
189 WorkGraphError::InvalidTransition(format!(
190 "attention binding {} refused authority recovery: {error:?}",
191 binding.binding_id
192 ))
193 })?;
194 let transition = attention_dsl::WorkAttentionLifecycleMachineMutator::apply(
195 &mut dsl_auth,
196 attention_dsl::WorkAttentionLifecycleInput::ClassifyAttentionAuthority {
197 mode,
198 delegated_authority,
199 },
200 )
201 .map_err(|error| {
202 WorkGraphError::InvalidTransition(format!(
203 "attention binding {} refused authority classification: {error:?}",
204 binding.binding_id
205 ))
206 })?;
207
208 let mut classified = None;
209 for effect in transition.effects() {
210 if let attention_dsl::WorkAttentionLifecycleEffect::AttentionAuthorityClassified {
211 can_get,
212 can_add_evidence,
213 can_release,
214 can_update,
215 can_block,
216 can_create,
217 can_link,
218 can_link_parent,
219 can_link_related,
220 can_link_derived_from,
221 can_close_own_review_item,
222 can_close_if_policy_allows,
223 } = effect
224 {
225 let verdict = ProjectedAttentionAuthority {
226 can_get: *can_get,
227 can_add_evidence: *can_add_evidence,
228 can_release: *can_release,
229 can_update: *can_update,
230 can_block: *can_block,
231 can_create: *can_create,
232 can_link: *can_link,
233 can_link_parent: *can_link_parent,
234 can_link_related: *can_link_related,
235 can_link_derived_from: *can_link_derived_from,
236 can_close_own_review_item: *can_close_own_review_item,
237 can_close_if_policy_allows: *can_close_if_policy_allows,
238 };
239 if classified.replace(verdict).is_some() {
240 return Err(WorkGraphError::Store(format!(
241 "attention binding {} emitted multiple authority verdicts",
242 binding.binding_id
243 )));
244 }
245 }
246 }
247
248 classified.ok_or_else(|| {
249 WorkGraphError::Store(format!(
250 "attention binding {} emitted no authority verdict",
251 binding.binding_id
252 ))
253 })
254 }
255}
256
257fn attention_mode_to_dsl(mode: WorkAttentionMode) -> attention_dsl::WorkAttentionMode {
258 match mode {
259 WorkAttentionMode::Pursue => attention_dsl::WorkAttentionMode::Pursue,
260 WorkAttentionMode::Coordinate => attention_dsl::WorkAttentionMode::Coordinate,
261 WorkAttentionMode::Review => attention_dsl::WorkAttentionMode::Review,
262 WorkAttentionMode::Falsify => attention_dsl::WorkAttentionMode::Falsify,
263 WorkAttentionMode::Judge => attention_dsl::WorkAttentionMode::Judge,
264 WorkAttentionMode::Observe => attention_dsl::WorkAttentionMode::Observe,
265 }
266}
267
268fn attention_delegated_authority_to_dsl(
269 authority: AttentionDelegatedAuthority,
270) -> attention_dsl::AttentionDelegatedAuthority {
271 match authority {
272 AttentionDelegatedAuthority::AddEvidence => {
273 attention_dsl::AttentionDelegatedAuthority::AddEvidence
274 }
275 AttentionDelegatedAuthority::CloseOwnReviewItem => {
276 attention_dsl::AttentionDelegatedAuthority::CloseOwnReviewItem
277 }
278 AttentionDelegatedAuthority::RequestClosure => {
279 attention_dsl::AttentionDelegatedAuthority::RequestClosure
280 }
281 AttentionDelegatedAuthority::CloseIfPolicyAllows => {
282 attention_dsl::AttentionDelegatedAuthority::CloseIfPolicyAllows
283 }
284 }
285}
286
287#[derive(Debug, Default, Clone, Copy)]
288pub struct WorkGraphMachine;
289
290impl WorkGraphMachine {
291 pub fn validate_item_projection(item: &WorkItem) -> Result<(), WorkGraphError> {
301 validate_item_machine_projection(item)
302 }
303
304 pub fn public_error_class(
313 error: &WorkGraphError,
314 ) -> Result<WorkGraphPublicErrorClass, WorkGraphError> {
315 let kind = work_graph_error_kind(error);
316 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
317 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
318 &mut dsl_auth,
319 wg_dsl::WorkGraphLifecycleInput::ClassifyWorkGraphPublicError { kind },
320 )
321 .map_err(|transition_error| {
322 WorkGraphError::Store(format!(
323 "generated WorkGraph public error classification refused kind {kind:?}: {transition_error:?}"
324 ))
325 })?;
326
327 let mut classified = None;
328 for effect in transition.effects() {
329 if let wg_dsl::WorkGraphLifecycleEffect::WorkGraphPublicErrorClassified {
330 kind: emitted_kind,
331 public_class,
332 } = effect
333 {
334 if *emitted_kind != kind {
335 return Err(WorkGraphError::Store(format!(
336 "generated WorkGraph public error classification emitted kind {emitted_kind:?} while classifying {kind:?}"
337 )));
338 }
339 if classified.replace(*public_class).is_some() {
340 return Err(WorkGraphError::Store(format!(
341 "generated WorkGraph public error classification emitted multiple classes for kind {kind:?}"
342 )));
343 }
344 }
345 }
346
347 classified.ok_or_else(|| {
348 WorkGraphError::Store(format!(
349 "generated WorkGraph public error classification did not emit a class for kind {kind:?}"
350 ))
351 })
352 }
353
354 pub fn classify_public_confirmation_admission(
367 completion_policy: &crate::types::WorkCompletionPolicy,
368 ) -> Result<wg_dsl::WorkPublicConfirmationAdmissionKind, WorkGraphError> {
369 let policy = completion_policy.to_machine();
370 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
371 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
372 &mut dsl_auth,
373 wg_dsl::WorkGraphLifecycleInput::ClassifyPublicConfirmationAdmission {
374 completion_policy: policy,
375 },
376 )
377 .map_err(|error| {
378 WorkGraphError::InvalidInput(format!(
379 "WorkGraphLifecycle refused public-confirmation admission for {policy:?}: {error:?}"
380 ))
381 })?;
382
383 let mut admission = None;
384 for effect in transition.effects() {
385 if let wg_dsl::WorkGraphLifecycleEffect::PublicConfirmationAdmissionClassified {
386 admission: emitted,
387 } = effect
388 && admission.replace(*emitted).is_some()
389 {
390 return Err(WorkGraphError::Store(format!(
391 "WorkGraphLifecycle public-confirmation admission emitted multiple verdicts for {policy:?}"
392 )));
393 }
394 }
395
396 admission.ok_or_else(|| {
397 WorkGraphError::Store(format!(
398 "WorkGraphLifecycle public-confirmation admission emitted no verdict for {policy:?}"
399 ))
400 })
401 }
402
403 pub fn classify_create_completion_policy_admission(
415 completion_policy: &crate::types::WorkCompletionPolicy,
416 ) -> Result<wg_dsl::WorkCreateCompletionPolicyAdmissionKind, WorkGraphError> {
417 let policy = completion_policy.to_machine();
418 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
419 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
420 &mut dsl_auth,
421 wg_dsl::WorkGraphLifecycleInput::ClassifyCreateCompletionPolicyAdmission {
422 completion_policy: policy,
423 },
424 )
425 .map_err(|error| {
426 WorkGraphError::InvalidInput(format!(
427 "WorkGraphLifecycle refused create completion-policy admission for {policy:?}: {error:?}"
428 ))
429 })?;
430
431 let mut admission = None;
432 for effect in transition.effects() {
433 if let wg_dsl::WorkGraphLifecycleEffect::CreateCompletionPolicyAdmissionClassified {
434 admission: emitted,
435 } = effect
436 && admission.replace(*emitted).is_some()
437 {
438 return Err(WorkGraphError::Store(format!(
439 "WorkGraphLifecycle create completion-policy admission emitted multiple verdicts for {policy:?}"
440 )));
441 }
442 }
443
444 admission.ok_or_else(|| {
445 WorkGraphError::Store(format!(
446 "WorkGraphLifecycle create completion-policy admission emitted no verdict for {policy:?}"
447 ))
448 })
449 }
450
451 pub fn classify_completion_policy_mutation_admission(
465 item: &WorkItem,
466 requested: &crate::types::WorkCompletionPolicy,
467 ) -> Result<wg_dsl::WorkCompletionPolicyMutationAdmissionKind, WorkGraphError> {
468 validate_item_machine_projection(item)?;
469 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(
470 item.machine_state.clone(),
471 )
472 .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
473 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
474 &mut dsl_auth,
475 wg_dsl::WorkGraphLifecycleInput::ClassifyCompletionPolicyMutationAdmission {
476 requested_completion_policy: requested.to_machine(),
477 requested_completion_supervisor_owner_key: requested.supervisor_owner_key(),
478 requested_completion_reviewer_quorum_threshold: requested
479 .reviewer_quorum_threshold(),
480 },
481 )
482 .map_err(|error| {
483 WorkGraphError::InvalidTransition(format!(
484 "work item {} refused completion-policy mutation classification: {error:?}",
485 item.id
486 ))
487 })?;
488
489 let mut admission = None;
490 for effect in transition.effects() {
491 if let wg_dsl::WorkGraphLifecycleEffect::CompletionPolicyMutationAdmissionClassified {
492 admission: emitted,
493 } = effect
494 && admission.replace(*emitted).is_some()
495 {
496 return Err(WorkGraphError::Store(format!(
497 "work item {} emitted multiple completion-policy mutation verdicts",
498 item.id
499 )));
500 }
501 }
502
503 admission.ok_or_else(|| {
504 WorkGraphError::Store(format!(
505 "work item {} emitted no completion-policy mutation verdict",
506 item.id
507 ))
508 })
509 }
510
511 pub fn classify_confirmation_admission(
526 completion_policy: &crate::types::WorkCompletionPolicy,
527 requested_principal: Option<&crate::types::WorkOwnerKey>,
528 supplied_evidence_kind: wg_dsl::WorkConfirmationEvidenceObservation,
529 ) -> Result<wg_dsl::WorkConfirmationAdmissionKind, WorkGraphError> {
530 let policy = completion_policy.to_machine();
531 let requested_principal_owner_key =
532 requested_principal.map(crate::types::work_owner_key_to_machine);
533 let requested_principal_kind = requested_principal
534 .map(|principal| crate::types::work_owner_kind_to_machine(principal.kind));
535 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
536 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
537 &mut dsl_auth,
538 wg_dsl::WorkGraphLifecycleInput::ClassifyConfirmationAdmission {
539 completion_policy: policy,
540 completion_supervisor_owner_key: completion_policy.supervisor_owner_key(),
541 requested_principal_owner_key,
542 requested_principal_kind,
543 supplied_evidence_kind,
544 },
545 )
546 .map_err(|error| {
547 WorkGraphError::Store(format!(
548 "WorkGraphLifecycle refused confirmation admission for {policy:?}: {error:?}"
549 ))
550 })?;
551
552 let mut admission = None;
553 for effect in transition.effects() {
554 if let wg_dsl::WorkGraphLifecycleEffect::ConfirmationAdmissionClassified {
555 admission: emitted,
556 } = effect
557 && admission.replace(*emitted).is_some()
558 {
559 return Err(WorkGraphError::Store(format!(
560 "WorkGraphLifecycle confirmation admission emitted multiple verdicts for {policy:?}"
561 )));
562 }
563 }
564
565 admission.ok_or_else(|| {
566 WorkGraphError::Store(format!(
567 "WorkGraphLifecycle confirmation admission emitted no verdict for {policy:?}"
568 ))
569 })
570 }
571
572 pub fn classify_terminality(item: &WorkItem) -> Result<bool, WorkGraphError> {
581 validate_item_machine_projection(item)?;
582 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(
583 item.machine_state.clone(),
584 )
585 .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
586 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
587 &mut dsl_auth,
588 wg_dsl::WorkGraphLifecycleInput::ClassifyTerminality {},
589 )
590 .map_err(|error| {
591 WorkGraphError::InvalidTransition(format!(
592 "work item {} refused terminality classification: {error:?}",
593 item.id
594 ))
595 })?;
596
597 let mut classified = None;
598 for effect in transition.effects() {
599 if let wg_dsl::WorkGraphLifecycleEffect::WorkItemTerminalityClassified { terminal } =
600 effect
601 && classified.replace(*terminal).is_some()
602 {
603 return Err(WorkGraphError::Store(format!(
604 "work item {} emitted multiple terminality verdicts",
605 item.id
606 )));
607 }
608 }
609
610 classified.ok_or_else(|| {
611 WorkGraphError::Store(format!(
612 "work item {} emitted no terminality verdict",
613 item.id
614 ))
615 })
616 }
617
618 pub fn classify_blocker_satisfied(
629 gated_item: &WorkItem,
630 blocker: Option<&WorkItem>,
631 ) -> Result<bool, WorkGraphError> {
632 validate_item_machine_projection(gated_item)?;
633 let blocker_lifecycle_phase = match blocker {
634 Some(blocker) => blocker.machine_state.lifecycle_phase,
635 None => wg_dsl::WorkLifecycleState::Absent,
636 };
637 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(
638 gated_item.machine_state.clone(),
639 )
640 .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
641 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
642 &mut dsl_auth,
643 wg_dsl::WorkGraphLifecycleInput::ClassifyBlockerSatisfied {
644 blocker_present: blocker.is_some(),
645 blocker_lifecycle_phase,
646 },
647 )
648 .map_err(|error| {
649 WorkGraphError::InvalidTransition(format!(
650 "work item {} refused blocker satisfaction classification: {error:?}",
651 gated_item.id
652 ))
653 })?;
654
655 let mut classified = None;
656 for effect in transition.effects() {
657 if let wg_dsl::WorkGraphLifecycleEffect::BlockerSatisfactionClassified { satisfied } =
658 effect
659 && classified.replace(*satisfied).is_some()
660 {
661 return Err(WorkGraphError::Store(format!(
662 "work item {} emitted multiple blocker satisfaction verdicts",
663 gated_item.id
664 )));
665 }
666 }
667
668 classified.ok_or_else(|| {
669 WorkGraphError::Store(format!(
670 "work item {} emitted no blocker satisfaction verdict",
671 gated_item.id
672 ))
673 })
674 }
675
676 pub fn create_item(
677 request: CreateWorkItemRequest,
678 realm_id: String,
679 namespace: WorkNamespace,
680 now: DateTime<Utc>,
681 ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
682 let title = validate_title(request.title)?;
683 let status = request.status.unwrap_or_default();
684 let input = match classify_create_status_admission(status)? {
691 wg_dsl::WorkCreateStatusAdmissionKind::AdmittedOpen => {
692 wg_dsl::WorkGraphLifecycleInput::CreateOpen {
693 due_at_utc_ms: request.due_at.map(datetime_to_millis),
694 not_before_utc_ms: request.not_before.map(datetime_to_millis),
695 snoozed_until_utc_ms: request.snoozed_until.map(datetime_to_millis),
696 completion_policy: request.completion_policy.clone().to_machine(),
697 completion_supervisor_owner_key: request
698 .completion_policy
699 .supervisor_owner_key(),
700 completion_reviewer_quorum_threshold: request
701 .completion_policy
702 .reviewer_quorum_threshold(),
703 unresolved_blocker_count: 0,
704 }
705 }
706 wg_dsl::WorkCreateStatusAdmissionKind::AdmittedBlocked => {
707 wg_dsl::WorkGraphLifecycleInput::CreateBlocked {
708 due_at_utc_ms: request.due_at.map(datetime_to_millis),
709 not_before_utc_ms: request.not_before.map(datetime_to_millis),
710 snoozed_until_utc_ms: request.snoozed_until.map(datetime_to_millis),
711 completion_policy: request.completion_policy.clone().to_machine(),
712 completion_supervisor_owner_key: request
713 .completion_policy
714 .supervisor_owner_key(),
715 completion_reviewer_quorum_threshold: request
716 .completion_policy
717 .reviewer_quorum_threshold(),
718 unresolved_blocker_count: 0,
719 }
720 }
721 wg_dsl::WorkCreateStatusAdmissionKind::Denied => {
722 return Err(WorkGraphError::InvalidTransition(
723 "new work items may only start open or blocked".to_string(),
724 ));
725 }
726 };
727 let dsl_state = apply_new_item_dsl(input)?;
728 let mut item = WorkItem {
729 id: WorkItemId::generated(),
730 realm_id,
731 namespace,
732 title,
733 description: request.description,
734 status: work_status_from_dsl(dsl_state.lifecycle_phase)?,
735 completion_policy: request.completion_policy,
736 priority: request.priority,
737 labels: normalize_labels(request.labels)?,
738 owner: None,
739 claim: None,
740 machine_state: dsl_state.clone(),
741 revision: dsl_state.revision,
742 due_at: dsl_state.due_at_utc_ms.and_then(millis_to_datetime),
743 not_before: dsl_state.not_before_utc_ms.and_then(millis_to_datetime),
744 snoozed_until: dsl_state.snoozed_until_utc_ms.and_then(millis_to_datetime),
745 created_at: now,
746 updated_at: now,
747 terminal_at: dsl_state.terminal_at_utc_ms.and_then(millis_to_datetime),
748 external_refs: request.external_refs,
749 evidence_refs: request.evidence_refs,
750 };
751 sync_item_from_machine_state(&mut item)?;
752 let event = item_event(&item, WorkGraphEventKind::Created, now)?;
753 Ok((item, event))
754 }
755
756 pub fn update_item(
757 mut item: WorkItem,
758 request: UpdateWorkItemRequest,
759 now: DateTime<Utc>,
760 ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
761 let due_at = request.due_at.or(item.due_at);
762 let not_before = request.not_before.or(item.not_before);
763 let snoozed_until = request.snoozed_until.or(item.snoozed_until);
764 let completion_policy = request
765 .completion_policy
766 .unwrap_or_else(|| item.completion_policy.clone());
767 let dsl_state = apply_item_dsl(
768 &item,
769 item.machine_state.unresolved_blocker_count,
770 wg_dsl::WorkGraphLifecycleInput::Update {
771 expected_revision: request.expected_revision,
772 due_at_utc_ms: due_at.map(datetime_to_millis),
773 not_before_utc_ms: not_before.map(datetime_to_millis),
774 snoozed_until_utc_ms: snoozed_until.map(datetime_to_millis),
775 completion_policy: completion_policy.clone().to_machine(),
776 completion_supervisor_owner_key: completion_policy.supervisor_owner_key(),
777 completion_reviewer_quorum_threshold: completion_policy.reviewer_quorum_threshold(),
778 unresolved_blocker_count: item.machine_state.unresolved_blocker_count,
779 },
780 Some(request.expected_revision),
781 )?;
782
783 if let Some(title) = request.title {
784 item.title = validate_title(title)?;
785 }
786 if let Some(description) = request.description {
787 item.description = Some(description);
788 }
789 if let Some(priority) = request.priority {
790 item.priority = priority;
791 }
792 if let Some(labels) = request.labels {
793 item.labels = normalize_labels(labels)?;
794 }
795 item.machine_state = dsl_state;
796 sync_item_from_machine_state(&mut item)?;
797 if !request.external_refs.is_empty() {
798 item.external_refs = request.external_refs;
799 }
800 item.updated_at = now;
801 let event = item_event(&item, WorkGraphEventKind::Updated, now)?;
802 Ok((item, event))
803 }
804
805 pub fn escalate_policy(
806 mut item: WorkItem,
807 request: PolicyEscalateRequest,
808 now: DateTime<Utc>,
809 ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
810 validate_item_machine_projection(&item)?;
811 let mut state = item.machine_state.clone();
812 state.unresolved_blocker_count = item.machine_state.unresolved_blocker_count;
813 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(state)
814 .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
815 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
816 &mut dsl_auth,
817 wg_dsl::WorkGraphLifecycleInput::PolicyEscalate {
818 expected_revision: request.expected_revision,
819 requested_completion_policy: request.completion_policy.to_machine(),
820 requested_completion_supervisor_owner_key: request
821 .completion_policy
822 .supervisor_owner_key(),
823 requested_completion_reviewer_quorum_threshold: request
824 .completion_policy
825 .reviewer_quorum_threshold(),
826 },
827 )
828 .map_err(|error| {
829 if item.revision != request.expected_revision {
830 return WorkGraphError::StaleRevision {
831 id: item.id.clone(),
832 expected: request.expected_revision,
833 actual: item.revision,
834 };
835 }
836 WorkGraphError::InvalidTransition(format!("{error:?}"))
837 })?;
838
839 let mut admission = None;
840 for effect in transition.effects() {
841 if let wg_dsl::WorkGraphLifecycleEffect::PolicyEscalationAdmissionClassified {
842 admission: emitted,
843 } = effect
844 && admission.replace(*emitted).is_some()
845 {
846 return Err(WorkGraphError::Store(format!(
847 "work item {} emitted multiple policy escalation verdicts",
848 item.id
849 )));
850 }
851 }
852 match admission.ok_or_else(|| {
853 WorkGraphError::Store(format!(
854 "work item {} emitted no policy escalation verdict",
855 item.id
856 ))
857 })? {
858 WorkPolicyEscalationAdmissionKind::Admitted => {}
859 WorkPolicyEscalationAdmissionKind::Denied => {
860 return Err(WorkGraphError::InvalidInput(format!(
861 "completion policy for work item {} can only be monotonically tightened",
862 item.id
863 )));
864 }
865 }
866
867 item.machine_state = dsl_auth.state().clone();
868 sync_item_from_machine_state(&mut item)?;
869 item.updated_at = now;
870 let event = item_event(&item, WorkGraphEventKind::Updated, now)?;
871 Ok((item, event))
872 }
873
874 pub fn claim_item(
875 item: WorkItem,
876 request: ClaimWorkItemRequest,
877 now: DateTime<Utc>,
878 ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
879 Self::claim_ready_item(item, request, now)
880 }
881
882 pub fn claim_ready_item(
883 item: WorkItem,
884 request: ClaimWorkItemRequest,
885 now: DateTime<Utc>,
886 ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
887 Self::claim_item_with_unresolved_blockers(
888 item.clone(),
889 item.machine_state.unresolved_blocker_count,
890 request,
891 now,
892 )
893 }
894
895 pub fn refresh_eligibility(
896 mut item: WorkItem,
897 unresolved_blocker_count: u64,
898 now: DateTime<Utc>,
899 ) -> Result<Option<(WorkItem, WorkGraphEvent)>, WorkGraphError> {
900 if item.machine_state.unresolved_blocker_count == unresolved_blocker_count {
901 return Ok(None);
902 }
903 let dsl_state = apply_item_dsl(
904 &item,
905 unresolved_blocker_count,
906 wg_dsl::WorkGraphLifecycleInput::RefreshEligibility {
907 unresolved_blocker_count,
908 },
909 None,
910 )?;
911 item.machine_state = dsl_state;
912 sync_item_from_machine_state(&mut item)?;
913 item.updated_at = now;
914 let event = item_event(&item, WorkGraphEventKind::Updated, now)?;
915 Ok(Some((item, event)))
916 }
917
918 pub(crate) fn claim_item_with_unresolved_blockers(
919 mut item: WorkItem,
920 unresolved_blocker_count: u64,
921 request: ClaimWorkItemRequest,
922 now: DateTime<Utc>,
923 ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
924 let lease_expires_at = request.lease_expires_at.or_else(|| {
925 request
926 .lease_seconds
927 .map(|seconds| now + seconds_to_duration(seconds))
928 });
929 let owner_key = work_owner_key(&request.owner)?;
930 let dsl_state = apply_item_dsl(
931 &item,
932 unresolved_blocker_count,
933 wg_dsl::WorkGraphLifecycleInput::Claim {
934 expected_revision: request.expected_revision,
935 owner_key,
936 now_utc_ms: datetime_to_millis(now),
937 lease_expires_at_utc_ms: lease_expires_at.map(datetime_to_millis),
938 },
939 Some(request.expected_revision),
940 )?;
941 item.owner = Some(request.owner.clone());
942 item.claim = Some(WorkClaim {
943 owner: request.owner,
944 claimed_at: now,
945 lease_expires_at,
946 });
947 item.machine_state = dsl_state;
948 sync_item_from_machine_state(&mut item)?;
949 item.updated_at = now;
950 let event = item_event(&item, WorkGraphEventKind::Claimed, now)?;
951 Ok((item, event))
952 }
953
954 pub fn release_item(
955 mut item: WorkItem,
956 request: ReleaseWorkItemRequest,
957 now: DateTime<Utc>,
958 ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
959 let dsl_state = apply_item_dsl(
960 &item,
961 item.machine_state.unresolved_blocker_count,
962 wg_dsl::WorkGraphLifecycleInput::Release {
963 expected_revision: request.expected_revision,
964 },
965 Some(request.expected_revision),
966 )?;
967 item.claim = None;
968 item.owner = None;
969 item.machine_state = dsl_state;
970 sync_item_from_machine_state(&mut item)?;
971 item.updated_at = now;
972 let event = item_event(&item, WorkGraphEventKind::Released, now)?;
973 Ok((item, event))
974 }
975
976 pub fn block_item(
977 mut item: WorkItem,
978 expected_revision: u64,
979 now: DateTime<Utc>,
980 ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
981 let dsl_state = apply_item_dsl(
982 &item,
983 item.machine_state.unresolved_blocker_count,
984 wg_dsl::WorkGraphLifecycleInput::Block { expected_revision },
985 Some(expected_revision),
986 )?;
987 item.claim = None;
988 item.owner = None;
989 item.machine_state = dsl_state;
990 sync_item_from_machine_state(&mut item)?;
991 item.updated_at = now;
992 let event = item_event(&item, WorkGraphEventKind::Blocked, now)?;
993 Ok((item, event))
994 }
995
996 pub fn close_item(
997 mut item: WorkItem,
998 request: CloseWorkItemRequest,
999 now: DateTime<Utc>,
1000 ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
1001 let dsl_input = match classify_close_status_admission(request.status)? {
1009 wg_dsl::WorkCloseStatusAdmissionKind::AdmittedCompleted => {
1010 wg_dsl::WorkGraphLifecycleInput::CloseCompleted {
1011 expected_revision: request.expected_revision,
1012 at_utc_ms: datetime_to_millis(now),
1013 }
1014 }
1015 wg_dsl::WorkCloseStatusAdmissionKind::AdmittedCancelled => {
1016 wg_dsl::WorkGraphLifecycleInput::CloseCancelled {
1017 expected_revision: request.expected_revision,
1018 at_utc_ms: datetime_to_millis(now),
1019 }
1020 }
1021 wg_dsl::WorkCloseStatusAdmissionKind::AdmittedFailed => {
1022 wg_dsl::WorkGraphLifecycleInput::CloseFailed {
1023 expected_revision: request.expected_revision,
1024 at_utc_ms: datetime_to_millis(now),
1025 }
1026 }
1027 wg_dsl::WorkCloseStatusAdmissionKind::DeniedNonTerminal => {
1028 return Err(WorkGraphError::InvalidTransition(
1029 "close requires a terminal status".to_string(),
1030 ));
1031 }
1032 };
1033 let dsl_state = apply_item_dsl(
1034 &item,
1035 item.machine_state.unresolved_blocker_count,
1036 dsl_input,
1037 Some(request.expected_revision),
1038 )?;
1039 item.claim = None;
1040 item.owner = None;
1041 item.machine_state = dsl_state;
1042 sync_item_from_machine_state(&mut item)?;
1043 item.updated_at = now;
1044 let event = item_event(&item, WorkGraphEventKind::Closed, now)?;
1045 Ok((item, event))
1046 }
1047
1048 pub fn add_evidence(
1049 mut item: WorkItem,
1050 request: AddEvidenceRequest,
1051 now: DateTime<Utc>,
1052 ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
1053 let evidence_kind = request
1054 .evidence
1055 .confirmation_kind
1056 .unwrap_or(crate::types::WorkEvidenceKind::SelfAttest)
1057 .to_machine();
1058 let confirming_owner_key = request
1059 .evidence
1060 .confirming_owner_key
1061 .as_ref()
1062 .map(crate::types::work_owner_key_to_machine);
1063 let dsl_state = apply_item_dsl(
1064 &item,
1065 item.machine_state.unresolved_blocker_count,
1066 wg_dsl::WorkGraphLifecycleInput::AddEvidence {
1067 expected_revision: request.expected_revision,
1068 evidence_kind,
1069 confirming_owner_key,
1070 },
1071 Some(request.expected_revision),
1072 )?;
1073 item.evidence_refs.push(request.evidence);
1074 item.machine_state = dsl_state;
1075 sync_item_from_machine_state(&mut item)?;
1076 item.updated_at = now;
1077 let event = item_event(&item, WorkGraphEventKind::EvidenceAdded, now)?;
1078 Ok((item, event))
1079 }
1080
1081 pub fn classify_readiness(item: &WorkItem, now: DateTime<Utc>) -> Result<bool, WorkGraphError> {
1091 validate_item_machine_projection(item)?;
1092 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(
1093 item.machine_state.clone(),
1094 )
1095 .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
1096 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
1097 &mut dsl_auth,
1098 wg_dsl::WorkGraphLifecycleInput::ClassifyReadiness {
1099 now_utc_ms: datetime_to_millis(now),
1100 },
1101 )
1102 .map_err(|error| {
1103 WorkGraphError::InvalidTransition(format!(
1104 "work item {} refused readiness classification: {error:?}",
1105 item.id
1106 ))
1107 })?;
1108
1109 let mut classified = None;
1110 for effect in transition.effects() {
1111 if let wg_dsl::WorkGraphLifecycleEffect::WorkItemReadinessClassified { ready } = effect
1112 && classified.replace(*ready).is_some()
1113 {
1114 return Err(WorkGraphError::Store(format!(
1115 "work item {} emitted multiple readiness verdicts",
1116 item.id
1117 )));
1118 }
1119 }
1120
1121 classified.ok_or_else(|| {
1122 WorkGraphError::Store(format!(
1123 "work item {} emitted no readiness verdict",
1124 item.id
1125 ))
1126 })
1127 }
1128
1129 pub fn is_ready(item: &WorkItem, now: DateTime<Utc>) -> bool {
1138 Self::classify_readiness(item, now).unwrap_or(false)
1139 }
1140
1141 pub fn ready_items(items: Vec<WorkItem>, now: DateTime<Utc>) -> Vec<WorkItem> {
1142 items
1143 .into_iter()
1144 .filter(|item| Self::is_ready(item, now))
1145 .collect()
1146 }
1147
1148 pub fn validate_link(
1149 edge: &WorkEdge,
1150 existing_items: &[WorkItem],
1151 existing_edges: &[WorkEdge],
1152 ) -> Result<(), WorkGraphError> {
1153 let topology_state = topology_state(existing_items, existing_edges);
1154 apply_link_validation_dsl(
1155 topology_state,
1156 wg_dsl::WorkGraphLifecycleInput::ValidateLink {
1157 kind: dsl_edge_kind(edge.kind),
1158 from_item_key: work_item_key(&edge.from_id),
1159 to_item_key: work_item_key(&edge.to_id),
1160 edge_key: work_edge_key(edge.kind, &edge.from_id, &edge.to_id),
1161 reverse_path_key: dependency_path_key(edge.kind, &edge.to_id, &edge.from_id),
1162 },
1163 )?;
1164 Ok(())
1165 }
1166}
1167
1168fn work_graph_error_kind(error: &WorkGraphError) -> wg_dsl::WorkGraphErrorKind {
1173 match error {
1174 WorkGraphError::NotFound { .. } => wg_dsl::WorkGraphErrorKind::NotFound,
1175 WorkGraphError::AttentionNotFound { .. } => wg_dsl::WorkGraphErrorKind::AttentionNotFound,
1176 WorkGraphError::StaleRevision { .. } => wg_dsl::WorkGraphErrorKind::StaleRevision,
1177 WorkGraphError::Conflict(_) => wg_dsl::WorkGraphErrorKind::Conflict,
1178 WorkGraphError::InvalidTransition(_) => wg_dsl::WorkGraphErrorKind::InvalidTransition,
1179 WorkGraphError::InvalidInput(_) => wg_dsl::WorkGraphErrorKind::InvalidInput,
1180 WorkGraphError::InvalidTimestampMillis { .. } => {
1181 wg_dsl::WorkGraphErrorKind::InvalidTimestampMillis
1182 }
1183 WorkGraphError::Store(_) => wg_dsl::WorkGraphErrorKind::Store,
1184 WorkGraphError::UnsupportedBackend(_) => wg_dsl::WorkGraphErrorKind::UnsupportedBackend,
1185 }
1186}
1187
1188pub(crate) fn completion_policy_name(policy: &WorkCompletionPolicy) -> &'static str {
1189 match policy {
1190 WorkCompletionPolicy::SelfAttest => "self_attest",
1191 WorkCompletionPolicy::HostConfirmed => "host_confirmed",
1192 WorkCompletionPolicy::PrincipalConfirmed => "principal_confirmed",
1193 WorkCompletionPolicy::Supervisor { .. } => "supervisor",
1194 WorkCompletionPolicy::ReviewerQuorum { .. } => "reviewer_quorum",
1195 }
1196}
1197
1198fn validate_title(title: String) -> Result<String, WorkGraphError> {
1199 let title = title.trim();
1200 if title.is_empty() {
1201 return Err(WorkGraphError::InvalidInput(
1202 "work item title must not be empty".to_string(),
1203 ));
1204 }
1205 Ok(title.to_string())
1206}
1207
1208fn normalize_labels(labels: BTreeSet<String>) -> Result<BTreeSet<String>, WorkGraphError> {
1209 let mut normalized = BTreeSet::new();
1210 for label in labels {
1211 let label = label.trim();
1212 if label.is_empty() {
1213 return Err(WorkGraphError::InvalidInput(
1214 "work item labels must not be empty".to_string(),
1215 ));
1216 }
1217 normalized.insert(label.to_string());
1218 }
1219 Ok(normalized)
1220}
1221
1222fn apply_new_item_dsl(
1223 input: wg_dsl::WorkGraphLifecycleInput,
1224) -> Result<wg_dsl::WorkGraphLifecycleMachineState, WorkGraphError> {
1225 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
1226 wg_dsl::WorkGraphLifecycleMachineMutator::apply(&mut dsl_auth, input)
1227 .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
1228 Ok(dsl_auth.state().clone())
1229}
1230
1231fn classify_create_status_admission(
1242 status: WorkStatus,
1243) -> Result<wg_dsl::WorkCreateStatusAdmissionKind, WorkGraphError> {
1244 let requested_status = crate::types::work_lifecycle_state_from_status(status);
1245 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
1246 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
1247 &mut dsl_auth,
1248 wg_dsl::WorkGraphLifecycleInput::ClassifyCreateStatusAdmission { requested_status },
1249 )
1250 .map_err(|error| {
1251 WorkGraphError::InvalidTransition(format!(
1252 "WorkGraphLifecycle refused create-status admission for {requested_status:?}: {error:?}"
1253 ))
1254 })?;
1255
1256 let mut admission = None;
1257 for effect in transition.effects() {
1258 if let wg_dsl::WorkGraphLifecycleEffect::CreateStatusAdmissionClassified {
1259 admission: emitted,
1260 } = effect
1261 && admission.replace(*emitted).is_some()
1262 {
1263 return Err(WorkGraphError::Store(format!(
1264 "WorkGraphLifecycle create-status admission emitted multiple verdicts for {requested_status:?}"
1265 )));
1266 }
1267 }
1268
1269 admission.ok_or_else(|| {
1270 WorkGraphError::Store(format!(
1271 "WorkGraphLifecycle create-status admission emitted no verdict for {requested_status:?}"
1272 ))
1273 })
1274}
1275
1276fn classify_close_status_admission(
1287 status: WorkStatus,
1288) -> Result<wg_dsl::WorkCloseStatusAdmissionKind, WorkGraphError> {
1289 let requested_status = crate::types::work_lifecycle_state_from_status(status);
1290 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
1291 let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
1292 &mut dsl_auth,
1293 wg_dsl::WorkGraphLifecycleInput::ClassifyCloseStatusAdmission { requested_status },
1294 )
1295 .map_err(|error| {
1296 WorkGraphError::InvalidTransition(format!(
1297 "WorkGraphLifecycle refused close-status admission for {requested_status:?}: {error:?}"
1298 ))
1299 })?;
1300
1301 let mut admission = None;
1302 for effect in transition.effects() {
1303 if let wg_dsl::WorkGraphLifecycleEffect::CloseStatusAdmissionClassified {
1304 admission: emitted,
1305 } = effect
1306 && admission.replace(*emitted).is_some()
1307 {
1308 return Err(WorkGraphError::Store(format!(
1309 "WorkGraphLifecycle close-status admission emitted multiple verdicts for {requested_status:?}"
1310 )));
1311 }
1312 }
1313
1314 admission.ok_or_else(|| {
1315 WorkGraphError::Store(format!(
1316 "WorkGraphLifecycle close-status admission emitted no verdict for {requested_status:?}"
1317 ))
1318 })
1319}
1320
1321fn apply_link_validation_dsl(
1322 state: WorkGraphMachineState,
1323 input: wg_dsl::WorkGraphLifecycleInput,
1324) -> Result<(), WorkGraphError> {
1325 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(state)
1326 .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
1327 wg_dsl::WorkGraphLifecycleMachineMutator::apply(&mut dsl_auth, input)
1328 .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
1329 Ok(())
1330}
1331
1332fn apply_attention_dsl(
1333 binding: &WorkAttentionBinding,
1334 input: attention_dsl::WorkAttentionLifecycleInput,
1335 expected_revision: Option<u64>,
1336) -> Result<attention_dsl::WorkAttentionLifecycleMachineState, WorkGraphError> {
1337 let mut dsl_auth = attention_dsl::WorkAttentionLifecycleMachineAuthority::recover_from_state(
1338 binding.machine_state.clone(),
1339 )
1340 .map_err(|error| {
1341 WorkGraphError::InvalidTransition(format!(
1342 "attention binding {} refused recovery: {error:?}",
1343 binding.binding_id
1344 ))
1345 })?;
1346 attention_dsl::WorkAttentionLifecycleMachineMutator::apply(&mut dsl_auth, input).map_err(
1347 |error| {
1348 if let Some(expected) = expected_revision
1349 && binding.machine_state.revision != expected
1350 {
1351 return WorkGraphError::StaleRevision {
1352 id: binding.work_ref.item_id.clone(),
1353 expected,
1354 actual: binding.machine_state.revision,
1355 };
1356 }
1357 WorkGraphError::InvalidTransition(format!(
1358 "attention binding {} refused transition: {error:?}",
1359 binding.binding_id
1360 ))
1361 },
1362 )?;
1363 Ok(dsl_auth.state().clone())
1364}
1365
1366fn apply_item_dsl(
1367 item: &WorkItem,
1368 unresolved_blocker_count: u64,
1369 input: wg_dsl::WorkGraphLifecycleInput,
1370 expected_revision: Option<u64>,
1371) -> Result<WorkGraphMachineState, WorkGraphError> {
1372 validate_item_machine_projection(item)?;
1373 let mut state = item.machine_state.clone();
1374 state.unresolved_blocker_count = unresolved_blocker_count;
1375 let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(state)
1376 .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
1377 wg_dsl::WorkGraphLifecycleMachineMutator::apply(&mut dsl_auth, input).map_err(|error| {
1378 if let Some(expected) = expected_revision
1379 && item.revision != expected
1380 {
1381 return WorkGraphError::StaleRevision {
1382 id: item.id.clone(),
1383 expected,
1384 actual: item.revision,
1385 };
1386 }
1387 WorkGraphError::InvalidTransition(format!("{error:?}"))
1388 })?;
1389 Ok(dsl_auth.state().clone())
1390}
1391
1392fn sync_attention_from_machine_state(binding: &mut WorkAttentionBinding) {
1393 binding.status = match binding.machine_state.lifecycle_phase {
1394 attention_dsl::WorkAttentionLifecycleState::Active => WorkAttentionStatus::Active,
1395 attention_dsl::WorkAttentionLifecycleState::Paused => WorkAttentionStatus::Paused {
1396 until: binding
1397 .machine_state
1398 .paused_until_utc_ms
1399 .and_then(millis_to_datetime),
1400 },
1401 attention_dsl::WorkAttentionLifecycleState::Superseded => WorkAttentionStatus::Superseded,
1402 attention_dsl::WorkAttentionLifecycleState::Stopped => WorkAttentionStatus::Stopped,
1403 };
1404}
1405
1406fn work_status_from_dsl(status: wg_dsl::WorkLifecycleState) -> Result<WorkStatus, WorkGraphError> {
1407 match status {
1408 wg_dsl::WorkLifecycleState::Open => Ok(WorkStatus::Open),
1409 wg_dsl::WorkLifecycleState::InProgress => Ok(WorkStatus::InProgress),
1410 wg_dsl::WorkLifecycleState::Blocked => Ok(WorkStatus::Blocked),
1411 wg_dsl::WorkLifecycleState::Completed => Ok(WorkStatus::Completed),
1412 wg_dsl::WorkLifecycleState::Cancelled => Ok(WorkStatus::Cancelled),
1413 wg_dsl::WorkLifecycleState::Failed => Ok(WorkStatus::Failed),
1414 wg_dsl::WorkLifecycleState::Absent => Err(WorkGraphError::InvalidTransition(
1415 "work item lifecycle state is absent".to_string(),
1416 )),
1417 }
1418}
1419
1420fn sync_item_from_machine_state(item: &mut WorkItem) -> Result<(), WorkGraphError> {
1421 item.status = work_status_from_dsl(item.machine_state.lifecycle_phase)?;
1422 item.revision = item.machine_state.revision;
1423 item.due_at = item
1424 .machine_state
1425 .due_at_utc_ms
1426 .and_then(millis_to_datetime);
1427 item.not_before = item
1428 .machine_state
1429 .not_before_utc_ms
1430 .and_then(millis_to_datetime);
1431 item.snoozed_until = item
1432 .machine_state
1433 .snoozed_until_utc_ms
1434 .and_then(millis_to_datetime);
1435 item.completion_policy = crate::types::WorkCompletionPolicy::from_machine(
1436 item.machine_state.completion_policy,
1437 item.machine_state.completion_supervisor_owner_key.clone(),
1438 item.machine_state.completion_reviewer_quorum_threshold,
1439 );
1440 item.terminal_at = item
1441 .machine_state
1442 .terminal_at_utc_ms
1443 .and_then(millis_to_datetime);
1444 Ok(())
1445}
1446
1447fn validate_item_machine_projection(item: &WorkItem) -> Result<(), WorkGraphError> {
1448 let status = work_status_from_dsl(item.machine_state.lifecycle_phase)?;
1449 if item.status != status {
1450 return Err(WorkGraphError::Store(format!(
1451 "work item {} status projection {:?} does not match machine state {:?}",
1452 item.id, item.status, status
1453 )));
1454 }
1455 if item.revision != item.machine_state.revision {
1456 return Err(WorkGraphError::Store(format!(
1457 "work item {} revision projection {} does not match machine state {}",
1458 item.id, item.revision, item.machine_state.revision
1459 )));
1460 }
1461 if item.due_at.map(datetime_to_millis) != item.machine_state.due_at_utc_ms {
1462 return Err(WorkGraphError::Store(format!(
1463 "work item {} due_at projection does not match machine state",
1464 item.id
1465 )));
1466 }
1467 if item.not_before.map(datetime_to_millis) != item.machine_state.not_before_utc_ms {
1468 return Err(WorkGraphError::Store(format!(
1469 "work item {} not_before projection does not match machine state",
1470 item.id
1471 )));
1472 }
1473 if item.snoozed_until.map(datetime_to_millis) != item.machine_state.snoozed_until_utc_ms {
1474 return Err(WorkGraphError::Store(format!(
1475 "work item {} snoozed_until projection does not match machine state",
1476 item.id
1477 )));
1478 }
1479 if item.completion_policy
1480 != crate::types::WorkCompletionPolicy::from_machine(
1481 item.machine_state.completion_policy,
1482 item.machine_state.completion_supervisor_owner_key.clone(),
1483 item.machine_state.completion_reviewer_quorum_threshold,
1484 )
1485 {
1486 return Err(WorkGraphError::Store(format!(
1487 "work item {} completion_policy projection does not match machine state",
1488 item.id
1489 )));
1490 }
1491 if item.terminal_at.map(datetime_to_millis) != item.machine_state.terminal_at_utc_ms {
1492 return Err(WorkGraphError::Store(format!(
1493 "work item {} terminal_at projection does not match machine state",
1494 item.id
1495 )));
1496 }
1497 if let Some(claim) = &item.claim {
1498 let claim_owner_key = work_owner_key(&claim.owner)?;
1499 if item.machine_state.claim_owner_key.as_ref() != Some(&claim_owner_key) {
1500 return Err(WorkGraphError::Store(format!(
1501 "work item {} claim owner projection does not match machine state",
1502 item.id
1503 )));
1504 }
1505 if item.machine_state.claimed_at_utc_ms != Some(datetime_to_millis(claim.claimed_at)) {
1506 return Err(WorkGraphError::Store(format!(
1507 "work item {} claim time projection does not match machine state",
1508 item.id
1509 )));
1510 }
1511 if item.machine_state.lease_expires_at_utc_ms
1512 != claim.lease_expires_at.map(datetime_to_millis)
1513 {
1514 return Err(WorkGraphError::Store(format!(
1515 "work item {} claim lease projection does not match machine state",
1516 item.id
1517 )));
1518 }
1519 } else if item.machine_state.claim_owner_key.is_some()
1520 || item.machine_state.claimed_at_utc_ms.is_some()
1521 || item.machine_state.lease_expires_at_utc_ms.is_some()
1522 {
1523 return Err(WorkGraphError::Store(format!(
1524 "work item {} machine state has a claim without a claim projection",
1525 item.id
1526 )));
1527 }
1528 Ok(())
1529}
1530
1531fn work_owner_key(owner: &crate::types::WorkOwner) -> Result<wg_dsl::WorkOwnerKey, WorkGraphError> {
1532 let kind = match owner.key.kind {
1533 crate::types::WorkOwnerKind::Principal => wg_dsl::WorkOwnerKind::Principal,
1534 crate::types::WorkOwnerKind::Agent => wg_dsl::WorkOwnerKind::Agent,
1535 crate::types::WorkOwnerKind::Session => wg_dsl::WorkOwnerKind::Session,
1536 crate::types::WorkOwnerKind::Mob => wg_dsl::WorkOwnerKind::Mob,
1537 crate::types::WorkOwnerKind::Label => wg_dsl::WorkOwnerKind::Label,
1538 };
1539 Ok(wg_dsl::WorkOwnerKey {
1540 kind,
1541 id: owner.key.id.clone(),
1542 })
1543}
1544
1545fn topology_state(
1546 existing_items: &[WorkItem],
1547 existing_edges: &[WorkEdge],
1548) -> WorkGraphMachineState {
1549 WorkGraphMachineState {
1550 topology_item_keys: existing_items
1551 .iter()
1552 .map(|item| work_item_key(&item.id))
1553 .collect(),
1554 topology_edge_keys: existing_edges
1555 .iter()
1556 .map(|edge| work_edge_key(edge.kind, &edge.from_id, &edge.to_id))
1557 .collect(),
1558 blocks_reachability: dependency_reachability(existing_edges, WorkEdgeKind::Blocks),
1559 parent_reachability: dependency_reachability(existing_edges, WorkEdgeKind::Parent),
1560 ..Default::default()
1561 }
1562}
1563
1564fn dependency_reachability(
1565 edges: &[WorkEdge],
1566 kind: WorkEdgeKind,
1567) -> BTreeSet<wg_dsl::WorkDependencyPathKey> {
1568 let mut adjacency = BTreeMap::<WorkItemId, BTreeSet<WorkItemId>>::new();
1569 for edge in edges.iter().filter(|edge| edge.kind == kind) {
1570 adjacency
1571 .entry(edge.from_id.clone())
1572 .or_default()
1573 .insert(edge.to_id.clone());
1574 }
1575
1576 let mut reachability = BTreeSet::new();
1577 for start in adjacency.keys() {
1578 let mut stack = adjacency
1579 .get(start)
1580 .into_iter()
1581 .flat_map(|targets| targets.iter().cloned())
1582 .collect::<Vec<_>>();
1583 let mut seen = BTreeSet::new();
1584 while let Some(current) = stack.pop() {
1585 if !seen.insert(current.clone()) {
1586 continue;
1587 }
1588 reachability.insert(dependency_path_key(kind, start, ¤t));
1589 if let Some(targets) = adjacency.get(¤t) {
1590 stack.extend(targets.iter().cloned());
1591 }
1592 }
1593 }
1594 reachability
1595}
1596
1597fn work_item_key(id: &WorkItemId) -> wg_dsl::WorkItemKey {
1598 wg_dsl::WorkItemKey(id.as_str().to_string())
1599}
1600
1601fn work_edge_key(
1602 kind: WorkEdgeKind,
1603 from_id: &WorkItemId,
1604 to_id: &WorkItemId,
1605) -> wg_dsl::WorkEdgeKey {
1606 wg_dsl::WorkEdgeKey(format!(
1607 "{}:{}:{}",
1608 edge_kind_key(kind),
1609 from_id.as_str(),
1610 to_id.as_str()
1611 ))
1612}
1613
1614fn dependency_path_key(
1615 kind: WorkEdgeKind,
1616 from_id: &WorkItemId,
1617 to_id: &WorkItemId,
1618) -> wg_dsl::WorkDependencyPathKey {
1619 wg_dsl::WorkDependencyPathKey(format!(
1620 "{}:{}:{}",
1621 edge_kind_key(kind),
1622 from_id.as_str(),
1623 to_id.as_str()
1624 ))
1625}
1626
1627fn dsl_edge_kind(kind: WorkEdgeKind) -> wg_dsl::WorkEdgeKind {
1628 match kind {
1629 WorkEdgeKind::Blocks => wg_dsl::WorkEdgeKind::Blocks,
1630 WorkEdgeKind::Parent => wg_dsl::WorkEdgeKind::Parent,
1631 WorkEdgeKind::Related => wg_dsl::WorkEdgeKind::Related,
1632 WorkEdgeKind::Supersedes => wg_dsl::WorkEdgeKind::Supersedes,
1633 WorkEdgeKind::DerivedFrom => wg_dsl::WorkEdgeKind::DerivedFrom,
1634 }
1635}
1636
1637fn edge_kind_key(kind: WorkEdgeKind) -> &'static str {
1638 match kind {
1639 WorkEdgeKind::Blocks => "blocks",
1640 WorkEdgeKind::Parent => "parent",
1641 WorkEdgeKind::Related => "related",
1642 WorkEdgeKind::Supersedes => "supersedes",
1643 WorkEdgeKind::DerivedFrom => "derived_from",
1644 }
1645}
1646
1647fn datetime_to_millis(dt: DateTime<Utc>) -> u64 {
1648 u64::try_from(dt.timestamp_millis()).unwrap_or(0)
1649}
1650
1651fn millis_to_datetime(ms: u64) -> Option<DateTime<Utc>> {
1652 DateTime::from_timestamp_millis(i64::try_from(ms).ok()?)
1653}
1654
1655fn item_event(
1656 item: &WorkItem,
1657 kind: WorkGraphEventKind,
1658 at: DateTime<Utc>,
1659) -> Result<WorkGraphEvent, WorkGraphError> {
1660 Ok(WorkGraphEvent::item(
1661 item.realm_id.clone(),
1662 item.namespace.clone(),
1663 item.id.clone(),
1664 kind,
1665 at,
1666 json!({ "item": item }),
1667 ))
1668}
1669
1670fn seconds_to_duration(seconds: u64) -> Duration {
1671 let seconds = i64::try_from(seconds).unwrap_or(i64::MAX);
1672 Duration::seconds(seconds)
1673}
1674
1675#[cfg(test)]
1676#[allow(
1677 clippy::expect_used,
1678 clippy::unwrap_used,
1679 clippy::panic,
1680 clippy::redundant_clone
1681)]
1682mod tests {
1683 use super::*;
1684 use crate::types::{
1685 AddEvidenceRequest, ClaimWorkItemRequest, CloseWorkItemRequest, UpdateWorkItemRequest,
1686 WorkEvidenceKind, WorkEvidenceRef, WorkOwner, WorkOwnerKey,
1687 };
1688
1689 fn create(title: &str, now: DateTime<Utc>) -> WorkItem {
1690 create_with_policy(title, WorkCompletionPolicy::SelfAttest, now)
1691 }
1692
1693 fn create_with_policy(
1694 title: &str,
1695 completion_policy: WorkCompletionPolicy,
1696 now: DateTime<Utc>,
1697 ) -> WorkItem {
1698 WorkGraphMachine::create_item(
1699 CreateWorkItemRequest {
1700 realm_id: None,
1701 namespace: None,
1702 title: title.to_string(),
1703 description: None,
1704 priority: Default::default(),
1705 completion_policy,
1706 labels: BTreeSet::new(),
1707 due_at: None,
1708 not_before: None,
1709 snoozed_until: None,
1710 external_refs: Vec::new(),
1711 evidence_refs: Vec::new(),
1712 status: None,
1713 },
1714 "realm".to_string(),
1715 WorkNamespace::default(),
1716 now,
1717 )
1718 .expect("create")
1719 .0
1720 }
1721
1722 fn owner(id: &str) -> WorkOwner {
1723 WorkOwner::new(WorkOwnerKey::label(id).expect("owner key"))
1724 }
1725
1726 fn create_with_status(
1727 status: Option<WorkStatus>,
1728 now: DateTime<Utc>,
1729 ) -> Result<WorkItem, WorkGraphError> {
1730 WorkGraphMachine::create_item(
1731 CreateWorkItemRequest {
1732 realm_id: None,
1733 namespace: None,
1734 title: "status-create".to_string(),
1735 description: None,
1736 priority: Default::default(),
1737 completion_policy: WorkCompletionPolicy::SelfAttest,
1738 labels: BTreeSet::new(),
1739 due_at: None,
1740 not_before: None,
1741 snoozed_until: None,
1742 external_refs: Vec::new(),
1743 evidence_refs: Vec::new(),
1744 status,
1745 },
1746 "realm".to_string(),
1747 WorkNamespace::default(),
1748 now,
1749 )
1750 .map(|(item, _)| item)
1751 }
1752
1753 #[test]
1754 fn create_status_admission_is_decided_by_machine() {
1755 use wg_dsl::WorkCreateStatusAdmissionKind as Admission;
1756 assert_eq!(
1758 classify_create_status_admission(WorkStatus::Open).expect("classify open"),
1759 Admission::AdmittedOpen
1760 );
1761 assert_eq!(
1762 classify_create_status_admission(WorkStatus::Blocked).expect("classify blocked"),
1763 Admission::AdmittedBlocked
1764 );
1765 for status in [
1766 WorkStatus::InProgress,
1767 WorkStatus::Completed,
1768 WorkStatus::Cancelled,
1769 WorkStatus::Failed,
1770 ] {
1771 assert_eq!(
1772 classify_create_status_admission(status)
1773 .unwrap_or_else(|error| panic!("classify {status:?}: {error:?}")),
1774 Admission::Denied,
1775 "requested status {status:?} must be denied as a creation state"
1776 );
1777 }
1778 }
1779
1780 #[test]
1781 fn create_item_admits_open_and_blocked_and_rejects_the_rest() {
1782 let now = Utc::now();
1783 assert_eq!(
1784 create_with_status(Some(WorkStatus::Open), now)
1785 .expect("open admitted")
1786 .status,
1787 WorkStatus::Open
1788 );
1789 assert_eq!(
1790 create_with_status(Some(WorkStatus::Blocked), now)
1791 .expect("blocked admitted")
1792 .status,
1793 WorkStatus::Blocked
1794 );
1795 assert_eq!(
1797 create_with_status(None, now)
1798 .expect("default admitted")
1799 .status,
1800 WorkStatus::Open
1801 );
1802 for status in [
1803 WorkStatus::InProgress,
1804 WorkStatus::Completed,
1805 WorkStatus::Cancelled,
1806 WorkStatus::Failed,
1807 ] {
1808 let error = create_with_status(Some(status), now)
1809 .expect_err("non-open/blocked create status must be rejected");
1810 match error {
1811 WorkGraphError::InvalidTransition(message) => assert_eq!(
1812 message, "new work items may only start open or blocked",
1813 "rejection message preserved for {status:?}"
1814 ),
1815 other => panic!("expected InvalidTransition for {status:?}, got {other:?}"),
1816 }
1817 }
1818 }
1819
1820 #[test]
1821 fn public_confirmation_admission_is_decided_by_machine() {
1822 use wg_dsl::WorkPublicConfirmationAdmissionKind as Admission;
1823 assert_eq!(
1826 WorkGraphMachine::classify_public_confirmation_admission(
1827 &WorkCompletionPolicy::SelfAttest
1828 )
1829 .expect("self-attest admission"),
1830 Admission::Admitted
1831 );
1832 let owner_key = WorkOwnerKey::label("supervisor").expect("owner key");
1833 let denied = [
1834 WorkCompletionPolicy::HostConfirmed,
1835 WorkCompletionPolicy::PrincipalConfirmed,
1836 WorkCompletionPolicy::Supervisor {
1837 owner_key: owner_key.clone(),
1838 },
1839 WorkCompletionPolicy::ReviewerQuorum { threshold: 2 },
1840 ];
1841 for policy in denied {
1842 assert_eq!(
1843 WorkGraphMachine::classify_public_confirmation_admission(&policy)
1844 .unwrap_or_else(|error| panic!("classify {policy:?}: {error:?}")),
1845 Admission::DeniedRequiresTrustedHost,
1846 "policy {policy:?} must require trusted host for public confirmation"
1847 );
1848 }
1849 }
1850
1851 #[test]
1852 fn create_completion_policy_admission_is_decided_by_machine() {
1853 use wg_dsl::WorkCreateCompletionPolicyAdmissionKind as Admission;
1854 assert_eq!(
1857 WorkGraphMachine::classify_create_completion_policy_admission(
1858 &WorkCompletionPolicy::SelfAttest
1859 )
1860 .expect("self-attest admission"),
1861 Admission::Admitted
1862 );
1863 let owner_key = WorkOwnerKey::label("supervisor").expect("owner key");
1864 let denied = [
1865 WorkCompletionPolicy::HostConfirmed,
1866 WorkCompletionPolicy::PrincipalConfirmed,
1867 WorkCompletionPolicy::Supervisor { owner_key },
1868 WorkCompletionPolicy::ReviewerQuorum { threshold: 2 },
1869 ];
1870 for policy in denied {
1871 assert_eq!(
1872 WorkGraphMachine::classify_create_completion_policy_admission(&policy)
1873 .unwrap_or_else(|error| panic!("classify {policy:?}: {error:?}")),
1874 Admission::DeniedNonSelfAttest,
1875 "policy {policy:?} must be denied at create for a non-goal work item"
1876 );
1877 }
1878 }
1879
1880 #[test]
1881 fn close_status_admission_is_decided_by_machine() {
1882 use wg_dsl::WorkCloseStatusAdmissionKind as Admission;
1883 assert_eq!(
1886 classify_close_status_admission(WorkStatus::Completed).expect("classify completed"),
1887 Admission::AdmittedCompleted
1888 );
1889 assert_eq!(
1890 classify_close_status_admission(WorkStatus::Cancelled).expect("classify cancelled"),
1891 Admission::AdmittedCancelled
1892 );
1893 assert_eq!(
1894 classify_close_status_admission(WorkStatus::Failed).expect("classify failed"),
1895 Admission::AdmittedFailed
1896 );
1897 for status in [
1898 WorkStatus::Open,
1899 WorkStatus::InProgress,
1900 WorkStatus::Blocked,
1901 ] {
1902 assert_eq!(
1903 classify_close_status_admission(status)
1904 .unwrap_or_else(|error| panic!("classify {status:?}: {error:?}")),
1905 Admission::DeniedNonTerminal,
1906 "requested close status {status:?} must be denied as a non-terminal target"
1907 );
1908 }
1909 }
1910
1911 #[test]
1912 fn close_item_rejects_non_terminal_status_with_preserved_message() {
1913 let now = Utc::now();
1914 for status in [
1915 WorkStatus::Open,
1916 WorkStatus::InProgress,
1917 WorkStatus::Blocked,
1918 ] {
1919 let item = create("close-target", now);
1920 let error = WorkGraphMachine::close_item(
1921 item.clone(),
1922 CloseWorkItemRequest {
1923 id: item.id.clone(),
1924 realm_id: None,
1925 namespace: None,
1926 expected_revision: item.revision,
1927 status,
1928 },
1929 now,
1930 )
1931 .expect_err("non-terminal close status must be rejected");
1932 match error {
1933 WorkGraphError::InvalidTransition(message) => assert_eq!(
1934 message, "close requires a terminal status",
1935 "rejection message preserved for {status:?}"
1936 ),
1937 other => panic!("expected InvalidTransition for {status:?}, got {other:?}"),
1938 }
1939 }
1940 }
1941
1942 #[test]
1943 fn blocked_items_are_never_ready() {
1944 let now = Utc::now();
1945 let item = create("blocked", now);
1946 let (item, _) = WorkGraphMachine::block_item(item, 1, now).expect("block");
1947 assert!(WorkGraphMachine::ready_items(vec![item], now).is_empty());
1948 }
1949
1950 #[test]
1951 fn future_due_items_are_not_ready() {
1952 let now = Utc::now();
1953 let item = create("future", now);
1954 let (item, _) = WorkGraphMachine::update_item(
1955 item,
1956 UpdateWorkItemRequest {
1957 id: WorkItemId::generated(),
1958 realm_id: None,
1959 namespace: None,
1960 expected_revision: 1,
1961 title: None,
1962 description: None,
1963 priority: None,
1964 completion_policy: None,
1965 labels: None,
1966 due_at: Some(now + Duration::hours(1)),
1967 not_before: None,
1968 snoozed_until: None,
1969 external_refs: Vec::new(),
1970 },
1971 now,
1972 )
1973 .expect("update due");
1974
1975 assert!(WorkGraphMachine::ready_items(vec![item], now).is_empty());
1976 }
1977
1978 #[test]
1979 fn readiness_is_decided_by_machine_matching_claim_guards() {
1980 let now = Utc::now();
1981
1982 let open = create("ready-open", now);
1985 assert_eq!(open.status, WorkStatus::Open);
1986 assert!(
1987 WorkGraphMachine::classify_readiness(&open, now).expect("classify open"),
1988 "an unblocked, due-eligible open item must be machine-classified ready"
1989 );
1990 assert!(WorkGraphMachine::is_ready(&open, now));
1991
1992 let (future, _) = WorkGraphMachine::update_item(
1994 open,
1995 UpdateWorkItemRequest {
1996 id: WorkItemId::generated(),
1997 realm_id: None,
1998 namespace: None,
1999 expected_revision: 1,
2000 title: None,
2001 description: None,
2002 priority: None,
2003 completion_policy: None,
2004 labels: None,
2005 due_at: Some(now + Duration::hours(1)),
2006 not_before: None,
2007 snoozed_until: None,
2008 external_refs: Vec::new(),
2009 },
2010 now,
2011 )
2012 .expect("update future due");
2013 assert!(
2014 !WorkGraphMachine::classify_readiness(&future, now).expect("classify future"),
2015 "a future-due open item must be machine-classified not ready"
2016 );
2017 assert!(!WorkGraphMachine::is_ready(&future, now));
2018
2019 let claimable = create("reclaim", now);
2022 let (claimed, _) = WorkGraphMachine::claim_item(
2023 claimable,
2024 ClaimWorkItemRequest {
2025 id: WorkItemId::generated(),
2026 realm_id: None,
2027 namespace: None,
2028 expected_revision: 1,
2029 owner: owner("worker"),
2030 lease_seconds: Some(30),
2031 lease_expires_at: None,
2032 },
2033 now,
2034 )
2035 .expect("claim");
2036 assert_eq!(claimed.status, WorkStatus::InProgress);
2037 assert!(
2038 !WorkGraphMachine::classify_readiness(&claimed, now).expect("classify live lease"),
2039 "an in-progress item with a live lease must not be machine-classified ready"
2040 );
2041 let after_lease = now + Duration::seconds(31);
2042 assert!(
2043 WorkGraphMachine::classify_readiness(&claimed, after_lease)
2044 .expect("classify expired lease"),
2045 "an in-progress item with an expired lease must be machine-classified ready"
2046 );
2047 assert!(WorkGraphMachine::is_ready(&claimed, after_lease));
2048 }
2049
2050 #[test]
2051 fn terminal_items_cannot_be_claimed() {
2052 let now = Utc::now();
2053 let item = create("done", now);
2054 let (item, _) = WorkGraphMachine::close_item(
2055 item,
2056 CloseWorkItemRequest {
2057 id: WorkItemId::generated(),
2058 realm_id: None,
2059 namespace: None,
2060 expected_revision: 1,
2061 status: WorkStatus::Completed,
2062 },
2063 now,
2064 )
2065 .expect("close");
2066 let error = WorkGraphMachine::claim_item(
2067 item,
2068 ClaimWorkItemRequest {
2069 id: WorkItemId::generated(),
2070 realm_id: None,
2071 namespace: None,
2072 expected_revision: 2,
2073 owner: owner("worker"),
2074 lease_seconds: None,
2075 lease_expires_at: None,
2076 },
2077 now,
2078 )
2079 .expect_err("terminal claim should fail");
2080 assert!(matches!(error, WorkGraphError::InvalidTransition(_)));
2081 }
2082
2083 #[test]
2084 fn completed_close_is_completion_policy_gated_by_machine() {
2085 let now = Utc::now();
2086 let item = create_with_policy(
2087 "needs host confirmation",
2088 WorkCompletionPolicy::HostConfirmed,
2089 now,
2090 );
2091
2092 let error = WorkGraphMachine::close_item(
2096 item.clone(),
2097 CloseWorkItemRequest {
2098 id: item.id.clone(),
2099 realm_id: None,
2100 namespace: None,
2101 expected_revision: 1,
2102 status: WorkStatus::Completed,
2103 },
2104 now,
2105 )
2106 .expect_err("machine must reject completed close without policy evidence");
2107 assert!(matches!(error, WorkGraphError::InvalidTransition(_)));
2108
2109 let (item, _) = WorkGraphMachine::add_evidence(
2113 item,
2114 AddEvidenceRequest {
2115 id: WorkItemId::generated(),
2116 realm_id: None,
2117 namespace: None,
2118 expected_revision: 1,
2119 evidence: WorkEvidenceRef {
2120 kind: "host_confirmation".to_string(),
2121 id: "acceptance".to_string(),
2122 label: None,
2123 summary: None,
2124 confirmation_kind: Some(WorkEvidenceKind::HostConfirmation),
2125 confirming_owner_key: None,
2126 execution_binding_id: None,
2127 },
2128 },
2129 now,
2130 )
2131 .expect("record host confirmation evidence");
2132
2133 let (closed, _) = WorkGraphMachine::close_item(
2134 item.clone(),
2135 CloseWorkItemRequest {
2136 id: item.id,
2137 realm_id: None,
2138 namespace: None,
2139 expected_revision: item.revision,
2140 status: WorkStatus::Completed,
2141 },
2142 now,
2143 )
2144 .expect("machine admits close once host confirmation is satisfied");
2145 assert_eq!(closed.status, WorkStatus::Completed);
2146 }
2147
2148 #[test]
2149 fn reviewer_quorum_close_requires_distinct_reviewers() {
2150 let now = Utc::now();
2151 let item = create_with_policy(
2152 "needs two reviewers",
2153 WorkCompletionPolicy::ReviewerQuorum { threshold: 2 },
2154 now,
2155 );
2156
2157 let reviewer_evidence = |reviewer: &str| WorkEvidenceRef {
2158 kind: "reviewer_confirmation".to_string(),
2159 id: reviewer.to_string(),
2160 label: Some(reviewer.to_string()),
2161 summary: None,
2162 confirmation_kind: Some(WorkEvidenceKind::ReviewerConfirmation),
2163 confirming_owner_key: Some(
2164 WorkOwnerKey::principal(reviewer).expect("reviewer principal"),
2165 ),
2166 execution_binding_id: None,
2167 };
2168
2169 let (item, _) = WorkGraphMachine::add_evidence(
2171 item,
2172 AddEvidenceRequest {
2173 id: WorkItemId::generated(),
2174 realm_id: None,
2175 namespace: None,
2176 expected_revision: 1,
2177 evidence: reviewer_evidence("alice"),
2178 },
2179 now,
2180 )
2181 .expect("record first reviewer");
2182
2183 let error = WorkGraphMachine::close_item(
2184 item.clone(),
2185 CloseWorkItemRequest {
2186 id: item.id.clone(),
2187 realm_id: None,
2188 namespace: None,
2189 expected_revision: item.revision,
2190 status: WorkStatus::Completed,
2191 },
2192 now,
2193 )
2194 .expect_err("single reviewer must not satisfy a quorum of two");
2195 assert!(matches!(error, WorkGraphError::InvalidTransition(_)));
2196
2197 let expected_revision = item.revision;
2200 let (item, _) = WorkGraphMachine::add_evidence(
2201 item,
2202 AddEvidenceRequest {
2203 id: WorkItemId::generated(),
2204 realm_id: None,
2205 namespace: None,
2206 expected_revision,
2207 evidence: reviewer_evidence("alice"),
2208 },
2209 now,
2210 )
2211 .expect("record duplicate reviewer");
2212
2213 let error = WorkGraphMachine::close_item(
2214 item.clone(),
2215 CloseWorkItemRequest {
2216 id: item.id.clone(),
2217 realm_id: None,
2218 namespace: None,
2219 expected_revision: item.revision,
2220 status: WorkStatus::Completed,
2221 },
2222 now,
2223 )
2224 .expect_err("duplicate reviewer must not satisfy a quorum of two");
2225 assert!(matches!(error, WorkGraphError::InvalidTransition(_)));
2226
2227 let expected_revision = item.revision;
2229 let (item, _) = WorkGraphMachine::add_evidence(
2230 item,
2231 AddEvidenceRequest {
2232 id: WorkItemId::generated(),
2233 realm_id: None,
2234 namespace: None,
2235 expected_revision,
2236 evidence: reviewer_evidence("bob"),
2237 },
2238 now,
2239 )
2240 .expect("record second reviewer");
2241
2242 let (closed, _) = WorkGraphMachine::close_item(
2243 item.clone(),
2244 CloseWorkItemRequest {
2245 id: item.id,
2246 realm_id: None,
2247 namespace: None,
2248 expected_revision: item.revision,
2249 status: WorkStatus::Completed,
2250 },
2251 now,
2252 )
2253 .expect("two distinct reviewers satisfy the quorum");
2254 assert_eq!(closed.status, WorkStatus::Completed);
2255 }
2256
2257 #[test]
2258 fn stale_revisions_fail() {
2259 let now = Utc::now();
2260 let item = create("stale", now);
2261 let error =
2262 WorkGraphMachine::block_item(item, 7, now).expect_err("stale transition should fail");
2263 assert!(matches!(error, WorkGraphError::StaleRevision { .. }));
2264 }
2265
2266 #[test]
2267 fn public_error_class_is_machine_owned() {
2268 use crate::types::{WorkAttentionBindingId, WorkNamespace};
2269
2270 let cases: &[(WorkGraphError, WorkGraphPublicErrorClass)] = &[
2271 (
2272 WorkGraphError::not_found(
2273 "realm".to_string(),
2274 WorkNamespace::default(),
2275 WorkItemId::generated(),
2276 ),
2277 WorkGraphPublicErrorClass::NotFound,
2278 ),
2279 (
2280 WorkGraphError::attention_not_found(
2281 "realm".to_string(),
2282 WorkNamespace::default(),
2283 WorkAttentionBindingId::generated(),
2284 ),
2285 WorkGraphPublicErrorClass::NotFound,
2286 ),
2287 (
2288 WorkGraphError::StaleRevision {
2289 id: WorkItemId::generated(),
2290 expected: 1,
2291 actual: 2,
2292 },
2293 WorkGraphPublicErrorClass::Conflict,
2294 ),
2295 (
2296 WorkGraphError::Conflict("conflict".to_string()),
2297 WorkGraphPublicErrorClass::Conflict,
2298 ),
2299 (
2300 WorkGraphError::InvalidTransition("bad".to_string()),
2301 WorkGraphPublicErrorClass::InvalidTransition,
2302 ),
2303 (
2304 WorkGraphError::InvalidInput("bad".to_string()),
2305 WorkGraphPublicErrorClass::InvalidArguments,
2306 ),
2307 (
2308 WorkGraphError::InvalidTimestampMillis {
2309 field: "due_at",
2310 millis: -1,
2311 },
2312 WorkGraphPublicErrorClass::InvalidArguments,
2313 ),
2314 (
2315 WorkGraphError::Store("store".to_string()),
2316 WorkGraphPublicErrorClass::StoreError,
2317 ),
2318 (
2319 WorkGraphError::UnsupportedBackend("backend".to_string()),
2320 WorkGraphPublicErrorClass::CapabilityUnavailable,
2321 ),
2322 ];
2323
2324 for (error, expected) in cases {
2325 let class = WorkGraphMachine::public_error_class(error)
2326 .expect("machine must classify every WorkGraphError variant");
2327 assert_eq!(class, *expected, "unexpected public class for {error:?}");
2328 }
2329 }
2330
2331 #[test]
2332 fn only_one_active_claim_can_exist() {
2333 let now = Utc::now();
2334 let item = create("claim", now);
2335 let (claimed, _) = WorkGraphMachine::claim_item(
2336 item,
2337 ClaimWorkItemRequest {
2338 id: WorkItemId::generated(),
2339 realm_id: None,
2340 namespace: None,
2341 expected_revision: 1,
2342 owner: owner("worker"),
2343 lease_seconds: Some(60),
2344 lease_expires_at: None,
2345 },
2346 now,
2347 )
2348 .expect("claim");
2349 let error = WorkGraphMachine::claim_item(
2350 claimed,
2351 ClaimWorkItemRequest {
2352 id: WorkItemId::generated(),
2353 realm_id: None,
2354 namespace: None,
2355 expected_revision: 2,
2356 owner: owner("worker-2"),
2357 lease_seconds: Some(60),
2358 lease_expires_at: None,
2359 },
2360 now,
2361 )
2362 .expect_err("double claim should fail");
2363 assert!(matches!(error, WorkGraphError::InvalidTransition(_)));
2364 }
2365
2366 #[test]
2367 fn validate_item_projection_only_rejects_never_derives() {
2368 let now = Utc::now();
2369
2370 let clean = create("projection-guard", now);
2372 WorkGraphMachine::validate_item_projection(&clean)
2373 .expect("a machine-built projection must agree with its machine state");
2374
2375 let mut status_drift = clean.clone();
2379 status_drift.status = WorkStatus::Completed;
2380 let err = WorkGraphMachine::validate_item_projection(&status_drift)
2381 .expect_err("status projection drift must be rejected, never repaired");
2382 assert!(
2383 matches!(&err, WorkGraphError::Store(message) if message.contains("status projection")),
2384 "rejection must cite the status projection drift, got: {err:?}"
2385 );
2386
2387 let mut revision_drift = clean.clone();
2391 revision_drift.revision = revision_drift.revision.wrapping_add(1);
2392 let err = WorkGraphMachine::validate_item_projection(&revision_drift)
2393 .expect_err("revision projection drift must be rejected, never repaired");
2394 assert!(
2395 matches!(&err, WorkGraphError::Store(message) if message.contains("revision projection")),
2396 "rejection must cite the revision projection drift, got: {err:?}"
2397 );
2398 }
2399}