things-api 0.19.3 → 0.20.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 +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 +42 -10
- package/dist/audit/schema.js +21 -0
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/commands/doctor.js +3 -1
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/op-result.js +25 -7
- package/dist/cli/commands/op-result.js.map +1 -1
- package/dist/cli/commands/repeat-flags.d.ts +19 -1
- package/dist/cli/commands/repeat-flags.js +71 -0
- package/dist/cli/commands/repeat-flags.js.map +1 -1
- package/dist/cli/commands/rescue.d.ts +12 -0
- package/dist/cli/commands/rescue.js +83 -0
- package/dist/cli/commands/rescue.js.map +1 -0
- package/dist/cli/commands/writes.js +86 -32
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/help.js +25 -25
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +12 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +9 -1
- package/dist/cli/resolve-invocation.js +71 -0
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/unknown-command.d.ts +26 -0
- package/dist/cli/unknown-command.js +135 -0
- package/dist/cli/unknown-command.js.map +1 -0
- package/dist/contracts.d.ts +4 -3
- package/dist/contracts.js +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +62 -10
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/entities.d.ts +11 -0
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.js +15 -0
- package/dist/model/mappers.js.map +1 -1
- package/dist/op-result.d.ts +24 -2
- package/dist/op-result.js +77 -20
- package/dist/op-result.js.map +1 -1
- package/dist/process-instance.d.ts +41 -0
- package/dist/process-instance.js +82 -0
- package/dist/process-instance.js.map +1 -0
- package/dist/rescue.d.ts +247 -0
- package/dist/rescue.js +811 -0
- package/dist/rescue.js.map +1 -0
- package/dist/session-grant.js +4 -15
- package/dist/session-grant.js.map +1 -1
- package/dist/ui-state.d.ts +8 -4
- package/dist/ui-state.js +8 -4
- 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/commands.d.ts +9 -0
- package/dist/write/commands.js +135 -50
- package/dist/write/commands.js.map +1 -1
- package/dist/write/disclosures.d.ts +275 -0
- package/dist/write/disclosures.js +277 -0
- package/dist/write/disclosures.js.map +1 -0
- package/dist/write/experimental.d.ts +16 -0
- package/dist/write/experimental.js +19 -0
- package/dist/write/experimental.js.map +1 -1
- package/dist/write/failure-hints.js +3 -2
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.d.ts +1 -1
- package/dist/write/guards.js +102 -3
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.js +4 -7
- package/dist/write/heading.js.map +1 -1
- package/dist/write/lock.d.ts +85 -1
- package/dist/write/lock.js +121 -6
- package/dist/write/lock.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/operations.d.ts +52 -6
- package/dist/write/operations.js +22 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +69 -8
- package/dist/write/opid.js +116 -20
- package/dist/write/opid.js.map +1 -1
- package/dist/write/param-schema.js +14 -2
- package/dist/write/param-schema.js.map +1 -1
- package/dist/write/pipeline.d.ts +82 -3
- package/dist/write/pipeline.js +204 -60
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/planner.d.ts +7 -0
- package/dist/write/planner.js +12 -0
- package/dist/write/planner.js.map +1 -1
- package/dist/write/preserve-modified.d.ts +17 -8
- package/dist/write/preserve-modified.js.map +1 -1
- package/dist/write/promote-clone.js +106 -53
- 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/repeat-rule.d.ts +25 -1
- package/dist/write/repeat-rule.js +69 -0
- package/dist/write/repeat-rule.js.map +1 -1
- package/dist/write/resolution-timestamps.js +104 -78
- package/dist/write/resolution-timestamps.js.map +1 -1
- package/dist/write/reversibility.js +2 -2
- package/dist/write/reversibility.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 +34 -35
- package/dist/write/template-mutation.js.map +1 -1
- package/dist/write/undo.js +45 -5
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/simulator.js +48 -2
- package/dist/write/vectors/simulator.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 +64 -6
- package/dist/write/vectors/ui-state.js +91 -4
- package/dist/write/vectors/ui-state.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +69 -3
- package/dist/write/vectors/ui.js +293 -146
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/vectors/url-scheme.js +26 -0
- package/dist/write/vectors/url-scheme.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/schema/envelope.schema.json +1 -2
- package/skills/things-cli/SKILL.md +4 -2
- package/skills/things-cli/references/repeating.md +1 -1
- package/dist/cli/commands/ui-state.d.ts +0 -11
- package/dist/cli/commands/ui-state.js +0 -27
- package/dist/cli/commands/ui-state.js.map +0 -1
package/dist/rescue.js
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `things rescue` — the headless emergency surface for un-sticking Things
|
|
3
|
+
* (issue #640).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. Things can reach a state where the app is up, answering, and
|
|
6
|
+
* unusable to automation, with no scripted way back. Three measured behaviours
|
|
7
|
+
* converge on it:
|
|
8
|
+
*
|
|
9
|
+
* - a standing modal sheet EMPTIES the app's top-level AppleScript collections
|
|
10
|
+
* (oddities §25 / MODALX1), so `delete` reports `-1728 Can't get to do id` on
|
|
11
|
+
* a row the database holds open — issue #620's "ghost clone", which has no
|
|
12
|
+
* second cause;
|
|
13
|
+
* - a standing sheet GATES Things Cloud sync entirely (oddities §24), so writes
|
|
14
|
+
* land locally and silently never leave the Mac;
|
|
15
|
+
* - a Repeat dialog opened while Things is BACKGROUNDED becomes a detached
|
|
16
|
+
* window that nothing dismisses (oddities §26 / DRVLAT1 §5) — not its own
|
|
17
|
+
* Cancel, not Escape, not ⌘W, not a real HID click at its Cancel frame.
|
|
18
|
+
*
|
|
19
|
+
* The shipped driver never creates the third state (every dialog recipe
|
|
20
|
+
* activates Things first, which is exactly why that step is permanent), but a
|
|
21
|
+
* killed client, a harness tool-timeout or a person walking away mid-dialog can
|
|
22
|
+
* all leave the first two. {@link rescueStatus} reports them; the other two
|
|
23
|
+
* verbs act on them. This is the single home for both halves — the read-only
|
|
24
|
+
* census that was once the top-level `ui-state` command lives here now.
|
|
25
|
+
*
|
|
26
|
+
* THE THREE VERBS, separated by what they can cost you:
|
|
27
|
+
*
|
|
28
|
+
* - {@link rescueStatus} free, ungated, read-only — the census + the lock
|
|
29
|
+
* table + a "we must not touch that" verdict;
|
|
30
|
+
* - {@link rescueDismiss} gated two ways — presses ONE dialog's own Cancel;
|
|
31
|
+
* - {@link rescueRelaunch} gated two ways — ends the process and starts it
|
|
32
|
+
* again, the only cure measured for oddities §26.
|
|
33
|
+
*
|
|
34
|
+
* PERMISSIONS DOCTRINE. Nothing here raises a macOS consent dialog. The screen
|
|
35
|
+
* is read behind the prompt-free capability verdict, exactly as `doctor
|
|
36
|
+
* --ui-state` reads it, and a machine that has not granted the access is TOLD so
|
|
37
|
+
* rather than
|
|
38
|
+
* prompted. {@link rescueRelaunch} is the deliberate exception to needing that
|
|
39
|
+
* grant at all — see its own note.
|
|
40
|
+
*/
|
|
41
|
+
import { createAuditWriter } from "./audit/log.js";
|
|
42
|
+
import { uiAllowed, uiCapability as uiCapabilityDefault } from "./capability.js";
|
|
43
|
+
import { loadConfig } from "./config.js";
|
|
44
|
+
import { PKG_VERSION } from "./contracts.js";
|
|
45
|
+
import { BASELINES } from "./db/baselines/index.js";
|
|
46
|
+
import { openConnection, ThingsDbOpenError } from "./db/connection.js";
|
|
47
|
+
import { compareToBaseline, observeSchema } from "./db/fingerprint.js";
|
|
48
|
+
import { locateThingsDb, ThingsDbNotFoundError } from "./db/locate.js";
|
|
49
|
+
import { auditDir, mutationLockPath } from "./paths.js";
|
|
50
|
+
import { formatHeldFor, readLockHolder } from "./write/lock.js";
|
|
51
|
+
import { censusUnverifiable, describeUiState, describeUnprovenProbes, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./write/vectors/ui-state.js";
|
|
52
|
+
import { axCancelDialogScript, axCancelFrameScript, defaultUiRunner, parseFrameCenter, readLiveUiState, uiClickPointCommand, } from "./write/vectors/ui.js";
|
|
53
|
+
import { execFile } from "node:child_process";
|
|
54
|
+
// ---------------------------------------------------------------- timings
|
|
55
|
+
/**
|
|
56
|
+
* Budget for the AppleScript `quit` rung. A standing sheet swallows ⌘Q, and the
|
|
57
|
+
* scripting `quit` verb can itself block behind one — which is the whole reason
|
|
58
|
+
* this rung is bounded rather than awaited. Short on purpose: the rungs under it
|
|
59
|
+
* cost nothing and always work.
|
|
60
|
+
*/
|
|
61
|
+
const QUIT_TIMEOUT_MS = 6_000;
|
|
62
|
+
/** How long each rung of the kill ladder waits for the process to actually go. */
|
|
63
|
+
const DEATH_WAIT_MS = 5_000;
|
|
64
|
+
/** How long to wait for a relaunched Things to be answering again. */
|
|
65
|
+
const RELAUNCH_WAIT_MS = 45_000;
|
|
66
|
+
/** Poll interval for every "has it happened yet" loop in this module. */
|
|
67
|
+
const POLL_MS = 250;
|
|
68
|
+
/** Per-hop budget for the two Cancel scripts (resolve + press). */
|
|
69
|
+
const CANCEL_TIMEOUT_MS = 10_000;
|
|
70
|
+
function run(cmd, args, timeoutMs) {
|
|
71
|
+
return new Promise((settle) => {
|
|
72
|
+
execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => {
|
|
73
|
+
settle({ ok: err === null, out: String(stdout ?? "").trim() });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** `pgrep -x Things3` — the process table, asked by name, costing no grant. */
|
|
78
|
+
async function livePids() {
|
|
79
|
+
const res = await run("/usr/bin/pgrep", ["-x", THINGS_PROCESS], 5_000);
|
|
80
|
+
if (!res.ok)
|
|
81
|
+
return []; // pgrep exits 1 when nothing matches
|
|
82
|
+
return res.out
|
|
83
|
+
.split(/\s+/)
|
|
84
|
+
.map((s) => Number(s))
|
|
85
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
86
|
+
}
|
|
87
|
+
function realSchemaStatus() {
|
|
88
|
+
let path;
|
|
89
|
+
try {
|
|
90
|
+
path = locateThingsDb().path;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
detail: err instanceof ThingsDbNotFoundError
|
|
96
|
+
? "the Things database could not be found"
|
|
97
|
+
: "the Things database could not be located",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
let conn;
|
|
101
|
+
try {
|
|
102
|
+
conn = openConnection(path);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
detail: err instanceof ThingsDbOpenError
|
|
108
|
+
? "the Things database is there but would not open yet"
|
|
109
|
+
: "the Things database would not open",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const status = compareToBaseline(observeSchema(conn.db), BASELINES);
|
|
114
|
+
return status.kind === "ok"
|
|
115
|
+
? { ok: true, detail: "the database opened and reads as the shape this version expects" }
|
|
116
|
+
: {
|
|
117
|
+
ok: false,
|
|
118
|
+
detail: "the database opened but no longer matches the shape this version expects",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
conn.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function resolve(deps) {
|
|
126
|
+
const env = deps.env ?? process.env;
|
|
127
|
+
return {
|
|
128
|
+
uiCapability: deps.uiCapability ?? (() => uiCapabilityDefault()),
|
|
129
|
+
run: deps.run ?? defaultUiRunner,
|
|
130
|
+
env,
|
|
131
|
+
now: deps.now ?? Date.now,
|
|
132
|
+
pidAlive: deps.pidAlive ??
|
|
133
|
+
((pid) => {
|
|
134
|
+
try {
|
|
135
|
+
process.kill(pid, 0);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
thingsPids: deps.thingsPids ?? livePids,
|
|
143
|
+
signal: deps.signal ??
|
|
144
|
+
((pid, sig) => {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(pid, sig);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// already gone — that is the outcome we wanted
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
launch: deps.launch ??
|
|
153
|
+
(async () => {
|
|
154
|
+
const res = await run("/usr/bin/open", ["-g", "-a", THINGS_PROCESS], 15_000);
|
|
155
|
+
return {
|
|
156
|
+
ok: res.ok,
|
|
157
|
+
detail: res.ok ? "Things was started in the background" : "Things would not start",
|
|
158
|
+
};
|
|
159
|
+
}),
|
|
160
|
+
schemaStatus: deps.schemaStatus ?? (() => realSchemaStatus()),
|
|
161
|
+
sleep: deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
|
|
162
|
+
audit: deps.audit ??
|
|
163
|
+
(() => {
|
|
164
|
+
const config = loadConfig(env);
|
|
165
|
+
return config.auditEnabled
|
|
166
|
+
? createAuditWriter({ dir: auditDir(env), secrets: [], enabled: true })
|
|
167
|
+
: null;
|
|
168
|
+
})(),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Read everything a stuck machine can be asked about, without touching any of
|
|
173
|
+
* it. Never throws and never refuses: the cases this exists for are exactly the
|
|
174
|
+
* ones where something is already wrong, so every failure is a REPORTED state.
|
|
175
|
+
*/
|
|
176
|
+
export async function rescueStatus(deps = {}) {
|
|
177
|
+
const d = resolve(deps);
|
|
178
|
+
const path = mutationLockPath(d.env);
|
|
179
|
+
const holder = readLockHolder(path, { pidAlive: d.pidAlive, now: d.now });
|
|
180
|
+
const lock = {
|
|
181
|
+
path,
|
|
182
|
+
held: holder.holder !== null,
|
|
183
|
+
pid: holder.holder?.pid ?? null,
|
|
184
|
+
since: holder.holder?.ts ?? null,
|
|
185
|
+
heldFor: holder.heldForMs === null ? null : formatHeldFor(holder.heldForMs),
|
|
186
|
+
alive: holder.alive,
|
|
187
|
+
suspect: holder.suspect,
|
|
188
|
+
detail: holder.holder === null
|
|
189
|
+
? "no change is holding the lock"
|
|
190
|
+
: holder.alive
|
|
191
|
+
? `pid ${holder.holder.pid} has held it${holder.heldForMs === null ? "" : ` for ${formatHeldFor(holder.heldForMs)}`}${holder.suspect
|
|
192
|
+
? " — far longer than any change takes; that process may be hung, and killing it releases the lock"
|
|
193
|
+
: ""}`
|
|
194
|
+
: `pid ${holder.holder.pid} holds it but is no longer running — the next change takes it`,
|
|
195
|
+
};
|
|
196
|
+
const capability = d.uiCapability();
|
|
197
|
+
if (!uiAllowed(capability)) {
|
|
198
|
+
return {
|
|
199
|
+
screenReadable: false,
|
|
200
|
+
detail: `the Things window cannot be read on this machine — ${capability.detail}`,
|
|
201
|
+
state: null,
|
|
202
|
+
lock,
|
|
203
|
+
foreignModal: null,
|
|
204
|
+
warnings: [],
|
|
205
|
+
remediation: capability.remediation,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const state = await readLiveUiState(d.run);
|
|
209
|
+
if (state === null) {
|
|
210
|
+
return {
|
|
211
|
+
screenReadable: true,
|
|
212
|
+
detail: "the window and focus state could not be read — Things may have stopped answering, or a " +
|
|
213
|
+
"system dialog is covering the screen",
|
|
214
|
+
state: null,
|
|
215
|
+
lock,
|
|
216
|
+
foreignModal: null,
|
|
217
|
+
warnings: [],
|
|
218
|
+
remediation: [
|
|
219
|
+
"look at the screen: a macOS privacy or consent dialog is not exposed to any app and has " +
|
|
220
|
+
"to be answered by hand",
|
|
221
|
+
"if Things itself has stopped answering, `things rescue relaunch` ends it and starts it again",
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const foreignModal = state.inspectable
|
|
226
|
+
? null
|
|
227
|
+
: {
|
|
228
|
+
owner: state.frontmostApp,
|
|
229
|
+
detail: "a system dialog owns the screen — macOS does not expose it to other apps, so nothing " +
|
|
230
|
+
`here can identify or close it${state.frontmostApp === null ? "" : ` (frontmost application: ${state.frontmostApp})`}`,
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
screenReadable: true,
|
|
234
|
+
detail: describeUiState(state),
|
|
235
|
+
state,
|
|
236
|
+
lock,
|
|
237
|
+
foreignModal,
|
|
238
|
+
warnings: state.sheetOpen ? [SYNC_GATE_WARNING] : [],
|
|
239
|
+
remediation: statusAdvice(state, foreignModal, lock),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** What the operator should do, given everything the census and the lock proved. */
|
|
243
|
+
function statusAdvice(state, foreignModal, lock) {
|
|
244
|
+
const next = [];
|
|
245
|
+
if (foreignModal !== null) {
|
|
246
|
+
next.push("answer that dialog at the screen — no rescue command will press a button on a dialog it " +
|
|
247
|
+
"cannot identify, and this one belongs to macOS rather than to Things");
|
|
248
|
+
}
|
|
249
|
+
else if (censusUnverifiable(state)) {
|
|
250
|
+
next.push(`part of the screen read did not answer (${describeUnprovenProbes(state)}) — run this ` +
|
|
251
|
+
"again, and if it keeps happening `things rescue relaunch` ends Things and starts it again");
|
|
252
|
+
}
|
|
253
|
+
else if (state.sheetOpen) {
|
|
254
|
+
if (state.sheetKind === "other") {
|
|
255
|
+
next.push("the open dialog is not one this command recognizes, so it will not press its buttons — " +
|
|
256
|
+
"close it at the screen, or run `things rescue relaunch` to end Things and start it again");
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
next.push("`things rescue dismiss` closes the dialog in front by pressing its own Cancel button" +
|
|
260
|
+
(state.sheetDepth > 1
|
|
261
|
+
? `; ${state.sheetDepth} are stacked, and each one needs its own invocation`
|
|
262
|
+
: ""));
|
|
263
|
+
}
|
|
264
|
+
if (state.sheetForm === "detached") {
|
|
265
|
+
next.push("this dialog is the detached kind, which has been measured to ignore every way of closing " +
|
|
266
|
+
"it — expect `things rescue dismiss` to report that it is still there, and use " +
|
|
267
|
+
"`things rescue relaunch`");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (lock.suspect) {
|
|
271
|
+
next.push(`the change lock has been held by pid ${lock.pid} for ${lock.heldFor ?? "a long time"}; if ` +
|
|
272
|
+
`that process is hung, \`kill ${lock.pid}\` releases it`);
|
|
273
|
+
}
|
|
274
|
+
return next;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Close the dialog IN FRONT by pressing its own Cancel button — FGRD2's proven
|
|
278
|
+
* Cancel, generalized off the drive's abort path and onto a dialog no drive
|
|
279
|
+
* opened.
|
|
280
|
+
*
|
|
281
|
+
* EXACTLY ONE LIFO LEVEL PER INVOCATION. Sheets nest as `AXSheet` children of
|
|
282
|
+
* the sheet below and dismiss strictly last-in-first-out (MODALX1 §6). A loop
|
|
283
|
+
* that cleared a whole stack in one command would be a loop pressing an unknown
|
|
284
|
+
* number of buttons on an unknown number of dialogs; instead this closes the top
|
|
285
|
+
* one, re-reads, and reports how many remain so the caller decides.
|
|
286
|
+
*
|
|
287
|
+
* PROVEN, never assumed. The press reporting success is NOT closure: the
|
|
288
|
+
* detached editor of oddities §26 accepts an AXPress on its Cancel and stays
|
|
289
|
+
* exactly where it is. Closure is decided by a fresh census showing the stack one
|
|
290
|
+
* level shorter, and an unconfirmable press is reported as unconfirmed.
|
|
291
|
+
*
|
|
292
|
+
* WHAT IT CAN COST YOU: the dialog's own pending edits, and nothing else. The
|
|
293
|
+
* database is untouched, and a URL command parked behind a consent sheet is
|
|
294
|
+
* discarded with it (URLEN1).
|
|
295
|
+
*/
|
|
296
|
+
export async function rescueDismiss(ack = {}, deps = {}) {
|
|
297
|
+
const d = resolve(deps);
|
|
298
|
+
const startedAt = new Date(d.now());
|
|
299
|
+
const refuse = (detail, remediation, before = null) => {
|
|
300
|
+
const result = {
|
|
301
|
+
outcome: "refused",
|
|
302
|
+
how: null,
|
|
303
|
+
before,
|
|
304
|
+
after: null,
|
|
305
|
+
levelsRemaining: before?.sheetDepth ?? null,
|
|
306
|
+
detail,
|
|
307
|
+
remediation,
|
|
308
|
+
notes: [],
|
|
309
|
+
warnings: before?.sheetOpen === true ? [SYNC_GATE_WARNING] : [],
|
|
310
|
+
};
|
|
311
|
+
return result;
|
|
312
|
+
};
|
|
313
|
+
if (ack.dangerouslyDismissDialog !== true) {
|
|
314
|
+
return refuse("this closes a dialog that is open in Things, discarding whatever was typed into it", [
|
|
315
|
+
"pass dangerouslyDismissDialog (--dangerously-dismiss-dialog) to proceed",
|
|
316
|
+
"run `things rescue status` first to see what is open",
|
|
317
|
+
]);
|
|
318
|
+
}
|
|
319
|
+
const capability = d.uiCapability();
|
|
320
|
+
if (!uiAllowed(capability)) {
|
|
321
|
+
return refuse(`the Things window cannot be reached on this machine — ${capability.detail}`, capability.remediation);
|
|
322
|
+
}
|
|
323
|
+
const before = await readLiveUiState(d.run);
|
|
324
|
+
if (before === null || censusUnverifiable(before)) {
|
|
325
|
+
return refuse(before === null
|
|
326
|
+
? "the window state could not be read, so there is no way to know which dialog a Cancel " +
|
|
327
|
+
"would land on — nothing was pressed"
|
|
328
|
+
: `the window state could not be established (${describeUnprovenProbes(before)}), so ` +
|
|
329
|
+
"there is no way to know which dialog a Cancel would land on — nothing was pressed", [
|
|
330
|
+
"run `things rescue status` and try again once the screen reads cleanly",
|
|
331
|
+
"if it will not, `things rescue relaunch` ends Things and starts it again",
|
|
332
|
+
], before);
|
|
333
|
+
}
|
|
334
|
+
if (!before.inspectable) {
|
|
335
|
+
return refuse("a system dialog owns the screen — macOS does not expose it to other apps, so nothing here " +
|
|
336
|
+
`can identify it, and nothing here will press its buttons${before.frontmostApp === null ? "" : ` (frontmost application: ${before.frontmostApp})`}`, ["answer that dialog at the screen; it belongs to macOS, not to Things"], before);
|
|
337
|
+
}
|
|
338
|
+
if (!before.sheetOpen) {
|
|
339
|
+
return {
|
|
340
|
+
outcome: "no-dialog",
|
|
341
|
+
how: null,
|
|
342
|
+
before,
|
|
343
|
+
after: before,
|
|
344
|
+
levelsRemaining: 0,
|
|
345
|
+
detail: "no dialog is open in Things — nothing was stranded and nothing was pressed",
|
|
346
|
+
remediation: [],
|
|
347
|
+
notes: [],
|
|
348
|
+
warnings: [],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
if (before.sheetKind === "other") {
|
|
352
|
+
return refuse("the dialog in front is not one this command recognizes, and it will not press buttons on a " +
|
|
353
|
+
"dialog it cannot identify", [
|
|
354
|
+
"close it at the screen if you can see it",
|
|
355
|
+
"otherwise `things rescue relaunch` ends Things and starts it again",
|
|
356
|
+
], before);
|
|
357
|
+
}
|
|
358
|
+
// --- press its own Cancel, then PROVE it.
|
|
359
|
+
const notes = [];
|
|
360
|
+
const pressed = await d.run({
|
|
361
|
+
primitive: "resolve",
|
|
362
|
+
label: "press the open dialog's Cancel button",
|
|
363
|
+
script: axCancelDialogScript(),
|
|
364
|
+
}, CANCEL_TIMEOUT_MS);
|
|
365
|
+
const verdict = pressed.stdout.trim();
|
|
366
|
+
if (pressed.ok && verdict === "NO-CANCEL") {
|
|
367
|
+
return refuse("the dialog in front has no Cancel button, so there is nothing here to press", ["`things rescue relaunch` ends Things and starts it again"], before);
|
|
368
|
+
}
|
|
369
|
+
if (pressed.ok && verdict === "NO-DIALOG") {
|
|
370
|
+
const after = await readLiveUiState(d.run);
|
|
371
|
+
return {
|
|
372
|
+
outcome: "no-dialog",
|
|
373
|
+
how: null,
|
|
374
|
+
before,
|
|
375
|
+
after,
|
|
376
|
+
levelsRemaining: after?.sheetDepth ?? null,
|
|
377
|
+
detail: "the dialog closed on its own between reading the screen and pressing Cancel",
|
|
378
|
+
remediation: [],
|
|
379
|
+
notes: [],
|
|
380
|
+
warnings: after?.sheetOpen === true ? [SYNC_GATE_WARNING] : [],
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
let after = await readLiveUiState(d.run);
|
|
384
|
+
let how = pressed.ok && verdict === "OK" ? "cancel-button" : null;
|
|
385
|
+
// The synthesized-click fallback, on the same button, resolved by address.
|
|
386
|
+
if (!closedOneLevel(before, after)) {
|
|
387
|
+
notes.push("its Cancel button did not close it, so the button was clicked at its own position");
|
|
388
|
+
const frame = await d.run({ primitive: "resolve", label: "find the Cancel button", script: axCancelFrameScript() }, CANCEL_TIMEOUT_MS);
|
|
389
|
+
const center = frame.ok ? parseFrameCenter(frame.stdout) : null;
|
|
390
|
+
if (center !== null) {
|
|
391
|
+
await d.run(uiClickPointCommand(center.x, center.y, "click the open dialog's Cancel button"), CANCEL_TIMEOUT_MS);
|
|
392
|
+
after = await readLiveUiState(d.run);
|
|
393
|
+
if (closedOneLevel(before, after))
|
|
394
|
+
how = "cancel-click";
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const result = judgeDismissal(before, after, how, notes);
|
|
398
|
+
recordRescue(d, {
|
|
399
|
+
op: "rescue.dismiss",
|
|
400
|
+
startedAt,
|
|
401
|
+
result: result.outcome === "dismissed" ? "ok" : `blocked:${result.outcome}`,
|
|
402
|
+
requested: { dangerouslyDismissDialog: true },
|
|
403
|
+
pre: censusRecord(before),
|
|
404
|
+
observed: after === null ? null : censusRecord(after),
|
|
405
|
+
});
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Did the stack get exactly one level shorter? This — not "the press returned
|
|
410
|
+
* OK", and not "no sheet is open" — is what closure means here: dismissing the
|
|
411
|
+
* top of a stack of two correctly leaves one standing, and a press that reported
|
|
412
|
+
* success against the §26 detached editor changes nothing at all.
|
|
413
|
+
*/
|
|
414
|
+
function closedOneLevel(before, after) {
|
|
415
|
+
if (after === null || censusUnverifiable(after) || !after.inspectable)
|
|
416
|
+
return false;
|
|
417
|
+
return after.sheetDepth < before.sheetDepth;
|
|
418
|
+
}
|
|
419
|
+
function judgeDismissal(before, after, how, notes) {
|
|
420
|
+
const levelsRemaining = after === null ? null : after.sheetDepth;
|
|
421
|
+
if (closedOneLevel(before, after) && after !== null) {
|
|
422
|
+
const remaining = after.sheetDepth;
|
|
423
|
+
return {
|
|
424
|
+
outcome: "dismissed",
|
|
425
|
+
how,
|
|
426
|
+
before,
|
|
427
|
+
after,
|
|
428
|
+
levelsRemaining: remaining,
|
|
429
|
+
detail: remaining === 0
|
|
430
|
+
? "the dialog was closed and no dialog is open in Things"
|
|
431
|
+
: `the dialog in front was closed; ${remaining} still open underneath it`,
|
|
432
|
+
remediation: remaining === 0
|
|
433
|
+
? []
|
|
434
|
+
: ["run `things rescue dismiss` again to close the next one — they close one at a time"],
|
|
435
|
+
notes,
|
|
436
|
+
warnings: remaining === 0 ? [] : [SYNC_GATE_WARNING],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (after === null || censusUnverifiable(after) || !after.inspectable) {
|
|
440
|
+
return {
|
|
441
|
+
outcome: "unverified",
|
|
442
|
+
how: null,
|
|
443
|
+
before,
|
|
444
|
+
after,
|
|
445
|
+
levelsRemaining,
|
|
446
|
+
detail: "Cancel was pressed, and the screen could not be read afterwards — so whether the dialog " +
|
|
447
|
+
"closed is unknown, and this reports that rather than assuming it",
|
|
448
|
+
remediation: [
|
|
449
|
+
"run `things rescue status` to see the current state",
|
|
450
|
+
"if the dialog is still there, `things rescue relaunch` ends Things and starts it again",
|
|
451
|
+
],
|
|
452
|
+
notes,
|
|
453
|
+
warnings: [SYNC_GATE_WARNING],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
outcome: "still-open",
|
|
458
|
+
how: null,
|
|
459
|
+
before,
|
|
460
|
+
after,
|
|
461
|
+
levelsRemaining,
|
|
462
|
+
detail: "Cancel was pressed, both by the button and at its position on screen, and the dialog is " +
|
|
463
|
+
"still open" +
|
|
464
|
+
(before.sheetForm === "detached"
|
|
465
|
+
? " — this is the detached kind of dialog, which has been measured to ignore every way of " +
|
|
466
|
+
"closing it"
|
|
467
|
+
: ""),
|
|
468
|
+
remediation: [
|
|
469
|
+
"`things rescue relaunch` ends Things and starts it again — it is the only thing measured to " +
|
|
470
|
+
"clear this",
|
|
471
|
+
],
|
|
472
|
+
notes,
|
|
473
|
+
warnings: [SYNC_GATE_WARNING],
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* End Things and start it again — the nuclear rung, and the ONLY cure this
|
|
478
|
+
* project has measured for the detached editor of oddities §26. DRVLAT1 §5 tried
|
|
479
|
+
* every other route against the same live dialog (its own Cancel backgrounded,
|
|
480
|
+
* foregrounded and after `AXRaise`; Escape; ⌘W then re-activate; a real HID click
|
|
481
|
+
* at the AX-resolved Cancel frame) and every one was inert.
|
|
482
|
+
*
|
|
483
|
+
* THE LADDER, each rung bounded:
|
|
484
|
+
*
|
|
485
|
+
* 1. ask Things to quit through its own scripting interface. Bounded, because
|
|
486
|
+
* a standing sheet swallows ⌘Q and the `quit` verb can block behind one.
|
|
487
|
+
* 2. SIGTERM, and wait for the process to go.
|
|
488
|
+
* 3. SIGKILL — the lab's canonical reset for a modal that will not clear.
|
|
489
|
+
* 4. start it again in the BACKGROUND: we do not steal the screen to fix the
|
|
490
|
+
* screen.
|
|
491
|
+
* 5. prove it: the process is answering, the database still reads as the shape
|
|
492
|
+
* this version expects, and no dialog is open.
|
|
493
|
+
*
|
|
494
|
+
* WHAT IS LOST: the Things database is write-ahead-logged, so everything already
|
|
495
|
+
* committed survives a kill unconditionally. The ceiling is what was never
|
|
496
|
+
* committed — the edits sitting in the dialog being destroyed.
|
|
497
|
+
*
|
|
498
|
+
* WHAT COMES BACK: sync. While a dialog stands, Things stops sending changes to
|
|
499
|
+
* Things Cloud (oddities §24); killing the dialog with the process releases that,
|
|
500
|
+
* and everything written on this Mac in the meantime goes out.
|
|
501
|
+
*
|
|
502
|
+
* DELIBERATELY NOT GATED ON THE GUI CAPABILITY. Every other verb that touches the
|
|
503
|
+
* app needs the GUI grant, because it synthesizes input into someone's window.
|
|
504
|
+
* This one signals a process the user already owns, which needs no grant at all —
|
|
505
|
+
* and the machines that need it most are exactly the ones where the Accessibility
|
|
506
|
+
* path is what broke. The quit rung and the census still run through the granted
|
|
507
|
+
* seam when it is available, and are skipped without complaint when it is not.
|
|
508
|
+
*/
|
|
509
|
+
export async function rescueRelaunch(ack = {}, deps = {}) {
|
|
510
|
+
const d = resolve(deps);
|
|
511
|
+
const startedAt = new Date(d.now());
|
|
512
|
+
const profile = loadConfig(d.env).profile;
|
|
513
|
+
const ladder = [];
|
|
514
|
+
const refuse = (detail, remediation) => ({
|
|
515
|
+
outcome: "refused",
|
|
516
|
+
endedBy: null,
|
|
517
|
+
before: null,
|
|
518
|
+
after: null,
|
|
519
|
+
ladder,
|
|
520
|
+
detail,
|
|
521
|
+
remediation,
|
|
522
|
+
notes: [],
|
|
523
|
+
warnings: [],
|
|
524
|
+
});
|
|
525
|
+
if (ack.yes !== true) {
|
|
526
|
+
return refuse("this ends the Things application and starts it again; anything typed into an open dialog " +
|
|
527
|
+
"and not yet saved is discarded", [
|
|
528
|
+
"pass --yes to proceed",
|
|
529
|
+
"run `things rescue status` first, and `things rescue dismiss` if the dialog will close on its own",
|
|
530
|
+
]);
|
|
531
|
+
}
|
|
532
|
+
if (profile === "workstation" && ack.dangerouslyForceQuit !== true) {
|
|
533
|
+
return refuse("this machine is configured as a workstation, where someone may be sitting in front of the " +
|
|
534
|
+
"dialog this would destroy", [
|
|
535
|
+
"pass --dangerously-force-quit as well to proceed",
|
|
536
|
+
"or set `things config set profile dedicated-server` on a machine nobody is sitting at",
|
|
537
|
+
]);
|
|
538
|
+
}
|
|
539
|
+
const capable = uiAllowed(d.uiCapability());
|
|
540
|
+
const before = capable ? await readLiveUiState(d.run) : null;
|
|
541
|
+
ladder.push(before === null
|
|
542
|
+
? capable
|
|
543
|
+
? "the screen could not be read before starting"
|
|
544
|
+
: "the screen was not read (this machine has not granted the access; it is not needed for this)"
|
|
545
|
+
: `before: ${describeUiState(before)}`);
|
|
546
|
+
// Rung 1 — ask nicely, on a short leash.
|
|
547
|
+
let pids = await d.thingsPids();
|
|
548
|
+
let endedBy = null;
|
|
549
|
+
if (pids.length === 0) {
|
|
550
|
+
endedBy = "not-running";
|
|
551
|
+
ladder.push("Things was not running");
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
if (capable) {
|
|
555
|
+
const quit = await d.run({
|
|
556
|
+
primitive: "resolve",
|
|
557
|
+
label: "ask Things to quit",
|
|
558
|
+
script: `tell application "${THINGS_PROCESS}" to quit`,
|
|
559
|
+
}, QUIT_TIMEOUT_MS);
|
|
560
|
+
ladder.push(quit.ok ? "asked Things to quit" : "asked Things to quit — it did not answer");
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
ladder.push("skipped asking Things to quit (that needs an access this machine has not granted)");
|
|
564
|
+
}
|
|
565
|
+
if (await waitForDeath(d, DEATH_WAIT_MS)) {
|
|
566
|
+
endedBy = "quit";
|
|
567
|
+
ladder.push("it quit on its own");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Rungs 2 and 3 — SIGTERM, then SIGKILL.
|
|
571
|
+
for (const [sig, label] of [
|
|
572
|
+
["SIGTERM", "sigterm"],
|
|
573
|
+
["SIGKILL", "sigkill"],
|
|
574
|
+
]) {
|
|
575
|
+
if (endedBy !== null)
|
|
576
|
+
break;
|
|
577
|
+
pids = await d.thingsPids();
|
|
578
|
+
if (pids.length === 0)
|
|
579
|
+
break;
|
|
580
|
+
for (const pid of pids)
|
|
581
|
+
d.signal(pid, sig);
|
|
582
|
+
ladder.push(`sent ${sig} to ${pids.map((p) => `pid ${p}`).join(", ")}`);
|
|
583
|
+
if (await waitForDeath(d, DEATH_WAIT_MS)) {
|
|
584
|
+
endedBy = label;
|
|
585
|
+
ladder.push("the process ended");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (endedBy === null && (await d.thingsPids()).length > 0) {
|
|
589
|
+
return {
|
|
590
|
+
outcome: "failed",
|
|
591
|
+
endedBy: null,
|
|
592
|
+
before,
|
|
593
|
+
after: null,
|
|
594
|
+
ladder,
|
|
595
|
+
detail: "Things would not end, even when killed — nothing was restarted",
|
|
596
|
+
remediation: [
|
|
597
|
+
"quit it from the Force Quit window (⌥⌘⎋) or restart the Mac",
|
|
598
|
+
"this should not happen; it is worth reporting",
|
|
599
|
+
],
|
|
600
|
+
notes: [],
|
|
601
|
+
warnings: [],
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
// Rung 4 — start it again, in the background.
|
|
605
|
+
const launched = await d.launch();
|
|
606
|
+
ladder.push(launched.detail);
|
|
607
|
+
if (!launched.ok) {
|
|
608
|
+
return {
|
|
609
|
+
outcome: "failed",
|
|
610
|
+
endedBy,
|
|
611
|
+
before,
|
|
612
|
+
after: null,
|
|
613
|
+
ladder,
|
|
614
|
+
detail: "Things was ended but would not start again",
|
|
615
|
+
remediation: ["start Things by hand, then run `things rescue status` to confirm it is clear"],
|
|
616
|
+
notes: [],
|
|
617
|
+
warnings: [],
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
// Rung 5 — prove it came back clean.
|
|
621
|
+
const after = capable ? await waitForHealthy(d) : null;
|
|
622
|
+
const schema = d.schemaStatus();
|
|
623
|
+
ladder.push(schema.detail);
|
|
624
|
+
const notes = [
|
|
625
|
+
"everything already saved survived: only what was typed into the open dialog and not saved is gone",
|
|
626
|
+
"Things sends changes to Things Cloud again now that no dialog is standing",
|
|
627
|
+
];
|
|
628
|
+
const back = after !== null && after.thingsRunning && !after.sheetOpen;
|
|
629
|
+
ladder.push(after === null
|
|
630
|
+
? capable
|
|
631
|
+
? "the screen could not be read after starting"
|
|
632
|
+
: "the screen was not read after starting"
|
|
633
|
+
: `after: ${describeUiState(after)}`);
|
|
634
|
+
const result = {
|
|
635
|
+
outcome: "relaunched",
|
|
636
|
+
endedBy,
|
|
637
|
+
before,
|
|
638
|
+
after,
|
|
639
|
+
ladder,
|
|
640
|
+
detail: back
|
|
641
|
+
? "Things was ended and started again, and no dialog is open"
|
|
642
|
+
: after !== null && after.sheetOpen
|
|
643
|
+
? "Things was ended and started again, and a dialog is open again"
|
|
644
|
+
: "Things was ended and started again; whether a dialog is open could not be confirmed from here",
|
|
645
|
+
remediation: back && schema.ok ? [] : ["run `things rescue status` to see the current state"],
|
|
646
|
+
notes,
|
|
647
|
+
warnings: schema.ok
|
|
648
|
+
? []
|
|
649
|
+
: [
|
|
650
|
+
"the Things database no longer reads as the shape this version expects — run `things doctor`",
|
|
651
|
+
],
|
|
652
|
+
};
|
|
653
|
+
recordRescue(d, {
|
|
654
|
+
op: "rescue.relaunch",
|
|
655
|
+
startedAt,
|
|
656
|
+
result: "ok",
|
|
657
|
+
requested: {
|
|
658
|
+
yes: true,
|
|
659
|
+
...(ack.dangerouslyForceQuit === true && { dangerouslyForceQuit: true }),
|
|
660
|
+
},
|
|
661
|
+
pre: before === null ? null : censusRecord(before),
|
|
662
|
+
observed: {
|
|
663
|
+
...(after === null ? {} : censusRecord(after)),
|
|
664
|
+
endedBy: endedBy ?? "unknown",
|
|
665
|
+
schemaOk: schema.ok,
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
return result;
|
|
669
|
+
}
|
|
670
|
+
/** Poll until no Things process is left, or the budget runs out. */
|
|
671
|
+
async function waitForDeath(d, budgetMs) {
|
|
672
|
+
const deadline = d.now() + budgetMs;
|
|
673
|
+
for (;;) {
|
|
674
|
+
if ((await d.thingsPids()).length === 0)
|
|
675
|
+
return true;
|
|
676
|
+
if (d.now() >= deadline)
|
|
677
|
+
return false;
|
|
678
|
+
await d.sleep(POLL_MS);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/** Poll until the relaunched app is answering the census, or the budget runs out. */
|
|
682
|
+
async function waitForHealthy(d) {
|
|
683
|
+
const deadline = d.now() + RELAUNCH_WAIT_MS;
|
|
684
|
+
let last = null;
|
|
685
|
+
for (;;) {
|
|
686
|
+
last = await readLiveUiState(d.run);
|
|
687
|
+
if (last !== null && last.thingsRunning && !censusUnverifiable(last))
|
|
688
|
+
return last;
|
|
689
|
+
if (d.now() >= deadline)
|
|
690
|
+
return last;
|
|
691
|
+
await d.sleep(POLL_MS);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// ------------------------------------------------------------ the record
|
|
695
|
+
/**
|
|
696
|
+
* The census as a change-history payload. ROLE COUNTS and kinds only — never a
|
|
697
|
+
* control's value and never a window title, exactly as the census itself
|
|
698
|
+
* promises (a stranded Repeat sheet is showing the user's own to-do text).
|
|
699
|
+
*/
|
|
700
|
+
function censusRecord(state) {
|
|
701
|
+
return {
|
|
702
|
+
sheetOpen: state.sheetOpen,
|
|
703
|
+
sheetKind: state.sheetKind,
|
|
704
|
+
sheetForm: state.sheetForm,
|
|
705
|
+
sheetDepth: state.sheetDepth,
|
|
706
|
+
sheetControls: state.sheetControls,
|
|
707
|
+
thingsRunning: state.thingsRunning,
|
|
708
|
+
thingsFrontmost: state.thingsFrontmost,
|
|
709
|
+
frontmostApp: state.frontmostApp,
|
|
710
|
+
inspectable: state.inspectable,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Record what a rescue ACTION did. `status` records nothing — it changes
|
|
715
|
+
* nothing. Best-effort by construction: the writer never throws, and a machine
|
|
716
|
+
* that cannot write its history is still a machine that needed rescuing.
|
|
717
|
+
*/
|
|
718
|
+
function recordRescue(d, fields) {
|
|
719
|
+
if (d.audit === null)
|
|
720
|
+
return;
|
|
721
|
+
const config = loadConfig(d.env);
|
|
722
|
+
d.audit.append({
|
|
723
|
+
v: 1,
|
|
724
|
+
ts: fields.startedAt.toISOString(),
|
|
725
|
+
actor: config.actor,
|
|
726
|
+
host: config.host,
|
|
727
|
+
op: fields.op,
|
|
728
|
+
uuid: null,
|
|
729
|
+
vector: "ui",
|
|
730
|
+
disruption: 3,
|
|
731
|
+
invocation: null,
|
|
732
|
+
requested: fields.requested,
|
|
733
|
+
pre: fields.pre,
|
|
734
|
+
observed: fields.observed,
|
|
735
|
+
result: fields.result,
|
|
736
|
+
verify: null,
|
|
737
|
+
durationMs: d.now() - fields.startedAt.getTime(),
|
|
738
|
+
env: { pkg: PKG_VERSION, dbVersion: null, fingerprint: "unknown" },
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
// ---------------------------------------------------------------- rendering
|
|
742
|
+
/**
|
|
743
|
+
* The human render of `things rescue status`.
|
|
744
|
+
*
|
|
745
|
+
* A ROW WHOSE PROBE DID NOT ANSWER SAYS SO (issue #629). Printing a field's
|
|
746
|
+
* unset default ("none", "unknown") beside rows that WERE measured is exactly
|
|
747
|
+
* what made a stalled inspection read as a clean screen in the field, so an
|
|
748
|
+
* unproven probe renders as "not established" rather than as its default, and
|
|
749
|
+
* the probes are named again in full on the `unproven:` row.
|
|
750
|
+
*/
|
|
751
|
+
export function rescueStatusLines(report) {
|
|
752
|
+
const lines = ["── Things ──", `screen: ${report.detail}`];
|
|
753
|
+
const state = report.state;
|
|
754
|
+
if (state !== null) {
|
|
755
|
+
const unproven = (p) => state.stalledProbes.includes(p) || state.failedProbes.includes(p);
|
|
756
|
+
lines.push(`dialog: ${unproven("dialog")
|
|
757
|
+
? "not established"
|
|
758
|
+
: state.sheetKind === "none"
|
|
759
|
+
? "none"
|
|
760
|
+
: `${state.sheetKind} (${state.sheetForm}; ${state.sheetControls ?? "no census"})`}`, `stacked: ${unproven("dialog") ? "not established" : state.sheetDepth}`, `frontmost: ${unproven("frontmost")
|
|
761
|
+
? "not established"
|
|
762
|
+
: `${state.frontmostApp ?? (unproven("frontapp") ? "not established" : "unknown")}${state.thingsFrontmost ? " (Things)" : ""}`}`,
|
|
763
|
+
// Which application owns the KEYBOARD, and what has focus inside it —
|
|
764
|
+
// not the same question as which application owns the screen, and the
|
|
765
|
+
// one that explains a keystroke landing in the void.
|
|
766
|
+
`focus: ${unproven("focus")
|
|
767
|
+
? "not established"
|
|
768
|
+
: state.focusOwner === null
|
|
769
|
+
? "unknown"
|
|
770
|
+
: `${state.focusOwner.app} · ${state.focusOwner.role || "no focused element"}${state.focusOwner.subrole === null ? "" : ` / ${state.focusOwner.subrole}`}`}`, `inspectable: ${state.inspectable ? "yes" : "no — a system dialog macOS does not expose"}`);
|
|
771
|
+
const unprovenText = describeUnprovenProbes(state);
|
|
772
|
+
if (unprovenText !== "")
|
|
773
|
+
lines.push(`unproven: ${unprovenText}`);
|
|
774
|
+
}
|
|
775
|
+
lines.push("── Change lock ──", `lock: ${report.lock.detail}`);
|
|
776
|
+
if (report.foreignModal !== null) {
|
|
777
|
+
lines.push("── Not ours ──", `dialog: ${report.foreignModal.detail}`);
|
|
778
|
+
}
|
|
779
|
+
for (const warning of report.warnings)
|
|
780
|
+
lines.push(` warning: ${warning}`);
|
|
781
|
+
for (const step of report.remediation)
|
|
782
|
+
lines.push(` next: ${step}`);
|
|
783
|
+
return lines;
|
|
784
|
+
}
|
|
785
|
+
/** The human render of `things rescue dismiss`. */
|
|
786
|
+
export function rescueDismissLines(result) {
|
|
787
|
+
const lines = [`${result.outcome}: ${result.detail}`];
|
|
788
|
+
if (result.levelsRemaining !== null)
|
|
789
|
+
lines.push(` dialogs still open: ${result.levelsRemaining}`);
|
|
790
|
+
for (const note of result.notes)
|
|
791
|
+
lines.push(` note: ${note}`);
|
|
792
|
+
for (const warning of result.warnings)
|
|
793
|
+
lines.push(` warning: ${warning}`);
|
|
794
|
+
for (const step of result.remediation)
|
|
795
|
+
lines.push(` next: ${step}`);
|
|
796
|
+
return lines;
|
|
797
|
+
}
|
|
798
|
+
/** The human render of `things rescue relaunch`. */
|
|
799
|
+
export function rescueRelaunchLines(result) {
|
|
800
|
+
const lines = [`${result.outcome}: ${result.detail}`];
|
|
801
|
+
for (const step of result.ladder)
|
|
802
|
+
lines.push(` · ${step}`);
|
|
803
|
+
for (const note of result.notes)
|
|
804
|
+
lines.push(` note: ${note}`);
|
|
805
|
+
for (const warning of result.warnings)
|
|
806
|
+
lines.push(` warning: ${warning}`);
|
|
807
|
+
for (const step of result.remediation)
|
|
808
|
+
lines.push(` next: ${step}`);
|
|
809
|
+
return lines;
|
|
810
|
+
}
|
|
811
|
+
//# sourceMappingURL=rescue.js.map
|