Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,288 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WorkflowDto } from "../dtos/WorkflowDto";
type WorkflowPreflightServiceTestMocks = {
getAvailableVariableNames: ReturnType<typeof vi.fn>;
getAvailableCredentialNames: ReturnType<typeof vi.fn>;
};
function getMocks(): WorkflowPreflightServiceTestMocks {
const store = globalThis as typeof globalThis & { __workflowPreflightServiceTestMocks__?: WorkflowPreflightServiceTestMocks };
store.__workflowPreflightServiceTestMocks__ ??= {
getAvailableVariableNames: vi.fn(),
getAvailableCredentialNames: vi.fn(),
};
return store.__workflowPreflightServiceTestMocks__;
}
vi.mock("./WorkflowVariablesAndCredentialsService", () => ({
default: {
getAvailableVariableNames: getMocks().getAvailableVariableNames,
getAvailableCredentialNames: getMocks().getAvailableCredentialNames,
},
}));
import WorkflowPreflightService from "./WorkflowPreflightService";
const mocks = getMocks();
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: null,
name: "Workflow",
description: "",
status: "live",
createdAt: new Date("2026-05-08T10:00:00.000Z"),
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
appliesToAllTenants: false,
blocks: [
{
id: "block_1",
index: 0,
type: "email",
variableName: "email",
description: "",
input: { to: "{{$vars.recipient}}", subject: "{{$vars.subject}}", body: "Hello", provider: "{{$credentials.mail}}" },
isTrigger: false,
isBlock: true,
conditionGroups: [],
toBlocks: [],
},
],
inputExamples: [],
_count: { executions: 0 },
...overrides,
};
}
describe("WorkflowPreflightService", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getAvailableVariableNames.mockResolvedValue(["recipient"]);
mocks.getAvailableCredentialNames.mockResolvedValue(["mail"]);
});
it("reports missing workflow variables", async () => {
const result = await WorkflowPreflightService.check({ workflow: workflow(), tenantId: "tenant_1" });
expect(result.ok).toBe(false);
expect(result.missingVariables).toEqual(["subject"]);
expect(result.missingCredentials).toEqual([]);
expect(result.missingRequiredInputs).toEqual([]);
expect(result.invalidValidationSchemas).toEqual([]);
expect(result.nativeCapabilityErrors).toEqual([]);
expect(result.errors).toEqual(["Missing workflow variable: subject"]);
});
it("reports missing workflow credentials without loading credential values", async () => {
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
mocks.getAvailableCredentialNames.mockResolvedValue([]);
const result = await WorkflowPreflightService.check({ workflow: workflow(), tenantId: "tenant_1" });
expect(result.ok).toBe(false);
expect(result.missingCredentials).toEqual(["mail"]);
expect(result.missingRequiredInputs).toEqual([]);
expect(result.invalidValidationSchemas).toEqual([]);
expect(mocks.getAvailableCredentialNames).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
});
it("reports missing workflow references in supported Handlebars lookup and with expressions", async () => {
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
mocks.getAvailableCredentialNames.mockResolvedValue(["mail"]);
const result = await WorkflowPreflightService.check({
workflow: workflow({
blocks: [
{
...workflow().blocks[0],
input: {
to: "{{$vars.recipient}}",
subject: "{{$vars.subject}}",
body: "{{#with $vars}}{{planTier}}{{/with}}",
provider: 'Bearer {{lookup $credentials "apiToken"}} {{#with $credentials}}{{webhookSecret}}{{/with}}',
},
},
],
}),
tenantId: "tenant_1",
});
expect(result.ok).toBe(false);
expect(result.missingVariables).toEqual(["planTier"]);
expect(result.missingCredentials).toEqual(["apiToken", "webhookSecret"]);
expect(result.missingRequiredInputs).toEqual([]);
expect(result.invalidValidationSchemas).toEqual([]);
expect(result.errors).toEqual([
"Missing workflow variable: planTier",
"Missing workflow credential: apiToken",
"Missing workflow credential: webhookSecret",
]);
});
it("reports missing required block inputs", async () => {
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
const result = await WorkflowPreflightService.check({
workflow: workflow({
blocks: [
{
...workflow().blocks[0],
input: { to: "{{$vars.recipient}}", subject: "{{$vars.subject}}", provider: "{{$credentials.mail}}" },
},
],
}),
tenantId: "tenant_1",
});
expect(result.ok).toBe(false);
expect(result.missingVariables).toEqual([]);
expect(result.missingCredentials).toEqual([]);
expect(result.missingRequiredInputs).toEqual([{ blockId: "block_1", blockType: "email", missingInputs: ["body"] }]);
expect(result.invalidValidationSchemas).toEqual([]);
expect(result.errors).toEqual(["Missing required input on block block_1: body"]);
});
it("reports blank required string inputs as missing", async () => {
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
const result = await WorkflowPreflightService.check({
workflow: workflow({
blocks: [
{
...workflow().blocks[0],
input: { to: "{{$vars.recipient}}", subject: "{{$vars.subject}}", provider: "{{$credentials.mail}}", body: " " },
},
],
}),
tenantId: "tenant_1",
});
expect(result.ok).toBe(false);
expect(result.missingRequiredInputs).toEqual([{ blockId: "block_1", blockType: "email", missingInputs: ["body"] }]);
});
it("reports invalid manual trigger validation schemas before execution is queued", async () => {
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
const result = await WorkflowPreflightService.check({
workflow: workflow({
blocks: [
{
...workflow().blocks[0],
id: "manual_1",
type: "manual",
isTrigger: true,
variableName: "manual",
input: { validation: "{not-json" },
},
],
}),
tenantId: "tenant_1",
});
expect(result.ok).toBe(false);
expect(result.invalidValidationSchemas).toHaveLength(1);
expect(result.invalidValidationSchemas[0]).toMatchObject({ blockId: "manual_1" });
expect(result.errors[0]).toContain("Invalid manual trigger validation schema on block manual_1:");
});
it("reports worker-native credential references as capability errors", async () => {
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
const result = await WorkflowPreflightService.check({ workflow: workflow(), tenantId: "tenant_1", requireNativeSupport: true });
expect(result).toMatchObject({
ok: false,
missingVariables: [],
missingCredentials: [],
missingRequiredInputs: [],
nativeCapabilityErrors: ["missing trigger block", 'unsupported credential reference "mail" on block block_1'],
});
});
it("reports worker-native credential references before queueing manual execution", async () => {
const result = await WorkflowPreflightService.check({
workflow: workflow({
blocks: [
{
...workflow().blocks[0],
id: "trigger",
type: "manual",
isTrigger: true,
variableName: "manual",
input: {},
},
{
...workflow().blocks[0],
id: "http",
type: "httpRequest",
variableName: "http",
input: { url: "{{$credentials.apiBase}}/users", method: "GET" },
},
],
}),
tenantId: "tenant_1",
requireNativeSupport: true,
});
expect(result.nativeCapabilityErrors).toEqual(['unsupported credential reference "apiBase" on block http']);
expect(result.errors).toContain('Worker-native execution does not support this workflow yet: unsupported credential reference "apiBase" on block http');
});
it("reports worker-native if edge shape errors before queueing manual execution", async () => {
const result = await WorkflowPreflightService.check({
workflow: workflow({
blocks: [
{
...workflow().blocks[0],
id: "trigger",
type: "manual",
isTrigger: true,
variableName: "manual",
input: {},
},
{
...workflow().blocks[0],
id: "if_1",
type: "if",
variableName: "if",
input: {},
conditionGroups: [],
toBlocks: [{ id: "edge_true", toBlockId: "next", condition: "true" }],
},
],
}),
tenantId: "tenant_1",
requireNativeSupport: true,
});
expect(result.nativeCapabilityErrors).toEqual(["if block if_1 must have exactly one condition group", "if block if_1 is missing false edge"]);
});
it("passes when refs and native capabilities are satisfied", async () => {
mocks.getAvailableVariableNames.mockResolvedValue(["message"]);
await expect(
WorkflowPreflightService.check({
workflow: workflow({
blocks: [
{
...workflow().blocks[0],
type: "log",
variableName: "log",
input: { message: "Hello {{$vars.message}}" },
},
],
}),
tenantId: "tenant_1",
})
).resolves.toMatchObject({
ok: true,
nativeCapabilityErrors: [],
errors: [],
});
});
});
@@ -0,0 +1,114 @@
import { t as i18nT } from "i18next";
import { getNativeWorkflowTriggerCapabilityErrors } from "@toolctrl/foundation";
import { WorkflowDto } from "../dtos/WorkflowDto";
import { preflightWorkflow, type WorkflowRequiredInputIssue } from "../helpers/WorkflowReferenceUtils";
import WorkflowVariablesAndCredentialsService from "./WorkflowVariablesAndCredentialsService";
import WorkflowsValidationService from "./WorkflowsValidationService";
export type WorkflowValidationSchemaIssue = {
blockId: string;
error: string;
};
export type WorkflowPreflightResult = {
ok: boolean;
missingVariables: string[];
missingCredentials: string[];
missingRequiredInputs: WorkflowRequiredInputIssue[];
invalidValidationSchemas: WorkflowValidationSchemaIssue[];
nativeCapabilityErrors: string[];
errors: string[];
};
function formatMissingReferences({
missingVariables,
missingCredentials,
missingRequiredInputs,
invalidValidationSchemas,
nativeCapabilityErrors,
}: {
missingVariables: string[];
missingCredentials: string[];
missingRequiredInputs: WorkflowRequiredInputIssue[];
invalidValidationSchemas: WorkflowValidationSchemaIssue[];
nativeCapabilityErrors: string[];
}) {
return [
...missingVariables.map((name) => i18nT("workflows.errors.missingWorkflowVariable", { name })),
...missingCredentials.map((name) => i18nT("workflows.errors.missingWorkflowCredential", { name })),
...missingRequiredInputs.flatMap((issue) =>
issue.missingInputs.map((name) =>
i18nT("workflows.errors.missingRequiredInputOnBlock", {
blockId: issue.blockId,
name,
})
)
),
...invalidValidationSchemas.map((issue) =>
i18nT("workflows.errors.invalidManualValidationSchema", {
blockId: issue.blockId,
error: issue.error,
})
),
...nativeCapabilityErrors.map((error) => i18nT("workflows.errors.nativeCapabilityUnsupported", { error })),
];
}
function getNativeCapabilityErrors(workflow: WorkflowDto): string[] {
return getNativeWorkflowTriggerCapabilityErrors(workflow.blocks);
}
function getInvalidValidationSchemas(workflow: WorkflowDto): WorkflowValidationSchemaIssue[] {
return workflow.blocks.flatMap((block) => {
if (block.type !== "manual" || !block.input.validation) {
return [];
}
const { error } = WorkflowsValidationService.parseJsonSchema(block.input.validation);
return error ? [{ blockId: block.id, error }] : [];
});
}
async function check({
workflow,
tenantId,
appliesToAllTenants,
requireNativeSupport,
}: {
workflow: WorkflowDto;
tenantId: string | null;
appliesToAllTenants?: boolean;
requireNativeSupport?: boolean;
}): Promise<WorkflowPreflightResult> {
const effectiveAppliesToAllTenants = appliesToAllTenants === true || workflow.appliesToAllTenants === true;
const [variables, credentials] = await Promise.all([
WorkflowVariablesAndCredentialsService.getAvailableVariableNames({ tenantId, appliesToAllTenants: effectiveAppliesToAllTenants }),
WorkflowVariablesAndCredentialsService.getAvailableCredentialNames({ tenantId, appliesToAllTenants: effectiveAppliesToAllTenants }),
]);
const { missingVariables, missingCredentials, missingRequiredInputs } = preflightWorkflow({ workflow, variables, credentials });
const invalidValidationSchemas = getInvalidValidationSchemas(workflow);
const nativeCapabilityErrors = requireNativeSupport === true ? getNativeCapabilityErrors(workflow) : [];
const errors = formatMissingReferences({ missingVariables, missingCredentials, missingRequiredInputs, invalidValidationSchemas, nativeCapabilityErrors });
return {
ok: errors.length === 0,
missingVariables,
missingCredentials,
missingRequiredInputs,
invalidValidationSchemas,
nativeCapabilityErrors,
errors,
};
}
function throwIfFailed(result: WorkflowPreflightResult) {
if (!result.ok) {
throw Object.assign(
new Error(i18nT("workflows.errors.preflightFailed", { errors: result.errors.join("; ") })),
{ status: 400, preflight: result }
);
}
}
export default {
check,
throwIfFailed,
};
@@ -0,0 +1,97 @@
import CryptoApi from "~/utils/api/.server/CryptoApi";
import { getAllWorkflowCredentials } from "../db/workflowCredentials.db.server";
import { getAllWorkflowVariables } from "../db/workflowVariable.db.server";
async function getCredentialsContext({
tenantId,
appliesToAllTenants,
}: {
tenantId: string | null;
appliesToAllTenants: boolean | undefined;
}): Promise<{ [key: string]: string }> {
let credentials: { [key: string]: string } = {};
if (appliesToAllTenants && tenantId) {
const adminItems = await getAllWorkflowCredentials({ tenantId: null });
adminItems.forEach((item) => {
const value = CryptoApi.decrypt(item.value);
credentials[item.name] = value;
});
}
const items = await getAllWorkflowCredentials({ tenantId });
items.forEach((item) => {
const value = CryptoApi.decrypt(item.value);
credentials[item.name] = value;
});
return credentials;
}
async function getVariablesContext({
tenantId,
appliesToAllTenants,
}: {
tenantId: string | null;
appliesToAllTenants: boolean | undefined;
}): Promise<{ [key: string]: string }> {
let variables: { [key: string]: string } = {};
if (appliesToAllTenants && tenantId) {
const adminItems = await getAllWorkflowVariables({ tenantId: null });
adminItems.forEach((item) => {
variables[item.name] = item.value;
});
}
const items = await getAllWorkflowVariables({ tenantId });
items.forEach((item) => {
variables[item.name] = item.value;
});
return variables;
}
async function getAvailableCredentialNames({
tenantId,
appliesToAllTenants,
}: {
tenantId: string | null;
appliesToAllTenants: boolean | undefined;
}): Promise<string[]> {
const names = new Set<string>();
if (appliesToAllTenants && tenantId) {
const adminItems = await getAllWorkflowCredentials({ tenantId: null });
adminItems.forEach((item) => names.add(item.name));
}
const items = await getAllWorkflowCredentials({ tenantId });
items.forEach((item) => names.add(item.name));
return Array.from(names).sort((a, b) => a.localeCompare(b));
}
async function getAvailableVariableNames({
tenantId,
appliesToAllTenants,
}: {
tenantId: string | null;
appliesToAllTenants: boolean | undefined;
}): Promise<string[]> {
const names = new Set<string>();
if (appliesToAllTenants && tenantId) {
const adminItems = await getAllWorkflowVariables({ tenantId: null });
adminItems.forEach((item) => names.add(item.name));
}
const items = await getAllWorkflowVariables({ tenantId });
items.forEach((item) => names.add(item.name));
return Array.from(names).sort((a, b) => a.localeCompare(b));
}
export default {
getCredentialsContext,
getVariablesContext,
getAvailableCredentialNames,
getAvailableVariableNames,
};
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import WorkflowsConditionsService from "./WorkflowsConditionsService";
import type { WorkflowConditionOperator } from "../dtos/WorkflowConditionDtos";
function validate(operator: WorkflowConditionOperator, variable: string, value = "match") {
return WorkflowsConditionsService.validateGroups({
conditionGroup: {
index: 0,
type: "AND",
conditions: [{ index: 0, variable: "{{value}}", operator, value }],
},
workflowContext: { value: variable },
});
}
describe("WorkflowsConditionsService", () => {
it.each([
["=", "match", "match", true],
["!=", "match", "other", true],
[">", "b", "a", true],
["<", "a", "b", true],
[">=", "b", "b", true],
["<=", "b", "b", true],
["contains", "pre-match-post", "match", true],
["doesNotContain", "pre-post", "match", true],
["startsWith", "match-post", "match", true],
["endsWith", "pre-match", "match", true],
] satisfies Array<[WorkflowConditionOperator, string, string, boolean]>)(
"evaluates %s conditions",
(operator, variable, value, expected) => {
expect(validate(operator, variable, value)).toBe(expected);
}
);
it.each([
["contains", "pre-post", "match"],
["doesNotContain", "pre-match-post", "match"],
["startsWith", "pre-match", "match"],
["endsWith", "match-post", "match"],
] satisfies Array<[WorkflowConditionOperator, string, string]>)("rejects failed %s conditions", (operator, variable, value) => {
expect(validate(operator, variable, value)).toBe(false);
});
});
@@ -0,0 +1,54 @@
import { WorkflowConditionOperators, WorkflowConditionsGroupDto } from "../dtos/WorkflowConditionDtos";
import { parseVariable } from "./WorkflowsVariablesService";
function validateGroups({ conditionGroup, workflowContext }: { conditionGroup: WorkflowConditionsGroupDto; workflowContext: { [key: string]: any } }) {
const results = conditionGroup.conditions.map((condition) => {
const operator = WorkflowConditionOperators.find((f) => f.value === condition.operator);
if (!operator) {
throw new Error("Invalid operator: " + condition.operator);
}
const variable = parseVariable(condition.variable, workflowContext);
if (!operator.requiresValue) {
switch (operator.value) {
case "isNotEmpty":
return variable && variable !== "";
case "isEmpty":
return !variable || variable.trim() === "";
default:
return false;
}
} else {
const value = parseVariable(condition.value, workflowContext);
switch (operator.value) {
case "=":
return (variable?.toString() || "") === (value?.toString() || "");
case "!=":
return (variable?.toString() || "") !== (value?.toString() || "");
case ">":
return variable && value && variable > value;
case "<":
return variable && value && variable < value;
case ">=":
return variable && value && variable >= value;
case "<=":
return variable && value && variable <= value;
case "startsWith":
return variable?.startsWith(value || "");
case "endsWith":
return variable?.endsWith(value || "");
case "contains":
return variable?.includes(value || "");
case "doesNotContain":
return !variable?.includes(value || "");
default:
throw new Error("Invalid operator: " + condition.operator);
}
}
});
const groupResult = conditionGroup.type === "AND" ? results.every((f) => f) : results.some((f) => f);
return groupResult;
}
export default {
validateGroups,
};
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
describe("WorkflowsExecutionsService environment boundary", () => {
it("loads runtime mode through the web config boundary", () => {
const source = readRepoFile("apps/web/app/modules/workflowEngine/services/WorkflowsExecutionsService.ts");
expect(source).toContain('from "@toolctrl/config"');
expect(source).toContain("loadWebConfig().config.nodeEnv");
expect(source).not.toMatch(/process\.env\.NODE_ENV\b/);
});
});
@@ -0,0 +1,929 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type WorkflowExecutionServiceTestMocks = {
workflowExecutionCreate: ReturnType<typeof vi.fn>;
transaction: ReturnType<typeof vi.fn>;
publishOutboxEvent: ReturnType<typeof vi.fn>;
tenantFindFirstOrThrow: ReturnType<typeof vi.fn>;
userFindUnique: ReturnType<typeof vi.fn>;
getWorkflowExecution: ReturnType<typeof vi.fn>;
updateWorkflowExecution: ReturnType<typeof vi.fn>;
getWorkflowById: ReturnType<typeof vi.fn>;
executeBlock: ReturnType<typeof vi.fn>;
getVariablesContext: ReturnType<typeof vi.fn>;
getCredentialsContext: ReturnType<typeof vi.fn>;
getAvailableVariableNames: ReturnType<typeof vi.fn>;
getAvailableCredentialNames: ReturnType<typeof vi.fn>;
emit: ReturnType<typeof vi.fn>;
};
function getMocks(): WorkflowExecutionServiceTestMocks {
const store = globalThis as typeof globalThis & { __workflowExecutionServiceTestMocks__?: WorkflowExecutionServiceTestMocks };
store.__workflowExecutionServiceTestMocks__ ??= {
workflowExecutionCreate: vi.fn(),
transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback({ workflowExecution: { create: getMocks().workflowExecutionCreate } })),
publishOutboxEvent: vi.fn(async () => ({ id: "outbox_1" })),
tenantFindFirstOrThrow: vi.fn(),
userFindUnique: vi.fn(),
getWorkflowExecution: vi.fn(),
updateWorkflowExecution: vi.fn(),
getWorkflowById: vi.fn(),
executeBlock: vi.fn(),
getVariablesContext: vi.fn(),
getCredentialsContext: vi.fn(),
getAvailableVariableNames: vi.fn(),
getAvailableCredentialNames: vi.fn(),
emit: vi.fn(),
};
return store.__workflowExecutionServiceTestMocks__;
}
vi.mock("~/utils/db.server", () => ({
db: {
$transaction: getMocks().transaction,
workflowExecution: { create: getMocks().workflowExecutionCreate },
tenant: { findFirstOrThrow: getMocks().tenantFindFirstOrThrow },
user: { findUnique: getMocks().userFindUnique },
},
}));
vi.mock("@toolctrl/db/outbox", () => ({
publishOutboxEvent: getMocks().publishOutboxEvent,
}));
vi.mock("../db/workflowExecutions.db.server", () => ({
getWorkflowExecution: getMocks().getWorkflowExecution,
updateWorkflowExecution: getMocks().updateWorkflowExecution,
}));
vi.mock("./WorkflowsService", () => ({
default: {
getById: getMocks().getWorkflowById,
},
}));
vi.mock("./blocks/WorkflowBlockService", () => ({
default: {
execute: getMocks().executeBlock,
},
}));
vi.mock("./WorkflowVariablesAndCredentialsService", () => ({
default: {
getVariablesContext: getMocks().getVariablesContext,
getCredentialsContext: getMocks().getCredentialsContext,
getAvailableVariableNames: getMocks().getAvailableVariableNames,
getAvailableCredentialNames: getMocks().getAvailableCredentialNames,
},
}));
vi.mock("~/utils/stream/emitter.server", () => ({
emitter: { emit: getMocks().emit },
}));
import WorkflowsExecutionsService from "./WorkflowsExecutionsService";
const mocks = getMocks();
const triggerBlock = {
id: "block_trigger",
index: 0,
type: "trigger",
variableName: "",
description: "Trigger",
input: {},
isTrigger: true,
isBlock: false,
conditionGroups: [],
toBlocks: [],
};
const workflow = {
id: "workflow_global",
tenantId: null,
tenant: null,
name: "Global workflow",
description: "",
status: "live",
createdAt: new Date("2026-05-08T10:00:00.000Z"),
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
appliesToAllTenants: true,
blocks: [triggerBlock],
inputExamples: [],
_count: { executions: 0 },
};
const tenantWorkflow = {
...workflow,
id: "workflow_tenant",
tenantId: "tenant_1",
tenant: { id: "tenant_1", name: "Acme", slug: "acme" },
name: "Tenant workflow",
appliesToAllTenants: false,
};
function executionRow(overrides: Record<string, unknown> = {}) {
return {
id: "execution_1",
createdAt: new Date("2026-05-08T10:00:00.000Z"),
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
tenantId: "tenant_1",
workflowId: "workflow_tenant",
type: "api",
input: "{}",
output: "{}",
status: "success",
duration: 1,
endedAt: null,
error: null,
waitingBlockId: null,
appliesToAllTenants: false,
workflow: { id: "workflow_tenant", name: "Tenant workflow" },
tenant: { id: "tenant_1", name: "Acme", slug: "acme" },
waitingBlock: null,
blockRuns: [],
...overrides,
};
}
describe("WorkflowsExecutionsService", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.workflowExecutionCreate.mockResolvedValue({ id: "execution_1" });
mocks.tenantFindFirstOrThrow.mockResolvedValue({ id: "tenant_1", name: "Acme" });
mocks.userFindUnique.mockResolvedValue({ id: "user_1", email: "ada@example.com", firstName: "Ada", lastName: "Lovelace" });
mocks.getWorkflowById.mockResolvedValue(workflow);
mocks.executeBlock.mockResolvedValue({ status: "success", workflowContext: { ok: true, $credentials: { token: "secret" } }, waitingBlockId: null });
mocks.getVariablesContext.mockResolvedValue({ tenantVariable: "value" });
mocks.getCredentialsContext.mockResolvedValue({ tenantCredential: "value" });
mocks.getAvailableVariableNames.mockResolvedValue(["recipient"]);
mocks.getAvailableCredentialNames.mockResolvedValue(["tenantCredential"]);
mocks.getWorkflowExecution.mockResolvedValue(executionRow({ status: "running", type: "manual", input: '{"ok":true}' }));
mocks.updateWorkflowExecution.mockResolvedValue(executionRow({ output: '{"ok":true}' }));
});
it("rejects global workflows for tenant API executions", async () => {
await expect(
WorkflowsExecutionsService.execute("workflow_global", {
type: "api",
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toMatchObject({
message: "Workflow is not available for tenant API execution.",
status: 403,
});
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
});
it("allows internal trigger executions for global workflows in tenant scope", async () => {
await WorkflowsExecutionsService.execute("workflow_global", {
type: "trigger",
input: { event: "row.created" },
session: { tenantId: "tenant_1", userId: null },
});
expect(mocks.workflowExecutionCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
tenantId: "tenant_1",
type: "trigger",
appliesToAllTenants: true,
}),
})
);
expect(mocks.getVariablesContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: true });
expect(mocks.getCredentialsContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: true });
});
it("rejects draft tenant workflows for API executions", async () => {
mocks.getWorkflowById.mockResolvedValueOnce({ ...tenantWorkflow, status: "draft" });
await expect(
WorkflowsExecutionsService.execute("workflow_tenant", {
type: "api",
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toMatchObject({
message: "Workflow is not live.",
status: 403,
});
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
});
it("rejects draft tenant workflows for manual executions", async () => {
mocks.getWorkflowById.mockResolvedValueOnce({ ...tenantWorkflow, status: "draft" });
await expect(
WorkflowsExecutionsService.execute("workflow_tenant", {
type: "manual",
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toMatchObject({
message: "Workflow is not live.",
status: 403,
});
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
expect(mocks.executeBlock).not.toHaveBeenCalled();
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
});
it("queues manual executions through the workflow outbox without executing inline", async () => {
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
mocks.workflowExecutionCreate.mockResolvedValueOnce({ id: "execution_manual_1" });
mocks.getWorkflowExecution.mockResolvedValueOnce(executionRow({ id: "execution_manual_1", status: "running", type: "manual", input: '{"name":"Ada"}' }));
const execution = await WorkflowsExecutionsService.queueManualExecution("workflow_tenant", {
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(execution.id).toBe("execution_manual_1");
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.workflowExecutionCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
tenantId: "tenant_1",
workflowId: "workflow_tenant",
createdByUserId: "user_1",
type: "manual",
status: "running",
input: JSON.stringify({ name: "Ada" }),
}),
})
);
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
tenantId: "tenant_1",
type: "workflow.execution.requested",
payload: expect.objectContaining({
workflowId: "workflow_tenant",
executionId: "execution_manual_1",
type: "manual",
input: { name: "Ada" },
userId: "user_1",
}),
dispatchKey: "workflow-execution:execution_manual_1",
})
);
expect(mocks.executeBlock).not.toHaveBeenCalled();
});
it("rejects queued manual executions with invalid manual trigger validation before creating outbox work", async () => {
mocks.getWorkflowById.mockResolvedValueOnce({
...tenantWorkflow,
blocks: [
{
...triggerBlock,
id: "manual_1",
type: "manual",
input: { validation: "{not-json" },
},
],
});
await expect(
WorkflowsExecutionsService.queueManualExecution("workflow_tenant", {
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toMatchObject({
status: 400,
preflight: expect.objectContaining({
invalidValidationSchemas: [expect.objectContaining({ blockId: "manual_1" })],
}),
});
expect(mocks.transaction).not.toHaveBeenCalled();
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
});
it("queues manual executions for HTTP-bridge-capable workflows without native block preflight", async () => {
mocks.getWorkflowById.mockResolvedValueOnce({
...tenantWorkflow,
blocks: [
triggerBlock,
{
...triggerBlock,
id: "block_row_create",
type: "rowCreate",
input: { entity: "contact", row: { name: "Ada" } },
isTrigger: false,
},
],
});
mocks.workflowExecutionCreate.mockResolvedValueOnce({ id: "execution_manual_http_bridge" });
mocks.getWorkflowExecution.mockResolvedValueOnce(
executionRow({
id: "execution_manual_http_bridge",
status: "running",
type: "manual",
input: '{"name":"Ada"}',
})
);
const execution = await WorkflowsExecutionsService.queueManualExecution("workflow_tenant", {
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(execution.id).toBe("execution_manual_http_bridge");
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: "workflow.execution.requested",
payload: expect.objectContaining({ executionId: "execution_manual_http_bridge" }),
})
);
expect(mocks.executeBlock).not.toHaveBeenCalled();
});
it("queues API executions through the workflow outbox without executing inline", async () => {
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
mocks.workflowExecutionCreate.mockResolvedValueOnce({ id: "execution_api_1" });
mocks.getWorkflowExecution.mockResolvedValueOnce(executionRow({ id: "execution_api_1", status: "running", type: "api", input: '{"name":"Ada"}' }));
const execution = await WorkflowsExecutionsService.queueApiExecution("workflow_tenant", {
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(execution.id).toBe("execution_api_1");
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.workflowExecutionCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
tenantId: "tenant_1",
workflowId: "workflow_tenant",
createdByUserId: "user_1",
type: "api",
status: "running",
input: JSON.stringify({ name: "Ada" }),
}),
})
);
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
tenantId: "tenant_1",
type: "workflow.execution.requested",
payload: expect.objectContaining({
workflowId: "workflow_tenant",
executionId: "execution_api_1",
type: "api",
input: { name: "Ada" },
userId: "user_1",
}),
dispatchKey: "workflow-execution:execution_api_1",
})
);
expect(mocks.executeBlock).not.toHaveBeenCalled();
});
it("queues API continuations through the workflow outbox without executing inline", async () => {
mocks.getWorkflowExecution
.mockResolvedValueOnce(executionRow({ id: "execution_waiting_1", status: "waitingBlock", waitingBlockId: "block_trigger", input: '{"step":1}' }))
.mockResolvedValueOnce(executionRow({ id: "execution_waiting_1", status: "waitingBlock", waitingBlockId: "block_trigger", input: '{"step":1}' }));
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
const execution = await WorkflowsExecutionsService.queueContinuationExecution("execution_waiting_1", {
type: "api",
input: { approved: true },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(execution.id).toBe("execution_waiting_1");
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
tenantId: "tenant_1",
type: "workflow.execution.continue.requested",
payload: expect.objectContaining({
workflowId: "workflow_tenant",
executionId: "execution_waiting_1",
type: "api",
input: { approved: true },
userId: "user_1",
}),
dispatchKey: expect.stringMatching(/^workflow-continuation:execution_waiting_1:block_trigger:\d+$/u),
})
);
expect(mocks.executeBlock).not.toHaveBeenCalled();
});
it("keeps legacy waiting continuations queueable while waitingBlock rows roll out", async () => {
mocks.getWorkflowExecution
.mockResolvedValueOnce(executionRow({ id: "execution_legacy_waiting_1", status: "waiting", waitingBlockId: "block_trigger", input: '{"step":1}' }))
.mockResolvedValueOnce(executionRow({ id: "execution_legacy_waiting_1", status: "waiting", waitingBlockId: "block_trigger", input: '{"step":1}' }));
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
await WorkflowsExecutionsService.queueContinuationExecution("execution_legacy_waiting_1", {
type: "manual",
input: { approved: true },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: "workflow.execution.continue.requested",
dispatchKey: expect.stringMatching(/^workflow-continuation:execution_legacy_waiting_1:block_trigger:\d+$/u),
})
);
});
it("keeps continuation redelivery idempotent for the same wait snapshot regardless of input but separates later waits", async () => {
const firstWait = executionRow({
id: "execution_multi_wait_1",
status: "waitingBlock",
waitingBlockId: "block_wait_1",
updatedAt: new Date("2026-05-08T10:00:01.000Z"),
input: '{"step":1}',
});
const secondWait = executionRow({
id: "execution_multi_wait_1",
status: "waitingBlock",
waitingBlockId: "block_wait_2",
updatedAt: new Date("2026-05-08T10:05:01.000Z"),
input: '{"step":2}',
});
mocks.getWorkflowExecution
.mockResolvedValueOnce(firstWait)
.mockResolvedValueOnce(firstWait)
.mockResolvedValueOnce(firstWait)
.mockResolvedValueOnce(firstWait)
.mockResolvedValueOnce(secondWait)
.mockResolvedValueOnce(secondWait);
mocks.getWorkflowById.mockResolvedValue(tenantWorkflow);
await WorkflowsExecutionsService.queueContinuationExecution("execution_multi_wait_1", {
type: "manual",
input: { approved: true },
session: { tenantId: "tenant_1", userId: "user_1" },
});
await WorkflowsExecutionsService.queueContinuationExecution("execution_multi_wait_1", {
type: "manual",
input: { approved: false, reason: "changed" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
await WorkflowsExecutionsService.queueContinuationExecution("execution_multi_wait_1", {
type: "manual",
input: { approved: false, reason: "changed" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
const dispatchKeys = mocks.publishOutboxEvent.mock.calls.map(([, event]) => event.dispatchKey);
expect(dispatchKeys[0]).toBe(dispatchKeys[1]);
expect(dispatchKeys[2]).not.toBe(dispatchKeys[0]);
expect(dispatchKeys[0]).toMatch(/^workflow-continuation:execution_multi_wait_1:block_wait_1:\d+$/u);
expect(dispatchKeys[2]).toMatch(/^workflow-continuation:execution_multi_wait_1:block_wait_2:\d+$/u);
});
it("rejects queueing continuations when the execution is not waiting", async () => {
mocks.getWorkflowExecution.mockResolvedValueOnce(executionRow({ id: "execution_done_1", status: "success", waitingBlockId: null }));
await expect(
WorkflowsExecutionsService.queueContinuationExecution("execution_done_1", {
type: "manual",
input: { approved: true },
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toThrow("Workflow execution is not waiting for a block");
expect(mocks.transaction).not.toHaveBeenCalled();
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
});
it("rejects global workflows for queued tenant API executions", async () => {
await expect(
WorkflowsExecutionsService.queueApiExecution("workflow_global", {
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toMatchObject({
message: "Workflow is not available for tenant API execution.",
status: 403,
});
expect(mocks.transaction).not.toHaveBeenCalled();
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
});
it("stores and executes tenant workflows with tenant scoped variables and credentials", async () => {
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
await WorkflowsExecutionsService.execute("workflow_tenant", {
type: "api",
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.workflowExecutionCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ appliesToAllTenants: false }),
})
);
expect(mocks.getVariablesContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
expect(mocks.getCredentialsContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
"execution_1",
{ tenantId: "tenant_1", userId: "user_1" },
expect.objectContaining({ status: "success" }),
expect.anything()
);
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: "plugin.hook.workflow.executed",
dispatchKey: "workflow-execution:execution_1:success",
tenantId: "tenant_1",
payload: expect.objectContaining({
tenantId: "tenant_1",
workflowId: "workflow_tenant",
workflowExecutionId: "execution_1",
userId: "user_1",
type: "api",
status: "success",
errorCode: null,
appliesToAllTenants: false,
}),
})
);
expect(mocks.publishOutboxEvent.mock.calls[0]?.[0]).toBe(mocks.updateWorkflowExecution.mock.calls[0]?.[3]);
});
it("redacts workflow error details before emitting plugin hooks", async () => {
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
mocks.executeBlock.mockRejectedValueOnce(new Error("Provider failed for https://api.example.test?token=sk_live_secret"));
mocks.updateWorkflowExecution.mockResolvedValueOnce(
executionRow({
status: "error",
error: "Provider failed for https://api.example.test?token=sk_live_secret",
})
);
await WorkflowsExecutionsService.execute("workflow_tenant", {
type: "api",
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
"execution_1",
{ tenantId: "tenant_1", userId: "user_1" },
expect.objectContaining({
status: "error",
error: "Provider failed for https://api.example.test?token=sk_live_secret",
}),
expect.anything()
);
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: "plugin.hook.workflow.executed",
dispatchKey: "workflow-execution:execution_1:error",
tenantId: "tenant_1",
payload: expect.objectContaining({
tenantId: "tenant_1",
workflowId: "workflow_tenant",
workflowExecutionId: "execution_1",
userId: "user_1",
type: "api",
status: "error",
errorCode: "workflow_execution_failed",
}),
})
);
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: "plugin.hook.workflow.failed",
dispatchKey: "workflow-execution:execution_1:failed",
tenantId: "tenant_1",
payload: expect.objectContaining({
tenantId: "tenant_1",
workflowId: "workflow_tenant",
workflowExecutionId: "execution_1",
userId: "user_1",
type: "api",
status: "error",
errorCode: "workflow_execution_failed",
}),
})
);
expect(mocks.publishOutboxEvent.mock.calls[0]?.[1]?.payload).not.toHaveProperty("error");
expect(mocks.publishOutboxEvent.mock.calls[1]?.[1]?.payload).not.toHaveProperty("error");
});
it("redacts credential values from persisted execution outputs and errors", async () => {
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
mocks.getCredentialsContext.mockResolvedValueOnce({ apiToken: "sk_live_secret" });
mocks.executeBlock.mockRejectedValueOnce(new Error("Provider echoed sk_live_secret"));
await WorkflowsExecutionsService.execute("workflow_tenant", {
type: "api",
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
"execution_1",
{ tenantId: "tenant_1", userId: "user_1" },
expect.objectContaining({
output: "{}",
error: "Provider echoed [REDACTED_CREDENTIAL]",
}),
expect.anything()
);
});
it("redacts copied credential values from persisted execution context", async () => {
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
mocks.getCredentialsContext.mockResolvedValueOnce({ apiToken: "sk_live_secret" });
mocks.executeBlock.mockResolvedValueOnce({
status: "success",
workflowContext: {
http: { body: "sk_live_secret" },
nested: { values: ["safe", "Bearer sk_live_secret"] },
$credentials: { apiToken: "sk_live_secret" },
},
waitingBlockId: null,
});
await WorkflowsExecutionsService.execute("workflow_tenant", {
type: "api",
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
"execution_1",
{ tenantId: "tenant_1", userId: "user_1" },
expect.objectContaining({
output: JSON.stringify({
http: { body: "[REDACTED_CREDENTIAL]" },
nested: { values: ["safe", "Bearer [REDACTED_CREDENTIAL]"] },
}),
}),
expect.anything()
);
expect(JSON.stringify(mocks.updateWorkflowExecution.mock.calls)).not.toContain("sk_live_secret");
});
it("does not emit workflow plugin hooks for waiting or platform-scope executions", async () => {
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
mocks.executeBlock.mockResolvedValueOnce({ status: "waitingBlock", workflowContext: { ok: false }, waitingBlockId: "block_wait" });
mocks.updateWorkflowExecution.mockResolvedValueOnce(executionRow({ status: "waitingBlock", waitingBlockId: "block_wait" }));
await WorkflowsExecutionsService.execute("workflow_tenant", {
type: "manual",
input: {},
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
mocks.getWorkflowById.mockResolvedValueOnce({ ...workflow, appliesToAllTenants: true });
mocks.updateWorkflowExecution.mockResolvedValueOnce(executionRow({ tenantId: null, appliesToAllTenants: true, status: "success" }));
await WorkflowsExecutionsService.execute("workflow_global", {
type: "manual",
input: {},
session: { tenantId: null, userId: "user_1" },
});
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
});
it("rejects missing workflow references before creating an execution", async () => {
mocks.getWorkflowById.mockResolvedValueOnce({
...tenantWorkflow,
blocks: [
{
...triggerBlock,
input: { value: "{{$vars.missingVariable}} {{$credentials.missingCredential}}" },
},
],
});
await expect(
WorkflowsExecutionsService.execute("workflow_tenant", {
type: "manual",
input: {},
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toMatchObject({
message: "Workflow preflight failed: Missing workflow variable: missingVariable; Missing workflow credential: missingCredential",
status: 400,
});
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
});
it("rejects missing condition references before creating an execution", async () => {
mocks.getWorkflowById.mockResolvedValueOnce({
...tenantWorkflow,
blocks: [
{
...triggerBlock,
id: "block_condition",
type: "if",
conditionGroups: [
{
index: 0,
type: "AND",
conditions: [
{
index: 0,
variable: "{{$vars.expectedPlan}}",
operator: "=",
value: "{{$credentials.planSecret}}",
},
],
},
],
},
],
});
await expect(
WorkflowsExecutionsService.execute("workflow_tenant", {
type: "manual",
input: {},
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toMatchObject({
message: "Workflow preflight failed: Missing workflow variable: expectedPlan; Missing workflow credential: planSecret",
status: 400,
});
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
});
it("rejects missing required block inputs before creating an execution", async () => {
mocks.getAvailableVariableNames.mockResolvedValue(["recipient"]);
mocks.getAvailableCredentialNames.mockResolvedValue(["tenantCredential"]);
mocks.getWorkflowById.mockResolvedValueOnce({
...tenantWorkflow,
blocks: [
{
...triggerBlock,
id: "block_email",
type: "email",
input: { provider: "tenantCredential", to: "{{$vars.recipient}}", subject: "Welcome" },
},
],
});
await expect(
WorkflowsExecutionsService.execute("workflow_tenant", {
type: "manual",
input: {},
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toMatchObject({
message: "Workflow preflight failed: Missing required input on block block_email: body",
status: 400,
});
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
});
it("continues waiting tenant executions with scoped updates", async () => {
mocks.getWorkflowExecution.mockResolvedValue(
executionRow({
status: "waiting",
waitingBlockId: "block_trigger",
input: '{"step":1}',
output: JSON.stringify({ priorBlock: { ok: true }, $params: { stale: true }, $session: { stale: true } }),
appliesToAllTenants: false,
})
);
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
await WorkflowsExecutionsService.continueExecution("execution_1", {
type: "api",
input: { approved: true },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.getWorkflowById).toHaveBeenCalledWith("workflow_tenant", { tenantId: "tenant_1", userId: "user_1" });
expect(mocks.getVariablesContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
expect(mocks.executeBlock).toHaveBeenCalledWith(
expect.objectContaining({
workflowContext: expect.objectContaining({
priorBlock: { ok: true },
$params: { approved: true },
$session: expect.objectContaining({
tenant: { id: "tenant_1", name: "Acme" },
user: expect.objectContaining({ id: "user_1", email: "ada@example.com" }),
}),
}),
})
);
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
"execution_1",
{ tenantId: "tenant_1", userId: "user_1" },
expect.objectContaining({ status: "success" }),
expect.anything()
);
});
it("rejects continuing executions when the backing workflow is no longer live", async () => {
mocks.getWorkflowExecution.mockResolvedValue(
executionRow({
appliesToAllTenants: false,
input: '{"step":1}',
status: "waiting",
waitingBlockId: "block_trigger",
})
);
mocks.getWorkflowById.mockResolvedValueOnce({ ...tenantWorkflow, status: "draft" });
await expect(
WorkflowsExecutionsService.continueExecution("execution_1", {
type: "manual",
input: { approved: true },
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toMatchObject({
message: "Workflow is not live.",
status: 403,
});
expect(mocks.executeBlock).not.toHaveBeenCalled();
expect(mocks.updateWorkflowExecution).not.toHaveBeenCalled();
});
it("rejects stream executions when the workflow is not live before creating a row", async () => {
mocks.getWorkflowById.mockResolvedValueOnce({ ...tenantWorkflow, status: "draft" });
await expect(
WorkflowsExecutionsService.stream("workflow_tenant", {
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
})
).rejects.toMatchObject({
message: "Workflow is not live.",
status: 403,
});
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
expect(mocks.executeBlock).not.toHaveBeenCalled();
});
it("starts stream executions with a running row and tenant scoped background execution", async () => {
mocks.getWorkflowById.mockResolvedValue(tenantWorkflow);
await expect(
WorkflowsExecutionsService.stream("workflow_tenant", {
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
})
).resolves.toEqual({ id: "execution_1" });
expect(mocks.workflowExecutionCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
tenantId: "tenant_1",
workflowId: "workflow_tenant",
createdByUserId: "user_1",
type: "stream",
input: '{"name":"Ada"}',
status: "running",
output: "{}",
appliesToAllTenants: false,
}),
})
);
await vi.waitFor(() => expect(mocks.executeBlock).toHaveBeenCalledOnce());
expect(mocks.getVariablesContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
expect(mocks.getCredentialsContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
"execution_1",
{ tenantId: "tenant_1", userId: "user_1" },
expect.objectContaining({ status: "success" }),
expect.anything()
);
});
});
@@ -0,0 +1,563 @@
import { loadWebConfig } from "@toolctrl/config";
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@toolctrl/db/outbox";
import { buildWorkflowExecutionContinueRequestedOutboxEvent, buildWorkflowExecutionRequestedOutboxEvent, type QueueJsonValue } from "@toolctrl/queue";
import { db } from "~/utils/db.server";
import { getWorkflowExecution, updateWorkflowExecution } from "../db/workflowExecutions.db.server";
import { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
import WorkflowExecutionUtils from "../helpers/WorkflowExecutionUtils";
import WorkflowsService from "./WorkflowsService";
import WorkflowBlockService from "./blocks/WorkflowBlockService";
import WorkflowVariablesAndCredentialsService from "./WorkflowVariablesAndCredentialsService";
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
import type { WorkflowDto } from "../dtos/WorkflowDto";
import { WorkflowStatus } from "../dtos/WorkflowStatus";
import { emitter } from "~/utils/stream/emitter.server";
import WorkflowPreflightService from "./WorkflowPreflightService";
import { emitPluginHook, PLUGIN_HOOK_WORKFLOW_EXECUTED, PLUGIN_HOOK_WORKFLOW_FAILED } from "@toolctrl/plugin-runtime";
import { redactWorkflowCredentials } from "./WorkflowsVariablesService";
function assertWorkflowIsLive(workflow: Pick<WorkflowDto, "status">) {
if (workflow.status !== "live") {
throw Object.assign(new Error("Workflow is not live."), { status: 403 });
}
}
function toQueueJsonValue(value: unknown): QueueJsonValue {
return JSON.parse(JSON.stringify(value ?? null)) as QueueJsonValue;
}
function workflowContinuationSourceId({
execution,
}: {
execution: { id: string; waitingBlockId: string | null; updatedAt: Date | string | number };
}): string {
const updatedAt = execution.updatedAt instanceof Date ? execution.updatedAt.getTime() : new Date(execution.updatedAt).getTime();
const waitVersion = Number.isFinite(updatedAt) ? String(updatedAt) : "unknown";
return `${execution.id}:${execution.waitingBlockId ?? "unknown"}:${waitVersion}`;
}
function parsePersistedWorkflowContext(output: string | null | undefined): Record<string, unknown> {
if (!output) {
return {};
}
try {
const parsed = JSON.parse(output);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
async function execute(
workflowId: string,
{
type,
input,
session,
execution,
fromBlockId,
appliesToAllTenants,
}: {
type: "manual" | "api" | "stream" | "trigger";
input: { [key: string]: any } | null;
session: { tenantId: string | null; userId: string | null };
execution?: { id: string; output?: string | null } | null;
fromBlockId?: string | null;
appliesToAllTenants?: boolean;
}
): Promise<WorkflowExecutionDto> {
const workflow = await WorkflowsService.getById(workflowId, session);
if (!workflow) {
throw new Error("Workflow not found: " + workflowId);
}
if (type === "api" && session.tenantId !== null && (workflow.tenantId === null || workflow.appliesToAllTenants === true)) {
throw Object.assign(new Error("Workflow is not available for tenant API execution."), { status: 403 });
}
assertWorkflowIsLive(workflow);
const effectiveAppliesToAllTenants = appliesToAllTenants === true || workflow.appliesToAllTenants === true;
const preflight = await WorkflowPreflightService.check({
workflow,
tenantId: session.tenantId,
appliesToAllTenants: effectiveAppliesToAllTenants,
});
WorkflowPreflightService.throwIfFailed(preflight);
if (!execution) {
execution = await db.workflowExecution.create({
data: {
tenantId: session.tenantId,
workflowId: workflow.id,
createdByUserId: session.userId,
type,
input: JSON.stringify(input),
status: "running",
output: null,
duration: null,
endedAt: null,
error: null,
appliesToAllTenants: effectiveAppliesToAllTenants,
},
});
}
let firstBlock: WorkflowBlockDto | undefined = undefined;
if (fromBlockId) {
firstBlock = workflow.blocks.find((f) => f.id === fromBlockId);
if (!firstBlock) {
throw new Error("Workflow has no block with id " + fromBlockId);
}
} else {
firstBlock = workflow.blocks.find((f) => f.isTrigger);
if (!firstBlock) {
throw new Error("Workflow has no trigger block");
}
}
const startTime = performance.now();
let error: string | null = null;
let result: {
status: WorkflowStatus;
workflowContext: { [key: string]: any };
waitingBlockId: string | null;
} = { status: "running", workflowContext: {}, waitingBlockId: null };
let tenant = session.tenantId
? await db.tenant.findFirstOrThrow({ where: { OR: [{ slug: session.tenantId }, { id: session.tenantId }] } }).catch(() => null)
: null;
let user = session.userId ? await db.user.findUnique({ where: { id: session.userId } }) : null;
let credentialContext: Record<string, unknown> = {};
try {
const variableContext = await WorkflowVariablesAndCredentialsService.getVariablesContext({
tenantId: session.tenantId,
appliesToAllTenants: effectiveAppliesToAllTenants,
});
credentialContext = await WorkflowVariablesAndCredentialsService.getCredentialsContext({
tenantId: session.tenantId,
appliesToAllTenants: effectiveAppliesToAllTenants,
});
const persistedContext = parsePersistedWorkflowContext(execution.output);
result = await WorkflowBlockService.execute({
workflowContext: {
...persistedContext,
$params: input,
$session: {
tenant: tenant ? { id: tenant.id, name: tenant.name } : null,
user: user ? { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName } : null,
},
$vars: variableContext,
$credentials: credentialContext,
},
workflowExecutionId: execution.id,
workflow,
block: firstBlock,
fromBlock: null,
session,
});
} catch (e: any) {
error = e.message;
if (loadWebConfig().config.nodeEnv === "development") {
// eslint-disable-next-line no-console
console.error(e.stack);
}
}
const endTime = performance.now();
const duration = endTime - startTime;
result.workflowContext = redactWorkflowCredentials(result.workflowContext, credentialContext);
delete result.workflowContext.$credentials;
error = redactWorkflowCredentials(error, credentialContext);
const updatedExecution = await finishWorkflowExecutionAndPublishHook({
executionId: execution.id,
workflowId: workflow.id,
type,
session,
status: error ? "error" : result.status,
output: JSON.stringify(result.workflowContext),
duration: Math.round(duration),
error,
waitingBlockId: result.waitingBlockId,
});
emitter.emit(`workflowExecutionUpdate:${execution.id}`, JSON.stringify(WorkflowExecutionUtils.rowToDto(updatedExecution)));
return WorkflowExecutionUtils.rowToDto(updatedExecution);
}
async function finishWorkflowExecutionAndPublishHook({
executionId,
workflowId,
type,
session,
status,
output,
duration,
error,
waitingBlockId,
}: {
executionId: string;
workflowId: string;
type: "manual" | "api" | "stream" | "trigger";
session: { tenantId: string | null; userId: string | null };
status: WorkflowStatus;
output: string;
duration: number;
error: string | null;
waitingBlockId: string | null;
}) {
return db.$transaction(async (tx) => {
const updatedExecution = await updateWorkflowExecution(
executionId,
session,
{
status,
output,
duration,
error,
waitingBlockId,
},
tx
);
await emitWorkflowExecutedHook({
tx,
executionId,
workflowId,
type,
session,
updatedExecution,
});
return updatedExecution;
});
}
async function emitWorkflowExecutedHook({
tx,
executionId,
workflowId,
type,
session,
updatedExecution,
}: {
tx: unknown;
executionId: string;
workflowId: string;
type: "manual" | "api" | "stream" | "trigger";
session: { tenantId: string | null; userId: string | null };
updatedExecution: { status: string; duration: number | null; error: string | null; appliesToAllTenants: boolean };
}) {
if (!session.tenantId) return;
if (updatedExecution.status !== "success" && updatedExecution.status !== "error") return;
const terminalStatus = updatedExecution.status as "success" | "error";
const errorCode = terminalStatus === "error" ? "workflow_execution_failed" : null;
const occurredAt = new Date().toISOString();
const payload = {
tenantId: session.tenantId,
workflowId,
workflowExecutionId: executionId,
userId: session.userId,
type,
status: terminalStatus,
durationMs: Math.max(0, Math.round(updatedExecution.duration ?? 0)),
errorCode,
appliesToAllTenants: updatedExecution.appliesToAllTenants,
occurredAt,
};
await emitPluginHook(
tx,
PLUGIN_HOOK_WORKFLOW_EXECUTED,
payload,
async (innerTx, args) => {
await publishOutboxEvent(innerTx as OutboxPublisherTransaction, {
tenantId: args.tenantId,
type: args.type,
payload: args.payload as unknown as Parameters<typeof publishOutboxEvent>[1]["payload"],
dispatchKey: args.dispatchKey,
});
},
{
tenantId: session.tenantId,
dispatchKey: `workflow-execution:${executionId}:${updatedExecution.status}`,
}
);
if (terminalStatus === "error") {
await emitPluginHook(
tx,
PLUGIN_HOOK_WORKFLOW_FAILED,
{ ...payload, status: "error", errorCode: "workflow_execution_failed" },
async (innerTx, args) => {
await publishOutboxEvent(innerTx as OutboxPublisherTransaction, {
tenantId: args.tenantId,
type: args.type,
payload: args.payload as unknown as Parameters<typeof publishOutboxEvent>[1]["payload"],
dispatchKey: args.dispatchKey,
});
},
{
tenantId: session.tenantId,
dispatchKey: `workflow-execution:${executionId}:failed`,
}
);
}
}
async function continueExecution(
executionId: string,
{
type,
input,
session,
}: {
type: "manual" | "api" | "stream";
input: { [key: string]: any } | null;
session: { tenantId: string | null; userId: string | null };
}
) {
const execution = await getWorkflowExecution(executionId, session);
if (!execution) {
throw new Error("Workflow execution not found");
}
if (!execution.waitingBlockId) {
throw new Error("Workflow execution is not waiting for a block");
}
return execute(execution?.workflowId, {
type,
input,
session,
fromBlockId: execution.waitingBlockId,
execution,
appliesToAllTenants: execution.appliesToAllTenants,
});
}
async function queueManualExecution(
workflowId: string,
{
input,
session,
}: {
input: { [key: string]: any } | null;
session: { tenantId: string | null; userId: string | null };
}
): Promise<WorkflowExecutionDto> {
return queueRequestedExecution(workflowId, { type: "manual", input, session });
}
async function queueApiExecution(
workflowId: string,
{
input,
session,
}: {
input: { [key: string]: any } | null;
session: { tenantId: string | null; userId: string | null };
}
): Promise<WorkflowExecutionDto> {
return queueRequestedExecution(workflowId, { type: "api", input, session });
}
async function queueContinuationExecution(
executionId: string,
{
type,
input,
session,
}: {
type: "manual" | "api";
input: { [key: string]: any } | null;
session: { tenantId: string | null; userId: string | null };
}
): Promise<WorkflowExecutionDto> {
const execution = await getWorkflowExecution(executionId, session);
if (!execution) {
throw new Error("Workflow execution not found");
}
if (!execution.waitingBlockId) {
throw new Error("Workflow execution is not waiting for a block");
}
if (execution.status !== "waitingBlock" && execution.status !== "waiting") {
throw new Error("Workflow execution is not waiting");
}
const workflow = await WorkflowsService.getById(execution.workflowId, session);
if (!workflow) {
throw new Error("Workflow not found: " + execution.workflowId);
}
if (type === "api" && session.tenantId !== null && (workflow.tenantId === null || workflow.appliesToAllTenants === true)) {
throw Object.assign(new Error("Workflow is not available for tenant API execution."), { status: 403 });
}
assertWorkflowIsLive(workflow);
const queueInput = toQueueJsonValue(input);
await db.$transaction(async (tx) => {
await publishOutboxEvent(
tx as OutboxPublisherTransaction,
buildWorkflowExecutionContinueRequestedOutboxEvent({
tenantId: session.tenantId,
workflowId: execution.workflowId,
executionId: execution.id,
type,
input: queueInput,
userId: session.userId,
sourceId: workflowContinuationSourceId({ execution }),
})
);
});
const queuedExecution = await getWorkflowExecution(execution.id, session);
if (!queuedExecution) {
throw new Error("Workflow execution not found");
}
return WorkflowExecutionUtils.rowToDto(queuedExecution);
}
async function queueRequestedExecution(
workflowId: string,
{
type,
input,
session,
}: {
type: "manual" | "api";
input: { [key: string]: any } | null;
session: { tenantId: string | null; userId: string | null };
}
): Promise<WorkflowExecutionDto> {
const workflow = await WorkflowsService.getById(workflowId, session);
if (!workflow) {
throw new Error("Workflow not found: " + workflowId);
}
if (type === "api" && session.tenantId !== null && (workflow.tenantId === null || workflow.appliesToAllTenants === true)) {
throw Object.assign(new Error("Workflow is not available for tenant API execution."), { status: 403 });
}
assertWorkflowIsLive(workflow);
const effectiveAppliesToAllTenants = workflow.appliesToAllTenants === true;
const preflight = await WorkflowPreflightService.check({
workflow,
tenantId: session.tenantId,
appliesToAllTenants: effectiveAppliesToAllTenants,
});
WorkflowPreflightService.throwIfFailed(preflight);
const inputValue = toQueueJsonValue(input);
const execution = await db.$transaction(async (tx) => {
const created = await tx.workflowExecution.create({
data: {
tenantId: session.tenantId,
workflowId: workflow.id,
createdByUserId: session.userId,
type,
input: JSON.stringify(input),
status: "running",
output: null,
duration: null,
endedAt: null,
error: null,
appliesToAllTenants: effectiveAppliesToAllTenants,
},
});
await publishOutboxEvent(
tx as OutboxPublisherTransaction,
buildWorkflowExecutionRequestedOutboxEvent({
tenantId: session.tenantId,
workflowId: workflow.id,
executionId: created.id,
type,
input: inputValue,
userId: session.userId,
sourceId: created.id,
})
);
return created;
});
const queuedExecution = await getWorkflowExecution(execution.id, session);
if (!queuedExecution) {
throw new Error("Workflow execution not found");
}
return WorkflowExecutionUtils.rowToDto(queuedExecution);
}
async function executeExisting(
executionId: string,
{
workflowId,
type,
input,
session,
}: {
workflowId: string;
type: "manual" | "api";
input: { [key: string]: any } | null;
session: { tenantId: string | null; userId: string | null };
}
): Promise<WorkflowExecutionDto> {
const execution = await getWorkflowExecution(executionId, session);
if (!execution) {
throw new Error("Workflow execution not found");
}
if (execution.workflowId !== workflowId) {
throw new Error("Workflow execution does not belong to workflow " + workflowId);
}
if (execution.type !== type) {
throw new Error("Workflow execution type mismatch");
}
if (execution.status !== "running") {
throw new Error("Workflow execution is not queued");
}
return execute(workflowId, {
type,
input,
session,
execution,
appliesToAllTenants: execution.appliesToAllTenants,
});
}
async function stream(
workflowId: string,
{
input,
session,
}: {
input: { [key: string]: any } | null;
session: { tenantId: string | null; userId: string | null };
}
) {
const workflow = await WorkflowsService.getById(workflowId, session);
if (!workflow) {
throw new Error("Workflow not found: " + workflowId);
}
assertWorkflowIsLive(workflow);
const execution = await db.workflowExecution.create({
data: {
tenantId: session.tenantId,
workflowId: workflow.id,
createdByUserId: session.userId,
type: "stream",
input: JSON.stringify(input),
status: "running",
output: "{}",
duration: null,
endedAt: null,
error: null,
appliesToAllTenants: workflow.appliesToAllTenants,
},
});
void execute(workflow.id, {
type: "stream",
input,
session,
execution,
appliesToAllTenants: workflow.appliesToAllTenants,
}).catch(async (error: unknown) => {
await updateWorkflowExecution(execution.id, session, {
status: "error",
output: "{}",
duration: 0,
error: error instanceof Error ? error.message : "Workflow stream execution failed.",
waitingBlockId: null,
});
});
return execution;
}
export default {
execute,
executeExisting,
continueExecution,
queueContinuationExecution,
queueApiExecution,
queueManualExecution,
stream,
};
@@ -0,0 +1,38 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const source = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "WorkflowsService.ts"), "utf8");
describe("WorkflowsService DTO conversion", () => {
it("does not repair missing condition groups with database writes during reads", () => {
const workflowToDtoBody = source.slice(source.indexOf("async function workflowToDto"), source.indexOf("async function addConditionGroup"));
expect(workflowToDtoBody).not.toContain("await addConditionGroup");
expect(workflowToDtoBody).not.toContain("db.workflowBlockConditionGroup.create");
expect(workflowToDtoBody).toContain("workflowBlock.conditionGroups.push");
});
});
describe("WorkflowsService tenant scope guards", () => {
it("keeps workflow child mutations scoped through the parent workflow tenant", () => {
expect(source).toContain("updateWorkflowBlock({ id, workflowId: workflow.id, tenantId: workflow.tenantId }");
expect(source).toContain("createInputExample({ workflowId, tenantId, title, input }");
expect(source).toContain("updateInputExample(scope: { id: string; workflowId: string; tenantId: string | null }");
expect(source).toContain("deleteInputExample(scope: { id: string; workflowId: string; tenantId: string | null }");
expect(source).toContain("workflowBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } }");
expect(source).toContain("fromBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } }");
expect(source).toContain("toBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } }");
expect(source).toContain("workflow: { tenantId: scope.tenantId }");
});
it("does not keep id-only workflow child delete/update calls", () => {
expect(source).not.toContain("db.workflowBlock.delete({");
expect(source).not.toContain("db.workflowBlockToBlock.delete({");
expect(source).not.toContain("db.workflowInputExample.update({");
expect(source).not.toContain("db.workflowInputExample.delete({");
expect(source).not.toContain("where: { id: scope.id, workflowId: scope.workflowId },");
expect(source).not.toContain("where: { id, workflowId: workflow.id },");
});
});
@@ -0,0 +1,401 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type WorkflowsServiceTestMocks = {
workflowFindMany: ReturnType<typeof vi.fn>;
workflowBlockCount: ReturnType<typeof vi.fn>;
workflowBlockDeleteMany: ReturnType<typeof vi.fn>;
workflowBlockFindFirstOrThrow: ReturnType<typeof vi.fn>;
workflowBlockConditionGroupCreate: ReturnType<typeof vi.fn>;
workflowBlockConditionGroupDeleteMany: ReturnType<typeof vi.fn>;
workflowBlockToBlockCreate: ReturnType<typeof vi.fn>;
workflowBlockToBlockDeleteMany: ReturnType<typeof vi.fn>;
createWorkflow: ReturnType<typeof vi.fn>;
deleteWorkflow: ReturnType<typeof vi.fn>;
createWorkflowBlock: ReturnType<typeof vi.fn>;
getWorkflowById: ReturnType<typeof vi.fn>;
updateWorkflowBlock: ReturnType<typeof vi.fn>;
updateWorkflow: ReturnType<typeof vi.fn>;
getAllWorkflowVariables: ReturnType<typeof vi.fn>;
getAllWorkflowCredentials: ReturnType<typeof vi.fn>;
emitPluginHookFireAndForget: ReturnType<typeof vi.fn>;
};
function getMocks(): WorkflowsServiceTestMocks {
const store = globalThis as typeof globalThis & { __workflowsServiceTestMocks__?: WorkflowsServiceTestMocks };
store.__workflowsServiceTestMocks__ ??= {
workflowFindMany: vi.fn(),
workflowBlockCount: vi.fn(),
workflowBlockDeleteMany: vi.fn(),
workflowBlockFindFirstOrThrow: vi.fn(),
workflowBlockConditionGroupCreate: vi.fn(),
workflowBlockConditionGroupDeleteMany: vi.fn(),
workflowBlockToBlockCreate: vi.fn(),
workflowBlockToBlockDeleteMany: vi.fn(),
createWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
createWorkflowBlock: vi.fn(),
getWorkflowById: vi.fn(),
updateWorkflowBlock: vi.fn(),
updateWorkflow: vi.fn(),
getAllWorkflowVariables: vi.fn(),
getAllWorkflowCredentials: vi.fn(),
emitPluginHookFireAndForget: vi.fn(),
};
return store.__workflowsServiceTestMocks__;
}
vi.mock("~/utils/db.server", () => ({
db: {
workflow: {
findMany: getMocks().workflowFindMany,
},
workflowBlock: {
count: getMocks().workflowBlockCount,
deleteMany: getMocks().workflowBlockDeleteMany,
findFirstOrThrow: getMocks().workflowBlockFindFirstOrThrow,
},
workflowBlockConditionGroup: {
create: getMocks().workflowBlockConditionGroupCreate,
deleteMany: getMocks().workflowBlockConditionGroupDeleteMany,
},
workflowBlockToBlock: {
create: getMocks().workflowBlockToBlockCreate,
deleteMany: getMocks().workflowBlockToBlockDeleteMany,
},
},
}));
vi.mock("../db/workflows.db.server", () => ({
createWorkflow: getMocks().createWorkflow,
deleteWorkflow: getMocks().deleteWorkflow,
getAllWorkflows: vi.fn(),
getAllWorkflowsAppliesToAllTenants: vi.fn(),
getWorkflowByAccessibleId: vi.fn(),
getWorkflowById: getMocks().getWorkflowById,
updateWorkflow: getMocks().updateWorkflow,
}));
vi.mock("../db/workflowBlocks.db.server", () => ({
createWorkflowBlock: getMocks().createWorkflowBlock,
getWorkflowBlockTypes: vi.fn(),
updateWorkflowBlock: getMocks().updateWorkflowBlock,
}));
vi.mock("../db/workflowExecutions.db.server", () => ({
getWorkflowExecution: vi.fn(),
getWorkflowExecutions: vi.fn(),
}));
vi.mock("../db/workflowVariable.db.server", () => ({
getAllWorkflowVariables: getMocks().getAllWorkflowVariables,
}));
vi.mock("../db/workflowCredentials.db.server", () => ({
getAllWorkflowCredentials: getMocks().getAllWorkflowCredentials,
}));
vi.mock("~/modules/plugins/services/.server/pluginHookPublisher", () => ({
emitPluginHookFireAndForget: getMocks().emitPluginHookFireAndForget,
}));
vi.mock("i18next", () => ({
t: (_key: string, options: { defaultValue: string }) => options.defaultValue,
}));
import WorkflowsService from "./WorkflowsService";
const mocks = getMocks();
function workflowRow(overrides: Record<string, unknown> = {}) {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: { id: "tenant_1", name: "Acme", slug: "acme" },
name: "Demo workflow",
description: "",
status: "draft",
createdByUserId: "user_1",
createdAt: new Date("2026-05-27T10:00:00.000Z"),
updatedAt: new Date("2026-05-27T10:00:00.000Z"),
appliesToAllTenants: false,
blocks: [],
inputExamples: [],
_count: { executions: 0 },
...overrides,
};
}
describe("WorkflowsService plugin hooks", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.workflowFindMany.mockResolvedValue([]);
mocks.workflowBlockCount.mockImplementation(async (args: { where?: { id?: { in?: string[] } } }) => args.where?.id?.in?.length ?? 0);
mocks.workflowBlockDeleteMany.mockResolvedValue({ count: 1 });
mocks.workflowBlockFindFirstOrThrow.mockResolvedValue({ id: "block_1", workflowId: "workflow_1" });
mocks.workflowBlockConditionGroupCreate.mockResolvedValue({});
mocks.workflowBlockConditionGroupDeleteMany.mockResolvedValue({ count: 1 });
mocks.workflowBlockToBlockCreate.mockResolvedValue({ id: "connection_1", fromBlockId: "block_1", toBlockId: "block_2", condition: null });
mocks.workflowBlockToBlockDeleteMany.mockResolvedValue({ count: 1 });
mocks.createWorkflow.mockResolvedValue(workflowRow());
mocks.getWorkflowById.mockResolvedValue(workflowRow());
mocks.createWorkflowBlock.mockResolvedValue({ id: "block_new", workflowId: "workflow_1", type: "log" });
mocks.updateWorkflowBlock.mockResolvedValue({ id: "block_1", workflowId: "workflow_1", type: "log" });
mocks.updateWorkflow.mockResolvedValue(workflowRow({ status: "live" }));
mocks.deleteWorkflow.mockResolvedValue({ count: 1 });
mocks.getAllWorkflowVariables.mockResolvedValue([]);
mocks.getAllWorkflowCredentials.mockResolvedValue([]);
});
it("emits workflow.created metadata after creating the workflow", async () => {
await WorkflowsService.create({ tenantId: "tenant_1", userId: "user_1" });
expect(mocks.createWorkflow).toHaveBeenCalledWith({
tenantId: "tenant_1",
name: "Untitled workflow 1",
description: "",
createdByUserId: "user_1",
});
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledWith(
expect.objectContaining({ name: "workflow.created" }),
expect.objectContaining({
tenantId: "tenant_1",
workflowId: "workflow_1",
userId: "user_1",
status: "draft",
appliesToAllTenants: false,
}),
{
tenantId: "tenant_1",
dispatchKey: "workflow:workflow_1:created",
}
);
expect(mocks.emitPluginHookFireAndForget.mock.calls[0]?.[1]).not.toHaveProperty("blocks");
expect(mocks.emitPluginHookFireAndForget.mock.calls[0]?.[1]).not.toHaveProperty("credentials");
expect(mocks.emitPluginHookFireAndForget.mock.calls[0]?.[1]).not.toHaveProperty("input");
});
it("uses stable English persisted generated names for new untitled workflows", async () => {
mocks.workflowFindMany.mockResolvedValueOnce([{ name: "Untitled workflow 1" }, { name: "Untitled workflow 2" }]);
await WorkflowsService.create({ tenantId: "tenant_1", userId: "user_1" });
expect(mocks.createWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
name: "Untitled workflow 3",
})
);
});
it("emits workflow.updated metadata with changed fields after updating the workflow", async () => {
await WorkflowsService.update(
"workflow_1",
{ name: "Demo workflow", description: "New description", status: "live" },
{ tenantId: "tenant_1", userId: "user_2" }
);
expect(mocks.updateWorkflow).toHaveBeenCalledWith(
{ id: "workflow_1", tenantId: "tenant_1" },
{
name: "Demo workflow",
description: "New description",
status: "live",
}
);
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledWith(
expect.objectContaining({ name: "workflow.updated" }),
expect.objectContaining({
tenantId: "tenant_1",
workflowId: "workflow_1",
userId: "user_2",
status: "live",
appliesToAllTenants: false,
changedFields: ["description", "status"],
}),
expect.objectContaining({
tenantId: "tenant_1",
dispatchKey: "workflow:workflow_1:updated:2026-05-27T10:00:00.000Z",
})
);
});
it("renames exact generated untitled workflows when an app event trigger is configured", async () => {
const workflow = {
...(await WorkflowsService.get("workflow_1", { tenantId: "tenant_1" }))!,
name: "Untitled workflow 2",
status: "draft" as const,
};
await WorkflowsService.updateBlock(
"block_event",
workflowBlock({
id: "block_event",
type: "event",
input: { event: "contact.created" },
isTrigger: true,
isBlock: false,
}),
{ workflow, userId: "user_9" }
);
expect(mocks.updateWorkflow).toHaveBeenNthCalledWith(
1,
{ id: "workflow_1", tenantId: "tenant_1" },
{
name: "App Event: contact.created",
}
);
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledWith(
expect.objectContaining({ name: "workflow.updated" }),
expect.objectContaining({
changedFields: ["blocks", "name"],
}),
expect.any(Object)
);
});
it("does not rename user-owned workflow names that only start with generated default copy", async () => {
const workflow = {
...(await WorkflowsService.get("workflow_1", { tenantId: "tenant_1" }))!,
name: "Untitled workflow imported template",
status: "draft" as const,
};
await WorkflowsService.updateBlock(
"block_event",
workflowBlock({
id: "block_event",
type: "event",
input: { event: "contact.created" },
isTrigger: true,
isBlock: false,
}),
{ workflow, userId: "user_9" }
);
expect(mocks.updateWorkflow).toHaveBeenCalledTimes(1);
expect(mocks.updateWorkflow).toHaveBeenCalledWith(
{ id: "workflow_1", tenantId: "tenant_1" },
{
status: "draft",
}
);
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledWith(
expect.objectContaining({ name: "workflow.updated" }),
expect.objectContaining({
changedFields: ["blocks"],
}),
expect.any(Object)
);
});
it("emits workflow.deleted metadata after deleting a tenant-scoped workflow", async () => {
await WorkflowsService.del("workflow_1", { tenantId: "tenant_1", userId: "user_3" });
expect(mocks.deleteWorkflow).toHaveBeenCalledWith({ id: "workflow_1", tenantId: "tenant_1" });
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledWith(
expect.objectContaining({ name: "workflow.deleted" }),
expect.objectContaining({
tenantId: "tenant_1",
workflowId: "workflow_1",
userId: "user_3",
status: "draft",
appliesToAllTenants: false,
}),
{
tenantId: "tenant_1",
dispatchKey: "workflow:workflow_1:deleted",
}
);
});
it("does not emit workflow definition hooks for platform-scoped workflows", async () => {
mocks.createWorkflow.mockResolvedValueOnce(workflowRow({ id: "workflow_platform_create", tenantId: null, appliesToAllTenants: true }));
await WorkflowsService.create({ tenantId: null, userId: "admin_1" });
expect(mocks.emitPluginHookFireAndForget).not.toHaveBeenCalled();
mocks.getWorkflowById.mockResolvedValueOnce(workflowRow({ tenantId: null, appliesToAllTenants: true }));
mocks.updateWorkflow.mockResolvedValueOnce(workflowRow({ tenantId: null, appliesToAllTenants: true, status: "live" }));
await WorkflowsService.update("workflow_1", { status: "live" }, { tenantId: null, userId: "admin_1" });
expect(mocks.emitPluginHookFireAndForget).not.toHaveBeenCalled();
mocks.getWorkflowById.mockResolvedValueOnce(workflowRow({ tenantId: null, appliesToAllTenants: true }));
await WorkflowsService.del("workflow_1", { tenantId: null, userId: "admin_1" });
expect(mocks.deleteWorkflow).toHaveBeenCalledWith({ id: "workflow_1", tenantId: null });
expect(mocks.emitPluginHookFireAndForget).not.toHaveBeenCalled();
});
it("emits workflow.updated hooks for builder graph mutations", async () => {
const workflow = {
...(await WorkflowsService.get("workflow_1", { tenantId: "tenant_1" }))!,
status: "live" as const,
blocks: [
workflowBlock({ id: "block_1" }),
workflowBlock({ id: "block_2" }),
],
};
await WorkflowsService.updateBlock("block_1", workflowBlock({ id: "block_1", type: "log" }), { workflow, userId: "user_9" });
await WorkflowsService.addBlock({ workflow, fromBlockId: "block_1", type: "log", condition: null, userId: "user_9" });
await WorkflowsService.deleteBlock("block_1", { workflow, userId: "user_9" });
await WorkflowsService.connectBlocks({ fromBlockId: "block_1", toBlockId: "block_2", condition: null }, { workflow, userId: "user_9" });
await WorkflowsService.deleteConnection({ fromBlockId: "block_1", toBlockId: "block_2" }, { workflow, userId: "user_9" });
await WorkflowsService.updateConditionsGroups(workflowBlock({ id: "block_1" }), [{ index: 0, type: "AND", conditions: [] }], { workflow, userId: "user_9" });
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledTimes(6);
expect(mocks.emitPluginHookFireAndForget).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: "workflow.updated" }),
expect.objectContaining({
tenantId: "tenant_1",
workflowId: "workflow_1",
userId: "user_9",
status: "live",
changedFields: ["blocks"],
}),
expect.objectContaining({
tenantId: "tenant_1",
dispatchKey: expect.stringContaining("workflow:workflow_1:updated:block-updated:block_1:"),
})
);
expect(mocks.emitPluginHookFireAndForget).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ name: "workflow.updated" }),
expect.objectContaining({ changedFields: ["blocks"] }),
expect.objectContaining({ dispatchKey: expect.stringContaining("workflow:workflow_1:updated:block-added:block_new:") })
);
expect(mocks.emitPluginHookFireAndForget).toHaveBeenNthCalledWith(
4,
expect.objectContaining({ name: "workflow.updated" }),
expect.objectContaining({ changedFields: ["connections"] }),
expect.objectContaining({ dispatchKey: expect.stringContaining("workflow:workflow_1:updated:connection-created:block_1:block_2:") })
);
expect(mocks.emitPluginHookFireAndForget).toHaveBeenNthCalledWith(
6,
expect.objectContaining({ name: "workflow.updated" }),
expect.objectContaining({ changedFields: ["conditions"] }),
expect.objectContaining({ dispatchKey: expect.stringContaining("workflow:workflow_1:updated:conditions-updated:block_1:") })
);
});
});
function workflowBlock(overrides: Partial<import("../dtos/WorkflowBlockDto").WorkflowBlockDto> = {}): import("../dtos/WorkflowBlockDto").WorkflowBlockDto {
return {
id: "block_1",
index: 0,
type: "log",
variableName: "block_1",
description: "",
input: { message: "Hello" },
isTrigger: false,
isBlock: true,
conditionGroups: [],
toBlocks: [],
...overrides,
};
}
@@ -0,0 +1,720 @@
import { db } from "~/utils/db.server";
import { createWorkflowBlock, getWorkflowBlockTypes, updateWorkflowBlock } from "../db/workflowBlocks.db.server";
import {
WorkflowWithDetails,
createWorkflow,
deleteWorkflow,
getAllWorkflows,
getAllWorkflowsAppliesToAllTenants,
getWorkflowByAccessibleId,
getWorkflowById,
updateWorkflow,
} from "../db/workflows.db.server";
import { WorkflowDto } from "../dtos/WorkflowDto";
import { WorkflowBlockInput, WorkflowBlockTypes } from "../dtos/WorkflowBlockTypes";
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
import WorkflowUtils from "../helpers/WorkflowUtils";
import { WorkflowConditionsGroupDto } from "../dtos/WorkflowConditionDtos";
import WorkflowExecutionUtils from "../helpers/WorkflowExecutionUtils";
import { getWorkflowExecution, getWorkflowExecutions } from "../db/workflowExecutions.db.server";
import { getAllWorkflowVariables } from "../db/workflowVariable.db.server";
import { getAllWorkflowCredentials } from "../db/workflowCredentials.db.server";
import WorkflowBlockUtils from "../helpers/WorkflowBlockUtils";
import { t as i18nT } from "i18next";
import {
PLUGIN_HOOK_WORKFLOW_CREATED,
PLUGIN_HOOK_WORKFLOW_DELETED,
PLUGIN_HOOK_WORKFLOW_UPDATED,
type PluginHookDefinition,
type PluginHookWorkflowDefinitionPayload,
} from "@toolctrl/plugin-runtime";
import { emitPluginHookFireAndForget } from "~/modules/plugins/services/.server/pluginHookPublisher";
// Persisted generated names are intentionally stable English identifiers.
// Localize display copy at the UI boundary instead of changing stored names.
const GENERATED_UNTITLED_WORKFLOW_PREFIX = "Untitled workflow";
const GENERATED_UNTITLED_WORKFLOW_NAME = /^Untitled workflow [1-9]\d*$/u;
const GENERATED_APP_EVENT_WORKFLOW_PREFIX = "App Event";
function workflowError(key: string, defaultValue: string, values?: Record<string, unknown>) {
return i18nT(`workflows.errors.${key}`, { defaultValue, ...(values ?? {}) });
}
async function create(session: { tenantId: string | null; userId: string | null }) {
const name = await getNextUntitledWorkflowName({ tenantId: session.tenantId });
const item = await createWorkflow({
tenantId: session.tenantId,
name,
description: "",
createdByUserId: session.userId,
});
await emitWorkflowCreatedHook(item, { userId: session.userId });
return item;
}
async function get(id: string, { tenantId }: { tenantId: string | null }): Promise<WorkflowDto | null> {
const item = await getWorkflowById({ id, tenantId });
if (!item) {
return null;
}
const $variables = await getAllWorkflowVariables({ tenantId });
const $credentials = await getAllWorkflowCredentials({ tenantId });
return await workflowToDto(item, {
$variables: $variables.map((f) => f.name),
$credentials: $credentials.map((f) => f.name),
});
}
async function getById(id: string, { tenantId }: { tenantId: string | null }): Promise<WorkflowDto | null> {
const item = await getWorkflowByAccessibleId({ id, tenantId });
if (!item) {
return null;
}
const $variables = await getAllWorkflowVariables({ tenantId });
const $credentials = await getAllWorkflowCredentials({ tenantId });
return await workflowToDto(item, {
$variables: $variables.map((f) => f.name),
$credentials: $credentials.map((f) => f.name),
});
}
async function getAll({ tenantId }: { tenantId: string | null }): Promise<WorkflowDto[]> {
const items = await getAllWorkflows({ tenantId });
return await Promise.all(items.map((item) => workflowToDto(item)));
}
async function getAllAppliesToAllTenants({ tenantId }: { tenantId: string | null }): Promise<WorkflowDto[]> {
const items = await getAllWorkflowsAppliesToAllTenants({ tenantId, status: "live" });
return await Promise.all(items.map((item) => workflowToDto(item)));
}
async function workflowToDto(
item: WorkflowWithDetails,
global?: {
$variables: string[];
$credentials: string[];
}
) {
const workflow: WorkflowDto = {
id: item.id,
tenantId: item.tenantId,
tenant: item.tenant,
name: item.name,
description: item.description,
status: item.status as WorkflowDto["status"],
createdAt: item.createdAt,
updatedAt: item.updatedAt,
appliesToAllTenants: item.appliesToAllTenants,
blocks: await Promise.all(
item.blocks.map(async (block, index) => {
const workflowBlock = WorkflowBlockUtils.rowToDto(block, index);
if (workflowBlock.type === "if") {
if (workflowBlock.conditionGroups.length === 0) {
workflowBlock.conditionGroups.push({
index: 0,
type: "AND",
conditions: [],
});
}
} else if (workflowBlock.type === "switch") {
const hasCase1 = workflowBlock.conditionGroups.find((f) => f.index === 0);
const hasCase2 = workflowBlock.conditionGroups.find((f) => f.index === 1);
if (!hasCase1) {
workflowBlock.conditionGroups.push({ index: 0, type: "AND", conditions: [] });
}
if (!hasCase2) {
workflowBlock.conditionGroups.push({ index: 1, type: "AND", conditions: [] });
}
}
return workflowBlock;
})
),
inputExamples: item.inputExamples.map((inputExample) => {
let input: { [key: string]: any } = {};
try {
input = inputExample.input ? JSON.parse(inputExample.input) : null;
} catch {}
return {
id: inputExample.id,
title: inputExample.title,
input,
createdAt: inputExample.createdAt,
};
}),
_count: item._count,
};
workflow.blocks.forEach((block) => {
const { variableName, index } = WorkflowUtils.getVariableName({ workflow, currentBlock: block });
block.variableName = variableName;
block.index = index;
});
workflow.blocks = workflow.blocks.sort((a, b) => a.index - b.index);
workflow.inputExamples = workflow.inputExamples.sort((a, b) => {
if (a.createdAt && b.createdAt) {
return a.createdAt > b.createdAt ? 1 : -1;
}
return 0;
});
workflow.$variables = global?.$variables ?? [];
workflow.$credentials = global?.$credentials ?? [];
return workflow;
}
async function addConditionGroup(workflowBlock: WorkflowBlockDto, workflow: WorkflowDto, data: WorkflowConditionsGroupDto[]) {
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [workflowBlock.id]);
return await Promise.all(
data.map(async (conditionGroup) => {
const conditions = conditionGroup.conditions.map((condition) => {
return {
index: condition.index,
variable: condition.variable,
operator: condition.operator,
value: condition.value,
};
});
await db.workflowBlockConditionGroup.create({
data: {
workflowBlockId: workflowBlock.id,
index: conditionGroup.index,
type: conditionGroup.type,
conditions: {
create: conditions,
},
},
});
workflowBlock.conditionGroups.push(conditionGroup);
return conditionGroup;
})
);
}
async function update(
id: string,
workflow: {
name?: string;
description?: string;
status?: "draft" | "live" | "archived";
blocks?: WorkflowBlockDto[];
},
session: { tenantId: string | null; userId?: string | null }
): Promise<WorkflowDto | null> {
const item = await get(id, session);
if (!item) {
return null;
}
if (item.tenantId !== session.tenantId) {
throw new Error(workflowError("cannotEditWorkflow", "You cannot edit this workflow"));
}
if (workflow.name !== undefined && workflow.name.trim() === "") {
workflow.name = await getNextUntitledWorkflowName({ tenantId: session.tenantId });
}
const changedFields = getChangedWorkflowFields(item, workflow);
const updatedItem = await updateWorkflow({ id, tenantId: session.tenantId }, {
name: workflow.name,
description: workflow.description,
status: workflow.status,
});
await emitWorkflowDefinitionHook(PLUGIN_HOOK_WORKFLOW_UPDATED, updatedItem, {
userId: session.userId ?? null,
changedFields,
dispatchKey: `workflow:${id}:updated:${updatedItem.updatedAt.toISOString()}`,
});
return get(id, session);
}
async function getNextUntitledWorkflowName({ tenantId }: { tenantId: string | null }) {
const allWorkflowNames = await db.workflow.findMany({
where: {
tenantId,
},
select: { name: true },
});
let workflowName = `${GENERATED_UNTITLED_WORKFLOW_PREFIX} 1`;
const existingWorkflowNames = allWorkflowNames.map((f) => f.name);
if (!existingWorkflowNames.includes(workflowName)) {
return workflowName;
}
let i = 2;
while (existingWorkflowNames.includes(workflowName)) {
workflowName = `${GENERATED_UNTITLED_WORKFLOW_PREFIX} ${i}`;
i++;
if (i > 1000) {
throw new Error(workflowError("tooManyUntitledWorkflows", "Too many untitled workflows"));
}
}
return workflowName;
}
async function del(id: string, session: { tenantId: string | null; userId?: string | null }) {
const item = await get(id, session);
if (!item) {
throw new Error(workflowError("workflowNotFound", "Workflow not found"));
}
if (item.tenantId !== session.tenantId) {
throw new Error(workflowError("cannotDeleteWorkflow", "You cannot delete this workflow"));
}
const deleted = await deleteWorkflow({ id, tenantId: session.tenantId });
await emitWorkflowDefinitionHook(PLUGIN_HOOK_WORKFLOW_DELETED, item, {
userId: session.userId ?? null,
dispatchKey: `workflow:${id}:deleted`,
});
return deleted;
}
function getChangedWorkflowFields(
item: Pick<WorkflowDto, "name" | "description" | "status">,
workflow: {
name?: string;
description?: string;
status?: "draft" | "live" | "archived";
}
): string[] {
const changedFields: string[] = [];
if (workflow.name !== undefined && workflow.name !== item.name) {
changedFields.push("name");
}
if (workflow.description !== undefined && workflow.description !== item.description) {
changedFields.push("description");
}
if (workflow.status !== undefined && workflow.status !== item.status) {
changedFields.push("status");
}
return changedFields;
}
async function emitWorkflowDefinitionHook(
def: PluginHookDefinition<PluginHookWorkflowDefinitionPayload>,
workflow: Pick<WorkflowDto, "id" | "tenantId" | "appliesToAllTenants"> & { status: string },
{
userId,
changedFields,
dispatchKey,
}: {
userId: string | null;
changedFields?: string[];
dispatchKey: string;
}
) {
if (!workflow.tenantId) {
return;
}
const status = toWorkflowDefinitionHookStatus(workflow.status);
if (!status) {
return;
}
await emitPluginHookFireAndForget(
def,
{
tenantId: workflow.tenantId,
workflowId: workflow.id,
userId,
status,
appliesToAllTenants: workflow.appliesToAllTenants,
...(changedFields && changedFields.length > 0 ? { changedFields } : {}),
occurredAt: new Date().toISOString(),
},
{
tenantId: workflow.tenantId,
dispatchKey,
}
);
}
export async function emitWorkflowCreatedHook(
workflow: Pick<WorkflowDto, "id" | "tenantId" | "appliesToAllTenants"> & { status: string },
{ userId }: { userId: string | null }
) {
await emitWorkflowDefinitionHook(PLUGIN_HOOK_WORKFLOW_CREATED, workflow, {
userId,
dispatchKey: `workflow:${workflow.id}:created`,
});
}
function toWorkflowDefinitionHookStatus(status: string): PluginHookWorkflowDefinitionPayload["status"] | null {
if (status === "draft" || status === "live" || status === "archived") {
return status;
}
return null;
}
async function updateBlock(id: string, block: WorkflowBlockDto, { workflow, userId = null }: { workflow: WorkflowDto; userId?: string | null }) {
const changedFields = ["blocks"];
const item = await updateWorkflowBlock({ id, workflowId: workflow.id, tenantId: workflow.tenantId }, {
type: block.type,
input: JSON.stringify(block.input),
description: block.description,
isTrigger: block.isTrigger,
isBlock: block.isBlock,
// positionX: block.position.x,
// positionY: block.position.y,
});
if (workflow.tenantId === null) {
await updateWorkflowApplyToAllTenants(item.workflowId, workflow.tenantId);
}
if (block.type === "event" && block.input.event) {
if (isGeneratedUntitledWorkflowName(workflow.name)) {
await updateWorkflow({ id: item.workflowId, tenantId: workflow.tenantId }, {
name: `${GENERATED_APP_EVENT_WORKFLOW_PREFIX}: ${block.input.event}`,
});
changedFields.push("name");
}
}
await emitWorkflowBuilderMutationHook(workflow, {
changedFields,
action: "block-updated",
targetId: id,
userId,
});
return item;
}
function isGeneratedUntitledWorkflowName(name: string) {
return GENERATED_UNTITLED_WORKFLOW_NAME.test(name);
}
async function addBlock({
workflow,
type,
fromBlockId,
condition,
userId,
}: {
workflow: WorkflowDto;
type: string;
fromBlockId: string | undefined;
condition: string | null;
userId?: string | null;
}) {
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === type);
if (!workflowBlock) {
throw new Error(workflowError("invalidBlockType", "Invalid workflow block type: {{type}}", { type }));
}
let inputData: { [key: string]: any } = {};
workflowBlock.inputs.forEach((input) => {
let blockInput = input as WorkflowBlockInput;
if (blockInput.defaultValue !== undefined) {
inputData[blockInput.name] = blockInput.defaultValue;
}
});
const toBlock = await createWorkflowBlock({
workflowId: workflow.id,
tenantId: workflow.tenantId,
type,
input: JSON.stringify(inputData),
description: "",
isTrigger: workflowBlock.type === "trigger",
isBlock: workflowBlock.type === "action",
// positionX: 0,
// positionY: 0,
});
if (fromBlockId) {
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [fromBlockId, toBlock.id]);
await db.workflowBlockToBlock.create({
data: {
fromBlockId,
toBlockId: toBlock.id,
condition,
},
});
}
if (workflowBlock.type === "trigger") {
const blocksWithoutParents = WorkflowUtils.getBlocksWithoutCallers(workflow);
if (blocksWithoutParents.length === 1) {
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [toBlock.id, blocksWithoutParents[0].id]);
await db.workflowBlockToBlock.create({
data: {
fromBlockId: toBlock.id,
toBlockId: blocksWithoutParents[0].id,
condition: null,
},
});
}
}
if (workflow.tenantId === null) {
await updateWorkflowApplyToAllTenants(workflow.id, workflow.tenantId);
}
await emitWorkflowBuilderMutationHook(workflow, {
changedFields: ["blocks"],
action: "block-added",
targetId: toBlock.id,
userId: userId ?? null,
});
return toBlock;
}
async function updateWorkflowApplyToAllTenants(workflowId: string, tenantId: string | null) {
const blocks = await getWorkflowBlockTypes(workflowId);
let appliesToAllTenants = false;
blocks.forEach((type) => {
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === type);
if (workflowBlock?.appliesToAllTenants) {
appliesToAllTenants = true;
}
});
const item = await updateWorkflow({ id: workflowId, tenantId }, {
appliesToAllTenants,
});
if (item.tenantId && item.appliesToAllTenants) {
// just for good measure
await updateWorkflow({ id: workflowId, tenantId: item.tenantId }, {
appliesToAllTenants: false,
});
}
return item;
}
async function deleteBlock(id: string, { workflow, userId = null }: { workflow: WorkflowDto; userId?: string | null }) {
const item = await db.workflowBlock.findFirstOrThrow({
where: { id, workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
});
const deleted = await db.workflowBlock.deleteMany({
where: { id, workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
});
if (deleted.count !== 1) {
throw new Error(workflowError("workflowBlockNotFoundWithId", "Workflow block {{id}} not found", { id }));
}
if (workflow.tenantId === null) {
await updateWorkflowApplyToAllTenants(item.workflowId, workflow.tenantId);
}
await emitWorkflowBuilderMutationHook(workflow, {
changedFields: ["blocks"],
action: "block-deleted",
targetId: id,
userId,
});
return item;
}
async function connectBlocks(
{ fromBlockId, toBlockId, condition }: { fromBlockId: string; toBlockId: string; condition: string | null },
{ workflow, userId = null }: { workflow: WorkflowDto; userId?: string | null }
) {
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [fromBlockId, toBlockId]);
const item = await db.workflowBlockToBlock.create({
data: {
fromBlockId,
toBlockId,
condition,
},
});
await emitWorkflowBuilderMutationHook(workflow, {
changedFields: ["connections"],
action: "connection-created",
targetId: `${fromBlockId}:${toBlockId}`,
userId,
});
return item;
}
async function deleteConnection(
params: { fromBlockId: string; toBlockId: string } | { id: string },
{ workflow, userId = null }: { workflow: WorkflowDto; userId?: string | null }
) {
if ("id" in params) {
const deleted = await db.workflowBlockToBlock.deleteMany({
where: {
id: params.id,
fromBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
toBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
},
});
if (deleted.count !== 1) {
throw new Error(workflowError("workflowConnectionNotFoundWithId", "Workflow connection {{id}} not found", { id: params.id }));
}
await emitWorkflowBuilderMutationHook(workflow, {
changedFields: ["connections"],
action: "connection-deleted",
targetId: params.id,
userId,
});
return deleted;
} else {
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [params.fromBlockId, params.toBlockId]);
const deleted = await db.workflowBlockToBlock.deleteMany({
where: {
fromBlockId: params.fromBlockId,
toBlockId: params.toBlockId,
fromBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
toBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
},
});
if (deleted.count > 0) {
await emitWorkflowBuilderMutationHook(workflow, {
changedFields: ["connections"],
action: "connection-deleted",
targetId: `${params.fromBlockId}:${params.toBlockId}`,
userId,
});
}
return deleted;
}
}
async function updateConditionsGroups(
block: WorkflowBlockDto,
conditionGroups: WorkflowConditionsGroupDto[],
{ workflow, userId = null }: { workflow: WorkflowDto; userId?: string | null }
) {
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [block.id]);
await db.workflowBlockConditionGroup.deleteMany({
where: {
workflowBlockId: block.id,
workflowBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
},
});
await Promise.all(
conditionGroups
.sort((a, b) => a.index - b.index)
.map(async (group, groupIndex) => {
const conditions = group.conditions
.sort((a, b) => a.index - b.index)
.map((condition, conditionIndex) => {
return {
index: conditionIndex,
variable: condition.variable,
operator: condition.operator,
value: condition.value,
};
});
await db.workflowBlockConditionGroup.create({
data: {
workflowBlockId: block.id,
index: groupIndex,
type: group.type,
conditions: {
create: conditions,
},
},
});
})
);
await emitWorkflowBuilderMutationHook(workflow, {
changedFields: ["conditions"],
action: "conditions-updated",
targetId: block.id,
userId,
});
}
async function emitWorkflowBuilderMutationHook(
workflow: Pick<WorkflowDto, "id" | "tenantId" | "appliesToAllTenants" | "status">,
{
changedFields,
action,
targetId,
userId = null,
}: {
changedFields: string[];
action: string;
targetId: string;
userId?: string | null;
}
) {
if (!workflow.tenantId) {
return;
}
const updatedWorkflow = await updateWorkflow({ id: workflow.id, tenantId: workflow.tenantId }, {
status: workflow.status,
});
await emitWorkflowDefinitionHook(PLUGIN_HOOK_WORKFLOW_UPDATED, updatedWorkflow, {
userId,
changedFields,
dispatchKey: `workflow:${workflow.id}:updated:${action}:${targetId}:${updatedWorkflow.updatedAt.toISOString()}`,
});
}
async function getExecutions(item: WorkflowDto, { tenantId }: { tenantId: string | null }) {
const items = await getWorkflowExecutions({ workflowId: item.id }, { tenantId });
return items.map((f) => WorkflowExecutionUtils.rowToDto(f));
}
async function getExecution(id: string, { tenantId }: { tenantId: string | null }) {
const item = await getWorkflowExecution(id, { tenantId });
if (!item) {
return null;
}
return WorkflowExecutionUtils.rowToDto(item);
}
async function createInputExample({ workflowId, tenantId, title, input }: { workflowId: string; tenantId: string | null; title: string; input: string }) {
await db.workflow.findFirstOrThrow({
where: { id: workflowId, tenantId },
select: { id: true },
});
return await db.workflowInputExample.create({
data: {
workflowId,
title,
input,
},
});
}
async function updateInputExample(scope: { id: string; workflowId: string; tenantId: string | null }, { title, input }: { title: string; input: string }) {
const updated = await db.workflowInputExample.updateMany({
where: { id: scope.id, workflowId: scope.workflowId, workflow: { tenantId: scope.tenantId } },
data: {
title,
input,
},
});
if (updated.count !== 1) {
throw new Error(workflowError("workflowInputExampleNotFoundWithId", "Workflow input example {{id}} not found", { id: scope.id }));
}
return await db.workflowInputExample.findFirstOrThrow({
where: { id: scope.id, workflowId: scope.workflowId, workflow: { tenantId: scope.tenantId } },
});
}
async function deleteInputExample(scope: { id: string; workflowId: string; tenantId: string | null }) {
const deleted = await db.workflowInputExample.deleteMany({
where: { id: scope.id, workflowId: scope.workflowId, workflow: { tenantId: scope.tenantId } },
});
if (deleted.count !== 1) {
throw new Error(workflowError("workflowInputExampleNotFoundWithId", "Workflow input example {{id}} not found", { id: scope.id }));
}
return deleted;
}
async function requireWorkflowBlocksInWorkflow({ workflowId, tenantId }: { workflowId: string; tenantId: string | null }, blockIds: string[]) {
const ids = Array.from(new Set(blockIds.filter(Boolean)));
if (ids.length === 0) {
return;
}
const count = await db.workflowBlock.count({
where: {
id: { in: ids },
workflowId,
workflow: { tenantId },
},
});
if (count !== ids.length) {
throw new Error(workflowError("blockNotFound", "Workflow block not found"));
}
}
export default {
create,
get,
getById,
getAll,
getAllAppliesToAllTenants,
update,
del,
updateBlock,
addBlock,
deleteBlock,
connectBlocks,
deleteConnection,
updateConditionsGroups,
getExecutions,
getExecution,
createInputExample,
updateInputExample,
deleteInputExample,
};
@@ -0,0 +1,259 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkflowDto } from "../dtos/WorkflowDto";
type TemplateImportMocks = {
transaction: ReturnType<typeof vi.fn>;
workflowVariableFindMany: ReturnType<typeof vi.fn>;
workflowVariableCreateMany: ReturnType<typeof vi.fn>;
workflowCredentialFindMany: ReturnType<typeof vi.fn>;
workflowCreate: ReturnType<typeof vi.fn>;
workflowBlockCreate: ReturnType<typeof vi.fn>;
workflowBlockConditionGroupCreate: ReturnType<typeof vi.fn>;
workflowBlockConditionCreateMany: ReturnType<typeof vi.fn>;
workflowBlockToBlockCreateMany: ReturnType<typeof vi.fn>;
workflowInputExampleCreateMany: ReturnType<typeof vi.fn>;
getAllEntities: ReturnType<typeof vi.fn>;
getWorkflow: ReturnType<typeof vi.fn>;
emitWorkflowCreatedHook: ReturnType<typeof vi.fn>;
};
function getMocks(): TemplateImportMocks {
const store = globalThis as typeof globalThis & { __workflowTemplateImportMocks__?: TemplateImportMocks };
store.__workflowTemplateImportMocks__ ??= {
transaction: vi.fn(),
workflowVariableFindMany: vi.fn(),
workflowVariableCreateMany: vi.fn(),
workflowCredentialFindMany: vi.fn(),
workflowCreate: vi.fn(),
workflowBlockCreate: vi.fn(),
workflowBlockConditionGroupCreate: vi.fn(),
workflowBlockConditionCreateMany: vi.fn(),
workflowBlockToBlockCreateMany: vi.fn(),
workflowInputExampleCreateMany: vi.fn(),
getAllEntities: vi.fn(),
getWorkflow: vi.fn(),
emitWorkflowCreatedHook: vi.fn(),
};
return store.__workflowTemplateImportMocks__;
}
vi.mock("~/utils/db.server", () => ({
db: {
$transaction: getMocks().transaction,
workflowVariable: { findMany: getMocks().workflowVariableFindMany, createMany: getMocks().workflowVariableCreateMany },
workflowCredential: { findMany: getMocks().workflowCredentialFindMany },
workflow: {
create: getMocks().workflowCreate,
},
workflowBlock: { create: getMocks().workflowBlockCreate },
workflowBlockConditionGroup: { create: getMocks().workflowBlockConditionGroupCreate },
workflowBlockCondition: { createMany: getMocks().workflowBlockConditionCreateMany },
workflowBlockToBlock: { createMany: getMocks().workflowBlockToBlockCreateMany },
workflowInputExample: { createMany: getMocks().workflowInputExampleCreateMany },
},
}));
vi.mock("~/utils/db/entities/entities.db.server", () => ({
getAllEntities: getMocks().getAllEntities,
}));
vi.mock("./WorkflowsService", () => ({
default: {
get: getMocks().getWorkflow,
},
emitWorkflowCreatedHook: getMocks().emitWorkflowCreatedHook,
}));
import WorkflowsTemplatesService from "./WorkflowsTemplatesService";
const mocks = getMocks();
function importedWorkflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
return {
id: "workflow_imported_1",
tenantId: "tenant_1",
tenant: null,
name: "Imported workflow",
description: "",
status: "draft",
createdAt: new Date("2026-05-28T00:00:00.000Z"),
updatedAt: null,
appliesToAllTenants: false,
blocks: [],
inputExamples: [],
_count: { executions: 0 },
...overrides,
};
}
const template = {
title: "Workflow templates",
workflows: [
{
name: "Imported workflow",
description: "Creates a tenant workflow",
blocks: [{ id: "log", type: "log", description: "Log event", input: {}, conditionGroups: [] }],
toBlocks: [],
inputExamples: [{ title: "Example", input: { leadId: "lead_1" } }],
},
],
};
function installTransactionMock() {
const tx = {
workflowVariable: { createMany: mocks.workflowVariableCreateMany },
workflow: { create: mocks.workflowCreate },
workflowBlock: { create: mocks.workflowBlockCreate },
workflowBlockConditionGroup: { create: mocks.workflowBlockConditionGroupCreate },
workflowBlockCondition: { createMany: mocks.workflowBlockConditionCreateMany },
workflowBlockToBlock: { createMany: mocks.workflowBlockToBlockCreateMany },
workflowInputExample: { createMany: mocks.workflowInputExampleCreateMany },
};
mocks.transaction.mockImplementation(async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx));
}
describe("WorkflowsTemplatesService import hooks", () => {
beforeEach(() => {
vi.clearAllMocks();
installTransactionMock();
mocks.workflowVariableFindMany.mockResolvedValue([]);
mocks.workflowCredentialFindMany.mockResolvedValue([]);
mocks.workflowCreate.mockResolvedValue({ id: "workflow_imported_1" });
mocks.workflowBlockCreate.mockResolvedValue({ id: "block_imported_1" });
mocks.workflowBlockToBlockCreateMany.mockResolvedValue({ count: 0 });
mocks.workflowInputExampleCreateMany.mockResolvedValue({ count: 1 });
mocks.getAllEntities.mockResolvedValue([]);
mocks.getWorkflow.mockResolvedValue(importedWorkflow());
});
it("emits workflow.created hooks for tenant-scoped template imports after import completes", async () => {
await WorkflowsTemplatesService.importWorkflows(template, { tenantId: "tenant_1", userId: "user_1" });
expect(mocks.workflowCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: "tenant_1",
name: "Imported workflow",
status: "draft",
createdByUserId: "user_1",
appliesToAllTenants: false,
}),
});
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.workflowInputExampleCreateMany).toHaveBeenCalledWith({
data: [
{
workflowId: "workflow_imported_1",
title: "Example",
input: JSON.stringify({ leadId: "lead_1" }),
},
],
});
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_imported_1", { tenantId: "tenant_1", userId: "user_1" });
expect(mocks.emitWorkflowCreatedHook).toHaveBeenCalledWith(
expect.objectContaining({
id: "workflow_imported_1",
tenantId: "tenant_1",
status: "draft",
}),
{ userId: "user_1" }
);
});
it("keeps platform-scoped template imports out of tenant plugin hook dispatch", async () => {
mocks.getWorkflow.mockResolvedValueOnce(importedWorkflow({ tenantId: null, appliesToAllTenants: true }));
await WorkflowsTemplatesService.importWorkflows(template, { tenantId: null, userId: "admin_1" });
expect(mocks.emitWorkflowCreatedHook).not.toHaveBeenCalled();
});
it("bulk inserts workflow edges and block conditions inside the import transaction", async () => {
const connectedTemplate = {
title: "Workflow templates",
workflows: [
{
name: "Connected workflow",
blocks: [
{
id: "trigger",
type: "manual",
input: {},
conditionGroups: [
{
type: "AND",
conditions: [
{ variable: "input.status", operator: "=", value: "ready" },
{ variable: "input.score", operator: ">", value: "10" },
],
},
],
},
{ id: "log", type: "log", input: {}, conditionGroups: [] },
],
toBlocks: [{ fromBlockId: "trigger", toBlockId: "log", condition: "true" }],
inputExamples: [{ title: "Example", input: { leadId: "lead_1" } }],
},
],
};
mocks.workflowBlockCreate.mockResolvedValueOnce({ id: "block_trigger" }).mockResolvedValueOnce({ id: "block_log" });
mocks.workflowBlockConditionGroupCreate.mockResolvedValueOnce({ id: "condition_group_1" });
await WorkflowsTemplatesService.importWorkflows(connectedTemplate, { tenantId: "tenant_1", userId: "user_1" });
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.workflowBlockConditionCreateMany).toHaveBeenCalledWith({
data: [
{
workflowBlockConditionGroupId: "condition_group_1",
index: 0,
variable: "input.status",
operator: "=",
value: "ready",
},
{
workflowBlockConditionGroupId: "condition_group_1",
index: 1,
variable: "input.score",
operator: ">",
value: "10",
},
],
});
expect(mocks.workflowBlockToBlockCreateMany).toHaveBeenCalledWith({
data: [
{
fromBlockId: "block_trigger",
toBlockId: "block_log",
condition: "true",
},
],
});
});
it("validates missing block references before opening the import transaction", async () => {
await expect(
WorkflowsTemplatesService.importWorkflows(
{
title: "Workflow templates",
workflows: [
{
name: "Invalid workflow",
blocks: [{ id: "log", type: "log", input: {}, conditionGroups: [] }],
toBlocks: [{ fromBlockId: "log", toBlockId: "missing" }],
inputExamples: [],
},
],
},
{ tenantId: "tenant_1", userId: "user_1" }
)
).rejects.toMatchObject({
code: "invalidWorkflowTemplateConnection",
names: ["missing"],
});
expect(mocks.transaction).not.toHaveBeenCalled();
expect(mocks.workflowCreate).not.toHaveBeenCalled();
expect(mocks.workflowBlockCreate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
import WorkflowsTemplatesService, { getWorkflowTemplateImportErrorMessage, WorkflowTemplateImportError } from "./WorkflowsTemplatesService";
describe("WorkflowsTemplatesService import errors", () => {
it("translates missing credential import errors with the missing names", () => {
const error = new WorkflowTemplateImportError("missingCredentialsForImport", ["mail", "stripe"], "fallback");
const t = (key: string, options?: Record<string, unknown>) => `${key}:${options?.names}`;
expect(getWorkflowTemplateImportErrorMessage(error, t)).toBe("workflows.errors.missingCredentialsForImport:mail, stripe");
});
it("translates missing entity import errors with the missing names", () => {
const error = new WorkflowTemplateImportError("missingEntitiesForImport", ["Contact"], "fallback");
const t = (key: string, options?: Record<string, unknown>) => `${key}:${options?.names}`;
expect(getWorkflowTemplateImportErrorMessage(error, t)).toBe("workflows.errors.missingEntitiesForImport:Contact");
});
it("maps invalid JSON errors to the localized invalid JSON copy", () => {
const t = (key: string) => key;
expect(getWorkflowTemplateImportErrorMessage(new SyntaxError("Unexpected token"), t)).toBe("workflows.errors.invalidJson");
});
it("does not expose English fallback copy for localized import errors", () => {
const source = fs.readFileSync(path.join(process.cwd(), "apps/web/app/modules/workflowEngine/services/WorkflowsTemplatesService.ts"), "utf8");
expect(source).not.toContain('defaultValue: "Invalid JSON"');
expect(source).not.toContain('defaultValue: "Something went wrong"');
expect(source).not.toContain("defaultValue: error.message");
});
it("does not expose generic service error messages to the import alert", () => {
const t = (key: string) => key;
expect(getWorkflowTemplateImportErrorMessage(new Error("Invalid workflow block type: nope"), t)).toBe("workflows.errors.unknown");
});
it("translates unsupported block type import errors with the block type", () => {
const error = new WorkflowTemplateImportError("invalidWorkflowBlockType", ["legacyBlock"], "fallback");
const t = (key: string, options?: Record<string, unknown>) => `${key}:${options?.names}`;
expect(getWorkflowTemplateImportErrorMessage(error, t)).toBe("workflows.errors.invalidWorkflowBlockType:legacyBlock");
});
it("translates invalid template connection errors with the referenced block id", () => {
const error = new WorkflowTemplateImportError("invalidWorkflowTemplateConnection", ["missing-block"], "fallback");
const t = (key: string, options?: Record<string, unknown>) => `${key}:${options?.names}`;
expect(getWorkflowTemplateImportErrorMessage(error, t)).toBe("workflows.errors.invalidWorkflowTemplateConnection:missing-block");
});
});
describe("WorkflowsTemplatesService templates", () => {
it("exports edges using block variable names via indexed block lookup", async () => {
const template = await WorkflowsTemplatesService.getTemplate(
[
{
name: "Lead workflow",
description: "Qualify leads",
blocks: [
{
id: "block_db_1",
variableName: "trigger",
type: "event",
description: "Lead captured",
input: { event: "marketing.lead.captured" },
conditionGroups: [],
toBlocks: [{ id: "edge_1", toBlockId: "block_db_3", condition: "qualified" }],
},
{
id: "block_db_2",
variableName: "audit",
type: "log",
description: "Audit",
input: { message: "audit" },
conditionGroups: [],
toBlocks: [],
},
{
id: "block_db_3",
variableName: "notify",
type: "alertUser",
description: "Notify",
input: { message: "notify" },
conditionGroups: [],
toBlocks: [],
},
],
inputExamples: [{ title: "Sample", input: { leadId: "lead_1" } }],
},
] as never,
[{ name: "threshold", value: "10" }] as never
);
expect(template.workflows[0]?.toBlocks).toEqual([
{
fromBlockId: "trigger",
toBlockId: "notify",
condition: "qualified",
},
]);
expect(template.variables).toEqual([{ name: "threshold", value: "10" }]);
});
it("uses the provided export title when present", async () => {
const template = await WorkflowsTemplatesService.getTemplate([], [], "Workflow-Export");
expect(template.title).toBe("Workflow-Export");
});
});
@@ -0,0 +1,293 @@
import { db } from "~/utils/db.server";
import { WorkflowTemplateDto, WorkflowsTemplateDto } from "../dtos/WorkflowsTemplateDto";
import { WorkflowBlockTypes } from "../dtos/WorkflowBlockTypes";
import WorkflowsService, { emitWorkflowCreatedHook } from "./WorkflowsService";
import { WorkflowDto } from "../dtos/WorkflowDto";
import { Prisma, WorkflowVariable } from "@toolctrl/db/prisma-browser";
import { getAllEntities } from "~/utils/db/entities/entities.db.server";
export type WorkflowTemplateImportErrorCode =
| "invalidJson"
| "invalidWorkflowBlockType"
| "invalidWorkflowTemplateConnection"
| "missingCredentialsForImport"
| "missingEntitiesForImport";
export class WorkflowTemplateImportError extends Error {
override readonly name = "WorkflowTemplateImportError";
constructor(
readonly code: WorkflowTemplateImportErrorCode,
readonly names: readonly string[],
message: string
) {
super(message);
}
}
type WorkflowTemplateImportDbClient = Pick<
Prisma.TransactionClient,
"workflow" | "workflowBlock" | "workflowBlockConditionGroup" | "workflowBlockCondition" | "workflowBlockToBlock" | "workflowInputExample" | "workflowVariable"
>;
export function getWorkflowTemplateImportErrorMessage(error: unknown, t: (key: string, options?: Record<string, unknown>) => string) {
if (error instanceof WorkflowTemplateImportError) {
return t(`workflows.errors.${error.code}`, {
names: error.names.join(", "),
});
}
if (error instanceof SyntaxError) {
return t("workflows.errors.invalidJson");
}
return t("workflows.errors.unknown");
}
async function importWorkflows(template: WorkflowsTemplateDto, session: { tenantId: string | null; userId: string | null }) {
const validatedWorkflows = template.workflows.map(validateWorkflowTemplateForImport);
const allWorkflowVariables = await db.workflowVariable.findMany({
where: { tenantId: session.tenantId },
});
if (template.credentialsRequired) {
const allCredentials = await db.workflowCredential.findMany({
where: { tenantId: session.tenantId },
});
const missingCredentials = template.credentialsRequired.filter((f) => !allCredentials.find((g) => g.name === f));
if (missingCredentials.length > 0) {
throw new WorkflowTemplateImportError(
"missingCredentialsForImport",
missingCredentials,
"To import this workflow, you must create the following credentials first: " + missingCredentials.join(", ")
);
}
}
const requiredEntities: string[] = [];
template.workflows.forEach((worklow) => {
const entityBlocks = worklow.blocks.filter((f) => f.type.startsWith("row"));
entityBlocks.forEach((entityBlock) => {
if (entityBlock.input?.entity && !requiredEntities.includes(entityBlock.input.entity)) {
requiredEntities.push(entityBlock.input.entity);
}
});
});
if (requiredEntities.length > 0) {
const allEntities = await getAllEntities({ tenantId: session.tenantId });
const missingEntities = requiredEntities.filter((f) => !allEntities.find((g) => g.name === f));
if (missingEntities.length > 0) {
throw new WorkflowTemplateImportError(
"missingEntitiesForImport",
missingEntities,
"To import this workflow, you must create the following entities first: " + missingEntities.join(", ")
);
}
}
const existingVariableNames = new Set(allWorkflowVariables.map((variable) => variable.name));
const variablesToCreate = (template.variables ?? []).filter((variable) => !existingVariableNames.has(variable.name));
const workflowIds = await db.$transaction(async (tx) => {
if (variablesToCreate.length > 0) {
await tx.workflowVariable.createMany({
data: variablesToCreate.map((variable) => ({
tenantId: session.tenantId,
name: variable.name,
value: variable.value,
createdByUserId: session.userId,
})),
});
}
return await Promise.all(
validatedWorkflows.map(async (workflow) => {
return await importWorkflow(workflow, session, tx);
})
);
});
const workflows = await Promise.all(
workflowIds.map(async (workflowId) => {
const workflow = await WorkflowsService.get(workflowId, session);
if (!workflow) {
throw new Error(`Imported workflow could not be loaded: ${workflowId}`);
}
return workflow;
})
);
await Promise.all(workflows.map(async (importedWorkflow) => {
if (importedWorkflow.tenantId) {
await emitWorkflowCreatedHook(importedWorkflow, { userId: session.userId });
}
}));
return workflows;
}
type ValidatedWorkflowTemplate = WorkflowsTemplateDto["workflows"][0] & {
appliesToAllTenants: boolean;
blocks: Array<WorkflowsTemplateDto["workflows"][0]["blocks"][number] & { workflowBlockType: (typeof WorkflowBlockTypes)[number] }>;
};
function validateWorkflowTemplateForImport(template: WorkflowsTemplateDto["workflows"][0]): ValidatedWorkflowTemplate {
let appliesToAllTenants = false;
const blockIds = new Set(template.blocks.map((block) => block.id));
const blocks = template.blocks.map((block) => {
const workflowBlockType = WorkflowBlockTypes.find((f) => f.value === block.type);
if (!workflowBlockType) {
throw new WorkflowTemplateImportError("invalidWorkflowBlockType", [block.type], "Workflow template contains an unsupported block type: " + block.type);
}
if (workflowBlockType.appliesToAllTenants) {
appliesToAllTenants = true;
}
return { ...block, workflowBlockType };
});
for (const toBlock of template.toBlocks) {
if (!blockIds.has(toBlock.toBlockId)) {
throw new WorkflowTemplateImportError(
"invalidWorkflowTemplateConnection",
[toBlock.toBlockId],
`Workflow template references a missing block: ${toBlock.toBlockId}`
);
}
if (!blockIds.has(toBlock.fromBlockId)) {
throw new WorkflowTemplateImportError(
"invalidWorkflowTemplateConnection",
[toBlock.fromBlockId],
`Workflow template references a missing block: ${toBlock.fromBlockId}`
);
}
}
return { ...template, appliesToAllTenants, blocks };
}
async function importWorkflow(
template: ValidatedWorkflowTemplate,
session: { tenantId: string | null; userId: string | null },
client: WorkflowTemplateImportDbClient
) {
const workflow = await client.workflow.create({
data: {
tenantId: session.tenantId,
name: template.name,
description: template.description || "",
status: "draft",
createdByUserId: session.userId,
appliesToAllTenants: template.appliesToAllTenants,
},
});
const blocksIdMap: { [key: string]: string } = {};
for (const block of template.blocks) {
const newBlock = await client.workflowBlock.create({
data: {
workflowId: workflow.id,
type: block.type,
description: block.description || "",
isTrigger: block.workflowBlockType.type === "trigger",
isBlock: block.workflowBlockType.type === "action",
// positionX: 0,
// positionY: 0,
input: JSON.stringify(block.input || {}),
},
});
blocksIdMap[block.id] = newBlock.id;
if (block.conditionGroups) {
let idx = 0;
for (const { type, conditions } of block.conditionGroups) {
const conditionGroup = await client.workflowBlockConditionGroup.create({
data: {
workflowBlockId: newBlock.id,
index: idx,
type: type || ("AND" as "AND" | "OR"),
},
});
if (conditions.length > 0) {
await client.workflowBlockCondition.createMany({
data: conditions.map(({ variable, operator, value }, idxCondition) => ({
workflowBlockConditionGroupId: conditionGroup.id,
index: idxCondition,
variable,
operator,
value,
})),
});
}
idx++;
}
}
}
if (template.toBlocks.length > 0) {
await client.workflowBlockToBlock.createMany({
data: template.toBlocks.map((toBlock) => ({
fromBlockId: blocksIdMap[toBlock.fromBlockId],
toBlockId: blocksIdMap[toBlock.toBlockId],
condition: toBlock.condition || null,
})),
});
}
if (template.inputExamples.length > 0) {
await client.workflowInputExample.createMany({
data: template.inputExamples.map((inputExample) => ({
workflowId: workflow.id,
title: inputExample.title,
input: JSON.stringify(inputExample.input),
})),
});
}
return workflow.id;
}
async function getTemplate(items: WorkflowDto[], variables: WorkflowVariable[], title = "Workflows"): Promise<WorkflowsTemplateDto> {
const workflows: WorkflowTemplateDto[] = [];
items.forEach((workflow) => {
let toBlocks: { fromBlockId: string; toBlockId: string; condition?: string }[] = [];
const item: WorkflowTemplateDto = {
name: workflow.name,
description: workflow.description || undefined,
blocks: workflow.blocks.map((block) => {
return {
id: block.variableName,
// index: block.index,
type: block.type,
description: block.description || undefined,
input: block.input,
conditionGroups: block.conditionGroups?.map((conditionGroup) => ({
type: conditionGroup.type,
case: block.type === "switch" ? `case${conditionGroup.index + 1}` : undefined,
conditions: conditionGroup.conditions.map((condition) => ({
variable: condition.variable,
operator: condition.operator,
value: condition.value,
})),
})),
};
}),
toBlocks,
inputExamples: workflow.inputExamples.map((inputExample) => ({
title: inputExample.title,
input: inputExample.input,
})),
};
const blocksById = new Map(workflow.blocks.map((block) => [block.id, block]));
workflow.blocks.forEach((block) => {
block.toBlocks.forEach(({ id, toBlockId, condition }) => {
const toBlock = blocksById.get(toBlockId);
if (!toBlock) {
throw new Error(`Block ${id} not found`);
}
toBlocks.push({
fromBlockId: block.variableName,
toBlockId: toBlock.variableName,
condition: condition || undefined,
});
});
});
item.toBlocks = toBlocks;
workflows.push(item);
});
return {
title,
workflows,
variables: variables.map((variable) => ({ name: variable.name, value: variable.value })),
};
}
export default {
importWorkflows,
getTemplate,
};
@@ -0,0 +1,63 @@
import Ajv from "ajv";
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function validate(input: any, schema: any): string | null {
const schemaError = validateJsonSchema(schema);
if (schemaError) {
return schemaError;
}
const validate = new Ajv().compile(schema);
const valid = validate(input);
if (!valid && validate.errors) {
return validate.errors
.map((f) => {
if (f.propertyName) {
return `${f.propertyName}: ${f.message}`;
}
if (f.instancePath) {
return `${f.instancePath}: ${f.message}`;
}
return f.message;
})
.join("\n");
}
return null;
}
function parseJsonSchema(input: unknown): { schema: unknown | null; error: string | null } {
if (typeof input === "string") {
if (input.trim().length === 0) {
return { schema: null, error: null };
}
try {
const schema = JSON.parse(input);
return { schema, error: validateJsonSchema(schema) };
} catch (error) {
return { schema: null, error: getErrorMessage(error) };
}
}
if (input === null || input === undefined) {
return { schema: null, error: null };
}
return { schema: input, error: validateJsonSchema(input) };
}
function validateJsonSchema(schema: unknown): string | null {
try {
new Ajv().compile(schema as any);
return null;
} catch (error) {
return getErrorMessage(error);
}
}
export default {
parseJsonSchema,
validate,
validateJsonSchema,
};
@@ -0,0 +1,80 @@
import Handlebars from "handlebars";
export const REDACTED_WORKFLOW_CREDENTIAL = "[REDACTED_CREDENTIAL]";
const CREDENTIAL_REFERENCE_PATTERN = /\{\{\{?[^}]*\$credentials(?:\b|\.|\[)/u;
export function containsWorkflowCredentialReference(value: unknown): boolean {
return typeof value === "string" && CREDENTIAL_REFERENCE_PATTERN.test(value);
}
export function redactWorkflowCredentials<T>(value: T, credentials: unknown): T {
const credentialValues = collectCredentialValues(credentials);
if (credentialValues.length === 0) {
return value;
}
return redactValue(value, credentialValues, new WeakSet<object>()) as T;
}
function collectCredentialValues(credentials: unknown): string[] {
const values = new Set<string>();
const visit = (current: unknown) => {
if (typeof current === "string" && current.length > 0) {
values.add(current);
return;
}
if (!current || typeof current !== "object") {
return;
}
if (Array.isArray(current)) {
current.forEach(visit);
return;
}
Object.values(current as Record<string, unknown>).forEach(visit);
};
visit(credentials);
return [...values].sort((a, b) => b.length - a.length);
}
function redactValue(value: unknown, credentialValues: string[], seen: WeakSet<object>): unknown {
if (typeof value === "string") {
return credentialValues.reduce((redacted, credentialValue) => redacted.split(credentialValue).join(REDACTED_WORKFLOW_CREDENTIAL), value);
}
if (!value || typeof value !== "object") {
return value;
}
if (seen.has(value)) {
return value;
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => redactValue(item, credentialValues, seen));
}
if (value instanceof Date) {
return value;
}
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, redactValue(item, credentialValues, seen)]));
}
export function parseVariable(value: string | null, workflowContext: { [key: string]: any }) {
if (!value) {
return null;
}
// let context = contexts.workflowContext;
// if (value.match(/{{\s*\$vars\./)) {
// context = contexts.workflowVariablesContext;
// } else if (value.match(/{{\s*\$credentials\./)) {
// context = contexts.workflowCredentialsContext;
// }
try {
const compiled = Handlebars.compile(value);
let result = compiled(workflowContext);
if (result.toString() === "[object Object]") {
result = JSON.stringify(result);
}
return result;
} catch (e: any) {
return "Error parsing string: " + e.message;
}
}
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
const mocks = vi.hoisted(() => ({
rowsApi: {
create: vi.fn(),
update: vi.fn(),
del: vi.fn(),
get: vi.fn(),
},
getEntity: vi.fn(),
getRowPropertiesFromJson: vi.fn(),
getApiFormat: vi.fn(),
}));
vi.mock("~/utils/api/.server/RowsApi", () => ({
RowsApi: mocks.rowsApi,
}));
vi.mock("~/modules/rows/repositories/EntitiesSingleton", () => ({
default: { getEntity: mocks.getEntity },
}));
vi.mock("~/utils/helpers/ApiHelper", () => ({
default: {
getRowPropertiesFromJson: mocks.getRowPropertiesFromJson,
getApiFormat: mocks.getApiFormat,
},
}));
vi.mock("~/modules/emails/services/EmailService.server", () => ({
default: { send: vi.fn() },
}));
import WorkflowBlockProService from "./WorkflowBlockProService";
const entity = { id: "entity-1", name: "contact", slug: "contacts", title: "Contacts" };
const session = { tenantId: "tenant-1", userId: "user-1" };
const rowValues = { dynamicProperties: [{ property: { name: "email" }, value: "hello@example.com" }] };
function block(overrides: Partial<WorkflowBlockDto>): WorkflowBlockDto {
return {
id: "block-1",
index: 0,
type: "rowCreate",
variableName: "row",
description: "",
input: {},
isTrigger: false,
isBlock: true,
conditionGroups: [],
toBlocks: [{ id: "edge-1", toBlockId: "next", condition: null }],
...overrides,
} as WorkflowBlockDto;
}
function params(workflowBlock: WorkflowBlockDto) {
return {
block: workflowBlock,
fromBlock: null,
workflow: { id: "workflow-1", blocks: [workflowBlock] },
workflowContext: { $vars: {} },
workflowExecutionId: "execution-1",
session,
} as never;
}
describe("WorkflowBlockProService row blocks", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getEntity.mockResolvedValue(entity);
mocks.getRowPropertiesFromJson.mockReturnValue(rowValues);
mocks.getApiFormat.mockImplementation((_entity, row) => ({ id: row.id, formatted: true }));
mocks.rowsApi.create.mockResolvedValue({ id: "row-created" });
mocks.rowsApi.update.mockResolvedValue({ id: "row-updated" });
mocks.rowsApi.del.mockResolvedValue(undefined);
mocks.rowsApi.get.mockResolvedValue({ item: { id: "row-1", email: "old@example.com" } });
});
test("passes workflow event context to rowCreate mutations", async () => {
const result = await WorkflowBlockProService.executeBlock(
params(
block({
type: "rowCreate",
input: { entity: "contact", row: JSON.stringify({ email: "hello@example.com" }) },
})
)
);
expect(mocks.rowsApi.create).toHaveBeenCalledWith({
entity,
tenantId: "tenant-1",
userId: "user-1",
rowValues,
eventContext: { source: "workflow" },
});
expect(result.output).toEqual({ row: { id: "row-created", formatted: true } });
});
test("passes workflow event context to rowUpdate mutations", async () => {
await WorkflowBlockProService.executeBlock(
params(
block({
type: "rowUpdate",
input: { entity: "contact", id: "row-1", data: JSON.stringify({ email: "new@example.com" }) },
})
)
);
expect(mocks.rowsApi.get).toHaveBeenCalledWith("row-1", {
entity,
tenantId: "tenant-1",
userId: "user-1",
});
expect(mocks.rowsApi.update).toHaveBeenCalledWith("row-1", {
entity,
tenantId: "tenant-1",
userId: "user-1",
rowValues,
eventContext: { source: "workflow" },
});
});
test("passes workflow event context to rowDelete mutations", async () => {
await WorkflowBlockProService.executeBlock(
params(
block({
type: "rowDelete",
input: { entity: "contact", id: "row-1" },
})
)
);
expect(mocks.rowsApi.del).toHaveBeenCalledWith("row-1", {
entity,
tenantId: "tenant-1",
userId: "user-1",
eventContext: { source: "workflow" },
});
});
});
@@ -0,0 +1,245 @@
import { RowsApi } from "~/utils/api/.server/RowsApi";
import { BlockExecutionParamsDto } from "../../dtos/BlockExecutionParamsDto";
import { BlockExecutionResultDto } from "../../dtos/BlockExecutionResultDto";
import WorkflowsValidationService from "../WorkflowsValidationService";
import { parseVariable } from "../WorkflowsVariablesService";
import ApiHelper from "~/utils/helpers/ApiHelper";
import EntitiesSingleton from "~/modules/rows/repositories/EntitiesSingleton";
import EmailService from "~/modules/emails/services/EmailService.server";
import { WorkflowBlockType } from "../../dtos/WorkflowBlockTypes";
export const PRO_WORKFLOW_BLOCK_TYPES = ["waitForInput", "rowGet", "rowCreate", "rowUpdate", "rowDelete", "email"] satisfies WorkflowBlockType[];
async function executeBlock(args: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
switch (args.block.type) {
case "waitForInput":
return await executeWaitForInputBlock(args);
case "rowGet":
return await executeRowGetBlock(args);
case "rowCreate":
return await executeRowCreateBlock(args);
case "rowUpdate":
return await executeRowUpdateBlock(args);
case "rowDelete":
return await executeRowDeleteBlock(args);
case "email":
return await executeEmailBlock(args);
default:
throw new Error("Block type not implemented: " + args.block.type);
}
}
async function executeWaitForInputBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
let input = workflowContext["$params"].input;
if (!input) {
input = {};
}
return {
output: { input },
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
async function executeRowGetBlock({ block, workflowContext, session }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const translated = parseVariable(JSON.stringify(block.input, null, 2), workflowContext)!;
const inputData = JSON.parse(translated);
const entityName = inputData.entity;
const id = inputData.id;
const error = WorkflowsValidationService.validate(
{ entity: entityName, id },
{
type: "object",
properties: {
entity: { type: "string" },
id: { type: "string" },
},
required: ["entity", "id"],
}
);
if (error) {
return { output: null, error, throwsError: true, toBlockIds: [] };
}
const entity = await EntitiesSingleton.getEntity({ name: entityName });
const existing = await RowsApi.get(id, {
entity,
tenantId: session.tenantId,
userId: session.userId ?? undefined,
}).catch(() => {
return null;
});
return {
output: {
row: !existing ? null : ApiHelper.getApiFormat(entity, existing.item),
},
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
async function executeRowCreateBlock({ block, workflowContext, session }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const translated = parseVariable(JSON.stringify(block.input, null, 2), workflowContext)!;
const inputData = JSON.parse(translated);
const entityName = inputData.entity;
const row = JSON.parse(inputData.row);
const error = WorkflowsValidationService.validate(
{ entity: entityName, row },
{
type: "object",
properties: {
entity: { type: "string" },
row: { type: "object" },
},
required: ["entity", "row"],
}
);
if (error) {
return { output: null, error, throwsError: true, toBlockIds: [] };
}
const entity = await EntitiesSingleton.getEntity({ name: entityName });
const rowValues = ApiHelper.getRowPropertiesFromJson(undefined, entity, row);
const created = await RowsApi.create({
entity,
tenantId: session.tenantId,
userId: session.userId ?? undefined,
rowValues,
eventContext: { source: "workflow" },
});
return {
output: {
row: ApiHelper.getApiFormat(entity, created),
},
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
async function executeRowUpdateBlock({ block, workflowContext, session }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const translated = parseVariable(JSON.stringify(block.input, null, 2), workflowContext)!;
const inputData = JSON.parse(translated);
const entityName = inputData.entity;
const id = inputData.id;
const data = JSON.parse(inputData.data);
const error = WorkflowsValidationService.validate(
{ entity: entityName, id, data },
{
type: "object",
properties: {
entity: { type: "string" },
id: { type: "string" },
data: { type: "object" },
},
required: ["entity", "id", "data"],
}
);
if (error) {
return { output: null, error, throwsError: true, toBlockIds: [] };
}
const entity = await EntitiesSingleton.getEntity({ name: entityName });
const existing = await RowsApi.get(id, {
entity,
tenantId: session.tenantId,
userId: session.userId ?? undefined,
});
const rowValues = ApiHelper.getRowPropertiesFromJson(undefined, entity, data, existing.item);
const updated = await RowsApi.update(id, {
entity,
tenantId: session.tenantId,
userId: session.userId ?? undefined,
rowValues,
eventContext: { source: "workflow" },
});
return {
output: {
row: ApiHelper.getApiFormat(entity, updated),
},
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
async function executeRowDeleteBlock({ block, workflowContext, session }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const translated = parseVariable(JSON.stringify(block.input, null, 2), workflowContext)!;
const inputData = JSON.parse(translated);
const entityName = inputData.entity;
const id = inputData.id;
const error = WorkflowsValidationService.validate(
{ entity: entityName, id },
{
type: "object",
properties: {
entity: { type: "string" },
id: { type: "string" },
},
required: ["entity", "id"],
}
);
if (error) {
return { output: null, error, throwsError: true, toBlockIds: [] };
}
const entity = await EntitiesSingleton.getEntity({ name: entityName });
try {
const existing = await RowsApi.get(id, {
entity,
tenantId: session.tenantId,
userId: session.userId ?? undefined,
});
await RowsApi.del(id, {
entity,
tenantId: session.tenantId,
userId: session.userId ?? undefined,
eventContext: { source: "workflow" },
});
return {
output: {
row: ApiHelper.getApiFormat(entity, existing.item),
},
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
} catch (e: any) {
return { output: null, error: e.message, throwsError: true, toBlockIds: block.toBlocks.map((f) => f.toBlockId) };
}
}
async function executeEmailBlock({ block, workflowContext, session }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const provider = parseVariable(block.input.provider, workflowContext);
const to = parseVariable(block.input.to, workflowContext)!;
const subject = parseVariable(block.input.subject, workflowContext)!;
const body = parseVariable(block.input.body, workflowContext)!;
if (!provider || !to || !subject || !body) {
return {
output: null,
error: "Missing email data: provider, to, subject, body",
throwsError: true,
toBlockIds: [],
};
}
if (provider !== "mailjet") {
return {
output: null,
error: "Invalid email provider: " + provider,
throwsError: true,
toBlockIds: [],
};
}
const sent = await EmailService.send({
provider,
data: { to, subject, body },
});
return {
output: { sent },
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
export default {
executeBlock,
};
@@ -0,0 +1,547 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import type { WorkflowDto } from "../../dtos/WorkflowDto";
type WorkflowBlockServiceMocks = {
createBlockExecution: ReturnType<typeof vi.fn>;
updateBlockExecution: ReturnType<typeof vi.fn>;
getWorkflowExecution: ReturnType<typeof vi.fn>;
emit: ReturnType<typeof vi.fn>;
};
function getMocks(): WorkflowBlockServiceMocks {
const store = globalThis as typeof globalThis & { __workflowBlockServiceTestMocks__?: WorkflowBlockServiceMocks };
store.__workflowBlockServiceTestMocks__ ??= {
createBlockExecution: vi.fn(),
updateBlockExecution: vi.fn(),
getWorkflowExecution: vi.fn(),
emit: vi.fn(),
};
return store.__workflowBlockServiceTestMocks__;
}
vi.mock("~/utils/db.server", () => ({
db: {
workflowBlockExecution: {
create: getMocks().createBlockExecution,
update: getMocks().updateBlockExecution,
},
},
}));
vi.mock("../../db/workflowExecutions.db.server", () => ({
getWorkflowExecution: getMocks().getWorkflowExecution,
}));
vi.mock("~/utils/stream/emitter.server", () => ({
emitter: { emit: getMocks().emit },
}));
import WorkflowBlockService from "./WorkflowBlockService";
const mocks = getMocks();
function block(overrides: Partial<WorkflowBlockDto>): WorkflowBlockDto {
return {
id: "block",
index: 0,
type: "doNothing",
variableName: "block",
description: "",
input: {},
isTrigger: false,
isBlock: true,
conditionGroups: [],
toBlocks: [],
...overrides,
} as WorkflowBlockDto;
}
function workflow(blocks: WorkflowBlockDto[]): WorkflowDto {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: { id: "tenant_1", name: "Acme", slug: "acme" },
name: "Workflow",
description: "",
status: "live",
createdAt: new Date("2026-05-08T10:00:00.000Z"),
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
appliesToAllTenants: false,
blocks,
inputExamples: [],
_count: { executions: 0 },
};
}
const session = { tenantId: "tenant_1", userId: "user_1" };
const publicHttpFetchOptions = (fetchImpl: typeof fetch) => ({
fetchImpl,
addressResolver: async () => ["93.184.216.34"],
});
describe("WorkflowBlockService.execute", () => {
beforeEach(() => {
vi.clearAllMocks();
let id = 0;
mocks.createBlockExecution.mockImplementation(async (args) => ({ id: `block_run_${++id}`, ...args.data }));
mocks.updateBlockExecution.mockResolvedValue({});
mocks.getWorkflowExecution.mockResolvedValue(null);
});
test("executes a doNothing chain and records each block run", async () => {
const first = block({ id: "first", variableName: "first", toBlocks: [{ id: "edge_1", toBlockId: "second", condition: null }] });
const second = block({ id: "second", variableName: "second" });
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {} },
workflowExecutionId: "execution_1",
workflow: workflow([first, second]),
block: first,
fromBlock: null,
session,
});
expect(result.status).toBe("success");
expect(result.waitingBlockId).toBeNull();
expect(mocks.createBlockExecution).toHaveBeenCalledTimes(2);
expect(mocks.createBlockExecution).toHaveBeenNthCalledWith(2, expect.objectContaining({ data: expect.objectContaining({ workflowBlockId: "second" }) }));
});
test("stops before executing waitForInput and returns waiting state", async () => {
const first = block({ id: "first", variableName: "first", toBlocks: [{ id: "edge_1", toBlockId: "wait", condition: null }] });
const wait = block({ id: "wait", type: "waitForInput", variableName: "wait" });
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {} },
workflowExecutionId: "execution_1",
workflow: workflow([first, wait]),
block: first,
fromBlock: null,
session,
});
expect(result.status).toBe("waitingBlock");
expect(result.waitingBlockId).toBe("wait");
expect(mocks.createBlockExecution).toHaveBeenCalledTimes(1);
});
test("executes variable blocks into the workflow variable context", async () => {
const variable = block({
id: "var",
type: "variable",
variableName: "var",
input: { variableName: "answer", value: "{{input}}" },
});
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {}, input: "42" },
workflowExecutionId: "execution_1",
workflow: workflow([variable]),
block: variable,
fromBlock: null,
session,
});
expect(result.workflowContext.$vars.answer).toBe("42");
expect(result.workflowContext.var).toBe("42");
});
test("rejects variable blocks that would persist workflow credentials", async () => {
const variable = block({
id: "var",
type: "variable",
variableName: "var",
input: { variableName: "secretCopy", value: "token={{ $credentials.apiToken }}" },
});
await expect(
WorkflowBlockService.execute({
workflowContext: { $vars: {}, $credentials: { apiToken: "secret" } },
workflowExecutionId: "execution_1",
workflow: { ...workflow([variable]), $credentials: ["apiToken"] },
block: variable,
fromBlock: null,
session,
})
).rejects.toThrow("Variable blocks cannot persist workflow credentials.");
expect(mocks.updateBlockExecution).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "error",
error: "Variable blocks cannot persist workflow credentials.",
}),
})
);
});
test("rejects handlebar variants that would persist workflow credentials", async () => {
for (const value of ["token={{{ $credentials.apiToken }}}", 'token={{lookup $credentials "apiToken"}}', "{{#with $credentials}}{{apiToken}}{{/with}}"]) {
const variable = block({
id: "var",
type: "variable",
variableName: "var",
input: { variableName: "secretCopy", value },
});
await expect(
WorkflowBlockService.execute({
workflowContext: { $vars: {}, $credentials: { apiToken: "secret" } },
workflowExecutionId: "execution_1",
workflow: { ...workflow([variable]), $credentials: ["apiToken"] },
block: variable,
fromBlock: null,
session,
})
).rejects.toThrow("Variable blocks cannot persist workflow credentials.");
}
});
test("redacts workflow credentials from log outputs without writing runtime logs", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
const log = block({
id: "log",
type: "log",
variableName: "log",
input: { message: "token={{$credentials.apiToken}}" },
});
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {}, $credentials: { apiToken: "sk_live_secret" } },
workflowExecutionId: "execution_1",
workflow: { ...workflow([log]), $credentials: ["apiToken"] },
block: log,
fromBlock: null,
session,
});
expect(result.workflowContext.log).toEqual({ message: "token=[REDACTED_CREDENTIAL]" });
expect(logSpy).not.toHaveBeenCalled();
expect(JSON.stringify(mocks.updateBlockExecution.mock.calls)).not.toContain("sk_live_secret");
logSpy.mockRestore();
});
test("routes if blocks to the matching branch", async () => {
const ifBlock = block({
id: "if",
type: "if",
variableName: "condition",
conditionGroups: [{ index: 0, type: "AND", conditions: [{ index: 0, variable: "{{flag}}", operator: "=", value: "yes" }] }],
toBlocks: [
{ id: "edge_true", toBlockId: "true_block", condition: "true" },
{ id: "edge_false", toBlockId: "false_block", condition: "false" },
],
});
const trueBlock = block({ id: "true_block", variableName: "trueBlock" });
const falseBlock = block({ id: "false_block", variableName: "falseBlock" });
await WorkflowBlockService.execute({
workflowContext: { $vars: {}, flag: "yes" },
workflowExecutionId: "execution_1",
workflow: workflow([ifBlock, trueBlock, falseBlock]),
block: ifBlock,
fromBlock: null,
session,
});
expect(mocks.createBlockExecution).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ workflowBlockId: "true_block" }) }));
expect(mocks.createBlockExecution).not.toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ workflowBlockId: "false_block" }) }));
});
test("routes switch blocks to the default branch when no case matches", async () => {
const switchBlock = block({
id: "switch",
type: "switch",
variableName: "switchResult",
conditionGroups: [{ index: 0, type: "AND", conditions: [{ index: 0, variable: "{{status}}", operator: "=", value: "active" }] }],
toBlocks: [
{ id: "edge_case", toBlockId: "case_block", condition: "case1" },
{ id: "edge_default", toBlockId: "default_block", condition: "default" },
],
});
const caseBlock = block({ id: "case_block", variableName: "caseBlock" });
const defaultBlock = block({ id: "default_block", variableName: "defaultBlock" });
await WorkflowBlockService.execute({
workflowContext: { $vars: {}, status: "inactive" },
workflowExecutionId: "execution_1",
workflow: workflow([switchBlock, caseBlock, defaultBlock]),
block: switchBlock,
fromBlock: null,
session,
});
expect(mocks.createBlockExecution).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ workflowBlockId: "default_block" }) }));
expect(mocks.createBlockExecution).not.toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ workflowBlockId: "case_block" }) }));
});
test("validates event blocks and returns event params", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
const event = block({ id: "event", type: "event", variableName: "event", input: { event: "row.created" } });
const input = { data: { id: "row_1", title: "Row 1", row: {}, entity: {} } };
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {}, $params: input },
workflowExecutionId: "execution_1",
workflow: workflow([event]),
block: event,
fromBlock: null,
session,
});
expect(result.workflowContext.event).toEqual(input);
expect(logSpy).not.toHaveBeenCalled();
logSpy.mockRestore();
});
test("executes http request blocks with guarded redirects and non-json responses", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 307, headers: { location: "https://api.example.com/ok" } }))
.mockResolvedValueOnce(new Response("created", { status: 201, headers: { "content-type": "text/plain" } }));
const httpRequest = block({
id: "http",
type: "httpRequest",
variableName: "http",
input: {
url: "https://api.example.com/start",
method: "POST",
body: '{"name":"Acme"}',
headers: '{"content-type":"application/json"}',
},
});
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {} },
workflowExecutionId: "execution_1",
workflow: workflow([httpRequest]),
block: httpRequest,
fromBlock: null,
session,
httpFetchOptions: publicHttpFetchOptions(fetchMock as typeof fetch),
});
expect(result.workflowContext.http).toEqual({ statusCode: 201, body: "created", error: null, truncated: false });
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.example.com/start",
expect.objectContaining({ method: "POST", redirect: "manual", signal: expect.any(AbortSignal) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.example.com/ok",
expect.objectContaining({ method: "POST", redirect: "manual", signal: expect.any(AbortSignal) })
);
});
test("marks oversized http response bodies as truncated", async () => {
const fetchMock = vi.fn(async () => new Response("a".repeat(20_000), { status: 200, headers: { "content-type": "text/plain" } }));
const httpRequest = block({
id: "http",
type: "httpRequest",
variableName: "http",
input: { url: "https://api.example.com/large", method: "GET" },
});
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {} },
workflowExecutionId: "execution_1",
workflow: workflow([httpRequest]),
block: httpRequest,
fromBlock: null,
session,
httpFetchOptions: publicHttpFetchOptions(fetchMock as typeof fetch),
});
expect(result.workflowContext.http).toEqual({
statusCode: 200,
body: "a".repeat(16_384),
error: null,
truncated: true,
});
});
test("keeps credentials available for http requests but redacts echoed secrets from persisted output", async () => {
const fetchMock = vi.fn(
async () => new Response(JSON.stringify({ echoed: "sk_live_secret" }), { status: 200, headers: { "content-type": "application/json" } })
);
const httpRequest = block({
id: "http",
type: "httpRequest",
variableName: "http",
input: {
url: "https://api.example.com/echo",
method: "POST",
body: "token={{$credentials.apiToken}}",
headers: '{"authorization":"Bearer {{$credentials.apiToken}}"}',
},
});
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {}, $credentials: { apiToken: "sk_live_secret" } },
workflowExecutionId: "execution_1",
workflow: { ...workflow([httpRequest]), $credentials: ["apiToken"] },
block: httpRequest,
fromBlock: null,
session,
httpFetchOptions: publicHttpFetchOptions(fetchMock as typeof fetch),
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/echo",
expect.objectContaining({
body: "token=sk_live_secret",
headers: { authorization: "Bearer sk_live_secret" },
})
);
expect(result.workflowContext.http.body).toEqual({ echoed: "sk_live_secret" });
expect(mocks.updateBlockExecution).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
output: JSON.stringify({ statusCode: 200, body: { echoed: "[REDACTED_CREDENTIAL]" }, error: null, truncated: false }),
}),
})
);
});
test("rejects private http request targets without fetching", async () => {
const fetchMock = vi.fn();
const httpRequest = block({
id: "http",
type: "httpRequest",
variableName: "http",
input: { url: "http://127.0.0.1/metadata", method: "GET" },
});
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {} },
workflowExecutionId: "execution_1",
workflow: workflow([httpRequest]),
block: httpRequest,
fromBlock: null,
session,
httpFetchOptions: publicHttpFetchOptions(fetchMock as typeof fetch),
});
expect(fetchMock).not.toHaveBeenCalled();
expect(result.workflowContext.http.statusCode).toBe(0);
expect(result.workflowContext.http.error).toBe("Outbound HTTP endpoint host is not allowed.");
expect(mocks.updateBlockExecution).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: "error" }) }));
});
test("rejects http request redirects to private targets without following them", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 302, headers: { location: "http://10.0.0.1/metadata" } }));
const httpRequest = block({
id: "http",
type: "httpRequest",
variableName: "http",
input: { url: "https://api.example.com/start", method: "GET" },
});
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {} },
workflowExecutionId: "execution_1",
workflow: workflow([httpRequest]),
block: httpRequest,
fromBlock: null,
session,
httpFetchOptions: publicHttpFetchOptions(fetchMock as typeof fetch),
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.workflowContext.http.error).toBe("Outbound HTTP endpoint host is not allowed.");
});
test("stores http request 4xx responses and throws only when configured", async () => {
const nonThrowingFetch = vi.fn(
async () => new Response(JSON.stringify({ message: "Nope" }), { status: 404, statusText: "Not Found", headers: { "content-type": "application/json" } })
);
const nonThrowingRequest = block({
id: "http",
type: "httpRequest",
variableName: "http",
input: { url: "https://api.example.com/missing", method: "GET", throwsError: false },
});
const result = await WorkflowBlockService.execute({
workflowContext: { $vars: {} },
workflowExecutionId: "execution_1",
workflow: workflow([nonThrowingRequest]),
block: nonThrowingRequest,
fromBlock: null,
session,
httpFetchOptions: publicHttpFetchOptions(nonThrowingFetch as typeof fetch),
});
expect(result.workflowContext.http).toEqual({ statusCode: 404, body: { message: "Nope" }, error: "Not Found", truncated: false });
expect(mocks.updateBlockExecution).toHaveBeenLastCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: "success" }) }));
vi.clearAllMocks();
mocks.createBlockExecution.mockResolvedValueOnce({ id: "block_run_throwing" });
mocks.updateBlockExecution.mockResolvedValue({});
mocks.getWorkflowExecution.mockResolvedValue(null);
const throwingFetch = vi.fn(
async () => new Response(JSON.stringify({ message: "Nope" }), { status: 400, statusText: "Bad Request", headers: { "content-type": "application/json" } })
);
const httpRequest = block({
id: "http",
type: "httpRequest",
variableName: "http",
input: { url: "https://api.example.com/fail", method: "GET", throwsError: true },
});
await expect(
WorkflowBlockService.execute({
workflowContext: { $vars: {} },
workflowExecutionId: "execution_1",
workflow: workflow([httpRequest]),
block: httpRequest,
fromBlock: null,
session,
httpFetchOptions: publicHttpFetchOptions(throwingFetch as typeof fetch),
})
).rejects.toThrow("Bad Request");
});
test("records invalid manual validation schemas as controlled block errors", async () => {
const manual = block({
id: "manual",
type: "manual",
variableName: "manual",
input: { validation: "{not-json" },
});
await expect(
WorkflowBlockService.execute({
workflowContext: { $vars: {}, $params: {} },
workflowExecutionId: "execution_1",
workflow: workflow([manual]),
block: manual,
fromBlock: null,
session,
})
).rejects.toThrow(/Expected property name|JSON/u);
expect(mocks.updateBlockExecution).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "error",
error: expect.any(String),
}),
})
);
});
test("rejects cycles before recursively re-entering a block", async () => {
const first = block({ id: "first", variableName: "first", toBlocks: [{ id: "edge_1", toBlockId: "second", condition: null }] });
const second = block({ id: "second", variableName: "second", toBlocks: [{ id: "edge_2", toBlockId: "first", condition: null }] });
await expect(
WorkflowBlockService.execute({
workflowContext: { $vars: {} },
workflowExecutionId: "execution_1",
workflow: workflow([first, second]),
block: first,
fromBlock: null,
session,
})
).rejects.toThrow("Workflow execution cycle detected at block first");
});
});
@@ -0,0 +1,500 @@
import { db } from "~/utils/db.server";
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import { WorkflowDto } from "../../dtos/WorkflowDto";
import { WorkflowBlockType, WorkflowBlockTypes } from "../../dtos/WorkflowBlockTypes";
import { containsWorkflowCredentialReference, parseVariable, redactWorkflowCredentials } from "../WorkflowsVariablesService";
import WorkflowsConditionsService from "../WorkflowsConditionsService";
import WorkflowConditionUtils from "../../helpers/WorkflowConditionUtils";
import { emitter } from "~/utils/stream/emitter.server";
import { BlockExecutionResultDto } from "../../dtos/BlockExecutionResultDto";
import WorkflowUtils from "../../helpers/WorkflowUtils";
import { BlockExecutionParamsDto } from "../../dtos/BlockExecutionParamsDto";
import WorkflowsBlocksProService from "./WorkflowBlockProService";
import { WorkflowStatus } from "../../dtos/WorkflowStatus";
import { getWorkflowExecution } from "../../db/workflowExecutions.db.server";
import WorkflowExecutionUtils from "../../helpers/WorkflowExecutionUtils";
import WorkflowsValidationService from "../WorkflowsValidationService";
import { fetchOutboundHttpWithRedirects, readLimitedResponseText, type OutboundHttpFetchOptions } from "~/utils/outboundHttp.server";
const MAX_WORKFLOW_EXECUTION_DEPTH = 200;
const WORKFLOW_HTTP_RESPONSE_BODY_LIMIT_BYTES = 16_384;
export const CORE_WORKFLOW_BLOCK_TYPES = [
"manual",
"doNothing",
"log",
"httpRequest",
"if",
"switch",
"alertUser",
"iterator",
"variable",
"event",
] satisfies WorkflowBlockType[];
async function execute({
workflowContext,
workflowExecutionId,
workflow,
block,
fromBlock,
session,
executionPath = [],
httpFetchOptions,
}: {
workflowContext: { [key: string]: any };
workflowExecutionId: string;
workflow: WorkflowDto;
block: WorkflowBlockDto;
fromBlock: WorkflowBlockDto | null;
session: {
tenantId: string | null;
userId: string | null;
};
executionPath?: string[];
httpFetchOptions?: OutboundHttpFetchOptions;
}): Promise<{
status: WorkflowStatus;
workflowContext: { [key: string]: any };
waitingBlockId: string | null;
}> {
if (!WorkflowUtils.canRun(workflow)) {
throw new Error("Workflow is not ready to run");
}
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === block.type);
if (!workflowBlock) {
throw new Error("Invalid workflow block type: " + block.type);
}
if (executionPath.includes(block.id)) {
throw new Error(`Workflow execution cycle detected at block ${block.id}`);
}
if (executionPath.length >= MAX_WORKFLOW_EXECUTION_DEPTH) {
throw new Error(`Workflow execution exceeded maximum depth of ${MAX_WORKFLOW_EXECUTION_DEPTH}`);
}
const nextExecutionPath = [...executionPath, block.id];
let blockExecution = await db.workflowBlockExecution.create({
data: {
workflowExecutionId,
workflowBlockId: block.id,
fromWorkflowBlockId: fromBlock ? fromBlock.id : null,
status: "pending",
startedAt: new Date(),
input: block.input ? JSON.stringify(block.input) : null,
output: null,
endedAt: null,
duration: null,
error: null,
},
});
const blockStartTime = performance.now();
let result: BlockExecutionResultDto | null = {
output: null,
toBlockIds: [],
error: null,
};
try {
result = await executeBlock({ block, workflowContext, workflow, workflowExecutionId, session, executionPath: nextExecutionPath, httpFetchOptions });
workflowContext = {
...workflowContext,
[block.variableName]: result.output,
};
} catch (e: any) {
const message = e instanceof Error ? e.message : String(e);
if (!result) {
result = {
error: message,
output: null,
toBlockIds: [],
};
} else {
result.error = message;
}
result.throwsError = true;
}
const blockEndTime = performance.now();
const blockDuration = blockEndTime - blockStartTime;
const redactedOutput = redactWorkflowCredentials(result.output, workflowContext.$credentials);
const redactedError = redactWorkflowCredentials(result.error, workflowContext.$credentials);
await db.workflowBlockExecution.update({
where: { id: blockExecution.id },
data: {
output: redactedOutput ? JSON.stringify(redactedOutput) : null,
status: result.error ? "error" : "success",
duration: Math.round(blockDuration),
endedAt: new Date(),
error: redactedError,
},
});
const updatedExecution = await getWorkflowExecution(workflowExecutionId, session);
if (updatedExecution) {
emitter.emit(`workflowExecutionUpdate:${workflowExecutionId}`, JSON.stringify(WorkflowExecutionUtils.rowToDto(updatedExecution)));
}
if (result.error && result.throwsError) {
throw new Error(redactedError ?? result.error);
}
for (const nextBlockId of result.toBlockIds) {
const toBlock = workflow.blocks.find((f) => f.id === nextBlockId);
if (toBlock) {
if (toBlock.type === "waitForInput") {
return {
status: "waitingBlock",
workflowContext: workflowContext,
waitingBlockId: toBlock.id,
};
}
const result = await execute({
workflowContext,
workflowExecutionId,
workflow,
block: toBlock,
fromBlock: block,
session,
executionPath: nextExecutionPath,
httpFetchOptions,
});
workflowContext = {
...workflowContext,
...result.workflowContext,
};
if (result.status === "waitingBlock") {
return result;
}
}
}
return {
status: result.error ? "error" : "success",
workflowContext: workflowContext,
waitingBlockId: null,
};
}
async function executeBlock(args: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
switch (args.block.type) {
case "manual":
return await executeManualBlock(args);
case "doNothing":
return { output: null, toBlockIds: args.block.toBlocks.map((f) => f.toBlockId) };
case "log":
return await executeLogBlock(args);
case "httpRequest":
return await executeHttpRequestBlock(args);
case "if":
return await executeIfBlock(args);
case "switch":
return await executeSwitchBlock(args);
case "alertUser":
return await executeAlertUserBlock(args);
case "iterator":
return await executeIteratorBlock(args);
case "variable":
return await executeVariableBlock(args);
case "event":
return await executeEventBlock(args);
default:
return await WorkflowsBlocksProService.executeBlock(args);
}
}
async function executeManualBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
if (block.input.validation) {
const parsed = WorkflowsValidationService.parseJsonSchema(block.input.validation);
if (parsed.error) {
return { output: null, error: parsed.error, throwsError: true, toBlockIds: [] };
}
if (parsed.schema === null) {
return {
output: null,
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
const error = WorkflowsValidationService.validate(workflowContext["$params"], parsed.schema);
if (error) {
return { output: null, error, throwsError: true, toBlockIds: [] };
}
block.input.validation = undefined;
}
return {
output: null,
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
async function executeLogBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const message = redactWorkflowCredentials(parseVariable(block.input.message, workflowContext), workflowContext.$credentials);
return {
output: {
message,
},
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
async function executeHttpRequestBlock({ block, workflowContext, httpFetchOptions }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const result: BlockExecutionResultDto = {
output: {
statusCode: 0,
body: null,
error: null,
truncated: false,
},
error: null,
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
const config = {
url: parseVariable(block.input.url, workflowContext),
method: parseVariable(block.input.method, workflowContext),
body: parseVariable(block.input.body, workflowContext),
headers: parseVariable(block.input.headers, workflowContext),
throwsError: block.input.throwsError,
};
if (!config.url) {
throw new Error("URL is required");
}
if (!config.method) {
throw new Error("Method is required");
}
try {
const response = await fetchOutboundHttpWithRedirects(
config.url,
{
method: config.method,
body: config.body,
headers: parseHttpRequestHeaders(config.headers),
},
httpFetchOptions
);
const responseText = await readLimitedResponseText(response, WORKFLOW_HTTP_RESPONSE_BODY_LIMIT_BYTES);
result.output.statusCode = response.status;
result.output.body = parseHttpResponseBody(responseText.body, response.headers.get("content-type"));
result.output.truncated = responseText.truncated;
if (result.output.statusCode >= 400) {
result.output.error = response.statusText;
result.error = config.throwsError ? response.statusText : null;
result.throwsError = config.throwsError;
}
} catch (e: any) {
const error = e.message;
result.error = error;
result.output.error = error;
result.throwsError = config.throwsError;
}
return result;
}
function parseHttpRequestHeaders(headers: string | Record<string, string> | null | undefined): HeadersInit | undefined {
if (!headers) {
return undefined;
}
if (typeof headers !== "string") {
return headers;
}
const parsed = JSON.parse(headers);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Headers must be a JSON object");
}
return parsed as Record<string, string>;
}
function parseHttpResponseBody(body: string, contentType: string | null): unknown {
if (!body) {
return null;
}
if (contentType?.toLowerCase().includes("application/json")) {
try {
return JSON.parse(body);
} catch {
return body;
}
}
return body;
}
async function executeIfBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const trueBlockId = block.toBlocks.find((f) => f.condition === "true")?.toBlockId;
const falseBlockId = block.toBlocks.find((f) => f.condition === "false")?.toBlockId;
if (!trueBlockId) {
throw new Error("Missing true block");
}
if (!falseBlockId) {
throw new Error("Missing false block");
}
if (block.conditionGroups.length !== 1) {
throw new Error("If block must have exactly one condition group");
}
const result = WorkflowsConditionsService.validateGroups({ conditionGroup: block.conditionGroups[0], workflowContext });
return {
output: {
condition: result,
expression: block.conditionGroups[0].conditions.map((f) => WorkflowConditionUtils.getConditionString(f)).join(` ${block.conditionGroups[0].type} `),
},
toBlockIds: result ? [trueBlockId] : [falseBlockId],
};
}
async function executeSwitchBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
for (const conditionGroup of block.conditionGroups) {
const result = WorkflowsConditionsService.validateGroups({ conditionGroup, workflowContext });
if (result) {
const condition = `case${conditionGroup.index + 1}`;
const foundBlockId = block.toBlocks.find((f) => f.condition === condition)?.toBlockId;
if (!foundBlockId) {
throw new Error(`[${block.variableName}] Missing block for condition: ${condition}`);
}
return {
output: {
condition,
expression: conditionGroup.conditions.map((f) => WorkflowConditionUtils.getConditionString(f)).join(` ${conditionGroup.type} `),
},
toBlockIds: [foundBlockId],
};
}
}
const defaultBlockId = block.toBlocks.find((f) => f.condition === "default")?.toBlockId;
if (!defaultBlockId) {
throw new Error("Missing default block");
}
return {
output: {
condition: "default",
},
toBlockIds: [defaultBlockId],
};
}
async function executeAlertUserBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const message = redactWorkflowCredentials(parseVariable(block.input.message, workflowContext), workflowContext.$credentials);
return {
output: {
type: block.input.type ?? "success",
message,
},
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
async function executeIteratorBlock({
block,
workflowContext,
workflow,
session,
workflowExecutionId,
executionPath,
httpFetchOptions,
}: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const loopNextBlockId = block.toBlocks.find((f) => f.condition === "loopNext")?.toBlockId;
const loopEndBlockId = block.toBlocks.find((f) => f.condition === "loopEnd")?.toBlockId;
const loopNextBlock = workflow.blocks.find((f) => f.id === loopNextBlockId);
if (!loopNextBlock) {
throw new Error(`[${block.variableName}] Missing loop next block`);
}
if (!loopEndBlockId) {
throw new Error(`[${block.variableName}] Missing loop end block`);
}
let variableName = block.input.variableName;
// remove {{ and }} with regex
variableName = variableName.replace(/{{/g, "").replace(/}}/g, "");
function getNestedProperty(obj: any, path: string) {
const keys = path.replace(/^/, "").split(".");
return getNestedPropertyHelper(obj, keys);
}
function getNestedPropertyHelper(currentObject: any, remainingKeys: string[]): any {
if (remainingKeys.length === 0) {
return currentObject;
}
const currentKey = remainingKeys[0];
if (currentObject !== null && typeof currentObject === "object" && currentKey in currentObject) {
return getNestedPropertyHelper(currentObject[currentKey], remainingKeys.slice(1));
} else {
throw new Error(`Property not found for key: ${currentKey}`);
}
}
const array = getNestedProperty(workflowContext, variableName);
if (!array) {
throw new Error(`Variable "${variableName}" not found`);
}
if (!Array.isArray(array)) {
throw new Error(`Variable "${variableName}" is not an array`);
}
for (let index = 0; index < array.length; index++) {
const item = array[index];
workflowContext = {
...workflowContext,
iterator: { index, item, isFirst: index === 0, isLast: index === array.length - 1 },
};
await execute({ workflowContext, workflowExecutionId, workflow, block: loopNextBlock, fromBlock: block, session, executionPath, httpFetchOptions });
}
return {
output: array.length,
toBlockIds: [loopEndBlockId],
};
}
async function executeVariableBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const variableName = block.input.variableName;
if (containsWorkflowCredentialReference(block.input.value)) {
return {
output: null,
error: "Variable blocks cannot persist workflow credentials.",
throwsError: true,
toBlockIds: [],
};
}
const value = parseVariable(block.input.value, workflowContext);
workflowContext.$vars[variableName] = value;
return {
output: value,
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
async function executeEventBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
const error = WorkflowsValidationService.validate(workflowContext["$params"], {
type: "object",
properties: { data: { type: "object" } },
required: ["data"],
});
if (error) {
return { output: null, error, throwsError: true, toBlockIds: [] };
}
if (["row.created", "row.updated", "row.deleted"].includes(block.input.event)) {
const error = WorkflowsValidationService.validate(workflowContext["$params"].data, {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
row: { type: "object" },
entity: { type: "object" },
},
required: ["id", "title", "row", "entity"],
});
if (error) {
return { output: null, error, throwsError: true, toBlockIds: [] };
}
}
block.input.validation = undefined;
return {
output: workflowContext.$params,
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
};
}
export default {
execute,
};