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}