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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
// The machine-wide registry of runs: `$XDG_STATE_HOME/rhei/runs/<id>.json`.
//
// Entries exist to map a bare id to a workspace; the workspace's own
// descriptor is the authoritative copy. An entry outlives its run, because the
// question `rhei attach <id>` answers after a run ends — "how did it go?" — is
// the one most worth answering.
// §FS-rhei-run-headless.2 §FS-rhei-run-headless.3 §FS-rhei-run-headless.6
/// How many ended runs the registry keeps. Retention is what makes a run id
/// resolvable after the fact; a cap is what keeps that from becoming an
/// unbounded directory on a machine that runs a plan every minute.
// §FS-rhei-run-headless.2
const RETAINED_ENDED_RUNS: usize = 100;
/// The machine-wide registry directory of runs.
/// `$XDG_STATE_HOME/rhei/runs`, falling back to `~/.local/state/rhei/runs`.
// §FS-rhei-run-headless.2
pub(crate) fn run_registry_dir() -> Option<PathBuf> {
let base = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))?;
Some(base.join("rhei").join("runs"))
}
pub(crate) fn run_registry_path(id: &str) -> Option<PathBuf> {
Some(run_registry_dir()?.join(format!("{id}.json")))
}
/// A registry entry the sweep could not decide about — an unreadable workspace,
/// a lock it could not probe, an entry a newer `rhei` wrote in a shape this
/// build does not understand.
// §FS-rhei-run-headless.3
pub(crate) struct UndecidedRun {
pub(crate) path: PathBuf,
pub(crate) descriptor: Option<RunDescriptor>,
pub(crate) reason: String,
}
impl UndecidedRun {
pub(crate) fn summary_line(&self) -> String {
match &self.descriptor {
Some(descriptor) => descriptor.summary_line(),
None => format!("(unreadable entry) {}", self.path.display()),
}
}
}
/// What one pass over the registry found.
#[derive(Default)]
pub(crate) struct RegistrySweep {
pub(crate) live: Vec<RunDescriptor>,
/// Runs that are over but whose workspace still names them.
pub(crate) ended: Vec<RunDescriptor>,
pub(crate) undecided: Vec<UndecidedRun>,
}
impl RegistrySweep {
/// Every entry this pass could not call finished: the live runs first, then
/// the ones whose liveness it could not decide.
///
/// An undecided entry is not an ended entry, and the commands that act on a
/// run — `attach`, `stop` — must be able to reach it. An entry too damaged
/// to parse has no descriptor and so cannot be named here at all.
// §FS-rhei-run-headless.3
pub(crate) fn not_known_to_have_ended(&self) -> Vec<&RunDescriptor> {
self.live
.iter()
.chain(self.undecided.iter().filter_map(|entry| entry.descriptor.as_ref()))
.collect()
}
}
/// Read every registry entry and classify it, pruning only what is provably
/// gone.
///
/// Pruning here rather than in a separate sweep is deliberate: the listing is
/// the only thing that reliably runs. But it is *destructive*, and an entry is
/// removed only when the workspace no longer names it. Anything unreadable is
/// kept and reported, because an unreadable file says nothing about the
/// process it describes.
// §FS-rhei-run-headless.3 §FS-rhei-run-headless.6
pub(crate) fn sweep_run_registry() -> RegistrySweep {
classify_run_registry(Pruning::Prune)
}
/// The same classification with nothing removed.
///
/// Shell completion reads the registry on a tab keypress, and a keypress must
/// not unlink a file: the operator did not ask for anything to happen yet, and
/// `rhei runs` — which the operator *did* ask for — prunes the same entries a
/// moment later anyway.
// §FS-rhei-run-headless.3
pub(crate) fn read_run_registry() -> RegistrySweep {
classify_run_registry(Pruning::Keep)
}
/// Whether a classification pass is allowed to remove what it finds.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Pruning {
Prune,
Keep,
}
fn classify_run_registry(pruning: Pruning) -> RegistrySweep {
let mut sweep = RegistrySweep::default();
let Some(dir) = run_registry_dir() else {
return sweep;
};
let Ok(entries) = fs::read_dir(&dir) else {
return sweep;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let descriptor = match read_descriptor_result(&path) {
DescriptorRead::Loaded(descriptor) => *descriptor,
// Raced with another sweep's prune; nothing to report.
DescriptorRead::Missing => continue,
// An entry a newer `rhei` wrote, or one this process could not
// read. Deleting it would let an older binary destroy a newer
// one's registry.
DescriptorRead::Unreadable(why) => {
sweep.undecided.push(UndecidedRun {
path,
descriptor: None,
reason: format!("the entry itself could not be read: {why}"),
});
continue;
}
};
match descriptor.liveness() {
Liveness::Live => sweep.live.push(descriptor),
Liveness::Ended => sweep.ended.push(descriptor),
Liveness::Gone => {
if pruning == Pruning::Prune {
let _ = fs::remove_file(&path);
}
}
Liveness::Unknown(reason) => {
sweep.undecided.push(UndecidedRun {
path,
descriptor: Some(descriptor),
reason,
});
}
}
}
newest_first(&mut sweep.live);
newest_first(&mut sweep.ended);
cap_ended_entries(&mut sweep.ended, pruning);
sweep
}
fn newest_first(runs: &mut [RunDescriptor]) {
runs.sort_by(|a, b| b.started_at.cmp(&a.started_at).then_with(|| a.id.cmp(&b.id)));
}
/// Drop the oldest ended entries past the retention cap. Sorted newest first
/// already, so the tail is exactly what goes.
fn cap_ended_entries(ended: &mut Vec<RunDescriptor>, pruning: Pruning) {
if ended.len() <= RETAINED_ENDED_RUNS {
return;
}
for descriptor in ended.drain(RETAINED_ENDED_RUNS..) {
let Some(path) = run_registry_path(&descriptor.id) else {
continue;
};
if pruning == Pruning::Keep {
continue;
}
// Re-read before removing: an entry file whose name and contents
// disagree must not take a different run's pointer down with it.
if read_descriptor(&path)
.is_some_and(|entry| entry.id == descriptor.id && entry.pid == descriptor.pid)
{
let _ = fs::remove_file(path);
}
}
}
/// Resolve a run reference: an exact id, a unique id prefix, a path, or — with
/// no reference at all — the enclosing workspace's run.
///
/// Two tiers, and the split is *not* live-versus-ended: it is **not known to
/// have ended** versus ended. An entry whose liveness could not be decided is
/// resolvable, because the run it names may well be working right now — and a
/// reference that stops resolving the moment a lock file becomes unreadable
/// takes `attach` and `stop` away exactly when they are needed. Within the
/// first tier a decidedly live run still wins an exact tie.
///
/// Ended entries come last because they accumulate: a prefix that resolves
/// today would otherwise start reporting "matches 4 runs" tomorrow, for runs
/// the operator has forgotten.
// §FS-rhei-run-headless.3
pub(crate) fn resolve_run(reference: Option<&str>) -> MietteResult<RunDescriptor> {
let Some(reference) = reference else {
return descriptor_for_path(Path::new("."));
};
let sweep = sweep_run_registry();
let current = sweep.not_known_to_have_ended();
if let Some(exact) = current.iter().find(|run| run.id == reference) {
return Ok((*exact).clone());
}
// A path is checked before a prefix so a directory named like an id — or an
// id-shaped plan stem — resolves to the thing the operator can see.
let as_path = Path::new(reference);
if as_path.exists() {
return descriptor_for_path(as_path);
}
match prefix_matches(¤t, reference).as_slice() {
[] => {}
[only] => return Ok((*only).clone()),
ambiguous => return Err(ambiguous_reference(reference, ambiguous, "runs")),
}
let ended = sweep.ended.iter().collect::<Vec<_>>();
if let Some(exact) = ended.iter().find(|run| run.id == reference) {
return Ok((*exact).clone());
}
match prefix_matches(&ended, reference).as_slice() {
[] => {}
[only] => return Ok((*only).clone()),
ambiguous => return Err(ambiguous_reference(reference, ambiguous, "runs that have ended")),
}
// Not "no *live* run": an ended run resolves too, so pointing only at the
// live listing points away from the answer. §FS-rhei-run-headless.2
Err(miette!(
help = "`rhei runs` lists what is live; a run that has ended resolves by its own id \
until it falls out of the 100 the registry keeps",
"no run matches '{reference}'"
))
}
fn prefix_matches<'a>(runs: &[&'a RunDescriptor], reference: &str) -> Vec<&'a RunDescriptor> {
runs.iter().copied().filter(|run| run.id.starts_with(reference)).collect()
}
/// How many candidates an ambiguous reference lists before it summarizes.
/// A hundred retained entries share prefixes freely, and 203 lines of
/// miette-wrapped output is not an answer to "which one did you mean?".
// §FS-rhei-run-headless.3
const LISTED_AMBIGUOUS_MATCHES: usize = 10;
fn ambiguous_reference(
reference: &str,
matches: &[&RunDescriptor],
what: &str,
) -> miette::Report {
let mut listed = matches
.iter()
.take(LISTED_AMBIGUOUS_MATCHES)
.map(|run| run.summary_line())
.collect::<Vec<_>>();
if let Some(rest) = matches.len().checked_sub(LISTED_AMBIGUOUS_MATCHES).filter(|n| *n > 0) {
listed.push(format!("... and {rest} more"));
}
miette!(
help = "name one of these runs in full",
"'{reference}' matches {} {what}:\n {}",
matches.len(),
listed.join("\n ")
)
}
/// The descriptor a plan path or workspace directory points at.
fn descriptor_for_path(path: &Path) -> MietteResult<RunDescriptor> {
let workspace = execution_workspace_root(&normalize_workspace_input(path));
let descriptor_path = run_descriptor_path(&workspace);
read_descriptor(&descriptor_path).ok_or_else(|| {
miette!(
help = format!(
"start one with `rhei run --headless {}`, or list live runs with `rhei runs`",
shell_quote(&path.display().to_string())
),
"no run has been recorded for {} (looked for {})",
workspace.display(),
descriptor_path.display()
)
})
}