@happyvertical/smrt-ui 0.43.3 → 0.43.5
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/AGENTS.md +33 -0
- package/README.md +50 -4
- package/dist/components/forms/Checkbox.svelte +15 -6
- package/dist/components/forms/Checkbox.svelte.d.ts.map +1 -1
- package/dist/components/forms/Combobox.svelte +45 -10
- package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
- package/dist/components/forms/FilePicker.svelte +5 -2
- package/dist/components/forms/Form.svelte +57 -0
- package/dist/components/forms/Form.svelte.d.ts +2 -0
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/Input.svelte +59 -13
- package/dist/components/forms/Input.svelte.d.ts.map +1 -1
- package/dist/components/forms/Listbox.svelte +33 -6
- package/dist/components/forms/Listbox.svelte.d.ts.map +1 -1
- package/dist/components/forms/MultiSelect.svelte +41 -19
- package/dist/components/forms/MultiSelect.svelte.d.ts.map +1 -1
- package/dist/components/forms/RadioGroup.svelte +14 -3
- package/dist/components/forms/RadioGroup.svelte.d.ts.map +1 -1
- package/dist/components/forms/RangeSlider.svelte +61 -6
- package/dist/components/forms/RangeSlider.svelte.d.ts.map +1 -1
- package/dist/components/forms/SegmentedControl.svelte +33 -6
- package/dist/components/forms/SegmentedControl.svelte.d.ts.map +1 -1
- package/dist/components/forms/Select.svelte +32 -9
- package/dist/components/forms/Select.svelte.d.ts.map +1 -1
- package/dist/components/forms/Slider.svelte +26 -6
- package/dist/components/forms/Slider.svelte.d.ts.map +1 -1
- package/dist/components/forms/StagedControlReview.svelte +637 -0
- package/dist/components/forms/StagedControlReview.svelte.d.ts +13 -0
- package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -0
- package/dist/components/forms/Switch.svelte +16 -7
- package/dist/components/forms/Switch.svelte.d.ts.map +1 -1
- package/dist/components/forms/TagsInput.svelte +36 -8
- package/dist/components/forms/TagsInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/Textarea.svelte +20 -8
- package/dist/components/forms/Textarea.svelte.d.ts.map +1 -1
- package/dist/components/forms/ToggleButton.svelte +20 -3
- package/dist/components/forms/ToggleButton.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/Form.test.js +106 -0
- package/dist/components/forms/__tests__/StagedControlReview.test.js +668 -0
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte +22 -4
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts +6 -2
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/composite-controls.test.js +96 -1
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte +41 -0
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts +13 -0
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/control-interaction.test.js +3323 -15
- package/dist/components/forms/__tests__/core-controls.fixture.svelte +16 -5
- package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts +1 -0
- package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/core-controls.test.js +223 -2
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte +25 -0
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte +25 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte +21 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte +23 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte +56 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts +12 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/control-interaction-context.d.ts +9 -1
- package/dist/components/forms/control-interaction-context.d.ts.map +1 -1
- package/dist/components/forms/control-interaction-context.js +16 -0
- package/dist/components/forms/control-interaction.d.ts +97 -7
- package/dist/components/forms/control-interaction.d.ts.map +1 -1
- package/dist/components/forms/control-interaction.js +1019 -92
- package/dist/components/forms/control-value-validation.d.ts +19 -0
- package/dist/components/forms/control-value-validation.d.ts.map +1 -0
- package/dist/components/forms/control-value-validation.js +152 -0
- package/dist/components/forms/index.d.ts +3 -1
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/forms/index.js +2 -1
- package/dist/components/forms/staged-control-review.d.ts +21 -0
- package/dist/components/forms/staged-control-review.d.ts.map +1 -0
- package/dist/components/forms/staged-control-review.js +1 -0
- package/dist/components/forms/use-control-registration.svelte.d.ts.map +1 -1
- package/dist/components/forms/use-control-registration.svelte.js +23 -4
- package/dist/i18n/strings.ui.d.ts +18 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +19 -0
- package/dist/svelte/playground/InteractiveControlsPreview.svelte +3 -4
- package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -5,18 +5,152 @@
|
|
|
5
5
|
* DOM. Controls register serializable metadata plus small imperative handles;
|
|
6
6
|
* adapters translate voice/chat/tutorial requests into commands.
|
|
7
7
|
*/
|
|
8
|
+
import { untrack } from 'svelte';
|
|
9
|
+
const localGestureBatchExecutors = new WeakMap();
|
|
10
|
+
// Intentionally module-private: enumerable so transparent object-spread
|
|
11
|
+
// registry adapters retain the same one-shot local-gesture proof path.
|
|
12
|
+
const localGestureBatchExecutor = Symbol('smrt.localGestureBatchExecutor');
|
|
13
|
+
const consumedFallbackGestureEvents = new WeakMap();
|
|
14
|
+
const eventIsTrustedGetter = typeof Event === 'undefined'
|
|
15
|
+
? undefined
|
|
16
|
+
: Object.getOwnPropertyDescriptor(Event.prototype, 'isTrusted')?.get;
|
|
17
|
+
const eventPhaseGetter = typeof Event === 'undefined'
|
|
18
|
+
? undefined
|
|
19
|
+
: Object.getOwnPropertyDescriptor(Event.prototype, 'eventPhase')?.get;
|
|
20
|
+
function nativeEventState(event) {
|
|
21
|
+
const ownIsTrusted = Object.getOwnPropertyDescriptor(event, 'isTrusted');
|
|
22
|
+
if (!eventPhaseGetter ||
|
|
23
|
+
Object.hasOwn(event, 'eventPhase') ||
|
|
24
|
+
(eventIsTrustedGetter && ownIsTrusted) ||
|
|
25
|
+
(!eventIsTrustedGetter &&
|
|
26
|
+
(!ownIsTrusted || ownIsTrusted.configurable || ownIsTrusted.writable))) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return {
|
|
31
|
+
isTrusted: eventIsTrustedGetter
|
|
32
|
+
? Boolean(eventIsTrustedGetter.call(event))
|
|
33
|
+
: Boolean(ownIsTrusted && 'get' in ownIsTrusted
|
|
34
|
+
? ownIsTrusted.get?.call(event)
|
|
35
|
+
: ownIsTrusted?.value),
|
|
36
|
+
eventPhase: Number(eventPhaseGetter.call(event)),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Native prototype getters reject plain objects and incompatible receivers.
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function cloneFailureResult(command) {
|
|
45
|
+
let action = 'stage';
|
|
46
|
+
let identity = { formId: '', controlId: '' };
|
|
47
|
+
try {
|
|
48
|
+
action = command.action;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A hostile accessor must not turn clone rejection into a thrown error.
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const source = command.identity;
|
|
55
|
+
identity = {
|
|
56
|
+
formId: source.formId,
|
|
57
|
+
controlId: source.controlId,
|
|
58
|
+
...(source.subject
|
|
59
|
+
? {
|
|
60
|
+
subject: {
|
|
61
|
+
type: source.subject.type,
|
|
62
|
+
id: source.subject.id,
|
|
63
|
+
...(source.subject.label === undefined
|
|
64
|
+
? {}
|
|
65
|
+
: { label: source.subject.label }),
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
: {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Preserve the structured failure shape without retaining caller aliases.
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
action,
|
|
77
|
+
identity,
|
|
78
|
+
reason: 'command_failed',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function cloneCommandBatch(commands) {
|
|
82
|
+
try {
|
|
83
|
+
return cloneValue(commands);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Execute one value-changing command from a local DOM event handler. */
|
|
90
|
+
export function executeLocalControlCommand(registry, command, event) {
|
|
91
|
+
return executeLocalControlBatch(registry, [command], event).then((batch) => batch.results[0]);
|
|
92
|
+
}
|
|
93
|
+
/** Execute an ordered best-effort batch from one local DOM event handler. */
|
|
94
|
+
export async function executeLocalControlBatch(registry, commands, event) {
|
|
95
|
+
const commandSnapshots = cloneCommandBatch(commands);
|
|
96
|
+
if (!commandSnapshots) {
|
|
97
|
+
const results = commands.map(cloneFailureResult);
|
|
98
|
+
return { ok: false, results };
|
|
99
|
+
}
|
|
100
|
+
const executor = localGestureBatchExecutors.get(registry) ??
|
|
101
|
+
registry[localGestureBatchExecutor];
|
|
102
|
+
if (!executor) {
|
|
103
|
+
const consumed = consumedFallbackGestureEvents.get(registry) ?? new WeakSet();
|
|
104
|
+
consumedFallbackGestureEvents.set(registry, consumed);
|
|
105
|
+
const eventState = nativeEventState(event);
|
|
106
|
+
if (!eventState?.isTrusted ||
|
|
107
|
+
eventState.eventPhase === 0 ||
|
|
108
|
+
consumed.has(event)) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
results: commandSnapshots.map((command) => ({
|
|
112
|
+
ok: false,
|
|
113
|
+
action: command.action,
|
|
114
|
+
identity: cloneValue(command.identity),
|
|
115
|
+
reason: 'local_gesture_required',
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
consumed.add(event);
|
|
120
|
+
const results = [];
|
|
121
|
+
for (const command of commandSnapshots) {
|
|
122
|
+
results.push(await registry.execute(command, {
|
|
123
|
+
source: 'user',
|
|
124
|
+
confirmed: true,
|
|
125
|
+
localGesture: true,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
return { ok: results.every((entry) => entry.ok), results };
|
|
129
|
+
}
|
|
130
|
+
return executor(registry, commandSnapshots, event);
|
|
131
|
+
}
|
|
8
132
|
function identityKey(identity) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
133
|
+
return JSON.stringify([
|
|
134
|
+
identity.formId,
|
|
135
|
+
identity.controlId,
|
|
136
|
+
identity.subject?.type ?? null,
|
|
137
|
+
identity.subject?.id ?? null,
|
|
138
|
+
]);
|
|
13
139
|
}
|
|
14
140
|
function isMutation(action) {
|
|
15
|
-
return ['stage', 'apply', 'clear', 'undo'].includes(action);
|
|
141
|
+
return ['stage', 'apply', 'discard', 'clear', 'undo'].includes(action);
|
|
16
142
|
}
|
|
17
143
|
function isSecret(registration) {
|
|
18
144
|
return registration.metadata.sensitivity === 'secret';
|
|
19
145
|
}
|
|
146
|
+
function hasValueSetter(registration) {
|
|
147
|
+
return Boolean(registration.setValueWithContext || registration.setValue);
|
|
148
|
+
}
|
|
149
|
+
function setRegistrationValue(registration, value, context) {
|
|
150
|
+
return registration.setValueWithContext
|
|
151
|
+
? registration.setValueWithContext(value, context)
|
|
152
|
+
: registration.setValue?.(value);
|
|
153
|
+
}
|
|
20
154
|
function capabilitiesOf(registration) {
|
|
21
155
|
const declared = registration.metadata.capabilities;
|
|
22
156
|
const capabilities = declared ? [...declared] : [];
|
|
@@ -33,10 +167,13 @@ function capabilitiesOf(registration) {
|
|
|
33
167
|
capabilities.push('highlight');
|
|
34
168
|
if (registration.validate)
|
|
35
169
|
capabilities.push('validate');
|
|
36
|
-
if (registration
|
|
37
|
-
capabilities.push('
|
|
170
|
+
if (hasValueSetter(registration)) {
|
|
171
|
+
capabilities.push('discard');
|
|
172
|
+
if (registration.metadata.writable !== false) {
|
|
173
|
+
capabilities.push('stage', 'apply', 'undo');
|
|
174
|
+
}
|
|
38
175
|
}
|
|
39
|
-
if ((registration.clear || registration
|
|
176
|
+
if ((registration.clear || hasValueSetter(registration)) &&
|
|
40
177
|
registration.metadata.writable !== false) {
|
|
41
178
|
capabilities.push('clear');
|
|
42
179
|
}
|
|
@@ -44,50 +181,305 @@ function capabilitiesOf(registration) {
|
|
|
44
181
|
return capabilities.filter((capability) => {
|
|
45
182
|
if (capability === 'read')
|
|
46
183
|
return !isSecret(registration);
|
|
47
|
-
if (
|
|
48
|
-
return
|
|
184
|
+
if (capability === 'discard')
|
|
185
|
+
return true;
|
|
186
|
+
if (isMutation(capability)) {
|
|
187
|
+
return (!isSecret(registration) && registration.metadata.writable !== false);
|
|
188
|
+
}
|
|
49
189
|
return true;
|
|
50
190
|
});
|
|
51
191
|
}
|
|
52
192
|
function redactsValue(registration) {
|
|
53
|
-
return isSecret(registration) ||
|
|
193
|
+
return (isSecret(registration) ||
|
|
194
|
+
registration.metadata.sensitivity === 'sensitive' ||
|
|
195
|
+
registration.metadata.readable === false);
|
|
54
196
|
}
|
|
55
|
-
function defaultPolicy(command, context, snapshot) {
|
|
197
|
+
function defaultPolicy(command, context, snapshot, localGestureConfirmed) {
|
|
56
198
|
if (!isMutation(command.action))
|
|
57
199
|
return { allowed: true };
|
|
58
|
-
if (
|
|
59
|
-
|
|
200
|
+
if (command.action !== 'discard') {
|
|
201
|
+
if (snapshot.metadata.sensitivity === 'secret') {
|
|
202
|
+
return { allowed: false, reason: 'sensitive_control' };
|
|
203
|
+
}
|
|
204
|
+
if (snapshot.metadata.writable === false) {
|
|
205
|
+
return { allowed: false, reason: 'control_not_writable' };
|
|
206
|
+
}
|
|
207
|
+
if (snapshot.state.disabled || snapshot.state.readonly) {
|
|
208
|
+
return { allowed: false, reason: 'control_not_editable' };
|
|
209
|
+
}
|
|
60
210
|
}
|
|
61
|
-
if (
|
|
62
|
-
return { allowed: false, reason: '
|
|
211
|
+
if (context.source === 'agent' && command.action !== 'stage') {
|
|
212
|
+
return { allowed: false, reason: 'human_confirmation_required' };
|
|
63
213
|
}
|
|
64
|
-
if (
|
|
65
|
-
|
|
214
|
+
if (['apply', 'discard', 'clear', 'undo'].includes(command.action) &&
|
|
215
|
+
!localGestureConfirmed) {
|
|
216
|
+
return { allowed: false, reason: 'local_gesture_required' };
|
|
66
217
|
}
|
|
67
|
-
if (
|
|
68
|
-
command.action !== 'stage' &&
|
|
69
|
-
!context.confirmed) {
|
|
218
|
+
if (command.action !== 'stage' && !context.confirmed) {
|
|
70
219
|
return { allowed: false, reason: 'consent_required' };
|
|
71
220
|
}
|
|
72
221
|
return { allowed: true };
|
|
73
222
|
}
|
|
223
|
+
function cloneValue(value) {
|
|
224
|
+
try {
|
|
225
|
+
return structuredClone(value);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
if (value === undefined)
|
|
229
|
+
return value;
|
|
230
|
+
const serialized = JSON.stringify(value, (_key, candidate) => {
|
|
231
|
+
if (typeof candidate === 'function' ||
|
|
232
|
+
typeof candidate === 'symbol' ||
|
|
233
|
+
typeof candidate === 'bigint') {
|
|
234
|
+
throw new DOMException('Value is not cloneable', 'DataCloneError');
|
|
235
|
+
}
|
|
236
|
+
return candidate;
|
|
237
|
+
});
|
|
238
|
+
if (serialized === undefined) {
|
|
239
|
+
throw new DOMException('Value is not cloneable', 'DataCloneError');
|
|
240
|
+
}
|
|
241
|
+
return JSON.parse(serialized);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function clonePublicValue(value) {
|
|
245
|
+
try {
|
|
246
|
+
return cloneValue(value);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function valuesEqual(left, right) {
|
|
253
|
+
if (Object.is(left, right))
|
|
254
|
+
return true;
|
|
255
|
+
try {
|
|
256
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function userEditSuperseded(registration, previousUserEdit) {
|
|
263
|
+
if (!previousUserEdit)
|
|
264
|
+
return false;
|
|
265
|
+
const currentUserEdit = registration.getUserEditSnapshot?.();
|
|
266
|
+
return (currentUserEdit !== undefined &&
|
|
267
|
+
currentUserEdit.revision !== previousUserEdit.revision);
|
|
268
|
+
}
|
|
269
|
+
async function restoreRegistrationValue(registration, previousValue, previousUserEdit, invokeExtension) {
|
|
270
|
+
let observedUserEdit = cloneValue(registration.getUserEditSnapshot?.()) ?? null;
|
|
271
|
+
let value = previousUserEdit &&
|
|
272
|
+
observedUserEdit &&
|
|
273
|
+
observedUserEdit.revision !== previousUserEdit.revision
|
|
274
|
+
? cloneValue(observedUserEdit.value)
|
|
275
|
+
: previousValue;
|
|
276
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
277
|
+
if (registration.restoreValue) {
|
|
278
|
+
await invokeExtension((context) => registration.restoreValue?.(value, context));
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
await invokeExtension((context) => setRegistrationValue(registration, value, context));
|
|
282
|
+
}
|
|
283
|
+
const latestUserEdit = cloneValue(registration.getUserEditSnapshot?.()) ?? null;
|
|
284
|
+
if (!observedUserEdit ||
|
|
285
|
+
!latestUserEdit ||
|
|
286
|
+
latestUserEdit.revision === observedUserEdit.revision) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
observedUserEdit = latestUserEdit;
|
|
290
|
+
value = cloneValue(latestUserEdit.value);
|
|
291
|
+
if (attempt === 7) {
|
|
292
|
+
// The bounded replay just observed a newer human value after the final
|
|
293
|
+
// restorer completed. Hand that value to the control once more without
|
|
294
|
+
// retrying. Keep using the infallible restoration path when one was
|
|
295
|
+
// supplied: the ordinary setter may be the fallible workflow that
|
|
296
|
+
// triggered rollback in the first place.
|
|
297
|
+
if (registration.restoreValue) {
|
|
298
|
+
await invokeExtension((context) => registration.restoreValue?.(value, context));
|
|
299
|
+
}
|
|
300
|
+
else if (hasValueSetter(registration)) {
|
|
301
|
+
await invokeExtension((context) => setRegistrationValue(registration, value, context));
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function redactedFailureReason(reason) {
|
|
308
|
+
return [
|
|
309
|
+
'stale_revision',
|
|
310
|
+
'staged_value_stale',
|
|
311
|
+
'no_staged_value',
|
|
312
|
+
'staged_value_invalid',
|
|
313
|
+
'staged_value_rejected',
|
|
314
|
+
'nothing_to_undo',
|
|
315
|
+
'sensitive_control',
|
|
316
|
+
].includes(reason)
|
|
317
|
+
? reason
|
|
318
|
+
: 'command_failed';
|
|
319
|
+
}
|
|
320
|
+
async function validateProposedValue(registration, value, context) {
|
|
321
|
+
if (!registration.validateValue)
|
|
322
|
+
return {};
|
|
323
|
+
// Validators are untrusted extension points. Never expose the registry's
|
|
324
|
+
// stored proposal or the candidate that will later be passed to the setter.
|
|
325
|
+
const validation = await registration.validateValue(cloneValue(value), context);
|
|
326
|
+
if (typeof validation === 'boolean')
|
|
327
|
+
return { valid: validation };
|
|
328
|
+
if (typeof validation === 'string') {
|
|
329
|
+
return { valid: false, validationMessage: validation };
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
valid: validation.valid,
|
|
333
|
+
validationMessage: validation.message,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
74
336
|
/** Create an isolated registry; apps choose where and how it is exposed. */
|
|
75
337
|
export function createControlInteractionRegistry(options = {}) {
|
|
76
338
|
const registrations = new Map();
|
|
339
|
+
const registrationGenerations = new Map();
|
|
77
340
|
const staged = new Map();
|
|
78
341
|
const undo = new Map();
|
|
342
|
+
const userEdits = new Map();
|
|
343
|
+
const registrationBaselines = new WeakMap();
|
|
344
|
+
const activeValueMutations = new Map();
|
|
79
345
|
const listeners = new Set();
|
|
346
|
+
const now = options.now ?? Date.now;
|
|
347
|
+
// The context itself is never authority. This private, one-use grant is
|
|
348
|
+
// object-identity bound to the exact command handed to an adapter, retains a
|
|
349
|
+
// private immutable snapshot to detect in-place substitution, and is also
|
|
350
|
+
// bound to the registration generation present during the gesture.
|
|
351
|
+
const localConfirmationGrants = new WeakMap();
|
|
352
|
+
const internallyQueuedContexts = new WeakSet();
|
|
353
|
+
const consumedLocalGestureEvents = new WeakSet();
|
|
354
|
+
const mutationQueues = new Map();
|
|
355
|
+
const synchronousExtensionDepth = new Map();
|
|
356
|
+
let stagedRevision = 0;
|
|
357
|
+
const editAwareRegistration = (key, registration) => ({
|
|
358
|
+
...registration,
|
|
359
|
+
getUserEditSnapshot: () => registration.getUserEditSnapshot?.() ??
|
|
360
|
+
userEdits.get(key) ?? {
|
|
361
|
+
revision: 0,
|
|
362
|
+
value: registration.getValue?.(),
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
const invokeExtension = (key, invoke) => {
|
|
366
|
+
synchronousExtensionDepth.set(key, (synchronousExtensionDepth.get(key) ?? 0) + 1);
|
|
367
|
+
const releaseDepth = (depths) => {
|
|
368
|
+
const remaining = (depths.get(key) ?? 1) - 1;
|
|
369
|
+
if (remaining === 0) {
|
|
370
|
+
depths.delete(key);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
depths.set(key, remaining);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const extensionContext = {
|
|
377
|
+
execute(command, context = { source: 'agent' }) {
|
|
378
|
+
if (isMutation(command.action) &&
|
|
379
|
+
identityKey(command.identity) === key) {
|
|
380
|
+
const registration = registrations.get(key);
|
|
381
|
+
return Promise.resolve(result(command, false, registration, registration && redactsValue(registration)
|
|
382
|
+
? 'command_failed'
|
|
383
|
+
: 'reentrant_mutation'));
|
|
384
|
+
}
|
|
385
|
+
return registry.execute(command, context);
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
let returned;
|
|
389
|
+
try {
|
|
390
|
+
returned = invoke(extensionContext);
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
releaseDepth(synchronousExtensionDepth);
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
releaseDepth(synchronousExtensionDepth);
|
|
397
|
+
return returned;
|
|
398
|
+
};
|
|
399
|
+
const reconcileSupersededRegistration = async (key, generation, previousValue) => {
|
|
400
|
+
const current = registrations.get(key);
|
|
401
|
+
const currentGeneration = registrationGenerations.get(key);
|
|
402
|
+
if (!current || !currentGeneration || currentGeneration === generation) {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
const currentWithEdits = editAwareRegistration(key, current);
|
|
406
|
+
const baseline = registrationBaselines.get(currentGeneration) ?? {
|
|
407
|
+
value: previousValue,
|
|
408
|
+
userEdit: { revision: 0, value: cloneValue(previousValue) },
|
|
409
|
+
};
|
|
410
|
+
await restoreRegistrationValue(currentWithEdits, cloneValue(baseline.value), cloneValue(baseline.userEdit), (invoke) => invokeExtension(key, invoke));
|
|
411
|
+
return true;
|
|
412
|
+
};
|
|
413
|
+
const enqueueMutation = (key, operation) => {
|
|
414
|
+
const previous = mutationQueues.get(key) ?? Promise.resolve();
|
|
415
|
+
const pending = previous.catch(() => undefined).then(operation);
|
|
416
|
+
const settled = pending.then(() => undefined, () => undefined);
|
|
417
|
+
mutationQueues.set(key, settled);
|
|
418
|
+
void settled.finally(() => {
|
|
419
|
+
if (mutationQueues.get(key) === settled)
|
|
420
|
+
mutationQueues.delete(key);
|
|
421
|
+
});
|
|
422
|
+
return pending;
|
|
423
|
+
};
|
|
424
|
+
const invokeTrackedValueMutation = async (key, registration, generation, previousValue, previousUserEdit, invoke) => {
|
|
425
|
+
const activeMutation = {
|
|
426
|
+
registration,
|
|
427
|
+
generation,
|
|
428
|
+
previousValue: cloneValue(previousValue),
|
|
429
|
+
immediateValue: cloneValue(previousValue),
|
|
430
|
+
previousUserEdit: cloneValue(previousUserEdit),
|
|
431
|
+
};
|
|
432
|
+
try {
|
|
433
|
+
activeValueMutations.set(key, activeMutation);
|
|
434
|
+
const pending = invokeExtension(key, invoke);
|
|
435
|
+
activeMutation.immediateValue = cloneValue(registration.getValue?.());
|
|
436
|
+
return await pending;
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
if (activeValueMutations.get(key) === activeMutation) {
|
|
440
|
+
activeValueMutations.delete(key);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
80
444
|
const emit = (event) => {
|
|
81
|
-
const next = { ...event, timestamp:
|
|
82
|
-
for (const listener of listeners)
|
|
83
|
-
|
|
445
|
+
const next = { ...event, timestamp: now() };
|
|
446
|
+
for (const listener of listeners) {
|
|
447
|
+
try {
|
|
448
|
+
listener(cloneValue(next));
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
// Observers cannot change a command's committed result or registry state.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
84
454
|
};
|
|
85
455
|
const snapshotOf = (registration) => {
|
|
86
456
|
const key = identityKey(registration.identity);
|
|
87
457
|
const redacted = redactsValue(registration);
|
|
88
|
-
const
|
|
458
|
+
const stagedEntry = staged.get(key);
|
|
459
|
+
const stale = stagedEntry
|
|
460
|
+
? !valuesEqual(stagedEntry.baseValue, registration.getValue?.())
|
|
461
|
+
: false;
|
|
462
|
+
const runtimeState = registration.getState?.();
|
|
463
|
+
const publicStaged = stagedEntry &&
|
|
464
|
+
!isSecret(registration) &&
|
|
465
|
+
registration.metadata.writable !== false
|
|
466
|
+
? {
|
|
467
|
+
value: redacted ? undefined : cloneValue(stagedEntry.value),
|
|
468
|
+
valueRedacted: redacted,
|
|
469
|
+
provenance: { ...stagedEntry.provenance },
|
|
470
|
+
stagedAt: stagedEntry.stagedAt,
|
|
471
|
+
revision: stagedEntry.revision,
|
|
472
|
+
stale,
|
|
473
|
+
valid: stagedEntry.valid,
|
|
474
|
+
validationMessage: redacted
|
|
475
|
+
? undefined
|
|
476
|
+
: stagedEntry.validationMessage,
|
|
477
|
+
}
|
|
478
|
+
: undefined;
|
|
479
|
+
const rawValue = redacted ? undefined : registration.getValue?.();
|
|
480
|
+
const publicValue = redacted ? undefined : clonePublicValue(rawValue);
|
|
89
481
|
return {
|
|
90
|
-
identity:
|
|
482
|
+
identity: cloneValue(registration.identity),
|
|
91
483
|
metadata: {
|
|
92
484
|
...registration.metadata,
|
|
93
485
|
capabilities: capabilitiesOf(registration),
|
|
@@ -99,32 +491,81 @@ export function createControlInteractionRegistry(options = {}) {
|
|
|
99
491
|
})),
|
|
100
492
|
},
|
|
101
493
|
state: {
|
|
102
|
-
...
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
494
|
+
...runtimeState,
|
|
495
|
+
validationMessage: redacted
|
|
496
|
+
? undefined
|
|
497
|
+
: runtimeState?.validationMessage,
|
|
498
|
+
value: publicValue,
|
|
499
|
+
valueRedacted: redacted || (rawValue !== undefined && publicValue === undefined),
|
|
500
|
+
...(publicStaged
|
|
501
|
+
? {
|
|
502
|
+
stagedValue: publicStaged.value,
|
|
503
|
+
stagedValueRedacted: publicStaged.valueRedacted,
|
|
504
|
+
staged: publicStaged,
|
|
505
|
+
}
|
|
506
|
+
: {}),
|
|
107
507
|
},
|
|
108
508
|
};
|
|
109
509
|
};
|
|
110
|
-
const result = (command, ok, registration, reason) =>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
510
|
+
const result = (command, ok, registration, reason) => {
|
|
511
|
+
let snapshot;
|
|
512
|
+
try {
|
|
513
|
+
snapshot = registration ? snapshotOf(registration) : undefined;
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// A broken control reader must not prevent a structured command result.
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
ok,
|
|
520
|
+
action: command.action,
|
|
521
|
+
identity: cloneValue(command.identity),
|
|
522
|
+
snapshot,
|
|
523
|
+
reason,
|
|
524
|
+
};
|
|
525
|
+
};
|
|
526
|
+
const registry = {
|
|
118
527
|
register(registration) {
|
|
119
528
|
const key = identityKey(registration.identity);
|
|
529
|
+
const generation = {};
|
|
530
|
+
// Capture synchronously so an already-released superseded setter cannot
|
|
531
|
+
// overwrite the replacement generation before its baseline is recorded.
|
|
532
|
+
// `untrack` keeps this read out of a caller's reactive registration effect.
|
|
533
|
+
untrack(() => {
|
|
534
|
+
try {
|
|
535
|
+
const currentValue = cloneValue(registration.getValue?.());
|
|
536
|
+
const activeMutation = activeValueMutations.get(key);
|
|
537
|
+
const userEdit = cloneValue(registration.getUserEditSnapshot?.() ?? userEdits.get(key));
|
|
538
|
+
const newerUserEdit = activeMutation &&
|
|
539
|
+
userEdit &&
|
|
540
|
+
(!activeMutation.previousUserEdit ||
|
|
541
|
+
userEdit.revision !== activeMutation.previousUserEdit.revision);
|
|
542
|
+
const value = activeMutation &&
|
|
543
|
+
registrationGenerations.get(key) === activeMutation.generation &&
|
|
544
|
+
(registration === activeMutation.registration ||
|
|
545
|
+
valuesEqual(currentValue, activeMutation.immediateValue)) &&
|
|
546
|
+
!newerUserEdit
|
|
547
|
+
? cloneValue(activeMutation.previousValue)
|
|
548
|
+
: currentValue;
|
|
549
|
+
registrationBaselines.set(generation, {
|
|
550
|
+
value,
|
|
551
|
+
userEdit: userEdit ?? { revision: 0, value: cloneValue(value) },
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
// A broken reader is handled by command execution, not registration.
|
|
556
|
+
}
|
|
557
|
+
});
|
|
120
558
|
registrations.set(key, registration);
|
|
559
|
+
registrationGenerations.set(key, generation);
|
|
121
560
|
emit({ type: 'registered', identity: registration.identity });
|
|
122
561
|
return () => {
|
|
123
|
-
if (
|
|
562
|
+
if (registrationGenerations.get(key) !== generation)
|
|
124
563
|
return;
|
|
125
564
|
registrations.delete(key);
|
|
565
|
+
registrationGenerations.delete(key);
|
|
126
566
|
staged.delete(key);
|
|
127
567
|
undo.delete(key);
|
|
568
|
+
userEdits.delete(key);
|
|
128
569
|
emit({ type: 'unregistered', identity: registration.identity });
|
|
129
570
|
};
|
|
130
571
|
},
|
|
@@ -132,10 +573,23 @@ export function createControlInteractionRegistry(options = {}) {
|
|
|
132
573
|
const key = identityKey(identity);
|
|
133
574
|
if (!registrations.delete(key))
|
|
134
575
|
return;
|
|
576
|
+
registrationGenerations.delete(key);
|
|
135
577
|
staged.delete(key);
|
|
136
578
|
undo.delete(key);
|
|
579
|
+
userEdits.delete(key);
|
|
137
580
|
emit({ type: 'unregistered', identity });
|
|
138
581
|
},
|
|
582
|
+
recordUserEdit(identity) {
|
|
583
|
+
const key = identityKey(identity);
|
|
584
|
+
const registration = registrations.get(key);
|
|
585
|
+
if (!registration)
|
|
586
|
+
return;
|
|
587
|
+
const previous = userEdits.get(key);
|
|
588
|
+
userEdits.set(key, {
|
|
589
|
+
revision: (previous?.revision ?? 0) + 1,
|
|
590
|
+
value: cloneValue(registration.getValue?.()),
|
|
591
|
+
});
|
|
592
|
+
},
|
|
139
593
|
list(formId) {
|
|
140
594
|
return [...registrations.values()]
|
|
141
595
|
.filter((registration) => formId ? registration.identity.formId === formId : true)
|
|
@@ -145,37 +599,160 @@ export function createControlInteractionRegistry(options = {}) {
|
|
|
145
599
|
const registration = registrations.get(identityKey(identity));
|
|
146
600
|
return registration ? snapshotOf(registration) : undefined;
|
|
147
601
|
},
|
|
602
|
+
refresh(formId) {
|
|
603
|
+
for (const registration of registrations.values()) {
|
|
604
|
+
if (formId && registration.identity.formId !== formId)
|
|
605
|
+
continue;
|
|
606
|
+
emit({ type: 'refreshed', identity: registration.identity });
|
|
607
|
+
}
|
|
608
|
+
},
|
|
148
609
|
async execute(command, context = { source: 'user', confirmed: true }) {
|
|
610
|
+
const suppliedCommand = command;
|
|
611
|
+
const localGrant = localConfirmationGrants.get(context);
|
|
612
|
+
if (localGrant)
|
|
613
|
+
localConfirmationGrants.delete(context);
|
|
614
|
+
if (!internallyQueuedContexts.has(context)) {
|
|
615
|
+
try {
|
|
616
|
+
command = cloneValue(command);
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
return cloneFailureResult(command);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
149
622
|
const key = identityKey(command.identity);
|
|
623
|
+
if (isMutation(command.action) &&
|
|
624
|
+
(synchronousExtensionDepth.get(key) ?? 0) > 0) {
|
|
625
|
+
const registration = registrations.get(key);
|
|
626
|
+
return result(command, false, registration, registration && redactsValue(registration)
|
|
627
|
+
? 'command_failed'
|
|
628
|
+
: 'reentrant_mutation');
|
|
629
|
+
}
|
|
630
|
+
const localGestureConfirmed = Boolean(localGrant &&
|
|
631
|
+
localGrant.command === suppliedCommand &&
|
|
632
|
+
valuesEqual(localGrant.snapshot, command) &&
|
|
633
|
+
localGrant.generation === registrationGenerations.get(key));
|
|
634
|
+
if (isMutation(command.action) &&
|
|
635
|
+
!internallyQueuedContexts.has(context)) {
|
|
636
|
+
const queuedContext = { ...context };
|
|
637
|
+
internallyQueuedContexts.add(queuedContext);
|
|
638
|
+
const commandSnapshot = command;
|
|
639
|
+
if (localGestureConfirmed) {
|
|
640
|
+
localConfirmationGrants.set(queuedContext, {
|
|
641
|
+
command: commandSnapshot,
|
|
642
|
+
snapshot: cloneValue(commandSnapshot),
|
|
643
|
+
generation: localGrant?.generation,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
return await enqueueMutation(key, () => this.execute(commandSnapshot, queuedContext));
|
|
647
|
+
}
|
|
150
648
|
const registration = registrations.get(key);
|
|
151
649
|
if (!registration)
|
|
152
650
|
return result(command, false, undefined, 'not_found');
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
});
|
|
176
|
-
return unsupported;
|
|
177
|
-
}
|
|
651
|
+
const generation = registrationGenerations.get(key);
|
|
652
|
+
if (!generation)
|
|
653
|
+
return result(command, false, undefined, 'not_found');
|
|
654
|
+
const registrationWithEdits = editAwareRegistration(key, registration);
|
|
655
|
+
const restoreMutationValue = async (previousValue, previousUserEdit) => {
|
|
656
|
+
if (await reconcileSupersededRegistration(key, generation, previousValue)) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
await restoreRegistrationValue(registrationWithEdits, previousValue, previousUserEdit, (invoke) => invokeExtension(key, invoke));
|
|
660
|
+
};
|
|
661
|
+
let publicCommand = redactsValue(registration) &&
|
|
662
|
+
(command.action === 'stage' || command.action === 'apply')
|
|
663
|
+
? cloneValue({ ...command, value: undefined })
|
|
664
|
+
: cloneValue(command);
|
|
665
|
+
const publicContext = {
|
|
666
|
+
source: context.source,
|
|
667
|
+
confirmed: context.confirmed,
|
|
668
|
+
localGesture: localGestureConfirmed,
|
|
669
|
+
actorId: context.actorId,
|
|
670
|
+
sessionId: context.sessionId,
|
|
671
|
+
};
|
|
672
|
+
const customPolicy = options.policy;
|
|
178
673
|
try {
|
|
674
|
+
let snapshot = snapshotOf(registration);
|
|
675
|
+
let preparedStageValue;
|
|
676
|
+
let preparedStageBaseValue;
|
|
677
|
+
const commandUserEditSnapshot = command.action === 'apply' ||
|
|
678
|
+
command.action === 'clear' ||
|
|
679
|
+
command.action === 'undo'
|
|
680
|
+
? (cloneValue(registrationWithEdits.getUserEditSnapshot?.()) ??
|
|
681
|
+
null)
|
|
682
|
+
: null;
|
|
683
|
+
let invariantDecision = defaultPolicy(command, publicContext, snapshot, publicContext.localGesture === true);
|
|
684
|
+
let policyDecision = invariantDecision;
|
|
685
|
+
if (invariantDecision.allowed && command.action === 'stage') {
|
|
686
|
+
// A control may resolve a proposal relative to its current value (for
|
|
687
|
+
// example, append-mode textareas). Custom policy must inspect the
|
|
688
|
+
// exact resolved value that will be staged. If a human changes the
|
|
689
|
+
// control while policy awaits, retry against the new stable base.
|
|
690
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
691
|
+
preparedStageBaseValue = cloneValue(registration.getValue?.());
|
|
692
|
+
preparedStageValue = cloneValue(registration.prepareValue
|
|
693
|
+
? invokeExtension(key, () => registration.prepareValue?.(command.value))
|
|
694
|
+
: command.value);
|
|
695
|
+
snapshot = snapshotOf(registration);
|
|
696
|
+
publicCommand = redactsValue(registration)
|
|
697
|
+
? cloneValue({ ...command, value: undefined })
|
|
698
|
+
: cloneValue({ ...command, value: preparedStageValue });
|
|
699
|
+
policyDecision = customPolicy
|
|
700
|
+
? await invokeExtension(key, (extensionContext) => customPolicy(cloneValue(publicCommand), cloneValue(publicContext), cloneValue(snapshot), extensionContext))
|
|
701
|
+
: invariantDecision;
|
|
702
|
+
if (registrationGenerations.get(key) !== generation) {
|
|
703
|
+
throw new Error('staged_value_stale');
|
|
704
|
+
}
|
|
705
|
+
invariantDecision = defaultPolicy(command, publicContext, snapshotOf(registration), publicContext.localGesture === true);
|
|
706
|
+
if (!invariantDecision.allowed) {
|
|
707
|
+
throw new Error(invariantDecision.reason ?? 'denied');
|
|
708
|
+
}
|
|
709
|
+
if (valuesEqual(preparedStageBaseValue, registration.getValue?.())) {
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
if (attempt === 7)
|
|
713
|
+
throw new Error('staged_value_stale');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
else if (invariantDecision.allowed && customPolicy) {
|
|
717
|
+
policyDecision = await invokeExtension(key, (extensionContext) => customPolicy(cloneValue(publicCommand), cloneValue(publicContext), cloneValue(snapshot), extensionContext));
|
|
718
|
+
}
|
|
719
|
+
if (registrationGenerations.get(key) !== generation) {
|
|
720
|
+
throw new Error('staged_value_stale');
|
|
721
|
+
}
|
|
722
|
+
if (commandUserEditSnapshot &&
|
|
723
|
+
userEditSuperseded(registrationWithEdits, commandUserEditSnapshot)) {
|
|
724
|
+
throw new Error('staged_value_stale');
|
|
725
|
+
}
|
|
726
|
+
const postPolicyInvariant = defaultPolicy(command, publicContext, snapshotOf(registration), publicContext.localGesture === true);
|
|
727
|
+
if (!postPolicyInvariant.allowed) {
|
|
728
|
+
throw new Error(postPolicyInvariant.reason ?? 'denied');
|
|
729
|
+
}
|
|
730
|
+
if (!policyDecision.allowed) {
|
|
731
|
+
const denialReason = redactsValue(registration)
|
|
732
|
+
? redactedFailureReason(policyDecision.reason ?? 'denied')
|
|
733
|
+
: (policyDecision.reason ?? 'denied');
|
|
734
|
+
const denied = result(command, false, registration, denialReason);
|
|
735
|
+
emit({
|
|
736
|
+
type: 'command',
|
|
737
|
+
identity: command.identity,
|
|
738
|
+
command: publicCommand,
|
|
739
|
+
context: publicContext,
|
|
740
|
+
result: denied,
|
|
741
|
+
});
|
|
742
|
+
return denied;
|
|
743
|
+
}
|
|
744
|
+
const capabilities = capabilitiesOf(registration);
|
|
745
|
+
if (!capabilities.includes(command.action)) {
|
|
746
|
+
const unsupported = result(command, false, registration, 'unsupported');
|
|
747
|
+
emit({
|
|
748
|
+
type: 'command',
|
|
749
|
+
identity: command.identity,
|
|
750
|
+
command: publicCommand,
|
|
751
|
+
context: publicContext,
|
|
752
|
+
result: unsupported,
|
|
753
|
+
});
|
|
754
|
+
return unsupported;
|
|
755
|
+
}
|
|
179
756
|
switch (command.action) {
|
|
180
757
|
case 'focus':
|
|
181
758
|
await registration.focus?.();
|
|
@@ -189,40 +766,287 @@ export function createControlInteractionRegistry(options = {}) {
|
|
|
189
766
|
case 'explain':
|
|
190
767
|
break;
|
|
191
768
|
case 'validate':
|
|
192
|
-
await registration.validate?.();
|
|
769
|
+
await invokeExtension(key, (extensionContext) => registration.validate?.(extensionContext));
|
|
193
770
|
break;
|
|
194
771
|
case 'stage':
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
772
|
+
stagedRevision += 1;
|
|
773
|
+
{
|
|
774
|
+
const revision = stagedRevision;
|
|
775
|
+
const preparedValue = preparedStageValue;
|
|
776
|
+
const entry = {
|
|
777
|
+
value: cloneValue(preparedValue),
|
|
778
|
+
baseValue: cloneValue(preparedStageBaseValue),
|
|
779
|
+
provenance: {
|
|
780
|
+
source: context.source,
|
|
781
|
+
actorId: context.actorId,
|
|
782
|
+
sessionId: context.sessionId,
|
|
783
|
+
},
|
|
784
|
+
stagedAt: now(),
|
|
785
|
+
revision,
|
|
786
|
+
};
|
|
787
|
+
const previousEntry = staged.get(key);
|
|
788
|
+
staged.set(key, entry);
|
|
789
|
+
try {
|
|
790
|
+
const validation = await invokeExtension(key, (extensionContext) => validateProposedValue(registration, preparedValue, extensionContext));
|
|
791
|
+
if (registrationGenerations.get(key) !== generation ||
|
|
792
|
+
staged.get(key) !== entry) {
|
|
793
|
+
throw new Error('stale_revision');
|
|
794
|
+
}
|
|
795
|
+
const commitSnapshot = {
|
|
796
|
+
identity: cloneValue(registration.identity),
|
|
797
|
+
metadata: {
|
|
798
|
+
...registration.metadata,
|
|
799
|
+
capabilities: capabilitiesOf(registration),
|
|
800
|
+
},
|
|
801
|
+
state: {
|
|
802
|
+
...registration.getState?.(),
|
|
803
|
+
valueRedacted: redactsValue(registration),
|
|
804
|
+
},
|
|
805
|
+
};
|
|
806
|
+
const commitInvariant = defaultPolicy(command, publicContext, commitSnapshot, publicContext.localGesture === true);
|
|
807
|
+
if (!commitInvariant.allowed) {
|
|
808
|
+
throw new Error(commitInvariant.reason ?? 'denied');
|
|
809
|
+
}
|
|
810
|
+
if (!capabilitiesOf(registration).includes('stage')) {
|
|
811
|
+
throw new Error('unsupported');
|
|
812
|
+
}
|
|
813
|
+
Object.assign(entry, validation);
|
|
814
|
+
const stageSnapshot = snapshotOf(registration);
|
|
815
|
+
const completed = {
|
|
816
|
+
ok: true,
|
|
817
|
+
action: command.action,
|
|
818
|
+
identity: { ...command.identity },
|
|
819
|
+
snapshot: stageSnapshot,
|
|
820
|
+
};
|
|
821
|
+
emit({
|
|
822
|
+
type: 'staged',
|
|
823
|
+
identity: command.identity,
|
|
824
|
+
command: publicCommand,
|
|
825
|
+
context: publicContext,
|
|
826
|
+
staged: stageSnapshot.state.staged,
|
|
827
|
+
});
|
|
828
|
+
emit({
|
|
829
|
+
type: 'command',
|
|
830
|
+
identity: command.identity,
|
|
831
|
+
command: publicCommand,
|
|
832
|
+
context: publicContext,
|
|
833
|
+
result: completed,
|
|
834
|
+
});
|
|
835
|
+
return completed;
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
if (staged.get(key) === entry) {
|
|
839
|
+
if (previousEntry) {
|
|
840
|
+
staged.set(key, previousEntry);
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
staged.delete(key);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
203
849
|
case 'apply': {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
850
|
+
const stagedEntry = staged.get(key);
|
|
851
|
+
if (command.revision !== undefined &&
|
|
852
|
+
stagedEntry?.revision !== command.revision) {
|
|
853
|
+
throw new Error('stale_revision');
|
|
854
|
+
}
|
|
855
|
+
if (stagedEntry &&
|
|
856
|
+
!valuesEqual(stagedEntry.baseValue, registration.getValue?.())) {
|
|
857
|
+
throw new Error('staged_value_stale');
|
|
858
|
+
}
|
|
859
|
+
const suppliedValue = 'value' in command && command.value !== undefined;
|
|
860
|
+
const usesExactStagedValue = suppliedValue &&
|
|
861
|
+
stagedEntry !== undefined &&
|
|
862
|
+
valuesEqual(command.value, stagedEntry.value);
|
|
863
|
+
const usesCanonicalReviewValue = suppliedValue &&
|
|
864
|
+
stagedEntry !== undefined &&
|
|
865
|
+
command.reviewedValueIsCanonical === true &&
|
|
866
|
+
localGestureConfirmed;
|
|
867
|
+
const nextValue = usesExactStagedValue
|
|
868
|
+
? stagedEntry.value
|
|
869
|
+
: usesCanonicalReviewValue
|
|
870
|
+
? registration.prepareReviewedValue
|
|
871
|
+
? invokeExtension(key, () => registration.prepareReviewedValue?.(command.value))
|
|
872
|
+
: registration.prepareValue
|
|
873
|
+
? invokeExtension(key, () => registration.prepareValue?.(command.value))
|
|
874
|
+
: command.value
|
|
875
|
+
: suppliedValue
|
|
876
|
+
? registration.prepareValue
|
|
877
|
+
? invokeExtension(key, () => registration.prepareValue?.(command.value))
|
|
878
|
+
: command.value
|
|
879
|
+
: stagedEntry?.value;
|
|
880
|
+
if (nextValue === undefined && !stagedEntry) {
|
|
208
881
|
throw new Error('no_staged_value');
|
|
209
882
|
}
|
|
883
|
+
const authorizedValue = cloneValue(nextValue);
|
|
884
|
+
const proposedValidation = await invokeExtension(key, (extensionContext) => validateProposedValue(registration, authorizedValue, extensionContext));
|
|
885
|
+
if (registrationGenerations.get(key) !== generation) {
|
|
886
|
+
throw new Error('staged_value_stale');
|
|
887
|
+
}
|
|
888
|
+
if (commandUserEditSnapshot &&
|
|
889
|
+
userEditSuperseded(registrationWithEdits, commandUserEditSnapshot)) {
|
|
890
|
+
throw new Error('staged_value_stale');
|
|
891
|
+
}
|
|
892
|
+
if (stagedEntry &&
|
|
893
|
+
(staged.get(key) !== stagedEntry ||
|
|
894
|
+
!valuesEqual(stagedEntry.baseValue, registration.getValue?.()))) {
|
|
895
|
+
throw new Error('stale_revision');
|
|
896
|
+
}
|
|
897
|
+
if (proposedValidation.valid === false) {
|
|
898
|
+
if (stagedEntry)
|
|
899
|
+
Object.assign(stagedEntry, proposedValidation);
|
|
900
|
+
throw new Error(redactsValue(registration)
|
|
901
|
+
? 'staged_value_invalid'
|
|
902
|
+
: (proposedValidation.validationMessage ??
|
|
903
|
+
'staged_value_invalid'));
|
|
904
|
+
}
|
|
905
|
+
const preMutationInvariant = defaultPolicy(command, publicContext, snapshotOf(registration), publicContext.localGesture === true);
|
|
906
|
+
if (!preMutationInvariant.allowed) {
|
|
907
|
+
throw new Error(preMutationInvariant.reason ?? 'denied');
|
|
908
|
+
}
|
|
909
|
+
const previousValue = cloneValue(registration.getValue?.());
|
|
210
910
|
const history = undo.get(key) ?? [];
|
|
211
|
-
|
|
911
|
+
const userEditSnapshot = commandUserEditSnapshot;
|
|
912
|
+
try {
|
|
913
|
+
await invokeTrackedValueMutation(key, registration, generation, previousValue, userEditSnapshot, (extensionContext) => setRegistrationValue(registration, cloneValue(authorizedValue), extensionContext));
|
|
914
|
+
}
|
|
915
|
+
catch (error) {
|
|
916
|
+
try {
|
|
917
|
+
await restoreMutationValue(previousValue, userEditSnapshot);
|
|
918
|
+
}
|
|
919
|
+
catch {
|
|
920
|
+
// Preserve the original setter failure for the command result.
|
|
921
|
+
}
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
if (registrationGenerations.get(key) !== generation ||
|
|
925
|
+
userEditSuperseded(registrationWithEdits, userEditSnapshot)) {
|
|
926
|
+
await restoreMutationValue(previousValue, userEditSnapshot);
|
|
927
|
+
throw new Error('staged_value_stale');
|
|
928
|
+
}
|
|
929
|
+
const appliedValue = cloneValue(registration.getValue?.());
|
|
930
|
+
if (!valuesEqual(appliedValue, authorizedValue)) {
|
|
931
|
+
try {
|
|
932
|
+
await restoreMutationValue(previousValue, userEditSnapshot);
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
// Preserve the rejected apply result and retain its proposal.
|
|
936
|
+
}
|
|
937
|
+
throw new Error('staged_value_rejected');
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
const valid = await invokeExtension(key, (extensionContext) => registration.validate?.(extensionContext));
|
|
941
|
+
if (registrationGenerations.get(key) !== generation ||
|
|
942
|
+
userEditSuperseded(registrationWithEdits, userEditSnapshot)) {
|
|
943
|
+
throw new Error('staged_value_stale');
|
|
944
|
+
}
|
|
945
|
+
if (!valuesEqual(registration.getValue?.(), appliedValue)) {
|
|
946
|
+
throw new Error('staged_value_stale');
|
|
947
|
+
}
|
|
948
|
+
if (valid === false) {
|
|
949
|
+
if (stagedEntry) {
|
|
950
|
+
stagedEntry.valid = false;
|
|
951
|
+
stagedEntry.validationMessage =
|
|
952
|
+
registration.getState?.().validationMessage ??
|
|
953
|
+
'staged_value_invalid';
|
|
954
|
+
}
|
|
955
|
+
throw new Error('staged_value_invalid');
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
catch (error) {
|
|
959
|
+
let currentValue;
|
|
960
|
+
try {
|
|
961
|
+
currentValue = registration.getValue?.();
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
// A broken reader is reported without attempting a blind overwrite.
|
|
965
|
+
}
|
|
966
|
+
if (valuesEqual(currentValue, appliedValue)) {
|
|
967
|
+
try {
|
|
968
|
+
await restoreMutationValue(previousValue, userEditSnapshot);
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
// Preserve the original validation failure for the command result.
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
throw error;
|
|
975
|
+
}
|
|
976
|
+
history.push({
|
|
977
|
+
previousValue,
|
|
978
|
+
appliedValue,
|
|
979
|
+
});
|
|
212
980
|
undo.set(key, history);
|
|
213
|
-
|
|
981
|
+
if (!stagedEntry || staged.get(key) === stagedEntry) {
|
|
982
|
+
staged.delete(key);
|
|
983
|
+
}
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
case 'discard': {
|
|
987
|
+
const stagedEntry = staged.get(key);
|
|
988
|
+
if (!stagedEntry)
|
|
989
|
+
throw new Error('no_staged_value');
|
|
990
|
+
if (command.revision !== undefined &&
|
|
991
|
+
stagedEntry.revision !== command.revision) {
|
|
992
|
+
throw new Error('stale_revision');
|
|
993
|
+
}
|
|
214
994
|
staged.delete(key);
|
|
215
|
-
await registration.validate?.();
|
|
216
995
|
break;
|
|
217
996
|
}
|
|
218
997
|
case 'clear': {
|
|
998
|
+
const previousValue = cloneValue(registration.getValue?.());
|
|
999
|
+
let clearDecision;
|
|
1000
|
+
const userEditSnapshot = commandUserEditSnapshot;
|
|
1001
|
+
try {
|
|
1002
|
+
const decision = await invokeTrackedValueMutation(key, registration, generation, previousValue, userEditSnapshot, (extensionContext) => registration.clear
|
|
1003
|
+
? registration.clear(extensionContext)
|
|
1004
|
+
: setRegistrationValue(registration, '', extensionContext));
|
|
1005
|
+
clearDecision =
|
|
1006
|
+
decision === true
|
|
1007
|
+
? true
|
|
1008
|
+
: decision === false
|
|
1009
|
+
? false
|
|
1010
|
+
: undefined;
|
|
1011
|
+
}
|
|
1012
|
+
catch (error) {
|
|
1013
|
+
try {
|
|
1014
|
+
await restoreMutationValue(previousValue, userEditSnapshot);
|
|
1015
|
+
}
|
|
1016
|
+
catch {
|
|
1017
|
+
// Preserve the original clear failure for the command result.
|
|
1018
|
+
}
|
|
1019
|
+
throw error;
|
|
1020
|
+
}
|
|
1021
|
+
if (registrationGenerations.get(key) !== generation ||
|
|
1022
|
+
userEditSuperseded(registrationWithEdits, userEditSnapshot)) {
|
|
1023
|
+
await restoreMutationValue(previousValue, userEditSnapshot);
|
|
1024
|
+
throw new Error('staged_value_stale');
|
|
1025
|
+
}
|
|
1026
|
+
const clearedValue = registration.getValue?.();
|
|
1027
|
+
const rejected = clearDecision === false ||
|
|
1028
|
+
(clearDecision !== true &&
|
|
1029
|
+
valuesEqual(clearedValue, previousValue) &&
|
|
1030
|
+
previousValue !== '' &&
|
|
1031
|
+
previousValue !== null &&
|
|
1032
|
+
previousValue !== undefined &&
|
|
1033
|
+
previousValue !== false &&
|
|
1034
|
+
!(Array.isArray(previousValue) && previousValue.length === 0));
|
|
1035
|
+
if (rejected) {
|
|
1036
|
+
try {
|
|
1037
|
+
await restoreMutationValue(previousValue, userEditSnapshot);
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
// The rejected command still retains its proposal for recovery.
|
|
1041
|
+
}
|
|
1042
|
+
throw new Error('staged_value_rejected');
|
|
1043
|
+
}
|
|
219
1044
|
const history = undo.get(key) ?? [];
|
|
220
|
-
history.push(
|
|
1045
|
+
history.push({
|
|
1046
|
+
previousValue,
|
|
1047
|
+
appliedValue: cloneValue(clearedValue),
|
|
1048
|
+
});
|
|
221
1049
|
undo.set(key, history);
|
|
222
|
-
if (registration.clear)
|
|
223
|
-
await registration.clear();
|
|
224
|
-
else
|
|
225
|
-
await registration.setValue?.('');
|
|
226
1050
|
staged.delete(key);
|
|
227
1051
|
break;
|
|
228
1052
|
}
|
|
@@ -230,7 +1054,42 @@ export function createControlInteractionRegistry(options = {}) {
|
|
|
230
1054
|
const history = undo.get(key) ?? [];
|
|
231
1055
|
if (history.length === 0)
|
|
232
1056
|
throw new Error('nothing_to_undo');
|
|
233
|
-
|
|
1057
|
+
const undoEntry = history.at(-1);
|
|
1058
|
+
if (!undoEntry)
|
|
1059
|
+
throw new Error('nothing_to_undo');
|
|
1060
|
+
const currentValue = cloneValue(registration.getValue?.());
|
|
1061
|
+
const expectedPreviousValue = cloneValue(undoEntry.previousValue);
|
|
1062
|
+
const userEditSnapshot = commandUserEditSnapshot;
|
|
1063
|
+
if (!valuesEqual(currentValue, undoEntry.appliedValue)) {
|
|
1064
|
+
throw new Error('staged_value_stale');
|
|
1065
|
+
}
|
|
1066
|
+
try {
|
|
1067
|
+
await invokeTrackedValueMutation(key, registration, generation, currentValue, userEditSnapshot, (extensionContext) => setRegistrationValue(registration, cloneValue(expectedPreviousValue), extensionContext));
|
|
1068
|
+
}
|
|
1069
|
+
catch (error) {
|
|
1070
|
+
try {
|
|
1071
|
+
await restoreMutationValue(currentValue, userEditSnapshot);
|
|
1072
|
+
}
|
|
1073
|
+
catch {
|
|
1074
|
+
// Preserve the original setter failure for the command result.
|
|
1075
|
+
}
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
if (registrationGenerations.get(key) !== generation ||
|
|
1079
|
+
userEditSuperseded(registrationWithEdits, userEditSnapshot)) {
|
|
1080
|
+
await restoreMutationValue(currentValue, userEditSnapshot);
|
|
1081
|
+
throw new Error('staged_value_stale');
|
|
1082
|
+
}
|
|
1083
|
+
if (!valuesEqual(registration.getValue?.(), expectedPreviousValue)) {
|
|
1084
|
+
try {
|
|
1085
|
+
await restoreMutationValue(currentValue, userEditSnapshot);
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
// Preserve the rejected undo result while retaining its history.
|
|
1089
|
+
}
|
|
1090
|
+
throw new Error('staged_value_rejected');
|
|
1091
|
+
}
|
|
1092
|
+
history.pop();
|
|
234
1093
|
undo.set(key, history);
|
|
235
1094
|
break;
|
|
236
1095
|
}
|
|
@@ -239,27 +1098,95 @@ export function createControlInteractionRegistry(options = {}) {
|
|
|
239
1098
|
emit({
|
|
240
1099
|
type: 'command',
|
|
241
1100
|
identity: command.identity,
|
|
242
|
-
command,
|
|
243
|
-
context,
|
|
1101
|
+
command: publicCommand,
|
|
1102
|
+
context: publicContext,
|
|
244
1103
|
result: completed,
|
|
245
1104
|
});
|
|
246
1105
|
return completed;
|
|
247
1106
|
}
|
|
248
1107
|
catch (error) {
|
|
249
|
-
const
|
|
1108
|
+
const errorReason = error instanceof Error ? error.message : 'command_failed';
|
|
1109
|
+
const failed = result(command, false, registration, redactsValue(registration)
|
|
1110
|
+
? redactedFailureReason(errorReason)
|
|
1111
|
+
: errorReason);
|
|
250
1112
|
emit({
|
|
251
1113
|
type: 'command',
|
|
252
1114
|
identity: command.identity,
|
|
253
|
-
command,
|
|
254
|
-
context,
|
|
1115
|
+
command: publicCommand,
|
|
1116
|
+
context: publicContext,
|
|
255
1117
|
result: failed,
|
|
256
1118
|
});
|
|
257
1119
|
return failed;
|
|
258
1120
|
}
|
|
259
1121
|
},
|
|
1122
|
+
async executeBatch(commands, context = { source: 'user', confirmed: true }) {
|
|
1123
|
+
const commandSnapshots = cloneCommandBatch(commands);
|
|
1124
|
+
if (!commandSnapshots) {
|
|
1125
|
+
const results = commands.map(cloneFailureResult);
|
|
1126
|
+
return { ok: false, results };
|
|
1127
|
+
}
|
|
1128
|
+
let contextSnapshot;
|
|
1129
|
+
try {
|
|
1130
|
+
contextSnapshot = cloneValue(context);
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
const results = commandSnapshots.map(cloneFailureResult);
|
|
1134
|
+
return { ok: false, results };
|
|
1135
|
+
}
|
|
1136
|
+
const results = [];
|
|
1137
|
+
for (const command of commandSnapshots) {
|
|
1138
|
+
results.push(await this.execute(command, contextSnapshot));
|
|
1139
|
+
}
|
|
1140
|
+
return { ok: results.every((entry) => entry.ok), results };
|
|
1141
|
+
},
|
|
260
1142
|
subscribe(listener) {
|
|
261
1143
|
listeners.add(listener);
|
|
262
1144
|
return () => listeners.delete(listener);
|
|
263
1145
|
},
|
|
264
1146
|
};
|
|
1147
|
+
const localGestureExecutor = async (suppliedRegistry, commands, event) => {
|
|
1148
|
+
const eventState = nativeEventState(event);
|
|
1149
|
+
const isLocalGesture = options.isLocalGesture ?? (() => eventState?.isTrusted);
|
|
1150
|
+
if (!eventState ||
|
|
1151
|
+
!isLocalGesture(event) ||
|
|
1152
|
+
eventState.eventPhase === 0 ||
|
|
1153
|
+
consumedLocalGestureEvents.has(event)) {
|
|
1154
|
+
return {
|
|
1155
|
+
ok: false,
|
|
1156
|
+
results: commands.map((command) => result(command, false, registrations.get(identityKey(command.identity)), 'local_gesture_required')),
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
consumedLocalGestureEvents.add(event);
|
|
1160
|
+
// Bind the entire already-snapshotted batch to the registration generations
|
|
1161
|
+
// present at gesture validation time. Earlier awaited commands must not be
|
|
1162
|
+
// able to authorize later commands against newly mounted replacements.
|
|
1163
|
+
const gestureGenerations = commands.map((command) => registrationGenerations.get(identityKey(command.identity)));
|
|
1164
|
+
const results = [];
|
|
1165
|
+
for (const [index, command] of commands.entries()) {
|
|
1166
|
+
const context = {
|
|
1167
|
+
source: 'user',
|
|
1168
|
+
confirmed: true,
|
|
1169
|
+
};
|
|
1170
|
+
localConfirmationGrants.set(context, {
|
|
1171
|
+
command,
|
|
1172
|
+
snapshot: cloneValue(command),
|
|
1173
|
+
generation: gestureGenerations[index],
|
|
1174
|
+
});
|
|
1175
|
+
try {
|
|
1176
|
+
results.push(await suppliedRegistry.execute(command, context));
|
|
1177
|
+
}
|
|
1178
|
+
finally {
|
|
1179
|
+
// Adapters may retain context references, but no authority survives the
|
|
1180
|
+
// terminal delegation/denial for this exact command.
|
|
1181
|
+
localConfirmationGrants.delete(context);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return { ok: results.every((entry) => entry.ok), results };
|
|
1185
|
+
};
|
|
1186
|
+
localGestureBatchExecutors.set(registry, localGestureExecutor);
|
|
1187
|
+
Object.defineProperty(registry, localGestureBatchExecutor, {
|
|
1188
|
+
value: localGestureExecutor,
|
|
1189
|
+
enumerable: true,
|
|
1190
|
+
});
|
|
1191
|
+
return registry;
|
|
265
1192
|
}
|