Skip to main content

agent_core/
action.rs

1// Shared semantic action dispatch. GUI adapters and all external transports call this core.
2// New library code, independent of native windows, document types and language models.
3
4use crate::{json::Value, schema::Schema};
5use alloc::{collections::BTreeSet, format, string::String, vec::Vec};
6
7/// Stable structured failure categories.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum ErrorCode {
11    /// External control has not been explicitly activated.
12    Disabled,
13    /// The action or resource is unknown.
14    NotFound,
15    /// Parameters fail their declared schema or bounds.
16    InvalidInput,
17    /// The platform lacks a required capability.
18    CapabilityUnavailable,
19    /// The action is temporarily unavailable (including modal UI states).
20    Unavailable,
21    /// The caller's expected revision differs from the current revision.
22    RevisionConflict,
23    /// An operation or atomic transaction is unsupported.
24    Unsupported,
25    /// A bounded collection or response limit was exceeded.
26    LimitExceeded,
27    /// A task was cancelled at a defined interruption point.
28    Cancelled,
29    /// The host violated its action contract or returned an internal failure.
30    Internal,
31    /// The application or the user does not allow this action for external control.
32    Forbidden,
33    /// The user was asked to confirm the action and has not answered yet; retry later.
34    ConfirmationRequired,
35}
36
37impl ErrorCode {
38    /// Stable wire spelling.
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Disabled => "control_disabled",
42            Self::NotFound => "not_found",
43            Self::InvalidInput => "invalid_input",
44            Self::CapabilityUnavailable => "capability_unavailable",
45            Self::Unavailable => "unavailable",
46            Self::RevisionConflict => "revision_conflict",
47            Self::Unsupported => "unsupported",
48            Self::LimitExceeded => "limit_exceeded",
49            Self::Cancelled => "cancelled",
50            Self::Internal => "internal_error",
51            Self::Forbidden => "forbidden",
52            Self::ConfirmationRequired => "confirmation_required",
53        }
54    }
55}
56
57/// Who may run an action through external control (agents, scripts over IPC / MCP).
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum Access {
60    /// External calls run after the usual checks.
61    Open,
62    /// The host asks the user before an external call runs (`Host::confirm`).
63    Confirm,
64    /// Only trusted in-process GUI calls; external calls fail with `forbidden`.
65    Local,
66}
67
68impl Access {
69    /// Stable wire spelling ("open", "confirm", "local").
70    pub fn as_str(self) -> &'static str {
71        match self {
72            Self::Open => "open",
73            Self::Confirm => "confirm",
74            Self::Local => "local",
75        }
76    }
77}
78
79/// An actionable failure with structured details; no transport-specific error numbers.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct ActionError {
82    /// Stable error category.
83    pub code: ErrorCode,
84    /// Human-readable diagnosis.
85    pub message: String,
86    /// Structured correction details such as a field pointer or current revision.
87    pub details: Value,
88}
89
90impl ActionError {
91    /// Construct a failure without extra details.
92    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
93        Self {
94            code,
95            message: message.into(),
96            details: Value::Null,
97        }
98    }
99    /// Attach machine-readable context.
100    pub fn with_details(mut self, details: Value) -> Self {
101        self.details = details;
102        self
103    }
104    /// JSON form shared by batch, JSON-RPC and MCP results.
105    pub fn to_json(&self) -> Value {
106        Value::object([
107            ("code", Value::string(self.code.as_str())),
108            ("message", Value::string(&self.message)),
109            ("details", self.details.clone()),
110        ])
111    }
112}
113
114/// A static semantic action contract. Register each implementation once in its host.
115#[derive(Clone, Copy, Debug)]
116pub struct ActionSpec {
117    /// Stable, language-independent identifier, e.g. `document.text.replace`.
118    pub name: &'static str,
119    /// Description for human and automated discovery.
120    pub description: &'static str,
121    /// Validated input schema.
122    pub input: &'static Schema,
123    /// Validated completed-result schema.
124    pub output: &'static Schema,
125    /// Bit set of platform services required by this action; defined by the platform layer.
126    pub capabilities: u64,
127    /// Whether the action can change application or document state.
128    pub mutation: bool,
129    /// Require an expected revision for external mutation calls.
130    pub revision_required: bool,
131    /// Whether the operation is integrated with the application's undo history.
132    pub undo: bool,
133    /// Transaction domain, or None when atomic batches are unsupported.
134    pub transaction: Option<&'static str>,
135    /// Whether the operation can return a cancellable task.
136    pub cancellable: bool,
137    /// Optional compatible numeric GUI command identifier.
138    pub command_id: Option<u32>,
139    /// Who may run it through external control; hosts may tighten it (`Host::access`).
140    pub access: Access,
141}
142
143impl ActionSpec {
144    /// Parameterless read action with a general structured-object result.
145    pub const fn read(name: &'static str, description: &'static str) -> Self {
146        Self {
147            name,
148            description,
149            input: &crate::schema::EMPTY_OBJECT,
150            output: &crate::schema::OBJECT,
151            capabilities: 0,
152            mutation: false,
153            revision_required: false,
154            undo: false,
155            transaction: None,
156            cancellable: false,
157            command_id: None,
158            access: Access::Open,
159        }
160    }
161    /// External calls need the user's confirmation first.
162    pub const fn confirm(mut self) -> Self {
163        self.access = Access::Confirm;
164        self
165    }
166    /// Only trusted in-process GUI calls; external control is refused.
167    pub const fn local_only(mut self) -> Self {
168        self.access = Access::Local;
169        self
170    }
171    /// Revision-checked mutation; the caller can set undo and transaction metadata.
172    pub const fn write(name: &'static str, description: &'static str) -> Self {
173        Self {
174            mutation: true,
175            revision_required: true,
176            ..Self::read(name, description)
177        }
178    }
179    /// Replace both schemas while preserving other metadata.
180    pub const fn schemas(mut self, input: &'static Schema, output: &'static Schema) -> Self {
181        self.input = input;
182        self.output = output;
183        self
184    }
185}
186
187/// Request metadata passed unchanged to registered handlers.
188#[derive(Clone, Copy, Debug, Default)]
189pub struct ActionContext {
190    /// Expected revision supplied by the caller and checked by the dispatcher.
191    pub expected_revision: Option<u64>,
192    /// True when executing inside a host transaction.
193    pub atomic: bool,
194    /// True for trusted in-process GUI commands, which may omit an expected revision.
195    pub local: bool,
196}
197
198/// Small metadata link to binary/file data, kept out of state snapshots.
199#[derive(Clone, Debug, PartialEq, Eq)]
200pub struct Resource {
201    /// Opaque application resource identifier.
202    pub uri: String,
203    /// Friendly name.
204    pub name: String,
205    /// Content media type.
206    pub mime_type: String,
207    /// Exact payload byte count if known.
208    pub size: Option<u64>,
209}
210
211impl Resource {
212    /// Metadata only; resource bytes are read separately.
213    pub fn to_json(&self) -> Value {
214        Value::object([
215            ("uri", Value::string(&self.uri)),
216            ("name", Value::string(&self.name)),
217            ("mimeType", Value::string(&self.mime_type)),
218            ("size", self.size.map(Value::u64).unwrap_or(Value::Null)),
219        ])
220    }
221}
222
223/// Action completion or a deferred host-managed task.
224#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct ActionResult {
226    /// Completed structured output (Null for deferred work).
227    pub value: Value,
228    /// Current host revision after execution.
229    pub revision: u64,
230    /// Task identifier when execution continues asynchronously.
231    pub task: Option<u64>,
232    /// Metadata for optional separately readable payloads.
233    pub resources: Vec<Resource>,
234}
235
236impl ActionResult {
237    /// Return a completed result.
238    pub fn completed(value: Value, revision: u64) -> Self {
239        Self {
240            value,
241            revision,
242            task: None,
243            resources: Vec::new(),
244        }
245    }
246    /// Return a task whose state can be queried or cancelled.
247    pub fn deferred(task: u64, revision: u64) -> Self {
248        Self {
249            value: Value::Null,
250            revision,
251            task: Some(task),
252            resources: Vec::new(),
253        }
254    }
255    /// Shared structured wire response.
256    pub fn to_json(&self) -> Value {
257        Value::object([
258            (
259                "status",
260                Value::string(if self.task.is_some() {
261                    "pending"
262                } else {
263                    "complete"
264                }),
265            ),
266            ("revision", Value::u64(self.revision)),
267            ("value", self.value.clone()),
268            ("task", self.task.map(Value::u64).unwrap_or(Value::Null)),
269            (
270                "resources",
271                Value::Array(self.resources.iter().map(Resource::to_json).collect()),
272            ),
273        ])
274    }
275}
276
277/// Application adapter; the dispatcher never invents domain semantics from pixels.
278pub trait Host {
279    /// All registered action contracts, normally a static slice.
280    fn actions(&self) -> &[ActionSpec];
281    /// Current document/application revision for optimistic concurrency control.
282    fn revision(&self) -> u64;
283    /// Services currently available from the native or browser platform.
284    fn capabilities(&self) -> u64;
285    /// Check dynamic availability, including modal state, selection and permissions.
286    fn availability(&self, _action: &ActionSpec) -> Result<(), ActionError> {
287        Ok(())
288    }
289    /// Who may run the action externally: its `ActionSpec::access`, or stricter (the app's
290    /// permission manifest).
291    fn access(&self, action: &ActionSpec) -> Access {
292        action.access
293    }
294    /// Ask the user before an external call of an `Access::Confirm` action runs. `Ok` lets it
295    /// run; `forbidden` when refused, `confirmation_required` while the question is open.
296    fn confirm(&mut self, _action: &ActionSpec, _input: &Value) -> Result<(), ActionError> {
297        Err(ActionError::new(
298            ErrorCode::Forbidden,
299            "This action needs the user's confirmation, which this host cannot ask for",
300        )
301        .with_details(Value::object([("reason", Value::string("unattended"))])))
302    }
303    /// An external call of an `Access::Local` action was refused (an audit hook).
304    fn refused(&mut self, _action: &ActionSpec) {}
305    /// Execute the same handler used by GUI/keyboard command adapters.
306    fn execute(
307        &mut self,
308        action: &ActionSpec,
309        input: &Value,
310        context: &ActionContext,
311    ) -> Result<ActionResult, ActionError>;
312    /// Whether a named transaction domain supports reliable rollback.
313    fn supports_transaction(&self, _domain: &str) -> bool {
314        false
315    }
316    /// Begin one undoable transaction. Atomic actions must complete synchronously.
317    fn begin_transaction(&mut self, _domain: &str) -> Result<(), ActionError> {
318        Err(ActionError::new(
319            ErrorCode::Unsupported,
320            "Transactions are unavailable",
321        ))
322    }
323    /// Commit all changes in the active transaction.
324    fn commit_transaction(&mut self) -> Result<(), ActionError> {
325        Err(ActionError::new(
326            ErrorCode::Unsupported,
327            "Transactions are unavailable",
328        ))
329    }
330    /// Restore state and revision to before begin_transaction.
331    fn rollback_transaction(&mut self) -> Result<(), ActionError> {
332        Err(ActionError::new(
333            ErrorCode::Unsupported,
334            "Transactions are unavailable",
335        ))
336    }
337    /// Filtered, paginated semantic state; these control methods (inspect, tasks, resources)
338    /// are not gated by `access`, so the host must apply its own access rules to them and
339    /// should not hand out guessable task ids; hosts may accept a `sinceRevision` query.
340    fn inspect(&self, _query: &Value) -> Result<Value, ActionError> {
341        Err(ActionError::new(
342            ErrorCode::Unsupported,
343            "Inspection is unavailable",
344        ))
345    }
346    /// Query a host-managed asynchronous task.
347    fn task_status(&self, _id: u64) -> Result<Value, ActionError> {
348        Err(ActionError::new(ErrorCode::NotFound, "Task not found"))
349    }
350    /// Request cancellation at the task's next defined interruption point.
351    fn task_cancel(&mut self, _id: u64) -> Result<Value, ActionError> {
352        Err(ActionError::new(ErrorCode::NotFound, "Task not found"))
353    }
354    /// Metadata for one application-owned resource; arbitrary filesystem paths are not URIs.
355    fn resource_info(&self, _uri: &str) -> Result<Resource, ActionError> {
356        Err(ActionError::new(ErrorCode::NotFound, "Resource not found"))
357    }
358    /// Read a bounded resource slice. Offsets are bytes; unknown URIs must be rejected.
359    fn resource_read(
360        &self,
361        _uri: &str,
362        _offset: u64,
363        _limit: usize,
364    ) -> Result<Vec<u8>, ActionError> {
365        Err(ActionError::new(ErrorCode::NotFound, "Resource not found"))
366    }
367}
368
369/// Borrowed single invocation.
370pub struct Invocation<'a> {
371    /// Stable action name.
372    pub action: &'a str,
373    /// Structured parameters.
374    pub input: &'a Value,
375    /// Expected revision for mutations.
376    pub expected_revision: Option<u64>,
377}
378
379/// Owned invocation for an ordered batch.
380#[derive(Clone, Debug)]
381pub struct BatchCall {
382    /// Stable action name.
383    pub action: String,
384    /// Structured parameters.
385    pub input: Value,
386    /// Revision at the moment this call should execute; atomic calls refer to the batch start.
387    pub expected_revision: Option<u64>,
388}
389
390/// Ordered batch outcome, retaining the outcome of each attempted call.
391#[derive(Clone, Debug)]
392pub struct BatchResult {
393    /// Per-call results in original order.
394    pub results: Vec<Result<ActionResult, ActionError>>,
395    /// True if a failed atomic batch was rolled back successfully.
396    pub rolled_back: bool,
397    /// Current revision, including rollback.
398    pub revision: u64,
399}
400
401impl BatchResult {
402    /// Structured batch result.
403    pub fn to_json(&self) -> Value {
404        Value::object([
405            (
406                "results",
407                Value::Array(
408                    self.results
409                        .iter()
410                        .map(|r| match r {
411                            Ok(v) => {
412                                Value::object([("ok", Value::Bool(true)), ("result", v.to_json())])
413                            }
414                            Err(e) => {
415                                Value::object([("ok", Value::Bool(false)), ("error", e.to_json())])
416                            }
417                        })
418                        .collect(),
419                ),
420            ),
421            ("rolledBack", Value::Bool(self.rolled_back)),
422            ("revision", Value::u64(self.revision)),
423        ])
424    }
425}
426
427/// One deterministic page of action discovery.
428#[derive(Clone, Debug)]
429pub struct Page {
430    /// Metadata of matching actions, without full schemas.
431    pub actions: Vec<Value>,
432    /// Exclusive action-name cursor for the next page.
433    pub next_cursor: Option<String>,
434    /// Current state revision.
435    pub revision: u64,
436}
437
438impl Page {
439    /// Shared discovery response.
440    pub fn to_json(&self) -> Value {
441        Value::object([
442            ("actions", Value::Array(self.actions.clone())),
443            (
444                "nextCursor",
445                self.next_cursor
446                    .as_ref()
447                    .map(|s| Value::string(s))
448                    .unwrap_or(Value::Null),
449            ),
450            ("revision", Value::u64(self.revision)),
451        ])
452    }
453}
454
455/// Activation gate and deterministic dispatcher. Opening transports remains a host decision.
456#[derive(Default)]
457pub struct Dispatcher {
458    enabled: bool,
459}
460
461impl Dispatcher {
462    /// Construct with all external access disabled.
463    pub const fn new() -> Self {
464        Self { enabled: false }
465    }
466    /// Explicitly enable/disable control; transports should also close when disabled.
467    pub fn set_enabled(&mut self, enabled: bool) {
468        self.enabled = enabled;
469    }
470    /// Whether external access was explicitly enabled.
471    pub fn enabled(&self) -> bool {
472        self.enabled
473    }
474    /// Reject access before consulting application state.
475    pub fn check_enabled(&self) -> Result<(), ActionError> {
476        if self.enabled {
477            Ok(())
478        } else {
479            Err(ActionError::new(
480                ErrorCode::Disabled,
481                "External control is disabled",
482            ))
483        }
484    }
485    fn find(&self, host: &impl Host, name: &str) -> Result<ActionSpec, ActionError> {
486        validate_registry(host.actions())?;
487        host.actions()
488            .iter()
489            .find(|action| action.name == name)
490            .copied()
491            .ok_or_else(|| {
492                ActionError::new(ErrorCode::NotFound, "Action not found")
493                    .with_details(Value::object([("action", Value::string(name))]))
494            })
495    }
496    fn ready(&self, host: &impl Host, action: &ActionSpec) -> Result<(), ActionError> {
497        let missing = action.capabilities & !host.capabilities();
498        if missing != 0 {
499            return Err(ActionError::new(
500                ErrorCode::CapabilityUnavailable,
501                "Required platform services are unavailable",
502            )
503            .with_details(Value::object([(
504                "missingCapabilities",
505                Value::u64(missing),
506            )])));
507        }
508        host.availability(action)
509    }
510    fn validate(
511        &self,
512        host: &impl Host,
513        action: &ActionSpec,
514        input: &Value,
515        context: &ActionContext,
516        revision: u64,
517    ) -> Result<(), ActionError> {
518        if let Err(e) = action.input.validate(input) {
519            return Err(ActionError::new(
520                ErrorCode::InvalidInput,
521                "Action input does not match its schema",
522            )
523            .with_details(Value::object([
524                ("path", Value::string(e.path)),
525                ("reason", Value::string(e.reason)),
526            ])));
527        }
528        self.ready(host, action)?;
529        if action.mutation
530            && action.revision_required
531            && !context.local
532            && context.expected_revision.is_none()
533        {
534            return Err(ActionError::new(
535                ErrorCode::InvalidInput,
536                "This mutation requires expectedRevision",
537            ));
538        }
539        if action.mutation
540            && context
541                .expected_revision
542                .is_some_and(|expected| expected != revision)
543        {
544            return Err(ActionError::new(
545                ErrorCode::RevisionConflict,
546                "The application state changed after it was inspected",
547            )
548            .with_details(Value::object([
549                (
550                    "expectedRevision",
551                    context
552                        .expected_revision
553                        .map(Value::u64)
554                        .unwrap_or(Value::Null),
555                ),
556                ("currentRevision", Value::u64(revision)),
557            ])));
558        }
559        Ok(())
560    }
561    fn invoke(
562        &self,
563        host: &mut impl Host,
564        action: &ActionSpec,
565        input: &Value,
566        context: &ActionContext,
567    ) -> Result<ActionResult, ActionError> {
568        let mut result = host.execute(action, input, context)?;
569        result.revision = host.revision();
570        if context.atomic && result.task.is_some() {
571            return Err(ActionError::new(
572                ErrorCode::Internal,
573                "An atomic action returned deferred work",
574            ));
575        }
576        if result.task.is_none() {
577            if let Err(e) = action.output.validate(&result.value) {
578                return Err(ActionError::new(
579                    ErrorCode::Internal,
580                    "The action returned an invalid result",
581                )
582                .with_details(Value::object([
583                    ("path", Value::string(e.path)),
584                    ("reason", Value::string(e.reason)),
585                ])));
586            }
587        }
588        Ok(result)
589    }
590    /// Execute an enabled external action after schema, capability and revision checks.
591    pub fn execute(
592        &self,
593        host: &mut impl Host,
594        call: Invocation<'_>,
595    ) -> Result<ActionResult, ActionError> {
596        self.check_enabled()?;
597        let action = self.find(host, call.action)?;
598        let context = ActionContext {
599            expected_revision: call.expected_revision,
600            atomic: false,
601            local: false,
602        };
603        let access = host.access(&action);
604        // a Local action reveals nothing about its schema or availability to external callers
605        if matches!(access, Access::Local) {
606            self.consent(host, &action, call.input)?;
607        }
608        self.validate(host, &action, call.input, &context, host.revision())?;
609        if !matches!(access, Access::Local) {
610            self.consent(host, &action, call.input)?;
611            if matches!(access, Access::Confirm) {
612                // the document may have changed while the user was asked
613                self.validate(host, &action, call.input, &context, host.revision())?;
614            }
615        }
616        self.invoke(host, &action, call.input, &context)
617    }
618    // An external call: the action's access (Local is refused, Confirm asks the host's user).
619    fn consent(
620        &self,
621        host: &mut impl Host,
622        action: &ActionSpec,
623        input: &Value,
624    ) -> Result<(), ActionError> {
625        match host.access(action) {
626            Access::Open => Ok(()),
627            Access::Local => {
628                host.refused(action);
629                Err(ActionError::new(
630                    ErrorCode::Forbidden,
631                    "This action is not available to external control",
632                )
633                .with_details(Value::object([
634                    ("action", Value::string(action.name)),
635                    ("reason", Value::string("app")),
636                ])))
637            }
638            Access::Confirm => host.confirm(action, input),
639        }
640    }
641    /// Invoke a trusted GUI/shortcut command through the same schema and handler.
642    pub fn execute_local(
643        &self,
644        host: &mut impl Host,
645        name: &str,
646        input: &Value,
647    ) -> Result<ActionResult, ActionError> {
648        let action = self.find(host, name)?;
649        let context = ActionContext {
650            local: true,
651            ..ActionContext::default()
652        };
653        self.validate(host, &action, input, &context, host.revision())?;
654        self.invoke(host, &action, input, &context)
655    }
656    /// Resolve a legacy numeric GUI command to its registered semantic handler.
657    pub fn execute_command(
658        &self,
659        host: &mut impl Host,
660        id: u32,
661        input: &Value,
662    ) -> Result<ActionResult, ActionError> {
663        validate_registry(host.actions())?;
664        let name = host
665            .actions()
666            .iter()
667            .find(|a| a.command_id == Some(id))
668            .map(|a| a.name)
669            .ok_or_else(|| {
670                ActionError::new(ErrorCode::NotFound, "GUI command is not registered")
671            })?;
672        self.execute_local(host, name, input)
673    }
674    /// Run an ordered batch. Atomic support and every static precondition are checked first.
675    pub fn batch(
676        &self,
677        host: &mut impl Host,
678        calls: &[BatchCall],
679        atomic: bool,
680        stop_on_error: bool,
681    ) -> Result<BatchResult, ActionError> {
682        self.check_enabled()?;
683        if calls.is_empty() || calls.len() > 128 {
684            return Err(ActionError::new(
685                ErrorCode::InvalidInput,
686                "A batch must contain 1 to 128 calls",
687            ));
688        }
689        let start_revision = host.revision();
690        let mut prepared = Vec::new();
691        if atomic {
692            let mut domain = None;
693            for call in calls {
694                let action = self.find(host, &call.action)?;
695                let Some(next) = action.transaction else {
696                    return Err(ActionError::new(
697                        ErrorCode::Unsupported,
698                        "An action does not support atomic batches",
699                    )
700                    .with_details(Value::object([("action", Value::string(&call.action))])));
701                };
702                if domain.is_some_and(|d| d != next) || !host.supports_transaction(next) {
703                    return Err(ActionError::new(
704                        ErrorCode::Unsupported,
705                        "The batch has no shared supported transaction domain",
706                    ));
707                }
708                domain = Some(next);
709                let context = ActionContext {
710                    expected_revision: call.expected_revision,
711                    atomic: true,
712                    local: false,
713                };
714                if matches!(host.access(&action), Access::Local) {
715                    self.consent(host, &action, &call.input)?;
716                }
717                self.validate(host, &action, &call.input, &context, start_revision)?;
718                self.consent(host, &action, &call.input)?;
719                prepared.push(action);
720            }
721            host.begin_transaction(domain.unwrap_or(""))?;
722        }
723        let mut results = Vec::with_capacity(calls.len());
724        for (index, call) in calls.iter().enumerate() {
725            let result = if atomic {
726                let action = prepared[index];
727                let context = ActionContext {
728                    expected_revision: call.expected_revision,
729                    atomic: true,
730                    local: false,
731                };
732                self.ready(host, &action)
733                    .and_then(|()| self.invoke(host, &action, &call.input, &context))
734            } else {
735                self.execute(
736                    host,
737                    Invocation {
738                        action: &call.action,
739                        input: &call.input,
740                        expected_revision: call.expected_revision,
741                    },
742                )
743            };
744            let failed = result.is_err();
745            results.push(result);
746            if failed && atomic {
747                host.rollback_transaction().map_err(|e| {
748                    ActionError::new(ErrorCode::Internal, "Atomic rollback failed")
749                        .with_details(e.to_json())
750                })?;
751                return Ok(BatchResult {
752                    results,
753                    rolled_back: true,
754                    revision: host.revision(),
755                });
756            }
757            if failed && stop_on_error {
758                break;
759            }
760        }
761        if atomic {
762            if let Err(error) = host.commit_transaction() {
763                host.rollback_transaction().map_err(|e| {
764                    ActionError::new(ErrorCode::Internal, "Rollback after commit failure failed")
765                        .with_details(e.to_json())
766                })?;
767                return Err(error);
768            }
769        }
770        Ok(BatchResult {
771            results,
772            rolled_back: false,
773            revision: host.revision(),
774        })
775    }
776    /// Find actions by name/description, with stable lexical ordering and name cursors.
777    pub fn discover(
778        &self,
779        host: &impl Host,
780        query: &str,
781        after: Option<&str>,
782        limit: usize,
783    ) -> Result<Page, ActionError> {
784        self.check_enabled()?;
785        validate_registry(host.actions())?;
786        if limit == 0 || limit > 100 || query.len() > 256 {
787            return Err(ActionError::new(
788                ErrorCode::InvalidInput,
789                "Discovery limit is 1 to 100 and query is at most 256 bytes",
790            ));
791        }
792        let query = query.to_lowercase();
793        let mut actions: Vec<&ActionSpec> = host
794            .actions()
795            .iter()
796            .filter(|a| {
797                (query.is_empty()
798                    || a.name.to_lowercase().contains(&query)
799                    || a.description.to_lowercase().contains(&query))
800                    && after.is_none_or(|cursor| a.name > cursor)
801            })
802            .collect();
803        actions.sort_by_key(|a| a.name);
804        let next_cursor = if actions.len() > limit {
805            Some(actions[limit - 1].name.into())
806        } else {
807            None
808        };
809        actions.truncate(limit);
810        Ok(Page {
811            actions: actions
812                .into_iter()
813                .map(|a| self.metadata(host, a, false))
814                .collect(),
815            next_cursor,
816            revision: host.revision(),
817        })
818    }
819    /// Return schemas, behavior metadata and a concrete availability reason for one action.
820    pub fn describe(&self, host: &impl Host, name: &str) -> Result<Value, ActionError> {
821        self.check_enabled()?;
822        let action = self.find(host, name)?;
823        Ok(self.metadata(host, &action, true))
824    }
825    fn metadata(&self, host: &impl Host, action: &ActionSpec, schemas: bool) -> Value {
826        let availability = self.ready(host, action);
827        let mut value = Value::object([
828            ("name", Value::string(action.name)),
829            ("description", Value::string(action.description)),
830            ("available", Value::Bool(availability.is_ok())),
831            (
832                "unavailableReason",
833                availability
834                    .err()
835                    .map(|e| e.to_json())
836                    .unwrap_or(Value::Null),
837            ),
838            ("capabilities", Value::u64(action.capabilities)),
839            ("mutation", Value::Bool(action.mutation)),
840            ("revisionRequired", Value::Bool(action.revision_required)),
841            ("undo", Value::Bool(action.undo)),
842            (
843                "transaction",
844                action.transaction.map(Value::string).unwrap_or(Value::Null),
845            ),
846            ("cancellable", Value::Bool(action.cancellable)),
847            ("access", Value::string(host.access(action).as_str())),
848            (
849                "commandId",
850                action
851                    .command_id
852                    .map(|id| Value::u64(id as u64))
853                    .unwrap_or(Value::Null),
854            ),
855        ]);
856        if schemas {
857            value.insert("inputSchema", action.input.to_json());
858            value.insert("outputSchema", action.output.to_json());
859        }
860        value
861    }
862}
863
864/// Validate stable identifiers and prevent ambiguous name/command registrations.
865pub fn validate_registry(actions: &[ActionSpec]) -> Result<(), ActionError> {
866    if actions.len() > 4096 {
867        return Err(ActionError::new(
868            ErrorCode::LimitExceeded,
869            "Too many registered actions",
870        ));
871    }
872    let mut names = BTreeSet::new();
873    let mut commands = BTreeSet::new();
874    for action in actions.iter() {
875        if action.name.is_empty()
876            || action.name.len() > 128
877            || !action
878                .name
879                .bytes()
880                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
881        {
882            return Err(ActionError::new(
883                ErrorCode::Internal,
884                "Invalid registered action name",
885            ));
886        }
887        if !names.insert(action.name)
888            || action.command_id.is_some_and(|id| !commands.insert(id))
889        {
890            return Err(ActionError::new(
891                ErrorCode::Internal,
892                format!("Duplicate action or command: {}", action.name),
893            ));
894        }
895        if action.revision_required && !action.mutation {
896            return Err(ActionError::new(
897                ErrorCode::Internal,
898                "A read action cannot require a mutation revision",
899            ));
900        }
901    }
902    Ok(())
903}