use crate::core::ctx::Ctx;
use crate::core::state::DispatchOutcome;
use crate::types::{now_unix, RunId};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::Digest;
use std::collections::HashMap;
use std::sync::Mutex;
use thiserror::Error;
pub mod sqlite;
pub use sqlite::SqliteReplayStore;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplayEntry {
pub run_id: RunId,
pub step_ref: String,
pub input_hash: String,
pub occurrence: u32,
pub ctx_snapshot_json: String,
pub step_output_json: String,
pub created_at: u64,
}
impl ReplayEntry {
pub fn from_completion(
run_id: RunId,
step_ref: impl Into<String>,
input_hash: impl Into<String>,
occurrence: u32,
ctx: &Ctx,
step_output: &Value,
) -> Result<Self, ReplayStoreError> {
let ctx_snapshot_json = serde_json::to_string(ctx)
.map_err(|e| ReplayStoreError::Encode(format!("ctx snapshot: {e}")))?;
let step_output_json = serde_json::to_string(step_output)
.map_err(|e| ReplayStoreError::Encode(format!("step output: {e}")))?;
Ok(Self {
run_id,
step_ref: step_ref.into(),
input_hash: input_hash.into(),
occurrence,
ctx_snapshot_json,
step_output_json,
created_at: now_unix(),
})
}
pub fn decode_step_output(&self) -> Result<Value, ReplayStoreError> {
serde_json::from_str(&self.step_output_json)
.map_err(|e| ReplayStoreError::Decode(format!("step output: {e}")))
}
pub fn decode_ctx_snapshot(&self) -> Result<Ctx, ReplayStoreError> {
serde_json::from_str(&self.ctx_snapshot_json)
.map_err(|e| ReplayStoreError::Decode(format!("ctx snapshot: {e}")))
}
}
#[derive(Debug, Error)]
pub enum ReplayStoreError {
#[error("duplicate replay entry: run_id={run_id} step_ref={step_ref} input_hash={input_hash} occurrence={occurrence}")]
Duplicate {
run_id: RunId,
step_ref: String,
input_hash: String,
occurrence: u32,
},
#[error("encode: {0}")]
Encode(String),
#[error("decode: {0}")]
Decode(String),
#[error("other: {0}")]
Other(String),
}
#[async_trait]
pub trait ReplayStore: Send + Sync {
fn name(&self) -> &str;
async fn append(&self, entry: ReplayEntry) -> Result<(), ReplayStoreError>;
async fn list_by_run(&self, run_id: &RunId) -> Result<Vec<ReplayEntry>, ReplayStoreError>;
}
#[derive(Default)]
pub struct InMemoryReplayStore {
inner: Mutex<InMemoryInner>,
}
#[derive(Default)]
struct InMemoryInner {
by_run: HashMap<RunId, Vec<ReplayEntry>>,
}
impl InMemoryReplayStore {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl ReplayStore for InMemoryReplayStore {
fn name(&self) -> &str {
"in-memory"
}
async fn append(&self, entry: ReplayEntry) -> Result<(), ReplayStoreError> {
let mut inner = self.inner.lock().expect("replay store mutex poisoned");
let rows = inner.by_run.entry(entry.run_id.clone()).or_default();
if rows.iter().any(|e| {
e.step_ref == entry.step_ref
&& e.input_hash == entry.input_hash
&& e.occurrence == entry.occurrence
}) {
return Err(ReplayStoreError::Duplicate {
run_id: entry.run_id,
step_ref: entry.step_ref,
input_hash: entry.input_hash,
occurrence: entry.occurrence,
});
}
rows.push(entry);
Ok(())
}
async fn list_by_run(&self, run_id: &RunId) -> Result<Vec<ReplayEntry>, ReplayStoreError> {
let inner = self.inner.lock().expect("replay store mutex poisoned");
Ok(inner.by_run.get(run_id).cloned().unwrap_or_default())
}
}
#[derive(Debug, Default)]
pub struct ReplayCursor {
by_key: HashMap<(String, String, u32), Value>,
seen: HashMap<(String, String), u32>,
}
impl ReplayCursor {
pub fn from_entries(entries: Vec<ReplayEntry>) -> Self {
let mut by_key: HashMap<(String, String, u32), Value> = HashMap::new();
for entry in entries {
match entry.decode_step_output() {
Ok(value) => {
by_key.insert(
(
entry.step_ref.clone(),
entry.input_hash.clone(),
entry.occurrence,
),
value,
);
}
Err(e) => {
tracing::warn!(
run_id = %entry.run_id,
step_ref = %entry.step_ref,
occurrence = entry.occurrence,
error = %e,
"ReplayCursor::from_entries: skipping row with undecodable step_output"
);
}
}
}
Self {
by_key,
seen: HashMap::new(),
}
}
pub fn next_occurrence(&mut self, step_ref: &str, input_hash: &str) -> u32 {
let key = (step_ref.to_string(), input_hash.to_string());
let counter = self.seen.entry(key).or_insert(0);
let current = *counter;
*counter = counter.saturating_add(1);
current
}
pub fn find(&self, step_ref: &str, input_hash: &str, occurrence: u32) -> Option<Value> {
self.by_key
.get(&(step_ref.to_string(), input_hash.to_string(), occurrence))
.cloned()
}
pub fn len(&self) -> usize {
self.by_key.len()
}
pub fn is_empty(&self) -> bool {
self.by_key.is_empty()
}
}
pub fn hash_input_value(value: &Value) -> String {
let s = serde_json::to_string(value).unwrap_or_else(|_| String::new());
let mut hasher = sha2::Sha256::new();
hasher.update(s.as_bytes());
hex::encode(hasher.finalize())
}
pub fn pass_value(outcome: &DispatchOutcome) -> Option<&Value> {
match outcome {
DispatchOutcome::Pass(v) => Some(v),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ctx::{Ctx, OperatorInfo};
use crate::types::StepId;
use serde_json::json;
fn mk_ctx() -> Ctx {
let mut ctx = Ctx::new(StepId::new(), 1, "step-a");
ctx.meta
.runtime
.insert("worker_handle".into(), json!("wh-abcd1234"));
ctx.meta
.observer
.insert("trace_id".into(), json!("trace-42"));
ctx.meta.authz.insert("who".into(), json!("op-1"));
ctx.meta.loop_ns.insert("iter".into(), json!(0));
ctx
}
#[test]
fn ctx_serde_round_trip_drops_operator_but_keeps_meta() {
let mut ctx = mk_ctx();
ctx.operator = OperatorInfo {
id: "some-operator".to_string(),
..OperatorInfo::default()
};
let s = serde_json::to_string(&ctx).expect("serialize ctx");
assert!(
!s.contains("some-operator"),
"operator field must be dropped by #[serde(skip)]"
);
let back: Ctx = serde_json::from_str(&s).expect("deserialize ctx");
assert_eq!(back.task_id, ctx.task_id);
assert_eq!(back.attempt, ctx.attempt);
assert_eq!(back.agent, ctx.agent);
assert_eq!(back.meta.runtime, ctx.meta.runtime);
assert_eq!(back.meta.authz, ctx.meta.authz);
assert_eq!(back.meta.observer, ctx.meta.observer);
assert_eq!(back.meta.loop_ns, ctx.meta.loop_ns);
let default_op = OperatorInfo::default();
assert_eq!(back.operator.id, default_op.id);
assert!(back.operator.senior_bridge.is_none());
assert!(back.operator.spawn_hook.is_none());
assert!(back.operator.operator.is_none());
}
#[tokio::test]
async fn inmemory_append_and_list_preserves_order() {
let store = InMemoryReplayStore::new();
let run_id = RunId::new();
let ctx = mk_ctx();
let e1 = ReplayEntry::from_completion(
run_id.clone(),
"step-a",
"hash-1",
0,
&ctx,
&json!({ "out": 1 }),
)
.unwrap();
let e2 = ReplayEntry::from_completion(
run_id.clone(),
"step-b",
"hash-2",
0,
&ctx,
&json!({ "out": 2 }),
)
.unwrap();
store.append(e1.clone()).await.unwrap();
store.append(e2.clone()).await.unwrap();
let listed = store.list_by_run(&run_id).await.unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].step_ref, "step-a");
assert_eq!(listed[1].step_ref, "step-b");
assert_eq!(listed[0].decode_step_output().unwrap(), json!({ "out": 1 }));
assert_eq!(listed[1].decode_step_output().unwrap(), json!({ "out": 2 }));
}
#[tokio::test]
async fn inmemory_duplicate_rejected() {
let store = InMemoryReplayStore::new();
let run_id = RunId::new();
let ctx = mk_ctx();
let e =
ReplayEntry::from_completion(run_id.clone(), "step-a", "hash-1", 0, &ctx, &json!("v"))
.unwrap();
store.append(e.clone()).await.unwrap();
let err = store.append(e).await.unwrap_err();
assert!(matches!(err, ReplayStoreError::Duplicate { .. }));
}
#[tokio::test]
async fn inmemory_list_by_run_isolates_runs() {
let store = InMemoryReplayStore::new();
let r1 = RunId::new();
let r2 = RunId::new();
let ctx = mk_ctx();
store
.append(ReplayEntry::from_completion(r1.clone(), "a", "h", 0, &ctx, &json!(1)).unwrap())
.await
.unwrap();
store
.append(ReplayEntry::from_completion(r2.clone(), "a", "h", 0, &ctx, &json!(2)).unwrap())
.await
.unwrap();
assert_eq!(store.list_by_run(&r1).await.unwrap().len(), 1);
assert_eq!(store.list_by_run(&r2).await.unwrap().len(), 1);
assert_eq!(
store.list_by_run(&r1).await.unwrap()[0]
.decode_step_output()
.unwrap(),
json!(1)
);
}
#[tokio::test]
async fn cursor_from_entries_hit_and_miss() {
let store = InMemoryReplayStore::new();
let run_id = RunId::new();
let ctx = mk_ctx();
store
.append(
ReplayEntry::from_completion(
run_id.clone(),
"step-a",
"hash-a",
0,
&ctx,
&json!({ "value": "stored-a" }),
)
.unwrap(),
)
.await
.unwrap();
let entries = store.list_by_run(&run_id).await.unwrap();
let cursor = ReplayCursor::from_entries(entries);
assert_eq!(cursor.len(), 1);
assert_eq!(
cursor.find("step-a", "hash-a", 0),
Some(json!({ "value": "stored-a" }))
);
assert!(cursor.find("step-a", "hash-a", 1).is_none());
assert!(cursor.find("step-b", "hash-a", 0).is_none());
assert!(cursor.find("step-a", "hash-b", 0).is_none());
}
#[test]
fn cursor_next_occurrence_increments_per_key() {
let mut cursor = ReplayCursor::default();
assert_eq!(cursor.next_occurrence("a", "h"), 0);
assert_eq!(cursor.next_occurrence("a", "h"), 1);
assert_eq!(cursor.next_occurrence("a", "h"), 2);
assert_eq!(cursor.next_occurrence("b", "h"), 0);
assert_eq!(cursor.next_occurrence("a", "h2"), 0);
}
#[tokio::test]
async fn occurrence_1_replay_row_coexists_with_occurrence_0() {
let store = InMemoryReplayStore::new();
let run_id = RunId::new();
let ctx = mk_ctx();
let e0 = ReplayEntry::from_completion(
run_id.clone(),
"step-a",
"hash-a",
0,
&ctx,
&json!("first"),
)
.unwrap();
let e1 = ReplayEntry::from_completion(
run_id.clone(),
"step-a",
"hash-a",
1,
&ctx,
&json!("second"),
)
.unwrap();
store.append(e0).await.unwrap();
store
.append(e1)
.await
.expect("occurrence=1 must not collide with occurrence=0");
let entries = store.list_by_run(&run_id).await.unwrap();
assert_eq!(entries.len(), 2);
let cursor = ReplayCursor::from_entries(entries);
assert_eq!(cursor.find("step-a", "hash-a", 0), Some(json!("first")));
assert_eq!(cursor.find("step-a", "hash-a", 1), Some(json!("second")));
}
#[test]
fn hash_input_value_deterministic_for_same_json() {
let v1 = json!({ "a": 1, "b": [2, 3] });
let v2 = json!({ "a": 1, "b": [2, 3] });
assert_eq!(hash_input_value(&v1), hash_input_value(&v2));
let v3 = json!({ "a": 2, "b": [2, 3] });
assert_ne!(hash_input_value(&v1), hash_input_value(&v3));
}
#[test]
fn pass_value_filters_non_pass_outcomes() {
assert!(pass_value(&DispatchOutcome::Pass(json!("ok"))).is_some());
assert!(pass_value(&DispatchOutcome::Blocked(json!("no"))).is_none());
assert!(pass_value(&DispatchOutcome::Cancelled).is_none());
assert!(pass_value(&DispatchOutcome::Timeout).is_none());
}
}