Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
+141
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
+245
@@ -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,
|
||||
};
|
||||
+547
@@ -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");
|
||||
});
|
||||
});
|
||||
+500
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user