things-api 0.19.0 → 0.19.2
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 +3 -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 +19 -2
- package/dist/audit/schema.js.map +1 -1
- package/dist/capability.d.ts +166 -4
- package/dist/capability.js +303 -13
- package/dist/capability.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +13 -1
- package/dist/cli/commands/doctor.js +52 -22
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/helpers.js +14 -3
- package/dist/cli/commands/helpers.js.map +1 -1
- package/dist/cli/commands/mcp.js +8 -0
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/repeat-flags.js +34 -2
- package/dist/cli/commands/repeat-flags.js.map +1 -1
- package/dist/cli/commands/setup.js +16 -3
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/ui-state.d.ts +11 -0
- package/dist/cli/commands/ui-state.js +27 -0
- package/dist/cli/commands/ui-state.js.map +1 -0
- package/dist/cli/commands/writes.js +102 -67
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/help.js +2 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/interrupt.d.ts +54 -6
- package/dist/cli/interrupt.js +78 -12
- package/dist/cli/interrupt.js.map +1 -1
- package/dist/cli/main.js +10 -5
- package/dist/cli/main.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/contracts.d.ts +4 -4
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/deputy/install.d.ts +15 -1
- package/dist/deputy/install.js +46 -6
- package/dist/deputy/install.js.map +1 -1
- package/dist/deputy/osa.d.ts +42 -2
- package/dist/deputy/osa.js +79 -5
- package/dist/deputy/osa.js.map +1 -1
- package/dist/deputy/routing.d.ts +34 -0
- package/dist/deputy/routing.js +95 -0
- package/dist/deputy/routing.js.map +1 -1
- package/dist/deputy/wake.d.ts +61 -0
- package/dist/deputy/wake.js +149 -0
- package/dist/deputy/wake.js.map +1 -0
- package/dist/diagnose.d.ts +6 -4
- package/dist/diagnose.js +20 -4
- package/dist/diagnose.js.map +1 -1
- package/dist/direct-setup.d.ts +31 -5
- package/dist/direct-setup.js +200 -26
- package/dist/direct-setup.js.map +1 -1
- package/dist/host-access.d.ts +12 -0
- package/dist/host-access.js +12 -0
- package/dist/host-access.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +23 -9
- package/dist/mcp/server.js.map +1 -1
- package/dist/op-result.js +8 -1
- package/dist/op-result.js.map +1 -1
- package/dist/session-grant.js +16 -1
- package/dist/session-grant.js.map +1 -1
- package/dist/surface-copy.d.ts +11 -3
- package/dist/surface-copy.js +11 -3
- package/dist/surface-copy.js.map +1 -1
- package/dist/ui-state.d.ts +46 -0
- package/dist/ui-state.js +82 -0
- package/dist/ui-state.js.map +1 -0
- package/dist/wizard.js +8 -1
- package/dist/wizard.js.map +1 -1
- package/dist/write/accessibility-probe.js +4 -1
- package/dist/write/accessibility-probe.js.map +1 -1
- package/dist/write/automation-probe.js +7 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/availability.d.ts +0 -15
- package/dist/write/availability.js +7 -62
- package/dist/write/availability.js.map +1 -1
- package/dist/write/batch.js +17 -6
- package/dist/write/batch.js.map +1 -1
- package/dist/write/commands.d.ts +10 -6
- package/dist/write/commands.js +120 -31
- package/dist/write/commands.js.map +1 -1
- package/dist/write/failure-hints.d.ts +36 -7
- package/dist/write/failure-hints.js +33 -14
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/field-limits.d.ts +100 -0
- package/dist/write/field-limits.js +144 -0
- package/dist/write/field-limits.js.map +1 -0
- package/dist/write/guards.js +3 -2
- package/dist/write/guards.js.map +1 -1
- package/dist/write/move.js +9 -12
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +15 -8
- package/dist/write/operations.js +9 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +67 -23
- package/dist/write/opid.js +141 -19
- package/dist/write/opid.js.map +1 -1
- package/dist/write/param-schema.d.ts +17 -44
- package/dist/write/param-schema.js +74 -7
- package/dist/write/param-schema.js.map +1 -1
- package/dist/write/pipeline.d.ts +38 -7
- package/dist/write/pipeline.js +134 -18
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +30 -16
- package/dist/write/pre-state.js +88 -20
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.d.ts +26 -0
- package/dist/write/promote-clone.js +333 -41
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/repeat-collateral.d.ts +31 -0
- package/dist/write/repeat-collateral.js +153 -0
- package/dist/write/repeat-collateral.js.map +1 -0
- package/dist/write/resolution-timestamps.js +25 -2
- package/dist/write/resolution-timestamps.js.map +1 -1
- package/dist/write/template-mutation.js +163 -16
- package/dist/write/template-mutation.js.map +1 -1
- package/dist/write/vectors/applescript.js +3 -10
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/simulator.js +8 -2
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +236 -4
- package/dist/write/vectors/ui-certification.js +63 -13
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-chord.d.ts +144 -0
- package/dist/write/vectors/ui-chord.js +428 -0
- package/dist/write/vectors/ui-chord.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +8 -4
- package/dist/write/vectors/ui-drag.js +2 -0
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +51 -3
- package/dist/write/vectors/ui-recipes.js +452 -44
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui-state.d.ts +157 -0
- package/dist/write/vectors/ui-state.js +237 -0
- package/dist/write/vectors/ui-state.js.map +1 -0
- package/dist/write/vectors/ui.d.ts +386 -13
- package/dist/write/vectors/ui.js +1543 -124
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/vectors/url-scheme.js +3 -0
- package/dist/write/vectors/url-scheme.js.map +1 -1
- package/dist/write/verify/delta.d.ts +41 -0
- package/dist/write/verify/delta.js +36 -2
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +4 -1
- package/dist/write/verify/poller.js +14 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +1 -1
- package/schema/envelope.schema.json +2 -1
- package/skills/things-cli/SKILL.md +2 -2
|
@@ -5,10 +5,11 @@ import { byUuid } from "../read/detail.js";
|
|
|
5
5
|
import { resolveProjectWriteTarget, resolveTaskUuidPrefix } from "../read/queries.js";
|
|
6
6
|
import { runCloneProject, runCloneTodo } from "./clone.js";
|
|
7
7
|
import { promoteProjectViaGui } from "./make-repeating-project.js";
|
|
8
|
+
import { replayIfApplied } from "./opid.js";
|
|
8
9
|
import { fingerprintLabel, runComposite, runMutation, } from "./pipeline.js";
|
|
9
10
|
import { assessOffRuleFirst, daysBetweenIso, deriveFixedAnchor, isIsoDate, } from "./repeat-anchor.js";
|
|
10
11
|
import { assertRepeatRule, ruleToInverseParams, splitAddRepeatingRule } from "./repeat-rule.js";
|
|
11
|
-
import { createDbReader } from "./verify/delta.js";
|
|
12
|
+
import { createDbReader, } from "./verify/delta.js";
|
|
12
13
|
import { H_UI_SESSION_UNREACHABLE } from "./vectors/session-reachability.js";
|
|
13
14
|
const PLACEMENT_NOTE = "the series' current instance lands at its container's default position — its prior slot was " +
|
|
14
15
|
"not automatically restored (best-effort placement is not yet wired for this container); " +
|
|
@@ -98,6 +99,29 @@ async function gateUiPreflight(deps, op) {
|
|
|
98
99
|
}
|
|
99
100
|
if (!deps.config.ui.enabled)
|
|
100
101
|
return null;
|
|
102
|
+
// 1½. AN OPEN DIALOG, before the seed (MODALX1, issue #620). This is the gap
|
|
103
|
+
// the field incident fell into twice over: the clone leg rides the URL scheme,
|
|
104
|
+
// which an open dialog does not touch, so it LANDS — and then the trash leg,
|
|
105
|
+
// the promote and the cleanup are all AppleScript, all refused with `-1728`,
|
|
106
|
+
// leaving a copy of the user's to-do in their lists. Measured in-lab: with a
|
|
107
|
+
// dialog standing, the compound mutates and then fails for a reason it could
|
|
108
|
+
// have read up front. A census that cannot be read proceeds (the drive's own
|
|
109
|
+
// precondition is the backstop); only a POSITIVE sighting refuses.
|
|
110
|
+
const dialogVector = deps.vectors.find((v) => v.probeUiState !== undefined);
|
|
111
|
+
if (dialogVector?.probeUiState !== undefined) {
|
|
112
|
+
const state = await dialogVector.probeUiState();
|
|
113
|
+
if (state !== null && state.inspectable && state.sheetOpen) {
|
|
114
|
+
return {
|
|
115
|
+
kind: "blocked",
|
|
116
|
+
op,
|
|
117
|
+
reason: "environment",
|
|
118
|
+
detail: "a dialog is already open in Things, and while one is open the app ignores changes " +
|
|
119
|
+
"like this one and stops sending anything to Things Cloud — nothing was created",
|
|
120
|
+
remediation: "dismiss the dialog in Things (click Cancel, or press Escape with Things in front), " +
|
|
121
|
+
"then run the same command again; `things ui-state` shows what is open",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
101
125
|
const ui = deps.vectors.find((v) => v.probeReachability !== undefined);
|
|
102
126
|
if (ui?.probeReachability === undefined)
|
|
103
127
|
return null;
|
|
@@ -139,6 +163,126 @@ async function cleanupSeed(deps, kind, createdUuid, promote, options, txnId) {
|
|
|
139
163
|
? { detail: `${promote.detail} — ${cleanupNote}` }
|
|
140
164
|
: {};
|
|
141
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Put a restored to-do back where it was (issue #620, field report M1).
|
|
168
|
+
*
|
|
169
|
+
* The ONLY scriptable restore-from-Trash is `move <to-do> to list "Inbox"`
|
|
170
|
+
* (E15) — and it does exactly that: the row lands in the INBOX, DE-SCHEDULED
|
|
171
|
+
* (`start=0, startDate=NULL`), with its project/heading link severed. That is
|
|
172
|
+
* the GUI's "Put Back" affordance's poorer scripted cousin, and it is why a
|
|
173
|
+
* failed promote handed back a to-do sitting unscheduled in the Inbox instead
|
|
174
|
+
* of where its owner left it. Everything else about the row survives (title,
|
|
175
|
+
* notes, tags, deadline, reminder byte), so the repair is exactly two writes:
|
|
176
|
+
* its container, then its schedule.
|
|
177
|
+
*
|
|
178
|
+
* Best-effort and fully disclosed: each half reports whether it landed, and a
|
|
179
|
+
* to-do that lived under a HEADING is returned to the owning project with the
|
|
180
|
+
* heading placement named as not restored — there is nothing to guess at.
|
|
181
|
+
* Returns the sentence describing what happened.
|
|
182
|
+
*/
|
|
183
|
+
async function reassertTodoPlacement(deps, src, options, txnId) {
|
|
184
|
+
const landed = [];
|
|
185
|
+
const missed = [];
|
|
186
|
+
const projectUuid = src.project?.uuid ?? src.headingProject?.uuid ?? null;
|
|
187
|
+
const areaUuid = src.area?.uuid ?? null;
|
|
188
|
+
const move = projectUuid !== null
|
|
189
|
+
? { uuid: src.uuid, project: { uuid: projectUuid } }
|
|
190
|
+
: areaUuid !== null
|
|
191
|
+
? { uuid: src.uuid, area: { uuid: areaUuid } }
|
|
192
|
+
: null;
|
|
193
|
+
if (move !== null) {
|
|
194
|
+
const res = await runMutation(deps, "todo.move", move, legOptions(options, txnId));
|
|
195
|
+
const where = projectUuid !== null ? "its project" : "its area";
|
|
196
|
+
(res.kind === "ok" ? landed : missed).push(where);
|
|
197
|
+
}
|
|
198
|
+
if (src.heading !== null)
|
|
199
|
+
missed.push("its position under a heading");
|
|
200
|
+
// The schedule. `evening` is only expressible for TODAY (the app has no other
|
|
201
|
+
// evening slot), so a stale evening marker degrades to its date.
|
|
202
|
+
const todayIso = localToday(deps.now?.() ?? new Date(), deps.zone);
|
|
203
|
+
const when = src.derived.evening === true && src.startDate === todayIso
|
|
204
|
+
? "evening"
|
|
205
|
+
: src.startDate !== null
|
|
206
|
+
? src.startDate
|
|
207
|
+
: src.derived.start === "someday"
|
|
208
|
+
? "someday"
|
|
209
|
+
: src.derived.start === "active"
|
|
210
|
+
? "anytime"
|
|
211
|
+
: null;
|
|
212
|
+
if (when !== null) {
|
|
213
|
+
const res = await runMutation(deps, "todo.update", { uuid: src.uuid, when }, legOptions(options, txnId));
|
|
214
|
+
(res.kind === "ok" ? landed : missed).push(`its "when" (${when})`);
|
|
215
|
+
}
|
|
216
|
+
if (landed.length === 0 && missed.length === 0)
|
|
217
|
+
return "it was already in the Inbox, unscheduled";
|
|
218
|
+
const ok = landed.length > 0 ? `${landed.join(" and ")} restored` : "";
|
|
219
|
+
const bad = missed.length > 0
|
|
220
|
+
? `${ok === "" ? "" : ", but "}${missed.join(" and ")} could not be restored — set ${missed.length > 1 ? "them" : "it"} yourself`
|
|
221
|
+
: "";
|
|
222
|
+
return `a scripted restore returns a to-do to the Inbox with no schedule, so ${ok}${bad}`;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Undo a failed make-repeating's OWN two mutations (issue #620).
|
|
226
|
+
*
|
|
227
|
+
* The compound trashes the original and mints a disposable copy before the
|
|
228
|
+
* promote runs, so a promote that does not land leaves two artifacts. Both are
|
|
229
|
+
* ours, so both are cleaned up here rather than described to the caller as
|
|
230
|
+
* homework: the original is restored AND put back where it was
|
|
231
|
+
* ({@link reassertTodoPlacement}), and the disposable copy is moved to the
|
|
232
|
+
* Trash — the same ratified reasoning as the add-repeating seed auto-trash
|
|
233
|
+
* (2026-08-15, issue #480): our artifact, recreatable verbatim, and the Trash
|
|
234
|
+
* is recoverable.
|
|
235
|
+
*
|
|
236
|
+
* When a step does NOT land, the report names the row and the command that
|
|
237
|
+
* finishes the job — and names the likeliest reason, because there is exactly
|
|
238
|
+
* one that matters here: a dialog still open in Things makes the app ignore
|
|
239
|
+
* scripted changes app-wide (docs/things-app-oddities.md §9cc) and holds Things
|
|
240
|
+
* Cloud sync with them. That is the mechanism behind the field report's
|
|
241
|
+
* "AppleScript could not get that to-do ID" on a row the database showed
|
|
242
|
+
* perfectly present.
|
|
243
|
+
*/
|
|
244
|
+
async function rollBackFailedPromote(deps, kind, ids, options, txnId) {
|
|
245
|
+
const expectedType = kind === "project" ? "project" : "to-do";
|
|
246
|
+
const restoreOp = kind === "project" ? "project.restore" : "todo.restore";
|
|
247
|
+
const restored = await runMutation(deps, restoreOp, { uuid: ids.srcUuid }, legOptions(options, txnId));
|
|
248
|
+
let originalNote;
|
|
249
|
+
if (restored.kind === "ok") {
|
|
250
|
+
// A project restore flips the row in place and keeps its area, schedule and
|
|
251
|
+
// children exactly as they were (P06) — only the to-do restore relocates.
|
|
252
|
+
const placement = ids.src.type === "to-do" ? await reassertTodoPlacement(deps, ids.src, options, txnId) : null;
|
|
253
|
+
originalNote =
|
|
254
|
+
`the original ${expectedType} (uuid ${ids.srcUuid}) was restored from the Trash` +
|
|
255
|
+
(placement === null ? "" : ` — ${placement}`);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
originalNote =
|
|
259
|
+
`the original ${expectedType} (uuid ${ids.srcUuid}) could NOT be restored from the Trash — ` +
|
|
260
|
+
`restore it with \`things ${kind} restore ${ids.srcUuid}\`${DISMISS_FIRST}`;
|
|
261
|
+
}
|
|
262
|
+
const cloneNote = await trashDisposableCopy(deps, kind, ids.cloneUuid, options, txnId);
|
|
263
|
+
return `the promote did not land. ${originalNote}; ${cloneNote}`;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Move the compound's own disposable copy to the Trash and say what happened.
|
|
267
|
+
* Shared by every failure path that has minted one, so a caller is never left
|
|
268
|
+
* holding a duplicate of their own to-do with no instruction.
|
|
269
|
+
*/
|
|
270
|
+
async function trashDisposableCopy(deps, kind, cloneUuid, options, txnId) {
|
|
271
|
+
const trashOp = kind === "project" ? "project.delete" : "todo.delete";
|
|
272
|
+
const trashed = await runMutation(deps, trashOp, { uuid: cloneUuid }, legOptions(options, txnId));
|
|
273
|
+
return trashed.kind === "ok"
|
|
274
|
+
? `the disposable copy this command made (uuid ${cloneUuid}) was moved to the Trash`
|
|
275
|
+
: `the disposable copy this command made (uuid ${cloneUuid}) is still in your lists and ` +
|
|
276
|
+
`could NOT be removed — remove it with \`things ${kind} delete ${cloneUuid}\`${DISMISS_FIRST}`;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The one remediation a stranded dialog needs, appended wherever a cleanup
|
|
280
|
+
* mutation fails: while a dialog is open, Things ignores scripted changes
|
|
281
|
+
* app-wide AND stops sending anything to Things Cloud.
|
|
282
|
+
*/
|
|
283
|
+
const DISMISS_FIRST = ". If a dialog is still open in Things, dismiss it first (click Cancel, or press Escape with " +
|
|
284
|
+
"Things in front): while one is open the app ignores changes like this and stops sending " +
|
|
285
|
+
"changes to Things Cloud";
|
|
142
286
|
/**
|
|
143
287
|
* Pick the rule fields (frequency/interval + calendar anchors + deadline offset)
|
|
144
288
|
* as a RepeatRuleParams, plus the requested first-occurrence date to drive into
|
|
@@ -258,6 +402,33 @@ function landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion) {
|
|
|
258
402
|
return firstOccurrenceOf(deps.db, templateUuid);
|
|
259
403
|
return instanceUuid === null ? null : instanceStartDate(deps.db, instanceUuid);
|
|
260
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Was the requested first occurrence honored? (#508's oracle, extended for the
|
|
407
|
+
* SAME-DAY case — issue #625.)
|
|
408
|
+
*
|
|
409
|
+
* A fixed series is normally read off the template's cursor. But when the
|
|
410
|
+
* requested first occurrence is TODAY, the app materializes that occurrence
|
|
411
|
+
* immediately on commit and ADVANCES the cursor to the next slot — measured on
|
|
412
|
+
* 3.23 (FGRD1 §8: `--when <today>` on a weekly rule left the template's cursor
|
|
413
|
+
* and next-date on the FOLLOWING week with `instanceCreationCount = 1`, while a
|
|
414
|
+
* live instance sat on today). Reading only the cursor there reports a
|
|
415
|
+
* `verify-failed:mismatch` on a series that landed exactly as asked — the same
|
|
416
|
+
* false-negative shape as #508, one case over. So the check accepts EITHER
|
|
417
|
+
* oracle: the cursor naming the requested date, or a materialized instance
|
|
418
|
+
* SITTING on it. An instance dated the requested day is proof by construction;
|
|
419
|
+
* nothing else can put one there.
|
|
420
|
+
*/
|
|
421
|
+
export function firstOccurrenceHonored(deps, args) {
|
|
422
|
+
const landed = landedFirstStart(deps, args.templateUuid, args.instanceUuid, args.afterCompletion);
|
|
423
|
+
// An after-completion series with no materialized instance is UNVERIFIABLE
|
|
424
|
+
// (the create delta already proved the series landed) — never a mismatch.
|
|
425
|
+
if (args.afterCompletion && landed === null)
|
|
426
|
+
return { honored: true, landed };
|
|
427
|
+
if (landed === args.expectedIso)
|
|
428
|
+
return { honored: true, landed };
|
|
429
|
+
const instanceIso = args.instanceUuid === null ? null : instanceStartDate(deps.db, args.instanceUuid);
|
|
430
|
+
return { honored: instanceIso === args.expectedIso, landed };
|
|
431
|
+
}
|
|
261
432
|
/**
|
|
262
433
|
* DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md, golden-v3 / Things 3.22.14):
|
|
263
434
|
* a promote whose source is PRESERVED (SRCFATE deadline / terminal-element trigger)
|
|
@@ -340,6 +511,36 @@ function discoveryOf(promote) {
|
|
|
340
511
|
};
|
|
341
512
|
}
|
|
342
513
|
// -------------------------------------------------------------- audit summary
|
|
514
|
+
/**
|
|
515
|
+
* The promote compound's presence oracle: "a repeating TEMPLATE with this title,
|
|
516
|
+
* created since this call started, exists". It is the same create-probe the
|
|
517
|
+
* compound's own dry-run plan states, TIME-BOUNDED to the call — the app mints a
|
|
518
|
+
* template with a fresh write-time creationDate even when the promoted row was
|
|
519
|
+
* created earlier (`--preserve-created`), so the bound is safe and it keeps an
|
|
520
|
+
* older namesake series from reading as this call's work. Recorded on an
|
|
521
|
+
* AMBIGUOUS summary so a resubmission can settle it (see {@link appendPromoteSummary}).
|
|
522
|
+
*/
|
|
523
|
+
function promotePresenceDelta(title, type, startedAt) {
|
|
524
|
+
return {
|
|
525
|
+
mode: "create",
|
|
526
|
+
probe: { title, type, sinceEpoch: Math.floor(startedAt.getTime() / 1000) },
|
|
527
|
+
assert: [{ field: "repeating.isTemplate", equals: true }],
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Append the promote compound's SUMMARY record — the ONE record that stands for
|
|
532
|
+
* the whole verb (its legs are `leg`-role records, excluded from direct undo),
|
|
533
|
+
* and the one record the caller's `opId` rides, so a resubmission matches the
|
|
534
|
+
* whole promote exactly once instead of matching a leg or nothing at all.
|
|
535
|
+
*
|
|
536
|
+
* Two shapes, one record either way:
|
|
537
|
+
* - the SUCCESS summary (default) — result `ok`, the single undoable unit;
|
|
538
|
+
* - an AMBIGUOUS summary (`ambiguous`) — result `verify-failed:timeout` with the
|
|
539
|
+
* presence oracle attached, written when a leg dispatched and never confirmed.
|
|
540
|
+
* It is not an undo target (undo reads `ok` records only); it exists so a
|
|
541
|
+
* resubmission carrying the same key re-reads state and decides instead of
|
|
542
|
+
* minting a second series.
|
|
543
|
+
*/
|
|
343
544
|
function appendPromoteSummary(deps, args) {
|
|
344
545
|
const fp = deps.fingerprint();
|
|
345
546
|
const observed = {
|
|
@@ -350,7 +551,7 @@ function appendPromoteSummary(deps, args) {
|
|
|
350
551
|
const record = {
|
|
351
552
|
v: 1,
|
|
352
553
|
ts: args.startedAt.toISOString(),
|
|
353
|
-
actor: deps.config.actor,
|
|
554
|
+
actor: args.options?.actor ?? deps.config.actor,
|
|
354
555
|
host: deps.config.host,
|
|
355
556
|
op: args.op,
|
|
356
557
|
uuid: args.templateUuid,
|
|
@@ -359,9 +560,11 @@ function appendPromoteSummary(deps, args) {
|
|
|
359
560
|
invocation: args.invocation,
|
|
360
561
|
requested: args.requested,
|
|
361
562
|
txn: { id: args.txnId, role: "summary" },
|
|
563
|
+
...(args.options?.opId !== undefined && { opId: args.options.opId }),
|
|
564
|
+
...(args.ambiguous !== undefined && { expected: args.ambiguous }),
|
|
362
565
|
pre: null,
|
|
363
|
-
observed,
|
|
364
|
-
result: "ok",
|
|
566
|
+
observed: args.ambiguous === undefined ? observed : null,
|
|
567
|
+
result: args.ambiguous === undefined ? "ok" : "verify-failed:timeout",
|
|
365
568
|
...(args.preModDates !== undefined && { preModDates: args.preModDates }),
|
|
366
569
|
verify: null,
|
|
367
570
|
durationMs: (deps.now?.() ?? new Date()).getTime() - args.startedAt.getTime(),
|
|
@@ -373,6 +576,31 @@ function appendPromoteSummary(deps, args) {
|
|
|
373
576
|
};
|
|
374
577
|
deps.audit.append(record);
|
|
375
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* Record the AMBIGUOUS summary when a promote compound's own outcome is a
|
|
581
|
+
* timeout — the one verdict where the series may or may not exist. Returns the
|
|
582
|
+
* outcome unchanged, so it wraps a composite body without re-shaping it. Only a
|
|
583
|
+
* keyed call records one: without an `opId` there is nothing to reconcile
|
|
584
|
+
* against later, and the leg records already carry the failure.
|
|
585
|
+
*/
|
|
586
|
+
function recordAmbiguousPromote(deps, outcome, args) {
|
|
587
|
+
if (args.options.opId === undefined)
|
|
588
|
+
return outcome;
|
|
589
|
+
if (outcome.kind !== "verify-failed" || outcome.reason !== "timeout")
|
|
590
|
+
return outcome;
|
|
591
|
+
appendPromoteSummary(deps, {
|
|
592
|
+
startedAt: args.startedAt,
|
|
593
|
+
op: args.op,
|
|
594
|
+
txnId: args.txnId,
|
|
595
|
+
templateUuid: null,
|
|
596
|
+
instanceUuid: null,
|
|
597
|
+
invocation: args.invocation,
|
|
598
|
+
requested: args.requested,
|
|
599
|
+
options: args.options,
|
|
600
|
+
ambiguous: promotePresenceDelta(args.title, args.type, args.startedAt),
|
|
601
|
+
});
|
|
602
|
+
return outcome;
|
|
603
|
+
}
|
|
376
604
|
/** Build the ok result for a promote (make/add-repeating). */
|
|
377
605
|
function promoteOk(args) {
|
|
378
606
|
const repeating = {
|
|
@@ -409,6 +637,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
409
637
|
const op = kind === "project" ? "project.make-repeating" : "todo.make-repeating";
|
|
410
638
|
// Validate the rule BEFORE anything (a bad rule must never mint a clone).
|
|
411
639
|
assertRepeatRule(params);
|
|
640
|
+
// Idempotency FIRST, before the target is even resolved: a successful promote
|
|
641
|
+
// moves the original to the Trash and hands back a different uuid, so a
|
|
642
|
+
// resubmission of the same key must be answered from the trail rather than
|
|
643
|
+
// re-driven against a source that is no longer the item it names. The key
|
|
644
|
+
// rides the compound's single summary record, so a match is the whole verb.
|
|
645
|
+
const replay = replayIfApplied(deps, options);
|
|
646
|
+
if (replay !== null)
|
|
647
|
+
return replay;
|
|
412
648
|
const now = deps.now?.() ?? new Date();
|
|
413
649
|
const srcUuid = kind === "project"
|
|
414
650
|
? resolveProjectWriteTarget(deps.db, params.uuid)
|
|
@@ -427,6 +663,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
427
663
|
: "verify the uuid with `things show <uuid>`, or use `things project make-repeating` for a project",
|
|
428
664
|
};
|
|
429
665
|
}
|
|
666
|
+
// Bound after the existence guard so the composite body (a hoisted function,
|
|
667
|
+
// where TypeScript cannot carry the narrowing) still sees a plain string.
|
|
668
|
+
const srcTitle = src.title;
|
|
669
|
+
// Same reason as srcTitle: the type guard above proved this is the to-do or
|
|
670
|
+
// project the caller named, but the narrowing does not survive into the
|
|
671
|
+
// hoisted composite body. The failure rollback reads its placement from here
|
|
672
|
+
// (captured BEFORE the trash, which is the only moment it is knowable).
|
|
673
|
+
const srcEntity = src;
|
|
430
674
|
// ANCH2 (issue #476): the app's Repeat dialog HAS a "Next:" first-occurrence
|
|
431
675
|
// field; its default is the today-anchored next match, but it is editable and
|
|
432
676
|
// honored (docs/lab/anch2-next-field.md). Drive it with the requested first
|
|
@@ -449,10 +693,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
449
693
|
? (params.startDaysEarlier ?? 0)
|
|
450
694
|
: 0;
|
|
451
695
|
const driveIso = isIsoDate(whenIso) ? addDaysIso(whenIso, deadlineShift) : undefined;
|
|
452
|
-
// The
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
696
|
+
// The ANCHOR is derived from the deadline-adjusted date (the anchor names the
|
|
697
|
+
// DUE date), but `next` is passed through as the requested START: the promote
|
|
698
|
+
// leg's own compile applies the "Next:" shift, and it is the ONLY place that
|
|
699
|
+
// does (NEXTPOP1 — shifting here as well made every downstream consumer that
|
|
700
|
+
// shifts, `assessOffRuleFirst` among them, shift a second time). The verify
|
|
701
|
+
// below expects the START to land on the requested `--when` either way.
|
|
702
|
+
const nextIso = isIsoDate(whenIso) ? whenIso : undefined;
|
|
703
|
+
const expectedStartIso = nextIso;
|
|
456
704
|
const effParams = { ...params, ...deriveFixedAnchor(params, driveIso) };
|
|
457
705
|
// The promote leg drives the GUI — block before minting a clone if the ack is missing.
|
|
458
706
|
if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
|
|
@@ -484,14 +732,29 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
484
732
|
const gate = await gateUiPreflight(deps, op);
|
|
485
733
|
if (gate !== null)
|
|
486
734
|
return gate;
|
|
735
|
+
const startedAt = now;
|
|
736
|
+
const txnId = newTxnId(startedAt);
|
|
487
737
|
// COMPOSITE LOCK: everything below is ONE verb executed as several mutations
|
|
488
738
|
// (clone → trash → promote → the DBLSPAWN1 clean-up), and they must not
|
|
489
739
|
// interleave with another writer's legs — the promote's row selection is by
|
|
490
740
|
// TITLE, so a concurrent clone of the same item makes it ambiguous. One lock,
|
|
491
741
|
// held to the end; each leg's own acquisition is a reentrant no-op.
|
|
492
742
|
return runComposite(deps, op, async () => {
|
|
493
|
-
const
|
|
494
|
-
|
|
743
|
+
const outcome = await promoteBody();
|
|
744
|
+
// An unconfirmed outcome gets the compound's AMBIGUOUS summary, so a
|
|
745
|
+
// resubmission of the same key reconciles instead of minting a second series.
|
|
746
|
+
return recordAmbiguousPromote(deps, outcome, {
|
|
747
|
+
startedAt,
|
|
748
|
+
op,
|
|
749
|
+
txnId,
|
|
750
|
+
title: srcTitle,
|
|
751
|
+
type: expectedType,
|
|
752
|
+
invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote (unconfirmed)`,
|
|
753
|
+
requested: effParams,
|
|
754
|
+
options,
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
async function promoteBody() {
|
|
495
758
|
// 1. Clone the source as a disposable, embedded leg (--preserve-created). The
|
|
496
759
|
// clone has captured X's full content by the time it returns.
|
|
497
760
|
const clone = kind === "project"
|
|
@@ -507,7 +770,7 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
507
770
|
reason: "mismatch",
|
|
508
771
|
expected: {
|
|
509
772
|
mode: "create",
|
|
510
|
-
probe: { title:
|
|
773
|
+
probe: { title: srcTitle, type: expectedType, sinceEpoch: 0 },
|
|
511
774
|
assert: [],
|
|
512
775
|
},
|
|
513
776
|
observed: null,
|
|
@@ -531,14 +794,16 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
531
794
|
// ambiguous (H-PROJECT-REPEAT). X survives in the Trash (the recoverable half).
|
|
532
795
|
const trash = await runMutation(deps, `${kind}.delete`, { uuid: srcUuid }, legOptions(options, txnId, undefined, preserveModified ? { preserveModified: true } : undefined));
|
|
533
796
|
if (trash.kind !== "ok") {
|
|
797
|
+
// The original never moved, so there is nothing to restore — but the
|
|
798
|
+
// disposable copy exists and is ours to clean up (issue #620).
|
|
799
|
+
const cloneNote = await trashDisposableCopy(deps, kind, cloneUuid, options, txnId);
|
|
534
800
|
return {
|
|
535
801
|
...trash,
|
|
536
802
|
op,
|
|
537
803
|
...("detail" in trash
|
|
538
804
|
? {
|
|
539
|
-
detail: `${trash.detail} — the
|
|
540
|
-
`
|
|
541
|
-
"the clone and retry",
|
|
805
|
+
detail: `${trash.detail} — the original ${srcUuid} could not be moved to the Trash, so it ` +
|
|
806
|
+
`was NOT promoted and is unchanged; ${cloneNote}`,
|
|
542
807
|
}
|
|
543
808
|
: {}),
|
|
544
809
|
};
|
|
@@ -549,22 +814,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
549
814
|
? await promoteProjectViaGui(deps, rule, legOptions(options, txnId, "ui"))
|
|
550
815
|
: await runMutation(deps, "todo.make-repeating", rule, legOptions(options, txnId, "ui"));
|
|
551
816
|
if (promote.kind !== "ok") {
|
|
552
|
-
// The clone persists but was not promoted
|
|
553
|
-
// the
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
const rollNote = rolledBack.kind === "ok"
|
|
557
|
-
? `the original ${srcUuid} was restored from the Trash`
|
|
558
|
-
: `the original ${srcUuid} could NOT be restored from the Trash — restore it in the app`;
|
|
817
|
+
// The clone persists but was not promoted. Undo our own two mutations, in
|
|
818
|
+
// the order that leaves the least behind (issue #620) — see
|
|
819
|
+
// {@link rollBackFailedPromote}.
|
|
820
|
+
const rollNote = await rollBackFailedPromote(deps, kind, { srcUuid, cloneUuid, src: srcEntity }, options, txnId);
|
|
559
821
|
return {
|
|
560
822
|
...promote,
|
|
561
823
|
op,
|
|
562
|
-
...("detail" in promote
|
|
563
|
-
? {
|
|
564
|
-
detail: `${promote.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
|
|
565
|
-
`promote did not land; ${rollNote}. Trash the clone and retry`,
|
|
566
|
-
}
|
|
567
|
-
: {}),
|
|
824
|
+
...("detail" in promote ? { detail: `${promote.detail} — ${rollNote}` } : {}),
|
|
568
825
|
};
|
|
569
826
|
}
|
|
570
827
|
const { templateUuid } = discoveryOf(promote);
|
|
@@ -577,10 +834,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
577
834
|
// (#508) — see landedFirstStart; an unverifiable after-completion series skips.
|
|
578
835
|
const afterCompletion = effParams.afterCompletion === true;
|
|
579
836
|
if (expectedStartIso !== undefined) {
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
837
|
+
const first = firstOccurrenceHonored(deps, {
|
|
838
|
+
templateUuid,
|
|
839
|
+
instanceUuid,
|
|
840
|
+
expectedIso: expectedStartIso,
|
|
841
|
+
afterCompletion,
|
|
842
|
+
});
|
|
843
|
+
if (!first.honored)
|
|
844
|
+
return nextMismatch(op, templateUuid, expectedStartIso, first.landed);
|
|
584
845
|
}
|
|
585
846
|
const warnings = [
|
|
586
847
|
landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
|
|
@@ -611,6 +872,7 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
611
872
|
originalUuid: srcUuid,
|
|
612
873
|
invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
|
|
613
874
|
requested: effParams,
|
|
875
|
+
options,
|
|
614
876
|
...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
|
|
615
877
|
});
|
|
616
878
|
return promoteOk({
|
|
@@ -618,11 +880,11 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
618
880
|
templateUuid,
|
|
619
881
|
instanceUuid,
|
|
620
882
|
replacedUuid: cloneUuid,
|
|
621
|
-
title:
|
|
883
|
+
title: srcTitle,
|
|
622
884
|
txnId,
|
|
623
885
|
warnings,
|
|
624
886
|
});
|
|
625
|
-
}
|
|
887
|
+
}
|
|
626
888
|
}
|
|
627
889
|
export function runMakeRepeatingTodo(deps, params, options = {}) {
|
|
628
890
|
return makeRepeatingViaClone(deps, "todo", params, options);
|
|
@@ -639,6 +901,12 @@ export function runMakeRepeatingProject(deps, params, options = {}) {
|
|
|
639
901
|
async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options) {
|
|
640
902
|
const op = kind === "project" ? "project.add-repeating" : "todo.add-repeating";
|
|
641
903
|
assertRepeatRule(rule);
|
|
904
|
+
// Idempotency FIRST: this verb CREATES, so a blind resubmission is a duplicate
|
|
905
|
+
// series. The key rides the compound's single summary record, so a match is the
|
|
906
|
+
// whole verb — the add leg and the promote leg together — not one of them.
|
|
907
|
+
const replay = replayIfApplied(deps, options);
|
|
908
|
+
if (replay !== null)
|
|
909
|
+
return replay;
|
|
642
910
|
// ANCH2 (issue #476): drive the Repeat dialog's "Next:" field with --when so the
|
|
643
911
|
// series starts on the requested date (the field's default is today-anchored but
|
|
644
912
|
// it is editable and honored). YANCH1 (issue #493): also DERIVE the calendar
|
|
@@ -658,8 +926,11 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
658
926
|
const whenIso = isIsoDate(addParams["when"]) ? addParams["when"] : null;
|
|
659
927
|
const deadlineShift = rule.deadline === true || (rule.startDaysEarlier ?? 0) > 0 ? (rule.startDaysEarlier ?? 0) : 0;
|
|
660
928
|
const driveIso = whenIso !== null ? addDaysIso(whenIso, deadlineShift) : null;
|
|
661
|
-
|
|
662
|
-
|
|
929
|
+
// The ANCHOR is derived from the deadline-adjusted date; `next` passes through
|
|
930
|
+
// as the requested START and the promote leg's compile applies the "Next:"
|
|
931
|
+
// shift — the one place that does (NEXTPOP1; see promoteViaCloneAndMakeRepeating).
|
|
932
|
+
const nextIso = whenIso ?? undefined;
|
|
933
|
+
const expectedStartIso = nextIso;
|
|
663
934
|
const effRule = {
|
|
664
935
|
...rule,
|
|
665
936
|
...deriveFixedAnchor(rule, driveIso),
|
|
@@ -706,12 +977,27 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
706
977
|
const gate = await gateUiPreflight(deps, op);
|
|
707
978
|
if (gate !== null)
|
|
708
979
|
return gate;
|
|
980
|
+
const startedAt = deps.now?.() ?? new Date();
|
|
981
|
+
const txnId = newTxnId(startedAt);
|
|
709
982
|
// COMPOSITE LOCK: add → promote (→ the seed auto-trash / DBLSPAWN1 clean-up)
|
|
710
983
|
// is one verb, several mutations; hold one lock across all of them so a
|
|
711
984
|
// concurrent composite cannot land its own legs between ours.
|
|
712
985
|
return runComposite(deps, op, async () => {
|
|
713
|
-
const
|
|
714
|
-
|
|
986
|
+
const outcome = await addBody();
|
|
987
|
+
// An unconfirmed outcome gets the compound's AMBIGUOUS summary, so a
|
|
988
|
+
// resubmission of the same key reconciles instead of creating a second series.
|
|
989
|
+
return recordAmbiguousPromote(deps, outcome, {
|
|
990
|
+
startedAt,
|
|
991
|
+
op,
|
|
992
|
+
txnId,
|
|
993
|
+
title,
|
|
994
|
+
type: expectedType,
|
|
995
|
+
invocation: `${op}: add "${title}" → promote (unconfirmed)`,
|
|
996
|
+
requested: { title, ...effRule },
|
|
997
|
+
options,
|
|
998
|
+
});
|
|
999
|
+
});
|
|
1000
|
+
async function addBody() {
|
|
715
1001
|
// 1. Create the item (full add vocabulary) as an embedded leg.
|
|
716
1002
|
const addOp = kind === "project" ? "project.add" : "todo.add";
|
|
717
1003
|
const add = await runMutation(deps, addOp, addParams, legOptions(options, txnId, "url-scheme"));
|
|
@@ -762,9 +1048,14 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
762
1048
|
// series verifies against its materialized instance, and skips when it has none.
|
|
763
1049
|
const afterCompletion = effRuleWithReminder.afterCompletion === true;
|
|
764
1050
|
if (expectedStartIso !== undefined) {
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
1051
|
+
const first = firstOccurrenceHonored(deps, {
|
|
1052
|
+
templateUuid,
|
|
1053
|
+
instanceUuid,
|
|
1054
|
+
expectedIso: expectedStartIso,
|
|
1055
|
+
afterCompletion,
|
|
1056
|
+
});
|
|
1057
|
+
if (!first.honored)
|
|
1058
|
+
return nextMismatch(op, templateUuid, expectedStartIso, first.landed);
|
|
768
1059
|
}
|
|
769
1060
|
const warnings = [
|
|
770
1061
|
landedRuleEcho(ruleParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
|
|
@@ -792,6 +1083,7 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
792
1083
|
instanceUuid,
|
|
793
1084
|
invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
|
|
794
1085
|
requested: { title, ...effRule },
|
|
1086
|
+
options,
|
|
795
1087
|
});
|
|
796
1088
|
return promoteOk({
|
|
797
1089
|
op,
|
|
@@ -802,7 +1094,7 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
802
1094
|
txnId,
|
|
803
1095
|
warnings,
|
|
804
1096
|
});
|
|
805
|
-
}
|
|
1097
|
+
}
|
|
806
1098
|
}
|
|
807
1099
|
/**
|
|
808
1100
|
* Fold a concrete item-level `deadline` (and/or an explicit `startDaysEarlier`)
|