diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts new file mode 100644 index 00000000..5918f401 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts @@ -0,0 +1,309 @@ +// 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"; +} diff --git a/frontend/packages/swarm-ui/test/flow-draft.test.mjs b/frontend/packages/swarm-ui/test/flow-draft.test.mjs new file mode 100644 index 00000000..9dcf985a --- /dev/null +++ b/frontend/packages/swarm-ui/test/flow-draft.test.mjs @@ -0,0 +1,235 @@ +// The notification flow editor's draft edits. Run by `npm test`. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + addChild, + blankNode, + depthOf, + duplicateFlow, + heightBelow, + isDirty, + newFlow, + newNodeId, + removeNode, + reparent, + replaceNode, + saveOutcome, + saveRequest, + slugId, + subtreeIds, + withFlow, + withoutFlow, +} from "../src/pages/notifications/FlowDraft.ts"; + +const todo = (id) => ({ + id, + kind: "todo", + to: { agent: "triage" }, + op: "upsert", + key: "k", + prefix: false, + text: "look", +}); + +// source -> a -> b -> t1, source -> c +const flow = () => ({ + id: "f", + name: "f", + enabled: true, + nodes: [ + { id: "source", kind: "source", events: ["issues"] }, + { id: "a", kind: "match", when: [] }, + { id: "b", kind: "match", when: [] }, + { id: "c", kind: "match", when: [] }, + todo("t1"), + ], + edges: [ + { from: "source", to: "a" }, + { from: "a", to: "b" }, + { from: "b", to: "t1" }, + { from: "source", to: "c" }, + ], +}); + +const ids = (f) => f.nodes.map((n) => n.id).sort(); + +test("depth, height and subtree", () => { + const f = flow(); + assert.equal(depthOf(f, "source"), 0); + assert.equal(depthOf(f, "t1"), 3); + assert.equal(heightBelow(f, "source"), 3); + assert.equal(heightBelow(f, "c"), 0); + assert.deepEqual(subtreeIds(f, "a").sort(), ["a", "b", "t1"]); +}); + +test("a cycle in the data cannot hang the walks", () => { + const f = flow(); + f.edges.push({ from: "t1", to: "a" }); + assert.ok(depthOf(f, "b") < 10); + assert.ok(heightBelow(f, "a") < 10); + assert.ok(subtreeIds(f, "a").length <= f.nodes.length); +}); + +test("addChild adds the node and its edge", () => { + const r = addChild(flow(), "c", todo("t2")); + assert.ok(r.ok); + assert.ok(r.flow.nodes.some((n) => n.id === "t2")); + assert.deepEqual(r.flow.edges.at(-1), { from: "c", to: "t2" }); +}); + +test("addChild refuses what a save would", () => { + const f = flow(); + assert.equal(addChild(f, "t1", todo("t2")).ok, false, "under a todo"); + assert.equal(addChild(f, "nope", todo("t2")).ok, false, "no parent"); + assert.equal(addChild(f, "c", todo("a")).ok, false, "id in use"); + assert.equal(addChild(f, "c", todo("bad id")).ok, false, "bad id"); + assert.equal( + addChild(f, "c", { id: "s2", kind: "source", events: ["issues"] }).ok, + false, + "second source", + ); + const small = { maxNodes: 5, maxDepth: 16, maxFlows: 100 }; + assert.equal(addChild(f, "c", todo("t2"), small).ok, false, "node cap"); + const shallow = { maxNodes: 64, maxDepth: 2, maxFlows: 100 }; + assert.equal(addChild(f, "b", todo("t2"), shallow).ok, false, "depth cap"); + assert.ok(addChild(f, "a", todo("t2"), shallow).ok, "right at the cap"); +}); + +test("removeNode takes the branch and keeps the rest", () => { + const r = removeNode(flow(), "a"); + assert.ok(r.ok); + assert.deepEqual(ids(r.flow), ["c", "source"]); + assert.deepEqual(r.flow.edges, [{ from: "source", to: "c" }]); + assert.equal(removeNode(flow(), "source").ok, false); + assert.equal(removeNode(flow(), "nope").ok, false); +}); + +test("reparent moves a branch and keeps one parent per node", () => { + const r = reparent(flow(), "b", "c"); + assert.ok(r.ok); + assert.equal(r.flow.edges.filter((e) => e.to === "b").length, 1); + assert.ok(r.flow.edges.some((e) => e.from === "c" && e.to === "b")); + assert.ok(r.flow.edges.some((e) => e.from === "b" && e.to === "t1")); + assert.equal(r.flow.nodes.length, 5); +}); + +test("reparent refuses a cycle, a todo parent and the source", () => { + const f = flow(); + assert.equal(reparent(f, "a", "b").ok, false, "under its own child"); + assert.equal(reparent(f, "a", "a").ok, false, "under itself"); + assert.equal(reparent(f, "c", "t1").ok, false, "under a todo"); + assert.equal(reparent(f, "source", "c").ok, false, "the source"); + assert.equal(reparent(f, "nope", "c").ok, false, "unknown node"); +}); + +test("reparent counts the whole branch against the depth cap", () => { + const limits = { maxNodes: 64, maxDepth: 4, maxFlows: 100 }; + // a (1) -> b -> t1 is a branch of height 2; under b's child would be deeper. + const f = flow(); + // c is at depth 1; moving a (height 2) under c puts t1 at depth 4. + assert.ok(reparent(f, "a", "c", limits).ok); + const tight = { ...limits, maxDepth: 3 }; + assert.equal(reparent(f, "a", "c", tight).ok, false); +}); + +test("replaceNode keeps the place and the kind class", () => { + const f = flow(); + const r = replaceNode(f, { + id: "a", + kind: "match", + when: [{ field: "action", equals: "x" }], + }); + assert.ok(r.ok); + assert.equal(r.flow.nodes.find((n) => n.id === "a").when.length, 1); + assert.equal(replaceNode(f, { ...todo("a") }).ok, false, "a has children"); + assert.ok(replaceNode(f, { ...todo("c") }).ok, "a leaf may become a todo"); + assert.equal( + replaceNode(f, { id: "source", kind: "match", when: [] }).ok, + false, + ); + assert.equal( + replaceNode(f, { id: "c", kind: "source", events: ["issues"] }).ok, + false, + ); + assert.equal(replaceNode(f, todo("nope")).ok, false); +}); + +test("new ids do not collide", () => { + const f = flow(); + assert.equal(newNodeId(f, "match"), "match1"); + const r = addChild(f, "c", blankNode(f, "match")); + assert.ok(r.ok); + assert.equal(newNodeId(r.flow, "match"), "match2"); + assert.equal(blankNode(f, "todo").kind, "todo"); +}); + +test("slugId builds an id the controller accepts and avoids taken ones", () => { + assert.equal(slugId("My Flow!", []), "my-flow"); + assert.equal(slugId("My Flow!", ["my-flow"]), "my-flow-2"); + assert.equal(slugId("My Flow!", ["my-flow", "my-flow-2"]), "my-flow-3"); + assert.equal(slugId("!!!", []), "flow"); + assert.ok(/^[A-Za-z0-9_-]{1,64}$/.test(slugId("x".repeat(200), []))); +}); + +test("newFlow is one source; duplicateFlow copies without sharing", () => { + const fresh = newFlow("Triage", ["triage"]); + assert.equal(fresh.id, "triage-2"); + assert.equal(fresh.nodes.length, 1); + assert.equal(fresh.nodes[0].kind, "source"); + assert.ok(fresh.enabled); + + const src = flow(); + const copy = duplicateFlow(src, "f copy", ["f"]); + assert.equal(copy.id, "f-copy"); + assert.equal(copy.nodes.length, src.nodes.length); + copy.nodes[1].when.push({ field: "action", equals: "x" }); + assert.equal(src.nodes[1].when.length, 0, "the original is untouched"); + const off = { ...src, enabled: false }; + assert.ok(duplicateFlow(off, "x", []).enabled, "a copy is on"); +}); + +test("withFlow replaces by id or appends; withoutFlow drops", () => { + const a = { ...flow(), id: "a" }; + const b = { ...flow(), id: "b" }; + assert.deepEqual( + withFlow([a], b).map((f) => f.id), + ["a", "b"], + ); + const renamed = { ...a, name: "renamed" }; + assert.equal(withFlow([a, b], renamed)[0].name, "renamed"); + assert.deepEqual( + withoutFlow([a, b], "a").map((f) => f.id), + ["b"], + ); +}); + +test("isDirty compares the draft with what was read", () => { + const view = { + revision: 3, + disabled_builtins: ["x"], + builtins: [], + flows: [flow()], + }; + assert.equal(isDirty(view, [flow()], ["x"]), false); + assert.equal(isDirty(view, [], ["x"]), true); + assert.equal(isDirty(view, [flow()], []), true); +}); + +test("saveRequest sends the whole document at the revision read", () => { + const view = { revision: 7, disabled_builtins: [], builtins: [], flows: [] }; + const req = saveRequest(view, [flow()], ["b1"]); + assert.equal(req.method, "PUT"); + const body = JSON.parse(req.body); + assert.equal(body.revision, 7); + assert.deepEqual(body.disabled_builtins, ["b1"]); + assert.equal(body.flows[0].id, "f"); + assert.equal("builtins" in body, false, "built-ins are never sent"); +}); + +test("saveOutcome tells a stale revision from a refused document", () => { + assert.equal(saveOutcome(200), "saved"); + assert.equal(saveOutcome(409), "conflict"); + assert.equal(saveOutcome(400), "invalid"); + assert.equal(saveOutcome(503), "error"); + assert.equal(saveOutcome(500), "error"); +});