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