stasis/application/runtime/
concurrent_pattern_job_handler.rs1use 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}