Skip to main content

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}