Skip to main content

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