Skip to main content

majordomus_cli/capability/builtin/
executions.rs

1//! The `executions` module: starting a capability as an execution, reading what it is
2//! doing, and asking it to stop.
3//!
4//! Every capability here is about executions; none of them is an execution engine of its
5//! own. What runs the work is [`crate::execution::ExecutionEngine`], reached through the
6//! context every handler already has, so a browser posting to `/api/v1/executions/start`,
7//! an agent calling `majordomus_execution_start` and a terminal running `majordomus run`
8//! reach one implementation and one store.
9//!
10//! There is no capability here that lists what may be run: that is `capabilities.list`,
11//! which already answers it, and a second list would be the same fact written twice. Every
12//! executable capability of the registry can be started as an execution, and the
13//! descriptor's own `execution` policy is what says what running it means.
14
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
20use crate::capability::handler::{CapabilityError, Context};
21use crate::capability::model::{
22    BenchmarkPolicy, CapabilityKind, Exposure, McpExposure, McpResource, Stability, WaiverReason,
23};
24use crate::capability::module::ModuleDescriptor;
25use crate::capability::CanonicalSchema;
26use crate::execution::engine::repository_of;
27use crate::execution::{
28    redact, Actor, ActorKind, CancelOutcome, Execution, ExecutionEvent, ExecutionId,
29    ExecutionState, Seed, SubmitError, PROTOCOL_VERSION, REPORT_SILENCE,
30};
31use crate::{capability, module};
32
33use super::{get, mcp, post, Empty};
34
35/// The MCP resource the list of executions is read at.
36pub const EXECUTIONS_URI: &str = "majordomus://executions";
37
38/// The MCP resource the live channel's own contract is read at.
39pub const EXECUTION_PROTOCOL_URI: &str = "majordomus://executions/protocol";
40
41/// The most executions one listing answers with.
42pub const MAX_LIST: usize = 200;
43
44/// The most events one page of history answers with.
45pub const MAX_EVENTS: usize = 500;
46
47// ---------------------------------------------------------------- shared views
48
49/// Where to read more about one execution, in this server's own terms.
50///
51/// Every value is derived from the registry's declared routes and from the live channel's
52/// own constant, so a route that moves moves here too and no client holds a path this
53/// server does not serve.
54#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
55pub struct ExecutionLinks {
56    /// The snapshot: `GET /api/v1/executions/get?id=…`.
57    #[serde(rename = "self")]
58    pub itself: String,
59    /// The retained history: `GET /api/v1/executions/events?id=…`.
60    pub events: String,
61    /// Where to ask it to stop: `POST /api/v1/executions/cancel`.
62    pub cancel: String,
63    /// The live channel, from the beginning: `GET /events?execution=…`.
64    pub websocket: String,
65    /// The page a person opens.
66    pub cockpit: String,
67}
68
69impl ExecutionLinks {
70    /// The links for one execution, built from what the registry declares.
71    pub fn of(ctx: &Context, id: &ExecutionId) -> Self {
72        let route = |capability: &str| {
73            ctx.registry
74                .get(capability)
75                .and_then(|c| c.exposure.http.as_ref())
76                .map(|h| h.path.clone())
77                .unwrap_or_default()
78        };
79        let query = crate::http::router::percent_encode(id.as_str());
80        ExecutionLinks {
81            itself: format!("{}?id={query}", route("executions.get")),
82            events: format!("{}?id={query}", route("executions.events")),
83            cancel: route("executions.cancel"),
84            websocket: format!("{}?execution={query}", crate::http::events::PATH),
85            cockpit: format!("{}/executions/{query}", crate::cockpit::PREFIX),
86        }
87    }
88}
89
90/// One execution with the links to everything else about it.
91#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
92pub struct ExecutionView {
93    /// The execution.
94    #[serde(flatten)]
95    pub execution: Execution,
96    /// Where to read more.
97    pub links: ExecutionLinks,
98}
99
100impl ExecutionView {
101    fn of(ctx: &Context, execution: Execution) -> Self {
102        let links = ExecutionLinks::of(ctx, &execution.id);
103        ExecutionView { execution, links }
104    }
105}
106
107fn parse_id(text: &str) -> Result<ExecutionId, CapabilityError> {
108    ExecutionId::parse(text).ok_or_else(|| {
109        CapabilityError::InvalidInput(format!(
110            "'{text}' is not an execution id; they look like x-20260908T010203Z-0a1b2c3d"
111        ))
112    })
113}
114
115fn actor(ctx: &Context) -> Actor {
116    match &ctx.caller {
117        Some(peer) => Actor {
118            kind: ActorKind::Mcp,
119            peer: Some(peer.to_string()),
120            pid: None,
121        },
122        None => Actor::of(ActorKind::Http),
123    }
124}
125
126// ---------------------------------------------------------------- executions.start
127
128#[derive(Debug, Serialize, Deserialize, JsonSchema)]
129#[serde(deny_unknown_fields)]
130/// The input of `executions.start`: which capability to run, and with what.
131pub struct StartInput {
132    /// The canonical id of the capability to run (`health.report`, `objects.verify`).
133    pub capability: String,
134    /// Its input, as its own input schema describes it; an empty object when it takes none.
135    #[serde(default)]
136    pub input: Option<Value>,
137}
138
139impl BenchmarkCases for StartInput {
140    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
141        vec![NamedCase::new(
142            "demonstrate",
143            StartInput {
144                capability: "executions.demonstrate".into(),
145                input: Some(serde_json::json!({ "steps": 1, "delay_ms": 0 })),
146            },
147        )]
148    }
149}
150
151fn executions_start(ctx: &Context, input: StartInput) -> Result<ExecutionView, CapabilityError> {
152    let payload = input.input.unwrap_or_else(|| serde_json::json!({}));
153    match ctx
154        .executions
155        .submit(ctx, &input.capability, payload, actor(ctx))
156    {
157        Ok(execution) => Ok(ExecutionView::of(ctx, execution)),
158        Err(SubmitError::UnknownCapability(id)) => Err(CapabilityError::NotFound(format!(
159            "no capability '{id}'; the executable ones are listed by capabilities.list"
160        ))),
161        Err(e @ SubmitError::NotExecutable(_)) => Err(CapabilityError::Refused(e.to_string())),
162        Err(SubmitError::InvalidInput(m)) => Err(CapabilityError::InvalidInput(m)),
163        Err(e @ SubmitError::Unavailable(_)) => Err(CapabilityError::Refused(e.to_string())),
164    }
165}
166
167// ---------------------------------------------------------------- executions.list
168
169#[derive(Debug, Serialize, Deserialize, JsonSchema)]
170#[serde(deny_unknown_fields)]
171/// The input of `executions.list`.
172pub struct ListInput {
173    /// Only executions in this state.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub state: Option<ExecutionState>,
176    /// Only executions of this capability.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub capability: Option<String>,
179    /// How many, newest first; the default and the bound are both `200`.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub limit: Option<usize>,
182}
183
184impl BenchmarkCases for ListInput {
185    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
186        // a case that binds a parameter: a listing with none bound is the same request with
187        // an empty query string, and the OpenAPI document would then show an operation with
188        // parameters and no example of any of them
189        vec![NamedCase::new(
190            "recent",
191            ListInput {
192                state: None,
193                capability: None,
194                limit: Some(50),
195            },
196        )]
197    }
198}
199
200#[derive(Debug, Serialize, Deserialize, JsonSchema)]
201/// The answer of `executions.list`.
202pub struct ExecutionList {
203    /// How many are answered here.
204    pub count: usize,
205    /// How many this process remembers in total, before the filter.
206    pub remembered: usize,
207    /// How many are not finished.
208    pub active: usize,
209    /// How many are waiting for a worker.
210    pub queued: usize,
211    /// How many live channels are following them.
212    pub live_channels: usize,
213    /// The executions, newest first.
214    pub executions: Vec<ExecutionView>,
215}
216
217fn executions_list(ctx: &Context, input: ListInput) -> Result<ExecutionList, CapabilityError> {
218    let store = ctx.executions.store();
219    store.abandon_silent(REPORT_SILENCE);
220    let limit = input.limit.unwrap_or(MAX_LIST).min(MAX_LIST);
221    let executions = store.list(input.state, input.capability.as_deref(), limit);
222    Ok(ExecutionList {
223        count: executions.len(),
224        remembered: store.len(),
225        active: store.active(),
226        queued: ctx.executions.queued(),
227        live_channels: crate::http::events::open_connections(),
228        executions: executions
229            .into_iter()
230            .map(|e| ExecutionView::of(ctx, e))
231            .collect(),
232    })
233}
234
235// ---------------------------------------------------------------- executions.get
236
237#[derive(Debug, Serialize, Deserialize, JsonSchema)]
238#[serde(deny_unknown_fields)]
239/// The input of `executions.get`.
240pub struct GetInput {
241    /// The execution's id.
242    pub id: String,
243}
244
245impl BenchmarkCases for GetInput {
246    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
247        Vec::new()
248    }
249}
250
251fn executions_get(ctx: &Context, input: GetInput) -> Result<ExecutionView, CapabilityError> {
252    let id = parse_id(&input.id)?;
253    ctx.executions.store().abandon_silent(REPORT_SILENCE);
254    ctx.executions
255        .store()
256        .get(&id)
257        .map(|e| ExecutionView::of(ctx, e))
258        .ok_or_else(|| {
259            CapabilityError::NotFound(format!(
260                "no execution {id} in this process; executions live as long as the server that ran them"
261            ))
262        })
263}
264
265// ---------------------------------------------------------------- executions.events
266
267#[derive(Debug, Serialize, Deserialize, JsonSchema)]
268#[serde(deny_unknown_fields)]
269/// The input of `executions.events`.
270pub struct EventsInput {
271    /// The execution's id.
272    pub id: String,
273    /// Only events after this sequence number: the cursor a reconnecting client holds.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub after: Option<u64>,
276    /// How many, oldest first; the default and the bound are both `500`.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub limit: Option<usize>,
279}
280
281impl BenchmarkCases for EventsInput {
282    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
283        Vec::new()
284    }
285}
286
287#[derive(Debug, Serialize, Deserialize, JsonSchema)]
288/// The answer of `executions.events`: one page of an execution's retained history.
289pub struct EventHistory {
290    /// The execution the events belong to.
291    pub execution_id: String,
292    /// Where the execution stands now, so a client needs one request rather than two.
293    pub state: ExecutionState,
294    /// The events, oldest first.
295    pub events: Vec<ExecutionEvent>,
296    /// The sequence of the last event here: the cursor for the next page, and the one to
297    /// open the live channel with.
298    pub last_sequence: u64,
299    /// Whether more events follow this page right now.
300    pub more: bool,
301    /// Whether events before this page had already been dropped by the store's bound.
302    pub truncated: bool,
303}
304
305fn executions_events(ctx: &Context, input: EventsInput) -> Result<EventHistory, CapabilityError> {
306    let id = parse_id(&input.id)?;
307    let store = ctx.executions.store();
308    store.abandon_silent(REPORT_SILENCE);
309    let snapshot = store
310        .get(&id)
311        .ok_or_else(|| CapabilityError::NotFound(format!("no execution {id} in this process")))?;
312    let limit = input.limit.unwrap_or(MAX_EVENTS).min(MAX_EVENTS);
313    let page = store
314        .events(&id, input.after.unwrap_or(0), limit)
315        .ok_or_else(|| CapabilityError::NotFound(format!("no execution {id} in this process")))?;
316    Ok(EventHistory {
317        execution_id: id.to_string(),
318        state: snapshot.state,
319        events: page.events.iter().map(|e| (**e).clone()).collect(),
320        last_sequence: page.last_sequence,
321        more: page.more,
322        truncated: page.truncated,
323    })
324}
325
326// ---------------------------------------------------------------- executions.cancel
327
328#[derive(Debug, Serialize, Deserialize, JsonSchema)]
329#[serde(deny_unknown_fields)]
330/// The input of `executions.cancel`.
331pub struct CancelInput {
332    /// The execution's id.
333    pub id: String,
334}
335
336impl BenchmarkCases for CancelInput {
337    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
338        Vec::new()
339    }
340}
341
342#[derive(Debug, Serialize, Deserialize, JsonSchema)]
343/// The answer of `executions.cancel`.
344pub struct CancelReport {
345    /// What asking achieved: `requested`, `already_requested` or `already_finished`.
346    pub outcome: String,
347    /// Whether the capability declares that it looks at its cancellation flag. When it
348    /// does not, the request is recorded and the execution runs to completion.
349    pub cancellable: bool,
350    /// The execution as it stands after the request.
351    pub execution: ExecutionView,
352}
353
354fn executions_cancel(ctx: &Context, input: CancelInput) -> Result<CancelReport, CapabilityError> {
355    let id = parse_id(&input.id)?;
356    let by = match &ctx.caller {
357        Some(peer) => peer.to_string(),
358        None => "client".to_string(),
359    };
360    let outcome = match ctx.executions.cancel(&id, &by) {
361        CancelOutcome::Requested => "requested",
362        CancelOutcome::AlreadyRequested => "already_requested",
363        CancelOutcome::AlreadyFinished(_) => "already_finished",
364        CancelOutcome::Unknown => {
365            return Err(CapabilityError::NotFound(format!(
366                "no execution {id} in this process"
367            )))
368        }
369    };
370    let execution =
371        ctx.executions.store().get(&id).ok_or_else(|| {
372            CapabilityError::NotFound(format!("no execution {id} in this process"))
373        })?;
374    let cancellable = execution.cancellable;
375    Ok(CancelReport {
376        outcome: outcome.into(),
377        cancellable,
378        execution: ExecutionView::of(ctx, execution),
379    })
380}
381
382// ---------------------------------------------------------------- executions.report
383
384#[derive(Debug, Serialize, Deserialize, JsonSchema)]
385#[serde(deny_unknown_fields)]
386/// The input of `executions.report`: what a process that runs an execution says about it to
387/// the server serving the same checkout.
388pub struct ReportInput {
389    /// The execution's id, as the reporting process minted it.
390    pub id: String,
391    /// The capability it runs, by its canonical id.
392    pub capability: String,
393    /// That capability's title.
394    pub title: String,
395    /// The input it was given, as the reporting process stored it: already redacted there,
396    /// and redacted again here by this registry's schema when this registry knows it.
397    #[serde(default)]
398    pub input: Value,
399    /// Whether the capability stops when asked. When it does, a cancel asked of this server
400    /// reaches the reporter in the answer to its next report.
401    #[serde(default)]
402    pub cancellable: bool,
403    /// The process that runs it.
404    pub pid: u32,
405    /// Its events after the last sequence this server answered `through`, in its own
406    /// sequence, oldest first; at most 500. Empty is a heartbeat.
407    #[serde(default)]
408    pub events: Vec<ExecutionEvent>,
409}
410
411impl BenchmarkCases for ReportInput {
412    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
413        Vec::new()
414    }
415}
416
417#[derive(Debug, Serialize, Deserialize, JsonSchema)]
418/// The answer of `executions.report`.
419pub struct ReportAnswer {
420    /// How many of the reported events were applied.
421    pub applied: usize,
422    /// The reporter's sequence this server holds everything up to: the next report sends
423    /// the events after it, so a lost or repeated report costs nothing.
424    pub through: u64,
425    /// Whether somebody asked this server to stop the execution. The reporter runs it, so
426    /// the reporter cancels it.
427    pub cancel_requested: bool,
428    /// The execution as this server now holds it.
429    pub execution: ExecutionView,
430}
431
432fn executions_report(ctx: &Context, input: ReportInput) -> Result<ReportAnswer, CapabilityError> {
433    let id = parse_id(&input.id)?;
434    if input.events.len() > MAX_EVENTS {
435        return Err(CapabilityError::InvalidInput(format!(
436            "{} events in one report; at most {MAX_EVENTS}, and the answer's `through` says where the next one starts",
437            input.events.len()
438        )));
439    }
440    if let Some(other) = input.events.iter().find(|e| e.execution_id != id) {
441        return Err(CapabilityError::InvalidInput(format!(
442            "an event of {} in a report about {id}",
443            other.execution_id
444        )));
445    }
446    let store = ctx.executions.store();
447    store.abandon_silent(REPORT_SILENCE);
448    let stored = match ctx.registry.get(&input.capability) {
449        Some(c) => redact(&c.input.schema, &input.input),
450        None => input.input,
451    };
452    store
453        .adopt(
454            id.clone(),
455            Seed {
456                capability: &input.capability,
457                title: &input.title,
458                input: stored,
459                cancellable: input.cancellable,
460                actor: Actor::reported(input.pid),
461                repository: repository_of(ctx),
462            },
463            input.pid,
464        )
465        .map_err(|e| CapabilityError::Refused(e.to_string()))?;
466    // adopted just now as a reported execution, so the store answers both readings; the
467    // refusal is named once, eagerly, rather than on two paths no report can take
468    let refused = CapabilityError::NotFound(format!("no reported execution {id} in this process"));
469    store
470        .report(
471            &id,
472            input.events.into_iter().map(|e| (e.sequence, e.payload)),
473        )
474        .zip(store.get(&id))
475        .map(|(outcome, execution)| ReportAnswer {
476            applied: outcome.applied,
477            through: outcome.through,
478            cancel_requested: outcome.cancel_requested,
479            execution: ExecutionView::of(ctx, execution),
480        })
481        .ok_or(refused)
482}
483
484// ---------------------------------------------------------------- executions.protocol
485
486#[derive(Debug, Serialize, Deserialize, JsonSchema)]
487/// The answer of `executions.protocol`: the live channel's whole contract, derived from
488/// the types that implement it.
489pub struct ProtocolReport {
490    /// The version of the event protocol this server speaks.
491    pub protocol_version: String,
492    /// Where the live channel is: a path on this same server, not a second daemon.
493    pub websocket: String,
494    /// How the subscription is expressed, and what a reconnect sends.
495    pub subscription: Vec<ParameterView>,
496    /// How many seconds of quiet before the server pings.
497    pub heartbeat_seconds: u64,
498    /// How many live channels this process serves at once.
499    pub max_connections: usize,
500    /// How many retained events a scoped connection replays before going live.
501    pub max_replay: usize,
502    /// The event types a client may receive, from the one enum that defines them.
503    pub event_types: Vec<String>,
504    /// The stream's own control messages.
505    pub stream_types: Vec<String>,
506    /// The JSON Schema of an event, derived from the Rust type.
507    pub event_schema: Value,
508    /// The JSON Schema of a control message, derived from the Rust type.
509    pub stream_schema: Value,
510    /// What the store keeps, so a client knows what it may ask for.
511    pub limits: LimitsView,
512}
513
514#[derive(Debug, Serialize, Deserialize, JsonSchema)]
515/// One query parameter of the live channel.
516pub struct ParameterView {
517    /// Its name.
518    pub name: String,
519    /// What it does.
520    pub description: String,
521    /// Whether it must be given.
522    pub required: bool,
523}
524
525#[derive(Debug, Serialize, Deserialize, JsonSchema)]
526/// What this process's execution store keeps.
527pub struct LimitsView {
528    /// How many executions are remembered.
529    pub max_executions: usize,
530    /// How many events are retained per execution.
531    pub max_events: usize,
532    /// The longest a single log line may be.
533    pub max_log_chars: usize,
534    /// How many executions run at once.
535    pub max_running: usize,
536    /// How far a live channel may fall behind before it is told to resynchronise.
537    pub max_subscriber_queue: usize,
538}
539
540fn variants_of(schema: &Value) -> Vec<String> {
541    schema
542        .get("oneOf")
543        .and_then(Value::as_array)
544        .map(|all| {
545            all.iter()
546                .filter_map(|v| {
547                    v.pointer("/properties/type/const")
548                        .and_then(Value::as_str)
549                        .map(str::to_string)
550                })
551                .collect()
552        })
553        .unwrap_or_default()
554}
555
556fn executions_protocol(ctx: &Context, _: Empty) -> Result<ProtocolReport, CapabilityError> {
557    let event_schema = CanonicalSchema::of::<ExecutionEvent>().schema;
558    let stream_schema = CanonicalSchema::of::<crate::http::events::StreamMessage>().schema;
559    let limits = ctx.executions.store().limits();
560    Ok(ProtocolReport {
561        protocol_version: PROTOCOL_VERSION.into(),
562        websocket: crate::http::events::PATH.into(),
563        subscription: vec![
564            ParameterView {
565                name: "execution".into(),
566                description: "An execution id to follow. Absent, the channel follows every execution of this process.".into(),
567                required: false,
568            },
569            ParameterView {
570                name: "after".into(),
571                description: "The sequence number the client last saw. The server replays the retained events after it, then goes live with no gap and nothing written twice.".into(),
572                required: false,
573            },
574        ],
575        heartbeat_seconds: crate::http::events::HEARTBEAT.as_secs(),
576        max_connections: crate::http::events::MAX_CONNECTIONS,
577        max_replay: crate::http::events::MAX_REPLAY,
578        event_types: variants_of(&event_schema),
579        stream_types: variants_of(&stream_schema),
580        event_schema,
581        stream_schema,
582        limits: LimitsView {
583            max_executions: limits.max_executions,
584            max_events: limits.max_events,
585            max_log_chars: limits.max_log_chars,
586            max_running: limits.max_running,
587            max_subscriber_queue: limits.max_subscriber_queue,
588        },
589    })
590}
591
592// ---------------------------------------------------------------- executions.demonstrate
593
594#[derive(Debug, Serialize, Deserialize, JsonSchema)]
595#[serde(deny_unknown_fields)]
596/// The input of `executions.demonstrate`.
597pub struct DemonstrateInput {
598    /// How many steps to walk through.
599    #[serde(default = "three")]
600    pub steps: u64,
601    /// How long each step takes, in milliseconds. Bounded at ten seconds a step, so this
602    /// cannot be used to hold a worker.
603    #[serde(default)]
604    pub delay_ms: u64,
605    /// Fail on this step instead of completing, to show what a failure looks like.
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    pub fail_at: Option<u64>,
608}
609
610fn three() -> u64 {
611    3
612}
613
614impl BenchmarkCases for DemonstrateInput {
615    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
616        vec![NamedCase::new(
617            "immediate",
618            DemonstrateInput {
619                steps: 1,
620                delay_ms: 0,
621                fail_at: None,
622            },
623        )]
624    }
625}
626
627#[derive(Debug, Serialize, Deserialize, JsonSchema)]
628/// The answer of `executions.demonstrate`.
629pub struct DemonstrateReport {
630    /// How many steps ran.
631    pub steps: u64,
632    /// How long it took, in milliseconds.
633    pub elapsed_ms: u64,
634    /// Whether anything was watching: false when it was called directly rather than as an
635    /// execution, which is the difference this capability exists to show.
636    pub observed: bool,
637}
638
639/// The longest one step of the demonstration may take.
640const MAX_STEP_MS: u64 = 10_000;
641
642/// The most steps the demonstration will walk.
643const MAX_STEPS: u64 = 100;
644
645fn executions_demonstrate(
646    ctx: &Context,
647    input: DemonstrateInput,
648) -> Result<DemonstrateReport, CapabilityError> {
649    let steps = input.steps.min(MAX_STEPS);
650    let delay = std::time::Duration::from_millis(input.delay_ms.min(MAX_STEP_MS));
651    let started = std::time::Instant::now();
652    let p = &ctx.progress;
653    for step in 1..=steps {
654        if p.cancelled() {
655            return Err(p.cancellation());
656        }
657        let name = format!("step-{step}");
658        p.step(&name, &format!("Step {step} of {steps}"));
659        p.log(format!("step {step} of {steps} is working"));
660        if !delay.is_zero() {
661            // slept in slices so that cancellation is noticed within a tenth of a second
662            // however long a step is
663            let slice = std::time::Duration::from_millis(100);
664            let mut left = delay;
665            while !left.is_zero() {
666                if p.cancelled() {
667                    p.step_done(&name, false, Some("cancelled".into()));
668                    return Err(p.cancellation());
669                }
670                let take = left.min(slice);
671                std::thread::sleep(take);
672                left -= take;
673            }
674        }
675        if input.fail_at == Some(step) {
676            p.step_done(&name, false, Some("asked to fail here".into()));
677            return Err(CapabilityError::Internal(format!(
678                "the demonstration was asked to fail at step {step} of {steps}"
679            )));
680        }
681        p.step_done(&name, true, None);
682        p.progress(step, Some(steps), format!("{step} of {steps} done"));
683    }
684    Ok(DemonstrateReport {
685        steps,
686        elapsed_ms: started.elapsed().as_millis() as u64,
687        observed: p.is_reporting(),
688    })
689}
690
691/// The module.
692pub fn module() -> ModuleDescriptor {
693    module! {
694        id: "executions",
695        title: "Executions",
696        description: "Running a capability of this registry as work that can be watched: started, followed event by event over the live channel, read back afterwards, and asked to stop. One plane per checkout: `majordomus run` executes in its own process and reports the execution to the server already serving the checkout, so that server lists it beside its own; with no server it says that nothing else can see the run. In memory; an execution does not outlive the server that holds it.",
697        stability: Stability::BehaviorallyVerified,
698        capabilities: [
699            capability! {
700                id: "executions.start",
701                kind: CapabilityKind::Command,
702                title: "Start a capability as an execution",
703                description: "Run any executable capability of this registry as an execution: the input is checked against that capability's own input schema, the work is queued, and this answers at once with the execution's id and the links to follow it. Nothing waits for the handler. The capability runs through the same executor every other interface calls, so there is no second implementation of anything. On the command line (`majordomus run`) it runs in that process and is reported to the server serving the checkout, when one is, through executions.report; a run never starts a server.",
704                input: StartInput,
705                output: ExecutionView,
706                stability: Stability::BehaviorallyVerified,
707                exposure: Exposure { mcp: mcp("majordomus_execution_start"), http: post("/api/v1/executions/start"), cli: Some(crate::capability::CliExposure { path: vec!["run".into()] }) },
708                tags: ["executions", "control-plane"],
709                handler: executions_start,
710            }
711            // It runs any executable capability, writers included, so its effect is the
712            // strongest of what it can start: a client that asks before running a writer
713            // asks before this too, instead of being told it changes only this process's
714            // memory while it transitions a tracked record (ADR 0126).
715            .writes_repository(),
716            capability! {
717                id: "executions.list",
718                title: "List executions",
719                description: "Every execution this process remembers, newest first, narrowed by state or by capability: the ones it ran and the ones `majordomus run` in another process of the checkout reported to it, each with who started it. The counts beside them — remembered, active, queued, live channels — are what a control plane shows without asking a second question.",
720                input: ListInput,
721                output: ExecutionList,
722                stability: Stability::BehaviorallyVerified,
723                exposure: Exposure {
724                    mcp: Some(McpExposure { tool: Some("majordomus_executions".into()), resource: Some(McpResource { uri: EXECUTIONS_URI.into(), name: "executions".into() }) }),
725                    http: get("/api/v1/executions"),
726                    cli: Some(crate::capability::CliExposure { path: vec!["executions".into(), "list".into()] }),
727                },
728                tags: ["executions", "control-plane"],
729                handler: executions_list,
730            },
731            capability! {
732                id: "executions.get",
733                title: "One execution",
734                description: "The whole of what is known about one execution: its state, its input as it was stored, its steps, its progress, its diagnostics, and its output or its error. Taken under one lock, so a snapshot that says it succeeded carries what it produced.",
735                input: GetInput,
736                output: ExecutionView,
737                stability: Stability::BehaviorallyVerified,
738                exposure: Exposure { mcp: mcp("majordomus_execution"), http: get("/api/v1/executions/get"), cli: Some(crate::capability::CliExposure { path: vec!["executions".into(), "show".into()] }) },
739                tags: ["executions", "control-plane"],
740                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
741                handler: executions_get,
742            },
743            capability! {
744                id: "executions.events",
745                title: "An execution's event history",
746                description: "The retained events of one execution, oldest first, after a sequence number. This is what a browser reads after a reload and what a client reads after a reconnect: the page carries the cursor to open the live channel with, so nothing is missed between the history and the stream.",
747                input: EventsInput,
748                output: EventHistory,
749                stability: Stability::BehaviorallyVerified,
750                exposure: Exposure { mcp: mcp("majordomus_execution_events"), http: get("/api/v1/executions/events"), cli: Some(crate::capability::CliExposure { path: vec!["executions".into(), "events".into()] }) },
751                tags: ["executions", "control-plane"],
752                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
753                handler: executions_events,
754            },
755            capability! {
756                id: "executions.cancel",
757                kind: CapabilityKind::Command,
758                title: "Ask an execution to stop",
759                description: "Set the execution's cancellation flag and say so on its stream. Cancellation is cooperative: a task looks at its flag and stops, and a capability whose policy says it is not cancellable runs to completion — which the answer says rather than pretending otherwise.",
760                input: CancelInput,
761                output: CancelReport,
762                stability: Stability::BehaviorallyVerified,
763                exposure: Exposure { mcp: mcp("majordomus_execution_cancel"), http: post("/api/v1/executions/cancel"), cli: Some(crate::capability::CliExposure { path: vec!["executions".into(), "cancel".into()] }) },
764                tags: ["executions", "control-plane"],
765                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
766                handler: executions_cancel,
767            },
768            capability! {
769                id: "executions.report",
770                kind: CapabilityKind::Command,
771                title: "Report an execution another process runs",
772                description: "What `majordomus run` posts while it runs a capability in its own process and a server already serves the checkout: the execution's description and its events after the last sequence this server took. The server holds it as an execution started by the command line, with the pid, in the same store, listing, Cockpit pages and live channel as its own; applies each event once and in order, answering where the next report starts; tells the reporter when somebody asked it to stop; and fails it with `unavailable` once the reporter has been silent for 30 s. Nothing runs here. Changes this process's memory only.",
773                input: ReportInput,
774                output: ReportAnswer,
775                stability: Stability::BehaviorallyVerified,
776                exposure: Exposure { mcp: None, http: post("/api/v1/executions/report"), cli: None },
777                tags: ["executions", "control-plane"],
778                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
779                handler: executions_report,
780            },
781            capability! {
782                id: "executions.protocol",
783                title: "The live channel's contract",
784                description: "Where the WebSocket is, how a subscription and a reconnect are expressed, what the server writes, and the JSON Schema of every message — derived from the Rust types that implement it, so a client validating against this is validating against the implementation. OpenAPI cannot describe a socket; this is where that contract lives.",
785                input: Empty,
786                output: ProtocolReport,
787                stability: Stability::BehaviorallyVerified,
788                exposure: Exposure {
789                    mcp: Some(McpExposure { tool: Some("majordomus_execution_protocol".into()), resource: Some(McpResource { uri: EXECUTION_PROTOCOL_URI.into(), name: "execution-protocol".into() }) }),
790                    http: get("/api/v1/executions/protocol"),
791                    cli: Some(crate::capability::CliExposure { path: vec!["executions".into(), "protocol".into()] }),
792                },
793                tags: ["executions", "control-plane", "protocol"],
794                handler: executions_protocol,
795            },
796            capability! {
797                id: "executions.demonstrate",
798                title: "Demonstrate an execution",
799                description: "Walk a given number of steps, reporting each one, logging a line and advancing progress, then finish — or fail at a step you name. It exists so that an operator, a probe and an end-to-end test can prove the whole path works without waiting for real work: it reads nothing, writes nothing, and its only effect is the events it produces. It looks at its cancellation flag between steps and while it waits, so cancelling it stops it.",
800                input: DemonstrateInput,
801                output: DemonstrateReport,
802                stability: Stability::BehaviorallyVerified,
803                exposure: Exposure { mcp: mcp("majordomus_demonstrate_execution"), http: get("/api/v1/executions/demonstrate"), cli: None },
804                tags: ["executions", "control-plane", "diagnostic"],
805                handler: executions_demonstrate,
806            }
807            .cancellable(),
808        ],
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use crate::capability::CapabilityKind;
816
817    /// The declaration is the only place these names exist; every projection is derived
818    /// from it. A dropped exposure would still compile and every behavioural test would
819    /// still pass. This is the assertion that would not.
820    #[test]
821    fn the_declaration_yields_the_projections_it_claims() {
822        let m = module();
823        assert_eq!(m.id.as_str(), "executions");
824        let expected: &[(&str, Option<&str>, &str, CapabilityKind)] = &[
825            (
826                "executions.start",
827                Some("majordomus_execution_start"),
828                "/api/v1/executions/start",
829                CapabilityKind::Command,
830            ),
831            (
832                "executions.list",
833                Some("majordomus_executions"),
834                "/api/v1/executions",
835                CapabilityKind::Query,
836            ),
837            (
838                "executions.get",
839                Some("majordomus_execution"),
840                "/api/v1/executions/get",
841                CapabilityKind::Query,
842            ),
843            (
844                "executions.events",
845                Some("majordomus_execution_events"),
846                "/api/v1/executions/events",
847                CapabilityKind::Query,
848            ),
849            (
850                "executions.cancel",
851                Some("majordomus_execution_cancel"),
852                "/api/v1/executions/cancel",
853                CapabilityKind::Command,
854            ),
855            (
856                "executions.report",
857                None,
858                "/api/v1/executions/report",
859                CapabilityKind::Command,
860            ),
861            (
862                "executions.protocol",
863                Some("majordomus_execution_protocol"),
864                "/api/v1/executions/protocol",
865                CapabilityKind::Query,
866            ),
867            (
868                "executions.demonstrate",
869                Some("majordomus_demonstrate_execution"),
870                "/api/v1/executions/demonstrate",
871                CapabilityKind::Query,
872            ),
873        ];
874        let ids: Vec<&str> = m
875            .capabilities
876            .iter()
877            .map(|e| e.capability.id.as_str())
878            .collect();
879        assert_eq!(ids, expected.iter().map(|(id, ..)| *id).collect::<Vec<_>>());
880        for (executable, (id, tool, path, kind)) in m.capabilities.iter().zip(expected) {
881            let c = &executable.capability;
882            assert_eq!(
883                c.exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
884                *tool,
885                "{id} lost, renamed or gained an MCP tool"
886            );
887            assert_eq!(
888                c.exposure.http.as_ref().map(|h| h.path.as_str()),
889                Some(*path),
890                "{id} lost or renamed its HTTP route"
891            );
892            assert_eq!(c.kind, *kind, "{id} changed kind");
893            let base = crate::capability::ExecutionPolicy::classify(*kind);
894            // executions.start may run a writer, so it is declared one (ADR 0126)
895            let writes = c.id.as_str() == "executions.start";
896            assert!(
897                c.execution == base
898                    || c.execution == base.stoppable()
899                    || (writes && c.execution == base.writes_repository()),
900                "{id} carries a policy neither its kind nor a cancellation declaration gives it"
901            );
902        }
903    }
904
905    /// Exactly one capability here looks at its cancellation flag, and it is the one that
906    /// waits. The Cockpit's Cancel button is derived from this and from nothing else.
907    #[test]
908    fn only_what_stops_when_asked_says_it_is_cancellable() {
909        let m = module();
910        let cancellable: Vec<&str> = m
911            .capabilities
912            .iter()
913            .filter(|e| e.capability.execution.cancellable)
914            .map(|e| e.capability.id.as_str())
915            .collect();
916        assert_eq!(cancellable, ["executions.demonstrate"]);
917        for e in &m.capabilities {
918            assert!(
919                !e.capability.cache.is_enabled(),
920                "{} is about state that moves and is never cached",
921                e.capability.id
922            );
923        }
924    }
925
926    #[test]
927    fn an_id_that_is_not_one_is_refused_before_anything_is_looked_up() {
928        for bad in ["", "../../etc/passwd", "x-nope"] {
929            let e = parse_id(bad).expect_err(bad);
930            assert!(matches!(e, CapabilityError::InvalidInput(_)), "{bad}");
931        }
932        assert!(parse_id(ExecutionId::fresh().as_str()).is_ok());
933    }
934
935    #[test]
936    fn the_protocol_report_names_every_variant_the_enums_define() {
937        let events = variants_of(&CanonicalSchema::of::<ExecutionEvent>().schema);
938        assert!(
939            events.contains(&"execution.completed".to_string()),
940            "{events:?}"
941        );
942        assert!(events.contains(&"execution.progress".to_string()));
943        assert_eq!(
944            events.len(),
945            12,
946            "every payload variant is described: {events:?}"
947        );
948        let stream =
949            variants_of(&CanonicalSchema::of::<crate::http::events::StreamMessage>().schema);
950        assert_eq!(
951            stream,
952            [
953                "stream.ready",
954                "stream.heartbeat",
955                "stream.lagged",
956                "stream.closing"
957            ],
958            "the control messages"
959        );
960    }
961
962    fn report_of(id: &str, capability: &str, events: Vec<ExecutionEvent>) -> ReportInput {
963        ReportInput {
964            id: id.to_string(),
965            capability: capability.to_string(),
966            title: "Demonstrate an execution".into(),
967            input: serde_json::json!({ "steps": 1 }),
968            cancellable: true,
969            pid: 4242,
970            events,
971        }
972    }
973
974    fn started(id: &ExecutionId, sequence: u64) -> ExecutionEvent {
975        ExecutionEvent::new(
976            id.clone(),
977            sequence,
978            crate::execution::EventPayload::Started,
979        )
980    }
981
982    /// A report is refused before anything is held when its id is not one, when it carries
983    /// more events than one report may, or when an event in it is about another execution;
984    /// and an id this server runs itself is never written into by a report.
985    #[test]
986    fn a_report_is_refused_for_a_bad_id_too_many_events_a_foreign_event_or_an_own_execution() {
987        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
988        let ctx = repo.context().expect("a context");
989        let refusal = |input: ReportInput| match executions_report(&ctx, input) {
990            Err(e) => e,
991            Ok(answer) => panic!("a report was taken: {}", answer.through),
992        };
993
994        let e = refusal(report_of("x-nope", "executions.demonstrate", Vec::new()));
995        assert!(matches!(e, CapabilityError::InvalidInput(_)), "{e}");
996
997        let id = ExecutionId::fresh();
998        let many = (2..=(MAX_EVENTS as u64 + 2))
999            .map(|n| started(&id, n))
1000            .collect();
1001        let e = refusal(report_of(id.as_str(), "executions.demonstrate", many));
1002        assert!(e.to_string().contains("events in one report"), "{e}");
1003
1004        let other = ExecutionId::fresh();
1005        let e = refusal(report_of(
1006            id.as_str(),
1007            "executions.demonstrate",
1008            vec![started(&other, 2)],
1009        ));
1010        assert!(e.to_string().contains("in a report about"), "{e}");
1011        assert!(ctx.executions.store().get(&id).is_none(), "nothing held");
1012
1013        let own = ExecutionId::fresh();
1014        ctx.executions.store().create(
1015            own.clone(),
1016            "executions.demonstrate",
1017            "Demonstrate an execution",
1018            serde_json::json!({}),
1019            true,
1020            Actor::of(ActorKind::Internal),
1021            repository_of(&ctx),
1022        );
1023        let e = refusal(report_of(
1024            own.as_str(),
1025            "executions.demonstrate",
1026            Vec::new(),
1027        ));
1028        assert!(matches!(e, CapabilityError::Refused(_)), "{e}");
1029    }
1030
1031    /// A report about a capability this registry does not know is still held, its input as
1032    /// the reporter stored it; one this registry knows is held too, and answers how far the
1033    /// server holds it.
1034    #[test]
1035    fn a_report_is_held_whether_or_not_this_registry_knows_its_capability() {
1036        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
1037        let ctx = repo.context().expect("a context");
1038
1039        let elsewhere = ExecutionId::fresh();
1040        let answer = executions_report(
1041            &ctx,
1042            report_of(
1043                elsewhere.as_str(),
1044                "nowhere.known",
1045                vec![started(&elsewhere, 2)],
1046            ),
1047        )
1048        .expect("held");
1049        assert_eq!((answer.applied, answer.through), (1, 2));
1050        assert!(!answer.cancel_requested);
1051        let held = ctx.executions.store().get(&elsewhere).expect("held");
1052        assert_eq!(held.input, serde_json::json!({ "steps": 1 }));
1053        assert_eq!(held.actor.pid, Some(4242));
1054
1055        let known = ExecutionId::fresh();
1056        let answer = executions_report(
1057            &ctx,
1058            report_of(known.as_str(), "executions.demonstrate", Vec::new()),
1059        )
1060        .expect("held");
1061        assert_eq!((answer.applied, answer.through), (0, 1));
1062        assert_eq!(answer.execution.execution.state, ExecutionState::Queued);
1063    }
1064
1065    /// The Cockpit's page about one execution names the process that runs a reported one and
1066    /// says how a stop reaches it; about one this server runs, it says only that the flag is
1067    /// set.
1068    #[test]
1069    fn the_execution_page_says_where_a_stop_goes_for_a_reported_and_an_own_execution() {
1070        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
1071        let ctx = repo.context().expect("a context");
1072
1073        let reported = ExecutionId::fresh();
1074        executions_report(
1075            &ctx,
1076            report_of(
1077                reported.as_str(),
1078                "executions.demonstrate",
1079                vec![started(&reported, 2)],
1080            ),
1081        )
1082        .expect("held");
1083        let html = crate::cockpit::pages::execution(&ctx, reported.as_str())
1084            .main
1085            .render();
1086        assert!(html.contains("majordomus run, process 4242"), "{html}");
1087        assert!(html.contains("It runs in process 4242"), "{html}");
1088
1089        let own = ExecutionId::fresh();
1090        ctx.executions.store().create(
1091            own.clone(),
1092            "executions.demonstrate",
1093            "Demonstrate an execution",
1094            serde_json::json!({}),
1095            true,
1096            Actor::of(ActorKind::Internal),
1097            repository_of(&ctx),
1098        );
1099        let html = crate::cockpit::pages::execution(&ctx, own.as_str())
1100            .main
1101            .render();
1102        assert!(html.contains("the flag is set"), "{html}");
1103        assert!(!html.contains("majordomus run, process"), "{html}");
1104    }
1105}