1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
//! The worklist contract.
use std::fmt::Debug;
use async_trait::async_trait;
use crate::core::{CaseId, StoreError, Task, TaskId, TaskState, Timestamp};
/// Why a claim was refused.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ClaimError {
#[error("task {0} does not exist")]
NotFound(TaskId),
#[error("task {task} is already {state:?}")]
NotPending { task: TaskId, state: TaskState },
#[error("task {task} is held by '{holder}'")]
AlreadyClaimed { task: TaskId, holder: String },
/// The four-eyes control: whoever proposed an action does not approve it.
#[error("'{actor}' proposed this action and may not also decide it")]
Excluded { actor: String },
#[error("'{actor}' holds none of the roles this task requires")]
WrongRole { actor: String },
/// A release from somebody who is not the holder.
///
/// Distinct from [`NotFound`](ClaimError::NotFound) because the two call for
/// opposite responses: a task that does not exist means the id is wrong, and
/// a task held by someone else means the release did nothing — which is the
/// answer a caller must not receive as success.
#[error("task {task} is not held by '{actor}'")]
NotHeld { task: TaskId, actor: String },
#[error(transparent)]
Store(#[from] StoreError),
}
/// Pending human work.
#[async_trait]
pub trait TaskStore: Send + Sync + Debug {
/// Create a task, or return the existing one with this id.
///
/// Idempotent because task ids are derived from the awaiting effect rather
/// than minted: a resumed run addresses the same task instead of opening a
/// second one for the same decision.
async fn open(&self, task: &Task) -> Result<Task, StoreError>;
/// Fetch one task. Named for what it returns — see `CaseStore::case`.
async fn task(&self, id: TaskId) -> Result<Option<Task>, StoreError>;
/// Reserve a task for one actor.
///
/// Enforces the four-eyes exclusion and role eligibility, atomically, so two
/// reviewers cannot both believe they hold it.
///
/// **Eligibility is checked before availability**, and the order is part of
/// the contract rather than an artefact of how it was written:
///
/// `NotFound` → `Excluded` → `WrongRole` → `NotPending` → `AlreadyClaimed`
///
/// Told "held by Bob", a barred reviewer waits for Bob to release it and
/// tries again — and is refused, for a reason nobody has yet mentioned. The
/// permanent answer has to win over the transient one, or the transient one
/// hides it. It also keeps queue state — who is reviewing what — from
/// anybody not eligible for that queue.
///
/// # Errors
///
/// [`ClaimError`], per the order above.
async fn claim(&self, id: TaskId, actor: &str, roles: &[String]) -> Result<Task, ClaimError>;
/// Release a claim without deciding.
///
/// Only the holder may. A release by anybody else must report
/// [`ClaimError::NotHeld`] rather than succeed silently: a caller who is
/// told "released" and then sees the task still assigned has no way to tell
/// which of the two is lying.
///
/// # Errors
///
/// [`ClaimError::NotHeld`] if `actor` does not hold it, or it is not
/// claimed.
async fn release(&self, id: TaskId, actor: &str) -> Result<(), ClaimError>;
async fn set_state(&self, id: TaskId, state: TaskState) -> Result<(), StoreError>;
/// Open work, highest priority and oldest first.
async fn queue(&self, roles: &[String], limit: usize) -> Result<Vec<Task>, StoreError>;
/// Everything pending on one matter.
async fn for_case(&self, case: CaseId) -> Result<Vec<Task>, StoreError>;
/// How many decisions are waiting on a person, across every role.
///
/// Separate from `queue` for the same reason `pending_count` is separate
/// from `pending`: a gauge must not be read from a `limit`-bounded list.
async fn open_count(&self) -> Result<u64, StoreError>;
/// Tasks whose window has closed and which nobody answered.
async fn overdue(&self, now: Timestamp, limit: usize) -> Result<Vec<Task>, StoreError>;
}