roder-dynamic-workflows 0.1.1

Agentic software development tools and SDKs for Roder.
Documentation
use boa_engine::{Context, Source};

use crate::model::{
    RawWorkflowDefinition, WorkflowDefinition, WorkflowRuntimeError, WorkflowRuntimeErrorKind,
    WorkflowRuntimeOptions, WorkflowRuntimeResult,
};

const DENIED_AMBIENT_PATTERNS: &[(&str, &str)] = &[
    (
        "import(",
        "dynamic module loading is not available in workflow scripts",
    ),
    (
        "import ",
        "module loading is not available in workflow scripts",
    ),
    (
        "require(",
        "CommonJS loading is not available in workflow scripts",
    ),
    ("eval(", "eval is not available in workflow scripts"),
    (
        "Function(",
        "dynamic function construction is not available in workflow scripts",
    ),
    (
        "process.",
        "ambient process access is not available in workflow scripts",
    ),
    (
        "process[",
        "ambient process access is not available in workflow scripts",
    ),
    (
        "Deno.",
        "ambient runtime access is not available in workflow scripts",
    ),
    (
        "Bun.",
        "ambient runtime access is not available in workflow scripts",
    ),
    (
        "fetch(",
        "network access is not available in workflow scripts",
    ),
    (
        "XMLHttpRequest",
        "network access is not available in workflow scripts",
    ),
    (
        "WebSocket",
        "network access is not available in workflow scripts",
    ),
    (
        "setTimeout(",
        "ambient timers are not available in workflow scripts",
    ),
    (
        "setInterval(",
        "ambient timers are not available in workflow scripts",
    ),
];

pub fn parse_workflow_definition(
    source: &str,
    options: &WorkflowRuntimeOptions,
) -> WorkflowRuntimeResult<WorkflowDefinition> {
    preflight_script(source)?;
    let mut context = new_context(options);
    install_definition_prelude(&mut context)?;
    eval_js(
        &mut context,
        source,
        WorkflowRuntimeErrorKind::ScriptExecution,
    )?;
    let metadata = read_global_string(&mut context, "globalThis.__roderWorkflowMetadataJson")?
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            WorkflowRuntimeError::new(
                WorkflowRuntimeErrorKind::MissingDefinition,
                "script must call workflow.define(metadata, handler)",
            )
        })?;
    parse_definition_json(&metadata, options)
}

pub(crate) fn preflight_script(source: &str) -> WorkflowRuntimeResult<()> {
    for (pattern, message) in DENIED_AMBIENT_PATTERNS {
        if source.contains(pattern) {
            return Err(WorkflowRuntimeError::new(
                WorkflowRuntimeErrorKind::DeniedAmbientApi,
                *message,
            ));
        }
    }
    Ok(())
}

pub(crate) fn new_context(options: &WorkflowRuntimeOptions) -> Context {
    let mut context = Context::default();
    context
        .runtime_limits_mut()
        .set_loop_iteration_limit(options.max_loop_iterations);
    context.runtime_limits_mut().set_recursion_limit(128);
    context
}

pub(crate) fn install_definition_prelude(context: &mut Context) -> WorkflowRuntimeResult<()> {
    eval_js(
        context,
        r#"
Object.defineProperty(globalThis, "workflow", {
  value: Object.freeze({
    define(metadata, handler) {
      if (typeof handler !== "function") {
        throw new Error("workflow.define requires a function handler");
      }
      globalThis.__roderWorkflowMetadataJson = JSON.stringify(metadata || {});
      globalThis.__roderWorkflowHandler = handler;
    }
  }),
  writable: false,
  configurable: false
});
"#,
        WorkflowRuntimeErrorKind::ScriptExecution,
    )
}

pub(crate) fn eval_js(
    context: &mut Context,
    source: &str,
    kind: WorkflowRuntimeErrorKind,
) -> WorkflowRuntimeResult<()> {
    context
        .eval(Source::from_bytes(source))
        .map(|_| ())
        .map_err(|err| classify_js_error(err.to_string(), kind))
}

pub(crate) fn read_global_string(
    context: &mut Context,
    expression: &str,
) -> WorkflowRuntimeResult<Option<String>> {
    let value = context
        .eval(Source::from_bytes(expression))
        .map_err(|err| {
            classify_js_error(err.to_string(), WorkflowRuntimeErrorKind::ScriptExecution)
        })?;
    if value.is_null_or_undefined() {
        return Ok(None);
    }
    let text = value
        .to_string(context)
        .map_err(|err| {
            classify_js_error(err.to_string(), WorkflowRuntimeErrorKind::ScriptExecution)
        })?
        .to_std_string_escaped();
    Ok(Some(text))
}

pub(crate) fn parse_definition_json(
    metadata: &str,
    options: &WorkflowRuntimeOptions,
) -> WorkflowRuntimeResult<WorkflowDefinition> {
    let raw: RawWorkflowDefinition = serde_json::from_str(metadata).map_err(|err| {
        WorkflowRuntimeError::new(
            WorkflowRuntimeErrorKind::InvalidMetadata,
            format!("invalid workflow metadata: {err}"),
        )
    })?;
    raw.into_definition(&options.limits)
}

pub(crate) fn classify_js_error(
    message: String,
    default_kind: WorkflowRuntimeErrorKind,
) -> WorkflowRuntimeError {
    if message.contains("loop iteration limit")
        || message.contains("limit:")
        || message.contains("Maximum call stack")
    {
        return WorkflowRuntimeError::new(WorkflowRuntimeErrorKind::LimitExceeded, message);
    }
    if message.contains("abort:") {
        return WorkflowRuntimeError::new(WorkflowRuntimeErrorKind::Aborted, message);
    }
    WorkflowRuntimeError::new(default_kind, message)
}