Skip to main content

stasis/application/runtime/
concurrent_pattern_job_handler.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use chrono::Utc;
5use serde_json::json;
6
7use crate::application::orchestration::runtime_job_payloads::{
8    ConcurrentBranchJobPayload, ConcurrentPatternJobPayload,
9};
10use crate::application::orchestration::concurrent_pattern_pipeline::{
11    ConcurrentPatternBranch, ConcurrentPatternExecutionRequest, ConcurrentPatternPipeline,
12};
13use crate::application::orchestration::prompt_pipeline::PromptExecutionPipeline;
14use crate::application::runtime::in_memory_runtime::{JobExecutionOutcome, JobHandler};
15use crate::domain::errors::Result;
16use crate::domain::runtime::job::Job;
17use crate::domain::runtime::thread::{NewThread, NewThreadEvent, ThreadMergeMetadata};
18use crate::ports::outbound::ai_chat_client::AiChatClient;
19use crate::ports::outbound::runtime::thread_store::ThreadStore;
20
21pub struct ConcurrentPatternJobHandler {
22    pipeline: ConcurrentPatternPipeline,
23    thread_store: Option<Arc<dyn ThreadStore>>,
24}
25
26impl ConcurrentPatternJobHandler {
27    pub fn new(chat_client: Arc<dyn AiChatClient>) -> Self {
28        Self::new_with_thread_store(chat_client, None)
29    }
30
31    pub fn new_with_thread_store(
32        chat_client: Arc<dyn AiChatClient>,
33        thread_store: Option<Arc<dyn ThreadStore>>,
34    ) -> Self {
35        let prompt_pipeline = PromptExecutionPipeline::new(chat_client);
36        Self {
37            pipeline: ConcurrentPatternPipeline::new(prompt_pipeline),
38            thread_store,
39        }
40    }
41
42    async fn ensure_thread(
43        &self,
44        thread_id: &str,
45        parent_thread_id: Option<String>,
46        branch_label: Option<String>,
47        now: chrono::DateTime<Utc>,
48    ) {
49        let Some(store) = &self.thread_store else {
50            return;
51        };
52
53        let exists = store.get_thread(thread_id).await.ok().flatten().is_some();
54        if exists {
55            return;
56        }
57
58        let _ = store
59            .create_thread(NewThread {
60                thread_id: thread_id.to_string(),
61                parent_thread_id,
62                branch_label,
63                created_at: now,
64            })
65            .await;
66    }
67
68    async fn append_thread_event(
69        &self,
70        event_id: String,
71        thread_id: &str,
72        event_kind: &str,
73        payload_ref: String,
74        occurred_at: chrono::DateTime<Utc>,
75    ) {
76        let Some(store) = &self.thread_store else {
77            return;
78        };
79
80        let _ = store
81            .append_event(NewThreadEvent {
82                event_id,
83                thread_id: thread_id.to_string(),
84                event_kind: event_kind.to_string(),
85                payload_ref,
86                occurred_at,
87            })
88            .await;
89    }
90
91    fn parse_payload(raw: &str) -> std::result::Result<ConcurrentPatternJobPayload, String> {
92        let payload: ConcurrentPatternJobPayload = serde_json::from_str(raw).map_err(|err| {
93            format!("policy violation: invalid concurrent-pattern payload json: {err}")
94        })?;
95
96        if payload.initial_user_prompt.trim().is_empty() {
97            return Err(
98                "policy violation: concurrent-pattern payload.initial_user_prompt must be non-empty"
99                    .to_string(),
100            );
101        }
102        if payload.branches.is_empty() {
103            return Err(
104                "policy violation: concurrent-pattern payload.branches must include at least one branch"
105                    .to_string(),
106            );
107        }
108
109        for branch in &payload.branches {
110            Self::validate_branch(branch)?;
111        }
112
113        Ok(payload)
114    }
115
116    fn validate_branch(branch: &ConcurrentBranchJobPayload) -> std::result::Result<(), String> {
117        if branch.branch_id.trim().is_empty() {
118            return Err(
119                "policy violation: concurrent-pattern payload.branches[].branch_id must be non-empty"
120                    .to_string(),
121            );
122        }
123        if branch.user_prompt_template.trim().is_empty() {
124            return Err(
125                "policy violation: concurrent-pattern payload.branches[].user_prompt_template must be non-empty"
126                    .to_string(),
127            );
128        }
129
130        Ok(())
131    }
132
133    fn build_failure(message: String) -> JobExecutionOutcome {
134        let diagnostics = json!({
135            "provider": "stasis-orchestration-concurrent",
136            "status": "failure",
137            "pattern": "concurrent",
138            "guardrail_code": "POLICY_VIOLATION",
139            "policy_reason": &message,
140        })
141        .to_string();
142
143        JobExecutionOutcome::FatalFailure {
144            message,
145            execution_id: None,
146            diagnostics: Some(diagnostics),
147        }
148    }
149}
150
151#[async_trait]
152impl JobHandler for ConcurrentPatternJobHandler {
153    fn job_type(&self) -> &'static str {
154        "workflow.stasis.orchestration.concurrent"
155    }
156
157    async fn execute(&self, job: &Job) -> Result<JobExecutionOutcome> {
158        let payload = match Self::parse_payload(&job.payload_ref) {
159            Ok(payload) => payload,
160            Err(message) => return Ok(Self::build_failure(message)),
161        };
162
163        let ConcurrentPatternJobPayload {
164            thread_id,
165            initial_user_prompt,
166            policy_profile,
167            model_hint,
168            merge_strategy,
169            branches,
170        } = payload;
171
172        let now = Utc::now();
173        let thread_id = thread_id.unwrap_or_else(|| job.correlation_id.clone());
174        self.ensure_thread(&thread_id, None, Some("concurrent".to_string()), now)
175            .await;
176        self.append_thread_event(
177            format!("{}:concurrent:start", job.id),
178            &thread_id,
179            "orchestration.concurrent.started",
180            initial_user_prompt.clone(),
181            now,
182        )
183        .await;
184
185        let request = ConcurrentPatternExecutionRequest {
186            initial_user_prompt,
187            trace_id: Some(job.trace_id.clone()),
188            correlation_id: Some(job.correlation_id.clone()),
189            policy_profile,
190            model_hint,
191            merge_strategy,
192            branches: branches
193                .into_iter()
194                .map(|branch| ConcurrentPatternBranch {
195                    branch_id: branch.branch_id,
196                    user_prompt_template: branch.user_prompt_template,
197                    system_prompt: branch.system_prompt,
198                    policy_profile: branch.policy_profile,
199                    model_hint: branch.model_hint,
200                })
201                .collect(),
202        };
203
204        let response = match self.pipeline.execute(request).await {
205            Ok(response) => response,
206            Err(err) => {
207                let error = err.to_string();
208                return Ok(JobExecutionOutcome::FatalFailure {
209                    message: error.clone(),
210                    execution_id: None,
211                    diagnostics: Some(
212                        json!({
213                            "provider": "stasis-orchestration-concurrent",
214                            "status": "failure",
215                            "pattern": "concurrent",
216                            "error": error,
217                        })
218                        .to_string(),
219                    ),
220                });
221            }
222        };
223
224        let branch_ids: Vec<String> = response
225            .branches
226            .iter()
227            .map(|branch| branch.branch_id.clone())
228            .collect();
229        let mut branch_thread_ids = Vec::new();
230        for branch in &response.branches {
231            let branch_thread_id = format!("{}::branch::{}", thread_id, branch.branch_id);
232            let branch_now = Utc::now();
233            self.ensure_thread(
234                &branch_thread_id,
235                Some(thread_id.clone()),
236                Some(branch.branch_id.clone()),
237                branch_now,
238            )
239            .await;
240            self.append_thread_event(
241                format!("{}:concurrent:branch:{}", job.id, branch.branch_id),
242                &branch_thread_id,
243                "orchestration.concurrent.branch.completed",
244                branch.output_text.clone(),
245                branch_now,
246            )
247            .await;
248            branch_thread_ids.push(branch_thread_id);
249        }
250
251        let merge_metadata = ThreadMergeMetadata {
252            parent_thread_id: thread_id.clone(),
253            branch_thread_ids: branch_thread_ids.clone(),
254            merge_strategy: response.merge_strategy.clone(),
255            merged_at: Utc::now(),
256        };
257        let merge_payload_ref =
258            serde_json::to_string(&merge_metadata).unwrap_or_else(|_| response.final_text.clone());
259
260        self.append_thread_event(
261            format!("{}:concurrent:completed", job.id),
262            &thread_id,
263            "orchestration.concurrent.completed",
264            merge_payload_ref,
265            Utc::now(),
266        )
267        .await;
268
269        Ok(JobExecutionOutcome::Success {
270            sttp_output_node_id: format!("sttp:orchestration:concurrent:{}", job.id),
271            execution_id: None,
272            diagnostics: Some(
273                json!({
274                    "provider": "stasis-orchestration-concurrent",
275                    "status": "success",
276                    "pattern": "concurrent",
277                    "branches_executed": response.branches.len(),
278                    "branch_ids": branch_ids,
279                    "thread_id": thread_id,
280                    "branch_thread_ids": branch_thread_ids,
281                    "thread_merge": merge_metadata,
282                    "merge_strategy": response.merge_strategy,
283                    "final_text": response.final_text,
284                    "termination_reason": response.termination_reason,
285                })
286                .to_string(),
287            ),
288        })
289    }
290}