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,
85 pub cache_dir: PathBuf,
88 pub working_dir: PathBuf,
91}
92
93impl NativeTsRuntimeConfig {
94 pub fn new(
95 compiler_binary: impl Into<PathBuf>,
96 cache_dir: impl Into<PathBuf>,
97 working_dir: impl Into<PathBuf>,
98 ) -> Self {
99 Self {
100 compiler_binary: compiler_binary.into(),
101 cache_dir: cache_dir.into(),
102 working_dir: working_dir.into(),
103 }
104 }
105}
106
107impl Default for NativeTsRuntimeConfig {
108 fn default() -> Self {
109 Self {
110 compiler_binary: PathBuf::from("a3s-flow-native-compiler"),
111 cache_dir: PathBuf::from(".a3s/flow/native-ts"),
112 working_dir: PathBuf::from("."),
113 }
114 }
115}
116
117#[derive(Debug, Clone)]
120pub struct NativeTsRuntime {
121 config: NativeTsRuntimeConfig,
122}
123
124#[cfg(feature = "native-ts")]
125#[derive(Debug, Clone)]
126struct NativeArtifact {
127 compiler_binary: PathBuf,
128 working_dir: PathBuf,
129 entrypoint: PathBuf,
130 binary: PathBuf,
131 source_hash: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136pub struct NativeTsRuntimePreflight {
137 pub entrypoint: PathBuf,
139 pub artifact: PathBuf,
141 pub source_hash: String,
143 pub cache_hit: bool,
145}
146
147impl NativeTsRuntime {
148 pub fn new(config: NativeTsRuntimeConfig) -> Self {
149 Self { config }
150 }
151
152 pub fn config(&self) -> &NativeTsRuntimeConfig {
153 &self.config
154 }
155
156 #[cfg(feature = "native-ts")]
157 pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
158 let (artifact, cache_hit) = self.compile_if_needed(spec).await?;
159 Ok(NativeTsRuntimePreflight {
160 entrypoint: artifact.entrypoint,
161 artifact: artifact.binary,
162 source_hash: artifact.source_hash,
163 cache_hit,
164 })
165 }
166
167 #[cfg(not(feature = "native-ts"))]
168 pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
169 Err(FlowError::Runtime(
170 "native-ts feature is disabled for NativeTsRuntime".to_string(),
171 ))
172 }
173
174 #[cfg(feature = "native-ts")]
175 async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
176 validate_native_ts_spec(spec)?;
177 let compiler_binary = executable_from_current_dir(&self.config.compiler_binary)?;
178 let working_dir = absolute_from_current_dir(&self.config.working_dir)?;
179 let entrypoint = resolve_against(&working_dir, &spec.runtime.entrypoint);
180 let cache_dir = absolute_from_current_dir(&self.config.cache_dir)?;
181 let source = tokio::fs::read(&entrypoint).await?;
182 let source_hash = stable_hash([
183 b"source".as_slice(),
184 spec.name.as_bytes(),
185 spec.version.as_bytes(),
186 spec.runtime.entrypoint.as_bytes(),
187 spec.runtime.export_name.as_bytes(),
188 &source,
189 ]);
190 let name = format!("{}-{source_hash}", sanitize_filename(&spec.name));
191 Ok(NativeArtifact {
192 compiler_binary,
193 working_dir,
194 entrypoint,
195 binary: cache_dir.join(name),
196 source_hash,
197 })
198 }
199
200 #[cfg(feature = "native-ts")]
201 async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
202 let artifact = self.artifact_for(spec).await?;
203 if tokio::fs::metadata(&artifact.binary).await.is_ok() {
204 return Ok((artifact, true));
205 }
206
207 let cache_dir = artifact.binary.parent().ok_or_else(|| {
208 FlowError::Runtime(format!(
209 "native TypeScript artifact {} has no cache directory",
210 artifact.binary.display()
211 ))
212 })?;
213 tokio::fs::create_dir_all(cache_dir).await?;
214 let output = Command::new(&artifact.compiler_binary)
215 .arg("compile")
216 .arg(&artifact.entrypoint)
217 .arg("-o")
218 .arg(&artifact.binary)
219 .current_dir(&artifact.working_dir)
220 .output()
221 .await?;
222
223 if !output.status.success() {
224 return Err(FlowError::Runtime(format!(
225 "native TypeScript compile failed: {}",
226 String::from_utf8_lossy(&output.stderr)
227 )));
228 }
229
230 tokio::fs::metadata(&artifact.binary).await.map_err(|err| {
231 FlowError::Runtime(format!(
232 "native TypeScript compiler did not produce artifact {}: {err}",
233 artifact.binary.display()
234 ))
235 })?;
236
237 Ok((artifact, false))
238 }
239
240 #[cfg(feature = "native-ts")]
241 async fn invoke<I, O>(
242 &self,
243 spec: &WorkflowSpec,
244 kind: NativeRuntimeKind,
245 payload: I,
246 ) -> Result<O>
247 where
248 I: Serialize + Send,
249 O: DeserializeOwned,
250 {
251 let (artifact, _) = self.compile_if_needed(spec).await?;
252 let request = NativeRuntimeRequest::new(
253 kind,
254 spec.runtime.export_name.clone(),
255 artifact.source_hash,
256 payload,
257 );
258
259 let mut child = Command::new(&artifact.binary)
260 .arg("--a3s-flow-runtime")
261 .stdin(std::process::Stdio::piped())
262 .stdout(std::process::Stdio::piped())
263 .stderr(std::process::Stdio::piped())
264 .current_dir(&artifact.working_dir)
265 .spawn()?;
266
267 let mut stdin = child
268 .stdin
269 .take()
270 .ok_or_else(|| FlowError::Runtime("failed to open runtime stdin".to_string()))?;
271 stdin
272 .write_all(serde_json::to_string(&request)?.as_bytes())
273 .await?;
274 stdin.shutdown().await?;
275 drop(stdin);
276
277 let output = child.wait_with_output().await?;
278 if !output.status.success() {
279 return Err(FlowError::Runtime(format!(
280 "native TypeScript runtime failed: {}",
281 String::from_utf8_lossy(&output.stderr)
282 )));
283 }
284
285 decode_native_response(kind, &output.stdout)
286 }
287}
288
289#[cfg(feature = "native-ts")]
290fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
291 spec.validate()?;
292 if spec.runtime.kind != RuntimeKind::NativeTs {
293 return Err(FlowError::InvalidWorkflow(format!(
294 "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
295 spec.runtime.kind
296 )));
297 }
298 Ok(())
299}
300
301#[async_trait]
302impl FlowRuntime for NativeTsRuntime {
303 #[cfg(feature = "native-ts")]
304 async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
305 let spec = invocation.spec.clone();
306 self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
307 .await
308 }
309
310 #[cfg(not(feature = "native-ts"))]
311 async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
312 Err(FlowError::Runtime(
313 "native-ts feature is disabled for NativeTsRuntime".to_string(),
314 ))
315 }
316
317 #[cfg(feature = "native-ts")]
318 async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
319 let spec = workflow_spec_from_history(&invocation.history)?;
320 self.invoke(&spec, NativeRuntimeKind::Step, invocation)
321 .await
322 }
323
324 #[cfg(not(feature = "native-ts"))]
325 async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
326 Err(FlowError::Runtime(
327 "native-ts feature is disabled for NativeTsRuntime".to_string(),
328 ))
329 }
330}
331
332#[cfg(feature = "native-ts")]
333fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
334 let first = history
335 .first()
336 .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
337 match &first.event {
338 crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
339 _ => Err(FlowError::Runtime(
340 "first history event is not run_created".to_string(),
341 )),
342 }
343}
344
345#[cfg(feature = "native-ts")]
346fn sanitize_filename(value: &str) -> String {
347 value
348 .chars()
349 .map(|ch| {
350 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
351 ch
352 } else {
353 '-'
354 }
355 })
356 .collect()
357}
358
359#[cfg(feature = "native-ts")]
360fn resolve_against(root: &Path, value: &str) -> PathBuf {
361 let path = PathBuf::from(value);
362 if path.is_absolute() {
363 path
364 } else {
365 root.join(path)
366 }
367}
368
369#[cfg(feature = "native-ts")]
370fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
371 if path.is_absolute() {
372 return Ok(path.to_path_buf());
373 }
374 Ok(std::env::current_dir()?.join(path))
375}
376
377#[cfg(feature = "native-ts")]
378fn executable_from_current_dir(path: &Path) -> Result<PathBuf> {
379 if path.components().count() == 1 {
380 return Ok(path.to_path_buf());
381 }
382 absolute_from_current_dir(path)
383}
384
385#[cfg(feature = "native-ts")]
386fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
387 let mut hasher = Sha256::new();
388 for part in parts {
389 let bytes = part.as_ref();
390 hasher.update(bytes.len().to_le_bytes());
391 hasher.update(bytes);
392 }
393 hex_lower(&hasher.finalize())
394}
395
396#[cfg(feature = "native-ts")]
397fn hex_lower(bytes: &[u8]) -> String {
398 const HEX: &[u8; 16] = b"0123456789abcdef";
399 let mut output = String::with_capacity(bytes.len() * 2);
400 for byte in bytes {
401 output.push(HEX[(byte >> 4) as usize] as char);
402 output.push(HEX[(byte & 0x0f) as usize] as char);
403 }
404 output
405}
406
407#[cfg(feature = "native-ts")]
408fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
409where
410 O: DeserializeOwned,
411{
412 let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
413 if response.protocol != NATIVE_RUNTIME_PROTOCOL {
414 return Err(FlowError::Runtime(format!(
415 "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
416 response.protocol
417 )));
418 }
419 if response.kind != kind {
420 return Err(FlowError::Runtime(format!(
421 "native TypeScript runtime response kind mismatch: expected {}, got {}",
422 kind.as_str(),
423 response.kind.as_str()
424 )));
425 }
426 if !response.ok {
427 let error = response
428 .error
429 .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
430 return Err(FlowError::Runtime(error));
431 }
432 let output = response.output.ok_or_else(|| {
433 FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
434 })?;
435 serde_json::from_value(output).map_err(FlowError::from)
436}
437
438#[cfg(test)]
439mod tests {
440 use super::NativeTsRuntimeConfig;
441 use std::path::Path;
442
443 #[test]
444 fn native_ts_default_cache_stays_under_a3s_state_root() {
445 let config = NativeTsRuntimeConfig::default();
446
447 assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
448 }
449}