Skip to main content

ossctl_core/release/
journal.rs

1//! Event-sourced release journal (ADR-0003).
2//!
3//! Append-only JSONL events under `git-common-dir/ossctl/releases/<run_id>/`,
4//! with an idempotent [`reduce`]r folding them into resumable [`RunState`]. The
5//! durable record `release resume`/`verify`/`show` read back.
6//!
7//! # The two halves
8//!
9//! - **The reducer** ([`reduce`] / [`apply`]) is a *pure* fold of `[JournalEvent]
10//!   → RunState`. It has no I/O and is the core testable unit: the same events
11//!   always fold to the same state, and re-applying a seen event changes nothing.
12//! - **The [`Journal`] handle** wires the reducer to durable storage through the
13//!   injected [`JournalStore`] / [`Clock`] / [`IdGen`] ports, enforcing the
14//!   append-then-apply atomicity discipline and holding the single-active-cut
15//!   lock for its lifetime.
16//!
17//! # Append-then-apply (ADR-0003 §2, from `octl-core`)
18//!
19//! Every mutation is: **(1)** fsync the event to `journal.jsonl` (durable),
20//! **(2)** apply it to the in-memory [`RunState`], **(3)** atomically rewrite the
21//! `manifest.json` cache. The journal is the single source of truth; the manifest
22//! is disposable and is always rebuilt by [`reduce`] on [`Journal::open`], so a
23//! crash *anywhere* in that sequence recovers cleanly — a durably-appended event
24//! is folded back in on the next open regardless of whether its manifest write
25//! landed.
26//!
27//! # Idempotency
28//!
29//! Replay idempotency — the property the crash-safety discipline needs — comes
30//! from two mechanisms working together:
31//!
32//! 1. **Watermark** — [`apply`] ignores any event whose `seq` is at or below the
33//!    already-applied high-water mark, so replaying the persisted log (or a seen
34//!    event) is a no-op. This is the "re-applying a seen event changes nothing"
35//!    guarantee.
36//! 2. **Structural** — the projection is built from keyed sets/maps, so folding a
37//!    fact about target `cargo` more than once yields the identical map.
38//!
39//! The log is **append-only facts**: [`Journal::append`] does not deduplicate on
40//! [`crate::protocol::journal::JournalEvent::idempotency_key`] (an earlier design
41//! did, which silently swallowed a legitimate `Failed`→`Ok` phase retry after a
42//! resume). Whether to *emit* an event is the coordinator's decision — and since
43//! the remote registry is ground truth (ADR-0003 §4), a resumed cut re-checks
44//! reality before re-emitting rather than trusting an append gate.
45//!
46//! # Terminal states
47//!
48//! Once the run reaches a terminal status ([`RunStatus::Completed`] or
49//! [`RunStatus::Abandoned`]) the reducer freezes: later events are ignored, so a
50//! corrupt or buggy log cannot un-abandon a run or resurrect a completed one.
51
52use std::io;
53use std::path::{Path, PathBuf};
54
55use crate::ports::{Clock, GitRepo, IdGen, JournalLock, JournalStore};
56use crate::protocol::journal::{
57    EventKind, JournalEvent, Phase, PhaseOutcome, PhaseRecord, RunState, RunStatus,
58    JOURNAL_SCHEMA_VERSION,
59};
60
61/// Resolved on-disk locations for a repo's release journals.
62///
63/// The releases root is `git-common-dir/ossctl/releases` (ADR-0003 §3), resolved
64/// via [`GitRepo::git_common_dir`] — **never** by concatenating `.git/` — or an
65/// explicit override for CI / debugging (`--journal-dir`). All per-run paths and
66/// the single-active-cut lock path are derived from it.
67#[derive(Debug, Clone)]
68pub struct JournalPaths {
69    releases_dir: PathBuf,
70}
71
72impl JournalPaths {
73    /// Build paths rooted at an explicit `releases_dir` (the `--journal-dir`
74    /// override, or a test root).
75    pub fn new(releases_dir: impl Into<PathBuf>) -> Self {
76        Self {
77            releases_dir: releases_dir.into(),
78        }
79    }
80
81    /// Resolve the releases root from git — `<git-common-dir>/ossctl/releases` —
82    /// unless `override_dir` is supplied, in which case it is used verbatim.
83    ///
84    /// # Errors
85    /// Propagates a [`GitRepo::git_common_dir`] failure (not a git repository, or
86    /// git unavailable) when no override is given.
87    pub fn from_git(git: &dyn GitRepo, override_dir: Option<&Path>) -> io::Result<Self> {
88        let releases_dir = match override_dir {
89            Some(dir) => dir.to_path_buf(),
90            None => git.git_common_dir()?.join("ossctl").join("releases"),
91        };
92        Ok(Self { releases_dir })
93    }
94
95    /// The releases root (`…/ossctl/releases`).
96    #[must_use]
97    pub fn releases_dir(&self) -> &Path {
98        &self.releases_dir
99    }
100
101    /// The single-active-cut lock path (`…/releases/.lock`).
102    #[must_use]
103    pub fn lock_file(&self) -> PathBuf {
104        self.releases_dir.join(".lock")
105    }
106
107    /// The per-run directory (`…/releases/<run_id>/`).
108    #[must_use]
109    pub fn run_dir(&self, run_id: &str) -> PathBuf {
110        self.releases_dir.join(run_id)
111    }
112
113    /// The append-only event log for a run (`…/<run_id>/journal.jsonl`).
114    #[must_use]
115    pub fn journal_file(&self, run_id: &str) -> PathBuf {
116        self.run_dir(run_id).join("journal.jsonl")
117    }
118
119    /// The materialized state cache for a run (`…/<run_id>/manifest.json`).
120    #[must_use]
121    pub fn manifest_file(&self, run_id: &str) -> PathBuf {
122        self.run_dir(run_id).join("manifest.json")
123    }
124}
125
126// ── The reducer (pure) ───────────────────────────────────────────────────────
127
128/// Fold an ordered event stream into the materialized [`RunState`] — the pure,
129/// I/O-free core of the journal.
130///
131/// Deterministic and total: the same events (in `seq` order) always produce the
132/// same state. Events are applied in ascending `seq` regardless of slice order,
133/// so a defensively re-sorted log reduces identically.
134#[must_use]
135pub fn reduce(events: &[JournalEvent]) -> RunState {
136    let mut ordered: Vec<&JournalEvent> = events.iter().collect();
137    ordered.sort_by_key(|e| e.seq);
138    let mut state = RunState::empty();
139    for ev in ordered {
140        apply(&mut state, ev);
141    }
142    state
143}
144
145/// Apply a single event to `state`, in place.
146///
147/// **Idempotent**: an event whose `seq` is at or below `state.applied_seq` is
148/// skipped (the high-water mark), and every mutation targets a keyed set/map, so
149/// re-applying a seen event leaves the state byte-identical. This is what makes
150/// append-then-apply crash-safe — replaying after a crash is a clean
151/// no-op-or-apply.
152///
153/// **Terminal-safe**: once the run is [`RunStatus::Completed`] or
154/// [`RunStatus::Abandoned`], further events are ignored (the watermark still
155/// advances so a later legitimate replay is consistent), so a corrupt log cannot
156/// mutate a run past its terminal fact.
157pub fn apply(state: &mut RunState, event: &JournalEvent) {
158    // Watermark: never fold an event already accounted for. `seq` starts at 1,
159    // so the first event (seq 1) always applies against the initial mark of 0.
160    if event.seq <= state.applied_seq {
161        return;
162    }
163    // Terminal states freeze the projection: nothing recorded after a run is
164    // completed or abandoned may change it. Advance the watermark so the state
165    // still reflects "everything up to here has been seen".
166    if matches!(state.status, RunStatus::Completed | RunStatus::Abandoned) {
167        state.applied_seq = event.seq;
168        return;
169    }
170    match &event.kind {
171        EventKind::RunCreated {
172            run_id,
173            plan_id,
174            version,
175            targets,
176        } => {
177            state.run_id.clone_from(run_id);
178            state.plan_id.clone_from(plan_id);
179            state.version.clone_from(version);
180            state.targets.clone_from(targets);
181            state.created_ts = event.ts;
182            state.status = RunStatus::InProgress;
183        }
184        EventKind::PhaseEntered { phase } => {
185            state.current_phase = Some(*phase);
186        }
187        EventKind::PhaseCompleted { phase, outcome } => {
188            upsert_phase(&mut state.phases, *phase, *outcome);
189            if state.current_phase == Some(*phase) {
190                state.current_phase = None;
191            }
192            // The final barrier completing OK is the run's completion signal. For a
193            // v2 cut that is the post-tag Dist barrier — it runs after Tag for every
194            // cut (a no-op when there is no post-tag target), so `Dist Ok` is the
195            // single, uniform completion signal (ADR-0002 §2, extended by
196            // `release-engine-cut-cargo-dist-flow`).
197            //
198            // A **v1** log has no Dist phase and ended at `Tag Ok`; that event
199            // carries `schema_version < 2`, so it still completes the run. Without
200            // this, a v1-completed run would reduce to InProgress — misreporting a
201            // finished release and making the manifest cache (which the old reducer
202            // wrote as Completed) disagree with a fresh reduce of the same log. A
203            // v2 `Tag Ok` (schema_version >= 2) does NOT complete: a Dist barrier
204            // always follows it, and completing early would freeze the projection
205            // before Dist runs.
206            let completes = match phase {
207                Phase::Dist => true,
208                Phase::Tag => event.schema_version < 2,
209                _ => false,
210            };
211            if completes && *outcome == PhaseOutcome::Ok {
212                state.status = RunStatus::Completed;
213            }
214        }
215        EventKind::TargetDryRun { target } => {
216            state.dry_run.insert(target.clone());
217        }
218        EventKind::TargetBuilt { target } => {
219            state.built.insert(target.clone());
220        }
221        EventKind::TargetPublished { target, receipt } => {
222            state.published.insert(target.clone(), receipt.clone());
223        }
224        EventKind::TargetCancelled { target, reason } => {
225            state.cancelled.insert(target.clone(), reason.clone());
226        }
227        EventKind::TargetDelegated { target, .. } => {
228            state.delegated.insert(target.clone());
229        }
230        EventKind::TagCreatedLocal { tag } => {
231            state.tags.entry(tag.clone()).or_default().created_local = true;
232        }
233        EventKind::TagPushedRemote { tag } => {
234            state.tags.entry(tag.clone()).or_default().pushed_remote = true;
235        }
236        EventKind::GithubReleaseCreated { tag, url } => {
237            let t = state.tags.entry(tag.clone()).or_default();
238            t.github_release = true;
239            t.github_release_url.clone_from(url);
240        }
241        EventKind::GithubReleaseDelegated { tag, .. } => {
242            state
243                .tags
244                .entry(tag.clone())
245                .or_default()
246                .github_release_delegated = true;
247        }
248        EventKind::RunAbandoned { reason } => {
249            state.status = RunStatus::Abandoned;
250            state.abandon_reason = Some(reason.clone());
251        }
252    }
253    state.applied_seq = event.seq;
254    state.updated_ts = event.ts;
255}
256
257/// Insert-or-update a completed-phase record, keeping `phases` sorted by phase
258/// order (so the manifest is deterministic).
259fn upsert_phase(phases: &mut Vec<PhaseRecord>, phase: Phase, outcome: PhaseOutcome) {
260    if let Some(rec) = phases.iter_mut().find(|r| r.phase == phase) {
261        rec.outcome = outcome;
262    } else {
263        phases.push(PhaseRecord { phase, outcome });
264        phases.sort_by_key(|r| r.phase);
265    }
266}
267
268// ── Reading events back (with forward tolerance) ─────────────────────────────
269
270/// Parse the JSONL journal at `path` into events, in ascending `seq`.
271///
272/// Forward-tolerant per ADR-0003 §2: additive fields are ignored (serde does not
273/// `deny_unknown_fields`), but an event whose `schema_version` is **newer** than
274/// this binary understands — or whose `kind` this binary does not know — is
275/// refused with an actionable error rather than silently mutating state.
276///
277/// # Errors
278/// [`io::ErrorKind::InvalidData`] on a malformed or too-new event line, or any
279/// I/O error surfaced by the store.
280pub fn read_events(store: &dyn JournalStore, path: &Path) -> io::Result<Vec<JournalEvent>> {
281    /// A minimal envelope parsed *before* the strict [`JournalEvent`] so a newer
282    /// schema (which may carry an unknown `kind` the enum cannot deserialize) is
283    /// refused with an actionable upgrade error rather than a generic parse error.
284    #[derive(serde::Deserialize)]
285    struct Envelope {
286        schema_version: u32,
287    }
288
289    let lines = store.read_lines(path)?;
290    let mut events = Vec::with_capacity(lines.len());
291    for (idx, line) in lines.iter().enumerate() {
292        let trimmed = line.trim();
293        if trimmed.is_empty() {
294            continue;
295        }
296        // Version gate first: a too-new event is refused before the strict enum
297        // parse (which would otherwise fail on an unknown `kind` with a generic
298        // message and never reach this check).
299        if let Ok(envelope) = serde_json::from_str::<Envelope>(trimmed) {
300            if envelope.schema_version > JOURNAL_SCHEMA_VERSION {
301                return Err(io::Error::new(
302                    io::ErrorKind::InvalidData,
303                    format!(
304                        "release journal {}: line {} has schema_version {} but this \
305                         ossctl understands at most {}; upgrade ossctl to resume this run",
306                        path.display(),
307                        idx + 1,
308                        envelope.schema_version,
309                        JOURNAL_SCHEMA_VERSION
310                    ),
311                ));
312            }
313        }
314        let event: JournalEvent = serde_json::from_str(trimmed).map_err(|e| {
315            io::Error::new(
316                io::ErrorKind::InvalidData,
317                format!(
318                    "release journal {}: line {} is not a recognized event \
319                     (corrupt, or written by a newer ossctl): {e}",
320                    path.display(),
321                    idx + 1
322                ),
323            )
324        })?;
325        events.push(event);
326    }
327    events.sort_by_key(|e| e.seq);
328    Ok(events)
329}
330
331/// Reject a `run_id` that is not a single safe path segment.
332///
333/// `run_id`s minted by `IdGen` are ULIDs, but `open`/`load_state` also take a
334/// caller-supplied id (a CLI argument), so a `../…`, an absolute path, or an
335/// empty string must never be joined into the releases root (path traversal).
336fn validate_run_id(run_id: &str) -> io::Result<()> {
337    let bad = run_id.is_empty()
338        || run_id == "."
339        || run_id == ".."
340        || run_id.contains('/')
341        || run_id.contains('\\')
342        || run_id.contains('\0');
343    if bad {
344        return Err(io::Error::new(
345            io::ErrorKind::InvalidInput,
346            format!("invalid run id {run_id:?}: must be a single path segment"),
347        ));
348    }
349    Ok(())
350}
351
352/// Read a run's current state **without** locking — the read-only path behind
353/// `release show`.
354///
355/// Prefers the atomically-written `manifest.json` cache (a torn-free read, and
356/// O(1) versus replaying the log); falls back to reducing the authoritative
357/// `journal.jsonl` when the manifest is absent, unparsable, or does not match the
358/// run. The cache can lag the log by the last event after a crash between append
359/// and manifest write, so this is *best-effort* for display — a
360/// correctness-critical read (resume/reconcile) must go through the locked
361/// [`Journal::open`], which always reduces the log.
362///
363/// Returns `Ok(None)` when the run has neither a usable manifest nor a journal.
364///
365/// # Errors
366/// An invalid `run_id`, or any store/parse error from [`read_events`].
367pub fn load_state(
368    store: &dyn JournalStore,
369    paths: &JournalPaths,
370    run_id: &str,
371) -> io::Result<Option<RunState>> {
372    validate_run_id(run_id)?;
373    // Fast path: the atomic manifest cache (never torn).
374    if let Some(bytes) = store.read(&paths.manifest_file(run_id))? {
375        if let Ok(state) = serde_json::from_slice::<RunState>(&bytes) {
376            if state.run_id == run_id && state.schema_version <= JOURNAL_SCHEMA_VERSION {
377                return Ok(Some(state));
378            }
379        }
380        // A corrupt/mismatched cache falls through to the authoritative rebuild.
381    }
382    let events = read_events(store, &paths.journal_file(run_id))?;
383    if events.is_empty() {
384        return Ok(None);
385    }
386    Ok(Some(reduce(&events)))
387}
388
389/// Authoritatively rebuild a run's [`RunState`] straight from its event log —
390/// the read-only path `release verify` reconciles from.
391///
392/// Unlike [`load_state`] this **ignores the `manifest.json` fast-path** and always
393/// reduces the authoritative `journal.jsonl`, so a manifest that lags the log by
394/// its last event (a crash between append and manifest write) cannot hide a
395/// just-published receipt from the reconcile. Unlike [`Journal::open`] it takes
396/// **no lock and writes nothing** — not even the manifest self-heal — so it is
397/// safe against a live run and leaves the journal byte-for-byte unchanged (the
398/// read-only guarantee `verify` promises).
399///
400/// Returns `Ok(None)` when the run has no journal.
401///
402/// # Errors
403/// An invalid `run_id` ([`io::ErrorKind::InvalidInput`]), or any store/parse error
404/// from [`read_events`] (a corrupt or too-new event line).
405pub fn read_run_state(
406    store: &dyn JournalStore,
407    paths: &JournalPaths,
408    run_id: &str,
409) -> io::Result<Option<RunState>> {
410    validate_run_id(run_id)?;
411    let events = read_events(store, &paths.journal_file(run_id))?;
412    if events.is_empty() {
413        return Ok(None);
414    }
415    Ok(Some(reduce(&events)))
416}
417
418/// Read a run's event log **and** its reduced state, read-only — the read path
419/// behind `release show`.
420///
421/// `release show` needs both halves: the ordered [`JournalEvent`] log (to stream
422/// as a live JSONL event window) *and* the folded [`RunState`] (to decide live
423/// vs. terminal and render the post-mortem summary). This is the write-free,
424/// unlocked twin of [`Journal::open`] that returns the log alongside the
425/// projection so the caller reduces once, not twice.
426///
427/// Like [`read_run_state`] it ignores the `manifest.json` fast path and reduces
428/// the authoritative `journal.jsonl`, so a manifest lagging the log by its last
429/// event cannot hide the newest fact from a live tail. Returns `Ok(None)` when
430/// the run has no journal.
431///
432/// # Errors
433/// An invalid `run_id` ([`io::ErrorKind::InvalidInput`]), or any store/parse
434/// error from [`read_events`].
435pub fn read_run(
436    store: &dyn JournalStore,
437    paths: &JournalPaths,
438    run_id: &str,
439) -> io::Result<Option<(Vec<JournalEvent>, RunState)>> {
440    validate_run_id(run_id)?;
441    let events = read_events(store, &paths.journal_file(run_id))?;
442    if events.is_empty() {
443        return Ok(None);
444    }
445    let state = reduce(&events);
446    Ok(Some((events, state)))
447}
448
449/// List the run ids present under the releases root — the enumeration behind
450/// `release list`.
451///
452/// Only entries that carry a non-empty `journal.jsonl` are returned, so the
453/// `.lock` file, atomic-write temp files, and any stray files/dirs the store may
454/// surface are excluded (a run is defined by having a journal).
455///
456/// # Errors
457/// Any store error listing the releases directory.
458pub fn list_runs(store: &dyn JournalStore, paths: &JournalPaths) -> io::Result<Vec<String>> {
459    let mut runs: Vec<String> = store
460        .list_dir(paths.releases_dir())?
461        .into_iter()
462        .filter(|name| validate_run_id(name).is_ok())
463        .filter(|name| {
464            // A real run has a non-empty journal; this also excludes temp files
465            // and empty/aborted run directories.
466            store
467                .read_lines(&paths.journal_file(name))
468                .is_ok_and(|lines| lines.iter().any(|l| !l.trim().is_empty()))
469        })
470        .collect();
471    runs.sort();
472    Ok(runs)
473}
474
475// ── The Journal handle (durable, locked) ─────────────────────────────────────
476
477/// A live, exclusively-locked handle to one release run's journal.
478///
479/// Holds the single-active-cut lock for its entire lifetime (dropping the handle
480/// releases it) and mediates every mutation through the append-then-apply
481/// discipline. Construct it with [`Journal::create`] (a new run) or
482/// [`Journal::open`] (resume an existing one).
483pub struct Journal<'a> {
484    store: &'a dyn JournalStore,
485    clock: &'a dyn Clock,
486    paths: JournalPaths,
487    run_id: String,
488    state: RunState,
489    /// The single-active-cut lock, released on drop. Held, never called.
490    _lock: Box<dyn JournalLock>,
491}
492
493impl<'a> Journal<'a> {
494    /// Create a brand-new run: take the single-active-cut lock, mint a `run_id`
495    /// via `idgen`, and record the `RunCreated` event (which is also persisted to
496    /// a fresh manifest).
497    ///
498    /// # Errors
499    /// [`io::ErrorKind::WouldBlock`] if another cut/resume holds the lock, plus
500    /// any store error appending the first event or writing the manifest.
501    pub fn create(
502        store: &'a dyn JournalStore,
503        clock: &'a dyn Clock,
504        idgen: &dyn IdGen,
505        paths: JournalPaths,
506        plan_id: String,
507        version: String,
508        targets: Vec<String>,
509    ) -> io::Result<Self> {
510        let lock = store.lock_exclusive(&paths.lock_file())?;
511        let run_id = idgen.new_id();
512        let mut journal = Self {
513            store,
514            clock,
515            paths,
516            run_id: run_id.clone(),
517            state: RunState::empty(),
518            _lock: lock,
519        };
520        journal.append(EventKind::RunCreated {
521            run_id,
522            plan_id,
523            version,
524            targets,
525        })?;
526        Ok(journal)
527    }
528
529    /// Resume an existing run: take the single-active-cut lock, rebuild state from
530    /// the journal (the source of truth), and best-effort re-persist the manifest
531    /// cache so it reflects the log even if a prior crash left it stale.
532    ///
533    /// A manifest-write failure here is **not** fatal: the manifest is disposable
534    /// and the in-memory state is already authoritative (rebuilt from the log), so
535    /// a transient cache-write error must not brick an otherwise-recoverable run.
536    ///
537    /// # Errors
538    /// [`io::ErrorKind::WouldBlock`] if the lock is held, [`io::ErrorKind::NotFound`]
539    /// if the run has no journal, [`io::ErrorKind::InvalidInput`] for a malformed
540    /// `run_id`, plus any store/parse error reading the log.
541    pub fn open(
542        store: &'a dyn JournalStore,
543        clock: &'a dyn Clock,
544        paths: JournalPaths,
545        run_id: &str,
546    ) -> io::Result<Self> {
547        validate_run_id(run_id)?;
548        let lock = store.lock_exclusive(&paths.lock_file())?;
549        let events = read_events(store, &paths.journal_file(run_id))?;
550        if events.is_empty() {
551            return Err(io::Error::new(
552                io::ErrorKind::NotFound,
553                format!("no release journal for run {run_id}"),
554            ));
555        }
556        let state = reduce(&events);
557        let journal = Self {
558            store,
559            clock,
560            paths,
561            run_id: run_id.to_string(),
562            state,
563            _lock: lock,
564        };
565        // Best-effort self-heal of the disposable manifest from the log.
566        let _ = journal.persist_manifest();
567        Ok(journal)
568    }
569
570    /// The run id.
571    #[must_use]
572    pub fn run_id(&self) -> &str {
573        &self.run_id
574    }
575
576    /// The current materialized state.
577    #[must_use]
578    pub fn state(&self) -> &RunState {
579        &self.state
580    }
581
582    /// The resolved paths this journal writes to.
583    #[must_use]
584    pub fn paths(&self) -> &JournalPaths {
585        &self.paths
586    }
587
588    /// Record a fact with append-then-apply atomicity, returning the updated
589    /// state.
590    ///
591    /// The sequence is strictly: **(1)** serialize and durably fsync the event to
592    /// the log (the source of truth), **(2)** apply it to the in-memory
593    /// projection, **(3)** best-effort rewrite the disposable manifest cache.
594    ///
595    /// Step 3 failing does **not** fail the append: once step 1 returns the event
596    /// is committed, so reporting `Err` would tempt the caller to retry an
597    /// already-committed fact. A stale manifest self-heals on the next append or
598    /// [`Journal::open`]. Only a step-1 failure is fatal, and it leaves the state
599    /// untouched.
600    ///
601    /// This is a low-level append of a raw [`EventKind`] (`seq`/`ts`/
602    /// `idempotency_key` are assigned here). It does **not** validate release
603    /// state-machine ordering — that policy belongs to the coordinator; the
604    /// reducer only guarantees replay-idempotency and terminal-state freezing.
605    ///
606    /// # Errors
607    /// A store error from the durable append (step 1), or an event that fails to
608    /// serialize.
609    pub fn append(&mut self, kind: EventKind) -> io::Result<&RunState> {
610        let event = JournalEvent {
611            schema_version: JOURNAL_SCHEMA_VERSION,
612            seq: self.state.applied_seq + 1,
613            ts: self.clock.now_unix(),
614            idempotency_key: kind.idempotency_key(),
615            kind,
616        };
617        let line = serde_json::to_string(&event).map_err(|e| {
618            io::Error::new(io::ErrorKind::InvalidData, format!("serialize event: {e}"))
619        })?;
620        // 1. Durable append FIRST (the event is the source of truth).
621        self.store
622            .append_line(&self.paths.journal_file(&self.run_id), &line)?;
623        // 2. Apply to the in-memory projection.
624        apply(&mut self.state, &event);
625        // 3. Best-effort rewrite of the disposable manifest cache.
626        let _ = self.persist_manifest();
627        Ok(&self.state)
628    }
629
630    /// Serialize the current state and atomically replace the manifest cache.
631    fn persist_manifest(&self) -> io::Result<()> {
632        let bytes = serde_json::to_vec_pretty(&self.state).map_err(|e| {
633            io::Error::new(
634                io::ErrorKind::InvalidData,
635                format!("serialize manifest: {e}"),
636            )
637        })?;
638        self.store
639            .write_atomic(&self.paths.manifest_file(&self.run_id), &bytes)
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use crate::protocol::journal::{PublishReceipt, TagState};
647    use std::cell::RefCell;
648    use std::collections::{HashMap, HashSet};
649    use std::rc::Rc;
650
651    // ── In-memory fakes for the ports ──────────────────────────────────────
652
653    #[derive(Default)]
654    struct StoreInner {
655        /// path → full byte contents (journal lines are stored joined by "\n").
656        files: HashMap<PathBuf, Vec<u8>>,
657        /// currently-held lock paths (single-active-cut simulation).
658        locked: HashSet<PathBuf>,
659        /// when set, the next `write_atomic` fails (crash-injection).
660        fail_next_atomic: bool,
661    }
662
663    #[derive(Clone, Default)]
664    struct FakeStore {
665        inner: Rc<RefCell<StoreInner>>,
666    }
667
668    impl FakeStore {
669        fn journal_lines(&self, path: &Path) -> Vec<String> {
670            self.inner
671                .borrow()
672                .files
673                .get(path)
674                .map(|b| {
675                    String::from_utf8_lossy(b)
676                        .lines()
677                        .map(str::to_string)
678                        .collect()
679                })
680                .unwrap_or_default()
681        }
682
683        fn arm_atomic_failure(&self) {
684            self.inner.borrow_mut().fail_next_atomic = true;
685        }
686    }
687
688    /// The lock guard: removes its path from `locked` on drop.
689    struct FakeLock {
690        inner: Rc<RefCell<StoreInner>>,
691        path: PathBuf,
692    }
693
694    impl JournalLock for FakeLock {}
695
696    impl Drop for FakeLock {
697        fn drop(&mut self) {
698            self.inner.borrow_mut().locked.remove(&self.path);
699        }
700    }
701
702    impl JournalStore for FakeStore {
703        fn lock_exclusive(&self, lock_path: &Path) -> io::Result<Box<dyn JournalLock>> {
704            let mut inner = self.inner.borrow_mut();
705            if inner.locked.contains(lock_path) {
706                return Err(io::Error::new(
707                    io::ErrorKind::WouldBlock,
708                    "another release cut holds the lock",
709                ));
710            }
711            inner.locked.insert(lock_path.to_path_buf());
712            Ok(Box::new(FakeLock {
713                inner: Rc::clone(&self.inner),
714                path: lock_path.to_path_buf(),
715            }))
716        }
717
718        fn append_line(&self, path: &Path, line: &str) -> io::Result<()> {
719            let mut inner = self.inner.borrow_mut();
720            let buf = inner.files.entry(path.to_path_buf()).or_default();
721            buf.extend_from_slice(line.as_bytes());
722            buf.push(b'\n');
723            Ok(())
724        }
725
726        fn read_lines(&self, path: &Path) -> io::Result<Vec<String>> {
727            Ok(self.journal_lines(path))
728        }
729
730        fn read(&self, path: &Path) -> io::Result<Option<Vec<u8>>> {
731            Ok(self.inner.borrow().files.get(path).cloned())
732        }
733
734        fn write_atomic(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
735            let mut inner = self.inner.borrow_mut();
736            if inner.fail_next_atomic {
737                inner.fail_next_atomic = false;
738                return Err(io::Error::other("injected atomic-write crash"));
739            }
740            inner.files.insert(path.to_path_buf(), bytes.to_vec());
741            Ok(())
742        }
743
744        fn list_dir(&self, dir: &Path) -> io::Result<Vec<String>> {
745            let inner = self.inner.borrow();
746            let mut names: HashSet<String> = HashSet::new();
747            for path in inner.files.keys() {
748                // Any file under `dir/<name>/…` contributes `<name>`.
749                if let Ok(rest) = path.strip_prefix(dir) {
750                    if let Some(first) = rest.components().next() {
751                        names.insert(first.as_os_str().to_string_lossy().into_owned());
752                    }
753                }
754            }
755            Ok(names.into_iter().collect())
756        }
757    }
758
759    struct FakeClock {
760        t: std::cell::Cell<u64>,
761    }
762    impl FakeClock {
763        fn at(t: u64) -> Self {
764            Self {
765                t: std::cell::Cell::new(t),
766            }
767        }
768    }
769    impl Clock for FakeClock {
770        fn now_unix(&self) -> u64 {
771            let now = self.t.get();
772            self.t.set(now + 1); // advance so successive events get distinct ts
773            now
774        }
775    }
776
777    struct FakeIdGen {
778        id: String,
779    }
780    impl IdGen for FakeIdGen {
781        fn new_id(&self) -> String {
782            self.id.clone()
783        }
784    }
785
786    struct FakeGit {
787        common_dir: PathBuf,
788    }
789    impl GitRepo for FakeGit {
790        fn head_commit(&self) -> io::Result<String> {
791            Ok("deadbeef".into())
792        }
793        fn is_work_tree(&self) -> bool {
794            true
795        }
796        fn shortlog(&self, _since: Option<&str>) -> io::Result<String> {
797            Ok(String::new())
798        }
799        fn tags(&self) -> io::Result<Vec<String>> {
800            Ok(Vec::new())
801        }
802        fn git_common_dir(&self) -> io::Result<PathBuf> {
803            Ok(self.common_dir.clone())
804        }
805    }
806
807    fn paths() -> JournalPaths {
808        JournalPaths::new("/repo/.git/ossctl/releases")
809    }
810
811    fn receipt(version: &str) -> PublishReceipt {
812        PublishReceipt {
813            ecosystem: "cargo".into(),
814            package: Some("ossctl".into()),
815            version: version.into(),
816            registry_url: Some("https://crates.io/crates/ossctl".into()),
817            digest: Some("sha256:abc".into()),
818        }
819    }
820
821    /// A representative full run's worth of events, seq 1..=N.
822    fn sample_events() -> Vec<JournalEvent> {
823        let kinds = vec![
824            EventKind::RunCreated {
825                run_id: "RUN01".into(),
826                plan_id: "plan-abc".into(),
827                version: "0.1.0".into(),
828                targets: vec!["cargo".into(), "npm".into()],
829            },
830            EventKind::PhaseEntered {
831                phase: Phase::DryRun,
832            },
833            EventKind::TargetDryRun {
834                target: "cargo".into(),
835            },
836            EventKind::PhaseCompleted {
837                phase: Phase::DryRun,
838                outcome: PhaseOutcome::Ok,
839            },
840            EventKind::PhaseEntered {
841                phase: Phase::Publish,
842            },
843            EventKind::TargetPublished {
844                target: "cargo".into(),
845                receipt: receipt("0.1.0"),
846            },
847        ];
848        kinds
849            .into_iter()
850            .enumerate()
851            .map(|(i, kind)| JournalEvent {
852                schema_version: JOURNAL_SCHEMA_VERSION,
853                seq: (i + 1) as u64,
854                ts: 1000 + i as u64,
855                idempotency_key: kind.idempotency_key(),
856                kind,
857            })
858            .collect()
859    }
860
861    // ── Path resolution ────────────────────────────────────────────────────
862
863    #[test]
864    fn paths_resolve_under_git_common_dir() {
865        let git = FakeGit {
866            common_dir: PathBuf::from("/repo/.git"),
867        };
868        let p = JournalPaths::from_git(&git, None).unwrap();
869        assert_eq!(p.releases_dir(), Path::new("/repo/.git/ossctl/releases"));
870        assert_eq!(
871            p.journal_file("RUN01"),
872            Path::new("/repo/.git/ossctl/releases/RUN01/journal.jsonl")
873        );
874        assert_eq!(
875            p.manifest_file("RUN01"),
876            Path::new("/repo/.git/ossctl/releases/RUN01/manifest.json")
877        );
878        assert_eq!(p.lock_file(), Path::new("/repo/.git/ossctl/releases/.lock"));
879    }
880
881    #[test]
882    fn path_override_wins_over_git() {
883        let git = FakeGit {
884            common_dir: PathBuf::from("/repo/.git"),
885        };
886        let p = JournalPaths::from_git(&git, Some(Path::new("/ci/journal"))).unwrap();
887        assert_eq!(p.releases_dir(), Path::new("/ci/journal"));
888    }
889
890    // ── Reducer: determinism + idempotency ─────────────────────────────────
891
892    #[test]
893    fn reduce_is_deterministic() {
894        let events = sample_events();
895        let a = reduce(&events);
896        let b = reduce(&events);
897        assert_eq!(a, b);
898        assert_eq!(a.run_id, "RUN01");
899        assert_eq!(a.plan_id, "plan-abc");
900        assert_eq!(a.targets, vec!["cargo".to_string(), "npm".to_string()]);
901        assert!(a.dry_run.contains("cargo"));
902        assert_eq!(a.published.get("cargo").unwrap().version, "0.1.0");
903        // DryRun completed → current phase cleared; Publish entered.
904        assert_eq!(a.current_phase, Some(Phase::Publish));
905        assert_eq!(a.applied_seq, 6);
906    }
907
908    #[test]
909    fn reduce_ignores_slice_order() {
910        let mut events = sample_events();
911        events.reverse();
912        let out = reduce(&events);
913        // Same result as in-order despite the reversed slice.
914        assert_eq!(out, reduce(&sample_events()));
915    }
916
917    #[test]
918    fn replaying_a_seen_event_is_a_no_op() {
919        let events = sample_events();
920        let mut state = reduce(&events);
921        let before = state.clone();
922        // Re-apply an already-folded event (seq below the watermark): no change.
923        apply(&mut state, &events[2]);
924        assert_eq!(state, before);
925        // Re-apply the whole stream on top: still no change (watermark holds).
926        for ev in &events {
927            apply(&mut state, ev);
928        }
929        assert_eq!(state, before);
930    }
931
932    #[test]
933    fn structural_idempotency_of_publish_and_tags() {
934        // Two publishes of the same target with distinct seq → one map entry, and
935        // the later receipt wins deterministically.
936        let mut state = RunState::empty();
937        let mk = |seq: u64, kind: EventKind| JournalEvent {
938            schema_version: JOURNAL_SCHEMA_VERSION,
939            seq,
940            ts: seq,
941            idempotency_key: kind.idempotency_key(),
942            kind,
943        };
944        apply(
945            &mut state,
946            &mk(
947                1,
948                EventKind::RunCreated {
949                    run_id: "R".into(),
950                    plan_id: "p".into(),
951                    version: "0.1.0".into(),
952                    targets: vec!["cargo".into()],
953                },
954            ),
955        );
956        apply(
957            &mut state,
958            &mk(
959                2,
960                EventKind::TargetPublished {
961                    target: "cargo".into(),
962                    receipt: receipt("0.1.0"),
963                },
964            ),
965        );
966        apply(
967            &mut state,
968            &mk(
969                3,
970                EventKind::TargetPublished {
971                    target: "cargo".into(),
972                    receipt: receipt("0.1.1"),
973                },
974            ),
975        );
976        assert_eq!(state.published.len(), 1);
977        assert_eq!(state.published.get("cargo").unwrap().version, "0.1.1");
978
979        apply(
980            &mut state,
981            &mk(
982                4,
983                EventKind::TagCreatedLocal {
984                    tag: "v0.1.1".into(),
985                },
986            ),
987        );
988        apply(
989            &mut state,
990            &mk(
991                5,
992                EventKind::TagPushedRemote {
993                    tag: "v0.1.1".into(),
994                },
995            ),
996        );
997        assert_eq!(
998            state.tags.get("v0.1.1"),
999            Some(&TagState {
1000                created_local: true,
1001                pushed_remote: true,
1002                github_release: false,
1003                github_release_url: None,
1004                github_release_delegated: false,
1005            })
1006        );
1007    }
1008
1009    #[test]
1010    fn github_release_delegation_reduces_to_the_tag_state_flag() {
1011        // A CI-delegated cut records `github_release_delegated` in place of
1012        // `github_release_created`: the reducer sets the delegation flag and leaves
1013        // `github_release` false (coordinator-release-vs-cargo-dist-ownership).
1014        let mut state = RunState::empty();
1015        let mk = |seq: u64, kind: EventKind| JournalEvent {
1016            schema_version: JOURNAL_SCHEMA_VERSION,
1017            seq,
1018            ts: seq,
1019            idempotency_key: kind.idempotency_key(),
1020            kind,
1021        };
1022        apply(
1023            &mut state,
1024            &mk(
1025                1,
1026                EventKind::TagCreatedLocal {
1027                    tag: "v1.0.0".into(),
1028                },
1029            ),
1030        );
1031        apply(
1032            &mut state,
1033            &mk(
1034                2,
1035                EventKind::TagPushedRemote {
1036                    tag: "v1.0.0".into(),
1037                },
1038            ),
1039        );
1040        apply(
1041            &mut state,
1042            &mk(
1043                3,
1044                EventKind::GithubReleaseDelegated {
1045                    tag: "v1.0.0".into(),
1046                    delegated_to: "cargo-dist".into(),
1047                },
1048            ),
1049        );
1050        assert_eq!(
1051            state.tags.get("v1.0.0"),
1052            Some(&TagState {
1053                created_local: true,
1054                pushed_remote: true,
1055                github_release: false,
1056                github_release_url: None,
1057                github_release_delegated: true,
1058            })
1059        );
1060    }
1061
1062    #[test]
1063    fn dist_phase_ok_completes_the_run_tag_ok_alone_does_not() {
1064        let mut state = reduce(&sample_events());
1065        assert_eq!(state.status, RunStatus::InProgress);
1066        let mut seq = state.applied_seq;
1067        let mut push = |state: &mut RunState, phase: Phase| {
1068            seq += 1;
1069            apply(
1070                state,
1071                &JournalEvent {
1072                    schema_version: JOURNAL_SCHEMA_VERSION,
1073                    seq,
1074                    ts: 9000 + seq,
1075                    idempotency_key: format!("phase_completed:{}", phase.as_str()),
1076                    kind: EventKind::PhaseCompleted {
1077                        phase,
1078                        outcome: PhaseOutcome::Ok,
1079                    },
1080                },
1081            );
1082        };
1083        // A v2 Tag `Ok` no longer completes the run — the post-tag Dist barrier does.
1084        push(&mut state, Phase::Tag);
1085        assert_eq!(state.status, RunStatus::InProgress);
1086        push(&mut state, Phase::Dist);
1087        assert_eq!(state.status, RunStatus::Completed);
1088    }
1089
1090    #[test]
1091    fn a_v1_tag_ok_completes_the_run_for_backward_compat() {
1092        // A v1 journal (schema_version 1, no Dist phase) ended at `Tag Ok`. The
1093        // reducer must still read it as Completed, or an upgraded binary would
1094        // misreport a finished release as InProgress (and disagree with the manifest
1095        // cache the old reducer wrote as Completed).
1096        let mut state = reduce(&sample_events());
1097        assert_eq!(state.status, RunStatus::InProgress);
1098        let seq = state.applied_seq + 1;
1099        apply(
1100            &mut state,
1101            &JournalEvent {
1102                schema_version: 1,
1103                seq,
1104                ts: 9000,
1105                idempotency_key: "phase_completed:tag".into(),
1106                kind: EventKind::PhaseCompleted {
1107                    phase: Phase::Tag,
1108                    outcome: PhaseOutcome::Ok,
1109                },
1110            },
1111        );
1112        assert_eq!(state.status, RunStatus::Completed);
1113    }
1114
1115    #[test]
1116    fn run_abandoned_is_terminal_with_reason() {
1117        let mut state = reduce(&sample_events());
1118        let seq = state.applied_seq + 1;
1119        apply(
1120            &mut state,
1121            &JournalEvent {
1122                schema_version: JOURNAL_SCHEMA_VERSION,
1123                seq,
1124                ts: 9000,
1125                idempotency_key: "run_abandoned".into(),
1126                kind: EventKind::RunAbandoned {
1127                    reason: "OTP timeout".into(),
1128                },
1129            },
1130        );
1131        assert_eq!(state.status, RunStatus::Abandoned);
1132        assert_eq!(state.abandon_reason.as_deref(), Some("OTP timeout"));
1133    }
1134
1135    // ── Journal handle: create / append / manifest round-trip ──────────────
1136
1137    #[test]
1138    fn create_writes_run_created_and_manifest() {
1139        let store = FakeStore::default();
1140        let clock = FakeClock::at(1000);
1141        let idgen = FakeIdGen { id: "RUN01".into() };
1142        let journal = Journal::create(
1143            &store,
1144            &clock,
1145            &idgen,
1146            paths(),
1147            "plan-abc".into(),
1148            "0.1.0".into(),
1149            vec!["cargo".into()],
1150        )
1151        .unwrap();
1152        assert_eq!(journal.run_id(), "RUN01");
1153        assert_eq!(journal.state().run_id, "RUN01");
1154        assert_eq!(journal.state().applied_seq, 1);
1155
1156        // One durable event line was written…
1157        let lines = store.journal_lines(&paths().journal_file("RUN01"));
1158        assert_eq!(lines.len(), 1);
1159        // …and the manifest cache deserializes back to the same state.
1160        let manifest = store
1161            .inner
1162            .borrow()
1163            .files
1164            .get(&paths().manifest_file("RUN01"))
1165            .cloned()
1166            .unwrap();
1167        let loaded: RunState = serde_json::from_slice(&manifest).unwrap();
1168        assert_eq!(&loaded, journal.state());
1169    }
1170
1171    #[test]
1172    fn append_records_facts_and_a_failed_phase_can_later_complete_ok() {
1173        // Regression: an earlier design deduped appends by idempotency key, which
1174        // silently swallowed a Failed→Ok phase retry after a resume. The log now
1175        // records both facts and the reducer's upsert lands the final outcome.
1176        let store = FakeStore::default();
1177        let clock = FakeClock::at(1000);
1178        let idgen = FakeIdGen { id: "RUN01".into() };
1179        let mut journal = Journal::create(
1180            &store,
1181            &clock,
1182            &idgen,
1183            paths(),
1184            "plan-abc".into(),
1185            "0.1.0".into(),
1186            vec!["cargo".into()],
1187        )
1188        .unwrap();
1189        journal
1190            .append(EventKind::PhaseCompleted {
1191                phase: Phase::Publish,
1192                outcome: PhaseOutcome::Failed,
1193            })
1194            .unwrap();
1195        journal
1196            .append(EventKind::PhaseCompleted {
1197                phase: Phase::Publish,
1198                outcome: PhaseOutcome::Ok,
1199            })
1200            .unwrap();
1201        // Both facts are durable (RunCreated + two PhaseCompleted).
1202        let lines = store.journal_lines(&paths().journal_file("RUN01"));
1203        assert_eq!(lines.len(), 3);
1204        // The projection reflects the final Ok outcome, once.
1205        let publish = journal
1206            .state()
1207            .phases
1208            .iter()
1209            .filter(|r| r.phase == Phase::Publish)
1210            .collect::<Vec<_>>();
1211        assert_eq!(publish.len(), 1);
1212        assert_eq!(publish[0].outcome, PhaseOutcome::Ok);
1213    }
1214
1215    #[test]
1216    fn terminal_state_freezes_further_events() {
1217        // After abandonment, a stray later event must not mutate the projection.
1218        let mut state = reduce(&sample_events());
1219        let published_before = state.published.clone();
1220        let mut seq = state.applied_seq;
1221        let mut next = |kind: EventKind| {
1222            seq += 1;
1223            JournalEvent {
1224                schema_version: JOURNAL_SCHEMA_VERSION,
1225                seq,
1226                ts: 9000 + seq,
1227                idempotency_key: kind.idempotency_key(),
1228                kind,
1229            }
1230        };
1231        apply(
1232            &mut state,
1233            &next(EventKind::RunAbandoned {
1234                reason: "aborted".into(),
1235            }),
1236        );
1237        assert_eq!(state.status, RunStatus::Abandoned);
1238        // A publish recorded after abandonment is ignored.
1239        apply(
1240            &mut state,
1241            &next(EventKind::TargetPublished {
1242                target: "npm".into(),
1243                receipt: receipt("9.9.9"),
1244            }),
1245        );
1246        assert_eq!(state.status, RunStatus::Abandoned);
1247        assert_eq!(state.published, published_before);
1248        assert!(!state.published.contains_key("npm"));
1249    }
1250
1251    // ── Append-then-apply crash safety ─────────────────────────────────────
1252
1253    #[test]
1254    fn event_survives_a_manifest_write_crash() {
1255        let store = FakeStore::default();
1256        let clock = FakeClock::at(1000);
1257        let idgen = FakeIdGen { id: "RUN01".into() };
1258        let mut journal = Journal::create(
1259            &store,
1260            &clock,
1261            &idgen,
1262            paths(),
1263            "plan-abc".into(),
1264            "0.1.0".into(),
1265            vec!["cargo".into()],
1266        )
1267        .unwrap();
1268        // Arm a crash on the NEXT manifest write, then append: the durable event
1269        // lands first and the append still SUCCEEDS (the manifest is disposable),
1270        // leaving only a stale cache.
1271        store.arm_atomic_failure();
1272        journal
1273            .append(EventKind::TargetPublished {
1274                target: "cargo".into(),
1275                receipt: receipt("0.1.0"),
1276            })
1277            .unwrap();
1278        drop(journal); // release the lock
1279
1280        // Reopen: state is rebuilt from the authoritative journal, so the event's
1281        // effect is present despite the manifest write having crashed.
1282        let clock2 = FakeClock::at(2000);
1283        let reopened = Journal::open(&store, &clock2, paths(), "RUN01").unwrap();
1284        assert!(reopened.state().published.contains_key("cargo"));
1285        assert_eq!(reopened.state().applied_seq, 2);
1286        // The self-heal on open rewrote the manifest to match the log.
1287        let manifest = store
1288            .inner
1289            .borrow()
1290            .files
1291            .get(&paths().manifest_file("RUN01"))
1292            .cloned()
1293            .unwrap();
1294        let loaded: RunState = serde_json::from_slice(&manifest).unwrap();
1295        assert_eq!(&loaded, reopened.state());
1296    }
1297
1298    #[test]
1299    fn open_reduces_from_journal_when_manifest_absent() {
1300        // Simulate a wiped manifest but intact journal: write only event lines.
1301        let store = FakeStore::default();
1302        for ev in sample_events() {
1303            let line = serde_json::to_string(&ev).unwrap();
1304            store
1305                .append_line(&paths().journal_file("RUN01"), &line)
1306                .unwrap();
1307        }
1308        let clock = FakeClock::at(1000);
1309        let journal = Journal::open(&store, &clock, paths(), "RUN01").unwrap();
1310        assert_eq!(journal.state(), &reduce(&sample_events()));
1311    }
1312
1313    // ── flock mutual exclusion ─────────────────────────────────────────────
1314
1315    #[test]
1316    fn second_create_fails_while_lock_is_held() {
1317        let store = FakeStore::default();
1318        let clock = FakeClock::at(1000);
1319        let idgen = FakeIdGen { id: "RUN01".into() };
1320        let held = Journal::create(
1321            &store,
1322            &clock,
1323            &idgen,
1324            paths(),
1325            "plan-abc".into(),
1326            "0.1.0".into(),
1327            vec!["cargo".into()],
1328        )
1329        .unwrap();
1330
1331        // A concurrent cut must fail fast while the first holds the lock.
1332        let clock2 = FakeClock::at(2000);
1333        let idgen2 = FakeIdGen { id: "RUN02".into() };
1334        // `Journal` is not `Debug` (it holds trait-object ports), so inspect the
1335        // error without `unwrap_err`.
1336        let result = Journal::create(
1337            &store,
1338            &clock2,
1339            &idgen2,
1340            paths(),
1341            "plan-def".into(),
1342            "0.1.0".into(),
1343            vec!["cargo".into()],
1344        );
1345        let err = result.err().expect("concurrent create must fail");
1346        assert_eq!(err.kind(), io::ErrorKind::WouldBlock);
1347
1348        // Once the first handle drops, the lock frees and a new cut succeeds.
1349        drop(held);
1350        let clock3 = FakeClock::at(3000);
1351        let idgen3 = FakeIdGen { id: "RUN02".into() };
1352        assert!(Journal::create(
1353            &store,
1354            &clock3,
1355            &idgen3,
1356            paths(),
1357            "plan-def".into(),
1358            "0.1.0".into(),
1359            vec!["cargo".into()],
1360        )
1361        .is_ok());
1362    }
1363
1364    // ── read-only helpers ──────────────────────────────────────────────────
1365
1366    #[test]
1367    fn load_state_is_read_only_and_takes_no_lock() {
1368        let store = FakeStore::default();
1369        let clock = FakeClock::at(1000);
1370        let idgen = FakeIdGen { id: "RUN01".into() };
1371        let journal = Journal::create(
1372            &store,
1373            &clock,
1374            &idgen,
1375            paths(),
1376            "plan-abc".into(),
1377            "0.1.0".into(),
1378            vec!["cargo".into()],
1379        )
1380        .unwrap();
1381        // The lock is still held by `journal`; load_state must not need it.
1382        let loaded = load_state(&store, &paths(), "RUN01").unwrap().unwrap();
1383        assert_eq!(&loaded, journal.state());
1384        assert!(load_state(&store, &paths(), "MISSING").unwrap().is_none());
1385    }
1386
1387    #[test]
1388    fn load_state_prefers_manifest_but_falls_back_to_journal() {
1389        let store = FakeStore::default();
1390        let clock = FakeClock::at(1000);
1391        let idgen = FakeIdGen { id: "RUN01".into() };
1392        let journal = Journal::create(
1393            &store,
1394            &clock,
1395            &idgen,
1396            paths(),
1397            "plan-abc".into(),
1398            "0.1.0".into(),
1399            vec!["cargo".into()],
1400        )
1401        .unwrap();
1402        let expected = journal.state().clone();
1403        drop(journal);
1404
1405        // Fast path: manifest present → returned as-is.
1406        assert_eq!(
1407            load_state(&store, &paths(), "RUN01").unwrap(),
1408            Some(expected.clone())
1409        );
1410
1411        // Corrupt the manifest → falls back to reducing the authoritative journal.
1412        store
1413            .write_atomic(&paths().manifest_file("RUN01"), b"{not json")
1414            .unwrap();
1415        assert_eq!(
1416            load_state(&store, &paths(), "RUN01").unwrap(),
1417            Some(expected)
1418        );
1419    }
1420
1421    #[test]
1422    fn read_run_state_reduces_from_journal_without_writing() {
1423        // Write only event lines (no manifest), simulating the authoritative log.
1424        let store = FakeStore::default();
1425        for ev in sample_events() {
1426            let line = serde_json::to_string(&ev).unwrap();
1427            store
1428                .append_line(&paths().journal_file("RUN01"), &line)
1429                .unwrap();
1430        }
1431        // Snapshot every file before the read so we can prove nothing was written.
1432        let files_before = store.inner.borrow().files.clone();
1433
1434        let state = read_run_state(&store, &paths(), "RUN01").unwrap().unwrap();
1435        assert_eq!(state, reduce(&sample_events()));
1436
1437        // Read-only: no manifest self-heal, no lock file, no new bytes anywhere.
1438        assert_eq!(
1439            store.inner.borrow().files,
1440            files_before,
1441            "read_run_state must not write anything"
1442        );
1443        assert!(
1444            store.inner.borrow().locked.is_empty(),
1445            "read_run_state must not take the lock"
1446        );
1447        assert!(
1448            !store
1449                .inner
1450                .borrow()
1451                .files
1452                .contains_key(&paths().manifest_file("RUN01")),
1453            "read_run_state must not materialize a manifest"
1454        );
1455
1456        // A run with no journal is a clean None, and a bad id is rejected.
1457        assert!(read_run_state(&store, &paths(), "MISSING")
1458            .unwrap()
1459            .is_none());
1460        assert_eq!(
1461            read_run_state(&store, &paths(), "../escape")
1462                .unwrap_err()
1463                .kind(),
1464            io::ErrorKind::InvalidInput
1465        );
1466    }
1467
1468    #[test]
1469    fn read_run_returns_events_and_reduced_state() {
1470        // `release show` reads the log and its projection together, read-only.
1471        let store = FakeStore::default();
1472        for ev in sample_events() {
1473            let line = serde_json::to_string(&ev).unwrap();
1474            store
1475                .append_line(&paths().journal_file("RUN01"), &line)
1476                .unwrap();
1477        }
1478        let files_before = store.inner.borrow().files.clone();
1479
1480        let (events, state) = read_run(&store, &paths(), "RUN01").unwrap().unwrap();
1481        assert_eq!(events, sample_events());
1482        assert_eq!(state, reduce(&sample_events()));
1483
1484        // Read-only: no manifest self-heal, no lock, no new bytes.
1485        assert_eq!(store.inner.borrow().files, files_before);
1486        assert!(store.inner.borrow().locked.is_empty());
1487
1488        // A missing run is a clean None; a traversal id is rejected.
1489        assert!(read_run(&store, &paths(), "MISSING").unwrap().is_none());
1490        assert_eq!(
1491            read_run(&store, &paths(), "../escape").unwrap_err().kind(),
1492            io::ErrorKind::InvalidInput
1493        );
1494    }
1495
1496    #[test]
1497    fn read_run_state_ignores_a_stale_manifest_fast_path() {
1498        // A manifest that lags the log must NOT shadow the authoritative reduce —
1499        // this is exactly the crash window `verify` must see through.
1500        let store = FakeStore::default();
1501        for ev in sample_events() {
1502            let line = serde_json::to_string(&ev).unwrap();
1503            store
1504                .append_line(&paths().journal_file("RUN01"), &line)
1505                .unwrap();
1506        }
1507        // Plant a deliberately-wrong manifest cache.
1508        let mut stale = RunState::empty();
1509        stale.run_id = "RUN01".into();
1510        stale.plan_id = "STALE".into();
1511        store
1512            .write_atomic(
1513                &paths().manifest_file("RUN01"),
1514                &serde_json::to_vec(&stale).unwrap(),
1515            )
1516            .unwrap();
1517
1518        // load_state trusts the (stale) manifest; read_run_state reduces the log.
1519        assert_eq!(
1520            load_state(&store, &paths(), "RUN01")
1521                .unwrap()
1522                .unwrap()
1523                .plan_id,
1524            "STALE"
1525        );
1526        assert_eq!(
1527            read_run_state(&store, &paths(), "RUN01")
1528                .unwrap()
1529                .unwrap()
1530                .plan_id,
1531            "plan-abc",
1532        );
1533    }
1534
1535    #[test]
1536    fn run_id_validation_rejects_path_traversal() {
1537        let store = FakeStore::default();
1538        let clock = FakeClock::at(1000);
1539        for bad in ["..", "a/b", "", ".", "x/../y"] {
1540            assert_eq!(
1541                load_state(&store, &paths(), bad).unwrap_err().kind(),
1542                io::ErrorKind::InvalidInput,
1543                "load_state must reject run id {bad:?}"
1544            );
1545            let err = Journal::open(&store, &clock, paths(), bad).err().unwrap();
1546            assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1547        }
1548    }
1549
1550    #[test]
1551    fn list_runs_excludes_lock_and_stray_files() {
1552        let store = FakeStore::default();
1553        let clock = FakeClock::at(1000);
1554        for id in ["RUN01", "RUN02"] {
1555            let idgen = FakeIdGen { id: id.into() };
1556            let _j = Journal::create(
1557                &store,
1558                &clock,
1559                &idgen,
1560                paths(),
1561                "plan".into(),
1562                "0.1.0".into(),
1563                vec!["cargo".into()],
1564            )
1565            .unwrap();
1566        }
1567        // A leftover atomic-write temp file and a stray top-level file must not be
1568        // reported as runs (only entries with a non-empty journal count).
1569        store
1570            .write_atomic(&paths().releases_dir().join("journal.jsonl.tmp"), b"x")
1571            .unwrap();
1572        store
1573            .write_atomic(&paths().releases_dir().join(".lock"), b"")
1574            .unwrap();
1575        let runs = list_runs(&store, &paths()).unwrap();
1576        assert_eq!(runs, vec!["RUN01".to_string(), "RUN02".to_string()]);
1577    }
1578
1579    // ── forward tolerance ──────────────────────────────────────────────────
1580
1581    #[test]
1582    fn read_events_refuses_a_too_new_schema_version() {
1583        let store = FakeStore::default();
1584        let mut ev = sample_events()[0].clone();
1585        ev.schema_version = JOURNAL_SCHEMA_VERSION + 1;
1586        let line = serde_json::to_string(&ev).unwrap();
1587        store
1588            .append_line(&paths().journal_file("RUN01"), &line)
1589            .unwrap();
1590        let err = read_events(&store, &paths().journal_file("RUN01")).unwrap_err();
1591        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1592    }
1593
1594    #[test]
1595    fn read_events_tolerates_unknown_additive_fields() {
1596        // An extra top-level field a newer ossctl might add is ignored, not fatal.
1597        let store = FakeStore::default();
1598        let line = r#"{"schema_version":1,"seq":1,"ts":1000,"idempotency_key":"run_created","kind":"run_created","run_id":"R","plan_id":"p","version":"0.1.0","targets":[],"future_field":42}"#;
1599        store
1600            .append_line(&paths().journal_file("RUN01"), line)
1601            .unwrap();
1602        let events = read_events(&store, &paths().journal_file("RUN01")).unwrap();
1603        assert_eq!(events.len(), 1);
1604        assert_eq!(events[0].seq, 1);
1605    }
1606
1607    #[test]
1608    fn run_status_as_str_matches_serde() {
1609        for s in [
1610            RunStatus::InProgress,
1611            RunStatus::Completed,
1612            RunStatus::Abandoned,
1613        ] {
1614            assert_eq!(
1615                serde_json::to_value(s).unwrap(),
1616                serde_json::Value::String(s.as_str().to_string()),
1617                "as_str() drifted from serde for {s:?}"
1618            );
1619        }
1620    }
1621
1622    #[test]
1623    fn read_events_skips_blank_lines() {
1624        let store = FakeStore::default();
1625        store
1626            .append_line(&paths().journal_file("RUN01"), "")
1627            .unwrap();
1628        assert!(read_events(&store, &paths().journal_file("RUN01"))
1629            .unwrap()
1630            .is_empty());
1631    }
1632}