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::{AsyncRead, AsyncReadExt, AsyncWriteExt};
12#[cfg(feature = "native-ts")]
13use tokio::process::{Child, 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 max_stdout_bytes: usize,
125 max_stderr_bytes: usize,
126}
127
128#[cfg(feature = "native-ts")]
129#[derive(Debug, Clone)]
130struct NativeArtifact {
131 compiler_binary: PathBuf,
132 working_dir: PathBuf,
133 entrypoint: PathBuf,
134 binary: PathBuf,
135 source_hash: String,
136}
137
138#[cfg(feature = "native-ts")]
139struct NativeProcessOutput {
140 status: std::process::ExitStatus,
141 stdout: Vec<u8>,
142 stderr: Vec<u8>,
143}
144
145#[cfg(feature = "native-ts")]
146#[derive(Debug)]
147enum NativeProcessOutputError {
148 Io(std::io::Error),
149 LimitExceeded { stream: &'static str, limit: usize },
150}
151
152#[cfg(feature = "native-ts")]
153struct TemporaryArtifactGuard {
154 path: PathBuf,
155 armed: bool,
156}
157
158#[cfg(feature = "native-ts")]
159impl TemporaryArtifactGuard {
160 fn new(path: PathBuf) -> Self {
161 Self { path, armed: true }
162 }
163
164 fn path(&self) -> &Path {
165 &self.path
166 }
167
168 async fn remove(&mut self) {
169 remove_temporary_artifact(&self.path).await;
170 self.armed = false;
171 }
172
173 fn disarm(&mut self) {
174 self.armed = false;
175 }
176}
177
178#[cfg(feature = "native-ts")]
179impl Drop for TemporaryArtifactGuard {
180 fn drop(&mut self) {
181 if !self.armed {
182 return;
183 }
184
185 let path = self.path.clone();
186 match tokio::runtime::Handle::try_current() {
187 Ok(runtime) => {
188 let _cleanup = runtime.spawn(async move {
189 remove_temporary_artifact(&path).await;
190 });
191 }
192 Err(error) => tracing::warn!(
193 path = %self.path.display(),
194 %error,
195 "failed to schedule cancelled native TypeScript artifact cleanup"
196 ),
197 }
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203pub struct NativeTsRuntimePreflight {
204 pub entrypoint: PathBuf,
206 pub artifact: PathBuf,
208 pub source_hash: String,
210 pub cache_hit: bool,
212}
213
214impl NativeTsRuntime {
215 pub const DEFAULT_MAX_STDOUT_BYTES: usize = 8 * 1024 * 1024;
217
218 pub const DEFAULT_MAX_STDERR_BYTES: usize = 256 * 1024;
220
221 pub fn new(config: NativeTsRuntimeConfig) -> Self {
222 Self {
223 config,
224 max_stdout_bytes: Self::DEFAULT_MAX_STDOUT_BYTES,
225 max_stderr_bytes: Self::DEFAULT_MAX_STDERR_BYTES,
226 }
227 }
228
229 pub fn config(&self) -> &NativeTsRuntimeConfig {
230 &self.config
231 }
232
233 pub fn with_output_limits(mut self, max_stdout_bytes: usize, max_stderr_bytes: usize) -> Self {
240 self.max_stdout_bytes = max_stdout_bytes;
241 self.max_stderr_bytes = max_stderr_bytes;
242 self
243 }
244
245 pub fn max_stdout_bytes(&self) -> usize {
247 self.max_stdout_bytes
248 }
249
250 pub fn max_stderr_bytes(&self) -> usize {
252 self.max_stderr_bytes
253 }
254
255 #[cfg(feature = "native-ts")]
256 pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
257 let (artifact, cache_hit) = self.compile_if_needed(spec).await?;
258 Ok(NativeTsRuntimePreflight {
259 entrypoint: artifact.entrypoint,
260 artifact: artifact.binary,
261 source_hash: artifact.source_hash,
262 cache_hit,
263 })
264 }
265
266 #[cfg(not(feature = "native-ts"))]
267 pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
268 Err(FlowError::Runtime(
269 "native-ts feature is disabled for NativeTsRuntime".to_string(),
270 ))
271 }
272
273 #[cfg(feature = "native-ts")]
274 async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
275 validate_native_ts_spec(spec)?;
276 let compiler_binary = executable_from_current_dir(&self.config.compiler_binary)?;
277 let working_dir = absolute_from_current_dir(&self.config.working_dir)?;
278 let entrypoint = resolve_against(&working_dir, &spec.runtime.entrypoint);
279 let cache_dir = absolute_from_current_dir(&self.config.cache_dir)?;
280 let source = tokio::fs::read(&entrypoint).await?;
281 let source_hash = native_source_hash(spec, &source);
285 let artifact_hash = native_artifact_cache_key(
286 &source_hash,
287 &compiler_binary,
288 &working_dir,
289 &entrypoint,
290 NATIVE_RUNTIME_PROTOCOL,
291 );
292 let name = format!("{}-{artifact_hash}", sanitize_filename(&spec.name));
293 Ok(NativeArtifact {
294 compiler_binary,
295 working_dir,
296 entrypoint,
297 binary: cache_dir.join(name),
298 source_hash,
299 })
300 }
301
302 #[cfg(feature = "native-ts")]
303 async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
304 let artifact = self.artifact_for(spec).await?;
305 if tokio::fs::metadata(&artifact.binary).await.is_ok() {
306 return Ok((artifact, true));
307 }
308
309 let cache_dir = artifact.binary.parent().ok_or_else(|| {
310 FlowError::Runtime(format!(
311 "native TypeScript artifact {} has no cache directory",
312 artifact.binary.display()
313 ))
314 })?;
315 tokio::fs::create_dir_all(cache_dir).await?;
316 let mut temporary_binary =
320 TemporaryArtifactGuard::new(temporary_artifact_path(&artifact.binary)?);
321 let child = match Command::new(&artifact.compiler_binary)
322 .arg("compile")
323 .arg(&artifact.entrypoint)
324 .arg("-o")
325 .arg(temporary_binary.path())
326 .current_dir(&artifact.working_dir)
327 .stdin(std::process::Stdio::null())
328 .stdout(std::process::Stdio::piped())
329 .stderr(std::process::Stdio::piped())
330 .kill_on_drop(true)
333 .spawn()
334 {
335 Ok(child) => child,
336 Err(error) => {
337 temporary_binary.remove().await;
338 return Err(error.into());
339 }
340 };
341 let output = match wait_with_bounded_output(
342 child,
343 "compiler",
344 self.max_stdout_bytes,
345 self.max_stderr_bytes,
346 )
347 .await
348 {
349 Ok(output) => output,
350 Err(error) => {
351 temporary_binary.remove().await;
352 return Err(error);
353 }
354 };
355
356 if !output.status.success() {
357 temporary_binary.remove().await;
358 return Err(FlowError::Runtime(format!(
359 "native TypeScript compile failed: {}",
360 String::from_utf8_lossy(&output.stderr)
361 )));
362 }
363
364 if let Err(error) = tokio::fs::metadata(temporary_binary.path()).await {
365 temporary_binary.remove().await;
366 return Err(FlowError::Runtime(format!(
367 "native TypeScript compiler did not produce artifact {}: {error}",
368 artifact.binary.display()
369 )));
370 }
371 publish_temporary_artifact(temporary_binary.path(), &artifact.binary).await?;
372 temporary_binary.disarm();
373
374 Ok((artifact, false))
375 }
376
377 #[cfg(feature = "native-ts")]
378 async fn invoke<I, O>(
379 &self,
380 spec: &WorkflowSpec,
381 kind: NativeRuntimeKind,
382 payload: I,
383 ) -> Result<O>
384 where
385 I: Serialize + Send,
386 O: DeserializeOwned,
387 {
388 let (artifact, _) = self.compile_if_needed(spec).await?;
389 let request = NativeRuntimeRequest::new(
390 kind,
391 spec.runtime.export_name.clone(),
392 artifact.source_hash,
393 payload,
394 );
395
396 let mut child = Command::new(&artifact.binary)
397 .arg("--a3s-flow-runtime")
398 .stdin(std::process::Stdio::piped())
399 .stdout(std::process::Stdio::piped())
400 .stderr(std::process::Stdio::piped())
401 .current_dir(&artifact.working_dir)
402 .kill_on_drop(true)
405 .spawn()?;
406
407 let mut stdin = child
408 .stdin
409 .take()
410 .ok_or_else(|| FlowError::Runtime("failed to open runtime stdin".to_string()))?;
411 stdin
412 .write_all(serde_json::to_string(&request)?.as_bytes())
413 .await?;
414 stdin.shutdown().await?;
415 drop(stdin);
416
417 let output = wait_with_bounded_output(
418 child,
419 "runtime",
420 self.max_stdout_bytes,
421 self.max_stderr_bytes,
422 )
423 .await?;
424 if !output.status.success() {
425 return Err(FlowError::Runtime(format!(
426 "native TypeScript runtime failed: {}",
427 String::from_utf8_lossy(&output.stderr)
428 )));
429 }
430
431 decode_native_response(kind, &output.stdout)
432 }
433}
434
435#[cfg(feature = "native-ts")]
436async fn wait_with_bounded_output(
437 mut child: Child,
438 process_kind: &'static str,
439 max_stdout_bytes: usize,
440 max_stderr_bytes: usize,
441) -> Result<NativeProcessOutput> {
442 let stdout = child.stdout.take().ok_or_else(|| {
443 FlowError::Runtime(format!(
444 "native TypeScript {process_kind} stdout pipe is unavailable"
445 ))
446 })?;
447 let stderr = child.stderr.take().ok_or_else(|| {
448 FlowError::Runtime(format!(
449 "native TypeScript {process_kind} stderr pipe is unavailable"
450 ))
451 })?;
452
453 let output = {
454 let wait = async { child.wait().await.map_err(NativeProcessOutputError::Io) };
455 let stdout = read_bounded_output(stdout, "stdout", max_stdout_bytes);
456 let stderr = read_bounded_output(stderr, "stderr", max_stderr_bytes);
457 tokio::try_join!(wait, stdout, stderr)
458 };
459
460 match output {
461 Ok((status, stdout, stderr)) => Ok(NativeProcessOutput {
462 status,
463 stdout,
464 stderr,
465 }),
466 Err(error) => {
467 let _ = child.start_kill();
470 let _ = child.wait().await;
471 match error {
472 NativeProcessOutputError::Io(error) => Err(error.into()),
473 NativeProcessOutputError::LimitExceeded { stream, limit } => {
474 Err(FlowError::Runtime(format!(
475 "native TypeScript {process_kind} {stream} exceeded the {limit}-byte limit"
476 )))
477 }
478 }
479 }
480 }
481}
482
483#[cfg(feature = "native-ts")]
484async fn read_bounded_output<R>(
485 mut reader: R,
486 stream: &'static str,
487 limit: usize,
488) -> std::result::Result<Vec<u8>, NativeProcessOutputError>
489where
490 R: AsyncRead + Unpin,
491{
492 let mut output = Vec::with_capacity(limit.min(8 * 1024));
493 let mut buffer = [0_u8; 8 * 1024];
494 loop {
495 let count = reader
496 .read(&mut buffer)
497 .await
498 .map_err(NativeProcessOutputError::Io)?;
499 if count == 0 {
500 return Ok(output);
501 }
502 if count > limit.saturating_sub(output.len()) {
503 return Err(NativeProcessOutputError::LimitExceeded { stream, limit });
504 }
505 output.extend_from_slice(&buffer[..count]);
506 }
507}
508
509#[cfg(feature = "native-ts")]
510fn native_source_hash(spec: &WorkflowSpec, source: &[u8]) -> String {
511 stable_hash([
512 b"source".as_slice(),
513 spec.name.as_bytes(),
514 spec.version.as_bytes(),
515 spec.runtime.entrypoint.as_bytes(),
516 spec.runtime.export_name.as_bytes(),
517 source,
518 ])
519}
520
521#[cfg(feature = "native-ts")]
522fn native_artifact_cache_key(
523 source_hash: &str,
524 compiler_binary: &Path,
525 working_dir: &Path,
526 entrypoint: &Path,
527 protocol: &str,
528) -> String {
529 stable_hash([
530 b"a3s.flow.native_ts.artifact.v1".as_slice(),
531 source_hash.as_bytes(),
532 protocol.as_bytes(),
533 compiler_binary.as_os_str().as_encoded_bytes(),
534 working_dir.as_os_str().as_encoded_bytes(),
535 entrypoint.as_os_str().as_encoded_bytes(),
536 std::env::consts::OS.as_bytes(),
537 std::env::consts::ARCH.as_bytes(),
538 ])
539}
540
541#[cfg(feature = "native-ts")]
542fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
543 spec.validate()?;
544 if spec.runtime.kind != RuntimeKind::NativeTs {
545 return Err(FlowError::InvalidWorkflow(format!(
546 "NativeTsRuntime requires a native_ts workflow spec, got {:?}",
547 spec.runtime.kind
548 )));
549 }
550 Ok(())
551}
552
553#[async_trait]
554impl FlowRuntime for NativeTsRuntime {
555 #[cfg(feature = "native-ts")]
556 async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
557 let spec = invocation.spec.clone();
558 self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
559 .await
560 }
561
562 #[cfg(not(feature = "native-ts"))]
563 async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
564 Err(FlowError::Runtime(
565 "native-ts feature is disabled for NativeTsRuntime".to_string(),
566 ))
567 }
568
569 #[cfg(feature = "native-ts")]
570 async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
571 let spec = workflow_spec_from_history(&invocation.history)?;
572 self.invoke(&spec, NativeRuntimeKind::Step, invocation)
573 .await
574 }
575
576 #[cfg(not(feature = "native-ts"))]
577 async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
578 Err(FlowError::Runtime(
579 "native-ts feature is disabled for NativeTsRuntime".to_string(),
580 ))
581 }
582}
583
584#[cfg(feature = "native-ts")]
585fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
586 let first = history
587 .first()
588 .ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
589 match &first.event {
590 crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
591 _ => Err(FlowError::Runtime(
592 "first history event is not run_created".to_string(),
593 )),
594 }
595}
596
597#[cfg(feature = "native-ts")]
598fn sanitize_filename(value: &str) -> String {
599 value
600 .chars()
601 .map(|ch| {
602 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
603 ch
604 } else {
605 '-'
606 }
607 })
608 .collect()
609}
610
611#[cfg(feature = "native-ts")]
612fn resolve_against(root: &Path, value: &str) -> PathBuf {
613 let path = PathBuf::from(value);
614 if path.is_absolute() {
615 path
616 } else {
617 root.join(path)
618 }
619}
620
621#[cfg(feature = "native-ts")]
622fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
623 if path.is_absolute() {
624 return Ok(path.to_path_buf());
625 }
626 Ok(std::env::current_dir()?.join(path))
627}
628
629#[cfg(feature = "native-ts")]
630fn executable_from_current_dir(path: &Path) -> Result<PathBuf> {
631 if path.components().count() == 1 {
632 return Ok(path.to_path_buf());
633 }
634 absolute_from_current_dir(path)
635}
636
637#[cfg(feature = "native-ts")]
638fn temporary_artifact_path(artifact: &Path) -> Result<PathBuf> {
639 let file_name = artifact.file_name().ok_or_else(|| {
640 FlowError::Runtime(format!(
641 "native TypeScript artifact {} has no file name",
642 artifact.display()
643 ))
644 })?;
645 let temporary_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
646 Ok(artifact.with_file_name(temporary_name))
647}
648
649#[cfg(feature = "native-ts")]
650async fn publish_temporary_artifact(temporary: &Path, artifact: &Path) -> Result<()> {
651 match tokio::fs::rename(temporary, artifact).await {
652 Ok(()) => Ok(()),
653 Err(rename_error) => {
654 if tokio::fs::metadata(artifact).await.is_ok() {
655 remove_temporary_artifact(temporary).await;
656 return Ok(());
657 }
658 remove_temporary_artifact(temporary).await;
659 Err(FlowError::Runtime(format!(
660 "native TypeScript artifact {} could not be published atomically: {rename_error}",
661 artifact.display()
662 )))
663 }
664 }
665}
666
667#[cfg(feature = "native-ts")]
668async fn remove_temporary_artifact(path: &Path) {
669 match tokio::fs::remove_file(path).await {
670 Ok(()) => {}
671 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
672 Err(error) => tracing::warn!(
673 path = %path.display(),
674 %error,
675 "failed to remove temporary native TypeScript artifact"
676 ),
677 }
678}
679
680#[cfg(feature = "native-ts")]
681fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
682 let mut hasher = Sha256::new();
683 for part in parts {
684 let bytes = part.as_ref();
685 hasher.update(bytes.len().to_le_bytes());
686 hasher.update(bytes);
687 }
688 hex_lower(&hasher.finalize())
689}
690
691#[cfg(feature = "native-ts")]
692fn hex_lower(bytes: &[u8]) -> String {
693 const HEX: &[u8; 16] = b"0123456789abcdef";
694 let mut output = String::with_capacity(bytes.len() * 2);
695 for byte in bytes {
696 output.push(HEX[(byte >> 4) as usize] as char);
697 output.push(HEX[(byte & 0x0f) as usize] as char);
698 }
699 output
700}
701
702#[cfg(feature = "native-ts")]
703fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
704where
705 O: DeserializeOwned,
706{
707 let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
708 if response.protocol != NATIVE_RUNTIME_PROTOCOL {
709 return Err(FlowError::Runtime(format!(
710 "native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
711 response.protocol
712 )));
713 }
714 if response.kind != kind {
715 return Err(FlowError::Runtime(format!(
716 "native TypeScript runtime response kind mismatch: expected {}, got {}",
717 kind.as_str(),
718 response.kind.as_str()
719 )));
720 }
721 if !response.ok {
722 let error = response
723 .error
724 .unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
725 return Err(FlowError::Runtime(error));
726 }
727 let output = response.output.ok_or_else(|| {
728 FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
729 })?;
730 serde_json::from_value(output).map_err(FlowError::from)
731}
732
733#[cfg(test)]
734mod tests {
735 #[cfg(feature = "native-ts")]
736 use super::{native_artifact_cache_key, read_bounded_output, NativeProcessOutputError};
737 use super::{NativeTsRuntime, NativeTsRuntimeConfig};
738 use std::path::Path;
739
740 #[test]
741 fn native_ts_default_cache_stays_under_a3s_state_root() {
742 let config = NativeTsRuntimeConfig::default();
743
744 assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
745 }
746
747 #[test]
748 fn native_ts_runtime_output_limits_are_configurable() {
749 let runtime = NativeTsRuntime::new(NativeTsRuntimeConfig::default());
750
751 assert_eq!(
752 runtime.max_stdout_bytes(),
753 NativeTsRuntime::DEFAULT_MAX_STDOUT_BYTES
754 );
755 assert_eq!(
756 runtime.max_stderr_bytes(),
757 NativeTsRuntime::DEFAULT_MAX_STDERR_BYTES
758 );
759
760 let runtime = runtime.with_output_limits(123, 45);
761 assert_eq!(runtime.max_stdout_bytes(), 123);
762 assert_eq!(runtime.max_stderr_bytes(), 45);
763 }
764
765 #[cfg(feature = "native-ts")]
766 #[tokio::test]
767 async fn native_ts_output_reader_accepts_exact_limit_and_rejects_next_byte() {
768 let exact = read_bounded_output(&b"1234"[..], "stdout", 4)
769 .await
770 .unwrap();
771 assert_eq!(exact, b"1234");
772
773 let error = read_bounded_output(&b"12345"[..], "stdout", 4)
774 .await
775 .unwrap_err();
776 assert!(matches!(
777 error,
778 NativeProcessOutputError::LimitExceeded {
779 stream: "stdout",
780 limit: 4
781 }
782 ));
783 }
784
785 #[cfg(feature = "native-ts")]
786 #[test]
787 fn native_ts_artifact_cache_key_covers_the_compile_environment() {
788 let identity = |source, compiler, working_dir, entrypoint, protocol| {
789 native_artifact_cache_key(
790 source,
791 Path::new(compiler),
792 Path::new(working_dir),
793 Path::new(entrypoint),
794 protocol,
795 )
796 };
797 let baseline = identity(
798 "source-a",
799 "/compiler-a",
800 "/workspace-a",
801 "/workspace-a/workflow.ts",
802 "protocol-a",
803 );
804 let variants = [
805 (
806 "source-b",
807 "/compiler-a",
808 "/workspace-a",
809 "/workspace-a/workflow.ts",
810 "protocol-a",
811 ),
812 (
813 "source-a",
814 "/compiler-b",
815 "/workspace-a",
816 "/workspace-a/workflow.ts",
817 "protocol-a",
818 ),
819 (
820 "source-a",
821 "/compiler-a",
822 "/workspace-b",
823 "/workspace-a/workflow.ts",
824 "protocol-a",
825 ),
826 (
827 "source-a",
828 "/compiler-a",
829 "/workspace-a",
830 "/workspace-b/workflow.ts",
831 "protocol-a",
832 ),
833 (
834 "source-a",
835 "/compiler-a",
836 "/workspace-a",
837 "/workspace-a/workflow.ts",
838 "protocol-b",
839 ),
840 ];
841
842 for (source, compiler, working_dir, entrypoint, protocol) in variants {
843 assert_ne!(
844 identity(source, compiler, working_dir, entrypoint, protocol),
845 baseline
846 );
847 }
848 }
849}