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