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::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}