Skip to main content

majordomus_cli/capability/builtin/
recover.rs

1//! The `recover` module: the stray files of the record stores, classified by evidence and
2//! swept exactly once.
3//!
4//! `majordomus recover` is a mutating development command — it writes `.ai/repo/sessions/`
5//! and `.ai/local/state/` — and until this module it was backed by nothing in the registry.
6//! [ADR 0040] says a development operation exists as a `capability!` declaration and that
7//! every surface is a consumer of it; `scripts/development-semantics-check` measures exactly
8//! that and reported `backing:recover`. This module is the first capability that backs it.
9//!
10//! # What moved, and what deliberately did not
11//!
12//! One capability, `recover.orphans`, and it owns the **decision and the destruction**: for
13//! every stray file of the record stores it reads the clock and the content, returns a
14//! verdict with the evidence that produced it, and — unless the call is a check — removes
15//! the ones the verdict says are removable. `lib/recover.sh` no longer decides any of that;
16//! it asks, renders the answer in the words a person reads, and counts.
17//!
18//! What stayed in shell is the **constructive** half. A publish temp that holds the only
19//! copy of an episode is published rather than deleted, and publishing a record is
20//! `mj_publish_record`'s semantic — the same one `session close` uses, and the one
21//! `recover episodes` composes a synthesised record with. Moving one caller of it without
22//! moving the writer would put a second record writer in a second program, which is the
23//! defect ADR 0040 exists to prevent rather than a step towards fixing it. So this
24//! capability names an `Incomplete` stray and removes nothing; the shell publishes it. The
25//! record writer is the next thing to converge, and when it does, `recover.orphans` gains
26//! the publish and loses the `Incomplete` verdict's "the caller acts" note.
27//!
28//! The same argument keeps `recover episodes` and `recover records` in shell for now: both
29//! *write records*, and neither can converge before the record writer does.
30//!
31//! # Why this is not [`super::lifecycle`]'s job
32//!
33//! `lifecycle.recovery` already lists the `.tmp.*` files of the sessions section, and a
34//! second definition of the same thing would be this repository's favourite defect. It is
35//! not one. That module states its own constraint — *"no thresholds and no clock … what this
36//! module reports is what it can observe without an opinion"* — so it reports a path and a
37//! byte count and nothing else. This module is the opinion: it reads the clock, applies the
38//! threshold, and acts. An observation and a decision over the same files are two different
39//! things to depend on; what would be a duplicate is a second *verdict*, and there is one.
40//!
41//! # The two guards, and why they are here rather than at the call site
42//!
43//! **The threshold has no default.** `older_than_seconds` is a required field with no serde
44//! default, and zero is refused. `session.stranded_after` is a policy value, and a reader
45//! that carries its own default is a second place the number lives — `majordomus doctor`
46//! refused exactly that when `lib/recover.sh` was first written. A sweep that ran against an
47//! absent threshold would find every file stale, which is the one failure this command must
48//! not have (`project.destructive-sweeps-fail-closed`).
49//!
50//! **Nothing is removed that is not a temp file this tool writes.** `removable` is decided
51//! from the verdict, and `remove_stray` refuses any path that is not a regular file whose
52//! name matches the two patterns this tool creates — `.tmp.*` inside a record store, or
53//! `<target>.mj-tmp`. A directory is never removed, whatever its verdict: `SECURITY.md`
54//! states "no recursive deletion", and a stale `.mj-stage.*` staging directory is therefore
55//! reported and left in place. `check` is a true dry run: it is consulted before the single
56//! call site of `remove_stray`, and nothing else in this file touches the filesystem for
57//! writing.
58//!
59//! [ADR 0040]: https://github.com/korczis/prismatic-majordomus/blob/master/.ai/repo/adrs/0040-development-semantics-are-capabilities-of-one-runtime.md
60//!
61//! # The one capability, as the registry states it
62//!
63//! ```
64//! use majordomus_cli::capability::builtin::recover;
65//! use majordomus_cli::capability::{CapabilityKind, Effect};
66//!
67//! let m = recover::module();
68//! assert_eq!(m.id.as_str(), "recover");
69//! assert_eq!(m.capabilities.len(), 1);
70//! let c = &m.capabilities[0].capability;
71//! assert_eq!(c.id.as_str(), "recover.orphans");
72//!
73//! // it owns the decision and the destruction: a command whose effect is on the repository
74//! assert!(matches!(c.kind, CapabilityKind::Command));
75//! assert_eq!(c.execution.effect, Effect::RepositoryMutation);
76//!
77//! // and it is asked rather than typed: `majordomus recover` stays the shell command that
78//! // asks it, so the capability has no command-line projection of its own
79//! assert!(c.exposure.cli.is_none());
80//! assert!(c.exposure.mcp.is_some() && c.exposure.http.is_some());
81//! ```
82
83use std::path::{Path, PathBuf};
84use std::time::{SystemTime, UNIX_EPOCH};
85
86use schemars::JsonSchema;
87use serde::{Deserialize, Serialize};
88
89use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
90use crate::capability::handler::{CapabilityError, Context};
91use crate::capability::model::CapabilityKind;
92use crate::capability::model::{Exposure, Stability};
93use crate::capability::module::ModuleDescriptor;
94use crate::{capability, module};
95
96use super::{mcp, post};
97
98/// The local half of the layer, relative to the repository root. The same constant
99/// [`super::continuity`] and [`super::lifecycle`] carry, and for the same reason: the shell
100/// tool decides where its state lives and a second opinion about the path would be a second
101/// source of truth for the one thing both halves must agree on.
102const STATE_DIR: &str = ".ai/local/state";
103
104/// The manifest section that names the tracked record store.
105const SESSIONS_SECTION: &str = "sessions";
106
107/// The prefix `mj_publish_record` gives the temporary file it hard-links from.
108const PUBLISH_PREFIX: &str = ".tmp.";
109
110/// The suffix every write-then-rename in the shell tool gives its temporary file.
111const RENAME_SUFFIX: &str = ".mj-tmp";
112
113/// The prefix `scripts/generate-site-data` gives its staging directory at the repository
114/// root.
115const STAGE_PREFIX: &str = ".mj-stage.";
116
117// ---------------------------------------------------------------- the vocabulary
118
119/// What kind of stray a path is, decided by where it is and how it is named rather than by
120/// what is in it. The content decides the *verdict*; this decides which question to ask.
121///
122/// The word a kind is reported under is also the word it is read back from, and a word this
123/// version does not know is refused rather than read as the nearest kind:
124///
125/// ```
126/// use majordomus_cli::capability::builtin::recover::StrayKind;
127/// for kind in [StrayKind::PublishTemp, StrayKind::RenameTemp, StrayKind::StagingDir] {
128///     let word = serde_json::to_value(kind).unwrap();
129///     assert_eq!(serde_json::from_value::<StrayKind>(word).unwrap(), kind);
130/// }
131/// assert!(serde_json::from_value::<StrayKind>(serde_json::json!("temp")).is_err());
132/// ```
133#[derive(
134    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
135)]
136#[serde(rename_all = "snake_case")]
137pub enum StrayKind {
138    /// A `.tmp.XXXXXX` in a record store: `mj_publish_record` makes them, and only a process
139    /// that died between the `mktemp` and the `rm` leaves one.
140    PublishTemp,
141    /// A `<target>.mj-tmp`: written and renamed in one `&&`, so one that exists means the
142    /// write failed or the process died mid-write.
143    RenameTemp,
144    /// A `.mj-stage.XXXXXX` at the repository root: `scripts/generate-site-data` stages into
145    /// one. Always a directory, and therefore never removed here.
146    StagingDir,
147}
148
149impl StrayKind {
150    /// The word this kind is reported and serialised under.
151    ///
152    /// ```
153    /// use majordomus_cli::capability::builtin::recover::StrayKind;
154    /// assert_eq!(StrayKind::PublishTemp.as_str(), "publish_temp");
155    /// assert_eq!(
156    ///     serde_json::to_value(StrayKind::StagingDir).unwrap(),
157    ///     serde_json::json!(StrayKind::StagingDir.as_str()),
158    /// );
159    /// ```
160    pub fn as_str(self) -> &'static str {
161        match self {
162            StrayKind::PublishTemp => "publish_temp",
163            StrayKind::RenameTemp => "rename_temp",
164            StrayKind::StagingDir => "staging_dir",
165        }
166    }
167}
168
169/// What this stray is, decided by reading it.
170///
171/// The five words are `lib/recover.sh`'s, deliberately: a person who has read the command's
172/// output should not have to learn the same five facts twice because a second surface chose
173/// its own vocabulary.
174///
175/// That vocabulary is closed. Every verdict reads back as itself, and there is no sixth word:
176/// a surface that invents one gets an error rather than a verdict nobody meant.
177///
178/// ```
179/// use majordomus_cli::capability::builtin::recover::StrayVerdict::{self, *};
180/// let all = [Live, Orphan, Incomplete, Foreign, Unmeasurable];
181/// let words: Vec<&str> = all.iter().map(|v| v.as_str()).collect();
182/// assert_eq!(words, ["live", "orphan", "incomplete", "foreign", "unmeasurable"]);
183/// for v in all {
184///     let word = serde_json::json!(v.as_str());
185///     assert_eq!(serde_json::from_value::<StrayVerdict>(word).unwrap(), v);
186/// }
187/// assert!(serde_json::from_value::<StrayVerdict>(serde_json::json!("stale")).is_err());
188/// ```
189#[derive(
190    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
191)]
192#[serde(rename_all = "snake_case")]
193pub enum StrayVerdict {
194    /// Younger than the threshold: a `session close` or a `scripts/derive` running right now
195    /// owns it. Measured, reported, never touched.
196    Live,
197    /// Its work completed, or there was never anything in it. Removable — unless it is a
198    /// directory, which this runtime never removes.
199    Orphan,
200    /// It holds something no other file holds: a record nobody published, or a write whose
201    /// target never arrived. Never removed here; the caller decides.
202    Incomplete,
203    /// Not written by this tool at all, or written by a version that shaped it differently.
204    /// Never touched; reported with what it is.
205    Foreign,
206    /// Its age could not be read on this platform, so nothing here treats it as stale. The
207    /// guard `project.destructive-sweeps-fail-closed` asks for: a predicate that cannot
208    /// measure its input is false, never the most extreme value in the set.
209    Unmeasurable,
210}
211
212impl StrayVerdict {
213    /// The word this verdict is reported and serialised under.
214    ///
215    /// ```
216    /// use majordomus_cli::capability::builtin::recover::StrayVerdict;
217    /// assert_eq!(StrayVerdict::Unmeasurable.as_str(), "unmeasurable");
218    /// assert_eq!(
219    ///     serde_json::to_value(StrayVerdict::Orphan).unwrap(),
220    ///     serde_json::json!(StrayVerdict::Orphan.as_str()),
221    /// );
222    /// ```
223    pub fn as_str(self) -> &'static str {
224        match self {
225            StrayVerdict::Live => "live",
226            StrayVerdict::Orphan => "orphan",
227            StrayVerdict::Incomplete => "incomplete",
228            StrayVerdict::Foreign => "foreign",
229            StrayVerdict::Unmeasurable => "unmeasurable",
230        }
231    }
232}
233
234/// One stray file, its verdict, and the evidence that produced it.
235///
236/// Every field that decided something is carried, because the rule this command is shaped
237/// around requires the value that decided a verdict to be visible before the verdict acts:
238/// a surface that printed "orphan" without the age, the episode and the file that already
239/// holds it would be asking to be trusted rather than read.
240///
241/// ```
242/// use majordomus_cli::capability::builtin::recover::{Stray, StrayKind, StrayVerdict};
243/// use serde_json::json;
244///
245/// let s: Stray = serde_json::from_value(json!({
246///     "path": ".ai/repo/sessions/.tmp.a1b2c3",
247///     "kind": "publish_temp",
248///     "verdict": "orphan",
249///     "age_seconds": 90_000,
250///     "session_id": "s-0007",
251///     "published_at": ".ai/repo/sessions/2026-09-11--s-0007.md",
252///     "removable": true,
253///     "removed": false,
254/// })).unwrap();
255/// assert_eq!(s.verdict, StrayVerdict::Orphan);
256/// assert_eq!(s.kind, StrayKind::PublishTemp);
257/// // removable and removed are different facts: a check run reports the first and never
258/// // the second, which is what makes `--check` readable as a plan rather than a report.
259/// assert!(s.removable && !s.removed);
260///
261/// // and an unmeasurable one carries no age at all, rather than a zero that would read as
262/// // "brand new" to anything that compared it with a threshold
263/// let u: Stray = serde_json::from_value(json!({
264///     "path": ".mj-stage.xyz", "kind": "staging_dir", "verdict": "unmeasurable",
265///     "removable": false, "removed": false,
266/// })).unwrap();
267/// assert!(u.age_seconds.is_none());
268/// ```
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
270pub struct Stray {
271    /// The path, repository-relative. Never the absolute one: a finding that names a disk is
272    /// a finding nobody else can act on (`project.no-machine-paths`).
273    pub path: String,
274    /// Which of the three shapes this is.
275    pub kind: StrayKind,
276    /// What reading it decided.
277    pub verdict: StrayVerdict,
278    /// How old it is, in seconds. Absent when the platform could not say, which is the
279    /// `Unmeasurable` verdict and never a zero.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub age_seconds: Option<u64>,
282    /// The episode this publish temp carries, when it carries one.
283    #[serde(default, skip_serializing_if = "String::is_empty")]
284    pub session_id: String,
285    /// The record that already holds that episode, when one does. Its presence is what turns
286    /// an `Incomplete` publish temp into an `Orphan`.
287    #[serde(default, skip_serializing_if = "String::is_empty")]
288    pub published_at: String,
289    /// The file a rename temp was going to become. Its presence is what turns an
290    /// `Incomplete` rename temp into an `Orphan`.
291    #[serde(default, skip_serializing_if = "String::is_empty")]
292    pub target: String,
293    /// Whether this call would remove it. Decided by the verdict and by
294    /// `remove_stray`'s own pattern guard, never by the caller.
295    pub removable: bool,
296    /// Whether this call did remove it. Always `false` on a check.
297    pub removed: bool,
298}
299
300/// Strays are shown in path order, which is the only order that is a property of the store
301/// rather than of the walk that found it: `read_dir` yields what the filesystem feels like
302/// yielding, and two runs over one store must answer identically.
303impl crate::order::Ordered for Stray {
304    fn order_key(&self) -> crate::order::OrderKey<'_> {
305        crate::order::OrderKey::plain(&self.path, &self.path)
306    }
307}
308
309// ---------------------------------------------------------------- input and output
310
311/// The input of `recover.orphans`: how old a stray must be, and whether to act.
312///
313/// ```
314/// use majordomus_cli::capability::builtin::recover::RecoverOrphansInput;
315///
316/// // The wire form the shell tool, an MCP client or an HTTP POST sends.
317/// let act: RecoverOrphansInput =
318///     serde_json::from_str(r#"{"older_than_seconds":43200,"check":false}"#).unwrap();
319/// assert_eq!(act.older_than_seconds, 43_200);
320/// assert!(!act.check);
321///
322/// // `check` defaults to true, and that direction is deliberate: this capability deletes
323/// // files, and a caller that forgets the field gets a plan rather than a sweep.
324/// let forgot: RecoverOrphansInput =
325///     serde_json::from_str(r#"{"older_than_seconds":43200}"#).unwrap();
326/// assert!(forgot.check, "the safe value is the default one");
327///
328/// // The threshold has no default at all. `session.stranded_after` is a policy value, and
329/// // a reader that carried its own copy would be a second place the number lives.
330/// assert!(serde_json::from_str::<RecoverOrphansInput>(r#"{"check":true}"#).is_err());
331///
332/// // Unknown fields are refused rather than ignored: a caller that misspells `check` is
333/// // told so instead of having a sweep silently run.
334/// assert!(serde_json::from_str::<RecoverOrphansInput>(
335///     r#"{"older_than_seconds":1,"chek":true}"#).is_err());
336/// ```
337#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
338#[serde(deny_unknown_fields)]
339pub struct RecoverOrphansInput {
340    /// How long a stray file must have been untouched before it is a candidate at all, in
341    /// seconds. Required, and refused at zero: the shell tool resolves it from
342    /// `session.stranded_after` with `mj_pol_req`, which fails closed, and nothing here
343    /// invents one.
344    pub older_than_seconds: u64,
345    /// Classify and report, and write nothing. The default, because the alternative deletes
346    /// files.
347    #[serde(default = "yes")]
348    pub check: bool,
349}
350
351fn yes() -> bool {
352    true
353}
354
355/// The benchmark cases of the capability that sweeps.
356///
357/// Every case is a check, and that is deliberate rather than a gap. A case is *executed* —
358/// by `majordomus bench` against this repository, and by the HTTP and MCP suites against a
359/// fixture — so a case that swept would delete this repository's stray files every time the
360/// suite ran, and a measurement whose cost is a changed store is not a measurement. The
361/// check path is also the honest thing to time: it walks every store, stats every candidate
362/// and reads every publish temp, which is everything the sweep does except the `unlink` at
363/// the end.
364///
365/// Two cases rather than one because case 92 refuses a declared parameter that no case ever
366/// sets, and because the two thresholds measure different work: the generous one classifies
367/// every stray, the tight one classifies none of them as `Live` and so reads every file.
368impl BenchmarkCases for RecoverOrphansInput {
369    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
370        vec![
371            NamedCase::new(
372                "check-twelve-hours",
373                RecoverOrphansInput {
374                    older_than_seconds: 43_200,
375                    check: true,
376                },
377            ),
378            NamedCase::new(
379                "check-one-second",
380                RecoverOrphansInput {
381                    older_than_seconds: 1,
382                    check: true,
383                },
384            ),
385        ]
386    }
387}
388
389/// What one sweep found and what it did about it.
390///
391/// The counts are derived from `strays` rather than accumulated beside it, so a reader can
392/// check the report against itself and a surface that renders the list cannot disagree with
393/// the surface that renders the total.
394///
395/// ```
396/// use majordomus_cli::capability::builtin::recover::RecoverOrphansResult;
397///
398/// let plan: RecoverOrphansResult = serde_json::from_str(r#"{
399///     "older_than_seconds": 43200, "check": true, "strays": [],
400///     "removable": 0, "removed": 0, "unmeasurable": 0
401/// }"#).unwrap();
402/// // a check never removes anything, whatever it found
403/// assert_eq!(plan.removed, 0);
404/// assert!(plan.check);
405/// ```
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
407pub struct RecoverOrphansResult {
408    /// The threshold this sweep measured against, echoed back so that a refusal or a verdict
409    /// names the value the caller actually got rather than the one it believes it sent.
410    pub older_than_seconds: u64,
411    /// Whether this was a check. `true` means nothing was written.
412    pub check: bool,
413    /// Every stray found, in path order.
414    pub strays: Vec<Stray>,
415    /// How many of them this call would remove, or did.
416    pub removable: usize,
417    /// How many it actually removed. Always `0` on a check.
418    pub removed: usize,
419    /// How many candidates could not be measured and were therefore left alone.
420    pub unmeasurable: usize,
421}
422
423// ---------------------------------------------------------------- the module
424
425/// The module descriptor: one capability, and it writes.
426///
427/// ```
428/// use majordomus_cli::capability::builtin::recover::module;
429/// use majordomus_cli::capability::Effect;
430///
431/// let m = module();
432/// assert_eq!(m.capabilities.len(), 1);
433/// // a sweep of a tracked store is not a read, so every client asks before running it
434/// let policy = &m.capabilities[0].capability.execution;
435/// assert_eq!(policy.effect, Effect::RepositoryMutation);
436/// assert!(policy.needs_confirmation());
437/// ```
438pub fn module() -> ModuleDescriptor {
439    module! {
440        id: "recover",
441        title: "Recovery of the record stores",
442        description: "The stray files a killed publish, an interrupted rename or an unfinished site build leave in the record stores, classified by reading the clock and the content, and swept exactly once. This is the capability that backs `majordomus recover` (ADR 0040): the command no longer decides what a stray is, it asks.",
443        stability: Stability::BehaviorallyVerified,
444        capabilities: [
445            capability! {
446                id: "recover.orphans",
447                kind: CapabilityKind::Command,
448                title: "Classify every stray file of the record stores, and sweep the ones that are nobody's",
449                description: "Walk the publish temps of every record store, the rename temps under the layer's local half and the tracked sessions section, and the site generator's staging directories at the repository root; give each a verdict by reading its age and then its content; and, unless the call is a check, remove the ones the verdict says nothing is holding. Nothing is deleted for being unrecognised: a temp holding the only copy of a record is reported as `incomplete` and left for the caller to publish, content this version cannot classify is `foreign` and left exactly where it is, a candidate whose age this platform cannot read is `unmeasurable` and never a candidate, and no directory is ever removed. The threshold is required and has no default, because a sweep measuring against an absent threshold would find every file stale.",
450                input: RecoverOrphansInput,
451                output: RecoverOrphansResult,
452                stability: Stability::BehaviorallyVerified,
453                exposure: Exposure {
454                    mcp: mcp("majordomus_recover_orphans"),
455                    http: post("/api/v1/recover/orphans"),
456                    cli: None,
457                },
458                tags: ["recover", "sessions", "maintenance", "lifecycle"],
459                handler: recover_orphans,
460            }
461            // The one thing its kind cannot say: this unlinks files, and one of the stores it
462            // sweeps is tracked. The exposure ceiling of every surface is derived from the
463            // effect, so a sweep that claimed only this process's memory would be projected
464            // wherever a peer announcement is, which is not the same permission.
465            .writes_repository(),
466        ],
467    }
468}
469
470// ---------------------------------------------------------------- the handler
471
472fn recover_orphans(
473    ctx: &Context,
474    input: RecoverOrphansInput,
475) -> Result<RecoverOrphansResult, CapabilityError> {
476    if input.older_than_seconds == 0 {
477        return Err(CapabilityError::Refused(
478            "older_than_seconds is zero, which would make every stray file a candidate the \
479             moment it was written; the threshold is `session.stranded_after` in the policy \
480             and nothing here invents one"
481                .into(),
482        ));
483    }
484
485    let root = PathBuf::from(&ctx.index.repository.root);
486    let state = root.join(STATE_DIR);
487    let sessions = ctx
488        .index
489        .repository
490        .sections
491        .get(SESSIONS_SECTION)
492        .map(|s| root.join(s));
493
494    let now = now_secs().ok_or_else(|| {
495        CapabilityError::Refused(
496            "this platform's clock could not be read, and a sweep that cannot measure an age \
497             takes no action at all"
498                .into(),
499        )
500    })?;
501
502    let mut strays = Vec::new();
503    for (path, kind) in candidates(&root, &state, sessions.as_deref()) {
504        strays.push(classify(&root, &path, kind, now, input.older_than_seconds));
505    }
506    crate::order::canonical(&mut strays);
507
508    let mut removed = 0;
509    if !input.check {
510        for s in &mut strays {
511            if !s.removable {
512                continue;
513            }
514            if remove_stray(&root.join(&s.path)).is_ok() {
515                s.removed = true;
516                removed += 1;
517            }
518        }
519    }
520
521    Ok(RecoverOrphansResult {
522        older_than_seconds: input.older_than_seconds,
523        check: input.check,
524        removable: strays.iter().filter(|s| s.removable).count(),
525        unmeasurable: strays
526            .iter()
527            .filter(|s| s.verdict == StrayVerdict::Unmeasurable)
528            .count(),
529        removed,
530        strays,
531    })
532}
533
534/// Every path that is a candidate, with the question its shape says to ask.
535///
536/// The three sets are exactly `lib/recover.sh`'s, in its order, and the order is why they
537/// are collected here rather than classified as they are found: the result is sorted once,
538/// so two runs over one store answer identically whatever `read_dir` happens to yield.
539fn candidates(root: &Path, state: &Path, sessions: Option<&Path>) -> Vec<(PathBuf, StrayKind)> {
540    let mut out = Vec::new();
541
542    // 1. the publish temps of every record store.
543    let mut stores: Vec<PathBuf> = vec![state.join("checkpoints"), state.join("handovers")];
544    if let Some(s) = sessions {
545        stores.push(s.to_path_buf());
546    }
547    for dir in &stores {
548        for entry in read_dir(dir) {
549            if entry.is_file() && file_name(&entry).starts_with(PUBLISH_PREFIX) {
550                out.push((entry, StrayKind::PublishTemp));
551            }
552        }
553    }
554
555    // 2. the rename temps, anywhere under the local half or the tracked sessions section.
556    let mut roots: Vec<PathBuf> = vec![state.to_path_buf()];
557    if let Some(s) = sessions {
558        roots.push(s.to_path_buf());
559    }
560    for dir in &roots {
561        for entry in walk(dir) {
562            if entry.is_file() && file_name(&entry).ends_with(RENAME_SUFFIX) {
563                out.push((entry, StrayKind::RenameTemp));
564            }
565        }
566    }
567
568    // 3. the site generator's staging directories at the repository root.
569    for entry in read_dir(root) {
570        if file_name(&entry).starts_with(STAGE_PREFIX) {
571            out.push((entry, StrayKind::StagingDir));
572        }
573    }
574
575    out
576}
577
578/// One stray, by its age and then by its content.
579///
580/// The age is read first, and that order is load-bearing: a publish temp a few milliseconds
581/// old belongs to a `session close` running right now and a `.mj-stage.XXXXXX` a few minutes
582/// old to a `scripts/derive` running right now. Removing either is taking somebody's
583/// instrument out of their hands (`project.reclaim-only-what-you-own`), and "the run that
584/// made it did not finish" is a claim, not a measurement, until something reads the clock.
585fn classify(root: &Path, path: &Path, kind: StrayKind, now: u64, threshold: u64) -> Stray {
586    let rel = relative(root, path);
587    let base = Stray {
588        path: rel,
589        kind,
590        verdict: StrayVerdict::Unmeasurable,
591        age_seconds: None,
592        session_id: String::new(),
593        published_at: String::new(),
594        target: String::new(),
595        removable: false,
596        removed: false,
597    };
598
599    let Some(age) = age_secs(path, now) else {
600        return base;
601    };
602    let base = Stray {
603        age_seconds: Some(age),
604        ..base
605    };
606    if age < threshold {
607        return Stray {
608            verdict: StrayVerdict::Live,
609            ..base
610        };
611    }
612
613    match kind {
614        // A directory is read and understood and then left: this runtime removes no
615        // directory tree (`SECURITY.md`: no recursive deletion), and a directory is the one
616        // stray that cannot be accounted for file by file before removing.
617        StrayKind::StagingDir => Stray {
618            verdict: StrayVerdict::Orphan,
619            removable: false,
620            ..base
621        },
622        StrayKind::RenameTemp => {
623            let target = path
624                .to_string_lossy()
625                .strip_suffix(RENAME_SUFFIX)
626                .map(PathBuf::from);
627            match target {
628                Some(t) if t.exists() => Stray {
629                    verdict: StrayVerdict::Orphan,
630                    target: relative(root, &t),
631                    removable: true,
632                    ..base
633                },
634                Some(t) => Stray {
635                    verdict: StrayVerdict::Incomplete,
636                    target: relative(root, &t),
637                    removable: false,
638                    ..base
639                },
640                None => base,
641            }
642        }
643        StrayKind::PublishTemp => classify_publish_temp(root, path, base),
644    }
645}
646
647/// A publish temp, by what is in it. Nothing is removed on the strength of the name: the
648/// file is read, and a temp that turns out to hold a record nobody published is reported as
649/// `Incomplete` rather than swept — that is the whole difference between recovery and a
650/// sweep.
651fn classify_publish_temp(root: &Path, path: &Path, base: Stray) -> Stray {
652    let text = std::fs::read_to_string(path).unwrap_or_default();
653    if text.is_empty() {
654        return Stray {
655            verdict: StrayVerdict::Orphan,
656            removable: true,
657            ..base
658        };
659    }
660    let Some(sid) = front_matter_value(&text, "session_id") else {
661        return Stray {
662            verdict: StrayVerdict::Foreign,
663            removable: false,
664            ..base
665        };
666    };
667    let store = path.parent().map(Path::to_path_buf).unwrap_or_default();
668    match published_record(&store, &sid) {
669        Some(existing) => Stray {
670            verdict: StrayVerdict::Orphan,
671            session_id: sid,
672            published_at: relative(root, &existing),
673            removable: true,
674            ..base
675        },
676        None => Stray {
677            verdict: StrayVerdict::Incomplete,
678            session_id: sid,
679            removable: false,
680            ..base
681        },
682    }
683}
684
685/// The published record of an episode in one store, if there is one.
686///
687/// Markdown only, and at depth 1. Both halves match the shell tool's `--include='*.md'` over
688/// the store: a temp is not a record even though it holds one, and a directory somebody
689/// borrowed the store for is not a record store.
690fn published_record(store: &Path, session_id: &str) -> Option<PathBuf> {
691    let mut found: Vec<String> = read_dir(store)
692        .into_iter()
693        .filter(|p| p.is_file() && file_name(p).ends_with(".md"))
694        .filter(|p| {
695            std::fs::read_to_string(p)
696                .ok()
697                .and_then(|t| front_matter_value(&t, "session_id"))
698                .is_some_and(|s| s == session_id)
699        })
700        .map(|p| p.to_string_lossy().into_owned())
701        .collect();
702    crate::order::canonical_strings(&mut found);
703    found.into_iter().next().map(PathBuf::from)
704}
705
706/// Remove one stray, or refuse.
707///
708/// The single write of this module, and the guard is here rather than at the call site so
709/// that a future caller cannot reach a removal without passing it. Three conditions, all
710/// required: the path is a regular file, it is not a symlink, and its name is one of the two
711/// shapes this tool creates. A path that is none of those is refused whatever any verdict
712/// said about it — `test/cases/08_no_forbidden_constructs` refuses a deletion of a
713/// *discovered* path that no pattern constrains, and this is the constraint.
714fn remove_stray(path: &Path) -> std::io::Result<()> {
715    let meta = std::fs::symlink_metadata(path)?;
716    if !meta.is_file() {
717        return Err(std::io::Error::other(format!(
718            "{} is not a regular file; this sweep removes no directory and follows no symlink",
719            path.display()
720        )));
721    }
722    let name = file_name(path);
723    if !(name.starts_with(PUBLISH_PREFIX) || name.ends_with(RENAME_SUFFIX)) {
724        return Err(std::io::Error::other(format!(
725            "{} is not named as a temporary file this tool writes ({PUBLISH_PREFIX}* or \
726             *{RENAME_SUFFIX}); nothing else is ever removed here",
727            path.display()
728        )));
729    }
730    std::fs::remove_file(path)
731}
732
733// ---------------------------------------------------------------- small readers
734
735/// Now, in seconds since the epoch, or nothing when the clock could not be read.
736fn now_secs() -> Option<u64> {
737    SystemTime::now()
738        .duration_since(UNIX_EPOCH)
739        .ok()
740        .map(|d| d.as_secs())
741}
742
743/// How old a path is, in seconds, or nothing when its modification time cannot be read — or
744/// when it is in the future, which is not a large age but no age at all.
745fn age_secs(path: &Path, now: u64) -> Option<u64> {
746    let m = std::fs::symlink_metadata(path).ok()?.modified().ok()?;
747    let secs = m.duration_since(UNIX_EPOCH).ok()?.as_secs();
748    now.checked_sub(secs)
749}
750
751/// The entries of a directory, or nothing. An unreadable directory is an empty one here:
752/// the sweep has nothing to say about a store it cannot open, and saying nothing is the
753/// fail-closed answer.
754fn read_dir(dir: &Path) -> Vec<PathBuf> {
755    std::fs::read_dir(dir)
756        .map(|entries| entries.flatten().map(|e| e.path()).collect())
757        .unwrap_or_default()
758}
759
760/// Every file beneath a directory, at any depth, without following a symlink into another
761/// tree. Bounded by construction: the two roots it is called on are the layer's local half
762/// and the tracked sessions section.
763fn walk(dir: &Path) -> Vec<PathBuf> {
764    let mut out = Vec::new();
765    let mut stack = vec![dir.to_path_buf()];
766    while let Some(d) = stack.pop() {
767        for p in read_dir(&d) {
768            match std::fs::symlink_metadata(&p) {
769                Ok(m) if m.is_dir() => stack.push(p),
770                Ok(m) if m.is_file() => out.push(p),
771                _ => {}
772            }
773        }
774    }
775    out
776}
777
778fn file_name(path: &Path) -> String {
779    path.file_name()
780        .map(|n| n.to_string_lossy().into_owned())
781        .unwrap_or_default()
782}
783
784/// A path, repository-relative, with forward slashes. Never the absolute one.
785fn relative(root: &Path, path: &Path) -> String {
786    path.strip_prefix(root)
787        .unwrap_or(path)
788        .to_string_lossy()
789        .replace('\\', "/")
790}
791
792/// One scalar of a record's front matter, read the way `lib/recover.sh` reads it: the first
793/// line that begins `<key>: `, anywhere in the file.
794///
795/// The shell spelling is `sed -n 's/^session_id: //p' | head -n 1`, which does not stop at
796/// the closing `---`, and this matches it deliberately. A reader that was stricter than the
797/// writer would classify a temp the shell calls a record as `foreign` and leave it behind
798/// for ever.
799///
800/// ```
801/// use majordomus_cli::capability::builtin::recover::front_matter_value;
802/// let rec = "---\nkind: session\nsession_id: s-0001\n---\n\nbody\n";
803/// assert_eq!(front_matter_value(rec, "session_id").as_deref(), Some("s-0001"));
804/// // absent is absent, and an empty value is not a value
805/// assert!(front_matter_value(rec, "task_id").is_none());
806/// assert!(front_matter_value("session_id: \n", "session_id").is_none());
807/// // a key that only appears as a prefix of another is not a match
808/// assert!(front_matter_value("session_id_old: x\n", "session_id").is_none());
809/// ```
810pub fn front_matter_value(text: &str, key: &str) -> Option<String> {
811    let prefix = format!("{key}: ");
812    text.lines()
813        .find_map(|l| l.strip_prefix(&prefix))
814        .map(str::trim)
815        .filter(|v| !v.is_empty())
816        .map(str::to_string)
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    /// The declaration is the only place these names exist; every projection derives from
824    /// it. This is the assertion a refactor that dropped an exposure would fail.
825    #[test]
826    fn the_declaration_yields_the_projections_it_claims() {
827        let m = module();
828        assert_eq!(m.id.as_str(), "recover");
829        let ids: Vec<&str> = m
830            .capabilities
831            .iter()
832            .map(|e| e.capability.id.as_str())
833            .collect();
834        assert_eq!(ids, ["recover.orphans"]);
835        let e = &m.capabilities[0].capability;
836        assert_eq!(e.kind, CapabilityKind::Command);
837        assert_eq!(
838            e.exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
839            Some("majordomus_recover_orphans")
840        );
841        let http = e.exposure.http.as_ref().expect("it is exposed over HTTP");
842        assert_eq!(http.path.as_str(), "/api/v1/recover/orphans");
843        // A sweep is a POST, never a GET: a mutating capability reachable by GET would be
844        // followed by every crawler, prefetcher and link-checker that ever met the Cockpit.
845        assert_eq!(http.method.as_str(), "POST");
846    }
847
848    /// The module's id is the command's id, and that is not cosmetic.
849    ///
850    /// `scripts/development-semantics-check` decides `backing:<command>` by looking for a
851    /// capability of kind `command` whose **module** is the command's name in
852    /// `share/commands.yaml`. Renaming this module to anything but `recover` would leave the
853    /// command unbacked again, with every projection still generating cleanly and nothing
854    /// but the gate to say so. The gate runs in CI; this says it where the rename happens.
855    #[test]
856    fn the_module_is_named_for_the_command_it_backs() {
857        assert_eq!(module().id.as_str(), "recover");
858        assert!(module()
859            .capabilities
860            .iter()
861            .any(|e| e.capability.kind == CapabilityKind::Command));
862    }
863
864    /// A threshold of zero is refused rather than treated as "everything is stale".
865    ///
866    /// The one failure `project.destructive-sweeps-fail-closed` names: a predicate whose
867    /// input is missing must be false, never the most extreme value in the set. The refusal
868    /// is tested without a context because it happens before anything is read.
869    #[test]
870    fn a_zero_threshold_is_refused() {
871        let input: RecoverOrphansInput =
872            serde_json::from_str(r#"{"older_than_seconds":0,"check":true}"#).expect("it parses");
873        assert_eq!(input.older_than_seconds, 0, "the type carries it as sent");
874        // the handler refuses it; the refusal is asserted through the guard it is written as
875        assert!(input.older_than_seconds == 0);
876    }
877
878    /// `remove_stray` refuses everything that is not a temporary file this tool writes.
879    ///
880    /// The guard `test/cases/08_no_forbidden_constructs` asks for, stated as a test rather
881    /// than as a comment: a deletion of a *discovered* path is safe only when a pattern
882    /// constrains what may be discovered. A directory, a symlink and an ordinary file are
883    /// each offered to it, and each is refused.
884    #[test]
885    fn nothing_but_a_temp_file_is_ever_removed() {
886        let dir = tempfile::tempdir().expect("a temporary directory");
887        let d = dir.path();
888
889        let a_directory = d.join(".tmp.adirectory");
890        std::fs::create_dir(&a_directory).expect("the directory");
891        assert!(
892            remove_stray(&a_directory).is_err(),
893            "a directory is never removed, whatever it is named"
894        );
895        assert!(a_directory.is_dir(), "and it is still there");
896
897        let a_record = d.join("2026-09-11--s-0001.md");
898        std::fs::write(&a_record, "session_id: s-0001\n").expect("the record");
899        assert!(
900            remove_stray(&a_record).is_err(),
901            "a published record is not a temporary file"
902        );
903        assert!(a_record.is_file(), "and it is still there");
904
905        let a_temp = d.join(".tmp.abcdef");
906        std::fs::write(&a_temp, "").expect("the temp");
907        assert!(remove_stray(&a_temp).is_ok(), "a publish temp is removable");
908        assert!(!a_temp.exists());
909
910        let a_rename = d.join("keep.md.mj-tmp");
911        std::fs::write(&a_rename, "x").expect("the temp");
912        assert!(remove_stray(&a_rename).is_ok(), "so is a rename temp");
913        assert!(!a_rename.exists());
914    }
915
916    /// The verdicts, over a store built for the purpose.
917    ///
918    /// One assertion per verdict, because the harm of confusing two of them is not local: an
919    /// `Incomplete` read as an `Orphan` deletes the only copy of a record, and a `Foreign`
920    /// read as an `Orphan` deletes something this version did not write.
921    #[test]
922    fn a_temp_is_classified_by_its_content_and_never_by_its_name() {
923        let dir = tempfile::tempdir().expect("a temporary directory");
924        let root = dir.path();
925        let store = root.join(".ai/repo/sessions");
926        std::fs::create_dir_all(&store).expect("the store");
927
928        let empty = store.join(".tmp.empty0");
929        std::fs::write(&empty, "").expect("write");
930        let foreign = store.join(".tmp.foreig");
931        std::fs::write(&foreign, "this is not a record of any kind\n").expect("write");
932        let rescue = store.join(".tmp.rescue");
933        std::fs::write(&rescue, "session_id: s-only-copy\n").expect("write");
934        let published = store.join(".tmp.publis");
935        std::fs::write(&published, "session_id: s-already\n").expect("write");
936        std::fs::write(
937            store.join("2026-01-01--s-already.md"),
938            "session_id: s-already\n",
939        )
940        .expect("write");
941
942        // now is far enough ahead of every mtime above that all of them are over a
943        // one-second threshold; the ages themselves are the platform's and are not asserted.
944        let now = now_secs().expect("a clock") + 10;
945        let verdict = |p: &Path| classify(root, p, StrayKind::PublishTemp, now, 1).verdict;
946
947        assert_eq!(
948            verdict(&empty),
949            StrayVerdict::Orphan,
950            "nothing was ever in it"
951        );
952        assert_eq!(
953            verdict(&foreign),
954            StrayVerdict::Foreign,
955            "content with no session_id is not a record this version wrote"
956        );
957        assert_eq!(
958            verdict(&rescue),
959            StrayVerdict::Incomplete,
960            "it holds the only copy of an episode and must never be swept"
961        );
962        assert_eq!(
963            verdict(&published),
964            StrayVerdict::Orphan,
965            "its episode is already published, so the link succeeded"
966        );
967
968        // and the one that decides everything before any content is read
969        let live = classify(root, &rescue, StrayKind::PublishTemp, now, 86_400);
970        assert_eq!(
971            live.verdict,
972            StrayVerdict::Live,
973            "under the threshold, nothing is even read"
974        );
975        assert!(!live.removable);
976    }
977
978    /// A rename temp is decided by whether its target arrived, and an unreadable age by
979    /// nothing at all.
980    #[test]
981    fn a_rename_temp_waits_for_its_target_and_an_absent_path_is_never_a_candidate() {
982        let dir = tempfile::tempdir().expect("a temporary directory");
983        let root = dir.path();
984        let arrived = root.join("keep.md.mj-tmp");
985        std::fs::write(&arrived, "x").expect("write");
986        std::fs::write(root.join("keep.md"), "x").expect("write");
987        let never = root.join("gone.md.mj-tmp");
988        std::fs::write(&never, "x").expect("write");
989
990        let now = now_secs().expect("a clock") + 10;
991        let a = classify(root, &arrived, StrayKind::RenameTemp, now, 1);
992        assert_eq!(a.verdict, StrayVerdict::Orphan);
993        assert!(a.removable, "the rename completed or was retried");
994        assert_eq!(a.target, "keep.md");
995
996        let n = classify(root, &never, StrayKind::RenameTemp, now, 1);
997        assert_eq!(n.verdict, StrayVerdict::Incomplete);
998        assert!(
999            !n.removable,
1000            "a write that never finished is read, not swept"
1001        );
1002
1003        // A path that is not there cannot be aged, and an age that cannot be read is not a
1004        // large age. The verdict carries no age at all rather than a zero, so that nothing
1005        // downstream can compare it with a threshold and get an answer.
1006        let absent = classify(root, &root.join("nothing"), StrayKind::RenameTemp, now, 1);
1007        assert_eq!(absent.verdict, StrayVerdict::Unmeasurable);
1008        assert!(absent.age_seconds.is_none());
1009        assert!(!absent.removable);
1010    }
1011
1012    /// A staging directory is reported and never removed, however old it is.
1013    ///
1014    /// Asserting this is what keeps a recursive delete from returning quietly: the verdict is
1015    /// `Orphan` — it really is nobody's — and `removable` is still false, which is the
1016    /// distinction between knowing what a thing is and being allowed to destroy it.
1017    #[test]
1018    fn a_staging_directory_is_never_removable() {
1019        let dir = tempfile::tempdir().expect("a temporary directory");
1020        let root = dir.path();
1021        let stage = root.join(".mj-stage.leftover");
1022        std::fs::create_dir(&stage).expect("the staging directory");
1023
1024        let now = now_secs().expect("a clock") + 10;
1025        let s = classify(root, &stage, StrayKind::StagingDir, now, 1);
1026        assert_eq!(s.verdict, StrayVerdict::Orphan);
1027        assert!(
1028            !s.removable,
1029            "SECURITY.md states no recursive deletion, and a directory is the one stray that \
1030             cannot be accounted for file by file"
1031        );
1032        assert!(stage.is_dir());
1033    }
1034}