Skip to main content

mlua_swarm/application/
enhance.rs

1//! `EnhanceApplication` — the dispatcher for the `POST /v1/issues`
2//! path.
3//!
4//! The entry point (`POST /v1/issues`) only does `IssueStore.create` —
5//! a synchronous enqueue. The actual dispatch is drained by a
6//! consumer loop calling `tick()`:
7//!
8//! ```text
9//! POST /v1/issues ──→ IssueStore (queue)
10//!                            ↓
11//!     consumer loop (tokio::spawn) ── tick() ──┐
12//!                                              ↓
13//!                  IssueStore.pop_pending + EnhanceSettingStore.get
14//!                                              ↓
15//!                  BPStore.read_head(setting.blueprint.id)      (fetched on use)
16//!                                              ↓
17//!                  TaskLaunchService.launch(...)                (engine bind + attach + start_task)
18//! ```
19//!
20//! Current scope:
21//!
22//! - Engine task-completion → `Issue.update_status` is a carry.
23//! - Setting `VersionSelector` (`Fixed` / `Latest` / `SemverReq`) is
24//!   a carry — today we always use `BPStore.read_head`.
25//! - The agent-selection convention is
26//!   `setting.blueprint.agents.first().name`.
27
28use super::semver_resolve::SemverResolveError;
29use super::{Application, VersionSelector};
30use crate::blueprint::store::{
31    blueprint_version, BlueprintEpoch, BlueprintId, BlueprintStore, BlueprintStoreError,
32    CommitMetadata, ContentHash, Traced,
33};
34use crate::blueprint::{AgentDef, Blueprint};
35use crate::core::errors::EngineError;
36use crate::enhance::blueprint::AG_PATCH_SPAWNER;
37use crate::service::{TaskLaunchError, TaskLaunchInput, TaskLaunchOutput, TaskLaunchService};
38use crate::store::enhance_log::{
39    EnhanceLogEntry, EnhanceLogStore, EnhanceLogStoreError, VerdictSummary,
40};
41use crate::store::enhance_setting::{
42    EnhanceSettingId, EnhanceSettingStore, EnhanceSettingStoreError,
43};
44use crate::store::issue::{IssueId, IssuePayload, IssueStatus, IssueStore, IssueStoreError};
45use crate::types::Role;
46use async_trait::async_trait;
47use std::sync::Arc;
48use std::time::Duration;
49use thiserror::Error;
50
51/// Failure modes of [`EnhanceApplication::tick`] and the internal
52/// `dispatch_one` step it wraps.
53#[derive(Debug, Error)]
54pub enum EnhanceApplicationError {
55    /// The `IssueStore` returned an error (enqueue, pop, or status
56    /// update).
57    #[error("issue store: {0}")]
58    Issue(#[from] IssueStoreError),
59
60    /// The `EnhanceSettingStore` returned an error while fetching the
61    /// active setting.
62    #[error("setting store: {0}")]
63    Setting(#[from] EnhanceSettingStoreError),
64
65    /// The `BlueprintStore` returned an error while resolving the
66    /// orbit or target Blueprint.
67    #[error("blueprint store: {0}")]
68    Bp(#[from] BlueprintStoreError),
69
70    /// The `EnhanceLogStore` returned an error while appending the
71    /// outcome entry.
72    #[error("enhance log store: {0}")]
73    Log(#[from] EnhanceLogStoreError),
74
75    /// `TaskLaunchService::launch` failed after setup succeeded.
76    #[error("launch: {0}")]
77    Launch(#[from] TaskLaunchError),
78
79    /// Serializing the target Blueprint (or a directive derived from
80    /// it) to JSON/YAML failed.
81    #[error("serialize directive: {0}")]
82    Serialize(#[from] serde_json::Error),
83
84    /// A stored version's `version_label` is not valid semver.
85    #[error("invalid semver version_label {label:?}: {source}")]
86    InvalidSemver {
87        /// The offending label string.
88        label: String,
89        /// The underlying semver parse error.
90        #[source]
91        source: semver::Error,
92    },
93
94    /// No stored version's label satisfies the setting's `SemverReq`.
95    #[error("no version matches semver req: {req}")]
96    NoMatchingVersion {
97        /// The requirement string that matched nothing.
98        req: String,
99    },
100
101    /// The engine reported an error (attach / dispatch).
102    #[error("engine: {0}")]
103    Engine(#[from] EngineError),
104
105    /// `final_ctx.commit` did not match the strict shape
106    /// `extract_commit` expects, or the committer/store hashes
107    /// disagreed.
108    #[error("commit shape: {0}")]
109    CommitShape(String),
110
111    /// The system clock reported a time before the UNIX epoch while
112    /// computing `now_ms`.
113    #[error("system time before UNIX epoch: {0}")]
114    Clock(#[from] std::time::SystemTimeError),
115
116    /// The setting carries an `EnhanceSetting::spawner` override, but the
117    /// orbit Blueprint declares no agent under the name the flow's
118    /// `Step.ref` points at. Fail loud: silently ignoring the override
119    /// would run the Blueprint's own spawner while the operator believes
120    /// the swap took effect.
121    #[error("spawner override: orbit blueprint declares no agent named {name:?}")]
122    SpawnerAgentNotFound {
123        /// The agent name the override targets (= the flow `Step.ref`).
124        name: String,
125    },
126}
127
128impl From<SemverResolveError> for EnhanceApplicationError {
129    fn from(e: SemverResolveError) -> Self {
130        match e {
131            SemverResolveError::Store(e) => EnhanceApplicationError::Bp(e),
132            SemverResolveError::InvalidSemver { label, source } => {
133                EnhanceApplicationError::InvalidSemver { label, source }
134            }
135            SemverResolveError::NoMatchingVersion { req } => {
136                EnhanceApplicationError::NoMatchingVersion { req }
137            }
138        }
139    }
140}
141
142/// Result of a single `tick`. `task_id` is gone — the flow-eval path
143/// runs many steps to completion instead of being tied to a single
144/// task id, so the entire `final_ctx` is the result. Outcomes are
145/// checked through `status`.
146#[derive(Debug, Clone)]
147pub struct TickOutcome {
148    /// The issue that was popped and dispatched this tick.
149    pub issue_id: IssueId,
150    /// The resulting status persisted to the `IssueStore`.
151    pub status: IssueStatus,
152}
153
154/// Configuration parameters for `EnhanceApplication`.
155///
156/// `ttl` moved onto `EnhanceSetting` so editing the setting acts as
157/// a hot reload. This `Config` only holds the identity information
158/// needed to stand up an Application instance.
159pub struct EnhanceApplicationConfig {
160    /// A short identifier for this Application instance (used in logs).
161    pub name: String,
162    /// The `EnhanceSetting` this instance reads on every tick.
163    pub setting_id: EnhanceSettingId,
164    /// The Operator id attached for every dispatched task.
165    pub operator_id: String,
166    /// The Operator's role for every dispatched task.
167    pub role: Role,
168}
169
170/// The `POST /v1/issues` dispatcher — enqueues via [`Application::handle`],
171/// drains via [`EnhanceApplication::tick`] / [`EnhanceApplication::run_forever`].
172pub struct EnhanceApplication {
173    name: String,
174    setting_id: EnhanceSettingId,
175    operator_id: String,
176    role: Role,
177    issue_store: Arc<dyn IssueStore>,
178    setting_store: Arc<dyn EnhanceSettingStore>,
179    bp_store: Arc<dyn BlueprintStore>,
180    log_store: Arc<dyn EnhanceLogStore>,
181    launch: Arc<TaskLaunchService>,
182}
183
184impl EnhanceApplication {
185    /// Wire up an `EnhanceApplication` from its config and store/service
186    /// dependencies.
187    pub fn new(
188        cfg: EnhanceApplicationConfig,
189        issue_store: Arc<dyn IssueStore>,
190        setting_store: Arc<dyn EnhanceSettingStore>,
191        bp_store: Arc<dyn BlueprintStore>,
192        log_store: Arc<dyn EnhanceLogStore>,
193        launch: Arc<TaskLaunchService>,
194    ) -> Self {
195        Self {
196            name: cfg.name,
197            setting_id: cfg.setting_id,
198            operator_id: cfg.operator_id,
199            role: cfg.role,
200            issue_store,
201            setting_store,
202            bp_store,
203            log_store,
204            launch,
205        }
206    }
207
208    /// The `IssueStore` this Application enqueues into and drains from.
209    pub fn issue_store(&self) -> &Arc<dyn IssueStore> {
210        &self.issue_store
211    }
212
213    /// The `BlueprintStore` used to resolve orbit/target Blueprints and
214    /// to persist Applied commits.
215    pub fn bp_store(&self) -> &Arc<dyn BlueprintStore> {
216        &self.bp_store
217    }
218
219    /// The `EnhanceLogStore` every dispatch outcome is appended to.
220    pub fn log_store(&self) -> &Arc<dyn EnhanceLogStore> {
221        &self.log_store
222    }
223
224    /// Pop one pending issue and dispatch it to the engine. Returns
225    /// `None` when nothing is pending.
226    ///
227    /// `dispatch_one` returns `Err` only for **infra faults** — store,
228    /// launch, clock, shape errors, and the like. Flow verifier denials
229    /// come back through `dispatch_one` on the `Ok` path with a
230    /// `Rejected` status, and the corresponding entry has already been
231    /// appended to `log_store` in the same commit. Even on an infra
232    /// fault, `tick` best-effort tries to update the store-side
233    /// status; if the store itself is broken the error propagates.
234    pub async fn tick(&self) -> Result<Option<TickOutcome>, EnhanceApplicationError> {
235        let Some(payload) = self.issue_store.pop_pending().await? else {
236            return Ok(None);
237        };
238        match self.dispatch_one(&payload).await {
239            Ok(status) => {
240                self.issue_store
241                    .update_status(&payload.issue_id, status.clone())
242                    .await?;
243                Ok(Some(TickOutcome {
244                    issue_id: payload.issue_id,
245                    status,
246                }))
247            }
248            Err(e) => {
249                // Infra fault: record status as Rejected, then propagate Err.
250                let reason = format!("dispatch failed: {e}");
251                self.issue_store
252                    .update_status(&payload.issue_id, IssueStatus::Rejected { reason })
253                    .await?;
254                Err(e)
255            }
256        }
257    }
258
259    /// Handle one issue as one enhance-flow completion.
260    ///
261    /// Flow:
262    /// 1. Fetch the setting (the enhance-orbit BP id, `verifier_axes`,
263    ///    and `ttl`).
264    /// 2. Resolve the orbit BP (for example the built-in
265    ///    `enhance-default` flow), then apply the setting's
266    ///    `spawner` override to it when one is declared.
267    /// 3. Resolve the target BP (`payload.blueprint_id`) — the
268    ///    object being modified, injected into `init_ctx` as
269    ///    `prev_bp`.
270    /// 4. Assemble `init_ctx` (`issue` / `prev_bp_yaml` / `prev_hash`
271    ///    / `epoch_id` / `verifiers`).
272    /// 5. Run to completion via `TaskLaunchService::launch` — pull
273    ///    `final_ctx` once every step finishes.
274    /// 6. Derive `IssueStatus` from `final_ctx.commit`; when Applied,
275    ///    persist via `bp_store.write_new`.
276    /// 7. Append a `LogEntry` to `log_store` — exactly one entry per
277    ///    outcome, Applied or Rejected.
278    async fn dispatch_one(
279        &self,
280        payload: &IssuePayload,
281    ) -> Result<IssueStatus, EnhanceApplicationError> {
282        let setting = self.setting_store.get(&self.setting_id).await?;
283
284        let mut traced_orch = self
285            .resolve_blueprint(&setting.blueprint_id, &setting.version)
286            .await?;
287        apply_spawner_override(&mut traced_orch.value, setting.spawner.as_ref())?;
288
289        let traced_target = self.bp_store.read_head(&payload.blueprint_id).await?;
290        let prev_bp_yaml = serde_yaml::to_string(&traced_target.value).map_err(|e| {
291            EnhanceApplicationError::Serialize(serde::ser::Error::custom(format!(
292                "prev_bp yaml: {e}"
293            )))
294        })?;
295        let prev_version = blueprint_version(&traced_target.value).map_err(|e| {
296            EnhanceApplicationError::Serialize(serde::ser::Error::custom(format!("prev_hash: {e}")))
297        })?;
298        let now_ms = std::time::SystemTime::now()
299            .duration_since(std::time::UNIX_EPOCH)?
300            .as_millis() as i64;
301        let epoch = BlueprintEpoch::new(payload.blueprint_id.clone(), prev_version, now_ms);
302        let prev_hash_hex = hex::encode(prev_version.0 .0);
303
304        let init_ctx = serde_json::json!({
305            "issue": {
306                "issue_id":     payload.issue_id.as_str(),
307                "blueprint_id": payload.blueprint_id.as_str(),
308                "intent":       payload.intent,
309            },
310            "prev_bp_yaml": prev_bp_yaml,
311            "prev_hash":    prev_hash_hex.clone(),
312            "epoch_id":     epoch.clone(),
313            "verifiers":    setting.verifier_axes.clone(),
314        });
315
316        let TaskLaunchOutput {
317            token: _,
318            final_ctx,
319        } = self
320            .launch
321            .launch(TaskLaunchInput::automate(
322                traced_orch.value,
323                self.operator_id.clone(),
324                self.role,
325                Duration::from_secs(setting.ttl_secs),
326                init_ctx,
327            ))
328            .await?;
329
330        // Strict commit extract (no 1-value default; missing required fields surface as Err).
331        let commit_decision = extract_commit(&final_ctx)?;
332
333        // When Applied, persist via bp_store.write_new (the core GOAL IO path).
334        let (status, log_entry) = match commit_decision {
335            CommitDecision::Applied {
336                new_bp,
337                new_version_hex,
338                rationale,
339                bump,
340                verdicts,
341            } => {
342                let patch_hash = ContentHash::from_bytes(rationale.as_bytes());
343                let metadata = CommitMetadata {
344                    epoch_id: epoch.clone(),
345                    rationale: rationale.clone(),
346                    patch_hash,
347                };
348                let new_version = self
349                    .bp_store
350                    .write_new(
351                        &payload.blueprint_id,
352                        &new_bp,
353                        std::slice::from_ref(&prev_version),
354                        metadata,
355                    )
356                    .await?;
357                let new_version_hex_actual = hex::encode(new_version.0 .0);
358                // If commit.new_version (the committer-computed hash) disagrees with the
359                // version assigned by bp_store, the canonicalisation is out of sync — Err.
360                if new_version_hex_actual != new_version_hex {
361                    return Err(EnhanceApplicationError::CommitShape(format!(
362                        "new_version mismatch: committer={new_version_hex} store={new_version_hex_actual}"
363                    )));
364                }
365                let entry = EnhanceLogEntry {
366                    issue_id: payload.issue_id.clone(),
367                    blueprint_id: payload.blueprint_id.clone(),
368                    prev_hash: prev_hash_hex.clone(),
369                    new_hash: new_version_hex_actual.clone(),
370                    intent: payload.intent.clone(),
371                    rationale: rationale.clone(),
372                    verdicts,
373                    status: "applied".into(),
374                    reasons: vec![],
375                    ts_ms: now_ms,
376                };
377                // CommitMetadata does not carry the bump label; surface it in
378                // the trace so the committer's version decision is observable.
379                tracing::info!(%bump, issue_id = %payload.issue_id, "commit bump label (not persisted in CommitMetadata)");
380                (
381                    IssueStatus::Applied {
382                        new_version: new_version_hex_actual,
383                    },
384                    entry,
385                )
386            }
387            CommitDecision::Rejected {
388                reasons,
389                rationale,
390                verdicts,
391            } => {
392                let entry = EnhanceLogEntry {
393                    issue_id: payload.issue_id.clone(),
394                    blueprint_id: payload.blueprint_id.clone(),
395                    prev_hash: prev_hash_hex.clone(),
396                    new_hash: String::new(),
397                    intent: payload.intent.clone(),
398                    rationale,
399                    verdicts,
400                    status: "rejected".into(),
401                    reasons: reasons.clone(),
402                    ts_ms: now_ms,
403                };
404                (
405                    IssueStatus::Rejected {
406                        reason: format!("verifier deny: {}", reasons.join("; ")),
407                    },
408                    entry,
409                )
410            }
411        };
412
413        self.log_store.append(log_entry).await?;
414        Ok(status)
415    }
416
417    /// Resolve a BP per the `VersionSelector`. `Latest` uses
418    /// `read_head`; `Fixed` uses `read_version`; `SemverReq` scans the
419    /// history and picks the semver-matching
420    /// `BlueprintMetadata.version_label`.
421    async fn resolve_blueprint(
422        &self,
423        bp_id: &BlueprintId,
424        selector: &VersionSelector,
425    ) -> Result<Traced<Blueprint>, EnhanceApplicationError> {
426        match selector {
427            VersionSelector::Latest => Ok(self.bp_store.read_head(bp_id).await?),
428            VersionSelector::Fixed { value } => {
429                Ok(self.bp_store.read_version(bp_id, *value).await?)
430            }
431            VersionSelector::SemverReq { req } => {
432                let v = super::semver_resolve::resolve_semver(self.bp_store.as_ref(), bp_id, req)
433                    .await?;
434                Ok(self.bp_store.read_version(bp_id, v).await?)
435            }
436        }
437    }
438
439    /// The consumer loop. At server startup, launch it with
440    /// `tokio::spawn(app.run_forever(interval))`; stop it with
441    /// `JoinHandle::abort()`.
442    ///
443    /// Behaviour:
444    ///
445    /// - `tick()` returns `Some` → immediately run another tick (burst
446    ///   drain).
447    /// - `tick()` returns `None` → sleep for `interval` (no-work
448    ///   back-off).
449    /// - `tick()` returns `Err` → log it and sleep for `interval`
450    ///   (a dispatch failure must not kill the loop).
451    pub async fn run_forever(self: Arc<Self>, interval: Duration) {
452        loop {
453            match self.tick().await {
454                Ok(Some(_)) => continue,
455                Ok(None) => tokio::time::sleep(interval).await,
456                Err(e) => {
457                    eprintln!("[{}] tick error: {e}", self.name);
458                    tokio::time::sleep(interval).await;
459                }
460            }
461        }
462    }
463}
464
465/// Input to [`EnhanceApplication::handle`] — the `POST /v1/issues` request
466/// body once decoded.
467#[derive(Debug, Clone)]
468pub struct EnhanceApplicationInput {
469    /// The Blueprint this issue proposes to modify.
470    pub blueprint_id: BlueprintId,
471    /// Free-form description of the change being requested.
472    pub intent: String,
473    /// Caller-supplied issue id, echoed back as `handle`'s `Output`.
474    pub issue_id: IssueId,
475}
476
477/// Swap the orbit Blueprint's [`AG_PATCH_SPAWNER`] agent for the
478/// definition the setting declares.
479///
480/// `spawner = None` leaves the Blueprint untouched — whatever it
481/// declares is what runs (the pre-override behaviour, byte-for-byte).
482/// `Some(def)` replaces the matching entry in `blueprint.agents` in
483/// place, which is the whole point of the knob: the spawner's execution
484/// backend can be changed without rewriting the Blueprint.
485///
486/// Two deliberate strictnesses:
487///
488/// - No agent under that name → `Err`. The flow step references the
489///   agent by name, so a missing target means the override is inert; a
490///   silent no-op here is the worst possible way for that to surface.
491/// - The swapped-in `name` is forced back to [`AG_PATCH_SPAWNER`], so a
492///   caller supplying a differently-named `AgentDef` cannot break the
493///   flow's `Step.ref` wiring.
494///
495/// The mutation is scoped to the in-memory copy used for this dispatch —
496/// nothing is written back to the `BlueprintStore`.
497fn apply_spawner_override(
498    blueprint: &mut Blueprint,
499    spawner: Option<&AgentDef>,
500) -> Result<(), EnhanceApplicationError> {
501    let Some(spawner) = spawner else {
502        return Ok(());
503    };
504    let slot = blueprint
505        .agents
506        .iter_mut()
507        .find(|a| a.name == AG_PATCH_SPAWNER)
508        .ok_or_else(|| EnhanceApplicationError::SpawnerAgentNotFound {
509            name: AG_PATCH_SPAWNER.to_string(),
510        })?;
511    let mut swapped = spawner.clone();
512    swapped.name = AG_PATCH_SPAWNER.to_string();
513    *slot = swapped;
514    Ok(())
515}
516
517/// Internal verdict produced by strictly parsing `committer.lua`'s
518/// output (`ctx.commit`).
519///
520/// Strict discipline: missing required fields or wrong types surface
521/// as `CommitShape` errors — no 1-value defaulting.
522enum CommitDecision {
523    Applied {
524        new_bp: Box<Blueprint>,
525        new_version_hex: String,
526        rationale: String,
527        bump: String,
528        verdicts: Vec<VerdictSummary>,
529    },
530    Rejected {
531        reasons: Vec<String>,
532        rationale: String,
533        verdicts: Vec<VerdictSummary>,
534    },
535}
536
537fn extract_commit(
538    final_ctx: &serde_json::Value,
539) -> Result<CommitDecision, EnhanceApplicationError> {
540    let shape_err =
541        |msg: String| -> EnhanceApplicationError { EnhanceApplicationError::CommitShape(msg) };
542
543    let commit = final_ctx
544        .get("commit")
545        .ok_or_else(|| shape_err("final_ctx missing $.commit".into()))?;
546    let committed = commit
547        .get("committed")
548        .and_then(|v| v.as_bool())
549        .ok_or_else(|| shape_err("commit.committed missing or not bool".into()))?;
550    let rationale = commit
551        .get("rationale")
552        .and_then(|v| v.as_str())
553        .ok_or_else(|| shape_err("commit.rationale missing or not string".into()))?
554        .to_string();
555    let verdicts = parse_verdicts_summary(commit)?;
556
557    if committed {
558        let new_version_hex = commit
559            .get("new_version")
560            .and_then(|v| v.as_str())
561            .ok_or_else(|| shape_err("commit.new_version missing or not string".into()))?
562            .to_string();
563        if new_version_hex.is_empty() {
564            return Err(shape_err("commit.new_version is empty (Applied)".into()));
565        }
566        let bump = commit
567            .get("bump")
568            .and_then(|v| v.as_str())
569            .ok_or_else(|| shape_err("commit.bump missing or not string".into()))?
570            .to_string();
571        let new_bp_json = commit
572            .get("new_bp_json")
573            .ok_or_else(|| shape_err("commit.new_bp_json missing".into()))?
574            .clone();
575        let new_bp: Box<Blueprint> = serde_json::from_value(new_bp_json)
576            .map_err(|e| shape_err(format!("commit.new_bp_json deserialize: {e}")))?;
577        Ok(CommitDecision::Applied {
578            new_bp,
579            new_version_hex,
580            rationale,
581            bump,
582            verdicts,
583        })
584    } else {
585        let reasons_arr = commit
586            .get("reasons")
587            .and_then(|v| v.as_array())
588            .ok_or_else(|| shape_err("commit.reasons missing or not array".into()))?;
589        let reasons: Vec<String> = reasons_arr
590            .iter()
591            .map(|v| {
592                v.as_str()
593                    .map(|s| s.to_string())
594                    .ok_or_else(|| shape_err("commit.reasons[] contains non-string element".into()))
595            })
596            .collect::<Result<_, _>>()?;
597        if reasons.is_empty() {
598            return Err(shape_err(
599                "commit.reasons is empty (Rejected requires at least 1)".into(),
600            ));
601        }
602        Ok(CommitDecision::Rejected {
603            reasons,
604            rationale,
605            verdicts,
606        })
607    }
608}
609
610fn parse_verdicts_summary(
611    commit: &serde_json::Value,
612) -> Result<Vec<VerdictSummary>, EnhanceApplicationError> {
613    let arr = commit
614        .get("verdicts_summary")
615        .and_then(|v| v.as_array())
616        .ok_or_else(|| {
617            EnhanceApplicationError::CommitShape(
618                "commit.verdicts_summary missing or not array".into(),
619            )
620        })?;
621    arr.iter()
622        .map(|v| {
623            let axis = v
624                .get("axis")
625                .and_then(|x| x.as_str())
626                .ok_or_else(|| {
627                    EnhanceApplicationError::CommitShape("verdicts_summary[].axis missing".into())
628                })?
629                .to_string();
630            let status = v
631                .get("status")
632                .and_then(|x| x.as_str())
633                .ok_or_else(|| {
634                    EnhanceApplicationError::CommitShape("verdicts_summary[].status missing".into())
635                })?
636                .to_string();
637            let detail = match status.as_str() {
638                "pass" => v
639                    .get("evidence")
640                    .and_then(|x| x.as_str())
641                    .ok_or_else(|| {
642                        EnhanceApplicationError::CommitShape(
643                            "verdicts_summary[].evidence missing for pass".into(),
644                        )
645                    })?
646                    .to_string(),
647                "deny" => v
648                    .get("reason")
649                    .and_then(|x| x.as_str())
650                    .ok_or_else(|| {
651                        EnhanceApplicationError::CommitShape(
652                            "verdicts_summary[].reason missing for deny".into(),
653                        )
654                    })?
655                    .to_string(),
656                other => {
657                    return Err(EnhanceApplicationError::CommitShape(format!(
658                        "verdicts_summary[].status must be pass|deny, got {other}"
659                    )))
660                }
661            };
662            Ok(VerdictSummary {
663                axis,
664                status,
665                detail,
666            })
667        })
668        .collect()
669}
670
671#[async_trait]
672impl Application for EnhanceApplication {
673    type Input = EnhanceApplicationInput;
674    type Output = IssueId;
675    type Error = EnhanceApplicationError;
676
677    fn name(&self) -> &str {
678        &self.name
679    }
680
681    /// Just push the issue onto `IssueStore` — a synchronous enqueue;
682    /// dispatch is entirely the consumer loop's job.
683    async fn handle(&self, input: Self::Input) -> Result<Self::Output, Self::Error> {
684        self.issue_store
685            .create(IssuePayload {
686                issue_id: input.issue_id.clone(),
687                blueprint_id: input.blueprint_id,
688                intent: input.intent,
689            })
690            .await?;
691        Ok(input.issue_id)
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use crate::blueprint::AgentKind;
699    use crate::enhance::blueprint::default_blueprint;
700
701    fn spawner_of(bp: &Blueprint) -> &AgentDef {
702        bp.agents
703            .iter()
704            .find(|a| a.name == AG_PATCH_SPAWNER)
705            .expect("blueprint declares a patch-spawner agent")
706    }
707
708    fn subprocess_spawner(name: &str) -> AgentDef {
709        serde_json::from_value(serde_json::json!({
710            "name": name,
711            "kind": "subprocess",
712            "spec": { "program": "true", "args": [] },
713        }))
714        .expect("literal is a valid AgentDef")
715    }
716
717    #[test]
718    fn no_override_keeps_the_blueprints_own_spawner() {
719        let mut bp = default_blueprint();
720        let before = spawner_of(&bp).clone();
721        apply_spawner_override(&mut bp, None).unwrap();
722        assert_eq!(spawner_of(&bp), &before);
723        assert_eq!(spawner_of(&bp).kind, AgentKind::AgentBlock);
724    }
725
726    #[test]
727    fn override_swaps_the_spawner_and_forces_the_referenced_name() {
728        let mut bp = default_blueprint();
729        let agents_before = bp.agents.len();
730        // A deliberately mis-named override: the flow references the
731        // agent by name, so the swap must rename it back.
732        let def = subprocess_spawner("my-own-spawner");
733        apply_spawner_override(&mut bp, Some(&def)).unwrap();
734
735        let swapped = spawner_of(&bp);
736        assert_eq!(swapped.kind, AgentKind::Subprocess);
737        assert_eq!(swapped.name, AG_PATCH_SPAWNER);
738        assert_eq!(swapped.spec, def.spec);
739        // A swap, not an insert — the other three agents are untouched.
740        assert_eq!(bp.agents.len(), agents_before);
741        assert!(!bp.agents.iter().any(|a| a.name == "my-own-spawner"));
742    }
743
744    #[test]
745    fn override_without_a_matching_agent_fails_loud() {
746        let mut bp = default_blueprint();
747        bp.agents.retain(|a| a.name != AG_PATCH_SPAWNER);
748        let err = apply_spawner_override(&mut bp, Some(&subprocess_spawner(AG_PATCH_SPAWNER)))
749            .expect_err("a missing override target must not be ignored");
750        assert!(matches!(
751            err,
752            EnhanceApplicationError::SpawnerAgentNotFound { ref name } if name == AG_PATCH_SPAWNER
753        ));
754        assert!(err.to_string().contains(AG_PATCH_SPAWNER));
755    }
756}