1use 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#[derive(Debug, Error)]
54pub enum EnhanceApplicationError {
55 #[error("issue store: {0}")]
58 Issue(#[from] IssueStoreError),
59
60 #[error("setting store: {0}")]
63 Setting(#[from] EnhanceSettingStoreError),
64
65 #[error("blueprint store: {0}")]
68 Bp(#[from] BlueprintStoreError),
69
70 #[error("enhance log store: {0}")]
73 Log(#[from] EnhanceLogStoreError),
74
75 #[error("launch: {0}")]
77 Launch(#[from] TaskLaunchError),
78
79 #[error("serialize directive: {0}")]
82 Serialize(#[from] serde_json::Error),
83
84 #[error("invalid semver version_label {label:?}: {source}")]
86 InvalidSemver {
87 label: String,
89 #[source]
91 source: semver::Error,
92 },
93
94 #[error("no version matches semver req: {req}")]
96 NoMatchingVersion {
97 req: String,
99 },
100
101 #[error("engine: {0}")]
103 Engine(#[from] EngineError),
104
105 #[error("commit shape: {0}")]
109 CommitShape(String),
110
111 #[error("system time before UNIX epoch: {0}")]
114 Clock(#[from] std::time::SystemTimeError),
115
116 #[error("spawner override: orbit blueprint declares no agent named {name:?}")]
122 SpawnerAgentNotFound {
123 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#[derive(Debug, Clone)]
147pub struct TickOutcome {
148 pub issue_id: IssueId,
150 pub status: IssueStatus,
152}
153
154pub struct EnhanceApplicationConfig {
160 pub name: String,
162 pub setting_id: EnhanceSettingId,
164 pub operator_id: String,
166 pub role: Role,
168}
169
170pub 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 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 pub fn issue_store(&self) -> &Arc<dyn IssueStore> {
210 &self.issue_store
211 }
212
213 pub fn bp_store(&self) -> &Arc<dyn BlueprintStore> {
216 &self.bp_store
217 }
218
219 pub fn log_store(&self) -> &Arc<dyn EnhanceLogStore> {
221 &self.log_store
222 }
223
224 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 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 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 let commit_decision = extract_commit(&final_ctx)?;
332
333 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 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 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 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 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#[derive(Debug, Clone)]
468pub struct EnhanceApplicationInput {
469 pub blueprint_id: BlueprintId,
471 pub intent: String,
473 pub issue_id: IssueId,
475}
476
477fn 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
517enum 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 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 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 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}