Skip to main content

astrid_core/kernel_api/
readiness.rs

1//! Agent-loop readiness DTOs carried over the kernel management API.
2//!
3//! These describe whether the loaded capsule set can actually serve an agent
4//! chat turn. They are *mirror* definitions: the readiness computation lives in
5//! `astrid_capsule::readiness`, but the DTOs are defined here (in `astrid-core`)
6//! because the `KernelRequest`/`KernelResponse` API surface lives here and
7//! `astrid-core` cannot depend on `astrid-capsule` without a dependency cycle
8//! (`astrid-capsule` already depends on `astrid-core`). `astrid_capsule`
9//! constructs these types directly, so the computation stays single-source —
10//! only the wire shape is defined here.
11
12use serde::{Deserialize, Serialize};
13
14/// Whether the loaded capsule set can serve an agent chat turn. Name-agnostic
15/// (no capsule name hardcoded): the prompt topic needs a subscriber, the reply
16/// topic a publisher, and every required import an exporter. A socket-only
17/// daemon reports `ready == false` instead of silently dropping prompts.
18/// Populated by `astrid_capsule::readiness::agent_loop_readiness`.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AgentLoopReadiness {
21    /// `true` iff there is a prompt subscriber, a response publisher, and no
22    /// unsatisfied required import.
23    pub ready: bool,
24    /// Loaded capsules whose `[subscribe]` matches the prompt topic.
25    pub prompt_subscribers: Vec<String>,
26    /// Loaded capsules whose `[publish]` matches the reply topic.
27    pub response_publishers: Vec<String>,
28    /// Required imports no loaded capsule exports — each breaks its importer.
29    pub unsatisfied_required_imports: Vec<MissingImport>,
30    /// All loaded capsule names, for context in diagnostics.
31    pub loaded_capsules: Vec<String>,
32}
33
34/// In-process agent-loop readiness probe.
35///
36/// Agent-loop serviceability ("can this daemon serve a chat turn?") is global
37/// daemon health, not per-principal authorization — so the co-located gateway's
38/// prompt fail-fast reads it directly instead of issuing the capability-gated
39/// [`crate::kernel_api::KernelRequest::GetAgentReadiness`] as the caller (which
40/// only admins/`capsule:list` holders could answer). The closure is built in
41/// `astrid-kernel` (which owns the live registry) and merely invoked by the
42/// gateway, so neither the capability model nor the gateway's dependency on the
43/// WASM engine is touched. Defined here so both crates can name it without a
44/// dependency cycle, and spelled with `std` types so `astrid-core` needs no
45/// `futures` dependency.
46#[derive(Clone)]
47pub struct AgentReadinessProbe(
48    #[allow(clippy::type_complexity)]
49    std::sync::Arc<
50        dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = AgentLoopReadiness> + Send>>
51            + Send
52            + Sync,
53    >,
54);
55
56impl AgentReadinessProbe {
57    /// Wrap a readiness-computing closure. The closure must be cheap and
58    /// self-contained (it captures whatever state it reads) so each call
59    /// reflects the current loaded set.
60    pub fn new(
61        f: impl Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = AgentLoopReadiness> + Send>>
62        + Send
63        + Sync
64        + 'static,
65    ) -> Self {
66        Self(std::sync::Arc::new(f))
67    }
68
69    /// Compute current readiness.
70    pub async fn probe(&self) -> AgentLoopReadiness {
71        (self.0)().await
72    }
73}
74
75impl std::fmt::Debug for AgentReadinessProbe {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.write_str("AgentReadinessProbe(..)")
78    }
79}
80
81/// In-process probe answering "does a loaded capsule subscribe to this
82/// topic?", computed from the live registry without a capability check.
83///
84/// The cap-free counterpart to the capability-gated
85/// [`crate::kernel_api::KernelRequest::GetCapsuleMetadata`], built the same
86/// way as [`AgentReadinessProbe`] and for the same reason: whether a verb is
87/// served is global daemon health, not per-principal authorization, so a
88/// gateway route can probe it for **every** authenticated caller without a
89/// capability check or leaking the capsule inventory. Lets a route degrade
90/// gracefully — e.g. answer `501 Not Implemented` when no loaded capsule
91/// handles a newer verb — instead of waiting out a bus timeout. The closure
92/// is built in `astrid-kernel` (which owns the registry) and merely invoked
93/// here; spelled with `std` types so `astrid-core` needs no `futures` dep.
94#[derive(Clone)]
95pub struct CapsuleTopicProbe(std::sync::Arc<CapsuleTopicProbeFns>);
96
97struct CapsuleTopicProbeFns {
98    is_subscribed: CapsuleTopicProbeFn,
99    ensure_subscribed: CapsuleTopicProbeFn,
100    subscriber_source_ids: CapsuleTopicSourceProbeFn,
101}
102
103#[allow(clippy::type_complexity)]
104type CapsuleTopicProbeFn = std::sync::Arc<
105    dyn Fn(String) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
106        + Send
107        + Sync,
108>;
109
110#[allow(clippy::type_complexity)]
111type CapsuleTopicSourceProbeFn = std::sync::Arc<
112    dyn Fn(String) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<uuid::Uuid>> + Send>>
113        + Send
114        + Sync,
115>;
116
117impl CapsuleTopicProbe {
118    /// Wrap a closure that answers whether `topic` has a loaded-capsule
119    /// subscriber. The closure captures the registry it reads, so each call
120    /// reflects the current loaded set (correct across live reloads).
121    pub fn new(
122        f: impl Fn(String) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
123        + Send
124        + Sync
125        + 'static,
126    ) -> Self {
127        let f = std::sync::Arc::new(f);
128        let is_subscribed = {
129            let f = std::sync::Arc::clone(&f);
130            std::sync::Arc::new(move |topic: String| f(topic))
131        };
132        let ensure_subscribed = std::sync::Arc::new(move |topic: String| f(topic));
133        Self(std::sync::Arc::new(CapsuleTopicProbeFns {
134            is_subscribed,
135            ensure_subscribed,
136            subscriber_source_ids: std::sync::Arc::new(|_| Box::pin(async { Vec::new() })),
137        }))
138    }
139
140    /// Wrap a closure pair: one passive readiness read, and one active
141    /// best-effort warm/read for routes that must not publish into an unloaded
142    /// caller view after restart.
143    pub fn new_with_ensure(
144        is_subscribed: impl Fn(
145            String,
146        )
147            -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
148        + Send
149        + Sync
150        + 'static,
151        ensure_subscribed: impl Fn(
152            String,
153        )
154            -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
155        + Send
156        + Sync
157        + 'static,
158    ) -> Self {
159        Self(std::sync::Arc::new(CapsuleTopicProbeFns {
160            is_subscribed: std::sync::Arc::new(is_subscribed),
161            ensure_subscribed: std::sync::Arc::new(ensure_subscribed),
162            subscriber_source_ids: std::sync::Arc::new(|_| Box::pin(async { Vec::new() })),
163        }))
164    }
165
166    /// Wrap readiness, warm-up, and trusted-source probes from the same live
167    /// capsule registry.
168    pub fn new_with_ensure_and_sources(
169        is_subscribed: impl Fn(
170            String,
171        )
172            -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
173        + Send
174        + Sync
175        + 'static,
176        ensure_subscribed: impl Fn(
177            String,
178        )
179            -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
180        + Send
181        + Sync
182        + 'static,
183        subscriber_source_ids: impl Fn(
184            String,
185        ) -> std::pin::Pin<
186            Box<dyn std::future::Future<Output = Vec<uuid::Uuid>> + Send>,
187        > + Send
188        + Sync
189        + 'static,
190    ) -> Self {
191        Self(std::sync::Arc::new(CapsuleTopicProbeFns {
192            is_subscribed: std::sync::Arc::new(is_subscribed),
193            ensure_subscribed: std::sync::Arc::new(ensure_subscribed),
194            subscriber_source_ids: std::sync::Arc::new(subscriber_source_ids),
195        }))
196    }
197
198    /// True if some loaded capsule's `[subscribe]` matches `topic`.
199    pub async fn is_subscribed(&self, topic: &str) -> bool {
200        (self.0.is_subscribed)(topic.to_string()).await
201    }
202
203    /// Best-effort warm-up for `topic`, then answer whether a subscriber is
204    /// present. Probes built with [`Self::new`] are passive and simply mirror
205    /// [`Self::is_subscribed`].
206    pub async fn ensure_subscribed(&self, topic: &str) -> bool {
207        (self.0.ensure_subscribed)(topic.to_string()).await
208    }
209
210    /// Kernel-stamped IPC source IDs of loaded subscribers matching `topic`.
211    pub async fn subscriber_source_ids(&self, topic: &str) -> Vec<uuid::Uuid> {
212        (self.0.subscriber_source_ids)(topic.to_string()).await
213    }
214}
215
216impl std::fmt::Debug for CapsuleTopicProbe {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.write_str("CapsuleTopicProbe(..)")
219    }
220}
221
222/// A required interface import with no matching export among loaded capsules.
223///
224/// `Ord` (by capsule, namespace, interface, requirement in declaration order)
225/// so readiness reports can present a stable, sorted list — the loaded set is
226/// iterated from a `HashMap`, which has no inherent order.
227#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
228pub struct MissingImport {
229    /// The capsule whose import is unsatisfied.
230    pub capsule: String,
231    /// Interface namespace (e.g. `astrid`).
232    pub namespace: String,
233    /// Interface name (e.g. `llm`).
234    pub interface: String,
235    /// The semver requirement string the import declared.
236    pub requirement: String,
237}