feat(server): implement workflow triggering and enhance script execution

- Added support for triggering workflows via a new triggerWorkflow function, allowing for fire-and-forget execution of workflows with optional data transformation using JSONata.
- Introduced a $workflows API in the script sandbox for accessing workflow triggers.
- Enhanced existing script execution functions to accommodate the new workflow triggering capabilities.
- Updated workflow-mermaid.js and WorkflowsPage to reflect the new workflow type in labels for better clarity.
This commit is contained in:
2026-08-14 22:12:34 +07:00
parent 3e4d5f443d
commit b8bbd56035
5 changed files with 303 additions and 35 deletions
+172 -23
View File
@@ -21,6 +21,18 @@ import * as fsStore from "./fs-store.js";
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
*/
const MAX_WORKFLOW_TRIGGER_DEPTH = 8;
/**
* @param {unknown} workflow
*/
function hasWorkflowTrigger(workflow) {
if (!workflow || typeof workflow !== "object") return false;
const triggers = /** @type {{ triggers?: Array<{ type?: string }> }} */ (workflow)
.triggers;
return (triggers ?? []).some((t) => t?.type === "workflow");
}
/**
* @param {import("fastify").FastifyInstance} server
*/
@@ -36,6 +48,49 @@ export function createRegistry(server) {
/** @type {Set<string>} */
const registeredHttpRoutes = new Set();
/**
* Resolve a same-owner workflow that opts in with `type: workflow`.
* Prefers YAML `name`, then filename (`name` or `name.yaml`).
*
* @param {string} owner
* @param {string} name
*/
function resolveWorkflowTriggerKey(owner, name) {
if (typeof name !== "string" || name.length === 0) {
throw new Error("workflow name is required");
}
/** @type {string[]} */
const byName = [];
for (const [key, entry] of workflows) {
if (entry.owner !== owner) continue;
if (entry.workflow?.enabled === false) continue;
if (!hasWorkflowTrigger(entry.workflow)) continue;
if (entry.workflow?.name === name) byName.push(key);
}
if (byName.length === 1) return byName[0];
if (byName.length > 1) {
throw new Error(`ambiguous workflow name "${name}"`);
}
const fileCandidates =
name.endsWith(".yaml") || name.endsWith(".yml")
? [`${owner}/${name}`]
: [`${owner}/${name}`, `${owner}/${name}.yaml`];
for (const key of fileCandidates) {
const entry = workflows.get(key);
if (!entry) continue;
if (entry.workflow?.enabled === false) continue;
if (!hasWorkflowTrigger(entry.workflow)) continue;
return key;
}
throw new Error(
`workflow "${name}" not found or has no workflow trigger (owner "${owner}")`,
);
}
function registerWorkflows() {
workflows.clear();
loadErrors.clear();
@@ -208,12 +263,22 @@ export function createRegistry(server) {
* @param {import("pino").Logger} runLog
* @param {string} key
* @param {string} owner
* @param {number} depth
*/
async function runLinearSteps(compiled, ctx, runId, runLog, key, owner) {
async function runLinearSteps(compiled, ctx, runId, runLog, key, owner, depth) {
let next = ctx;
for (const index of compiled.order) {
const parsed = compiled.steps[index];
next = await runCompiledStep(parsed, next, index, runId, runLog, key, owner);
next = await runCompiledStep(
parsed,
next,
index,
runId,
runLog,
key,
owner,
depth,
);
}
return next;
}
@@ -225,8 +290,9 @@ export function createRegistry(server) {
* @param {import("pino").Logger} runLog
* @param {string} key
* @param {string} owner
* @param {number} depth
*/
async function runDagSteps(compiled, ctx, runId, runLog, key, owner) {
async function runDagSteps(compiled, ctx, runId, runLog, key, owner, depth) {
const triggerData = ctx.data;
/** @type {Map<string, unknown>} */
const outputsById = new Map();
@@ -243,6 +309,7 @@ export function createRegistry(server) {
runLog,
key,
owner,
depth,
);
if (parsed.id) {
outputsById.set(parsed.id, last);
@@ -251,6 +318,39 @@ export function createRegistry(server) {
return last;
}
/**
* @param {string} owner
* @param {string} parentKey
* @param {string} parentRunId
* @param {number} depth
*/
function createWorkflowsApi(owner, parentKey, parentRunId, depth) {
return {
/**
* @param {string} name
* @param {unknown} [data]
*/
async trigger(name, data) {
if (depth >= MAX_WORKFLOW_TRIGGER_DEPTH) {
throw new Error(
`workflow trigger depth limit (${MAX_WORKFLOW_TRIGGER_DEPTH}) exceeded`,
);
}
const destKey = resolveWorkflowTriggerKey(owner, name);
return runWorkflow(
destKey,
{ data },
{ type: "workflow", detail: parentKey },
{
parentRunId,
depth: depth + 1,
detach: true,
},
);
},
};
}
/**
* @param {import("./workflow-parse.js").CompiledStep} parsed
* @param {{ data?: unknown, config?: unknown }} ctx
@@ -259,8 +359,18 @@ export function createRegistry(server) {
* @param {import("pino").Logger} runLog
* @param {string} key
* @param {string} owner
* @param {number} depth
*/
async function runCompiledStep(parsed, ctx, index, runId, runLog, key, owner) {
async function runCompiledStep(
parsed,
ctx,
index,
runId,
runLog,
key,
owner,
depth,
) {
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
const config = parsed.config;
const step = await store.startStep({
@@ -292,6 +402,7 @@ export function createRegistry(server) {
log: stepLog,
workflowName: key,
owner,
$workflows: createWorkflowsApi(owner, key, runId, depth),
});
await store.finishStep(step.id, "success", result);
return result;
@@ -305,8 +416,17 @@ export function createRegistry(server) {
* @param {string} key
* @param {{ data?: unknown }} context
* @param {{ type: string, detail?: string | null }} trigger
* @param {{
* parentRunId?: string | null,
* depth?: number,
* detach?: boolean,
* }} [opts]
*/
async function runWorkflow(key, context, trigger) {
async function runWorkflow(key, context, trigger, opts = {}) {
const parentRunId = opts.parentRunId ?? null;
const depth = opts.depth ?? 0;
const detach = opts.detach === true;
const entry = workflows.get(key);
if (!entry) {
log.error({ workflow: key }, "workflow not found");
@@ -320,33 +440,62 @@ export function createRegistry(server) {
workflowName: workflow?.name,
trigger,
input: context.data,
parentRunId,
});
const runLog = log.child({ runId: run.id, owner, workflow: key });
runLog.debug("running workflow");
runLog.debug(detach ? "running workflow (detached)" : "running workflow");
let ctx = {
const initialCtx = {
...context,
data: context.data ?? workflow.data ?? null,
};
try {
const compiled = compileWorkflowScripts(workflow.scripts);
if (compiled.dagMode) {
ctx = await runDagSteps(compiled, ctx, run.id, runLog, key, owner);
} else {
ctx = await runLinearSteps(compiled, ctx, run.id, runLog, key, owner);
const execute = async () => {
let ctx = initialCtx;
try {
const compiled = compileWorkflowScripts(workflow.scripts);
if (compiled.dagMode) {
ctx = await runDagSteps(
compiled,
ctx,
run.id,
runLog,
key,
owner,
depth,
);
} else {
ctx = await runLinearSteps(
compiled,
ctx,
run.id,
runLog,
key,
owner,
depth,
);
}
await store.finishRun(run.id, "success", ctx);
return { runId: run.id, status: "success", result: ctx };
} catch (err) {
runLog.error({ err }, "workflow failed");
await store.finishRun(run.id, "failed", null, err);
return {
runId: run.id,
status: "failed",
error: err instanceof Error ? err.message : String(err),
};
}
await store.finishRun(run.id, "success", ctx);
return { runId: run.id, status: "success", result: ctx };
} catch (err) {
runLog.error({ err }, "workflow failed");
await store.finishRun(run.id, "failed", null, err);
return {
runId: run.id,
status: "failed",
error: err instanceof Error ? err.message : String(err),
};
};
if (detach) {
execute().catch((err) => {
runLog.error({ err }, "detached workflow failed unexpectedly");
});
return { runId: run.id, status: "started" };
}
return execute();
}
function reregister() {