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,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,
};