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        lint_warnings: &serde_json::Value,
114    ) -> String {
115        json!({
116            "provider": "grapheme-sdk",
117            "status": "success",
118            "duration_ms": duration_ms,
119            "execution_id": execution_id,
120            "execution": execution,
121            "final_state": final_state,
122            "lint_warnings": lint_warnings,
123        })
124        .to_string()
125    }
126
127    fn build_failure_diagnostics(duration_ms: u128, err: &StasisError) -> String {
128        let message = err.to_string();
129        let guardrail_code = Self::classify_guardrail_code(&message);
130        let policy_reason = if message.contains("policy violation") {
131            Some(message.clone())
132        } else {
133            None
134        };
135
136        json!({
137            "provider": "grapheme-sdk",
138            "status": "failure",
139            "duration_ms": duration_ms,
140            "guardrail_code": guardrail_code,
141            "policy_reason": policy_reason,
142            "error": message,
143        })
144        .to_string()
145    }
146}
147
148#[async_trait]
149impl JobHandler for GraphemeJobHandler {
150    fn job_type(&self) -> &'static str {
151        "workflow.grapheme.run"
152    }
153
154    async fn execute(&self, job: &Job) -> Result<JobExecutionOutcome> {
155        let started = Instant::now();
156        let _grapheme_span = self
157            .telemetry
158            .as_ref()
159            .map(|telemetry| telemetry.grapheme_span(&job.id));
160
161        let (source, state_current) = match Self::resolve_payload(&job.payload_ref) {
162            Ok(payload) => payload,
163            Err(err) => {
164                let duration_ms = started.elapsed().as_millis();
165                return Ok(JobExecutionOutcome::FatalFailure {
166                    message: err.to_string(),
167                    execution_id: None,
168                    diagnostics: Some(Self::build_failure_diagnostics(duration_ms, &err)),
169                });
170            }
171        };
172
173        match self
174            .engine
175            .execute_grapheme_source(&source, state_current.as_ref())
176            .await
177        {
178            Ok(output) => {
179                let duration_ms = started.elapsed().as_millis();
180                Ok(JobExecutionOutcome::Success {
181                    sttp_output_node_id: format!("sttp:{}:{}", output.run_id, job.id),
182                    execution_id: Some(output.run_id.clone()),
183                    diagnostics: Some(Self::build_success_diagnostics(
184                        duration_ms,
185                        &output.run_id,
186                        &output.execution,
187                        &output.final_state,
188                        &output.lint_warnings,
189                    )),
190                })
191            }
192            Err(err) => {
193                let duration_ms = started.elapsed().as_millis();
194                Ok(JobExecutionOutcome::FatalFailure {
195                    message: err.to_string(),
196                    execution_id: None,
197                    diagnostics: Some(Self::build_failure_diagnostics(duration_ms, &err)),
198                })
199            }
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use std::sync::Mutex;
207
208    use chrono::Utc;
209
210    use super::*;
211    use crate::domain::runtime::job::{BackoffPolicy, NewJob};
212    use crate::ports::outbound::runtime::workflow_engine::WorkflowExecutionOutput;
213
214    struct RecordingWorkflowEngine {
215        seen_source: Mutex<Option<String>>,
216        seen_state_current: Mutex<Option<Value>>,
217    }
218
219    impl RecordingWorkflowEngine {
220        fn new() -> Self {
221            Self {
222                seen_source: Mutex::new(None),
223                seen_state_current: Mutex::new(None),
224            }
225        }
226    }
227
228    #[async_trait]
229    impl WorkflowEngine for RecordingWorkflowEngine {
230        async fn execute_grapheme_source(
231            &self,
232            source: &str,
233            state_current: Option<&Value>,
234        ) -> Result<WorkflowExecutionOutput> {
235            *self.seen_source.lock().expect("source mutex poisoned") = Some(source.to_string());
236            *self
237                .seen_state_current
238                .lock()
239                .expect("state mutex poisoned") = state_current.cloned();
240            Ok(WorkflowExecutionOutput {
241                run_id: "run-1".to_string(),
242                execution: json!({"ok": true}),
243                final_state: json!({"done": true}),
244                lint_warnings: json!([]),
245            })
246        }
247    }
248
249    fn sample_job(payload_ref: &str) -> Job {
250        NewJob {
251            id: "job-1".to_string(),
252            queue: "workflow".to_string(),
253            job_type: "workflow.grapheme.run".to_string(),
254            payload_ref: payload_ref.to_string(),
255            priority: 0,
256            max_attempts: 1,
257            idempotency_key: "idem-1".to_string(),
258            correlation_id: "corr-1".to_string(),
259            causation_id: "cause-1".to_string(),
260            trace_id: "trace-1".to_string(),
261            sttp_input_node_id: "sttp:input:1".to_string(),
262            scheduled_at: Utc::now(),
263            backoff_policy: BackoffPolicy::default(),
264        }
265        .into_job()
266    }
267
268    #[test]
269    fn resolve_payload_supports_json_prefix_with_state_current() {
270        let payload = r#"grapheme:json:{"source":"op echo()","state_current":{"count":3}}"#;
271        let (source, state_current) =
272            GraphemeJobHandler::resolve_payload(payload).expect("payload should parse");
273        assert_eq!(source, "op echo()");
274        assert_eq!(state_current, Some(json!({"count": 3})));
275    }
276
277    #[test]
278    fn resolve_payload_supports_legacy_inline_source() {
279        let payload = "grapheme:inline:op echo()";
280        let (source, state_current) =
281            GraphemeJobHandler::resolve_payload(payload).expect("payload should parse");
282        assert_eq!(source, "op echo()");
283        assert_eq!(state_current, None);
284    }
285
286    #[tokio::test]
287    async fn execute_passes_state_current_to_engine_when_present() {
288        let engine = Arc::new(RecordingWorkflowEngine::new());
289        let handler = GraphemeJobHandler::new(engine.clone());
290        let job = sample_job(
291            r#"grapheme:json:{"source":"op echo()","state_current":{"cursor":"abc"}}"#,
292        );
293
294        let outcome = handler
295            .execute(&job)
296            .await
297            .expect("handler execution should succeed");
298
299        match outcome {
300            JobExecutionOutcome::Success { execution_id, .. } => {
301                assert_eq!(execution_id, Some("run-1".to_string()));
302            }
303            _ => panic!("expected success outcome"),
304        }
305
306        assert_eq!(
307            *engine
308                .seen_source
309                .lock()
310                .expect("source mutex poisoned"),
311            Some("op echo()".to_string())
312        );
313        assert_eq!(
314            *engine
315                .seen_state_current
316                .lock()
317                .expect("state mutex poisoned"),
318            Some(json!({"cursor": "abc"}))
319        );
320    }
321}