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