// --------------------------------------------------------------------------- // automation/AutomationTrigger.tsx — what STARTS an automation (contract C3). // // ONE implementation, two frames. The board renders it as the strip above the // lanes; the numbered-steps view renders it inside Step 1's card. It used to be // written inline in `AutomationSteps`, and the moment the board needed the same // controls that was a second copy of the cron round-trip, the tick honesty note // and the next-run sentence — three things that go subtly wrong and stay wrong // invisibly. There is one of each. // // ⛔ THE VOCABULARY IS THE SERVER'S (C3, and this is the rule the wave was // explicit about). The options come from `triggers: [{key, label, ready, needs}]` // on `GET /automations`. There is NO client union over the keys: a union means // the two lists can diverge, and they diverge SILENTLY — the server offers a // trigger the client has no arm for, the client draws nothing, and the product // quietly loses a feature it shipped (the wave-9 silent-drop class). The keys are // `string` here on purpose, and an unrecognised one still renders. // // A NOT-READY TRIGGER IS OFFERED, NOT HIDDEN. `ready: false` plus `needs` // ("connect_gmail") renders as an option you can pick with a next step attached. // R9 set that precedent with the disabled "when a field changes" slot and the // reasoning holds: the question "can it run when an email arrives?" has an // answer, and an option nobody can use yet SAYS it — a missing option says // nothing and gets asked again. // --------------------------------------------------------------------------- import type { OAuthStatus, TickState, TriggerOption } from "./automationApi"; import { WEEKDAYS, readCron, writeCron } from "./steps"; interface Props { /** * The server's trigger list. ABSENT until the engine ships C3 — and absent is * not "no triggers": the face falls back to the two shapes today's server can * actually express, marked below, and stops consulting that fallback the moment * the list rides. */ triggers?: TriggerOption[]; /** The trigger currently stored, as a key. */ current: string; cron: string; enabled: boolean; /** `null` = the server did not report whether schedules fire here (R9's third state). */ tick: TickState | null; nextRunAt?: string; cronPresets: { cron: string; label: string }[]; busy: boolean; /** Picking a trigger PERSISTS — a trigger you have to remember to Save is not a switch. */ onPick: (key: string) => void; onSchedule: (next: { cron: string; enabled: boolean }) => void; /** Free-text cron edits: local only, committed on blur. See the input's note. */ onCronText: (cron: string) => void; /** Whether this USER has connected each provider (C5). `null` = not asked yet. */ oauth: OAuthStatus | null; /** True when the raw cron face is open — a VIEW choice, not a schedule change. */ showCron: boolean; onShowCron: (v: boolean) => void; /** * WAVE 23 — the BUILDER supplies its own "Trigger type" select (Properties → Trigger details), * so this face drops its picker and contributes only the schedule half. It is a prop rather * than a second component because the thing worth having exactly once is what sits BELOW the * picker: the cron round-trip, the preset list and the tick-honesty note, three things that go * wrong invisibly. Two pickers for one fact is the defect this file's header forbids. */ hidePicker?: boolean; /** * WAVE 24, owner item 7 — DOES THE CRON DRIVE THIS TRIGGER? REQUIRED, and it is a fact the * CALLER establishes rather than one this component derives, because deriving it means naming * keys and this file's header forbids a client union over the trigger vocabulary. * * `schedule` is no longer the only answer: `ig_profile_match` watches nothing — it MAKES rows, * on the cron — so C-TRIG gives it the Scheduled-run controls "always". */ schedules: boolean; } /* * ⛔ `FALLBACK_TRIGGERS` IS GONE (D-43, wave 23). It described the only two shapes a stored * definition could express before C3 shipped (manual / on a schedule), and its own note set * the deletion condition: "NEVER consulted once the list arrives; when C3 lands, deleting this * constant changes nothing on screen." C3 has been live for two releases and the vocabulary is * now nine keys plus two planned, so the fallback could only ever have been WRONG — a server * hiccup would have painted a two-option picker that looks authoritative. * * The absent case is a STATE now, not a substitute list: no `triggers` means the surface says * so in one line (below) rather than quietly offering a vocabulary of its own. */ /** The one-line next step a not-ready trigger carries. Server words where there are any. */ function needsLabel(needs: string): string { if (needs === "connect_gmail") return "Connect Gmail"; // An unrecognised `needs` is still SAID, verbatim. A blank next step is how a // not-ready trigger becomes a dead control. return needs.replace(/_/g, " "); } /** * What an option says about itself. THREE states, and they are not the same fact: * ready (nothing), waiting on a connection the USER can make ("needs setting up"), and * declared-but-not-built ("coming soon", R2) which is waiting on us. */ function optionLabel(t: TriggerOption): string { if (t.planned) return `${t.label} — coming soon`; return t.ready === false ? `${t.label} — needs setting up` : t.label; } /** * What the deployment will actually do with a schedule. * * ⛔ THREE ANSWERS, AND ONE OF THEM IS "WE WERE NOT TOLD". A card that said * "schedules won't fire" because a field was missing would be inventing a * measurement, and it would be WRONG on this product's own production: the * external EventBridge tick fires schedules on a deployment whose in-process * scheduler is off ([[aws-automation-cron]]). So `external` says the door is open, * not that the caller is alive — the server can only prove the first. */ function tickNote(tick: TickState | null): { text: string; tone: string } { // ⚠ AN EXPLICIT BOOLEAN OR NOTHING. A payload carrying `"tick": {}` — a // half-shipped field, a serialiser that dropped a false, a version skew — has no // `enabled` at all, and reading `undefined` as "off" printed "schedules won't // fire" as though it had been measured. if (!tick || typeof tick.enabled !== "boolean") { return { text: "Whether this deployment runs schedules is not reported here yet — press Run now if you need it now.", tone: "is-unknown", }; } if (!tick.enabled) { return { text: "Schedules won't fire on this deployment — ask your admin to switch the scheduler on. Run now still works.", tone: "is-off", }; } if (tick.source === "external") { return { text: "Schedules fire when the external scheduler calls in. This deployment does not run them itself.", tone: "is-on", }; } return { text: "This deployment runs schedules itself.", tone: "is-on" }; } export default function AutomationTrigger({ triggers, current, cron, enabled, tick, nextRunAt, cronPresets, busy, onPick, onSchedule, onCronText, oauth, showCron, onShowCron, hidePicker, schedules, }: Props) { // ⛔ THE SERVER'S LIST OR NOTHING (D-43). An empty array is not a fallback trigger to // invent — it is the one honest thing to say, and it is said below. const options = triggers || []; const picked = options.find((t) => t.key === current) || null; /** * ⚠ RENAMED FROM `current === "schedule"` (item 7). The cron face belongs to any trigger the * cron DRIVES, which is now more than one key — and the caller is the only thing entitled to * say which, so this reads the prop rather than the key it used to test. */ const isSchedule = schedules; /** * ⛔⛔ WAVE 32 · T44 (owner item 2) — THE ARM SWITCH IS DELETED, AND ITS OWN NOTE ARGUED FOR * DELETING IT. * * That note (kept below, because the reasoning is still correct and only its conclusion was * wrong) said a switch beside the `schedule` picker *"would be two controls for one fact, which * this component's siblings forbid"* — and then rendered exactly that for every cron-driven * trigger that is not `schedule`, because the rail's own per-automation switch was not counted * as one of the two. It is one: `AutomationSurface`'s `auto-row-switch` writes the SAME * `schedule.enabled`, on every automation, from the row that carries its name. * * Owner, item 2: exactly one on/off control per automation, the one under its name — and the * tell they reported is that the other *"keeps disappearing or appearing"*. That is this * control's render condition read back verbatim: `schedules && current !== "schedule"` shows and * hides it as the trigger changes, so the same automation has one switch or two depending on * what it is triggered by. * * ⛔ THE CONDITION IS NOT WHAT GOES — THE CONTROL IS. Keeping the condition and hiding the * switch leaves a control that exists in one state and not the other, which is the complaint. * Nothing loses the ability to arm: the `schedule` trigger arms by being PICKED (`pickTrigger` * writes `enabled: true`), and every other cron-driven trigger arms from the rail switch, which * is reachable from every surface and does not come and go. * * ⚠ THE ORIGINAL REASONING, PRESERVED because the next reader will ask why a cron trigger does * not arm itself: picking `ig_profile_match` deliberately stores `enabled: false`, because * arming a cron the moment somebody chooses a trigger would put a BILLED vendor run on a timer * nobody asked for (C's amendment A1: *"the seed cannot spend … runs only when a person presses * Run now or arms the schedule"*). That is still true. The arming is now the rail switch. */ const shape = readCron(cron); const note = tickNote(tick); const every = showCron ? "custom" : shape.every; // The connector this trigger is waiting on, and the route to it, BOTH COMPOSED BY THE // SERVER (`TriggerOption.connect`). No `connect` = we cannot name a route, which is a state // the block below states in words instead of drawing a button that goes nowhere. const waiting = !!picked && picked.ready === false && !picked.planned && !!picked.needs; const provider = picked?.connect?.provider || ""; const connected = !!(provider && oauth?.[provider]?.connected); const startUrl = picked?.connect?.startUrl || ""; const pickEvery = (next: string) => { onShowCron(next === "custom"); if (next !== "custom") onSchedule({ cron: writeCron(next, shape.time || "06:00", cron), enabled }); }; return ( <>
{/* ⚠ CONDITIONALLY RENDERED, never `hidden` — `.auto-field` sets `display: flex`, which beats the `hidden` attribute's UA `display: none`, so the attribute would have left a second trigger picker on screen looking exactly as if it had been asked for. */} {hidePicker ? null : (
whose `value` matches no ) : null} {options.map((t) => ( /* Not-ready is stated ON the option rather than by disabling it: the option is pickable, and picking it shows what it is waiting for. ⚠ PLANNED IS THE ONE EXCEPTION, and it is not the same fact (R2). The server REFUSES a planned key at the door, so leaving it pickable would offer a control whose only possible outcome is a refusal. It still renders — the question "can it run when a button is clicked" has an answer — it just is not an offer. */ ))}
)} {/* ⛔ WAVE 32 · T44 — the second on/off control stood here ("Run on this schedule") and is DELETED, not hidden. See the note on `isSchedule` above for why, and for where arming lives now. `enabled` is still read on this component: every cron write below carries it through unchanged, which is what stops editing a repeat interval from silently disarming an automation. */} {isSchedule ? ( <>
{every === "custom" ? ( <>
{/* ⚠ THE PRESET LIST IS THE SERVER'S. Daily and weekly are not everything the engine runs — hourly, quarter-hourly and monthly are `CRON_PRESETS` — and the parser that ACCEPTS a cron lives in Python, so a client-invented preset is a schedule the server can refuse while the UI looks fine. */}
{/* ⚠ TYPING DOES NOT PATCH. Every other control here persists on change because each is one discrete decision; a cron STRING is free text, so per-keystroke saving would PATCH a half-typed schedule five times and the server would refuse most of them out loud. It edits locally and commits on BLUR. */} onCronText(e.target.value)} onBlur={() => onSchedule({ cron, enabled })} />
) : (
onSchedule({ cron: writeCron(shape.every, e.target.value, cron), enabled })} />
)} ) : null}
{/* THE ABSENT LIST, SAID OUT LOUD (D-43). With the fallback deleted there is no client-side vocabulary left to fall back TO, and that is the point: a picker that quietly offers two options because a payload was short is a surface stating something nobody measured. One line, no paragraph (R13). */} {!options.length && !hidePicker ? (

This server did not offer a trigger list.

) : null} {/* NOT CONFIGURED IS A STATE WITH A NEXT STEP, never a dead control (C3). */} {waiting ? (
{connected ? "Connected — this trigger is still being switched on for this deployment." : "This trigger is not set up yet."} {!connected && startUrl ? ( {needsLabel(picked?.needs || "")} ) : null}
) : null} {connected && provider && oauth?.[provider]?.email ? (

Connected as {oauth[provider]?.email}.

) : null} {isSchedule ?

{note.text}

: null}

{/* ⚠ "NEXT RUN 12:30" IS A PROMISE, AND IT IS ONLY TRUE IF SOMETHING TICKS. The server computes the next fire from the cron alone — it does not know whether a scheduler is running — so printing it flat beside a line that has just said "schedules won't fire" is the surface contradicting itself twice over. */} {isSchedule ? nextRunAt ? tick && tick.enabled === false ? `It would run next at ${nextRunAt}, once a scheduler is running.` : `Next run ${nextRunAt}.` : "Schedules start from the moment they are switched on — turning on a daily job after today's time does not fire it today." : current === "manual" ? "It runs when you press Run now, and nothing else starts it." : /* ⛔ NOTHING. This line printed `It runs when ${label.toLowerCase()}` and the server's label is "When an email arrives" — so the screen read "It runs when when an email arrives". Composing a sentence out of somebody else's label is how that happens, and the repair is not a better composition: the field above is LABELLED "When it runs" and the picker already says the answer, so a caption restating it was over-explaining even when it was grammatical (R13). */ ""}

); }