1use async_trait::async_trait;
2use serde::de::DeserializeOwned;
3use serde::{Deserialize, Serialize};
4#[cfg(feature = "native-ts")]
5use sha2::{Digest, Sha256};
6#[cfg(feature = "native-ts")]
7use std::path::Path;
8use std::path::PathBuf;
9
10#[cfg(feature = "native-ts")]
11use tokio::io::AsyncWriteExt;
12#[cfg(feature = "native-ts")]
13use tokio::process::Command;
14
15use crate::context::WorkflowContext;
16use crate::error::{FlowError, Result};
17#[cfg(feature = "native-ts")]
18use crate::model::RuntimeKind;
19use crate::model::{FlowEventEnvelope, JsonValue, RuntimeCommand, WorkflowSpec};
20#[cfg(feature = "native-ts")]
21use crate::protocol::{
22 NativeRuntimeKind, NativeRuntimeRequest, NativeRuntimeResponse, NATIVE_RUNTIME_PROTOCOL,
23};
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct WorkflowInvocation {
28 pub run_id: String,
29 pub spec: WorkflowSpec,
30 pub input: JsonValue,
31 pub history: Vec<FlowEventEnvelope>,
32}
33
34impl WorkflowInvocation {
35 pub fn context(&self) -> WorkflowContext<'_> {
37 WorkflowContext::new(self)
38 }
39
40 pub fn input_as<T>(&self) -> Result<T>
42 where
43 T: DeserializeOwned,
44 {
45 serde_json::from_value(self.input.clone()).map_err(FlowError::from)
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct StepInvocation {
52 pub run_id: String,
53 pub step_id: String,
54 pub step_name: String,
55 pub input: JsonValue,
56 pub history: Vec<FlowEventEnvelope>,
57}
58
59impl StepInvocation {
60 pub fn input_as<T>(&self) -> Result<T>
62 where
63 T: DeserializeOwned,
64 {
65 serde_json::from_value(self.input.clone()).map_err(FlowError::from)
66 }
67}
68
69#[async_trait]
71pub trait FlowRuntime: Send + Sync {
72 async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand>;
74
75 async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue>;
77}
78
79#[derive(Debug, Clone)]
81pub struct NativeTsRuntimeConfig {
82 pub compiler_binary: PathBuf,
83 pub cache_dir: PathBuf,
84 pub working_dir: PathBuf,
85}
86
87impl NativeTsRuntimeConfig {
88 pub fn new(
89 compiler_binary: impl Into<PathBuf>,
90 cache_dir: impl Into<PathBuf>,
91 working_dir: impl Into<PathBuf>,
92 ) -> Self {
93 Self {
94 compiler_binary: compiler_binary.into(),
95 cache_dir: cache_dir.into(),
96 working_dir: working_dir.into(),
97 }
98 }
99}
100
101impl Default for NativeTsRuntimeConfig {
102 fn default() -> Self {
103 Self {
104 compiler_binary: PathBuf::from("a3s-flow-native-compiler"),
105 cache_dir: PathBuf::from(".a3s-flow/native-ts"),
106 working_dir: PathBuf::from("."),
107 }
108 }
109}
110
111#[derive(Debug, Clone)]
114pub struct NativeTsRuntime {
115 config: NativeTsRuntimeConfig,
116}
117
118#[cfg(feature = "native-ts")]
119#[derive(Debug, Clone)]
120struct NativeArtifact {
121 entrypoint: PathBuf,
122 binary: PathBuf,
123 source_hash: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128pub struct NativeTsRuntimePreflight {
129 pub entrypoint: PathBuf,
131 pub artifact: PathBuf,
133 pub source_hash: String,
135 pub cache_hit: bool,
137}
138
139impl NativeTsRuntime {
140 pub fn new(config: NativeTsRuntimeConfig) -> Self {
141 Self { config }
142 }
143
144 pub fn config(&self) -> &NativeTsRuntimeConfig {
145 &self.config
146 }
147
148 #[cfg(feature = "native-ts")]
149 pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
150 self.compile_if_needed(spec).await
151 }
152
153 #[cfg(not(feature = "native-ts"))]
154 pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
155 Err(FlowError::Runtime(
156 "native-ts feature is disabled for NativeTsRuntime".to_string(),
157 ))
158 }
159
160 #[cfg(feature = "native-ts")]
161 async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
162 validate_native_ts_spec(spec)?;
163 let entrypoint = resolve_against(&self.config.working_dir, &spec.runtime.entrypoint);
164 let source = tokio::fs::read(&entrypoint).await?;
165 let source_hash = stable_hash([
166 b"source".as_slice(),
167 spec.name.as_bytes(),
168 spec.version.as_bytes(),
169 spec.runtime.entrypoint.as_bytes(),
170 spec.runtime.export_name.as_bytes(),
171 &source,
172 ]);
173 let name = format!("{}-{source_hash}", sanitize_filename(&spec.name));
174 Ok(NativeArtifact {
175 entrypoint,
176 binary: self.config.cache_dir.join(name),
177 source_hash,
178 })
179 }
180
181 #[cfg(feature = "native-ts")]
182 async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
183 let artifact = self.artifact_for(spec).await?;
184 if tokio::fs::metadata(&artifact.binary).await.is_ok() {
185 return Ok(NativeTsRuntimePreflight {
186 entrypoint: artifact.entrypoint,
187 artifact: artifact.binary,
188 source_hash: artifact.source_hash,
189 cache_hit: true,
190 });
191 }
192
193 tokio::fs::create_dir_all(&self.config.cache_dir).await?;
194 let output = Command::new(&self.config.compiler_binary)
195 .arg("compile")
196 .arg(&artifact.entrypoint)
197 .arg("-o")
198 .arg(&artifact.binary)
199 .current_dir(&self.config.working_dir)
200 .output()
201 .await?;
202
203 if !output.status.success() {
204 return Err(FlowError::Runtime(format!(
205 "native TypeScript compile failed: {}",
206 String::from_utf8_lossy(&output.stderr)
207 )));
208 }
209
210 tokio::fs::metadata(&artifact.binary).await.map_err(|err| {
211 FlowError::Runtime(format!(
212 "native TypeScript compiler did not produce artifact {}: {err}",
213 artifact.binary.display()
214 ))
215 })?;
216
217 Ok(NativeTsRuntimePreflight {
218 entrypoint: artifact.entrypoint,
219 artifact: artifact.binary,
220 source_hash: artifact.source_hash,
221 cache_hit: false,
222 })
223 }
224
225 #[cfg(feature = "native-ts")]
226 async fn invoke<I, O>(
227 &self,
228 spec: &WorkflowSpec,
229 kind: NativeRuntimeKind,
230 payload: I,
231 ) -> Result<O>
232 where
233 I: Serialize + Send,
234 O: DeserializeOwned,
235 {
236 let preflight = self.compile_if_needed(spec).await?;
237 let request = NativeRuntimeRequest::new(
238 kind,
239 spec.runtime.export_name.clone(),
240 preflight.source_hash,
241 payload,
242 );
243
244 let mut child = Command::new(&preflight.artifact)
245 .arg("--a3s-flow-runtime")
246 .stdin(std::process::Stdio::piped())
247 .stdout(std::process::Stdio::piped())
248 .stderr(std::process::Stdio::piped())
249 .current_dir(&self.config.working_dir)
250 .spawn()?;
251
252 let mut stdin = child
253 .stdin
254 .take()
255 .ok_or_else(|| FlowError::Runtime("failed to open runtime stdin".to_string()))?;
256 stdin
257 .write_all(serde_json::to_string(&request)?.as_bytes())
258 .await?;
259 stdin.shutdown().await?;
260 drop(stdin);
261
262 let output = child.wait_with_output().await?;
263 if !output.status.success() {
264 return Err(FlowError::Runtime(format!(
265 "native TypeScript runtime failed: {}",
266 String::from_utf8_lossy(&output.stderr)
267 )));
268 }
269
270 decode_native_response(kind, &output.stdout)
271 }
272}
273
274#[cfg(feature = "native-ts")]
275fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
276 spec.validate()?;
277 if spec.runtime.kind != RuntimeKind::NativeTs {
278 return Err(FlowError::InvalidWorkflow(format!(
279 "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
280 spec.runtime.kind
281 )));
282 }
283 Ok(())
284}
285
286#[async_trait]
287impl FlowRuntime for NativeTsRuntime {
288 #[cfg(feature = "native-ts")]
289 async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
290 let spec = invocation.spec.clone();
291 self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
292 .await
293 }
294
295 #[cfg(not(feature = "native-ts"))]
296 async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
297 Err(FlowError::Runtime(
298 "native-ts feature is disabled for NativeTsRuntime".to_string(),
299 ))
300 }
301
302 #[cfg(feature = "native-ts")]
303 async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
304 let spec = workflow_spec_from_history(&invocation.history)?;
305 self.invoke(&spec, NativeRuntimeKind::Step, invocation)
306 .await
307 }
308
309 #[cfg(not(feature = "native-ts"))]
310 async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
311 Err(FlowError::Runtime(
312 "native-ts feature is disabled for NativeTsRuntime".to_string(),
313 ))
314 }
315}
316
317#[cfg(feature = "native-ts")]
318fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
319 let first = history
320 .first()
321 .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
322 match &first.event {
323 crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
324 _ => Err(FlowError::Runtime(
325 "first history event is not run_created".to_string(),
326 )),
327 }
328}
329
330#[cfg(feature = "native-ts")]
331fn sanitize_filename(value: &str) -> String {
332 value
333 .chars()
334 .map(|ch| {
335 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
336 ch
337 } else {
338 '-'
339 }
340 })
341 .collect()
342}
343
344#[cfg(feature = "native-ts")]
345fn resolve_against(root: &Path, value: &str) -> PathBuf {
346 let path = PathBuf::from(value);
347 if path.is_absolute() {
348 path
349 } else {
350 root.join(path)
351 }
352}
353
354#[cfg(feature = "native-ts")]
355fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
356 let mut hasher = Sha256::new();
357 for part in parts {
358 let bytes = part.as_ref();
359 hasher.update(bytes.len().to_le_bytes());
360 hasher.update(bytes);
361 }
362 hex_lower(&hasher.finalize())
363}
364
365#[cfg(feature = "native-ts")]
366fn hex_lower(bytes: &[u8]) -> String {
367 const HEX: &[u8; 16] = b"0123456789abcdef";
368 let mut output = String::with_capacity(bytes.len() * 2);
369 for byte in bytes {
370 output.push(HEX[(byte >> 4) as usize] as char);
371 output.push(HEX[(byte & 0x0f) as usize] as char);
372 }
373 output
374}
375
376#[cfg(feature = "native-ts")]
377fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
378where
379 O: DeserializeOwned,
380{
381 let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
382 if response.protocol != NATIVE_RUNTIME_PROTOCOL {
383 return Err(FlowError::Runtime(format!(
384 "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
385 response.protocol
386 )));
387 }
388 if response.kind != kind {
389 return Err(FlowError::Runtime(format!(
390 "native TypeScript runtime response kind mismatch: expected {}, got {}",
391 kind.as_str(),
392 response.kind.as_str()
393 )));
394 }
395 if !response.ok {
396 let error = response
397 .error
398 .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
399 return Err(FlowError::Runtime(error));
400 }
401 let output = response.output.ok_or_else(|| {
402 FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
403 })?;
404 serde_json::from_value(output).map_err(FlowError::from)
405}