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/pipeline.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { type EnvironmentTracker } from "./environment.ts";
|
|
|
9
9
|
import { type LikelyCause } from "./failure-hints.ts";
|
|
10
10
|
import { type HazardId } from "./guards.ts";
|
|
11
11
|
import { type Acknowledgements, type OperationKind, type OperationParamsMap } from "./operations.ts";
|
|
12
|
+
import { type CompletionContext } from "./completion-context.ts";
|
|
13
|
+
import { type PreserveModifiedFailure } from "./preserve-modified.ts";
|
|
12
14
|
import type { VectorId, WriteVector } from "./vectors/types.ts";
|
|
13
15
|
import { type DeltaSpec, type RepeatingDiscovery } from "./verify/delta.ts";
|
|
14
16
|
import { type PollerDeps } from "./verify/poller.ts";
|
|
@@ -20,6 +22,18 @@ export interface WriteOptions extends Acknowledgements {
|
|
|
20
22
|
verifyTimeoutMs?: number;
|
|
21
23
|
/** Return the plan without executing (nothing is audited). */
|
|
22
24
|
dryRun?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Keep this change off the `userModificationDate` (`umd`) timeline. When set,
|
|
27
|
+
* the pipeline captures every pre-existing TARGET row's `umd` before the write
|
|
28
|
+
* and — after the change verifies — restores it through the AppleScript `set
|
|
29
|
+
* modification date` leg (preserve-modified.ts), so a `changes`/watch query
|
|
30
|
+
* keyed on `umd` does not surface the edit. Rows the op CREATES are untouched
|
|
31
|
+
* (their `umd` is legitimately new); a create-only op (add) is a silent no-op.
|
|
32
|
+
* BEST-EFFORT: a failed restore never fails the (already-verified) mutation —
|
|
33
|
+
* it is disclosed per row on the result. Restore lands on `floor(umd0)` (the
|
|
34
|
+
* AppleScript 1-second floor) and is proven only on an UNSYNCED store (SYNC2).
|
|
35
|
+
*/
|
|
36
|
+
preserveModified?: boolean;
|
|
23
37
|
/**
|
|
24
38
|
* Skip the post-execute state VERIFY poll for this write, treating a clean
|
|
25
39
|
* transport (exit 0) as ok. Fail-loud on the transport itself is preserved (a
|
|
@@ -73,6 +87,17 @@ export interface WriteOptions extends Acknowledgements {
|
|
|
73
87
|
* stay on the host clock.
|
|
74
88
|
*/
|
|
75
89
|
normalizeWhen?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* TEMPLATE-DIRECT clone (template-clone via re-promote): tells the clone
|
|
92
|
+
* orchestrator to copy a repeating TEMPLATE source's content as a PLAIN item
|
|
93
|
+
* (recurrence stripped — no rule, no schedule/reminder), instead of refusing it
|
|
94
|
+
* (H-CLONE-SOURCE). NEVER set by a consumer entry point (the CLI/MCP/batch/client
|
|
95
|
+
* do not thread it) — only `cloneTemplateViaRepromote` sets it on the embedded
|
|
96
|
+
* clone leg, which it then native-promotes with the source's decoded rule. A bare
|
|
97
|
+
* `todo/project clone` of a template still routes through the compound (which sets
|
|
98
|
+
* this flag internally). See promote-clone.ts cloneTemplateViaRepromote.
|
|
99
|
+
*/
|
|
100
|
+
cloneTemplateAsPlain?: boolean;
|
|
76
101
|
}
|
|
77
102
|
export interface MutationPlan {
|
|
78
103
|
op: OperationKind;
|
|
@@ -130,6 +155,34 @@ export type MutationResult = {
|
|
|
130
155
|
* the ORIGINAL mutation's identity. Absent on a normal (executed) result.
|
|
131
156
|
*/
|
|
132
157
|
alreadyApplied?: true;
|
|
158
|
+
/**
|
|
159
|
+
* `--preserve-modified` disclosure (ADDITIVE, presence-keyed): the count of
|
|
160
|
+
* pre-existing target rows whose `userModificationDate` was restored to its
|
|
161
|
+
* pre-write value (to the floored second) so the change stays off the
|
|
162
|
+
* `changes`/watch timeline. Present only when the flag did real work —
|
|
163
|
+
* absent on a create-only or already-silent op, where restore is a no-op.
|
|
164
|
+
*/
|
|
165
|
+
preservedModified?: number;
|
|
166
|
+
/**
|
|
167
|
+
* `--preserve-modified` per-row failures (ADDITIVE): rows the restore leg
|
|
168
|
+
* could not neutralize. Best-effort — the mutation itself stands; these are
|
|
169
|
+
* disclosed, never fatal. Absent when every restore leg succeeded.
|
|
170
|
+
*/
|
|
171
|
+
preserveFailures?: PreserveModifiedFailure[];
|
|
172
|
+
/**
|
|
173
|
+
* HINTS1 completion-context (ADDITIVE, presence-keyed): on a successful
|
|
174
|
+
* `todo.complete` / `todo.cancel` only, the remaining OPEN work in the
|
|
175
|
+
* to-do's container(s), computed from a cheap post-verify re-read so an
|
|
176
|
+
* agent can notice an emptied project/Today without a second read.
|
|
177
|
+
* `project` is present when the to-do was in a project (directly or under a
|
|
178
|
+
* heading) and carries its `uuid`, `title`, and the OPEN, untrashed,
|
|
179
|
+
* non-template to-dos remaining in it (heading children included; `0` = the
|
|
180
|
+
* project's open work is now empty). `today` is present when the to-do was
|
|
181
|
+
* a Today member at mutation time and carries the OPEN Today members
|
|
182
|
+
* remaining. The hint INFORMS, never auto-acts; absent when neither applies
|
|
183
|
+
* (and on every other op). See {@link CompletionContext}.
|
|
184
|
+
*/
|
|
185
|
+
context?: CompletionContext;
|
|
133
186
|
} | {
|
|
134
187
|
kind: "verify-failed";
|
|
135
188
|
op: OperationKind;
|
|
@@ -140,6 +193,16 @@ export type MutationResult = {
|
|
|
140
193
|
/** Advisory attribution when the failure signals point somewhere. */
|
|
141
194
|
likelyCause?: LikelyCause;
|
|
142
195
|
hint?: string;
|
|
196
|
+
/**
|
|
197
|
+
* The outcome is genuinely UNCERTAIN, not confirmed-failed (TRACE1, #487):
|
|
198
|
+
* a UI drive was aborted by the watchdog (or the process was interrupted)
|
|
199
|
+
* while the app might have been mid-commit, so the caller must re-check
|
|
200
|
+
* with `things show <uuid>` rather than assume nothing changed. Present
|
|
201
|
+
* only on the ui-drive watchdog timeout.
|
|
202
|
+
*/
|
|
203
|
+
uncertain?: true;
|
|
204
|
+
/** The local trace file reconstructing this drive's timeline (TRACE1). */
|
|
205
|
+
tracePath?: string;
|
|
143
206
|
} | {
|
|
144
207
|
kind: "blocked";
|
|
145
208
|
op: OperationKind;
|
|
@@ -170,6 +233,20 @@ export interface WriteDeps {
|
|
|
170
233
|
/** Injectable for tests/lab: returns true when Things is up (launching if needed). */
|
|
171
234
|
ensureRunning?: (alreadyRunning: boolean) => Promise<boolean>;
|
|
172
235
|
isAppRunning?: () => boolean;
|
|
236
|
+
/**
|
|
237
|
+
* Seam for the default launch: the consent-free launch-readiness probe polled
|
|
238
|
+
* after a background launch, before dispatch (past the startup URL-drop
|
|
239
|
+
* window, #486). Only consulted by the default {@link ensureRunning}; ignored
|
|
240
|
+
* when a test injects its own `ensureRunning`.
|
|
241
|
+
*/
|
|
242
|
+
appReady?: () => boolean;
|
|
243
|
+
/**
|
|
244
|
+
* The resolved on-disk database path (client-wired from the located DB). The
|
|
245
|
+
* default launch uses `${dbPath}-wal` as its consent-free readiness signal
|
|
246
|
+
* (the app has written since we launched it, #486). Absent = fall back to the
|
|
247
|
+
* LaunchServices launch-completion label.
|
|
248
|
+
*/
|
|
249
|
+
dbPath?: string;
|
|
173
250
|
/** Canary seam: does the installed sdef still declare the private command? */
|
|
174
251
|
sdefProbe?: () => boolean;
|
|
175
252
|
/** Consent-churn tripwire: tuple recorded per verified mutation (client wires the default). */
|
package/dist/write/pipeline.js
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
* after verify.
|
|
8
8
|
*/
|
|
9
9
|
import { execFile, execFileSync } from "node:child_process";
|
|
10
|
+
import { statSync } from "node:fs";
|
|
10
11
|
import { undoToken } from "../audit/schema.js";
|
|
11
12
|
import { blockedCode, verifyFailedCode } from "../contracts.js";
|
|
12
13
|
import { localToday } from "../model/dates.js";
|
|
13
|
-
import { resolveProjectWriteTarget, resolveTaskUuidPrefix } from "../read/queries.js";
|
|
14
|
+
import { liveSeriesInstances, resolveProjectWriteTarget, resolveTaskUuidPrefix, } from "../read/queries.js";
|
|
14
15
|
import { namedProjectClause, taskMembershipClause } from "../read/scope.js";
|
|
15
16
|
import { evaluateScope } from "./scope-guard.js";
|
|
17
|
+
import { isThingsRunning } from "./automation-probe.js";
|
|
16
18
|
import { readShortcutProxies, readUrlSchemeEnabled } from "./availability.js";
|
|
17
19
|
import { COMMANDS } from "./commands.js";
|
|
18
20
|
import { describeEnvironmentChanges, diffEnvironment, } from "./environment.js";
|
|
@@ -21,11 +23,15 @@ import { classifyTransportFailure, classifyVerifyFailure, } from "./failure-hint
|
|
|
21
23
|
import { evaluateGuards } from "./guards.js";
|
|
22
24
|
import { acquireMutationLock, MutationLockError } from "./lock.js";
|
|
23
25
|
import { isHeadingTargetOp, } from "./operations.js";
|
|
26
|
+
import { computeCompletionContext } from "./completion-context.js";
|
|
24
27
|
import { planVector } from "./planner.js";
|
|
28
|
+
import { isRepeatingTemplate } from "./pre-state.js";
|
|
29
|
+
import { restoreModDates, } from "./preserve-modified.js";
|
|
25
30
|
import { REVERSIBILITY } from "./reversibility.js";
|
|
26
31
|
import { certificationOf } from "./vectors/ui-certification.js";
|
|
27
32
|
import { createDbReader, evaluateDelta, getField, } from "./verify/delta.js";
|
|
28
33
|
import { pollUntilVerified } from "./verify/poller.js";
|
|
34
|
+
import { setInflight, trace } from "../trace/tracer.js";
|
|
29
35
|
/**
|
|
30
36
|
* Bounded backoff for the post-transport-failure re-verify (0½ defect (a)): a
|
|
31
37
|
* GUI drive can abort part-way yet still have LANDED the change, so a nonzero
|
|
@@ -41,33 +47,108 @@ export function readAuthToken(db) {
|
|
|
41
47
|
return null;
|
|
42
48
|
}
|
|
43
49
|
}
|
|
44
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The ONE process-presence signal (shared with `doctor`/sync-health via
|
|
52
|
+
* automation-probe.ts) — a single stable `pgrep -x Things3` shape so macOS
|
|
53
|
+
* never sees a second command form.
|
|
54
|
+
*/
|
|
55
|
+
const defaultIsAppRunning = isThingsRunning;
|
|
56
|
+
/** Poll interval for the launch waits. */
|
|
57
|
+
const LAUNCH_POLL_INTERVAL_MS = 200;
|
|
58
|
+
/** Cap on how long we wait for the launched PROCESS to appear. */
|
|
59
|
+
const LAUNCH_PROCESS_TIMEOUT_MS = 20_000;
|
|
60
|
+
/**
|
|
61
|
+
* Cap on how long we wait — after the process appears — for the app to become
|
|
62
|
+
* READY to land a write. A freshly launched Things registers its URL handler
|
|
63
|
+
* before it can actually apply a command: a command dispatched into that
|
|
64
|
+
* startup window is accepted and silently dropped (issue #486). We poll a cheap
|
|
65
|
+
* consent-free readiness signal past that window before dispatching.
|
|
66
|
+
*/
|
|
67
|
+
const LAUNCH_READY_TIMEOUT_MS = 12_000;
|
|
68
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
69
|
+
/** The write-ahead-log's mtime in ms, or null when it cannot be read. */
|
|
70
|
+
function walMtimeMs(walPath) {
|
|
45
71
|
try {
|
|
46
|
-
|
|
72
|
+
return statSync(walPath).mtimeMs;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Consent-free launch-completion signal via LaunchServices — the fallback used
|
|
80
|
+
* only when the database path is unknown. NOT an Apple Event, so it never drags
|
|
81
|
+
* Automation consent onto the consent-free URL path. `lsappinfo` reports a
|
|
82
|
+
* process still coming up as `"Not Finished Launching"`; anything else is
|
|
83
|
+
* treated as launched. False on any error (treated as "not ready yet").
|
|
84
|
+
*/
|
|
85
|
+
function lsappinfoLaunched() {
|
|
86
|
+
try {
|
|
87
|
+
const out = execFileSync("lsappinfo", ["info", "-only", "StatusLabel", "-app", "com.culturedcode.ThingsMac"], { encoding: "utf8", timeout: 3000 });
|
|
88
|
+
if (out.trim() === "" || /Not Finished Launching/i.test(out))
|
|
89
|
+
return false;
|
|
47
90
|
return true;
|
|
48
91
|
}
|
|
49
92
|
catch {
|
|
50
93
|
return false;
|
|
51
94
|
}
|
|
52
95
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Consent-free launch-readiness signal — a DB-write-capability probe. A freshly
|
|
98
|
+
* launched Things recomputes its Today/repeat state and writes to its database
|
|
99
|
+
* shortly after launch; a URL command applied BEFORE the app is truly up is at
|
|
100
|
+
* risk of being dropped in the startup window (issue #486). Reading a file mtime
|
|
101
|
+
* needs no Apple Event, so this never triggers a consent prompt. "Ready" = the
|
|
102
|
+
* write-ahead log has advanced past the pre-launch baseline (the app has written
|
|
103
|
+
* since we launched it). APPRUN1 (golden-v3, Things 3.22.14): the WAL advanced
|
|
104
|
+
* ~0.9s after `open -g` — a real post-launch signal — while the process appeared
|
|
105
|
+
* at ~0.03s; the drop window itself did not reproduce in a clean airgapped clone
|
|
106
|
+
* (first URL landed immediately), so this wait is a defensive floor, and any
|
|
107
|
+
* residual drop is caught by verify and attributed `app-not-running`. Falls back
|
|
108
|
+
* to the LaunchServices label when the DB path is unknown. See
|
|
109
|
+
* docs/lab/apprun1-launch-readiness.md.
|
|
110
|
+
*/
|
|
111
|
+
function walAdvancedSince(walPath, baseline) {
|
|
112
|
+
if (walPath === undefined)
|
|
113
|
+
return lsappinfoLaunched();
|
|
114
|
+
const m = walMtimeMs(walPath);
|
|
115
|
+
return m !== null && (baseline === null || m > baseline);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Background-launch Things (tier 1, by policy — `open -g` keeps it off the
|
|
119
|
+
* foreground) and wait CLOSED-LOOP for it to become ready to land a write:
|
|
120
|
+
* first for the process to appear, then for the readiness signal to trip (past
|
|
121
|
+
* the startup URL-drop window, #486). Returns false only if the process never
|
|
122
|
+
* appears within {@link LAUNCH_PROCESS_TIMEOUT_MS}; once it is up we proceed
|
|
123
|
+
* after readiness trips OR the readiness cap elapses (best-effort — a residual
|
|
124
|
+
* drop is then caught by verify + attributed "app was not running").
|
|
125
|
+
*/
|
|
126
|
+
async function defaultEnsureRunning(alreadyRunning, opts = {}) {
|
|
55
127
|
if (alreadyRunning)
|
|
56
128
|
return true;
|
|
129
|
+
// Capture the readiness baseline BEFORE launching, so "the app has written
|
|
130
|
+
// since we launched it" is judged against the pre-launch WAL state.
|
|
131
|
+
const walPath = opts.dbPath !== undefined ? `${opts.dbPath}-wal` : undefined;
|
|
132
|
+
const walBaseline = walPath !== undefined ? walMtimeMs(walPath) : null;
|
|
133
|
+
const appReady = opts.appReady ?? (() => walAdvancedSince(walPath, walBaseline));
|
|
57
134
|
await new Promise((resolve) => {
|
|
58
135
|
execFile("open", ["-g", "-a", "Things3"], () => resolve());
|
|
59
136
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
137
|
+
// Phase 1: wait for the process to exist.
|
|
138
|
+
const processDeadline = Date.now() + LAUNCH_PROCESS_TIMEOUT_MS;
|
|
139
|
+
while (!defaultIsAppRunning()) {
|
|
140
|
+
if (Date.now() >= processDeadline)
|
|
141
|
+
return false;
|
|
142
|
+
await sleep(LAUNCH_POLL_INTERVAL_MS);
|
|
143
|
+
}
|
|
144
|
+
// Phase 2: wait for launch-readiness (past the URL-drop window).
|
|
145
|
+
const readyDeadline = Date.now() + LAUNCH_READY_TIMEOUT_MS;
|
|
146
|
+
while (!appReady()) {
|
|
147
|
+
if (Date.now() >= readyDeadline)
|
|
148
|
+
break; // best-effort: proceed and let verify judge
|
|
149
|
+
await sleep(LAUNCH_POLL_INTERVAL_MS);
|
|
69
150
|
}
|
|
70
|
-
return
|
|
151
|
+
return true;
|
|
71
152
|
}
|
|
72
153
|
/** Capture pre-values of asserted fields + movement tripwires for the spec. */
|
|
73
154
|
function capturePre(spec, deps, pre) {
|
|
@@ -514,14 +595,35 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
514
595
|
// plain opens and AppleEvents to a closed Things steal focus (A40/A41).
|
|
515
596
|
// A simulating vector applies SQL to a fixture DB and never touches the
|
|
516
597
|
// real app, so it neither needs nor may trigger the background launch.
|
|
517
|
-
|
|
518
|
-
|
|
598
|
+
// A write can only land through a real transport when Things is up (a
|
|
599
|
+
// command dispatched into a closed app is silently dropped, #486), so a
|
|
600
|
+
// closed app is either launched-and-readied here or — when auto-launch is
|
|
601
|
+
// disabled — refused BEFORE dispatch with a plain environment error.
|
|
602
|
+
if (vector.simulates !== true && !appRunning) {
|
|
603
|
+
if (!config.autoLaunch) {
|
|
604
|
+
audit({ result: blockedCode({ reason: "environment" }) });
|
|
605
|
+
return {
|
|
606
|
+
kind: "blocked",
|
|
607
|
+
op,
|
|
608
|
+
reason: "environment",
|
|
609
|
+
likelyCause: "app-not-running",
|
|
610
|
+
detail: "Things is not running, and auto-launch is turned off",
|
|
611
|
+
remediation: "open Things (or run `things config set auto-launch true` to let writes launch it), " +
|
|
612
|
+
"then retry",
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const running = await (deps.ensureRunning ??
|
|
616
|
+
((ar) => defaultEnsureRunning(ar, {
|
|
617
|
+
...(deps.dbPath !== undefined && { dbPath: deps.dbPath }),
|
|
618
|
+
...(deps.appReady !== undefined && { appReady: deps.appReady }),
|
|
619
|
+
})))(appRunning);
|
|
519
620
|
if (!running) {
|
|
520
621
|
audit({ result: blockedCode({ reason: "environment" }) });
|
|
521
622
|
return {
|
|
522
623
|
kind: "blocked",
|
|
523
624
|
op,
|
|
524
625
|
reason: "environment",
|
|
626
|
+
likelyCause: "app-not-running",
|
|
525
627
|
detail: "Things did not become available after a background launch attempt",
|
|
526
628
|
remediation: "launch Things manually and retry",
|
|
527
629
|
};
|
|
@@ -554,7 +656,54 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
554
656
|
pre: flattenPreFields(preCapture.fields),
|
|
555
657
|
uuid: intentUuid,
|
|
556
658
|
});
|
|
557
|
-
|
|
659
|
+
// Mark the write as touching the app (read by the CLI's signal handler so a
|
|
660
|
+
// SIGTERM/SIGINT can name the exact op — and last UI step — it interrupted,
|
|
661
|
+
// TRACE1 #487) and open the execute stage in the trace. Cleared the moment
|
|
662
|
+
// execute returns, whatever the outcome.
|
|
663
|
+
setInflight({
|
|
664
|
+
op,
|
|
665
|
+
uuid: intentUuid,
|
|
666
|
+
vector: vector.id,
|
|
667
|
+
uiDrive: vector.id === "ui",
|
|
668
|
+
startedAt: (deps.now?.() ?? new Date()).getTime(),
|
|
669
|
+
});
|
|
670
|
+
trace(() => ({
|
|
671
|
+
phase: "stage",
|
|
672
|
+
stage: "execute-start",
|
|
673
|
+
op,
|
|
674
|
+
vector: vector.id,
|
|
675
|
+
tier: effectiveTier,
|
|
676
|
+
}));
|
|
677
|
+
let executeResult;
|
|
678
|
+
try {
|
|
679
|
+
executeResult = await vector.execute(invocation);
|
|
680
|
+
}
|
|
681
|
+
finally {
|
|
682
|
+
setInflight(null);
|
|
683
|
+
}
|
|
684
|
+
trace(() => ({
|
|
685
|
+
phase: "stage",
|
|
686
|
+
stage: "execute-done",
|
|
687
|
+
op,
|
|
688
|
+
exitCode: executeResult.exitCode,
|
|
689
|
+
timedOut: executeResult.timedOut === true,
|
|
690
|
+
...(executeResult.watchdog !== undefined && { watchdog: executeResult.watchdog }),
|
|
691
|
+
}));
|
|
692
|
+
// A vector that REFUSED at runtime before touching the app (the ui vector's
|
|
693
|
+
// session-reachability gate: a locked / full-screen session leaves no
|
|
694
|
+
// AX-reachable Things window for the dialog, SESSGATE #480). Nothing was
|
|
695
|
+
// mutated → a `blocked` outcome (exit 4), never a transport failure.
|
|
696
|
+
if (executeResult.blocked !== undefined) {
|
|
697
|
+
const { hazard, detail, remediation } = executeResult.blocked;
|
|
698
|
+
audit({
|
|
699
|
+
result: blockedCode({ hazard, reason: "hazard" }),
|
|
700
|
+
vector: vector.id,
|
|
701
|
+
disruption: effectiveTier,
|
|
702
|
+
invocation: invocation.redactedPayload,
|
|
703
|
+
pre: flattenPreFields(preCapture.fields),
|
|
704
|
+
});
|
|
705
|
+
return { kind: "blocked", op, reason: "hazard", hazard, detail, remediation };
|
|
706
|
+
}
|
|
558
707
|
// Verify under the injected clock (deps.now/deps.zone), never the wall
|
|
559
708
|
// clock: an `evening`/`today` write dated pinned-today must read back IN
|
|
560
709
|
// Today at verify time, or its today/evening marker assertion fails under a
|
|
@@ -576,6 +725,46 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
576
725
|
// with bounded backoff and treat a landed target state as SUCCESS.
|
|
577
726
|
const recovery = await pollUntilVerified(() => evaluateDelta(delta, reader, preCapture), RECOVERY_VERIFY_TIMEOUT_MS, deps.poller ?? {});
|
|
578
727
|
if (recovery.kind !== "ok") {
|
|
728
|
+
// The ui-drive WATCHDOG timeout is its own honest outcome (TRACE1 #487):
|
|
729
|
+
// the CLI gave up first and cleared the dialog, but a drive aborted while
|
|
730
|
+
// the OK was possibly mid-commit cannot promise the app is untouched — so
|
|
731
|
+
// it is reported UNCERTAIN with the trace path and a "re-check first"
|
|
732
|
+
// remediation, distinct from a plain transport failure with no landed change.
|
|
733
|
+
const wd = executeResult.watchdog;
|
|
734
|
+
if (wd !== undefined) {
|
|
735
|
+
audit({
|
|
736
|
+
result: verifyFailedCode({ reason: "timeout" }),
|
|
737
|
+
vector: vector.id,
|
|
738
|
+
disruption: effectiveTier,
|
|
739
|
+
invocation: invocation.redactedPayload,
|
|
740
|
+
pre: flattenPreFields(preCapture.fields),
|
|
741
|
+
observed: recovery.observed,
|
|
742
|
+
});
|
|
743
|
+
const budgetS = Math.round(wd.budgetMs / 1000);
|
|
744
|
+
const elapsedS = Math.round(wd.elapsedMs / 1000);
|
|
745
|
+
const cleared = wd.clear === "dismissed"
|
|
746
|
+
? "the open dialog was dismissed"
|
|
747
|
+
: wd.clear === "cleared-blind"
|
|
748
|
+
? "the Things window was closed and reopened to clear the open dialog"
|
|
749
|
+
: "a dialog may still be open in Things";
|
|
750
|
+
const traceNote = wd.tracePath != null && wd.tracePath !== ""
|
|
751
|
+
? ` The step timeline is at ${wd.tracePath}.`
|
|
752
|
+
: "";
|
|
753
|
+
return {
|
|
754
|
+
kind: "verify-failed",
|
|
755
|
+
op,
|
|
756
|
+
reason: "timeout",
|
|
757
|
+
expected: delta,
|
|
758
|
+
observed: recovery.observed,
|
|
759
|
+
detail: `the GUI drive ran past its ${budgetS}s budget at step "${wd.lastStep}" (after ` +
|
|
760
|
+
`~${elapsedS}s) and the CLI stopped it before the change could be confirmed; ` +
|
|
761
|
+
`${cleared}. The outcome is UNCERTAIN — a rule committed at the last moment could ` +
|
|
762
|
+
`still appear — so re-check with \`things show ${intentUuid ?? "<uuid>"}\` before ` +
|
|
763
|
+
`retrying (retrying could create a duplicate series).${traceNote}`,
|
|
764
|
+
uncertain: true,
|
|
765
|
+
...(wd.tracePath != null && wd.tracePath !== "" && { tracePath: wd.tracePath }),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
579
768
|
audit({
|
|
580
769
|
result: verifyFailedCode({ reason: "silent-noop" }),
|
|
581
770
|
vector: vector.id,
|
|
@@ -612,6 +801,15 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
612
801
|
else {
|
|
613
802
|
outcome = await pollUntilVerified(() => evaluateDelta(delta, reader, preCapture), timeoutMs, deps.poller ?? {});
|
|
614
803
|
}
|
|
804
|
+
trace(() => ({
|
|
805
|
+
phase: "stage",
|
|
806
|
+
stage: "verify",
|
|
807
|
+
op,
|
|
808
|
+
kind: outcome.kind,
|
|
809
|
+
attempts: outcome.attempts,
|
|
810
|
+
elapsedMs: outcome.elapsedMs,
|
|
811
|
+
recovered: transportRecovered,
|
|
812
|
+
}));
|
|
615
813
|
const auditCommon = {
|
|
616
814
|
vector: vector.id,
|
|
617
815
|
disruption: effectiveTier,
|
|
@@ -627,15 +825,53 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
627
825
|
: delta.mode === "ordering"
|
|
628
826
|
? (delta.subject ?? null)
|
|
629
827
|
: null);
|
|
630
|
-
|
|
828
|
+
// --preserve-modified: after the change verifies, restore the pre-write
|
|
829
|
+
// `userModificationDate` of every pre-existing TARGET row the op BUMPED, so
|
|
830
|
+
// the edit stays off the umd-keyed `changes`/watch timeline (TAGMOD T5).
|
|
831
|
+
// The pre-read already captured each asserted/cascade row's umd
|
|
832
|
+
// (preCapture.modDates); a null capture is a row the op CREATED (skip it),
|
|
833
|
+
// and a row whose umd did not rise needs no restore (a silent op — no-op).
|
|
834
|
+
// Best-effort: a failed restore is disclosed per row, never fatal (the
|
|
835
|
+
// mutation already stands). The captured pre-values ride the audit record
|
|
836
|
+
// when the flag is active (enables a future symmetric undo).
|
|
837
|
+
let preserve = null;
|
|
838
|
+
let preModDatesAudit;
|
|
839
|
+
if (options.preserveModified === true) {
|
|
840
|
+
const postReader = createDbReader(deps.db, deps.now?.() ?? new Date(), deps.zone);
|
|
841
|
+
const captured = {};
|
|
842
|
+
const targets = [];
|
|
843
|
+
for (const [tUuid, preUmd] of Object.entries(preCapture.modDates)) {
|
|
844
|
+
captured[tUuid] = preUmd;
|
|
845
|
+
if (preUmd === null)
|
|
846
|
+
continue; // a row the op created — legitimately new umd
|
|
847
|
+
const post = postReader.modDateOf(tUuid);
|
|
848
|
+
if (post !== null && post > preUmd)
|
|
849
|
+
targets.push({ uuid: tUuid, preUmd });
|
|
850
|
+
}
|
|
851
|
+
if (Object.keys(captured).length > 0)
|
|
852
|
+
preModDatesAudit = captured;
|
|
853
|
+
preserve = await restoreModDates(deps.db, deps.vectors, targets);
|
|
854
|
+
}
|
|
855
|
+
audit({
|
|
856
|
+
...auditCommon,
|
|
857
|
+
result: "ok",
|
|
858
|
+
uuid,
|
|
859
|
+
...(preModDatesAudit !== undefined && { preModDates: preModDatesAudit }),
|
|
860
|
+
});
|
|
631
861
|
if (deps.environment !== undefined) {
|
|
632
862
|
deps.environment.record(deps.environment.capture());
|
|
633
863
|
}
|
|
864
|
+
// Deleting a repeating TEMPLATE is allowed (byte-identical to the GUI's own
|
|
865
|
+
// Edit ▸ Delete, SERDEL S1) but is IRREVERSIBLE headlessly — the app forbids
|
|
866
|
+
// restoring a template out to a list (AS 301), so its only revival is Trash ▸
|
|
867
|
+
// Put Back. Treat it like an irreversible op (no undo token) and disclose the
|
|
868
|
+
// consequences below. Trashing an INSTANCE (no rule of its own) is unaffected.
|
|
869
|
+
const templateDelete = (op === "todo.delete" || op === "project.delete") && isRepeatingTemplate(pre.target);
|
|
634
870
|
// The undo token identifies THIS record on the trail (see undoToken); a
|
|
635
871
|
// leg's token would be its shared txn id, but legs are never undone
|
|
636
872
|
// directly, so we only surface it for non-leg writes. Irreversible ops get
|
|
637
873
|
// NO token: `undo --txn` can only refuse it, so emitting one is misleading.
|
|
638
|
-
const resultToken = options.txn?.role === "leg" || REVERSIBILITY[op].class === "irreversible"
|
|
874
|
+
const resultToken = options.txn?.role === "leg" || REVERSIBILITY[op].class === "irreversible" || templateDelete
|
|
639
875
|
? undefined
|
|
640
876
|
: undoToken({
|
|
641
877
|
ts: startedAt.toISOString(),
|
|
@@ -646,6 +882,28 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
646
882
|
...(options.txn !== undefined && { txn: options.txn }),
|
|
647
883
|
});
|
|
648
884
|
const warnings = [];
|
|
885
|
+
// Auto-launch disclosure (#486): the app was not running when this write
|
|
886
|
+
// started, so it was background-launched for the write. Never silent — a
|
|
887
|
+
// side effect the caller should see (a simulating vector never launches).
|
|
888
|
+
if (!appRunning && vector.simulates !== true) {
|
|
889
|
+
warnings.push("Things was not running, so it was launched in the background for this write");
|
|
890
|
+
}
|
|
891
|
+
// Template-delete disclosure (public deletes only — internal trash-both legs
|
|
892
|
+
// run under a txn and aggregate their own result). The series stops, its live
|
|
893
|
+
// instances are left in place (count + name the current occurrence), and the
|
|
894
|
+
// only revival is the app's Trash ▸ Put Back.
|
|
895
|
+
if (templateDelete && options.txn?.role !== "leg" && pre.target !== null) {
|
|
896
|
+
const kindNoun = op === "project.delete" ? "project" : "to-do";
|
|
897
|
+
const series = liveSeriesInstances(deps.db, pre.target.uuid);
|
|
898
|
+
warnings.push("this repeating series will no longer generate new occurrences");
|
|
899
|
+
if (series.count > 0) {
|
|
900
|
+
warnings.push(`its ${series.count} existing occurrence${series.count === 1 ? " was" : "s were"} ` +
|
|
901
|
+
"left in place (not moved to the Trash)" +
|
|
902
|
+
(series.currentUuid !== null ? ` — the current one is ${series.currentUuid}` : ""));
|
|
903
|
+
}
|
|
904
|
+
warnings.push(`this cannot be undone here — to bring the series back, use the Things app's Trash ` +
|
|
905
|
+
`(Put Back on the ${kindNoun})`);
|
|
906
|
+
}
|
|
649
907
|
if (transportRecovered) {
|
|
650
908
|
warnings.push("the GUI drive reported a transport error, but a follow-up re-read confirmed the " +
|
|
651
909
|
"requested change DID land — no retry is needed (retrying could overwrite it)");
|
|
@@ -678,6 +936,15 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
678
936
|
`${describeEnvironmentChanges(envChanges)} — the first use of another ` +
|
|
679
937
|
`capability may show a macOS consent prompt`);
|
|
680
938
|
}
|
|
939
|
+
// HINTS1 completion-context: on a verified complete/cancel of a to-do,
|
|
940
|
+
// attach the remaining OPEN work in its project and/or Today. Applicability
|
|
941
|
+
// is read off the captured pre-state, so a to-do in neither costs no extra
|
|
942
|
+
// reads; the counts are a cheap post-verify re-read via the library's own
|
|
943
|
+
// read paths.
|
|
944
|
+
const completionContext = op === "todo.complete" || op === "todo.cancel"
|
|
945
|
+
? computeCompletionContext(deps.db, pre.target, deps.now?.() ?? new Date(), deps.zone)
|
|
946
|
+
: undefined;
|
|
947
|
+
trace(() => ({ phase: "result", op, kind: "ok", uuid, vector: vector.id }));
|
|
681
948
|
return {
|
|
682
949
|
kind: "ok",
|
|
683
950
|
op,
|
|
@@ -692,6 +959,16 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
692
959
|
tier: effectiveTier,
|
|
693
960
|
...(resultToken !== undefined && { undoToken: resultToken }),
|
|
694
961
|
...(outcome.repeating !== undefined && { repeating: outcome.repeating }),
|
|
962
|
+
// Disclose --preserve-modified only when it did real work (restored a
|
|
963
|
+
// bump or hit a restore failure) — a create-only / already-silent op
|
|
964
|
+
// stays clean (no `preservedModified: 0` noise).
|
|
965
|
+
...(preserve !== null &&
|
|
966
|
+
(preserve.restored > 0 || preserve.failures.length > 0) && {
|
|
967
|
+
preservedModified: preserve.restored,
|
|
968
|
+
}),
|
|
969
|
+
...(preserve !== null &&
|
|
970
|
+
preserve.failures.length > 0 && { preserveFailures: preserve.failures }),
|
|
971
|
+
...(completionContext !== undefined && { context: completionContext }),
|
|
695
972
|
...(warnings.length > 0 && { warnings }),
|
|
696
973
|
};
|
|
697
974
|
}
|
|
@@ -712,6 +989,7 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
712
989
|
reason: outcome.kind,
|
|
713
990
|
vector: vector.id,
|
|
714
991
|
urlSchemeEnabled: (deps.urlSchemeEnabled ?? (() => readUrlSchemeEnabled().enabled))(),
|
|
992
|
+
appWasRunning: appRunning,
|
|
715
993
|
environmentChanges: envChanges,
|
|
716
994
|
}));
|
|
717
995
|
}
|