Skip to main content

aion_server/assistant/
document.rs

1//! The embedded assistant document: its bytes, its compiled identity, and the
2//! session contract the operator verbs bind to.
3//!
4//! # One document, one place
5//!
6//! `crates/aion-server/assistant-embed/assistant.awl` is the ONLY copy of the
7//! assistant document in this repository. It is not a copy of an example — the
8//! example was moved here, so there is no second file to drift from. Everything
9//! downstream (the boot install, the `/assistant` description, the `aion
10//! assistant` verbs, the ops-console contract test) reads this one artifact.
11//!
12//! # The session contract is named once and VERIFIED, never restated
13//!
14//! An operator verb has to know which input carries the objective, which signal
15//! continues the session, and which fields that signal's payload takes. Those
16//! names are declared here exactly once, as constants, and [`EmbeddedAssistant::load`]
17//! proves each one against the compiled document before returning. A document
18//! edit that renames `objective`, drops the `assistant_continue` signal, or
19//! changes the continuation's fields therefore fails loudly at load — at boot,
20//! in the verb, and in this module's own tests — instead of leaving a constant
21//! quietly disagreeing with the document it names.
22//!
23//! # The task queue is DERIVED, and it is never `default`
24//!
25//! The built-in assistant owns a private queue named after itself:
26//! [`private_task_queue`] IS the rule, and the queue is that function of the
27//! document's own workflow type — not a constant beside it, not a
28//! configuration key with a default. [`EmbeddedAssistant::load`] proves the
29//! document declares exactly that one queue and refuses it otherwise, so the
30//! derivation cannot drift from the document and the document cannot drift
31//! from the server.
32//!
33//! `default` is where an out-of-box worker comes up when nobody has told it
34//! otherwise. Contract admission holds a registering worker against EVERY
35//! reachable contract on its queue at once, so while the assistant sat on
36//! `default` the first worker a newcomer started was refused for not
37//! advertising `assistant` — the built-in assistant starved the workers it
38//! exists to welcome (#200). The queue therefore belongs to those workers, and
39//! this document never claims it.
40
41use std::path::Path;
42use std::sync::OnceLock;
43
44use aion_awl::{CompiledWorkflow, TypeBody};
45use aion_package::{
46    ContentHash, ExtractionLimits, Package, PackageContract, PackageError, SignalContract,
47};
48use serde_json::Value;
49
50/// The embedded assistant document, compiled into the binary.
51///
52/// `include_str!` from inside the crate, exactly as the ops-console bundle is
53/// embedded from `ops-console-embed/`: the file is git-tracked under the crate
54/// root, so `cargo package` carries it and an installed binary holds the same
55/// bytes this repository does.
56pub const EMBEDDED_ASSISTANT_DOCUMENT: &str = include_str!("../../assistant-embed/assistant.awl");
57
58/// The document's own filename, recorded in the assembled archive's `awl/`
59/// provenance tree so a deployed package carries the source it was built from.
60pub const EMBEDDED_ASSISTANT_FILENAME: &str = "assistant.awl";
61
62/// The start input carrying the operator's opening ask.
63pub const OBJECTIVE_INPUT: &str = "objective";
64
65/// The start input carrying the repository the session grounds itself in.
66/// The document's documented scratch mode is the empty string.
67pub const REPO_PATH_INPUT: &str = "repo_path";
68
69/// The one control signal a parked session listens on.
70pub const CONTINUE_SIGNAL: &str = "assistant_continue";
71
72/// The continuation field carrying the operator's next prompt.
73pub const CONTINUE_MESSAGE_FIELD: &str = "message";
74
75/// The continuation field that ends the session cleanly.
76pub const CONTINUE_END_FIELD: &str = "end";
77
78/// The read-only query reporting a live session's phase and round count.
79pub const STATUS_QUERY: &str = "assistant_status";
80
81/// The assistant's private task queue, derived from its own identity.
82///
83/// The derivation IS the value: the built-in assistant serves the queue named
84/// after the workflow type it exports, and there is nowhere else a queue name
85/// is written down. [`EmbeddedAssistant::load`] holds the embedded document to
86/// this — a document declaring any other queue does not load — so the server,
87/// the document, and every surface that reports the queue read one rule.
88///
89/// It is deliberately NOT operator-configurable and has no default to fall
90/// back to. A configurable queue with a default is how the assistant ended up
91/// on `default` in the first place.
92#[must_use]
93pub const fn private_task_queue(workflow_type: &str) -> &str {
94    workflow_type
95}
96
97/// The schema-import root presented to the compiler.
98///
99/// The document is embedded as bytes with no directory beside it, so it cannot
100/// carry `schema(…)` imports — [`EmbeddedAssistant::load`] refuses one before
101/// compiling. This root is therefore never read; it is a path that does not
102/// exist so that a future import would fail loudly here rather than silently
103/// resolve against whatever directory the server happens to be running in.
104const EMBEDDED_SCHEMA_ROOT: &str = "<embedded-assistant-has-no-schema-directory>";
105
106/// A refusal to produce the embedded assistant.
107///
108/// Every variant names what about the document made it unusable. There is no
109/// "assistant unavailable" catch-all: an operator reading a boot log or an
110/// `/assistant` error must be able to act on it.
111#[derive(Debug, thiserror::Error)]
112pub enum EmbeddedAssistantError {
113    /// The embedded document does not parse.
114    #[error("the embedded assistant document does not parse: {message}")]
115    Parse {
116        /// The parser's diagnostic, verbatim.
117        message: String,
118    },
119
120    /// The embedded document carries a `schema(…)` import.
121    ///
122    /// The binary embeds one file and no directory, so an import has nothing to
123    /// resolve against. This is a refusal to guess, not a limitation dressed up
124    /// as one: inlining the type into the document removes it.
125    #[error(
126        "the embedded assistant document imports schema `{path}`, but the binary embeds the \
127         document alone and has no directory to resolve imports against; declare the type \
128         inline in the document"
129    )]
130    SchemaImport {
131        /// The import path, verbatim from the document.
132        path: String,
133    },
134
135    /// The embedded document does not compile.
136    #[error("the embedded assistant document does not compile: {message}")]
137    Compile {
138        /// The compiler's diagnostic, verbatim.
139        message: String,
140    },
141
142    /// The compiled document could not be assembled into an archive.
143    #[error("the embedded assistant document could not be packaged: {message}")]
144    Assemble {
145        /// The assembler's diagnostic, verbatim.
146        message: String,
147    },
148
149    /// The assembled archive did not load back as a validated package.
150    #[error("the embedded assistant package did not validate: {source}")]
151    Package {
152        /// The package validation failure.
153        #[from]
154        source: PackageError,
155    },
156
157    /// The document does not declare an input the session contract names.
158    #[error(
159        "the embedded assistant document declares no `{name}` input; the session contract in \
160         crates/aion-server/src/assistant/document.rs names it, so document and contract have \
161         diverged"
162    )]
163    MissingInput {
164        /// The input the contract names.
165        name: &'static str,
166    },
167
168    /// The document does not declare the continuation signal.
169    #[error(
170        "the embedded assistant document declares no `{name}` signal; the session contract in \
171         crates/aion-server/src/assistant/document.rs names it, so document and contract have \
172         diverged"
173    )]
174    MissingSignal {
175        /// The signal the contract names.
176        name: &'static str,
177    },
178
179    /// The continuation signal's payload lacks a field the contract sends.
180    #[error(
181        "the `{signal}` signal payload declares no `{field}` field; the session contract in \
182         crates/aion-server/src/assistant/document.rs sends it, so document and contract have \
183         diverged"
184    )]
185    MissingSignalField {
186        /// The signal whose payload was inspected.
187        signal: &'static str,
188        /// The field the contract sends.
189        field: &'static str,
190    },
191
192    /// The document does not declare the status query.
193    #[error(
194        "the embedded assistant document declares no `{name}` query; the session contract in \
195         crates/aion-server/src/assistant/document.rs names it, so document and contract have \
196         diverged"
197    )]
198    MissingQuery {
199        /// The query the contract names.
200        name: &'static str,
201    },
202
203    /// The compiled package declares no signal contract at all.
204    #[error(
205        "the embedded assistant package carries no contract, so its signal payloads cannot be \
206         read: {message}"
207    )]
208    MissingContract {
209        /// Why the contract could not be read.
210        message: String,
211    },
212
213    /// The document's own workflow type would derive the out-of-box workers'
214    /// queue, so the assistant cannot have a private queue at all.
215    #[error(
216        "the embedded assistant exports workflow type `{workflow_type}`, so its derived private \
217         queue would be `{default_queue}` — the queue every out-of-box worker comes up on. The \
218         built-in assistant never claims it (#200); the workflow must be named something else"
219    )]
220    QueueWouldBeDefault {
221        /// The workflow type the document exports.
222        workflow_type: String,
223        /// The out-of-box workers' queue this would collide with.
224        default_queue: &'static str,
225    },
226
227    /// The document declares no `worker` block, so it claims no queue.
228    #[error(
229        "the embedded assistant document declares no `worker` block, so it claims no task queue; \
230         the assistant's queue is derived from its own workflow type and the document must \
231         declare `worker {expected}`"
232    )]
233    MissingWorkerBlock {
234        /// The queue the derivation requires.
235        expected: String,
236    },
237
238    /// The document declares more than one `worker` block, so "the assistant's
239    /// queue" is not one value.
240    #[error(
241        "the embedded assistant document declares more than one `worker` block (`{declared}`); \
242         the built-in assistant serves exactly one derived private queue, `{expected}`"
243    )]
244    AmbiguousQueue {
245        /// Every declared queue, in declaration order, comma-separated.
246        declared: String,
247        /// The queue the derivation requires.
248        expected: String,
249    },
250
251    /// The document declares a queue other than its derived private one.
252    #[error(
253        "the embedded assistant document declares task queue `{declared}`, but the assistant's \
254         queue is derived from its own workflow type and must be `{expected}`. `default` is the \
255         out-of-box workers' queue and the built-in assistant never claims it (#200)"
256    )]
257    QueueNotDerived {
258        /// The queue the document declares.
259        declared: String,
260        /// The queue the derivation requires.
261        expected: String,
262    },
263}
264
265/// The embedded assistant: the document's bytes, the package compiled from
266/// them, and the contract surfaces an operator drives it through.
267///
268/// Construction is the verification: holding one of these is proof that the
269/// embedded document compiled, packaged, and carries every surface the session
270/// contract names.
271#[derive(Debug, Clone)]
272pub struct EmbeddedAssistant {
273    source: &'static str,
274    package: Package,
275    workflow_type: String,
276    task_queue: String,
277    input_schema: Value,
278    signals: Vec<SignalContract>,
279    queries: Vec<String>,
280}
281
282impl EmbeddedAssistant {
283    /// Compiles, packages, and verifies the embedded document.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`EmbeddedAssistantError`] naming the stage that refused: parse,
288    /// a schema import the binary cannot carry, compilation, archive assembly,
289    /// package validation, or a session-contract surface the document does not
290    /// declare.
291    pub fn load() -> Result<Self, EmbeddedAssistantError> {
292        Self::from_source(EMBEDDED_ASSISTANT_DOCUMENT)
293    }
294
295    /// The whole preparation, over an arbitrary document.
296    ///
297    /// [`Self::load`] is this applied to the embedded bytes. It is separate so
298    /// the session-contract verification can be exercised against a document
299    /// that deliberately omits a surface — a check nothing ever fails is a
300    /// check nobody has measured.
301    ///
302    /// # Errors
303    ///
304    /// As [`Self::load`].
305    pub fn from_source(source: &'static str) -> Result<Self, EmbeddedAssistantError> {
306        let document = aion_awl::parse(source).map_err(|error| EmbeddedAssistantError::Parse {
307            message: error.message,
308        })?;
309        for declaration in &document.types {
310            if let TypeBody::SchemaImport { path, .. } = &declaration.body {
311                return Err(EmbeddedAssistantError::SchemaImport { path: path.clone() });
312            }
313        }
314
315        let root = Path::new(EMBEDDED_SCHEMA_ROOT);
316        let prepared =
317            aion_awl_package::compile_and_assemble_awl(source, root, EMBEDDED_ASSISTANT_FILENAME)
318                .map_err(|error| match error {
319                aion_awl_package::PrepareAwlError::Compile(compile) => {
320                    EmbeddedAssistantError::Compile {
321                        message: compile.to_string(),
322                    }
323                }
324                other => EmbeddedAssistantError::Assemble {
325                    message: other.to_string(),
326                },
327            })?;
328        let CompiledWorkflow { input_schema, .. } = prepared.compiled;
329
330        // Trusted, compile-time content assembled by this process moments ago —
331        // not network input — so extraction carries no inflate ceiling
332        // (`ExtractionLimits::unbounded`'s stated use).
333        let package = Package::load_from_bytes(&prepared.archive, ExtractionLimits::unbounded())?;
334        let workflow_type = package.manifest().entry_module.clone();
335
336        let contract =
337            package
338                .contract()
339                .map_err(|error| EmbeddedAssistantError::MissingContract {
340                    message: error.to_string(),
341                })?;
342        let signals = contract.signals.clone();
343        let task_queue = verified_task_queue(&workflow_type, contract)?;
344
345        for name in [OBJECTIVE_INPUT, REPO_PATH_INPUT] {
346            if !document.inputs.iter().any(|input| input.name == name) {
347                return Err(EmbeddedAssistantError::MissingInput { name });
348            }
349        }
350        let continuation = signals
351            .iter()
352            .find(|signal| signal.name == CONTINUE_SIGNAL)
353            .ok_or(EmbeddedAssistantError::MissingSignal {
354                name: CONTINUE_SIGNAL,
355            })?;
356        for field in [CONTINUE_MESSAGE_FIELD, CONTINUE_END_FIELD] {
357            if !schema_declares_property(&continuation.input_schema, field) {
358                return Err(EmbeddedAssistantError::MissingSignalField {
359                    signal: CONTINUE_SIGNAL,
360                    field,
361                });
362            }
363        }
364        let queries: Vec<String> = document
365            .queries
366            .iter()
367            .map(|query| query.name.clone())
368            .collect();
369        if !queries.iter().any(|name| name == STATUS_QUERY) {
370            return Err(EmbeddedAssistantError::MissingQuery { name: STATUS_QUERY });
371        }
372
373        Ok(Self {
374            source,
375            package,
376            workflow_type,
377            task_queue,
378            input_schema,
379            signals,
380            queries,
381        })
382    }
383
384    /// The validated package the engine loads.
385    #[must_use]
386    pub const fn package(&self) -> &Package {
387        &self.package
388    }
389
390    /// The workflow type an operator starts.
391    #[must_use]
392    pub fn workflow_type(&self) -> &str {
393        &self.workflow_type
394    }
395
396    /// The assistant's private task queue: the one a worker must serve for a
397    /// session to run, and never `default`.
398    ///
399    /// Verified at load against [`private_task_queue`], so this is the
400    /// document's own declaration and the derivation at once — holding an
401    /// [`EmbeddedAssistant`] is proof they agree.
402    #[must_use]
403    pub fn task_queue(&self) -> &str {
404        &self.task_queue
405    }
406
407    /// The package's content hash — this document's version identity.
408    #[must_use]
409    pub const fn content_hash(&self) -> &ContentHash {
410        self.package.content_hash()
411    }
412
413    /// The document source, verbatim.
414    #[must_use]
415    pub const fn source(&self) -> &'static str {
416        self.source
417    }
418
419    /// The derived JSON Schema of the start input.
420    #[must_use]
421    pub const fn input_schema(&self) -> &Value {
422        &self.input_schema
423    }
424
425    /// Every declared signal with its payload schema.
426    #[must_use]
427    pub fn signals(&self) -> &[SignalContract] {
428        &self.signals
429    }
430
431    /// Every declared query name, in document order.
432    #[must_use]
433    pub fn queries(&self) -> &[String] {
434        &self.queries
435    }
436
437    /// The continuation signal's payload schema.
438    ///
439    /// Present by construction: [`Self::load`] refuses a document that does not
440    /// declare [`CONTINUE_SIGNAL`].
441    ///
442    /// # Errors
443    ///
444    /// Returns [`EmbeddedAssistantError::MissingSignal`] if the signal set is
445    /// ever mutated out from under construction — reported rather than assumed
446    /// away.
447    pub fn continuation_schema(&self) -> Result<&Value, EmbeddedAssistantError> {
448        self.signals
449            .iter()
450            .find(|signal| signal.name == CONTINUE_SIGNAL)
451            .map(|signal| &signal.input_schema)
452            .ok_or(EmbeddedAssistantError::MissingSignal {
453                name: CONTINUE_SIGNAL,
454            })
455    }
456}
457
458/// The queue the compiled document declares, proved equal to the derivation.
459///
460/// Four ways this refuses, and each is a refusal to guess rather than a
461/// limitation: a workflow type whose derived queue would be the out-of-box
462/// workers' queue, a document that declares no queue at all, one that declares
463/// several (so "the assistant's queue" is not one value), and one that declares
464/// a queue the derivation did not produce.
465fn verified_task_queue(
466    workflow_type: &str,
467    contract: &PackageContract,
468) -> Result<String, EmbeddedAssistantError> {
469    let expected = private_task_queue(workflow_type);
470    if expected == aion_core::DEFAULT_TASK_QUEUE {
471        return Err(EmbeddedAssistantError::QueueWouldBeDefault {
472            workflow_type: workflow_type.to_owned(),
473            default_queue: aion_core::DEFAULT_TASK_QUEUE,
474        });
475    }
476    let declared: Vec<&str> = contract
477        .workers
478        .iter()
479        .map(|worker| worker.task_queue.as_str())
480        .collect();
481    let [only] = declared.as_slice() else {
482        return Err(if declared.is_empty() {
483            EmbeddedAssistantError::MissingWorkerBlock {
484                expected: expected.to_owned(),
485            }
486        } else {
487            EmbeddedAssistantError::AmbiguousQueue {
488                declared: declared.join(", "),
489                expected: expected.to_owned(),
490            }
491        });
492    };
493    if *only != expected {
494        return Err(EmbeddedAssistantError::QueueNotDerived {
495            declared: (*only).to_owned(),
496            expected: expected.to_owned(),
497        });
498    }
499    Ok(expected.to_owned())
500}
501
502/// Whether `schema` declares `property` under `properties`.
503///
504/// A signal payload's derived schema for a NAMED type is a reference beside its
505/// own definitions — `{"$ref": "#/$defs/Continuation", "$defs": {…}}` — so the
506/// properties live one hop away. That one local form is followed; anything else
507/// is left unresolved rather than guessed at, because a guess here would report
508/// a field the payload does not carry and the operator verbs would send it.
509fn schema_declares_property(schema: &Value, property: &str) -> bool {
510    resolve_local_ref(schema)
511        .and_then(|resolved| resolved.get("properties"))
512        .and_then(Value::as_object)
513        .is_some_and(|properties| properties.contains_key(property))
514}
515
516/// Follows a top-level `#/$defs/<name>` reference into the sibling `$defs` map.
517/// A schema with no `$ref` is already the definition; an unresolvable reference
518/// yields `None`.
519fn resolve_local_ref(schema: &Value) -> Option<&Value> {
520    let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
521        return Some(schema);
522    };
523    let name = reference.strip_prefix("#/$defs/")?;
524    schema.get("$defs")?.get(name)
525}
526
527/// The process-wide embedded assistant, compiled once on first use.
528///
529/// The document is compile-time constant, so its compiled form is too: every
530/// caller (boot install, `/assistant`, the operator verbs) reads this one
531/// value, and a failure is computed once and reported identically everywhere.
532///
533/// # Errors
534///
535/// Returns the [`EmbeddedAssistantError`] from the single load attempt.
536pub fn embedded_assistant() -> Result<&'static EmbeddedAssistant, &'static EmbeddedAssistantError> {
537    static EMBEDDED: OnceLock<Result<EmbeddedAssistant, EmbeddedAssistantError>> = OnceLock::new();
538    EMBEDDED.get_or_init(EmbeddedAssistant::load).as_ref()
539}
540
541#[cfg(test)]
542#[path = "document_tests.rs"]
543mod document_tests;