Skip to main content

stasis/application/runtime/
grapheme_job_handler.rs

1use std::fs;
2use std::sync::Arc;
3use std::time::Instant;
4
5use async_trait::async_trait;
6use serde::Deserialize;
7use serde_json::json;
8use serde_json::Value;
9
10use crate::application::runtime::in_memory_runtime::{JobExecutionOutcome, JobHandler};
11use crate::domain::errors::Result;
12use crate::domain::errors::StasisError;
13use crate::domain::runtime::job::Job;
14use crate::ports::outbound::runtime::workflow_engine::WorkflowEngine;
15
16const INLINE_PREFIX: &str = "grapheme:inline:";
17const FILE_PREFIX: &str = "grapheme:file:";
18const JSON_PREFIX: &str = "grapheme:json:";
19
20#[derive(Debug, Deserialize)]
21struct GraphemeExecutionPayload {
22    source: String,
23    #[serde(default)]
24    state_current: Option<Value>,
25}
26
27pub struct GraphemeJobHandler {
28    engine: Arc<dyn WorkflowEngine>,
29}
30
31impl GraphemeJobHandler {
32    pub fn new(engine: Arc<dyn WorkflowEngine>) -> Self {
33        Self { engine }
34    }
35
36    fn resolve_payload(payload_ref: &str) -> Result<(String, Option<Value>)> {
37        if let Some(path) = payload_ref.strip_prefix(FILE_PREFIX) {
38            return fs::read_to_string(path)
39                .map(|source| (source, None))
40                .map_err(|e| {
41                crate::domain::errors::StasisError::PortFailure(format!(
42                    "read grapheme source file '{}': {}",
43                    path, e
44                ))
45            });
46        }
47
48        if let Some(inline) = payload_ref.strip_prefix(INLINE_PREFIX) {
49            return Ok((inline.to_string(), None));
50        }
51
52        if let Some(payload_json) = payload_ref.strip_prefix(JSON_PREFIX) {
53            let payload: GraphemeExecutionPayload = serde_json::from_str(payload_json).map_err(
54                |e| {
55                    StasisError::PortFailure(format!(
56                        "invalid grapheme execution payload json: {}",
57                        e
58                    ))
59                },
60            )?;
61            return Ok((payload.source, payload.state_current));
62        }
63
64        if payload_ref.trim_start().starts_with('{')
65            && payload_ref.contains("\"source\"")
66            && let Ok(payload) = serde_json::from_str::<GraphemeExecutionPayload>(payload_ref)
67        {
68            return Ok((payload.source, payload.state_current));
69        }
70
71        Ok((payload_ref.to_string(), None))
72    }
73
74    fn classify_guardrail_code(message: &str) -> &'static str {
75        if message.contains("not allowlisted") {
76            return "IMPORT_NOT_ALLOWLISTED";
77        }
78
79        if message.contains("source size") {
80            return "SOURCE_TOO_LARGE";
81        }
82
83        if message.contains("timed out") {
84            return "EXECUTION_TIMEOUT";
85        }
86
87        if message.contains("timeout must be greater than 0ms") {
88            return "INVALID_TIMEOUT_CONFIG";
89        }
90
91        if message.contains("policy violation") {
92            return "POLICY_VIOLATION";
93        }
94
95        "EXECUTION_ERROR"
96    }
97
98    fn build_success_diagnostics(
99        duration_ms: u128,
100        execution_id: &str,
101        execution: &serde_json::Value,
102        final_state: &serde_json::Value,
103    ) -> String {
104        json!({
105            "provider": "grapheme-sdk",
106            "status": "success",
107            "duration_ms": duration_ms,
108            "execution_id": execution_id,
109            "execution": execution,
110            "final_state": final_state
111        })
112        .to_string()
113    }
114
115    fn build_failure_diagnostics(duration_ms: u128, err: &StasisError) -> String {
116        let message = err.to_string();
117        let guardrail_code = Self::classify_guardrail_code(&message);
118        let policy_reason = if message.contains("policy violation") {
119            Some(message.clone())
120        } else {
121            None
122        };
123
124        json!({
125            "provider": "grapheme-sdk",
126            "status": "failure",
127            "duration_ms": duration_ms,
128            "guardrail_code": guardrail_code,
129            "policy_reason": policy_reason,
130            "error": message,
131        })
132        .to_string()
133    }
134}
135
136#[async_trait]
137impl JobHandler for GraphemeJobHandler {
138    fn job_type(&self) -> &'static str {
139        "workflow.grapheme.run"
140    }
141
142    async fn execute(&self, job: &Job) -> Result<JobExecutionOutcome> {
143        let started = Instant::now();
144        let (source, state_current) = match Self::resolve_payload(&job.payload_ref) {
145            Ok(payload) => payload,
146            Err(err) => {
147                let duration_ms = started.elapsed().as_millis();
148                return Ok(JobExecutionOutcome::FatalFailure {
149                    message: err.to_string(),
150                    execution_id: None,
151                    diagnostics: Some(Self::build_failure_diagnostics(duration_ms, &err)),
152                });
153            }
154        };
155
156        match self
157            .engine
158            .execute_grapheme_source(&source, state_current.as_ref())
159            .await
160        {
161            Ok(output) => {
162                let duration_ms = started.elapsed().as_millis();
163                Ok(JobExecutionOutcome::Success {
164                    sttp_output_node_id: format!("sttp:{}:{}", output.run_id, job.id),
165                    execution_id: Some(output.run_id.clone()),
166                    diagnostics: Some(Self::build_success_diagnostics(
167                        duration_ms,
168                        &output.run_id,
169                        &output.execution,
170                        &output.final_state,
171                    )),
172                })
173            }
174            Err(err) => {
175                let duration_ms = started.elapsed().as_millis();
176                Ok(JobExecutionOutcome::FatalFailure {
177                    message: err.to_string(),
178                    execution_id: None,
179                    diagnostics: Some(Self::build_failure_diagnostics(duration_ms, &err)),
180                })
181            }
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use std::sync::Mutex;
189
190    use chrono::Utc;
191
192    use super::*;
193    use crate::domain::runtime::job::{BackoffPolicy, NewJob};
194    use crate::ports::outbound::runtime::workflow_engine::WorkflowExecutionOutput;
195
196    struct RecordingWorkflowEngine {
197        seen_source: Mutex<Option<String>>,
198        seen_state_current: Mutex<Option<Value>>,
199    }
200
201    impl RecordingWorkflowEngine {
202        fn new() -> Self {
203            Self {
204                seen_source: Mutex::new(None),
205                seen_state_current: Mutex::new(None),
206            }
207        }
208    }
209
210    #[async_trait]
211    impl WorkflowEngine for RecordingWorkflowEngine {
212        async fn execute_grapheme_source(
213            &self,
214            source: &str,
215            state_current: Option<&Value>,
216        ) -> Result<WorkflowExecutionOutput> {
217            *self.seen_source.lock().expect("source mutex poisoned") = Some(source.to_string());
218            *self
219                .seen_state_current
220                .lock()
221                .expect("state mutex poisoned") = state_current.cloned();
222            Ok(WorkflowExecutionOutput {
223                run_id: "run-1".to_string(),
224                execution: json!({"ok": true}),
225                final_state: json!({"done": true}),
226            })
227        }
228    }
229
230    fn sample_job(payload_ref: &str) -> Job {
231        NewJob {
232            id: "job-1".to_string(),
233            queue: "workflow".to_string(),
234            job_type: "workflow.grapheme.run".to_string(),
235            payload_ref: payload_ref.to_string(),
236            priority: 0,
237            max_attempts: 1,
238            idempotency_key: "idem-1".to_string(),
239            correlation_id: "corr-1".to_string(),
240            causation_id: "cause-1".to_string(),
241            trace_id: "trace-1".to_string(),
242            sttp_input_node_id: "sttp:input:1".to_string(),
243            scheduled_at: Utc::now(),
244            backoff_policy: BackoffPolicy::default(),
245        }
246        .into_job()
247    }
248
249    #[test]
250    fn resolve_payload_supports_json_prefix_with_state_current() {
251        let payload = r#"grapheme:json:{"source":"op echo()","state_current":{"count":3}}"#;
252        let (source, state_current) =
253            GraphemeJobHandler::resolve_payload(payload).expect("payload should parse");
254        assert_eq!(source, "op echo()");
255        assert_eq!(state_current, Some(json!({"count": 3})));
256    }
257
258    #[test]
259    fn resolve_payload_supports_legacy_inline_source() {
260        let payload = "grapheme:inline:op echo()";
261        let (source, state_current) =
262            GraphemeJobHandler::resolve_payload(payload).expect("payload should parse");
263        assert_eq!(source, "op echo()");
264        assert_eq!(state_current, None);
265    }
266
267    #[tokio::test]
268    async fn execute_passes_state_current_to_engine_when_present() {
269        let engine = Arc::new(RecordingWorkflowEngine::new());
270        let handler = GraphemeJobHandler::new(engine.clone());
271        let job = sample_job(
272            r#"grapheme:json:{"source":"op echo()","state_current":{"cursor":"abc"}}"#,
273        );
274
275        let outcome = handler
276            .execute(&job)
277            .await
278            .expect("handler execution should succeed");
279
280        match outcome {
281            JobExecutionOutcome::Success { execution_id, .. } => {
282                assert_eq!(execution_id, Some("run-1".to_string()));
283            }
284            _ => panic!("expected success outcome"),
285        }
286
287        assert_eq!(
288            *engine
289                .seen_source
290                .lock()
291                .expect("source mutex poisoned"),
292            Some("op echo()".to_string())
293        );
294        assert_eq!(
295            *engine
296                .seen_state_current
297                .lock()
298                .expect("state mutex poisoned"),
299            Some(json!({"cursor": "abc"}))
300        );
301    }
302}