loopable / web /src /automation /AutomationTrigger.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
20.1 kB
// ---------------------------------------------------------------------------
// 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 (
<>
<div className="auto-field-row">
{/* ⚠ 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 : (
<div className="auto-field">
<label htmlFor="auto-trigger-kind">When it runs</label>
<select
id="auto-trigger-kind"
className="auto-input"
value={current}
disabled={busy}
onChange={(e) => onPick(e.target.value)}
>
{/*
THE STORED VALUE IS ALWAYS AN OPTION. A <select> whose `value` matches
no <option> renders the FIRST one, so the next write persists a trigger
nobody chose β€” the defect this codebase has already paid for twice
([[cg-condition-builder-items]]).
*/}
{current && !options.some((t) => t.key === current) ? (
<option value={current}>{current} (not offered here)</option>
) : 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.
*/
<option key={t.key} value={t.key} disabled={!!t.planned}>
{optionLabel(t)}
</option>
))}
</select>
</div>
)}
{/* β›” 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 ? (
<>
<div className="auto-field">
<label htmlFor="auto-trigger-every">Repeat</label>
<select
id="auto-trigger-every"
className="auto-input"
value={every}
disabled={busy}
onChange={(e) => pickEvery(e.target.value)}
>
<option value="day">Every day</option>
{WEEKDAYS.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
{/*
THE ESCAPE HATCH, AND IT IS LOAD-BEARING. Daily and weekly do not cover
the schedules the engine accepts (hourly, every 15 minutes, monthly, a
weekday range), and a picker that cannot express a stored cron would
render its FIRST option instead β€” so the next Save would write "every
day at 06:00" over a schedule nobody changed.
*/}
<option value="custom">Custom (cron)</option>
</select>
</div>
{every === "custom" ? (
<>
<div className="auto-field">
<label htmlFor="auto-trigger-preset">Or a preset</label>
{/*
⚠ 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.
*/}
<select
id="auto-trigger-preset"
className="auto-input"
value={cronPresets.some((p) => p.cron === cron) ? cron : ""}
disabled={busy}
onChange={(e) => e.target.value && onSchedule({ cron: e.target.value, enabled })}
>
<option value="">Typed below</option>
{cronPresets.map((p) => (
<option key={p.cron} value={p.cron}>
{p.label}
</option>
))}
</select>
</div>
<div className="auto-field">
<label htmlFor="auto-trigger-custom">Cron (minute hour day month weekday)</label>
{/*
⚠ 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.
*/}
<input
id="auto-trigger-custom"
className="auto-input is-mono"
value={cron}
disabled={busy}
onChange={(e) => onCronText(e.target.value)}
onBlur={() => onSchedule({ cron, enabled })}
/>
</div>
</>
) : (
<div className="auto-field">
<label htmlFor="auto-trigger-time">At</label>
<input
id="auto-trigger-time"
className="auto-input"
type="time"
value={shape.time || "06:00"}
disabled={busy}
onChange={(e) => onSchedule({ cron: writeCron(shape.every, e.target.value, cron), enabled })}
/>
</div>
)}
</>
) : null}
</div>
{/*
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 ? (
<p className="auto-note">This server did not offer a trigger list.</p>
) : null}
{/* NOT CONFIGURED IS A STATE WITH A NEXT STEP, never a dead control (C3). */}
{waiting ? (
<div className="autob-needs">
<span className="autob-needs-text">
{connected
? "Connected β€” this trigger is still being switched on for this deployment."
: "This trigger is not set up yet."}
</span>
{!connected && startUrl ? (
<a className="auto-btn is-primary autob-connect" href={startUrl}>
{needsLabel(picked?.needs || "")}
</a>
) : null}
</div>
) : null}
{connected && provider && oauth?.[provider]?.email ? (
<p className="auto-step-detail">Connected as {oauth[provider]?.email}.</p>
) : null}
{isSchedule ? <p className={"auto-tick-note " + note.tone}>{note.text}</p> : null}
<p className="auto-step-detail">
{/*
⚠ "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).
*/
""}
</p>
</>
);
}