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