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
//! §8 op lifecycle + §14 rollback — the verb-agnostic engine.
//!
//! balls authors a base change, an ordered plugin chain acts on it, balls
//! SEALS it (commit + integrate, atomically — [`crate::git`]), and plugins
//! react; any abort unwinds the whole op in reverse (§14). Two collaborators
//! are seams this engine owns the contract for: [`BaseChange`] (the verb's diff
//! authoring — §9, bl-dfbd) and [`Plugins`] (the subprocess plugin chain —
//! §6/§7, bl-5d56). The skeleton ships the orchestration and the real git seal;
//! the plugin chain has no production impl yet ("no real plugins yet").
//!
//! Mutating ops run the full Author → Pre → Seal → Post → Teardown shape;
//! diffless ops (reads, sync/prime) "skip steps 1/3/5" — pre/post run
//! against the store/landing checkout, no worktree and no seal (§8). One rule governs the
//! unwind: every plugin that ran a phase for THIS op rolls back in reverse,
//! THEN core un-seals its own tier-1 change — discard the worktree on a
//! pre-abort, `git reset` the anvil on a post-abort (§14). The committed
//! `[hooks]` plugin set (§6 — `config/plugins.toml`, bl-8540) is resolved once at
//! op-start (the §6 snapshot) and passed in, so the engine never reads config itself.
use std::path::Path;
use crate::git::Anvil;
use crate::log::{Level, Log};
use crate::op::Phase;
use crate::registry::PluginRef;
use crate::verb::Verb;
// The verb-agnostic contract (the diff seam, the plugin-chain seam, the post
// facts, the abort taxonomy) lives in a sibling; re-exported so consumers keep
// reaching `crate::lifecycle::{BaseChange, Sealed, Plugins, OpError}`.
#[path = "lifecycle_contract.rs"]
mod contract;
pub use contract::{BaseChange, OpError, Plugins, Sealed};
/// What the op moved, owned for the §14 unwind: the prior tip to reset back to,
/// the new commit, and the §5 message — enough to hand a `post`-phase rollback
/// the same [`Sealed`] facts its forward run saw (§7). `message` is `None` on a
/// diffless op (§13): no seal ran, so there is no §5 message to parse, and the
/// facts the record renders are the checkout tip before/after the op.
struct SealRecord {
previous_commit: String,
commit: String,
message: Option<String>,
}
impl SealRecord {
/// Borrow this record as the [`Sealed`] facts passed across the seam.
fn facts(&self) -> Sealed<'_> {
Sealed {
commit: &self.commit,
previous_commit: &self.previous_commit,
message: self.message.as_deref(),
}
}
}
/// What core did this op, for the §14 unwind: the plugins that ran (in order,
/// each tagged with its phase), whether the change worktree was opened, and the
/// seal record once the seal landed (`Some` ⇒ a post-abort un-seals to its tip).
#[derive(Default)]
struct Trace {
ran: Vec<(PluginRef, Phase)>,
opened: bool,
seal: Option<SealRecord>,
}
/// The §8 engine: an anvil, a plugin chain, and the op's [`Log`] sink. It
/// emits the op-level lifecycle records (begin/seal/abort, §6), interleaved with
/// the per-plugin `invoke`/envelope records the [`Plugins`] seam writes there.
pub struct Engine<'a> {
anvil: &'a dyn Anvil,
plugins: &'a dyn Plugins,
log: &'a Log,
}
impl<'a> Engine<'a> {
/// Build an engine over an anvil, a plugin chain, and the op's log sink.
pub fn new(anvil: &'a dyn Anvil, plugins: &'a dyn Plugins, log: &'a Log) -> Self {
Self { anvil, plugins, log }
}
/// Run a MUTATING op (§8 steps 1–6): Author → Pre → Seal → Post → Teardown,
/// with §14 rollback on any abort. `pre`/`post` are the committed `[hooks]`
/// plugin sets resolved at op-start (§6). Logs `begin`/`seal`/`abort` (§6); returns the sha.
pub fn seal(
&self,
base: &dyn BaseChange,
op: Verb,
change_dir: &Path,
pre: &[PluginRef],
post: &[PluginRef],
) -> Result<String, OpError> {
self.log.record(Level::Debug, "core", None, "begin");
let mut trace = Trace::default();
match self.run_inner(base, op, change_dir, pre, post, &mut trace) {
Ok(sha) => {
let _ = self.anvil.close(change_dir); // (5) teardown, best-effort
self.log.record(Level::Debug, "core", None, &format!("seal {sha}"));
Ok(sha)
}
Err(e) => {
self.rollback(op, change_dir, &trace);
self.log.record(Level::Error, "core", None, &format!("abort {e}"));
Err(e)
}
}
}
/// The fallible Author → Pre → Seal → Post body; `trace` records what ran so
/// [`Engine::rollback`] can unwind it.
fn run_inner(
&self,
base: &dyn BaseChange,
op: Verb,
change_dir: &Path,
pre: &[PluginRef],
post: &[PluginRef],
trace: &mut Trace,
) -> Result<String, OpError> {
self.anvil.open(change_dir).map_err(OpError::Anvil)?; // (1) make the place
trace.opened = true;
base.stage(change_dir).map_err(OpError::Author)?; // (1) stage the base
run_phase(self.plugins, op, Phase::Pre, change_dir, pre, None, &mut trace.ran)?; // (2)
validate::changed_balls(self.anvil, change_dir, &trace.ran)?; // (3) seal-validate, bl-528c
let prev = self.anvil.head().map_err(OpError::Anvil)?;
let message = base.finalize(change_dir).map_err(OpError::Author)?;
let sha = self.anvil.seal(change_dir, &message).map_err(OpError::Anvil)?; // (3) SEAL
if base.narrated() && sha == prev {
return Err(OpError::Narration); // converged no-op seal would drop the note (bl-cf93)
}
// boundary crossed: record the seal so post (and any post-abort) gets §7 facts.
let sealed = trace
.seal
.insert(SealRecord { previous_commit: prev, commit: sha.clone(), message: Some(message) })
.facts();
run_phase(self.plugins, op, Phase::Post, change_dir, post, Some(&sealed), &mut trace.ran)?; // (4)
Ok(sha)
}
/// §14 unwind: roll every run plugin back in reverse, THEN core un-seals its
/// tier-1 change — `git reset` the anvil on a post-abort, discard the
/// change worktree always. Core's un-seal is local, so its errors are
/// swallowed (best-effort; the op already failed).
fn rollback(&self, op: Verb, change_dir: &Path, trace: &Trace) {
unwind(self.plugins, op, change_dir, &trace.ran, trace.seal.as_ref());
if let Some(record) = &trace.seal {
let _ = self.anvil.unseal(&record.previous_commit); // post-abort: reset the anvil
}
if trace.opened {
let _ = self.anvil.close(change_dir); // discard the change worktree
}
}
}
/// Run one phase's plugins in resolved hook-list order, recording each success on
/// `ran` (a failing plugin cleaned up inline, so it is NOT recorded — §14).
fn run_phase(
plugins: &dyn Plugins,
op: Verb,
phase: Phase,
dir: &Path,
list: &[PluginRef],
sealed: Option<&Sealed>,
ran: &mut Vec<(PluginRef, Phase)>,
) -> Result<(), OpError> {
for plugin in list {
plugins
.run(plugin, op, phase, dir, sealed)
.map_err(|source| OpError::Plugin { name: plugin.name.clone(), source })?;
ran.push((plugin.clone(), phase));
}
Ok(())
}
/// Roll back every recorded plugin run in strict reverse execution order,
/// regardless of which phase it ran — the op is the unit of atomicity (§14).
/// EVERY rollback gets the [`Sealed`] facts once the seal landed — §14's id
/// rule ("post/rollback from the sealed §5 trailer") makes no phase split, and
/// a post-abort leaves the change worktree CLEAN, so a pre-phase rollback
/// starved of the trailer once silently no-oped (bl-430e, in the days of the
/// delivery un-squash). On a pre-abort there is no seal: `sealed` is `None`.
/// Identity never depended on that split again after bl-a5f3 — the ball is an
/// op input on EVERY wire ([`crate::wire::Command::id`]), which is what makes
/// the third state (a FAILED seal: worktree committed, nothing integrated, no
/// seal record) unwind like any other.
fn unwind(plugins: &dyn Plugins, op: Verb, dir: &Path, ran: &[(PluginRef, Phase)], seal: Option<&SealRecord>) {
let sealed = seal.map(SealRecord::facts);
for (plugin, phase) in ran.iter().rev() {
plugins.rollback(plugin, op, *phase, dir, sealed.as_ref());
}
}
/// §13 diffless ops (`sync`/`prime`) — pre/post against a checkout, no seal —
/// live in a sibling, an `impl Engine` block reaching this module's private
/// `run_phase`/`unwind`/`SealRecord` seams.
#[path = "lifecycle_diffless.rs"]
mod diffless;
// §8.3 seal validation (bl-528c): every CHANGED `tasks/*.md` must still parse.
#[path = "lifecycle_validate.rs"]
mod validate;
#[cfg(test)]
#[path = "lifecycle_tests.rs"]
mod tests;