// The editor's draft of the notification router's flows: pure edits that // each return a new flow or say why not. // // The controller validates a whole document on save and is the authority; // these edits only refuse what could never be saved, so the canvas can // turn a bad drop away with a reason instead of drawing a flow the save // would reject. The tree rules mirror `Flow::validate` in swarm-wire. import type { Flow, FlowNode, FlowsView } from "./FlowTypes.js"; export interface Limits { maxNodes: number; maxDepth: number; maxFlows: number; } // What the controller enforces today. Its `schema.limits`, when it sends // one, replaces these. export const DEFAULT_LIMITS: Limits = { maxNodes: 64, maxDepth: 16, maxFlows: 100, }; export type Edit = { ok: true; flow: Flow } | { ok: false; reason: string }; const ID = /^[A-Za-z0-9_-]{1,64}$/; export function isId(id: string): boolean { return ID.test(id); } function refuse(reason: string): Edit { return { ok: false, reason }; } export function childrenOf(flow: Flow, id: string): string[] { return flow.edges.filter((e) => e.from === id).map((e) => e.to); } export function parentOf(flow: Flow, id: string): string | undefined { return flow.edges.find((e) => e.to === id)?.from; } // Edges from the source down to `id`; 0 for the source. export function depthOf(flow: Flow, id: string): number { let depth = 0; const seen = new Set([id]); for (let at = parentOf(flow, id); at !== undefined; at = parentOf(flow, at)) { // A cycle never leaves the editor, but a loop here would hang the tab. if (seen.has(at)) break; seen.add(at); depth += 1; } return depth; } // `id` and every node below it. export function subtreeIds(flow: Flow, id: string): string[] { const out: string[] = []; const seen = new Set(); const stack = [id]; while (stack.length > 0) { const at = stack.pop() as string; if (seen.has(at)) continue; seen.add(at); out.push(at); stack.push(...childrenOf(flow, at)); } return out; } // How many levels lie below `id`: 0 for a leaf. export function heightBelow(flow: Flow, id: string): number { let height = 0; let level = [id]; const seen = new Set(level); for (;;) { const next = level .flatMap((n) => childrenOf(flow, n)) .filter((n) => !seen.has(n)); if (next.length === 0) return height; next.forEach((n) => seen.add(n)); height += 1; level = next; } } function node(flow: Flow, id: string): FlowNode | undefined { return flow.nodes.find((n) => n.id === id); } // A node id not yet in `flow`, such as `match2`. export function newNodeId(flow: Flow, prefix: string): string { const taken = new Set(flow.nodes.map((n) => n.id)); for (let n = 1; ; n += 1) { if (!taken.has(`${prefix}${n}`)) return `${prefix}${n}`; } } // A node of `kind` with the fields a user is asked to fill in left empty. export function blankNode( flow: Flow, kind: "match" | "todo", ): Extract { const id = newNodeId(flow, kind); return kind === "match" ? { id, kind, when: [] } : { id, kind, to: { field: "assignees" }, op: "upsert", key: "", prefix: false, text: "", }; } // `added` under `parentId`. export function addChild( flow: Flow, parentId: string, added: FlowNode, limits: Limits = DEFAULT_LIMITS, ): Edit { const parent = node(flow, parentId); if (!parent) return refuse("that parent is not in the flow"); if (parent.kind === "todo") return refuse("a todo ends a branch"); if (added.kind === "source") return refuse("a flow has one source"); if (!isId(added.id) || node(flow, added.id)) { return refuse(`${added.id} is not a free node id`); } if (flow.nodes.length >= limits.maxNodes) { return refuse(`a flow holds at most ${limits.maxNodes} nodes`); } if (depthOf(flow, parentId) + 1 > limits.maxDepth) { return refuse(`a flow is at most ${limits.maxDepth} levels deep`); } return { ok: true, flow: { ...flow, nodes: [...flow.nodes, added], edges: [...flow.edges, { from: parentId, to: added.id }], }, }; } // `id` and everything below it taken out. The source stays. export function removeNode(flow: Flow, id: string): Edit { const target = node(flow, id); if (!target) return refuse("that node is not in the flow"); if (target.kind === "source") return refuse("the source cannot be removed"); const gone = new Set(subtreeIds(flow, id)); return { ok: true, flow: { ...flow, nodes: flow.nodes.filter((n) => !gone.has(n.id)), edges: flow.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)), }, }; } // `id`, with its subtree, moved under `parentId`. export function reparent( flow: Flow, id: string, parentId: string, limits: Limits = DEFAULT_LIMITS, ): Edit { const target = node(flow, id); const parent = node(flow, parentId); if (!target || !parent) return refuse("that node is not in the flow"); if (target.kind === "source") return refuse("the source stays the root"); if (parent.kind === "todo") return refuse("a todo ends a branch"); if (subtreeIds(flow, id).includes(parentId)) { return refuse("a node cannot go under itself or its own branch"); } if (depthOf(flow, parentId) + 1 + heightBelow(flow, id) > limits.maxDepth) { return refuse(`a flow is at most ${limits.maxDepth} levels deep`); } return { ok: true, flow: { ...flow, edges: [ ...flow.edges.filter((e) => e.to !== id), { from: parentId, to: id }, ], }, }; } // The node with `next.id` swapped for `next`. A node keeps its place and its // kind-class: the source stays the source, and nothing with children becomes // a todo. export function replaceNode(flow: Flow, next: FlowNode): Edit { const old = node(flow, next.id); if (!old) return refuse("that node is not in the flow"); if ((old.kind === "source") !== (next.kind === "source")) { return refuse( "the source cannot change kind, and no other node can become one", ); } if (next.kind === "todo" && childrenOf(flow, next.id).length > 0) { return refuse("a node with children cannot become a todo"); } return { ok: true, flow: { ...flow, nodes: flow.nodes.map((n) => (n.id === next.id ? next : n)), }, }; } // A flow id made of `name`, not in `taken`: `my-flow`, `my-flow-2`. export function slugId(name: string, taken: Iterable): string { const used = new Set(taken); const base = name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 56) || "flow"; if (!used.has(base)) return base; for (let n = 2; ; n += 1) { if (!used.has(`${base}-${n}`)) return `${base}-${n}`; } } // An empty flow: just a source on issue events. export function newFlow(name: string, taken: Iterable): Flow { return { id: slugId(name, taken), name, enabled: true, nodes: [{ id: "source", kind: "source", events: ["issues"] }], edges: [], }; } // A copy of `flow` (a built-in or a custom one) as a custom flow, on. export function duplicateFlow( flow: Flow, name: string, taken: Iterable, ): Flow { return structuredClone({ ...flow, id: slugId(name, taken), name, enabled: true, }); } // `flows` with `flow` in place of the one of its id, or added last. export function withFlow(flows: Flow[], flow: Flow): Flow[] { return flows.some((f) => f.id === flow.id) ? flows.map((f) => (f.id === flow.id ? flow : f)) : [...flows, flow]; } export function withoutFlow(flows: Flow[], id: string): Flow[] { return flows.filter((f) => f.id !== id); } // Whether the draft differs from what the controller sent. export function isDirty( view: FlowsView, flows: Flow[], disabledBuiltins: string[], ): boolean { return ( JSON.stringify(flows) !== JSON.stringify(view.flows) || JSON.stringify(disabledBuiltins) !== JSON.stringify(view.disabled_builtins) ); } // The `PUT /api/notify/flows` request for the draft: the whole document, with // the revision it was read at. export function saveRequest( view: FlowsView, flows: Flow[], disabledBuiltins: string[], ): { method: "PUT"; headers: Record; body: string } { return { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ revision: view.revision, disabled_builtins: disabledBuiltins, flows, }), }; } export type SaveOutcome = "saved" | "conflict" | "invalid" | "error"; // What a save's HTTP status means to the editor: 409 is a stale revision (read // again, keep the draft or drop it), 400 is a document the controller refuses // and says why. export function saveOutcome(status: number): SaveOutcome { if (status >= 200 && status < 300) return "saved"; if (status === 409) return "conflict"; if (status === 400) return "invalid"; return "error"; }