majordomus_cli/capability/handler.rs
1//! What a capability's behaviour is, and what it is allowed to know.
2//!
3//! A handler is a typed function — `fn(&Context, Input) -> Result<Output, CapabilityError>`
4//! — placed behind a JSON boundary by [`handler`]. The boundary is what lets one registry
5//! hold capabilities with different input and output types; the typing is what keeps the
6//! handler from doing its own deserialisation.
7//!
8//! # What a handler may know
9//!
10//! [`Context`] is the whole of it: the index of the repository, the registry it belongs to,
11//! the board of peers attached to this process, the Why catalogue, the executor, the
12//! resolved web topology, and — only when the call arrived through an MCP session — which
13//! peer made it. A handler never learns whether MCP, HTTP or the command line called it,
14//! and that is deliberate: an answer that depended on the transport would be a second
15//! implementation hiding inside the first.
16//!
17//! Index, registry and topology are immutable for the life of a process and are shared by
18//! `Arc`, so a request never rebuilds canonical state. The peer board is the one thing that
19//! changes, and it lives in memory only.
20//!
21//! # The error model
22//!
23//! [`CapabilityError`] has four cases and names no status code and no JSON-RPC code. Each
24//! transport maps them into its own vocabulary, so adding a transport cannot change what a
25//! handler is able to say.
26//!
27//! ```
28//! use majordomus_cli::capability::{CapabilityError, Context, handler::handler};
29//! use serde::{Deserialize, Serialize};
30//!
31//! #[derive(Deserialize)]
32//! struct In { n: u32 }
33//! #[derive(Serialize)]
34//! struct Out { doubled: u32 }
35//!
36//! fn double(_: &Context, input: In) -> Result<Out, CapabilityError> {
37//! if input.n > 100 {
38//! return Err(CapabilityError::Refused("n is above the limit".into()));
39//! }
40//! Ok(Out { doubled: input.n * 2 })
41//! }
42//!
43//! // the JSON boundary: the same handler, called the way every transport calls it
44//! let boxed = handler::<In, Out, _>(double);
45//! assert_eq!(
46//! CapabilityError::Refused("n is above the limit".into()).to_string(),
47//! "refused: n is above the limit"
48//! );
49//! // an input the type cannot read is the caller's fault, and says so
50//! assert!(matches!(
51//! serde_json::from_value::<In>(serde_json::json!({ "n": "twelve" })),
52//! Err(_)
53//! ));
54//! let _ = boxed;
55//! ```
56
57use std::marker::PhantomData;
58use std::sync::Arc;
59
60use serde::de::DeserializeOwned;
61use serde::Serialize;
62use serde_json::Value;
63
64use crate::index::Index;
65use crate::peers::{PeerBoard, PeerId};
66use crate::web::Topology;
67
68use super::executor::CapabilityExecutor;
69
70use super::model::Capability;
71use super::registry::CapabilityRegistry;
72
73/// Why a call did not produce an output. Transport adapters map these to their own
74/// vocabularies; nothing here names a status code or a JSON-RPC code.
75#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
76pub enum CapabilityError {
77 #[error("invalid input: {0}")]
78 /// The input did not deserialize into the handler's type, or a filter named something the repository does not have.
79 InvalidInput(String),
80 #[error("not found: {0}")]
81 /// The thing asked for does not exist: an object, a capability.
82 NotFound(String),
83 #[error("refused: {0}")]
84 /// The input is well-formed and the call is declined, with the reason (a blank query).
85 Refused(String),
86 #[error("internal: {0}")]
87 /// The handler itself failed; never the caller's fault.
88 Internal(String),
89 #[error("forbidden: {0}")]
90 /// The caller may not run this from where it is: a request from another host for a
91 /// capability that changes something and does not authenticate its input (ADR 0126).
92 Forbidden(String),
93}
94
95impl CapabilityError {
96 /// The category as one word, the same on every transport: the `error.code` of an HTTP
97 /// answer and the `_meta.majordomus.error.code` of an MCP tool result that is an error.
98 /// A caller branches on this and reads the message; it never has to parse the message
99 /// to learn which of the five happened.
100 ///
101 /// ```
102 /// use majordomus_cli::capability::CapabilityError;
103 /// assert_eq!(CapabilityError::InvalidInput("x".into()).code(), "invalid_input");
104 /// assert_eq!(CapabilityError::NotFound("x".into()).code(), "not_found");
105 /// assert_eq!(CapabilityError::Refused("x".into()).code(), "refused");
106 /// assert_eq!(CapabilityError::Internal("x".into()).code(), "internal");
107 /// assert_eq!(CapabilityError::Forbidden("x".into()).code(), "forbidden");
108 /// ```
109 pub fn code(&self) -> &'static str {
110 match self {
111 CapabilityError::InvalidInput(_) => "invalid_input",
112 CapabilityError::NotFound(_) => "not_found",
113 CapabilityError::Refused(_) => "refused",
114 CapabilityError::Internal(_) => "internal",
115 CapabilityError::Forbidden(_) => "forbidden",
116 }
117 }
118
119 /// The refusal a caller on another host meets when it asks for a capability that
120 /// changes something and does not authenticate its input. One sentence on every
121 /// transport, so the HTTP route and the MCP tool refuse alike.
122 ///
123 /// ```
124 /// use majordomus_cli::capability::CapabilityError;
125 /// let e = CapabilityError::remote("plan.transition");
126 /// assert_eq!(e.code(), "forbidden");
127 /// assert!(e.to_string().contains("plan.transition"));
128 /// ```
129 pub fn remote(id: &str) -> Self {
130 CapabilityError::Forbidden(format!(
131 "'{id}' changes something, and this request came from another host: a caller \
132 beyond loopback reads and changes nothing, because this server cannot tell who \
133 it is (ADR 0126). Run it on this machine, or reach this server through a tunnel \
134 that ends on loopback"
135 ))
136 }
137}
138
139/// What a handler may read: the index of the repository, the registry it belongs to, the
140/// board of peers attached to this process, and, when the call came through an MCP
141/// session, which peer made it. Index and registry are immutable for the life of a
142/// process; the board is the one thing that changes, and it lives in memory only.
143#[derive(Clone)]
144pub struct Context {
145 /// The index of the repository's objects.
146 pub index: Arc<Index>,
147 /// The registry the handler belongs to, for introspection capabilities.
148 pub registry: Arc<CapabilityRegistry>,
149 /// The peers attached to this process.
150 pub peers: Arc<PeerBoard>,
151 /// The execution episodes this process holds: one per client that asked for one, keyed
152 /// by the client's own durable identity and driven by the connection (ADR 0103).
153 ///
154 /// Beside the peer board and not inside it, because a peer and an episode are not the
155 /// same thing: a connection holds zero or one episode, and an episode outlives the
156 /// connection that opened it so that a client which reconnects comes back to its own.
157 pub episodes: Arc<crate::episodes::EpisodeBoard>,
158 /// The Why catalogue, derived from the index once when this context is composed and
159 /// shared by every projection that reads it. A request never rebuilds it.
160 pub why: Arc<crate::why::Catalogue>,
161 /// The product model: the features of the layer with every fact a surface may say
162 /// about them derived from the registry, the index, the catalogue and the topology.
163 /// Built once, after the three it reads, and shared the same way.
164 pub product: Arc<crate::product::ProductModel>,
165 /// The one execution path: counters, cache, handler dispatch. Shared by every
166 /// transport and every session of this process.
167 pub executor: Arc<CapabilityExecutor>,
168 /// The peer this call came from, when it came through an MCP session; `None` for the
169 /// command line and for a plain HTTP request.
170 pub caller: Option<PeerId>,
171 /// The executions this process is running, and the engine that starts them.
172 ///
173 /// One per process, so that an execution started from a browser is the same execution
174 /// an MCP client asks about and the command line lists. A handler reads it only when
175 /// it is itself the execution plane's own capability; every other handler reports
176 /// through [`Context::progress`] and never learns whether anything was listening.
177 pub executions: Arc<crate::execution::ExecutionEngine>,
178 /// What this call reports its steps and progress to, and how it learns it should stop.
179 ///
180 /// Silent unless the call came through the execution engine, which is why a handler
181 /// may report unconditionally and a benchmark sample costs nothing.
182 pub progress: crate::execution::Progress,
183 /// The repository's web surfaces, resolved once for this process.
184 ///
185 /// Resolved here and never again: a projection that serves a subset of it narrows this
186 /// value rather than discovering its own, so what the HTTP router serves, what the home
187 /// page lists and what `web.surfaces` answers are three readings of one resolution.
188 pub web: Arc<Topology>,
189 /// The mesh runtime: the discovered nodes of this process, and the machinery that
190 /// observes them. Inactive (an empty registry with a reason) until a shared server
191 /// activates it from the repository's mesh declaration; the capabilities of the
192 /// `mesh` module are its projections.
193 pub mesh: Arc<crate::mesh::MeshRuntime>,
194 /// Which reading of the repository this is: a number no other [`Context::new`] of this
195 /// process was given, shared by every view of it ([`Context::for_caller`],
196 /// [`Context::reporting`], [`Context::with_web`]). A reload is a new reading. It is what
197 /// a [`crate::capability::CachePolicy::Generation`] entry is kept under, because the
198 /// registry fingerprint covers what is declared and not the git state an index was read
199 /// at.
200 pub reading: u64,
201}
202
203/// The number the next [`Context::new`] takes as its [`Context::reading`].
204static READINGS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
205
206impl Context {
207 /// A context over an index and a registry, with an empty board, a fresh executor and
208 /// no caller.
209 pub fn new(index: Arc<Index>, registry: Arc<CapabilityRegistry>) -> Self {
210 let index_root = std::path::PathBuf::from(&index.repository.root);
211 let web = Arc::new(resolve_web(&index));
212 let why = Arc::new(crate::why::Catalogue::build(&index, ®istry));
213 let product = Arc::new(crate::product::ProductModel::build(
214 &index, ®istry, &why, &web,
215 ));
216 Context {
217 index,
218 registry,
219 why,
220 product,
221 peers: Arc::new(PeerBoard::new()),
222 // The board drives the repository the index was read from: the episode a client
223 // opens here is an episode of *this* checkout, written by the same command a
224 // provider hook runs. A checkout whose tool cannot be found says so in every
225 // episode rather than recording nothing quietly.
226 episodes: Arc::new(crate::episodes::EpisodeBoard::for_repository(
227 index_root.clone(),
228 )),
229 executor: Arc::new(CapabilityExecutor::new()),
230 executions: Arc::new(crate::execution::ExecutionEngine::default()),
231 progress: crate::execution::Progress::silent(),
232 caller: None,
233 web,
234 mesh: Arc::new(crate::mesh::MeshRuntime::new()),
235 reading: READINGS.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
236 }
237 }
238
239 /// This context's reading of the repository, carrying on the in-memory life of an
240 /// earlier one: what [`crate::live::Live`] hands out after the repository has moved.
241 ///
242 /// A reload is not a restart. The index, the registry and everything derived from them
243 /// — the Why catalogue, the product model, the web topology — are pictures of the
244 /// repository and are replaced wholesale, which is the point. The peer board, the
245 /// executions this process is running, the episodes its clients opened and the capability
246 /// executor are not pictures of
247 /// anything: they are this process's own life, and a peer that announced its intent
248 /// must not vanish from the board because somebody else committed. The executor is
249 /// carried because its cache is keyed by the registry's fingerprint, so the entries of
250 /// an older generation are already unreachable and the ones of an unchanged capability
251 /// are still worth having.
252 ///
253 /// ```
254 /// # use std::sync::Arc;
255 /// # use majordomus_cli::capability::Context;
256 /// # fn example(before: Arc<Context>, after: Arc<Context>) {
257 /// let renewed = after.continuing(&before);
258 /// assert!(Arc::ptr_eq(&renewed.peers, &before.peers), "the board survives a reload");
259 /// assert!(Arc::ptr_eq(&renewed.episodes, &before.episodes), "so do the episodes");
260 /// assert!(Arc::ptr_eq(&renewed.index, &after.index), "the picture does not");
261 /// # }
262 /// ```
263 pub fn continuing(&self, previous: &Context) -> Self {
264 Context {
265 peers: Arc::clone(&previous.peers),
266 executions: Arc::clone(&previous.executions),
267 executor: Arc::clone(&previous.executor),
268 // The episode board is this process's life too: the episodes its clients opened
269 // and the ones a lost connection detached. A reload that handed out a fresh board
270 // would forget every one of them because the repository moved, and an index
271 // refresh by `git status` is enough to move it.
272 episodes: Arc::clone(&previous.episodes),
273 // The mesh runtime is this process's, not this generation's: it holds the
274 // sockets that are already announcing and the registry every observation has
275 // converged into. A rebuild that handed the surfaces a fresh one would leave
276 // the server announcing from a runtime nothing could read.
277 mesh: Arc::clone(&previous.mesh),
278 ..self.same()
279 }
280 }
281
282 /// The same context, seen from one peer: what a session hands its handlers.
283 pub fn for_caller(&self, caller: PeerId) -> Self {
284 Context {
285 caller: Some(caller),
286 ..self.same()
287 }
288 }
289
290 /// The same context, seen by one execution: what the engine hands the handler it runs.
291 ///
292 /// Everything else is shared, the engine included, so a capability that reads the
293 /// executions of this process sees its own while it runs.
294 pub fn reporting(&self, progress: crate::execution::Progress) -> Self {
295 Context {
296 progress,
297 ..self.same()
298 }
299 }
300
301 /// The same context with the web topology narrowed to what one projection serves.
302 ///
303 /// The narrowing is a filter over the value this context already holds, never a second
304 /// discovery: a projection cannot serve a surface the process did not resolve.
305 pub fn with_web(&self, web: Arc<Topology>) -> Self {
306 Context { web, ..self.same() }
307 }
308
309 fn same(&self) -> Self {
310 Context {
311 index: Arc::clone(&self.index),
312 registry: Arc::clone(&self.registry),
313 why: Arc::clone(&self.why),
314 product: Arc::clone(&self.product),
315 peers: Arc::clone(&self.peers),
316 episodes: Arc::clone(&self.episodes),
317 executor: Arc::clone(&self.executor),
318 executions: Arc::clone(&self.executions),
319 progress: self.progress.clone(),
320 caller: self.caller.clone(),
321 web: Arc::clone(&self.web),
322 mesh: Arc::clone(&self.mesh),
323 reading: self.reading,
324 }
325 }
326
327 /// Execute a capability by id: the one way anything calls a handler.
328 pub fn execute(&self, id: &str, input: Value) -> Result<Value, CapabilityError> {
329 self.executor.execute(self, id, input)
330 }
331
332 /// The same, with the cache stepped over: what an execution runs.
333 ///
334 /// See [`CapabilityExecutor::execute_uncached`] for why watching something happen and
335 /// being handed a remembered answer are not the same request.
336 pub fn execute_observed(&self, id: &str, input: Value) -> Result<Value, CapabilityError> {
337 self.executor.execute_uncached(self, id, input)
338 }
339
340 /// The latest reading of a capability, at once, with how old it is: what a page shows.
341 ///
342 /// Never an answer: see [`crate::capability::readings`] for what it is instead, and
343 /// what it costs.
344 ///
345 /// ```
346 /// use majordomus_cli::capability::readings::Freshness;
347 /// use majordomus_cli::synthetic::SyntheticRepository;
348 ///
349 /// let repo = SyntheticRepository::small().unwrap();
350 /// let ctx = repo.context().unwrap();
351 /// let reading = ctx.read("plan.status", serde_json::json!({}));
352 /// // a first reading is waited for, so it holds what `execute` answers, and says how it
353 /// // stands: read just now, in the repository as it is
354 /// assert_eq!(reading.value.ok(), ctx.execute("plan.status", serde_json::json!({})).ok());
355 /// assert_eq!(reading.freshness, Freshness::Current);
356 /// assert!(reading.age.is_some());
357 /// ```
358 pub fn read(&self, id: &str, input: Value) -> super::readings::Latest {
359 super::readings::read(self, id, input, super::readings::Patience::of_process())
360 }
361}
362
363/// The topology of the repository the index was read from.
364///
365/// Discovery reads the site's configuration, the generated web root and the executable's
366/// own constants; a malformed producer declaration is reported and leaves the process with
367/// the surfaces it answers itself, which is what lets a broken report be diagnosed over
368/// the very surfaces that would otherwise be unreachable.
369fn resolve_web(index: &Index) -> Topology {
370 let root = std::path::Path::new(&index.repository.root);
371 match crate::web::discover::discover(root, crate::web::discover::Runtime::full()) {
372 Ok(topology) => topology,
373 Err(e) => {
374 tracing::warn!(
375 "a web surface could not be resolved: {e}; this process serves its own routes only"
376 );
377 Topology::new(crate::web::discover::native(
378 crate::web::discover::Runtime::full(),
379 ))
380 }
381 }
382}
383
384/// The JSON boundary of a handler.
385pub trait Handler: Send + Sync {
386 /// Run the handler on a JSON input and answer with a JSON output.
387 fn call(&self, ctx: &Context, input: Value) -> Result<Value, CapabilityError>;
388}
389
390struct Typed<I, O, F> {
391 f: F,
392 _io: PhantomData<fn(I) -> O>,
393}
394
395impl<I, O, F> Handler for Typed<I, O, F>
396where
397 I: DeserializeOwned,
398 O: Serialize,
399 F: Fn(&Context, I) -> Result<O, CapabilityError> + Send + Sync,
400{
401 fn call(&self, ctx: &Context, input: Value) -> Result<Value, CapabilityError> {
402 let input: I = serde_json::from_value(input)
403 .map_err(|e| CapabilityError::InvalidInput(e.to_string()))?;
404 let output = (self.f)(ctx, input)?;
405 serde_json::to_value(output).map_err(|e| CapabilityError::Internal(e.to_string()))
406 }
407}
408
409/// Wrap a typed function as a handler.
410pub fn handler<I, O, F>(f: F) -> Arc<dyn Handler>
411where
412 I: DeserializeOwned + 'static,
413 O: Serialize + 'static,
414 F: Fn(&Context, I) -> Result<O, CapabilityError> + Send + Sync + 'static,
415{
416 Arc::new(Typed {
417 f,
418 _io: PhantomData,
419 })
420}
421
422/// A descriptor with its behaviour and its benchmark cases: what the builtin source
423/// contributes.
424pub struct Executable {
425 /// The descriptor.
426 pub capability: Capability,
427 /// The behaviour, behind the JSON boundary.
428 pub handler: Arc<dyn Handler>,
429 /// The representative inputs, from the input type's `BenchmarkCases`.
430 pub cases: super::benchmark::CaseProvider,
431}
432
433impl Executable {
434 /// Declare that this handler looks at its cancellation flag and stops.
435 ///
436 /// The one thing about a capability that its kind cannot decide, because it is a fact
437 /// about the handler's own code rather than about what the call means. Everything a
438 /// client does with it — whether the Cockpit offers a Cancel button, what
439 /// `executions.cancel` reports — is read from here, so a handler that ignores its flag
440 /// never produces a control that lies.
441 ///
442 /// ```
443 /// use majordomus_cli::capability;
444 /// use majordomus_cli::capability::{BenchmarkCases, CaseContext, Context, CapabilityError, Exposure, NamedCase, Stability};
445 /// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
446 /// struct In {}
447 /// impl BenchmarkCases for In {
448 /// fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> { vec![NamedCase::new("default", In {})] }
449 /// }
450 /// #[derive(serde::Serialize, schemars::JsonSchema)]
451 /// struct Out { ok: bool }
452 /// fn walk(ctx: &Context, _: In) -> Result<Out, CapabilityError> {
453 /// if ctx.progress.cancelled() { return Err(ctx.progress.cancellation()); }
454 /// Ok(Out { ok: true })
455 /// }
456 /// let e = capability! {
457 /// id: "demo.walk", title: "Walk", description: "Walks.", input: In, output: Out,
458 /// stability: Stability::Experimental, exposure: Exposure::default(), tags: [],
459 /// handler: walk,
460 /// }.cancellable();
461 /// assert!(e.capability.execution.cancellable);
462 /// ```
463 pub fn cancellable(mut self) -> Self {
464 self.capability.execution = self.capability.execution.stoppable();
465 self
466 }
467
468 /// Declare that this handler writes the repository's own tracked files.
469 ///
470 /// The second thing a kind cannot decide, and for the same reason: two commands are the
471 /// same kind whether one announces a peer into this process's memory and the other
472 /// stamps a field into a record a commit will carry. The difference is the whole of what
473 /// a caller needs before saying yes, and every projection reads it from here — the
474 /// Cockpit's confirmation, and the exposure ceiling that decides which surfaces may
475 /// reach it at all.
476 ///
477 /// Declaring it is the handler author's obligation, not an optimisation. A handler that
478 /// writes a tracked file without saying so is projected as though it did not, onto
479 /// surfaces whose ceiling exists to exclude exactly that.
480 ///
481 /// ```
482 /// use majordomus_cli::capability;
483 /// use majordomus_cli::capability::{BenchmarkCases, CapabilityKind, CaseContext, Context, CapabilityError, Effect, Exposure, NamedCase, Stability};
484 /// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
485 /// struct In {}
486 /// impl BenchmarkCases for In {
487 /// fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> { vec![NamedCase::new("default", In {})] }
488 /// }
489 /// #[derive(serde::Serialize, schemars::JsonSchema)]
490 /// struct Out { ok: bool }
491 /// fn write(_: &Context, _: In) -> Result<Out, CapabilityError> { Ok(Out { ok: true }) }
492 /// let e = capability! {
493 /// id: "demo.write", kind: CapabilityKind::Command, title: "Write", description: "Writes.",
494 /// input: In, output: Out, stability: Stability::Experimental,
495 /// exposure: Exposure::default(), tags: [], handler: write,
496 /// }.writes_repository();
497 /// assert_eq!(e.capability.execution.effect, Effect::RepositoryMutation);
498 /// ```
499 pub fn writes_repository(mut self) -> Self {
500 self.capability.execution = self.capability.execution.writes_repository();
501 self
502 }
503
504 /// Declare the short name a person invokes this by from an agent's prompt, and the
505 /// input field the text typed after it fills. See [`crate::capability::SkillExposure`];
506 /// the registry refuses a name that is malformed, reserved or taken, and an argument
507 /// the input does not have.
508 ///
509 /// ```
510 /// use majordomus_cli::capability;
511 /// use majordomus_cli::capability::{BenchmarkCases, CapabilityKind, CaseContext, Context, CapabilityError, Exposure, NamedCase, Stability};
512 /// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
513 /// struct In { text: String }
514 /// impl BenchmarkCases for In {
515 /// fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> { vec![] }
516 /// }
517 /// #[derive(serde::Serialize, schemars::JsonSchema)]
518 /// struct Out {}
519 /// fn say(_: &Context, _: In) -> Result<Out, CapabilityError> { Ok(Out {}) }
520 /// let e = capability! {
521 /// id: "demo.say", kind: CapabilityKind::Command, title: "Say", description: "Says.",
522 /// input: In, output: Out, stability: Stability::Experimental,
523 /// exposure: Exposure::default(), tags: [], handler: say,
524 /// }.skill("say", "text");
525 /// let skill = e.capability.skill.unwrap();
526 /// assert_eq!((skill.alias.as_str(), skill.argument.as_str()), ("say", "text"));
527 /// ```
528 pub fn skill(mut self, alias: &str, argument: &str) -> Self {
529 self.capability.skill = Some(crate::capability::SkillExposure {
530 alias: alias.into(),
531 argument: argument.into(),
532 });
533 self
534 }
535
536 /// Declare that the input is a signed message the handler verifies before it changes
537 /// anything, so a caller on another host is admitted by its key rather than refused by
538 /// its address. See [`crate::capability::ExecutionPolicy::authenticates_its_input`].
539 ///
540 /// ```
541 /// use majordomus_cli::capability;
542 /// use majordomus_cli::capability::{BenchmarkCases, CapabilityKind, CaseContext, Context, CapabilityError, Effect, Exposure, NamedCase, Stability};
543 /// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
544 /// struct Signed {}
545 /// impl BenchmarkCases for Signed {
546 /// fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> { vec![NamedCase::new("default", Signed {})] }
547 /// }
548 /// #[derive(serde::Serialize, schemars::JsonSchema)]
549 /// struct Out { ok: bool }
550 /// fn hello(_: &Context, _: Signed) -> Result<Out, CapabilityError> { Ok(Out { ok: true }) }
551 /// let e = capability! {
552 /// id: "demo.hello", kind: CapabilityKind::Command, title: "Hello", description: "Verifies a signed hello.",
553 /// input: Signed, output: Out, stability: Stability::Experimental,
554 /// exposure: Exposure::default(), tags: [], handler: hello,
555 /// }.authenticates_its_input();
556 /// assert!(e.capability.execution.signed_input && e.capability.execution.admits_remote());
557 /// assert_eq!(e.capability.execution.effect, Effect::ProcessState);
558 /// ```
559 pub fn authenticates_its_input(mut self) -> Self {
560 self.capability.execution = self.capability.execution.authenticates_its_input();
561 self
562 }
563}
564
565/// The one canonical declaration of an executable capability. From it every projection is
566/// derived: the MCP tool and its schemas, the HTTP route and its OpenAPI operation, the
567/// Swagger UI entry, the command line's introspection, the benchmark targets for every
568/// transport it is exposed on, the cache policy the executor applies, and the generated
569/// reference. Nothing is declared a second time anywhere.
570///
571/// Fields, in this order: `id`, an optional `kind` (a query by default;
572/// `CapabilityKind::Command` for one that changes this process's memory), `title`,
573/// `description`, `input`, `output`, `stability`, `exposure`, `tags`, an optional `cache`
574/// (`CachePolicy::Disabled` by default), an optional `benchmark`
575/// (`BenchmarkPolicy::Required` by default), and `handler`. `$input` and `$output` derive
576/// `schemars::JsonSchema` (and serde), and `$input` implements
577/// [`super::benchmark::BenchmarkCases`]: their schemas become the canonical schemas,
578/// their doc comments the descriptions a client reads, and the input's cases the
579/// benchmark inputs of every transport. The provenance is the Rust module the macro is
580/// expanded in; the capability's module is stamped by [`crate::module!`]. Nothing here
581/// registers anything: the result is composed explicitly into a module.
582///
583/// ```
584/// use majordomus_cli::capability;
585/// use majordomus_cli::capability::{BenchmarkCases, CachePolicy, CaseContext, Context, CapabilityError, Exposure, NamedCase, Stability};
586/// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
587/// struct In {}
588/// impl BenchmarkCases for In {
589/// fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> { vec![NamedCase::new("default", In {})] }
590/// }
591/// #[derive(serde::Serialize, schemars::JsonSchema)]
592/// struct Out { ok: bool }
593/// fn ping(_: &Context, _: In) -> Result<Out, CapabilityError> { Ok(Out { ok: true }) }
594/// let e = capability! {
595/// id: "demo.ping", title: "Ping", description: "Answers.", input: In, output: Out,
596/// stability: Stability::Experimental, exposure: Exposure::default(), tags: ["demo"],
597/// cache: CachePolicy::Process { max_entries: 8, ttl_seconds: None },
598/// handler: ping,
599/// };
600/// assert_eq!(e.capability.id.as_str(), "demo.ping");
601/// assert!(e.capability.cache.is_enabled());
602/// ```
603#[macro_export]
604macro_rules! capability {
605 // ---- the full form: every field present
606 (
607 id: $id:expr,
608 kind: $kind:expr,
609 title: $title:expr,
610 description: $description:expr,
611 input: $input:ty,
612 output: $output:ty,
613 stability: $stability:expr,
614 exposure: $exposure:expr,
615 tags: [$($tag:expr),* $(,)?],
616 cache: $cache:expr,
617 benchmark: $benchmark:expr,
618 handler: $handler:expr $(,)?
619 ) => {{
620 // classified here, from the kind and the exposure this declaration already
621 // carries, so that no declaration restates what it has just said and no
622 // projection decides it later
623 let kind = $kind;
624 let exposure = $exposure;
625 $crate::capability::Executable {
626 capability: $crate::capability::Capability {
627 id: $crate::capability::CapabilityId::unchecked($id),
628 module: $crate::capability::ModuleId::unchecked(""),
629 kind,
630 title: String::from($title),
631 description: String::from($description),
632 input: $crate::capability::CanonicalSchema::of::<$input>(),
633 output: $crate::capability::CanonicalSchema::of::<$output>(),
634 provenance: $crate::capability::Provenance::Builtin { module: String::from(module_path!()) },
635 availability: $crate::capability::Availability::classify(kind, &exposure),
636 visibility: $crate::capability::Visibility::classify(&exposure),
637 exposure,
638 stability: $stability,
639 tags: vec![$(String::from($tag)),*],
640 benchmark: $benchmark,
641 cache: $cache,
642 execution: $crate::capability::ExecutionPolicy::classify(kind),
643 skill: None,
644 },
645 handler: $crate::capability::handler::handler::<$input, $output, _>($handler),
646 cases: <$input as $crate::capability::BenchmarkCases>::benchmark_cases_json,
647 }
648 }};
649 // ---- defaults: kind Query, cache Disabled, benchmark Required, in every combination
650 ( id: $id:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], handler: $h:expr $(,)? ) => {
651 $crate::capability! { id: $id, kind: $crate::capability::CapabilityKind::Query, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $crate::capability::CachePolicy::Disabled, benchmark: $crate::capability::BenchmarkPolicy::Required, handler: $h }
652 };
653 ( id: $id:expr, kind: $k:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], handler: $h:expr $(,)? ) => {
654 $crate::capability! { id: $id, kind: $k, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $crate::capability::CachePolicy::Disabled, benchmark: $crate::capability::BenchmarkPolicy::Required, handler: $h }
655 };
656 ( id: $id:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], cache: $c:expr, handler: $h:expr $(,)? ) => {
657 $crate::capability! { id: $id, kind: $crate::capability::CapabilityKind::Query, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $c, benchmark: $crate::capability::BenchmarkPolicy::Required, handler: $h }
658 };
659 ( id: $id:expr, kind: $k:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], cache: $c:expr, handler: $h:expr $(,)? ) => {
660 $crate::capability! { id: $id, kind: $k, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $c, benchmark: $crate::capability::BenchmarkPolicy::Required, handler: $h }
661 };
662 ( id: $id:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], benchmark: $b:expr, handler: $h:expr $(,)? ) => {
663 $crate::capability! { id: $id, kind: $crate::capability::CapabilityKind::Query, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $crate::capability::CachePolicy::Disabled, benchmark: $b, handler: $h }
664 };
665 ( id: $id:expr, kind: $k:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], benchmark: $b:expr, handler: $h:expr $(,)? ) => {
666 $crate::capability! { id: $id, kind: $k, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $crate::capability::CachePolicy::Disabled, benchmark: $b, handler: $h }
667 };
668 ( id: $id:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], cache: $c:expr, benchmark: $b:expr, handler: $h:expr $(,)? ) => {
669 $crate::capability! { id: $id, kind: $crate::capability::CapabilityKind::Query, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $c, benchmark: $b, handler: $h }
670 };
671}