// --------------------------------------------------------------------------- // automation/steps.ts — the arithmetic behind the numbered Steps (owner ruling R9). // // Pure functions, deliberately separate from the component: both of the things in // here are the kind that go subtly wrong (a step number, a cron round-trip) and // stay wrong invisibly, so they are written where they can be read on their own. // // ⚠ NOTHING HERE INVENTS A STEP. The node list, its order and its `col` all come // from the server's `engine.graph()` — the same module that RUNS the steps — and // this file only counts them (contract C6). A client-side second idea of what the // steps are is the exact drift the split was built to prevent; the rationale is // written at AutomationCreate.tsx:1-13. // --------------------------------------------------------------------------- import type { Action, ActionCatalogRow, Branch, Cond, GraphNode, TriggerOption } from "./automationApi"; /** * The branches of an If / then, TOLERATING the pre-wave-24 `{cond, actions}` shape on READ. * * ⚠ THIS MIRRORS `automation_engine.group_branches` AND THE DUPLICATION IS DELIBERATE, which is * worth defending because this file's own header forbids a second idea of the server's data. It * is a READ-SIDE TOLERANCE, not a second validator: it never decides what is legal, and every * write still goes to `clean_actions`, which returns the one canonical shape. The alternative is * strictly worse — a group stored in the old shape would render with no branches and no children, * so a live automation's actions would simply VANISH from the screen while running perfectly. * A migration the server performs on write and the client tolerates on read is the pair that has * no window; trusting `branches` alone has one, for exactly as long as a definition sits unsaved. */ export function groupBranches(a: Action): Branch[] { const cfg = (a?.config || {}) as { branches?: Branch[]; cond?: Cond | null; actions?: Action[]; }; if (Array.isArray(cfg.branches)) return cfg.branches; // The pre-wave-24 shape, read as the one branch the engine migrates it to. if (Array.isArray(cfg.actions)) return [{ id: "b1", label: "A", cond: cfg.cond ?? null, actions: cfg.actions }]; return []; } /** * R8's STEP NUMBERS for a whole flow, keyed by action id. * * THE RULING, and each clause is a line here: * · the TRIGGER is unnumbered — it is not in this map at all, and the card carries the word * "Trigger" instead. (This REVERSES wave 21's R9, "Step 1 is always the Trigger".) * · the first action is 1, the second 2, … * · an If / then occupies ONE number. Its branches are alternatives, not later steps, so they * consume no numbers of their own. * · a branch's children are numbered RELATIVE TO THEIR BRANCH — the fork at step 2 gives every * one of its branches a `2.1`, a `2.2`, and so on. Two branches therefore both contain a * `2.1`, which is correct rather than colliding: they are alternatives in lettered lanes and * only one of them ever runs (the engine takes the first matching branch and breaks). * * ⛔ IT DOES NOT LETTER THE BRANCHES, and that is a change from the contract text. `clean_actions` * assigns the letter server-side (`_branch_letter`, and "Otherwise" for the null-cond last leg), * preserving any label the client sends — so the letter rides on `Branch.label` and a second * lettering here would be exactly the client-copy-of-a-server-vocabulary this wave keeps deleting. * The client sends an EMPTY label and renders what comes back, which also means letters re-flow * correctly when a branch is deleted instead of going stale. */ export function numberActions(actions: Action[]): Map { const out = new Map(); const walk = (list: Action[], prefix: string) => { (list || []).forEach((a, i) => { const n = prefix ? `${prefix}.${i + 1}` : String(i + 1); out.set(a.id, n); if (a.kind === "group") for (const br of groupBranches(a)) walk(br.actions || [], n); }); }; walk(actions || [], ""); return out; } export interface Step { node: GraphNode; /** The number the card carries. */ n: number; /** True when this node shares its position with the one before it — an ALTERNATIVE, not a next. */ alt: boolean; } /** * Number the server's nodes for display. * * ⭐ THE NUMBER IS `col + 1`, NOT the array index, and that is the honest one. It exists * because a graph can FORK: two nodes at the same `col` are the SAME position in the flow * reached two ways, and numbering them 4 and 5 would state a sequence that never happens. * They share a number and the second is marked `alt`, so the list reads "either of these" * rather than "then". * ⚠ 2026-08-09 (wave 28, R5): the fork this was WRITTEN for is gone — `capture_paid` and * `capture_anon` were the paid-rung/anonymous-ladder pair, and the ladder is retired, so * today's graph is linear. The `col`-based rule STAYS because it is about forks in general, * not about those two nodes; this note records that no shipped graph currently exercises it. * * "Step 1 is always the Trigger" (R9) therefore falls out of the payload — the * trigger is the node at `col: 0` — instead of being asserted by this client. If * the engine ever emits something else first, the UI shows what the engine does. */ export function numberSteps(nodes: GraphNode[]): Step[] { return (nodes || []).map((node, i) => { const col = typeof node.col === "number" ? node.col : i; const before = i > 0 ? nodes[i - 1] : null; const beforeCol = before && typeof before.col === "number" ? before.col : -1; return { node, n: col + 1, alt: i > 0 && beforeCol === col }; }); } /** * Move `dragId` into `dropId`'s position within one list (owner item 11). * * ⛔ IT RETURNS `null` RATHER THAN THE LIST UNCHANGED, and the distinction is what keeps a * pointless PATCH off the wire: "these two are the same card", "one of them is not in this list" * and "here is your new order" are three different answers, and collapsing the first two into * "the order you already had" would have the caller write the flow back to the server on every * aborted drag. The caller writes only when this says something happened. * * THE CARD LANDS EXACTLY WHERE THE TARGET WAS, in both directions — splice out, then splice in at * the target's ORIGINAL index. Dragging down, the target shifts up; dragging up, it shifts down. * (The tempting version — insert at the target's index *after* the removal — is off by one when * dragging downwards and drops the card one slot short of where the pointer is, which reads as * the drag not having worked.) */ export function reorderList( list: T[], dragId: string, dropId: string ): T[] | null { if (!dragId || !dropId || dragId === dropId) return null; const src = list || []; const from = src.findIndex((x) => x.id === dragId); const to = src.findIndex((x) => x.id === dropId); if (from < 0 || to < 0) return null; const next = [...src]; const [moved] = next.splice(from, 1); if (!moved) return null; next.splice(to, 0, moved); return next; } /** One Properties section under "How this fetches": a panel, and every node that opens it. */ export interface PanelGroup { /** The panel key — what the caller passes to `renderNodeBody`. */ panel: string; /** Every machine node whose `panel` is this one, in the server's own order. */ nodes: GraphNode[]; } /** * The machine steps, GROUPED BY PANEL (item 5, contract C-CFG). * * ⛔ GROUPED, NEVER MAPPED ONE-TO-ONE, and the difference is a defect rather than a nicety. * `panel` is MANY-TO-ONE over nodes, which is easy to miss because the old surface hid it: you * clicked ONE card and got ONE panel. `discover_instagram` gives both of its nodes * `panel: "find"`, and `field_instagram` gives every capture node `panel: "capture"`. * ⚠ 2026-08-09 (wave 28, R5/C3): this used to name `capture`, `capture_paid`, `capture_anon` * AND `capture_metrics` with a line citation — all four ids are DELETED and the citation was * stale, which is worse than vague, because a stale line number reads as authoritative. The * grouping rule is unchanged: the capture nodes are now `capture_posts` + `capture_comments`. * So a body rendered per NODE would print the capture prose once per node, and — the one that * actually * loses work — mount the 21-toggle discovery filter TWICE against a single `preds` array, two * editors writing one piece of state where whichever blurred last silently wins. * * The detail panel has always joined on `panel` and never on node id (`AutomationDetail`'s own * note says so); this keeps that law now that the panels are no longer reached by clicking a card. * * ORDER IS THE SERVER'S — first appearance wins, so the sections read in flow order rather than * in whatever order a Map or a sort would produce. */ export function groupByPanel(nodes: GraphNode[]): PanelGroup[] { const order: string[] = []; const byPanel = new Map(); for (const n of nodes || []) { // `panel` falls back to the id exactly as `graph()`'s own `node()` does (`panel or nid`), // so a node the engine ships without one still gets its own section instead of joining // every other panel-less node under the empty string. const key = n.panel || n.id; const seen = byPanel.get(key); if (seen) seen.push(n); else { byPanel.set(key, [n]); order.push(key); } } return order.map((panel) => ({ panel, nodes: byPanel.get(panel) || [] })); } /** How the Step-1 card is currently set: what it repeats on, and at what time. */ export interface TriggerShape { /** `day` = every day · `0`..`6` = that weekday (cron numbering, 0 = Sunday) · `custom` = a cron only. */ every: string; /** `HH:MM`, empty when the cron does not express a single fire time. */ time: string; } const DAILY = /^(\d{1,2}) (\d{1,2}) \* \* \*$/; const WEEKLY = /^(\d{1,2}) (\d{1,2}) \* \* ([0-7])$/; function two(n: number): string { return String(n).padStart(2, "0"); } /** * A stored cron → the controls that can express it. * * ⛔ ANYTHING THIS CANNOT EXPRESS COMES BACK AS `custom`, NEVER AS THE FIRST OPTION. * A `` used as its only ordering and is still the honest fallback for a server that * sends no `groupOrder` at all. * * ⛔ AND NOTHING IS DROPPED. Every option in, every option out — `planned` and `ready:false` rows * included, because a picker that hides them answers "can it run when an email arrives?" with * silence, and the question then gets asked again (R9's precedent). Faded is a wall the server * enforces at `clean_trigger`; a shorter list is a lie. */ export function groupTriggers(options: TriggerOption[]): TriggerGroup[] { const out: TriggerGroup[] = []; const byKey = new Map(); for (const t of options) { const key = t.group || ""; let g = byKey.get(key); if (!g) { g = { key, // ⚠ `groupLabel` OR NOTHING. Falling back to `t.group` would print the id ("connector") // as a heading — a client inventing the server's wording, one `||` at a time. label: t.groupLabel || "", order: typeof t.groupOrder === "number" ? t.groupOrder : Number.MAX_SAFE_INTEGER, rows: [], sub: [], }; byKey.set(key, g); out.push(g); } const c = t.connector; if (c && c.key) { let s = g.sub.find((x) => x.key === c.key); if (!s) { s = { key: c.key, label: c.label || c.key, rows: [] }; g.sub.push(s); } s.rows.push(t); } else { g.rows.push(t); } } // A STABLE sort by order alone: `Array.prototype.sort` is stable in every runtime this ships to // (ES2019 mandates it), so equal orders keep the first-appearance sequence above. return out.sort((a, b) => a.order - b.order); } /** One rendered section of the ACTION menu: the same shape `TriggerGroup` has, over the * catalog's rows. */ export interface ActionGroup { key: string; order: number; rows: ActionCatalogRow[]; sub: { key: string; label: string; rows: ActionCatalogRow[] }[]; } /** * ⭐ WAVE 27 · OWNER ITEM 33 / CONTRACT C4 — the action menu, with connector rows NESTED. * * ⛔ THE SAME ARITHMETIC AS `groupTriggers`, AND THAT IS THE POINT. The trigger picker has * nested connectors since wave 24; the action menu is the identical question about a different * catalog, and C4 asks for the nest to be "driven by server data, name-for-name". So this reads * `row.connector` exactly as `groupTriggers` reads `t.connector`, sorts by `groupOrder` with an * absent one LAST, and holds no list of connector names of its own. * * ⚠ INERT UNTIL THE SERVER STAMPS THE ROWS, by construction rather than by a flag: with no * `connector` on any row, every row lands in `rows` and `sub` is empty — which renders the flat * menu that ships today. That is what lets this half land before B's, instead of two sessions * having to meet in the middle. * * ⛔ THE GROUP KEY IS ALSO ITS LABEL here, unlike triggers. The action catalog's `group` IS the * printed caption ("Web action", "Database", "Connected", "Advanced logic") — the server sends * no separate `groupLabel` for actions — so this returns `key` and the caller prints it. Adding * a `label` member that merely copied `key` would invent a second name for one string. */ export function groupActions(rows: ActionCatalogRow[]): ActionGroup[] { const out: ActionGroup[] = []; const byKey = new Map(); for (const c of rows) { const key = c.group || ""; let g = byKey.get(key); if (!g) { g = { key, order: typeof c.groupOrder === "number" ? c.groupOrder : Number.MAX_SAFE_INTEGER, rows: [], sub: [], }; byKey.set(key, g); out.push(g); } const con = c.connector; if (con && con.key) { let s = g.sub.find((x) => x.key === con.key); if (!s) { // The server's LABEL, or its key when it sent none — never a prettified guess. s = { key: con.key, label: con.label || con.key, rows: [] }; g.sub.push(s); } s.rows.push(c); } else { g.rows.push(c); } } // Stable by order alone (ES2019 mandates a stable sort), so equal orders keep first // appearance — the same rule `groupTriggers` states one function up. return out.sort((a, b) => a.order - b.order); } /** * The option the picker must render as SELECTED — and this function is the scar, in one place. * * ⛔ A `