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