Skip to main content

mlua_swarm/store/output/
mod.rs

1//! # output_store — Data-plane support layer
2//!
3//! **Module built on the Data (Big Response handling) vs. Domain (Flow / verdict)
4//! separation axis.** Owns the machinery for shuttling big response bodies
5//! (4k-token-scale text / file paths / blobs) between SubAgents, and stays out
6//! of the Engine's flow control (the BLOCKED/PASS `if`/`else` verdicts). The
7//! Domain path — `engine.rs` `submit_output` / `output_tail` / dispatch — is
8//! left untouched.
9//!
10//! ## Why (Data / Domain separation)
11//!
12//! The Engine's `output_store` `HashMap` plus `Final.ok` extraction in
13//! the dispatch path already covers the Domain (verdict flow). Pushing Big Response
14//! payloads (LLM answers of several kilotokens, intermediate files, large
15//! blobs) through the same path floods MainAI context after only a handful of
16//! SubAgents and turns the return-text channel into a file-path junk drawer.
17//!
18//! Resolution: **MainAI only carries `OutputRef` (a small `out_id`); this
19//! module owns the big bodies.** The Domain (flow control) stays inside the
20//! Engine, the Data plane (Big Response handling) is completed by this module
21//! and its paired `SpawnerLayer`s, and the two do not interfere.
22//!
23//! Note that the Sub/Main-Agent split is not just a context-size trick — it is
24//! a support scaffold for MainAI, which is what keeps a pure Flow orchestrator
25//! from becoming either rigid or brittle. Data-plane offloading is one of the
26//! things that scaffold needs to work.
27//!
28//! ## Architecture (three lifecycle axes)
29//!
30//! Data handling is cut along three lifecycles. Mixing them collapses into
31//! Agent hardcoding, non-portability, or unmanaged growth:
32//!
33//! - **LC1 — Agent authoring (Swarm-independent):** the Agent contract in
34//!   `Agent.md` speaks in terms of `$IN_REFS` and a single EMIT tool. It does
35//!   not know Swarm-specific paths or ids.
36//! - **LC2 — Agent execution (Swarm = runtime environment):** at spawn time
37//!   the runtime injects env (`$IN_REFS` = previous `out_id` list, EMIT tool
38//!   plus token). The SubAgent POSTs directly to the store, bypassing MainAgent.
39//! - **LC3 — Swarm management (this module = Data owner):** intake (EMIT) →
40//!   allocate `OutputRef` → register → optional disk persistence. `get(out_id)`
41//!   feeds the next spawn's `IN_REFS`.
42//!
43//! ## Discipline
44//!
45//! - **SubAgent → MainAgent direct return is forbidden.** Big bodies never
46//!   ride the return text; MainAgent only holds an `OutputRef` (small id).
47//! - **Write path is the EMIT tool, once.** No file-side channel, no smuggling
48//!   through return text — the goal is to remove the "LLM forgets at the tail
49//!   of the task" failure mode by construction.
50//! - **Same-shape `SpawnerLayer` pattern.** All intake / inject flows through
51//!   `middleware/sink.rs` / `middleware/input_inject.rs` (both `SpawnerLayer`
52//!   impls). Same shape as `AgentResolver` / `ProjectNameAliasLayer`.
53//! - **Multi-in / multi-out is the default assumption**, even when the current
54//!   traffic is one or two refs. All handling goes through the sink pattern.
55//! - **Zero change to engine core.** Only additive Data-plane wiring; the
56//!   Domain path (`submit_output` / `output_tail` / dispatch verdict) stays as
57//!   it was.
58//!
59//! ## History
60//!
61//! The former standalone `mlua-swarm-output-store` crate was folded into
62//! `engine-core` as a module (a Repository/Store sibling to `issue_store` /
63//! `blueprint_store`). Independent distribution, separate ownership, and
64//! dependency isolation did not justify the crate boundary. The duplicated
65//! `OutputEvent` / `ContentRef` in `worker/output.rs` were absorbed here
66//! (canonical), and `worker/output.rs` was narrowed to re-exports plus the
67//! engine-specific `OutputSink` / `EngineSink`.
68
69pub mod sqlite;
70pub use sqlite::SqliteOutputStore;
71
72use async_trait::async_trait;
73use serde::{Deserialize, Serialize};
74use serde_json::Value;
75use std::collections::HashMap;
76use std::path::PathBuf;
77use std::sync::Arc;
78use thiserror::Error;
79use tokio::sync::Mutex;
80
81/// Errors surfaced by the output store layer.
82#[derive(Debug, Error)]
83pub enum OutputStoreError {
84    /// The given `out_id` is not present in the store.
85    #[error("output not found: {0}")]
86    NotFound(String),
87    /// Internal invariant violation (i.e. an implementation bug).
88    #[error("internal: {0}")]
89    Internal(String),
90}
91
92/// Reference handle for a stored output (the id carried by `IN_REFS` at LC2).
93#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
94pub struct OutputRef(
95    /// The `out-`-prefixed short id string.
96    pub String,
97);
98
99impl OutputRef {
100    /// Allocate a fresh short reference (`out-` + 10 hex chars).
101    ///
102    /// Uses the same in-process-unique id form as `wh-` worker handles
103    /// (`types::uid_hex`). Short ids are a deliberate trade: legible / cheap
104    /// to carry in prompts, not unguessable — access control is the auth
105    /// gate's job, not the id's.
106    pub fn new() -> Self {
107        OutputRef(format!("out-{}", crate::types::uid_hex(5)))
108    }
109}
110
111impl Default for OutputRef {
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117/// A single output event submitted from a worker into the engine.
118///
119/// The only event type after `WorkerResult` was folded into this enum. The
120/// `SpawnerAdapter` is responsible for turning the wire form (stdout / NDJSON /
121/// file path / IPC) into this typed representation at the boundary.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(tag = "type", rename_all = "snake_case")]
124pub enum OutputEvent {
125    /// Progress marker (state name / note); carries no payload.
126    Progress {
127        /// Stage name.
128        stage: String,
129        /// Optional note.
130        note: Option<String>,
131    },
132    /// Streaming chunk (LLM tokens, partial JSON, etc.).
133    Partial {
134        /// The chunk itself.
135        chunk: ContentRef,
136    },
137    /// Named artifact (file / blob / intermediate product).
138    Artifact {
139        /// Artifact name.
140        name: String,
141        /// Artifact body.
142        content: ContentRef,
143    },
144    /// Terminal event (the former `WorkerResult`). Exactly one per attempt,
145    /// emitted last.
146    Final {
147        /// Output body.
148        content: ContentRef,
149        /// Transport-level success flag. This is the only piece of information
150        /// the engine's dispatch path consults for flow control. Domain-level
151        /// verdicts (e.g. `"blocked"`) live as plain data inside `content` and
152        /// are consumed by Flow.ir conds (`Eq($.<step>.verdict, Lit(..))`).
153        ok: bool,
154    },
155}
156
157/// How content travels — inline value or file path. Streaming is not carried
158/// as its own variant in this iteration.
159///
160/// The `SpawnerAdapter` picks the appropriate variant at the boundary. When
161/// metadata is unavailable it is acceptable to fall back to `Inline` with the
162/// raw value, prioritising basic functionality over metadata fidelity.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(tag = "kind", rename_all = "snake_case")]
165pub enum ContentRef {
166    /// Inline JSON. Kilobyte-scale, the default for structured data.
167    Inline {
168        /// JSON body.
169        value: Value,
170    },
171    /// File-path handoff for large / binary / artifact content. File
172    /// ownership and cleanup belong to the engine side (carry); the spawner
173    /// only hands the path over.
174    FileRef {
175        /// File path.
176        path: PathBuf,
177        /// MIME hint.
178        mime: Option<String>,
179        /// Size hint in bytes.
180        size_hint: Option<u64>,
181    },
182}
183
184impl ContentRef {
185    /// Wrap a `serde_json::Value` as an `Inline` content ref.
186    pub fn inline(value: Value) -> Self {
187        ContentRef::Inline { value }
188    }
189
190    /// Wrap raw text as an `Inline` content ref (the common path for a
191    /// `ProcessSpawner` running in plain mode).
192    pub fn inline_text(text: impl Into<String>) -> Self {
193        ContentRef::Inline {
194            value: Value::String(text.into()),
195        }
196    }
197
198    /// `FileRef` helper. Fill in `mime` / `size_hint` when the spawner knows
199    /// them.
200    pub fn file_ref(
201        path: impl Into<PathBuf>,
202        mime: Option<String>,
203        size_hint: Option<u64>,
204    ) -> Self {
205        ContentRef::FileRef {
206            path: path.into(),
207            mime,
208            size_hint,
209        }
210    }
211}
212
213/// Metadata for one registered output.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct OutputRecord {
216    /// Allocated id.
217    pub id: OutputRef,
218    /// Producing task.
219    pub task_id: String,
220    /// Attempt number.
221    pub attempt: u32,
222    /// Producing agent name.
223    pub producer_agent: String,
224    /// The event itself.
225    pub event: OutputEvent,
226    /// Parent output refs (chained ids received via handoff).
227    pub parent_refs: Vec<OutputRef>,
228}
229
230/// The LC3 (Swarm management) interface.
231///
232/// Backing implementations are pluggable (in-memory, SQLite, filesystem,
233/// etc.). The MVP ships only the in-memory backend; SQLite / filesystem
234/// backends are a future carry.
235#[async_trait]
236pub trait OutputStore: Send + Sync {
237    /// Intake an event, allocate an id, and register the record. Returns the
238    /// freshly allocated ref.
239    async fn append(
240        &self,
241        task_id: &str,
242        attempt: u32,
243        producer_agent: &str,
244        event: OutputEvent,
245        parent_refs: Vec<OutputRef>,
246    ) -> Result<OutputRef, OutputStoreError>;
247
248    /// Look up a record by id (LC2 `IN_REFS` resolution — the value handed
249    /// to the next spawn on handoff).
250    async fn get(&self, id: &OutputRef) -> Result<OutputRecord, OutputStoreError>;
251
252    /// Look up the **latest** record emitted under the given producer name
253    /// (`out_name` addressing — the logical, agent-based sibling of `get`).
254    /// Names are producer-scoped, not task-scoped: the newest emit wins.
255    async fn get_latest_by_name(&self, name: &str) -> Result<OutputRecord, OutputStoreError>;
256
257    /// GH #23 Layer 2 — the Run-scoped sibling of [`Self::get_latest_by_name`].
258    /// Looks up the **latest** record emitted under `name`, restricted to
259    /// one `(task_id, attempt)` run. [`Self::get_latest_by_name`] resolves
260    /// across every Run the store has ever seen, so two concurrently
261    /// dispatched Runs that happen to share a producer name race each
262    /// other (documented as a `KNOWN LIMITATION` in
263    /// `crates/mlua-swarm-server/src/projection.rs`); this method closes
264    /// that race by construction — the newest emit for `name` *inside*
265    /// `(task_id, attempt)` wins, other Runs' emits under the same name
266    /// are invisible to it.
267    async fn get_latest_by_name_in_run(
268        &self,
269        task_id: &str,
270        attempt: u32,
271        name: &str,
272    ) -> Result<OutputRecord, OutputStoreError>;
273
274    /// List every record for a given `(task_id, attempt)` pair. Used where
275    /// the dispatch path pulls the verdict view.
276    async fn list_for_attempt(
277        &self,
278        task_id: &str,
279        attempt: u32,
280    ) -> Result<Vec<OutputRecord>, OutputStoreError>;
281}
282
283/// MVP implementation — in-memory, and the default for tests and prototyping.
284///
285/// Production deployments swap in a SQLite or filesystem backend (carry).
286#[derive(Debug, Default, Clone)]
287pub struct InMemoryOutputStore {
288    inner: Arc<Mutex<InMemoryInner>>,
289}
290
291#[derive(Debug, Default)]
292struct InMemoryInner {
293    by_id: HashMap<OutputRef, OutputRecord>,
294    by_attempt: HashMap<(String, u32), Vec<OutputRef>>,
295    /// producer_agent → emitted refs in insertion order (last = latest).
296    by_name: HashMap<String, Vec<OutputRef>>,
297    /// `(task_id, attempt, producer_agent)` → emitted refs in insertion
298    /// order (last = latest) — the Run-scoped sibling of `by_name` (GH #23
299    /// Layer 2, backs [`OutputStore::get_latest_by_name_in_run`]). Same
300    /// shape as `by_attempt` plus the name dimension, so a producer name
301    /// shared by two concurrent `(task_id, attempt)` Runs never
302    /// cross-resolves.
303    by_name_run: HashMap<(String, u32, String), Vec<OutputRef>>,
304}
305
306impl InMemoryOutputStore {
307    /// Construct a fresh, empty store.
308    pub fn new() -> Self {
309        Self::default()
310    }
311}
312
313#[async_trait]
314impl OutputStore for InMemoryOutputStore {
315    async fn append(
316        &self,
317        task_id: &str,
318        attempt: u32,
319        producer_agent: &str,
320        event: OutputEvent,
321        parent_refs: Vec<OutputRef>,
322    ) -> Result<OutputRef, OutputStoreError> {
323        let id = OutputRef::new();
324        let record = OutputRecord {
325            id: id.clone(),
326            task_id: task_id.to_string(),
327            attempt,
328            producer_agent: producer_agent.to_string(),
329            event,
330            parent_refs,
331        };
332        let mut guard = self.inner.lock().await;
333        guard.by_id.insert(id.clone(), record);
334        guard
335            .by_attempt
336            .entry((task_id.to_string(), attempt))
337            .or_default()
338            .push(id.clone());
339        guard
340            .by_name
341            .entry(producer_agent.to_string())
342            .or_default()
343            .push(id.clone());
344        guard
345            .by_name_run
346            .entry((task_id.to_string(), attempt, producer_agent.to_string()))
347            .or_default()
348            .push(id.clone());
349        Ok(id)
350    }
351
352    async fn get(&self, id: &OutputRef) -> Result<OutputRecord, OutputStoreError> {
353        let guard = self.inner.lock().await;
354        guard
355            .by_id
356            .get(id)
357            .cloned()
358            .ok_or_else(|| OutputStoreError::NotFound(id.0.clone()))
359    }
360
361    async fn get_latest_by_name(&self, name: &str) -> Result<OutputRecord, OutputStoreError> {
362        let guard = self.inner.lock().await;
363        let latest = guard
364            .by_name
365            .get(name)
366            .and_then(|ids| ids.last())
367            .ok_or_else(|| OutputStoreError::NotFound(name.to_string()))?;
368        guard
369            .by_id
370            .get(latest)
371            .cloned()
372            .ok_or_else(|| OutputStoreError::Internal(format!("name index dangling: {name}")))
373    }
374
375    async fn get_latest_by_name_in_run(
376        &self,
377        task_id: &str,
378        attempt: u32,
379        name: &str,
380    ) -> Result<OutputRecord, OutputStoreError> {
381        let guard = self.inner.lock().await;
382        let key = (task_id.to_string(), attempt, name.to_string());
383        let latest = guard
384            .by_name_run
385            .get(&key)
386            .and_then(|ids| ids.last())
387            .ok_or_else(|| OutputStoreError::NotFound(format!("{task_id}/{attempt}/{name}")))?;
388        guard.by_id.get(latest).cloned().ok_or_else(|| {
389            OutputStoreError::Internal(format!(
390                "name-in-run index dangling: {task_id}/{attempt}/{name}"
391            ))
392        })
393    }
394
395    async fn list_for_attempt(
396        &self,
397        task_id: &str,
398        attempt: u32,
399    ) -> Result<Vec<OutputRecord>, OutputStoreError> {
400        let guard = self.inner.lock().await;
401        let ids = guard
402            .by_attempt
403            .get(&(task_id.to_string(), attempt))
404            .cloned()
405            .unwrap_or_default();
406        let mut out = Vec::with_capacity(ids.len());
407        for id in ids {
408            if let Some(r) = guard.by_id.get(&id) {
409                out.push(r.clone());
410            }
411        }
412        Ok(out)
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[tokio::test]
421    async fn append_then_get_roundtrip() {
422        let store = InMemoryOutputStore::new();
423        let event = OutputEvent::Final {
424            content: ContentRef::Inline {
425                value: Value::String("hello".into()),
426            },
427            ok: true,
428        };
429        let id = store
430            .append("task-1", 1, "agent-a", event.clone(), vec![])
431            .await
432            .expect("append");
433        let got = store.get(&id).await.expect("get");
434        assert_eq!(got.id, id);
435        assert_eq!(got.task_id, "task-1");
436        assert_eq!(got.attempt, 1);
437        assert_eq!(got.producer_agent, "agent-a");
438        match got.event {
439            OutputEvent::Final { ok, .. } => assert!(ok),
440            _ => panic!("wrong event variant"),
441        }
442    }
443
444    #[tokio::test]
445    async fn list_for_attempt_orders_by_insertion() {
446        let store = InMemoryOutputStore::new();
447        let e1 = OutputEvent::Progress {
448            stage: "s1".into(),
449            note: None,
450        };
451        let e2 = OutputEvent::Progress {
452            stage: "s2".into(),
453            note: None,
454        };
455        let id1 = store.append("t", 1, "a", e1, vec![]).await.expect("append");
456        let id2 = store.append("t", 1, "a", e2, vec![]).await.expect("append");
457        let list = store.list_for_attempt("t", 1).await.expect("list");
458        assert_eq!(list.len(), 2);
459        assert_eq!(list[0].id, id1);
460        assert_eq!(list[1].id, id2);
461    }
462
463    #[tokio::test]
464    async fn out_ref_is_short_prefixed_form() {
465        let r = OutputRef::new();
466        assert!(r.0.starts_with("out-"), "prefix: {}", r.0);
467        let hex = &r.0["out-".len()..];
468        assert_eq!(hex.len(), 10, "10 hex chars: {}", r.0);
469        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()), "hex: {}", r.0);
470    }
471
472    #[tokio::test]
473    async fn get_latest_by_name_returns_newest_emit() {
474        let store = InMemoryOutputStore::new();
475        let e = |s: &str| OutputEvent::Progress {
476            stage: s.into(),
477            note: None,
478        };
479        store
480            .append("t", 1, "agent-a", e("first"), vec![])
481            .await
482            .expect("append 1");
483        let id2 = store
484            .append("t2", 1, "agent-a", e("second"), vec![])
485            .await
486            .expect("append 2");
487        // no producer bleed-through between attempts
488        store
489            .append("t", 1, "agent-b", e("other"), vec![])
490            .await
491            .expect("append 3");
492        let got = store.get_latest_by_name("agent-a").await.expect("by name");
493        assert_eq!(got.id, id2, "latest emit wins");
494        assert_eq!(got.task_id, "t2");
495    }
496
497    #[tokio::test]
498    async fn get_latest_by_name_unknown_returns_not_found() {
499        let store = InMemoryOutputStore::new();
500        let err = store.get_latest_by_name("nobody").await.unwrap_err();
501        assert!(matches!(err, OutputStoreError::NotFound(_)));
502    }
503
504    #[tokio::test]
505    async fn get_latest_by_name_in_run_does_not_cross_resolve_between_runs() {
506        let store = InMemoryOutputStore::new();
507        let e = |s: &str| OutputEvent::Progress {
508            stage: s.into(),
509            note: None,
510        };
511        let id_t1 = store
512            .append("t1", 1, "same-producer", e("run-1"), vec![])
513            .await
514            .expect("append t1");
515        let id_t2 = store
516            .append("t2", 1, "same-producer", e("run-2"), vec![])
517            .await
518            .expect("append t2");
519
520        let got_t1 = store
521            .get_latest_by_name_in_run("t1", 1, "same-producer")
522            .await
523            .expect("run t1 lookup");
524        assert_eq!(got_t1.id, id_t1, "must not cross-resolve to t2's emit");
525
526        let got_t2 = store
527            .get_latest_by_name_in_run("t2", 1, "same-producer")
528            .await
529            .expect("run t2 lookup");
530        assert_eq!(got_t2.id, id_t2, "must not cross-resolve to t1's emit");
531    }
532
533    #[tokio::test]
534    async fn get_latest_by_name_in_run_returns_newest_within_run() {
535        let store = InMemoryOutputStore::new();
536        let e = |s: &str| OutputEvent::Progress {
537            stage: s.into(),
538            note: None,
539        };
540        store
541            .append("t", 1, "p", e("first"), vec![])
542            .await
543            .expect("append 1");
544        let id2 = store
545            .append("t", 1, "p", e("second"), vec![])
546            .await
547            .expect("append 2");
548        let got = store
549            .get_latest_by_name_in_run("t", 1, "p")
550            .await
551            .expect("lookup");
552        assert_eq!(got.id, id2, "latest emit within the run wins");
553    }
554
555    #[tokio::test]
556    async fn get_latest_by_name_in_run_wrong_run_returns_not_found() {
557        let store = InMemoryOutputStore::new();
558        store
559            .append(
560                "t",
561                1,
562                "same-producer",
563                OutputEvent::Progress {
564                    stage: "x".into(),
565                    note: None,
566                },
567                vec![],
568            )
569            .await
570            .expect("append");
571        // Right name, wrong attempt — must not fall back to a different run.
572        let err = store
573            .get_latest_by_name_in_run("t", 2, "same-producer")
574            .await
575            .unwrap_err();
576        assert!(matches!(err, OutputStoreError::NotFound(_)));
577    }
578
579    #[tokio::test]
580    async fn get_not_found_returns_error() {
581        let store = InMemoryOutputStore::new();
582        let missing = OutputRef("missing".into());
583        let err = store.get(&missing).await.unwrap_err();
584        assert!(matches!(err, OutputStoreError::NotFound(_)));
585    }
586
587    #[tokio::test]
588    async fn parent_refs_are_persisted() {
589        let store = InMemoryOutputStore::new();
590        let parent = OutputRef::new();
591        let event = OutputEvent::Final {
592            content: ContentRef::Inline { value: Value::Null },
593            ok: true,
594        };
595        let id = store
596            .append("t", 1, "a", event, vec![parent.clone()])
597            .await
598            .expect("append");
599        let got = store.get(&id).await.expect("get");
600        assert_eq!(got.parent_refs, vec![parent]);
601    }
602}