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