use anyhow::{Result, anyhow};
use rusqlite::params;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use licoup_workflow::{ReducerEvent, RunSnapshot};
use super::store::StrategyStore;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TransitionIntent {
pub run_id: String,
pub sequence: u64,
pub event: ReducerEvent,
pub before: RunSnapshot,
pub after: RunSnapshot,
pub created_at_unix_ms: i64,
}
pub type CommittedTransition = TransitionIntent;
pub trait TransitionObserver: Send + Sync {
fn after_commit(&self, transition: &CommittedTransition) -> Result<()>;
}
impl<F> TransitionObserver for F
where
F: Fn(&CommittedTransition) -> Result<()> + Send + Sync,
{
fn after_commit(&self, transition: &CommittedTransition) -> Result<()> {
self(transition)
}
}
#[derive(Clone)]
pub struct TransitionDecorator {
store: StrategyStore,
observer: Option<Arc<dyn TransitionObserver>>,
}
impl std::fmt::Debug for TransitionDecorator {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("TransitionDecorator")
.field("store", &self.store)
.field("observer_attached", &self.observer.is_some())
.finish()
}
}
impl TransitionDecorator {
pub fn new(store: StrategyStore) -> Self {
Self {
store,
observer: None,
}
}
pub fn with_observer(mut self, observer: Arc<dyn TransitionObserver>) -> Self {
self.observer = Some(observer);
self
}
pub fn store(&self) -> &StrategyStore {
&self.store
}
pub fn apply_event(&self, run_id: &str, event: ReducerEvent) -> Result<RunSnapshot> {
let committed = self.store.apply_event_with_commit(run_id, event)?;
let Some(committed) = committed else {
return self.store.run(run_id);
};
self.dispatch(committed.clone());
Ok(committed.after)
}
pub fn reconcile_pending(&self) -> Result<usize> {
let intents = self.store.pending_transition_intents()?;
let mut completed = 0usize;
for intent in intents {
if self.dispatch(intent) {
completed += 1;
}
}
Ok(completed)
}
fn dispatch(&self, intent: TransitionIntent) -> bool {
let Some(observer) = &self.observer else {
return false;
};
if observer.after_commit(&intent).is_err() {
return false;
}
self.store
.acknowledge_transition_intent(&intent.run_id, intent.sequence)
.is_ok()
}
}
impl StrategyStore {
pub(crate) fn pending_transition_intents(&self) -> Result<Vec<TransitionIntent>> {
self.with_connection(|connection| {
let mut statement = connection.prepare(
"SELECT run_id, sequence, event_json, before_json, after_json, created_at
FROM workflow_transition_intents
WHERE status='pending' ORDER BY created_at ASC, run_id ASC, sequence ASC",
)?;
let rows = statement.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, i64>(5)?,
))
})?;
rows.map(|row| {
let (run_id, sequence, event, before, after, created_at) = row?;
Ok(TransitionIntent {
run_id,
sequence: sequence.max(0) as u64,
event: serde_json::from_str(&event)?,
before: serde_json::from_str(&before)?,
after: serde_json::from_str(&after)?,
created_at_unix_ms: created_at,
})
})
.collect()
})
}
pub(crate) fn acknowledge_transition_intent(&self, run_id: &str, sequence: u64) -> Result<()> {
let changed = self.with_connection(|connection| -> anyhow::Result<usize> {
Ok(connection.execute(
"UPDATE workflow_transition_intents
SET status='dispatched', dispatched_at=?3
WHERE run_id=?1 AND sequence=?2 AND status='pending'",
params![run_id, sequence as i64, unix_ms()],
)?)
})?;
if changed == 1 {
Ok(())
} else {
Err(anyhow!("workflow_transition_intent_missing"))
}
}
}
pub(crate) fn initialize_schema(connection: &rusqlite::Connection) -> Result<()> {
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS workflow_transition_intents(
run_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
event_json TEXT NOT NULL,
before_json TEXT NOT NULL,
after_json TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'dispatched')),
created_at INTEGER NOT NULL,
dispatched_at INTEGER,
PRIMARY KEY(run_id, sequence)
);
CREATE INDEX IF NOT EXISTS workflow_transition_intents_pending_idx
ON workflow_transition_intents(status, created_at);",
)?;
Ok(())
}
pub(crate) fn insert_intent(
transaction: &rusqlite::Transaction<'_>,
run_id: &str,
sequence: u64,
event: &ReducerEvent,
before: &RunSnapshot,
after: &RunSnapshot,
created_at: i64,
) -> Result<()> {
transaction.execute(
"INSERT INTO workflow_transition_intents(
run_id, sequence, event_json, before_json, after_json,
status, created_at, dispatched_at
) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, NULL)",
params![
run_id,
sequence as i64,
serde_json::to_string(event)?,
serde_json::to_string(before)?,
serde_json::to_string(after)?,
created_at,
],
)?;
Ok(())
}
fn unix_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BindingValue;
use licoup_workflow::{
ActorSlot, GraphState, GraphStateKind, RetryPolicy, Transition, TransitionEvent,
TransitionMode, WorkflowDefinition, WorkflowLimits, WorkflowMetadata,
};
use serde_json::json;
use std::sync::{Arc, Mutex};
fn assistant_workflow() -> WorkflowDefinition {
WorkflowDefinition {
schema: licoup_workflow::WORKFLOW_SCHEMA_VERSION.into(),
metadata: WorkflowMetadata {
id: "assistant-temporary-commit-test".into(),
name: "Commit test".into(),
version: "1".into(),
description: String::new(),
},
limits: WorkflowLimits::default(),
actor_slots: vec![ActorSlot::required_actor("worker", "Worker")],
runtimes: vec![],
worksets: vec![],
initial: "work".into(),
states: vec![
GraphState {
id: "work".into(),
kind: GraphStateKind::Actor,
label: "Work".into(),
instruction: String::new(),
binding: Some("worker".into()),
runtime: None,
entry: None,
workset: None,
retry: RetryPolicy::default(),
},
GraphState {
id: "done".into(),
kind: GraphStateKind::Succeed,
label: "Done".into(),
instruction: String::new(),
binding: None,
runtime: None,
entry: None,
workset: None,
retry: RetryPolicy::default(),
},
GraphState {
id: "fail".into(),
kind: GraphStateKind::Fail,
label: "Fail".into(),
instruction: String::new(),
binding: None,
runtime: None,
entry: None,
workset: None,
retry: RetryPolicy::default(),
},
],
transitions: vec![
Transition {
id: "done".into(),
from: "work".into(),
to: "done".into(),
event: TransitionEvent::Success,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "failed".into(),
from: "work".into(),
to: "fail".into(),
event: TransitionEvent::Failure,
mode: TransitionMode::Flow,
guard: None,
},
],
}
}
#[test]
fn observer_runs_after_commit_and_pending_intents_reconcile() {
let store = StrategyStore::open_in_memory().unwrap();
let (run, _) = store
.admit_assistant_run(
"commit-test-revision",
"commit-test-semantics",
&assistant_workflow(),
&[BindingValue {
slot_id: "worker".into(),
ordinal: 0,
value_id: "agent:test".into(),
model: String::new(),
reasoning_effort: String::new(),
revision: 1,
}],
json!({"input": "synthetic"}),
"commit-test-idempotency",
"conversation:test",
"membership:test",
json!({"source": "test"}),
)
.unwrap();
let observed_sequences = Arc::new(Mutex::new(Vec::new()));
let attempts = Arc::new(Mutex::new(0usize));
let observer_store = store.clone();
let observer = Arc::new({
let observed_sequences = observed_sequences.clone();
let attempts = attempts.clone();
move |transition: &CommittedTransition| -> Result<()> {
let persisted = observer_store.run(&transition.run_id)?;
assert_eq!(persisted.sequence, transition.after.sequence);
let mut attempt = attempts.lock().unwrap();
if *attempt == 0 {
*attempt += 1;
return Err(anyhow!("synthetic post-commit interruption"));
}
observed_sequences.lock().unwrap().push(transition.sequence);
Ok(())
}
});
let decorator = TransitionDecorator::new(store.clone()).with_observer(observer);
assert_eq!(decorator.reconcile_pending().unwrap(), 0);
assert_eq!(decorator.reconcile_pending().unwrap(), 1);
let command = run.commands.values().next().unwrap();
let after = decorator
.apply_event(
&run.run_id,
ReducerEvent::CommandClaimed {
command_id: command.id.clone(),
attempt_token: command.attempt_token.clone(),
},
)
.unwrap();
assert_eq!(after.sequence, run.sequence + 1);
assert_eq!(*observed_sequences.lock().unwrap(), vec![1, 2]);
assert_eq!(decorator.reconcile_pending().unwrap(), 0);
}
}