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&rsquo;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&rsquo;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>
  );
}