Skip to main content

mlua_swarm/store/replay/
mod.rs

1//! `ReplayStore` — per-run log of completed step outputs plus the whole
2//! [`Ctx`] snapshot captured at the moment each step passed.
3//!
4//! # Architecture
5//!
6//! This module is the Core primitive for restart-equivalent recovery: at
7//! the moment `Engine::dispatch_attempt_with_run_ctx` sees a step's Adapter
8//! dispatch complete with `DispatchOutcome::Pass(value)`, it appends a
9//! [`ReplayEntry`] carrying `(run_id, step_ref, input_hash, occurrence,
10//! ctx_snapshot_json, step_output_json)` — no Adapter-external state (WS
11//! session id, `worker_handle`, spawner registrations) is persisted. On
12//! restart, a fresh `Engine` re-runs the same Blueprint and consults a
13//! [`ReplayCursor`] built from the stored entries: a matching `(step_ref,
14//! input_hash, occurrence)` returns the stored value verbatim as
15//! `DispatchOutcome::Pass`, skipping the worker spawn entirely; a miss
16//! dispatches normally.
17//!
18//! `Ctx` carries `#[serde(skip)] operator: OperatorInfo`, so the trait-object
19//! faces (bridges, hooks, operator backends) naturally drop out of the
20//! snapshot; on the replay side, `Ctx::deserialize` rebuilds them as
21//! `OperatorInfo::default()`, and the fresh `Engine`'s registries repopulate
22//! them at dispatch time. This is the "Adapter concern stays in the
23//! Adapter" boundary the subtask brief calls out.
24//!
25//! ## Contract summary
26//!
27//! - **`ReplayEntry`** — plain data row, `Serialize`/`Deserialize` and
28//!   `Clone`. `input_hash` is a hex-encoded SHA-256 over a canonicalized
29//!   JSON form of the resolved input (see [`hash_input_value`]);
30//!   `occurrence` counts repeated dispatches of the same
31//!   `(run, step, input)` triple in order (loop bodies re-visiting the
32//!   same step).
33//! - **`ReplayStore`** — async trait: `append` writes one row, `list_by_run`
34//!   returns every row for a `RunId` in insertion order. The two backends
35//!   shipped here are [`InMemoryReplayStore`] (default, process-volatile)
36//!   and [`SqliteReplayStore`](sqlite::SqliteReplayStore) (file-backed).
37//! - **`ReplayCursor`** — the read-side helper: `from_entries` builds an
38//!   in-memory index and `next_occurrence` + `find` are the two calls the
39//!   dispatcher makes per attempt.
40//!
41//! ## Not in scope (this iteration)
42//!
43//! - HTTP resume trigger, CLI flags, and boot-time auto-resume — these
44//!   sit above this Core primitive and are out of scope for this iteration.
45//! - Operator/worker session re-registration — the Adapter-external state
46//!   is Adapter concern; the fresh factory re-mints handles/tokens.
47
48use crate::core::ctx::Ctx;
49use crate::core::state::DispatchOutcome;
50use crate::types::{now_unix, RunId};
51use async_trait::async_trait;
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use sha2::Digest;
55use std::collections::HashMap;
56use std::sync::Mutex;
57use thiserror::Error;
58
59pub mod sqlite;
60
61pub use sqlite::SqliteReplayStore;
62
63// ──────────────────────────────────────────────────────────────────────────
64// ReplayEntry — one persisted replay row.
65// ──────────────────────────────────────────────────────────────────────────
66
67/// One persisted row in the replay log — a step's completion snapshot the
68/// dispatcher stored after `DispatchOutcome::Pass`.
69///
70/// The row's identity is the 4-tuple `(run_id, step_ref, input_hash,
71/// occurrence)`; the SQLite backend enforces this as a `UNIQUE` constraint.
72/// `ctx_snapshot_json` and `step_output_json` are the two payloads: the
73/// former is a full [`Ctx`] serde-JSON with `operator` dropped by the
74/// `#[serde(skip)]` on that field, and the latter is the `DispatchOutcome::Pass`
75/// value the worker produced (audit + the value the replay hit returns).
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ReplayEntry {
78    /// The Run this dispatch belonged to (issue #13 hierarchy).
79    pub run_id: RunId,
80    /// Blueprint step ref that was dispatched (`TaskSpec.agent` at the
81    /// engine boundary).
82    pub step_ref: String,
83    /// Deterministic hex-encoded SHA-256 hash of the resolved input value
84    /// — see [`hash_input_value`].
85    pub input_hash: String,
86    /// Zero-based counter for repeated dispatches of the same
87    /// `(run_id, step_ref, input_hash)` triple within one Run.
88    pub occurrence: u32,
89    /// Serde-JSON of the whole [`Ctx`] at the moment the step passed.
90    /// `Ctx.operator` is dropped by the `#[serde(skip)]` on that field —
91    /// see this module's doc.
92    pub ctx_snapshot_json: String,
93    /// Serde-JSON of the `DispatchOutcome::Pass` value.
94    pub step_output_json: String,
95    /// Unix epoch seconds — when this row was recorded.
96    pub created_at: u64,
97}
98
99impl ReplayEntry {
100    /// Build a fresh entry from the dispatch-completion state, encoding
101    /// `ctx` + `step_output` to their JSON representations and stamping
102    /// `created_at` to `now_unix()`. Returns a [`ReplayStoreError::Encode`]
103    /// if either serialization fails.
104    pub fn from_completion(
105        run_id: RunId,
106        step_ref: impl Into<String>,
107        input_hash: impl Into<String>,
108        occurrence: u32,
109        ctx: &Ctx,
110        step_output: &Value,
111    ) -> Result<Self, ReplayStoreError> {
112        let ctx_snapshot_json = serde_json::to_string(ctx)
113            .map_err(|e| ReplayStoreError::Encode(format!("ctx snapshot: {e}")))?;
114        let step_output_json = serde_json::to_string(step_output)
115            .map_err(|e| ReplayStoreError::Encode(format!("step output: {e}")))?;
116        Ok(Self {
117            run_id,
118            step_ref: step_ref.into(),
119            input_hash: input_hash.into(),
120            occurrence,
121            ctx_snapshot_json,
122            step_output_json,
123            created_at: now_unix(),
124        })
125    }
126
127    /// Decode the `step_output_json` back to a [`Value`] — used by the
128    /// replay-hit path in `Engine::dispatch_attempt_with_run_ctx` to hand
129    /// a `DispatchOutcome::Pass` back to the caller without touching the
130    /// Adapter.
131    pub fn decode_step_output(&self) -> Result<Value, ReplayStoreError> {
132        serde_json::from_str(&self.step_output_json)
133            .map_err(|e| ReplayStoreError::Decode(format!("step output: {e}")))
134    }
135
136    /// Decode the `ctx_snapshot_json` back to a [`Ctx`]. `Ctx.operator`
137    /// is not serialized (`#[serde(skip)]`), so the round-tripped value
138    /// carries `OperatorInfo::default()` in that field.
139    pub fn decode_ctx_snapshot(&self) -> Result<Ctx, ReplayStoreError> {
140        serde_json::from_str(&self.ctx_snapshot_json)
141            .map_err(|e| ReplayStoreError::Decode(format!("ctx snapshot: {e}")))
142    }
143}
144
145// ──────────────────────────────────────────────────────────────────────────
146// Errors.
147// ──────────────────────────────────────────────────────────────────────────
148
149/// Errors surfaced by a [`ReplayStore`] implementation.
150#[derive(Debug, Error)]
151pub enum ReplayStoreError {
152    /// The backend refused the row because its 4-tuple identity already
153    /// exists (`UNIQUE (run_id, step_ref, input_hash, occurrence)` in the
154    /// SQLite backend).
155    #[error("duplicate replay entry: run_id={run_id} step_ref={step_ref} input_hash={input_hash} occurrence={occurrence}")]
156    Duplicate {
157        /// The Run this attempt was rejected for.
158        run_id: RunId,
159        /// The step ref of the rejected attempt.
160        step_ref: String,
161        /// The input hash of the rejected attempt.
162        input_hash: String,
163        /// The occurrence counter of the rejected attempt.
164        occurrence: u32,
165    },
166    /// Encoding a Ctx/output to JSON failed while building an entry.
167    #[error("encode: {0}")]
168    Encode(String),
169    /// Decoding a stored JSON payload back to Rust types failed.
170    #[error("decode: {0}")]
171    Decode(String),
172    /// Backend-specific failure not covered by the other variants
173    /// (I/O errors, driver errors, etc.).
174    #[error("other: {0}")]
175    Other(String),
176}
177
178// ──────────────────────────────────────────────────────────────────────────
179// ReplayStore trait.
180// ──────────────────────────────────────────────────────────────────────────
181
182/// Persistence interface for the replay log — one row per completed step
183/// per Run. Backends must preserve insertion order for `list_by_run`.
184#[async_trait]
185pub trait ReplayStore: Send + Sync {
186    /// Backend name — for diagnostics/logging.
187    fn name(&self) -> &str;
188
189    /// Append one entry. Returns [`ReplayStoreError::Duplicate`] if the
190    /// backend already carries a row with the same
191    /// `(run_id, step_ref, input_hash, occurrence)`.
192    async fn append(&self, entry: ReplayEntry) -> Result<(), ReplayStoreError>;
193
194    /// Return every entry for `run_id`, in insertion order (the order the
195    /// dispatcher wrote them).
196    async fn list_by_run(&self, run_id: &RunId) -> Result<Vec<ReplayEntry>, ReplayStoreError>;
197
198    /// Delete every entry for `run_id` whose position in insertion order is
199    /// `>= from_index` — i.e. keep the first `from_index` entries and drop
200    /// the rest. Returns the number of rows deleted.
201    ///
202    /// Used by the rerun-from-step path
203    /// (`POST /v1/runs/:id/rerun-from`, [`crate::store::run::RunStatus`]):
204    /// the caller has already located the cut point via [`Self::list_by_run`]
205    /// and now needs the store to physically remove the target step's row
206    /// plus every downstream row so that the rerun dispatch's
207    /// [`Self::append`] does not collide with the old
208    /// `(step_ref, input_hash, occurrence)` row, and so subsequent
209    /// [`Self::list_by_run`] introspection reflects the rerun's real
210    /// history rather than the pre-rerun ghost.
211    ///
212    /// `from_index >= list_by_run(...).len()` is a no-op returning `0`
213    /// (not an error) — the caller is not expected to re-check the length.
214    ///
215    /// Index-based rather than `created_at`-based: `created_at` is Unix
216    /// seconds and two entries appended within the same second are
217    /// indistinguishable, so a timestamp cut would be ambiguous. The
218    /// insertion-order index the handler already computes is unambiguous.
219    async fn delete_from(
220        &self,
221        run_id: &RunId,
222        from_index: usize,
223    ) -> Result<usize, ReplayStoreError>;
224}
225
226// ──────────────────────────────────────────────────────────────────────────
227// InMemoryReplayStore.
228// ──────────────────────────────────────────────────────────────────────────
229
230/// Process-volatile [`ReplayStore`] — the default backend. Entries are
231/// lost on restart; use [`SqliteReplayStore`](sqlite::SqliteReplayStore)
232/// when survive-restart is what the caller wants.
233#[derive(Default)]
234pub struct InMemoryReplayStore {
235    inner: Mutex<InMemoryInner>,
236}
237
238#[derive(Default)]
239struct InMemoryInner {
240    /// Every appended entry, keyed by RunId, in append order per Run.
241    by_run: HashMap<RunId, Vec<ReplayEntry>>,
242}
243
244impl InMemoryReplayStore {
245    /// Create an empty store.
246    pub fn new() -> Self {
247        Self::default()
248    }
249}
250
251#[async_trait]
252impl ReplayStore for InMemoryReplayStore {
253    fn name(&self) -> &str {
254        "in-memory"
255    }
256
257    async fn append(&self, entry: ReplayEntry) -> Result<(), ReplayStoreError> {
258        let mut inner = self.inner.lock().expect("replay store mutex poisoned");
259        let rows = inner.by_run.entry(entry.run_id.clone()).or_default();
260        if rows.iter().any(|e| {
261            e.step_ref == entry.step_ref
262                && e.input_hash == entry.input_hash
263                && e.occurrence == entry.occurrence
264        }) {
265            return Err(ReplayStoreError::Duplicate {
266                run_id: entry.run_id,
267                step_ref: entry.step_ref,
268                input_hash: entry.input_hash,
269                occurrence: entry.occurrence,
270            });
271        }
272        rows.push(entry);
273        Ok(())
274    }
275
276    async fn list_by_run(&self, run_id: &RunId) -> Result<Vec<ReplayEntry>, ReplayStoreError> {
277        let inner = self.inner.lock().expect("replay store mutex poisoned");
278        Ok(inner.by_run.get(run_id).cloned().unwrap_or_default())
279    }
280
281    async fn delete_from(
282        &self,
283        run_id: &RunId,
284        from_index: usize,
285    ) -> Result<usize, ReplayStoreError> {
286        let mut inner = self.inner.lock().expect("replay store mutex poisoned");
287        let Some(rows) = inner.by_run.get_mut(run_id) else {
288            return Ok(0);
289        };
290        if from_index >= rows.len() {
291            return Ok(0);
292        }
293        let dropped = rows.len() - from_index;
294        rows.truncate(from_index);
295        Ok(dropped)
296    }
297}
298
299// ──────────────────────────────────────────────────────────────────────────
300// ReplayCursor — dispatcher-side read helper.
301// ──────────────────────────────────────────────────────────────────────────
302
303/// Dispatcher-side read helper: an in-memory index of the entries loaded
304/// from a `ReplayStore` for one Run, plus the per-`(step_ref, input_hash)`
305/// occurrence counter the dispatcher bumps at each call.
306///
307/// `next_occurrence` and `find` are the two operations the dispatcher
308/// performs per attempt: the former advances the counter and returns
309/// the current occurrence value, the latter tries to resolve
310/// `(step_ref, input_hash, occurrence)` against the loaded entries.
311#[derive(Debug, Default)]
312pub struct ReplayCursor {
313    /// `(step_ref, input_hash, occurrence) → decoded step output`.
314    by_key: HashMap<(String, String, u32), Value>,
315    /// Per-`(step_ref, input_hash)` occurrence counter — bumped by
316    /// `next_occurrence` on every call.
317    seen: HashMap<(String, String), u32>,
318}
319
320impl ReplayCursor {
321    /// Build a cursor from a run's worth of entries. Entries whose
322    /// `step_output_json` fails to decode are silently skipped after a
323    /// `tracing::warn!` — a corrupt row must not prevent the rest of the
324    /// run from replaying.
325    pub fn from_entries(entries: Vec<ReplayEntry>) -> Self {
326        let mut by_key: HashMap<(String, String, u32), Value> = HashMap::new();
327        for entry in entries {
328            match entry.decode_step_output() {
329                Ok(value) => {
330                    by_key.insert(
331                        (
332                            entry.step_ref.clone(),
333                            entry.input_hash.clone(),
334                            entry.occurrence,
335                        ),
336                        value,
337                    );
338                }
339                Err(e) => {
340                    tracing::warn!(
341                        run_id = %entry.run_id,
342                        step_ref = %entry.step_ref,
343                        occurrence = entry.occurrence,
344                        error = %e,
345                        "ReplayCursor::from_entries: skipping row with undecodable step_output"
346                    );
347                }
348            }
349        }
350        Self {
351            by_key,
352            seen: HashMap::new(),
353        }
354    }
355
356    /// Advance the occurrence counter for `(step_ref, input_hash)` and
357    /// return the value **for THIS call** (0 on the first call, 1 on the
358    /// second, and so on). Consumes one occurrence: two consecutive calls
359    /// with the same key return 0 then 1.
360    pub fn next_occurrence(&mut self, step_ref: &str, input_hash: &str) -> u32 {
361        let key = (step_ref.to_string(), input_hash.to_string());
362        let counter = self.seen.entry(key).or_insert(0);
363        let current = *counter;
364        *counter = counter.saturating_add(1);
365        current
366    }
367
368    /// Look up a stored `DispatchOutcome::Pass` value by
369    /// `(step_ref, input_hash, occurrence)`. `None` = miss (no matching
370    /// row was appended for this triple).
371    pub fn find(&self, step_ref: &str, input_hash: &str, occurrence: u32) -> Option<Value> {
372        self.by_key
373            .get(&(step_ref.to_string(), input_hash.to_string(), occurrence))
374            .cloned()
375    }
376
377    /// Number of `(step_ref, input_hash, occurrence)` entries loaded.
378    /// Handy for tests / diagnostics.
379    pub fn len(&self) -> usize {
380        self.by_key.len()
381    }
382
383    /// `true` when no entries are loaded — every `find` will miss.
384    pub fn is_empty(&self) -> bool {
385        self.by_key.is_empty()
386    }
387}
388
389// ──────────────────────────────────────────────────────────────────────────
390// hash_input_value — deterministic input hash.
391// ──────────────────────────────────────────────────────────────────────────
392
393/// Compute a deterministic hex-encoded SHA-256 of a resolved input value
394/// (`TaskSpec.initial_directive` at the engine boundary).
395///
396/// The hash is over the canonical JSON serialization produced by
397/// `serde_json::to_string(&value)`. `serde_json` preserves the object-key
398/// insertion order that `Value::Object` (backed by `serde_json::Map`,
399/// which is `IndexMap` by default) already carries — so identical `Value`s
400/// hash identically. Callers pass the SAME `Value` shape across the
401/// original run and the replay run; nothing in this hash function
402/// canonicalizes further, and that is by design: the replay contract is
403/// value-identity, not semantic equivalence.
404pub fn hash_input_value(value: &Value) -> String {
405    let s = serde_json::to_string(value).unwrap_or_else(|_| String::new());
406    let mut hasher = sha2::Sha256::new();
407    hasher.update(s.as_bytes());
408    hex::encode(hasher.finalize())
409}
410
411// ──────────────────────────────────────────────────────────────────────────
412// Convenience — outcome → replay entry (used by the dispatcher).
413// ──────────────────────────────────────────────────────────────────────────
414
415/// If `outcome` is `DispatchOutcome::Pass(value)`, borrow the value for
416/// [`ReplayEntry::from_completion`]; every other outcome (Blocked / err)
417/// returns `None` — the dispatcher intentionally does not log those rows
418/// to prevent partial-state poisoning of the replay log.
419pub fn pass_value(outcome: &DispatchOutcome) -> Option<&Value> {
420    match outcome {
421        DispatchOutcome::Pass(v) => Some(v),
422        _ => None,
423    }
424}
425
426// ──────────────────────────────────────────────────────────────────────────
427// Unit tests.
428// ──────────────────────────────────────────────────────────────────────────
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::core::ctx::{Ctx, OperatorInfo};
434    use crate::types::StepId;
435    use serde_json::json;
436
437    fn mk_ctx() -> Ctx {
438        let mut ctx = Ctx::new(StepId::new(), 1, "step-a");
439        ctx.meta
440            .runtime
441            .insert("worker_handle".into(), json!("wh-abcd1234"));
442        ctx.meta
443            .observer
444            .insert("trace_id".into(), json!("trace-42"));
445        ctx.meta.authz.insert("who".into(), json!("op-1"));
446        ctx.meta.loop_ns.insert("iter".into(), json!(0));
447        ctx
448    }
449
450    #[test]
451    fn ctx_serde_round_trip_drops_operator_but_keeps_meta() {
452        let mut ctx = mk_ctx();
453        // Set a non-default operator id to prove operator field is skipped.
454        ctx.operator = OperatorInfo {
455            id: "some-operator".to_string(),
456            ..OperatorInfo::default()
457        };
458
459        let s = serde_json::to_string(&ctx).expect("serialize ctx");
460        assert!(
461            !s.contains("some-operator"),
462            "operator field must be dropped by #[serde(skip)]"
463        );
464
465        let back: Ctx = serde_json::from_str(&s).expect("deserialize ctx");
466        assert_eq!(back.task_id, ctx.task_id);
467        assert_eq!(back.attempt, ctx.attempt);
468        assert_eq!(back.agent, ctx.agent);
469        assert_eq!(back.meta.runtime, ctx.meta.runtime);
470        assert_eq!(back.meta.authz, ctx.meta.authz);
471        assert_eq!(back.meta.observer, ctx.meta.observer);
472        assert_eq!(back.meta.loop_ns, ctx.meta.loop_ns);
473        // operator round-trips to `OperatorInfo::default()` — the `id`
474        // slot's default is `"default-automate"` (see `ctx.rs`) and the
475        // three trait-object faces are `None`.
476        let default_op = OperatorInfo::default();
477        assert_eq!(back.operator.id, default_op.id);
478        assert!(back.operator.senior_bridge.is_none());
479        assert!(back.operator.spawn_hook.is_none());
480        assert!(back.operator.operator.is_none());
481    }
482
483    #[tokio::test]
484    async fn inmemory_append_and_list_preserves_order() {
485        let store = InMemoryReplayStore::new();
486        let run_id = RunId::new();
487        let ctx = mk_ctx();
488        let e1 = ReplayEntry::from_completion(
489            run_id.clone(),
490            "step-a",
491            "hash-1",
492            0,
493            &ctx,
494            &json!({ "out": 1 }),
495        )
496        .unwrap();
497        let e2 = ReplayEntry::from_completion(
498            run_id.clone(),
499            "step-b",
500            "hash-2",
501            0,
502            &ctx,
503            &json!({ "out": 2 }),
504        )
505        .unwrap();
506        store.append(e1.clone()).await.unwrap();
507        store.append(e2.clone()).await.unwrap();
508
509        let listed = store.list_by_run(&run_id).await.unwrap();
510        assert_eq!(listed.len(), 2);
511        assert_eq!(listed[0].step_ref, "step-a");
512        assert_eq!(listed[1].step_ref, "step-b");
513        assert_eq!(listed[0].decode_step_output().unwrap(), json!({ "out": 1 }));
514        assert_eq!(listed[1].decode_step_output().unwrap(), json!({ "out": 2 }));
515    }
516
517    #[tokio::test]
518    async fn inmemory_duplicate_rejected() {
519        let store = InMemoryReplayStore::new();
520        let run_id = RunId::new();
521        let ctx = mk_ctx();
522        let e =
523            ReplayEntry::from_completion(run_id.clone(), "step-a", "hash-1", 0, &ctx, &json!("v"))
524                .unwrap();
525        store.append(e.clone()).await.unwrap();
526        let err = store.append(e).await.unwrap_err();
527        assert!(matches!(err, ReplayStoreError::Duplicate { .. }));
528    }
529
530    #[tokio::test]
531    async fn inmemory_list_by_run_isolates_runs() {
532        let store = InMemoryReplayStore::new();
533        let r1 = RunId::new();
534        let r2 = RunId::new();
535        let ctx = mk_ctx();
536        store
537            .append(ReplayEntry::from_completion(r1.clone(), "a", "h", 0, &ctx, &json!(1)).unwrap())
538            .await
539            .unwrap();
540        store
541            .append(ReplayEntry::from_completion(r2.clone(), "a", "h", 0, &ctx, &json!(2)).unwrap())
542            .await
543            .unwrap();
544        assert_eq!(store.list_by_run(&r1).await.unwrap().len(), 1);
545        assert_eq!(store.list_by_run(&r2).await.unwrap().len(), 1);
546        assert_eq!(
547            store.list_by_run(&r1).await.unwrap()[0]
548                .decode_step_output()
549                .unwrap(),
550            json!(1)
551        );
552    }
553
554    #[tokio::test]
555    async fn cursor_from_entries_hit_and_miss() {
556        let store = InMemoryReplayStore::new();
557        let run_id = RunId::new();
558        let ctx = mk_ctx();
559        store
560            .append(
561                ReplayEntry::from_completion(
562                    run_id.clone(),
563                    "step-a",
564                    "hash-a",
565                    0,
566                    &ctx,
567                    &json!({ "value": "stored-a" }),
568                )
569                .unwrap(),
570            )
571            .await
572            .unwrap();
573
574        let entries = store.list_by_run(&run_id).await.unwrap();
575        let cursor = ReplayCursor::from_entries(entries);
576
577        assert_eq!(cursor.len(), 1);
578        assert_eq!(
579            cursor.find("step-a", "hash-a", 0),
580            Some(json!({ "value": "stored-a" }))
581        );
582        assert!(cursor.find("step-a", "hash-a", 1).is_none());
583        assert!(cursor.find("step-b", "hash-a", 0).is_none());
584        assert!(cursor.find("step-a", "hash-b", 0).is_none());
585    }
586
587    #[test]
588    fn cursor_next_occurrence_increments_per_key() {
589        let mut cursor = ReplayCursor::default();
590        assert_eq!(cursor.next_occurrence("a", "h"), 0);
591        assert_eq!(cursor.next_occurrence("a", "h"), 1);
592        assert_eq!(cursor.next_occurrence("a", "h"), 2);
593        // Different key starts its own counter.
594        assert_eq!(cursor.next_occurrence("b", "h"), 0);
595        assert_eq!(cursor.next_occurrence("a", "h2"), 0);
596    }
597
598    #[tokio::test]
599    async fn occurrence_1_replay_row_coexists_with_occurrence_0() {
600        // The occurrence counter is what lets the dispatcher log a second
601        // dispatch of the SAME (run, step, input) without hitting the
602        // UNIQUE constraint. The InMemory store verifies the same rule.
603        let store = InMemoryReplayStore::new();
604        let run_id = RunId::new();
605        let ctx = mk_ctx();
606        let e0 = ReplayEntry::from_completion(
607            run_id.clone(),
608            "step-a",
609            "hash-a",
610            0,
611            &ctx,
612            &json!("first"),
613        )
614        .unwrap();
615        let e1 = ReplayEntry::from_completion(
616            run_id.clone(),
617            "step-a",
618            "hash-a",
619            1,
620            &ctx,
621            &json!("second"),
622        )
623        .unwrap();
624        store.append(e0).await.unwrap();
625        store
626            .append(e1)
627            .await
628            .expect("occurrence=1 must not collide with occurrence=0");
629
630        let entries = store.list_by_run(&run_id).await.unwrap();
631        assert_eq!(entries.len(), 2);
632        let cursor = ReplayCursor::from_entries(entries);
633        assert_eq!(cursor.find("step-a", "hash-a", 0), Some(json!("first")));
634        assert_eq!(cursor.find("step-a", "hash-a", 1), Some(json!("second")));
635    }
636
637    #[test]
638    fn hash_input_value_deterministic_for_same_json() {
639        let v1 = json!({ "a": 1, "b": [2, 3] });
640        let v2 = json!({ "a": 1, "b": [2, 3] });
641        assert_eq!(hash_input_value(&v1), hash_input_value(&v2));
642
643        // Different value → different hash.
644        let v3 = json!({ "a": 2, "b": [2, 3] });
645        assert_ne!(hash_input_value(&v1), hash_input_value(&v3));
646    }
647
648    #[test]
649    fn pass_value_filters_non_pass_outcomes() {
650        assert!(pass_value(&DispatchOutcome::Pass(json!("ok"))).is_some());
651        assert!(pass_value(&DispatchOutcome::Blocked(json!("no"))).is_none());
652        assert!(pass_value(&DispatchOutcome::Cancelled).is_none());
653        assert!(pass_value(&DispatchOutcome::Timeout).is_none());
654    }
655
656    #[tokio::test]
657    async fn inmemory_delete_from_truncates_and_returns_count() {
658        let store = InMemoryReplayStore::new();
659        let run_id = RunId::new();
660        let ctx = mk_ctx();
661        for (step, occ) in [("a", 0), ("b", 0), ("c", 0), ("d", 0)] {
662            store
663                .append(
664                    ReplayEntry::from_completion(
665                        run_id.clone(),
666                        step,
667                        "h",
668                        occ,
669                        &ctx,
670                        &json!({ "s": step }),
671                    )
672                    .unwrap(),
673                )
674                .await
675                .unwrap();
676        }
677
678        // Cut at index 2: keep a, b; drop c, d.
679        let dropped = store.delete_from(&run_id, 2).await.unwrap();
680        assert_eq!(dropped, 2);
681
682        let remaining = store.list_by_run(&run_id).await.unwrap();
683        let refs: Vec<String> = remaining.iter().map(|e| e.step_ref.clone()).collect();
684        assert_eq!(refs, vec!["a", "b"]);
685    }
686
687    #[tokio::test]
688    async fn inmemory_delete_from_past_length_is_noop() {
689        let store = InMemoryReplayStore::new();
690        let run_id = RunId::new();
691        let ctx = mk_ctx();
692        store
693            .append(
694                ReplayEntry::from_completion(run_id.clone(), "a", "h", 0, &ctx, &json!(1)).unwrap(),
695            )
696            .await
697            .unwrap();
698
699        assert_eq!(store.delete_from(&run_id, 1).await.unwrap(), 0);
700        assert_eq!(store.delete_from(&run_id, 99).await.unwrap(), 0);
701        assert_eq!(store.list_by_run(&run_id).await.unwrap().len(), 1);
702    }
703
704    #[tokio::test]
705    async fn inmemory_delete_from_missing_run_is_noop() {
706        let store = InMemoryReplayStore::new();
707        let run_id = RunId::new();
708        assert_eq!(store.delete_from(&run_id, 0).await.unwrap(), 0);
709    }
710
711    #[tokio::test]
712    async fn inmemory_delete_from_zero_wipes_run() {
713        let store = InMemoryReplayStore::new();
714        let run_id = RunId::new();
715        let ctx = mk_ctx();
716        for step in ["a", "b", "c"] {
717            store
718                .append(
719                    ReplayEntry::from_completion(run_id.clone(), step, "h", 0, &ctx, &json!(step))
720                        .unwrap(),
721                )
722                .await
723                .unwrap();
724        }
725        assert_eq!(store.delete_from(&run_id, 0).await.unwrap(), 3);
726        assert!(store.list_by_run(&run_id).await.unwrap().is_empty());
727    }
728
729    #[tokio::test]
730    async fn inmemory_delete_from_isolates_runs() {
731        let store = InMemoryReplayStore::new();
732        let r1 = RunId::new();
733        let r2 = RunId::new();
734        let ctx = mk_ctx();
735        for step in ["a", "b"] {
736            store
737                .append(
738                    ReplayEntry::from_completion(r1.clone(), step, "h", 0, &ctx, &json!(step))
739                        .unwrap(),
740                )
741                .await
742                .unwrap();
743            store
744                .append(
745                    ReplayEntry::from_completion(r2.clone(), step, "h", 0, &ctx, &json!(step))
746                        .unwrap(),
747                )
748                .await
749                .unwrap();
750        }
751        assert_eq!(store.delete_from(&r1, 0).await.unwrap(), 2);
752        assert!(store.list_by_run(&r1).await.unwrap().is_empty());
753        assert_eq!(store.list_by_run(&r2).await.unwrap().len(), 2);
754    }
755
756    #[tokio::test]
757    async fn inmemory_delete_from_frees_slots_for_reappend() {
758        // After delete_from cuts the target row, the caller (rerun dispatch)
759        // must be able to re-append the same (step_ref, input_hash, occurrence)
760        // triple without hitting the Duplicate guard.
761        let store = InMemoryReplayStore::new();
762        let run_id = RunId::new();
763        let ctx = mk_ctx();
764        for step in ["a", "b"] {
765            store
766                .append(
767                    ReplayEntry::from_completion(
768                        run_id.clone(),
769                        step,
770                        "hash",
771                        0,
772                        &ctx,
773                        &json!({ "v": step }),
774                    )
775                    .unwrap(),
776                )
777                .await
778                .unwrap();
779        }
780        assert_eq!(store.delete_from(&run_id, 1).await.unwrap(), 1);
781        // Re-append `b` with a fresh output — must succeed.
782        store
783            .append(
784                ReplayEntry::from_completion(
785                    run_id.clone(),
786                    "b",
787                    "hash",
788                    0,
789                    &ctx,
790                    &json!({ "v": "b-fresh" }),
791                )
792                .unwrap(),
793            )
794            .await
795            .expect("re-append after delete_from must not collide");
796        let listed = store.list_by_run(&run_id).await.unwrap();
797        assert_eq!(listed.len(), 2);
798        assert_eq!(
799            listed[1].decode_step_output().unwrap(),
800            json!({ "v": "b-fresh" })
801        );
802    }
803}