loopable / web /src /automation /steps.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
0adeb0c verified
Raw
History Blame Contribute Delete
20.5 kB
// ---------------------------------------------------------------------------
// 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 };
}