use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
#[cfg(feature = "native-ts")]
use sha2::{Digest, Sha256};
#[cfg(feature = "native-ts")]
use std::path::Path;
use std::path::PathBuf;
#[cfg(feature = "native-ts")]
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
#[cfg(feature = "native-ts")]
use tokio::process::{Child, Command};
#[cfg(feature = "native-ts")]
use uuid::Uuid;
use crate::context::WorkflowContext;
use crate::error::{FlowError, Result};
#[cfg(feature = "native-ts")]
use crate::model::RuntimeKind;
use crate::model::{FlowEventEnvelope, JsonValue, RuntimeCommand, WorkflowSpec};
#[cfg(feature = "native-ts")]
use crate::protocol::{
NativeRuntimeKind, NativeRuntimeRequest, NativeRuntimeResponse, NATIVE_RUNTIME_PROTOCOL,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowInvocation {
pub run_id: String,
pub spec: WorkflowSpec,
pub input: JsonValue,
pub history: Vec<FlowEventEnvelope>,
}
impl WorkflowInvocation {
pub fn context(&self) -> WorkflowContext<'_> {
WorkflowContext::new(self)
}
pub fn input_as<T>(&self) -> Result<T>
where
T: DeserializeOwned,
{
serde_json::from_value(self.input.clone()).map_err(FlowError::from)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepInvocation {
pub run_id: String,
pub step_id: String,
pub step_name: String,
pub input: JsonValue,
pub history: Vec<FlowEventEnvelope>,
}
impl StepInvocation {
pub fn input_as<T>(&self) -> Result<T>
where
T: DeserializeOwned,
{
serde_json::from_value(self.input.clone()).map_err(FlowError::from)
}
}
#[async_trait]
pub trait FlowRuntime: Send + Sync {
async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand>;
async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue>;
}
#[derive(Debug, Clone)]
pub struct NativeTsRuntimeConfig {
pub compiler_binary: PathBuf,
pub cache_dir: PathBuf,
pub working_dir: PathBuf,
}
impl NativeTsRuntimeConfig {
pub fn new(
compiler_binary: impl Into<PathBuf>,
cache_dir: impl Into<PathBuf>,
working_dir: impl Into<PathBuf>,
) -> Self {
Self {
compiler_binary: compiler_binary.into(),
cache_dir: cache_dir.into(),
working_dir: working_dir.into(),
}
}
}
impl Default for NativeTsRuntimeConfig {
fn default() -> Self {
Self {
compiler_binary: PathBuf::from("a3s-flow-native-compiler"),
cache_dir: PathBuf::from(".a3s/flow/native-ts"),
working_dir: PathBuf::from("."),
}
}
}
#[derive(Debug, Clone)]
pub struct NativeTsRuntime {
config: NativeTsRuntimeConfig,
max_stdout_bytes: usize,
max_stderr_bytes: usize,
}
#[cfg(feature = "native-ts")]
#[derive(Debug, Clone)]
struct NativeArtifact {
compiler_binary: PathBuf,
working_dir: PathBuf,
entrypoint: PathBuf,
binary: PathBuf,
source_hash: String,
}
#[cfg(feature = "native-ts")]
struct NativeProcessOutput {
status: std::process::ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
#[cfg(feature = "native-ts")]
#[derive(Debug)]
enum NativeProcessOutputError {
Io(std::io::Error),
LimitExceeded { stream: &'static str, limit: usize },
}
#[cfg(feature = "native-ts")]
struct TemporaryArtifactGuard {
path: PathBuf,
armed: bool,
}
#[cfg(feature = "native-ts")]
impl TemporaryArtifactGuard {
fn new(path: PathBuf) -> Self {
Self { path, armed: true }
}
fn path(&self) -> &Path {
&self.path
}
async fn remove(&mut self) {
remove_temporary_artifact(&self.path).await;
self.armed = false;
}
fn disarm(&mut self) {
self.armed = false;
}
}
#[cfg(feature = "native-ts")]
impl Drop for TemporaryArtifactGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let path = self.path.clone();
match tokio::runtime::Handle::try_current() {
Ok(runtime) => {
let _cleanup = runtime.spawn(async move {
remove_temporary_artifact(&path).await;
});
}
Err(error) => tracing::warn!(
path = %self.path.display(),
%error,
"failed to schedule cancelled native TypeScript artifact cleanup"
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NativeTsRuntimePreflight {
pub entrypoint: PathBuf,
pub artifact: PathBuf,
pub source_hash: String,
pub cache_hit: bool,
}
impl NativeTsRuntime {
pub const DEFAULT_MAX_STDOUT_BYTES: usize = 8 * 1024 * 1024;
pub const DEFAULT_MAX_STDERR_BYTES: usize = 256 * 1024;
pub fn new(config: NativeTsRuntimeConfig) -> Self {
Self {
config,
max_stdout_bytes: Self::DEFAULT_MAX_STDOUT_BYTES,
max_stderr_bytes: Self::DEFAULT_MAX_STDERR_BYTES,
}
}
pub fn config(&self) -> &NativeTsRuntimeConfig {
&self.config
}
pub fn with_output_limits(mut self, max_stdout_bytes: usize, max_stderr_bytes: usize) -> Self {
self.max_stdout_bytes = max_stdout_bytes;
self.max_stderr_bytes = max_stderr_bytes;
self
}
pub fn max_stdout_bytes(&self) -> usize {
self.max_stdout_bytes
}
pub fn max_stderr_bytes(&self) -> usize {
self.max_stderr_bytes
}
#[cfg(feature = "native-ts")]
pub async fn preflight(&self, spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
let (artifact, cache_hit) = self.compile_if_needed(spec).await?;
Ok(NativeTsRuntimePreflight {
entrypoint: artifact.entrypoint,
artifact: artifact.binary,
source_hash: artifact.source_hash,
cache_hit,
})
}
#[cfg(not(feature = "native-ts"))]
pub async fn preflight(&self, _spec: &WorkflowSpec) -> Result<NativeTsRuntimePreflight> {
Err(FlowError::Runtime(
"native-ts feature is disabled for NativeTsRuntime".to_string(),
))
}
#[cfg(feature = "native-ts")]
async fn artifact_for(&self, spec: &WorkflowSpec) -> Result<NativeArtifact> {
validate_native_ts_spec(spec)?;
let compiler_binary = executable_from_current_dir(&self.config.compiler_binary)?;
let working_dir = absolute_from_current_dir(&self.config.working_dir)?;
let entrypoint = resolve_against(&working_dir, &spec.runtime.entrypoint);
let cache_dir = absolute_from_current_dir(&self.config.cache_dir)?;
let source = tokio::fs::read(&entrypoint).await?;
let source_hash = native_source_hash(spec, &source);
let artifact_hash = native_artifact_cache_key(
&source_hash,
&compiler_binary,
&working_dir,
&entrypoint,
NATIVE_RUNTIME_PROTOCOL,
);
let name = format!("{}-{artifact_hash}", sanitize_filename(&spec.name));
Ok(NativeArtifact {
compiler_binary,
working_dir,
entrypoint,
binary: cache_dir.join(name),
source_hash,
})
}
#[cfg(feature = "native-ts")]
async fn compile_if_needed(&self, spec: &WorkflowSpec) -> Result<(NativeArtifact, bool)> {
let artifact = self.artifact_for(spec).await?;
if tokio::fs::metadata(&artifact.binary).await.is_ok() {
return Ok((artifact, true));
}
let cache_dir = artifact.binary.parent().ok_or_else(|| {
FlowError::Runtime(format!(
"native TypeScript artifact {} has no cache directory",
artifact.binary.display()
))
})?;
tokio::fs::create_dir_all(cache_dir).await?;
let mut temporary_binary =
TemporaryArtifactGuard::new(temporary_artifact_path(&artifact.binary)?);
let child = match Command::new(&artifact.compiler_binary)
.arg("compile")
.arg(&artifact.entrypoint)
.arg("-o")
.arg(temporary_binary.path())
.current_dir(&artifact.working_dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
{
Ok(child) => child,
Err(error) => {
temporary_binary.remove().await;
return Err(error.into());
}
};
let output = match wait_with_bounded_output(
child,
"compiler",
self.max_stdout_bytes,
self.max_stderr_bytes,
)
.await
{
Ok(output) => output,
Err(error) => {
temporary_binary.remove().await;
return Err(error);
}
};
if !output.status.success() {
temporary_binary.remove().await;
return Err(FlowError::Runtime(format!(
"native TypeScript compile failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
if let Err(error) = tokio::fs::metadata(temporary_binary.path()).await {
temporary_binary.remove().await;
return Err(FlowError::Runtime(format!(
"native TypeScript compiler did not produce artifact {}: {error}",
artifact.binary.display()
)));
}
publish_temporary_artifact(temporary_binary.path(), &artifact.binary).await?;
temporary_binary.disarm();
Ok((artifact, false))
}
#[cfg(feature = "native-ts")]
async fn invoke<I, O>(
&self,
spec: &WorkflowSpec,
kind: NativeRuntimeKind,
payload: I,
) -> Result<O>
where
I: Serialize + Send,
O: DeserializeOwned,
{
let (artifact, _) = self.compile_if_needed(spec).await?;
let request = NativeRuntimeRequest::new(
kind,
spec.runtime.export_name.clone(),
artifact.source_hash,
payload,
);
let mut child = Command::new(&artifact.binary)
.arg("--a3s-flow-runtime")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.current_dir(&artifact.working_dir)
.kill_on_drop(true)
.spawn()?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| FlowError::Runtime("failed to open runtime stdin".to_string()))?;
stdin
.write_all(serde_json::to_string(&request)?.as_bytes())
.await?;
stdin.shutdown().await?;
drop(stdin);
let output = wait_with_bounded_output(
child,
"runtime",
self.max_stdout_bytes,
self.max_stderr_bytes,
)
.await?;
if !output.status.success() {
return Err(FlowError::Runtime(format!(
"native TypeScript runtime failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
decode_native_response(kind, &output.stdout)
}
}
#[cfg(feature = "native-ts")]
async fn wait_with_bounded_output(
mut child: Child,
process_kind: &'static str,
max_stdout_bytes: usize,
max_stderr_bytes: usize,
) -> Result<NativeProcessOutput> {
let stdout = child.stdout.take().ok_or_else(|| {
FlowError::Runtime(format!(
"native TypeScript {process_kind} stdout pipe is unavailable"
))
})?;
let stderr = child.stderr.take().ok_or_else(|| {
FlowError::Runtime(format!(
"native TypeScript {process_kind} stderr pipe is unavailable"
))
})?;
let output = {
let wait = async { child.wait().await.map_err(NativeProcessOutputError::Io) };
let stdout = read_bounded_output(stdout, "stdout", max_stdout_bytes);
let stderr = read_bounded_output(stderr, "stderr", max_stderr_bytes);
tokio::try_join!(wait, stdout, stderr)
};
match output {
Ok((status, stdout, stderr)) => Ok(NativeProcessOutput {
status,
stdout,
stderr,
}),
Err(error) => {
let _ = child.start_kill();
let _ = child.wait().await;
match error {
NativeProcessOutputError::Io(error) => Err(error.into()),
NativeProcessOutputError::LimitExceeded { stream, limit } => {
Err(FlowError::Runtime(format!(
"native TypeScript {process_kind} {stream} exceeded the {limit}-byte limit"
)))
}
}
}
}
}
#[cfg(feature = "native-ts")]
async fn read_bounded_output<R>(
mut reader: R,
stream: &'static str,
limit: usize,
) -> std::result::Result<Vec<u8>, NativeProcessOutputError>
where
R: AsyncRead + Unpin,
{
let mut output = Vec::with_capacity(limit.min(8 * 1024));
let mut buffer = [0_u8; 8 * 1024];
loop {
let count = reader
.read(&mut buffer)
.await
.map_err(NativeProcessOutputError::Io)?;
if count == 0 {
return Ok(output);
}
if count > limit.saturating_sub(output.len()) {
return Err(NativeProcessOutputError::LimitExceeded { stream, limit });
}
output.extend_from_slice(&buffer[..count]);
}
}
#[cfg(feature = "native-ts")]
fn native_source_hash(spec: &WorkflowSpec, source: &[u8]) -> String {
stable_hash([
b"source".as_slice(),
spec.name.as_bytes(),
spec.version.as_bytes(),
spec.runtime.entrypoint.as_bytes(),
spec.runtime.export_name.as_bytes(),
source,
])
}
#[cfg(feature = "native-ts")]
fn native_artifact_cache_key(
source_hash: &str,
compiler_binary: &Path,
working_dir: &Path,
entrypoint: &Path,
protocol: &str,
) -> String {
stable_hash([
b"a3s.flow.native_ts.artifact.v1".as_slice(),
source_hash.as_bytes(),
protocol.as_bytes(),
compiler_binary.as_os_str().as_encoded_bytes(),
working_dir.as_os_str().as_encoded_bytes(),
entrypoint.as_os_str().as_encoded_bytes(),
std::env::consts::OS.as_bytes(),
std::env::consts::ARCH.as_bytes(),
])
}
#[cfg(feature = "native-ts")]
fn validate_native_ts_spec(spec: &WorkflowSpec) -> Result<()> {
spec.validate()?;
if spec.runtime.kind != RuntimeKind::NativeTs {
return Err(FlowError::InvalidWorkflow(format!(
"NativeTsRuntime requires a native_ts workflow spec, got {:?}",
spec.runtime.kind
)));
}
Ok(())
}
#[async_trait]
impl FlowRuntime for NativeTsRuntime {
#[cfg(feature = "native-ts")]
async fn run_workflow(&self, invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
let spec = invocation.spec.clone();
self.invoke(&spec, NativeRuntimeKind::Workflow, invocation)
.await
}
#[cfg(not(feature = "native-ts"))]
async fn run_workflow(&self, _invocation: WorkflowInvocation) -> Result<RuntimeCommand> {
Err(FlowError::Runtime(
"native-ts feature is disabled for NativeTsRuntime".to_string(),
))
}
#[cfg(feature = "native-ts")]
async fn run_step(&self, invocation: StepInvocation) -> Result<JsonValue> {
let spec = workflow_spec_from_history(&invocation.history)?;
self.invoke(&spec, NativeRuntimeKind::Step, invocation)
.await
}
#[cfg(not(feature = "native-ts"))]
async fn run_step(&self, _invocation: StepInvocation) -> Result<JsonValue> {
Err(FlowError::Runtime(
"native-ts feature is disabled for NativeTsRuntime".to_string(),
))
}
}
#[cfg(feature = "native-ts")]
fn workflow_spec_from_history(history: &[FlowEventEnvelope]) -> Result<WorkflowSpec> {
let first = history
.first()
.ok_or_else(|| FlowError::Runtime("step invocation has empty history".to_string()))?;
match &first.event {
crate::model::FlowEvent::RunCreated { spec, .. } => Ok(spec.clone()),
_ => Err(FlowError::Runtime(
"first history event is not run_created".to_string(),
)),
}
}
#[cfg(feature = "native-ts")]
fn sanitize_filename(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
})
.collect()
}
#[cfg(feature = "native-ts")]
fn resolve_against(root: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
root.join(path)
}
}
#[cfg(feature = "native-ts")]
fn absolute_from_current_dir(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
Ok(std::env::current_dir()?.join(path))
}
#[cfg(feature = "native-ts")]
fn executable_from_current_dir(path: &Path) -> Result<PathBuf> {
if path.components().count() == 1 {
return Ok(path.to_path_buf());
}
absolute_from_current_dir(path)
}
#[cfg(feature = "native-ts")]
fn temporary_artifact_path(artifact: &Path) -> Result<PathBuf> {
let file_name = artifact.file_name().ok_or_else(|| {
FlowError::Runtime(format!(
"native TypeScript artifact {} has no file name",
artifact.display()
))
})?;
let temporary_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
Ok(artifact.with_file_name(temporary_name))
}
#[cfg(feature = "native-ts")]
async fn publish_temporary_artifact(temporary: &Path, artifact: &Path) -> Result<()> {
match tokio::fs::rename(temporary, artifact).await {
Ok(()) => Ok(()),
Err(rename_error) => {
if tokio::fs::metadata(artifact).await.is_ok() {
remove_temporary_artifact(temporary).await;
return Ok(());
}
remove_temporary_artifact(temporary).await;
Err(FlowError::Runtime(format!(
"native TypeScript artifact {} could not be published atomically: {rename_error}",
artifact.display()
)))
}
}
}
#[cfg(feature = "native-ts")]
async fn remove_temporary_artifact(path: &Path) {
match tokio::fs::remove_file(path).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(
path = %path.display(),
%error,
"failed to remove temporary native TypeScript artifact"
),
}
}
#[cfg(feature = "native-ts")]
fn stable_hash(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
let mut hasher = Sha256::new();
for part in parts {
let bytes = part.as_ref();
hasher.update(bytes.len().to_le_bytes());
hasher.update(bytes);
}
hex_lower(&hasher.finalize())
}
#[cfg(feature = "native-ts")]
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
#[cfg(feature = "native-ts")]
fn decode_native_response<O>(kind: NativeRuntimeKind, bytes: &[u8]) -> Result<O>
where
O: DeserializeOwned,
{
let response: NativeRuntimeResponse = serde_json::from_slice(bytes)?;
if response.protocol != NATIVE_RUNTIME_PROTOCOL {
return Err(FlowError::Runtime(format!(
"native TypeScript runtime protocol mismatch: expected {NATIVE_RUNTIME_PROTOCOL}, got {}",
response.protocol
)));
}
if response.kind != kind {
return Err(FlowError::Runtime(format!(
"native TypeScript runtime response kind mismatch: expected {}, got {}",
kind.as_str(),
response.kind.as_str()
)));
}
if !response.ok {
let error = response
.error
.unwrap_or_else(|| "runtime returned ok=false without an error".to_string());
return Err(FlowError::Runtime(error));
}
let output = response.output.ok_or_else(|| {
FlowError::Runtime("native TypeScript runtime returned ok=true without output".to_string())
})?;
serde_json::from_value(output).map_err(FlowError::from)
}
#[cfg(test)]
mod tests {
#[cfg(feature = "native-ts")]
use super::{native_artifact_cache_key, read_bounded_output, NativeProcessOutputError};
use super::{NativeTsRuntime, NativeTsRuntimeConfig};
use std::path::Path;
#[test]
fn native_ts_default_cache_stays_under_a3s_state_root() {
let config = NativeTsRuntimeConfig::default();
assert_eq!(config.cache_dir, Path::new(".a3s/flow/native-ts"));
}
#[test]
fn native_ts_runtime_output_limits_are_configurable() {
let runtime = NativeTsRuntime::new(NativeTsRuntimeConfig::default());
assert_eq!(
runtime.max_stdout_bytes(),
NativeTsRuntime::DEFAULT_MAX_STDOUT_BYTES
);
assert_eq!(
runtime.max_stderr_bytes(),
NativeTsRuntime::DEFAULT_MAX_STDERR_BYTES
);
let runtime = runtime.with_output_limits(123, 45);
assert_eq!(runtime.max_stdout_bytes(), 123);
assert_eq!(runtime.max_stderr_bytes(), 45);
}
#[cfg(feature = "native-ts")]
#[tokio::test]
async fn native_ts_output_reader_accepts_exact_limit_and_rejects_next_byte() {
let exact = read_bounded_output(&b"1234"[..], "stdout", 4)
.await
.unwrap();
assert_eq!(exact, b"1234");
let error = read_bounded_output(&b"12345"[..], "stdout", 4)
.await
.unwrap_err();
assert!(matches!(
error,
NativeProcessOutputError::LimitExceeded {
stream: "stdout",
limit: 4
}
));
}
#[cfg(feature = "native-ts")]
#[test]
fn native_ts_artifact_cache_key_covers_the_compile_environment() {
let identity = |source, compiler, working_dir, entrypoint, protocol| {
native_artifact_cache_key(
source,
Path::new(compiler),
Path::new(working_dir),
Path::new(entrypoint),
protocol,
)
};
let baseline = identity(
"source-a",
"/compiler-a",
"/workspace-a",
"/workspace-a/workflow.ts",
"protocol-a",
);
let variants = [
(
"source-b",
"/compiler-a",
"/workspace-a",
"/workspace-a/workflow.ts",
"protocol-a",
),
(
"source-a",
"/compiler-b",
"/workspace-a",
"/workspace-a/workflow.ts",
"protocol-a",
),
(
"source-a",
"/compiler-a",
"/workspace-b",
"/workspace-a/workflow.ts",
"protocol-a",
),
(
"source-a",
"/compiler-a",
"/workspace-a",
"/workspace-b/workflow.ts",
"protocol-a",
),
(
"source-a",
"/compiler-a",
"/workspace-a",
"/workspace-a/workflow.ts",
"protocol-b",
),
];
for (source, compiler, working_dir, entrypoint, protocol) in variants {
assert_ne!(
identity(source, compiler, working_dir, entrypoint, protocol),
baseline
);
}
}
}