things-api 0.18.0 → 0.19.1
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 +18 -9
- package/deputy/AppIcon.icns +0 -0
- package/deputy/VERSION +1 -1
- package/deputy/helpers-Info.plist +4 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +3 -1
- 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/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +15 -2
- package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +5 -1
- package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +14 -2
- package/deputy/reader/Info.plist +2 -0
- package/deputy/reader/entitlements.plist +30 -0
- package/deputy/reader/main.swift +129 -40
- package/deputy/src/server.swift +25 -2
- package/deputy/src/tcc.swift +58 -0
- package/dist/audit/schema.d.ts +30 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/capability.d.ts +322 -0
- package/dist/capability.js +706 -0
- package/dist/capability.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +13 -1
- package/dist/cli/commands/doctor.js +141 -5
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/helpers.js +189 -51
- 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/op-result.js +6 -0
- package/dist/cli/commands/op-result.js.map +1 -1
- package/dist/cli/commands/reads.js +47 -1
- package/dist/cli/commands/reads.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.d.ts +9 -0
- package/dist/cli/commands/setup.js +76 -83
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/writes.d.ts +1 -1
- package/dist/cli/commands/writes.js +311 -142
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/dry-run.d.ts +1 -1
- package/dist/cli/dry-run.js +1 -1
- package/dist/cli/glyphs.d.ts +23 -4
- package/dist/cli/glyphs.js +46 -19
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +49 -27
- 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 +8 -5
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/period.d.ts +3 -1
- package/dist/cli/period.js +1 -1
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.js +26 -1
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +31 -4
- package/dist/cli/render.js +127 -15
- package/dist/cli/render.js.map +1 -1
- package/dist/client.d.ts +23 -3
- package/dist/client.js +36 -13
- package/dist/client.js.map +1 -1
- package/dist/contracts.d.ts +8 -4
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/deputy/install.d.ts +234 -6
- package/dist/deputy/install.js +843 -31
- package/dist/deputy/install.js.map +1 -1
- package/dist/deputy/notices.js +2 -2
- package/dist/deputy/notices.js.map +1 -1
- package/dist/deputy/protocol.d.ts +59 -5
- package/dist/deputy/protocol.js +48 -11
- package/dist/deputy/protocol.js.map +1 -1
- package/dist/deputy/routing.d.ts +37 -0
- package/dist/deputy/routing.js +181 -27
- 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 +19 -4
- package/dist/diagnose.js +64 -8
- package/dist/diagnose.js.map +1 -1
- package/dist/direct-setup.d.ts +104 -0
- package/dist/direct-setup.js +658 -0
- package/dist/direct-setup.js.map +1 -0
- package/dist/host-access.d.ts +75 -0
- package/dist/host-access.js +177 -0
- package/dist/host-access.js.map +1 -0
- package/dist/index.d.ts +20 -5
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +27 -1
- package/dist/mcp/server.js +308 -109
- package/dist/mcp/server.js.map +1 -1
- package/dist/op-result.d.ts +9 -0
- package/dist/op-result.js +13 -1
- package/dist/op-result.js.map +1 -1
- package/dist/read/filter-contract.d.ts +1 -1
- package/dist/read/filter-contract.js +4 -0
- package/dist/read/filter-contract.js.map +1 -1
- package/dist/read/shape.js +4 -0
- package/dist/read/shape.js.map +1 -1
- package/dist/read/views.d.ts +29 -0
- package/dist/read/views.js +60 -0
- package/dist/read/views.js.map +1 -1
- package/dist/session-grant.d.ts +48 -0
- package/dist/session-grant.js +185 -0
- package/dist/session-grant.js.map +1 -0
- package/dist/surface-copy.d.ts +19 -3
- package/dist/surface-copy.js +19 -3
- package/dist/surface-copy.js.map +1 -1
- package/dist/wizard.d.ts +58 -0
- package/dist/wizard.js +174 -0
- package/dist/wizard.js.map +1 -0
- package/dist/write/availability.d.ts +1 -16
- package/dist/write/availability.js +10 -65
- package/dist/write/availability.js.map +1 -1
- package/dist/write/batch.d.ts +20 -0
- package/dist/write/batch.js +111 -21
- package/dist/write/batch.js.map +1 -1
- package/dist/write/capabilities.d.ts +9 -0
- package/dist/write/capabilities.js +2 -0
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/clear-reminder.js +1 -1
- package/dist/write/clear-reminder.js.map +1 -1
- package/dist/write/commands.d.ts +22 -7
- package/dist/write/commands.js +231 -254
- package/dist/write/commands.js.map +1 -1
- package/dist/write/failure-hints.d.ts +32 -7
- package/dist/write/failure-hints.js +9 -14
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.js +22 -4
- package/dist/write/guards.js.map +1 -1
- package/dist/write/lock.d.ts +16 -13
- package/dist/write/lock.js +55 -0
- package/dist/write/lock.js.map +1 -1
- package/dist/write/make-repeating-project.js +9 -13
- package/dist/write/make-repeating-project.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 +51 -29
- package/dist/write/operations.js +16 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +72 -14
- package/dist/write/opid.js +152 -8
- package/dist/write/opid.js.map +1 -1
- package/dist/write/param-schema.d.ts +80 -0
- package/dist/write/param-schema.js +754 -0
- package/dist/write/param-schema.js.map +1 -0
- package/dist/write/pipeline.d.ts +110 -5
- package/dist/write/pipeline.js +288 -14
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +38 -16
- package/dist/write/pre-state.js +117 -22
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.js +658 -444
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/reorder.js +25 -8
- package/dist/write/reorder.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/repeat-rule.d.ts +28 -1
- package/dist/write/repeat-rule.js +54 -4
- package/dist/write/repeat-rule.js.map +1 -1
- package/dist/write/resolution-timestamps.d.ts +11 -0
- package/dist/write/resolution-timestamps.js +108 -40
- package/dist/write/resolution-timestamps.js.map +1 -1
- package/dist/write/reversibility.js +4 -0
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/template-mutation.d.ts +137 -0
- package/dist/write/template-mutation.js +598 -0
- package/dist/write/template-mutation.js.map +1 -0
- package/dist/write/undo.js +10 -20
- package/dist/write/undo.js.map +1 -1
- package/dist/write/update-fields.d.ts +151 -0
- package/dist/write/update-fields.js +427 -0
- package/dist/write/update-fields.js.map +1 -0
- package/dist/write/vectors/applescript.js +6 -10
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +1 -1
- package/dist/write/vectors/registry.js +10 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/shortcuts.js +2 -2
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.js +15 -4
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +268 -1
- 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 +70 -3
- package/dist/write/vectors/ui-recipes.js +482 -44
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +280 -10
- package/dist/write/vectors/ui.js +1068 -77
- 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 +62 -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 +2 -1
- package/schema/envelope.schema.json +6 -4
- package/scripts/build-helpers.sh +5 -0
- package/skills/things-cli/SKILL.md +5 -3
- package/skills/things-cli/references/contracts.md +1 -0
- package/skills/things-cli/references/data-model.md +3 -0
- package/skills/things-cli/references/errors.md +1 -1
- package/skills/things-cli/references/gui.md +1 -0
- package/skills/things-cli/references/repeating.md +6 -0
- package/skills/things-cli/references/tag-cleanup.md +64 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { undoToken } from "../audit/schema.js";
|
|
2
|
+
import { uiAllowed, uiCapability as uiCapabilityDefault } from "../capability.js";
|
|
2
3
|
import { addDaysIso, decodePackedDate, localToday } from "../model/dates.js";
|
|
3
4
|
import { byUuid } from "../read/detail.js";
|
|
4
5
|
import { resolveProjectWriteTarget, resolveTaskUuidPrefix } from "../read/queries.js";
|
|
5
6
|
import { runCloneProject, runCloneTodo } from "./clone.js";
|
|
6
7
|
import { promoteProjectViaGui } from "./make-repeating-project.js";
|
|
7
|
-
import {
|
|
8
|
+
import { replayIfApplied } from "./opid.js";
|
|
9
|
+
import { fingerprintLabel, runComposite, runMutation, } from "./pipeline.js";
|
|
8
10
|
import { assessOffRuleFirst, daysBetweenIso, deriveFixedAnchor, isIsoDate, } from "./repeat-anchor.js";
|
|
9
|
-
import { assertRepeatRule, ruleToInverseParams } from "./repeat-rule.js";
|
|
10
|
-
import { createDbReader } from "./verify/delta.js";
|
|
11
|
+
import { assertRepeatRule, ruleToInverseParams, splitAddRepeatingRule } from "./repeat-rule.js";
|
|
12
|
+
import { createDbReader, } from "./verify/delta.js";
|
|
11
13
|
import { H_UI_SESSION_UNREACHABLE } from "./vectors/session-reachability.js";
|
|
12
14
|
const PLACEMENT_NOTE = "the series' current instance lands at its container's default position — its prior slot was " +
|
|
13
15
|
"not automatically restored (best-effort placement is not yet wired for this container); " +
|
|
@@ -50,22 +52,51 @@ function blockedUiDrive(op) {
|
|
|
50
52
|
detail: "this operation promotes an item to a repeating series by driving the local Things app " +
|
|
51
53
|
"through the Accessibility API (the Repeat… dialog) — it may briefly interact with the UI",
|
|
52
54
|
remediation: "pass dangerouslyDriveGui (--dangerously-drive-gui) to proceed; the vector also requires " +
|
|
53
|
-
"`things config set ui-enabled true` and
|
|
55
|
+
"`things config set ui-enabled true` and `things helpers setup --gui`, which grants " +
|
|
56
|
+
"GUI-driving to the helper pair — the only identity it is granted to " +
|
|
57
|
+
"(docs/design/permissions-doctrine.md, Article IV)",
|
|
54
58
|
};
|
|
55
59
|
}
|
|
56
60
|
/**
|
|
57
|
-
* SESSGATE
|
|
58
|
-
* it SEEDS a row (clone / add) before the GUI promote leg drives the
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
61
|
+
* The pre-seed UI PREFLIGHT (SESSGATE #480 + issue #512). A promote composite is
|
|
62
|
+
* NOT atomic: it SEEDS a row (clone / add) before the GUI promote leg drives the
|
|
63
|
+
* dialog. Anything that will certainly stop that drive must be established BEFORE
|
|
64
|
+
* the seed exists, or the composite mutates, fails, and cleans up after itself for
|
|
65
|
+
* a reason it could have known up front. Two checks, cheapest and most decisive
|
|
66
|
+
* first, both prompt-free:
|
|
67
|
+
*
|
|
68
|
+
* 1. STANDING (permissions doctrine, Article IV). Driving the window needs
|
|
69
|
+
* Accessibility + Automation → System Events, held by the helper pair and
|
|
70
|
+
* nothing else. The pipeline already gates this — but only on the PROMOTE leg,
|
|
71
|
+
* which runs after the seed, so a machine without the grants used to create
|
|
72
|
+
* the row, refuse, and trash it. Same verdict, same copy, one leg earlier;
|
|
73
|
+
* nothing is created. Keyed on a vector's `drivesGui` DECLARATION exactly as
|
|
74
|
+
* the pipeline's gate is, never on its id, so a fake/simulator substituted
|
|
75
|
+
* under "ui" is not gated on the developer's own host state.
|
|
76
|
+
* 2. REACHABILITY (SESSGATE). If the Mac's session is AX-blind (screen locked /
|
|
77
|
+
* full-screen Space), the dialog would open on an unreachable window. Only
|
|
78
|
+
* "session" scope refuses here: a window merely on another Space is left for
|
|
79
|
+
* the in-drive relocation (the reveal has not run yet, so refusing before the
|
|
80
|
+
* seed would be a false positive). A fail-open probe proceeds — the promote
|
|
81
|
+
* leg's own gate + the seed cleanup remain the backstop.
|
|
82
|
+
*
|
|
83
|
+
* Running the standing check FIRST also keeps the probe itself doctrine-clean: no
|
|
84
|
+
* System Events call is attempted on a host that has not granted one.
|
|
67
85
|
*/
|
|
68
|
-
async function
|
|
86
|
+
async function gateUiPreflight(deps, op) {
|
|
87
|
+
const gui = deps.vectors.find((v) => v.drivesGui === true && v.simulates !== true);
|
|
88
|
+
if (gui !== undefined) {
|
|
89
|
+
const capability = (deps.uiCapability ?? (() => uiCapabilityDefault()))();
|
|
90
|
+
if (!uiAllowed(capability)) {
|
|
91
|
+
return {
|
|
92
|
+
kind: "blocked",
|
|
93
|
+
op,
|
|
94
|
+
reason: "environment",
|
|
95
|
+
detail: `this operation drives the Things window, and ${capability.detail} — nothing was created`,
|
|
96
|
+
remediation: capability.remediation.join("; "),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
69
100
|
if (!deps.config.ui.enabled)
|
|
70
101
|
return null;
|
|
71
102
|
const ui = deps.vectors.find((v) => v.probeReachability !== undefined);
|
|
@@ -117,35 +148,23 @@ async function cleanupSeed(deps, kind, createdUuid, promote, options, txnId) {
|
|
|
117
148
|
*
|
|
118
149
|
* The `rule` bag is the SUPERSET the make/add legs actually carry: make-repeating
|
|
119
150
|
* passes a full {@link RepeatRuleParams} (rule-level `deadline`/`startDaysEarlier`
|
|
120
|
-
* included), add-repeating an
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
151
|
+
* and the requested `next` included), add-repeating an
|
|
152
|
+
* {@link AddRepeatingRuleFields} plus whichever of the deadline pair its geometry
|
|
153
|
+
* folded in. Every field flows through by SPREAD, never by a hand-copied field
|
|
154
|
+
* list — a list here is how the make-repeating promote once stripped
|
|
155
|
+
* `deadline`/`startDaysEarlier` (a deadlined make-repeating produced a
|
|
156
|
+
* NON-deadlined template, YANCH1 #493) and how the project promote later dropped
|
|
157
|
+
* `next` (#549). The ONE field that is deliberately re-derived rather than copied
|
|
158
|
+
* is `next`: the drive date is deadline-shifted by the caller, and an
|
|
159
|
+
* after-completion dialog has no first-occurrence field to drive at all.
|
|
126
160
|
*/
|
|
127
161
|
function ruleParamsFor(uuid, rule, nextIso) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
...(rule.monthly !== undefined && { monthly: rule.monthly }),
|
|
135
|
-
...(rule.yearly !== undefined && { yearly: rule.yearly }),
|
|
136
|
-
...(rule.ends !== undefined && { ends: rule.ends }),
|
|
137
|
-
// The repeat reminder picker is drivable (ANCH2). make-repeating carries a
|
|
138
|
-
// rule-level reminder through; add-repeating threads the base to-do's
|
|
139
|
-
// --reminder here too (ADR1 #480), since the dialog conversion otherwise drops
|
|
140
|
-
// the seed's one-off reminder from the series.
|
|
141
|
-
...(rule.reminder !== undefined && { reminder: rule.reminder }),
|
|
142
|
-
// The deadline offset is a rule-level field on make-repeating (the "Add
|
|
143
|
-
// deadlines" checkbox + "start N days earlier"); it must ride the promote or a
|
|
144
|
-
// `make-repeating --deadline` lands a non-deadlined series (YANCH1 #493).
|
|
145
|
-
...(rule.deadline !== undefined && { deadline: rule.deadline }),
|
|
146
|
-
...(rule.startDaysEarlier !== undefined && { startDaysEarlier: rule.startDaysEarlier }),
|
|
147
|
-
...(nextIso !== undefined && rule.afterCompletion !== true && { next: nextIso }),
|
|
148
|
-
};
|
|
162
|
+
const out = { ...rule, uuid };
|
|
163
|
+
// Never the caller's raw `next` — see above; the drive date arrives as `nextIso`.
|
|
164
|
+
delete out.next;
|
|
165
|
+
if (nextIso !== undefined && rule.afterCompletion !== true)
|
|
166
|
+
out.next = nextIso;
|
|
167
|
+
return out;
|
|
149
168
|
}
|
|
150
169
|
const UNIT_SINGULAR = {
|
|
151
170
|
daily: "day",
|
|
@@ -214,6 +233,32 @@ function instanceStartDate(db, instanceUuid) {
|
|
|
214
233
|
return null;
|
|
215
234
|
return decodePackedDate(row.s);
|
|
216
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* The oracle for the post-drive first-occurrence check, picked by rule KIND
|
|
238
|
+
* (issue #508).
|
|
239
|
+
*
|
|
240
|
+
* A FIXED-schedule series is anchored on the calendar: the dialog's "Next:" field
|
|
241
|
+
* IS driven, and the template's own cursor (`rt1_instanceCreationStartDate`) holds
|
|
242
|
+
* the resulting first occurrence — the right thing to compare against.
|
|
243
|
+
*
|
|
244
|
+
* An AFTER-COMPLETION series has NO calendar anchor, so {@link ruleParamsFor}
|
|
245
|
+
* deliberately leaves "Next:" alone, and the app mints the template with an EMPTY
|
|
246
|
+
* cursor (RSIM2 / RSIM-P P4: no next / reference dates exist until a completion
|
|
247
|
+
* happens). Comparing that empty cursor against `--when` reported a FALSE
|
|
248
|
+
* `verify-failed:mismatch` on a perfectly correct creation (#508, reproduced 6/6
|
|
249
|
+
* on the live host). The requested date lives on the materialized INSTANCE's own
|
|
250
|
+
* `startDate` — the row the promote preserved and relinked — so that is the oracle
|
|
251
|
+
* for an after-completion series.
|
|
252
|
+
*
|
|
253
|
+
* Returns `null` when no oracle is reachable. For an after-completion rule that
|
|
254
|
+
* means "unverifiable" and the caller SKIPS the check (the create delta already
|
|
255
|
+
* proved the series landed); for a fixed rule an absent cursor is a genuine miss.
|
|
256
|
+
*/
|
|
257
|
+
function landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion) {
|
|
258
|
+
if (!afterCompletion)
|
|
259
|
+
return firstOccurrenceOf(deps.db, templateUuid);
|
|
260
|
+
return instanceUuid === null ? null : instanceStartDate(deps.db, instanceUuid);
|
|
261
|
+
}
|
|
217
262
|
/**
|
|
218
263
|
* DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md, golden-v3 / Things 3.22.14):
|
|
219
264
|
* a promote whose source is PRESERVED (SRCFATE deadline / terminal-element trigger)
|
|
@@ -234,7 +279,16 @@ function instanceStartDate(db, instanceUuid) {
|
|
|
234
279
|
* promote did not preserve a future instance. Best-effort: a failed trash is reported
|
|
235
280
|
* in the warning rather than failing the whole compound (the series is already sound).
|
|
236
281
|
*/
|
|
237
|
-
async function trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now) {
|
|
282
|
+
async function trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now, afterCompletion) {
|
|
283
|
+
// AFTER-COMPLETION series are exempt: the double-book is a CURSOR phenomenon
|
|
284
|
+
// (`rt1_nextInstanceStartDate` pointing at an already-materialized occurrence),
|
|
285
|
+
// and an after-completion template is minted with NO cursor at all — the next
|
|
286
|
+
// occurrence is unknown until a completion happens (RSIM2 / RSIM-P P4). Its
|
|
287
|
+
// preserved instance is therefore the series' ONLY occurrence, whatever its
|
|
288
|
+
// date; trashing it would destroy the series' current occurrence rather than a
|
|
289
|
+
// duplicate. (Unreachable before #508, whose false verify-failed returned first.)
|
|
290
|
+
if (afterCompletion)
|
|
291
|
+
return null;
|
|
238
292
|
const rep = promote.repeating;
|
|
239
293
|
// Preserved iff the native promote relinked the source (replacedUuid === null) AND
|
|
240
294
|
// there is a materialized instance. A DELETE-fate promote reports replacedUuid !==
|
|
@@ -287,6 +341,36 @@ function discoveryOf(promote) {
|
|
|
287
341
|
};
|
|
288
342
|
}
|
|
289
343
|
// -------------------------------------------------------------- audit summary
|
|
344
|
+
/**
|
|
345
|
+
* The promote compound's presence oracle: "a repeating TEMPLATE with this title,
|
|
346
|
+
* created since this call started, exists". It is the same create-probe the
|
|
347
|
+
* compound's own dry-run plan states, TIME-BOUNDED to the call — the app mints a
|
|
348
|
+
* template with a fresh write-time creationDate even when the promoted row was
|
|
349
|
+
* created earlier (`--preserve-created`), so the bound is safe and it keeps an
|
|
350
|
+
* older namesake series from reading as this call's work. Recorded on an
|
|
351
|
+
* AMBIGUOUS summary so a resubmission can settle it (see {@link appendPromoteSummary}).
|
|
352
|
+
*/
|
|
353
|
+
function promotePresenceDelta(title, type, startedAt) {
|
|
354
|
+
return {
|
|
355
|
+
mode: "create",
|
|
356
|
+
probe: { title, type, sinceEpoch: Math.floor(startedAt.getTime() / 1000) },
|
|
357
|
+
assert: [{ field: "repeating.isTemplate", equals: true }],
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Append the promote compound's SUMMARY record — the ONE record that stands for
|
|
362
|
+
* the whole verb (its legs are `leg`-role records, excluded from direct undo),
|
|
363
|
+
* and the one record the caller's `opId` rides, so a resubmission matches the
|
|
364
|
+
* whole promote exactly once instead of matching a leg or nothing at all.
|
|
365
|
+
*
|
|
366
|
+
* Two shapes, one record either way:
|
|
367
|
+
* - the SUCCESS summary (default) — result `ok`, the single undoable unit;
|
|
368
|
+
* - an AMBIGUOUS summary (`ambiguous`) — result `verify-failed:timeout` with the
|
|
369
|
+
* presence oracle attached, written when a leg dispatched and never confirmed.
|
|
370
|
+
* It is not an undo target (undo reads `ok` records only); it exists so a
|
|
371
|
+
* resubmission carrying the same key re-reads state and decides instead of
|
|
372
|
+
* minting a second series.
|
|
373
|
+
*/
|
|
290
374
|
function appendPromoteSummary(deps, args) {
|
|
291
375
|
const fp = deps.fingerprint();
|
|
292
376
|
const observed = {
|
|
@@ -297,7 +381,7 @@ function appendPromoteSummary(deps, args) {
|
|
|
297
381
|
const record = {
|
|
298
382
|
v: 1,
|
|
299
383
|
ts: args.startedAt.toISOString(),
|
|
300
|
-
actor: deps.config.actor,
|
|
384
|
+
actor: args.options?.actor ?? deps.config.actor,
|
|
301
385
|
host: deps.config.host,
|
|
302
386
|
op: args.op,
|
|
303
387
|
uuid: args.templateUuid,
|
|
@@ -306,9 +390,11 @@ function appendPromoteSummary(deps, args) {
|
|
|
306
390
|
invocation: args.invocation,
|
|
307
391
|
requested: args.requested,
|
|
308
392
|
txn: { id: args.txnId, role: "summary" },
|
|
393
|
+
...(args.options?.opId !== undefined && { opId: args.options.opId }),
|
|
394
|
+
...(args.ambiguous !== undefined && { expected: args.ambiguous }),
|
|
309
395
|
pre: null,
|
|
310
|
-
observed,
|
|
311
|
-
result: "ok",
|
|
396
|
+
observed: args.ambiguous === undefined ? observed : null,
|
|
397
|
+
result: args.ambiguous === undefined ? "ok" : "verify-failed:timeout",
|
|
312
398
|
...(args.preModDates !== undefined && { preModDates: args.preModDates }),
|
|
313
399
|
verify: null,
|
|
314
400
|
durationMs: (deps.now?.() ?? new Date()).getTime() - args.startedAt.getTime(),
|
|
@@ -320,6 +406,31 @@ function appendPromoteSummary(deps, args) {
|
|
|
320
406
|
};
|
|
321
407
|
deps.audit.append(record);
|
|
322
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* Record the AMBIGUOUS summary when a promote compound's own outcome is a
|
|
411
|
+
* timeout — the one verdict where the series may or may not exist. Returns the
|
|
412
|
+
* outcome unchanged, so it wraps a composite body without re-shaping it. Only a
|
|
413
|
+
* keyed call records one: without an `opId` there is nothing to reconcile
|
|
414
|
+
* against later, and the leg records already carry the failure.
|
|
415
|
+
*/
|
|
416
|
+
function recordAmbiguousPromote(deps, outcome, args) {
|
|
417
|
+
if (args.options.opId === undefined)
|
|
418
|
+
return outcome;
|
|
419
|
+
if (outcome.kind !== "verify-failed" || outcome.reason !== "timeout")
|
|
420
|
+
return outcome;
|
|
421
|
+
appendPromoteSummary(deps, {
|
|
422
|
+
startedAt: args.startedAt,
|
|
423
|
+
op: args.op,
|
|
424
|
+
txnId: args.txnId,
|
|
425
|
+
templateUuid: null,
|
|
426
|
+
instanceUuid: null,
|
|
427
|
+
invocation: args.invocation,
|
|
428
|
+
requested: args.requested,
|
|
429
|
+
options: args.options,
|
|
430
|
+
ambiguous: promotePresenceDelta(args.title, args.type, args.startedAt),
|
|
431
|
+
});
|
|
432
|
+
return outcome;
|
|
433
|
+
}
|
|
323
434
|
/** Build the ok result for a promote (make/add-repeating). */
|
|
324
435
|
function promoteOk(args) {
|
|
325
436
|
const repeating = {
|
|
@@ -356,6 +467,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
356
467
|
const op = kind === "project" ? "project.make-repeating" : "todo.make-repeating";
|
|
357
468
|
// Validate the rule BEFORE anything (a bad rule must never mint a clone).
|
|
358
469
|
assertRepeatRule(params);
|
|
470
|
+
// Idempotency FIRST, before the target is even resolved: a successful promote
|
|
471
|
+
// moves the original to the Trash and hands back a different uuid, so a
|
|
472
|
+
// resubmission of the same key must be answered from the trail rather than
|
|
473
|
+
// re-driven against a source that is no longer the item it names. The key
|
|
474
|
+
// rides the compound's single summary record, so a match is the whole verb.
|
|
475
|
+
const replay = replayIfApplied(deps, options);
|
|
476
|
+
if (replay !== null)
|
|
477
|
+
return replay;
|
|
359
478
|
const now = deps.now?.() ?? new Date();
|
|
360
479
|
const srcUuid = kind === "project"
|
|
361
480
|
? resolveProjectWriteTarget(deps.db, params.uuid)
|
|
@@ -374,6 +493,9 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
374
493
|
: "verify the uuid with `things show <uuid>`, or use `things project make-repeating` for a project",
|
|
375
494
|
};
|
|
376
495
|
}
|
|
496
|
+
// Bound after the existence guard so the composite body (a hoisted function,
|
|
497
|
+
// where TypeScript cannot carry the narrowing) still sees a plain string.
|
|
498
|
+
const srcTitle = src.title;
|
|
377
499
|
// ANCH2 (issue #476): the app's Repeat dialog HAS a "Next:" first-occurrence
|
|
378
500
|
// field; its default is the today-anchored next match, but it is editable and
|
|
379
501
|
// honored (docs/lab/anch2-next-field.md). Drive it with the requested first
|
|
@@ -396,10 +518,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
396
518
|
? (params.startDaysEarlier ?? 0)
|
|
397
519
|
: 0;
|
|
398
520
|
const driveIso = isIsoDate(whenIso) ? addDaysIso(whenIso, deadlineShift) : undefined;
|
|
399
|
-
// The
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
521
|
+
// The ANCHOR is derived from the deadline-adjusted date (the anchor names the
|
|
522
|
+
// DUE date), but `next` is passed through as the requested START: the promote
|
|
523
|
+
// leg's own compile applies the "Next:" shift, and it is the ONLY place that
|
|
524
|
+
// does (NEXTPOP1 — shifting here as well made every downstream consumer that
|
|
525
|
+
// shifts, `assessOffRuleFirst` among them, shift a second time). The verify
|
|
526
|
+
// below expects the START to land on the requested `--when` either way.
|
|
527
|
+
const nextIso = isIsoDate(whenIso) ? whenIso : undefined;
|
|
528
|
+
const expectedStartIso = nextIso;
|
|
403
529
|
const effParams = { ...params, ...deriveFixedAnchor(params, driveIso) };
|
|
404
530
|
// The promote leg drives the GUI — block before minting a clone if the ack is missing.
|
|
405
531
|
if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
|
|
@@ -424,142 +550,168 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
424
550
|
},
|
|
425
551
|
};
|
|
426
552
|
}
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
553
|
+
// PRE-SEED UI PREFLIGHT (#480/#512): refuse a host that cannot drive the window,
|
|
554
|
+
// or a locked / full-screen session, BEFORE minting a clone — otherwise the
|
|
555
|
+
// promote's dialog never opens and the whole compound fails, stranding a
|
|
556
|
+
// disposable clone. Zero mutation on refusal.
|
|
557
|
+
const gate = await gateUiPreflight(deps, op);
|
|
431
558
|
if (gate !== null)
|
|
432
559
|
return gate;
|
|
433
560
|
const startedAt = now;
|
|
434
561
|
const txnId = newTxnId(startedAt);
|
|
435
|
-
//
|
|
436
|
-
// clone
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
op,
|
|
447
|
-
reason: "mismatch",
|
|
448
|
-
expected: {
|
|
449
|
-
mode: "create",
|
|
450
|
-
probe: { title: src.title, type: expectedType, sinceEpoch: 0 },
|
|
451
|
-
assert: [],
|
|
452
|
-
},
|
|
453
|
-
observed: null,
|
|
454
|
-
detail: "the disposable clone was created but its uuid was not discovered — nothing was promoted or trashed",
|
|
455
|
-
}
|
|
456
|
-
: { ...clone, op };
|
|
457
|
-
}
|
|
458
|
-
const cloneUuid = clone.uuid;
|
|
459
|
-
// --preserve-modified: X is the ONLY pre-existing row the compound touches (the
|
|
460
|
-
// clone/promote legs mint fresh rows). Capture its pre-write umd BEFORE the
|
|
461
|
-
// trash bumps it — the trash leg restores it forward, and the value rides the
|
|
462
|
-
// summary record's preModDates so the symmetric undo restore fires on the
|
|
463
|
-
// revived X (undo.ts, 2026-08-13 ruling). The clone leg above reads X but never
|
|
464
|
-
// writes it, so its umd is still pristine here.
|
|
465
|
-
const preserveModified = options.preserveModified === true;
|
|
466
|
-
const preUmd = preserveModified
|
|
467
|
-
? createDbReader(deps.db, now, deps.zone).modDateOf(srcUuid)
|
|
468
|
-
: null;
|
|
469
|
-
// 2. Trash the original BEFORE promoting — the clone already holds X's content,
|
|
470
|
-
// and a live same-titled X would make the promote's project row-selection
|
|
471
|
-
// ambiguous (H-PROJECT-REPEAT). X survives in the Trash (the recoverable half).
|
|
472
|
-
const trash = await runMutation(deps, `${kind}.delete`, { uuid: srcUuid }, legOptions(options, txnId, undefined, preserveModified ? { preserveModified: true } : undefined));
|
|
473
|
-
if (trash.kind !== "ok") {
|
|
474
|
-
return {
|
|
475
|
-
...trash,
|
|
562
|
+
// COMPOSITE LOCK: everything below is ONE verb executed as several mutations
|
|
563
|
+
// (clone → trash → promote → the DBLSPAWN1 clean-up), and they must not
|
|
564
|
+
// interleave with another writer's legs — the promote's row selection is by
|
|
565
|
+
// TITLE, so a concurrent clone of the same item makes it ambiguous. One lock,
|
|
566
|
+
// held to the end; each leg's own acquisition is a reentrant no-op.
|
|
567
|
+
return runComposite(deps, op, async () => {
|
|
568
|
+
const outcome = await promoteBody();
|
|
569
|
+
// An unconfirmed outcome gets the compound's AMBIGUOUS summary, so a
|
|
570
|
+
// resubmission of the same key reconciles instead of minting a second series.
|
|
571
|
+
return recordAmbiguousPromote(deps, outcome, {
|
|
572
|
+
startedAt,
|
|
476
573
|
op,
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const rolledBack = await runMutation(deps, restoreOp, { uuid: srcUuid }, legOptions(options, txnId));
|
|
496
|
-
const rollNote = rolledBack.kind === "ok"
|
|
497
|
-
? `the original ${srcUuid} was restored from the Trash`
|
|
498
|
-
: `the original ${srcUuid} could NOT be restored from the Trash — restore it in the app`;
|
|
499
|
-
return {
|
|
500
|
-
...promote,
|
|
501
|
-
op,
|
|
502
|
-
...("detail" in promote
|
|
574
|
+
txnId,
|
|
575
|
+
title: srcTitle,
|
|
576
|
+
type: expectedType,
|
|
577
|
+
invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote (unconfirmed)`,
|
|
578
|
+
requested: effParams,
|
|
579
|
+
options,
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
async function promoteBody() {
|
|
583
|
+
// 1. Clone the source as a disposable, embedded leg (--preserve-created). The
|
|
584
|
+
// clone has captured X's full content by the time it returns.
|
|
585
|
+
const clone = kind === "project"
|
|
586
|
+
? await runCloneProject(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId))
|
|
587
|
+
: await runCloneTodo(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId));
|
|
588
|
+
if (clone.kind !== "ok" || clone.uuid === null) {
|
|
589
|
+
// A clone refusal (nested repeating template, H-CLONE-SOURCE) surfaces
|
|
590
|
+
// coherently here — re-label it to the make-repeating op for the caller.
|
|
591
|
+
return clone.kind === "ok"
|
|
503
592
|
? {
|
|
504
|
-
|
|
505
|
-
|
|
593
|
+
kind: "verify-failed",
|
|
594
|
+
op,
|
|
595
|
+
reason: "mismatch",
|
|
596
|
+
expected: {
|
|
597
|
+
mode: "create",
|
|
598
|
+
probe: { title: srcTitle, type: expectedType, sinceEpoch: 0 },
|
|
599
|
+
assert: [],
|
|
600
|
+
},
|
|
601
|
+
observed: null,
|
|
602
|
+
detail: "the disposable clone was created but its uuid was not discovered — nothing was promoted or trashed",
|
|
506
603
|
}
|
|
507
|
-
: {}
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
const { templateUuid } = discoveryOf(promote);
|
|
511
|
-
let { instanceUuid } = discoveryOf(promote);
|
|
512
|
-
// Post-drive verify (ANCH2 + YANCH1): the instance START must have landed on the
|
|
513
|
-
// requested `--when` — for a deadlined rule the driven Next is the deadline
|
|
514
|
-
// (when + startDaysEarlier) and the app back-shifts the start to `--when`, so the
|
|
515
|
-
// check is against `expectedStartIso`, not the raw drive date. Fail closed on
|
|
516
|
-
// mismatch rather than report a wrong-phase ok.
|
|
517
|
-
if (expectedStartIso !== undefined) {
|
|
518
|
-
const landed = firstOccurrenceOf(deps.db, templateUuid);
|
|
519
|
-
if (landed !== expectedStartIso) {
|
|
520
|
-
return nextMismatch(op, templateUuid, expectedStartIso, landed);
|
|
604
|
+
: { ...clone, op };
|
|
521
605
|
}
|
|
606
|
+
const cloneUuid = clone.uuid;
|
|
607
|
+
// --preserve-modified: X is the ONLY pre-existing row the compound touches (the
|
|
608
|
+
// clone/promote legs mint fresh rows). Capture its pre-write umd BEFORE the
|
|
609
|
+
// trash bumps it — the trash leg restores it forward, and the value rides the
|
|
610
|
+
// summary record's preModDates so the symmetric undo restore fires on the
|
|
611
|
+
// revived X (undo.ts, 2026-08-13 ruling). The clone leg above reads X but never
|
|
612
|
+
// writes it, so its umd is still pristine here.
|
|
613
|
+
const preserveModified = options.preserveModified === true;
|
|
614
|
+
const preUmd = preserveModified
|
|
615
|
+
? createDbReader(deps.db, now, deps.zone).modDateOf(srcUuid)
|
|
616
|
+
: null;
|
|
617
|
+
// 2. Trash the original BEFORE promoting — the clone already holds X's content,
|
|
618
|
+
// and a live same-titled X would make the promote's project row-selection
|
|
619
|
+
// ambiguous (H-PROJECT-REPEAT). X survives in the Trash (the recoverable half).
|
|
620
|
+
const trash = await runMutation(deps, `${kind}.delete`, { uuid: srcUuid }, legOptions(options, txnId, undefined, preserveModified ? { preserveModified: true } : undefined));
|
|
621
|
+
if (trash.kind !== "ok") {
|
|
622
|
+
return {
|
|
623
|
+
...trash,
|
|
624
|
+
op,
|
|
625
|
+
...("detail" in trash
|
|
626
|
+
? {
|
|
627
|
+
detail: `${trash.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
|
|
628
|
+
`original ${srcUuid} could not be moved to the Trash, so it was NOT promoted; trash ` +
|
|
629
|
+
"the clone and retry",
|
|
630
|
+
}
|
|
631
|
+
: {}),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
// 3. Native-promote the clone (with the ANCH2 Next drive + derived weekday).
|
|
635
|
+
const rule = ruleParamsFor(cloneUuid, effParams, nextIso);
|
|
636
|
+
const promote = kind === "project"
|
|
637
|
+
? await promoteProjectViaGui(deps, rule, legOptions(options, txnId, "ui"))
|
|
638
|
+
: await runMutation(deps, "todo.make-repeating", rule, legOptions(options, txnId, "ui"));
|
|
639
|
+
if (promote.kind !== "ok") {
|
|
640
|
+
// The clone persists but was not promoted; best-effort ROLL BACK the trash so
|
|
641
|
+
// the original is not stranded in the Trash.
|
|
642
|
+
const restoreOp = kind === "project" ? "project.restore" : "todo.restore";
|
|
643
|
+
const rolledBack = await runMutation(deps, restoreOp, { uuid: srcUuid }, legOptions(options, txnId));
|
|
644
|
+
const rollNote = rolledBack.kind === "ok"
|
|
645
|
+
? `the original ${srcUuid} was restored from the Trash`
|
|
646
|
+
: `the original ${srcUuid} could NOT be restored from the Trash — restore it in the app`;
|
|
647
|
+
return {
|
|
648
|
+
...promote,
|
|
649
|
+
op,
|
|
650
|
+
...("detail" in promote
|
|
651
|
+
? {
|
|
652
|
+
detail: `${promote.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
|
|
653
|
+
`promote did not land; ${rollNote}. Trash the clone and retry`,
|
|
654
|
+
}
|
|
655
|
+
: {}),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
const { templateUuid } = discoveryOf(promote);
|
|
659
|
+
let { instanceUuid } = discoveryOf(promote);
|
|
660
|
+
// Post-drive verify (ANCH2 + YANCH1): the instance START must have landed on the
|
|
661
|
+
// requested `--when` — for a deadlined rule the driven Next is the deadline
|
|
662
|
+
// (when + startDaysEarlier) and the app back-shifts the start to `--when`, so the
|
|
663
|
+
// check is against `expectedStartIso`, not the raw drive date. Fail closed on
|
|
664
|
+
// mismatch rather than report a wrong-phase ok. The ORACLE is rule-kind dependent
|
|
665
|
+
// (#508) — see landedFirstStart; an unverifiable after-completion series skips.
|
|
666
|
+
const afterCompletion = effParams.afterCompletion === true;
|
|
667
|
+
if (expectedStartIso !== undefined) {
|
|
668
|
+
const landed = landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion);
|
|
669
|
+
if (!(afterCompletion && landed === null) && landed !== expectedStartIso) {
|
|
670
|
+
return nextMismatch(op, templateUuid, expectedStartIso, landed);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const warnings = [
|
|
674
|
+
landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
|
|
675
|
+
`the original ${expectedType} (uuid ${srcUuid}) was moved to the Trash; \`things undo\` ` +
|
|
676
|
+
"removes the new series (trash-both) and restores it",
|
|
677
|
+
PLACEMENT_NOTE,
|
|
678
|
+
];
|
|
679
|
+
// DBLSPAWN1: if the promote PRESERVED the source (deadline / terminal-element
|
|
680
|
+
// trigger) as a FUTURE-dated instance, the app would spawn a duplicate on that date
|
|
681
|
+
// — trash the redundant occurrence and disclose (cursor mints the single real one).
|
|
682
|
+
const dbl = await trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now, afterCompletion);
|
|
683
|
+
if (dbl !== null) {
|
|
684
|
+
warnings.push(dbl.warning);
|
|
685
|
+
if (instanceUuid === dbl.trashedUuid)
|
|
686
|
+
instanceUuid = null;
|
|
687
|
+
}
|
|
688
|
+
const offRule = offRuleFirstNote(effParams);
|
|
689
|
+
if (offRule !== null)
|
|
690
|
+
warnings.push(offRule);
|
|
691
|
+
if (promote.warnings !== undefined)
|
|
692
|
+
warnings.push(...promote.warnings);
|
|
693
|
+
appendPromoteSummary(deps, {
|
|
694
|
+
startedAt,
|
|
695
|
+
op,
|
|
696
|
+
txnId,
|
|
697
|
+
templateUuid,
|
|
698
|
+
instanceUuid,
|
|
699
|
+
originalUuid: srcUuid,
|
|
700
|
+
invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
|
|
701
|
+
requested: effParams,
|
|
702
|
+
options,
|
|
703
|
+
...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
|
|
704
|
+
});
|
|
705
|
+
return promoteOk({
|
|
706
|
+
op,
|
|
707
|
+
templateUuid,
|
|
708
|
+
instanceUuid,
|
|
709
|
+
replacedUuid: cloneUuid,
|
|
710
|
+
title: srcTitle,
|
|
711
|
+
txnId,
|
|
712
|
+
warnings,
|
|
713
|
+
});
|
|
522
714
|
}
|
|
523
|
-
const warnings = [
|
|
524
|
-
landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
|
|
525
|
-
`the original ${expectedType} (uuid ${srcUuid}) was moved to the Trash; \`things undo\` ` +
|
|
526
|
-
"removes the new series (trash-both) and restores it",
|
|
527
|
-
PLACEMENT_NOTE,
|
|
528
|
-
];
|
|
529
|
-
// DBLSPAWN1: if the promote PRESERVED the source (deadline / terminal-element
|
|
530
|
-
// trigger) as a FUTURE-dated instance, the app would spawn a duplicate on that date
|
|
531
|
-
// — trash the redundant occurrence and disclose (cursor mints the single real one).
|
|
532
|
-
const dbl = await trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now);
|
|
533
|
-
if (dbl !== null) {
|
|
534
|
-
warnings.push(dbl.warning);
|
|
535
|
-
if (instanceUuid === dbl.trashedUuid)
|
|
536
|
-
instanceUuid = null;
|
|
537
|
-
}
|
|
538
|
-
const offRule = offRuleFirstNote(effParams);
|
|
539
|
-
if (offRule !== null)
|
|
540
|
-
warnings.push(offRule);
|
|
541
|
-
if (promote.warnings !== undefined)
|
|
542
|
-
warnings.push(...promote.warnings);
|
|
543
|
-
appendPromoteSummary(deps, {
|
|
544
|
-
startedAt,
|
|
545
|
-
op,
|
|
546
|
-
txnId,
|
|
547
|
-
templateUuid,
|
|
548
|
-
instanceUuid,
|
|
549
|
-
originalUuid: srcUuid,
|
|
550
|
-
invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
|
|
551
|
-
requested: effParams,
|
|
552
|
-
...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
|
|
553
|
-
});
|
|
554
|
-
return promoteOk({
|
|
555
|
-
op,
|
|
556
|
-
templateUuid,
|
|
557
|
-
instanceUuid,
|
|
558
|
-
replacedUuid: cloneUuid,
|
|
559
|
-
title: src.title,
|
|
560
|
-
txnId,
|
|
561
|
-
warnings,
|
|
562
|
-
});
|
|
563
715
|
}
|
|
564
716
|
export function runMakeRepeatingTodo(deps, params, options = {}) {
|
|
565
717
|
return makeRepeatingViaClone(deps, "todo", params, options);
|
|
@@ -576,6 +728,12 @@ export function runMakeRepeatingProject(deps, params, options = {}) {
|
|
|
576
728
|
async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options) {
|
|
577
729
|
const op = kind === "project" ? "project.add-repeating" : "todo.add-repeating";
|
|
578
730
|
assertRepeatRule(rule);
|
|
731
|
+
// Idempotency FIRST: this verb CREATES, so a blind resubmission is a duplicate
|
|
732
|
+
// series. The key rides the compound's single summary record, so a match is the
|
|
733
|
+
// whole verb — the add leg and the promote leg together — not one of them.
|
|
734
|
+
const replay = replayIfApplied(deps, options);
|
|
735
|
+
if (replay !== null)
|
|
736
|
+
return replay;
|
|
579
737
|
// ANCH2 (issue #476): drive the Repeat dialog's "Next:" field with --when so the
|
|
580
738
|
// series starts on the requested date (the field's default is today-anchored but
|
|
581
739
|
// it is editable and honored). YANCH1 (issue #493): also DERIVE the calendar
|
|
@@ -595,8 +753,11 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
595
753
|
const whenIso = isIsoDate(addParams["when"]) ? addParams["when"] : null;
|
|
596
754
|
const deadlineShift = rule.deadline === true || (rule.startDaysEarlier ?? 0) > 0 ? (rule.startDaysEarlier ?? 0) : 0;
|
|
597
755
|
const driveIso = whenIso !== null ? addDaysIso(whenIso, deadlineShift) : null;
|
|
598
|
-
|
|
599
|
-
|
|
756
|
+
// The ANCHOR is derived from the deadline-adjusted date; `next` passes through
|
|
757
|
+
// as the requested START and the promote leg's compile applies the "Next:"
|
|
758
|
+
// shift — the one place that does (NEXTPOP1; see promoteViaCloneAndMakeRepeating).
|
|
759
|
+
const nextIso = whenIso ?? undefined;
|
|
760
|
+
const expectedStartIso = nextIso;
|
|
600
761
|
const effRule = {
|
|
601
762
|
...rule,
|
|
602
763
|
...deriveFixedAnchor(rule, driveIso),
|
|
@@ -636,165 +797,201 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
636
797
|
},
|
|
637
798
|
};
|
|
638
799
|
}
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
800
|
+
// PRE-SEED UI PREFLIGHT (#480/#512): refuse a host that cannot drive the window,
|
|
801
|
+
// or a locked / full-screen session, BEFORE seeding the row (the two legs are not
|
|
802
|
+
// atomic — a doomed promote would strand the seed). Zero mutation on refusal; a
|
|
803
|
+
// window merely on another Space is relocated in-drive.
|
|
804
|
+
const gate = await gateUiPreflight(deps, op);
|
|
643
805
|
if (gate !== null)
|
|
644
806
|
return gate;
|
|
645
807
|
const startedAt = deps.now?.() ?? new Date();
|
|
646
808
|
const txnId = newTxnId(startedAt);
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
809
|
+
// COMPOSITE LOCK: add → promote (→ the seed auto-trash / DBLSPAWN1 clean-up)
|
|
810
|
+
// is one verb, several mutations; hold one lock across all of them so a
|
|
811
|
+
// concurrent composite cannot land its own legs between ours.
|
|
812
|
+
return runComposite(deps, op, async () => {
|
|
813
|
+
const outcome = await addBody();
|
|
814
|
+
// An unconfirmed outcome gets the compound's AMBIGUOUS summary, so a
|
|
815
|
+
// resubmission of the same key reconciles instead of creating a second series.
|
|
816
|
+
return recordAmbiguousPromote(deps, outcome, {
|
|
817
|
+
startedAt,
|
|
818
|
+
op,
|
|
819
|
+
txnId,
|
|
820
|
+
title,
|
|
821
|
+
type: expectedType,
|
|
822
|
+
invocation: `${op}: add "${title}" → promote (unconfirmed)`,
|
|
823
|
+
requested: { title, ...effRule },
|
|
824
|
+
options,
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
async function addBody() {
|
|
828
|
+
// 1. Create the item (full add vocabulary) as an embedded leg.
|
|
829
|
+
const addOp = kind === "project" ? "project.add" : "todo.add";
|
|
830
|
+
const add = await runMutation(deps, addOp, addParams, legOptions(options, txnId, "url-scheme"));
|
|
831
|
+
if (add.kind !== "ok" || add.uuid === null) {
|
|
832
|
+
return add.kind === "ok"
|
|
833
|
+
? {
|
|
834
|
+
kind: "verify-failed",
|
|
835
|
+
op,
|
|
836
|
+
reason: "mismatch",
|
|
837
|
+
expected: {
|
|
838
|
+
mode: "create",
|
|
839
|
+
probe: { title, type: expectedType, sinceEpoch: 0 },
|
|
840
|
+
assert: [],
|
|
841
|
+
},
|
|
842
|
+
observed: null,
|
|
843
|
+
detail: `the ${expectedType} was created but its uuid was not discovered — it cannot be promoted to repeating`,
|
|
844
|
+
}
|
|
845
|
+
: { ...add, op };
|
|
846
|
+
}
|
|
847
|
+
const createdUuid = add.uuid;
|
|
848
|
+
// 2. Native-promote the fresh row (ANCH2 Next drive + derived weekday + the
|
|
849
|
+
// base reminder driven onto the series, ADR1).
|
|
850
|
+
const ruleParams = ruleParamsFor(createdUuid, effRuleWithReminder, nextIso);
|
|
851
|
+
const promote = kind === "project"
|
|
852
|
+
? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
|
|
853
|
+
: await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
|
|
854
|
+
if (promote.kind !== "ok") {
|
|
855
|
+
// The seed persists (the two legs are not atomic) but the promote did not
|
|
856
|
+
// land. RATIFIED RULING (2026-08-15, issue #480): auto-trash our OWN seed
|
|
857
|
+
// inside the txn — it is our artifact, recreatable verbatim from the command
|
|
858
|
+
// args, and the Trash is recoverable — then disclose it. If the auto-trash
|
|
859
|
+
// itself fails, the result carries the seed's REAL, resolvable uuid with a
|
|
860
|
+
// working `delete` remediation, so cleanup is never ambiguous (the #480
|
|
861
|
+
// second bug: a failed add-repeating left a residue whose reported uuid was
|
|
862
|
+
// not actionable).
|
|
863
|
+
const patch = await cleanupSeed(deps, kind, createdUuid, promote, options, txnId);
|
|
864
|
+
return { ...promote, op, ...patch };
|
|
865
|
+
}
|
|
866
|
+
const { templateUuid } = discoveryOf(promote);
|
|
867
|
+
let { instanceUuid } = discoveryOf(promote);
|
|
868
|
+
// Post-drive verify (ANCH2 + DBLSPAWN1): the instance START must have landed on the
|
|
869
|
+
// requested `--when` — for a deadlined rule the driven Next is the deadline (when +
|
|
870
|
+
// startDaysEarlier) and the app back-shifts the start to `--when`, so the check is
|
|
871
|
+
// against `expectedStartIso`, not the raw drive date. Fail closed on mismatch rather
|
|
872
|
+
// than report a wrong-phase ok. The series EXISTS here (promote landed) but on the
|
|
873
|
+
// wrong phase, so this is a genuine partial success, NOT a seed to trash. The
|
|
874
|
+
// ORACLE is rule-kind dependent (#508) — see landedFirstStart; an after-completion
|
|
875
|
+
// series verifies against its materialized instance, and skips when it has none.
|
|
876
|
+
const afterCompletion = effRuleWithReminder.afterCompletion === true;
|
|
877
|
+
if (expectedStartIso !== undefined) {
|
|
878
|
+
const landed = landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion);
|
|
879
|
+
if (!(afterCompletion && landed === null) && landed !== expectedStartIso)
|
|
880
|
+
return nextMismatch(op, templateUuid, expectedStartIso, landed);
|
|
881
|
+
}
|
|
882
|
+
const warnings = [
|
|
883
|
+
landedRuleEcho(ruleParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
|
|
884
|
+
PLACEMENT_NOTE,
|
|
885
|
+
];
|
|
886
|
+
// DBLSPAWN1 backstop: the deadline-mapping above keeps the seed deadline-free (no
|
|
887
|
+
// SRCFATE preserve), but any OTHER preserve trigger reaching the seed (defensive)
|
|
888
|
+
// would double-book a future first occurrence — trash the redundant instance.
|
|
889
|
+
const dbl = await trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, startedAt, afterCompletion);
|
|
890
|
+
if (dbl !== null) {
|
|
891
|
+
warnings.push(dbl.warning);
|
|
892
|
+
if (instanceUuid === dbl.trashedUuid)
|
|
893
|
+
instanceUuid = null;
|
|
894
|
+
}
|
|
895
|
+
const offRule = offRuleFirstNote(ruleParams);
|
|
896
|
+
if (offRule !== null)
|
|
897
|
+
warnings.push(offRule);
|
|
898
|
+
if (promote.warnings !== undefined)
|
|
899
|
+
warnings.push(...promote.warnings);
|
|
900
|
+
appendPromoteSummary(deps, {
|
|
901
|
+
startedAt,
|
|
902
|
+
op,
|
|
903
|
+
txnId,
|
|
904
|
+
templateUuid,
|
|
905
|
+
instanceUuid,
|
|
906
|
+
invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
|
|
907
|
+
requested: { title, ...effRule },
|
|
908
|
+
options,
|
|
909
|
+
});
|
|
910
|
+
return promoteOk({
|
|
911
|
+
op,
|
|
912
|
+
templateUuid,
|
|
913
|
+
instanceUuid,
|
|
914
|
+
replacedUuid: createdUuid,
|
|
915
|
+
title,
|
|
916
|
+
txnId,
|
|
917
|
+
warnings,
|
|
918
|
+
});
|
|
665
919
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Fold a concrete item-level `deadline` (and/or an explicit `startDaysEarlier`)
|
|
923
|
+
* into the RULE, returning the rule to promote with and the deadline the SEED
|
|
924
|
+
* should keep (none — the rule owns it). Shared by the to-do and project
|
|
925
|
+
* add-repeating verbs so one geometry, and one set of refusals, serves both.
|
|
926
|
+
*
|
|
927
|
+
* DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md): each occurrence is due
|
|
928
|
+
* `deadline − when` days after its start (the "Add deadlines" + "start N days
|
|
929
|
+
* earlier" dialog fields). This is the deadline the series actually wants, and
|
|
930
|
+
* mapping it up front is also what keeps the SEED deadline-free — a to-do seed
|
|
931
|
+
* carrying a deadline is SRCFATE-preserved as a materialized instance, and a
|
|
932
|
+
* future-dated first occurrence then DOUBLE-BOOKS against the template cursor
|
|
933
|
+
* and spawns a duplicate on the date (cell C). A project seed is DELETE-fate, so
|
|
934
|
+
* there the un-mapped deadline was simply LOST rather than duplicated — a
|
|
935
|
+
* quieter bug with the same cause and the same fix. A deadline needs a concrete
|
|
936
|
+
* `when` (the per-occurrence offset is measured from the start) on/after it.
|
|
937
|
+
*
|
|
938
|
+
* DEADLINE/OFFSET AGREEMENT (ruling 2026-08-18): the same rule-global offset can
|
|
939
|
+
* be named two ways — a concrete `deadline` (offset = deadline − when) OR an
|
|
940
|
+
* explicit `startDaysEarlier` N (deadline derived as when + N). The geometry
|
|
941
|
+
* when/deadline/N is OVER-DETERMINED: the dialog's start-offset is rule-global
|
|
942
|
+
* and the first occurrence's start is derived as due − N, so there is no
|
|
943
|
+
* per-first-instance gap to absorb a disagreement (unlike DACON1's off-rule-first
|
|
944
|
+
* calendar freedom). So when BOTH are given they must AGREE — an exact match is
|
|
945
|
+
* harmless redundancy, a mismatch is inexpressible and refused fast (zero
|
|
946
|
+
* mutation). Either input ALONE maps to the rule.
|
|
947
|
+
*/
|
|
948
|
+
function mapDeadlineOntoRule(baseRule, when, deadline, startDaysEarlier) {
|
|
949
|
+
if (!isIsoDate(when)) {
|
|
950
|
+
throw new RangeError("a repeating --deadline or --start-days-earlier needs a concrete --when date (the deadline " +
|
|
951
|
+
"offset is measured from each occurrence's start) — schedule the series on a YYYY-MM-DD " +
|
|
952
|
+
"--when, or drop --deadline / --start-days-earlier");
|
|
684
953
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
954
|
+
let startEarlier;
|
|
955
|
+
if (deadline !== undefined) {
|
|
956
|
+
const derived = daysBetweenIso(when, deadline);
|
|
957
|
+
if (derived < 0) {
|
|
958
|
+
throw new RangeError(`--deadline (${deadline}) must be on or after --when (${when}) — a deadline cannot ` +
|
|
959
|
+
"precede the occurrence's own start");
|
|
960
|
+
}
|
|
961
|
+
if (startDaysEarlier !== undefined && startDaysEarlier !== derived) {
|
|
962
|
+
throw new RangeError(`--deadline (${deadline}) puts each occurrence's due date ${derived} day` +
|
|
963
|
+
`${derived === 1 ? "" : "s"} after its start (--when ${when}), but ` +
|
|
964
|
+
`--start-days-earlier says ${startDaysEarlier} — these disagree. Drop one, or make them ` +
|
|
965
|
+
`agree (--start-days-earlier ${derived}, or --deadline ${addDaysIso(when, startDaysEarlier)}).`);
|
|
966
|
+
}
|
|
967
|
+
startEarlier = derived;
|
|
697
968
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
// DBLSPAWN1 backstop: the deadline-mapping above keeps the seed deadline-free (no
|
|
703
|
-
// SRCFATE preserve), but any OTHER preserve trigger reaching the seed (defensive)
|
|
704
|
-
// would double-book a future first occurrence — trash the redundant instance.
|
|
705
|
-
const dbl = await trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, startedAt);
|
|
706
|
-
if (dbl !== null) {
|
|
707
|
-
warnings.push(dbl.warning);
|
|
708
|
-
if (instanceUuid === dbl.trashedUuid)
|
|
709
|
-
instanceUuid = null;
|
|
969
|
+
else {
|
|
970
|
+
// `--start-days-earlier N` alone: the deadline is derived as when + N. A bad N
|
|
971
|
+
// (non-integer / negative) is refused by assertRepeatRule once mapped.
|
|
972
|
+
startEarlier = startDaysEarlier;
|
|
710
973
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
appendPromoteSummary(deps, {
|
|
717
|
-
startedAt,
|
|
718
|
-
op,
|
|
719
|
-
txnId,
|
|
720
|
-
templateUuid,
|
|
721
|
-
instanceUuid,
|
|
722
|
-
invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
|
|
723
|
-
requested: { title, ...effRule },
|
|
724
|
-
});
|
|
725
|
-
return promoteOk({
|
|
726
|
-
op,
|
|
727
|
-
templateUuid,
|
|
728
|
-
instanceUuid,
|
|
729
|
-
replacedUuid: createdUuid,
|
|
730
|
-
title,
|
|
731
|
-
txnId,
|
|
732
|
-
warnings,
|
|
733
|
-
});
|
|
974
|
+
// The RULE owns the deadline; the seed carries none.
|
|
975
|
+
return {
|
|
976
|
+
rule: { ...baseRule, deadline: true, startDaysEarlier: startEarlier },
|
|
977
|
+
seedDeadline: undefined,
|
|
978
|
+
};
|
|
734
979
|
}
|
|
735
980
|
export async function runAddRepeatingTodo(deps, params, options = {}) {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
// DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md): a concrete item-level
|
|
747
|
-
// `--deadline <date>` on add-repeating maps to the RULE's deadline — each occurrence
|
|
748
|
-
// is due `deadline − when` days after its start (the "Add deadlines" + "start N days
|
|
749
|
-
// earlier" dialog fields). This is the deadline the series actually wants, and it
|
|
750
|
-
// keeps the SEED deadline-free: a seed carrying a deadline is SRCFATE-preserved as a
|
|
751
|
-
// materialized instance, and when that first occurrence is future-dated the app
|
|
752
|
-
// DOUBLE-BOOKS it against the template cursor and spawns a duplicate on the date
|
|
753
|
-
// (cell C). The seed owning the deadline was the two-step dance the live agent hit
|
|
754
|
-
// (add-repeating dropped the rule deadline, forcing a follow-up reschedule-repeat);
|
|
755
|
-
// mapping it up front makes that unnecessary. A deadline needs a concrete `--when`
|
|
756
|
-
// (the per-occurrence offset is deadline − start) and must be on/after it.
|
|
757
|
-
//
|
|
758
|
-
// DEADLINE/OFFSET AGREEMENT (ruling 2026-08-18): the same rule-global offset can be
|
|
759
|
-
// named two ways — a concrete `--deadline <date>` (offset = deadline − when) OR an
|
|
760
|
-
// explicit `--start-days-earlier N` (offset = N, deadline derived as when + N). The
|
|
761
|
-
// geometry when/deadline/N is OVER-DETERMINED: the dialog's start-offset is
|
|
762
|
-
// rule-global and the first occurrence's start is derived as due − N, so there is no
|
|
763
|
-
// per-first-instance gap to absorb a disagreement (unlike DACON1's off-rule-first
|
|
764
|
-
// calendar freedom). So when BOTH are given they must AGREE (`deadline − when == N`)
|
|
765
|
-
// — an exact match is harmless redundancy, a mismatch is inexpressible and refused
|
|
766
|
-
// fast (zero mutation). Either input ALONE maps to the rule and keeps the seed
|
|
767
|
-
// deadline-free.
|
|
981
|
+
// The rule/add split is driven by the exhaustive key map (splitAddRepeatingRule),
|
|
982
|
+
// not by a hand-written destructure — a field added to either vocabulary lands on
|
|
983
|
+
// the right leg instead of falling between them (#549 / YANCH1 #493).
|
|
984
|
+
const { rule: baseRule, add } = splitAddRepeatingRule(params);
|
|
985
|
+
const { afterCompletion } = params;
|
|
986
|
+
const startDaysEarlier = add.startDaysEarlier;
|
|
987
|
+
// A concrete `--deadline` (or `--start-days-earlier`) belongs to the RULE, not
|
|
988
|
+
// the seed — see mapDeadlineOntoRule for the geometry and the refusals. The
|
|
989
|
+
// to-do stakes: an un-mapped seed deadline makes the app preserve the seed as a
|
|
990
|
+
// future-dated instance that double-books the cursor (DBLSPAWN1 cell C).
|
|
768
991
|
let rule = baseRule;
|
|
769
992
|
let seedDeadline = add.deadline;
|
|
770
993
|
if ((add.deadline !== undefined || startDaysEarlier !== undefined) && afterCompletion !== true) {
|
|
771
|
-
|
|
772
|
-
throw new RangeError("a repeating --deadline or --start-days-earlier needs a concrete --when date (the deadline " +
|
|
773
|
-
"offset is measured from each occurrence's start) — schedule the series on a YYYY-MM-DD " +
|
|
774
|
-
"--when, or drop --deadline / --start-days-earlier");
|
|
775
|
-
}
|
|
776
|
-
let startEarlier;
|
|
777
|
-
if (add.deadline !== undefined) {
|
|
778
|
-
const derived = daysBetweenIso(add.when, add.deadline);
|
|
779
|
-
if (derived < 0) {
|
|
780
|
-
throw new RangeError(`--deadline (${add.deadline}) must be on or after --when (${add.when}) — a deadline cannot ` +
|
|
781
|
-
"precede the occurrence's own start");
|
|
782
|
-
}
|
|
783
|
-
if (startDaysEarlier !== undefined && startDaysEarlier !== derived) {
|
|
784
|
-
throw new RangeError(`--deadline (${add.deadline}) puts each occurrence's due date ${derived} day` +
|
|
785
|
-
`${derived === 1 ? "" : "s"} after its start (--when ${add.when}), but ` +
|
|
786
|
-
`--start-days-earlier says ${startDaysEarlier} — these disagree. Drop one, or make them ` +
|
|
787
|
-
`agree (--start-days-earlier ${derived}, or --deadline ${addDaysIso(add.when, startDaysEarlier)}).`);
|
|
788
|
-
}
|
|
789
|
-
startEarlier = derived;
|
|
790
|
-
}
|
|
791
|
-
else {
|
|
792
|
-
// `--start-days-earlier N` alone: the deadline is derived as when + N. A bad N
|
|
793
|
-
// (non-integer / negative) is refused by assertRepeatRule once mapped.
|
|
794
|
-
startEarlier = startDaysEarlier;
|
|
795
|
-
}
|
|
796
|
-
rule = { ...baseRule, deadline: true, startDaysEarlier: startEarlier };
|
|
797
|
-
seedDeadline = undefined; // the RULE owns the deadline; the seed carries none
|
|
994
|
+
({ rule, seedDeadline } = mapDeadlineOntoRule(baseRule, add.when, add.deadline, startDaysEarlier));
|
|
798
995
|
}
|
|
799
996
|
else if (startDaysEarlier !== undefined) {
|
|
800
997
|
// afterCompletion === true here: an after-completion repeat has no calendar
|
|
@@ -802,45 +999,54 @@ export async function runAddRepeatingTodo(deps, params, options = {}) {
|
|
|
802
999
|
throw new RangeError("--start-days-earlier applies only to a fixed-schedule deadline — an after-completion repeat " +
|
|
803
1000
|
"has no calendar start to count back from; drop --after-completion or --start-days-earlier");
|
|
804
1001
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
...(add.area !== undefined && { area: add.area }),
|
|
815
|
-
...(add.heading !== undefined && { heading: add.heading }),
|
|
816
|
-
...(add.createdAt !== undefined && { createdAt: add.createdAt }),
|
|
817
|
-
};
|
|
1002
|
+
// The seed carries the WHOLE add vocabulary by spread (a field added to
|
|
1003
|
+
// TodoAddRepeatingParams reaches the create leg without a line here), minus the
|
|
1004
|
+
// two the rule took: `startDaysEarlier` is rule-only, and the deadline is
|
|
1005
|
+
// whatever the geometry left for the seed (none, once mapped).
|
|
1006
|
+
const addParams = { ...add };
|
|
1007
|
+
delete addParams["startDaysEarlier"];
|
|
1008
|
+
delete addParams["deadline"];
|
|
1009
|
+
if (seedDeadline !== undefined)
|
|
1010
|
+
addParams["deadline"] = seedDeadline;
|
|
818
1011
|
return addRepeatingViaCreate(deps, "todo", addParams, rule, add.title, options);
|
|
819
1012
|
}
|
|
820
|
-
export function runAddRepeatingProject(deps, params, options = {}) {
|
|
821
|
-
|
|
822
|
-
const rule =
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1013
|
+
export async function runAddRepeatingProject(deps, params, options = {}) {
|
|
1014
|
+
// Same exhaustive rule/add split as the to-do verb (one key map, both verbs).
|
|
1015
|
+
const { rule: baseRule, add } = splitAddRepeatingRule(params);
|
|
1016
|
+
const { afterCompletion } = params;
|
|
1017
|
+
// A concrete `--deadline` maps to the RULE here exactly as it does on the to-do
|
|
1018
|
+
// verb (DBLSPAWN1 residual). The project stakes are quieter than the to-do's but
|
|
1019
|
+
// no more acceptable: a project seed is DELETE-fate, so the app does not preserve
|
|
1020
|
+
// it into a double-booked instance — it simply DROPS the deadline, and the series
|
|
1021
|
+
// came out undeadlined with nothing said. Mapped, every occurrence is due
|
|
1022
|
+
// `deadline − when` days after its start.
|
|
1023
|
+
let rule = baseRule;
|
|
1024
|
+
let seedDeadline = add.deadline;
|
|
1025
|
+
if (add.deadline !== undefined) {
|
|
1026
|
+
if (afterCompletion === true) {
|
|
1027
|
+
// The to-do verb can leave a deadline on an after-completion seed because
|
|
1028
|
+
// that seed SURVIVES as the series' instance. An after-completion project's
|
|
1029
|
+
// seed is deleted and its instance is minted deadline-free (RSIM-P P4), so
|
|
1030
|
+
// the deadline would vanish either way — refuse rather than drop it quietly.
|
|
1031
|
+
throw new RangeError("an after-completion repeating project cannot carry a --deadline: the rule has no calendar " +
|
|
1032
|
+
"start to measure a per-occurrence deadline from, and the created project is replaced by " +
|
|
1033
|
+
"the series' own instance, which is created without one — drop --after-completion to " +
|
|
1034
|
+
"deadline every occurrence, or drop --deadline");
|
|
1035
|
+
}
|
|
1036
|
+
({ rule, seedDeadline } = mapDeadlineOntoRule(baseRule, add.when, add.deadline, undefined));
|
|
1037
|
+
}
|
|
831
1038
|
// Seed a pure-AX taxonomy: an area lands a selectable AREA-view row; otherwise
|
|
832
1039
|
// create in Someday (UIC4-f) so the promote skips the anytime-header problem.
|
|
833
1040
|
const seedWhen = add.when ?? (add.area === undefined ? "someday" : undefined);
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
};
|
|
1041
|
+
// The whole add vocabulary by spread (see the to-do verb), with the two the
|
|
1042
|
+
// promote owns re-derived: the seed taxonomy `when` and the mapped deadline.
|
|
1043
|
+
const addParams = { ...add };
|
|
1044
|
+
delete addParams["when"];
|
|
1045
|
+
delete addParams["deadline"];
|
|
1046
|
+
if (seedWhen !== undefined)
|
|
1047
|
+
addParams["when"] = seedWhen;
|
|
1048
|
+
if (seedDeadline !== undefined)
|
|
1049
|
+
addParams["deadline"] = seedDeadline;
|
|
844
1050
|
return addRepeatingViaCreate(deps, "project", addParams, rule, add.title, options);
|
|
845
1051
|
}
|
|
846
1052
|
// ====================================================== template-direct clone
|
|
@@ -941,95 +1147,103 @@ export async function cloneTemplateViaRepromote(deps, kind, src, srcUuid, params
|
|
|
941
1147
|
},
|
|
942
1148
|
};
|
|
943
1149
|
}
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
|
|
1150
|
+
// PRE-SEED UI PREFLIGHT (#480/#512): refuse a host that cannot drive the window,
|
|
1151
|
+
// or a locked / full-screen session, BEFORE minting the plain clone (a doomed
|
|
1152
|
+
// promote would strand it). Zero mutation on refusal.
|
|
1153
|
+
const gate = await gateUiPreflight(deps, op);
|
|
947
1154
|
if (gate !== null)
|
|
948
1155
|
return gate;
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
?
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
probe: { title, type: expectedType, sinceEpoch: 0 },
|
|
974
|
-
assert: [],
|
|
975
|
-
},
|
|
976
|
-
observed: null,
|
|
977
|
-
detail: "the plain clone was created but its uuid was not discovered — nothing was promoted",
|
|
978
|
-
}
|
|
979
|
-
: { ...clone, op };
|
|
980
|
-
}
|
|
981
|
-
const cloneUuid = clone.uuid;
|
|
982
|
-
// 5. Native-promote the clone with the FULL decoded rule (ruleToInverseParams
|
|
983
|
-
// carries deadline/start-earlier + the calendar anchors + ends).
|
|
984
|
-
const ruleParams = { uuid: cloneUuid, ...inverse };
|
|
985
|
-
const promote = kind === "project"
|
|
986
|
-
? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
|
|
987
|
-
: await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
|
|
988
|
-
if (promote.kind !== "ok") {
|
|
989
|
-
// The plain clone persists but was not promoted — honest report (no original
|
|
990
|
-
// to roll back; the clone is a fresh row the caller can trash and retry).
|
|
991
|
-
return {
|
|
992
|
-
...promote,
|
|
993
|
-
op,
|
|
994
|
-
...("detail" in promote
|
|
1156
|
+
// COMPOSITE LOCK: clone-as-plain → promote-with-the-source's-rule is one verb;
|
|
1157
|
+
// hold one lock across both legs.
|
|
1158
|
+
return runComposite(deps, op, async () => {
|
|
1159
|
+
const startedAt = deps.now?.() ?? new Date();
|
|
1160
|
+
const txnId = newTxnId(startedAt);
|
|
1161
|
+
// 4. Mint the plain clone as an embedded leg — cloneTemplateAsPlain reaches the
|
|
1162
|
+
// clone orchestrator's plain-content path (recurrence + schedule stripped);
|
|
1163
|
+
// --title/--preserve-created ride through the CloneParams.
|
|
1164
|
+
const cloneParams = {
|
|
1165
|
+
uuid: srcUuid,
|
|
1166
|
+
...(params.title !== undefined && { title: params.title }),
|
|
1167
|
+
...(params.preserveCreated === true && { preserveCreated: true }),
|
|
1168
|
+
};
|
|
1169
|
+
const cloneOptions = {
|
|
1170
|
+
...legOptions(options, txnId),
|
|
1171
|
+
cloneTemplateAsPlain: true,
|
|
1172
|
+
};
|
|
1173
|
+
const clone = kind === "project"
|
|
1174
|
+
? await runCloneProject(deps, cloneParams, cloneOptions)
|
|
1175
|
+
: await runCloneTodo(deps, cloneParams, cloneOptions);
|
|
1176
|
+
if (clone.kind !== "ok" || clone.uuid === null) {
|
|
1177
|
+
// A nested-repeater refusal (a template CONTAINING a nested repeater) or any
|
|
1178
|
+
// clone failure surfaces coherently here — re-label it to the compound op.
|
|
1179
|
+
return clone.kind === "ok"
|
|
995
1180
|
? {
|
|
996
|
-
|
|
997
|
-
|
|
1181
|
+
kind: "verify-failed",
|
|
1182
|
+
op,
|
|
1183
|
+
reason: "mismatch",
|
|
1184
|
+
expected: {
|
|
1185
|
+
mode: "create",
|
|
1186
|
+
probe: { title, type: expectedType, sinceEpoch: 0 },
|
|
1187
|
+
assert: [],
|
|
1188
|
+
},
|
|
1189
|
+
observed: null,
|
|
1190
|
+
detail: "the plain clone was created but its uuid was not discovered — nothing was promoted",
|
|
998
1191
|
}
|
|
999
|
-
: {}
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
warnings
|
|
1192
|
+
: { ...clone, op };
|
|
1193
|
+
}
|
|
1194
|
+
const cloneUuid = clone.uuid;
|
|
1195
|
+
// 5. Native-promote the clone with the FULL decoded rule (ruleToInverseParams
|
|
1196
|
+
// carries deadline/start-earlier + the calendar anchors + ends).
|
|
1197
|
+
const ruleParams = { uuid: cloneUuid, ...inverse };
|
|
1198
|
+
const promote = kind === "project"
|
|
1199
|
+
? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
|
|
1200
|
+
: await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
|
|
1201
|
+
if (promote.kind !== "ok") {
|
|
1202
|
+
// The plain clone persists but was not promoted — honest report (no original
|
|
1203
|
+
// to roll back; the clone is a fresh row the caller can trash and retry).
|
|
1204
|
+
return {
|
|
1205
|
+
...promote,
|
|
1206
|
+
op,
|
|
1207
|
+
...("detail" in promote
|
|
1208
|
+
? {
|
|
1209
|
+
detail: `${promote.detail} — the plain clone (uuid ${cloneUuid}) was created but the promote ` +
|
|
1210
|
+
`did not land; trash the clone with \`things ${kind} delete ${cloneUuid}\` and retry`,
|
|
1211
|
+
}
|
|
1212
|
+
: {}),
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
const { templateUuid, instanceUuid } = discoveryOf(promote);
|
|
1216
|
+
const warnings = [NEW_SERIES_NOTE, PLACEMENT_NOTE];
|
|
1217
|
+
if (params.preserveCreated === true) {
|
|
1218
|
+
warnings.push("--preserve-created is best-effort on a template clone: the promote may replace the clone " +
|
|
1219
|
+
"row with the new template, whose creation date is the conversion time");
|
|
1220
|
+
}
|
|
1221
|
+
if (src.repeating.paused === true) {
|
|
1222
|
+
warnings.push(`the source template was PAUSED; the new series is created UNPAUSED and begins spawning — ` +
|
|
1223
|
+
`pause it with \`things ${kind} pause-repeat\` if you want it suspended`);
|
|
1224
|
+
}
|
|
1225
|
+
if (promote.warnings !== undefined)
|
|
1226
|
+
warnings.push(...promote.warnings);
|
|
1227
|
+
// Summary WITHOUT originalUuid → undo is the add-repeating trash-both (remove
|
|
1228
|
+
// the minted series; there is no original to restore).
|
|
1229
|
+
appendPromoteSummary(deps, {
|
|
1230
|
+
startedAt,
|
|
1231
|
+
op,
|
|
1232
|
+
txnId,
|
|
1233
|
+
templateUuid,
|
|
1234
|
+
instanceUuid,
|
|
1235
|
+
invocation: `${kind}.clone (template) ${srcUuid}: clone → promote ${cloneUuid} → template ${templateUuid}`,
|
|
1236
|
+
requested: { source: srcUuid, title, ...inverse },
|
|
1237
|
+
});
|
|
1238
|
+
return promoteOk({
|
|
1239
|
+
op,
|
|
1240
|
+
templateUuid,
|
|
1241
|
+
instanceUuid,
|
|
1242
|
+
replacedUuid: cloneUuid,
|
|
1243
|
+
title,
|
|
1244
|
+
txnId,
|
|
1245
|
+
warnings,
|
|
1246
|
+
});
|
|
1033
1247
|
});
|
|
1034
1248
|
}
|
|
1035
1249
|
//# sourceMappingURL=promote-clone.js.map
|