stasis/application/runtime/
grapheme_job_handler.rs1use 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}