Skip to main content

stasis/application/runtime/
sequential_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    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}