use a2a_protocol_types::error::{A2aError, ErrorCode};
use a2a_protocol_types::task::{TaskId, TaskState};
pub const TERMINAL_STATE_CONFLICT_MARKER: &str = "terminalStateConflict";
#[must_use]
pub fn refuses_write(stored: TaskState, written: TaskState) -> bool {
stored.is_terminal() && stored != written
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct TerminalStateConflict {
pub task_id: TaskId,
pub stored: TaskState,
pub attempted: TaskState,
}
impl TerminalStateConflict {
#[must_use]
pub const fn new(task_id: TaskId, stored: TaskState, attempted: TaskState) -> Self {
Self {
task_id,
stored,
attempted,
}
}
#[must_use]
pub fn into_error(self) -> A2aError {
A2aError::with_data(
ErrorCode::UnsupportedOperation,
format!(
"task {} is in terminal state '{}'; a write of '{}' was refused",
self.task_id, self.stored, self.attempted
),
serde_json::json!({
TERMINAL_STATE_CONFLICT_MARKER: {
"taskId": self.task_id.0,
"storedState": self.stored,
"attemptedState": self.attempted,
}
}),
)
}
#[must_use]
pub fn from_error(err: &A2aError) -> Option<Self> {
if err.code != ErrorCode::UnsupportedOperation {
return None;
}
let body = err.data.as_ref()?.get(TERMINAL_STATE_CONFLICT_MARKER)?;
let task_id = body.get("taskId")?.as_str()?;
let stored = serde_json::from_value(body.get("storedState")?.clone()).ok()?;
let attempted = serde_json::from_value(body.get("attemptedState")?.clone()).ok()?;
Some(Self::new(TaskId::new(task_id), stored, attempted))
}
}
impl From<TerminalStateConflict> for A2aError {
fn from(conflict: TerminalStateConflict) -> Self {
conflict.into_error()
}
}
#[cfg(any(feature = "sqlite", feature = "postgres"))]
macro_rules! sql_write_allowed {
($before:literal, $column:literal, $written:literal $(, $after:literal)?) => {
concat!(
$before,
"(",
$column,
" NOT IN ('TASK_STATE_COMPLETED', 'TASK_STATE_FAILED', \
'TASK_STATE_CANCELED', 'TASK_STATE_REJECTED') OR ",
$column,
" = ",
$written,
")"
$(, $after)?
)
};
}
#[cfg(any(feature = "sqlite", feature = "postgres"))]
pub(crate) use sql_write_allowed;
#[cfg(feature = "sqlite")]
pub(crate) const SQL_STATE_NOT_TERMINAL_OR_EQUALS: &str = "(state NOT IN \
('TASK_STATE_COMPLETED', 'TASK_STATE_FAILED', 'TASK_STATE_CANCELED', 'TASK_STATE_REJECTED') \
OR state = ";
#[cfg(any(feature = "sqlite", feature = "postgres"))]
pub(crate) fn parse_state_column(value: &str) -> A2aResult<TaskState> {
serde_json::from_value(serde_json::Value::String(value.to_owned())).map_err(|e| {
A2aError::internal(format!(
"stored task has an unreadable state {value:?}: {e}"
))
})
}
#[cfg(any(feature = "sqlite", feature = "postgres"))]
pub(crate) fn refusal(task: &Task, stored: Option<&str>) -> A2aError {
match stored.map(parse_state_column) {
Some(Ok(stored)) => {
TerminalStateConflict::new(task.id.clone(), stored, task.status.state).into_error()
}
Some(Err(e)) => e,
None => A2aError::internal(format!(
"a write of task {} changed nothing, and the task is no longer stored",
task.id
)),
}
}
#[cfg(any(feature = "sqlite", feature = "postgres"))]
use a2a_protocol_types::error::A2aResult;
#[cfg(any(feature = "sqlite", feature = "postgres"))]
use a2a_protocol_types::task::Task;
#[cfg(test)]
mod tests {
use super::*;
const EVERY_STATE: [TaskState; 9] = [
TaskState::Unspecified,
TaskState::Submitted,
TaskState::Working,
TaskState::InputRequired,
TaskState::AuthRequired,
TaskState::Completed,
TaskState::Failed,
TaskState::Canceled,
TaskState::Rejected,
];
#[test]
fn a_terminal_state_refuses_every_other_state_and_accepts_itself() {
for stored in EVERY_STATE {
for written in EVERY_STATE {
assert_eq!(
refuses_write(stored, written),
stored.is_terminal() && stored != written,
"{stored} <- {written}"
);
}
}
assert!(refuses_write(TaskState::Canceled, TaskState::Completed));
assert!(!refuses_write(TaskState::Canceled, TaskState::Canceled));
assert!(
!refuses_write(TaskState::InputRequired, TaskState::Working),
"interrupted states are not sticky"
);
}
#[test]
fn the_error_round_trips_and_nothing_else_matches() {
let conflict = TerminalStateConflict::new(
TaskId::new("t-1"),
TaskState::Canceled,
TaskState::Completed,
);
let err = conflict.clone().into_error();
assert_eq!(err.code, ErrorCode::UnsupportedOperation);
assert!(err.message.contains("t-1"), "{}", err.message);
assert_eq!(
TerminalStateConflict::from_error(&err),
Some(conflict.clone())
);
assert_eq!(
TerminalStateConflict::from_error(&A2aError::from(conflict.clone())),
Some(conflict)
);
assert_eq!(
TerminalStateConflict::from_error(&A2aError::unsupported_operation("x")),
None,
"the code alone is not the marker"
);
let mut wrong_code = err.clone();
wrong_code.code = ErrorCode::InternalError;
assert_eq!(
TerminalStateConflict::from_error(&wrong_code),
None,
"the marker under another code is not this refusal"
);
let mut garbled = err;
garbled.data = Some(serde_json::json!({
TERMINAL_STATE_CONFLICT_MARKER: {"taskId": "t", "storedState": "nope", "attemptedState": "TASK_STATE_WORKING"}
}));
assert_eq!(TerminalStateConflict::from_error(&garbled), None);
}
#[cfg(any(feature = "sqlite", feature = "postgres"))]
#[test]
fn the_sql_list_is_the_terminal_states() {
let sql = sql_write_allowed!("WHERE ", "state", "?1");
for state in EVERY_STATE {
assert_eq!(
sql.contains(&format!("'{state}'")),
state.is_terminal(),
"{state} in {sql}"
);
}
assert_eq!(
crate::store::retention::terminal_state_labels().len(),
4,
"a new terminal state must be added to the SQL list above"
);
assert!(sql.starts_with("WHERE (state NOT IN ("), "{sql}");
assert!(sql.ends_with("OR state = ?1)"), "{sql}");
assert!(
sql_write_allowed!("", "s", "?2", " AND x").ends_with("OR s = ?2) AND x"),
"the trailing literal is appended"
);
#[cfg(feature = "sqlite")]
assert_eq!(
format!("{SQL_STATE_NOT_TERMINAL_OR_EQUALS}?1)"),
sql_write_allowed!("", "state", "?1"),
"the run-time form must be the macro's, cut open"
);
}
#[cfg(any(feature = "sqlite", feature = "postgres"))]
#[test]
fn state_columns_parse_and_garbage_is_an_error() {
assert_eq!(
parse_state_column("TASK_STATE_CANCELED").ok(),
Some(TaskState::Canceled)
);
assert!(parse_state_column("BOGUS").is_err());
}
#[cfg(any(feature = "sqlite", feature = "postgres"))]
#[test]
fn a_refusal_names_the_stored_state_or_says_the_row_is_gone() {
let task = Task {
id: TaskId::new("t-r"),
context_id: a2a_protocol_types::task::ContextId::new("c"),
status: a2a_protocol_types::task::TaskStatus::new(TaskState::Completed),
history: None,
artifacts: None,
metadata: None,
};
assert_eq!(
TerminalStateConflict::from_error(&refusal(&task, Some("TASK_STATE_CANCELED"))),
Some(TerminalStateConflict::new(
TaskId::new("t-r"),
TaskState::Canceled,
TaskState::Completed
))
);
let gone = refusal(&task, None);
assert_eq!(gone.code, ErrorCode::InternalError);
assert!(gone.message.contains("t-r"), "{}", gone.message);
assert_eq!(refusal(&task, Some("BOGUS")).code, ErrorCode::InternalError);
}
}