Skip to main content

prikk_store/
active.rs

1//! Active-session commit helpers.
2//!
3//! This module is the narrow boundary between higher-level commit construction and the
4//! durable active WAL. It owns lock acquisition for the default active session and appends only
5//! already-constructed, signed patch envelopes. It also owns the local ref-name metadata that makes a
6//! non-empty active WAL unambiguously belong to one target ref.
7//!
8//! RFC 102 Stage 5, design-v1.md §14.5/§14.6: the ref-name metadata file is pre-allocated at `init`
9//! (`layout.rs`) and never removed again -- "cleared" now means truncated to empty, "set" means
10//! truncated-then-appended, both `atomic_replace`-free. `write_active_ref_metadata` truncates
11//! internally rather than trusting caller discipline: it is `pub` API, and a bare append would let a
12//! second call silently concatenate two ref names into one file rather than replacing it.
13
14use prikk_error::{PrikkError, Result};
15use prikk_object::ObjectEnvelope;
16
17use crate::fsutil::{append_file_required, read_file_if_exists, truncate_file_empty_required};
18use crate::layout::RepositoryLayout;
19use crate::lock::ActiveLock;
20use crate::refs::{ensure_no_incomplete_publication, validate_local_branch_ref};
21use crate::wal::Wal;
22
23/// Result of appending a patch envelope to the active session.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ActiveCommitResult {
26    /// WAL sequence assigned to the appended patch envelope.
27    pub wal_sequence: u64,
28}
29
30/// Active-WAL ref metadata read result.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ActiveRefMetadata {
33    /// Metadata file is absent.
34    Missing,
35    /// Metadata file contains a valid canonical local branch ref.
36    Valid(String),
37    /// Metadata file exists but is malformed or not a valid local branch ref.
38    Invalid(String),
39}
40
41/// Default active-session handle.
42#[derive(Debug, Clone)]
43pub struct ActiveSession {
44    layout: RepositoryLayout,
45}
46
47impl ActiveSession {
48    /// Create an active-session handle for a repository layout.
49    #[must_use]
50    pub fn new(layout: RepositoryLayout) -> Self {
51        Self { layout }
52    }
53
54    /// Append one signed patch envelope while holding the active-session lock. `active_patch_limit`
55    /// is DC-57's hard block (NFR-PERF-02): once the active WAL already holds this many patches, no
56    /// more may be appended — see `node_authoring.rs::author_inner`'s identical check, the one other
57    /// authoring path this definition must also hold for.
58    pub fn append_patch(
59        &self,
60        envelope: &ObjectEnvelope,
61        active_patch_limit: usize,
62    ) -> Result<ActiveCommitResult> {
63        self.layout.require_current_format()?;
64        let _lock = ActiveLock::acquire(&self.layout)?;
65        ensure_no_incomplete_publication(&self.layout)?;
66        let wal = Wal::for_layout(&self.layout);
67        let replay = wal.replay()?;
68        if replay.trailing_partial_bytes != 0 {
69            return Err(PrikkError::Integrity(format!(
70                "active WAL has {} trailing partial bytes; run doctor before appending",
71                replay.trailing_partial_bytes
72            )));
73        }
74        // RFC 102 Stage 2: `replay.records` below (the active-patch-limit count, and the
75        // empty-vs-non-empty branch) silently omits a damaged record rather than erroring now.
76        if replay.has_item_failure() {
77            return Err(PrikkError::Integrity(
78                "active WAL has a damaged record; run doctor before appending".to_string(),
79            ));
80        }
81        if crate::worktree_patch::active_patch_limit_exceeded(
82            replay.records.len(),
83            active_patch_limit,
84        ) {
85            return Err(PrikkError::LockConflict(format!(
86                "active WAL has {} queued patches, at or above the configured limit \
87                 ({active_patch_limit}); run doctor or seal before appending again",
88                replay.records.len()
89            )));
90        }
91        if replay.records.is_empty() {
92            prepare_empty_active_ref_for_append(&self.layout, "heads/main")?;
93        } else {
94            // DC-66: a non-empty active WAL now queues rather than refusing outright; ownership must
95            // still be unambiguous — see `node_authoring.rs::author_inner`'s identical guard change.
96            require_active_ref_for_non_empty_wal(&self.layout, "heads/main")?;
97        }
98        let wal_sequence = wal.append_patch(envelope)?;
99        Ok(ActiveCommitResult { wal_sequence })
100    }
101}
102
103/// Read active-WAL ref metadata without mutating it.
104pub fn read_active_ref_metadata(layout: &RepositoryLayout) -> Result<ActiveRefMetadata> {
105    let relative = layout.repository_relative(&layout.default_active_ref_name_path())?;
106    let Some(bytes) = read_file_if_exists(layout.repository_mutation_root(), &relative)? else {
107        return Ok(ActiveRefMetadata::Missing);
108    };
109    // RFC 102 Stage 5, design-v1.md §14.6: the file is pre-allocated at `init` and never removed, so
110    // "no active session" is now represented by empty content as well as (pre-migration) absence --
111    // both read as `Missing`. Empty content can only be the cleared state; a real ref name is never
112    // zero bytes (`validate_local_branch_ref` rejects an empty string).
113    if bytes.is_empty() {
114        return Ok(ActiveRefMetadata::Missing);
115    }
116    let text = match std::str::from_utf8(&bytes) {
117        Ok(text) => text,
118        Err(err) => {
119            return Ok(ActiveRefMetadata::Invalid(format!(
120                "active ref metadata is not UTF-8: {err}"
121            )));
122        }
123    };
124    match validate_local_branch_ref(text) {
125        Ok(canonical) => Ok(ActiveRefMetadata::Valid(canonical)),
126        Err(err) => Ok(ActiveRefMetadata::Invalid(err.to_string())),
127    }
128}
129
130/// Write active-WAL ref metadata, replacing whatever was there before. `pub` API, so the
131/// replace-semantics contract is enforced structurally rather than by caller discipline (design-v1.md
132/// §14.6's condition): truncates to empty, then appends the canonical ref name, so a second call can
133/// never concatenate two names into one file the way a bare append would.
134pub fn write_active_ref_metadata(layout: &RepositoryLayout, ref_name: &str) -> Result<String> {
135    layout.require_current_format()?;
136    let canonical = validate_local_branch_ref(ref_name)?;
137    let relative = layout.repository_relative(&layout.default_active_ref_name_path())?;
138    truncate_file_empty_required(layout.repository_mutation_root(), &relative)?;
139    append_file_required(
140        layout.repository_mutation_root(),
141        &relative,
142        canonical.as_bytes(),
143    )?;
144    Ok(canonical)
145}
146
147/// Clear active-WAL ref metadata and fsync the active-session directory. Returns whether there was
148/// non-empty content to clear (the pre-migration "did a file exist to remove" contract, now answered
149/// by content rather than presence -- the file itself is permanent from `init` onward).
150pub fn remove_active_ref_metadata(layout: &RepositoryLayout) -> Result<bool> {
151    layout.require_current_format()?;
152    remove_active_ref_metadata_authorized(layout)
153}
154
155fn remove_active_ref_metadata_authorized(layout: &RepositoryLayout) -> Result<bool> {
156    let relative = layout.repository_relative(&layout.default_active_ref_name_path())?;
157    let had_content = !read_file_if_exists(layout.repository_mutation_root(), &relative)?
158        .unwrap_or_default()
159        .is_empty();
160    truncate_file_empty_required(layout.repository_mutation_root(), &relative)?;
161    Ok(had_content)
162}
163
164/// Drain a fully published active WAL and remove its ownership metadata under the active lock.
165pub fn finish_active_publication_cleanup(
166    layout: &RepositoryLayout,
167    active_lock: &ActiveLock,
168) -> Result<()> {
169    layout.require_current_format()?;
170    active_lock.require_layout(layout)?;
171    Wal::for_layout(layout).truncate_empty()?;
172    remove_active_ref_metadata_authorized(layout)?;
173    Ok(())
174}
175
176/// Prepare active ref metadata for the first WAL append.
177///
178/// Caller must hold the active-session lock and must call this only after replay has proven that the
179/// active WAL has no records and no trailing partial bytes.
180///
181/// RFC 102 Stage 5, design-v1.md §14.6: no longer branches on the metadata's prior state --
182/// `write_active_ref_metadata` truncates before it appends, so any stale `Valid`/`Invalid` debris left
183/// over from a fully-drained-but-uncleared session is replaced unconditionally, the same as the
184/// `Missing` case. The pre-clear-then-write two-step this function used to perform is now internal to
185/// `write_active_ref_metadata` itself; the crash window between clear and write moved, it did not grow.
186pub(crate) fn prepare_empty_active_ref_for_append(
187    layout: &RepositoryLayout,
188    ref_name: &str,
189) -> Result<String> {
190    write_active_ref_metadata(layout, ref_name)
191}
192
193/// Validate active ref metadata for a non-empty active WAL.
194pub fn require_active_ref_for_non_empty_wal(
195    layout: &RepositoryLayout,
196    ref_name: &str,
197) -> Result<String> {
198    let expected = validate_local_branch_ref(ref_name)?;
199    match read_active_ref_metadata(layout)? {
200        ActiveRefMetadata::Valid(actual) if actual == expected => Ok(actual),
201        ActiveRefMetadata::Valid(actual) => Err(PrikkError::LockConflict(format!(
202            "active WAL is owned by {actual}; requested ref {expected}"
203        ))),
204        ActiveRefMetadata::Missing => Err(PrikkError::Integrity(
205            "active WAL has records but active ref metadata is missing".to_string(),
206        )),
207        ActiveRefMetadata::Invalid(reason) => Err(PrikkError::Integrity(format!(
208            "active WAL has records but active ref metadata is malformed: {reason}"
209        ))),
210    }
211}
212
213// DC-71: every test here sets up its scenario via real repository mutation (RepositoryLayout::init
214// or equivalent), which is Linux-only; the module never compiles a non-Linux-meaningful test.
215#[cfg(all(test, target_os = "linux"))]
216mod tests;