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;
9use std::time::Duration;
10
11#[cfg(feature = "native-ts")]
12use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
13#[cfg(feature = "native-ts")]
14use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
15#[cfg(feature = "native-ts")]
16use uuid::Uuid;
17
18use crate::context::WorkflowContext;
19use crate::error::{FlowError, Result};
20#[cfg(feature = "native-ts")]
21use crate::model::RuntimeKind;
22use crate::model::{FlowEventEnvelope, JsonValue, RuntimeCommand, WorkflowSpec};
23#[cfg(feature = "native-ts")]
24use crate::protocol::{
25 NativeRuntimeKind, NativeRuntimeRequest, NativeRuntimeResponse, NATIVE_RUNTIME_PROTOCOL,
26};
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct WorkflowInvocation {
31 pub run_id: String,
32 pub spec: WorkflowSpec,
33 pub input: JsonValue,
34 pub history: Vec<FlowEventEnvelope>,
35}
36
37impl WorkflowInvocation {
38 pub fn context(&self) -> WorkflowContext<'_> {
40 WorkflowContext::new(self)
41 }
42
43 pub fn input_as<T>(&self) -> Result<T>
45 where
46 T: DeserializeOwned,
47 {
48 serde_json::from_value(self.input.clone()).map_err(FlowError::from)
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct StepInvocation {
55 pub run_id: String,
56 pub step_id: String,
57 pub step_name: String,
58 pub input: JsonValue,
59 pub history: Vec<FlowEventEnvelope>,
60}
61
62impl StepInvocation {
63 pub fn input_as<T>(&self) -> Result<T>
65 where
66 T: DeserializeOwned,
67 {
68 serde_json::from_value(self.input.clone()).map_err(FlowError::from)
69 }
70}
71
72#[async_trait]
74pub trait FlowRuntime: Send + Sync {
75 async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand>;
77
78 async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue>;
80}
81
82#[derive(Debug, Clone)]
84pub struct NativeTsRuntimeConfig {
85 pub compiler_binary: PathBuf,
88 pub cache_dir: PathBuf,
91 pub working_dir: PathBuf,
94}
95
96impl NativeTsRuntimeConfig {
97 pub fn new(
98 compiler_binary: impl Into<PathBuf>,
99 cache_dir: impl Into<PathBuf>,
100 working_dir: impl Into<PathBuf>,
101 ) -> Self {
102 Self {
103 compiler_binary: compiler_binary.into(),
104 cache_dir: cache_dir.into(),
105 working_dir: working_dir.into(),
106 }
107 }
108}
109
110impl Default for NativeTsRuntimeConfig {
111 fn default() -> Self {
112 Self {
113 compiler_binary: PathBuf::from("a3s-flow-native-compiler"),
114 cache_dir: PathBuf::from(".a3s/flow/native-ts"),
115 working_dir: PathBuf::from("."),
116 }
117 }
118}
119
120#[derive(Debug, Clone)]
123pub struct NativeTsRuntime {
124 config: NativeTsRuntimeConfig,
125 max_stdout_bytes: usize,
126 max_stderr_bytes: usize,
127 compile_timeout: Option<Duration>,
128 invocation_timeout: Option<Duration>,
129}
130
131#[cfg(feature = "native-ts")]
132#[derive(Debug, Clone)]
133struct NativeArtifact {
134 compiler_binary: PathBuf,
135 working_dir: PathBuf,
136 entrypoint: PathBuf,
137 binary: PathBuf,
138 source_hash: String,
139}
140
141#[cfg(feature = "native-ts")]
142struct NativeProcessOutput {
143 status: std::process::ExitStatus,
144 stdout: Vec<u8>,
145 stderr: Vec<u8>,
146}
147
148#[cfg(feature = "native-ts")]
149#[derive(Debug)]
150enum NativeProcessOutputError {
151 Io(std::io::Error),
152 LimitExceeded { stream: &'static str, limit: usize },
153}
154
155#[cfg(feature = "native-ts")]
156struct TemporaryArtifactGuard {
157 path: PathBuf,
158 armed: bool,
159}
160
161#[cfg(feature = "native-ts")]
162impl TemporaryArtifactGuard {
163 fn new(path: PathBuf) -> Self {
164 Self { path, armed: true }
165 }
166
167 fn path(&self) -> &Path {
168 &self.path
169 }
170
171 async fn remove(&mut self) {
172 remove_temporary_artifact(&self.path).await;
173 self.armed = false;
174 }
175
176 fn disarm(&mut self) {
177 self.armed = false;
178 }
179}
180
181#[cfg(feature = "native-ts")]
182impl Drop for TemporaryArtifactGuard {
183 fn drop(&mut self) {
184 if !self.armed {
185 return;
186 }
187
188 let path = self.path.clone();
189 match tokio::runtime::Handle::try_current() {
190 Ok(runtime) => {
191 let _cleanup = runtime.spawn(async move {
192 remove_temporary_artifact(&path).await;
193 });
194 }
195 Err(error) => tracing::warn!(
196 path = %self.path.display(),
197 %error,
198 "failed to schedule cancelled native TypeScript artifact cleanup"
199 ),
200 }
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
206pub struct NativeTsRuntimePreflight {
207 pub entrypoint: PathBuf,
209 pub artifact: PathBuf,
211 pub source_hash: String,
213 pub cache_hit: bool,
215}
216
217impl NativeTsRuntime {
218 pub const DEFAULT_MAX_STDOUT_BYTES: usize = 8 * 1024 * 1024;
220
221 pub const DEFAULT_MAX_STDERR_BYTES: usize = 256 * 1024;
223
224 pub fn new(config: NativeTsRuntimeConfig) -> Self {
225 Self {
226 config,
227 max_stdout_bytes: Self::DEFAULT_MAX_STDOUT_BYTES,
228 max_stderr_bytes: Self::DEFAULT_MAX_STDERR_BYTES,
229 compile_timeout: None,
230 invocation_timeout: None,
231 }
232 }
233
234 pub fn config(&self) -> &NativeTsRuntimeConfig {
235 &self.config
236 }
237
238 pub fn with_output_limits(mut self, max_stdout_bytes: usize, max_stderr_bytes: usize) -> Self {
245 self.max_stdout_bytes = max_stdout_bytes;
246 self.max_stderr_bytes = max_stderr_bytes;
247 self
248 }
249
250 pub fn max_stdout_bytes(&self) -> usize {
252 self.max_stdout_bytes
253 }
254
255 pub fn max_stderr_bytes(&self) -> usize {
257 self.max_stderr_bytes
258 }
259
260 pub fn with_compile_timeout(mut self, timeout: Duration) -> Self {
266 self.compile_timeout = Some(timeout);
267 self
268 }
269
270 pub fn with_invocation_timeout(mut self, timeout: Duration) -> Self {
277 self.invocation_timeout = Some(timeout);
278 self
279 }
280
281 pub fn compile_timeout(&self) -> Option<Duration> {
283 self.compile_timeout
284 }
285
286 pub fn invocation_timeout(&self) -> Option<Duration> {
288 self.invocation_timeout
289 }
290
291 #[cfg(feature = "native-ts")]
292 pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
293 let (artifact, cache_hit) = self.compile_if_needed(spec).await?;
294 Ok(NativeTsRuntimePreflight {
295 entrypoint: artifact.entrypoint,
296 artifact: artifact.binary,
297 source_hash: artifact.source_hash,
298 cache_hit,
299 })
300 }
301
302 #[cfg(not(feature = "native-ts"))]
303 pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
304 Err(FlowError::Runtime(
305 "native-ts feature is disabled for NativeTsRuntime".to_string(),
306 ))
307 }
308
309 #[cfg(feature = "native-ts")]
310 async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
311 validate_native_ts_spec(spec)?;
312 let compiler_binary = executable_from_current_dir(&self.config.compiler_binary)?;
313 let working_dir = absolute_from_current_dir(&self.config.working_dir)?;
314 let entrypoint = resolve_against(&working_dir, &spec.runtime.entrypoint);
315 let cache_dir = absolute_from_current_dir(&self.config.cache_dir)?;
316 let source = tokio::fs::read(&entrypoint).await?;
317 let source_hash = native_source_hash(spec, &source);
321 let artifact_hash = native_artifact_cache_key(
322 &source_hash,
323 &compiler_binary,
324 &working_dir,
325 &entrypoint,
326 NATIVE_RUNTIME_PROTOCOL,
327 );
328 let name = format!("{}-{artifact_hash}", sanitize_filename(&spec.name));
329 Ok(NativeArtifact {
330 compiler_binary,
331 working_dir,
332 entrypoint,
333 binary: cache_dir.join(name),
334 source_hash,
335 })
336 }
337
338 #[cfg(feature = "native-ts")]
339 async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
340 let artifact = self.artifact_for(spec).await?;
341 if tokio::fs::metadata(&artifact.binary).await.is_ok() {
342 return Ok((artifact, true));
343 }
344
345 let cache_dir = artifact.binary.parent().ok_or_else(|| {
346 FlowError::Runtime(format!(
347 "native TypeScript artifact {} has no cache directory",
348 artifact.binary.display()
349 ))
350 })?;
351 tokio::fs::create_dir_all(cache_dir).await?;
352 let mut temporary_binary =
356 TemporaryArtifactGuard::new(temporary_artifact_path(&artifact.binary)?);
357 let child = match Command::new(&artifact.compiler_binary)
358 .arg("compile")
359 .arg(&artifact.entrypoint)
360 .arg("-o")
361 .arg(temporary_binary.path())
362 .current_dir(&artifact.working_dir)
363 .stdin(std::process::Stdio::null())
364 .stdout(std::process::Stdio::piped())
365 .stderr(std::process::Stdio::piped())
366 .kill_on_drop(true)
369 .spawn()
370 {
371 Ok(child) => child,
372 Err(error) => {
373 temporary_binary.remove().await;
374 return Err(error.into());
375 }
376 };
377 let output = match communicate_with_bounded_output(
378 child,
379 "compiler",
380 None,
381 self.max_stdout_bytes,
382 self.max_stderr_bytes,
383 self.compile_timeout,
384 )
385 .await
386 {
387 Ok(output) => output,
388 Err(error) => {
389 temporary_binary.remove().await;
390 return Err(error);
391 }
392 };
393
394 if !output.status.success() {
395 temporary_binary.remove().await;
396 return Err(FlowError::Runtime(format!(
397 "native TypeScript compile failed: {}",
398 String::from_utf8_lossy(&output.stderr)
399 )));
400 }
401
402 if let Err(error) = tokio::fs::metadata(temporary_binary.path()).await {
403 temporary_binary.remove().await;
404 return Err(FlowError::Runtime(format!(
405 "native TypeScript compiler did not produce artifact {}: {error}",
406 artifact.binary.display()
407 )));
408 }
409 publish_temporary_artifact(temporary_binary.path(), &artifact.binary).await?;
410 temporary_binary.disarm();
411
412 Ok((artifact, false))
413 }
414
415 #[cfg(feature = "native-ts")]
416 async fn invoke<I, O>(
417 &self,
418 spec: &WorkflowSpec,
419 kind: NativeRuntimeKind,
420 payload: I,
421 ) -> Result<O>
422 where
423 I: Serialize + Send,
424 O: DeserializeOwned,
425 {
426 let (artifact, _) = self.compile_if_needed(spec).await?;
427 let request = serde_json::to_vec(&NativeRuntimeRequest::new(
428 kind,
429 spec.runtime.export_name.clone(),
430 artifact.source_hash,
431 payload,
432 ))?;
433
434 let child = Command::new(&artifact.binary)
435 .arg("--a3s-flow-runtime")
436 .stdin(std::process::Stdio::piped())
437 .stdout(std::process::Stdio::piped())
438 .stderr(std::process::Stdio::piped())
439 .current_dir(&artifact.working_dir)
440 .kill_on_drop(true)
443 .spawn()?;
444
445 let output = communicate_with_bounded_output(
446 child,
447 "runtime",
448 Some(request),
449 self.max_stdout_bytes,
450 self.max_stderr_bytes,
451 self.invocation_timeout,
452 )
453 .await?;
454 if !output.status.success() {
455 return Err(FlowError::Runtime(format!(
456 "native TypeScript runtime failed: {}",
457 String::from_utf8_lossy(&output.stderr)
458 )));
459 }
460
461 decode_native_response(kind, &output.stdout)
462 }
463}
464
465#[cfg(feature = "native-ts")]
466async fn communicate_with_bounded_output(
467 mut child: Child,
468 process_kind: &'static str,
469 stdin_bytes: Option<Vec<u8>>,
470 max_stdout_bytes: usize,
471 max_stderr_bytes: usize,
472 process_timeout: Option<Duration>,
473) -> Result<NativeProcessOutput> {
474 let stdin = match stdin_bytes {
475 Some(bytes) => Some((
476 child.stdin.take().ok_or_else(|| {
477 FlowError::Runtime(format!(
478 "native TypeScript {process_kind} stdin pipe is unavailable"
479 ))
480 })?,
481 bytes,
482 )),
483 None => None,
484 };
485 let stdout = child.stdout.take().ok_or_else(|| {
486 FlowError::Runtime(format!(
487 "native TypeScript {process_kind} stdout pipe is unavailable"
488 ))
489 })?;
490 let stderr = child.stderr.take().ok_or_else(|| {
491 FlowError::Runtime(format!(
492 "native TypeScript {process_kind} stderr pipe is unavailable"
493 ))
494 })?;
495
496 let communication = collect_native_process_output(
497 &mut child,
498 stdin,
499 stdout,
500 stderr,
501 max_stdout_bytes,
502 max_stderr_bytes,
503 );
504 let output = match process_timeout {
505 Some(timeout) => match tokio::time::timeout(timeout, communication).await {
506 Ok(output) => output,
507 Err(_) => {
508 terminate_and_reap(&mut child).await;
509 return Err(FlowError::Runtime(format!(
510 "native TypeScript {process_kind} timed out after {timeout:?}"
511 )));
512 }
513 },
514 None => communication.await,
515 };
516
517 match output {
518 Ok(output) => Ok(output),
519 Err(error) => {
520 terminate_and_reap(&mut child).await;
523 match error {
524 NativeProcessOutputError::Io(error) => Err(error.into()),
525 NativeProcessOutputError::LimitExceeded { stream, limit } => {
526 Err(FlowError::Runtime(format!(
527 "native TypeScript {process_kind} {stream} exceeded the {limit}-byte limit"
528 )))
529 }
530 }
531 }
532 }
533}
534
535#[cfg(feature = "native-ts")]
536async fn collect_native_process_output(
537 child: &mut Child,
538 stdin: Option<(ChildStdin, Vec<u8>)>,
539 stdout: ChildStdout,
540 stderr: ChildStderr,
541 max_stdout_bytes: usize,
542 max_stderr_bytes: usize,
543) -> std::result::Result<NativeProcessOutput, NativeProcessOutputError> {
544 let write_stdin = async move {
545 if let Some((mut stdin, bytes)) = stdin {
546 stdin
547 .write_all(&bytes)
548 .await
549 .map_err(NativeProcessOutputError::Io)?;
550 stdin
551 .shutdown()
552 .await
553 .map_err(NativeProcessOutputError::Io)?;
554 }
555 Ok(())
556 };
557 let wait = async { child.wait().await.map_err(NativeProcessOutputError::Io) };
558 let stdout = read_bounded_output(stdout, "stdout", max_stdout_bytes);
559 let stderr = read_bounded_output(stderr, "stderr", max_stderr_bytes);
560 let (status, (), stdout, stderr) = tokio::try_join!(wait, write_stdin, stdout, stderr)?;
561 Ok(NativeProcessOutput {
562 status,
563 stdout,
564 stderr,
565 })
566}
567
568#[cfg(feature = "native-ts")]
569async fn terminate_and_reap(child: &mut Child) {
570 let _ = child.start_kill();
571 let _ = child.wait().await;
572}
573
574#[cfg(feature = "native-ts")]
575async fn read_bounded_output<R>(
576 mut reader: R,
577 stream: &'static str,
578 limit: usize,
579) -> std::result::Result<Vec<u8>, NativeProcessOutputError>
580where
581 R: AsyncRead + Unpin,
582{
583 let mut output = Vec::with_capacity(limit.min(8 * 1024));
584 let mut buffer = [0_u8; 8 * 1024];
585 loop {
586 let count = reader
587 .read(&mut buffer)
588 .await
589 .map_err(NativeProcessOutputError::Io)?;
590 if count == 0 {
591 return Ok(output);
592 }
593 if count > limit.saturating_sub(output.len()) {
594 return Err(NativeProcessOutputError::LimitExceeded { stream, limit });
595 }
596 output.extend_from_slice(&buffer[..count]);
597 }
598}
599
600#[cfg(feature = "native-ts")]
601fn native_source_hash(spec: &WorkflowSpec, source: &[u8]) -> String {
602 stable_hash([
603 b"source".as_slice(),
604 spec.name.as_bytes(),
605 spec.version.as_bytes(),
606 spec.runtime.entrypoint.as_bytes(),
607 spec.runtime.export_name.as_bytes(),
608 source,
609 ])
610}
611
612#[cfg(feature = "native-ts")]
613fn native_artifact_cache_key(
614 source_hash: &str,
615 compiler_binary: &Path,
616 working_dir: &Path,
617 entrypoint: &Path,
618 protocol: &str,
619) -> String {
620 stable_hash([
621 b"a3s.flow.native_ts.artifact.v1".as_slice(),
622 source_hash.as_bytes(),
623 protocol.as_bytes(),
624 compiler_binary.as_os_str().as_encoded_bytes(),
625 working_dir.as_os_str().as_encoded_bytes(),
626 entrypoint.as_os_str().as_encoded_bytes(),
627 std::env::consts::OS.as_bytes(),
628 std::env::consts::ARCH.as_bytes(),
629 ])
630}
631
632#[cfg(feature = "native-ts")]
633fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
634 spec.validate()?;
635 if spec.runtime.kind != RuntimeKind::NativeTs {
636 return Err(FlowError::InvalidWorkflow(format!(
637 "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
638 spec.runtime.kind
639 )));
640 }
641 Ok(())
642}
643
644#[async_trait]
645impl FlowRuntime for NativeTsRuntime {
646 #[cfg(feature = "native-ts")]
647 async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
648 let spec = invocation.spec.clone();
649 self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
650 .await
651 }
652
653 #[cfg(not(feature = "native-ts"))]
654 async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
655 Err(FlowError::Runtime(
656 "native-ts feature is disabled for NativeTsRuntime".to_string(),
657 ))
658 }
659
660 #[cfg(feature = "native-ts")]
661 async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
662 let spec = workflow_spec_from_history(&invocation.history)?;
663 self.invoke(&spec, NativeRuntimeKind::Step, invocation)
664 .await
665 }
666
667 #[cfg(not(feature = "native-ts"))]
668 async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
669 Err(FlowError::Runtime(
670 "native-ts feature is disabled for NativeTsRuntime".to_string(),
671 ))
672 }
673}
674
675#[cfg(feature = "native-ts")]
676fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
677 let first = history
678 .first()
679 .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
680 match &first.event {
681 crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
682 _ => Err(FlowError::Runtime(
683 "first history event is not run_created".to_string(),
684 )),
685 }
686}
687
688#[cfg(feature = "native-ts")]
689fn sanitize_filename(value: &str) -> String {
690 value
691 .chars()
692 .map(|ch| {
693 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
694 ch
695 } else {
696 '-'
697 }
698 })
699 .collect()
700}
701
702#[cfg(feature = "native-ts")]
703fn resolve_against(root: &Path, value: &str) -> PathBuf {
704 let path = PathBuf::from(value);
705 if path.is_absolute() {
706 path
707 } else {
708 root.join(path)
709 }
710}
711
712#[cfg(feature = "native-ts")]
713fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
714 if path.is_absolute() {
715 return Ok(path.to_path_buf());
716 }
717 Ok(std::env::current_dir()?.join(path))
718}
719
720#[cfg(feature = "native-ts")]
721fn executable_from_current_dir(path: &Path) -> Result<PathBuf> {
722 if path.components().count() == 1 {
723 return Ok(path.to_path_buf());
724 }
725 absolute_from_current_dir(path)
726}
727
728#[cfg(feature = "native-ts")]
729fn temporary_artifact_path(artifact: &Path) -> Result<PathBuf> {
730 let file_name = artifact.file_name().ok_or_else(|| {
731 FlowError::Runtime(format!(
732 "native TypeScript artifact {} has no file name",
733 artifact.display()
734 ))
735 })?;
736 let temporary_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
737 Ok(artifact.with_file_name(temporary_name))
738}
739
740#[cfg(feature = "native-ts")]
741async fn publish_temporary_artifact(temporary: &Path, artifact: &Path) -> Result<()> {
742 match tokio::fs::rename(temporary, artifact).await {
743 Ok(()) => Ok(()),
744 Err(rename_error) => {
745 if tokio::fs::metadata(artifact).await.is_ok() {
746 remove_temporary_artifact(temporary).await;
747 return Ok(());
748 }
749 remove_temporary_artifact(temporary).await;
750 Err(FlowError::Runtime(format!(
751 "native TypeScript artifact {} could not be published atomically: {rename_error}",
752 artifact.display()
753 )))
754 }
755 }
756}
757
758#[cfg(feature = "native-ts")]
759async fn remove_temporary_artifact(path: &Path) {
760 match tokio::fs::remove_file(path).await {
761 Ok(()) => {}
762 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
763 Err(error) => tracing::warn!(
764 path = %path.display(),
765 %error,
766 "failed to remove temporary native TypeScript artifact"
767 ),
768 }
769}
770
771#[cfg(feature = "native-ts")]
772fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
773 let mut hasher = Sha256::new();
774 for part in parts {
775 let bytes = part.as_ref();
776 hasher.update(bytes.len().to_le_bytes());
777 hasher.update(bytes);
778 }
779 hex_lower(&hasher.finalize())
780}
781
782#[cfg(feature = "native-ts")]
783fn hex_lower(bytes: &[u8]) -> String {
784 const HEX: &[u8; 16] = b"0123456789abcdef";
785 let mut output = String::with_capacity(bytes.len() * 2);
786 for byte in bytes {
787 output.push(HEX[(byte >> 4) as usize] as char);
788 output.push(HEX[(byte & 0x0f) as usize] as char);
789 }
790 output
791}
792
793#[cfg(feature = "native-ts")]
794fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
795where
796 O: DeserializeOwned,
797{
798 let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
799 if response.protocol != NATIVE_RUNTIME_PROTOCOL {
800 return Err(FlowError::Runtime(format!(
801 "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
802 response.protocol
803 )));
804 }
805 if response.kind != kind {
806 return Err(FlowError::Runtime(format!(
807 "native TypeScript runtime response kind mismatch: expected {}, got {}",
808 kind.as_str(),
809 response.kind.as_str()
810 )));
811 }
812 if !response.ok {
813 let error = response
814 .error
815 .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
816 return Err(FlowError::Runtime(error));
817 }
818 let output = response.output.ok_or_else(|| {
819 FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
820 })?;
821 serde_json::from_value(output).map_err(FlowError::from)
822}
823
824#[cfg(test)]
825mod tests {
826 #[cfg(feature = "native-ts")]
827 use super::{native_artifact_cache_key, read_bounded_output, NativeProcessOutputError};
828 use super::{NativeTsRuntime, NativeTsRuntimeConfig};
829 use std::path::Path;
830 use std::time::Duration;
831
832 #[test]
833 fn native_ts_default_cache_stays_under_a3s_state_root() {
834 let config = NativeTsRuntimeConfig::default();
835
836 assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
837 }
838
839 #[test]
840 fn native_ts_runtime_output_limits_are_configurable() {
841 let runtime = NativeTsRuntime::new(NativeTsRuntimeConfig::default());
842
843 assert_eq!(
844 runtime.max_stdout_bytes(),
845 NativeTsRuntime::DEFAULT_MAX_STDOUT_BYTES
846 );
847 assert_eq!(
848 runtime.max_stderr_bytes(),
849 NativeTsRuntime::DEFAULT_MAX_STDERR_BYTES
850 );
851
852 let runtime = runtime.with_output_limits(123, 45);
853 assert_eq!(runtime.max_stdout_bytes(), 123);
854 assert_eq!(runtime.max_stderr_bytes(), 45);
855 }
856
857 #[test]
858 fn native_ts_runtime_timeouts_are_opt_in_and_configurable() {
859 let runtime = NativeTsRuntime::new(NativeTsRuntimeConfig::default());
860
861 assert_eq!(runtime.compile_timeout(), None);
862 assert_eq!(runtime.invocation_timeout(), None);
863
864 let runtime = runtime
865 .with_compile_timeout(Duration::from_secs(30))
866 .with_invocation_timeout(Duration::from_secs(5));
867 assert_eq!(runtime.compile_timeout(), Some(Duration::from_secs(30)));
868 assert_eq!(runtime.invocation_timeout(), Some(Duration::from_secs(5)));
869 }
870
871 #[cfg(feature = "native-ts")]
872 #[tokio::test]
873 async fn native_ts_output_reader_accepts_exact_limit_and_rejects_next_byte() {
874 let exact = read_bounded_output(&b"1234"[..], "stdout", 4)
875 .await
876 .unwrap();
877 assert_eq!(exact, b"1234");
878
879 let error = read_bounded_output(&b"12345"[..], "stdout", 4)
880 .await
881 .unwrap_err();
882 assert!(matches!(
883 error,
884 NativeProcessOutputError::LimitExceeded {
885 stream: "stdout",
886 limit: 4
887 }
888 ));
889 }
890
891 #[cfg(feature = "native-ts")]
892 #[test]
893 fn native_ts_artifact_cache_key_covers_the_compile_environment() {
894 let identity = |source, compiler, working_dir, entrypoint, protocol| {
895 native_artifact_cache_key(
896 source,
897 Path::new(compiler),
898 Path::new(working_dir),
899 Path::new(entrypoint),
900 protocol,
901 )
902 };
903 let baseline = identity(
904 "source-a",
905 "/compiler-a",
906 "/workspace-a",
907 "/workspace-a/workflow.ts",
908 "protocol-a",
909 );
910 let variants = [
911 (
912 "source-b",
913 "/compiler-a",
914 "/workspace-a",
915 "/workspace-a/workflow.ts",
916 "protocol-a",
917 ),
918 (
919 "source-a",
920 "/compiler-b",
921 "/workspace-a",
922 "/workspace-a/workflow.ts",
923 "protocol-a",
924 ),
925 (
926 "source-a",
927 "/compiler-a",
928 "/workspace-b",
929 "/workspace-a/workflow.ts",
930 "protocol-a",
931 ),
932 (
933 "source-a",
934 "/compiler-a",
935 "/workspace-a",
936 "/workspace-b/workflow.ts",
937 "protocol-a",
938 ),
939 (
940 "source-a",
941 "/compiler-a",
942 "/workspace-a",
943 "/workspace-a/workflow.ts",
944 "protocol-b",
945 ),
946 ];
947
948 for (source, compiler, working_dir, entrypoint, protocol) in variants {
949 assert_ne!(
950 identity(source, compiler, working_dir, entrypoint, protocol),
951 baseline
952 );
953 }
954 }
955}