1use 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
35pub const EXECUTIONS_URI: &str = "majordomus://executions";
37
38pub const EXECUTION_PROTOCOL_URI: &str = "majordomus://executions/protocol";
40
41pub const MAX_LIST: usize = 200;
43
44pub const MAX_EVENTS: usize = 500;
46
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
55pub struct ExecutionLinks {
56 #[serde(rename = "self")]
58 pub itself: String,
59 pub events: String,
61 pub cancel: String,
63 pub websocket: String,
65 pub cockpit: String,
67}
68
69impl ExecutionLinks {
70 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
92pub struct ExecutionView {
93 #[serde(flatten)]
95 pub execution: Execution,
96 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
129#[serde(deny_unknown_fields)]
130pub struct StartInput {
132 pub capability: String,
134 #[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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
170#[serde(deny_unknown_fields)]
171pub struct ListInput {
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub state: Option<ExecutionState>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub capability: Option<String>,
179 #[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 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)]
201pub struct ExecutionList {
203 pub count: usize,
205 pub remembered: usize,
207 pub active: usize,
209 pub queued: usize,
211 pub live_channels: usize,
213 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
238#[serde(deny_unknown_fields)]
239pub struct GetInput {
241 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
268#[serde(deny_unknown_fields)]
269pub struct EventsInput {
271 pub id: String,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub after: Option<u64>,
276 #[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)]
288pub struct EventHistory {
290 pub execution_id: String,
292 pub state: ExecutionState,
294 pub events: Vec<ExecutionEvent>,
296 pub last_sequence: u64,
299 pub more: bool,
301 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
329#[serde(deny_unknown_fields)]
330pub struct CancelInput {
332 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)]
343pub struct CancelReport {
345 pub outcome: String,
347 pub cancellable: bool,
350 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
385#[serde(deny_unknown_fields)]
386pub struct ReportInput {
389 pub id: String,
391 pub capability: String,
393 pub title: String,
395 #[serde(default)]
398 pub input: Value,
399 #[serde(default)]
402 pub cancellable: bool,
403 pub pid: u32,
405 #[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)]
418pub struct ReportAnswer {
420 pub applied: usize,
422 pub through: u64,
425 pub cancel_requested: bool,
428 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 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
487pub struct ProtocolReport {
490 pub protocol_version: String,
492 pub websocket: String,
494 pub subscription: Vec<ParameterView>,
496 pub heartbeat_seconds: u64,
498 pub max_connections: usize,
500 pub max_replay: usize,
502 pub event_types: Vec<String>,
504 pub stream_types: Vec<String>,
506 pub event_schema: Value,
508 pub stream_schema: Value,
510 pub limits: LimitsView,
512}
513
514#[derive(Debug, Serialize, Deserialize, JsonSchema)]
515pub struct ParameterView {
517 pub name: String,
519 pub description: String,
521 pub required: bool,
523}
524
525#[derive(Debug, Serialize, Deserialize, JsonSchema)]
526pub struct LimitsView {
528 pub max_executions: usize,
530 pub max_events: usize,
532 pub max_log_chars: usize,
534 pub max_running: usize,
536 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
595#[serde(deny_unknown_fields)]
596pub struct DemonstrateInput {
598 #[serde(default = "three")]
600 pub steps: u64,
601 #[serde(default)]
604 pub delay_ms: u64,
605 #[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)]
628pub struct DemonstrateReport {
630 pub steps: u64,
632 pub elapsed_ms: u64,
634 pub observed: bool,
637}
638
639const MAX_STEP_MS: u64 = 10_000;
641
642const 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 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
691pub 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 .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 #[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 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 #[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 #[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 #[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 #[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}