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/write/pipeline.js
CHANGED
|
@@ -11,6 +11,7 @@ import { statSync } from "node:fs";
|
|
|
11
11
|
import { undoToken } from "../audit/schema.js";
|
|
12
12
|
import { blockedCode, verifyFailedCode } from "../contracts.js";
|
|
13
13
|
import { localToday } from "../model/dates.js";
|
|
14
|
+
import { currentInstance } from "../process-instance.js";
|
|
14
15
|
import { liveSeriesInstances, resolveProjectWriteTarget, resolveTaskUuidPrefix, } from "../read/queries.js";
|
|
15
16
|
import { namedProjectClause, taskMembershipClause } from "../read/scope.js";
|
|
16
17
|
import { evaluateScope } from "./scope-guard.js";
|
|
@@ -18,13 +19,19 @@ import { isThingsRunning } from "./automation-probe.js";
|
|
|
18
19
|
import { readShortcutProxies } from "./availability.js";
|
|
19
20
|
import { uiAllowed, uiCapability as uiCapabilityDefault, urlSchemeAllowed, urlSchemeCapability as urlSchemeCapabilityDefault, writeAllowed, writeCapability as writeCapabilityDefault, } from "../capability.js";
|
|
20
21
|
import { COMMANDS } from "./commands.js";
|
|
22
|
+
import { carry, disclose, newDisclosures, tiers } from "./disclosures.js";
|
|
21
23
|
import { describeEnvironmentChanges, diffEnvironment, } from "./environment.js";
|
|
22
|
-
import { privateReorderIsNoOp, sdefDeclaresPrivateReorder } from "./experimental.js";
|
|
24
|
+
import { privateReorderIsNoOp, sdefDeclaresPrivateReorder, urlReanchorSupported, } from "./experimental.js";
|
|
23
25
|
import { classifyTransportFailure, classifyVerifyFailure, } from "./failure-hints.js";
|
|
24
26
|
import { evaluateGuards } from "./guards.js";
|
|
25
|
-
import { acquireMutationLock, MutationLockError, withMutationLock, } from "./lock.js";
|
|
26
|
-
import { isHeadingTargetOp, } from "./operations.js";
|
|
27
|
+
import { acquireMutationLock, describeLockRefusal, MutationLockError, withMutationLock, } from "./lock.js";
|
|
28
|
+
import { isHeadingTargetOp, isRepeatReanchor, } from "./operations.js";
|
|
27
29
|
import { describeTruncation } from "./field-limits.js";
|
|
30
|
+
// The idempotency gate is a MUTUAL import with this module (it builds its replay
|
|
31
|
+
// results from `replayResultFromRecord` here, and the double-checked lookback
|
|
32
|
+
// here calls back into it). Both sides are hoisted function declarations, so the
|
|
33
|
+
// cycle resolves at module-evaluation time; nothing is read at import time.
|
|
34
|
+
import { replayIfApplied } from "./opid.js";
|
|
28
35
|
import { assertOperationParams } from "./param-schema.js";
|
|
29
36
|
import { computeCompletionContext } from "./completion-context.js";
|
|
30
37
|
import { assessOffRuleFirst } from "./repeat-anchor.js";
|
|
@@ -43,6 +50,15 @@ import { setInflight, trace } from "../trace/tracer.js";
|
|
|
43
50
|
* transport exit is re-verified over ~2s before the write is declared lost.
|
|
44
51
|
*/
|
|
45
52
|
const RECOVERY_VERIFY_TIMEOUT_MS = 2000;
|
|
53
|
+
/**
|
|
54
|
+
* The drive's step play-by-play as a spreadable fragment, omitted when there is
|
|
55
|
+
* none (a transport vector runs no steps). One helper so the FAILURE paths, the
|
|
56
|
+
* change-history record, and the `--verbose` success all read the same field and
|
|
57
|
+
* cannot drift apart (#632).
|
|
58
|
+
*/
|
|
59
|
+
function stepsOf(result) {
|
|
60
|
+
return result.steps !== undefined && result.steps.length > 0 ? { steps: result.steps } : {};
|
|
61
|
+
}
|
|
46
62
|
export function readAuthToken(db) {
|
|
47
63
|
try {
|
|
48
64
|
const row = db.prepare("SELECT uriSchemeAuthenticationToken AS t FROM TMSettings").get();
|
|
@@ -295,40 +311,92 @@ function truncationDetail(delta, observed) {
|
|
|
295
311
|
}
|
|
296
312
|
return undefined;
|
|
297
313
|
}
|
|
298
|
-
|
|
299
|
-
* Run a COMPOSITE — a single verb the engine executes as several mutations —
|
|
300
|
-
* under ONE mutation lock held end-to-end, so it serializes against other
|
|
301
|
-
* writers as a whole instead of leg by leg. Every leg's own
|
|
302
|
-
* `acquireMutationLock` inside `body` is a reentrant no-op (see
|
|
303
|
-
* {@link withMutationLock}); the per-leg lock stays the base case for an
|
|
304
|
-
* ordinary single mutation.
|
|
305
|
-
*
|
|
306
|
-
* Wrap only the MUTATING tail: validation, dry-run planning, and the hazard /
|
|
307
|
-
* session gates belong outside, so a refusal never takes the lock. On contention
|
|
308
|
-
* the refusal is the same `blocked` / `lock` shape the pipeline returns for a
|
|
309
|
-
* single op, carrying the composite's own op name. No audit record is written
|
|
310
|
-
* for it — unlike the single-op path, nothing was attempted, so there is no leg
|
|
311
|
-
* to record.
|
|
312
|
-
*/
|
|
313
|
-
export async function runComposite(deps, op, body,
|
|
314
|
+
export async function runComposite(deps, op, body, keyed,
|
|
314
315
|
/** @internal test seam — see {@link AcquireMutationLockOptions}. */
|
|
315
316
|
lockOptions = {}) {
|
|
316
317
|
try {
|
|
317
|
-
return await withMutationLock(deps.lockPath,
|
|
318
|
+
return await withMutationLock(deps.lockPath, async () => {
|
|
319
|
+
if (keyed === undefined || keyed.options.opId === undefined)
|
|
320
|
+
return body();
|
|
321
|
+
const locked = replayIfApplied(deps, keyed.options);
|
|
322
|
+
if (locked !== null)
|
|
323
|
+
return locked;
|
|
324
|
+
appendSummaryIntent(deps, op, keyed);
|
|
325
|
+
const outcome = await body();
|
|
326
|
+
closeSummaryIntent(deps, op, keyed, outcome);
|
|
327
|
+
return outcome;
|
|
328
|
+
}, lockOptions);
|
|
318
329
|
}
|
|
319
330
|
catch (err) {
|
|
320
331
|
if (err instanceof MutationLockError) {
|
|
321
|
-
return {
|
|
322
|
-
kind: "blocked",
|
|
323
|
-
op,
|
|
324
|
-
reason: "lock",
|
|
325
|
-
detail: err.message,
|
|
326
|
-
remediation: "wait for the concurrent mutation to finish and retry",
|
|
327
|
-
};
|
|
332
|
+
return { kind: "blocked", op, reason: "lock", ...describeLockRefusal(err) };
|
|
328
333
|
}
|
|
329
334
|
throw err;
|
|
330
335
|
}
|
|
331
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* The keyed composite's write-ahead intent: ONE summary-layer record marking the
|
|
339
|
+
* key as in flight, with the holder identity that makes "still running" a
|
|
340
|
+
* decidable question rather than a hedge. Superseded, append-only, by whichever
|
|
341
|
+
* summary the verb writes when it finishes — this record is never rewritten.
|
|
342
|
+
*/
|
|
343
|
+
function appendSummaryIntent(deps, op, keyed, result = "intent") {
|
|
344
|
+
const fp = deps.fingerprint();
|
|
345
|
+
const inFlight = result === "intent";
|
|
346
|
+
deps.audit.append({
|
|
347
|
+
v: 1,
|
|
348
|
+
ts: keyed.startedAt.toISOString(),
|
|
349
|
+
actor: keyed.options.actor ?? deps.config.actor,
|
|
350
|
+
host: deps.config.host,
|
|
351
|
+
op,
|
|
352
|
+
uuid: keyed.uuid,
|
|
353
|
+
vector: null,
|
|
354
|
+
disruption: null,
|
|
355
|
+
invocation: null,
|
|
356
|
+
txn: { id: keyed.txnId, role: "summary" },
|
|
357
|
+
opId: keyed.options.opId,
|
|
358
|
+
...(inFlight && { holder: currentInstance() }),
|
|
359
|
+
...(inFlight && keyed.expected !== undefined && { expected: keyed.expected }),
|
|
360
|
+
requested: keyed.requested,
|
|
361
|
+
pre: null,
|
|
362
|
+
observed: null,
|
|
363
|
+
result,
|
|
364
|
+
verify: null,
|
|
365
|
+
durationMs: 0,
|
|
366
|
+
env: {
|
|
367
|
+
pkg: deps.pkgVersion ?? "0.0.1",
|
|
368
|
+
dbVersion: fp.observation.databaseVersion,
|
|
369
|
+
fingerprint: fingerprintLabel(fp, deps.config),
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Supersede the write-ahead intent for the outcomes whose composite bodies write
|
|
375
|
+
* NO summary of their own — a refusal, an unsupported verb, or a verification
|
|
376
|
+
* failure that is not a timeout. Those all mean "this key is finished and
|
|
377
|
+
* nothing landed", and without a record saying so the intent would stand
|
|
378
|
+
* unsuperseded: once this process exits, its holder reads as dead and every
|
|
379
|
+
* later use of the key would be met with a reconcile refusal for a change that
|
|
380
|
+
* never happened.
|
|
381
|
+
*
|
|
382
|
+
* The two outcomes NOT closed here are the two the bodies already record: `ok`
|
|
383
|
+
* (the success summary, the verb's single undoable unit) and a `timeout`
|
|
384
|
+
* verify-failed (the AMBIGUOUS summary carrying the presence oracle). Writing a
|
|
385
|
+
* second record for either would compete with the one the caller's replay is
|
|
386
|
+
* meant to find.
|
|
387
|
+
*/
|
|
388
|
+
function closeSummaryIntent(deps, op, keyed, outcome) {
|
|
389
|
+
if (outcome.kind === "ok" || outcome.kind === "dry-run")
|
|
390
|
+
return;
|
|
391
|
+
if (outcome.kind === "verify-failed" && outcome.reason === "timeout")
|
|
392
|
+
return;
|
|
393
|
+
const result = outcome.kind === "blocked"
|
|
394
|
+
? blockedCode(outcome)
|
|
395
|
+
: outcome.kind === "unsupported"
|
|
396
|
+
? "unsupported"
|
|
397
|
+
: verifyFailedCode(outcome);
|
|
398
|
+
appendSummaryIntent(deps, op, keyed, result);
|
|
399
|
+
}
|
|
332
400
|
export async function runMutation(deps, op, params, options = {}) {
|
|
333
401
|
const startedAt = deps.now?.() ?? new Date();
|
|
334
402
|
// Things' own "Enable Things URLs" standing, read at most ONCE per mutation
|
|
@@ -452,17 +520,29 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
452
520
|
catch (err) {
|
|
453
521
|
if (err instanceof MutationLockError) {
|
|
454
522
|
audit({ result: blockedCode({ reason: "lock" }) });
|
|
455
|
-
return {
|
|
456
|
-
kind: "blocked",
|
|
457
|
-
op,
|
|
458
|
-
reason: "lock",
|
|
459
|
-
detail: err.message,
|
|
460
|
-
remediation: "wait for the concurrent mutation to finish and retry",
|
|
461
|
-
};
|
|
523
|
+
return { kind: "blocked", op, reason: "lock", ...describeLockRefusal(err) };
|
|
462
524
|
}
|
|
463
525
|
throw err;
|
|
464
526
|
}
|
|
465
527
|
try {
|
|
528
|
+
// 2a. DOUBLE-CHECKED IDEMPOTENCY (#639). The consumer entry already ran this
|
|
529
|
+
// lookback before we got here — but it ran it BEFORE the lock, and the whole
|
|
530
|
+
// point of a lock is that the world changes while you wait for it. A retry
|
|
531
|
+
// fired while the original was still mid-drive saw no record, queued here,
|
|
532
|
+
// and used to wake up after the original finished and execute the verb a
|
|
533
|
+
// SECOND time. Re-asking under the lock is what makes the earlier answer
|
|
534
|
+
// safe to have acted on: by now the original has written its record, so the
|
|
535
|
+
// answer is a replay (or a reconcile refusal) instead of a duplicate.
|
|
536
|
+
//
|
|
537
|
+
// The pre-lock check is kept as the cheap fast path — a settled key never
|
|
538
|
+
// reaches the lock at all — so this costs one extra trail read on exactly
|
|
539
|
+
// the writes that raced.
|
|
540
|
+
// (Returning here releases the lock through the `finally` below — never an
|
|
541
|
+
// explicit release as well: releasing twice unlinks whatever lock the NEXT
|
|
542
|
+
// writer has since created.)
|
|
543
|
+
const locked = replayIfApplied(deps, options);
|
|
544
|
+
if (locked !== null)
|
|
545
|
+
return locked;
|
|
466
546
|
// 3. Pre-read. The consumer zone (options.zone ?? deps.zone) is threaded so
|
|
467
547
|
// any boundary-derived pre-state (the reorder swept/unswept log boundary,
|
|
468
548
|
// LOGSORT ORD-13) is computed under the SAME zone the reads use.
|
|
@@ -543,12 +623,36 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
543
623
|
}
|
|
544
624
|
params = norm.params;
|
|
545
625
|
}
|
|
626
|
+
// 3c. The series RE-ANCHOR's VERSION gate (REANCH1 §6). A dated `when=` on a
|
|
627
|
+
// repeating template re-anchors the series on Things 3.23 and KILLS the
|
|
628
|
+
// process on 3.22.14 — an unannounced behavior change, in the unusual
|
|
629
|
+
// direction. So the capability is gated on the installed version and fails
|
|
630
|
+
// CLOSED on an unreadable one; below the gate the dialog spelling (which
|
|
631
|
+
// works on both lines) is the remedy. Checked here, before planning, so the
|
|
632
|
+
// refusal names the version rather than surfacing as an unsupported vector.
|
|
633
|
+
if ((op === "todo.reschedule-repeat" || op === "project.reschedule-repeat") &&
|
|
634
|
+
isRepeatReanchor(params) &&
|
|
635
|
+
!urlReanchorSupported(deps.environment?.capture().thingsVersion ?? null)) {
|
|
636
|
+
audit({ result: blockedCode({ reason: "environment" }) });
|
|
637
|
+
return {
|
|
638
|
+
kind: "blocked",
|
|
639
|
+
op,
|
|
640
|
+
reason: "environment",
|
|
641
|
+
detail: "this version of Things cannot move a repeating item's next occurrence on its own — " +
|
|
642
|
+
"the version that can is 3.23 or later",
|
|
643
|
+
remediation: "restate the rule instead: `things " +
|
|
644
|
+
(op.startsWith("project.") ? "project" : "todo") +
|
|
645
|
+
" reschedule-repeat <ref> --frequency <freq> --interval <n> --when <date> " +
|
|
646
|
+
"--dangerously-drive-gui`, or update Things",
|
|
647
|
+
};
|
|
648
|
+
}
|
|
546
649
|
// 4. Vector planning under the disruption policy.
|
|
547
650
|
const appRunning = (deps.isAppRunning ?? defaultIsAppRunning)();
|
|
548
651
|
const plan = planVector(op, deps.vectors, {
|
|
549
652
|
maxDisruption,
|
|
550
653
|
appRunning,
|
|
551
654
|
allowExperimental: config.allowExperimental,
|
|
655
|
+
...(spec.vectorsFor !== undefined && { allowVectors: spec.vectorsFor(params) }),
|
|
552
656
|
...(options.vector !== undefined && { forcedVector: options.vector }),
|
|
553
657
|
});
|
|
554
658
|
if (plan.kind === "unsupported") {
|
|
@@ -674,6 +778,8 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
674
778
|
const preEval = evaluateDelta(delta, preReader, preCapture);
|
|
675
779
|
if (preEval.satisfied) {
|
|
676
780
|
const uuid = delta.uuid;
|
|
781
|
+
const noOp = newDisclosures();
|
|
782
|
+
disclose(noOp, "already-in-state", "the item was already in the requested state — no GUI drive was performed (idempotent no-op)");
|
|
677
783
|
audit({
|
|
678
784
|
result: "ok",
|
|
679
785
|
vector: vector.id,
|
|
@@ -693,9 +799,7 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
693
799
|
vector: vector.id,
|
|
694
800
|
tier: effectiveTier,
|
|
695
801
|
// No undoToken: nothing changed, so there is nothing to invert.
|
|
696
|
-
|
|
697
|
-
"the item was already in the requested state — no GUI drive was performed (idempotent no-op)",
|
|
698
|
-
],
|
|
802
|
+
...tiers(noOp),
|
|
699
803
|
};
|
|
700
804
|
}
|
|
701
805
|
}
|
|
@@ -866,6 +970,12 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
866
970
|
// ONLY evidence the mutation may have landed. `things doctor` surfaces such
|
|
867
971
|
// orphans; the final record written after verify supersedes this one.
|
|
868
972
|
// (dry-run returned above, so nothing is recorded for a dry-run — preserved.)
|
|
973
|
+
//
|
|
974
|
+
// A KEYED write additionally makes this intent READABLE (#639): the holder's
|
|
975
|
+
// instance identity turns "no outcome recorded" into a decidable question
|
|
976
|
+
// (still running vs. died mid-flight), and `expected` gives a dead holder's
|
|
977
|
+
// resubmission the same presence oracle a timeout gets. Both are recorded
|
|
978
|
+
// only under a key — an unkeyed intent is crash evidence nobody polls.
|
|
869
979
|
const intentUuid = delta.mode === "update" || delta.mode === "state"
|
|
870
980
|
? delta.uuid
|
|
871
981
|
: delta.mode === "ordering"
|
|
@@ -878,6 +988,7 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
878
988
|
invocation: invocation.redactedPayload,
|
|
879
989
|
pre: flattenPreFields(preCapture.fields),
|
|
880
990
|
uuid: intentUuid,
|
|
991
|
+
...(options.opId !== undefined && { holder: currentInstance(), expected: delta }),
|
|
881
992
|
});
|
|
882
993
|
// Mark the write as touching the app (read by the CLI's signal handler so a
|
|
883
994
|
// SIGTERM/SIGINT can name the exact op — and last UI step — it interrupted,
|
|
@@ -962,6 +1073,7 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
962
1073
|
invocation: invocation.redactedPayload,
|
|
963
1074
|
pre: flattenPreFields(preCapture.fields),
|
|
964
1075
|
observed: recovery.observed,
|
|
1076
|
+
...stepsOf(executeResult),
|
|
965
1077
|
// The watchdog abort is THE ambiguous outcome — the detail below says
|
|
966
1078
|
// so — so the assertion rides the record and a resubmission with the
|
|
967
1079
|
// same opId re-reads state instead of risking the duplicate.
|
|
@@ -990,6 +1102,7 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
990
1102
|
`retrying (retrying could create a duplicate series).${traceNote}`,
|
|
991
1103
|
uncertain: true,
|
|
992
1104
|
...(wd.tracePath != null && wd.tracePath !== "" && { tracePath: wd.tracePath }),
|
|
1105
|
+
...stepsOf(executeResult),
|
|
993
1106
|
};
|
|
994
1107
|
}
|
|
995
1108
|
// The ui drive stopped because the Things WINDOW stopped answering
|
|
@@ -1011,6 +1124,7 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
1011
1124
|
invocation: invocation.redactedPayload,
|
|
1012
1125
|
pre: flattenPreFields(preCapture.fields),
|
|
1013
1126
|
observed: recovery.observed,
|
|
1127
|
+
...stepsOf(executeResult),
|
|
1014
1128
|
});
|
|
1015
1129
|
return {
|
|
1016
1130
|
kind: "verify-failed",
|
|
@@ -1018,6 +1132,7 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
1018
1132
|
reason: "ui-unreachable",
|
|
1019
1133
|
expected: delta,
|
|
1020
1134
|
observed: recovery.observed,
|
|
1135
|
+
...stepsOf(executeResult),
|
|
1021
1136
|
detail: `the Things window could not be driven: the drive stopped at step ` +
|
|
1022
1137
|
`"${unreachable.step}" because ` +
|
|
1023
1138
|
(unreachable.cause === "unreachable"
|
|
@@ -1106,7 +1221,8 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
1106
1221
|
// and a row whose umd did not rise needs no restore (a silent op — no-op).
|
|
1107
1222
|
// Best-effort: a failed restore is disclosed per row, never fatal (the
|
|
1108
1223
|
// mutation already stands). The captured pre-values ride the audit record
|
|
1109
|
-
// when the flag is active
|
|
1224
|
+
// when the flag is active, which is what the symmetric undo restores from
|
|
1225
|
+
// (undo.ts, keyed on `preModDates`).
|
|
1110
1226
|
let preserve = null;
|
|
1111
1227
|
let preModDatesAudit;
|
|
1112
1228
|
if (options.preserveModified === true) {
|
|
@@ -1125,10 +1241,15 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
1125
1241
|
preModDatesAudit = captured;
|
|
1126
1242
|
preserve = await restoreModDates(deps.db, deps.vectors, targets);
|
|
1127
1243
|
}
|
|
1244
|
+
// The change-history record is the APPEND-ONLY DEBUG LOG (#632): it
|
|
1245
|
+
// captures the drive's step list on success as well as on failure, so the
|
|
1246
|
+
// play-by-play a default success output no longer prints is still
|
|
1247
|
+
// retrievable — `things op-result <key>` renders it.
|
|
1128
1248
|
audit({
|
|
1129
1249
|
...auditCommon,
|
|
1130
1250
|
result: "ok",
|
|
1131
1251
|
uuid,
|
|
1252
|
+
...stepsOf(executeResult),
|
|
1132
1253
|
...(preModDatesAudit !== undefined && { preModDates: preModDatesAudit }),
|
|
1133
1254
|
});
|
|
1134
1255
|
if (deps.environment !== undefined) {
|
|
@@ -1154,12 +1275,12 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
1154
1275
|
uuid,
|
|
1155
1276
|
...(options.txn !== undefined && { txn: options.txn }),
|
|
1156
1277
|
});
|
|
1157
|
-
const
|
|
1278
|
+
const bag = newDisclosures();
|
|
1158
1279
|
// Auto-launch disclosure (#486): the app was not running when this write
|
|
1159
1280
|
// started, so it was background-launched for the write. Never silent — a
|
|
1160
1281
|
// side effect the caller should see (a simulating vector never launches).
|
|
1161
1282
|
if (!appRunning && vector.simulates !== true) {
|
|
1162
|
-
|
|
1283
|
+
disclose(bag, "auto-launch", "Things was not running, so it was launched in the background for this write");
|
|
1163
1284
|
}
|
|
1164
1285
|
// Template-delete disclosure (public deletes only — internal trash-both legs
|
|
1165
1286
|
// run under a txn and aggregate their own result). The series stops, its live
|
|
@@ -1168,48 +1289,64 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
1168
1289
|
if (templateDelete && options.txn?.role !== "leg" && pre.target !== null) {
|
|
1169
1290
|
const kindNoun = op === "project.delete" ? "project" : "to-do";
|
|
1170
1291
|
const series = liveSeriesInstances(deps.db, pre.target.uuid);
|
|
1171
|
-
|
|
1292
|
+
disclose(bag, "template-delete-series-stops", "this repeating series will no longer generate new occurrences");
|
|
1172
1293
|
if (series.count > 0) {
|
|
1173
|
-
|
|
1294
|
+
disclose(bag, "template-delete-occurrences-left", `its ${series.count} existing occurrence${series.count === 1 ? " was" : "s were"} ` +
|
|
1174
1295
|
"left in place (not moved to the Trash)" +
|
|
1175
1296
|
(series.currentUuid !== null ? ` — the current one is ${series.currentUuid}` : ""));
|
|
1176
1297
|
}
|
|
1177
|
-
|
|
1298
|
+
disclose(bag, "template-delete-irreversible", `this cannot be undone here — to bring the series back, use the Things app's Trash ` +
|
|
1178
1299
|
`(Put Back on the ${kindNoun})`);
|
|
1179
1300
|
}
|
|
1180
1301
|
if (transportRecovered) {
|
|
1181
|
-
|
|
1302
|
+
disclose(bag, "transport-recovered", "the GUI drive reported a transport error, but a follow-up re-read confirmed the " +
|
|
1182
1303
|
"requested change DID land — no retry is needed (retrying could overwrite it)");
|
|
1183
1304
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1305
|
+
// The instance-derivation disclosures arrive ALREADY TIERED from the
|
|
1306
|
+
// expectation assertion (#634) — carried, never reclassified here.
|
|
1307
|
+
carry(bag, outcome.repeatingDisclosures);
|
|
1186
1308
|
// DACON1 off-rule-first disclosure (reschedule-repeat): an explicit anchor
|
|
1187
1309
|
// that disagrees with --when lands an OFF-RULE first occurrence (honored for
|
|
1188
1310
|
// weekly/yearly). State both halves of the landed pattern; the dishonored
|
|
1189
1311
|
// monthly shape was already refused at validation. (make/add-repeating carry
|
|
1190
1312
|
// their own disclosure via the promote result.)
|
|
1191
1313
|
if (op === "todo.reschedule-repeat" || op === "project.reschedule-repeat") {
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1314
|
+
const reschedule = params;
|
|
1315
|
+
if (isRepeatReanchor(reschedule)) {
|
|
1316
|
+
// The three things a re-anchor does that the caller did not ask for,
|
|
1317
|
+
// all measured (REANCH1 §2.3, §2.2/§7, §8): the recurring pattern moves
|
|
1318
|
+
// with the date, the slots in between are dropped, and there is no
|
|
1319
|
+
// headless way back.
|
|
1320
|
+
disclose(bag, "reanchor-series-moved", `the whole series moved: it now repeats from ${reschedule.next} — a weekly item ` +
|
|
1321
|
+
"moved to a Thursday repeats on Thursdays, a monthly one moved to the 17th " +
|
|
1322
|
+
"repeats on the 17th");
|
|
1323
|
+
disclose(bag, "reanchor-slots-skipped", `any occurrence due before ${reschedule.next} will not appear — the series resumes ` +
|
|
1324
|
+
"on the new date");
|
|
1325
|
+
disclose(bag, "reanchor-irreversible", "this cannot be undone here — move the series to another date, or use the Things " +
|
|
1326
|
+
"app's own undo");
|
|
1327
|
+
}
|
|
1328
|
+
else {
|
|
1329
|
+
const offRuleParams = reschedule;
|
|
1330
|
+
const offRule = assessOffRuleFirst(offRuleParams, preservedDeadlineOffsetFor(pre, offRuleParams));
|
|
1331
|
+
if (offRule?.kind === "honored") {
|
|
1332
|
+
disclose(bag, "promote-off-rule-first", offRule.disclosure.message);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1196
1335
|
}
|
|
1197
1336
|
if (vector.id === "ui") {
|
|
1198
|
-
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
// the
|
|
1202
|
-
|
|
1203
|
-
if (driveSummary !== "")
|
|
1204
|
-
warnings.push(driveSummary);
|
|
1337
|
+
// COMPRESSED mechanism disclosure (#632). The old sentence spent a line
|
|
1338
|
+
// explaining the Accessibility API on every GUI-applied write; `vector`
|
|
1339
|
+
// and `tier` already carry the fact structurally, so the prose is now
|
|
1340
|
+
// just the few words a reader needs to place it.
|
|
1341
|
+
disclose(bag, "ui-mechanism", `applied via GUI drive (tier ${effectiveTier})`);
|
|
1205
1342
|
const cert = certificationOf(op);
|
|
1206
1343
|
if (cert !== undefined && cert.status !== "certified") {
|
|
1207
|
-
|
|
1344
|
+
disclose(bag, "ui-recipe-uncertified", `this operation is ${cert.status}: its GUI recipe has not been confirmed on real ` +
|
|
1208
1345
|
"hardware (see `things doctor` / docs/lab/ui-certification-runbook.md)");
|
|
1209
1346
|
}
|
|
1210
1347
|
}
|
|
1211
1348
|
if (envChanges.length > 0) {
|
|
1212
|
-
|
|
1349
|
+
disclose(bag, "environment-changed", `environment changed since the last verified write: ` +
|
|
1213
1350
|
`${describeEnvironmentChanges(envChanges)} — the first use of another ` +
|
|
1214
1351
|
`capability may show a macOS consent prompt`);
|
|
1215
1352
|
}
|
|
@@ -1246,12 +1383,18 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
1246
1383
|
...(preserve !== null &&
|
|
1247
1384
|
preserve.failures.length > 0 && { preserveFailures: preserve.failures }),
|
|
1248
1385
|
...(completionContext !== undefined && { context: completionContext }),
|
|
1249
|
-
|
|
1386
|
+
// The step list rides a SUCCESS only when the caller asked for it —
|
|
1387
|
+
// the diagnostic ladder's middle rung (#632).
|
|
1388
|
+
...(options.verbose === true && stepsOf(executeResult)),
|
|
1389
|
+
...tiers(bag),
|
|
1250
1390
|
};
|
|
1251
1391
|
}
|
|
1392
|
+
// A FAILURE always carries the drive's play-by-play — on the record and on
|
|
1393
|
+
// the result (#632). It is why the field bug reports were rich enough to fix.
|
|
1252
1394
|
audit({
|
|
1253
1395
|
...auditCommon,
|
|
1254
1396
|
result: verifyFailedCode({ reason: outcome.kind }),
|
|
1397
|
+
...stepsOf(executeResult),
|
|
1255
1398
|
// A TIMEOUT is the one ambiguous verdict — the change may or may not have
|
|
1256
1399
|
// landed — so the assertion this attempt was checking is recorded with it.
|
|
1257
1400
|
// A resubmission carrying the same opId re-evaluates it against current
|
|
@@ -1264,6 +1407,7 @@ export async function runMutation(deps, op, params, options = {}) {
|
|
|
1264
1407
|
reason: outcome.kind,
|
|
1265
1408
|
expected: delta,
|
|
1266
1409
|
observed: outcome.observed,
|
|
1410
|
+
...stepsOf(executeResult),
|
|
1267
1411
|
detail:
|
|
1268
1412
|
// The collateral verdict carries its own sentence — which field moved,
|
|
1269
1413
|
// from what to what, and why a retry is not the answer (CGRD1).
|