things-api 0.15.0 → 0.17.0
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 +4 -4
- package/dist/audit/schema.d.ts +8 -0
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/commands/install-skill.js +9 -2
- package/dist/cli/commands/install-skill.js.map +1 -1
- package/dist/cli/commands/repeat-flags.d.ts +14 -4
- package/dist/cli/commands/repeat-flags.js +113 -33
- package/dist/cli/commands/repeat-flags.js.map +1 -1
- package/dist/cli/commands/todo.js +49 -2
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +241 -66
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/help.js +23 -13
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/interrupt.d.ts +42 -0
- package/dist/cli/interrupt.js +105 -0
- package/dist/cli/interrupt.js.map +1 -0
- package/dist/cli/main.js +5 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/read-driver.d.ts +2 -2
- package/dist/cli/skill.d.ts +24 -0
- package/dist/cli/skill.js +40 -0
- package/dist/cli/skill.js.map +1 -1
- package/dist/client.d.ts +53 -12
- package/dist/client.js +9 -1
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +39 -0
- package/dist/config.js +16 -1
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +9 -2
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +132 -31
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/entities.d.ts +35 -0
- package/dist/model/entities.js.map +1 -1
- package/dist/model/occurrences.d.ts +14 -1
- package/dist/model/occurrences.js +20 -4
- package/dist/model/occurrences.js.map +1 -1
- package/dist/model/recurrence.d.ts +60 -9
- package/dist/model/recurrence.js +76 -19
- package/dist/model/recurrence.js.map +1 -1
- package/dist/paths.d.ts +8 -0
- package/dist/paths.js +10 -0
- package/dist/paths.js.map +1 -1
- package/dist/read/detail.js +49 -1
- package/dist/read/detail.js.map +1 -1
- package/dist/read/queries.d.ts +12 -0
- package/dist/read/queries.js +14 -0
- package/dist/read/queries.js.map +1 -1
- package/dist/read/shape.d.ts +25 -22
- package/dist/read/shape.js +81 -55
- package/dist/read/shape.js.map +1 -1
- package/dist/read/stage.d.ts +5 -5
- package/dist/read/stage.js +2 -2
- package/dist/read/views.d.ts +1 -1
- package/dist/read/views.js +4 -4
- package/dist/surface-copy.d.ts +2 -2
- package/dist/surface-copy.js +2 -2
- package/dist/sync-health.js +2 -1
- package/dist/sync-health.js.map +1 -1
- package/dist/trace/tracer.d.ts +87 -0
- package/dist/trace/tracer.js +167 -0
- package/dist/trace/tracer.js.map +1 -0
- package/dist/write/batch.d.ts +28 -3
- package/dist/write/batch.js +162 -46
- package/dist/write/batch.js.map +1 -1
- package/dist/write/clear-reminder.js +24 -21
- package/dist/write/clear-reminder.js.map +1 -1
- package/dist/write/clone.d.ts +4 -0
- package/dist/write/clone.js +531 -0
- package/dist/write/clone.js.map +1 -0
- package/dist/write/commands.js +140 -49
- package/dist/write/commands.js.map +1 -1
- package/dist/write/completion-context.d.ts +57 -0
- package/dist/write/completion-context.js +49 -0
- package/dist/write/completion-context.js.map +1 -0
- package/dist/write/failure-hints.d.ts +10 -1
- package/dist/write/failure-hints.js +8 -0
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.d.ts +1 -1
- package/dist/write/guards.js +43 -5
- package/dist/write/guards.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +2 -3
- package/dist/write/make-repeating-project.js +2 -73
- package/dist/write/make-repeating-project.js.map +1 -1
- package/dist/write/move.js +8 -1
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +140 -9
- package/dist/write/operations.js +4 -1
- package/dist/write/operations.js.map +1 -1
- package/dist/write/pipeline.d.ts +77 -0
- package/dist/write/pipeline.js +298 -20
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +1 -1
- package/dist/write/pre-state.js +9 -3
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/preserve-modified.d.ts +55 -0
- package/dist/write/preserve-modified.js +106 -0
- package/dist/write/preserve-modified.js.map +1 -0
- package/dist/write/promote-clone.d.ts +32 -0
- package/dist/write/promote-clone.js +867 -0
- package/dist/write/promote-clone.js.map +1 -0
- package/dist/write/repeat-anchor.d.ts +110 -0
- package/dist/write/repeat-anchor.js +238 -0
- package/dist/write/repeat-anchor.js.map +1 -0
- package/dist/write/repeat-asserts.d.ts +28 -0
- package/dist/write/repeat-asserts.js +169 -0
- package/dist/write/repeat-asserts.js.map +1 -0
- package/dist/write/repeat-rule.js +27 -12
- package/dist/write/repeat-rule.js.map +1 -1
- package/dist/write/resolution-timestamps.js +22 -0
- package/dist/write/resolution-timestamps.js.map +1 -1
- package/dist/write/reversibility.js +27 -14
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/undo.d.ts +11 -1
- package/dist/write/undo.js +126 -10
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/session-reachability.d.ts +78 -0
- package/dist/write/vectors/session-reachability.js +103 -0
- package/dist/write/vectors/session-reachability.js.map +1 -0
- package/dist/write/vectors/simulator.js +100 -30
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +117 -10
- package/dist/write/vectors/ui-certification.d.ts +1 -1
- package/dist/write/vectors/ui-certification.js +10 -3
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +8 -0
- package/dist/write/vectors/ui-recipes.js +120 -22
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +79 -13
- package/dist/write/vectors/ui.js +415 -59
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/delta.d.ts +12 -14
- package/dist/write/verify/delta.js +35 -34
- package/dist/write/verify/delta.js.map +1 -1
- package/package.json +1 -1
- package/schema/envelope.schema.json +22 -0
- package/skills/things-cli/SKILL.md +7 -3
- package/skills/things-cli/references/banner.md +1 -1
- package/skills/things-cli/references/contracts.md +17 -9
- package/skills/things-cli/references/data-model.md +2 -1
package/dist/write/vectors/ui.js
CHANGED
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
* v1 shells out to `osascript` with ONE stable command shape per primitive.
|
|
27
27
|
*/
|
|
28
28
|
import { execFile } from "node:child_process";
|
|
29
|
+
import { DEFAULT_UI_DRIVE_BUDGET_MS } from "../../config.js";
|
|
30
|
+
import { noteInflightStep, trace, traceActive, tracePath } from "../../trace/tracer.js";
|
|
29
31
|
import { UI_DRIVE_OPS } from "../operations.js";
|
|
30
32
|
import { escapeAppleScript } from "./applescript.js";
|
|
33
|
+
import { H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
|
|
31
34
|
import { certificationOf } from "./ui-certification.js";
|
|
32
35
|
import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
|
|
33
36
|
/** GUI driving can stall on an unanswered sheet; give each step headroom. */
|
|
@@ -98,6 +101,39 @@ export function axSetValueScript(path, value, attempts = 3) {
|
|
|
98
101
|
error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
99
102
|
end tell`;
|
|
100
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* ensure-checkbox: converge a dialog checkbox to a target state through a
|
|
106
|
+
* DETERMINISTIC CLOSED LOOP (RRD1, determinism doctrine) — never a blind toggle.
|
|
107
|
+
* It reads the checkbox's `AXValue` (0 = unchecked, 1 = checked), presses it ONLY
|
|
108
|
+
* when the observed value differs from `target`, then RE-READS to confirm the new
|
|
109
|
+
* value equals `target`. A press that did not register (or a value that has not
|
|
110
|
+
* settled) is retried a bounded number of times; if it never converges the script
|
|
111
|
+
* FAILS CLOSED (an `error`, i.e. a transport failure the pipeline re-verifies).
|
|
112
|
+
*
|
|
113
|
+
* This is what makes the "Add deadlines" / "Add reminders" checkboxes safe on a
|
|
114
|
+
* PRE-POPULATED reschedule dialog: the dialog opens with the item's CURRENT
|
|
115
|
+
* deadline/reminder state already ticked, so the old unconditional `click`
|
|
116
|
+
* FLIPPED an already-correct box the wrong way — the live #493-adjacent bug where
|
|
117
|
+
* a blind "Add deadlines" press UNCHECKED an already-deadlined rule and hid the
|
|
118
|
+
* "start N days earlier" field, collapsing the drive. Reading before pressing
|
|
119
|
+
* makes an already-correct box a no-op. One stable command shape per primitive.
|
|
120
|
+
*/
|
|
121
|
+
export function axEnsureCheckboxScript(path, target, attempts = 3) {
|
|
122
|
+
const want = target ? 1 : 0;
|
|
123
|
+
const n = Math.max(1, Math.trunc(attempts));
|
|
124
|
+
return `${SE}
|
|
125
|
+
set cb to (${path})
|
|
126
|
+
repeat ${n} times
|
|
127
|
+
set cur to (value of cb) as integer
|
|
128
|
+
if cur is ${want} then return "OK"
|
|
129
|
+
click cb
|
|
130
|
+
delay 0.2
|
|
131
|
+
end repeat
|
|
132
|
+
set cur to (value of cb) as integer
|
|
133
|
+
if cur is ${want} then return "OK"
|
|
134
|
+
error "checkbox did not converge to ${want} after ${n} attempt(s); still " & cur
|
|
135
|
+
end tell`;
|
|
136
|
+
}
|
|
101
137
|
/**
|
|
102
138
|
* select-popup: choose an item in a pop-up button by NAME. Setting `value` on a
|
|
103
139
|
* Things pop-up button is a silent no-op (UIC1 / UI2-i) — the control must be
|
|
@@ -213,6 +249,41 @@ export function axSelectHeadingRowScript(tablePath, ordinal) {
|
|
|
213
249
|
end tell
|
|
214
250
|
return "NOMATCH"`;
|
|
215
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* assert-eligible: after a `things:///show?id=` reveal, VERIFY the target to-do
|
|
254
|
+
* is genuinely the sole selection AND that the menu item that acts on it is
|
|
255
|
+
* enabled — before the menu is pressed (ADR1, issue #480). The reveal is assumed
|
|
256
|
+
* to select the row, but on some surfaces it can navigate without selecting; an
|
|
257
|
+
* AXPress on the resulting DISABLED `Items ▸ Repeat…` is a silent no-op, so the
|
|
258
|
+
* dialog never opens and the drive dies far downstream at the dialog-wait timeout
|
|
259
|
+
* with no hint of the real cause. Reading `Things3 → id of selected to dos` is
|
|
260
|
+
* uuid-precise (never a fuzzy title match), so a match GUARANTEES the intended row
|
|
261
|
+
* is selected. Returns "OK" only when exactly the target is selected and the menu
|
|
262
|
+
* item is enabled; otherwise a diagnostic (`NOTSEL…`/`WRONGSEL…`/`DISABLED…`)
|
|
263
|
+
* naming expected vs observed. Pure System Events + Things scripting, background-
|
|
264
|
+
* capable. One stable command shape per primitive.
|
|
265
|
+
*/
|
|
266
|
+
export function axAssertEligibleScript(targetUuid, menuItemPath) {
|
|
267
|
+
const u = escapeAppleScript(targetUuid);
|
|
268
|
+
return `set selIds to {}
|
|
269
|
+
tell application "Things3"
|
|
270
|
+
try
|
|
271
|
+
set selIds to id of selected to dos
|
|
272
|
+
end try
|
|
273
|
+
end tell
|
|
274
|
+
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"
|
|
275
|
+
if (count of selIds) is greater than 1 then return "NOTSEL " & (count of selIds) & " to-dos are selected, expected exactly the target ${u}"
|
|
276
|
+
set theId to (item 1 of selIds) as text
|
|
277
|
+
if theId is not "${u}" then return "WRONGSEL the selected to-do is " & theId & ", expected the target ${u}"
|
|
278
|
+
set repEnabled to false
|
|
279
|
+
tell application "System Events" to tell process "Things3"
|
|
280
|
+
try
|
|
281
|
+
set repEnabled to enabled of ${menuItemPath}
|
|
282
|
+
end try
|
|
283
|
+
end tell
|
|
284
|
+
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)"
|
|
285
|
+
return "OK"`;
|
|
286
|
+
}
|
|
216
287
|
/** activate: foreground Things (the fallback preamble step). */
|
|
217
288
|
export function axActivateScript() {
|
|
218
289
|
return `tell application "Things3" to activate`;
|
|
@@ -232,6 +303,29 @@ export function axKeyScript(keys) {
|
|
|
232
303
|
export function axAbortScript() {
|
|
233
304
|
return `tell application "System Events" to key code 53`; // Escape
|
|
234
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
|
|
308
|
+
* recovery): close the front Things window — which takes an attached modal sheet
|
|
309
|
+
* with it — then reopen and activate. Runs entirely through Things' own
|
|
310
|
+
* AppleScript dictionary, so it works WITHOUT the Accessibility tree (the exact
|
|
311
|
+
* property needed when the session is AX-blind). Two uses:
|
|
312
|
+
* - CLEANUP: clear a stuck modal sheet a failed drive left open (unblocks the
|
|
313
|
+
* app-wide AppleScript-mutation freeze that sheet imposes);
|
|
314
|
+
* - RELOCATION: pull a window that was on another Space back to the current one
|
|
315
|
+
* so its dialog can open AX-reachably (the wrong-Space recovery branch).
|
|
316
|
+
* `reopen` restores the default window on the CURRENT Space; `activate` foregrounds
|
|
317
|
+
* it. Returns "OK".
|
|
318
|
+
*/
|
|
319
|
+
export function axCloseReopenActivateScript() {
|
|
320
|
+
return `tell application "Things3"
|
|
321
|
+
try
|
|
322
|
+
close window 1
|
|
323
|
+
end try
|
|
324
|
+
reopen
|
|
325
|
+
activate
|
|
326
|
+
end tell
|
|
327
|
+
return "OK"`;
|
|
328
|
+
}
|
|
235
329
|
/**
|
|
236
330
|
* sheet-open probe: is a modal SHEET attached to the Things standard window, OR
|
|
237
331
|
* a detached repeat-editor / popover window (an `AXUnknown` that is not the
|
|
@@ -262,19 +356,80 @@ async function sheetStillOpen(run) {
|
|
|
262
356
|
return !(res.ok && res.stdout.trim() === "false");
|
|
263
357
|
}
|
|
264
358
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
359
|
+
* Clear a half-open sheet/popover a failed drive left behind — HONESTLY (SESSGATE
|
|
360
|
+
* #480 fix; supersedes the old verifiedAbort, whose AX-blind sheet probe returned
|
|
361
|
+
* "gone" it could not actually see, letting the still-open modal freeze the
|
|
362
|
+
* app-wide AppleScript mutations the caller then attempted — the auto-trash
|
|
363
|
+
* silent-noop). Escape first; then:
|
|
364
|
+
* - AX-BLIND (a not-reachable probe): Escape may never have reached the sheet
|
|
365
|
+
* and the sheet probe cannot see it, so run the app-level close+reopen that
|
|
366
|
+
* works blind (it takes the stuck sheet with the window). Reported as
|
|
367
|
+
* "cleared-blind" — never falsely "confirmed gone".
|
|
368
|
+
* - REACHABLE: the sheet probe is trustworthy — confirm the dismissal (retry
|
|
369
|
+
* Escape once), and if it will not go, warn "may remain".
|
|
270
370
|
*/
|
|
271
|
-
async function
|
|
371
|
+
async function clearDialog(run) {
|
|
272
372
|
const escape = () => run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
|
|
273
373
|
await escape();
|
|
374
|
+
const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
|
|
375
|
+
if (!reach.reachable) {
|
|
376
|
+
// Cannot trust Escape or the sheet probe while AX-blind — use the proven
|
|
377
|
+
// app-level maneuver, which clears a stuck sheet without the Accessibility tree.
|
|
378
|
+
await run({
|
|
379
|
+
primitive: "resolve",
|
|
380
|
+
label: "clear a stuck dialog (close the Things window and reopen it)",
|
|
381
|
+
script: axCloseReopenActivateScript(),
|
|
382
|
+
}, STEP_TIMEOUT_MS);
|
|
383
|
+
return { state: "cleared-blind" };
|
|
384
|
+
}
|
|
274
385
|
if (!(await sheetStillOpen(run)))
|
|
275
|
-
return {
|
|
386
|
+
return { state: "dismissed" };
|
|
276
387
|
await escape(); // one retry
|
|
277
|
-
return {
|
|
388
|
+
return { state: (await sheetStillOpen(run)) ? "may-remain" : "dismissed" };
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The dialog-class reachability GATE (SESSGATE, #480), run AFTER the reveal/
|
|
392
|
+
* activate preamble (which surfaces a window in a healthy session) and BEFORE any
|
|
393
|
+
* menu press. Three outcomes matched to the live session state:
|
|
394
|
+
* - reachable → proceed;
|
|
395
|
+
* - not reachable, "session" → REFUSE (locked screen / full-screen Space —
|
|
396
|
+
* the certain-failure case): block, zero mutation;
|
|
397
|
+
* - not reachable, "window" → RELOCATE: only Things' window is off the
|
|
398
|
+
* current Space, so run the app-level close+reopen
|
|
399
|
+
* that pulls it back, then RE-PROBE closed-loop.
|
|
400
|
+
* Reachable now → proceed (disclosed); still not →
|
|
401
|
+
* block with the Space remediation.
|
|
402
|
+
*/
|
|
403
|
+
async function ensureWindowReachable(run) {
|
|
404
|
+
const first = await probeSessionReachability(run, STEP_TIMEOUT_MS);
|
|
405
|
+
if (first.reachable)
|
|
406
|
+
return { ok: true, relocated: false };
|
|
407
|
+
if (first.scope === "session")
|
|
408
|
+
return { ok: false, verdict: first };
|
|
409
|
+
// scope "window": Things' window is on another Space (or absent) while the
|
|
410
|
+
// session is otherwise fine — try to bring it to the current Space, then re-probe.
|
|
411
|
+
await run({
|
|
412
|
+
primitive: "resolve",
|
|
413
|
+
label: "move the Things window to the current desktop",
|
|
414
|
+
script: axCloseReopenActivateScript(),
|
|
415
|
+
}, STEP_TIMEOUT_MS);
|
|
416
|
+
const second = await probeSessionReachability(run, STEP_TIMEOUT_MS);
|
|
417
|
+
if (second.reachable)
|
|
418
|
+
return { ok: true, relocated: true };
|
|
419
|
+
return { ok: false, verdict: second.reachable ? first : second };
|
|
420
|
+
}
|
|
421
|
+
/** The blocked ExecuteResult a dialog-class op returns when the session is unreachable. */
|
|
422
|
+
function blockedReachability(verdict) {
|
|
423
|
+
return {
|
|
424
|
+
exitCode: 4,
|
|
425
|
+
stdout: "",
|
|
426
|
+
stderr: `${verdict.detail} ${verdict.remediation}`,
|
|
427
|
+
blocked: {
|
|
428
|
+
hazard: H_UI_SESSION_UNREACHABLE,
|
|
429
|
+
detail: verdict.detail,
|
|
430
|
+
remediation: verdict.remediation,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
278
433
|
}
|
|
279
434
|
/**
|
|
280
435
|
* resolve-frame: read the element's on-screen frame (top-left origin, points)
|
|
@@ -314,40 +469,78 @@ function clickPointCommand(x, y, label) {
|
|
|
314
469
|
return { primitive: "click-point", label, lang: "javascript", script: jxaClickScript(x, y) };
|
|
315
470
|
}
|
|
316
471
|
/**
|
|
317
|
-
* set-datetime: set the Repeat dialog's `AXDateTimeArea`
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* `AXUIElementSetAttributeValue(…, AXValue, <NSDate>)` directly.
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* `
|
|
327
|
-
*
|
|
472
|
+
* set-datetime: set ONE of the Repeat dialog's `AXDateTimeArea` controls via the
|
|
473
|
+
* ObjC AX bridge. Things' date/time controls hold an NSDate, and System Events
|
|
474
|
+
* cannot write them (`set value … to <date>` → -10000, UIC6), so — like the
|
|
475
|
+
* mouse-synthesis primitive — this runs in JXA and calls
|
|
476
|
+
* `AXUIElementSetAttributeValue(…, AXValue, <NSDate>)` directly.
|
|
477
|
+
*
|
|
478
|
+
* A fixed rule can expose up to THREE date areas at once — "Next:" (first
|
|
479
|
+
* occurrence), "Ends: on date", and the reminder time (ANCH2 census). Targeting
|
|
480
|
+
* "the first AXDateTimeArea by role" is therefore AMBIGUOUS — that ambiguity
|
|
481
|
+
* collapsed the series when `--ends-on` added a second area (oddities §8v, now
|
|
482
|
+
* retracted) and made the reminder look undrivable (UIC6-g, now retracted). This
|
|
483
|
+
* driver selects DETERMINISTICALLY by `target` (ANCH2, docs/lab/anch2-next-field.md):
|
|
484
|
+
* - `reminder` — the only area carrying a time-of-day (the date pickers sit at
|
|
485
|
+
* midnight); falls back to the bottom-most area if none carry a time.
|
|
486
|
+
* - `next` — the TOP (smallest-y) midnight date picker.
|
|
487
|
+
* - `ends` — the BOTTOM (largest-y) midnight date picker (present only once
|
|
488
|
+
* "Ends: on date" is selected).
|
|
489
|
+
* The areas are polled briefly (revealed a beat after the checkbox/pop-up), and
|
|
490
|
+
* the script THROWS a NAMED, structured error when the addressed control is
|
|
491
|
+
* absent — reporting which target was sought and the FULL date-area inventory of
|
|
492
|
+
* the current dialog state (count + per-area y / time-of-day) — so a dialog shape
|
|
493
|
+
* that does not present the target (e.g. the deadline-mode variant, YANCH1 #493)
|
|
494
|
+
* fails closed with an actionable message, never an uncaught `-[__NSArray0
|
|
495
|
+
* objectAtIndex:]` (-2700) from indexing an empty collection. It then READS THE
|
|
496
|
+
* CONTROL BACK after the write and throws if the committed value differs from the
|
|
497
|
+
* request: a control that silently rejects the write (the macOS error beep the
|
|
498
|
+
* user hears) must fail the step loudly, never leave a garbled/default value to be
|
|
499
|
+
* verified as ok (YANCH1; UIC6-g refuse-rather-than-commit precedent). `spec` is
|
|
500
|
+
* `time:HH:mm` (keep the date, set the time-of-day) or `date:YYYY-MM-DD` (set the
|
|
501
|
+
* calendar date at midnight). One stable JXA shape.
|
|
328
502
|
*/
|
|
329
|
-
export function axSetDateTimeScript(spec) {
|
|
503
|
+
export function axSetDateTimeScript(spec, target) {
|
|
330
504
|
return `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
|
|
331
505
|
function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
|
|
332
506
|
function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
|
|
333
507
|
function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
334
|
-
function
|
|
508
|
+
function collect(el,role,depth,out){ if(depth<0) return; if(rolestr(el)===role) out.push(el); var ks=kids(el); for(var i=0;i<ks.length;i++) collect(ks[i],role,depth-1,out); }
|
|
509
|
+
function posY(el){ var p=attr(el,'AXPosition'); if(!p) return 0; var d=ObjC.castRefToObject($.CFCopyDescription(p)).js; var m=String(d).match(/y:([-0-9.]+)/); return m? +m[1] : 0; }
|
|
510
|
+
function timeOfDay(el){ var v=attr(el,'AXValue'); if(!v) return -1; var cal=$.NSCalendar.currentCalendar; return cal.componentFromDate($.NSCalendarUnitHour,v)*60 + cal.componentFromDate($.NSCalendarUnitMinute,v); }
|
|
511
|
+
function pick(areas,target){
|
|
512
|
+
if(areas.length===0) return null;
|
|
513
|
+
var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
|
|
514
|
+
if(target==='reminder'){
|
|
515
|
+
var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
|
|
516
|
+
return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
|
|
517
|
+
}
|
|
518
|
+
var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
|
|
519
|
+
if(midnight.length===0) midnight=sorted;
|
|
520
|
+
return target==='ends' ? midnight[midnight.length-1] : midnight[0];
|
|
521
|
+
}
|
|
522
|
+
function inv(areas){ var s=[]; for(var i=0;i<areas.length;i++){ s.push('#'+i+'(y='+Math.round(posY(areas[i]))+',tod='+timeOfDay(areas[i])+')'); } return areas.length? s.join(' ') : '(none)'; }
|
|
523
|
+
function ymdStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var y=cal.componentFromDate($.NSCalendarUnitYear,v), m=cal.componentFromDate($.NSCalendarUnitMonth,v), dd=cal.componentFromDate($.NSCalendarUnitDay,v); return y+'-'+('0'+m).slice(-2)+'-'+('0'+dd).slice(-2); }
|
|
524
|
+
function hmStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var h=cal.componentFromDate($.NSCalendarUnitHour,v), mi=cal.componentFromDate($.NSCalendarUnitMinute,v); return h+':'+('0'+mi).slice(-2); }
|
|
335
525
|
function run(){
|
|
336
526
|
var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
|
|
337
527
|
if(!apps || apps.count===0) throw new Error('Things not running');
|
|
338
|
-
var
|
|
339
|
-
var
|
|
340
|
-
var dt=null;
|
|
341
|
-
for(var t=0;t<20 && !dt;t++){ dt=find(app,'AXDateTimeArea',16); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
|
|
342
|
-
if(!dt) throw new Error('no AXDateTimeArea in the Repeat dialog');
|
|
528
|
+
var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
|
|
529
|
+
var target=${JSON.stringify(target)};
|
|
343
530
|
var spec=${JSON.stringify(spec)};
|
|
531
|
+
// Poll for the addressed area. collect is wrapped so a stale-element ObjC
|
|
532
|
+
// exception during traversal cannot bubble as a raw -2700; pick guards the
|
|
533
|
+
// empty set, so dt is null (never a crash) when the target is absent.
|
|
534
|
+
var areas=[]; var dt=null;
|
|
535
|
+
for(var t=0;t<20 && !dt;t++){ areas=[]; try{ collect(app,'AXDateTimeArea',16,areas); }catch(e){ areas=[]; } dt=pick(areas,target); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
|
|
536
|
+
if(!dt) throw new Error('set-datetime '+target+': this Repeat-dialog state presents '+areas.length+' date area(s) ['+inv(areas)+'] but none is the '+target+' control — the requested first occurrence / bound cannot be set in this dialog shape');
|
|
344
537
|
var cal=$.NSCalendar.currentCalendar;
|
|
345
538
|
var d;
|
|
346
539
|
if(spec.indexOf('time:')===0){
|
|
347
|
-
// Set the time-of-day on the control's own
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
var cur=attr(dt,'AXValue'); if(!cur) throw new Error('date/time control has no value');
|
|
540
|
+
// Set the time-of-day on the control's own date via the purpose-built
|
|
541
|
+
// calendar API — component-bag mutation via JXA silently drops the hour,
|
|
542
|
+
// leaking the current wall-clock hour into the reminder (UIC6).
|
|
543
|
+
var cur=attr(dt,'AXValue'); if(!cur) throw new Error('set-datetime '+target+': the date/time control has no value to anchor the time on');
|
|
351
544
|
var hm=spec.slice(5).split(':');
|
|
352
545
|
d=cal.dateBySettingHourMinuteSecondOfDateOptions(+hm[0], +hm[1], 0, cur, 0);
|
|
353
546
|
} else if(spec.indexOf('date:')===0){
|
|
@@ -358,8 +551,18 @@ function run(){
|
|
|
358
551
|
} else { throw new Error('bad datetime spec: '+spec); }
|
|
359
552
|
if(!d) throw new Error('could not build date from '+spec);
|
|
360
553
|
var err=$.AXUIElementSetAttributeValue(dt,$('AXValue'),d);
|
|
361
|
-
if(err!==0) throw new Error('
|
|
554
|
+
if(err!==0) throw new Error('set-datetime '+target+': the control refused the write (AX err='+err+')');
|
|
362
555
|
$.NSThread.sleepForTimeInterval(0.2);
|
|
556
|
+
// READ-BACK: a control can accept the AX write (err 0) yet reject the value —
|
|
557
|
+
// the macOS error beep — leaving its prior/default value. Fail the step loudly
|
|
558
|
+
// rather than let a garbled commit verify as ok (YANCH1 #493).
|
|
559
|
+
if(spec.indexOf('date:')===0){
|
|
560
|
+
var got=ymdStr(dt,cal); var want=spec.slice(5);
|
|
561
|
+
if(got!==want) throw new Error('set-datetime '+target+' rejected: the control committed '+(got||'(no value)')+', not the requested '+want+' — the write did not take');
|
|
562
|
+
} else {
|
|
563
|
+
var gott=hmStr(dt,cal); var wanth=spec.slice(5).split(':'); var wantt=(+wanth[0])+':'+('0'+(+wanth[1])).slice(-2);
|
|
564
|
+
if(gott!==wantt) throw new Error('set-datetime '+target+' rejected: the control committed '+(gott||'(no value)')+', not the requested '+wantt+' — the write did not take');
|
|
565
|
+
}
|
|
363
566
|
return 'OK';
|
|
364
567
|
}`;
|
|
365
568
|
}
|
|
@@ -399,6 +602,40 @@ function defaultRun(command, timeoutMs) {
|
|
|
399
602
|
});
|
|
400
603
|
});
|
|
401
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
|
|
607
|
+
* step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
|
|
608
|
+
* even with tracing off), and — when tracing is on — a `ui-dispatch` start/end
|
|
609
|
+
* pair lands in the trace carrying the hop's duration and outcome. This
|
|
610
|
+
* per-osascript granularity is exactly what reconstructs a hang: the timeline
|
|
611
|
+
* shows which step's osascript was in flight, and for how long, when it stopped
|
|
612
|
+
* (TRACE1 #487). Overhead when tracing is off is one boolean check + a field write.
|
|
613
|
+
*/
|
|
614
|
+
function tracingRun(inner) {
|
|
615
|
+
return async (command, timeoutMs) => {
|
|
616
|
+
noteInflightStep(command.label);
|
|
617
|
+
if (!traceActive())
|
|
618
|
+
return inner(command, timeoutMs);
|
|
619
|
+
const started = Date.now();
|
|
620
|
+
trace(() => ({
|
|
621
|
+
phase: "ui-dispatch",
|
|
622
|
+
event: "start",
|
|
623
|
+
primitive: command.primitive,
|
|
624
|
+
label: command.label,
|
|
625
|
+
}));
|
|
626
|
+
const res = await inner(command, timeoutMs);
|
|
627
|
+
trace(() => ({
|
|
628
|
+
phase: "ui-dispatch",
|
|
629
|
+
event: "end",
|
|
630
|
+
primitive: command.primitive,
|
|
631
|
+
label: command.label,
|
|
632
|
+
durationMs: Date.now() - started,
|
|
633
|
+
ok: res.ok,
|
|
634
|
+
timedOut: res.timedOut === true,
|
|
635
|
+
}));
|
|
636
|
+
return res;
|
|
637
|
+
};
|
|
638
|
+
}
|
|
402
639
|
/** The element paths the preflight canary resolves (static steps only). */
|
|
403
640
|
function canaryPaths(recipe) {
|
|
404
641
|
const out = [];
|
|
@@ -485,7 +722,13 @@ export function commandForStep(step, targetUuid) {
|
|
|
485
722
|
primitive: "set-datetime",
|
|
486
723
|
label: step.label,
|
|
487
724
|
lang: "javascript",
|
|
488
|
-
script: axSetDateTimeScript(step.value ?? ""),
|
|
725
|
+
script: axSetDateTimeScript(step.value ?? "", step.dtTarget ?? "next"),
|
|
726
|
+
};
|
|
727
|
+
case "ensure-checkbox":
|
|
728
|
+
return {
|
|
729
|
+
primitive: "ensure-checkbox",
|
|
730
|
+
label: step.label,
|
|
731
|
+
script: axEnsureCheckboxScript(step.path ?? "", step.checkboxTarget === true),
|
|
489
732
|
};
|
|
490
733
|
case "wait":
|
|
491
734
|
return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
|
|
@@ -501,6 +744,12 @@ export function commandForStep(step, targetUuid) {
|
|
|
501
744
|
label: step.label,
|
|
502
745
|
script: axSelectHeadingRowScript(step.path ?? "", Number(step.value ?? "0")),
|
|
503
746
|
};
|
|
747
|
+
case "assert-eligible":
|
|
748
|
+
return {
|
|
749
|
+
primitive: "assert-eligible",
|
|
750
|
+
label: step.label,
|
|
751
|
+
script: axAssertEligibleScript(step.value ?? targetUuid, step.path ?? ""),
|
|
752
|
+
};
|
|
504
753
|
case "key":
|
|
505
754
|
return { primitive: "key", label: step.label, script: axKeyScript(step.keys ?? "") };
|
|
506
755
|
case "click-element":
|
|
@@ -567,20 +816,69 @@ async function driveClickElement(step, run) {
|
|
|
567
816
|
}
|
|
568
817
|
return { ok: true };
|
|
569
818
|
}
|
|
570
|
-
async function drive(recipe, run, aux) {
|
|
819
|
+
async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
571
820
|
const done = [];
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
|
|
821
|
+
// The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
|
|
822
|
+
// own timeout on a slow production database (large + Things-Cloud syncing
|
|
823
|
+
// commits the Repeat dialog several times slower than the lab golden), which
|
|
824
|
+
// is how #487 fired: the caller's 30s kill left empty stdout and no retained
|
|
825
|
+
// exit code. This budget lets the CLI give up FIRST — clearing any open dialog
|
|
826
|
+
// and returning an honest, uncertain-outcome timeout — so the caller always
|
|
827
|
+
// receives structured output. Checked between steps (per-step execFile
|
|
828
|
+
// timeouts bound each osascript, so a step boundary is never far off).
|
|
829
|
+
const driveStart = Date.now();
|
|
830
|
+
const driveDeadline = driveStart + budgetMs;
|
|
831
|
+
const overBudget = () => Date.now() >= driveDeadline;
|
|
832
|
+
const watchdogResult = async (lastStep) => {
|
|
833
|
+
// Attempt the SESSGATE dialog clearance so the watchdog never leaves a stuck
|
|
834
|
+
// modal behind (#485), then report honestly. The outcome is UNCERTAIN: a rule
|
|
835
|
+
// whose OK press was mid-commit could still land — the pipeline re-verifies
|
|
836
|
+
// and shapes the final result accordingly.
|
|
837
|
+
const clear = await clearDialog(run);
|
|
838
|
+
trace(() => ({
|
|
839
|
+
phase: "watchdog",
|
|
840
|
+
budgetMs,
|
|
841
|
+
elapsedMs: Date.now() - driveStart,
|
|
842
|
+
lastStep,
|
|
843
|
+
clear: clear.state,
|
|
844
|
+
completed: done,
|
|
845
|
+
}));
|
|
846
|
+
return {
|
|
847
|
+
exitCode: 1,
|
|
848
|
+
stdout: `ui drive watchdog stopped after ${done.length} step(s): ${done.join(" → ") || "nothing"}`,
|
|
849
|
+
stderr: `ui drive exceeded its ${Math.round(budgetMs / 1000)}s budget at "${lastStep}"`,
|
|
850
|
+
timedOut: true,
|
|
851
|
+
watchdog: {
|
|
852
|
+
budgetMs,
|
|
853
|
+
elapsedMs: Date.now() - driveStart,
|
|
854
|
+
lastStep,
|
|
855
|
+
clear: clear.state,
|
|
856
|
+
tracePath: tracePath(),
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
};
|
|
860
|
+
// A note prepended to the success summary when the drive had to RELOCATE the
|
|
861
|
+
// Things window to the current Space to open its dialog (SESSGATE wrong-Space
|
|
862
|
+
// recovery) — surfaced to the caller as a disclosure warning.
|
|
863
|
+
let relocationNote = "";
|
|
864
|
+
// `clear`: how a half-open sheet was cleaned up after a failure (honest — never
|
|
865
|
+
// claim a dismissal we could not see, SESSGATE #480); undefined = no sheet was
|
|
866
|
+
// opened / no cleanup ran (a benign preamble/canary failure).
|
|
867
|
+
const partial = (failed, why, clear) => {
|
|
576
868
|
const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
|
|
577
|
-
const cleanup =
|
|
578
|
-
? "
|
|
579
|
-
:
|
|
580
|
-
? "
|
|
581
|
-
|
|
582
|
-
"
|
|
583
|
-
|
|
869
|
+
const cleanup = clear === undefined
|
|
870
|
+
? ""
|
|
871
|
+
: clear.state === "dismissed"
|
|
872
|
+
? " The open sheet/popover was dismissed (Escape, confirmed gone)."
|
|
873
|
+
: clear.state === "cleared-blind"
|
|
874
|
+
? " Things had no window reachable on the current screen (the Mac may be locked, or a" +
|
|
875
|
+
" full-screen app is covering the desktop), so the open dialog could not be confirmed" +
|
|
876
|
+
" through the on-screen layer — the Things window was closed and reopened to clear it," +
|
|
877
|
+
" discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
|
|
878
|
+
" before retrying."
|
|
879
|
+
: " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
|
|
880
|
+
" Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
|
|
881
|
+
" make the next drive's preflight fail).";
|
|
584
882
|
return refusal(base + cleanup);
|
|
585
883
|
};
|
|
586
884
|
// 0. Run the leading reveal/activate preamble BEFORE the canary. The Items
|
|
@@ -603,6 +901,22 @@ async function drive(recipe, run, aux) {
|
|
|
603
901
|
// Let the selection settle so the menu bar repopulates before the canary reads it.
|
|
604
902
|
if (idx > 0)
|
|
605
903
|
await new Promise((r) => setTimeout(r, SETTLE_AFTER_REVEAL_MS));
|
|
904
|
+
// 0½. Session-reachability GATE for dialog-class ops (SESSGATE, #480). A recipe
|
|
905
|
+
// that opens a sheet on the main window needs that window AX-reachable on
|
|
906
|
+
// the current Space. Probed AFTER the preamble (which surfaces a window in a
|
|
907
|
+
// healthy session) and BEFORE the canary/press (no mutation yet): a locked /
|
|
908
|
+
// full-screen session REFUSES (blocked, zero mutation); a window merely on
|
|
909
|
+
// another Space is RELOCATED back and disclosed. Menu-only recipes skip this.
|
|
910
|
+
if (recipe.needsWindowReachability === true) {
|
|
911
|
+
const reach = await ensureWindowReachable(run);
|
|
912
|
+
if (!reach.ok)
|
|
913
|
+
return blockedReachability(reach.verdict);
|
|
914
|
+
if (reach.relocated) {
|
|
915
|
+
relocationNote =
|
|
916
|
+
"the Things window was on another desktop, so it was moved to the desktop you're viewing " +
|
|
917
|
+
"to open the dialog. ";
|
|
918
|
+
}
|
|
919
|
+
}
|
|
606
920
|
// 1. Recipe canary: resolve every statically-reachable element (now that the
|
|
607
921
|
// target is selected). A miss refuses the whole drive before anything is
|
|
608
922
|
// pressed. (This is also the localization check: English titles must resolve.)
|
|
@@ -631,6 +945,11 @@ async function drive(recipe, run, aux) {
|
|
|
631
945
|
// 2. Execute the remaining steps in order; a dynamic element is waited-for.
|
|
632
946
|
for (let i = idx; i < recipe.steps.length; i += 1) {
|
|
633
947
|
let step = recipe.steps[i];
|
|
948
|
+
// Overall-drive watchdog: if the budget is spent, stop at THIS step boundary
|
|
949
|
+
// (the per-step execFile timeouts keep the boundary close), clear any open
|
|
950
|
+
// dialog, and return the honest uncertain-outcome timeout (TRACE1 #487).
|
|
951
|
+
if (overBudget())
|
|
952
|
+
return watchdogResult(step.label);
|
|
634
953
|
if (step.primitive === "wait") {
|
|
635
954
|
// A candidate-addressed wait polls for ANY of its shapes to appear (the
|
|
636
955
|
// dialog opening as an attached sheet OR a detached AXUnknown window).
|
|
@@ -638,8 +957,8 @@ async function drive(recipe, run, aux) {
|
|
|
638
957
|
const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
|
|
639
958
|
if (!ok) {
|
|
640
959
|
// the abort keystroke must land (and be verified) before returning the partial-state report
|
|
641
|
-
const
|
|
642
|
-
return partial(step.label, "the expected element never appeared within the timeout",
|
|
960
|
+
const clear = await clearDialog(run);
|
|
961
|
+
return partial(step.label, "the expected element never appeared within the timeout", clear);
|
|
643
962
|
}
|
|
644
963
|
done.push(step.label);
|
|
645
964
|
continue;
|
|
@@ -665,9 +984,9 @@ async function drive(recipe, run, aux) {
|
|
|
665
984
|
const effective = await resolveStepPath(step, run);
|
|
666
985
|
if (effective === null) {
|
|
667
986
|
// dismiss whatever opened (and verify) before reporting
|
|
668
|
-
const
|
|
987
|
+
const clear = await clearDialog(run);
|
|
669
988
|
return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
|
|
670
|
-
"detached repeat editor window)",
|
|
989
|
+
"detached repeat editor window)", clear);
|
|
671
990
|
}
|
|
672
991
|
step = { ...step, path: effective };
|
|
673
992
|
}
|
|
@@ -680,7 +999,7 @@ async function drive(recipe, run, aux) {
|
|
|
680
999
|
const res = await run(command, STEP_TIMEOUT_MS);
|
|
681
1000
|
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
682
1001
|
// clear any transient state (and verify) before reporting
|
|
683
|
-
const
|
|
1002
|
+
const clear = await clearDialog(run);
|
|
684
1003
|
const noMatch = step.primitive === "select-heading-row"
|
|
685
1004
|
? "the project view exposed no selectable heading row at the target position — the " +
|
|
686
1005
|
"heading may have been converted/deleted already, or the project's headings changed"
|
|
@@ -690,7 +1009,29 @@ async function drive(recipe, run, aux) {
|
|
|
690
1009
|
? noMatch
|
|
691
1010
|
: res.timedOut === true
|
|
692
1011
|
? "the row-selection step timed out"
|
|
693
|
-
: res.stderr.trim() || "the row-selection step failed",
|
|
1012
|
+
: res.stderr.trim() || "the row-selection step failed", clear);
|
|
1013
|
+
}
|
|
1014
|
+
done.push(step.label);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if (step.primitive === "assert-eligible") {
|
|
1018
|
+
// ADR1 (#480): fail EARLY + NAMED when the reveal did not land an eligible
|
|
1019
|
+
// selection, rather than letting a disabled-menu no-op surface downstream
|
|
1020
|
+
// as an opaque dialog-wait timeout. The script returns "OK" or a diagnostic
|
|
1021
|
+
// (NOTSEL…/WRONGSEL…/DISABLED…) that IS the human-readable failure reason.
|
|
1022
|
+
// the selection/enabled state must be confirmed before the menu is pressed
|
|
1023
|
+
const res = await run(command, STEP_TIMEOUT_MS);
|
|
1024
|
+
const verdict = res.stdout.trim();
|
|
1025
|
+
if (!res.ok || verdict !== "OK") {
|
|
1026
|
+
// clear any transient state (and verify) before reporting
|
|
1027
|
+
const clear = await clearDialog(run);
|
|
1028
|
+
return partial(step.label, res.ok
|
|
1029
|
+
? verdict !== ""
|
|
1030
|
+
? verdict
|
|
1031
|
+
: "the target to-do was not confirmed selected/eligible after the reveal"
|
|
1032
|
+
: res.timedOut === true
|
|
1033
|
+
? "the eligibility check timed out"
|
|
1034
|
+
: res.stderr.trim() || "the eligibility check failed", clear);
|
|
694
1035
|
}
|
|
695
1036
|
done.push(step.label);
|
|
696
1037
|
continue;
|
|
@@ -701,9 +1042,9 @@ async function drive(recipe, run, aux) {
|
|
|
701
1042
|
// the click depends on the UI state the previous step produced
|
|
702
1043
|
const outcome = await driveClickElement(step, run);
|
|
703
1044
|
if (!outcome.ok) {
|
|
704
|
-
//
|
|
705
|
-
const
|
|
706
|
-
return partial(step.label, outcome.why ?? "the click failed",
|
|
1045
|
+
// clear whatever the click opened (honest cleanup) before reporting
|
|
1046
|
+
const clear = outcome.needsAbort === true ? await clearDialog(run) : undefined;
|
|
1047
|
+
return partial(step.label, outcome.why ?? "the click failed", clear);
|
|
707
1048
|
}
|
|
708
1049
|
done.push(step.label);
|
|
709
1050
|
continue;
|
|
@@ -711,15 +1052,20 @@ async function drive(recipe, run, aux) {
|
|
|
711
1052
|
// each recipe step depends on the UI state the previous step produced; they cannot be parallelized
|
|
712
1053
|
const res = await run(command, STEP_TIMEOUT_MS);
|
|
713
1054
|
if (!res.ok) {
|
|
714
|
-
//
|
|
715
|
-
|
|
716
|
-
|
|
1055
|
+
// clear the half-open sheet/popover (honest — never claim an unconfirmed
|
|
1056
|
+
// dismissal) before reporting partial state
|
|
1057
|
+
const clear = step.primitive !== "reveal" && step.primitive !== "activate"
|
|
1058
|
+
? await clearDialog(run)
|
|
717
1059
|
: undefined;
|
|
718
|
-
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed",
|
|
1060
|
+
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear);
|
|
719
1061
|
}
|
|
720
1062
|
done.push(step.label);
|
|
721
1063
|
}
|
|
722
|
-
return {
|
|
1064
|
+
return {
|
|
1065
|
+
exitCode: 0,
|
|
1066
|
+
stdout: `${relocationNote}drove ${done.length} step(s): ${done.join(" → ")}`,
|
|
1067
|
+
stderr: "",
|
|
1068
|
+
};
|
|
723
1069
|
}
|
|
724
1070
|
async function waitForElement(command, timeoutMs, run) {
|
|
725
1071
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -796,6 +1142,11 @@ function disabledMatrix() {
|
|
|
796
1142
|
*/
|
|
797
1143
|
export function createUiVector(config, run = defaultRun, aux = {}) {
|
|
798
1144
|
const enabled = config.ui.enabled;
|
|
1145
|
+
const budgetMs = config.ui.driveBudgetMs ?? DEFAULT_UI_DRIVE_BUDGET_MS;
|
|
1146
|
+
// Every osascript hop runs through the tracing seam: it notes the step on the
|
|
1147
|
+
// in-flight marker (for the signal handler) and, when tracing is on, records a
|
|
1148
|
+
// start/end pair with timing/outcome (TRACE1 #487).
|
|
1149
|
+
const tracedRun = tracingRun(run);
|
|
799
1150
|
return {
|
|
800
1151
|
id: "ui",
|
|
801
1152
|
matrix: enabled ? enabledMatrix() : disabledMatrix(),
|
|
@@ -806,8 +1157,13 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
|
|
|
806
1157
|
if (invocation.recipe === undefined) {
|
|
807
1158
|
return refusal("ui invocation carried no recipe (compile bug).");
|
|
808
1159
|
}
|
|
809
|
-
return drive(invocation.recipe,
|
|
1160
|
+
return drive(invocation.recipe, tracedRun, aux, budgetMs);
|
|
810
1161
|
},
|
|
1162
|
+
// Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
|
|
1163
|
+
// live session BEFORE they seed a row, so a locked/full-screen session refuses
|
|
1164
|
+
// with zero mutation. Present regardless of `enabled` (the orchestrator has
|
|
1165
|
+
// already cleared the H-UI-DRIVE ack by the time it consults this).
|
|
1166
|
+
probeReachability: () => probeSessionReachability(tracedRun, STEP_TIMEOUT_MS),
|
|
811
1167
|
};
|
|
812
1168
|
}
|
|
813
1169
|
//# sourceMappingURL=ui.js.map
|