majordomus_cli/episodes.rs
1//! Episodes: the execution episode of a client that has no provider hooks, drawn by the
2//! MCP connection itself (ADR 0103).
3//!
4//! # The defect this exists for
5//!
6//! Majordomus draws the episode boundary below the model, so that the episode which
7//! mattered — the one that ended in a crash, a compaction, or somebody closing the window —
8//! is recorded whether or not a model remembered to record it. Until this module that was
9//! wired for exactly one provider. Claude Code fires `SessionStart` and `SessionEnd`, a
10//! shim runs `capture session`, and the repository gets an episode. Every other client got
11//! nothing at all, and the repository's claim to draw the boundary below the model was a
12//! claim about one vendor.
13//!
14//! A client with no hooks still has a boundary, and this server is standing on it: the
15//! client speaks `initialize`, makes calls, and goes. That is an episode. The connection is
16//! the event source, the peer board already tracks attach, activity and detach, and the
17//! repository's own `majordomus capture session` already opens and closes episodes for a
18//! provider that names its session. Nothing new needs inventing; the three had to be joined.
19//!
20//! # A peer is not an episode
21//!
22//! They are deliberately separate types and the distinction has cost real time here. A peer
23//! id (`p1`, `p2`, ...) is *this process's* name for *this connection*, handed out in
24//! attachment order and reused by the next server. It is not a worker identity: a session
25//! that reconnects comes back as a different peer, and on 2026-09-09 a session in this
26//! repository announced, was re-established under a new id, and was invisible to eight
27//! others for three hours because everybody had been treating the id as durable.
28//!
29//! So an episode is keyed by an **external identity** the client supplies — its own
30//! conversation id, thread id, whatever it durably calls itself — and the peer holding it is
31//! a field that changes. The invariants:
32//!
33//! * a connection holds **zero or one** episode: `initialize` alone opens nothing, and a
34//! client that never calls `episodes.attach` is a peer and not a worker with a record;
35//! * a client that reconnects **re-associates** with its own episode by external identity,
36//! under whatever peer id it now has;
37//! * an episode outlives the connection that opened it, for a bounded grace, because a
38//! dropped socket is not the end of somebody's work — it is the most ordinary thing that
39//! happens to one.
40//!
41//! # What is honestly not here
42//!
43//! Raw prompt capture. An MCP server is handed `initialize`, tool calls and notifications;
44//! the person's prompt is never among them, in any version of the protocol. Prompt capture
45//! stays provider-specific, `share/providers.yaml` declares `prompts: none` for the generic
46//! provider with that reasoning written down, and no surface implies otherwise.
47//!
48//! # The lifecycle, end to end
49//!
50//! A client attaches under its own name, its connection drops, it comes back as a different
51//! peer under the same name and gets the same episode, and then it says it is done:
52//!
53//! ```
54//! use majordomus_cli::episodes::{CloseReason, ConnectionEpisodeState, EpisodeBoard};
55//! use majordomus_cli::peers::{PeerBoard, Transport};
56//!
57//! let peers = PeerBoard::new();
58//! let board = EpisodeBoard::new(); // records nothing in a repository
59//! let first = peers.attach(Transport::Http);
60//! let (opened, resumed) = board.attach(&first, "thread-7", "generic");
61//! assert!(!resumed);
62//!
63//! board.detach(&first); // the socket went; the work did not
64//! assert_eq!(board.list()[0].state, ConnectionEpisodeState::Detached);
65//!
66//! let second = peers.attach(Transport::Http); // a new peer id, the same worker
67//! let (again, resumed) = board.attach(&second, "thread-7", "generic");
68//! assert!(resumed);
69//! assert_eq!((again.opened_at.as_str(), again.attachments), (opened.opened_at.as_str(), 2));
70//!
71//! let closed = board.close("thread-7", CloseReason::Detach).unwrap();
72//! assert_eq!(closed.external_id, "thread-7");
73//! assert!(board.list().is_empty());
74//! ```
75
76use std::collections::BTreeMap;
77use std::path::{Path, PathBuf};
78use std::process::Command;
79use std::sync::Mutex;
80use std::time::{Duration, Instant, SystemTime};
81
82use schemars::JsonSchema;
83use serde::{Deserialize, Serialize};
84
85use crate::peers::PeerId;
86
87/// How long an episode whose connection has gone is kept before it is closed.
88///
89/// Longer than the transport's own idle timeout on purpose. `http::mcp::SESSION_IDLE_TIMEOUT`
90/// decides when a *connection* is forgotten — ninety seconds, which is right for a socket —
91/// and a client that is restarted, rebuilt or resumed takes longer than that to come back.
92/// Closing the episode at the same moment as the connection would publish a record saying
93/// the work ended, for work that resumed four minutes later under a new peer id, which is
94/// precisely the fiction this whole subsystem exists to avoid.
95pub const REATTACH_GRACE: Duration = Duration::from_secs(15 * 60);
96
97/// Why an episode was closed. Reported to the repository as the end event's `reason`, where
98/// `share/providers.yaml`'s deliberate-end list decides whether the record says `closed` or
99/// `interrupted` — the same table the provider hooks are read through, so a connection
100/// episode and a hook episode cannot disagree about what "ended deliberately" means.
101///
102/// The serialised word and [`CloseReason::as_str`] are one vocabulary:
103///
104/// ```
105/// use majordomus_cli::episodes::CloseReason;
106/// for reason in [CloseReason::Detach, CloseReason::Shutdown, CloseReason::Expired] {
107/// let json = serde_json::to_string(&reason).unwrap();
108/// assert_eq!(json, format!("\"{}\"", reason.as_str()));
109/// }
110/// ```
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
112#[serde(rename_all = "snake_case")]
113pub enum CloseReason {
114 /// The client said so: `episodes.detach`, or `DELETE /mcp`. A deliberate end.
115 Detach,
116 /// The server is stopping and every episode goes with it. Deliberate: nothing was cut
117 /// short by this, the machine was.
118 Shutdown,
119 /// The connection went and did not come back inside [`REATTACH_GRACE`]. Not deliberate,
120 /// and the record says `interrupted`, because calling a crashed episode complete is the
121 /// worse of the two mistakes.
122 Expired,
123}
124
125impl CloseReason {
126 /// The word sent to the repository's end event, as its `reason`. Only `expired` is
127 /// outside the deliberate-end list, so only it records the episode as interrupted.
128 ///
129 /// ```
130 /// use majordomus_cli::episodes::CloseReason;
131 /// assert_eq!(CloseReason::Detach.as_str(), "detach");
132 /// assert_eq!(CloseReason::Expired.as_str(), "expired");
133 /// ```
134 pub fn as_str(self) -> &'static str {
135 match self {
136 CloseReason::Detach => "detach",
137 CloseReason::Shutdown => "shutdown",
138 CloseReason::Expired => "expired",
139 }
140 }
141}
142
143/// Where an episode stands: held by a live connection, or waiting for its client to come
144/// back. There is no closed state, because a closed episode leaves the board; what survives
145/// it is the repository's own record.
146///
147/// ```
148/// use majordomus_cli::episodes::{ConnectionEpisodeState, EpisodeBoard};
149/// use majordomus_cli::peers::{PeerBoard, Transport};
150/// let peer = PeerBoard::new().attach(Transport::Stdio);
151/// let board = EpisodeBoard::new();
152/// assert_eq!(board.attach(&peer, "w", "generic").0.state, ConnectionEpisodeState::Open);
153/// assert_eq!(board.detach(&peer)[0].state, ConnectionEpisodeState::Detached);
154/// assert_eq!(serde_json::to_string(&ConnectionEpisodeState::Detached).unwrap(), "\"detached\"");
155/// ```
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
157#[serde(rename_all = "snake_case")]
158pub enum ConnectionEpisodeState {
159 /// A connection holds it and the client is speaking.
160 Open,
161 /// The connection has gone; the episode is kept for [`REATTACH_GRACE`] so the client
162 /// can come back to it. This is the state a crash lands in, and the one a reconnect
163 /// recovers from.
164 Detached,
165}
166
167/// One episode: what the client calls itself, which connection holds it now, and what the
168/// repository did about it.
169///
170/// ```
171/// use majordomus_cli::episodes::{ConnectionEpisode, EpisodeBoard};
172/// use majordomus_cli::peers::{PeerBoard, Transport};
173/// let peer = PeerBoard::new().attach(Transport::Http);
174/// let (episode, _) = EpisodeBoard::new().attach(&peer, "conv-42", "generic");
175/// let episode: ConnectionEpisode = episode;
176/// assert_eq!((episode.external_id.as_str(), episode.provider.as_str()), ("conv-42", "generic"));
177/// assert_eq!(episode.peer.as_ref(), Some(&peer));
178/// // never empty: a board with no repository behind it says so
179/// assert!(episode.repository.contains("without a driver"));
180/// ```
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
182pub struct ConnectionEpisode {
183 /// The client's own durable name for this piece of work, and the key everything here is
184 /// addressed by. Never a peer id: see the module documentation.
185 pub external_id: String,
186 /// The provider id this episode is recorded under, as `share/providers.yaml` names one.
187 pub provider: String,
188 /// The connection holding it, when one does. `None` while detached — which is a state
189 /// an episode is *supposed* to reach and come back from, not a fault.
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub peer: Option<PeerId>,
192 /// When it was first opened, RFC 3339, UTC. Unchanged by a re-attach: the episode is
193 /// the work, and the work did not restart because a socket did.
194 pub opened_at: String,
195 /// Seconds since the client last spoke on it, through any connection.
196 pub last_activity_seconds_ago: u64,
197 /// How many times a connection has taken it over, the first attach included.
198 pub attachments: u32,
199 /// Open, or detached and waiting for its client.
200 pub state: ConnectionEpisodeState,
201 /// What the repository's own episode command reported, verbatim — the record it opened,
202 /// or the reason it could not. Never interpreted here, and never empty: a subsystem
203 /// that silently does nothing is the failure mode this field exists to make visible.
204 pub repository: String,
205}
206
207/// The episodes of one server.
208///
209/// In memory, like the peer board, and for a narrower reason: what survives the process is
210/// the repository's own record, written by the same command a provider hook runs. This board
211/// holds only the association between a live connection and that record, which is a fact
212/// about now and stops being true when the process does.
213///
214/// ```
215/// use majordomus_cli::episodes::EpisodeBoard;
216/// use majordomus_cli::peers::{PeerBoard, Transport};
217/// let peers = PeerBoard::new();
218/// let (a, b) = (peers.attach(Transport::Stdio), peers.attach(Transport::Http));
219/// let board = EpisodeBoard::default();
220/// board.attach(&b, "zeta", "generic");
221/// board.attach(&a, "alpha", "generic");
222/// // listed by external identity, never by peer or attachment order
223/// let ids: Vec<String> = board.list().into_iter().map(|e| e.external_id).collect();
224/// assert_eq!(ids, ["alpha", "zeta"]);
225/// assert_eq!(board.close_all().len(), 2);
226/// ```
227pub struct EpisodeBoard {
228 episodes: Mutex<BTreeMap<String, Entry>>,
229 /// How the repository's episode boundary is driven. Injected so that the unit suites can
230 /// drive the board without a repository and the real server drives the real command.
231 driver: Box<dyn EpisodeDriver>,
232}
233
234struct Entry {
235 episode: ConnectionEpisode,
236 last_activity: Instant,
237 detached_since: Option<Instant>,
238}
239
240/// What opens and closes the repository's own episode.
241///
242/// One trait with one real implementation, because the alternative was for this module to
243/// learn how a session record is written — and a second writer of the record the provider
244/// hooks already write is exactly the repeated semantic definition this repository refuses
245/// (ADR 0004). [`ToolDriver`] runs `majordomus capture session`, the same command, with the
246/// same payload shape, through the same adapter resolution.
247///
248/// A driver of one's own is how a board is driven without a repository; what it returns is
249/// what the episode's `repository` field carries:
250///
251/// ```
252/// use majordomus_cli::episodes::{CloseReason, EpisodeBoard, EpisodeDriver};
253/// use majordomus_cli::peers::{PeerBoard, Transport};
254///
255/// struct Echo;
256/// impl EpisodeDriver for Echo {
257/// fn open(&self, provider: &str, id: &str) -> String { format!("opened {provider} {id}") }
258/// fn close(&self, _: &str, id: &str, reason: CloseReason) -> String {
259/// format!("closed {id} ({})", reason.as_str())
260/// }
261/// }
262///
263/// let board = EpisodeBoard::with_driver(Box::new(Echo));
264/// let peer = PeerBoard::new().attach(Transport::Stdio);
265/// assert_eq!(board.attach(&peer, "t1", "generic").0.repository, "opened generic t1");
266/// assert_eq!(board.close("t1", CloseReason::Shutdown).unwrap().repository, "closed t1 (shutdown)");
267/// ```
268pub trait EpisodeDriver: Send + Sync {
269 /// Open the episode for `external_id` under `provider`. Returns what to record in
270 /// [`ConnectionEpisode::repository`] — a description of what happened, successful or not.
271 /// It is called once per episode, on the first attach; a resume does not call it again.
272 ///
273 /// ```
274 /// use majordomus_cli::episodes::{EpisodeDriver, NullDriver};
275 /// let said = NullDriver.open("generic", "t1");
276 /// assert!(said.starts_with("no repository episode"), "a driver never answers blank");
277 /// ```
278 fn open(&self, provider: &str, external_id: &str) -> String;
279 /// Close it, with the reason the record is to carry. Returns what the repository did,
280 /// which replaces the episode's `repository` field on the way out.
281 ///
282 /// ```
283 /// use majordomus_cli::episodes::{CloseReason, EpisodeDriver, NullDriver};
284 /// let said = NullDriver.close("generic", "t1", CloseReason::Expired);
285 /// assert!(said.contains("without a driver"));
286 /// ```
287 fn close(&self, provider: &str, external_id: &str, reason: CloseReason) -> String;
288}
289
290/// The driver that runs the repository's own `majordomus capture session`.
291///
292/// It finds the tool the way a provider hook's shim does and in the same order — the
293/// repository's `bin/majordomus`, an installation under `.majordomus/`, then whatever is on
294/// the path — because a second resolution order would be a second answer to "which tool is
295/// this repository's", and the shims are the ones that have been right about it for months.
296///
297/// When no tool is found it records nothing and says why, in the episode's own field:
298///
299/// ```
300/// use majordomus_cli::episodes::{EpisodeDriver, ToolDriver};
301/// let empty = std::env::temp_dir().join("mj-tooldriver-doctest-no-tool");
302/// std::env::remove_var("PATH"); // nor any majordomus on the path
303/// let said = ToolDriver::new(&empty).open("generic", "t1");
304/// assert!(said.starts_with("no episode was recorded"), "{said}");
305/// ```
306#[derive(Debug, Clone)]
307pub struct ToolDriver {
308 root: PathBuf,
309}
310
311impl ToolDriver {
312 /// A driver for the repository at `root`. Nothing is resolved or run here: the tool is
313 /// looked for on every event, so a `bin/majordomus` built after the server started is the
314 /// one used.
315 ///
316 /// ```
317 /// use majordomus_cli::episodes::ToolDriver;
318 /// let driver = ToolDriver::new("/srv/repo");
319 /// assert!(format!("{driver:?}").contains("/srv/repo"));
320 /// ```
321 pub fn new(root: impl Into<PathBuf>) -> Self {
322 ToolDriver { root: root.into() }
323 }
324
325 /// The shell tool, resolved as `.claude/hooks/majordomus-session-start` resolves it.
326 fn tool(&self) -> Option<PathBuf> {
327 for candidate in ["bin/majordomus", ".majordomus/bin/majordomus"] {
328 let p = self.root.join(candidate);
329 if is_executable(&p) {
330 return Some(p);
331 }
332 }
333 which("majordomus")
334 }
335
336 fn run(&self, provider: &str, event: &str, payload: &str) -> String {
337 self.run_tool(self.tool(), provider, event, payload)
338 }
339
340 /// Run `tool`'s `capture session` with `payload` on stdin, or say that there is no tool.
341 fn run_tool(
342 &self,
343 tool: Option<PathBuf>,
344 provider: &str,
345 event: &str,
346 payload: &str,
347 ) -> String {
348 let Some(tool) = tool else {
349 return format!(
350 "no episode was recorded: neither bin/majordomus nor .majordomus/bin/majordomus is executable under {} and none is on the path",
351 self.root.display()
352 );
353 };
354 // The payload goes in on stdin exactly as a provider's own event does, so the one
355 // reader in lib/capture.sh parses both. Writing it with a pipe rather than an
356 // argument is not decoration: `capture session` reads the episode key out of the
357 // payload, and a key that arrived as an argument would be a second way in.
358 let mut child = match Command::new(&tool)
359 .arg("--repo")
360 .arg(&self.root)
361 .args([
362 "capture",
363 "session",
364 "--provider",
365 provider,
366 "--event",
367 event,
368 ])
369 .current_dir(&self.root)
370 .stdin(std::process::Stdio::piped())
371 .stdout(std::process::Stdio::piped())
372 .stderr(std::process::Stdio::piped())
373 .spawn()
374 {
375 Ok(c) => c,
376 Err(e) => {
377 return format!(
378 "no episode was recorded: {} did not start: {e}",
379 tool.display()
380 )
381 }
382 };
383 // stdin was asked for as a pipe, so it is there; it is closed when the write is done,
384 // and a tool that exited without reading it has answered all the same
385 let _ = child.stdin.take().map(|mut stdin| {
386 use std::io::Write;
387 stdin.write_all(payload.as_bytes())
388 });
389 reported(event, &tool, child.wait_with_output())
390 }
391}
392
393/// What a finished `capture session` run says happened: its last line on stderr, or, when it
394/// said nothing or could not be waited for, a sentence that says so and names the tool.
395fn reported(event: &str, tool: &Path, finished: std::io::Result<std::process::Output>) -> String {
396 match finished {
397 // `capture session` writes nothing to stdout by contract and reports what it did
398 // on stderr, because on a start event its stdout would be loaded into a model's
399 // context. Its last line is the one that says what happened.
400 Ok(out) => {
401 let text = String::from_utf8_lossy(&out.stderr);
402 let last = text
403 .lines()
404 .rev()
405 .find(|l| !l.trim().is_empty())
406 .unwrap_or("");
407 if last.is_empty() {
408 format!(
409 "`capture session --event {event}` exited {} and said nothing",
410 out.status
411 )
412 } else {
413 last.trim().to_string()
414 }
415 }
416 Err(e) => format!(
417 "no episode was recorded: {} did not finish: {e}",
418 tool.display()
419 ),
420 }
421}
422
423impl EpisodeDriver for ToolDriver {
424 fn open(&self, provider: &str, external_id: &str) -> String {
425 self.run(
426 provider,
427 "start",
428 &format!(
429 "{{\"session_id\":{},\"source\":\"attach\"}}\n",
430 json_string(external_id)
431 ),
432 )
433 }
434
435 fn close(&self, provider: &str, external_id: &str, reason: CloseReason) -> String {
436 self.run(
437 provider,
438 "end",
439 &format!(
440 "{{\"session_id\":{},\"reason\":\"{}\"}}\n",
441 json_string(external_id),
442 reason.as_str()
443 ),
444 )
445 }
446}
447
448/// A driver that records nothing: the board without a repository behind it.
449///
450/// Used by the unit suites and by a server serving a checkout whose tool cannot be found. It
451/// says so in every episode's `repository` field rather than leaving it blank, because an
452/// episode that quietly recorded nothing is indistinguishable, to a reader, from one that
453/// recorded everything.
454///
455/// ```
456/// use majordomus_cli::episodes::{EpisodeBoard, NullDriver};
457/// use majordomus_cli::peers::{PeerBoard, Transport};
458/// let board = EpisodeBoard::with_driver(Box::new(NullDriver));
459/// let peer = PeerBoard::new().attach(Transport::Stdio);
460/// let (episode, _) = board.attach(&peer, "t1", "generic");
461/// assert_eq!(episode.repository, "no repository episode: this board is running without a driver");
462/// ```
463#[derive(Debug, Clone, Default)]
464pub struct NullDriver;
465
466impl EpisodeDriver for NullDriver {
467 fn open(&self, _: &str, _: &str) -> String {
468 "no repository episode: this board is running without a driver".into()
469 }
470 fn close(&self, _: &str, _: &str, _: CloseReason) -> String {
471 "no repository episode: this board is running without a driver".into()
472 }
473}
474
475impl std::fmt::Debug for EpisodeBoard {
476 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477 f.debug_struct("EpisodeBoard")
478 .field("episodes", &self.list().len())
479 .finish()
480 }
481}
482
483impl Default for EpisodeBoard {
484 fn default() -> Self {
485 Self::new()
486 }
487}
488
489impl EpisodeBoard {
490 /// A board that records nothing in the repository: every episode's `repository` field
491 /// says so. The board the unit suites and a repository-less server use.
492 ///
493 /// ```
494 /// use majordomus_cli::episodes::EpisodeBoard;
495 /// let board = EpisodeBoard::new();
496 /// assert!(board.list().is_empty(), "a new board holds no episode");
497 /// ```
498 pub fn new() -> Self {
499 EpisodeBoard {
500 episodes: Mutex::new(BTreeMap::new()),
501 driver: Box::new(NullDriver),
502 }
503 }
504
505 /// A board that drives the repository at `root` through its own `capture session`, by
506 /// a [`ToolDriver`]. Constructing it runs nothing; the first attach does.
507 ///
508 /// ```
509 /// use majordomus_cli::episodes::EpisodeBoard;
510 /// let board = EpisodeBoard::for_repository(std::env::temp_dir());
511 /// assert!(board.list().is_empty(), "no command ran and no episode exists yet");
512 /// assert_eq!(board.close_all().len(), 0);
513 /// ```
514 pub fn for_repository(root: impl Into<PathBuf>) -> Self {
515 EpisodeBoard {
516 episodes: Mutex::new(BTreeMap::new()),
517 driver: Box::new(ToolDriver::new(root)),
518 }
519 }
520
521 /// A board over an arbitrary driver: the seam a test, or a server with another way of
522 /// recording, plugs into. The driver is asked once per opened and once per closed episode.
523 ///
524 /// ```
525 /// use majordomus_cli::episodes::{CloseReason, EpisodeBoard, EpisodeDriver};
526 /// use majordomus_cli::peers::{PeerBoard, Transport};
527 /// struct Fixed;
528 /// impl EpisodeDriver for Fixed {
529 /// fn open(&self, _: &str, _: &str) -> String { "opened".into() }
530 /// fn close(&self, _: &str, _: &str, _: CloseReason) -> String { "closed".into() }
531 /// }
532 /// let board = EpisodeBoard::with_driver(Box::new(Fixed));
533 /// let peer = PeerBoard::new().attach(Transport::Stdio);
534 /// assert_eq!(board.attach(&peer, "t", "generic").0.repository, "opened");
535 /// ```
536 pub fn with_driver(driver: Box<dyn EpisodeDriver>) -> Self {
537 EpisodeBoard {
538 episodes: Mutex::new(BTreeMap::new()),
539 driver,
540 }
541 }
542
543 /// Open an episode for `external_id`, or take an existing one over.
544 ///
545 /// The whole point is in the second half. A client that comes back — after a crash, a
546 /// rebuild, a dropped socket, a client restart — names itself the same way and gets the
547 /// same episode, with a new peer holding it and `attachments` one higher. Nothing is
548 /// re-opened, no second record is written, and the ledger lines on either side of the
549 /// gap belong to one episode, which is what makes the record of it true.
550 ///
551 /// Returns the episode and whether it was resumed rather than opened.
552 ///
553 /// ```
554 /// use majordomus_cli::episodes::EpisodeBoard;
555 /// use majordomus_cli::peers::{PeerBoard, Transport};
556 /// let peers = PeerBoard::new();
557 /// let board = EpisodeBoard::new();
558 /// let (p1, p2) = (peers.attach(Transport::Stdio), peers.attach(Transport::Http));
559 /// assert!(!board.attach(&p1, "work", "generic").1, "first: opened");
560 /// let (episode, resumed) = board.attach(&p2, "work", "generic");
561 /// assert!(resumed, "same identity, another peer: resumed");
562 /// assert_eq!((episode.attachments, episode.peer.as_ref()), (2, Some(&p2)));
563 /// assert_eq!(board.list().len(), 1, "one episode, not two");
564 /// ```
565 pub fn attach(
566 &self,
567 peer: &PeerId,
568 external_id: &str,
569 provider: &str,
570 ) -> (ConnectionEpisode, bool) {
571 let mut episodes = self.lock();
572 // A connection holds zero or one episode. A peer attaching to a second external
573 // identity is not a second worker, it is the same connection changing its mind —
574 // the first is released (and left detached, recoverable) rather than held by a peer
575 // that has stopped speaking for it.
576 for entry in episodes.values_mut().filter(|e| {
577 e.episode.peer.as_ref() == Some(peer) && e.episode.external_id != external_id
578 }) {
579 entry.episode.peer = None;
580 entry.episode.state = ConnectionEpisodeState::Detached;
581 entry.detached_since = Some(Instant::now());
582 }
583
584 if let Some(entry) = episodes.get_mut(external_id) {
585 entry.episode.peer = Some(peer.clone());
586 entry.episode.state = ConnectionEpisodeState::Open;
587 entry.episode.attachments = entry.episode.attachments.saturating_add(1);
588 entry.detached_since = None;
589 entry.last_activity = Instant::now();
590 entry.episode.last_activity_seconds_ago = 0;
591 return (entry.episode.clone(), true);
592 }
593
594 // The driver runs while the map is locked, which is deliberate and not an oversight:
595 // two connections racing to attach under one external identity must produce one
596 // episode, and releasing the lock across the subprocess is how they would produce
597 // two. The cost is one attach at a time per server, which is the correct price.
598 //
599 // It is re-entrant across processes and safe to be. `capture session --event start`
600 // runs `serve ensure`, which probes this very server over HTTP from a child process
601 // — so an attach handled on the stdio thread is answered by the HTTP listener's
602 // thread, and one handled by the HTTP endpoint is answered by another of its
603 // workers. Nothing on that path touches this mutex, so the probe cannot wait on the
604 // attach that started it. `test/cases/200_generic_mcp_episode.sh` exercises both
605 // directions — a stdio client that is the server, and a client bridged to somebody
606 // else's — because an argument about threads is worth exactly as much as the run
607 // that confirms it.
608 let repository = self.driver.open(provider, external_id);
609 let episode = ConnectionEpisode {
610 external_id: external_id.to_string(),
611 provider: provider.to_string(),
612 peer: Some(peer.clone()),
613 opened_at: crate::peers::rfc3339(SystemTime::now()),
614 last_activity_seconds_ago: 0,
615 attachments: 1,
616 state: ConnectionEpisodeState::Open,
617 repository,
618 };
619 episodes.insert(
620 external_id.to_string(),
621 Entry {
622 episode: episode.clone(),
623 last_activity: Instant::now(),
624 detached_since: None,
625 },
626 );
627 (episode, false)
628 }
629
630 /// The client spoke. Every message on a connection holding an episode is a heartbeat for
631 /// it, so a working client never has its episode reaped and no client has to send
632 /// anything it would not otherwise send.
633 ///
634 /// ```
635 /// use std::time::Duration;
636 /// use majordomus_cli::episodes::EpisodeBoard;
637 /// use majordomus_cli::peers::{PeerBoard, Transport};
638 /// let peer = PeerBoard::new().attach(Transport::Http);
639 /// let board = EpisodeBoard::new();
640 /// board.attach(&peer, "busy", "generic");
641 /// board.touch(&peer);
642 /// assert_eq!(board.list()[0].last_activity_seconds_ago, 0);
643 /// assert!(board.reap(Duration::ZERO).is_empty(), "a held episode is never reaped");
644 /// ```
645 pub fn touch(&self, peer: &PeerId) {
646 let mut episodes = self.lock();
647 for entry in episodes.values_mut() {
648 if entry.episode.peer.as_ref() == Some(peer) {
649 entry.last_activity = Instant::now();
650 }
651 }
652 }
653
654 /// The connection went. The episode is **not** closed: it is detached, and the client
655 /// can come back to it by name for [`REATTACH_GRACE`].
656 ///
657 /// This is the difference between a lifecycle and a session counter. Closing here would
658 /// publish a record saying the work ended every time a socket dropped, and the work
659 /// almost never ends when a socket drops.
660 ///
661 /// ```
662 /// use majordomus_cli::episodes::{ConnectionEpisodeState, EpisodeBoard};
663 /// use majordomus_cli::peers::{PeerBoard, Transport};
664 /// let peer = PeerBoard::new().attach(Transport::Http);
665 /// let board = EpisodeBoard::new();
666 /// board.attach(&peer, "kept", "generic");
667 /// let detached = board.detach(&peer);
668 /// assert_eq!(detached.len(), 1);
669 /// assert!(detached[0].peer.is_none());
670 /// assert_eq!(board.list()[0].state, ConnectionEpisodeState::Detached, "still on the board");
671 /// assert!(board.detach(&peer).is_empty(), "nothing is held twice");
672 /// ```
673 pub fn detach(&self, peer: &PeerId) -> Vec<ConnectionEpisode> {
674 let mut episodes = self.lock();
675 let mut out = Vec::new();
676 for entry in episodes.values_mut() {
677 if entry.episode.peer.as_ref() == Some(peer) {
678 entry.episode.peer = None;
679 entry.episode.state = ConnectionEpisodeState::Detached;
680 entry.detached_since = Some(Instant::now());
681 out.push(entry.episode.clone());
682 }
683 }
684 out
685 }
686
687 /// Close one episode deliberately, by name: the client said it was done.
688 ///
689 /// ```
690 /// use majordomus_cli::episodes::{CloseReason, EpisodeBoard};
691 /// use majordomus_cli::peers::{PeerBoard, Transport};
692 /// let peer = PeerBoard::new().attach(Transport::Stdio);
693 /// let board = EpisodeBoard::new();
694 /// board.attach(&peer, "done", "generic");
695 /// let closed = board.close("done", CloseReason::Detach).expect("it was open");
696 /// assert!(closed.peer.is_none());
697 /// assert!(board.close("done", CloseReason::Detach).is_none(), "closed once only");
698 /// assert!(board.of_peer(&peer).is_none());
699 /// ```
700 pub fn close(&self, external_id: &str, reason: CloseReason) -> Option<ConnectionEpisode> {
701 let mut episodes = self.lock();
702 let entry = episodes.remove(external_id)?;
703 let mut episode = entry.episode;
704 episode.repository = self
705 .driver
706 .close(&episode.provider, &episode.external_id, reason);
707 episode.peer = None;
708 episode.state = ConnectionEpisodeState::Detached;
709 Some(episode)
710 }
711
712 /// Close every episode detached longer than `grace`. The reaper, and the answer to the
713 /// client that never comes back. Returns what it closed, each as
714 /// [`CloseReason::Expired`], which the record reads as interrupted.
715 ///
716 /// ```
717 /// use std::time::Duration;
718 /// use majordomus_cli::episodes::EpisodeBoard;
719 /// use majordomus_cli::peers::{PeerBoard, Transport};
720 /// let peer = PeerBoard::new().attach(Transport::Http);
721 /// let board = EpisodeBoard::new();
722 /// board.attach(&peer, "gone", "generic");
723 /// board.detach(&peer);
724 /// assert!(board.reap(Duration::from_secs(3600)).is_empty(), "inside the grace it waits");
725 /// assert_eq!(board.reap(Duration::ZERO).len(), 1);
726 /// assert!(board.list().is_empty());
727 /// ```
728 pub fn reap(&self, grace: Duration) -> Vec<ConnectionEpisode> {
729 let expired: Vec<String> = {
730 let episodes = self.lock();
731 episodes
732 .values()
733 .filter(|e| e.detached_since.is_some_and(|t| t.elapsed() > grace))
734 .map(|e| e.episode.external_id.clone())
735 .collect()
736 };
737 expired
738 .into_iter()
739 .filter_map(|id| self.close(&id, CloseReason::Expired))
740 .collect()
741 }
742
743 /// Close every episode: the server is stopping.
744 ///
745 /// An episode left open by a server that has gone is an episode nothing will ever close,
746 /// and the repository would carry it as open for ever — the failure ADR 0041 names on
747 /// the hook side, reached here by a different road.
748 ///
749 /// ```
750 /// use majordomus_cli::episodes::EpisodeBoard;
751 /// use majordomus_cli::peers::{PeerBoard, Transport};
752 /// let peers = PeerBoard::new();
753 /// let board = EpisodeBoard::new();
754 /// board.attach(&peers.attach(Transport::Stdio), "held", "generic");
755 /// let detached = peers.attach(Transport::Http);
756 /// board.attach(&detached, "waiting", "generic");
757 /// board.detach(&detached);
758 /// assert_eq!(board.close_all().len(), 2, "held and detached alike");
759 /// assert!(board.list().is_empty());
760 /// ```
761 pub fn close_all(&self) -> Vec<ConnectionEpisode> {
762 let ids: Vec<String> = self.lock().keys().cloned().collect();
763 ids.into_iter()
764 .filter_map(|id| self.close(&id, CloseReason::Shutdown))
765 .collect()
766 }
767
768 /// Every episode this server holds, open and detached, in external-identity order, each
769 /// with its idle time measured now rather than when it was last written.
770 ///
771 /// ```
772 /// use majordomus_cli::episodes::{ConnectionEpisodeState, EpisodeBoard};
773 /// use majordomus_cli::peers::{PeerBoard, Transport};
774 /// let peers = PeerBoard::new();
775 /// let board = EpisodeBoard::new();
776 /// let p = peers.attach(Transport::Stdio);
777 /// board.attach(&p, "b", "generic");
778 /// board.detach(&p);
779 /// board.attach(&peers.attach(Transport::Http), "a", "generic");
780 /// let states: Vec<_> = board.list().into_iter().map(|e| (e.external_id, e.state)).collect();
781 /// assert_eq!(states, [("a".to_string(), ConnectionEpisodeState::Open),
782 /// ("b".to_string(), ConnectionEpisodeState::Detached)]);
783 /// ```
784 pub fn list(&self) -> Vec<ConnectionEpisode> {
785 let episodes = self.lock();
786 episodes
787 .values()
788 .map(|e| {
789 let mut episode = e.episode.clone();
790 episode.last_activity_seconds_ago = e.last_activity.elapsed().as_secs();
791 episode
792 })
793 .collect()
794 }
795
796 /// The episode a connection holds, if it holds one. A connection that only initialised
797 /// holds none, and neither does one whose episode was detached from it.
798 ///
799 /// ```
800 /// use majordomus_cli::episodes::EpisodeBoard;
801 /// use majordomus_cli::peers::{PeerBoard, Transport};
802 /// let peer = PeerBoard::new().attach(Transport::Http);
803 /// let board = EpisodeBoard::new();
804 /// assert!(board.of_peer(&peer).is_none(), "a peer is not a worker until it attaches");
805 /// board.attach(&peer, "mine", "generic");
806 /// assert_eq!(board.of_peer(&peer).map(|e| e.external_id).as_deref(), Some("mine"));
807 /// ```
808 pub fn of_peer(&self, peer: &PeerId) -> Option<ConnectionEpisode> {
809 self.list()
810 .into_iter()
811 .find(|e| e.peer.as_ref() == Some(peer))
812 }
813
814 fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Entry>> {
815 self.episodes.lock().unwrap_or_else(|e| e.into_inner())
816 }
817}
818
819fn is_executable(p: &Path) -> bool {
820 #[cfg(unix)]
821 {
822 use std::os::unix::fs::PermissionsExt;
823 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
824 }
825 #[cfg(not(unix))]
826 {
827 p.is_file()
828 }
829}
830
831fn which(name: &str) -> Option<PathBuf> {
832 std::env::var_os("PATH").and_then(|path| find_in(&path, name))
833}
834
835/// The first executable `name` in the directories of a `PATH`-shaped list.
836fn find_in(path: &std::ffi::OsStr, name: &str) -> Option<PathBuf> {
837 std::env::split_paths(path)
838 .map(|d| d.join(name))
839 .find(|p| is_executable(p))
840}
841
842/// One JSON string, escaped. The payload is this tool's own and the identity in it came from
843/// a client, so it is escaped rather than trusted: a quote in an external identity that
844/// reached a shell's stdin unescaped would be a payload of somebody else's making.
845fn json_string(s: &str) -> String {
846 let mut out = String::with_capacity(s.len() + 2);
847 out.push('"');
848 for c in s.chars() {
849 match c {
850 '"' => out.push_str("\\\""),
851 '\\' => out.push_str("\\\\"),
852 '\n' => out.push_str("\\n"),
853 '\r' => out.push_str("\\r"),
854 '\t' => out.push_str("\\t"),
855 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
856 c => out.push(c),
857 }
858 }
859 out.push('"');
860 out
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866 use std::sync::Mutex as StdMutex;
867
868 #[derive(Default)]
869 struct Recording {
870 calls: StdMutex<Vec<String>>,
871 }
872
873 impl EpisodeDriver for std::sync::Arc<Recording> {
874 fn open(&self, provider: &str, external_id: &str) -> String {
875 self.calls
876 .lock()
877 .unwrap()
878 .push(format!("open {provider} {external_id}"));
879 format!("episode opened for {external_id}")
880 }
881 fn close(&self, provider: &str, external_id: &str, reason: CloseReason) -> String {
882 self.calls.lock().unwrap().push(format!(
883 "close {provider} {external_id} {}",
884 reason.as_str()
885 ));
886 format!("episode closed for {external_id}")
887 }
888 }
889
890 fn board() -> (EpisodeBoard, std::sync::Arc<Recording>) {
891 let rec = std::sync::Arc::new(Recording::default());
892 (EpisodeBoard::with_driver(Box::new(rec.clone())), rec)
893 }
894
895 /// Peer ids come from a real board, because they are handed out in attachment order and
896 /// a test that invented them would be testing a numbering this code never sees.
897 fn peers(n: usize) -> Vec<PeerId> {
898 let board = crate::peers::PeerBoard::new();
899 (0..n)
900 .map(|_| board.attach(crate::peers::Transport::Stdio))
901 .collect()
902 }
903
904 /// THE REGRESSION this module exists for. A client that loses its connection and comes
905 /// back gets *its own* episode, not a second one, and the repository is asked to open an
906 /// episode exactly once.
907 #[test]
908 fn a_reconnect_resumes_the_episode_by_external_identity() {
909 let (board, rec) = board();
910 let ids = peers(2);
911 let first = ids[0].clone();
912 let (opened, resumed) = board.attach(&first, "thread-7", "generic");
913 assert!(!resumed, "the first attach is not a resume");
914 assert_eq!(opened.attachments, 1);
915
916 board.detach(&first);
917 assert_eq!(
918 board.list().len(),
919 1,
920 "a detach must not discard the episode"
921 );
922 assert_eq!(board.list()[0].state, ConnectionEpisodeState::Detached);
923
924 // a new connection: a different peer id, the same worker
925 let second = ids[1].clone();
926 let (again, resumed) = board.attach(&second, "thread-7", "generic");
927 assert!(
928 resumed,
929 "the same external identity must resume, not re-open"
930 );
931 assert_eq!(again.opened_at, opened.opened_at, "it is the same episode");
932 assert_eq!(again.attachments, 2);
933 assert_eq!(again.peer.as_ref(), Some(&second));
934
935 assert_eq!(
936 *rec.calls.lock().unwrap(),
937 vec!["open generic thread-7".to_string()],
938 "the repository episode was opened once, not once per connection"
939 );
940 }
941
942 /// A connection holds zero or one episode, and `initialize` alone holds none.
943 #[test]
944 fn a_connection_holds_at_most_one_episode() {
945 let (board, _) = board();
946 let p = peers(1).remove(0);
947 assert!(
948 board.of_peer(&p).is_none(),
949 "attaching nothing holds nothing"
950 );
951 board.attach(&p, "one", "generic");
952 board.attach(&p, "two", "generic");
953 assert_eq!(board.of_peer(&p).map(|e| e.external_id), Some("two".into()));
954 let one = board
955 .list()
956 .into_iter()
957 .find(|e| e.external_id == "one")
958 .unwrap();
959 assert_eq!(
960 one.state,
961 ConnectionEpisodeState::Detached,
962 "the episode it stopped speaking for is released, and recoverable"
963 );
964 }
965
966 /// The reaper closes what never came back, and only that.
967 #[test]
968 fn the_reaper_closes_only_what_is_past_its_grace() {
969 let (board, rec) = board();
970 let p = peers(1).remove(0);
971 board.attach(&p, "gone", "generic");
972 assert!(
973 board.reap(Duration::from_secs(1)).is_empty(),
974 "an attached episode is never reaped"
975 );
976 board.detach(&p);
977 assert!(
978 board.reap(Duration::from_secs(3600)).is_empty(),
979 "inside the grace it waits"
980 );
981 let closed = board.reap(Duration::ZERO);
982 assert_eq!(closed.len(), 1);
983 assert!(board.list().is_empty());
984 assert_eq!(
985 rec.calls.lock().unwrap().last().map(String::as_str),
986 Some("close generic gone expired"),
987 "a cut-short episode is closed as expired, never as a deliberate end"
988 );
989 }
990
991 /// A stopping server closes what it holds; an episode nothing will ever close is an
992 /// episode the repository carries as open for ever.
993 #[test]
994 fn shutdown_closes_every_episode() {
995 let (board, rec) = board();
996 let ids = peers(2);
997 board.attach(&ids[0], "a", "generic");
998 board.attach(&ids[1], "b", "generic");
999 assert_eq!(board.close_all().len(), 2);
1000 assert!(board.list().is_empty());
1001 let calls = rec.calls.lock().unwrap();
1002 assert!(calls.contains(&"close generic a shutdown".to_string()));
1003 assert!(calls.contains(&"close generic b shutdown".to_string()));
1004 }
1005
1006 #[test]
1007 fn an_external_identity_is_escaped_into_the_payload() {
1008 assert_eq!(json_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
1009 assert_eq!(json_string("line\nbreak"), "\"line\\nbreak\"");
1010 }
1011
1012 /// Every control character is escaped, the named ones by name and the rest by code, so
1013 /// no identity can end the payload's line or smuggle a byte a JSON reader refuses.
1014 #[test]
1015 fn every_control_character_is_escaped() {
1016 assert_eq!(json_string("a\rb\tc"), "\"a\\rb\\tc\"");
1017 assert_eq!(json_string("\u{1}\u{1f}"), "\"\\u0001\\u001f\"");
1018 let parsed: String = serde_json::from_str(&json_string("x\r\t\u{7}y")).unwrap();
1019 assert_eq!(
1020 parsed, "x\r\t\u{7}y",
1021 "what is escaped reads back as it was"
1022 );
1023 }
1024
1025 /// A board built without a driver still keeps the episodes; what it reports for the
1026 /// repository is that there is no driver, never a record that was not made.
1027 #[test]
1028 fn a_board_without_a_driver_keeps_episodes_and_says_none_was_recorded() {
1029 for board in [EpisodeBoard::new(), EpisodeBoard::default()] {
1030 let p = peers(1).remove(0);
1031 let (episode, _) = board.attach(&p, "quiet", "generic");
1032 assert_eq!(
1033 episode.repository,
1034 "no repository episode: this board is running without a driver"
1035 );
1036 assert_eq!(format!("{board:?}"), "EpisodeBoard { episodes: 1 }");
1037 let closed = board.close("quiet", CloseReason::Detach).unwrap();
1038 assert_eq!(closed.repository, episode.repository);
1039 assert_eq!(format!("{board:?}"), "EpisodeBoard { episodes: 0 }");
1040 }
1041 }
1042
1043 /// Activity, a lost connection and a deliberate close each reach only the episode they
1044 /// name; closing one nobody holds is nothing, not an error and not somebody else's.
1045 #[test]
1046 fn touch_detach_and_close_reach_only_their_own_episode() {
1047 let (board, _) = board();
1048 let ids = peers(2);
1049 board.attach(&ids[0], "mine", "generic");
1050 board.attach(&ids[1], "theirs", "generic");
1051 let activity = |id: &str| board.lock().get(id).unwrap().last_activity;
1052 let theirs_before = activity("theirs");
1053 let mine_before = activity("mine");
1054 std::thread::sleep(Duration::from_millis(5));
1055 board.touch(&ids[0]);
1056 assert!(
1057 activity("mine") > mine_before,
1058 "the toucher's episode moved"
1059 );
1060 assert_eq!(activity("theirs"), theirs_before, "the other did not");
1061
1062 let detached = board.detach(&ids[0]);
1063 assert_eq!(detached.len(), 1);
1064 assert_eq!(detached[0].external_id, "mine");
1065 assert_eq!(
1066 board.of_peer(&ids[1]).map(|e| e.state),
1067 Some(ConnectionEpisodeState::Open),
1068 "a detach of one connection leaves the other open"
1069 );
1070
1071 assert!(board
1072 .close("nobody-holds-this", CloseReason::Detach)
1073 .is_none());
1074 assert_eq!(board.list().len(), 2);
1075 }
1076
1077 /// A thread that panicked while holding the board does not take the board with it: the
1078 /// map it guarded is still whole and the next attach is served.
1079 #[test]
1080 fn a_poisoned_board_still_answers() {
1081 let board = std::sync::Arc::new(EpisodeBoard::new());
1082 let held = std::sync::Arc::clone(&board);
1083 let _ = std::thread::spawn(move || {
1084 let _guard = held.episodes.lock().unwrap();
1085 panic!("a handler died holding the board");
1086 })
1087 .join();
1088 assert!(board.episodes.is_poisoned());
1089 let p = peers(1).remove(0);
1090 board.attach(&p, "after", "generic");
1091 assert_eq!(board.list().len(), 1);
1092 }
1093
1094 /// The tool on the path is the first executable of that name, in path order; a file that
1095 /// is there but cannot be run is not a tool.
1096 #[test]
1097 fn the_tool_on_the_path_is_the_first_executable_one() {
1098 use std::os::unix::fs::PermissionsExt;
1099 let dir = tempfile::tempdir().unwrap();
1100 let (plain, runnable) = (dir.path().join("plain"), dir.path().join("runnable"));
1101 for (d, mode) in [(&plain, 0o644), (&runnable, 0o755)] {
1102 std::fs::create_dir_all(d).unwrap();
1103 let tool = d.join("majordomus");
1104 std::fs::write(&tool, "#!/bin/sh\n").unwrap();
1105 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(mode)).unwrap();
1106 }
1107 let path = std::env::join_paths([&plain, &runnable]).unwrap();
1108 assert_eq!(
1109 find_in(&path, "majordomus"),
1110 Some(runnable.join("majordomus"))
1111 );
1112 assert_eq!(find_in(&path, "absent"), None);
1113 assert_eq!(
1114 find_in(&std::env::join_paths([&plain]).unwrap(), "majordomus"),
1115 None
1116 );
1117 }
1118
1119 /// Write `body` as the repository's own `bin/majordomus`, runnable.
1120 fn tool_at(root: &Path, body: &str) -> PathBuf {
1121 use std::os::unix::fs::PermissionsExt;
1122 let tool = root.join("bin/majordomus");
1123 std::fs::create_dir_all(tool.parent().unwrap()).unwrap();
1124 std::fs::write(&tool, body).unwrap();
1125 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1126 tool
1127 }
1128
1129 /// The repository's own tool is asked exactly as a provider hook asks it — the
1130 /// repository, `capture session`, the provider and the event as arguments, the payload
1131 /// on stdin — and what the episode reports is the last thing it said.
1132 #[test]
1133 fn the_repository_tool_is_asked_as_a_hook_asks_it_and_its_last_word_is_the_answer() {
1134 let dir = tempfile::tempdir().unwrap();
1135 let root = dir.path();
1136 tool_at(
1137 root,
1138 "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\ncat > stdin.txt\nprintf 'noise\\n\\n episode e1 recorded \\n\\n' >&2\n",
1139 );
1140 let driver = ToolDriver::new(root);
1141 assert_eq!(driver.open("generic", "e1"), "episode e1 recorded");
1142 let args = std::fs::read_to_string(root.join("args.txt")).unwrap();
1143 assert_eq!(
1144 args.lines().collect::<Vec<_>>(),
1145 vec![
1146 "--repo",
1147 root.to_str().unwrap(),
1148 "capture",
1149 "session",
1150 "--provider",
1151 "generic",
1152 "--event",
1153 "start"
1154 ]
1155 );
1156 assert_eq!(
1157 std::fs::read_to_string(root.join("stdin.txt")).unwrap(),
1158 "{\"session_id\":\"e1\",\"source\":\"attach\"}\n"
1159 );
1160 assert_eq!(
1161 driver.close("generic", "e1", CloseReason::Expired),
1162 "episode e1 recorded"
1163 );
1164 assert_eq!(
1165 std::fs::read_to_string(root.join("stdin.txt")).unwrap(),
1166 "{\"session_id\":\"e1\",\"reason\":\"expired\"}\n"
1167 );
1168 }
1169
1170 /// Every way the tool can fail to say what it did is said instead: no tool at all, a
1171 /// tool that cannot start, a tool that said nothing, a tool that could not be waited for.
1172 #[test]
1173 fn every_way_the_tool_fails_to_answer_is_named() {
1174 let dir = tempfile::tempdir().unwrap();
1175 let root = dir.path();
1176 let driver = ToolDriver::new(root);
1177
1178 assert_eq!(
1179 driver.run_tool(None, "generic", "start", "{}"),
1180 format!(
1181 "no episode was recorded: neither bin/majordomus nor .majordomus/bin/majordomus is executable under {} and none is on the path",
1182 root.display()
1183 )
1184 );
1185
1186 tool_at(root, "#!/bin/sh\nexit 3\n");
1187 assert_eq!(
1188 driver.close("generic", "e1", CloseReason::Detach),
1189 "`capture session --event end` exited exit status: 3 and said nothing"
1190 );
1191
1192 // a tool that was found and is gone by the time it is started
1193 let gone = root.join("bin/gone");
1194 let refused = driver.run_tool(Some(gone.clone()), "generic", "start", "{}");
1195 assert!(
1196 refused.starts_with(&format!(
1197 "no episode was recorded: {} did not start: ",
1198 gone.display()
1199 )),
1200 "{refused}"
1201 );
1202
1203 assert_eq!(
1204 reported(
1205 "end",
1206 Path::new("/bin/majordomus"),
1207 Err(std::io::Error::other("the pipe broke"))
1208 ),
1209 "no episode was recorded: /bin/majordomus did not finish: the pipe broke"
1210 );
1211 }
1212}