Skip to main content

aion_server/assistant/
descriptor.rs

1//! What the server says about the assistant it carries.
2//!
3//! The description is DERIVED on every read: the identity and contract come
4//! from the embedded document's compiled form, and residency comes from the
5//! engine catalog at request time. Nothing is remembered from boot, so a
6//! description cannot go stale behind an operator's `aion deploy` or
7//! `aion route` between restarts.
8
9use aion::Engine;
10use serde::Serialize;
11use serde_json::Value;
12
13use super::document::{
14    CONTINUE_END_FIELD, CONTINUE_MESSAGE_FIELD, CONTINUE_SIGNAL, EMBEDDED_ASSISTANT_FILENAME,
15    EmbeddedAssistant, OBJECTIVE_INPUT, REPO_PATH_INPUT, STATUS_QUERY,
16};
17
18/// Where the embedded document stands in the engine catalog right now.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case", tag = "state")]
21pub enum AssistantResidency {
22    /// The embedded version is loaded and holds the route: starting the
23    /// assistant runs this document.
24    Routed,
25    /// The embedded version is loaded but another version holds the route (or
26    /// nothing does). Starting the assistant does not run this document.
27    LoadedNotRouted {
28        /// The routed hash, when some resident version holds the route.
29        routed_hash: Option<String>,
30    },
31    /// The embedded version is not in the catalog at all. `routed_hash` names
32    /// whatever version of the type does hold the route; `None` means the type
33    /// is unstartable on this server.
34    NotLoaded {
35        /// The routed hash, when some other version holds the route.
36        routed_hash: Option<String>,
37    },
38    /// The catalog could not be read, so residency is unknown — never guessed.
39    Unknown {
40        /// Why the catalog could not be read.
41        reason: String,
42    },
43}
44
45/// The names the operator surfaces bind to, verified against the document at
46/// load time. Published so a client drives the session by reading rather than
47/// by restating them.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49pub struct AssistantSessionContract {
50    /// Start input carrying the operator's opening ask.
51    pub objective_input: &'static str,
52    /// Start input carrying the repository path (empty = scratch mode).
53    pub repo_path_input: &'static str,
54    /// The signal a parked session listens on.
55    pub continue_signal: &'static str,
56    /// Continuation field carrying the next prompt.
57    pub message_field: &'static str,
58    /// Continuation field that ends the session.
59    pub end_field: &'static str,
60    /// The read-only status query.
61    pub status_query: &'static str,
62}
63
64impl AssistantSessionContract {
65    /// The contract this binary was built with.
66    #[must_use]
67    pub const fn current() -> Self {
68        Self {
69            objective_input: OBJECTIVE_INPUT,
70            repo_path_input: REPO_PATH_INPUT,
71            continue_signal: CONTINUE_SIGNAL,
72            message_field: CONTINUE_MESSAGE_FIELD,
73            end_field: CONTINUE_END_FIELD,
74            status_query: STATUS_QUERY,
75        }
76    }
77}
78
79/// One declared signal and its payload schema.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
81pub struct AssistantSignal {
82    /// Signal name.
83    pub name: String,
84    /// Payload schema, draft 2020-12, derived from the document.
85    pub input_schema: Value,
86}
87
88/// The served description of the built-in assistant.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
90pub struct AssistantDescriptor {
91    /// The workflow type an operator starts.
92    pub workflow_type: String,
93    /// The private task queue a worker must serve for a session to run.
94    ///
95    /// Published because an operator with no checkout has no other way to
96    /// learn it, and because getting it wrong is silent: a worker on the wrong
97    /// queue connects, registers, and never receives a dispatch. Derived from
98    /// the document's own identity and never `default` — see
99    /// [`super::document::private_task_queue`].
100    pub task_queue: String,
101    /// The embedded document's content hash — its version identity.
102    pub content_hash: String,
103    /// The document's filename inside the binary.
104    pub document_filename: &'static str,
105    /// Length of the embedded document in bytes, so a client can tell a
106    /// truncated fetch of `/assistant/document` from a complete one.
107    pub document_bytes: usize,
108    /// Start input schema, draft 2020-12, derived from the document.
109    pub input_schema: Value,
110    /// Every declared signal with its payload schema.
111    pub signals: Vec<AssistantSignal>,
112    /// Every declared query name, in document order.
113    pub queries: Vec<String>,
114    /// The verified session contract.
115    pub session: AssistantSessionContract,
116    /// Where the embedded version stands in the catalog right now.
117    pub residency: AssistantResidency,
118}
119
120/// Describes `embedded` against `engine`'s current catalog.
121#[must_use]
122pub fn describe(embedded: &EmbeddedAssistant, engine: &Engine) -> AssistantDescriptor {
123    AssistantDescriptor {
124        workflow_type: embedded.workflow_type().to_owned(),
125        task_queue: embedded.task_queue().to_owned(),
126        content_hash: embedded.content_hash().to_string(),
127        document_filename: EMBEDDED_ASSISTANT_FILENAME,
128        document_bytes: embedded.source().len(),
129        input_schema: embedded.input_schema().clone(),
130        signals: embedded
131            .signals()
132            .iter()
133            .map(|signal| AssistantSignal {
134                name: signal.name.clone(),
135                input_schema: signal.input_schema.clone(),
136            })
137            .collect(),
138        queries: embedded.queries().to_vec(),
139        session: AssistantSessionContract::current(),
140        residency: residency(embedded, engine),
141    }
142}
143
144/// Reads the catalog and classifies the embedded version's standing.
145fn residency(embedded: &EmbeddedAssistant, engine: &Engine) -> AssistantResidency {
146    let versions = match engine.list_workflow_versions() {
147        Ok(versions) => versions,
148        Err(error) => {
149            return AssistantResidency::Unknown {
150                reason: format!("the engine catalog could not be read: {error}"),
151            };
152        }
153    };
154    let embedded_hash = embedded.content_hash().to_string();
155    let resident: Vec<_> = versions
156        .into_iter()
157        .filter(|version| version.workflow_type == embedded.workflow_type())
158        .collect();
159    let loaded = resident
160        .iter()
161        .any(|version| version.content_hash.to_string() == embedded_hash);
162    let routed_hash = resident
163        .iter()
164        .find(|version| version.route_active)
165        .map(|version| version.content_hash.to_string());
166    if !loaded {
167        return AssistantResidency::NotLoaded { routed_hash };
168    }
169    if routed_hash.as_deref() == Some(embedded_hash.as_str()) {
170        AssistantResidency::Routed
171    } else {
172        AssistantResidency::LoadedNotRouted { routed_hash }
173    }
174}