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#[cfg(feature = "native-ts")]
137struct TemporaryArtifactGuard {
138 path: PathBuf,
139 armed: bool,
140}
141
142#[cfg(feature = "native-ts")]
143impl TemporaryArtifactGuard {
144 fn new(path: PathBuf) -> Self {
145 Self { path, armed: true }
146 }
147
148 fn path(&self) -> &Path {
149 &self.path
150 }
151
152 async fn remove(&mut self) {
153 remove_temporary_artifact(&self.path).await;
154 self.armed = false;
155 }
156
157 fn disarm(&mut self) {
158 self.armed = false;
159 }
160}
161
162#[cfg(feature = "native-ts")]
163impl Drop for TemporaryArtifactGuard {
164 fn drop(&mut self) {
165 if !self.armed {
166 return;
167 }
168
169 let path = self.path.clone();
170 match tokio::runtime::Handle::try_current() {
171 Ok(runtime) => {
172 let _cleanup = runtime.spawn(async move {
173 remove_temporary_artifact(&path).await;
174 });
175 }
176 Err(error) => tracing::warn!(
177 path = %self.path.display(),
178 %error,
179 "failed to schedule cancelled native TypeScript artifact cleanup"
180 ),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187pub struct NativeTsRuntimePreflight {
188 pub entrypoint: PathBuf,
190 pub artifact: PathBuf,
192 pub source_hash: String,
194 pub cache_hit: bool,
196}
197
198impl NativeTsRuntime {
199 pub fn new(config: NativeTsRuntimeConfig) -> Self {
200 Self { config }
201 }
202
203 pub fn config(&self) -> &NativeTsRuntimeConfig {
204 &self.config
205 }
206
207 #[cfg(feature = "native-ts")]
208 pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
209 let (artifact, cache_hit) = self.compile_if_needed(spec).await?;
210 Ok(NativeTsRuntimePreflight {
211 entrypoint: artifact.entrypoint,
212 artifact: artifact.binary,
213 source_hash: artifact.source_hash,
214 cache_hit,
215 })
216 }
217
218 #[cfg(not(feature = "native-ts"))]
219 pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
220 Err(FlowError::Runtime(
221 "native-ts feature is disabled for NativeTsRuntime".to_string(),
222 ))
223 }
224
225 #[cfg(feature = "native-ts")]
226 async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
227 validate_native_ts_spec(spec)?;
228 let compiler_binary = executable_from_current_dir(&self.config.compiler_binary)?;
229 let working_dir = absolute_from_current_dir(&self.config.working_dir)?;
230 let entrypoint = resolve_against(&working_dir, &spec.runtime.entrypoint);
231 let cache_dir = absolute_from_current_dir(&self.config.cache_dir)?;
232 let source = tokio::fs::read(&entrypoint).await?;
233 let source_hash = native_source_hash(spec, &source);
237 let artifact_hash = native_artifact_cache_key(
238 &source_hash,
239 &compiler_binary,
240 &working_dir,
241 &entrypoint,
242 NATIVE_RUNTIME_PROTOCOL,
243 );
244 let name = format!("{}-{artifact_hash}", sanitize_filename(&spec.name));
245 Ok(NativeArtifact {
246 compiler_binary,
247 working_dir,
248 entrypoint,
249 binary: cache_dir.join(name),
250 source_hash,
251 })
252 }
253
254 #[cfg(feature = "native-ts")]
255 async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
256 let artifact = self.artifact_for(spec).await?;
257 if tokio::fs::metadata(&artifact.binary).await.is_ok() {
258 return Ok((artifact, true));
259 }
260
261 let cache_dir = artifact.binary.parent().ok_or_else(|| {
262 FlowError::Runtime(format!(
263 "native TypeScript artifact {} has no cache directory",
264 artifact.binary.display()
265 ))
266 })?;
267 tokio::fs::create_dir_all(cache_dir).await?;
268 let mut temporary_binary =
272 TemporaryArtifactGuard::new(temporary_artifact_path(&artifact.binary)?);
273 let output = match Command::new(&artifact.compiler_binary)
274 .arg("compile")
275 .arg(&artifact.entrypoint)
276 .arg("-o")
277 .arg(temporary_binary.path())
278 .current_dir(&artifact.working_dir)
279 .kill_on_drop(true)
282 .output()
283 .await
284 {
285 Ok(output) => output,
286 Err(error) => {
287 temporary_binary.remove().await;
288 return Err(error.into());
289 }
290 };
291
292 if !output.status.success() {
293 temporary_binary.remove().await;
294 return Err(FlowError::Runtime(format!(
295 "native TypeScript compile failed: {}",
296 String::from_utf8_lossy(&output.stderr)
297 )));
298 }
299
300 if let Err(error) = tokio::fs::metadata(temporary_binary.path()).await {
301 temporary_binary.remove().await;
302 return Err(FlowError::Runtime(format!(
303 "native TypeScript compiler did not produce artifact {}: {error}",
304 artifact.binary.display()
305 )));
306 }
307 publish_temporary_artifact(temporary_binary.path(), &artifact.binary).await?;
308 temporary_binary.disarm();
309
310 Ok((artifact, false))
311 }
312
313 #[cfg(feature = "native-ts")]
314 async fn invoke<I, O>(
315 &self,
316 spec: &WorkflowSpec,
317 kind: NativeRuntimeKind,
318 payload: I,
319 ) -> Result<O>
320 where
321 I: Serialize + Send,
322 O: DeserializeOwned,
323 {
324 let (artifact, _) = self.compile_if_needed(spec).await?;
325 let request = NativeRuntimeRequest::new(
326 kind,
327 spec.runtime.export_name.clone(),
328 artifact.source_hash,
329 payload,
330 );
331
332 let mut child = Command::new(&artifact.binary)
333 .arg("--a3s-flow-runtime")
334 .stdin(std::process::Stdio::piped())
335 .stdout(std::process::Stdio::piped())
336 .stderr(std::process::Stdio::piped())
337 .current_dir(&artifact.working_dir)
338 .kill_on_drop(true)
341 .spawn()?;
342
343 let mut stdin = child
344 .stdin
345 .take()
346 .ok_or_else(|| FlowError::Runtime("failed to open runtime stdin".to_string()))?;
347 stdin
348 .write_all(serde_json::to_string(&request)?.as_bytes())
349 .await?;
350 stdin.shutdown().await?;
351 drop(stdin);
352
353 let output = child.wait_with_output().await?;
354 if !output.status.success() {
355 return Err(FlowError::Runtime(format!(
356 "native TypeScript runtime failed: {}",
357 String::from_utf8_lossy(&output.stderr)
358 )));
359 }
360
361 decode_native_response(kind, &output.stdout)
362 }
363}
364
365#[cfg(feature = "native-ts")]
366fn native_source_hash(spec: &WorkflowSpec, source: &[u8]) -> String {
367 stable_hash([
368 b"source".as_slice(),
369 spec.name.as_bytes(),
370 spec.version.as_bytes(),
371 spec.runtime.entrypoint.as_bytes(),
372 spec.runtime.export_name.as_bytes(),
373 source,
374 ])
375}
376
377#[cfg(feature = "native-ts")]
378fn native_artifact_cache_key(
379 source_hash: &str,
380 compiler_binary: &Path,
381 working_dir: &Path,
382 entrypoint: &Path,
383 protocol: &str,
384) -> String {
385 stable_hash([
386 b"a3s.flow.native_ts.artifact.v1".as_slice(),
387 source_hash.as_bytes(),
388 protocol.as_bytes(),
389 compiler_binary.as_os_str().as_encoded_bytes(),
390 working_dir.as_os_str().as_encoded_bytes(),
391 entrypoint.as_os_str().as_encoded_bytes(),
392 std::env::consts::OS.as_bytes(),
393 std::env::consts::ARCH.as_bytes(),
394 ])
395}
396
397#[cfg(feature = "native-ts")]
398fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
399 spec.validate()?;
400 if spec.runtime.kind != RuntimeKind::NativeTs {
401 return Err(FlowError::InvalidWorkflow(format!(
402 "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
403 spec.runtime.kind
404 )));
405 }
406 Ok(())
407}
408
409#[async_trait]
410impl FlowRuntime for NativeTsRuntime {
411 #[cfg(feature = "native-ts")]
412 async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
413 let spec = invocation.spec.clone();
414 self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
415 .await
416 }
417
418 #[cfg(not(feature = "native-ts"))]
419 async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
420 Err(FlowError::Runtime(
421 "native-ts feature is disabled for NativeTsRuntime".to_string(),
422 ))
423 }
424
425 #[cfg(feature = "native-ts")]
426 async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
427 let spec = workflow_spec_from_history(&invocation.history)?;
428 self.invoke(&spec, NativeRuntimeKind::Step, invocation)
429 .await
430 }
431
432 #[cfg(not(feature = "native-ts"))]
433 async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
434 Err(FlowError::Runtime(
435 "native-ts feature is disabled for NativeTsRuntime".to_string(),
436 ))
437 }
438}
439
440#[cfg(feature = "native-ts")]
441fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
442 let first = history
443 .first()
444 .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
445 match &first.event {
446 crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
447 _ => Err(FlowError::Runtime(
448 "first history event is not run_created".to_string(),
449 )),
450 }
451}
452
453#[cfg(feature = "native-ts")]
454fn sanitize_filename(value: &str) -> String {
455 value
456 .chars()
457 .map(|ch| {
458 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
459 ch
460 } else {
461 '-'
462 }
463 })
464 .collect()
465}
466
467#[cfg(feature = "native-ts")]
468fn resolve_against(root: &Path, value: &str) -> PathBuf {
469 let path = PathBuf::from(value);
470 if path.is_absolute() {
471 path
472 } else {
473 root.join(path)
474 }
475}
476
477#[cfg(feature = "native-ts")]
478fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
479 if path.is_absolute() {
480 return Ok(path.to_path_buf());
481 }
482 Ok(std::env::current_dir()?.join(path))
483}
484
485#[cfg(feature = "native-ts")]
486fn executable_from_current_dir(path: &Path) -> Result<PathBuf> {
487 if path.components().count() == 1 {
488 return Ok(path.to_path_buf());
489 }
490 absolute_from_current_dir(path)
491}
492
493#[cfg(feature = "native-ts")]
494fn temporary_artifact_path(artifact: &Path) -> Result<PathBuf> {
495 let file_name = artifact.file_name().ok_or_else(|| {
496 FlowError::Runtime(format!(
497 "native TypeScript artifact {} has no file name",
498 artifact.display()
499 ))
500 })?;
501 let temporary_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
502 Ok(artifact.with_file_name(temporary_name))
503}
504
505#[cfg(feature = "native-ts")]
506async fn publish_temporary_artifact(temporary: &Path, artifact: &Path) -> Result<()> {
507 match tokio::fs::rename(temporary, artifact).await {
508 Ok(()) => Ok(()),
509 Err(rename_error) => {
510 if tokio::fs::metadata(artifact).await.is_ok() {
511 remove_temporary_artifact(temporary).await;
512 return Ok(());
513 }
514 remove_temporary_artifact(temporary).await;
515 Err(FlowError::Runtime(format!(
516 "native TypeScript artifact {} could not be published atomically: {rename_error}",
517 artifact.display()
518 )))
519 }
520 }
521}
522
523#[cfg(feature = "native-ts")]
524async fn remove_temporary_artifact(path: &Path) {
525 match tokio::fs::remove_file(path).await {
526 Ok(()) => {}
527 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
528 Err(error) => tracing::warn!(
529 path = %path.display(),
530 %error,
531 "failed to remove temporary native TypeScript artifact"
532 ),
533 }
534}
535
536#[cfg(feature = "native-ts")]
537fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
538 let mut hasher = Sha256::new();
539 for part in parts {
540 let bytes = part.as_ref();
541 hasher.update(bytes.len().to_le_bytes());
542 hasher.update(bytes);
543 }
544 hex_lower(&hasher.finalize())
545}
546
547#[cfg(feature = "native-ts")]
548fn hex_lower(bytes: &[u8]) -> String {
549 const HEX: &[u8; 16] = b"0123456789abcdef";
550 let mut output = String::with_capacity(bytes.len() * 2);
551 for byte in bytes {
552 output.push(HEX[(byte >> 4) as usize] as char);
553 output.push(HEX[(byte & 0x0f) as usize] as char);
554 }
555 output
556}
557
558#[cfg(feature = "native-ts")]
559fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
560where
561 O: DeserializeOwned,
562{
563 let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
564 if response.protocol != NATIVE_RUNTIME_PROTOCOL {
565 return Err(FlowError::Runtime(format!(
566 "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
567 response.protocol
568 )));
569 }
570 if response.kind != kind {
571 return Err(FlowError::Runtime(format!(
572 "native TypeScript runtime response kind mismatch: expected {}, got {}",
573 kind.as_str(),
574 response.kind.as_str()
575 )));
576 }
577 if !response.ok {
578 let error = response
579 .error
580 .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
581 return Err(FlowError::Runtime(error));
582 }
583 let output = response.output.ok_or_else(|| {
584 FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
585 })?;
586 serde_json::from_value(output).map_err(FlowError::from)
587}
588
589#[cfg(test)]
590mod tests {
591 #[cfg(feature = "native-ts")]
592 use super::native_artifact_cache_key;
593 use super::NativeTsRuntimeConfig;
594 use std::path::Path;
595
596 #[test]
597 fn native_ts_default_cache_stays_under_a3s_state_root() {
598 let config = NativeTsRuntimeConfig::default();
599
600 assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
601 }
602
603 #[cfg(feature = "native-ts")]
604 #[test]
605 fn native_ts_artifact_cache_key_covers_the_compile_environment() {
606 let identity = |source, compiler, working_dir, entrypoint, protocol| {
607 native_artifact_cache_key(
608 source,
609 Path::new(compiler),
610 Path::new(working_dir),
611 Path::new(entrypoint),
612 protocol,
613 )
614 };
615 let baseline = identity(
616 "source-a",
617 "/compiler-a",
618 "/workspace-a",
619 "/workspace-a/workflow.ts",
620 "protocol-a",
621 );
622 let variants = [
623 (
624 "source-b",
625 "/compiler-a",
626 "/workspace-a",
627 "/workspace-a/workflow.ts",
628 "protocol-a",
629 ),
630 (
631 "source-a",
632 "/compiler-b",
633 "/workspace-a",
634 "/workspace-a/workflow.ts",
635 "protocol-a",
636 ),
637 (
638 "source-a",
639 "/compiler-a",
640 "/workspace-b",
641 "/workspace-a/workflow.ts",
642 "protocol-a",
643 ),
644 (
645 "source-a",
646 "/compiler-a",
647 "/workspace-a",
648 "/workspace-b/workflow.ts",
649 "protocol-a",
650 ),
651 (
652 "source-a",
653 "/compiler-a",
654 "/workspace-a",
655 "/workspace-a/workflow.ts",
656 "protocol-b",
657 ),
658 ];
659
660 for (source, compiler, working_dir, entrypoint, protocol) in variants {
661 assert_ne!(
662 identity(source, compiler, working_dir, entrypoint, protocol),
663 baseline
664 );
665 }
666 }
667}