| // --------------------------------------------------------------------------- | |
| // automation/AutomationDetail.tsx β one automation, as a flow you can walk. | |
| // | |
| // THE SHAPE (wave-21 R9, replacing the W19 canvas): the automation is a column of | |
| // NUMBERED STEPS, trigger first, and the right-hand panel shows the configuration | |
| // of whichever step you clicked. The two halves answer different questions and | |
| // neither used to be answerable at a glance β the steps answer "what does this | |
| // thing do, in what order, and which step went wrong last night", the panel | |
| // answers "what exactly is step 3 configured to do". The canvas answered the | |
| // first question as a SHAPE; the engine executes a list, and a list is what the | |
| // owner asked to read. | |
| // | |
| // ONE CONTROL PER FACT. A step's SWITCH is the only way to turn it on or off; the | |
| // panel never carries a second checkbox for the same flag. STEP 1 IS THE ONE | |
| // EXCEPTION AND IT IS THE SAME RULE: the trigger's picker IS its on/off, so the | |
| // panel has no schedule face at all any more. Two controls for one boolean is how | |
| // a UI starts disagreeing with itself. | |
| // | |
| // EVERY COUNT DRILLS TO ROWS. A run that says "412 updated" opens the 412 | |
| // ([[no-unverifiable-aggregates]]) β a number with no way back to its records is | |
| // what this codebase treats as a defect rather than a summary. | |
| // --------------------------------------------------------------------------- | |
| import { useCallback, useEffect, useState } from "react"; | |
| import AutomationBuilder from "./AutomationBuilder"; | |
| import AutomationFind from "./AutomationFind"; | |
| import AutomationTrigger from "./AutomationTrigger"; | |
| import type { | |
| ActionCatalogRow, | |
| Automation, | |
| /* β `AutomationKind` LEFT WITH THE `kinds` PROP THAT NAMED IT (wave 25, D-57). `noUnusedLocals` | |
| is what turned the second half of that deletion into a build error, which is the type system | |
| doing the sweeping β the same property that made D-57 a one-change close instead of two. */ | |
| FlowVocab, | |
| OAuthStatus, | |
| Predicate, | |
| RunEntry, | |
| RunRows, | |
| DiscoverEstimate, | |
| DiscoverVocab, | |
| SourcePreview, | |
| TickState, | |
| TriggerOption, | |
| UserTable, | |
| } from "./automationApi"; | |
| import { | |
| AutomationError, | |
| COUNT_LABELS, | |
| deleteAutomation, | |
| discoverEstimate, | |
| fieldKeyFor, | |
| listTables, | |
| oauthStatus, | |
| patchAutomation, | |
| previewSource, | |
| runAutomation, | |
| runBlock, | |
| runRows, | |
| toggleNode, | |
| } from "./automationApi"; | |
| interface Props { | |
| automation: Automation; | |
| /* | |
| * β `kinds` STOOD HERE AND IS DELETED (wave 25, closing DEBT D-57) β from `Props` AND from | |
| * `AutomationSurface`'s call site, in ONE change, because either alone is a `tsc` error. | |
| * | |
| * It was REQUIRED and had NO READER for a whole wave: `.auto-kindtag` was its last consumer and | |
| * item 15b emptied the header that carried it (R6 β the KIND stopped being a user-facing fact | |
| * when every new automation became `plain`). The register's own words for that state: "dead | |
| * weight held in place by the type system, which is the honest state but not a resting place." | |
| * It survived only because retiring it crossed an ownership fence in wave 24; wave 25 gives | |
| * both files to one session, which is the entire reason it can close. | |
| * | |
| * β THE SERVER'S `kinds` PAYLOAD KEY IS NOT TOUCHED HERE. `GET /automations` still sends it and | |
| * `AutomationList.kinds` still parses it β that is SESSION B's file and its decision. What is | |
| * asserted client-side is only that nothing in this tree reads it any more. | |
| */ | |
| /** The server's schedule vocabulary, forwarded to Step 1's Custom face. */ | |
| cronPresets: { cron: string; label: string }[]; | |
| paidReady: boolean; | |
| /** The server-declared discovery vocabulary (absent until the list loads). */ | |
| discover?: DiscoverVocab; | |
| /** The server's trigger vocabulary (C3). Absent until the engine ships it. */ | |
| triggers?: TriggerOption[]; | |
| /** The ACTION MENU and the builder's ceilings (C4). Same rule: absent is a state, not a gap. */ | |
| catalog?: ActionCatalogRow[]; | |
| vocab?: FlowVocab; | |
| /** | |
| * Can a schedule fire here? `null` = the server did not say (C6 amendment #1 is not live | |
| * yet) β a REQUIRED key so an unmounted prop fails `tsc` rather than silently reading as | |
| * "off", which would make Step 1 state a measurement nobody made. | |
| */ | |
| tick: TickState | null; | |
| /** | |
| * β W24-W1 β WHAT THE RUN IS DOING RIGHT NOW, and it is the honest half of owner item 6 | |
| * ("Run once now is laggy / looks stuck"). | |
| * | |
| * MEASURED by the session that owns the surface: the engine work item 6 blamed is ~0.9 ms of a | |
| * ~23 ms request, so there is no performance defect to fix. What IS true is that a | |
| * `discover_instagram` run legitimately blocks up to `BD_FILTER_WAIT` = 120 s at the vendor, | |
| * and the engine has been publishing a live step text the whole time that NOTHING has ever | |
| * rendered β so a two-minute vendor wait and a genuinely hung thread have been pixel-identical. | |
| * They are not the same thing and the server already knows which one it is. | |
| * | |
| * REQUIRED, not optional: an optional prop degrades to "the feature does not exist", which is | |
| * exactly what shipped for the last four releases and is indistinguishable from never having | |
| * built it. `""` while nothing is running is a real value and the honest one β a step text left | |
| * over from a finished run is a worse answer than no answer. | |
| * β Filled from `liveStepOf(a)`, never from `automation.liveStep`: there is no such field on | |
| * the wire (C-TYPES amendment A2) and reading one would compile, type-check and be `undefined` | |
| * forever. | |
| */ | |
| liveStep: string; | |
| /** | |
| * β W31-T31 β `fresh` is the definition the WRITE JUST RETURNED, when the caller has one. | |
| * Given it, the surface corrects that one row; without it, it falls back to a full reload. Every | |
| * mutation door here already had the response in hand and was discarding it, which is what made | |
| * a save cost two round trips. Optional on purpose: the doors that genuinely do not have a fresh | |
| * definition (a delete, a run) still reload, and must. | |
| */ | |
| onSaved: (id?: string, fresh?: Automation) => void | Promise<void>; | |
| onDeleted: () => void | Promise<void>; | |
| } | |
| /* | |
| * β `STATE_LABELS` IS GONE WITH THE HEADER CHIP IT WORDED (item 15b, C-DETAIL). It mapped the | |
| * stored run enum to a sentence for `.auto-chip`, and the chip left the title row so that | |
| * Builder/Board could have that space. The FACT it carried is not lost and was never only here: | |
| * the builder's own `chip()` says "Last run succeeded / failed / Paused / Not set up / Finish | |
| * configuration" on the trigger step, the header's Run-now button says "Runningβ¦", and the rail | |
| * carries a status dot per automation. A second wording of the same enum in this file would have | |
| * been a third place for the three to disagree. | |
| */ | |
| /* | |
| * β `CRON_DRIVEN_TRIGGERS = ["schedule", "ig_profile_match"]` STOOD HERE AND IS DELETED | |
| * (wave 25 β DEBT D-55 CLOSES, both halves in one change or neither). | |
| * | |
| * It was this file's ONE client-side copy of a server fact: which triggers the cron drives. | |
| * Nothing on the wire said it β `TriggerOption` carried `key/label/ready/needs/planned/connect/ | |
| * detail/group` and none of them answer it β so the client named the keys itself, with a comment | |
| * naming its own replacement. | |
| * | |
| * The wire carries it now: `TriggerOption.schedules`, derived server-side from | |
| * `TRIGGER_SCHEDULE_KEYS` MINUS `manual`. That subtraction is the whole reason the engine could | |
| * not simply ship its existing set: `manual` is in it only to decide which way the trigger node's | |
| * SWITCH flips, and a manual trigger drawing a cron face would contradict its own sentence | |
| * ("nothing else starts it"). | |
| * | |
| * β WHY IT WAS WORTH CLOSING, given it failed VISIBLY rather than silently: a new cron-driven | |
| * trigger would simply show no schedule face β a control missing, with nothing red and nothing | |
| * to explain it. That is cheaper than corrupt data and more expensive than it looks, because the | |
| * person who notices is a user, not a gate. D-55's own history is the second lesson: it was | |
| * booked LATE, after the wave that found it had already shipped, because a worker cannot write | |
| * the debt register and the ask never reached it. | |
| */ | |
| interface ColumnPlan { | |
| column: string; | |
| include: boolean; | |
| key: string; | |
| } | |
| export default function AutomationDetail({ | |
| automation, | |
| cronPresets, | |
| paidReady, | |
| discover, | |
| triggers, | |
| catalog, | |
| vocab, | |
| tick, | |
| liveStep, | |
| onSaved, | |
| onDeleted, | |
| }: Props) { | |
| const cfg = (automation.config || {}) as Record<string, string | number | boolean>; | |
| const kind = automation.kind; | |
| /** | |
| * ββ WAVE 30 Β· W30-T17 β IS THIS A CORPUS SEARCH? ONE PREDICATE, AND THAT IS THE POINT. | |
| * | |
| * β THE DEFECT THIS CLOSES, AND WHY IT WAS A 400 RATHER THAN A MISSING FEATURE. | |
| * `automation_engine.py:clean_config`'s discovery branch is already SHARED across both kinds | |
| * (`if kind in ("discover_instagram","discover_tiktok")`) and it reads `recordsLimit` with NO | |
| * fallback to the stored value β so a config that omits the key is read as `limit = 0` and | |
| * refused with the owner's exact sentence about an unbounded discovery query. Three arms in | |
| * this file tested `kind === "discover_instagram"` as a literal, so a `discover_tiktok` | |
| * automation fell through `buildConfig` to `{ targetTable }` and every save after the first | |
| * 400'd β on a panel that was showing the person a records limit the whole time. | |
| * | |
| * β A SHARED PREDICATE, NOT A SECOND STRING TEST, AND THE REASON IS MEASURED RATHER THAN | |
| * STYLISTIC: TikTok's kind shipped in wave 29 and was missed in FOUR places here and THREE in | |
| * the engine, because each site was an independent literal and none of them was wrong on its | |
| * own. B is making the same change server-side in the same wave for the same reason. The next | |
| * platform gets missed once β here β instead of seven times. | |
| * | |
| * β NOT the same question as "does this automation have a TikTok trigger". The kind is what | |
| * `clean_config` branches on and what `RUNNERS` dispatches on; the trigger is what a person | |
| * picked. `clean_definition`'s law 1 derives one from the other, server-side, and this client | |
| * reads the RESULT rather than re-deriving it β two derivations of one fact is how the two | |
| * halves disagree. | |
| */ | |
| const isDiscovery = kind === "discover_instagram" || kind === "discover_tiktok"; | |
| const [name, setName] = useState(automation.name || ""); | |
| const [cron, setCron] = useState(automation.schedule?.cron || "0 6 * * *"); | |
| const [message, setMessage] = useState(""); | |
| const [problem, setProblem] = useState(""); | |
| const [saving, setSaving] = useState(false); | |
| const [touched, setTouched] = useState(false); | |
| const [triggerBusy, setTriggerBusy] = useState(false); | |
| const [oauth, setOauth] = useState<OAuthStatus | null>(null); | |
| /** | |
| * β "SHOW ME THE CRON" IS A VIEW CHOICE, NOT A SCHEDULE CHANGE β and forgetting that once | |
| * made the Custom option DEAD. Everything else on the trigger face is derived from the | |
| * stored cron, so picking "Custom (cron)" wrote⦠the same cron, which `readCron` read back | |
| * as "every Wednesday" and the select snapped straight back. It lives HERE, above the two | |
| * builder controls does not lose it. | |
| */ | |
| const [showCron, setShowCron] = useState(false); | |
| // --- scrape_db half | |
| const [url, setUrl] = useState(String(cfg.url || "")); | |
| const [extract, setExtract] = useState(String(cfg.extract || "table")); | |
| const [tableIndex, setTableIndex] = useState(Number(cfg.tableIndex || 0)); | |
| const [targetLabel, setTargetLabel] = useState(String(cfg.targetLabel || "")); | |
| const [preview, setPreview] = useState<SourcePreview | null>(null); | |
| const [reading, setReading] = useState(false); | |
| const [plan, setPlan] = useState<ColumnPlan[]>(() => | |
| Object.entries((automation.config as { fieldMap?: Record<string, string> })?.fieldMap || {}) | |
| .map(([column, key]) => ({ column, include: true, key })) | |
| ); | |
| const [keyField, setKeyField] = useState(String(cfg.keyField || "")); | |
| // --- field_instagram half | |
| const [tables, setTables] = useState<UserTable[]>([]); | |
| const [targetTable, setTargetTable] = useState(String(cfg.targetTable || "")); | |
| const [fieldKey, setFieldKey] = useState(String(cfg.fieldKey || "")); | |
| const [urlField, setUrlField] = useState(String(cfg.urlField || "")); | |
| /* β WAVE 26 Β· ITEM 7 / C5 β the seed is `DEFAULT_POSTS_PER_PULL` (10), and it is CLAMPED. | |
| β TWO SEPARATE WAYS THIS PANEL COULD SEND A VALUE THE SAVE DOOR REFUSES, and fixing only the | |
| first would have looked complete: | |
| 1. NO stored value -> the fallback. It was 24, which is now ABOVE the ceiling, so a fresh | |
| automation seeded a number its own save door rejects β [[default-must-pass-its-own-guard]] | |
| exactly: a law added later turns yesterday's safe default into a value the product | |
| refuses, and nothing goes red because the default predates the law. | |
| 2. A STORED value of 24 β which is EVERY `field_instagram` automation written before this | |
| wave (A-2 says so in as many words). `buildConfig`'s arm always includes `maxPosts`, so | |
| `clean_config` sees `sent=True` and `clean_max_posts` REFUSES rather than clamps: opening | |
| a legacy automation and pressing Save produced a 400 naming a cap the user never chose | |
| and could not see. The panel bounding its INPUT does not help β the input was never | |
| touched. | |
| β A-2 closed the inherited case on the server for the ACTION path (`_clean_action_config` uses | |
| `submitted=False`, which clamps). It could not close it here, because `submitted` is the | |
| server asking "did a person type this?" and only the CLIENT knows β so on this path the clamp | |
| is the panel's job. Displaying what it will send is the whole fix. */ | |
| const [maxPosts, setMaxPosts] = useState( | |
| Math.min(Math.max(1, Number(cfg.maxPosts) || 10), 12) | |
| ); | |
| // --- the DISCOVERY half (R7) β `discover_instagram` and, since W30-T17, `discover_tiktok`. | |
| // β The 25 is not this panel's invention: it mirrors `automation_engine.py`'s own | |
| // `DISCOVER_SEED_RECORDS`, which is what the server writes when a discovery trigger is | |
| // first picked. Two numbers here would be two answers to "how many by default". | |
| const [recordsLimit, setRecordsLimit] = useState(Number(cfg.recordsLimit || 25)); | |
| const [joinOp, setJoinOp] = useState(String(cfg.operator || "and")); | |
| /** | |
| * β NO SEEDED CONDITION, AND THAT IS THE FIX. | |
| * | |
| * This used to seed `{followers >= 10000}` when nothing was stored β a field the narrowing | |
| * guard does not count and a comparison it does not count either. Every Save of a discovery | |
| * automation therefore sent it and 400'd with "add at least one CONTENT conditionβ¦": renaming | |
| * the automation, changing the cron, editing a step, all of it, on an automation whose filter | |
| * the user had never touched. Wave 24 made it unmissable by deleting the wizard, so this seed | |
| * became what every new Instagram automation carries from birth. | |
| * | |
| * β EMPTY IS LEGAL AND MEANS SOMETHING. `clean_predicates` stores `[]` inert (wave 24 A2) and | |
| * `configured: false` rides the wire, so the surface says "finish setting this up" instead of | |
| * refusing the save β while `run_discover_instagram` still refuses to SPEND on a filter that | |
| * narrows nothing. Incomplete is a state; only the money door is a wall. | |
| */ | |
| const [preds, setPreds] = useState<Predicate[]>(() => { | |
| const stored = (automation.config as { predicates?: Predicate[] })?.predicates; | |
| return stored && stored.length ? stored.map((p) => ({ ...p })) : []; | |
| }); | |
| const [estimate, setEstimate] = useState<DiscoverEstimate | null>(null); | |
| // --- run history drill | |
| const [drill, setDrill] = useState<RunRows | null>(null); | |
| const [drillFor, setDrillFor] = useState(""); | |
| /** | |
| * β WAVE 27 Β· OWNER ITEM 33 / CONTRACT C11 β WHICH PANEL OWNS THE RIGHT-HAND COLUMN. | |
| * | |
| * β THE STATE IS HERE BECAUSE THE TWO PANELS ARE NOT SIBLINGS IN ONE FILE. Properties is | |
| * rendered by `AutomationBuilder`; the Run log is rendered below by this component. Both are | |
| * `.auto-panel` children of `.auto-work`, so they were drawn SIDE BY SIDE β two 380px columns | |
| * on a surface whose actual content is the flow. The reference (`reference/Airtable | |
| * Automation 11.png`) shows one dismissable panel, and the only component that can guarantee | |
| * "exactly one" is the parent of both. | |
| * | |
| * β PROPERTIES IS THE DEFAULT, not the log. Opening an automation to read last night's run is | |
| * the rarer errand; opening it to change something is the common one, and the panel that | |
| * appears should be the one a click on a step is about to fill. | |
| */ | |
| const [aside, setAside] = useState<"properties" | "runs">("properties"); | |
| /** | |
| * The switch, drawn in BOTH panel heads so it does not move when the panel does. | |
| * | |
| * β A RADIOGROUP, not two buttons that happen to look joined: they are two values of one | |
| * setting, and `aria-pressed` on each would tell a screen reader about two independent | |
| * toggles that can both be off β a state this control cannot express. | |
| */ | |
| const panelTabs = ( | |
| <div className="auto-paneltabs" role="radiogroup" aria-label="Right-hand panel"> | |
| {([["properties", "Properties"], ["runs", "Run history"]] as const).map(([k, label]) => ( | |
| <button | |
| key={k} | |
| type="button" | |
| role="radio" | |
| aria-checked={aside === k} | |
| className={"auto-paneltab" + (aside === k ? " is-on" : "")} | |
| onClick={() => setAside(k)} | |
| > | |
| {label} | |
| </button> | |
| ))} | |
| </div> | |
| ); | |
| useEffect(() => { | |
| const ac = new AbortController(); | |
| listTables(ac.signal) | |
| .then((r) => setTables(r.tables)) | |
| .catch(() => setTables([])); | |
| return () => ac.abort(); | |
| }, []); | |
| /** | |
| * β WAVE 26 Β· ITEM 9 / R11 β RE-READ THE DATABASES after one is created from inside an action's | |
| * picker. The create route answers `{key}` only, so the new row's label, `rowCount` and fields | |
| * come from here rather than being invented client-side β a spliced-in row would carry three | |
| * guessed facts, and the picker renders two of them. | |
| */ | |
| const reloadTables = () => | |
| listTables() | |
| .then((r) => setTables(r.tables)) | |
| .catch(() => undefined); | |
| useEffect(() => { | |
| const ac = new AbortController(); | |
| oauthStatus(ac.signal) | |
| .then(setOauth) | |
| // Unconfigured OAuth is FAIL-CLOSED, not an error (C5): no status means no | |
| // connection, which the trigger face already states as "not set up yet". | |
| .catch(() => setOauth(null)); | |
| return () => ac.abort(); | |
| }, []); | |
| const table = tables.find((t) => t.key === targetTable) || null; | |
| /** ITEM 22 / D-70 / R12 β may Run be pressed, and what does it say if not. One definition, | |
| * shared with the Builder's own Run button (`runBlock`). */ | |
| const runState = runBlock(automation); | |
| /** | |
| * β THE WALKING RECORD'S DATABASE β the root cause of "the condition on the group names no | |
| * field" (owner item 12a), and it was never a condition bug. | |
| * | |
| * `AutomationBuilder` resolved the field list from `trigger.table` ALONE. Every trigger without | |
| * a table β manual, schedule, and the new `ig_profile_match`, which watches nothing because it | |
| * MAKES rows β therefore handed `CondBuilder` an empty list, so the field picker offered | |
| * "Choose a fieldβ¦" and nothing else, and any condition saved against it named no field. The | |
| * picker looked broken; the automation simply had no table for it to read. | |
| * | |
| * β MIRRORS `automation_engine._flow_table` (`:4319`) RATHER THAN INVENTING A SECOND RULE, and | |
| * the ORDER is the part worth copying: the automation's own `targetTable` WINS over the | |
| * trigger's table, not the other way round. A flow whose actions walk one database while its | |
| * trigger watches another is a real shape, and getting the precedence backwards would offer the | |
| * columns of the database the actions do not touch β a wrong list that looks authoritative, | |
| * which this file already says is worse than no list. | |
| * β The trigger's OWN pickers keep using the trigger's own table; these are two different | |
| * questions and answering both from one variable is what produced the bug. | |
| */ | |
| /* | |
| β W30-T17 β BOTH DISCOVERY KINDS TAKE THIS BRANCH NOW, AND THE FALLBACK DELIBERATELY DOES NOT. | |
| β `discover.table` IS THE INSTAGRAM VOCABULARY'S OWN TABLE, not "the discovery table". | |
| MEASURED (scout, 2026-08-12): the wire carries exactly ONE `discover` object β built by | |
| `routes_automation.py:list_automations` from `automation_engine.py:BD_FILTER_FIELDS` and | |
| friends, with no platform argument anywhere on the route β and its `table` is Instagram's. | |
| Widening this fallback to every discovery kind would therefore have offered a TikTok | |
| automation the columns of an Instagram database: a list that looks authoritative and names | |
| the wrong database, which is the exact failure the comment above says is worse than no list. | |
| So TikTok gets `cfg.targetTable` (which `clean_config` sets to its own `ut_tt_profile`) or | |
| NOTHING, and nothing renders as "this automation has no table for it to read" β true. | |
| */ | |
| const walkTable = isDiscovery | |
| ? String(cfg.targetTable || "") || | |
| (kind === "discover_instagram" ? String(discover?.table || "") : "") | |
| : String(cfg.targetTable || "") || String(automation.trigger?.table || ""); | |
| /** | |
| * WHAT STARTS THIS ONE, as a key. The server's stored trigger when it ships one; | |
| * otherwise derived from the only two shapes a definition can express today. | |
| * Derived rather than defaulted: `schedule.enabled` IS the answer on today's | |
| * engine, so this is a reading of stored state, not a guess about it. | |
| */ | |
| const triggerKey = | |
| automation.trigger?.key || (automation.schedule?.enabled ? "schedule" : "manual"); | |
| /** | |
| * β WAVE 25 β D-55's OTHER HALF: does the cron drive THIS trigger? The SERVER's answer. | |
| * | |
| * β `=== true`, NOT TRUTHY, and absent reads as NO. A server older than this client sends no | |
| * `schedules` at all, and the fail-closed direction is to draw FEWER controls: a missing | |
| * schedule face is a control the reader can go and find, while a cron face on a manual trigger | |
| * is the surface contradicting its own sentence. `?? CRON_DRIVEN_TRIGGERS.includes(...)` was | |
| * the tempting shape and it is exactly the copy this debt row closes β a fallback list is a | |
| * list, and it goes stale on the same schedule the original did. | |
| * | |
| * β AN UNKNOWN KEY GETS `false` for the same reason: `triggers` may not carry the stored key at | |
| * all (a trigger this deployment withholds), and inventing a cron face for one nobody offered | |
| * would be the client answering a question the server declined. | |
| */ | |
| const cronDriven = (triggers || []).find((t) => t.key === triggerKey)?.schedules === true; | |
| const edit = <T,>(set: (v: T) => void) => (v: T) => { | |
| setTouched(true); | |
| set(v); | |
| }; | |
| const readPage = useCallback(async () => { | |
| setReading(true); | |
| setProblem(""); | |
| try { | |
| const p = await previewSource(url, extract, tableIndex); | |
| setPreview(p); | |
| if (!p.ok) { | |
| setProblem(p.note || `That page answered ${p.status}.`); | |
| return; | |
| } | |
| // Seed the plan from what the page ACTUALLY offers, keeping any mapping the | |
| // user already made for a column that is still there β re-reading a page | |
| // must not throw away the work of mapping it. | |
| setPlan((prev) => { | |
| const had = new Map(prev.map((c) => [c.column, c])); | |
| return p.columns.map((column) => { | |
| const before = had.get(column); | |
| return { | |
| column, | |
| include: before ? before.include : true, | |
| key: before ? before.key : fieldKeyFor(column), | |
| }; | |
| }); | |
| }); | |
| setKeyField((k) => | |
| k && p.columns.some((c) => fieldKeyFor(c) === k) ? k : fieldKeyFor(p.columns[0] || "") | |
| ); | |
| if (!targetLabel) setTargetLabel(p.title || "Scraped table"); | |
| setTouched(true); | |
| setMessage( | |
| `Read ${p.rowCount} rows and ${p.columns.length} columns` + | |
| (p.tableCount && p.tableCount > 1 ? ` (of ${p.tableCount} tables on the page)` : "") | |
| ); | |
| } catch (e) { | |
| setProblem(e instanceof AutomationError ? e.message : "That page could not be read."); | |
| } finally { | |
| setReading(false); | |
| } | |
| }, [url, extract, tableIndex, targetLabel]); | |
| /** | |
| * β The switch flags (`tier`, `noFallback`, `dryRun`) are DELIBERATELY absent. | |
| * They are node switches, not panel fields, and the server keeps the stored | |
| * value for any flag a config omits β so a Save from here can never quietly | |
| * turn the paid rung off. (`clean_config`'s `flag()`, asserted in section N.) | |
| */ | |
| /** | |
| * The discovery conditions that are FINISHED enough to send. | |
| * | |
| * A condition is written in two gestures β pick the comparison, then type the value β and the | |
| * server refuses a non-nullary comparison with no value ("give a value to compare 'biography' | |
| * against"). Sending a half-written row therefore turned every Save between those two gestures | |
| * into an error about a condition the user was still in the middle of writing. Held back | |
| * instead: the row stays on screen and in state, it just is not claimed to the server yet. | |
| * | |
| * β HELD BACK IS SAID OUT LOUD (see `persist`) β a row that vanishes from the stored filter | |
| * without a word is the silent-drop class this module refuses everywhere else. | |
| * | |
| * The nullary list comes off the wire; the literal is only for a server that did not send one, | |
| * and it errs toward SENDING (the server then refuses, loudly) rather than toward dropping. | |
| */ | |
| const completePreds = (): Predicate[] => { | |
| const nullary = discover?.nullaryOperators || ["is_null", "is_not_null"]; | |
| return preds.filter((p) => { | |
| if (!p.name || !p.operator) return false; | |
| if (nullary.includes(p.operator)) return true; | |
| return !( | |
| p.value === undefined || | |
| p.value === null || | |
| (typeof p.value === "string" && !p.value.trim()) | |
| ); | |
| }); | |
| }; | |
| /** | |
| * The sentence for whatever `completePreds` held back, or "" when it held nothing. | |
| * | |
| * β EVERY SAVE PATH SAYS IT, not just the Save button. The trigger picker and the cron control | |
| * persist IMMEDIATELY (R9) and announce nothing on success β so without this, CLEARING a | |
| * condition's value and then changing the trigger would drop that condition from the stored | |
| * filter with the row still on screen and not a word anywhere. Holding a half-written row back | |
| * is a courtesy; holding it back in silence is the drop class this module refuses everywhere. | |
| */ | |
| /** | |
| * β WAVE 27 (item 11) β the COUNT, split out of the sentence below. | |
| * | |
| * `start()` has to say the same fact in a different sentence ("it is running without it"), | |
| * and the alternative was for it to read `heldNote()`'s prose and sniff it for a plural β | |
| * a second reader of a string that exists to be shown to a person. One arithmetic, two | |
| * wordings, and neither wording is parsed by anything. | |
| */ | |
| /* W30-T17: both discovery kinds hold half-written rows back, because `completePreds` is what | |
| `buildConfig` sends for both and a held row that is never announced is the silent-drop class | |
| this module refuses everywhere else. A TikTok automation used to report 0 held, always. */ | |
| const heldCount = (): number => | |
| isDiscovery ? preds.length - completePreds().length : 0; | |
| const heldNote = (): string => { | |
| const held = heldCount(); | |
| if (held < 1) return ""; | |
| return `Saved β ${held} condition${held === 1 ? " was" : "s were"} not saved yet, ` | |
| + `${held === 1 ? "it needs" : "they need"} a value.`; | |
| }; | |
| const buildConfig = (): Record<string, unknown> => { | |
| if (kind === "scrape_db") { | |
| const fieldMap: Record<string, string> = {}; | |
| for (const c of plan) if (c.include && c.key) fieldMap[c.column] = c.key; | |
| return { | |
| url, | |
| extract, | |
| tableIndex, | |
| fieldMap, | |
| keyField, | |
| targetTable: String(cfg.targetTable || ""), | |
| targetLabel, | |
| }; | |
| } | |
| /* | |
| β BOTH DISCOVERY KINDS, ONE SHAPE (W30-T17). This arm was `kind === "discover_instagram"`, | |
| so a TikTok discovery automation fell all the way through to `{ targetTable }` and dropped | |
| `recordsLimit` on EVERY save. The server's discovery branch reads that key with no | |
| prev-fallback, so the drop is not "the old value survives" β it is `limit = 0`, which the | |
| save door refuses outright. Instagram never showed it because this arm existed. | |
| β The FIRST save is a different question and it is not fixed here: `pickTrigger` calls this | |
| while `kind` is still `plain` (the server flips it after reading the trigger), so the | |
| opening PATCH of a brand-new discovery automation carries no limit by construction. That | |
| one is the engine's seed to set β W30-T04 β and it is why the two tickets are separate. | |
| */ | |
| if (isDiscovery) { | |
| return { | |
| recordsLimit, | |
| operator: joinOp, | |
| predicates: completePreds(), | |
| targetTable: String(cfg.targetTable || ""), | |
| }; | |
| } | |
| if (kind === "field_instagram") { | |
| return { targetTable, fieldKey, urlField, maxPosts }; | |
| } | |
| /* | |
| β EVERY ARM IS EXPLICIT AND THE FALL-THROUGH IS THE MINIMUM (wave 24, R6). | |
| This used to END at `field_instagram`'s shape, as a bare `return` β so it was not a default, | |
| it was a SILENT DEFAULT KIND, and R6 made `plain` the kind of every new automation. Picking | |
| a trigger (the first thing anyone does now) called this and sent | |
| `{targetTable, fieldKey, urlField, maxPosts}` for an automation that has no Instagram column | |
| and no posts. It happens to be harmless today β `clean_config`'s `plain` arm reads only | |
| `targetTable` and `lanes`, and falls back to the stored `lanes` β so nothing is lost and | |
| nothing 400s. It is fixed anyway, because "harmless" here is luck rather than design: the | |
| keys are dropped by a validator that was written independently, and the day `plain` grows a | |
| key this shape happens to contain, a Save from any panel would write a value nobody chose. | |
| This is the same defect C found twice in the engine on the same day β `graph()`'s final | |
| `else` is `scrape_db`'s branch and `compose_sentence`'s is `discover_instagram`'s β so a new | |
| kind silently inherited another kind's whole description. A last bare `else` is not a | |
| default; it is whichever arm happened to be written last. | |
| `targetTable` is the one key EVERY kind shares (it is what `_flow_table` resolves), so the | |
| remaining fall-through carries that and nothing else. | |
| */ | |
| return { targetTable }; | |
| }; | |
| /** | |
| * β W31-T31 β RETURNS THE FRESH AUTOMATION, not just its id. The PATCH response already carries | |
| * the whole updated definition; handing it to `onSaved` is what lets the surface correct one row | |
| * instead of re-reading the entire list to learn what it was just told. | |
| */ | |
| const persist = async (announce = true, sched?: { cron: string; enabled: boolean }) => { | |
| const res = await patchAutomation(automation.id, { | |
| name, | |
| kind, | |
| config: buildConfig(), | |
| schedule: sched || { cron, enabled: !!automation.schedule?.enabled }, | |
| }); | |
| setTouched(false); | |
| if (announce) setMessage(heldNote() || "Saved."); | |
| return res.automation; | |
| }; | |
| /** | |
| * Step 1's controls persist IMMEDIATELY (R9). A trigger you have to remember to Save is the | |
| * same defect as a switch you have to remember to Save β the node switches next to it are | |
| * immediate, and two save disciplines in one column is how a user ends up with an automation | |
| * that says "every Monday" and runs every day. | |
| * | |
| * β The CUSTOM CRON STRING is the exception, and deliberately: it is free text, so persisting | |
| * per keystroke would PATCH a half-typed schedule five times. It marks the editor touched and | |
| * rides the header's Save β and because every immediate write goes through `persist()`, an | |
| * unsaved cron edit is written with it rather than being discarded by the reload. | |
| */ | |
| const saveTrigger = async (next: { cron: string; enabled: boolean }) => { | |
| setTriggerBusy(true); | |
| setProblem(""); | |
| setMessage(""); | |
| setCron(next.cron); | |
| try { | |
| const { automation: fresh } = await patchAutomation(automation.id, { | |
| name, | |
| kind, | |
| config: buildConfig(), | |
| schedule: { cron: next.cron, enabled: next.enabled }, | |
| }); | |
| setTouched(false); | |
| setMessage(heldNote()); | |
| await onSaved(fresh.id, fresh); // W31-T31: the response, not a second read | |
| } catch (e) { | |
| setProblem(e instanceof AutomationError ? e.message : "That schedule was not saved."); | |
| } finally { | |
| setTriggerBusy(false); | |
| } | |
| }; | |
| /** | |
| * Pick WHAT STARTS this automation (C3). | |
| * | |
| * β TWO WRITES IN ONE PATCH, on purpose. `trigger: {key}` is the shape this wave | |
| * introduces; `schedule.enabled` is what today's engine actually reads, and the | |
| * two must agree or a picker that says "manual" would leave a live cron behind | |
| * it. The schedule half is derived from the key rather than remembered β the | |
| * trigger IS the on/off, which is the one-control-per-fact rule this file's | |
| * header states. | |
| * | |
| * β ASSUMPTION, STATED: the engine session confirms the write shape in the wave | |
| * mailbox. An engine that ignores an unknown `trigger` key still gets the | |
| * schedule half right, so the fallback is correct rather than merely harmless. | |
| */ | |
| const pickTrigger = async (key: string) => { | |
| setTriggerBusy(true); | |
| setProblem(""); | |
| setMessage(""); | |
| if (key !== "schedule") setShowCron(false); | |
| try { | |
| const { automation: fresh } = await patchAutomation(automation.id, { | |
| name, | |
| kind, | |
| config: buildConfig(), | |
| trigger: { key }, | |
| schedule: { cron, enabled: key === "schedule" }, | |
| }); | |
| setTouched(false); | |
| setMessage(heldNote()); | |
| await onSaved(fresh.id, fresh); // W31-T31: the response, not a second read | |
| } catch (e) { | |
| setProblem(e instanceof AutomationError ? e.message : "That trigger was not saved."); | |
| } finally { | |
| setTriggerBusy(false); | |
| } | |
| }; | |
| /** | |
| * Move ONE record along the flow β R1's promotion. | |
| * β ITEM 15 / R6: a clause here used to equate this with the discovery flow's old checkbox | |
| * column, which the owner deleted this wave. A move writes the record's STAGE, and the stages | |
| * are the ones the USER defined β the automation has no opinion about what happens to a | |
| * candidate after it is found. | |
| * β The dead column's NAME is deliberately not restated anywhere in this module: the gate's | |
| * rule is that the token does not appear at all, which is the only version of this check that | |
| * cannot be satisfied by a comment merely claiming the deletion happened. | |
| * | |
| * β THE SERVER DECIDES, and its refusal is printed VERBATIM. This posts the drop | |
| * it was given; a client that pre-judged legality would be a second copy of a | |
| * permission rule, and the two copies disagreeing leaves a card that will not | |
| * move for a reason nobody can read. | |
| */ | |
| /** | |
| * ββ WAVE 31 Β· T31 β "SAVINGβ¦" IS THE LIFETIME OF THE WRITE, AND NOTHING ELSE. | |
| * | |
| * β THE DEFECT, in the owner's words: *"It just say Saving... and takes a long time for me to | |
| * change options and configs etc."* This function used to hold `saving` across `persist()` AND | |
| * `onSaved()` β and `onSaved` reloads the whole list. So the label a person reads as "your edit is | |
| * being written" was in fact reporting a write plus an unrelated full `GET /automations`, and the | |
| * server-side half of that read is the one W31-T30 measured at seconds. Two round trips wearing | |
| * one word. | |
| * | |
| * β AND THE SPLIT IS A CORRECTNESS FIX, NOT ONLY A SPEED ONE. The old `try` wrapped both calls in | |
| * one `catch` that printed *"That could not be saved."* β so a save that SUCCEEDED and whose list | |
| * refresh then failed told the person their work was lost while it sat safely in the store. The | |
| * inverse of the bug being fixed, and the worse of the two. The write's failure is reported; the | |
| * refresh's failure is not allowed to speak for it. | |
| */ | |
| const save = async () => { | |
| setSaving(true); | |
| setProblem(""); | |
| setMessage(""); | |
| let fresh: Automation | undefined; | |
| try { | |
| fresh = await persist(); | |
| } catch (e) { | |
| setProblem(e instanceof AutomationError ? e.message : "That could not be saved."); | |
| return; | |
| } finally { | |
| // β HERE, not after `onSaved`. This is the whole ticket. | |
| setSaving(false); | |
| } | |
| try { | |
| await onSaved(fresh.id, fresh); | |
| } catch { | |
| /* The save is already committed β see the note above. A failed refresh is not a failed save. */ | |
| } | |
| }; | |
| /** | |
| * A node switch is immediate β a switch that needs a separate Save is not a | |
| * switch. Unsaved panel edits are persisted FIRST rather than being discarded | |
| * by the reload that follows, which is the one way this could lose work. | |
| */ | |
| const flip = async (nodeId: string) => { | |
| setProblem(""); | |
| setMessage(""); | |
| try { | |
| if (touched) await persist(false); | |
| // β W31-T31 β the toggle RESPONSE is the new definition. Passing it on means a node switch | |
| // costs the toggle itself, where it used to cost a toggle plus a whole-list read (plus a | |
| // PATCH when the panel was dirty β three round trips for one click). | |
| const { automation: fresh } = await toggleNode(automation.id, nodeId); | |
| await onSaved(fresh.id, fresh); | |
| } catch (e) { | |
| setProblem(e instanceof AutomationError ? e.message : "That step was not changed."); | |
| } | |
| }; | |
| /** | |
| * β WAVE 27 Β· OWNER ITEM 11 β **RUN NOW WRITES WHAT IS ON SCREEN.** | |
| * | |
| * β THE DEFECT, in the owner's words: *"I have to save twice"*. Every trigger-side control | |
| * this panel owns β the discovery conditions (`preds`), the record limit, the and/or join, | |
| * `maxPosts` β lives in LOCAL STATE until something calls `persist()`. `start()` called | |
| * `runAutomation` directly, so pressing Run after editing a condition ran the automation | |
| * against the config as it was BEFORE the edit, and the only tell was that pressing Save | |
| * first made it behave. Two presses looked like a fussy UI; it was a run against stale | |
| * config, and on a `discover_instagram` automation that is a PAID search of the wrong filter. | |
| * | |
| * β THE FIX IS `flip()`'s, VERBATIM (`:606`), and deliberately not a new discipline: a node | |
| * switch already persisted unsaved panel edits before toggling, for exactly this reason, and | |
| * the two buttons sit in the same header. `if (touched)` and not an unconditional PATCH β | |
| * an untouched panel writing its config back would re-send `buildConfig()` on every Run, | |
| * which is how a clamp or a validator default silently becomes a value the user never chose | |
| * ([[default-must-pass-its-own-guard]]). | |
| * | |
| * β A FAILED FLUSH MUST NOT RUN. Both calls share one `try`, so a PATCH that 400s leaves the | |
| * server's sentence on screen and `runAutomation` unreached β running anyway would spend | |
| * money on precisely the config the server just refused. | |
| * | |
| * β AND THE HELD-BACK SENTENCE IS SAID HERE TOO. `persist(false)` suppresses `heldNote()` | |
| * because `flip()` had nothing to add to it; this path does. `completePreds()` withholds a | |
| * condition that has a comparison and no value yet, so a Run pressed mid-edit legitimately | |
| * searches WITHOUT it β silence there would be the module's own drop class (see `heldNote`), | |
| * and it is worse on this button than on any other because this one is the one that bills. | |
| */ | |
| const start = async () => { | |
| setProblem(""); | |
| try { | |
| if (touched) await persist(false); | |
| await runAutomation(automation.id); | |
| const held = heldCount(); | |
| setMessage( | |
| held < 1 | |
| ? "Started. The node dots follow it." | |
| : `Started β running without ${held} condition${held === 1 ? "" : "s"} that still ` | |
| + `${held === 1 ? "needs" : "need"} a value.` | |
| ); | |
| await onSaved(automation.id); | |
| } catch (e) { | |
| setProblem(e instanceof AutomationError ? e.message : "It did not start."); | |
| } | |
| }; | |
| const remove = async () => { | |
| setProblem(""); | |
| try { | |
| await deleteAutomation(automation.id); | |
| await onDeleted(); | |
| } catch (e) { | |
| setProblem(e instanceof AutomationError ? e.message : "It was not deleted."); | |
| } | |
| }; | |
| const openDrill = async (entry: RunEntry) => { | |
| if (drillFor === entry.ts) { | |
| setDrill(null); | |
| setDrillFor(""); | |
| return; | |
| } | |
| try { | |
| const rows = await runRows(automation.id); | |
| setDrill(rows); | |
| setDrillFor(entry.ts); | |
| } catch (e) { | |
| setProblem(e instanceof AutomationError ? e.message : "Those rows could not be read."); | |
| } | |
| }; | |
| /** | |
| * The SCHEDULE half of the trigger face, built once and rendered by the Builder's Properties | |
| * panel. `AutomationTrigger` owns the vocabulary (C3), the cron round-trip and the tick | |
| * honesty note; this file owns only what a change should WRITE. | |
| * | |
| * β ONE FACE NOW, NOT TWO (item 10). There used to be a second copy with its picker SHOWN, | |
| * rendered by the board's `.autob-triggerbar`. The board is for reading where records ARE β | |
| * the trigger belongs to the Builder β so the bar is gone and with it the only caller that | |
| * ever wanted the picker from this file. `hidePicker` survives because it is what makes ONE | |
| * cron round-trip serve the Properties panel without a second trigger dropdown above it. | |
| */ | |
| const scheduleFace = ( | |
| <AutomationTrigger | |
| triggers={triggers} | |
| current={triggerKey} | |
| cron={cron} | |
| enabled={!!automation.schedule?.enabled} | |
| tick={tick} | |
| nextRunAt={automation.nextRunAt} | |
| cronPresets={cronPresets} | |
| busy={triggerBusy} | |
| onPick={(key) => void pickTrigger(key)} | |
| onSchedule={(next) => void saveTrigger(next)} | |
| onCronText={(next) => edit(setCron)(next)} | |
| oauth={oauth} | |
| showCron={showCron} | |
| onShowCron={setShowCron} | |
| hidePicker | |
| schedules={cronDriven} | |
| /> | |
| ); | |
| /** | |
| * The Builder's writer (C4). It PATCHES a PARTIAL definition β `{trigger}` or `{flow}` β and | |
| * nothing else, which is what makes an immediate write safe here: `clean_trigger` and | |
| * `clean_flow` merge against what is stored, so sending the one key that changed cannot | |
| * blank the rest. (`persist()` above is the whole-form save the machine-step panels use; the | |
| * two must not be confused, because that one DOES rewrite `config` from local state.) | |
| */ | |
| const writeDefinition = async (body: Record<string, unknown>) => { | |
| setTriggerBusy(true); | |
| setProblem(""); | |
| setMessage(""); | |
| try { | |
| const { automation: fresh } = await patchAutomation(automation.id, body); | |
| await onSaved(fresh.id, fresh); // W31-T31: the response, not a second read | |
| } catch (e) { | |
| // β THE SERVER'S SENTENCE, VERBATIM. `clean_flow` refuses an empty group, an unready | |
| // action kind, a valueless compare β each with a reason worth reading. A client that | |
| // predicted those refusals would be a second validator free to disagree with the one | |
| // that decides. | |
| setProblem(e instanceof AutomationError ? e.message : "That change was not saved."); | |
| } finally { | |
| setTriggerBusy(false); | |
| } | |
| }; | |
| /** | |
| * THE NAME, now that it lives at the top of Properties instead of in the page header | |
| * (item 15b, C-DETAIL). | |
| * | |
| * β IT COMMITS ON BLUR AND WRITES IMMEDIATELY, like every other free-text field in that | |
| * panel (the Gmail query, the review prompt, a value). That is not a style choice: the panel | |
| * stamps "All changes saved", which is its receipt for the immediate-write discipline β a | |
| * name sitting unsaved underneath it would make the stamp a lie. It is the cron field's exact | |
| * shape (controlled while typing, one write on blur), because per-keystroke would PATCH a | |
| * half-typed name. | |
| * | |
| * β A BLANK NAME IS NOT A NAME, and the header's Save has always refused one | |
| * (`disabled={β¦ || !name.trim()}`). Blurring an emptied box therefore RESTORES the stored | |
| * name rather than writing an empty string or leaving the box contradicting the store β the | |
| * two states a bare early-return would have left behind. | |
| */ | |
| const commitName = async () => { | |
| const next = name.trim(); | |
| const stored = automation.name || ""; | |
| if (!next) { | |
| setName(stored); | |
| return; | |
| } | |
| // Whitespace-only edits still normalise the box; they just have nothing to write. | |
| if (next === stored) { | |
| if (next !== name) setName(next); | |
| return; | |
| } | |
| setName(next); | |
| // β `{name}` ALONE IS A LEGAL PATCH β `engine.patch` merges by key over the stored | |
| // definition (`automation_engine.py:1176` lists `name` among the patchable six), so this | |
| // cannot blank the config, the schedule, the trigger or the flow. | |
| await writeDefinition({ name: next }); | |
| }; | |
| /** | |
| * THE MACHINE STEPS' CONFIG BODIES, joined on `panel` (C1 amendment 1) and rendered by | |
| * BOTH views. | |
| * | |
| * β EXTRACTED, NOT COPIED, and the difference is the whole point. The Builder shows the | |
| * same engine-derived steps the Board's process strip does, so a second copy of these | |
| * five panels would be two surfaces that must agree with `clean_config` forever β and the | |
| * way they would stop agreeing is silent: one of them keeps writing a key the engine | |
| * renamed. One body, two callers. | |
| */ | |
| const panelBody = (panelKey: string) => ( | |
| <> | |
| {panelKey === "source" && kind === "scrape_db" ? ( | |
| <> | |
| <div className="auto-field"> | |
| <label htmlFor="auto-url">Page URL</label> | |
| <input | |
| id="auto-url" | |
| className="auto-input" | |
| value={url} | |
| placeholder="https://en.wikipedia.org/wiki/List_of_S%26P_500_companies" | |
| onChange={(e) => edit(setUrl)(e.target.value)} | |
| /> | |
| </div> | |
| <button | |
| type="button" | |
| className="auto-btn" | |
| disabled={!url.trim() || reading} | |
| onClick={() => void readPage()} | |
| > | |
| {reading ? "Readingβ¦" : "Read the page"} | |
| </button> | |
| <div className="auto-field"> | |
| <label htmlFor="auto-extract">Read</label> | |
| <select | |
| id="auto-extract" | |
| className="auto-input" | |
| value={extract} | |
| onChange={(e) => edit(setExtract)(e.target.value)} | |
| > | |
| <option value="table">An HTML table</option> | |
| <option value="jsonld">Structured data (JSON-LD)</option> | |
| </select> | |
| </div> | |
| {extract === "table" ? ( | |
| <div className="auto-field"> | |
| <label htmlFor="auto-tidx">Which table</label> | |
| <input | |
| id="auto-tidx" | |
| className="auto-input" | |
| type="number" | |
| min={0} | |
| value={tableIndex} | |
| onChange={(e) => edit(setTableIndex)(Number(e.target.value) || 0)} | |
| /> | |
| </div> | |
| ) : null} | |
| {/* "loopback and link-local addresses" is the SSRF rail describing itself. | |
| The rail still refuses them, loudly, at the moment it happens. */} | |
| <p className="auto-hint">Public web pages only.</p> | |
| </> | |
| ) : null} | |
| {panelKey === "columns" ? ( | |
| plan.length ? ( | |
| <> | |
| {/* The upsert rules, in full, to somebody choosing a column. What they | |
| need is which column, and that rows are updated rather than duplicated. */} | |
| <p className="auto-hint"> | |
| Pick the column that identifies a row, so re-runs update it instead of | |
| adding it again. | |
| </p> | |
| <div className="auto-panel-scroll"> | |
| <table className="auto-table"> | |
| <thead> | |
| <tr> | |
| <th>Use</th> | |
| <th>Source column</th> | |
| <th>Field key</th> | |
| <th>Key</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {plan.map((c, i) => ( | |
| <tr key={c.column}> | |
| <td> | |
| <input | |
| type="checkbox" | |
| checked={c.include} | |
| aria-label={`Include ${c.column}`} | |
| onChange={(e) => | |
| edit(setPlan)( | |
| plan.map((x, j) => | |
| j === i ? { ...x, include: e.target.checked } : x | |
| ) | |
| ) | |
| } | |
| /> | |
| </td> | |
| <td className="auto-cell-name">{c.column}</td> | |
| <td> | |
| <input | |
| className="auto-input is-small" | |
| value={c.key} | |
| aria-label={`Field key for ${c.column}`} | |
| onChange={(e) => | |
| edit(setPlan)( | |
| plan.map((x, j) => | |
| j === i | |
| ? { ...x, key: fieldKeyFor(e.target.value) } | |
| : x | |
| ) | |
| ) | |
| } | |
| /> | |
| </td> | |
| <td> | |
| <input | |
| type="radio" | |
| name="auto-keyfield" | |
| checked={keyField === c.key && c.include} | |
| disabled={!c.include} | |
| aria-label={`Use ${c.column} as the key`} | |
| onChange={() => edit(setKeyField)(c.key)} | |
| /> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| {preview?.sample?.length ? ( | |
| <p className="auto-hint"> | |
| First row read: {Object.values(preview.sample[0]).slice(0, 4).join(" Β· ")} | |
| </p> | |
| ) : null} | |
| </> | |
| ) : ( | |
| <p className="auto-note"> | |
| Open the Fetch step and read the page to see what columns it offers. | |
| </p> | |
| ) | |
| ) : null} | |
| {panelKey === "source" && kind === "field_instagram" ? ( | |
| <> | |
| <div className="auto-field"> | |
| <label htmlFor="auto-table">Database</label> | |
| <select | |
| id="auto-table" | |
| className="auto-input" | |
| value={targetTable} | |
| onChange={(e) => { | |
| edit(setTargetTable)(e.target.value); | |
| setFieldKey(""); | |
| setUrlField(""); | |
| }} | |
| > | |
| <option value="">Choose a databaseβ¦</option> | |
| {targetTable && !tables.some((t) => t.key === targetTable) ? ( | |
| <option value={targetTable}>{targetTable} (not visible to you)</option> | |
| ) : null} | |
| {tables.map((t) => ( | |
| <option key={t.key} value={t.key}> | |
| {t.label} ({t.rowCount} {t.rowCount === 1 ? "row" : "rows"}) | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| <div className="auto-field"> | |
| <label htmlFor="auto-urlfield">Profile URL column</label> | |
| <select | |
| id="auto-urlfield" | |
| className="auto-input" | |
| value={urlField} | |
| onChange={(e) => edit(setUrlField)(e.target.value)} | |
| > | |
| <option value="">The column the field is bound to</option> | |
| {(table?.fields || []).map((f) => ( | |
| <option key={f.key} value={f.key}> | |
| {f.label} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| <div className="auto-field"> | |
| <label htmlFor="auto-fieldkey">Automation column</label> | |
| <select | |
| id="auto-fieldkey" | |
| className="auto-input" | |
| value={fieldKey} | |
| onChange={(e) => edit(setFieldKey)(e.target.value)} | |
| > | |
| <option value="">Choose a columnβ¦</option> | |
| {/* | |
| β THE STORED VALUE IS ALWAYS AN OPTION, even when the list | |
| below does not contain it. A <select> whose `value` matches | |
| no <option> renders the FIRST one β so a column the server | |
| knows about but this list has not caught up with would look | |
| like a DIFFERENT column, and the next Save would write that | |
| different column without anybody choosing it. Showing it as | |
| "(not in this database)" is the honest version. | |
| */} | |
| {fieldKey && !(table?.fields || []).some((f) => f.key === fieldKey) ? ( | |
| <option value={fieldKey}>{fieldKey} (not in this database)</option> | |
| ) : null} | |
| {(table?.fields || []) | |
| .filter((f) => f.type === "automation") | |
| .map((f) => ( | |
| <option key={f.key} value={f.key}> | |
| {f.label} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| {table && !(table.fields || []).some((f) => f.type === "automation") ? ( | |
| <p className="auto-note"> | |
| That database has no automation column yet. Add one from the grid’s | |
| column menu, then choose it here. | |
| </p> | |
| ) : null} | |
| </> | |
| ) : null} | |
| {panelKey === "capture" ? ( | |
| <> | |
| <div className="auto-field"> | |
| <label htmlFor="auto-maxposts">Posts per pull</label> | |
| <input | |
| id="auto-maxposts" | |
| className="auto-input" | |
| type="number" | |
| min={1} | |
| /* β WAVE 26 Β· ITEM 7 / C5 β 12, THE VENDOR'S OWN CEILING, MEASURED. | |
| β THIS BOX WAS THE LIVE DEFECT, not just an over-generous bound: it | |
| offered 200 and fell back to 24, and `clean_config` REFUSES a `maxPosts` | |
| the panel SENT above 12 (`clean_max_posts`, `submitted=True`). This | |
| panel sends the key on every save of a `field_instagram`, so clearing | |
| the box produced a 400 naming a cap the control had just invited the | |
| user past. */ | |
| max={12} | |
| value={maxPosts} | |
| onChange={(e) => edit(setMaxPosts)(Number(e.target.value) || 10)} | |
| /> | |
| <p className="auto-hint"> | |
| At most 12 β the provider returns a profile’s top 12 posts and no | |
| more. | |
| </p> | |
| </div> | |
| {/* β THREE <h3> ESSAYS ON THE CAPTURE RUNGS lived here β the vendor by name, | |
| datacenter egress, "204K not 204,312", which rung answers when. The | |
| CHOICE a person makes is exact-or-estimated and what it costs; the rest | |
| was the architecture explaining itself. DESIGN.md Β§4. */} | |
| {/* | |
| ββ WAVE 28 Β· R5 + R7 β THREE HEADINGS DESCRIBED A LADDER THAT NO LONGER | |
| EXISTS. "Exact counts" / "Post engagement" / "Estimated counts" named the | |
| capture RUNGS, and R5 retired the free anonymous rung from enrichment | |
| entirely: exact numbers or nothing, a vendor refusal reports blocked. A | |
| panel offering to "switch it off to get exact numbers or nothing" describes | |
| a choice the runner stopped having β a picture of the engine that disagrees | |
| with the engine, which is the defect this module refuses everywhere else. | |
| β THE MONEY SENTENCES WENT WITH THEM (R7): "charged per profile" and | |
| "Charged per post, so it is its own switch". Spend is reported in the RUN | |
| LOG against work already done, never as a permanent caption on a control. | |
| β `paidReady` SURVIVES, and R5 makes it MORE load-bearing rather than less. | |
| It is the one live reader of the flag, and with no free ladder underneath, a | |
| workspace with no provider connected gets nothing at all rather than a thin | |
| row β so the panel says so. That is a system fact, not a price. | |
| */} | |
| {/* | |
| β THE SENTENCE MAKES NO CLAIM ABOUT WHAT THE SWITCHES DO, and the first cut | |
| did: it read "post and comment data follow the switches above", which is | |
| NOT what `postMetrics` gates. Measured against the engine β | |
| `automation_engine.py:5358` ("likes/comments per post are bought, or the | |
| engagement series does not grow") and `connectors_ig.py:710` ("a separate, | |
| opt-in, ~13x-cost rung rather than a free by-product") β the profile pull | |
| keeps up to `maxPosts` posts REGARDLESS of that switch; what the switch buys | |
| is per-post ENGAGEMENT. R6 rules the LABEL ("Post data"), and the label is | |
| the owner's to set; a caption asserting a mechanism is mine, and this one | |
| would have been a picture of the engine that disagrees with the engine β | |
| the defect this module refuses in five other places. | |
| β ASK ->B is open on whether R5's reshape changes that. Until it is answered | |
| the honest panel says the one thing that is true either way. | |
| */} | |
| <h3>Included</h3> | |
| <p className="auto-hint"> | |
| The profile is always read. | |
| {paidReady ? "" : " No provider is connected on this workspace yet."} | |
| </p> | |
| </> | |
| ) : null} | |
| {panelKey === "find" ? ( | |
| <AutomationFind | |
| discover={discover} | |
| recordsLimit={recordsLimit} | |
| onRecordsLimit={(n) => { | |
| edit(setRecordsLimit)(n); | |
| setEstimate(null); | |
| }} | |
| joinOp={joinOp} | |
| onJoinOp={edit(setJoinOp)} | |
| preds={preds} | |
| onPreds={edit(setPreds)} | |
| estimate={estimate} | |
| onEstimate={() => { | |
| void discoverEstimate(recordsLimit) | |
| .then(setEstimate) | |
| .catch(() => setEstimate(null)); | |
| }} | |
| /> | |
| ) : null} | |
| {/* | |
| β WAVE 26 Β· ITEM 17 / R15 β THE LAST OF THE WRITE PANEL'S PROSE IS DELETED. | |
| Three paragraphs stood here after wave 24 had already cut four: "Profiles you | |
| find are saved here for you to review", "Each run adds a dated rowβ¦", and | |
| "Switch this off to test the automation without saving anything" (plus the | |
| `field_instagram` rider that a dry run still costs). Each was true; none was a | |
| decision anybody came to this panel to make. | |
| β WHAT SURVIVES IS THE ONE CONTROL: `scrape_db`'s Database name. R15 is | |
| "remove the explainer prose, KEEP THE SWITCHES", and the dry-run switch itself | |
| was never here β it is the write node's `auto-step-switch` in the Builder, which | |
| is exactly why this text could go without retiring anything. | |
| β So for a `discover_instagram` this panel body is now EMPTY, deliberately: the | |
| machine row above it still carries the node's name and its switch, and an empty | |
| body is the honest rendering of "there is nothing to configure here". | |
| */} | |
| {panelKey === "write" && kind === "scrape_db" ? ( | |
| <div className="auto-field"> | |
| <label htmlFor="auto-tlabel">Database name</label> | |
| <input | |
| id="auto-tlabel" | |
| className="auto-input" | |
| value={targetLabel} | |
| placeholder="S&P 500 companies" | |
| onChange={(e) => edit(setTargetLabel)(e.target.value)} | |
| /> | |
| </div> | |
| ) : null} | |
| </> | |
| ); | |
| return ( | |
| <div className="auto-detail"> | |
| <header className="auto-head"> | |
| {/* | |
| β ITEM 15b β THE TITLE ROW IS THE VIEW SWITCH NOW. It held an editable name, a run-state | |
| chip and a kind tag; all three left and Builder/Board took the space, left-aligned, with | |
| Run now / Save / Delete still on the right. | |
| WHERE EACH ONE WENT, because "removed" and "lost" are different claims: | |
| Β· the NAME is the first field of the Builder's Properties panel, writing on blur; | |
| Β· the STATE is the builder's own step chip, the Run-now button's "Runningβ¦", and the | |
| rail's per-automation dot β three surfaces that were already saying it; | |
| Β· the KIND stops being a user-facing fact at all this wave (R6: every new automation | |
| is `plain`), and the two legacy machine kinds describe themselves in Properties β | |
| Configuration β "How this fetches". | |
| β AND THE PAGE TITLE IS NOT DRAWN HERE EITHER WAY. The shell's `DbHead` (W23-W1) draws | |
| the SURFACE's name β the literal "Automation" off the nav payload β never this | |
| automation's. See the dated amendment under C-DETAIL: the contract's premise was wrong | |
| on that point and the consequence (no name in the Board view) is booked, not patched. | |
| */} | |
| <div className="auto-head-title"> | |
| <span className="autox-view is-on">Builder</span> | |
| </div> | |
| <div className="auto-head-actions"> | |
| {/* | |
| β W24-W1 β WHAT IT IS DOING, beside the button that started it. This is the whole of | |
| item 6's honest fix: "Runningβ¦" for two minutes is indistinguishable from a hung | |
| thread, and the engine has been publishing the step text the entire time. | |
| β IT IS A STATEMENT, NOT A CONTROL, and it renders only while there is something to | |
| say β `liveStepOf` returns `""` unless the automation is running, so this never shows | |
| a step left over from a run that finished. | |
| */} | |
| {liveStep ? ( | |
| <span className="auto-livestep" role="status"> | |
| {liveStep} | |
| </span> | |
| ) : runState.blocked ? ( | |
| /* β ITEM 22 / D-70 β THE SENTENCE THE EXIT CONDITION ASKS FOR, on screen rather than | |
| in a tooltip. A disabled button with no explanation reads as "it broke"; this says | |
| the search is already paid for and collects itself. It takes the `liveStep` slot | |
| because they answer the same question and only one of them is ever true. */ | |
| <span className="auto-livestep" role="status"> | |
| {runState.why} | |
| </span> | |
| ) : null} | |
| {/* β ITEM 22 / D-70 / R12 β ONE RUN PER AUTOMATION, and the label says which reason. | |
| The guard is `runBlock`, not `automation.running`: `running` is process state and is | |
| FALSE for the whole 20-30 minute vendor wait, which is precisely the window in which | |
| a second paid search was measured being bought. */} | |
| <button | |
| type="button" | |
| className="auto-btn" | |
| disabled={runState.blocked} | |
| title={runState.why || undefined} | |
| onClick={() => void start()} | |
| > | |
| {runState.blocked ? runState.label : "Run now"} | |
| </button> | |
| <button | |
| type="button" | |
| className="auto-btn is-primary" | |
| disabled={saving || !name.trim()} | |
| onClick={() => void save()} | |
| > | |
| {saving ? "Savingβ¦" : "Save"} | |
| </button> | |
| <button type="button" className="auto-btn is-danger" onClick={() => void remove()}> | |
| Delete | |
| </button> | |
| </div> | |
| </header> | |
| {problem ? ( | |
| <div className="auto-banner is-error" role="alert"> | |
| {problem} | |
| </div> | |
| ) : null} | |
| {message ? ( | |
| <div className="auto-banner is-ok" role="status"> | |
| {message} | |
| </div> | |
| ) : null} | |
| <div className="auto-work"> | |
| {/* | |
| β THE VIEW IS CHOSEN, NEVER INFERRED (C14 leg 1). This block used to be | |
| `{board ? <Board/> : <Steps/>}` β the page's SHAPE decided by whether a fetch had | |
| answered β so every automation you opened drew one layout and then replaced it with | |
| another. That second paint, showing the previous automation's shape inside the new | |
| one's frame, is the ghost the owner reported. | |
| `AutomationSteps` is DELETED with that branch. It was the honest fallback for a server | |
| with no `/board`; the Builder is a better answer to the same question (it renders the | |
| engine's own steps AND what the owner added, from data the rail already had) and there | |
| is no longer a state in which the reader gets it by accident. | |
| */} | |
| <> | |
| <AutomationBuilder | |
| automation={automation} | |
| // ITEM 15b β the name left the header, and this is where it went. Three REQUIRED | |
| // props rather than one: the cron field's shape (controlled while typing, one write | |
| // on blur) needs the text and the commit to be separable, and an OPTIONAL name would | |
| // degrade to "the field does not exist", which is indistinguishable from never | |
| // having built it. | |
| name={name} | |
| // β NOT `edit(setName)` β the name has its own immediate write (`commitName`), so | |
| // marking the whole FORM dirty for it would leave `touched` true forever after a | |
| // name edit and make the panel's stamp say "unsaved changes" about something it had | |
| // just saved. `touched` now means exactly "a field the header's Save owns has been | |
| // edited", which is what the stamp below reports. | |
| onNameText={setName} | |
| onNameCommit={() => void commitName()} | |
| // β THE PANEL'S STAMP MUST NOT LIE, and item 5 is what made this urgent. "All changes | |
| // saved" was honest while Properties held only immediate-write controls; the machine | |
| // steps' panels moved in this wave and THOSE commit with the header's Save (url, | |
| // extract, the column map, the discovery filters). A stamp promising they are saved, | |
| // above the fields that are not, is the same defect as an unsaved name under it. | |
| dirty={touched} | |
| {...(triggers ? { triggers } : {})} | |
| catalog={catalog || []} | |
| {...(vocab ? { vocab } : {})} | |
| tables={tables} | |
| /* β ITEM 9 / R11 β an action's Database picker can now MINT one, so the list has to | |
| be able to catch up. REQUIRED rather than optional: an optional refresh degrades to | |
| "the database was created and does not appear", which is worse than no button. */ | |
| onTablesChanged={reloadTables} | |
| oauth={oauth} | |
| busy={triggerBusy || saving} | |
| onPatch={(body) => void writeDefinition(body)} | |
| onToggleNode={(id) => void flip(id)} | |
| // β THE EXISTING TWO-WRITE SHAPE, not a second copy of it. `pickTrigger` writes | |
| // `trigger:{key}` AND `schedule.enabled` together, because the schedule half is | |
| // what today's engine reads β a builder that wrote only the key would leave "At a | |
| // scheduled time" selected on an automation with the cron switched off. | |
| onPickTrigger={(key) => void pickTrigger(key)} | |
| onRunNow={() => void start()} | |
| // Item 7's third clause: the IG trigger's Configuration carries the Scheduled-run | |
| // controls too, so the builder needs to know when to render the face it was handed. | |
| schedules={cronDriven} | |
| // Item 12a's root-cause fix β resolved HERE because it needs `discover.table`, which | |
| // is this component's prop and has no business being threaded into the builder just | |
| // to be read once. | |
| walkTable={walkTable} | |
| // ITEM 5 / C-CFG β handed over DIRECTLY now, not wrapped in `(node) => panelBody( | |
| // node.panel)`. The builder renders these grouped by panel key under "How this | |
| // fetches", so a wrapper taking a node would have to pick one node of a group and | |
| // imply the body belonged to it. | |
| renderNodeBody={panelBody} | |
| // ONE cron round-trip, rendered inside the Properties panel with its own picker | |
| // suppressed β the builder supplies the "Trigger type" select above it. | |
| scheduleFace={scheduleFace} | |
| // β WAVE 27 Β· C11 β the panel switch and its state, both owned here. | |
| showProperties={aside === "properties"} | |
| panelTabs={panelTabs} | |
| onStepPicked={() => setAside("properties")} | |
| /> | |
| {/* β THE RUN LOG IS UNMOUNTED WHEN HIDDEN, unlike Properties, and the asymmetry is | |
| deliberate rather than an oversight: this panel holds no editing state (its only | |
| local state, the drill-down, is this component's and survives), while re-mounting | |
| it re-reads nothing β the runs ride on `automation`. Properties keeps a half-typed | |
| name and cron, so hiding it has to leave it alive. */} | |
| <aside | |
| className="auto-panel is-appearing" | |
| aria-label="Run history" | |
| hidden={aside !== "runs"} | |
| > | |
| <> | |
| <div className="auto-panel-head"> | |
| {panelTabs} | |
| </div> | |
| {!automation.runs?.length ? ( | |
| <p className="auto-note"> | |
| It has not run yet. Press Run now, or give it a trigger. | |
| </p> | |
| ) : ( | |
| <ol className="auto-runs"> | |
| {automation.runs.map((r) => ( | |
| <li key={r.ts} className={"auto-run is-" + (r.ok ? "ok" : "error")}> | |
| <button | |
| type="button" | |
| className="auto-run-head" | |
| onClick={() => void openDrill(r)} | |
| aria-expanded={drillFor === r.ts} | |
| > | |
| <span className="auto-run-ts">{r.ts.replace("T", " ")}</span> | |
| <span className="auto-run-summary">{r.summary}</span> | |
| </button> | |
| <div className="auto-counts"> | |
| {COUNT_LABELS.filter(([k]) => typeof r.counts?.[k] === "number").map( | |
| ([k, label]) => ( | |
| <span | |
| key={k} | |
| className={ | |
| "auto-count" + (k === "capped" && r.counts[k] ? " is-warn" : "") | |
| } | |
| > | |
| <b>{r.counts[k]}</b> {label} | |
| </span> | |
| ) | |
| )} | |
| </div> | |
| {/* ββ D-103 β WHAT ACTUALLY HAPPENED, PER RECORD. The counts row above | |
| answers "how many" and the summary answers "roughly what"; neither | |
| could ever answer "why", which is the only question somebody opens this | |
| panel with. These are the vendor's own sentences, not our paraphrase. | |
| β Rendered unconditionally rather than behind the drill-down: the drill | |
| is a paid-ish round trip that lists ROWS, and a reason you have to go | |
| looking for is most of the way back to not having it. */} | |
| {r.notes?.length ? ( | |
| <ul className="auto-run-notes"> | |
| {r.notes.map((n, i) => ( | |
| <li key={i}>{n}</li> | |
| ))} | |
| </ul> | |
| ) : null} | |
| {drillFor === r.ts && drill ? ( | |
| <div className="auto-drill"> | |
| <p className="auto-hint"> | |
| The rows this run touched in {drill.label} | |
| {drill.truncated ? " (first 200)" : ""}. | |
| </p> | |
| <div className="auto-drill-scroll"> | |
| <table className="auto-table"> | |
| <thead> | |
| <tr> | |
| {drill.fields.map((f) => ( | |
| <th key={f.key}>{f.label}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {drill.rows.slice(0, 50).map((row) => ( | |
| <tr key={String(row.id)}> | |
| {drill.fields.map((f) => ( | |
| <td key={f.key}>{String(row[f.key] ?? "")}</td> | |
| ))} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ) : null} | |
| </li> | |
| ))} | |
| </ol> | |
| )} | |
| <p className="auto-hint"> | |
| Open a run to inspect the records it touched. | |
| </p> | |
| </> | |
| </aside> | |
| </> | |
| </div> | |
| </div> | |
| ); | |
| } | |