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#[cfg(feature = "native-ts")]
15use uuid::Uuid;
16
17use crate::context::WorkflowContext;
18use crate::error::{FlowError, Result};
19#[cfg(feature = "native-ts")]
20use crate::model::RuntimeKind;
21use crate::model::{FlowEventEnvelope, JsonValue, RuntimeCommand, WorkflowSpec};
22#[cfg(feature = "native-ts")]
23use crate::protocol::{
24 NativeRuntimeKind, NativeRuntimeRequest, NativeRuntimeResponse, NATIVE_RUNTIME_PROTOCOL,
25};
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct WorkflowInvocation {
30 pub run_id: String,
31 pub spec: WorkflowSpec,
32 pub input: JsonValue,
33 pub history: Vec<FlowEventEnvelope>,
34}
35
36impl WorkflowInvocation {
37 pub fn context(&self) -> WorkflowContext<'_> {
39 WorkflowContext::new(self)
40 }
41
42 pub fn input_as<T>(&self) -> Result<T>
44 where
45 T: DeserializeOwned,
46 {
47 serde_json::from_value(self.input.clone()).map_err(FlowError::from)
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct StepInvocation {
54 pub run_id: String,
55 pub step_id: String,
56 pub step_name: String,
57 pub input: JsonValue,
58 pub history: Vec<FlowEventEnvelope>,
59}
60
61impl StepInvocation {
62 pub fn input_as<T>(&self) -> Result<T>
64 where
65 T: DeserializeOwned,
66 {
67 serde_json::from_value(self.input.clone()).map_err(FlowError::from)
68 }
69}
70
71#[async_trait]
73pub trait FlowRuntime: Send + Sync {
74 async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand>;
76
77 async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue>;
79}
80
81#[derive(Debug, Clone)]
83pub struct NativeTsRuntimeConfig {
84 pub compiler_binary: PathBuf,
87 pub cache_dir: PathBuf,
90 pub working_dir: PathBuf,
93}
94
95impl NativeTsRuntimeConfig {
96 pub fn new(
97 compiler_binary: impl Into<PathBuf>,
98 cache_dir: impl Into<PathBuf>,
99 working_dir: impl Into<PathBuf>,
100 ) -> Self {
101 Self {
102 compiler_binary: compiler_binary.into(),
103 cache_dir: cache_dir.into(),
104 working_dir: working_dir.into(),
105 }
106 }
107}
108
109impl Default for NativeTsRuntimeConfig {
110 fn default() -> Self {
111 Self {
112 compiler_binary: PathBuf::from("a3s-flow-native-compiler"),
113 cache_dir: PathBuf::from(".a3s/flow/native-ts"),
114 working_dir: PathBuf::from("."),
115 }
116 }
117}
118
119#[derive(Debug, Clone)]
122pub struct NativeTsRuntime {
123 config: NativeTsRuntimeConfig,
124}
125
126#[cfg(feature = "native-ts")]
127#[derive(Debug, Clone)]
128struct NativeArtifact {
129 compiler_binary: PathBuf,
130 working_dir: PathBuf,
131 entrypoint: PathBuf,
132 binary: PathBuf,
133 source_hash: String,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct NativeTsRuntimePreflight {
139 pub entrypoint: PathBuf,
141 pub artifact: PathBuf,
143 pub source_hash: String,
145 pub cache_hit: bool,
147}
148
149impl NativeTsRuntime {
150 pub fn new(config: NativeTsRuntimeConfig) -> Self {
151 Self { config }
152 }
153
154 pub fn config(&self) -> &NativeTsRuntimeConfig {
155 &self.config
156 }
157
158 #[cfg(feature = "native-ts")]
159 pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
160 let (artifact, cache_hit) = self.compile_if_needed(spec).await?;
161 Ok(NativeTsRuntimePreflight {
162 entrypoint: artifact.entrypoint,
163 artifact: artifact.binary,
164 source_hash: artifact.source_hash,
165 cache_hit,
166 })
167 }
168
169 #[cfg(not(feature = "native-ts"))]
170 pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
171 Err(FlowError::Runtime(
172 "native-ts feature is disabled for NativeTsRuntime".to_string(),
173 ))
174 }
175
176 #[cfg(feature = "native-ts")]
177 async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
178 validate_native_ts_spec(spec)?;
179 let compiler_binary = executable_from_current_dir(&self.config.compiler_binary)?;
180 let working_dir = absolute_from_current_dir(&self.config.working_dir)?;
181 let entrypoint = resolve_against(&working_dir, &spec.runtime.entrypoint);
182 let cache_dir = absolute_from_current_dir(&self.config.cache_dir)?;
183 let source = tokio::fs::read(&entrypoint).await?;
184 let source_hash = stable_hash([
185 b"source".as_slice(),
186 spec.name.as_bytes(),
187 spec.version.as_bytes(),
188 spec.runtime.entrypoint.as_bytes(),
189 spec.runtime.export_name.as_bytes(),
190 &source,
191 ]);
192 let name = format!("{}-{source_hash}", sanitize_filename(&spec.name));
193 Ok(NativeArtifact {
194 compiler_binary,
195 working_dir,
196 entrypoint,
197 binary: cache_dir.join(name),
198 source_hash,
199 })
200 }
201
202 #[cfg(feature = "native-ts")]
203 async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
204 let artifact = self.artifact_for(spec).await?;
205 if tokio::fs::metadata(&artifact.binary).await.is_ok() {
206 return Ok((artifact, true));
207 }
208
209 let cache_dir = artifact.binary.parent().ok_or_else(|| {
210 FlowError::Runtime(format!(
211 "native TypeScript artifact {} has no cache directory",
212 artifact.binary.display()
213 ))
214 })?;
215 tokio::fs::create_dir_all(cache_dir).await?;
216 let temporary_binary = temporary_artifact_path(&artifact.binary)?;
220 let output = match Command::new(&artifact.compiler_binary)
221 .arg("compile")
222 .arg(&artifact.entrypoint)
223 .arg("-o")
224 .arg(&temporary_binary)
225 .current_dir(&artifact.working_dir)
226 .output()
227 .await
228 {
229 Ok(output) => output,
230 Err(error) => {
231 remove_temporary_artifact(&temporary_binary).await;
232 return Err(error.into());
233 }
234 };
235
236 if !output.status.success() {
237 remove_temporary_artifact(&temporary_binary).await;
238 return Err(FlowError::Runtime(format!(
239 "native TypeScript compile failed: {}",
240 String::from_utf8_lossy(&output.stderr)
241 )));
242 }
243
244 if let Err(error) = tokio::fs::metadata(&temporary_binary).await {
245 remove_temporary_artifact(&temporary_binary).await;
246 return Err(FlowError::Runtime(format!(
247 "native TypeScript compiler did not produce artifact {}: {error}",
248 artifact.binary.display()
249 )));
250 }
251 publish_temporary_artifact(&temporary_binary, &artifact.binary).await?;
252
253 Ok((artifact, false))
254 }
255
256 #[cfg(feature = "native-ts")]
257 async fn invoke<I, O>(
258 &self,
259 spec: &WorkflowSpec,
260 kind: NativeRuntimeKind,
261 payload: I,
262 ) -> Result<O>
263 where
264 I: Serialize + Send,
265 O: DeserializeOwned,
266 {
267 let (artifact, _) = self.compile_if_needed(spec).await?;
268 let request = NativeRuntimeRequest::new(
269 kind,
270 spec.runtime.export_name.clone(),
271 artifact.source_hash,
272 payload,
273 );
274
275 let mut child = Command::new(&artifact.binary)
276 .arg("--a3s-flow-runtime")
277 .stdin(std::process::Stdio::piped())
278 .stdout(std::process::Stdio::piped())
279 .stderr(std::process::Stdio::piped())
280 .current_dir(&artifact.working_dir)
281 .spawn()?;
282
283 let mut stdin = child
284 .stdin
285 .take()
286 .ok_or_else(|| FlowError::Runtime("failed to open runtime stdin".to_string()))?;
287 stdin
288 .write_all(serde_json::to_string(&request)?.as_bytes())
289 .await?;
290 stdin.shutdown().await?;
291 drop(stdin);
292
293 let output = child.wait_with_output().await?;
294 if !output.status.success() {
295 return Err(FlowError::Runtime(format!(
296 "native TypeScript runtime failed: {}",
297 String::from_utf8_lossy(&output.stderr)
298 )));
299 }
300
301 decode_native_response(kind, &output.stdout)
302 }
303}
304
305#[cfg(feature = "native-ts")]
306fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
307 spec.validate()?;
308 if spec.runtime.kind != RuntimeKind::NativeTs {
309 return Err(FlowError::InvalidWorkflow(format!(
310 "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
311 spec.runtime.kind
312 )));
313 }
314 Ok(())
315}
316
317#[async_trait]
318impl FlowRuntime for NativeTsRuntime {
319 #[cfg(feature = "native-ts")]
320 async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
321 let spec = invocation.spec.clone();
322 self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
323 .await
324 }
325
326 #[cfg(not(feature = "native-ts"))]
327 async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
328 Err(FlowError::Runtime(
329 "native-ts feature is disabled for NativeTsRuntime".to_string(),
330 ))
331 }
332
333 #[cfg(feature = "native-ts")]
334 async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
335 let spec = workflow_spec_from_history(&invocation.history)?;
336 self.invoke(&spec, NativeRuntimeKind::Step, invocation)
337 .await
338 }
339
340 #[cfg(not(feature = "native-ts"))]
341 async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
342 Err(FlowError::Runtime(
343 "native-ts feature is disabled for NativeTsRuntime".to_string(),
344 ))
345 }
346}
347
348#[cfg(feature = "native-ts")]
349fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
350 let first = history
351 .first()
352 .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
353 match &first.event {
354 crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
355 _ => Err(FlowError::Runtime(
356 "first history event is not run_created".to_string(),
357 )),
358 }
359}
360
361#[cfg(feature = "native-ts")]
362fn sanitize_filename(value: &str) -> String {
363 value
364 .chars()
365 .map(|ch| {
366 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
367 ch
368 } else {
369 '-'
370 }
371 })
372 .collect()
373}
374
375#[cfg(feature = "native-ts")]
376fn resolve_against(root: &Path, value: &str) -> PathBuf {
377 let path = PathBuf::from(value);
378 if path.is_absolute() {
379 path
380 } else {
381 root.join(path)
382 }
383}
384
385#[cfg(feature = "native-ts")]
386fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
387 if path.is_absolute() {
388 return Ok(path.to_path_buf());
389 }
390 Ok(std::env::current_dir()?.join(path))
391}
392
393#[cfg(feature = "native-ts")]
394fn executable_from_current_dir(path: &Path) -> Result<PathBuf> {
395 if path.components().count() == 1 {
396 return Ok(path.to_path_buf());
397 }
398 absolute_from_current_dir(path)
399}
400
401#[cfg(feature = "native-ts")]
402fn temporary_artifact_path(artifact: &Path) -> Result<PathBuf> {
403 let file_name = artifact.file_name().ok_or_else(|| {
404 FlowError::Runtime(format!(
405 "native TypeScript artifact {} has no file name",
406 artifact.display()
407 ))
408 })?;
409 let temporary_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
410 Ok(artifact.with_file_name(temporary_name))
411}
412
413#[cfg(feature = "native-ts")]
414async fn publish_temporary_artifact(temporary: &Path, artifact: &Path) -> Result<()> {
415 match tokio::fs::rename(temporary, artifact).await {
416 Ok(()) => Ok(()),
417 Err(rename_error) => {
418 if tokio::fs::metadata(artifact).await.is_ok() {
419 remove_temporary_artifact(temporary).await;
420 return Ok(());
421 }
422 remove_temporary_artifact(temporary).await;
423 Err(FlowError::Runtime(format!(
424 "native TypeScript artifact {} could not be published atomically: {rename_error}",
425 artifact.display()
426 )))
427 }
428 }
429}
430
431#[cfg(feature = "native-ts")]
432async fn remove_temporary_artifact(path: &Path) {
433 match tokio::fs::remove_file(path).await {
434 Ok(()) => {}
435 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
436 Err(error) => tracing::warn!(
437 path = %path.display(),
438 %error,
439 "failed to remove temporary native TypeScript artifact"
440 ),
441 }
442}
443
444#[cfg(feature = "native-ts")]
445fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
446 let mut hasher = Sha256::new();
447 for part in parts {
448 let bytes = part.as_ref();
449 hasher.update(bytes.len().to_le_bytes());
450 hasher.update(bytes);
451 }
452 hex_lower(&hasher.finalize())
453}
454
455#[cfg(feature = "native-ts")]
456fn hex_lower(bytes: &[u8]) -> String {
457 const HEX: &[u8; 16] = b"0123456789abcdef";
458 let mut output = String::with_capacity(bytes.len() * 2);
459 for byte in bytes {
460 output.push(HEX[(byte >> 4) as usize] as char);
461 output.push(HEX[(byte & 0x0f) as usize] as char);
462 }
463 output
464}
465
466#[cfg(feature = "native-ts")]
467fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
468where
469 O: DeserializeOwned,
470{
471 let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
472 if response.protocol != NATIVE_RUNTIME_PROTOCOL {
473 return Err(FlowError::Runtime(format!(
474 "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
475 response.protocol
476 )));
477 }
478 if response.kind != kind {
479 return Err(FlowError::Runtime(format!(
480 "native TypeScript runtime response kind mismatch: expected {}, got {}",
481 kind.as_str(),
482 response.kind.as_str()
483 )));
484 }
485 if !response.ok {
486 let error = response
487 .error
488 .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
489 return Err(FlowError::Runtime(error));
490 }
491 let output = response.output.ok_or_else(|| {
492 FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
493 })?;
494 serde_json::from_value(output).map_err(FlowError::from)
495}
496
497#[cfg(test)]
498mod tests {
499 use super::NativeTsRuntimeConfig;
500 use std::path::Path;
501
502 #[test]
503 fn native_ts_default_cache_stays_under_a3s_state_root() {
504 let config = NativeTsRuntimeConfig::default();
505
506 assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
507 }
508}