use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::core::{CaseId, Digest, EffectKey, Operator, RunId, StoreError, Timestamp};
#[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 },
#[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 },
#[error("task {task} is not held by '{actor}'")]
NotHeld { task: TaskId, actor: String },
#[error("task {task} was already answered; the run consumed another decision")]
AlreadyAnswered { task: TaskId },
#[error(transparent)]
Store(#[from] StoreError),
}
#[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.strip_prefix("task_").unwrap_or(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,
Withdrawn,
}
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",
Self::Withdrawn => "withdrawn",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|c| c.as_str() == s)
}
pub const ALL: [Self; 6] = [
Self::Open,
Self::Claimed,
Self::Completed,
Self::Expired,
Self::Escalated,
Self::Withdrawn,
];
#[must_use]
pub const fn is_pending(self) -> bool {
match self {
Self::Open | Self::Claimed | Self::Escalated => true,
Self::Completed | Self::Expired | Self::Withdrawn => false,
}
}
#[must_use]
pub const fn is_queued(self) -> bool {
match self {
Self::Open | Self::Escalated => true,
Self::Claimed | Self::Completed | Self::Expired | Self::Withdrawn => false,
}
}
#[must_use]
pub const fn awaits_expiry(self) -> bool {
match self {
Self::Open | Self::Claimed => true,
Self::Completed | Self::Expired | Self::Escalated | Self::Withdrawn => false,
}
}
}
#[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",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|c| c.as_str() == s)
}
pub const ALL: [Self; 4] = [Self::Low, Self::Normal, Self::High, Self::Urgent];
#[must_use]
pub const fn rank(self) -> u8 {
match self {
Self::Urgent => 0,
Self::High => 1,
Self::Normal => 2,
Self::Low => 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnExpiry {
Deny,
Escalate,
Proceed,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Expiry {
#[default]
Deny,
Escalate {
to: Vec<String>,
},
ProceedUnattended,
}
impl Expiry {
#[must_use]
pub fn escalate_to<R: Into<String>>(roles: impl IntoIterator<Item = R>) -> Self {
Self::Escalate {
to: roles.into_iter().map(Into::into).collect(),
}
}
#[must_use]
pub const fn policy(&self) -> OnExpiry {
match self {
Self::Deny => OnExpiry::Deny,
Self::Escalate { .. } => OnExpiry::Escalate,
Self::ProceedUnattended => OnExpiry::Proceed,
}
}
#[must_use]
pub fn escalate_roles(&self) -> &[String] {
match self {
Self::Escalate { to } => to,
Self::Deny | Self::ProceedUnattended => &[],
}
}
}
impl OnExpiry {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Deny => "deny",
Self::Escalate => "escalate",
Self::Proceed => "proceed",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|c| c.as_str() == s)
}
pub const ALL: [Self; 3] = [Self::Deny, Self::Escalate, Self::Proceed];
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Justification {
pub summary: crate::core::Tainted<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<crate::core::Tainted<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<crate::core::Tainted<String>>,
}
impl Justification {
#[must_use]
pub fn digest(&self) -> Digest {
let value = serde_json::to_value(self)
.expect("a justification holds only infallibly serializable fields");
let mut framed = b"agentplane.task.justification.v1\0".to_vec();
framed.extend_from_slice(&crate::core::canon::value_bytes(&value));
Digest::of(&framed)
}
pub fn new(summary: crate::core::Tainted<String>, proposed_action: Value) -> Self {
Self {
summary,
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: crate::core::Tainted<String>) -> Self {
self.cost = Some(c);
self
}
#[must_use]
pub fn evidence(mut self, e: crate::core::Tainted<String>) -> Self {
self.evidence.push(e);
self
}
#[must_use]
pub fn has_untrusted_prose(&self) -> bool {
let untrusted =
|t: &crate::core::Tainted<String>| t.label().trust != crate::core::Trust::Trusted;
untrusted(&self.summary)
|| self.cost.as_ref().is_some_and(untrusted)
|| self.evidence.iter().any(untrusted)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TaskSpec {
pub kind: String,
pub justification: Justification,
pub candidate_roles: Vec<String>,
pub priority: Priority,
pub deadline: String,
pub on_expiry: Expiry,
pub excluded_actors: Vec<String>,
}
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: Expiry::Deny,
excluded_actors: Vec::new(),
}
}
#[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: Expiry) -> Self {
self.on_expiry = e;
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,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub escalate_to: Vec<String>,
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub withheld: Option<Withheld>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Withheld {
Sealed,
Erased,
Undecodable,
}
impl Withheld {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Sealed => "sealed",
Self::Erased => "erased",
Self::Undecodable => "undecodable",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|c| c.as_str() == s)
}
pub const ALL: [Self; 3] = [Self::Sealed, Self::Erased, Self::Undecodable];
}
impl std::fmt::Display for Withheld {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Sealed => "it is sealed, and this plane holds no key ring to open it",
Self::Erased => "it was erased: the key it was sealed under is destroyed",
Self::Undecodable => "its sealed bytes opened and do not decode",
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct Rendering {
#[serde(skip_serializing_if = "Option::is_none")]
pub withheld: Option<Withheld>,
pub summary: String,
pub proposed_action: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<String>,
pub escaped: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub mixed_script: Vec<MixedScript>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct MixedScript {
pub at: String,
pub word: String,
pub scripts: Vec<&'static str>,
}
struct Renderer {
escaped: bool,
mixed_script: Vec<MixedScript>,
}
impl Renderer {
fn text(&mut self, at: &str, text: &str) -> String {
for word in text.split_whitespace() {
if let Some(scripts) = crate::core::visible::mixed_scripts(word) {
self.mixed_script.push(MixedScript {
at: at.to_owned(),
word: crate::core::visible::escape(word).0,
scripts,
});
}
}
let (shown, escaped) = crate::core::visible::escape(text);
self.escaped |= escaped;
shown
}
fn value(&mut self, at: &str, value: &Value) -> Value {
match value {
Value::String(s) => Value::String(self.text(at, s)),
Value::Array(items) => Value::Array(
items
.iter()
.enumerate()
.map(|(i, v)| self.value(&format!("{at}/{i}"), v))
.collect(),
),
Value::Object(fields) => Value::Object(
fields
.iter()
.map(|(k, v)| {
let key = self.text(&format!("{at} (a key)"), k);
let inner = self.value(&format!("{at}/{k}"), v);
(key, inner)
})
.collect(),
),
other => other.clone(),
}
}
}
impl Task {
#[must_use]
pub fn rendering(&self) -> Rendering {
let j = &self.justification;
let mut r = Renderer {
escaped: false,
mixed_script: Vec::new(),
};
let summary = r.text("summary", j.summary.peek());
let cost = j.cost.as_ref().map(|c| r.text("cost", c.peek()));
let (proposed_action, evidence) = if self.withheld.is_some() {
(Value::Null, Vec::new())
} else {
(
r.value("proposed_action", &j.proposed_action),
j.evidence
.iter()
.enumerate()
.map(|(i, line)| r.text(&format!("evidence/{i}"), line.peek()))
.collect(),
)
};
Rendering {
withheld: self.withheld,
summary,
proposed_action,
cost,
evidence,
escaped: r.escaped,
mixed_script: r.mixed_script,
}
}
#[must_use]
pub fn shown_justification(&self) -> Justification {
let mut shown = self.justification.clone();
if self.withheld.is_some() {
shown.proposed_action = Value::Null;
shown.evidence.clear();
}
shown
}
#[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))
}
pub fn escalate(&mut self) {
self.state = TaskState::Escalated;
self.assignee = None;
if !self.candidate_roles.is_empty() {
for role in &self.escalate_to {
if !self.candidate_roles.contains(role) {
self.candidate_roles.push(role.clone());
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Decided {
By(Operator),
OnExpiry(OnExpiry),
}
impl Decided {
#[must_use]
pub const fn operator(&self) -> Option<&Operator> {
match self {
Self::By(op) => Some(op),
Self::OnExpiry(_) => None,
}
}
}
impl std::fmt::Display for Decided {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::By(op) => f.write_str(op.actor()),
Self::OnExpiry(policy) => write!(f, "the declared policy ({})", policy.as_str()),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Decision {
pub approved: bool,
pub decided: Decided,
pub reason: String,
#[serde(default)]
pub amendment: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewed: Option<Digest>,
}
impl Decision {
pub fn approve(by: Operator, reason: impl Into<String>) -> Self {
Self {
approved: true,
decided: Decided::By(by),
reason: reason.into(),
amendment: Value::Null,
reviewed: None,
}
}
pub fn reject(by: Operator, reason: impl Into<String>) -> Self {
Self {
approved: false,
decided: Decided::By(by),
reason: reason.into(),
amendment: Value::Null,
reviewed: None,
}
}
#[must_use]
pub fn amend(mut self, v: Value) -> Self {
self.amendment = v;
self
}
#[must_use]
pub fn expired(on_expiry: OnExpiry) -> Self {
Self {
approved: on_expiry == OnExpiry::Proceed,
decided: Decided::OnExpiry(on_expiry),
reason: match on_expiry {
OnExpiry::Proceed => {
"no answer within the window; proceeding was pre-authorised".to_owned()
}
OnExpiry::Deny | OnExpiry::Escalate => "no answer within the window".to_owned(),
},
amendment: Value::Null,
reviewed: None,
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn rita() -> Operator {
Operator::asserted("rita").expect("a fixture names its operator")
}
#[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.decided, plain.decided);
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");
}
#[test]
fn a_task_id_parses_from_its_own_display_form() {
let id = TaskId::derive(
crate::core::RunId::generate(),
crate::core::EffectKey::from_hex(&format!("{:064x}", 7)).expect("hex key"),
);
assert_eq!(TaskId::parse(&id.to_string()).expect("display form"), id);
assert_eq!(TaskId::parse(&id.to_hex()).expect("bare hex"), id);
assert!(TaskId::parse("run_0123").is_err());
}
}