| // --------------------------------------------------------------------------- | |
| // 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<string, string> { | |
| const out = new Map<string, string>(); | |
| 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<T extends { id: string }>( | |
| 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<string, GraphNode[]>(); | |
| 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 `<select>` whose value matches no `<option>` renders the first one, and the next | |
| * Save then writes a schedule nobody chose β the defect this codebase has already | |
| * paid for twice ([[cg-condition-builder-items]]; the two `(not in this database)` | |
| * options in AutomationDetail exist for the same reason). A quarter-hourly weekday | |
| * cron is a legal schedule the engine runs happily; the editor's job is to leave it | |
| * alone, not to quietly turn it into "every day at 08:00". | |
| * | |
| * (Written without the literal cron string on purpose β a step-slash inside a block | |
| * comment ENDS the comment, and the compiler's 20 cascading errors say nothing about | |
| * the cron.) | |
| */ | |
| export function readCron(cron: string): TriggerShape { | |
| const raw = String(cron || "").trim(); | |
| const daily = DAILY.exec(raw); | |
| if (daily) { | |
| const [, m, h] = daily; | |
| if (Number(h) <= 23 && Number(m) <= 59) return { every: "day", time: `${two(Number(h))}:${two(Number(m))}` }; | |
| } | |
| const weekly = WEEKLY.exec(raw); | |
| if (weekly) { | |
| const [, m, h, d] = weekly; | |
| if (Number(h) <= 23 && Number(m) <= 59) { | |
| return { every: String(Number(d) % 7), time: `${two(Number(h))}:${two(Number(m))}` }; | |
| } | |
| } | |
| return { every: "custom", time: "" }; | |
| } | |
| /** The controls β a cron string. `custom` keeps whatever the user typed, untouched. */ | |
| export function writeCron(every: string, time: string, custom: string): string { | |
| if (every === "custom") return String(custom || "").trim(); | |
| const [h, m] = String(time || "06:00").split(":"); | |
| const hh = Math.min(23, Math.max(0, Number(h) || 0)); | |
| const mm = Math.min(59, Math.max(0, Number(m) || 0)); | |
| return every === "day" ? `${mm} ${hh} * * *` : `${mm} ${hh} * * ${Number(every) || 0}`; | |
| } | |
| /** The weekday options, in the order a week is read rather than in cron's 0-first order. */ | |
| export const WEEKDAYS: { value: string; label: string }[] = [ | |
| { value: "1", label: "Every Monday" }, | |
| { value: "2", label: "Every Tuesday" }, | |
| { value: "3", label: "Every Wednesday" }, | |
| { value: "4", label: "Every Thursday" }, | |
| { value: "5", label: "Every Friday" }, | |
| { value: "6", label: "Every Saturday" }, | |
| { value: "0", label: "Every Sunday" }, | |
| ]; | |
| // ββ WAVE 25 Β· C2: THE GROUPED TRIGGER PICKER'S ARITHMETIC βββββββββββββββββββββββββββββββββββ | |
| // | |
| // β WHY THIS IS HERE AND NOT IN THE COMPONENT. Both functions below are the kind that go subtly | |
| // wrong and stay wrong INVISIBLY β a group silently sorted first, a stored trigger silently | |
| // rendering as nothing β and this file exists precisely so that class can be executed under node | |
| // by `verify_steps.py` instead of eyeballed in a screenshot. It is also D-58's minimum: the | |
| // builder's largest surface has had no gate but `tsc` and eyes, and the picker's grouped and | |
| // SELECTED states are the two things a screenshot is worst at proving (the selected row looks | |
| // identical to an unselected one at a glance, which is the whole scar). | |
| // | |
| // β NOTHING HERE INVENTS A GROUP, A CAPTION OR AN ORDER β the file header's law, applied. Every | |
| // one of the three rides `TriggerOption` from `routes_automation._triggers_vocab`. | |
| /** One connector's rows inside the Connector group (Gmail / Webhooks / Scraper / TikTok). */ | |
| export interface TriggerSubGroup { | |
| key: string; | |
| label: string; | |
| rows: TriggerOption[]; | |
| } | |
| /** One rendered section of the picker: a caption, the rows under it, and any connector nests. */ | |
| export interface TriggerGroup { | |
| /** The server's group id. `""` when the server sent none β one unlabelled section. */ | |
| key: string; | |
| /** The server's caption, PRINTED verbatim. `""` renders no heading, never an invented one. */ | |
| label: string; | |
| order: number; | |
| /** Rows that hang directly off the group head. */ | |
| rows: TriggerOption[]; | |
| /** Connector nests, in first-appearance order. Empty for Time and Database. */ | |
| sub: TriggerSubGroup[]; | |
| } | |
| /** | |
| * The picker's sections: CATEGORY first (Time / Database / Connector), then the trigger, with | |
| * connector rows nested under their own product name. | |
| * | |
| * β ORDER COMES FROM `groupOrder`, AND AN ABSENT ONE SORTS **LAST**. This is the | |
| * `ACTION_GROUP_ORDER` rule and it is the safe direction: an unordered group is one this client | |
| * has no server opinion about, and putting it at the TOP of the menu would present the | |
| * unclassified thing as the primary answer. Ties keep FIRST-APPEARANCE order, which is what the | |
| * old `<select>` 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<string, TriggerGroup>(); | |
| 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<string, ActionGroup>(); | |
| 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 `<select>` WHOSE `value` MATCHES NO `<option>` RENDERS THE FIRST ONE. A custom listbox | |
| * fails DIFFERENTLY and worse: it renders nothing selected, looks unconfigured, and the next | |
| * patch writes the blank over a real stored key. This repo has paid for the first form twice | |
| * (`enters_view`'s view id, and the condition builder's operator) and the control it replaces | |
| * carried an explicit guard for it. | |
| * | |
| * So: a stored key the server did not offer comes back as a SYNTHETIC row saying so, never as | |
| * `null`. `null` means one thing only β nothing is picked yet β which is also what R14 gates the | |
| * Configuration section on, so conflating the two would hide a configured automation's settings. | |
| */ | |
| export function selectedTrigger( | |
| options: TriggerOption[], | |
| key: string | |
| ): TriggerOption | null { | |
| if (!key) return null; | |
| const hit = options.find((t) => t.key === key); | |
| if (hit) return hit; | |
| // β `ready: false` and NO `detail`: this row is not something the reader can act on, and | |
| // inventing a description for a trigger this deployment does not offer would be the client | |
| // speaking for a server that said nothing. | |
| return { key, label: `${key} (not offered here)`, ready: false }; | |
| } | |