File size: 20,476 Bytes
bf8519f 0adeb0c bf8519f 0adeb0c bf8519f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 | // ---------------------------------------------------------------------------
// 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 };
}
|