majordomus_cli/capability/builtin/episodes.rs
1//! The `episodes` module: the execution episode of a client that has no provider hooks,
2//! opened and closed by the MCP connection itself (ADR 0103).
3//!
4//! Three capabilities, and the shape of them is the argument. `attach` is what a client
5//! calls to say *this connection is carrying this piece of work*, naming the work with its
6//! own durable identity; `detach` is what it calls when the work is done; `list` is what
7//! anybody reads. `initialize` is not on that list, deliberately: a connection that never
8//! attaches holds no episode and writes nothing, so a client that merely wants to read the
9//! repository's AI layer is a peer and never becomes a worker with a record. That is also
10//! what keeps `test/cases/90_mcp_shared_server.sh` true — serving changes the repository not
11//! at all — while an episode still opens for the client that asks for one.
12//!
13//! Everything after `attach` is automatic and needs no cooperation: the client's own traffic
14//! is the heartbeat, losing the connection detaches rather than closes, and coming back under
15//! the same identity resumes. See [`crate::episodes`] for why a peer id cannot be that
16//! identity.
17//!
18//! ```
19//! use majordomus_cli::capability::builtin::episodes;
20//! let m = episodes::module();
21//! let ids: Vec<&str> = m.capabilities.iter().map(|e| e.capability.id.as_str()).collect();
22//! assert_eq!(ids, ["episodes.list", "episodes.attach", "episodes.detach"]);
23//! // only the listing reads; attaching and detaching are commands with an effect
24//! let reads: Vec<bool> = m.capabilities.iter().map(|e| e.capability.kind.is_read_only()).collect();
25//! assert_eq!(reads, [true, false, false]);
26//! ```
27
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30
31use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
32use crate::capability::handler::{CapabilityError, Context};
33use crate::capability::model::{CapabilityKind, Exposure, Stability};
34use crate::capability::module::ModuleDescriptor;
35use crate::episodes::{CloseReason, ConnectionEpisode, REATTACH_GRACE};
36use crate::{capability, module};
37
38use super::{get, mcp, post, Empty};
39
40/// The provider an episode drawn by a connection is recorded under.
41///
42/// Always `generic`, and never the vendor behind the client, because the provider field says
43/// *what drew the boundary* and what drew this one was the connection. A Claude Code window
44/// whose own `SessionEnd` hook closes its episode is a `claude-code` episode; the same window
45/// calling `attach` here would be a second, generic one, which is why a client with working
46/// hooks has no reason to call it. `share/providers.yaml` declares
47/// `generic.offers.episode: connection` with the evidence, and `capture session` admits it
48/// without an adapter line for exactly that reason.
49pub const CONNECTION_PROVIDER: &str = "generic";
50
51// ---------------------------------------------------------------- episodes.attach
52
53#[derive(Debug, Serialize, Deserialize, JsonSchema)]
54#[serde(deny_unknown_fields)]
55/// The input of `episodes.attach`: which piece of work this connection is carrying.
56///
57/// The identity is the only field, and an unknown one is refused rather than ignored, so a
58/// client that sends a peer id under another name learns it at once:
59///
60/// ```
61/// use majordomus_cli::capability::builtin::episodes::AttachInput;
62/// let input: AttachInput = serde_json::from_str(r#"{"external_id":"conv-42"}"#).unwrap();
63/// assert_eq!(input.external_id, "conv-42");
64/// assert!(serde_json::from_str::<AttachInput>(r#"{"external_id":"x","peer":"p1"}"#).is_err());
65/// assert!(serde_json::from_str::<AttachInput>("{}").is_err(), "the identity is required");
66/// ```
67pub struct AttachInput {
68 /// The client's own durable name for this episode — its conversation id, thread id, or
69 /// whatever it calls the sitting it is in.
70 ///
71 /// **It must survive a reconnect**, because that is the whole of its job: a client that
72 /// comes back under the same identity is given its own episode again rather than a
73 /// second one. Anything the client can reproduce will do; what will not do is this
74 /// server's peer id, which is handed out per connection and is a different string every
75 /// time the client reconnects.
76 pub external_id: String,
77}
78
79impl BenchmarkCases for AttachInput {
80 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
81 vec![NamedCase::new(
82 "default",
83 AttachInput {
84 external_id: "benchmark-episode".into(),
85 },
86 )]
87 }
88}
89
90#[derive(Debug, Serialize, Deserialize, JsonSchema)]
91/// The answer of `episodes.attach`: the episode the connection now holds, whether it was
92/// resumed rather than opened, and how long it outlives a lost connection.
93///
94/// ```
95/// use majordomus_cli::capability::builtin::episodes::Attached;
96/// let schema = serde_json::to_value(schemars::schema_for!(Attached)).unwrap();
97/// for field in ["episode", "resumed", "reattach_grace_seconds"] {
98/// assert!(schema["required"].as_array().unwrap().iter().any(|f| f == field), "{field}");
99/// }
100/// ```
101pub struct Attached {
102 /// The episode this connection now holds.
103 pub episode: ConnectionEpisode,
104 /// Whether an episode already under this identity was resumed rather than a new one
105 /// opened. True after a reconnect, a client restart or a crash, and the field a caller
106 /// checks to know whether it is continuing something.
107 pub resumed: bool,
108 /// How long this episode survives its connection before the reaper closes it, in
109 /// seconds. Stated rather than left to be discovered: a client that knows the grace can
110 /// decide whether reconnecting is worth it.
111 pub reattach_grace_seconds: u64,
112}
113
114fn episodes_attach(ctx: &Context, input: AttachInput) -> Result<Attached, CapabilityError> {
115 let Some(caller) = &ctx.caller else {
116 return Err(CapabilityError::Refused(
117 "attach needs an MCP session: this call came through an interface with no connection to bind an episode to (call the majordomus_session_attach tool)".into(),
118 ));
119 };
120 let id = input.external_id.trim();
121 if id.is_empty() {
122 return Err(CapabilityError::InvalidInput(
123 "argument 'external_id' is required and must not be blank: it is what a reconnecting client is recognised by".into(),
124 ));
125 }
126 // A newline in the identity would split the payload the driver writes on stdin into two
127 // events. The escaping in `episodes::json_string` would survive it, but an identity with
128 // a line break in it is a mistake on the client's side either way and is better refused
129 // here, where the message can say so, than carried into a record nobody can read back.
130 if id.contains('\n') || id.contains('\r') {
131 return Err(CapabilityError::InvalidInput(
132 "argument 'external_id' must be one line".into(),
133 ));
134 }
135 let (episode, resumed) = ctx.episodes.attach(caller, id, CONNECTION_PROVIDER);
136 Ok(Attached {
137 episode,
138 resumed,
139 reattach_grace_seconds: REATTACH_GRACE.as_secs(),
140 })
141}
142
143// ---------------------------------------------------------------- episodes.detach
144
145#[derive(Debug, Serialize, Deserialize, JsonSchema)]
146#[serde(deny_unknown_fields)]
147/// The input of `episodes.detach`: the episode to close.
148///
149/// Empty is a valid input, meaning the episode the calling connection holds; over an
150/// interface with no connection that input is refused, since there is nothing to take
151/// "this one's episode" from.
152///
153/// ```
154/// use majordomus_cli::capability::builtin::episodes::DetachInput;
155/// let mine: DetachInput = serde_json::from_str("{}").unwrap();
156/// assert!(mine.external_id.is_none());
157/// let named: DetachInput = serde_json::from_str(r#"{"external_id":"conv-42"}"#).unwrap();
158/// assert_eq!(named.external_id.as_deref(), Some("conv-42"));
159/// assert_eq!(serde_json::to_string(&mine).unwrap(), "{}", "an omitted id is not written as null");
160/// ```
161pub struct DetachInput {
162 /// The episode to close. Omitted, it is whichever one this connection holds, which is
163 /// what a client ending its own sitting means.
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub external_id: Option<String>,
166}
167
168impl BenchmarkCases for DetachInput {
169 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
170 vec![NamedCase::new("default", DetachInput { external_id: None })]
171 }
172}
173
174#[derive(Debug, Serialize, Deserialize, JsonSchema)]
175/// The answer of `episodes.detach`: the episode as it was closed, no longer held by any
176/// connection, with what the repository's end event reported.
177///
178/// ```
179/// use majordomus_cli::capability::builtin::episodes::Detached;
180/// let schema = serde_json::to_value(schemars::schema_for!(Detached)).unwrap();
181/// assert_eq!(schema["required"], serde_json::json!(["episode"]));
182/// ```
183pub struct Detached {
184 /// The episode as it was closed, its `repository` field carrying what the repository's
185 /// own end event reported.
186 pub episode: ConnectionEpisode,
187}
188
189fn episodes_detach(ctx: &Context, input: DetachInput) -> Result<Detached, CapabilityError> {
190 let id = match input.external_id.as_deref().map(str::trim) {
191 Some(id) if !id.is_empty() => id.to_string(),
192 _ => {
193 // Refused, as `attach` is over the same interface: the input is well formed, and
194 // what is missing is a connection to take "this one's episode" from.
195 let Some(caller) = &ctx.caller else {
196 return Err(CapabilityError::Refused(
197 "detach without 'external_id' needs an MCP session: this call came through an interface with no connection, so there is no 'whichever episode this one holds'; name the episode to close".into(),
198 ));
199 };
200 ctx.episodes
201 .of_peer(caller)
202 .map(|e| e.external_id)
203 .ok_or_else(|| {
204 CapabilityError::Refused(format!(
205 "peer {caller} holds no episode; call majordomus_session_attach first, or name the episode to close"
206 ))
207 })?
208 }
209 };
210 let episode = ctx
211 .episodes
212 .close(&id, CloseReason::Detach)
213 .ok_or_else(|| CapabilityError::NotFound(format!("no episode '{id}' on this server")))?;
214 Ok(Detached { episode })
215}
216
217// ---------------------------------------------------------------- episodes.list
218
219#[derive(Debug, Serialize, Deserialize, JsonSchema)]
220/// The answer of `episodes.list`: every episode this server holds, counted by state, and the
221/// one the caller holds. In memory only — it is the association between live connections and
222/// the repository's records, which stops being true when the process stops.
223///
224/// The two counts partition the list, and `mine` is left out of the JSON rather than written
225/// as `null` when the caller holds nothing, which is always so over plain HTTP:
226///
227/// ```
228/// use majordomus_cli::capability::builtin::episodes::EpisodeList;
229/// let none = EpisodeList { open: 0, detached: 0, episodes: vec![], mine: None };
230/// let json = serde_json::to_value(&none).unwrap();
231/// assert_eq!(json, serde_json::json!({"open": 0, "detached": 0, "episodes": []}));
232/// let back: EpisodeList = serde_json::from_value(json).unwrap();
233/// assert_eq!(back.open + back.detached, back.episodes.len());
234/// ```
235pub struct EpisodeList {
236 /// How many are open — held by a connection that is still there.
237 pub open: usize,
238 /// How many are detached, waiting for a client to come back inside the grace. Not a
239 /// fault count: this is where a crashed client's work waits to be resumed.
240 pub detached: usize,
241 /// Every episode, open and detached, in external-identity order.
242 pub episodes: Vec<ConnectionEpisode>,
243 /// The episode the calling connection holds, when it holds one.
244 #[serde(skip_serializing_if = "Option::is_none")]
245 pub mine: Option<ConnectionEpisode>,
246}
247
248fn episodes_list(ctx: &Context, _: Empty) -> Result<EpisodeList, CapabilityError> {
249 let episodes = ctx.episodes.list();
250 Ok(EpisodeList {
251 open: episodes
252 .iter()
253 .filter(|e| e.state == crate::episodes::ConnectionEpisodeState::Open)
254 .count(),
255 detached: episodes
256 .iter()
257 .filter(|e| e.state == crate::episodes::ConnectionEpisodeState::Detached)
258 .count(),
259 mine: ctx.caller.as_ref().and_then(|c| ctx.episodes.of_peer(c)),
260 episodes,
261 })
262}
263
264/// The module the registry composes: one read and two commands, each declared once here and
265/// projected to HTTP and MCP; none to the command line, since a command-line call has no
266/// connection to hold an episode.
267///
268/// ```
269/// use majordomus_cli::capability::builtin::episodes;
270/// let m = episodes::module();
271/// assert_eq!(m.id.as_str(), "episodes");
272/// assert!(m.capabilities.iter().all(|e| e.capability.id.as_str().starts_with("episodes.")));
273/// ```
274pub fn module() -> ModuleDescriptor {
275 module! {
276 id: "episodes",
277 title: "Episodes",
278 description: "The execution episodes this shared server holds: one per client that asked for one, opened when the client attaches, kept alive by its own traffic, detached rather than closed when the connection goes, and closed on a deliberate detach, on shutdown, or by the reaper when nothing came back. The episode boundary for a client with no provider hooks of its own (ADR 0103).",
279 stability: Stability::BehaviorallyVerified,
280 capabilities: [
281 capability! {
282 id: "episodes.list",
283 title: "List episodes",
284 description: "Every execution episode this shared server holds, open and detached, with the connection holding each, when its client last spoke, how many connections have carried it, and what the repository's own episode command reported. In-memory: what survives the process is the repository's session record.",
285 input: Empty,
286 output: EpisodeList,
287 stability: Stability::BehaviorallyVerified,
288 exposure: Exposure { mcp: mcp("majordomus_episodes"), http: get("/api/v1/episodes"), cli: None },
289 tags: ["episodes", "session", "coordination"],
290 handler: episodes_list,
291 },
292 capability! {
293 id: "episodes.attach",
294 kind: CapabilityKind::Command,
295 title: "Attach this connection to an episode",
296 description: "Open an execution episode for the calling client, or resume the one it already had. 'external_id' is the client's own durable name for the sitting — its conversation or thread id — and must survive a reconnect: a client that comes back under the same identity is given its own episode again, under whatever peer id it now has, rather than a second one. This server's peer id will not do; it is handed out per connection. After this the client's own traffic keeps the episode alive, losing the connection detaches rather than closes it, and it is closed by majordomus_session_detach, by the server stopping, or by the reaper once the reattach grace has passed. The repository's episode is opened by the same command a provider hook runs, and what it reported is in the answer. Needs an MCP session: over plain HTTP there is no connection to bind to.",
297 input: AttachInput,
298 output: Attached,
299 stability: Stability::BehaviorallyVerified,
300 exposure: Exposure { mcp: mcp("majordomus_session_attach"), http: post("/api/v1/episodes/attach"), cli: None },
301 tags: ["episodes", "session", "coordination"],
302 handler: episodes_attach,
303 },
304 capability! {
305 id: "episodes.detach",
306 kind: CapabilityKind::Command,
307 title: "Close this connection's episode",
308 description: "Close an execution episode deliberately: the work is done, not merely interrupted, and the repository's record says so. Without 'external_id' it is whichever episode the calling connection holds, which needs an MCP session: over plain HTTP there is no connection, so the call is refused and the episode must be named. A client that simply goes away does not need this — its episode detaches and the reaper closes it as interrupted — and the difference between those two records is the one thing about an ended episode that changes what somebody does next.",
309 input: DetachInput,
310 output: Detached,
311 stability: Stability::BehaviorallyVerified,
312 exposure: Exposure { mcp: mcp("majordomus_session_detach"), http: post("/api/v1/episodes/detach"), cli: None },
313 tags: ["episodes", "session", "coordination"],
314 handler: episodes_detach,
315 },
316 ],
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 /// The declaration is the only place these names exist and every projection is derived
325 /// from it; a refactor that dropped an exposure would still compile. This is the
326 /// assertion that would not.
327 #[test]
328 fn the_declaration_yields_the_projections_it_claims() {
329 let m = module();
330 assert_eq!(m.id.as_str(), "episodes");
331 let expected: &[(&str, &str, &str)] = &[
332 ("episodes.list", "majordomus_episodes", "/api/v1/episodes"),
333 (
334 "episodes.attach",
335 "majordomus_session_attach",
336 "/api/v1/episodes/attach",
337 ),
338 (
339 "episodes.detach",
340 "majordomus_session_detach",
341 "/api/v1/episodes/detach",
342 ),
343 ];
344 let ids: Vec<&str> = m
345 .capabilities
346 .iter()
347 .map(|e| e.capability.id.as_str())
348 .collect();
349 let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
350 assert_eq!(ids, want);
351 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
352 let exposure = &executable.capability.exposure;
353 assert_eq!(
354 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
355 Some(*tool),
356 "{id} lost or renamed its MCP tool"
357 );
358 assert_eq!(
359 exposure.http.as_ref().map(|h| h.path.as_str()),
360 Some(*path),
361 "{id} lost or renamed its HTTP route"
362 );
363 }
364 }
365
366 /// An attach is refused before anything is opened when the identity it would be
367 /// recognised by is blank or would split the driver's one-line payload.
368 #[test]
369 fn an_attach_with_a_blank_or_multi_line_identity_is_invalid_input() {
370 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
371 let ctx = repo.context().unwrap();
372 let peer = ctx.peers.attach(crate::peers::Transport::Stdio);
373 let caller = ctx.for_caller(peer);
374 for (id, says) in [
375 (" ", "must not be blank"),
376 ("one\ntwo", "must be one line"),
377 ("one\rtwo", "must be one line"),
378 ] {
379 match episodes_attach(
380 &caller,
381 AttachInput {
382 external_id: id.into(),
383 },
384 ) {
385 Err(CapabilityError::InvalidInput(m)) => assert!(m.contains(says), "{m}"),
386 other => panic!("{id:?} was attached: {other:?}"),
387 }
388 }
389 assert!(caller.episodes.list().is_empty(), "nothing was opened");
390 }
391
392 /// A detach that names its episode closes that one, from any interface; one that names
393 /// an episode this server does not hold is not found.
394 #[test]
395 fn a_named_detach_closes_that_episode_and_an_unknown_one_is_not_found() {
396 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
397 let ctx = repo.context().unwrap();
398 let peer = ctx.peers.attach(crate::peers::Transport::Stdio);
399 // the board's own driver is not this test's subject: attach through the board
400 // with a driver that records nothing, then detach through the capability
401 let board = std::sync::Arc::new(crate::episodes::EpisodeBoard::new());
402 let mut held = (*ctx).clone();
403 held.episodes = std::sync::Arc::clone(&board);
404 board.attach(&peer, "named", CONNECTION_PROVIDER);
405
406 let closed = episodes_detach(
407 &held,
408 DetachInput {
409 external_id: Some(" named ".into()),
410 },
411 )
412 .expect("a named episode is closed");
413 assert_eq!(closed.episode.external_id, "named");
414 assert!(board.list().is_empty());
415
416 match episodes_detach(
417 &held,
418 DetachInput {
419 external_id: Some("never-opened".into()),
420 },
421 ) {
422 Err(CapabilityError::NotFound(m)) => {
423 assert_eq!(m, "no episode 'never-opened' on this server")
424 }
425 other => panic!("an unknown episode was closed: {other:?}"),
426 }
427 }
428}