From e825fd06f36571949c512edb48a2a13c5ef3586b Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Sat, 8 Nov 2025 11:47:20 -0300 Subject: [PATCH 1/2] Introduce Run.script task with JavaScript Signed-off-by: Matheus Cruz --- .../impl/executors/RunScriptExecutor.java | 282 ++++++++++++++++++ .../impl/executors/ScriptTaskRunner.java | 41 +++ ...erlessworkflow.impl.executors.RunnableTask | 3 +- impl/pom.xml | 18 ++ impl/script-js/pom.xml | 25 ++ .../script/js/JavaScriptScriptTaskRunner.java | 138 +++++++++ ...ssworkflow.impl.executors.ScriptTaskRunner | 1 + impl/test/pom.xml | 4 + .../impl/test/RunScriptJavaScriptTest.java | 186 ++++++++++++ .../run-script/console-log-args.yaml | 14 + .../run-script/console-log-envs.yaml | 14 + .../console-log-external-source.yaml | 13 + .../run-script/console-log-not-awaiting.yaml | 13 + .../run-script/console-log.yaml | 12 + .../function-with-syntax-error.yaml | 19 ++ .../run-script/function-with-throw-all.yaml | 18 ++ .../run-script/function-with-throw.yaml | 18 ++ .../workflows-samples/run-script/script.js | 1 + 18 files changed, 819 insertions(+), 1 deletion(-) create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/executors/ScriptTaskRunner.java create mode 100644 impl/script-js/pom.xml create mode 100644 impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java create mode 100644 impl/script-js/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.ScriptTaskRunner create mode 100644 impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-script/script.js diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java new file mode 100644 index 00000000..32519afc --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java @@ -0,0 +1,282 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors; + +import io.serverlessworkflow.api.types.RunScript; +import io.serverlessworkflow.api.types.RunTaskConfiguration; +import io.serverlessworkflow.api.types.Script; +import io.serverlessworkflow.api.types.ScriptUnion; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowError; +import io.serverlessworkflow.impl.WorkflowException; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import io.serverlessworkflow.impl.resources.ResourceLoaderUtils; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.concurrent.CompletableFuture; + +public class RunScriptExecutor implements RunnableTask { + + public enum ScriptLanguage { + JS("js"), + PYTHON("python"); + + private final String lang; + + ScriptLanguage(String lang) { + this.lang = lang; + } + + public String getLang() { + return lang; + } + + public static boolean isSupported(String lang) { + for (ScriptLanguage l : ScriptLanguage.values()) { + if (l.getLang().equalsIgnoreCase(lang)) { + return true; + } + } + return false; + } + } + + @FunctionalInterface + private interface CodeSupplier { + String apply(WorkflowContext workflowContext, TaskContext taskContext); + } + + @SuppressWarnings("rawtypes") + private Map environmentExpr; + @SuppressWarnings("rawtypes") + private Map argumentExpr; + private CodeSupplier codeSupplier; + private boolean isAwait; + private RunTaskConfiguration.ProcessReturnType returnType; + private ScriptTaskRunner taskRunner; + + @Override + public void init(RunScript taskConfiguration, WorkflowDefinition definition) { + ScriptUnion scriptUnion = taskConfiguration.getScript(); + Script script = scriptUnion.get(); + String language = scriptUnion.get().getLanguage(); + + WorkflowApplication application = definition.application(); + if (language == null || !ScriptLanguage.isSupported(language)) { + throw new IllegalArgumentException( + "Unsupported script language: " + + language + + ". Supported languages are: " + + Arrays.toString( + Arrays.stream(ScriptLanguage.values()).map(ScriptLanguage::getLang).toArray())); + } + + this.taskRunner = + ServiceLoader.load(ScriptTaskRunner.class) + .findFirst() + .orElseThrow( + () -> new IllegalStateException("No implementation found for ScriptTaskRunner")); + + this.isAwait = taskConfiguration.isAwait(); + + this.returnType = taskConfiguration.getReturn(); + + if (script.getEnvironment() != null + && script.getEnvironment().getAdditionalProperties() != null) { + this.environmentExpr = + buildMapResolvers(application, script.getEnvironment().getAdditionalProperties()); + } else { + this.environmentExpr = Map.of(); + } + + if (script.getArguments() != null && script.getArguments().getAdditionalProperties() != null) { + this.argumentExpr = + buildMapResolvers(application, script.getArguments().getAdditionalProperties()); + } else { + this.argumentExpr = Map.of(); + } + + this.codeSupplier = + (workflowContext, taskContext) -> { + if (scriptUnion.getInlineScript() != null) { + return scriptUnion.getInlineScript().getCode(); + } else if (scriptUnion.getExternalScript() == null) { + throw new WorkflowException( + WorkflowError.runtime( + taskContext, new IllegalStateException("No script source defined.")) + .build()); + } else { + return definition + .resourceLoader() + .load( + scriptUnion.getExternalScript().getSource(), + ResourceLoaderUtils::readString, + workflowContext, + taskContext, + taskContext.input()); + } + }; + } + + @Override + public CompletableFuture apply( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + + RunScriptContext.RunScriptContextBuilder builder = + new RunScriptContext.RunScriptContextBuilder(); + + Map envs = new HashMap<>(); + this.environmentExpr.forEach( + (k, v) -> { + Object resolved = v.apply(workflowContext, taskContext, input); + envs.put(k, resolved.toString()); + }); + + Map args = new HashMap<>(); + this.argumentExpr.forEach( + (k, v) -> { + Object resolved = v.apply(workflowContext, taskContext, input); + args.put(k, resolved); + }); + + String code = this.codeSupplier.apply(workflowContext, taskContext); + + RunScriptContext ctx = + builder + .withApplication(workflowContext.definition().application()) + .withReturnType(returnType) + .withCode(code) + .withArguments(args) + .withEnvironment(envs) + .withAwait(isAwait) + .build(); + + return CompletableFuture.supplyAsync(() -> taskRunner.buildRun(taskContext).apply(ctx, input)); + } + + @Override + public boolean accept(Class clazz) { + return RunScript.class.equals(clazz); + } + + /** Builds a map of WorkflowValueResolvers from the provided properties. */ + @SuppressWarnings("rawtypes") + private Map buildMapResolvers( + WorkflowApplication application, Map properties) { + Map resolvers = new HashMap<>(); + if (properties != null) { + for (Map.Entry entry : properties.entrySet()) { + WorkflowValueResolver valueResolver = + WorkflowUtils.buildStringFilter(application, entry.getValue().toString()); + resolvers.put(entry.getKey(), valueResolver); + } + } + return resolvers; + } + + public static class RunScriptContext { + private final WorkflowApplication application; + private final Map args; + private final Map envs; + private final String code; + private final boolean isAwait; + private final RunTaskConfiguration.ProcessReturnType returnType; + + public RunScriptContext(RunScriptContextBuilder builder) { + this.application = builder.application; + this.args = builder.args; + this.envs = builder.envs; + this.code = builder.code; + this.isAwait = builder.awaiting; + this.returnType = builder.returnType; + } + + public Map getArgs() { + return args; + } + + public Map getEnvs() { + return envs; + } + + public String getCode() { + return code; + } + + public boolean isAwait() { + return isAwait; + } + + public WorkflowApplication getApplication() { + return application; + } + + public RunTaskConfiguration.ProcessReturnType getReturnType() { + return returnType; + } + + public static class RunScriptContextBuilder { + private Map args; + private Map envs; + private String code; + private boolean awaiting; + private WorkflowApplication application; + private RunTaskConfiguration.ProcessReturnType returnType; + + public RunScriptContextBuilder withArguments(Map args) { + this.args = args; + return this; + } + + public RunScriptContextBuilder withEnvironment(Map envs) { + this.envs = envs; + return this; + } + + public RunScriptContextBuilder withCode(String code) { + this.code = code; + return this; + } + + public RunScriptContextBuilder withAwait(boolean awaiting) { + this.awaiting = awaiting; + return this; + } + + public RunScriptContextBuilder withApplication(WorkflowApplication application) { + this.application = application; + return this; + } + + public RunScriptContextBuilder withReturnType( + RunTaskConfiguration.ProcessReturnType returnType) { + this.returnType = returnType; + return this; + } + + public RunScriptContext build() { + return new RunScriptContext(this); + } + } + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ScriptTaskRunner.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ScriptTaskRunner.java new file mode 100644 index 00000000..c52e0f69 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ScriptTaskRunner.java @@ -0,0 +1,41 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors; + +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowModel; +import java.util.function.BiFunction; + +/** Represents a script task that executes a script in a specific scripting language. */ +public interface ScriptTaskRunner { + + /** + * The scripting language supported by this script task runner. + * + * @return the scripting language as {@link RunScriptExecutor.ScriptLanguage} enum. + */ + RunScriptExecutor.ScriptLanguage forLanguage(); + + /** + * Returns a function that executes the script task. + * + * @param taskContext the task context for the script task. + * @return a @{@link BiFunction}} that takes a RunScriptContext and a WorkflowModel as input and + * returns a WorkflowModel as output. + */ + BiFunction buildRun( + TaskContext taskContext); +} diff --git a/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask b/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask index 7d2be4f9..ea1bb37e 100644 --- a/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask +++ b/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask @@ -1,2 +1,3 @@ io.serverlessworkflow.impl.executors.RunWorkflowExecutor -io.serverlessworkflow.impl.executors.RunShellExecutor \ No newline at end of file +io.serverlessworkflow.impl.executors.RunShellExecutor +io.serverlessworkflow.impl.executors.RunScriptExecutor \ No newline at end of file diff --git a/impl/pom.xml b/impl/pom.xml index 00d900f9..e6621cfd 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -14,6 +14,7 @@ 4.0.0 1.6.0 3.1.11 + 23.1.1 @@ -92,6 +93,22 @@ serverlessworkflow-impl-openapi ${project.version} + + io.serverlessworkflow + serverlessworkflow-impl-script-js + ${project.version} + + + org.graalvm.polyglot + js + ${version.org.graalvm.plyglot} + pom + + + org.graalvm.polyglot + polyglot + ${version.org.graalvm.plyglot} + net.thisptr jackson-jq @@ -140,5 +157,6 @@ lifecycleevent validation test + script-js diff --git a/impl/script-js/pom.xml b/impl/script-js/pom.xml new file mode 100644 index 00000000..1101de3f --- /dev/null +++ b/impl/script-js/pom.xml @@ -0,0 +1,25 @@ + + 4.0.0 + + io.serverlessworkflow + serverlessworkflow-impl + 8.0.0-SNAPSHOT + + serverlessworkflow-impl-script-js + Serverless Workflow :: Impl :: Script JavaScript + + + io.serverlessworkflow + serverlessworkflow-impl-core + + + org.graalvm.polyglot + polyglot + + + org.graalvm.polyglot + js + pom + + + \ No newline at end of file diff --git a/impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java b/impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java new file mode 100644 index 00000000..8e881c2b --- /dev/null +++ b/impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java @@ -0,0 +1,138 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors.script.js; + +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowError; +import io.serverlessworkflow.impl.WorkflowException; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowModelFactory; +import io.serverlessworkflow.impl.executors.ProcessResult; +import io.serverlessworkflow.impl.executors.RunScriptExecutor; +import io.serverlessworkflow.impl.executors.ScriptTaskRunner; +import java.io.ByteArrayOutputStream; +import java.util.Map; +import java.util.function.BiFunction; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Value; + +public class JavaScriptScriptTaskRunner implements ScriptTaskRunner { + + @Override + public RunScriptExecutor.ScriptLanguage forLanguage() { + return RunScriptExecutor.ScriptLanguage.JS; + } + + @Override + public BiFunction buildRun( + TaskContext taskContext) { + return (script, input) -> { + String js = forLanguage().getLang(); + WorkflowApplication application = script.getApplication(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + + WorkflowModelFactory modelFactory = application.modelFactory(); + try (Context ctx = + Context.newBuilder() + .err(stderr) + .out(stdout) + .useSystemExit(true) + .allowCreateProcess(false) + .option("engine.WarnInterpreterOnly", "false") + .build()) { + + script + .getArgs() + .forEach( + (key, val) -> { + ctx.getBindings(js).putMember(key, val); + }); + + configureProcessEnv(ctx, script.getEnvs()); + + if (!script.isAwait()) { + script + .getApplication() + .executorService() + .submit( + () -> { + ctx.eval(js, script.getCode()); + }); + return application.modelFactory().fromAny(input); + } + + ctx.eval(js, script.getCode()); + + return switch (script.getReturnType()) { + case ALL -> + modelFactory.fromAny(new ProcessResult(0, stdout.toString(), stderr.toString())); + case NONE -> modelFactory.fromNull(); + case CODE -> modelFactory.from(0); + case STDOUT -> modelFactory.from(stdout.toString().trim()); + case STDERR -> modelFactory.from(stderr.toString().trim()); + }; + } catch (PolyglotException e) { + if (e.getExitStatus() != 0 || e.isSyntaxError()) { + throw new WorkflowException(WorkflowError.runtime(taskContext, e).build()); + } else { + return switch (script.getReturnType()) { + case ALL -> + modelFactory.fromAny( + new ProcessResult( + e.getExitStatus(), stdout.toString().trim(), buildStderr(e, stderr))); + case NONE -> modelFactory.fromNull(); + case CODE -> modelFactory.from(e.getExitStatus()); + case STDOUT -> modelFactory.from(stdout.toString().trim()); + case STDERR -> modelFactory.from(buildStderr(e, stderr)); + }; + } + } + }; + } + + /** + * Gets the stderr message from the PolyglotException or the stderr stream. + * + * @param e the {@link PolyglotException} thrown during script execution + * @param stderr the stderr stream + * @return the stderr message + */ + private String buildStderr(PolyglotException e, ByteArrayOutputStream stderr) { + String err = stderr.toString(); + return err.isBlank() ? e.getMessage() : err.trim(); + } + + /** + * Configures the process.env object in the JavaScript context with the provided environment + * variables. + * + * @param context the GraalVM context + * @param envs the environment variables to set + */ + private void configureProcessEnv(Context context, Map envs) { + String js = RunScriptExecutor.ScriptLanguage.JS.getLang(); + Value bindings = context.getBindings(js); + Value process = context.eval(js, "({ env: {} })"); + + for (var entry : envs.entrySet()) { + process.getMember("env").putMember(entry.getKey(), entry.getValue()); + } + bindings.putMember("process", process); + } +} diff --git a/impl/script-js/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.ScriptTaskRunner b/impl/script-js/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.ScriptTaskRunner new file mode 100644 index 00000000..0e3bb877 --- /dev/null +++ b/impl/script-js/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.ScriptTaskRunner @@ -0,0 +1 @@ +io.serverlessworkflow.impl.executors.script.js.JavaScriptScriptTaskRunner \ No newline at end of file diff --git a/impl/test/pom.xml b/impl/test/pom.xml index 7672bbef..5e6638e1 100644 --- a/impl/test/pom.xml +++ b/impl/test/pom.xml @@ -41,6 +41,10 @@ io.serverlessworkflow serverlessworkflow-impl-openapi + + io.serverlessworkflow + serverlessworkflow-impl-script-js + org.glassfish.jersey.core jersey-client diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java new file mode 100644 index 00000000..24832ebf --- /dev/null +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java @@ -0,0 +1,186 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.test; + +import io.serverlessworkflow.api.WorkflowReader; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.executors.ProcessResult; +import java.io.IOException; +import java.util.Map; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class RunScriptJavaScriptTest { + + private MockWebServer fileServer; + + @BeforeEach + void setUp() throws IOException { + fileServer = new MockWebServer(); + fileServer.start(8886); + } + + @AfterEach + void tearDown() throws IOException { + fileServer.shutdown(); + } + + @Test + void testConsoleLog() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/run-script/console-log.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("hello from script"); + }); + } + } + + @Test + void testConsoleLogWithArgs() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-args.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("Hello, world!"); + }); + } + } + + @Test + void testConsoleLogWithEnvs() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-envs.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly + .assertThat(model.asText().get()) + .isEqualTo("Running JavaScript code using Serverless Workflow!"); + }); + } + } + + @Test + void testConsoleLogWithExternalSource() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-external-source.yaml"); + + fileServer.enqueue( + new MockResponse() + .setBody( + """ + console.log("hello from script"); + """) + .setHeader("Content-Type", "application/javascript") + .setResponseCode(200)); + + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("hello from script"); + }); + } + } + + @Test + void testFunctionThrowingError() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/function-with-throw.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("Error: This is a test error"); + }); + } + } + + @Test + void testFunctionThrowingErrorAndReturnAll() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/function-with-throw-all.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + ProcessResult r = model.as(ProcessResult.class).orElseThrow(); + softly.assertThat(r.stderr()).isEqualTo("Error: This is a test error"); + softly.assertThat(r.stdout()).isEqualTo("logged before the 'throw' statement"); + softly.assertThat(r.code()).isEqualTo(0); + }); + } + } + + @Test + void testFunctionWithSyntaxError() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/function-with-syntax-error.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + Assertions.assertThatThrownBy( + () -> { + appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + }) + .hasMessageContaining("SyntaxError"); + } + } + + @Test + void testConsoleLogNotAwaiting() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-not-awaiting.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + Map input = Map.of("hello", "world"); + + WorkflowModel model = appl.workflowDefinition(workflow).instance(input).start().join(); + + Map output = model.asMap().orElseThrow(); + + Assertions.assertThat(output).isEqualTo(input); + } + } +} diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml new file mode 100644 index 00000000..c51681ea --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + arguments: + greetings: Hello, world! + code: > + console.log(greetings) \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml new file mode 100644 index 00000000..36684b2b --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + console.log(`Running ${process.env.LANGUAGE} code using Serverless Workflow!`); + environment: + LANGUAGE: JavaScript \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml new file mode 100644 index 00000000..5ab9ba4d --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml @@ -0,0 +1,13 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + source: + endpoint: + uri: http://localhost:8886/script.js diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml new file mode 100644 index 00000000..c67adb64 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml @@ -0,0 +1,13 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + console.log("hello from script"); + await: false diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml new file mode 100644 index 00000000..79c5013a --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml @@ -0,0 +1,12 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + console.log("hello from script"); diff --git a/impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml b/impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml new file mode 100644 index 00000000..75bd074d --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml @@ -0,0 +1,19 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + // there is no reserved word func in JavaScript, it should be function + func hello() { + console.log("hello from script"); + throw new Error("This is a test error"); + } + + hello(); + return: stderr diff --git a/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml new file mode 100644 index 00000000..c6e6cf3f --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml @@ -0,0 +1,18 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + function hello() { + console.log("logged before the 'throw' statement"); + throw new Error("This is a test error"); + } + + hello(); + return: all diff --git a/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml new file mode 100644 index 00000000..89236129 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml @@ -0,0 +1,18 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + function hello() { + console.log("hello from script"); + throw new Error("This is a test error"); + } + + hello(); + return: stderr diff --git a/impl/test/src/test/resources/workflows-samples/run-script/script.js b/impl/test/src/test/resources/workflows-samples/run-script/script.js new file mode 100644 index 00000000..f7b8bd8c --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/script.js @@ -0,0 +1 @@ +console.log("hello from script"); \ No newline at end of file From 587450e4eaa0c6b73ec71d31efb349aafcf06648 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Sat, 8 Nov 2025 12:28:10 -0300 Subject: [PATCH 2/2] Introduce Run.script task with JavaScript Signed-off-by: Matheus Cruz --- .../impl/executors/RunScriptExecutor.java | 17 ++++++++++------- .../impl/executors/ScriptTaskRunner.java | 4 ++-- .../script/js/JavaScriptScriptTaskRunner.java | 15 ++++++++++----- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java index 32519afc..9ed8222a 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java @@ -37,13 +37,13 @@ public class RunScriptExecutor implements RunnableTask { - public enum ScriptLanguage { + public enum LanguageId { JS("js"), PYTHON("python"); private final String lang; - ScriptLanguage(String lang) { + LanguageId(String lang) { this.lang = lang; } @@ -52,7 +52,7 @@ public String getLang() { } public static boolean isSupported(String lang) { - for (ScriptLanguage l : ScriptLanguage.values()) { + for (LanguageId l : LanguageId.values()) { if (l.getLang().equalsIgnoreCase(lang)) { return true; } @@ -68,8 +68,10 @@ private interface CodeSupplier { @SuppressWarnings("rawtypes") private Map environmentExpr; + @SuppressWarnings("rawtypes") private Map argumentExpr; + private CodeSupplier codeSupplier; private boolean isAwait; private RunTaskConfiguration.ProcessReturnType returnType; @@ -82,13 +84,13 @@ public void init(RunScript taskConfiguration, WorkflowDefinition definition) { String language = scriptUnion.get().getLanguage(); WorkflowApplication application = definition.application(); - if (language == null || !ScriptLanguage.isSupported(language)) { + if (language == null || !LanguageId.isSupported(language)) { throw new IllegalArgumentException( "Unsupported script language: " + language + ". Supported languages are: " + Arrays.toString( - Arrays.stream(ScriptLanguage.values()).map(ScriptLanguage::getLang).toArray())); + Arrays.stream(LanguageId.values()).map(LanguageId::getLang).toArray())); } this.taskRunner = @@ -161,7 +163,7 @@ public CompletableFuture apply( String code = this.codeSupplier.apply(workflowContext, taskContext); - RunScriptContext ctx = + RunScriptContext scriptContext = builder .withApplication(workflowContext.definition().application()) .withReturnType(returnType) @@ -171,7 +173,8 @@ public CompletableFuture apply( .withAwait(isAwait) .build(); - return CompletableFuture.supplyAsync(() -> taskRunner.buildRun(taskContext).apply(ctx, input)); + return CompletableFuture.supplyAsync( + () -> taskRunner.buildRun(taskContext).apply(scriptContext, input)); } @Override diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ScriptTaskRunner.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ScriptTaskRunner.java index c52e0f69..361ddcdd 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ScriptTaskRunner.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ScriptTaskRunner.java @@ -25,9 +25,9 @@ public interface ScriptTaskRunner { /** * The scripting language supported by this script task runner. * - * @return the scripting language as {@link RunScriptExecutor.ScriptLanguage} enum. + * @return the scripting language as {@link RunScriptExecutor.LanguageId} enum. */ - RunScriptExecutor.ScriptLanguage forLanguage(); + RunScriptExecutor.LanguageId identifier(); /** * Returns a function that executes the script task. diff --git a/impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java b/impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java index 8e881c2b..d827c61a 100644 --- a/impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java +++ b/impl/script-js/src/main/java/io/serverlessworkflow/impl/executors/script/js/JavaScriptScriptTaskRunner.java @@ -29,20 +29,25 @@ import java.util.function.BiFunction; import org.graalvm.polyglot.Context; import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Source; import org.graalvm.polyglot.Value; +/** + * JavaScript implementation of the {@link ScriptTaskRunner} interface that executes JavaScript + * scripts using GraalVM Polyglot API. + */ public class JavaScriptScriptTaskRunner implements ScriptTaskRunner { @Override - public RunScriptExecutor.ScriptLanguage forLanguage() { - return RunScriptExecutor.ScriptLanguage.JS; + public RunScriptExecutor.LanguageId identifier() { + return RunScriptExecutor.LanguageId.JS; } @Override public BiFunction buildRun( TaskContext taskContext) { return (script, input) -> { - String js = forLanguage().getLang(); + String js = identifier().getLang(); WorkflowApplication application = script.getApplication(); ByteArrayOutputStream stderr = new ByteArrayOutputStream(); ByteArrayOutputStream stdout = new ByteArrayOutputStream(); @@ -77,7 +82,7 @@ public BiFunction @@ -126,7 +131,7 @@ private String buildStderr(PolyglotException e, ByteArrayOutputStream stderr) { * @param envs the environment variables to set */ private void configureProcessEnv(Context context, Map envs) { - String js = RunScriptExecutor.ScriptLanguage.JS.getLang(); + String js = RunScriptExecutor.LanguageId.JS.getLang(); Value bindings = context.getBindings(js); Value process = context.eval(js, "({ env: {} })");