Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/builder/__tests__/create-flow-request-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, test } from "vitest"
import { createFlowRequest } from "@/features/flows/schema/action"

describe("createFlowRequest", () => {
test("accepts a bare name/folderId body with no content", () => {
const result = createFlowRequest.safeParse({
name: "New flow",
folderId: null,
})

expect(result.success).toBe(true)
})

test("accepts a raw nodes/edges body with no positions", () => {
const result = createFlowRequest.safeParse({
name: "New flow",
folderId: null,
nodes: [{ id: "n1" }, { id: "n2" }],
edges: [{ source: "n1", target: "n2" }],
})

expect(result.success).toBe(true)
})

test("rejects a body combining spec with nodes", () => {
const result = createFlowRequest.safeParse({
name: "New flow",
folderId: null,
spec: {
formatVersion: 1,
name: "Spec flow",
steps: [{ type: "send", text: "Hi" }],
},
nodes: [{ id: "n1" }],
})

expect(result.success).toBe(false)
expect(result.success ? [] : result.error.issues[0].path).toEqual(["spec"])
})

test("rejects edges without nodes", () => {
const result = createFlowRequest.safeParse({
name: "New flow",
folderId: null,
edges: [{ source: "n1", target: "n2" }],
})

expect(result.success).toBe(false)
expect(result.success ? [] : result.error.issues[0].path).toEqual(["edges"])
})

test("rejects publish: true with neither spec nor nodes", () => {
const result = createFlowRequest.safeParse({
name: "New flow",
folderId: null,
publish: true,
})

expect(result.success).toBe(false)
expect(result.success ? [] : result.error.issues[0].path).toEqual([
"publish",
])
})

test("accepts publish: true alongside spec", () => {
const result = createFlowRequest.safeParse({
name: "New flow",
folderId: null,
spec: {
formatVersion: 1,
name: "Spec flow",
steps: [{ type: "send", text: "Hi" }],
},
publish: true,
})

expect(result.success).toBe(true)
})

test("accepts publish: true alongside nodes", () => {
const result = createFlowRequest.safeParse({
name: "New flow",
folderId: null,
nodes: [{ id: "n1" }],
publish: true,
})

expect(result.success).toBe(true)
})
})
129 changes: 128 additions & 1 deletion apps/builder/__tests__/flows-public-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { FlowAuthoringException } from "@chatbotx.io/flow-config"
import {
FlowAuthoringException,
sendMessageNodeDefaultFn,
} from "@chatbotx.io/flow-config"
import { beforeEach, describe, expect, test, vi } from "vitest"

const SPEC_STEP_PATH_PATTERN = /^steps\[/
Expand Down Expand Up @@ -53,6 +56,7 @@ const flowService = {
list: vi.fn(),
findById: vi.fn(),
createDraft: vi.fn(),
createPublished: vi.fn(),
update: vi.fn(),
deleteMany: vi.fn(),
duplicate: vi.fn(),
Expand Down Expand Up @@ -216,6 +220,129 @@ describe("POST /v1/flows", () => {
data: { name: "New flow" },
})
})

test("spec content: createDraft receives a positioned graph and startNodeId; publish is not called", async () => {
flowService.createDraft.mockResolvedValueOnce({ id: "flow-1" })

const response = await procedure.handler?.({
context: { workspace: { id: "workspace-1" } },
input: {
name: "New flow",
folderId: null,
spec: {
formatVersion: 1,
name: "Spec flow",
steps: [{ type: "send", text: "Hi" }],
},
},
})

expect(flowService.createDraft).toHaveBeenCalledTimes(1)
const call = flowService.createDraft.mock.calls[0][0]
expect(call.data).toEqual({ name: "New flow", folderId: null })
expect(call.graph.nodes).toHaveLength(1)
expect(call.graph.nodes[0].position).toEqual({ x: 100, y: 100 })
expect(call.graph.startNodeId).toBe(call.graph.nodes[0].id)
expect(flowVersionService.publish).not.toHaveBeenCalled()
expect(flowService.createPublished).not.toHaveBeenCalled()
// A `{ spec }` graph has no authored node ids to report.
expect(response.nodeIds).toBeUndefined()
})

test("raw graph content: createDraft receives normalized positions/edges and startNodeId", async () => {
flowService.createDraft.mockResolvedValueOnce({ id: "flow-1" })

const response = await procedure.handler?.({
context: { workspace: { id: "workspace-1" } },
input: {
name: "New flow",
folderId: null,
nodes: [
{ id: "n1", type: "sendMessage", data: {} },
{ id: "n2", type: "sendMessage", data: {} },
],
edges: [{ source: "n1", target: "n2" }],
},
})

const call = flowService.createDraft.mock.calls[0][0]
const [nodeN1, nodeN2] = call.graph.nodes
// Authored ids ("n1"/"n2") are request-scoped wiring tokens, remapped to
// fresh internal ids server-side — assert the relationship, not the
// literal authored string.
expect(nodeN1.id).not.toBe("n1")
expect(nodeN2.id).not.toBe("n2")
expect(nodeN1.position).toEqual({ x: 100, y: 100 })
expect(call.graph.edges[0]).toEqual(
expect.objectContaining({
source: nodeN1.id,
target: nodeN2.id,
sourceHandle: nodeN1.id,
targetHandle: nodeN2.id,
}),
)
expect(call.graph.startNodeId).toBe(nodeN1.id)
expect(flowVersionService.publish).not.toHaveBeenCalled()
expect(flowService.createPublished).not.toHaveBeenCalled()
// The response reports the authored → persisted id mapping so the
// caller can edit the graph it just created without a `flows.get`.
expect(response.nodeIds).toEqual({ n1: nodeN1.id, n2: nodeN2.id })
})

test("publish: true validates the graph and creates it atomically via flowService.createPublished, not createDraft + publish", async () => {
flowService.createPublished.mockResolvedValueOnce({ id: "flow-9" })
// Authored id is deliberately non-numeric ("n1") — normalizeAuthoredGraph
// must remap it to an internal createId() id before publishFlowSchema
// validates it, since baseNodeSchema.id requires digits only.
const node = sendMessageNodeDefaultFn({
nodeProps: { id: "n1" },
dataProps: {},
detailProps: {},
})

const response = await procedure.handler?.({
context: { workspace: { id: "workspace-1" } },
input: {
name: "New flow",
folderId: null,
nodes: [node],
edges: [],
publish: true,
},
})

expect(flowService.createDraft).not.toHaveBeenCalled()
expect(flowVersionService.publish).not.toHaveBeenCalled()
expect(flowService.createPublished).toHaveBeenCalledTimes(1)
const createPublishedCall = flowService.createPublished.mock.calls[0][0]
expect(createPublishedCall.workspaceId).toBe("workspace-1")
expect(createPublishedCall.data).toEqual({
name: "New flow",
folderId: null,
})
expect(createPublishedCall.graph.nodes[0].id).not.toBe("n1")
expect(response).toEqual({
id: "flow-9",
nodeIds: { n1: createPublishedCall.graph.nodes[0].id },
})
})

test("publish: true rejects an invalid node before createDraft is called", async () => {
await expect(
procedure.handler?.({
context: { workspace: { id: "workspace-1" } },
input: {
name: "New flow",
folderId: null,
nodes: [{ id: "n1", type: "sendMessage", data: {} }],
edges: [],
publish: true,
},
}),
).rejects.toBeInstanceOf(FlowAuthoringException)

expect(flowService.createDraft).not.toHaveBeenCalled()
})
})

describe("PATCH /v1/flows/{id}", () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/builder/src/features/capabilities/api/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const capabilitiesPublicRouter = {
path: "/v1/capabilities",
summary: "Discover workspace capabilities",
description:
"Returns compact (id + name, plus a couple of decisive fields) lists of the workspace entities an agent needs to reference by id — inboxes, WhatsApp templates, custom/bot fields, tags, AI agents, sequences, and flows — plus the flow-spec DSL's step types and valid wait units/channels. Bot fields are reference data only; they cannot be used as a branch condition's `field` (only custom fields and built-in contact fields can). Use `include` (comma-separated) to narrow the response; omit it for the default set an agent needs to build a flow. Call this before `flows.publish`/`flows.updateDraft`/`flows.validate` so names in a flow spec resolve to real ids instead of guesses.",
"Returns compact (id + name, plus a couple of decisive fields) lists of the workspace entities an agent needs to reference by id — inboxes, WhatsApp templates, custom/bot fields, tags, AI agents, sequences, and flows — plus the flow-spec DSL's step types and valid wait units/channels. Bot fields are reference data only; they cannot be used as a branch condition's `field` (only custom fields and built-in contact fields can). Use `include` (comma-separated) to narrow the response; omit it for the default set an agent needs to build a flow. Call this before `flows.create`/`flows.publish`/`flows.updateDraft`/`flows.validate` so names in a flow spec resolve to real ids instead of guesses.",
tags: ["Capabilities"],
spec: mcpSpec({ visibility: "default", alwaysVisible: true }),
})
Expand All @@ -76,7 +76,7 @@ export const schemasPublicRouter = {
path: "/v1/schemas/flow-spec",
summary: "Get JSON Schema for flow-spec DSL",
description:
"Returns the JSON Schema for the `spec` object accepted by `flows.publish`'s, `flows.updateDraft`'s, and `flows.validate`'s `{ spec }` input — the authoritative reference for every step type's fields. Use `capabilities.get` first to resolve the names (templates, flows, tags, custom fields) a spec references into real ids.",
"Returns the JSON Schema for the `spec` object accepted by `flows.create`'s, `flows.publish`'s, `flows.updateDraft`'s, and `flows.validate`'s `{ spec }` input — the authoritative reference for every step type's fields. Use `capabilities.get` first to resolve the names (templates, flows, tags, custom fields) a spec references into real ids.",
tags: ["Capabilities"],
spec: mcpSpec({ visibility: "default" }),
})
Expand Down
60 changes: 45 additions & 15 deletions apps/builder/src/features/flows/api/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import {
compileAndValidateSpec,
compileSpecToGraph,
} from "../lib/compile-spec-to-graph"
import { resolveFlowGraphInput } from "../lib/resolve-flow-graph-input"
import {
createFlowSchema,
createFlowRequest,
flowSpecRequest,
publishFlowRequest,
publishFlowSchema,
Expand Down Expand Up @@ -97,21 +98,46 @@ export const flowsPublicRouter = {
path: "/v1/flows",
summary: "Create flow",
description:
"Starts a draft flow with its default start node. Use `flows.list` to inspect existing flows first, then call `flows.updateDraft` or `flows.publish` to complete it.",
"Creates a flow. With no `spec`/`nodes` it starts a draft with one default start node. Supply `spec` (flow-spec DSL, see `GET /v1/schemas/flow-spec`) or a raw `nodes`/`edges` graph to seed the draft in the same call — node `position`/`measured`, node ids, and edge ids/handles are all generated server-side (a raw node's `id` is only a request-scoped token for wiring `edges`; the response's `nodeIds` maps each authored id to its persisted id). Add `publish: true` to validate the graph exactly like `flows.publish` and create the flow's first version immediately. Use `flows.list` to inspect existing flows first.",
successStatus: 201,
tags: ["Flows"],
spec: mcpSpec({ visibility: "default" }),
})
.input(createFlowSchema)
.output(z.object({ id: z.string() }))
.input(createFlowRequest)
.output(
z.object({
id: z.string(),
nodeIds: z
.optional(z.record(z.string(), z.string()))
.describe(
"Authored node id → persisted node id, present only when the request sent raw `nodes` (omitted for `spec` input, which has no authored ids).",
),
}),
)
.errors(possibleErrorsOnCreatingResource)
.handler(
async ({ context, input }) =>
await flowService.createDraft({
workspaceId: context.workspace.id,
data: input,
}),
),
.handler(async ({ context, input }) => {
const workspaceId = context.workspace.id
const { name, folderId, spec, nodes, edges, publish } = input
// Compile/validate before any write, so a rejected graph creates no flow row.
const graph = await resolveFlowGraphInput(
{ spec, nodes, edges },
workspaceId,
{ validate: publish === true },
)
const flow =
publish && graph
? await flowService.createPublished({
workspaceId,
data: { name, folderId },
graph,
})
: await flowService.createDraft({
workspaceId,
data: { name, folderId },
graph,
})
return { id: flow.id, nodeIds: graph?.nodeIds }
}),

update: workspaceTokenAuthAPI
.route({
Expand Down Expand Up @@ -211,17 +237,21 @@ export const flowsPublicRouter = {
.input(flowSpecRequest)
.output(publishFlowSchema)
.errors(possibleErrorsOnMutatingResource)
.handler(async ({ context, input }) =>
compileAndValidateSpec(input.spec, context.workspace.id),
),
.handler(async ({ context, input }) => {
const { nodes, edges } = await compileAndValidateSpec(
input.spec,
context.workspace.id,
)
return { nodes, edges }
}),

updateDraft: workspaceTokenAuthAPI
.route({
method: "PUT",
path: "/v1/flows/{id}/draft",
summary: "Update flow draft",
description:
"Overwrites the draft version's nodes/edges in place, without publishing. Accepts either the raw `{ nodes, edges }` graph the builder UI sends, or `{ spec }` compiled server-side into that same graph — draft nodes are not otherwise validated (see `flows.validate` to check a spec before writing it).",
"Overwrites the draft version's nodes/edges in place, without publishing. Accepts either the raw `{ nodes, edges }` graph the builder UI sends, or `{ spec }` compiled server-side into that same graph — draft nodes are not otherwise validated (see `flows.validate` to check a spec before writing it). Unlike `flows.create`, a raw node's `id` is persisted verbatim, not remapped, so it must already be a numeric string (the same format `flows.create`'s `nodeIds` response and `flows.get` return).",
successStatus: 204,
tags: ["Flows"],
spec: mcpSpec({ visibility: "default" }),
Expand Down
27 changes: 18 additions & 9 deletions apps/builder/src/features/flows/lib/compile-spec-to-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,16 @@ async function compileWithContext(
export async function compileSpecToGraph(
spec: FlowSpec,
workspaceId: string,
): Promise<{ nodes: FlowVersionSchema[]; edges: EdgeSchema[] }> {
const { nodes, edges } = await compileWithContext(spec, workspaceId)
return { nodes, edges }
): Promise<{
nodes: FlowVersionSchema[]
edges: EdgeSchema[]
startNodeId: string
}> {
const { nodes, edges, startNodeId } = await compileWithContext(
spec,
workspaceId,
)
return { nodes, edges, startNodeId }
}

/**
Expand Down Expand Up @@ -70,11 +77,13 @@ function mapPublishIssuePath(
export async function compileAndValidateSpec(
spec: FlowSpec,
workspaceId: string,
): Promise<{ nodes: FlowVersionSchema[]; edges: EdgeSchema[] }> {
const { nodes, edges, specPathByNodeId } = await compileWithContext(
spec,
workspaceId,
)
): Promise<{
nodes: FlowVersionSchema[]
edges: EdgeSchema[]
startNodeId: string
}> {
const { nodes, edges, specPathByNodeId, startNodeId } =
await compileWithContext(spec, workspaceId)

const result = publishFlowSchema.safeParse({ nodes, edges })
if (!result.success) {
Expand All @@ -85,5 +94,5 @@ export async function compileAndValidateSpec(
)
}

return result.data
return { ...result.data, startNodeId }
}
Loading