use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::core::{CaseId, Digest, EffectKey, RunId, Timestamp};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TaskId(Digest);
impl TaskId {
#[must_use]
pub fn derive(run: RunId, effect: EffectKey) -> Self {
let mut bytes = run.to_string().into_bytes();
bytes.extend_from_slice(effect.to_hex().as_bytes());
Self(Digest::of(&bytes))
}
#[must_use]
pub fn to_hex(self) -> String {
self.0.to_hex()
}
pub fn parse(s: &str) -> Result<Self, hex::FromHexError> {
Digest::from_hex(s).map(Self)
}
}
impl std::fmt::Display for TaskId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "task_{}", self.0.to_hex())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskState {
Open,
Claimed,
Completed,
Expired,
Escalated,
}
impl TaskState {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Claimed => "claimed",
Self::Completed => "completed",
Self::Expired => "expired",
Self::Escalated => "escalated",
}
}
#[must_use]
pub fn is_pending(self) -> bool {
matches!(self, Self::Open | Self::Claimed | Self::Escalated)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
Low,
Normal,
High,
Urgent,
}
impl Priority {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Normal => "normal",
Self::High => "high",
Self::Urgent => "urgent",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnExpiry {
Deny,
Escalate,
Proceed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Justification {
pub summary: String,
pub proposed_action: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<String>,
}
impl Justification {
pub fn new(summary: impl Into<String>, proposed_action: Value) -> Self {
Self {
summary: summary.into(),
proposed_action,
confidence: None,
cost: None,
evidence: Vec::new(),
}
}
#[must_use]
pub fn confidence(mut self, c: f64) -> Self {
self.confidence = Some(c);
self
}
#[must_use]
pub fn cost(mut self, c: impl Into<String>) -> Self {
self.cost = Some(c.into());
self
}
#[must_use]
pub fn evidence(mut self, e: impl Into<String>) -> Self {
self.evidence.push(e.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskSpec {
pub kind: String,
pub justification: Justification,
pub candidate_roles: Vec<String>,
pub priority: Priority,
pub deadline: String,
pub on_expiry: OnExpiry,
pub excluded_actors: Vec<String>,
pub allow_unattended: bool,
}
impl TaskSpec {
pub fn new(
kind: impl Into<String>,
justification: Justification,
deadline: impl Into<String>,
) -> Self {
Self {
kind: kind.into(),
justification,
candidate_roles: Vec::new(),
priority: Priority::Normal,
deadline: deadline.into(),
on_expiry: OnExpiry::Deny,
excluded_actors: Vec::new(),
allow_unattended: false,
}
}
#[must_use]
pub fn role(mut self, r: impl Into<String>) -> Self {
self.candidate_roles.push(r.into());
self
}
#[must_use]
pub fn priority(mut self, p: Priority) -> Self {
self.priority = p;
self
}
#[must_use]
pub fn excluding(mut self, actor: impl Into<String>) -> Self {
self.excluded_actors.push(actor.into());
self
}
#[must_use]
pub fn on_expiry(mut self, e: OnExpiry) -> Self {
self.on_expiry = e;
self
}
#[must_use]
pub fn allow_unattended(mut self) -> Self {
self.allow_unattended = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Task {
pub id: TaskId,
pub run: RunId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub case: Option<CaseId>,
pub kind: String,
pub justification: Justification,
pub candidate_roles: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
pub priority: Priority,
pub state: TaskState,
pub on_expiry: OnExpiry,
pub excluded_actors: Vec<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: Timestamp,
#[serde(default, with = "time::serde::rfc3339::option")]
pub due_at: Option<Timestamp>,
}
impl Task {
#[must_use]
pub fn may_decide(&self, actor: &str, roles: &[String]) -> bool {
if self.excluded_actors.iter().any(|a| a == actor) {
return false;
}
self.candidate_roles.is_empty() || self.candidate_roles.iter().any(|r| roles.contains(r))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Decision {
pub approved: bool,
pub actor: String,
pub reason: String,
#[serde(default)]
pub amendment: Value,
}
impl Decision {
pub fn approve(actor: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
approved: true,
actor: actor.into(),
reason: reason.into(),
amendment: Value::Null,
}
}
pub fn reject(actor: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
approved: false,
actor: actor.into(),
reason: reason.into(),
amendment: Value::Null,
}
}
#[must_use]
pub fn amend(mut self, v: Value) -> Self {
self.amendment = v;
self
}
#[must_use]
pub fn expired(on_expiry: OnExpiry) -> Self {
match on_expiry {
OnExpiry::Proceed => Self {
approved: true,
actor: "system:unattended".into(),
reason: "no answer within the window; proceeding was pre-authorised".into(),
amendment: Value::Null,
},
OnExpiry::Deny | OnExpiry::Escalate => Self {
approved: false,
actor: "system:expiry".into(),
reason: "no answer within the window".into(),
amendment: Value::Null,
},
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn an_amendment_rides_along_without_disturbing_the_verdict() {
let plain = Decision::approve("rita", "within her limit");
let amended = Decision::approve("rita", "within her limit").amend(json!({"cap": 5000}));
assert_eq!(amended.amendment, json!({"cap": 5000}));
assert_eq!(plain.amendment, Value::Null, "the default carries none");
assert_eq!(amended.approved, plain.approved);
assert_eq!(amended.actor, plain.actor);
assert_eq!(amended.reason, plain.reason);
let rejected = Decision::reject("rita", "over her limit").amend(json!({"cap": 5000}));
assert!(!rejected.approved, "amending must not approve");
}
}