things-api 0.19.2 → 0.19.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
- package/dist/audit/schema.d.ts +14 -0
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/commands/op-result.js +13 -1
- package/dist/cli/commands/op-result.js.map +1 -1
- package/dist/cli/commands/writes.js +51 -7
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +7 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/op-result.d.ts +9 -0
- package/dist/op-result.js +5 -0
- package/dist/op-result.js.map +1 -1
- package/dist/ui-state.d.ts +1 -1
- package/dist/ui-state.js +33 -8
- package/dist/ui-state.js.map +1 -1
- package/dist/write/batch.js +4 -1
- package/dist/write/batch.js.map +1 -1
- package/dist/write/clone.js +12 -6
- package/dist/write/clone.js.map +1 -1
- package/dist/write/disclosures.d.ts +263 -0
- package/dist/write/disclosures.js +265 -0
- package/dist/write/disclosures.js.map +1 -0
- package/dist/write/heading.js +4 -7
- package/dist/write/heading.js.map +1 -1
- package/dist/write/make-repeating-project.js +2 -1
- package/dist/write/make-repeating-project.js.map +1 -1
- package/dist/write/opid.js +5 -3
- package/dist/write/opid.js.map +1 -1
- package/dist/write/pipeline.d.ts +39 -1
- package/dist/write/pipeline.js +49 -23
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/promote-clone.js +46 -36
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/reorder.js +45 -39
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/resolution-timestamps.js +4 -1
- package/dist/write/resolution-timestamps.js.map +1 -1
- package/dist/write/spawn-expectation.d.ts +135 -0
- package/dist/write/spawn-expectation.js +237 -0
- package/dist/write/spawn-expectation.js.map +1 -0
- package/dist/write/template-mutation.js +20 -33
- package/dist/write/template-mutation.js.map +1 -1
- package/dist/write/vectors/types.d.ts +12 -0
- package/dist/write/vectors/ui-recipes.js +11 -1
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui-state.d.ts +136 -6
- package/dist/write/vectors/ui-state.js +372 -70
- package/dist/write/vectors/ui-state.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +62 -2
- package/dist/write/vectors/ui.js +401 -151
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/delta.d.ts +17 -2
- package/dist/write/verify/delta.js +46 -16
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +3 -2
- package/dist/write/verify/poller.js +3 -1
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +1 -1
- package/skills/things-cli/SKILL.md +1 -1
package/dist/write/vectors/ui.js
CHANGED
|
@@ -35,37 +35,50 @@ import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachabi
|
|
|
35
35
|
import { certificationOf } from "./ui-certification.js";
|
|
36
36
|
import { chordCommand, driveHeadingChordReorder } from "./ui-chord.js";
|
|
37
37
|
import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
|
|
38
|
-
import { AX_DIALOG_SHELL_SNIPPET, describeFocusOwner, readUiState, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./ui-state.js";
|
|
38
|
+
import { AX_DIALOG_SHELL_SNIPPET, axFocusGuardPrelude, CENSUS_TIMEOUT_MS, censusUnverifiable, describeFocusOwner, describeUnprovenProbes, GUARD_REFUSED_TAG, parseGuardLog, readUiState, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./ui-state.js";
|
|
39
39
|
/** GUI driving can stall on an unanswered sheet; give each step headroom. */
|
|
40
40
|
const STEP_TIMEOUT_MS = 15_000;
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
* after the PERF2 audit: the control a mode switch reveals takes ~462ms to appear
|
|
44
|
-
* on the golden (S5b, [docs/lab/perf2-step-latency.md]), which EXCEEDS this
|
|
45
|
-
* interval — so a 300ms poll catches it on its second round; a finer interval
|
|
46
|
-
* would only add osascript hops for a marginal detection gain (UIC6 confirmed).
|
|
47
|
-
*/
|
|
48
|
-
const WAIT_POLL_MS = 300;
|
|
49
|
-
/**
|
|
50
|
-
* How long `resolveStepPath` polls a candidate-addressed control before failing
|
|
42
|
+
* How long a candidate-addressed control is polled for before the step fails
|
|
51
43
|
* closed. The full-vocabulary dialog reveals a pop-up/field a beat AFTER the
|
|
52
44
|
* frequency/Ends switch that precedes it (UIC6: ~250 ms), so the effective-form
|
|
53
|
-
* resolution must poll, not snap once.
|
|
45
|
+
* resolution must poll, not snap once. Since DRVLAT1 (issue #633) that poll runs
|
|
46
|
+
* IN-SCRIPT, inside the hop that acts on the control ({@link axCandidatePrelude}),
|
|
47
|
+
* rather than as its own osascript round-trip per candidate per round.
|
|
54
48
|
*/
|
|
55
49
|
const RESOLVE_CANDIDATE_TIMEOUT_MS = 5_000;
|
|
56
50
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
*
|
|
51
|
+
* In-script poll cadence for the element waits the drive folded into their own
|
|
52
|
+
* hops (DRVLAT1, issue #633). The JS-side poll it replaces was kept at a coarse
|
|
53
|
+
* 300ms deliberately — it paid a PROCESS SPAWN per round (PERF2 S5b), so a finer
|
|
54
|
+
* interval bought detection at the price of hops. An in-script poll pays one
|
|
55
|
+
* addressed `exists` per round, so it can be fine enough that a wait ends when
|
|
56
|
+
* the element lands rather than at the next 300ms boundary.
|
|
57
|
+
*/
|
|
58
|
+
const IN_SCRIPT_POLL_S = 0.05;
|
|
59
|
+
/**
|
|
60
|
+
* How long the canary and the eligibility assertion poll for the menu bar to
|
|
61
|
+
* repopulate around the newly-selected target (UIC1: the Items ▸ Repeat submenu
|
|
62
|
+
* appears only once a matching item is selected, and the update is not
|
|
63
|
+
* instantaneous).
|
|
64
|
+
*
|
|
65
|
+
* This REPLACES the fixed post-preamble settle (DRVLAT1, issue #633). That settle
|
|
66
|
+
* was 1500ms, trimmed to 1000ms by PERF2 against a measured ~92ms median / 116ms
|
|
67
|
+
* max menu repopulation (S5a) — i.e. it spent ~900ms of every drive waiting out a
|
|
68
|
+
* margin, on every host, whether or not the menu was already there. The closed-loop
|
|
69
|
+
* form is strictly better on both axes: it proceeds the moment the menu answers
|
|
70
|
+
* (the common case, ~one poll), and it tolerates a host slower than any fixed
|
|
71
|
+
* settle would have covered. Under-margining still only ever costs a fail-closed
|
|
72
|
+
* refusal, never a bad write (determinism doctrine; BEEP1 shape-settle precedent).
|
|
73
|
+
*/
|
|
74
|
+
const MENU_SETTLE_TIMEOUT_MS = 4_000;
|
|
75
|
+
/**
|
|
76
|
+
* Default window for a click's post-condition wait when the step names none.
|
|
77
|
+
* Every recipe that asserts one sets 5000 explicitly; this only keeps the
|
|
78
|
+
* in-script poll comfortably INSIDE the hop's own {@link STEP_TIMEOUT_MS}, which
|
|
79
|
+
* a 15s default would not (the poll would outlive its transport).
|
|
67
80
|
*/
|
|
68
|
-
const
|
|
81
|
+
const WAIT_ASSERT_TIMEOUT_MS = 5_000;
|
|
69
82
|
/**
|
|
70
83
|
* A shape-dependent step reached without the dialog having been measured — a
|
|
71
84
|
* recipe bug (the `probe-dialog-shape` step is missing or ran after its
|
|
@@ -299,6 +312,82 @@ end fgAssertFront`;
|
|
|
299
312
|
export function axResolveScript(path) {
|
|
300
313
|
return `${SE} to return (exists (${path}))`;
|
|
301
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* The AppleScript variable a folded candidate resolution binds the live element
|
|
317
|
+
* to. Every addressed script takes its target as `(<path>)`, so handing it this
|
|
318
|
+
* name — with {@link axCandidatePrelude} in front — makes the resolution and the
|
|
319
|
+
* action ONE hop instead of two (DRVLAT1, issue #633).
|
|
320
|
+
*/
|
|
321
|
+
export const STEP_ELEMENT_REF = "fgStepEl";
|
|
322
|
+
/**
|
|
323
|
+
* The message a folded candidate resolution raises when NONE of a step's expected
|
|
324
|
+
* element shapes appeared. Byte-identical to the driver's own wording for the
|
|
325
|
+
* separate-hop resolution it replaces, so a report reads the same either way.
|
|
326
|
+
*/
|
|
327
|
+
export const CANDIDATES_MISSED = "none of its expected element shapes resolved (neither the attached sheet nor the " +
|
|
328
|
+
"detached repeat editor window)";
|
|
329
|
+
/**
|
|
330
|
+
* IN-SCRIPT CANDIDATE RESOLUTION (DRVLAT1, issue #633).
|
|
331
|
+
*
|
|
332
|
+
* A candidate-addressed step used to dispatch one `resolve` hop PER CANDIDATE PER
|
|
333
|
+
* POLL ROUND before the hop that acted on whichever answered — a process spawn
|
|
334
|
+
* each, on top of a 300ms JS-side poll floor. This prelude does the same work in
|
|
335
|
+
* the acting hop's own process: it polls the candidates in the SAME priority
|
|
336
|
+
* order, binds the first that exists to {@link STEP_ELEMENT_REF}, and fails closed
|
|
337
|
+
* with {@link CANDIDATES_MISSED} when the window elapses with none of them there.
|
|
338
|
+
*
|
|
339
|
+
* Collapsing it is also strictly better against TOCTOU: the element the script
|
|
340
|
+
* acts on is the one it just proved exists, with nothing dispatched in between.
|
|
341
|
+
*/
|
|
342
|
+
export function axCandidatePrelude(paths, timeoutMs = RESOLVE_CANDIDATE_TIMEOUT_MS) {
|
|
343
|
+
const probes = paths
|
|
344
|
+
.map((p) => ` try
|
|
345
|
+
if (exists (${p})) then set ${STEP_ELEMENT_REF} to (${p})
|
|
346
|
+
end try
|
|
347
|
+
if ${STEP_ELEMENT_REF} is not missing value then exit repeat`)
|
|
348
|
+
.join("\n");
|
|
349
|
+
return `set ${STEP_ELEMENT_REF} to missing value
|
|
350
|
+
set fgT0 to (current date)
|
|
351
|
+
${SE}
|
|
352
|
+
repeat
|
|
353
|
+
${probes}
|
|
354
|
+
if ((current date) - fgT0) is greater than or equal to ${pollSeconds(timeoutMs)} then exit repeat
|
|
355
|
+
delay ${IN_SCRIPT_POLL_S}
|
|
356
|
+
end repeat
|
|
357
|
+
end tell
|
|
358
|
+
if ${STEP_ELEMENT_REF} is missing value then error "${escapeAppleScript(CANDIDATES_MISSED)}"`;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* wait: poll for ANY of the awaited element shapes to appear, IN-SCRIPT (DRVLAT1).
|
|
362
|
+
* One hop for the whole wait instead of one per candidate per round — which is
|
|
363
|
+
* what a slow host paid most: the dialog the drive is waiting for is exactly the
|
|
364
|
+
* thing that is slow when the app is busy. Returns "true", or "false" when the
|
|
365
|
+
* window elapses with none of them present (the driver's abort path is unchanged).
|
|
366
|
+
*/
|
|
367
|
+
export function axWaitAnyScript(paths, timeoutMs) {
|
|
368
|
+
const probes = paths
|
|
369
|
+
.map((p) => ` try
|
|
370
|
+
if (exists (${p})) then return "true"
|
|
371
|
+
end try`)
|
|
372
|
+
.join("\n");
|
|
373
|
+
return `set fgT0 to (current date)
|
|
374
|
+
${SE}
|
|
375
|
+
repeat
|
|
376
|
+
${probes}
|
|
377
|
+
if ((current date) - fgT0) is greater than or equal to ${pollSeconds(timeoutMs)} then return "false"
|
|
378
|
+
delay ${IN_SCRIPT_POLL_S}
|
|
379
|
+
end repeat
|
|
380
|
+
end tell`;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* A poll window in whole seconds, as the in-script `current date` deadline reads
|
|
384
|
+
* it (AppleScript dates carry second granularity). Never below 1: a sub-second
|
|
385
|
+
* window would make a loop that checks its deadline after the FIRST probe into a
|
|
386
|
+
* single-shot check, which is the race these loops exist to close.
|
|
387
|
+
*/
|
|
388
|
+
function pollSeconds(timeoutMs) {
|
|
389
|
+
return Math.max(1, Math.round(timeoutMs / 1000));
|
|
390
|
+
}
|
|
302
391
|
/** press: AXPress the element. */
|
|
303
392
|
export function axPressScript(path) {
|
|
304
393
|
return `${SE} to click (${path})`;
|
|
@@ -821,12 +910,20 @@ export function axSelectPopupScript(path, value) {
|
|
|
821
910
|
*/
|
|
822
911
|
export function axSelectPopupCandidatesScript(path, values) {
|
|
823
912
|
const list = values.map((v) => `"${escapeAppleScript(v)}"`).join(", ");
|
|
913
|
+
// The menu is WAITED FOR, not slept on (DRVLAT1, issue #633): the old loop paid
|
|
914
|
+
// a flat 0.3s after every click before it would look again, so the common case —
|
|
915
|
+
// one click, menu up in well under that — spent the remainder of the settle
|
|
916
|
+
// doing nothing. The click cadence is unchanged (one click per round, never a
|
|
917
|
+
// second click into a menu that is opening — BEEP1); only the looking is finer.
|
|
824
918
|
return `${SE}
|
|
825
919
|
set pu to (${path})
|
|
826
920
|
repeat 20 times
|
|
827
921
|
if (exists menu 1 of pu) then exit repeat
|
|
828
922
|
click pu
|
|
829
|
-
|
|
923
|
+
repeat 6 times
|
|
924
|
+
if (exists menu 1 of pu) then exit repeat
|
|
925
|
+
delay ${IN_SCRIPT_POLL_S}
|
|
926
|
+
end repeat
|
|
830
927
|
end repeat
|
|
831
928
|
repeat with candidate in {${list}}
|
|
832
929
|
if (exists menu item candidate of menu 1 of pu) then
|
|
@@ -1257,26 +1354,37 @@ return "NOMATCH"`;
|
|
|
1257
1354
|
* naming expected vs observed. Pure System Events + Things scripting, background-
|
|
1258
1355
|
* capable. One stable command shape per primitive.
|
|
1259
1356
|
*/
|
|
1260
|
-
export function axAssertEligibleScript(targetUuid, menuItemPath) {
|
|
1357
|
+
export function axAssertEligibleScript(targetUuid, menuItemPath, settleMs = MENU_SETTLE_TIMEOUT_MS) {
|
|
1261
1358
|
const u = escapeAppleScript(targetUuid);
|
|
1262
|
-
return `set
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
set
|
|
1273
|
-
tell application "
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
end tell
|
|
1278
|
-
if
|
|
1279
|
-
return "
|
|
1359
|
+
return `set t0 to (current date)
|
|
1360
|
+
set verdict to my aeCheck()
|
|
1361
|
+
repeat until verdict is "OK"
|
|
1362
|
+
if ((current date) - t0) is greater than or equal to ${pollSeconds(settleMs)} then exit repeat
|
|
1363
|
+
delay ${IN_SCRIPT_POLL_S}
|
|
1364
|
+
set verdict to my aeCheck()
|
|
1365
|
+
end repeat
|
|
1366
|
+
return verdict
|
|
1367
|
+
|
|
1368
|
+
on aeCheck()
|
|
1369
|
+
set selIds to {}
|
|
1370
|
+
tell application "Things3"
|
|
1371
|
+
try
|
|
1372
|
+
set selIds to id of selected to dos
|
|
1373
|
+
end try
|
|
1374
|
+
end tell
|
|
1375
|
+
if (count of selIds) is 0 then return "NOTSEL no to-do is selected after the reveal (expected ${u}) — the show URL navigated without selecting an eligible row"
|
|
1376
|
+
if (count of selIds) is greater than 1 then return "NOTSEL " & (count of selIds) & " to-dos are selected, expected exactly the target ${u}"
|
|
1377
|
+
set theId to (item 1 of selIds) as text
|
|
1378
|
+
if theId is not "${u}" then return "WRONGSEL the selected to-do is " & theId & ", expected the target ${u}"
|
|
1379
|
+
set repEnabled to false
|
|
1380
|
+
tell application "System Events" to tell process "Things3"
|
|
1381
|
+
try
|
|
1382
|
+
set repEnabled to enabled of ${menuItemPath}
|
|
1383
|
+
end try
|
|
1384
|
+
end tell
|
|
1385
|
+
if repEnabled is false then return "DISABLED the target ${u} is selected but its Repeat menu item is disabled (not an eligible row for this action)"
|
|
1386
|
+
return "OK"
|
|
1387
|
+
end aeCheck`;
|
|
1280
1388
|
}
|
|
1281
1389
|
/** activate: foreground Things (the fallback preamble step). */
|
|
1282
1390
|
export function axActivateScript() {
|
|
@@ -1483,6 +1591,25 @@ ${AX_DIALOG_SHELL_SNIPPET}
|
|
|
1483
1591
|
return "OK"
|
|
1484
1592
|
end tell`;
|
|
1485
1593
|
}
|
|
1594
|
+
/**
|
|
1595
|
+
* The Cancel button's on-screen FRAME, resolved through the same addressed
|
|
1596
|
+
* dialog-shell path the AXPress dismissal uses (issue #629). Feeds the pointer
|
|
1597
|
+
* fallback: if `AXPress` on the button reports success and the dialog is still
|
|
1598
|
+
* standing, a real click at the button's own AX-resolved centre is the next
|
|
1599
|
+
* thing to try before discarding the window wholesale. The frame comes from the
|
|
1600
|
+
* tree, never from a remembered coordinate, so a moved dialog fails closed.
|
|
1601
|
+
*/
|
|
1602
|
+
export function axCancelFrameScript() {
|
|
1603
|
+
return `${SE}
|
|
1604
|
+
${AX_DIALOG_SHELL_SNIPPET}
|
|
1605
|
+
if shellRef is missing value then error "no dialog is open"
|
|
1606
|
+
if not (exists button "Cancel" of shellRef) then error "the open dialog has no Cancel button"
|
|
1607
|
+
set _b to button "Cancel" of shellRef
|
|
1608
|
+
set _p to position of _b
|
|
1609
|
+
set _s to size of _b
|
|
1610
|
+
return ((item 1 of _p) as text) & " " & ((item 2 of _p) as text) & " " & ((item 1 of _s) as text) & " " & ((item 2 of _s) as text)
|
|
1611
|
+
end tell`;
|
|
1612
|
+
}
|
|
1486
1613
|
/**
|
|
1487
1614
|
* The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
|
|
1488
1615
|
* recovery): close the front Things window — which takes an attached modal sheet
|
|
@@ -1570,6 +1697,67 @@ async function pressCancel(run) {
|
|
|
1570
1697
|
}, STEP_TIMEOUT_MS);
|
|
1571
1698
|
return res.ok && res.stdout.trim() === "OK";
|
|
1572
1699
|
}
|
|
1700
|
+
/**
|
|
1701
|
+
* The pointer fallback for the Cancel rung (issue #629): a real click at the
|
|
1702
|
+
* button's own AX-resolved centre, for the case where `AXPress` reports success
|
|
1703
|
+
* and the dialog is demonstrably still standing. Needs Things frontmost — the
|
|
1704
|
+
* HID tap posts at the foreground surface (NATIVE1-e) — so it activates first,
|
|
1705
|
+
* and re-reads the frame AFTERWARDS, because bringing Things forward can
|
|
1706
|
+
* re-attach a detached editor as a sheet and move the button.
|
|
1707
|
+
*/
|
|
1708
|
+
async function clickCancel(run) {
|
|
1709
|
+
await run({
|
|
1710
|
+
primitive: "activate",
|
|
1711
|
+
label: "bring Things forward to click its dialog's Cancel button",
|
|
1712
|
+
script: axActivateScript(),
|
|
1713
|
+
}, STEP_TIMEOUT_MS);
|
|
1714
|
+
const frameRes = await run({
|
|
1715
|
+
primitive: "resolve-frame",
|
|
1716
|
+
label: "locate the open dialog's Cancel button",
|
|
1717
|
+
script: axCancelFrameScript(),
|
|
1718
|
+
}, STEP_TIMEOUT_MS);
|
|
1719
|
+
if (!frameRes.ok)
|
|
1720
|
+
return false;
|
|
1721
|
+
const center = parseFrameCenter(frameRes.stdout);
|
|
1722
|
+
if (center === null)
|
|
1723
|
+
return false;
|
|
1724
|
+
const res = await run(clickPointCommand(center.x, center.y, "click the open dialog's Cancel button"), STEP_TIMEOUT_MS);
|
|
1725
|
+
return res.ok;
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* The dismissal that needs NO working inspection (issue #629): press the
|
|
1729
|
+
* dialog's own Cancel button, PROVE the dialog is gone with one addressed
|
|
1730
|
+
* existence read, and fall through to a real click at the button's frame if the
|
|
1731
|
+
* press reported success while the dialog stayed up.
|
|
1732
|
+
*
|
|
1733
|
+
* This is the rung the field incident needed and did not have. Its cleanup
|
|
1734
|
+
* re-ran the census that had just stalled, learned nothing three times over,
|
|
1735
|
+
* and ended in the AX-blind close+reopen — which left the sheet standing, and
|
|
1736
|
+
* with it the app-wide scripting freeze that stopped the composite trashing its
|
|
1737
|
+
* own disposable copy (MODALX1 §2.1) and the Things Cloud sync gate.
|
|
1738
|
+
*
|
|
1739
|
+
* Everything here is addressed inside `process "Things3"`: {@link
|
|
1740
|
+
* axCancelDialogScript} and {@link axSheetOpenScript} are the same shape as the
|
|
1741
|
+
* drive steps that kept working while the census did not.
|
|
1742
|
+
*/
|
|
1743
|
+
async function semanticCancel(run, expected, owner) {
|
|
1744
|
+
const kind = expected === null ? {} : { sheetKind: expected };
|
|
1745
|
+
// A stack unwinds LIFO (MODALX1 §6), so press-and-verify in a loop rather
|
|
1746
|
+
// than pressing once and assuming.
|
|
1747
|
+
for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
|
|
1748
|
+
if (!(await pressCancel(run)))
|
|
1749
|
+
break;
|
|
1750
|
+
if (!(await sheetStillOpen(run))) {
|
|
1751
|
+
return { state: "dismissed", how: "cancel-button", ...kind, ...owner, unverified: true };
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
if (await clickCancel(run)) {
|
|
1755
|
+
if (!(await sheetStillOpen(run))) {
|
|
1756
|
+
return { state: "dismissed", how: "cancel-button", ...kind, ...owner, unverified: true };
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
return closeReopenRung(run, expected, owner, false, true);
|
|
1760
|
+
}
|
|
1573
1761
|
/**
|
|
1574
1762
|
* Clear a half-open dialog a failed drive left behind — AUDITED at every rung
|
|
1575
1763
|
* (issue #620; supersedes the unconditional Escape, which was measured firing
|
|
@@ -1594,8 +1782,15 @@ async function pressCancel(run) {
|
|
|
1594
1782
|
* `expected` is the dialog kind this drive was observed driving; see
|
|
1595
1783
|
* {@link oursToDismiss}.
|
|
1596
1784
|
*/
|
|
1597
|
-
async function clearDialog(run, expected = null) {
|
|
1598
|
-
|
|
1785
|
+
async function clearDialog(run, expected = null, inspectionStalled = false) {
|
|
1786
|
+
// #629: the inspection already refused to answer once. Asking it again buys
|
|
1787
|
+
// nothing and costs the caller another deadline — go straight to the rung
|
|
1788
|
+
// that needs no inspection and proves itself with one addressed read.
|
|
1789
|
+
if (inspectionStalled)
|
|
1790
|
+
return semanticCancel(run, expected, {});
|
|
1791
|
+
const census = await readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1792
|
+
if (censusUnverifiable(census))
|
|
1793
|
+
return semanticCancel(run, expected, {});
|
|
1599
1794
|
const owner = census !== null && !census.thingsFrontmost ? { focusOwner: describeFocusOwner(census) } : {};
|
|
1600
1795
|
const readable = census !== null && census.inspectable;
|
|
1601
1796
|
// 0. A clean, readable "no dialog" — but only trustworthy on a session whose
|
|
@@ -1623,7 +1818,7 @@ async function clearDialog(run, expected = null) {
|
|
|
1623
1818
|
for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
|
|
1624
1819
|
if (!(await pressCancel(run)))
|
|
1625
1820
|
break;
|
|
1626
|
-
const after = await readUiState(run,
|
|
1821
|
+
const after = await readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1627
1822
|
if (after === null || !after.inspectable)
|
|
1628
1823
|
break;
|
|
1629
1824
|
if (!after.sheetOpen)
|
|
@@ -1642,7 +1837,7 @@ async function clearDialog(run, expected = null) {
|
|
|
1642
1837
|
label: "bring Things forward to dismiss its dialog",
|
|
1643
1838
|
script: axActivateScript(),
|
|
1644
1839
|
}, STEP_TIMEOUT_MS);
|
|
1645
|
-
const reaudit = await readUiState(run,
|
|
1840
|
+
const reaudit = await readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1646
1841
|
if (reaudit !== null && reaudit.inspectable) {
|
|
1647
1842
|
if (!reaudit.sheetOpen)
|
|
1648
1843
|
return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
|
|
@@ -1654,7 +1849,7 @@ async function clearDialog(run, expected = null) {
|
|
|
1654
1849
|
}
|
|
1655
1850
|
if (front) {
|
|
1656
1851
|
await run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
|
|
1657
|
-
const after = await readUiState(run,
|
|
1852
|
+
const after = await readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1658
1853
|
if (after !== null && after.inspectable && !after.sheetOpen) {
|
|
1659
1854
|
return { state: "dismissed", how: "escape", ...kind, ...owner };
|
|
1660
1855
|
}
|
|
@@ -1668,7 +1863,13 @@ async function clearDialog(run, expected = null) {
|
|
|
1668
1863
|
* `blind` says the session itself is AX-blind, in which case NOTHING the census
|
|
1669
1864
|
* reports afterwards is evidence — the outcome is honestly unconfirmed.
|
|
1670
1865
|
*/
|
|
1671
|
-
async function closeReopenRung(run, expected, owner, blind = false
|
|
1866
|
+
async function closeReopenRung(run, expected, owner, blind = false,
|
|
1867
|
+
/**
|
|
1868
|
+
* #629: the window-state inspection is not answering, so the outcome is
|
|
1869
|
+
* decided by the ADDRESSED sheet-open read instead of a fresh census. The
|
|
1870
|
+
* verdict is still proven — just proven by a narrower question.
|
|
1871
|
+
*/
|
|
1872
|
+
inspectionStalled = false) {
|
|
1672
1873
|
await run({
|
|
1673
1874
|
primitive: "resolve",
|
|
1674
1875
|
label: "clear a stuck dialog (close the Things window and reopen it)",
|
|
@@ -1676,7 +1877,14 @@ async function closeReopenRung(run, expected, owner, blind = false) {
|
|
|
1676
1877
|
}, STEP_TIMEOUT_MS);
|
|
1677
1878
|
if (blind)
|
|
1678
1879
|
return { state: "cleared-blind", how: "window-close", ...owner };
|
|
1679
|
-
|
|
1880
|
+
if (inspectionStalled) {
|
|
1881
|
+
const kind = expected === null ? {} : { sheetKind: expected };
|
|
1882
|
+
if (!(await sheetStillOpen(run))) {
|
|
1883
|
+
return { state: "dismissed", how: "window-close", ...kind, ...owner, unverified: true };
|
|
1884
|
+
}
|
|
1885
|
+
return { state: "may-remain", ...kind, ...owner, unverified: true };
|
|
1886
|
+
}
|
|
1887
|
+
const after = await readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1680
1888
|
if (after === null || !after.inspectable) {
|
|
1681
1889
|
return { state: "cleared-blind", how: "window-close", ...owner };
|
|
1682
1890
|
}
|
|
@@ -1965,7 +2173,7 @@ async function defaultRun(command, timeoutMs) {
|
|
|
1965
2173
|
* nothing, and changes no state; see src/write/vectors/ui-state.ts.
|
|
1966
2174
|
*/
|
|
1967
2175
|
export function readLiveUiState(run = defaultRun) {
|
|
1968
|
-
return readUiState(run,
|
|
2176
|
+
return readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1969
2177
|
}
|
|
1970
2178
|
/**
|
|
1971
2179
|
* Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
|
|
@@ -2053,6 +2261,13 @@ export function judgeFocusGuard(state, expectedSheet, label) {
|
|
|
2053
2261
|
return refuse("the window and focus state could not be read, so there is no proof the input would reach " +
|
|
2054
2262
|
"Things — nothing was sent");
|
|
2055
2263
|
}
|
|
2264
|
+
// #629: a probe that did not come back is a DIAGNOSTIC, not a state. Say so
|
|
2265
|
+
// in those words, name what could not be established, and route the drive
|
|
2266
|
+
// straight to its cleanup — the caller must not read this as "retry".
|
|
2267
|
+
if (censusUnverifiable(state)) {
|
|
2268
|
+
return refuse(`the window state inspection timed out — treating the dialog as unverifiable (${describeUnprovenProbes(state)}). Nothing was sent, and the dialog this command opened is being closed. Check that Things ` +
|
|
2269
|
+
"is responding, then run the same command again");
|
|
2270
|
+
}
|
|
2056
2271
|
if (!state.inspectable) {
|
|
2057
2272
|
return refuse(`${describeFocusOwner(state)}. Input sent now would go to it, not to Things — nothing was ` +
|
|
2058
2273
|
"sent. Answer or dismiss the system dialog, then run the same command again");
|
|
@@ -2079,19 +2294,32 @@ export function judgeFocusGuard(state, expectedSheet, label) {
|
|
|
2079
2294
|
* state, taken immediately before the hop, and the in-script assertions close
|
|
2080
2295
|
* the remaining milliseconds (UI-automation determinism doctrine; the #595
|
|
2081
2296
|
* pre-commit audit and BEEP1 shape-settle are the same pattern).
|
|
2297
|
+
*
|
|
2298
|
+
* FOLDED for keystroke-class hops (DRVLAT1, issue #633). Those hops are
|
|
2299
|
+
* AppleScript, so the census can be — and now is — the PRELUDE OF THE VERY SCRIPT
|
|
2300
|
+
* THAT TYPES ({@link axFocusGuardPrelude}) rather than a hop of its own. Two
|
|
2301
|
+
* things improve at once: the drive stops paying a process spawn per typed
|
|
2302
|
+
* control, and the TOCTOU window between "the census approved this" and "the
|
|
2303
|
+
* keystroke went out" closes to nothing — no dispatch happens in between, because
|
|
2304
|
+
* there is nothing in between. The judgement is still made BEFORE the keystroke
|
|
2305
|
+
* (in-script, fail-closed) and the sentence a caller reads is still built HERE by
|
|
2306
|
+
* {@link judgeFocusGuard}, from the census that same hop logged, so there remains
|
|
2307
|
+
* exactly one wording of every refusal.
|
|
2308
|
+
*
|
|
2309
|
+
* POINTER-class hops keep the separate census: they dispatch JXA, which cannot
|
|
2310
|
+
* carry an AppleScript prelude.
|
|
2082
2311
|
*/
|
|
2083
2312
|
function guardedRun(inner, latch) {
|
|
2084
2313
|
return async (command, timeoutMs) => {
|
|
2085
|
-
|
|
2314
|
+
const keystroke = KEYSTROKE_CLASS.has(command.primitive);
|
|
2315
|
+
if (!keystroke && !POINTER_CLASS.has(command.primitive)) {
|
|
2086
2316
|
return inner(command, timeoutMs);
|
|
2087
2317
|
}
|
|
2088
|
-
const state = await readUiState(inner, STEP_TIMEOUT_MS);
|
|
2089
2318
|
// The dialog invariant applies to keystroke-class hops only: a pointer hop
|
|
2090
2319
|
// is aimed at a frame it resolved a moment ago and fails closed on its own
|
|
2091
2320
|
// if that frame moved.
|
|
2092
|
-
const expected =
|
|
2093
|
-
const
|
|
2094
|
-
if (guardRefusal !== null) {
|
|
2321
|
+
const expected = keystroke ? latch.sheet : null;
|
|
2322
|
+
const refuse = (state, why) => {
|
|
2095
2323
|
trace(() => ({
|
|
2096
2324
|
phase: "focus-guard",
|
|
2097
2325
|
event: "refused",
|
|
@@ -2100,11 +2328,43 @@ function guardedRun(inner, latch) {
|
|
|
2100
2328
|
frontmost: state?.frontmostApp ?? null,
|
|
2101
2329
|
sheetKind: state?.sheetKind ?? null,
|
|
2102
2330
|
inspectable: state?.inspectable ?? false,
|
|
2331
|
+
stalled: state?.stalledProbes ?? null,
|
|
2103
2332
|
}));
|
|
2104
|
-
return { ok: false, stdout: "", stderr:
|
|
2333
|
+
return { ok: false, stdout: "", stderr: why };
|
|
2334
|
+
};
|
|
2335
|
+
// An inspection that would not answer poisons every later inspection's
|
|
2336
|
+
// credibility, so the cleanup ladder is told once and never asks again
|
|
2337
|
+
// (issue #629).
|
|
2338
|
+
const noteStall = (state) => {
|
|
2339
|
+
if (state === null || censusUnverifiable(state))
|
|
2340
|
+
latch.inspectionStalled = true;
|
|
2341
|
+
};
|
|
2342
|
+
// Latched only on a census that APPROVED the hop — a dialog seen while
|
|
2343
|
+
// refusing is, by construction, not the one this drive is driving.
|
|
2344
|
+
const latchSheet = (state) => {
|
|
2345
|
+
if (state !== null && state.sheetOpen && latch.sheet === null)
|
|
2346
|
+
latch.sheet = state.sheetKind;
|
|
2347
|
+
};
|
|
2348
|
+
if (keystroke && command.lang !== "javascript" && typeof command.script === "string") {
|
|
2349
|
+
const res = await inner({ ...command, script: `${axFocusGuardPrelude(expected)}\n${command.script}` }, timeoutMs);
|
|
2350
|
+
const { state, stderr } = parseGuardLog(res.stderr);
|
|
2351
|
+
noteStall(state);
|
|
2352
|
+
if (!res.ok && stderr.includes(GUARD_REFUSED_TAG)) {
|
|
2353
|
+
// The script refused. Re-judge the census it logged for the sentence; a
|
|
2354
|
+
// census too damaged to re-judge still refuses — the hop already did.
|
|
2355
|
+
return refuse(state, judgeFocusGuard(state, expected, command.label) ??
|
|
2356
|
+
`refused to run "${command.label}": the window and focus state could not be read, so ` +
|
|
2357
|
+
"there is no proof the input would reach Things — nothing was sent");
|
|
2358
|
+
}
|
|
2359
|
+
latchSheet(state);
|
|
2360
|
+
return { ...res, stderr };
|
|
2105
2361
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2362
|
+
const state = await readUiState(inner, CENSUS_TIMEOUT_MS);
|
|
2363
|
+
noteStall(state);
|
|
2364
|
+
const guardRefusal = judgeFocusGuard(state, expected, command.label);
|
|
2365
|
+
if (guardRefusal !== null)
|
|
2366
|
+
return refuse(state, guardRefusal);
|
|
2367
|
+
latchSheet(state);
|
|
2108
2368
|
return inner(command, timeoutMs);
|
|
2109
2369
|
};
|
|
2110
2370
|
}
|
|
@@ -2127,6 +2387,10 @@ function dialogNoun(kind) {
|
|
|
2127
2387
|
*/
|
|
2128
2388
|
export function describeCleanup(clear) {
|
|
2129
2389
|
const owner = clear.focusOwner === undefined ? "" : ` (${clear.focusOwner} when cleanup started)`;
|
|
2390
|
+
// #629: when the window-state inspection stalled, the cleanup still ran and
|
|
2391
|
+
// still proved its outcome — through the narrower addressed read. Say which
|
|
2392
|
+
// it was, so nobody reads "confirmed closed" as more than it is.
|
|
2393
|
+
const how = clear.unverified === true ? " (the window state could not be inspected)" : "";
|
|
2130
2394
|
switch (clear.state) {
|
|
2131
2395
|
case "none":
|
|
2132
2396
|
return "No dialog was left open in Things.";
|
|
@@ -2135,7 +2399,7 @@ export function describeCleanup(clear) {
|
|
|
2135
2399
|
? `${dialogNoun(clear.sheetKind)} was closed with its own Cancel button`
|
|
2136
2400
|
: clear.how === "escape"
|
|
2137
2401
|
? `${dialogNoun(clear.sheetKind)} was dismissed with Escape`
|
|
2138
|
-
: `${dialogNoun(clear.sheetKind)} was cleared by closing and reopening the Things window`}, confirmed closed${owner}.`;
|
|
2402
|
+
: `${dialogNoun(clear.sheetKind)} was cleared by closing and reopening the Things window`}, confirmed closed${how}${owner}.`;
|
|
2139
2403
|
case "cleared-blind":
|
|
2140
2404
|
return ("Things had no window reachable on the current screen (the Mac may be locked, or a" +
|
|
2141
2405
|
" full-screen app is covering the desktop), so the open dialog could not be confirmed" +
|
|
@@ -2178,40 +2442,43 @@ function canaryPaths(recipe) {
|
|
|
2178
2442
|
}
|
|
2179
2443
|
return out;
|
|
2180
2444
|
}
|
|
2181
|
-
/**
|
|
2182
|
-
* Resolve a step's effective element path. A `pathCandidates` step dispatches
|
|
2183
|
-
* against the FIRST candidate that exists (the dialog-form disjunction — attached
|
|
2184
|
-
* sheet vs detached AXUnknown window, UIC4-a). The candidates are POLLED over a
|
|
2185
|
-
* bounded window because the full-vocabulary controls are REVEALED by the
|
|
2186
|
-
* preceding step: switching the frequency pop-up to weekly/monthly/yearly (or
|
|
2187
|
-
* ticking Ends=after) re-lays-out the cadence group, and the new pop-up/field
|
|
2188
|
-
* lands ~250 ms later (UIC6). A single immediate exists-check races that render
|
|
2189
|
-
* and would spuriously fail closed; polling matches the `dynamic` nature these
|
|
2190
|
-
* steps already declare. Returns null when none resolve within the window.
|
|
2191
|
-
*/
|
|
2192
|
-
async function resolveStepPath(step, run) {
|
|
2193
|
-
if (step.pathCandidates === undefined)
|
|
2194
|
-
return step.path ?? null;
|
|
2195
|
-
const candidates = step.pathCandidates;
|
|
2196
|
-
const deadline = Date.now() + (step.timeoutMs ?? RESOLVE_CANDIDATE_TIMEOUT_MS);
|
|
2197
|
-
for (;;) {
|
|
2198
|
-
for (const candidate of candidates) {
|
|
2199
|
-
// candidates are tried in priority order; the first hit wins, so a race would blur which form matched
|
|
2200
|
-
const res = await run({ primitive: "resolve", label: step.label, script: axResolveScript(candidate) }, STEP_TIMEOUT_MS);
|
|
2201
|
-
if (res.ok && res.stdout.trim() === "true")
|
|
2202
|
-
return candidate;
|
|
2203
|
-
}
|
|
2204
|
-
if (Date.now() >= deadline)
|
|
2205
|
-
return null;
|
|
2206
|
-
// the revealed control lands a beat after the mode switch; poll until it does
|
|
2207
|
-
await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
|
|
2208
|
-
}
|
|
2209
|
-
}
|
|
2210
2445
|
function refusal(detail) {
|
|
2211
2446
|
return { exitCode: 1, stdout: "", stderr: detail };
|
|
2212
2447
|
}
|
|
2213
|
-
/**
|
|
2448
|
+
/**
|
|
2449
|
+
* Compile one recipe step into its primitive command (no dispatch).
|
|
2450
|
+
*
|
|
2451
|
+
* A CANDIDATE-ADDRESSED step compiles to ONE script that resolves its own element
|
|
2452
|
+
* and then acts on it (DRVLAT1, issue #633): the addressed body is generated
|
|
2453
|
+
* against {@link STEP_ELEMENT_REF} and {@link axCandidatePrelude} is prepended, so
|
|
2454
|
+
* the resolution that used to be a separate `resolve` hop (or several) now rides
|
|
2455
|
+
* the acting hop. Steps whose script is JXA, or that resolve their own target,
|
|
2456
|
+
* are unaffected.
|
|
2457
|
+
*/
|
|
2214
2458
|
export function commandForStep(step, targetUuid) {
|
|
2459
|
+
if (step.primitive === "wait") {
|
|
2460
|
+
// The whole wait is ONE hop: the candidates are polled in-script until one of
|
|
2461
|
+
// them exists or the step's own window elapses (DRVLAT1).
|
|
2462
|
+
const paths = step.pathCandidates ?? [step.path ?? ""];
|
|
2463
|
+
return {
|
|
2464
|
+
primitive: "wait",
|
|
2465
|
+
label: step.label,
|
|
2466
|
+
script: axWaitAnyScript(paths, step.timeoutMs ?? STEP_TIMEOUT_MS),
|
|
2467
|
+
};
|
|
2468
|
+
}
|
|
2469
|
+
if (step.pathCandidates !== undefined && step.path === undefined) {
|
|
2470
|
+
const candidates = step.pathCandidates;
|
|
2471
|
+
const inner = commandForStep({ ...step, path: STEP_ELEMENT_REF }, targetUuid);
|
|
2472
|
+
// Only an AppleScript body can take the AppleScript prelude; a JXA step
|
|
2473
|
+
// (set-datetime, the pointer primitives) resolves its own target anyway.
|
|
2474
|
+
if (inner.lang === "javascript" || typeof inner.script !== "string" || inner.script === "") {
|
|
2475
|
+
return inner;
|
|
2476
|
+
}
|
|
2477
|
+
return {
|
|
2478
|
+
...inner,
|
|
2479
|
+
script: `${axCandidatePrelude(candidates, RESOLVE_CANDIDATE_TIMEOUT_MS)}\n${inner.script}`,
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2215
2482
|
switch (step.primitive) {
|
|
2216
2483
|
case "reveal":
|
|
2217
2484
|
return { primitive: "reveal", label: step.label, url: revealUrl(step.value ?? targetUuid) };
|
|
@@ -2301,8 +2568,6 @@ export function commandForStep(step, targetUuid) {
|
|
|
2301
2568
|
// shape-selected group pop-up index of the first weekday row (RDLG2).
|
|
2302
2569
|
script: axConvergeWeekdaysScript(step.path ?? "", weekdayBaseOf(step.value ?? ""), weekdayTitlesOf(step.value ?? "")),
|
|
2303
2570
|
};
|
|
2304
|
-
case "wait":
|
|
2305
|
-
return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
|
|
2306
2571
|
case "select-row":
|
|
2307
2572
|
return {
|
|
2308
2573
|
primitive: "select-row",
|
|
@@ -2418,12 +2683,13 @@ async function driveClickElement(step, run) {
|
|
|
2418
2683
|
};
|
|
2419
2684
|
}
|
|
2420
2685
|
if (step.assertPath !== undefined) {
|
|
2421
|
-
|
|
2686
|
+
// one hop, polled in-script (DRVLAT1)
|
|
2687
|
+
const res = await run({
|
|
2422
2688
|
primitive: "wait",
|
|
2423
2689
|
label: step.assertLabel ?? step.label,
|
|
2424
|
-
script:
|
|
2425
|
-
},
|
|
2426
|
-
if (!ok) {
|
|
2690
|
+
script: axWaitAnyScript([step.assertPath], step.assertTimeoutMs ?? WAIT_ASSERT_TIMEOUT_MS),
|
|
2691
|
+
}, STEP_TIMEOUT_MS);
|
|
2692
|
+
if (!(res.ok && res.stdout.trim() === "true")) {
|
|
2427
2693
|
return {
|
|
2428
2694
|
ok: false,
|
|
2429
2695
|
why: `${step.assertLabel ?? "the expected element"} did not appear after the click`,
|
|
@@ -2552,11 +2818,12 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
|
|
|
2552
2818
|
// Every step below dispatches through the PER-STEP FOCUS GUARD (issue #620);
|
|
2553
2819
|
// the latch records the dialog this drive is observed driving, so the cleanup
|
|
2554
2820
|
// ladder can tell our own half-open dialog from one the user opened after us.
|
|
2555
|
-
const latch = { sheet: null };
|
|
2821
|
+
const latch = { sheet: null, inspectionStalled: false };
|
|
2556
2822
|
const run = guardedRun(rawRun, latch);
|
|
2557
2823
|
// The cleanup ladder audits for itself (it is what decides whether a keystroke
|
|
2558
|
-
// may be sent at all), so it runs OUTSIDE the guard
|
|
2559
|
-
|
|
2824
|
+
// may be sent at all), so it runs OUTSIDE the guard — and it is told when the
|
|
2825
|
+
// inspection has already stalled, so it never re-runs it (issue #629).
|
|
2826
|
+
const clearNow = () => clearDialog(rawRun, latch.sheet, latch.inspectionStalled);
|
|
2560
2827
|
const done = [];
|
|
2561
2828
|
// The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
|
|
2562
2829
|
// own timeout on a slow production database (large + Things-Cloud syncing
|
|
@@ -2587,6 +2854,7 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
|
|
|
2587
2854
|
exitCode: 1,
|
|
2588
2855
|
stdout: `ui drive watchdog stopped after ${done.length} step(s): ${done.join(" → ") || "nothing"}`,
|
|
2589
2856
|
stderr: `ui drive exceeded its ${Math.round(budgetMs / 1000)}s budget at "${lastStep}"`,
|
|
2857
|
+
steps: [...done],
|
|
2590
2858
|
timedOut: true,
|
|
2591
2859
|
watchdog: {
|
|
2592
2860
|
budgetMs,
|
|
@@ -2620,7 +2888,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
|
|
|
2620
2888
|
// reachable to be driven — which is not the app accepting a command and
|
|
2621
2889
|
// changing nothing, and must not be reported as that.
|
|
2622
2890
|
const cause = clear?.state === "cleared-blind" ? "unreachable" : stepTimedOut ? "unresponsive" : null;
|
|
2623
|
-
|
|
2891
|
+
// The step list rides EVERY partial exit: a failure always carries the
|
|
2892
|
+
// play-by-play, which is what made the field bug reports rich (#632). The
|
|
2893
|
+
// step that stopped the drive is named as the last entry so the list reads
|
|
2894
|
+
// as the whole attempt, not only the part that worked.
|
|
2895
|
+
const res = { ...refusal(base + cleanup), steps: [...done, `${failed} — FAILED: ${why}`] };
|
|
2624
2896
|
if (cause === null)
|
|
2625
2897
|
return res;
|
|
2626
2898
|
return {
|
|
@@ -2654,9 +2926,9 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
|
|
|
2654
2926
|
done.push(step.label);
|
|
2655
2927
|
idx += 1;
|
|
2656
2928
|
}
|
|
2657
|
-
//
|
|
2658
|
-
|
|
2659
|
-
|
|
2929
|
+
// The menu bar repopulates around the new selection a beat after the preamble
|
|
2930
|
+
// (UIC1). That beat is WAITED OUT IN THE CANARY below, which polls each element
|
|
2931
|
+
// it must resolve — no fixed settle stands here any more (DRVLAT1, issue #633).
|
|
2660
2932
|
// 0½. Session-reachability GATE for dialog-class ops (SESSGATE, #480). A recipe
|
|
2661
2933
|
// that opens a sheet on the main window needs that window AX-reachable on
|
|
2662
2934
|
// the current Space. Probed AFTER the preamble (which surfaces a window in a
|
|
@@ -2681,7 +2953,12 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
|
|
|
2681
2953
|
// directly, for every recipe, before anything is pressed — and a dialog
|
|
2682
2954
|
// standing here also means the app is ignoring scripted changes app-wide
|
|
2683
2955
|
// and holding Things Cloud sync, which is the operator's real problem.
|
|
2684
|
-
const startState = await readUiState(rawRun,
|
|
2956
|
+
const startState = await readUiState(rawRun, CENSUS_TIMEOUT_MS);
|
|
2957
|
+
// An inspection that stalls at the very first hop is remembered, so a later
|
|
2958
|
+
// failure's cleanup does not go asking it again (issue #629). The preflight
|
|
2959
|
+
// itself stays permissive — only a POSITIVE sighting refuses (MODALX1 §7).
|
|
2960
|
+
if (censusUnverifiable(startState))
|
|
2961
|
+
latch.inspectionStalled = true;
|
|
2685
2962
|
if (startState !== null && startState.inspectable && startState.sheetOpen) {
|
|
2686
2963
|
return refusal(`ui preflight refused: a dialog is already open in Things (${startState.sheetKind}${startState.sheetDepth > 1 ? `, on top of ${startState.sheetDepth - 1} more` : ""}), most likely left over from an earlier command or opened by hand. While one is open the ` +
|
|
2687
2964
|
"app disables its menu bar, ignores keyboard input aimed at anything else, and " +
|
|
@@ -2693,7 +2970,13 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
|
|
|
2693
2970
|
// pressed. (This is also the localization check: English titles must resolve.)
|
|
2694
2971
|
for (const { path, label } of canaryPaths(recipe)) {
|
|
2695
2972
|
// the canary resolves elements one at a time; a single miss aborts before anything is pressed, so parallelizing would waste work and blur which element failed
|
|
2696
|
-
|
|
2973
|
+
// POLLED, not snapped: this is where the drive waits out the menu-bar
|
|
2974
|
+
// repopulation the preamble triggered (DRVLAT1 — it replaces the fixed settle).
|
|
2975
|
+
const res = await run({
|
|
2976
|
+
primitive: "resolve",
|
|
2977
|
+
label,
|
|
2978
|
+
script: axWaitAnyScript([path], MENU_SETTLE_TIMEOUT_MS),
|
|
2979
|
+
}, STEP_TIMEOUT_MS);
|
|
2697
2980
|
if (!res.ok || res.stdout.trim() !== "true") {
|
|
2698
2981
|
// (e) A leftover modal sheet/popover from an earlier aborted drive disables
|
|
2699
2982
|
// the menu bar, so the Items ▸ Repeat path cannot resolve. Detect that
|
|
@@ -2754,9 +3037,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
|
|
|
2754
3037
|
return watchdogResult(step.label);
|
|
2755
3038
|
if (step.primitive === "wait") {
|
|
2756
3039
|
// A candidate-addressed wait polls for ANY of its shapes to appear (the
|
|
2757
|
-
// dialog opening as an attached sheet OR a detached AXUnknown window)
|
|
3040
|
+
// dialog opening as an attached sheet OR a detached AXUnknown window) — the
|
|
3041
|
+
// whole poll inside ONE hop (DRVLAT1).
|
|
2758
3042
|
// steps are strictly sequential: this wait must resolve before the step that acts on the awaited element runs
|
|
2759
|
-
const
|
|
3043
|
+
const res = await run(commandForStep(step, recipe.targetUuid), STEP_TIMEOUT_MS);
|
|
3044
|
+
const ok = res.ok && res.stdout.trim() === "true";
|
|
2760
3045
|
if (!ok) {
|
|
2761
3046
|
// the abort keystroke must land (and be verified) before returning the partial-state report
|
|
2762
3047
|
const clear = await clearNow();
|
|
@@ -2794,19 +3079,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
|
|
|
2794
3079
|
done.push(`${step.label} (${outcome.detail})`);
|
|
2795
3080
|
continue;
|
|
2796
3081
|
}
|
|
2797
|
-
//
|
|
2798
|
-
//
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
if (effective === null) {
|
|
2803
|
-
// dismiss whatever opened (and verify) before reporting
|
|
2804
|
-
const clear = await clearNow();
|
|
2805
|
-
return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
|
|
2806
|
-
"detached repeat editor window)", clear);
|
|
2807
|
-
}
|
|
2808
|
-
step = { ...step, path: effective };
|
|
2809
|
-
}
|
|
3082
|
+
// A candidate-addressed step resolves its effective element (the
|
|
3083
|
+
// sheet-vs-detached-window disjunction) INSIDE its own script now — see
|
|
3084
|
+
// commandForStep / axCandidatePrelude. A miss raises CANDIDATES_MISSED there
|
|
3085
|
+
// and lands on this step's ordinary failure path, with the same wording and
|
|
3086
|
+
// the same clean abort it had when the resolution was its own hop (DRVLAT1).
|
|
2810
3087
|
const command = commandForStep(step, recipe.targetUuid);
|
|
2811
3088
|
if (step.primitive === "probe-dialog-shape") {
|
|
2812
3089
|
// MEASURE the dialog (RDLG2) before any shape-dependent control is touched.
|
|
@@ -2922,39 +3199,12 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
|
|
|
2922
3199
|
exitCode: 0,
|
|
2923
3200
|
stdout: `${relocationNote}drove ${done.length} step(s): ${done.join(" → ")}`,
|
|
2924
3201
|
stderr: "",
|
|
3202
|
+
// The same play-by-play as a LIST (#632). `stdout` keeps the prose form the
|
|
3203
|
+
// trace and the transport-failure paths already read; `steps` is what the
|
|
3204
|
+
// change-history record stores and `--verbose` renders.
|
|
3205
|
+
steps: relocationNote === "" ? [...done] : [relocationNote.trim(), ...done],
|
|
2925
3206
|
};
|
|
2926
3207
|
}
|
|
2927
|
-
async function waitForElement(command, timeoutMs, run) {
|
|
2928
|
-
const deadline = Date.now() + timeoutMs;
|
|
2929
|
-
for (;;) {
|
|
2930
|
-
// polling the same element until it appears is inherently sequential
|
|
2931
|
-
const res = await run(command, STEP_TIMEOUT_MS);
|
|
2932
|
-
if (res.ok && res.stdout.trim() === "true")
|
|
2933
|
-
return true;
|
|
2934
|
-
if (Date.now() >= deadline)
|
|
2935
|
-
return false;
|
|
2936
|
-
// inter-poll delay between sequential existence checks
|
|
2937
|
-
await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
|
|
2938
|
-
}
|
|
2939
|
-
}
|
|
2940
|
-
/** Poll until ANY of the candidate element shapes exists (the sheet-vs-detached-window disjunction). */
|
|
2941
|
-
async function waitForAnyElement(paths, label, timeoutMs, run) {
|
|
2942
|
-
const deadline = Date.now() + timeoutMs;
|
|
2943
|
-
for (;;) {
|
|
2944
|
-
for (const path of paths) {
|
|
2945
|
-
// Emitted as the `wait` primitive (not `resolve`) so the command stream a
|
|
2946
|
-
// caller observes is unchanged from the single-path waitForElement.
|
|
2947
|
-
// candidates checked in priority order; the first present shape ends the wait
|
|
2948
|
-
const res = await run({ primitive: "wait", label, script: axResolveScript(path) }, STEP_TIMEOUT_MS);
|
|
2949
|
-
if (res.ok && res.stdout.trim() === "true")
|
|
2950
|
-
return true;
|
|
2951
|
-
}
|
|
2952
|
-
if (Date.now() >= deadline)
|
|
2953
|
-
return false;
|
|
2954
|
-
// inter-poll delay between sequential existence checks
|
|
2955
|
-
await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
|
|
2956
|
-
}
|
|
2957
|
-
}
|
|
2958
3208
|
function enabledMatrix() {
|
|
2959
3209
|
const matrix = {};
|
|
2960
3210
|
for (const op of UI_DRIVE_OPS) {
|
|
@@ -3031,7 +3281,7 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
|
|
|
3031
3281
|
// composite's FIRST leg mints a row through the URL scheme, which sails
|
|
3032
3282
|
// straight past an open dialog — and every AppleScript leg after it then
|
|
3033
3283
|
// fails, leaving a copy behind. The orchestrator asks this BEFORE it seeds.
|
|
3034
|
-
probeUiState: () => readUiState(tracedRun,
|
|
3284
|
+
probeUiState: () => readUiState(tracedRun, CENSUS_TIMEOUT_MS),
|
|
3035
3285
|
// Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
|
|
3036
3286
|
// live session BEFORE they seed a row, so a locked/full-screen session refuses
|
|
3037
3287
|
// with zero mutation. Present regardless of `enabled` (the orchestrator has
|