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