octl_core/events.rs
1//! Event append primitive + `seq` recovery (design.md §1.4, §4).
2
3use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
4use std::path::{Path, PathBuf};
5
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::atomic::{open_events_append, write_atomic};
11use crate::error::{Error, Result};
12use crate::lock::{LockedRun, RunLock};
13use crate::paths::RunPaths;
14use crate::projections::{derive_counters, read_manifest_opt, write_manifest};
15use crate::reducer::{commit_ops, reduce_event_to_ops};
16use crate::schema::{Event, NodeId};
17
18/// Backward-scan chunk size when looking for the previous newline.
19const SCAN_CHUNK: u64 = 64 * 1024;
20
21/// Read the last `seq` from `events.jsonl`, or `0` if empty/missing.
22///
23/// Tolerates:
24/// - lines larger than any fixed buffer (`node.report` payloads can be 10s of KB
25/// per `design.md` §1.4) — we scan backwards in chunks for the previous `\n`.
26/// - a crash-truncated final line lacking a trailing `\n` — that partial tail
27/// is discarded and recovery uses the last complete record.
28///
29/// Caller must already hold the run's [`RunLock`] for correctness against
30/// concurrent appenders.
31pub fn recover_last_seq(events_path: &Path) -> Result<u64> {
32 let mut f = match std::fs::File::open(events_path) {
33 Ok(f) => f,
34 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
35 Err(e) => return Err(Error::io(events_path, e)),
36 };
37 let len = f.metadata().map_err(|e| Error::io(events_path, e))?.len();
38 if len == 0 {
39 return Ok(0);
40 }
41
42 // Require a newline-terminated final line; otherwise treat the last
43 // partial chunk as torn and recover from the previous complete line.
44 let mut tail_byte = [0u8; 1];
45 f.seek(SeekFrom::End(-1))
46 .map_err(|e| Error::io(events_path, e))?;
47 f.read_exact(&mut tail_byte)
48 .map_err(|e| Error::io(events_path, e))?;
49 let mut end = if tail_byte[0] == b'\n' {
50 len - 1
51 } else {
52 match find_prev_newline(&mut f, len, events_path)? {
53 Some(p) => p,
54 None => return Ok(0),
55 }
56 };
57
58 // `end` is the byte index of the trailing `\n` of the last complete
59 // record. Walk backward over complete lines, skipping any that are empty
60 // or whitespace-only — consecutive newlines or blank/whitespace lines (e.g.
61 // from external editing) shouldn't fool recovery into reading the wrong
62 // last record — and recover the seq from the last line bearing real bytes.
63 loop {
64 let line_start = match find_prev_newline(&mut f, end, events_path)? {
65 Some(p) => p + 1,
66 None => 0,
67 };
68 let line_len = end - line_start;
69 f.seek(SeekFrom::Start(line_start))
70 .map_err(|e| Error::io(events_path, e))?;
71 let mut line = vec![0u8; line_len as usize];
72 f.read_exact(&mut line)
73 .map_err(|e| Error::io(events_path, e))?;
74 // Any non-whitespace byte means a real record — parse it. Lines that
75 // are empty or hold only ASCII whitespace (a stray `\r`, `\t`, or
76 // spaces left by external editing) carry no record, so skip them and
77 // keep scanning back; serde tolerates whitespace surrounding a real
78 // envelope, so a genuine record with trailing spaces still parses.
79 if line.iter().any(|b| !b.is_ascii_whitespace()) {
80 return parse_seq(&line, events_path);
81 }
82 // Whitespace-only line: no record here. Step to the newline before it
83 // and keep scanning; reaching the start means the log holds no event.
84 if line_start == 0 {
85 return Ok(0);
86 }
87 end = line_start - 1;
88 }
89}
90
91/// The envelope fields recovered from the last complete line. Required fields
92/// mirror [`Event`]'s required shape, so `recover_last_seq` accepts a last line
93/// iff [`read_all_events`] would — the two readers agree on what the last
94/// record is. `data` / `idempotency_key` are skipped (serde ignores unknown
95/// fields) so a multi-KB `node.report` payload isn't re-materialized on the
96/// hot append path just to read `seq`.
97#[derive(Deserialize)]
98#[allow(dead_code)] // fields exist to force serde validation, not to be read
99struct SeqLine {
100 seq: u64,
101 ts: chrono::DateTime<chrono::Utc>,
102 kind: String,
103 run_id: crate::schema::RunId,
104 #[serde(default)]
105 node_id: Option<NodeId>,
106}
107
108fn parse_seq(line: &[u8], events_path: &Path) -> Result<u64> {
109 // The last complete line must be a full, valid event envelope — the same
110 // bar `read_all_events` applies to every line — so a `\n`-terminated line
111 // that parses as JSON but isn't a valid event (e.g. `{"seq":1}` missing
112 // `ts`/`run_id`) is event-log corruption, not a usable seq source. This
113 // keeps the three readers aligned on the last record.
114 let hdr: SeqLine = serde_json::from_slice(line).map_err(|e| Error::CorruptEventLog {
115 path: events_path.to_path_buf(),
116 reason: format!(
117 "last complete line is not a valid event: {} [{e}]",
118 excerpt(line)
119 ),
120 })?;
121 Ok(hdr.seq)
122}
123
124/// Find the byte offset of the last `\n` strictly before `before`. Returns
125/// `None` if no newline exists in `[0, before)`.
126fn find_prev_newline(
127 f: &mut std::fs::File,
128 before: u64,
129 events_path: &Path,
130) -> Result<Option<u64>> {
131 if before == 0 {
132 return Ok(None);
133 }
134 let mut pos = before;
135 loop {
136 let start = pos.saturating_sub(SCAN_CHUNK);
137 let len = pos - start;
138 f.seek(SeekFrom::Start(start))
139 .map_err(|e| Error::io(events_path, e))?;
140 let mut buf = vec![0u8; len as usize];
141 f.read_exact(&mut buf)
142 .map_err(|e| Error::io(events_path, e))?;
143 if let Some(i) = buf.iter().rposition(|b| *b == b'\n') {
144 return Ok(Some(start + i as u64));
145 }
146 if start == 0 {
147 return Ok(None);
148 }
149 pos = start;
150 }
151}
152
153/// Truncate a torn (newline-less) final line off `events.jsonl` so the next
154/// append never concatenates onto a partial record.
155///
156/// `recover_last_seq` only *ignores* a torn tail for seq purposes — it never
157/// removes the bytes. Without this, an append after a crash-truncated write
158/// would write its `\n`-terminated line directly onto the partial bytes,
159/// producing one malformed `…torn…{"seq":…}` line that every later reader
160/// (now sharing a strict torn-tail policy) hard-errors on. Cutting back to
161/// the last complete record here guarantees the file is always empty or
162/// `\n`-terminated before we append.
163///
164/// Caller must hold the run's [`RunLock`]. No-op when the file is absent,
165/// empty, or already `\n`-terminated (the common, clean case — one `stat` +
166/// one-byte read, no rewrite).
167fn truncate_torn_tail(events_path: &Path) -> Result<()> {
168 let mut opts = std::fs::OpenOptions::new();
169 opts.read(true).write(true);
170 // `O_NOFOLLOW`: refuse to rewrite the tail through a symlinked event log.
171 crate::paths::nofollow(&mut opts);
172 let mut f = match opts.open(events_path) {
173 Ok(f) => f,
174 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
175 Err(e) => return Err(Error::io(events_path, e)),
176 };
177 let len = f.metadata().map_err(|e| Error::io(events_path, e))?.len();
178 if len == 0 {
179 return Ok(());
180 }
181 let mut tail = [0u8; 1];
182 f.seek(SeekFrom::End(-1))
183 .map_err(|e| Error::io(events_path, e))?;
184 f.read_exact(&mut tail)
185 .map_err(|e| Error::io(events_path, e))?;
186 if tail[0] == b'\n' {
187 return Ok(());
188 }
189 // Torn final line: cut back to just past the last complete record's
190 // trailing newline, or to empty when no complete record exists.
191 let keep = match find_prev_newline(&mut f, len, events_path)? {
192 Some(nl) => nl + 1,
193 None => 0,
194 };
195 f.set_len(keep).map_err(|e| Error::io(events_path, e))?;
196 f.sync_all().map_err(|e| Error::io(events_path, e))?;
197 // Surface the recovery so an operator inspecting the run knows a
198 // crash-torn tail was discarded (and how many bytes), rather than the
199 // truncation happening invisibly under the lock.
200 tracing::warn!(
201 target: "octl_core::events",
202 path = %events_path.display(),
203 discarded_bytes = len - keep,
204 kept_bytes = keep,
205 "truncated crash-torn final line off events.jsonl before append"
206 );
207 Ok(())
208}
209
210/// Append one event with a caller-supplied `seq`. The `_witness: &LockedRun`
211/// is compile-time proof the caller holds the run's exclusive [`RunLock`] for
212/// the duration of this call; the caller is still responsible for ensuring
213/// `seq` is monotonic. Misuse can corrupt the event log.
214///
215/// Test-only (`#[cfg(test)]`): a raw, no-reducer, caller-managed-`seq`
216/// primitive used by the crate's fixtures and the flock stress test to craft
217/// event logs with explicit seqs. Production mutation goes through
218/// [`append_and_apply_event`]; projection rebuild (future) replays via
219/// [`crate::reducer`], so neither needs this.
220#[cfg(test)]
221pub(crate) fn append_event_with_seq(
222 _witness: &LockedRun<'_>,
223 paths: &RunPaths,
224 seq: u64,
225 kind: &str,
226 node_id: Option<&NodeId>,
227 idempotency_key: Option<&str>,
228 data: Value,
229) -> Result<()> {
230 write_event_line(paths, seq, kind, node_id, idempotency_key, data)
231}
232
233#[cfg(test)]
234fn write_event_line(
235 paths: &RunPaths,
236 seq: u64,
237 kind: &str,
238 node_id: Option<&NodeId>,
239 idempotency_key: Option<&str>,
240 data: Value,
241) -> Result<()> {
242 let ev = Event {
243 ts: Utc::now(),
244 seq,
245 kind: kind.to_string(),
246 run_id: paths.run_id.clone(),
247 node_id: node_id.cloned(),
248 idempotency_key: idempotency_key.map(str::to_string),
249 data,
250 };
251 let events_path = paths.events();
252 let mut line = serde_json::to_vec(&ev).map_err(|e| Error::json(events_path.clone(), e))?;
253 line.push(b'\n');
254 let mut f = open_events_append(&events_path)?;
255 f.write_all(&line)
256 .map_err(|e| Error::io(events_path.clone(), e))?;
257 f.sync_all().map_err(|e| Error::io(events_path, e))?;
258 Ok(())
259}
260
261/// Outcome of an [`append_and_apply_event`] call.
262///
263/// `seq` is the value a caller surfaces to a user: the freshly appended
264/// event's `seq`, or — on an idempotent replay — the `seq` of the
265/// pre-existing matching event. A reducer no-op (e.g. an event dropped by
266/// the terminal-state guard) is still a success at this layer: `seq` names
267/// the appended event regardless of whether the reducer changed anything.
268///
269/// There is intentionally no `derived_event_ids` field. This API mutates
270/// exactly one event; the supervisor's report consumption, which emits a
271/// *batch* of derived discussion/spinoff events under one held lock, uses
272/// [`append_and_apply_unlocked`] instead (the sanctioned lock-held
273/// composition path) and tracks its own emitted ids.
274#[derive(Debug, Serialize)]
275pub struct AppendResult {
276 /// `seq` of the appended event, or of the prior event on an idempotent
277 /// replay.
278 pub seq: u64,
279 /// True when `idempotency_key` matched a prior event so nothing new was
280 /// appended or applied; `seq`/`prior` then describe that prior event.
281 pub idempotent_replay: bool,
282 /// True when the reducer produced at least one projection write for THIS
283 /// append — i.e. the event actually changed state, rather than folding to a
284 /// no-op (an unknown/audit kind, or an event dropped by a `*.created` /
285 /// terminal-state guard). Lets a caller distinguish "the reducer applied my
286 /// event" from "it was a dead event" WITHOUT re-reading the projection and
287 /// pattern-matching a field (issue `reducer-adopt-explicit-merge`).
288 ///
289 /// This is a report of what the reducer did on THIS call, NOT a durable
290 /// "is teardown pending?" signal: it is `false` both on an idempotent replay
291 /// AND on a fresh append the reducer no-op'd (e.g. re-submitting the exact
292 /// report already adopted). Callers making a DURABLE decision (does the run
293 /// still need a teardown actor?) must read projection state, not this flag —
294 /// see `run merge`'s `ensure_report_consumer`, which deliberately does NOT gate
295 /// its reattach on `applied` (that was a crash-retry leak caught in review).
296 pub applied: bool,
297 /// On an idempotent replay, the prior event's recorded `node_id` and
298 /// `data`, so a caller can reject a key reused with a conflicting
299 /// request (Stripe-style). `None` on a fresh append.
300 #[serde(skip_serializing_if = "Option::is_none")]
301 pub prior: Option<PriorEvent>,
302}
303
304/// The one canonical mutation entry point: append a single event to
305/// `events.jsonl` *and* fold it into the projection files via the reducer,
306/// all under the run's `flock`, with idempotency-key dedup.
307///
308/// On success, every `events.jsonl` line is folded into `manifest.json` /
309/// `nodes/*.json` / `discussions/*.json` / `spinoffs/*.json` before the lock
310/// is released, so a read CLI run a millisecond later never sees a stale
311/// projection. This is *not* a crash-atomic transaction: the event is fsynced
312/// before the reducer runs, so a crash (or an I/O error from `apply_event`)
313/// after the append but before the projection write leaves the log ahead of
314/// the projections — recoverable only by a future `rebuild_projections`. The
315/// log is the source of truth; projections are a derived cache.
316///
317/// The append is transactional against reducer *validation*: the event is
318/// first reduced through [`reduce_event_to_ops`](crate::reducer) under the
319/// lock — the single plan-then-commit path that both validates and computes
320/// the projection writes — and only a validating event is appended (and
321/// fsynced) and then committed by the reducer. A reducer-rejected event (a
322/// `CorruptEventLog` for a malformed payload) errors *before* any bytes are
323/// written, so the log never gains a poison line that a future replay /
324/// `rebuild_projections` would choke on.
325/// (A pre-existing torn tail may still be truncated before validation runs —
326/// those bytes are uncommitted by definition; see [`recover_last_seq`].)
327///
328/// When `idempotency_key` is `Some` and a prior event with the same `kind` +
329/// key already exists ([`find_prior_with_key`](crate::events)), nothing is appended or
330/// applied: the result carries the prior event's `seq`, `idempotent_replay:
331/// true`, and `prior: Some(..)` so the caller can detect a key reused with a
332/// conflicting payload. With `idempotency_key: None` no scan runs.
333///
334/// Callers that must compose several writes — or a read-modify-write
335/// transaction (read a projection, decide, then append) — under one lock
336/// window hold the lock themselves and use [`append_and_apply_unlocked`],
337/// the sanctioned lock-held composition path. Re-entering this function
338/// while already holding the lock would deadlock: `flock` blocks when a
339/// second open of the lock file from the same process tries `LOCK_EX`.
340pub fn append_and_apply_event(
341 paths: &RunPaths,
342 kind: &str,
343 node_id: Option<&NodeId>,
344 idempotency_key: Option<&str>,
345 data: Value,
346) -> Result<AppendResult> {
347 RunLock::with_lock(paths, |lock| {
348 // Catch the projections up to the event log before either the
349 // idempotency lookup or a fresh append. This is the recovery half of
350 // append+apply atomicity: any unapplied tail left by a prior crash is
351 // folded here, under the same lock, so an idempotent replay returns
352 // only once the prior event's projection is durably committed
353 // (`applied_seq >= prior.seq`) — never a stale "found, but not applied"
354 // result. A clean run with no tail makes this a cheap no-op.
355 let events_path = paths.checked_events()?;
356 truncate_torn_tail(&events_path)?;
357 replay_unapplied(paths, &events_path)?;
358 // Idempotency lookup + append share this one lock window so a
359 // concurrent retry can't see "no prior event" and double-append.
360 if let Some(key) = idempotency_key {
361 if let Some(prior) = find_prior_with_key(lock, paths, kind, key)? {
362 return Ok(AppendResult {
363 seq: prior.seq,
364 idempotent_replay: true,
365 // Nothing was applied by THIS call — the prior event (already
366 // folded) carried any state change.
367 applied: false,
368 prior: Some(prior),
369 });
370 }
371 }
372 let (seq, applied) =
373 append_and_apply_reporting(lock, paths, kind, node_id, idempotency_key, data)?;
374 Ok(AppendResult {
375 seq,
376 idempotent_replay: false,
377 applied,
378 prior: None,
379 })
380 })
381}
382
383/// Append one event and fold it into projections. The `_witness: &LockedRun`
384/// is compile-time proof the caller already holds the run's exclusive
385/// [`RunLock`] — obtained from [`RunLock::with_lock`] or [`RunLock::witness`],
386/// so this entry point cannot be reached without the lock. The **sanctioned
387/// lock-held composition path**: use it to fold extra logic (an idempotency-key
388/// lookup, a status precondition) or several writes (the supervisor's
389/// derived discussion/spinoff batch) into one locked critical section.
390/// Calling [`append_and_apply_event`] from within a held lock would
391/// deadlock because `flock` blocks when a second open of the lock file from
392/// the same process tries to acquire `LOCK_EX`.
393///
394/// # The witness is mandatory
395///
396/// Without a `&LockedRun` proof the lock is held, this does not compile — there
397/// is no way to skip the parameter, and [`LockedRun`] cannot be constructed
398/// outside this crate (its field is private), so the only source is a held
399/// [`RunLock`]:
400///
401/// ```compile_fail
402/// use octl_core::{append_and_apply_unlocked, RunPaths};
403/// # fn demo(paths: &RunPaths) {
404/// // No witness passed — the first argument must be a `&LockedRun`, which a
405/// // caller can only obtain by actually holding the run's exclusive lock.
406/// let _ = append_and_apply_unlocked(paths, "run.status", None, None, serde_json::json!({}));
407/// # }
408/// ```
409pub fn append_and_apply_unlocked(
410 witness: &LockedRun<'_>,
411 paths: &RunPaths,
412 kind: &str,
413 node_id: Option<&NodeId>,
414 idempotency_key: Option<&str>,
415 data: Value,
416) -> Result<u64> {
417 append_and_apply_reporting(witness, paths, kind, node_id, idempotency_key, data)
418 .map(|(seq, _)| seq)
419}
420
421/// As [`append_and_apply_unlocked`], but also reports whether the reducer APPLIED
422/// (produced ≥1 projection op) vs folded to a no-op — the `bool` feeding
423/// [`AppendResult::applied`]. Kept private so the public composition primitive
424/// stays `-> u64` for its 15+ callers (none of which need the applied bit); only
425/// [`append_and_apply_event`] threads it out. See [`AppendResult::applied`] for
426/// why callers want it (issue `reducer-adopt-explicit-merge`).
427fn append_and_apply_reporting(
428 _witness: &LockedRun<'_>,
429 paths: &RunPaths,
430 kind: &str,
431 node_id: Option<&NodeId>,
432 idempotency_key: Option<&str>,
433 data: Value,
434) -> Result<(u64, bool)> {
435 // Symlink containment runs once here, before truncate/recover/open all
436 // reuse this path — guarding the run root and the event log itself so a
437 // swapped `events.jsonl` can't redirect the run's source-of-truth write
438 // outside the run tree.
439 let events_path = paths.checked_events()?;
440 // Remove any crash-torn final line BEFORE recovering the seq or
441 // appending, so the new record is never concatenated onto a partial one
442 // and `seq` is recovered from a clean, `\n`-terminated file.
443 truncate_torn_tail(&events_path)?;
444 // Replay any unapplied tail (`seq > applied_seq`) before appending, so this
445 // append never stacks onto a projection that is behind the log. When called
446 // from `append_and_apply_event` the tail was already drained a moment ago,
447 // so this is a no-op; direct lock-held callers (supervisor batch, cancel,
448 // discussion/spinoff resolution) get the same recovery for free.
449 replay_unapplied(paths, &events_path)?;
450 let last = recover_last_seq(&events_path)?;
451 let seq = last + 1;
452 let ev = Event {
453 ts: Utc::now(),
454 seq,
455 kind: kind.to_string(),
456 run_id: paths.run_id.clone(),
457 node_id: node_id.cloned(),
458 idempotency_key: idempotency_key.map(str::to_string),
459 data,
460 };
461 // Transactional gate, plan-then-commit: reduce the event against current
462 // projection state BEFORE the durable append. `reduce_event_to_ops` both
463 // validates and computes the exact projection writes to make; a reducer-
464 // rejected event errors here and is never written, so a later replay /
465 // rebuild can't trip on a poison line. The planned ops are then committed
466 // *after* the fsynced append — nothing mutates the projections between the
467 // plan and the commit (the append only touches `events.jsonl`), so the
468 // planned writes are still valid. One reduce pass serves both the gate and
469 // the apply, so there is no validate/apply branch pair to drift apart.
470 let ops = reduce_event_to_ops(paths, &ev)?;
471 // Whether the reducer changed state for this event — reported to the caller
472 // via `AppendResult::applied`. Captured before `commit_ops` consumes `ops`.
473 let applied = !ops.is_empty();
474 let mut line = serde_json::to_vec(&ev).map_err(|e| Error::json(events_path.clone(), e))?;
475 line.push(b'\n');
476 let mut f = open_events_append(&events_path)?;
477 f.write_all(&line)
478 .map_err(|e| Error::io(events_path.clone(), e))?;
479 f.sync_all().map_err(|e| Error::io(events_path, e))?;
480 commit_ops(paths, ops)?;
481 // Advance the watermark only after every projection this event touched is
482 // durably committed. A crash before this point leaves `applied_seq < seq`,
483 // and the next lock acquisition replays the event (idempotently — the
484 // reducer's existence/terminal guards make a re-fold a no-op) before
485 // advancing. So the watermark can only ever lag the projections, never lead
486 // them — the projection a reader sees is always at least as new as
487 // `applied_seq` claims.
488 advance_applied_seq(paths, seq)?;
489 Ok((seq, applied))
490}
491
492/// The three observable outcomes of an [`append_and_apply_idempotent`] call —
493/// the shared `--idempotency-key` contract that `event create`, `discussion
494/// resolve`, and future keyed verbs (`spinoff approve|reject`, `run create`,
495/// `node report`) all answer to, lifted out of each CLI's private log scan.
496///
497/// The discriminator is whether a prior event with the same `kind` + key
498/// already exists, and — if so — whether the call's `(node_id, data)` identity
499/// matches that prior event:
500///
501/// - [`AppendOutcome::Appended`] — no prior event carried this key: a fresh
502/// event was appended and folded into the projections. `seq` is its sequence.
503/// - [`AppendOutcome::IdempotentReplay`] — a prior event carried this key **and**
504/// the same `node_id` + `data`: a true retry. Nothing was appended; the
505/// `prior` event (its `seq` / `node_id` / `data`) is returned so the caller
506/// can surface the original sequence.
507/// - [`AppendOutcome::Conflict`] — a prior event carried this key but with a
508/// **different** `node_id` or `data`: the key was reused for a different
509/// request (a client bug, Stripe-style). Nothing was appended; `prior` is
510/// returned so the caller can build a precise conflict error (e.g. diff the
511/// payload vs. the node id).
512#[derive(Debug)]
513pub enum AppendOutcome {
514 /// A fresh event was appended and applied; `seq` is its sequence number.
515 Appended {
516 /// The appended event's `seq`.
517 seq: u64,
518 },
519 /// The key matched a prior event with identical `node_id` + `data`. No new
520 /// event was written; `prior.seq` is the original sequence to surface.
521 IdempotentReplay {
522 /// The pre-existing matching event (its `seq`, `node_id`, and `data`).
523 prior: PriorEvent,
524 },
525 /// The key matched a prior event whose `node_id` or `data` differs from this
526 /// request. No new event was written; the caller should reject the reuse.
527 Conflict {
528 /// The pre-existing event recorded under the same key, for the caller's
529 /// conflict diagnostics (`prior.seq` is the original sequence).
530 prior: PriorEvent,
531 },
532}
533
534/// Append one keyed event idempotently: scan for a prior event with the same
535/// `kind` + `key`, and either replay it, reject a conflicting reuse, or append
536/// fresh — the centralized `--idempotency-key` primitive (issue
537/// `core-idempotency-api`).
538///
539/// This is the **sanctioned lock-held composition path** for keyed appends: the
540/// `_witness: &LockedRun` proves the caller already holds the run's exclusive
541/// [`RunLock`] (from [`RunLock::with_lock`] or [`RunLock::witness`]), so the
542/// scan and the append share one lock window and a concurrent retry can never
543/// see "no prior event" and double-append. Calling it composes with the
544/// applied-seq watermark and the path-traversal defense exactly as
545/// [`append_and_apply_unlocked`] does — it catches the projections up to the log
546/// (`truncate_torn_tail` + `replay_unapplied`) before scanning, guards the run
547/// root + event log via `RunPaths::checked_events`, and routes the fresh
548/// append through `append_and_apply_unlocked`.
549///
550/// `build` lazily produces the event's `data` payload given the sequence the
551/// fresh event *would* receive. It is a **pure** constructor: it is invoked once
552/// to materialize the candidate payload (to compare against a prior event, or to
553/// write a fresh one) and must not encode caller-side domain preconditions — a
554/// verb whose append is gated on projection state (e.g. `discussion resolve`'s
555/// already-resolved / no-op decision) keeps that logic in its own locked body
556/// and uses [`find_prior_with_key`] directly. The `u64` lets a payload embed its
557/// own `seq`; a payload that does so is not replay-stable and should not be used
558/// with idempotency.
559///
560/// The key must be non-empty: an empty key is rejected with
561/// [`Error::EmptyIdempotencyKey`] before any scan, since `""` would collapse
562/// every keyless append into one dedup slot.
563///
564/// # Examples
565///
566/// ```no_run
567/// use octl_core::{append_and_apply_idempotent, AppendOutcome, RunLock, RunPaths};
568/// use serde_json::json;
569///
570/// # fn demo(paths: &RunPaths) -> octl_core::Result<()> {
571/// let outcome = RunLock::with_lock(paths, |lock| {
572/// append_and_apply_idempotent(
573/// paths,
574/// lock,
575/// "node.status",
576/// None, // no target node
577/// "retry-key-42", // the caller's idempotency key (non-empty)
578/// |_seq| Ok(json!({ "status": "running" })),
579/// )
580/// })?;
581/// match outcome {
582/// AppendOutcome::Appended { seq } => println!("appended at seq {seq}"),
583/// AppendOutcome::IdempotentReplay { prior } => println!("replayed seq {}", prior.seq),
584/// AppendOutcome::Conflict { prior } => println!("key reused; prior seq {}", prior.seq),
585/// }
586/// # Ok(())
587/// # }
588/// ```
589pub fn append_and_apply_idempotent<F>(
590 paths: &RunPaths,
591 witness: &LockedRun<'_>,
592 kind: &str,
593 node_id: Option<&NodeId>,
594 key: &str,
595 build: F,
596) -> Result<AppendOutcome>
597where
598 F: FnOnce(u64) -> Result<Value>,
599{
600 if key.is_empty() {
601 return Err(Error::EmptyIdempotencyKey);
602 }
603 // Catch the projections up to the log before scanning, mirroring
604 // `append_and_apply_unlocked`'s recovery half: an idempotent replay must
605 // only report once the prior event's projection is durably committed, never
606 // a stale "found, but not applied" result. A clean run makes this a no-op.
607 let events_path = paths.checked_events()?;
608 truncate_torn_tail(&events_path)?;
609 replay_unapplied(paths, &events_path)?;
610
611 // The sequence a fresh append *would* take. Computed once, after catch-up,
612 // so `build`'s payload sees the same seq `append_and_apply_unlocked` will
613 // assign under this still-held lock.
614 let next_seq = recover_last_seq(&events_path)? + 1;
615 let data = build(next_seq)?;
616
617 if let Some(prior) = find_prior_with_key(witness, paths, kind, key)? {
618 // A prior event carries this key. It is a true replay only when the
619 // full request identity — the envelope `node_id` *and* the `data`
620 // payload — matches; any divergence is a key reused for a different
621 // request and must surface as a conflict, never a silent no-op.
622 let same_node = prior.node_id.as_deref() == node_id.map(NodeId::as_str);
623 if same_node && prior.data == data {
624 return Ok(AppendOutcome::IdempotentReplay { prior });
625 }
626 return Ok(AppendOutcome::Conflict { prior });
627 }
628
629 let seq = append_and_apply_unlocked(witness, paths, kind, node_id, Some(key), data)?;
630 Ok(AppendOutcome::Appended { seq })
631}
632
633/// Replay every unapplied tail event — those with `seq > manifest.applied_seq`
634/// — into the projections, advancing the watermark after each, so the
635/// projection cache is caught up to `events.jsonl` before any new append.
636///
637/// This is the recovery half of the append+apply atomicity guarantee. A writer
638/// that crashed after fsyncing an event row but before fsyncing its projection
639/// (or before advancing `applied_seq`) leaves `applied_seq < last_seq`; the
640/// next lock acquisition heals it here. The reducer is idempotent — every
641/// `*.created` reducer short-circuits when its projection already exists, and
642/// every status/report reducer is a no-op once the target is terminal — so
643/// re-folding an event whose projection *did* land changes nothing. The
644/// manifest's denormalized counters can't desync across this replay either:
645/// they are not folded incrementally but re-derived from projection state by
646/// [`advance_applied_seq`] after each event, so a re-fold simply recomputes the
647/// same totals.
648///
649/// No manifest yet (pre-`run.created`) means there is no watermark to anchor
650/// and nothing durable to catch up, so this returns immediately until the
651/// manifest exists. A legacy manifest reads as `applied_seq = 0` (serde
652/// default), so the first call re-folds the entire log; that is intentional
653/// and safe — see [`crate::schema::Manifest::applied_seq`].
654///
655/// # Corrupt-line tolerance
656///
657/// A line that does not parse as an [`Event`] is skipped, not hard-errored —
658/// the same definition of "corrupt" the quarantine path uses, and the same
659/// tolerance the pre-watermark append path had (it only ever parsed the *last*
660/// line via [`recover_last_seq`]). Bricking every append on an interior poison
661/// line would, among other things, make it impossible to even *record* the
662/// supervisor's `event_log_skipped_line` diagnostic about that very line.
663/// Healing such a line is the supervisor's quarantine job, not the writer's.
664///
665/// A *parse-valid* event whose payload is semantically corrupt is skipped the
666/// same way (with a `warn`), rather than hard-erroring. The dangerous subclass
667/// is an event carrying an embedded id (`child_run_id`, `child_node_id`) that
668/// fails its strict `parse_str` and would
669/// otherwise be joined onto a path — the reducer's independent second line of
670/// defense against a corrupt log, a restored backup, or a future writer that
671/// bypasses the CLI validators (issue `reducer-path-traversal-defense`). Such
672/// an event is a *valid `Event` envelope* (only its `data` is bad), so the
673/// supervisor's [`quarantine_corrupt_lines`] — which only excises lines that
674/// fail the strict envelope parse — can never heal it; hard-erroring here would
675/// brick every future append on that line with no automated recovery path.
676/// Skipping it converges the projection to the largest safe subset and never
677/// joins a tainted id onto a path (the typed-id constructors already make
678/// traversal structurally impossible — a `"../escape"` id never parses into a
679/// [`RunId`](crate::RunId) / [`NodeId`](crate::NodeId), so it can never reach
680/// `nodes/<id>.json`). The append
681/// *gate* stays fail-closed: [`reduce_event_to_ops`] rejects such an event
682/// before it is ever written, so a sanctioned log never reaches this branch and
683/// re-reducing real events on replay is a clean idempotent no-op. A genuine I/O
684/// fault (from the commit or watermark write) still propagates.
685///
686/// Because a sanctioned log is appended in `seq` order under the lock, file
687/// order equals `seq` order for real events; the only out-of-order bytes are
688/// skipped junk, so advancing the watermark to each applied event's `seq` never
689/// jumps over an unfolded real event.
690///
691/// Caller must hold the run's [`RunLock`] and must have already truncated any
692/// torn tail, so the final line is either complete or absent.
693fn replay_unapplied(paths: &RunPaths, events_path: &Path) -> Result<()> {
694 let applied = match read_manifest_opt(paths)? {
695 Some(m) => m.applied_seq,
696 None => return Ok(()),
697 };
698 // Cheap fast path for the overwhelmingly common clean case: the watermark
699 // already covers the log, so there is nothing to replay and no full scan.
700 if applied >= recover_last_seq(events_path)? {
701 return Ok(());
702 }
703 let f = match std::fs::File::open(events_path) {
704 Ok(f) => f,
705 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
706 Err(e) => return Err(Error::io(events_path, e)),
707 };
708 let mut reader = PhysicalLineReader::new(BufReader::new(f));
709 while let Some(line) = reader.next_line().map_err(|e| Error::io(events_path, e))? {
710 // A torn final line is an uncommitted partial write — stop, exactly as
711 // every other reader does.
712 if !line.complete {
713 break;
714 }
715 if line.content.is_empty() {
716 continue;
717 }
718 // Skip a parse-failing line (external junk by the quarantine
719 // definition); apply every event past the watermark in order.
720 let ev: Event = match serde_json::from_slice(line.content) {
721 Ok(ev) => ev,
722 Err(_) => continue,
723 };
724 if ev.seq <= applied {
725 continue;
726 }
727 // Plan the projection writes. A parse-valid but domain-corrupt event —
728 // most dangerously one whose embedded id fails its strict `parse_str`
729 // and would otherwise be joined onto a path — surfaces here as
730 // `CorruptEventLog`. Quarantine cannot excise it (it is a valid
731 // envelope), so we skip it with a warn rather than aborting the whole
732 // catch-up replay; the watermark is not advanced for a skipped event.
733 // See this function's "Corrupt-line tolerance" doc. I/O faults from the
734 // commit/watermark write below still propagate.
735 let ops = match reduce_event_to_ops(paths, &ev) {
736 Ok(ops) => ops,
737 Err(Error::CorruptEventLog { reason, .. }) => {
738 tracing::warn!(
739 target: "octl_core::events",
740 path = %events_path.display(),
741 seq = ev.seq,
742 kind = %ev.kind,
743 reason = %reason,
744 "skipping corrupt event during replay (unsafe id or malformed payload); projection not advanced for it"
745 );
746 continue;
747 }
748 Err(e) => return Err(e),
749 };
750 commit_ops(paths, ops)?;
751 advance_applied_seq(paths, ev.seq)?;
752 }
753 Ok(())
754}
755
756/// Advance `manifest.applied_seq` to `seq` and fsync the manifest (atomic
757/// temp-file + rename), recording that every projection touched by event `seq`
758/// is durably committed.
759///
760/// A no-op when no manifest exists yet, or when the watermark already covers
761/// `seq` — so re-folding an already-applied event (during replay) doesn't churn
762/// the manifest. The reducer for the event may itself have just rewritten the
763/// manifest (e.g. a status transition); reading it back here preserves those
764/// fields while moving only the watermark forward. Caller holds the [`RunLock`].
765///
766/// This is also the single point that persists the manifest's denormalized
767/// `node_count` counter. It is **derived**, not incremented: [`derive_counters`]
768/// recomputes it from the
769/// projection directories — which, because the caller commits an event's
770/// projection ops *before* calling this, already reflect event `seq`. Pinning
771/// the counters to the watermark advance is what makes them undriftable: even
772/// when a crash-replay re-folds an event whose reducer short-circuits to zero
773/// ops (its projection already landed before the crash), this still runs and
774/// re-derives the true counts, healing any counter the old incremental path
775/// would have stranded. See [`derive_counters`] and issue
776/// `manifest-counter-desync`.
777fn advance_applied_seq(paths: &RunPaths, seq: u64) -> Result<()> {
778 if let Some(mut m) = read_manifest_opt(paths)? {
779 if m.applied_seq < seq {
780 let counters = derive_counters(paths)?;
781 m.node_count = counters.node_count;
782 m.applied_seq = seq;
783 write_manifest(paths, &m)?;
784 }
785 }
786 Ok(())
787}
788
789/// One physical line surfaced by [`PhysicalLineReader`]: its content with
790/// any trailing terminator stripped, plus enough framing for the torn-tail
791/// policy (whether it was newline-terminated) and for error context (byte
792/// offset + 1-based line number).
793struct PhysicalLine<'a> {
794 /// Line content with a single trailing terminator (`\n`, optionally
795 /// preceded by `\r`) removed. Interior/leading bytes are untouched.
796 content: &'a [u8],
797 /// `false` only for a final line lacking a trailing `\n` — a torn,
798 /// in-flight append. `true` for every newline-terminated line. Because a
799 /// non-terminated line can only be the last bytes in the file, this is
800 /// `false` for at most one line, and only ever the last one.
801 complete: bool,
802 /// 1-based line number, for `CorruptEventLog` context.
803 lineno: u64,
804}
805
806/// The single physical-line reader behind both [`read_all_events`] and
807/// [`find_prior_with_key`], so the read paths can never disagree about the
808/// torn-tail policy (design.md §1.4; torn-line-policy-consistency).
809///
810/// Bytes are read with [`BufRead::read_until`] (not `read_line`/`lines()`)
811/// for two reasons: it keeps the trailing `\n` so a torn final line is
812/// distinguishable from a newline-terminated interior one, and it reads raw
813/// bytes so a torn tail that cuts a multi-byte UTF-8 sequence is tolerated as
814/// a partial write rather than surfacing as an I/O error. A *newline-
815/// terminated* line with invalid UTF-8 still reaches the caller's parse,
816/// which classifies it as `CorruptEventLog`.
817///
818/// `next_line` lends a slice into an internal buffer, so a caller holds at
819/// most one line at a time — the streaming (lending-iterator) pattern, which
820/// keeps the per-line allocation cost to a single reused buffer.
821struct PhysicalLineReader<R: BufRead> {
822 reader: R,
823 buf: Vec<u8>,
824 lineno: u64,
825 done: bool,
826}
827
828impl<R: BufRead> PhysicalLineReader<R> {
829 fn new(reader: R) -> Self {
830 Self {
831 reader,
832 buf: Vec::new(),
833 lineno: 0,
834 done: false,
835 }
836 }
837
838 /// Yield the next physical line, or `None` at end of file. I/O errors are
839 /// surfaced raw so the caller can attach the log path.
840 fn next_line(&mut self) -> std::io::Result<Option<PhysicalLine<'_>>> {
841 if self.done {
842 return Ok(None);
843 }
844 self.buf.clear();
845 let n = self.reader.read_until(b'\n', &mut self.buf)?;
846 if n == 0 {
847 self.done = true;
848 return Ok(None);
849 }
850 self.lineno += 1;
851 let complete = self.buf.last() == Some(&b'\n');
852 // A non-terminated line is necessarily the final bytes of the file;
853 // stop after handing it back so the torn-tail policy only ever sees
854 // it last.
855 if !complete {
856 self.done = true;
857 }
858 let len = trim_line_end(&self.buf).len();
859 Ok(Some(PhysicalLine {
860 content: &self.buf[..len],
861 complete,
862 lineno: self.lineno,
863 }))
864 }
865}
866
867/// Stream `events.jsonl` line by line, deserializing each complete line into a
868/// caller-chosen envelope probe `T` and invoking `visit(probe, raw_line)`.
869///
870/// This is the streaming counterpart to [`read_all_events`]: it shares the exact
871/// [`PhysicalLineReader`] torn-tail / [`Error::CorruptEventLog`] policy (a torn
872/// final line lacking a trailing `\n` is dropped *without* parsing even if its
873/// bytes are valid JSON; any newline-terminated unparseable line is interior
874/// corruption surfaced as [`Error::CorruptEventLog`]) but never materializes the
875/// whole log — the caller accumulates only what it needs into its own state.
876///
877/// `T` deserializes only the envelope fields it declares; serde ignores the
878/// rest, so a multi-KB `node.report` `data` payload is scanned but never
879/// allocated. The raw line bytes are *lent* to `visit` (a streaming
880/// lending-iterator borrow into the reader's reused buffer), so the closure can
881/// re-parse the full payload for the rare line it must materialize without the
882/// reader holding more than one line at a time.
883///
884/// A missing log is an empty stream (`Ok(())` with no calls). Caller must hold
885/// the run's [`RunLock`]; the scan is read-only over an append-only file.
886pub(crate) fn for_each_event_probe<T, F>(events_path: &Path, mut visit: F) -> Result<()>
887where
888 T: serde::de::DeserializeOwned,
889 F: FnMut(T, &[u8]) -> Result<()>,
890{
891 let f = match std::fs::File::open(events_path) {
892 Ok(f) => f,
893 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
894 Err(e) => return Err(Error::io(events_path, e)),
895 };
896 let mut reader = PhysicalLineReader::new(BufReader::new(f));
897 while let Some(line) = reader.next_line().map_err(|e| Error::io(events_path, e))? {
898 // Torn final line (no trailing newline): uncommitted partial write,
899 // discarded without parsing — mirrors `recover_last_seq`.
900 if !line.complete {
901 break;
902 }
903 if line.content.is_empty() {
904 continue;
905 }
906 let probe: T =
907 serde_json::from_slice(line.content).map_err(|e| Error::CorruptEventLog {
908 path: events_path.to_path_buf(),
909 reason: format!(
910 "line {} is not a valid event: {} [{e}]",
911 line.lineno,
912 excerpt(line.content)
913 ),
914 })?;
915 visit(probe, line.content)?;
916 }
917 Ok(())
918}
919
920/// Read every event from `events.jsonl`. Used by tests and reducer replays.
921///
922/// # Torn-line policy
923///
924/// Built on the shared [`for_each_event_probe`](crate::events) (hence
925/// [`PhysicalLineReader`](crate::events)), so it matches
926/// [`find_prior_with_key`](crate::events) and [`recover_last_seq`] exactly: a
927/// torn final line lacking a trailing `\n` is an in-flight partial write,
928/// dropped *without* parsing even if its bytes happen to be valid JSON. Any
929/// newline-terminated line that fails to parse is interior corruption and
930/// surfaces as [`Error::CorruptEventLog`] — not a transient JSON fault — so a
931/// replay rejects a poisoned log loudly instead of silently dropping a line.
932pub fn read_all_events(events_path: &Path) -> Result<Vec<Event>> {
933 let mut out = Vec::new();
934 for_each_event_probe::<Event, _>(events_path, |ev, _raw| {
935 out.push(ev);
936 Ok(())
937 })?;
938 Ok(out)
939}
940
941/// Outcome of a [`quarantine_corrupt_lines`] call that removed at least one
942/// poison line. `backup_path` is the renamed copy of the original log (kept
943/// verbatim for operator forensics / hand-repair); `removed_byte_offsets`
944/// are the start offsets, in that original, of every newline-terminated line
945/// that failed to parse as an [`Event`] and was excised from the recovered
946/// `events.jsonl`.
947#[derive(Debug, Clone, Serialize)]
948pub struct Quarantine {
949 /// Path to the timestamped `.bak` holding the original poisoned log.
950 pub backup_path: PathBuf,
951 /// Byte offsets (in the original log) of every excised corrupt line.
952 pub removed_byte_offsets: Vec<u64>,
953}
954
955/// Heal a poisoned `events.jsonl` by excising its corrupt physical lines.
956///
957/// P2 made the supervisor *skip* a corrupt JSONL line in memory and keep
958/// tailing, but the bytes stayed on disk forever — so every fresh strict
959/// reader ([`read_all_events`] / a future `rebuild_projections`) still
960/// hard-errors on them, and the skip diagnostic is unreachable to a strict
961/// replay (the corrupt line aborts the read before it). This is the durable
962/// repair: under the run's [`RunLock`], the original log is renamed to
963/// `events.jsonl.corrupt-<ts>.bak` and a recovered `events.jsonl` is written
964/// in its place containing every line *except* the corrupt ones.
965///
966/// "Corrupt" means exactly what the strict readers reject: a
967/// newline-terminated, non-empty line that does not parse as a full [`Event`]
968/// envelope. Empty lines and a torn (newline-less) final line are retained
969/// verbatim — the readers already tolerate both, so excising them would be a
970/// behavior change, not a repair.
971///
972/// Returns `Ok(None)` when the log is missing or already clean (no rename, no
973/// rewrite — the common case is cheap: one read, no corrupt line found).
974/// Returns `Ok(Some(_))` with the backup path and removed offsets when at
975/// least one line was excised. Caller is expected to surface the outcome
976/// (e.g. a `supervisor.event_log_quarantined` diagnostic) and, for a live
977/// tail, restart its read cursor at offset 0 since every byte offset shifts.
978///
979/// `backup_ts` is supplied by the caller (kept out of core so the rename is
980/// deterministic in tests); a filename-safe basic-ISO stamp like
981/// `20260628T120000Z` is the intended form.
982///
983/// # Operator recovery
984///
985/// The excised bytes are never destroyed — they survive verbatim in the
986/// `events.jsonl.corrupt-<ts>.bak` sibling (named by the emitted
987/// `supervisor.event_log_quarantined { backup_path }` diagnostic). To recover
988/// a line the automated repair dropped: open the `.bak`, inspect the line(s)
989/// at the reported `removed_byte_offsets`, hand-fix any salvageable JSON, and —
990/// if you want the record back — stop the run's supervisor, append the
991/// corrected line to the live `events.jsonl` (or replace the file wholesale
992/// from a fixed copy of the backup), then restart the supervisor. The healed
993/// log is the source of truth; projections rebuild from it.
994pub fn quarantine_corrupt_lines(paths: &RunPaths, backup_ts: &str) -> Result<Option<Quarantine>> {
995 RunLock::with_lock(paths, |lock| {
996 quarantine_corrupt_lines_unlocked(lock, paths, backup_ts)
997 })
998}
999
1000/// As [`quarantine_corrupt_lines`] but takes a `&LockedRun` witness proving the
1001/// caller already holds the run's exclusive [`RunLock`] — the sanctioned
1002/// lock-held composition path, mirroring [`append_and_apply_unlocked`].
1003/// Re-entering [`quarantine_corrupt_lines`] under a held lock would deadlock on
1004/// the second `flock` open.
1005pub fn quarantine_corrupt_lines_unlocked(
1006 _witness: &LockedRun<'_>,
1007 paths: &RunPaths,
1008 backup_ts: &str,
1009) -> Result<Option<Quarantine>> {
1010 // Guard the run root + event log against symlink redirection before the
1011 // rename/rewrite, exactly as the append path does.
1012 let events_path = paths.checked_events()?;
1013 let raw = match std::fs::read(&events_path) {
1014 Ok(b) => b,
1015 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1016 Err(e) => return Err(Error::io(&events_path, e)),
1017 };
1018
1019 // Walk physical lines, keeping the raw bytes (terminator included) of every
1020 // retained line so the recovered file is byte-identical save for the
1021 // excised corruption. A line is corrupt iff it is newline-terminated,
1022 // non-empty, and fails the same strict `Event` parse `read_all_events`
1023 // applies — so the recovered log is guaranteed to pass a strict replay.
1024 let mut recovered: Vec<u8> = Vec::with_capacity(raw.len());
1025 let mut removed_byte_offsets: Vec<u64> = Vec::new();
1026 let mut offset: u64 = 0;
1027 let mut i = 0usize;
1028 while i < raw.len() {
1029 let (line_end, complete) = match raw[i..].iter().position(|b| *b == b'\n') {
1030 Some(p) => (i + p + 1, true), // include the trailing '\n'
1031 None => (raw.len(), false), // torn final line, no '\n'
1032 };
1033 let raw_line = &raw[i..line_end];
1034 let content = trim_line_end(raw_line);
1035 let corrupt =
1036 complete && !content.is_empty() && serde_json::from_slice::<Event>(content).is_err();
1037 if corrupt {
1038 removed_byte_offsets.push(offset);
1039 } else {
1040 recovered.extend_from_slice(raw_line);
1041 }
1042 offset += raw_line.len() as u64;
1043 i = line_end;
1044 }
1045
1046 if removed_byte_offsets.is_empty() {
1047 return Ok(None);
1048 }
1049
1050 // Rename the poisoned log aside (forensics), then atomically drop the
1051 // recovered log in its place. Order matters: the rename frees the path for
1052 // `write_atomic`'s tempfile+rename and preserves the original even if the
1053 // rewrite then fails.
1054 let backup_path = backup_path_for(&events_path, backup_ts);
1055 std::fs::rename(&events_path, &backup_path).map_err(|e| Error::io(&backup_path, e))?;
1056 write_atomic(&events_path, &recovered)?;
1057 Ok(Some(Quarantine {
1058 backup_path,
1059 removed_byte_offsets,
1060 }))
1061}
1062
1063/// Build the `events.jsonl.corrupt-<ts>.bak` sibling path for a quarantine
1064/// backup, preserving the original file name as a prefix.
1065fn backup_path_for(events_path: &Path, ts: &str) -> PathBuf {
1066 let mut name = events_path
1067 .file_name()
1068 .map(std::ffi::OsStr::to_os_string)
1069 .unwrap_or_default();
1070 name.push(format!(".corrupt-{ts}.bak"));
1071 events_path.with_file_name(name)
1072}
1073
1074/// A prior event located by [`find_prior_with_key`](crate::events). Carries enough to let
1075/// an idempotent-retry caller both return the recorded `seq` and verify the
1076/// retry payload matches what was originally written.
1077#[derive(Debug, Clone, PartialEq, Serialize)]
1078pub struct PriorEvent {
1079 /// The recorded `seq` of the matching event.
1080 pub seq: u64,
1081 /// The event's top-level `node_id`, if any.
1082 pub node_id: Option<String>,
1083 /// The event's `data` payload.
1084 pub data: Value,
1085}
1086
1087/// Fields skimmed from every line to test for a match without ever
1088/// allocating the (potentially large) `data` payload. `seq` is optional and
1089/// used only for best-effort error context — it is never a match key, so a
1090/// line missing it must not change whether a `kind` + `idempotency_key`
1091/// match is found.
1092#[derive(Deserialize)]
1093struct ProbeFields {
1094 #[serde(default)]
1095 seq: Option<u64>,
1096 kind: String,
1097 idempotency_key: Option<String>,
1098}
1099
1100/// Fields pulled from the one matching line, including the full payload.
1101#[derive(Deserialize)]
1102struct FullEventForReplay {
1103 seq: u64,
1104 node_id: Option<String>,
1105 data: Value,
1106}
1107
1108/// Maximum number of bytes from a malformed line to surface (escaped) in an
1109/// [`Error::CorruptEventLog`] reason.
1110const CORRUPT_LINE_EXCERPT_BYTES: usize = 100;
1111
1112/// Stream-scan `events.jsonl` for the first event with matching `kind` and
1113/// `idempotency_key`, returning a typed [`PriorEvent`] (or `None` when the
1114/// log is missing or holds no such event).
1115///
1116/// The skim parses each line's envelope (`kind` / `idempotency_key` / `seq`)
1117/// but never materializes `data` for non-matching lines; the full payload
1118/// (`node_id` plus `data`) is deserialized only for the one matching line.
1119/// JSON parsing still scans every byte of every line, so the scan is linear
1120/// in total log bytes under the lock — there is no payload-skipping shortcut.
1121///
1122/// # Torn-line policy
1123///
1124/// [`recover_last_seq`] tolerates a crash-truncated *final* line that lacks
1125/// a trailing newline and discards it regardless of whether its bytes
1126/// happen to form valid JSON. This scanner mirrors that exactly: a final
1127/// line with no trailing `\n` is treated as an in-flight partial write and
1128/// ignored — *before* any parse attempt — so the read (dedup) and write
1129/// (recovery) paths never disagree about whether that tail is committed.
1130///
1131/// Any *interior* line that fails to parse (it is newline-terminated, so a
1132/// later line follows) is a data-integrity fault, so it returns
1133/// [`Error::CorruptEventLog`] rather than silently skipping a line that
1134/// might carry the very key being looked up, which would let the caller
1135/// double-append. This is strictly *more* conservative than
1136/// `recover_last_seq` (which only inspects the last complete line) — a
1137/// deliberate choice for the dedup read.
1138///
1139/// Bytes are read with [`std::io::BufRead::read_until`] rather than
1140/// `read_line` so a torn tail that cuts a multi-byte UTF-8 sequence is
1141/// tolerated as a partial write (matching `recover_last_seq`) instead of
1142/// surfacing as an I/O error; a *newline-terminated* line containing
1143/// invalid UTF-8 is reported as `CorruptEventLog`, not I/O.
1144///
1145/// The `_witness: &LockedRun` is compile-time proof the caller holds the run's
1146/// exclusive [`RunLock`] — the scan is read-only, but it is only meaningful
1147/// fused with an append under one lock window (otherwise a concurrent retry can
1148/// see "no prior event" and double-append). The witness gates the public surface
1149/// so a caller cannot run the scan-then-append race: it must already hold the
1150/// lock to scan, and the same held lock covers the append it threads into
1151/// [`append_and_apply_unlocked`]. [`append_and_apply_idempotent`] fuses the two
1152/// for the common case; a caller that must interleave domain logic between the
1153/// scan and the append (e.g. `discussion resolve`'s already-resolved / no-op
1154/// precedence) calls this primitive directly under its own held lock.
1155pub fn find_prior_with_key(
1156 _witness: &LockedRun<'_>,
1157 paths: &RunPaths,
1158 kind: &str,
1159 idempotency_key: &str,
1160) -> Result<Option<PriorEvent>> {
1161 // Guard the run root + event log before reading: the idempotency scan
1162 // opens `events.jsonl` ahead of the append, so it must refuse a symlinked
1163 // log too rather than read through it.
1164 let events_path = paths.checked_events()?;
1165 let f = match std::fs::File::open(&events_path) {
1166 Ok(f) => f,
1167 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1168 Err(e) => return Err(Error::io(&events_path, e)),
1169 };
1170 let mut reader = PhysicalLineReader::new(BufReader::new(f));
1171 // `seq` of the last successfully-parsed line, for best-effort error
1172 // context pointing at where corruption begins.
1173 let mut last_good_seq: u64 = 0;
1174 while let Some(line) = reader.next_line().map_err(|e| Error::io(&events_path, e))? {
1175 // Mirror `recover_last_seq`: a final line lacking a trailing newline
1176 // is an uncommitted partial write, discarded WITHOUT parsing — even
1177 // if its bytes form valid JSON. Parsing it could otherwise return a
1178 // "match" for an event recovery considers unwritten, double-counting
1179 // the seq or skipping a real append.
1180 if !line.complete {
1181 break;
1182 }
1183 if line.content.is_empty() {
1184 continue;
1185 }
1186 let probe: ProbeFields =
1187 serde_json::from_slice(line.content).map_err(|e| Error::CorruptEventLog {
1188 path: events_path.clone(),
1189 reason: format!(
1190 "line {} is not a valid event envelope (last good seq {last_good_seq}): \
1191 {} [{e}]",
1192 line.lineno,
1193 excerpt(line.content),
1194 ),
1195 })?;
1196 if let Some(seq) = probe.seq {
1197 last_good_seq = seq;
1198 }
1199 if probe.kind != kind || probe.idempotency_key.as_deref() != Some(idempotency_key) {
1200 continue;
1201 }
1202 let full: FullEventForReplay =
1203 serde_json::from_slice(line.content).map_err(|e| Error::CorruptEventLog {
1204 path: events_path.clone(),
1205 reason: format!(
1206 "line {} matched idempotency key but is not a replayable event: {} [{e}]",
1207 line.lineno,
1208 excerpt(line.content),
1209 ),
1210 })?;
1211 return Ok(Some(PriorEvent {
1212 seq: full.seq,
1213 node_id: full.node_id,
1214 data: full.data,
1215 }));
1216 }
1217 Ok(None)
1218}
1219
1220/// Strip a single trailing line terminator (`\n`, optionally preceded by
1221/// `\r`) from a raw line. Unlike `trim_end_matches`, this removes exactly
1222/// one terminator so interior/leading bytes are never altered.
1223fn trim_line_end(buf: &[u8]) -> &[u8] {
1224 let mut end = buf.len();
1225 if end > 0 && buf[end - 1] == b'\n' {
1226 end -= 1;
1227 if end > 0 && buf[end - 1] == b'\r' {
1228 end -= 1;
1229 }
1230 }
1231 &buf[..end]
1232}
1233
1234/// Render a bounded, escaped prefix of a malformed log line for inclusion
1235/// in an error message. Bytes are lossily decoded (a torn multi-byte tail
1236/// becomes the replacement char) and control characters are escaped so an
1237/// excerpt can't inject newlines or ANSI sequences into CLI output.
1238pub(crate) fn excerpt(line: &[u8]) -> String {
1239 let shown = &line[..line.len().min(CORRUPT_LINE_EXCERPT_BYTES)];
1240 let mut out: String = String::from_utf8_lossy(shown).escape_debug().to_string();
1241 if line.len() > CORRUPT_LINE_EXCERPT_BYTES {
1242 out.push('…');
1243 }
1244 out
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249 use super::*;
1250 use crate::RunPaths;
1251 use serde_json::json;
1252 use tempfile::TempDir;
1253
1254 #[test]
1255 fn envelope_run_id_comes_from_paths_not_directory_basename() {
1256 // The whole point of storing run_id: even when the on-disk directory
1257 // name disagrees with the run id (symlinked/non-canonical root, the
1258 // original `root.file_name()` bug), the envelope must carry the stored
1259 // run_id verbatim — never the basename.
1260 let tmp = TempDir::new().unwrap();
1261 let dir = tmp.path().join("not-a-ulid-basename");
1262 std::fs::create_dir_all(&dir).unwrap();
1263 let run_id = "01jxsnap000000000000000000";
1264 let paths = RunPaths::new(dir, run_id).unwrap();
1265
1266 let r = append_and_apply_event(&paths, "run.status", None, None, serde_json::json!({}))
1267 .unwrap();
1268 assert_eq!(r.seq, 1);
1269
1270 let events = read_all_events(&paths.events()).unwrap();
1271 assert_eq!(events.len(), 1);
1272 assert_eq!(events[0].run_id.as_str(), run_id);
1273 }
1274
1275 #[cfg(unix)]
1276 #[test]
1277 fn append_rejects_a_symlinked_event_log() {
1278 // `events.jsonl` is the run's source of truth and highest-leverage
1279 // write — a symlinked log must be refused, not appended through.
1280 use crate::Error;
1281 use std::os::unix::fs::symlink;
1282 let tmp = TempDir::new().unwrap();
1283 let paths = fresh_run(&tmp);
1284 let target = tmp.path().join("evil-events.jsonl");
1285 symlink(&target, paths.events()).unwrap();
1286 let err = append_and_apply_event(&paths, "run.status", None, None, json!({})).unwrap_err();
1287 assert!(
1288 matches!(err, Error::SymlinkStateFile { name: "events", .. }),
1289 "got {err:?}"
1290 );
1291 // The forged append never reached the symlink target.
1292 assert!(!target.exists());
1293 }
1294
1295 /// Build a fresh, empty run directory with a valid `RunPaths` whose
1296 /// `run_id` matches the envelope the reducer will fold.
1297 fn fresh_run(tmp: &TempDir) -> RunPaths {
1298 let run_id = "01jxsnap000000000000000000";
1299 let dir = tmp.path().join(run_id);
1300 std::fs::create_dir_all(&dir).unwrap();
1301 RunPaths::new(dir, run_id).unwrap()
1302 }
1303
1304 /// Parse a `NodeId` for a test append call (the typed envelope id).
1305 fn nid(s: &str) -> NodeId {
1306 NodeId::parse_str(s).unwrap()
1307 }
1308
1309 /// Drive a run to a live node so reducer-affecting events have a target.
1310 fn bootstrap_live_node(paths: &RunPaths) {
1311 append_and_apply_event(
1312 paths,
1313 "run.created",
1314 None,
1315 None,
1316 serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "fix" }),
1317 )
1318 .unwrap();
1319 append_and_apply_event(
1320 paths,
1321 "node.created",
1322 Some(&nid("n-0001")),
1323 None,
1324 serde_json::json!({ "kind": "spinoff" }),
1325 )
1326 .unwrap();
1327 }
1328
1329 #[test]
1330 fn append_and_apply_event_success_path_appends_and_folds() {
1331 let tmp = TempDir::new().unwrap();
1332 let paths = fresh_run(&tmp);
1333
1334 let r = append_and_apply_event(
1335 &paths,
1336 "run.created",
1337 None,
1338 None,
1339 serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
1340 )
1341 .unwrap();
1342 assert_eq!(r.seq, 1);
1343 assert!(!r.idempotent_replay);
1344 assert!(r.prior.is_none());
1345
1346 // The reducer ran under the same lock: the manifest projection exists.
1347 let m = crate::read_manifest(&paths).unwrap();
1348 assert_eq!(m.run_id.as_str(), paths.run_id.as_str());
1349 }
1350
1351 #[test]
1352 fn append_and_apply_idempotent_appended_path_returns_fresh_seq() {
1353 let tmp = TempDir::new().unwrap();
1354 let paths = fresh_run(&tmp);
1355 bootstrap_live_node(&paths); // seq 1 run.created, seq 2 node.created
1356
1357 let before = read_all_events(&paths.events()).unwrap().len();
1358 let data = json!({ "status": "running" });
1359 let outcome = RunLock::with_lock(&paths, |lock| {
1360 append_and_apply_idempotent(
1361 &paths,
1362 lock,
1363 "node.status",
1364 Some(&nid("n-0001")),
1365 "k1",
1366 |_seq| Ok(data.clone()),
1367 )
1368 })
1369 .unwrap();
1370 match outcome {
1371 AppendOutcome::Appended { seq } => {
1372 assert_eq!(seq, 3, "fresh append takes the next seq");
1373 }
1374 other => panic!("expected Appended, got {other:?}"),
1375 }
1376 assert_eq!(
1377 read_all_events(&paths.events()).unwrap().len(),
1378 before + 1,
1379 "a fresh key appends exactly one event"
1380 );
1381 }
1382
1383 #[test]
1384 fn append_and_apply_idempotent_replay_returns_prior_without_appending() {
1385 let tmp = TempDir::new().unwrap();
1386 let paths = fresh_run(&tmp);
1387 bootstrap_live_node(&paths);
1388 let node = nid("n-0001");
1389 let data = json!({ "status": "running" });
1390
1391 let first = RunLock::with_lock(&paths, |lock| {
1392 append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
1393 Ok(data.clone())
1394 })
1395 })
1396 .unwrap();
1397 let first_seq = match first {
1398 AppendOutcome::Appended { seq } => seq,
1399 other => panic!("expected Appended, got {other:?}"),
1400 };
1401 let after_first = read_all_events(&paths.events()).unwrap().len();
1402
1403 // Same kind + key + node + data → a true replay: nothing appended, the
1404 // prior event (its seq + data) is returned.
1405 let replay = RunLock::with_lock(&paths, |lock| {
1406 append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
1407 Ok(data.clone())
1408 })
1409 })
1410 .unwrap();
1411 match replay {
1412 AppendOutcome::IdempotentReplay { prior } => {
1413 assert_eq!(prior.seq, first_seq);
1414 assert_eq!(prior.node_id.as_deref(), Some("n-0001"));
1415 assert_eq!(prior.data, data);
1416 }
1417 other => panic!("expected IdempotentReplay, got {other:?}"),
1418 }
1419 assert_eq!(
1420 read_all_events(&paths.events()).unwrap().len(),
1421 after_first,
1422 "a replay must not append a new event"
1423 );
1424 }
1425
1426 #[test]
1427 fn append_and_apply_idempotent_conflict_on_different_data() {
1428 let tmp = TempDir::new().unwrap();
1429 let paths = fresh_run(&tmp);
1430 bootstrap_live_node(&paths);
1431 let node = nid("n-0001");
1432
1433 let first = RunLock::with_lock(&paths, |lock| {
1434 append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
1435 Ok(json!({ "status": "running" }))
1436 })
1437 })
1438 .unwrap();
1439 let first_seq = match first {
1440 AppendOutcome::Appended { seq } => seq,
1441 other => panic!("expected Appended, got {other:?}"),
1442 };
1443 let after_first = read_all_events(&paths.events()).unwrap().len();
1444
1445 // Same key, DIFFERENT payload → conflict, carrying the prior event's seq;
1446 // nothing new is appended.
1447 let conflict = RunLock::with_lock(&paths, |lock| {
1448 append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
1449 Ok(json!({ "status": "done" }))
1450 })
1451 })
1452 .unwrap();
1453 match conflict {
1454 AppendOutcome::Conflict { prior } => {
1455 assert_eq!(prior.seq, first_seq);
1456 assert_eq!(prior.data, json!({ "status": "running" }));
1457 }
1458 other => panic!("expected Conflict, got {other:?}"),
1459 }
1460 assert_eq!(
1461 read_all_events(&paths.events()).unwrap().len(),
1462 after_first,
1463 "a conflict must not append a new event"
1464 );
1465 }
1466
1467 #[test]
1468 fn append_and_apply_idempotent_conflict_on_different_node_id() {
1469 // Same key + same data but a different envelope node is still a reused
1470 // key for a different request → conflict, not a silent replay.
1471 let tmp = TempDir::new().unwrap();
1472 let paths = fresh_run(&tmp);
1473 bootstrap_live_node(&paths);
1474 // A second live node so the conflicting append targets a real node.
1475 append_and_apply_event(
1476 &paths,
1477 "node.created",
1478 Some(&nid("n-0002")),
1479 None,
1480 json!({ "kind": "spinoff" }),
1481 )
1482 .unwrap();
1483 let data = json!({ "status": "running" });
1484
1485 RunLock::with_lock(&paths, |lock| {
1486 append_and_apply_idempotent(
1487 &paths,
1488 lock,
1489 "node.status",
1490 Some(&nid("n-0001")),
1491 "k1",
1492 |_seq| Ok(data.clone()),
1493 )
1494 })
1495 .unwrap();
1496
1497 let conflict = RunLock::with_lock(&paths, |lock| {
1498 append_and_apply_idempotent(
1499 &paths,
1500 lock,
1501 "node.status",
1502 Some(&nid("n-0002")),
1503 "k1",
1504 |_seq| Ok(data.clone()),
1505 )
1506 })
1507 .unwrap();
1508 assert!(
1509 matches!(conflict, AppendOutcome::Conflict { prior } if prior.node_id.as_deref() == Some("n-0001")),
1510 "a node-id mismatch under the same key is a conflict"
1511 );
1512 }
1513
1514 #[test]
1515 fn append_and_apply_idempotent_rejects_empty_key() {
1516 let tmp = TempDir::new().unwrap();
1517 let paths = fresh_run(&tmp);
1518 bootstrap_live_node(&paths);
1519 let err = RunLock::with_lock(&paths, |lock| {
1520 append_and_apply_idempotent(
1521 &paths,
1522 lock,
1523 "node.status",
1524 Some(&nid("n-0001")),
1525 "",
1526 |_seq| Ok(json!({ "status": "running" })),
1527 )
1528 })
1529 .unwrap_err();
1530 assert!(matches!(err, Error::EmptyIdempotencyKey), "got {err:?}");
1531 }
1532
1533 #[test]
1534 fn append_and_apply_event_idempotent_replay_returns_prior_without_appending() {
1535 let tmp = TempDir::new().unwrap();
1536 let paths = fresh_run(&tmp);
1537 bootstrap_live_node(&paths);
1538
1539 let data = serde_json::json!({ "status": "running" });
1540 let first = append_and_apply_event(
1541 &paths,
1542 "node.status",
1543 Some(&nid("n-0001")),
1544 Some("k1"),
1545 data.clone(),
1546 )
1547 .unwrap();
1548 assert!(!first.idempotent_replay);
1549 let before = read_all_events(&paths.events()).unwrap().len();
1550
1551 // Same kind + key: a replay returns the prior event and appends nothing.
1552 let replay = append_and_apply_event(
1553 &paths,
1554 "node.status",
1555 Some(&nid("n-0001")),
1556 Some("k1"),
1557 data.clone(),
1558 )
1559 .unwrap();
1560 assert!(replay.idempotent_replay);
1561 assert!(
1562 !replay.applied,
1563 "an idempotent replay applies nothing this call (applied: false)"
1564 );
1565 assert_eq!(replay.seq, first.seq);
1566 let prior = replay.prior.expect("replay carries the prior event");
1567 assert_eq!(prior.node_id.as_deref(), Some("n-0001"));
1568 assert_eq!(prior.data, data);
1569 assert_eq!(
1570 read_all_events(&paths.events()).unwrap().len(),
1571 before,
1572 "replay must not append a new line"
1573 );
1574 }
1575
1576 #[test]
1577 fn append_and_apply_event_reducer_noop_is_still_a_success() {
1578 let tmp = TempDir::new().unwrap();
1579 let paths = fresh_run(&tmp);
1580 bootstrap_live_node(&paths);
1581
1582 // Settle the node terminal. A real state change → `applied: true`.
1583 let n0001 = nid("n-0001");
1584 let settle = append_and_apply_event(
1585 &paths,
1586 "node.report",
1587 Some(&n0001),
1588 None,
1589 serde_json::json!({ "success": true }),
1590 )
1591 .unwrap();
1592 assert!(
1593 settle.applied,
1594 "a report that terminalizes a live node applied a projection op"
1595 );
1596 assert_eq!(
1597 crate::read_node(&paths, &n0001).unwrap().status,
1598 crate::schema::Status::Done
1599 );
1600
1601 // A later status event is dropped by the terminal-state guard, but the
1602 // append still happened: the result names the appended event's seq and
1603 // is not a replay. The node stays Done. `applied` is FALSE — the reducer
1604 // planned zero ops (issue `reducer-adopt-explicit-merge`).
1605 let before = read_all_events(&paths.events()).unwrap().len();
1606 let r = append_and_apply_event(
1607 &paths,
1608 "node.status",
1609 Some(&n0001),
1610 None,
1611 serde_json::json!({ "status": "running" }),
1612 )
1613 .unwrap();
1614 assert!(!r.idempotent_replay);
1615 assert!(
1616 !r.applied,
1617 "a dead event dropped by the terminal guard reports applied: false"
1618 );
1619 assert_eq!(r.seq as usize, before + 1);
1620 assert_eq!(
1621 read_all_events(&paths.events()).unwrap().len(),
1622 before + 1,
1623 "the event is appended even when the reducer no-ops"
1624 );
1625 assert_eq!(
1626 crate::read_node(&paths, &n0001).unwrap().status,
1627 crate::schema::Status::Done,
1628 "terminal status is frozen"
1629 );
1630 }
1631
1632 #[test]
1633 fn bootstrap_advances_the_watermark_past_every_appended_event() {
1634 // Baseline for the replay tests: the normal append path keeps the
1635 // watermark pinned to the last appended seq, so `applied_seq == last`
1636 // whenever the log is clean.
1637 let tmp = TempDir::new().unwrap();
1638 let paths = fresh_run(&tmp);
1639 bootstrap_live_node(&paths); // seq 1 run.created, seq 2 node.created
1640 assert_eq!(
1641 crate::read_manifest(&paths).unwrap().applied_seq,
1642 2,
1643 "watermark tracks the last appended event"
1644 );
1645 }
1646
1647 #[test]
1648 fn append_replays_unapplied_tail_before_appending() {
1649 use crate::schema::Status;
1650 // Failure scenario 1: a reducer crash after the event-row fsync but
1651 // before the projection/watermark write leaves the log ahead of the
1652 // projections. The next lock acquisition must replay that tail.
1653 let tmp = TempDir::new().unwrap();
1654 let paths = fresh_run(&tmp);
1655 bootstrap_live_node(&paths); // applied_seq == 2, node n-0001 Pending
1656 let n0001 = nid("n-0001");
1657
1658 // Append a tail event (seq 3) WITHOUT running the reducer — exactly the
1659 // on-disk state a crash between the row fsync and the projection write
1660 // would leave behind. The raw append still needs the witness (lock held).
1661 RunLock::with_lock(&paths, |lock| {
1662 append_event_with_seq(
1663 lock,
1664 &paths,
1665 3,
1666 "node.status",
1667 Some(&n0001),
1668 None,
1669 json!({ "status": "running" }),
1670 )
1671 })
1672 .unwrap();
1673 assert_eq!(
1674 crate::read_node(&paths, &n0001).unwrap().status,
1675 Status::Pending,
1676 "the tail event's projection has not landed yet"
1677 );
1678 assert_eq!(crate::read_manifest(&paths).unwrap().applied_seq, 2);
1679
1680 // Any new append acquires the lock and replays seq 3 first, so the new
1681 // event takes seq 4 and the stale projection is healed.
1682 let r = append_and_apply_event(
1683 &paths,
1684 "run.status",
1685 None,
1686 None,
1687 json!({ "status": "running" }),
1688 )
1689 .unwrap();
1690 assert_eq!(r.seq, 4, "the new event follows the replayed tail");
1691 assert_eq!(
1692 crate::read_node(&paths, &n0001).unwrap().status,
1693 Status::Running,
1694 "the previously-unapplied tail event is now folded"
1695 );
1696 assert_eq!(
1697 crate::read_manifest(&paths).unwrap().applied_seq,
1698 4,
1699 "the watermark now covers the whole log"
1700 );
1701 }
1702
1703 #[test]
1704 fn legacy_manifest_without_applied_seq_migrates_on_next_write() {
1705 use crate::schema::Status;
1706 // A `manifest.json` written before `applied_seq` existed must read back
1707 // as 0 (serde default) and self-migrate on the next write via an
1708 // idempotent full replay — without double-counting counters or
1709 // resurrecting a terminal node (failure scenario 2's no-double-count
1710 // guarantee, exercised over the whole log).
1711 let tmp = TempDir::new().unwrap();
1712 let paths = fresh_run(&tmp);
1713 bootstrap_live_node(&paths);
1714 let n0001 = nid("n-0001");
1715 append_and_apply_event(
1716 &paths,
1717 "node.report",
1718 Some(&n0001),
1719 None,
1720 json!({ "success": true }),
1721 )
1722 .unwrap(); // seq 3 → node Done, applied_seq == 3, node_count == 1
1723
1724 // Rewrite the manifest WITHOUT an `applied_seq` field, mimicking a
1725 // pre-watermark binary's output.
1726 let mut mv: serde_json::Value =
1727 serde_json::from_slice(&std::fs::read(paths.manifest()).unwrap()).unwrap();
1728 assert!(mv.as_object_mut().unwrap().remove("applied_seq").is_some());
1729 std::fs::write(paths.manifest(), serde_json::to_vec_pretty(&mv).unwrap()).unwrap();
1730 assert_eq!(
1731 crate::read_manifest(&paths).unwrap().applied_seq,
1732 0,
1733 "a legacy manifest reads as applied_seq 0"
1734 );
1735
1736 // The next write triggers a full idempotent replay of seq 1..=3 (all
1737 // no-ops) and advances the watermark to last_seq.
1738 append_and_apply_event(
1739 &paths,
1740 "run.status",
1741 None,
1742 None,
1743 json!({ "status": "running" }),
1744 )
1745 .unwrap(); // seq 4
1746 let m = crate::read_manifest(&paths).unwrap();
1747 assert_eq!(m.applied_seq, 4, "watermark caught up to the log");
1748 assert_eq!(
1749 m.node_count, 1,
1750 "full replay did not double-count node_count"
1751 );
1752 assert_eq!(
1753 crate::read_node(&paths, &n0001).unwrap().status,
1754 Status::Done,
1755 "replaying its history did not resurrect the terminal node"
1756 );
1757 }
1758
1759 #[test]
1760 fn replay_skips_events_with_unsafe_ids_and_never_escapes_run_dir() {
1761 // Issue `reducer-path-traversal-defense`: the reducer must independently
1762 // defend against ids read from `events.jsonl` that bypass the CLI
1763 // validators — a corrupt log, a restored backup, or a future writer.
1764 // We craft a log straight onto disk (skipping the append gate) holding
1765 // two poison `child.spawned` lines (a traversal-laden and an empty
1766 // `child_run_id`) and one good one, then drive a catch-up replay and
1767 // assert: the poison events are skipped (not fatal) and the good event
1768 // still applies (its child ref lands on the parent node).
1769 let tmp = TempDir::new().unwrap();
1770 let paths = fresh_run(&tmp);
1771 bootstrap_live_node(&paths); // applied_seq == 2, node n-0001 live
1772
1773 // seq 3 — a traversal-laden `child_run_id`; seq 4 — an empty one. Both
1774 // fail their strict `parse_str`, so `reduce_event_to_ops` rejects them.
1775 // seq 5 — a well-formed pair that must be applied despite the poison
1776 // lines preceding it. All three raw appends share one held lock.
1777 let child_run = "02jxsnap000000000000000000";
1778 RunLock::with_lock(&paths, |lock| {
1779 append_event_with_seq(
1780 lock,
1781 &paths,
1782 3,
1783 "child.spawned",
1784 Some(&nid("n-0001")),
1785 None,
1786 json!({ "child_run_id": "../escape", "child_node_id": "n-0001" }),
1787 )?;
1788 append_event_with_seq(
1789 lock,
1790 &paths,
1791 4,
1792 "child.spawned",
1793 Some(&nid("n-0001")),
1794 None,
1795 json!({ "child_run_id": "", "child_node_id": "n-0001" }),
1796 )?;
1797 append_event_with_seq(
1798 lock,
1799 &paths,
1800 5,
1801 "child.spawned",
1802 Some(&nid("n-0001")),
1803 None,
1804 json!({ "child_run_id": child_run, "child_node_id": "n-0001" }),
1805 )
1806 })
1807 .unwrap();
1808
1809 // The poison lines must NOT abort the replay (the regression this fixes:
1810 // a `..`-laden id is a valid envelope quarantine can't excise, so a hard
1811 // error here would brick every future append on the run).
1812 replay_unapplied(&paths, &paths.events()).expect("poison lines skipped, not fatal");
1813
1814 // The good child.spawned landed: the parent node carries exactly one
1815 // child ref, and the two poison ids added nothing.
1816 let parent = crate::read_node(&paths, &nid("n-0001")).unwrap();
1817 assert_eq!(
1818 parent.children.len(),
1819 1,
1820 "only the good child ref was applied; poison ids added none"
1821 );
1822 assert_eq!(parent.children[0].run_id.as_str(), child_run);
1823 assert!(
1824 !paths.root.join("escape").exists(),
1825 "traversal id must never have been joined onto a path"
1826 );
1827
1828 // The watermark jumped past the skipped seqs to the applied good event.
1829 let m = crate::read_manifest(&paths).unwrap();
1830 assert_eq!(
1831 m.applied_seq, 5,
1832 "watermark advanced past the skipped poison"
1833 );
1834 }
1835
1836 #[test]
1837 fn node_count_desync_heals_on_replay() {
1838 // Faithful reproduction of issue `manifest-counter-desync`: a crash left
1839 // the node projection on disk but lost the follow-on manifest write (the
1840 // counter bump + watermark advance). Before the fix, the replay
1841 // short-circuited on the already-existing node and the stale counter
1842 // stuck forever; now the counter is re-derived at the watermark advance.
1843 let tmp = TempDir::new().unwrap();
1844 let paths = fresh_run(&tmp);
1845 bootstrap_live_node(&paths); // node n-0001 on disk, node_count == 1, applied_seq == 2
1846
1847 // Rewind the manifest to the exact mid-crash state: the node file
1848 // exists, but the manifest still shows the pre-node counter and a
1849 // watermark that sits before the `node.created` at seq 2.
1850 let mut m = crate::read_manifest(&paths).unwrap();
1851 assert_eq!(m.node_count, 1, "precondition: bootstrap counted the node");
1852 m.node_count = 0;
1853 m.applied_seq = 1;
1854 write_manifest(&paths, &m).unwrap();
1855
1856 // The next append acquires the lock, replays seq 2 (node already exists,
1857 // so the reducer plans zero ops), and re-derives the counter when it
1858 // advances the watermark past seq 2.
1859 append_and_apply_event(
1860 &paths,
1861 "run.status",
1862 None,
1863 None,
1864 json!({ "status": "running" }),
1865 )
1866 .unwrap();
1867
1868 let healed = crate::read_manifest(&paths).unwrap();
1869 assert_eq!(
1870 healed.node_count, 1,
1871 "node_count converged to the true projection count"
1872 );
1873 assert!(healed.applied_seq >= 2, "watermark caught up past the node");
1874 }
1875
1876 #[test]
1877 fn full_replay_does_not_double_count_any_counter() {
1878 // Idempotence across a full from-scratch replay: re-folding every event
1879 // must re-derive the same total, never accumulate. Covers `node_count`.
1880 let tmp = TempDir::new().unwrap();
1881 let paths = fresh_run(&tmp);
1882 bootstrap_live_node(&paths);
1883 append_and_apply_event(
1884 &paths,
1885 "node.created",
1886 Some(&nid("n-0002")),
1887 None,
1888 json!({ "kind": "spinoff" }),
1889 )
1890 .unwrap();
1891 let before = crate::read_manifest(&paths).unwrap();
1892 assert_eq!(before.node_count, 2, "precondition: two nodes");
1893
1894 // Reset the watermark to force a full idempotent replay of the whole log
1895 // on the next append (the legacy-migration path), and deliberately
1896 // corrupt the counter so a heal is observable.
1897 let mut m = before;
1898 m.applied_seq = 0;
1899 m.node_count = 99;
1900 write_manifest(&paths, &m).unwrap();
1901 append_and_apply_event(
1902 &paths,
1903 "run.status",
1904 None,
1905 None,
1906 json!({ "status": "running" }),
1907 )
1908 .unwrap();
1909
1910 let after = crate::read_manifest(&paths).unwrap();
1911 assert_eq!(
1912 after.node_count, 2,
1913 "counter re-derived to the true total — no double-count across full replay"
1914 );
1915 }
1916
1917 #[test]
1918 fn idempotent_replay_catches_up_projection_before_returning() {
1919 use crate::projections::write_manifest;
1920 use crate::schema::Status;
1921 use crate::write_node;
1922 // Requirement 3: an idempotency-key replay must ensure the projection is
1923 // caught up (`applied_seq >= prior.seq`) before returning the prior
1924 // envelope — never a "found, but not yet applied" result.
1925 let tmp = TempDir::new().unwrap();
1926 let paths = fresh_run(&tmp);
1927 bootstrap_live_node(&paths);
1928 let n0001 = nid("n-0001");
1929
1930 // A keyed event lands and folds normally...
1931 let first = append_and_apply_event(
1932 &paths,
1933 "node.status",
1934 Some(&n0001),
1935 Some("k1"),
1936 json!({ "status": "running" }),
1937 )
1938 .unwrap(); // seq 3
1939 assert!(!first.idempotent_replay);
1940
1941 // ...then simulate a crash that lost the fold: rewind the watermark
1942 // below seq 3 and revert the node to its pre-event Pending state.
1943 let mut m = crate::read_manifest(&paths).unwrap();
1944 m.applied_seq = 2;
1945 write_manifest(&paths, &m).unwrap();
1946 let mut n = crate::read_node(&paths, &n0001).unwrap();
1947 n.status = Status::Pending;
1948 write_node(&paths, &n).unwrap();
1949
1950 // The idempotent retry returns the prior seq AND catches the projection
1951 // up first.
1952 let replay = append_and_apply_event(
1953 &paths,
1954 "node.status",
1955 Some(&n0001),
1956 Some("k1"),
1957 json!({ "status": "running" }),
1958 )
1959 .unwrap();
1960 assert!(replay.idempotent_replay);
1961 assert_eq!(replay.seq, first.seq);
1962 assert!(
1963 crate::read_manifest(&paths).unwrap().applied_seq >= first.seq,
1964 "watermark caught up before the replay returned"
1965 );
1966 assert_eq!(
1967 crate::read_node(&paths, &n0001).unwrap().status,
1968 Status::Running,
1969 "the prior event's projection is durable before returning"
1970 );
1971 }
1972
1973 /// Build a `RunPaths` over a fresh tempdir and write `bytes` verbatim to
1974 /// `events.jsonl` — verbatim so a test can craft torn-line boundaries
1975 /// (a missing trailing `\n`) that the append path never produces.
1976 fn paths_with_events(tmp: &TempDir, bytes: &[u8]) -> RunPaths {
1977 let dir = tmp.path().join("run");
1978 std::fs::create_dir_all(&dir).unwrap();
1979 let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
1980 std::fs::write(paths.events(), bytes).unwrap();
1981 paths
1982 }
1983
1984 /// Run [`find_prior_with_key`] under a freshly-acquired exclusive lock —
1985 /// the witness it now requires. The scan is read-only, so taking the lock
1986 /// just to mint the witness is exactly what a real caller does.
1987 fn scan(paths: &RunPaths, kind: &str, key: &str) -> Result<Option<PriorEvent>> {
1988 RunLock::with_lock(paths, |w| find_prior_with_key(w, paths, kind, key))
1989 }
1990
1991 #[test]
1992 fn find_prior_with_key_missing_log_is_none() {
1993 let tmp = TempDir::new().unwrap();
1994 let dir = tmp.path().join("run");
1995 std::fs::create_dir_all(&dir).unwrap();
1996 let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
1997 // No events.jsonl written at all.
1998 let got = scan(&paths, "node.report", "k1").unwrap();
1999 assert!(got.is_none());
2000 }
2001
2002 #[test]
2003 fn find_prior_with_key_finds_the_matching_line() {
2004 let tmp = TempDir::new().unwrap();
2005 let log = concat!(
2006 r#"{"seq":1,"kind":"node.status","idempotency_key":"k0","node_id":"n-1","data":{}}"#,
2007 "\n",
2008 r#"{"seq":2,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{"ok":true}}"#,
2009 "\n",
2010 );
2011 let paths = paths_with_events(&tmp, log.as_bytes());
2012 let got = scan(&paths, "node.report", "k1").unwrap().expect("match");
2013 assert_eq!(got.seq, 2);
2014 assert_eq!(got.node_id.as_deref(), Some("n-1"));
2015 assert_eq!(got.data, serde_json::json!({"ok": true}));
2016 }
2017
2018 #[test]
2019 fn find_prior_with_key_no_match_is_none() {
2020 let tmp = TempDir::new().unwrap();
2021 let log = concat!(
2022 r#"{"seq":1,"kind":"node.report","idempotency_key":"other","node_id":"n-1","data":{}}"#,
2023 "\n",
2024 );
2025 let paths = paths_with_events(&tmp, log.as_bytes());
2026 assert!(scan(&paths, "node.report", "k1").unwrap().is_none());
2027 }
2028
2029 #[test]
2030 fn find_prior_with_key_tolerates_torn_final_line() {
2031 // A complete record, then a crash-truncated final line with NO
2032 // trailing newline — exactly what `recover_last_seq` tolerates.
2033 // The scan must still return the earlier match and never error.
2034 let tmp = TempDir::new().unwrap();
2035 let mut log = String::new();
2036 log.push_str(
2037 r#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{"ok":true}}"#,
2038 );
2039 log.push('\n');
2040 log.push_str(r#"{"seq":2,"kind":"node.rep"#); // torn mid-write, no newline
2041 let paths = paths_with_events(&tmp, log.as_bytes());
2042
2043 let got = scan(&paths, "node.report", "k1")
2044 .unwrap()
2045 .expect("match before the torn tail");
2046 assert_eq!(got.seq, 1);
2047
2048 // A torn final line with no matching key ahead of it returns None,
2049 // not an error.
2050 let tmp2 = TempDir::new().unwrap();
2051 let paths2 = paths_with_events(&tmp2, br#"{"seq":1,"kind":"node.rep"#);
2052 assert!(scan(&paths2, "node.report", "k1").unwrap().is_none());
2053 }
2054
2055 #[test]
2056 fn find_prior_with_key_ignores_valid_json_final_line_without_newline() {
2057 // The dangerous case: a crash landed a COMPLETE, valid-JSON event
2058 // but the trailing newline never flushed. `recover_last_seq`
2059 // discards any newline-less tail, so it considers this event
2060 // unwritten (returns 0). The dedup scan MUST agree and return None
2061 // — otherwise it would report "already appended", the caller skips
2062 // the append, and the event is lost / the seq double-counts.
2063 let tmp = TempDir::new().unwrap();
2064 let line =
2065 br#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#;
2066 let paths = paths_with_events(&tmp, line);
2067 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
2068 assert!(
2069 scan(&paths, "node.report", "k1").unwrap().is_none(),
2070 "torn tail must be ignored even when it parses as valid JSON"
2071 );
2072 }
2073
2074 #[test]
2075 fn find_prior_with_key_skips_nonmatching_line_missing_seq() {
2076 // `seq` is not a match key, so a NON-matching envelope that happens
2077 // to lack `seq` must be skimmed past, not treated as corruption that
2078 // aborts the scan before a later match. (The pre-lift scanner's
2079 // probe didn't require `seq`; making it required would have been a
2080 // regression that hid a real key behind an unrelated seq-less line.)
2081 let tmp = TempDir::new().unwrap();
2082 let log = concat!(
2083 r#"{"kind":"node.status","idempotency_key":"other","node_id":"n-1","data":{}}"#,
2084 "\n",
2085 r#"{"seq":2,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{"ok":true}}"#,
2086 "\n",
2087 );
2088 let paths = paths_with_events(&tmp, log.as_bytes());
2089 let got = scan(&paths, "node.report", "k1")
2090 .unwrap()
2091 .expect("match after a seq-less non-matching line");
2092 assert_eq!(got.seq, 2);
2093 assert_eq!(got.node_id.as_deref(), Some("n-1"));
2094 }
2095
2096 #[test]
2097 fn find_prior_with_key_matched_line_bad_payload_is_corrupt_log() {
2098 // A line that skims fine (kind + key match) but whose full payload
2099 // is malformed (`node_id` is a number, not a string) is event-log
2100 // corruption — it must surface as CorruptEventLog (exit 1), not a
2101 // generic JSON/io error (exit 2).
2102 let tmp = TempDir::new().unwrap();
2103 let log = concat!(
2104 r#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":42,"data":{}}"#,
2105 "\n",
2106 );
2107 let paths = paths_with_events(&tmp, log.as_bytes());
2108 let err = scan(&paths, "node.report", "k1").unwrap_err();
2109 assert!(
2110 matches!(err, Error::CorruptEventLog { .. }),
2111 "expected CorruptEventLog, got {err:?}"
2112 );
2113 }
2114
2115 #[test]
2116 fn find_prior_with_key_handles_crlf_line_endings() {
2117 let tmp = TempDir::new().unwrap();
2118 let log = concat!(
2119 r#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#,
2120 "\r\n",
2121 );
2122 let paths = paths_with_events(&tmp, log.as_bytes());
2123 let got = scan(&paths, "node.report", "k1")
2124 .unwrap()
2125 .expect("CRLF-terminated match");
2126 assert_eq!(got.seq, 1);
2127 }
2128
2129 #[test]
2130 fn find_prior_with_key_tolerates_partial_utf8_torn_tail() {
2131 // A crash can cut a multi-byte UTF-8 sequence mid-character. With
2132 // byte-oriented reading this torn (newline-less) tail is tolerated
2133 // like any other partial write, not surfaced as an I/O error.
2134 let tmp = TempDir::new().unwrap();
2135 let mut log = Vec::new();
2136 log.extend_from_slice(
2137 br#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#,
2138 );
2139 log.push(b'\n');
2140 log.extend_from_slice(&[0xF0, 0x9F]); // start of a 4-byte char, truncated
2141 let paths = paths_with_events(&tmp, &log);
2142 let got = scan(&paths, "node.report", "k1")
2143 .unwrap()
2144 .expect("match before the partial-UTF8 tail");
2145 assert_eq!(got.seq, 1);
2146 }
2147
2148 #[test]
2149 fn recover_last_seq_newline_terminated_garbage_is_corrupt_log() {
2150 // Consistency guard with find_prior_with_key: a newline-terminated
2151 // final line that isn't valid JSON is CorruptEventLog from BOTH
2152 // readers, so the CLI maps both to the same corrupt-event-log exit.
2153 let tmp = TempDir::new().unwrap();
2154 let paths = paths_with_events(&tmp, b"{not json at all\n");
2155 let err = recover_last_seq(&paths.events()).unwrap_err();
2156 assert!(
2157 matches!(err, Error::CorruptEventLog { .. }),
2158 "expected CorruptEventLog, got {err:?}"
2159 );
2160 }
2161
2162 #[test]
2163 fn rejected_event_is_not_appended() {
2164 // The transactional fix: a reducer-rejected event must error BEFORE
2165 // any durable write, so events.jsonl never gains a poison line.
2166 let tmp = TempDir::new().unwrap();
2167 let paths = fresh_run(&tmp);
2168 bootstrap_live_node(&paths);
2169 let before = read_all_events(&paths.events()).unwrap().len();
2170
2171 // `node.report` with neither success nor cancelled → reducer rejects.
2172 let err =
2173 append_and_apply_event(&paths, "node.report", Some(&nid("n-0001")), None, json!({}))
2174 .unwrap_err();
2175 assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
2176
2177 assert_eq!(
2178 read_all_events(&paths.events()).unwrap().len(),
2179 before,
2180 "a rejected event must not be appended"
2181 );
2182 // The log is still clean and re-readable (no poison line stranded it).
2183 assert!(recover_last_seq(&paths.events()).is_ok());
2184 let next = append_and_apply_event(
2185 &paths,
2186 "node.report",
2187 Some(&nid("n-0001")),
2188 None,
2189 json!({ "success": true }),
2190 )
2191 .unwrap();
2192 assert_eq!(
2193 next.seq as usize,
2194 before + 1,
2195 "the next valid append reuses the seq the rejected event never consumed"
2196 );
2197 }
2198
2199 #[test]
2200 fn validate_event_agrees_with_apply_event() {
2201 // Drift guard: `validate_event` (the pre-append gate) must return Err
2202 // in EXACTLY the cases `apply_event` would, for the same state — else
2203 // it would refuse a harmless no-op or let a poison line through.
2204 use crate::reducer::{apply_event, validate_event};
2205
2206 fn ev(paths: &RunPaths, kind: &str, node_id: Option<&str>, data: Value) -> Event {
2207 Event {
2208 ts: Utc::now(),
2209 seq: 999,
2210 kind: kind.to_string(),
2211 run_id: paths.run_id.clone(),
2212 node_id: node_id.map(|s| crate::schema::NodeId::parse_str(s).unwrap()),
2213 idempotency_key: None,
2214 data,
2215 }
2216 }
2217 // validate is read-only, so running it first leaves apply's pre-state
2218 // intact; we compare the two verdicts on the same fresh run.
2219 fn agree(paths: &RunPaths, e: &Event, label: &str) {
2220 let v = validate_event(paths, e).is_err();
2221 let a = apply_event(paths, e).is_err();
2222 assert_eq!(v, a, "{label}: validate_err={v} apply_err={a}");
2223 }
2224
2225 // Live node: bad report rejected; good report accepted; missing
2226 // node_id rejected; bad status rejected.
2227 {
2228 let tmp = TempDir::new().unwrap();
2229 let paths = fresh_run(&tmp);
2230 bootstrap_live_node(&paths);
2231 agree(
2232 &paths,
2233 &ev(&paths, "node.report", Some("n-0001"), json!({})),
2234 "report-bare",
2235 );
2236 }
2237 {
2238 let tmp = TempDir::new().unwrap();
2239 let paths = fresh_run(&tmp);
2240 bootstrap_live_node(&paths);
2241 agree(
2242 &paths,
2243 &ev(
2244 &paths,
2245 "node.report",
2246 Some("n-0001"),
2247 json!({ "success": true }),
2248 ),
2249 "report-good",
2250 );
2251 }
2252 {
2253 let tmp = TempDir::new().unwrap();
2254 let paths = fresh_run(&tmp);
2255 bootstrap_live_node(&paths);
2256 agree(
2257 &paths,
2258 &ev(&paths, "node.report", None, json!({})),
2259 "report-no-node-id",
2260 );
2261 }
2262 {
2263 let tmp = TempDir::new().unwrap();
2264 let paths = fresh_run(&tmp);
2265 bootstrap_live_node(&paths);
2266 agree(
2267 &paths,
2268 &ev(&paths, "node.status", Some("n-0001"), json!({})),
2269 "status-missing",
2270 );
2271 }
2272 // Terminal node: a malformed report is a clean no-op (guard before
2273 // validate) — both must accept it.
2274 {
2275 let tmp = TempDir::new().unwrap();
2276 let paths = fresh_run(&tmp);
2277 bootstrap_live_node(&paths);
2278 append_and_apply_event(
2279 &paths,
2280 "node.report",
2281 Some(&nid("n-0001")),
2282 None,
2283 json!({ "success": true }),
2284 )
2285 .unwrap();
2286 agree(
2287 &paths,
2288 &ev(&paths, "node.report", Some("n-0001"), json!({})),
2289 "report-bare-on-terminal",
2290 );
2291 }
2292 // Missing node: a status with no `status` field is a no-op.
2293 {
2294 let tmp = TempDir::new().unwrap();
2295 let paths = fresh_run(&tmp);
2296 agree(
2297 &paths,
2298 &ev(&paths, "node.status", Some("n-0001"), json!({})),
2299 "status-missing-node",
2300 );
2301 }
2302 // Existing manifest: a run.status with no `status` is rejected.
2303 {
2304 let tmp = TempDir::new().unwrap();
2305 let paths = fresh_run(&tmp);
2306 bootstrap_live_node(&paths);
2307 agree(
2308 &paths,
2309 &ev(&paths, "run.status", None, json!({})),
2310 "run-status-missing",
2311 );
2312 }
2313 // Open discussion: a resolve without `resolution` is rejected.
2314 {
2315 let tmp = TempDir::new().unwrap();
2316 let paths = fresh_run(&tmp);
2317 bootstrap_live_node(&paths);
2318 append_and_apply_event(
2319 &paths,
2320 "discussion.opened",
2321 Some(&nid("n-0001")),
2322 None,
2323 json!({ "discussion_id": "d-abcdefghij", "topic": "t", "node_id": "n-0001" }),
2324 )
2325 .unwrap();
2326 agree(
2327 &paths,
2328 &ev(
2329 &paths,
2330 "discussion.resolved",
2331 None,
2332 json!({ "discussion_id": "d-abcdefghij" }),
2333 ),
2334 "resolve-missing-resolution",
2335 );
2336 }
2337 // node.created: new node missing `kind` rejected; replay over an
2338 // existing node with bad payload is a no-op (existence short-circuit).
2339 {
2340 let tmp = TempDir::new().unwrap();
2341 let paths = fresh_run(&tmp);
2342 agree(
2343 &paths,
2344 &ev(&paths, "node.created", Some("n-0002"), json!({})),
2345 "node-created-missing-kind",
2346 );
2347 }
2348 {
2349 let tmp = TempDir::new().unwrap();
2350 let paths = fresh_run(&tmp);
2351 bootstrap_live_node(&paths);
2352 agree(
2353 &paths,
2354 &ev(&paths, "node.created", Some("n-0001"), json!({})),
2355 "node-created-replay-bad-payload",
2356 );
2357 }
2358 // discussion.opened missing `topic`.
2359 {
2360 let tmp = TempDir::new().unwrap();
2361 let paths = fresh_run(&tmp);
2362 bootstrap_live_node(&paths);
2363 agree(
2364 &paths,
2365 &ev(
2366 &paths,
2367 "discussion.opened",
2368 Some("n-0001"),
2369 json!({ "discussion_id": "d-abcdefghij", "node_id": "n-0001" }),
2370 ),
2371 "discussion-opened-missing-topic",
2372 );
2373 }
2374 // spinoff.proposed missing `proposed_title`; spinoff.{approved,rejected}
2375 // with an unparseable proposal id.
2376 {
2377 let tmp = TempDir::new().unwrap();
2378 let paths = fresh_run(&tmp);
2379 bootstrap_live_node(&paths);
2380 agree(
2381 &paths,
2382 &ev(
2383 &paths,
2384 "spinoff.proposed",
2385 Some("n-0001"),
2386 json!({ "proposal_id": "p-abcdefghij", "proposed_kind": "spinoff", "node_id": "n-0001" }),
2387 ),
2388 "spinoff-proposed-missing-title",
2389 );
2390 }
2391 {
2392 let tmp = TempDir::new().unwrap();
2393 let paths = fresh_run(&tmp);
2394 agree(
2395 &paths,
2396 &ev(
2397 &paths,
2398 "spinoff.approved",
2399 None,
2400 json!({ "proposal_id": "not a valid id" }),
2401 ),
2402 "spinoff-approved-bad-id",
2403 );
2404 agree(
2405 &paths,
2406 &ev(
2407 &paths,
2408 "spinoff.rejected",
2409 None,
2410 json!({ "proposal_id": "not a valid id" }),
2411 ),
2412 "spinoff-rejected-bad-id",
2413 );
2414 }
2415 // child.spawned: missing/invalid child_run_id.
2416 {
2417 let tmp = TempDir::new().unwrap();
2418 let paths = fresh_run(&tmp);
2419 agree(
2420 &paths,
2421 &ev(&paths, "child.spawned", Some("n-0001"), json!({})),
2422 "child-spawned-missing-child-run-id",
2423 );
2424 agree(
2425 &paths,
2426 &ev(
2427 &paths,
2428 "child.spawned",
2429 Some("n-0001"),
2430 json!({ "child_run_id": "bad" }),
2431 ),
2432 "child-spawned-bad-child-run-id",
2433 );
2434 }
2435 // Cross-run envelope and unknown kind.
2436 {
2437 let tmp = TempDir::new().unwrap();
2438 let paths = fresh_run(&tmp);
2439 let mut foreign = ev(&paths, "run.status", None, json!({ "status": "running" }));
2440 foreign.run_id = crate::schema::RunId::parse_str("02jxsnap000000000000000000").unwrap();
2441 agree(&paths, &foreign, "cross-run");
2442 agree(
2443 &paths,
2444 &ev(&paths, "totally.unknown", None, json!({})),
2445 "unknown-kind",
2446 );
2447 }
2448 }
2449
2450 #[test]
2451 fn read_all_events_drops_torn_final_line() {
2452 // The bug this fixes: `read_all_events` used to silently ACCEPT a
2453 // valid-JSON final line lacking a trailing newline — a line
2454 // `recover_last_seq` discards as an uncommitted partial write. Now it
2455 // shares the torn-tail policy: the torn final line is dropped without
2456 // error, and the reader agrees with `recover_last_seq`.
2457 let tmp = TempDir::new().unwrap();
2458 let mut log = String::new();
2459 log.push_str(
2460 r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2461 );
2462 log.push('\n');
2463 // A COMPLETE, valid-JSON event whose trailing newline never flushed.
2464 log.push_str(
2465 r#"{"ts":"2026-06-12T00:00:00Z","seq":2,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2466 );
2467 let paths = paths_with_events(&tmp, log.as_bytes());
2468
2469 let events = read_all_events(&paths.events()).unwrap();
2470 assert_eq!(
2471 events.iter().map(|e| e.seq).collect::<Vec<_>>(),
2472 vec![1],
2473 "torn final line must be dropped, not parsed"
2474 );
2475 // And it agrees with the recovery path.
2476 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 1);
2477 }
2478
2479 #[test]
2480 fn recover_last_seq_rejects_seq_only_last_line() {
2481 // A `\n`-terminated last line that is valid JSON with a `seq` but is
2482 // NOT a valid event envelope (missing ts/kind/run_id) must be rejected
2483 // by recover_last_seq, matching read_all_events — otherwise an append
2484 // would continue past a line replay can never fold.
2485 let tmp = TempDir::new().unwrap();
2486 let paths = paths_with_events(&tmp, b"{\"seq\":99}\n");
2487 let err = recover_last_seq(&paths.events()).unwrap_err();
2488 assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
2489 // And the forward reader agrees.
2490 assert!(matches!(
2491 read_all_events(&paths.events()).unwrap_err(),
2492 Error::CorruptEventLog { .. }
2493 ));
2494 }
2495
2496 #[test]
2497 fn recover_last_seq_skips_multiple_trailing_blank_lines() {
2498 // External editing can leave several trailing blank lines. The forward
2499 // reader skips them; seq recovery must walk back over all of them to
2500 // the last real record (not just one), so the two readers agree.
2501 let tmp = TempDir::new().unwrap();
2502 let mut log = String::new();
2503 log.push_str(
2504 r#"{"ts":"2026-06-12T00:00:00Z","seq":7,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2505 );
2506 log.push_str("\n\n\n\n");
2507 let paths = paths_with_events(&tmp, log.as_bytes());
2508 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 7);
2509 let events = read_all_events(&paths.events()).unwrap();
2510 assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![7]);
2511 }
2512
2513 #[test]
2514 fn recover_last_seq_skips_trailing_whitespace_only_lines() {
2515 // External editing can leave trailing lines holding only spaces, tabs,
2516 // or stray CRs. Recovery must walk back over every whitespace-only line
2517 // to the last real record, not stop at (and fail to parse) the blanks.
2518 let tmp = TempDir::new().unwrap();
2519 let mut log = String::new();
2520 log.push_str(
2521 r#"{"ts":"2026-06-12T00:00:00Z","seq":5,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2522 );
2523 log.push_str("\n \n\t\n \r\n");
2524 let paths = paths_with_events(&tmp, log.as_bytes());
2525 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 5);
2526 }
2527
2528 #[test]
2529 fn recover_last_seq_all_whitespace_file_is_zero() {
2530 // A log holding only blank/whitespace lines carries no event — recovery
2531 // returns the zero-event sentinel rather than erroring on the blanks.
2532 let tmp = TempDir::new().unwrap();
2533 let paths = paths_with_events(&tmp, b"\n \n\t\n \r\n");
2534 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
2535 }
2536
2537 #[test]
2538 fn recover_last_seq_single_newline_terminated_record_is_regression_guard() {
2539 // The common, healthy case: one record with a single trailing newline
2540 // must still recover its seq unchanged after the blank-line tolerance.
2541 let tmp = TempDir::new().unwrap();
2542 let paths = paths_with_events(
2543 &tmp,
2544 concat!(
2545 r#"{"ts":"2026-06-12T00:00:00Z","seq":5,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2546 "\n",
2547 )
2548 .as_bytes(),
2549 );
2550 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 5);
2551 }
2552
2553 #[test]
2554 fn read_all_events_rejects_corrupt_middle_line() {
2555 // A newline-terminated garbage line FOLLOWED by another line is
2556 // interior corruption — a hard `CorruptEventLog`, never a silent skip.
2557 let tmp = TempDir::new().unwrap();
2558 let log = concat!(
2559 r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2560 "\n",
2561 "{not valid json at all\n",
2562 r#"{"ts":"2026-06-12T00:00:00Z","seq":3,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2563 "\n",
2564 );
2565 let paths = paths_with_events(&tmp, log.as_bytes());
2566 let err = read_all_events(&paths.events()).unwrap_err();
2567 match err {
2568 Error::CorruptEventLog { reason, .. } => {
2569 assert!(reason.contains("line 2"), "reason was: {reason}");
2570 }
2571 other => panic!("expected CorruptEventLog, got {other:?}"),
2572 }
2573 }
2574
2575 #[test]
2576 fn append_truncates_torn_tail_before_writing() {
2577 // A crash left a valid record then a torn (newline-less) partial
2578 // write. The next append must truncate the torn bytes BEFORE writing,
2579 // so the log never gains a `…torn…{"seq":N}` malformed line.
2580 let tmp = TempDir::new().unwrap();
2581 let mut bytes = Vec::new();
2582 bytes.extend_from_slice(
2583 br#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2584 );
2585 bytes.push(b'\n');
2586 bytes.extend_from_slice(br#"{"seq":2,"kind":"TORN_PARTIAL_NEVER_FLUSHED"#); // no newline
2587 let paths = paths_with_events(&tmp, &bytes);
2588
2589 // The torn tail is ignored for seq recovery (last complete seq = 1).
2590 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 1);
2591
2592 // `marker` is an unknown kind → reducer no-op, so the append succeeds
2593 // without any projection prerequisites.
2594 let r = append_and_apply_event(&paths, "marker", None, None, serde_json::json!({"x": 1}))
2595 .unwrap();
2596 assert_eq!(r.seq, 2, "seq continues from the last complete record");
2597
2598 let raw = std::fs::read(paths.events()).unwrap();
2599 assert!(
2600 raw.ends_with(b"\n"),
2601 "log must be newline-terminated after a clean append"
2602 );
2603 assert!(
2604 !String::from_utf8_lossy(&raw).contains("TORN_PARTIAL_NEVER_FLUSHED"),
2605 "the torn tail must be truncated away before the append"
2606 );
2607 let events = read_all_events(&paths.events()).unwrap();
2608 assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2]);
2609 }
2610
2611 #[test]
2612 fn append_truncates_all_torn_file_to_empty_then_writes_seq_1() {
2613 // The whole file is one torn (newline-less) partial write — no complete
2614 // record exists. truncate_torn_tail must cut it to empty, and the next
2615 // append starts a fresh seq 1.
2616 let tmp = TempDir::new().unwrap();
2617 let paths = paths_with_events(&tmp, br#"{"seq":1,"kind":"marker"#);
2618 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
2619
2620 let r = append_and_apply_event(&paths, "marker", None, None, json!({})).unwrap();
2621 assert_eq!(r.seq, 1);
2622 let events = read_all_events(&paths.events()).unwrap();
2623 assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
2624 }
2625
2626 #[test]
2627 fn truncate_torn_tail_cuts_partial_line_at_last_newline() {
2628 // The headline case (issue torn-write-truncate-tail): a complete
2629 // record followed by a torn (newline-less) partial write. Recovery
2630 // must cut the file back to the byte immediately after the last
2631 // complete record's trailing `\n` — the partial bytes are gone.
2632 let tmp = TempDir::new().unwrap();
2633 let complete = r#"{"ts":"2026-06-12T00:00:00Z","seq":5,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
2634 let mut bytes = Vec::new();
2635 bytes.extend_from_slice(complete.as_bytes());
2636 bytes.push(b'\n');
2637 let keep = bytes.len() as u64; // offset just past seq-5's newline
2638 bytes.extend_from_slice(br#"{"seq":6,"par"#); // torn mid-line, no newline
2639 let paths = paths_with_events(&tmp, &bytes);
2640
2641 truncate_torn_tail(&paths.events()).unwrap();
2642
2643 let raw = std::fs::read(paths.events()).unwrap();
2644 assert_eq!(
2645 raw.len() as u64,
2646 keep,
2647 "file must end at the offset after seq-5's newline"
2648 );
2649 assert!(raw.ends_with(b"\n"), "file is newline-terminated after cut");
2650 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 5);
2651 }
2652
2653 #[test]
2654 fn truncate_torn_tail_clean_file_is_noop() {
2655 // A file already ending in `\n` is the clean, common case: recovery
2656 // must leave every byte untouched (no rewrite, no length change).
2657 let tmp = TempDir::new().unwrap();
2658 let log = concat!(
2659 r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2660 "\n",
2661 );
2662 let paths = paths_with_events(&tmp, log.as_bytes());
2663
2664 truncate_torn_tail(&paths.events()).unwrap();
2665
2666 assert_eq!(
2667 std::fs::read(paths.events()).unwrap(),
2668 log.as_bytes(),
2669 "a clean, newline-terminated log must be left byte-for-byte intact"
2670 );
2671 }
2672
2673 #[test]
2674 fn truncate_torn_tail_zero_length_file_is_noop() {
2675 // An empty log has no tail to cut: recovery is a no-op and the file
2676 // stays empty.
2677 let tmp = TempDir::new().unwrap();
2678 let paths = paths_with_events(&tmp, b"");
2679 truncate_torn_tail(&paths.events()).unwrap();
2680 assert_eq!(std::fs::read(paths.events()).unwrap(), b"");
2681 }
2682
2683 #[test]
2684 fn truncate_torn_tail_missing_file_is_noop() {
2685 // No `events.jsonl` at all (a run that never appended): recovery must
2686 // not create the file or error.
2687 let tmp = TempDir::new().unwrap();
2688 let dir = tmp.path().join("run");
2689 std::fs::create_dir_all(&dir).unwrap();
2690 let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
2691 truncate_torn_tail(&paths.events()).unwrap();
2692 assert!(!paths.events().exists());
2693 }
2694
2695 #[test]
2696 fn truncate_torn_tail_single_complete_row_is_noop() {
2697 // Exactly one complete `\n`-terminated record and nothing else: the
2698 // last byte is already a newline, so there is no tail to cut.
2699 let tmp = TempDir::new().unwrap();
2700 let log = concat!(
2701 r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2702 "\n",
2703 );
2704 let paths = paths_with_events(&tmp, log.as_bytes());
2705 truncate_torn_tail(&paths.events()).unwrap();
2706 assert_eq!(std::fs::read(paths.events()).unwrap(), log.as_bytes());
2707 }
2708
2709 #[test]
2710 fn truncate_torn_tail_single_partial_row_truncates_to_zero() {
2711 // The whole file is one torn (newline-less) partial write with no
2712 // complete record ahead of it: there is nothing to keep, so recovery
2713 // truncates the file to zero length.
2714 let tmp = TempDir::new().unwrap();
2715 let paths = paths_with_events(&tmp, br#"{"seq":1,"kind":"marker"#);
2716 truncate_torn_tail(&paths.events()).unwrap();
2717 assert_eq!(
2718 std::fs::read(paths.events()).unwrap(),
2719 b"",
2720 "a file holding only a partial row must be cut to empty"
2721 );
2722 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
2723 }
2724
2725 #[test]
2726 fn quarantine_excises_corrupt_middle_line_and_recovers() {
2727 // A valid record, a newline-terminated garbage line, then another
2728 // valid record. Quarantine must rename the original aside, write a
2729 // recovered log holding only the two valid lines, and report the bad
2730 // line's byte offset.
2731 let tmp = TempDir::new().unwrap();
2732 let good1 = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
2733 let bad = "{not valid json at all";
2734 let good3 = r#"{"ts":"2026-06-12T00:00:00Z","seq":3,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
2735 let log = format!("{good1}\n{bad}\n{good3}\n");
2736 let paths = paths_with_events(&tmp, log.as_bytes());
2737
2738 // Strict replay chokes on the poison line beforehand.
2739 assert!(matches!(
2740 read_all_events(&paths.events()).unwrap_err(),
2741 Error::CorruptEventLog { .. }
2742 ));
2743
2744 let q = quarantine_corrupt_lines(&paths, "20260612T000000Z")
2745 .unwrap()
2746 .expect("a corrupt line was excised");
2747 // The bad line started at the byte after `good1\n`.
2748 assert_eq!(q.removed_byte_offsets, vec![(good1.len() + 1) as u64]);
2749 assert_eq!(
2750 q.backup_path.file_name().unwrap().to_str().unwrap(),
2751 "events.jsonl.corrupt-20260612T000000Z.bak"
2752 );
2753
2754 // The backup is the verbatim original; the recovered log now replays
2755 // strictly with only the two valid records.
2756 assert_eq!(std::fs::read(&q.backup_path).unwrap(), log.as_bytes());
2757 let events = read_all_events(&paths.events()).unwrap();
2758 assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
2759 }
2760
2761 #[test]
2762 fn quarantine_clean_log_is_noop() {
2763 // A log with no corruption must not be renamed or rewritten.
2764 let tmp = TempDir::new().unwrap();
2765 let log = concat!(
2766 r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2767 "\n",
2768 );
2769 let paths = paths_with_events(&tmp, log.as_bytes());
2770 assert!(quarantine_corrupt_lines(&paths, "20260612T000000Z")
2771 .unwrap()
2772 .is_none());
2773 // No backup created; original untouched.
2774 assert_eq!(std::fs::read(paths.events()).unwrap(), log.as_bytes());
2775 let bak = paths
2776 .events()
2777 .with_file_name("events.jsonl.corrupt-20260612T000000Z.bak");
2778 assert!(!bak.exists());
2779 }
2780
2781 #[test]
2782 fn quarantine_missing_log_is_none() {
2783 let tmp = TempDir::new().unwrap();
2784 let dir = tmp.path().join("run");
2785 std::fs::create_dir_all(&dir).unwrap();
2786 let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
2787 assert!(quarantine_corrupt_lines(&paths, "20260612T000000Z")
2788 .unwrap()
2789 .is_none());
2790 }
2791
2792 #[test]
2793 fn quarantine_preserves_torn_tail_and_excises_only_corruption() {
2794 // A valid record, a corrupt newline-terminated line, then a torn
2795 // (newline-less) final line. Only the corrupt middle line is excised;
2796 // the torn tail is retained verbatim (the readers tolerate it as an
2797 // in-flight partial write — excising it would change behavior).
2798 let tmp = TempDir::new().unwrap();
2799 let good = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
2800 let bad = "{garbage";
2801 let torn = r#"{"seq":2,"kind":"node.rep"#; // mid-write, no newline
2802 let mut log = Vec::new();
2803 log.extend_from_slice(format!("{good}\n{bad}\n{torn}").as_bytes());
2804 let paths = paths_with_events(&tmp, &log);
2805
2806 let q = quarantine_corrupt_lines(&paths, "20260612T000000Z")
2807 .unwrap()
2808 .expect("the corrupt middle line was excised");
2809 assert_eq!(q.removed_byte_offsets, vec![(good.len() + 1) as u64]);
2810
2811 let recovered = std::fs::read(paths.events()).unwrap();
2812 assert_eq!(recovered, format!("{good}\n{torn}").as_bytes());
2813 // The torn tail still recovers the last complete seq as 1.
2814 assert_eq!(recover_last_seq(&paths.events()).unwrap(), 1);
2815 }
2816
2817 #[test]
2818 fn find_prior_with_key_rejects_torn_middle_line() {
2819 // A newline-terminated garbage line FOLLOWED by another line: this
2820 // is interior corruption, not an in-flight tail. It must be a hard
2821 // error, never a silent skip — a skipped line could carry the very
2822 // key being looked up and let the caller double-append.
2823 let tmp = TempDir::new().unwrap();
2824 let log = concat!(
2825 r#"{"seq":1,"kind":"node.report","idempotency_key":"k0","node_id":"n-1","data":{}}"#,
2826 "\n",
2827 "{not valid json at all\n",
2828 r#"{"seq":3,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#,
2829 "\n",
2830 );
2831 let paths = paths_with_events(&tmp, log.as_bytes());
2832 let err = scan(&paths, "node.report", "k1").unwrap_err();
2833 match err {
2834 Error::CorruptEventLog { reason, .. } => {
2835 assert!(reason.contains("line 2"), "reason was: {reason}");
2836 assert!(reason.contains("last good seq 1"), "reason was: {reason}");
2837 }
2838 other => panic!("expected CorruptEventLog, got {other:?}"),
2839 }
2840 }
2841}