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