feat(server): enhance workflow step execution and JSONata integration
- Refactored step execution logic to introduce runCompiledStep for improved clarity and functionality. - Added support for "set" steps, allowing context manipulation using JSONata expressions. - Implemented evaluateJsonata and isJsonataTruthy functions for evaluating conditions and expressions. - Updated workflow parsing to handle new step types and validation rules. - Enhanced logging and error handling for skipped steps and invalid configurations. - Modified UI components to reflect new step types and statuses, including "skipped".
This commit is contained in:
@@ -7,7 +7,10 @@ import { log } from "./logger.js";
|
|||||||
import * as store from "./store.js";
|
import * as store from "./store.js";
|
||||||
import { clearScriptCache, runScript } from "./script-sandbox.js";
|
import { clearScriptCache, runScript } from "./script-sandbox.js";
|
||||||
import {
|
import {
|
||||||
|
SET_STEP_SCRIPT,
|
||||||
compileWorkflowScripts,
|
compileWorkflowScripts,
|
||||||
|
evaluateJsonata,
|
||||||
|
isJsonataTruthy,
|
||||||
mergeStepData,
|
mergeStepData,
|
||||||
namespacedPath,
|
namespacedPath,
|
||||||
parseScriptStep,
|
parseScriptStep,
|
||||||
@@ -209,7 +212,7 @@ export function createRegistry(server) {
|
|||||||
let next = ctx;
|
let next = ctx;
|
||||||
for (const index of compiled.order) {
|
for (const index of compiled.order) {
|
||||||
const parsed = compiled.steps[index];
|
const parsed = compiled.steps[index];
|
||||||
next = await executeStep(parsed, next, index, runId, runLog, key);
|
next = await runCompiledStep(parsed, next, index, runId, runLog, key);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
@@ -230,7 +233,7 @@ export function createRegistry(server) {
|
|||||||
for (const [orderIndex, stepIndex] of compiled.order.entries()) {
|
for (const [orderIndex, stepIndex] of compiled.order.entries()) {
|
||||||
const parsed = compiled.steps[stepIndex];
|
const parsed = compiled.steps[stepIndex];
|
||||||
const data = mergeStepData(parsed, outputsById, triggerData);
|
const data = mergeStepData(parsed, outputsById, triggerData);
|
||||||
last = await executeStep(
|
last = await runCompiledStep(
|
||||||
parsed,
|
parsed,
|
||||||
{ ...ctx, data },
|
{ ...ctx, data },
|
||||||
orderIndex,
|
orderIndex,
|
||||||
@@ -253,8 +256,9 @@ export function createRegistry(server) {
|
|||||||
* @param {import("pino").Logger} runLog
|
* @param {import("pino").Logger} runLog
|
||||||
* @param {string} key
|
* @param {string} key
|
||||||
*/
|
*/
|
||||||
async function executeStep(parsed, ctx, index, runId, runLog, key) {
|
async function runCompiledStep(parsed, ctx, index, runId, runLog, key) {
|
||||||
const { script, config } = parsed;
|
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||||
|
const config = parsed.config;
|
||||||
const step = await store.startStep({
|
const step = await store.startStep({
|
||||||
runId,
|
runId,
|
||||||
index,
|
index,
|
||||||
@@ -263,6 +267,23 @@ export function createRegistry(server) {
|
|||||||
});
|
});
|
||||||
const stepLog = runLog.child({ stepId: step.id, script });
|
const stepLog = runLog.child({ stepId: step.id, script });
|
||||||
try {
|
try {
|
||||||
|
if (parsed.when) {
|
||||||
|
const whenResult = await evaluateJsonata(parsed.when, ctx);
|
||||||
|
if (!isJsonataTruthy(whenResult)) {
|
||||||
|
stepLog.debug({ when: parsed.when }, "step skipped");
|
||||||
|
await store.finishStep(step.id, "skipped", ctx);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.kind === "set") {
|
||||||
|
if (ctx == null || typeof ctx !== "object" || Array.isArray(ctx)) {
|
||||||
|
throw new Error("set requires an object context");
|
||||||
|
}
|
||||||
|
const value = await evaluateJsonata(parsed.expression, ctx);
|
||||||
|
const result = { ...ctx, [parsed.as]: value };
|
||||||
|
await store.finishStep(step.id, "success", result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
const result = await runScript(script, { ...ctx, config }, {
|
const result = await runScript(script, { ...ctx, config }, {
|
||||||
log: stepLog,
|
log: stepLog,
|
||||||
workflowName: key,
|
workflowName: key,
|
||||||
@@ -334,7 +355,8 @@ export function createRegistry(server) {
|
|||||||
for (const { workflow } of workflows.values()) {
|
for (const { workflow } of workflows.values()) {
|
||||||
for (const raw of workflow.scripts ?? []) {
|
for (const raw of workflow.scripts ?? []) {
|
||||||
try {
|
try {
|
||||||
refs.add(parseScriptStep(raw).script);
|
const parsed = parseScriptStep(raw);
|
||||||
|
if (parsed.kind === "script") refs.add(parsed.script);
|
||||||
} catch {
|
} catch {
|
||||||
// skip invalid steps
|
// skip invalid steps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ function scriptNames(workflow) {
|
|||||||
const names = [];
|
const names = [];
|
||||||
for (const raw of workflow.scripts ?? []) {
|
for (const raw of workflow.scripts ?? []) {
|
||||||
try {
|
try {
|
||||||
names.push(parseScriptStep(raw).script);
|
const parsed = parseScriptStep(raw);
|
||||||
|
names.push(parsed.kind === "set" ? `set:${parsed.as}` : parsed.script);
|
||||||
} catch {
|
} catch {
|
||||||
names.push(null);
|
names.push(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export async function startStep({ runId, index, script, config = null }) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {"success" | "failed"} status
|
* @param {"success" | "failed" | "skipped"} status
|
||||||
* @param {unknown} [output]
|
* @param {unknown} [output]
|
||||||
* @param {Error | unknown} [err]
|
* @param {Error | unknown} [err]
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
|
import jsonata from "jsonata";
|
||||||
|
|
||||||
|
const AS_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
const RESERVED_AS = new Set(["data", "config"]);
|
||||||
|
|
||||||
|
export const SET_STEP_SCRIPT = "set";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ alias: string, from: string }} NeedEdge
|
* @typedef {{ alias: string, from: string }} NeedEdge
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
|
* kind: "script",
|
||||||
* script: string,
|
* script: string,
|
||||||
* config: unknown | null,
|
* config: unknown | null,
|
||||||
|
* expression?: undefined,
|
||||||
|
* as?: undefined,
|
||||||
* id: string | null,
|
* id: string | null,
|
||||||
* needsKind: "none" | "list" | "map",
|
* needsKind: "none" | "list" | "map",
|
||||||
* needs: NeedEdge[],
|
* needs: NeedEdge[],
|
||||||
* }} ParsedStep
|
* when: string | null,
|
||||||
|
* }} ParsedScriptStep
|
||||||
|
* @typedef {{
|
||||||
|
* kind: "set",
|
||||||
|
* script: typeof SET_STEP_SCRIPT,
|
||||||
|
* config: { expression: string, as: string },
|
||||||
|
* expression: string,
|
||||||
|
* as: string,
|
||||||
|
* id: string | null,
|
||||||
|
* needsKind: "none",
|
||||||
|
* needs: NeedEdge[],
|
||||||
|
* when: string | null,
|
||||||
|
* }} ParsedSetStep
|
||||||
|
* @typedef {ParsedScriptStep | ParsedSetStep} ParsedStep
|
||||||
* @typedef {ParsedStep & { index: number }} CompiledStep
|
* @typedef {ParsedStep & { index: number }} CompiledStep
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
* dagMode: boolean,
|
* dagMode: boolean,
|
||||||
@@ -16,30 +39,36 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} step
|
* Compile a JSONata source so save/load fails on syntax errors.
|
||||||
* @returns {ParsedStep}
|
* @param {string} source
|
||||||
|
* @param {string} label
|
||||||
*/
|
*/
|
||||||
export function parseScriptStep(step) {
|
export function compileJsonata(source, label) {
|
||||||
if (typeof step === "string") {
|
try {
|
||||||
return {
|
return jsonata(source);
|
||||||
script: step,
|
} catch (err) {
|
||||||
config: null,
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
id: null,
|
throw new Error(`Invalid ${label}: ${msg}`);
|
||||||
needsKind: "none",
|
|
||||||
needs: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (step?.script) {
|
}
|
||||||
const { needsKind, needs } = parseNeeds(step.needs);
|
|
||||||
return {
|
/**
|
||||||
script: step.script,
|
* @param {unknown} value
|
||||||
config: step.config ?? null,
|
*/
|
||||||
id: parseOptionalId(step.id),
|
export function isJsonataTruthy(value) {
|
||||||
needsKind,
|
if (value == null || value === false) return false;
|
||||||
needs,
|
if (Array.isArray(value) && value.length === 0) return false;
|
||||||
};
|
return true;
|
||||||
}
|
}
|
||||||
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
|
||||||
|
/**
|
||||||
|
* Evaluate JSONata against the full ctx (jsonata 2 may return a Promise).
|
||||||
|
* @param {string} source
|
||||||
|
* @param {unknown} ctx
|
||||||
|
*/
|
||||||
|
export async function evaluateJsonata(source, ctx) {
|
||||||
|
const result = compileJsonata(source, "expression").evaluate(ctx);
|
||||||
|
return await result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,6 +81,56 @@ export function namespacedPath(owner, triggerPath) {
|
|||||||
return `/u/${owner}/${cleaned}`;
|
return `/u/${owner}/${cleaned}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} step
|
||||||
|
* @returns {ParsedStep}
|
||||||
|
*/
|
||||||
|
export function parseScriptStep(step) {
|
||||||
|
if (typeof step === "string") {
|
||||||
|
return {
|
||||||
|
kind: "script",
|
||||||
|
script: step,
|
||||||
|
config: null,
|
||||||
|
id: null,
|
||||||
|
needsKind: "none",
|
||||||
|
needs: [],
|
||||||
|
when: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step == null || typeof step !== "object" || Array.isArray(step)) {
|
||||||
|
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasScript = step.script != null && step.script !== "";
|
||||||
|
const hasSet = step.set != null;
|
||||||
|
|
||||||
|
if (hasScript && hasSet) {
|
||||||
|
throw new Error("Step cannot have both script and set");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSet) {
|
||||||
|
return parseSetStep(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasScript) {
|
||||||
|
if (typeof step.script !== "string") {
|
||||||
|
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||||
|
}
|
||||||
|
const { needsKind, needs } = parseNeeds(step.needs);
|
||||||
|
return {
|
||||||
|
kind: "script",
|
||||||
|
script: step.script,
|
||||||
|
config: step.config ?? null,
|
||||||
|
id: parseOptionalId(step.id),
|
||||||
|
needsKind,
|
||||||
|
needs,
|
||||||
|
when: parseWhen(step.when),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse scripts, detect linear vs DAG mode, and return a topological order.
|
* Parse scripts, detect linear vs DAG mode, and return a topological order.
|
||||||
* Linear mode (no `needs` on any step) keeps array order.
|
* Linear mode (no `needs` on any step) keeps array order.
|
||||||
@@ -81,6 +160,18 @@ export function compileWorkflowScripts(scripts) {
|
|||||||
ids.set(step.id, step.index);
|
ids.set(step.id, step.index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dagMode) {
|
||||||
|
for (const step of steps) {
|
||||||
|
const who = step.id ?? String(step.index);
|
||||||
|
if (step.kind === "set") {
|
||||||
|
throw new Error(`set steps are not allowed in DAG workflows (step ${who})`);
|
||||||
|
}
|
||||||
|
if (step.when) {
|
||||||
|
throw new Error(`when is not allowed in DAG workflows (step ${who})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!dagMode) {
|
if (!dagMode) {
|
||||||
return { dagMode: false, steps, order: steps.map((s) => s.index) };
|
return { dagMode: false, steps, order: steps.map((s) => s.index) };
|
||||||
}
|
}
|
||||||
@@ -167,6 +258,56 @@ export function mergeStepData(step, outputsById, triggerData) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} step
|
||||||
|
* @returns {ParsedSetStep}
|
||||||
|
*/
|
||||||
|
function parseSetStep(step) {
|
||||||
|
if (step.needs != null) {
|
||||||
|
throw new Error("needs is not allowed on set steps");
|
||||||
|
}
|
||||||
|
const spec = step.set;
|
||||||
|
if (spec == null || typeof spec !== "object" || Array.isArray(spec)) {
|
||||||
|
throw new Error(`Invalid set step: ${JSON.stringify(step)}`);
|
||||||
|
}
|
||||||
|
const expression = spec.expression;
|
||||||
|
const as = spec.as;
|
||||||
|
if (typeof expression !== "string" || !expression.trim()) {
|
||||||
|
throw new Error("set.expression is required");
|
||||||
|
}
|
||||||
|
if (typeof as !== "string" || !AS_IDENT.test(as)) {
|
||||||
|
throw new Error(`Invalid set.as: ${JSON.stringify(as)}`);
|
||||||
|
}
|
||||||
|
if (RESERVED_AS.has(as)) {
|
||||||
|
throw new Error(`set.as "${as}" is reserved`);
|
||||||
|
}
|
||||||
|
compileJsonata(expression, "set.expression");
|
||||||
|
return {
|
||||||
|
kind: "set",
|
||||||
|
script: SET_STEP_SCRIPT,
|
||||||
|
config: { expression, as },
|
||||||
|
expression,
|
||||||
|
as,
|
||||||
|
id: parseOptionalId(step.id),
|
||||||
|
needsKind: "none",
|
||||||
|
needs: [],
|
||||||
|
when: parseWhen(step.when),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} when
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
function parseWhen(when) {
|
||||||
|
if (when == null || when === "") return null;
|
||||||
|
if (typeof when !== "string") {
|
||||||
|
throw new Error(`Invalid when: ${JSON.stringify(when)}`);
|
||||||
|
}
|
||||||
|
compileJsonata(when, "when");
|
||||||
|
return when;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} id
|
* @param {unknown} id
|
||||||
* @returns {string | null}
|
* @returns {string | null}
|
||||||
@@ -181,7 +322,7 @@ function parseOptionalId(id) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} needs
|
* @param {unknown} needs
|
||||||
* @returns {{ needsKind: ParsedStep["needsKind"], needs: NeedEdge[] }}
|
* @returns {{ needsKind: ParsedScriptStep["needsKind"], needs: NeedEdge[] }}
|
||||||
*/
|
*/
|
||||||
function parseNeeds(needs) {
|
function parseNeeds(needs) {
|
||||||
if (needs == null) {
|
if (needs == null) {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export function StatusBadge({ status }) {
|
|||||||
? "badge-error"
|
? "badge-error"
|
||||||
: status === "running"
|
: status === "running"
|
||||||
? "badge-warning"
|
? "badge-warning"
|
||||||
|
: status === "skipped"
|
||||||
|
? "badge-info"
|
||||||
: "badge-ghost";
|
: "badge-ghost";
|
||||||
return <span className={`badge badge-sm ${cls}`}>{status ?? "—"}</span>;
|
return <span className={`badge badge-sm ${cls}`}>{status ?? "—"}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
function parseStep(step) {
|
function parseStep(step) {
|
||||||
if (typeof step === "string") {
|
if (typeof step === "string") {
|
||||||
return { script: step, id: null, needs: null };
|
return { kind: "script", script: step, id: null, needs: null, when: null, as: null };
|
||||||
|
}
|
||||||
|
if (step?.set) {
|
||||||
|
const as = typeof step.set?.as === "string" && step.set.as ? step.set.as : "set";
|
||||||
|
return {
|
||||||
|
kind: "set",
|
||||||
|
script: null,
|
||||||
|
as,
|
||||||
|
id: typeof step.id === "string" && step.id ? step.id : null,
|
||||||
|
needs: null,
|
||||||
|
when: typeof step.when === "string" && step.when ? step.when : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (step?.script) {
|
if (step?.script) {
|
||||||
return {
|
return {
|
||||||
|
kind: "script",
|
||||||
script: step.script,
|
script: step.script,
|
||||||
|
as: null,
|
||||||
id: typeof step.id === "string" && step.id ? step.id : null,
|
id: typeof step.id === "string" && step.id ? step.id : null,
|
||||||
needs: step.needs ?? null,
|
needs: step.needs ?? null,
|
||||||
|
when: typeof step.when === "string" && step.when ? step.when : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -38,6 +52,15 @@ function triggerLabel(t) {
|
|||||||
return `${method} ${path}`.trim();
|
return `${method} ${path}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stepLabel(s) {
|
||||||
|
if (s.kind === "set") {
|
||||||
|
const base = s.id ? `${s.id}: set ${s.as}` : `set ${s.as}`;
|
||||||
|
return s.when ? `${base} when: ${s.when}` : base;
|
||||||
|
}
|
||||||
|
const base = s.id ? `${s.id}: ${s.script}` : s.script;
|
||||||
|
return s.when ? `${base} when: ${s.when}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
export function workflowToFlowchart(parsed) {
|
export function workflowToFlowchart(parsed) {
|
||||||
/** @type {Record<string, string>} */
|
/** @type {Record<string, string>} */
|
||||||
const scriptIds = {};
|
const scriptIds = {};
|
||||||
@@ -72,9 +95,13 @@ export function workflowToFlowchart(parsed) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const s of parsedSteps) {
|
for (const s of parsedSteps) {
|
||||||
|
const label = mermaidLabel(stepLabel(s));
|
||||||
|
if (s.kind === "set") {
|
||||||
|
lines.push(` ${s.mermaidId}(["${label}"])`);
|
||||||
|
} else {
|
||||||
scriptIds[s.mermaidId] = s.script;
|
scriptIds[s.mermaidId] = s.script;
|
||||||
const label = s.id ? `${s.id}: ${s.script}` : s.script;
|
lines.push(` ${s.mermaidId}["${label}"]`);
|
||||||
lines.push(` ${s.mermaidId}["${mermaidLabel(label)}"]`);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dagMode) {
|
if (dagMode) {
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import { useRun } from "../api/hooks.js";
|
|||||||
import { LogViewer } from "../components/LogViewer.jsx";
|
import { LogViewer } from "../components/LogViewer.jsx";
|
||||||
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
||||||
|
|
||||||
|
function stepLabel(s) {
|
||||||
|
if (s.script === "set") {
|
||||||
|
return s.config?.as ? `set:${s.config.as}` : "set";
|
||||||
|
}
|
||||||
|
return s.script;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableScript(s) {
|
||||||
|
return Boolean(s.script) && s.script !== "set";
|
||||||
|
}
|
||||||
|
|
||||||
export function EventDetailPage() {
|
export function EventDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { data: run, isLoading, error } = useRun(id);
|
const { data: run, isLoading, error } = useRun(id);
|
||||||
@@ -67,9 +78,13 @@ export function EventDetailPage() {
|
|||||||
<tr key={s.id}>
|
<tr key={s.id}>
|
||||||
<td>{s.step_index}</td>
|
<td>{s.step_index}</td>
|
||||||
<td className="font-mono">
|
<td className="font-mono">
|
||||||
|
{isEditableScript(s) ? (
|
||||||
<Link className="link" to={`/scripts/${encodeURIComponent(s.script)}/edit`}>
|
<Link className="link" to={`/scripts/${encodeURIComponent(s.script)}/edit`}>
|
||||||
{s.script}
|
{stepLabel(s)}
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
stepLabel(s)
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<StatusBadge status={s.status} />
|
<StatusBadge status={s.status} />
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function EventsPage() {
|
|||||||
<option value="running">running</option>
|
<option value="running">running</option>
|
||||||
<option value="success">success</option>
|
<option value="success">success</option>
|
||||||
<option value="failed">failed</option>
|
<option value="failed">failed</option>
|
||||||
|
<option value="skipped">skipped</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user