lattice_plugin_host/picker_source.rs
1//! PH7.4c.2 — the `WasmPickerSource` host adapter (the create path).
2//!
3//! Wraps a plugin's picker exports (driven through the [`PickerClient`] bridge,
4//! PH7.4c.1b) as an `Arc<dyn PickerSourceGenerator>` so a plugin source is
5//! indistinguishable from a first-party one at the `PickerRegistry`
6//! (`register_generator`). The boundary conversions are PH7.4a's
7//! `WitBoundary` + `project_picker_context`; this adapter only sequences them
8//! against the trait's sync/async contract.
9//!
10//! ## Mapping the trait onto an async, actor-bound guest
11//!
12//! `PickerSourceGenerator`'s methods are synchronous, but the guest exports are
13//! async and bound to the plugin's actor task (`picker_task.rs`). The three
14//! methods resolve that mismatch differently:
15//!
16//! - **`spec`** is fetched once at [`connect`](WasmPickerSource::connect) time
17//! and cached natively, so the sync `spec(&self) -> &PickerSourceSpec` is a
18//! borrow — no per-call guest hop.
19//! - **`init`** returns [`PickerInitResult::Future`]: the sync prelude projects
20//! the borrowed context into an owned WIT record (§4.2) and moves it into a
21//! `'static` future that awaits `client.init` off-thread, then converts the
22//! WIT candidate pairs back. This drops straight into the host's existing
23//! `pending_picker_init` drain.
24//! - **`accept`** returns `Some` from [`accept_async`](PickerSourceGenerator::accept_async):
25//! the same sync-prelude-then-future shape, awaiting `client.accept`. The
26//! host applies the outcome via the pending-accept drain. The synchronous
27//! [`accept`](PickerSourceGenerator::accept) is a defensive tripwire — the
28//! host always prefers `accept_async` when it returns `Some`, so a plugin
29//! accept never blocks the actor thread (paramount #4).
30
31use std::sync::Arc;
32
33use lattice_completion::candidate::RawCandidate;
34use lattice_picker::context::PickerContext;
35use lattice_picker::outcome::PickerAcceptOutcome;
36use lattice_picker::source::PickerSourceSpec;
37use lattice_picker::{
38 AcceptFuture, CandidateBatch, PickerInitResult, PickerSourceGenerator, RoutingPayload,
39 SourceResult,
40};
41
42use crate::WitBoundary;
43use crate::boundary_picker::project_picker_context;
44use crate::picker_task::{CandidatePair, PickerContext as WitPickerContext};
45use crate::{PickerClient, PluginHostError};
46
47/// An `Arc<dyn PickerSourceGenerator>`-ready adapter over ONE of a picker
48/// plugin's registered sources.
49///
50/// Cheap to clone (the client is an mpsc `Sender` clone + a cached spec); every
51/// clone talks to the same actor. **N adapters share one actor and one guest
52/// instance** since OR.5b — each carries the source id it was registered under
53/// and passes it on every `init` / `accept`, which is how one component serves
54/// several pickers.
55pub struct WasmPickerSource {
56 client: PickerClient,
57 /// The native spec, converted once at registration. Held so the trait's
58 /// `spec(&self) -> &PickerSourceSpec` is a borrow.
59 spec: PickerSourceSpec,
60 /// Which of the plugin's sources this adapter is. Sent on every guest call
61 /// so the one instance can tell them apart.
62 source: String,
63}
64
65impl WasmPickerSource {
66 /// Build an adapter for one already-declared source.
67 fn new(client: PickerClient, spec: PickerSourceSpec) -> Self {
68 let source = spec.id.to_string();
69 Self {
70 client,
71 spec,
72 source,
73 }
74 }
75
76 /// Drive the plugin's `register-picker-sources` export and wrap each source
77 /// it declared.
78 ///
79 /// Async because registration is a guest call. A dead actor or a trapping
80 /// registration is a typed error, so a bad plugin fails loudly rather than
81 /// registering a broken source. An empty list is NOT an error — a plugin
82 /// that declares nothing registers nothing, which is what it asked for.
83 pub async fn connect_all(client: PickerClient) -> Result<Vec<Self>, PluginHostError> {
84 // Already NATIVE — `register-picker-source` converted each spec at the
85 // host-import call, so there is nothing left to cross here.
86 let specs = client.register_sources().await?;
87 Ok(specs
88 .into_iter()
89 .map(|spec| Self::new(client.clone(), spec))
90 .collect())
91 }
92
93 /// The host-issued id of the plugin behind this source.
94 pub fn plugin_id(&self) -> crate::PluginId {
95 self.client.id()
96 }
97
98 /// Project the borrowed native context into its owned WIT mirror (§4.2) —
99 /// the synchronous prelude both `init` and `accept_async` run before
100 /// handing work to a `'static` future.
101 fn project(ctx: &PickerContext<'_>) -> Result<WitPickerContext, String> {
102 project_picker_context(ctx)
103 }
104}
105
106/// Flatten the bridge's nested result into the trait's `SourceResult`: the
107/// outer host error (trap / plugin-gone) and the inner guest WIT `err` both
108/// collapse to the `String` error the picker echoes.
109fn flatten<T>(call: Result<Result<T, String>, PluginHostError>) -> Result<T, String> {
110 match call {
111 Ok(inner) => inner,
112 Err(host_err) => Err(format!("picker plugin: {host_err}")),
113 }
114}
115
116impl PickerSourceGenerator for WasmPickerSource {
117 fn spec(&self) -> &PickerSourceSpec {
118 &self.spec
119 }
120
121 /// PH.1: the plugin behind this source, so `<C-h>` can find the page the
122 /// plugin registered as `<plugin>.picker-<id>` — and only that plugin's.
123 /// Same `u64` widening the loader applies to `HelpTopic::plugin_id`.
124 fn owner_plugin(&self) -> Option<u64> {
125 Some(u64::from(self.plugin_id().0))
126 }
127
128 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
129 // Sync prelude: project the borrowed context now, then release the
130 // borrow. Everything the future needs is owned + `'static`.
131 let wit_ctx = Self::project(ctx)?;
132 let args = args.to_vec();
133 let client = self.client.clone();
134 let source = self.source.clone();
135 Ok(PickerInitResult::Future(Box::pin(async move {
136 let pairs = flatten(client.init(source, wit_ctx, args).await)?;
137 wit_pairs_to_batch(pairs)
138 })))
139 }
140
141 fn accept(
142 &self,
143 _ctx: &PickerContext<'_>,
144 _routing: &RoutingPayload,
145 ) -> SourceResult<PickerAcceptOutcome> {
146 // Defensive tripwire: the host always routes a WASM source's accept
147 // through `accept_async` (which returns `Some`), so this is unreachable
148 // in the wired path. Surfacing an error rather than a silent `NoOp`
149 // makes any future host-wiring regression loud.
150 Err("WasmPickerSource::accept must be resolved via accept_async".to_string())
151 }
152
153 fn accept_async(
154 &self,
155 ctx: &PickerContext<'_>,
156 routing: &RoutingPayload,
157 ) -> Option<AcceptFuture> {
158 // Sync prelude: project the context + lower the routing token now. A
159 // projection/lowering error is still surfaced *through* the future (as
160 // `Some`) so it reaches the pending-accept drain rather than being
161 // swallowed by the host's `None => sync accept` fallback.
162 let prep = Self::project(ctx).and_then(|wit_ctx| Ok((wit_ctx, routing.to_wit()?)));
163 let client = self.client.clone();
164 let source = self.source.clone();
165 Some(Box::pin(async move {
166 let (wit_ctx, wit_routing) = prep?;
167 let wit_outcome = flatten(client.accept(source, wit_ctx, wit_routing).await)?;
168 PickerAcceptOutcome::from_wit(wit_outcome)
169 }))
170 }
171}
172
173/// Convert the guest's `list<candidate-pair>` into the native
174/// [`CandidateBatch`]. A pair that fails to cross (malformed candidate, non-
175/// UTF-8 path) fails the whole batch as a typed error — never a silent drop.
176fn wit_pairs_to_batch(pairs: Vec<CandidatePair>) -> SourceResult<CandidateBatch> {
177 let mut batch = CandidateBatch::with_capacity(pairs.len());
178 for pair in pairs {
179 let candidate = RawCandidate::from_wit(pair.candidate)?;
180 let routing = RoutingPayload::from_wit(pair.routing)?;
181 batch.push((candidate, routing));
182 }
183 Ok(batch)
184}
185
186/// Convenience: drive registration and wrap each declared source as the
187/// `Arc<dyn PickerSourceGenerator>` the `PickerRegistry` stores.
188///
189/// Registration itself is one call per source —
190/// `registry.register_generator(source)` — keyed by its `spec().id`; provenance
191/// (`SourceLayer::Plugin`) is a grammar-contribution concern (PH7.7), not a
192/// picker-registry one. Returns a `Vec` since OR.5b: one component may declare
193/// several, which is the whole point of that slice.
194pub async fn connect_picker_sources(
195 client: PickerClient,
196) -> Result<Vec<Arc<dyn PickerSourceGenerator>>, PluginHostError> {
197 Ok(WasmPickerSource::connect_all(client)
198 .await?
199 .into_iter()
200 .map(|s| Arc::new(s) as Arc<dyn PickerSourceGenerator>)
201 .collect())
202}