Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: draft edits for the notification flow editor

Pure edits on a flow (add a child, remove a branch, move a branch, replace a
node, new and duplicate flow) that refuse what the controller's tree rules
would, plus the save request and what its status means. Nothing uses them
yet: the editor page that does comes next.
This commit is contained in:
iris 2026-10-24 13:46:03 +02:00
commit d19bd83aa5
2 changed files with 544 additions and 0 deletions

View file

@ -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<string>([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<string>();
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<string>(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<FlowNode, { kind: "match" | "todo" }> {
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>): 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<string>): 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<string>,
): 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<string, string>; 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";
}