rhei-cli 0.2.0

Command-line driver for the Rhei agent runtime.
Documentation
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
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
// `rhei run --headless`: re-execute `rhei run` in its own session and hand the
// operator its id.
//
// There is no daemon here and no second execution path. The child is an
// ordinary `rhei run` that happens to have been started with its console
// redirected and its session detached; it takes the same locks, drives the same
// loop, and writes the same report.

// §FS-rhei-run-headless.1 §DA-detached-runs

/// Environment marker telling a spawned `rhei run` that it is the detached
/// child, so it records itself as headless and stays alive for human gates.
/// Cleared again for every subprocess the run supervises, so an agent's own
/// `rhei run` is not mistaken for this one.
// §FS-rhei-run-headless.1.2
pub(crate) const HEADLESS_CHILD_ENV: &str = "RHEI_HEADLESS_CHILD";

/// Flags the launcher consumes rather than forwarding. `--headless` would make
/// the child detach again; `--json` and its companion describe *the launcher's*
/// output, and a JSON stream into a log file nobody reads is not what the
/// caller asked for — the detached run's machine-readable form is
/// `runtime/events.jsonl`.
// §FS-rhei-run-headless.1
const LAUNCHER_ONLY_FLAGS: [&str; 3] = ["--headless", "--json", "--json-agent-output"];

/// How long to wait for the child to report itself running before giving up on
/// the handshake. Generous: a large project's initial validation is real work.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
const HANDSHAKE_POLL: Duration = Duration::from_millis(50);

/// The launcher's own lock, inside the workspace it is about to launch into.
/// Distinct from the run lock: it covers the launcher's non-atomic stretch —
/// pre-check, truncate `run.log`, spawn, handshake — which two simultaneous
/// launches would otherwise interleave, clobbering one another's console log
/// and each waiting out the full handshake on the other's child.
// §FS-rhei-run-headless.1.1
const HEADLESS_LAUNCH_LOCK: &str = "headless-launch.lock";

/// Whether this process is the detached child of a `--headless` launch.
pub(crate) fn is_headless_child() -> bool {
    std::env::var_os(HEADLESS_CHILD_ENV).is_some()
}

/// The one-live-run-per-rhei refusal, naming the run that is in the way.
///
/// Shared by the launcher's pre-check and the detached child's own fail-fast,
/// so an operator sees one diagnostic whichever of the two noticed first.
// §FS-rhei-run.2.6
pub(crate) fn run_lock_conflict(root: &Path) -> miette::Report {
    match read_descriptor(&run_descriptor_path(root)).filter(|run| !run.liveness().has_ended()) {
        Some(live) => miette!(
            help = format!(
                "watch it with `rhei attach {id}`, or stop it with `rhei stop {id}`",
                id = live.id
            ),
            "a run is already live on {}:\n  {}",
            root.display(),
            live.summary_line()
        ),
        None => miette!(
            help = "see what is live on this machine with: rhei runs",
            "a run is already live on {} and holds its .rhei/run.lock",
            root.display()
        ),
    }
}

/// Launch a detached run and report its id. §FS-rhei-run-headless.1
pub(crate) fn launch_headless_run(
    input: &Path,
    json: bool,
    announce_dashboard: bool,
) -> MietteResult<()> {
    let workspace_root = execution_workspace_root(&normalize_workspace_input(input));
    // Held from here to the end of the handshake. §FS-rhei-run-headless.1.1
    let _launch_lock = acquire_launch_lock(&workspace_root)?;
    // Refuse before spawning anything when a live run already owns this
    // workspace: the child would fail on the lock a moment later, and this way
    // the diagnostic names the run that is in the way.

    // This is the one place an undecided probe may be read as "go ahead": the
    // child takes the real run lock a moment later and fails fast on it, so a
    // pre-check that guesses wrong costs a worse diagnostic and never a second
    // run. Written as a match so the choice is visible rather than hidden in a
    // helper.

    // §FS-rhei-run.2.6 §FS-rhei-run-headless.3 §FS-rhei-run-headless.1.1
    let occupied = read_descriptor(&run_descriptor_path(&workspace_root))
        .is_some_and(|run| match run.liveness() {
            Liveness::Live => true,
            Liveness::Ended | Liveness::Gone | Liveness::Unknown(_) => false,
        });
    if occupied {
        return Err(run_lock_conflict(&workspace_root));
    }

    if run_registry_dir().is_none() {
        return Err(miette!(
            help = "set HOME or XDG_STATE_HOME, or run in the foreground with `rhei run --no-tui`",
            "a detached run needs a state directory to publish its id into, and neither \
             XDG_STATE_HOME nor HOME is set"
        ));
    }

    let log_path = run_console_log_path(&workspace_root);
    let mut child = spawn_detached_run(&log_path)?;
    let pid = child.id();

    match await_child_ready(&mut child, pid, &workspace_root) {
        Ok(LaunchOutcome::Running(descriptor)) => {
            report_launched(&descriptor, json, announce_dashboard);
            warn_if_unregistered(&descriptor);
            Ok(())
        }
        Ok(LaunchOutcome::FinishedEarly(descriptor)) => {
            report_finished_early(&descriptor, json);
            warn_if_unregistered(&descriptor);
            Ok(())
        }
        // A run that exited `0` did what it was asked; there is simply nothing
        // left to attach to. Reporting that as a startup failure would fail a
        // CI step for a plan that succeeded. §FS-rhei-run-headless.1.1
        Err(HandshakeFailure::Exited(status)) if status.success() => {
            eprintln!(
                "The run finished before it published a descriptor, so there is nothing to \
                 attach to.\n  its console is at {}",
                log_path.display()
            );
            Ok(())
        }
        Err(HandshakeFailure::Exited(status)) => Err(miette!(
            help = format!("the run's console is at {}", log_path.display()),
            "the run exited before it started ({}):\n{}",
            exit_status_text(&status),
            indent_block(&log_tail(&log_path, 20))
        )),
        Err(HandshakeFailure::TimedOut) => Err(miette!(
            help = format!(
                "it may still be starting: check `rhei runs`, or read {}",
                log_path.display()
            ),
            "the run (pid {pid}) did not report itself ready within {}s",
            HANDSHAKE_TIMEOUT.as_secs()
        )),
    }
}

/// Take the launcher's lock, or refuse without waiting. A second launcher on
/// this workspace is a mistake to report, not a queue to join.
// §FS-rhei-run-headless.1.1
fn acquire_launch_lock(workspace_root: &Path) -> MietteResult<HeldRunLock> {
    let rhei_dir = workspace_root.join(".rhei");
    fs::create_dir_all(&rhei_dir)
        .map_err(|err| file_io_report(&rhei_dir, "failed to create .rhei directory", err))?;
    let path = rhei_dir.join(HEADLESS_LAUNCH_LOCK);
    let file = fs::OpenOptions::new()
        .create(true)
        .truncate(false)
        .read(true)
        .write(true)
        .open(&path)
        .map_err(|err| file_io_report(&path, "failed to open the headless launch lock", err))?;
    match file.try_lock_exclusive() {
        Ok(()) => Ok(HeldRunLock { file }),
        Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
            Err(concurrent_launch_report(workspace_root))
        }
        Err(err) => {
            Err(file_io_report(&path, "failed to inspect the headless launch lock", err))
        }
    }
}

/// The diagnostic for losing the launch race. It must not read like a run-lock
/// failure: nothing is wrong with the workspace, another launcher simply got
/// there first and its run is the one to talk about.
// §FS-rhei-run-headless.1.1
fn concurrent_launch_report(workspace_root: &Path) -> miette::Report {
    match read_descriptor(&run_descriptor_path(workspace_root)).filter(|run| !run.liveness().has_ended())
    {
        Some(live) => miette!(
            help = format!("watch it with `rhei attach {id}`", id = live.id),
            "another `rhei run --headless` is starting a run on {}:\n  {}",
            workspace_root.display(),
            live.summary_line()
        ),
        None => miette!(
            help = "wait for it to print its id, then see `rhei runs`",
            "another `rhei run --headless` is already starting a run on {}",
            workspace_root.display()
        ),
    }
}

/// Say so when the id the launcher just printed does not resolve.
///
/// The child warns about a registry it could not write, but that warning goes
/// into `runtime/run.log`, which nobody is reading yet. Without this the
/// operator is handed an id that `rhei attach` will not accept and no reason
/// why.
// §FS-rhei-run-headless.2
fn warn_if_unregistered(descriptor: &RunDescriptor) {
    let registered =
        run_registry_path(&descriptor.id).is_some_and(|entry| read_descriptor(&entry).is_some());
    if registered {
        return;
    }
    eprintln!(
        "warning: run {} has no registry entry, so its id will not resolve from another \
         directory.\n  reach it by path instead: rhei attach {}",
        descriptor.id,
        shell_quote(&descriptor.workspace.display().to_string())
    );
}

fn report_launched(descriptor: &RunDescriptor, json: bool, announce_dashboard: bool) {
    if json {
        println!("{}", descriptor_json(descriptor));
        return;
    }
    println!("Run {} started headless (pid {}).", descriptor.id, descriptor.pid);
    println!("  attach:  rhei attach {}", descriptor.id);
    println!("  stop:    rhei stop {}", descriptor.id);
    if let Some(log) = &descriptor.log {
        println!("  log:     {}", log.display());
    }
    // Withheld under `--no-dashboard`: the control server is up because an
    // attached surface needs it, but nobody asked to be sent to a browser.
    // §FS-rhei-run-headless.4
    if announce_dashboard {
        if let Some(url) = &descriptor.control_url {
            println!("  browser: {url}");
        }
    }
}

/// A run short enough to finish inside the handshake window. It started, it
/// worked, and it ended — the id still resolves, so say so rather than call a
/// completed plan a failed launch. §FS-rhei-run-headless.1.1
fn report_finished_early(descriptor: &RunDescriptor, json: bool) {
    if json {
        // The record still carries the id, so the CI shape of §5.3 keeps
        // working; the prose about it belongs on stderr.
        println!("{}", descriptor_json(descriptor));
        eprintln!("Run {} finished before the launcher returned.", descriptor.id);
        return;
    }
    println!("Run {} finished before the launcher returned.", descriptor.id);
    match descriptor.exit_code {
        Some(code) => println!("  It exited {code}."),
        None => println!("  It recorded no exit status."),
    }
    println!("  attach:  rhei attach {}", descriptor.id);
    if let Some(log) = &descriptor.log {
        println!("  log:     {}", log.display());
    }
}

fn descriptor_json(descriptor: &RunDescriptor) -> String {
    serde_json::to_string(descriptor).unwrap_or_else(|_| format!(r#"{{"id":"{}"}}"#, descriptor.id))
}

enum HandshakeFailure {
    /// The child exited before publishing — an invalid plan, a held lock, an
    /// unresolvable agent. Its own diagnostic is in the console log.
    Exited(std::process::ExitStatus),
    TimedOut,
}

/// How a launch ended, both ways being a launch that worked.
enum LaunchOutcome {
    Running(RunDescriptor),
    FinishedEarly(RunDescriptor),
}

/// `ExitStatus`'s own `Display` is already a sentence ("exit status: 0"), so
/// interpolating it after the word "status" said it twice.
fn exit_status_text(status: &std::process::ExitStatus) -> String {
    if let Some(code) = status.code() {
        return format!("exit status {code}");
    }
    #[cfg(unix)]
    {
        use std::os::unix::process::ExitStatusExt as _;
        if let Some(signal) = status.signal() {
            return format!("killed by signal {signal}");
        }
    }
    "no exit status".to_string()
}

/// Wait for the child to publish a descriptor naming its own pid, or to exit.
///
/// The **workspace** descriptor is the handshake, not the registry entry: the
/// launcher already knows the workspace, and a registry the launcher never
/// checked it could write turned an unwritable state directory into a
/// 30-second wait for a run that was working the whole time.
///
/// Matching on the pid is what makes this race-free without deleting anything:
/// a descriptor left by an earlier run in the same workspace names a different
/// process, so it can never be mistaken for this one's handshake.
// §FS-rhei-run-headless.1.1
fn await_child_ready(
    child: &mut std::process::Child,
    pid: u32,
    workspace_root: &Path,
) -> Result<LaunchOutcome, HandshakeFailure> {
    let descriptor_path = run_descriptor_path(workspace_root);
    let deadline = Instant::now() + HANDSHAKE_TIMEOUT;
    loop {
        if let Some(outcome) = child_outcome(&descriptor_path, pid) {
            return Ok(outcome);
        }
        // Checked after the descriptor so a run that started and finished
        // inside one poll interval is still reported as started.
        if let Ok(Some(status)) = child.try_wait() {
            if let Some(outcome) = child_outcome(&descriptor_path, pid) {
                return Ok(outcome);
            }
            return Err(HandshakeFailure::Exited(status));
        }
        if Instant::now() >= deadline {
            return Err(HandshakeFailure::TimedOut);
        }
        std::thread::sleep(HANDSHAKE_POLL);
    }
}

/// What the workspace descriptor says about this child, if it is this child's.
/// A run that recorded a *failing* end is left to the exit path, which has the
/// console tail to explain it with.
fn child_outcome(descriptor_path: &Path, pid: u32) -> Option<LaunchOutcome> {
    let descriptor = read_descriptor(descriptor_path).filter(|run| run.pid == pid)?;
    match descriptor.status {
        RunStatus::Running => Some(LaunchOutcome::Running(descriptor)),
        RunStatus::Finished if descriptor.exit_code == Some(0) => {
            Some(LaunchOutcome::FinishedEarly(descriptor))
        }
        RunStatus::Finished | RunStatus::Failed => None,
    }
}

/// Re-execute this binary's `rhei run` invocation, minus the launcher-only
/// flags, in a new session with its console redirected.
fn spawn_detached_run(log_path: &Path) -> MietteResult<std::process::Child> {
    if !cfg!(unix) {
        return Err(miette!(
            help = "run it in the foreground instead: rhei run --no-tui <plan>",
            "`--headless` needs a POSIX session to detach into and is not supported on this \
             platform yet"
        ));
    }

    let exe = std::env::current_exe().map_err(|err| {
        miette!(
            help = "a detached run re-executes this binary, so it must still be on disk",
            "could not locate the rhei binary to re-execute: {err}"
        )
    })?;
    if let Some(parent) = log_path.parent() {
        fs::create_dir_all(parent)
            .map_err(|err| file_io_report(parent, "failed to create the runtime directory", err))?;
    }
    // Truncated per run: one file is one run's console, superseded rather than
    // accumulated. Safe under the launch lock, which is what keeps a second
    // launcher from truncating this one's log. §FS-rhei-run-headless.8
    let log = fs::File::create(log_path)
        .map_err(|err| file_io_report(log_path, "failed to open the run console log", err))?;
    let stderr = log
        .try_clone()
        .map_err(|err| file_io_report(log_path, "failed to open the run console log", err))?;

    let mut command = std::process::Command::new(exe);
    command.args(child_arguments());
    command.env(HEADLESS_CHILD_ENV, "1");
    command.stdin(std::process::Stdio::null());
    command.stdout(std::process::Stdio::from(log));
    command.stderr(std::process::Stdio::from(stderr));
    detach_session(&mut command);

    command.spawn().map_err(|err| {
        miette!(
            help = "check that the rhei binary is still on disk and executable",
            "could not start the detached run: {err}"
        )
    })
}

/// Put the child in its own session, so the launching terminal's `SIGHUP`
/// cannot reach it.
/// `setsid` and not `Command::process_group`: the latter makes the child a
/// process-group leader, and `setsid` then fails with `EPERM`. `setsid` alone
/// creates both a new session and a new process group, which is what
/// detachment needs.
/// The child deliberately does **not** arm a parent-death signal. That backstop
/// belongs to supervised *work*; armed on the
/// supervisor it would kill every detached run the moment its launcher
/// returned.
// §FS-rhei-run-headless.1 §DA-supervised-process-groups §DA-detached-runs
#[cfg(unix)]
fn detach_session(command: &mut std::process::Command) {
    use std::os::unix::process::CommandExt;
    // SAFETY: `setsid` is async-signal-safe and the closure does nothing else.
    unsafe {
        command.pre_exec(|| {
            nix::unistd::setsid()
                .map(|_| ())
                .map_err(|err| std::io::Error::from_raw_os_error(err as i32))
        });
    }
}

#[cfg(not(unix))]
fn detach_session(_command: &mut std::process::Command) {}

/// This process's own arguments with the launcher-only flags removed, so the
/// child re-runs exactly what was asked for.
///
/// Filtering stops at `--`: everything after it is an operand, and a plan named
/// `--json` is a plan, not a flag. Dropping it there silently ran a different
/// plan than the one the operator typed.
// §FS-rhei-run-headless.1
fn child_arguments() -> Vec<std::ffi::OsString> {
    child_arguments_from(std::env::args_os().skip(1))
}

fn child_arguments_from(
    given: impl IntoIterator<Item = std::ffi::OsString>,
) -> Vec<std::ffi::OsString> {
    let separator = std::ffi::OsStr::new("--");
    let mut arguments = Vec::new();
    let mut past_separator = false;
    for argument in given {
        if past_separator {
            arguments.push(argument);
            continue;
        }
        if argument == separator {
            past_separator = true;
        } else if LAUNCHER_ONLY_FLAGS.iter().any(|flag| argument == std::ffi::OsStr::new(flag)) {
            continue;
        }
        arguments.push(argument);
    }
    arguments
}

/// The last `lines` non-empty lines of a console log, for a startup diagnostic.
fn log_tail(path: &Path, lines: usize) -> String {
    let Ok(contents) = fs::read_to_string(path) else {
        return format!("(no console output at {})", path.display());
    };
    let tail: Vec<&str> = contents.lines().filter(|line| !line.trim().is_empty()).collect();
    if tail.is_empty() {
        return format!("(the run wrote nothing to {})", path.display());
    }
    tail[tail.len().saturating_sub(lines)..].join("\n")
}

fn indent_block(text: &str) -> String {
    text.lines().map(|line| format!("  {line}")).collect::<Vec<_>>().join("\n")
}