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}