File size: 77,702 Bytes
bf8519f 051f280 bf8519f e1b3e71 bf8519f e1b3e71 bf8519f e1b3e71 bf8519f e1b3e71 bf8519f e1b3e71 bf8519f e1b3e71 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 0adeb0c bf8519f 0adeb0c bf8519f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 | // ---------------------------------------------------------------------------
// 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>
);
}
|