jsfe 1.6.0 → 1.7.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 +32 -4
- package/dist/forms/action-projection.d.ts +4 -0
- package/dist/forms/action-projection.d.ts.map +1 -0
- package/dist/forms/action-projection.js +21 -0
- package/dist/forms/action-recovery.d.ts +12 -0
- package/dist/forms/action-recovery.d.ts.map +1 -0
- package/dist/forms/action-recovery.js +125 -0
- package/dist/forms/action-tools.d.ts +29 -0
- package/dist/forms/action-tools.d.ts.map +1 -0
- package/dist/forms/action-tools.js +6 -0
- package/dist/forms/address-flows.d.ts +2 -0
- package/dist/forms/address-flows.d.ts.map +1 -0
- package/dist/forms/address-flows.js +39 -0
- package/dist/forms/choice-flow.d.ts +419 -0
- package/dist/forms/choice-flow.d.ts.map +1 -0
- package/dist/forms/choice-flow.js +707 -0
- package/dist/forms/contracts.d.ts +237 -0
- package/dist/forms/contracts.d.ts.map +1 -0
- package/dist/forms/contracts.js +278 -0
- package/dist/forms/flows.d.ts +611 -0
- package/dist/forms/flows.d.ts.map +1 -0
- package/dist/forms/flows.js +675 -0
- package/dist/forms/identity-flows.d.ts +17 -0
- package/dist/forms/identity-flows.d.ts.map +1 -0
- package/dist/forms/identity-flows.js +302 -0
- package/dist/forms/index.d.ts +7 -0
- package/dist/forms/index.d.ts.map +1 -0
- package/dist/forms/index.js +10 -0
- package/dist/forms/prompt-compiler.d.ts +3 -0
- package/dist/forms/prompt-compiler.d.ts.map +1 -0
- package/dist/forms/prompt-compiler.js +30 -0
- package/dist/forms/session-actions.d.ts +14 -0
- package/dist/forms/session-actions.d.ts.map +1 -0
- package/dist/forms/session-actions.js +40 -0
- package/dist/helpers/address.d.ts +27 -0
- package/dist/helpers/address.d.ts.map +1 -0
- package/dist/helpers/address.js +29 -0
- package/dist/helpers/answer.d.ts +5 -0
- package/dist/helpers/answer.d.ts.map +1 -1
- package/dist/helpers/answer.js +5 -3
- package/dist/helpers/choice-offer.d.ts +3 -0
- package/dist/helpers/choice-offer.d.ts.map +1 -0
- package/dist/helpers/choice-offer.js +6 -0
- package/dist/helpers/form-actions.d.ts +13 -0
- package/dist/helpers/form-actions.d.ts.map +1 -0
- package/dist/helpers/form-actions.js +144 -0
- package/dist/helpers/form-sources.d.ts +7 -0
- package/dist/helpers/form-sources.d.ts.map +1 -0
- package/dist/helpers/form-sources.js +47 -0
- package/dist/helpers/form-state.d.ts +94 -0
- package/dist/helpers/form-state.d.ts.map +1 -0
- package/dist/helpers/form-state.js +870 -0
- package/dist/helpers/index.d.ts +114 -1
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/index.js +9 -4
- package/dist/helpers/match-inputs.d.ts +14 -0
- package/dist/helpers/match-inputs.d.ts.map +1 -1
- package/dist/helpers/match-inputs.js +154 -41
- package/dist/helpers/money.d.ts +12 -0
- package/dist/helpers/money.d.ts.map +1 -0
- package/dist/helpers/money.js +54 -0
- package/dist/helpers/spoken-money.d.ts +20 -0
- package/dist/helpers/spoken-money.d.ts.map +1 -0
- package/dist/helpers/spoken-money.js +143 -0
- package/dist/host/action-journal.d.ts +98 -0
- package/dist/host/action-journal.d.ts.map +1 -0
- package/dist/host/action-journal.js +218 -0
- package/dist/host/action-session.d.ts +12 -0
- package/dist/host/action-session.d.ts.map +1 -0
- package/dist/host/action-session.js +29 -0
- package/dist/host/capabilities.d.ts +14 -0
- package/dist/host/capabilities.d.ts.map +1 -0
- package/dist/host/capabilities.js +60 -0
- package/dist/host/checkpoint-codec-error.d.ts +6 -0
- package/dist/host/checkpoint-codec-error.d.ts.map +1 -0
- package/dist/host/checkpoint-codec-error.js +4 -0
- package/dist/host/index.d.ts +6 -0
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +6 -0
- package/dist/host/intent-stages.d.ts +1 -0
- package/dist/host/intent-stages.d.ts.map +1 -1
- package/dist/host/intent-stages.js +3 -3
- package/dist/host/node.d.ts +17 -0
- package/dist/host/node.d.ts.map +1 -0
- package/dist/host/node.js +64 -0
- package/dist/host/recovery-identity.d.ts +16 -0
- package/dist/host/recovery-identity.d.ts.map +1 -0
- package/dist/host/recovery-identity.js +73 -0
- package/dist/host/recovery-policy.d.ts +5 -0
- package/dist/host/recovery-policy.d.ts.map +1 -0
- package/dist/host/recovery-policy.js +54 -0
- package/dist/host/runtime-manifest.d.ts +3 -0
- package/dist/host/runtime-manifest.d.ts.map +1 -0
- package/dist/host/runtime-manifest.js +38 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +186 -45
- package/dist/runtime-identity.d.ts +3 -0
- package/dist/runtime-identity.d.ts.map +1 -0
- package/dist/runtime-identity.js +1 -0
- package/docs/development-release.md +25 -0
- package/docs/forms.md +132 -0
- package/package.json +15 -4
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/** Shared FORM semantics. Pure readers plus explicit mutations of the supplied session container; no host I/O. */
|
|
2
|
+
import { readInputProfile } from './match-inputs.js';
|
|
3
|
+
import { validateMoneyOptions } from './money.js';
|
|
4
|
+
const own = (o, k) => Object.prototype.hasOwnProperty.call(o || {}, k);
|
|
5
|
+
const forbidden = new Set(['__proto__', 'prototype', 'constructor']);
|
|
6
|
+
export function formValuesEqual(a, b) {
|
|
7
|
+
if (Object.is(a, b))
|
|
8
|
+
return true;
|
|
9
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b))
|
|
10
|
+
return false;
|
|
11
|
+
const ak = Object.keys(a), bk = Object.keys(b);
|
|
12
|
+
return ak.length === bk.length && ak.every(k => own(b, k) && formValuesEqual(a[k], b[k]));
|
|
13
|
+
}
|
|
14
|
+
export const hasFormValue = v => v !== undefined && v !== null && v !== '';
|
|
15
|
+
export function formPath(root, path) {
|
|
16
|
+
return String(path || '').split('.').reduce((o, k) => forbidden.has(k) || o === undefined || o === null || !own(o, k) ? undefined : o[k], root);
|
|
17
|
+
}
|
|
18
|
+
/** Resolve once before the state is used by collection, validation or completion. */
|
|
19
|
+
export function resolveFormInputs(inputs, presets = {}) {
|
|
20
|
+
if (!Array.isArray(inputs))
|
|
21
|
+
throw new Error('FORM inputs must be an array');
|
|
22
|
+
const names = new Set();
|
|
23
|
+
const resolve = raw => {
|
|
24
|
+
if (!raw || typeof raw !== 'object' || !raw.name || forbidden.has(raw.name))
|
|
25
|
+
throw new Error('FORM input needs a safe name');
|
|
26
|
+
if (names.has(raw.name))
|
|
27
|
+
throw new Error(`Duplicate FORM input ${raw.name}`);
|
|
28
|
+
names.add(raw.name);
|
|
29
|
+
if (raw.field && !own(presets, raw.field))
|
|
30
|
+
throw new Error(`Unknown FORM field preset ${raw.field}`);
|
|
31
|
+
const input = { ...(raw.field ? presets[raw.field] : {}), ...raw };
|
|
32
|
+
if (input.profile?.money !== undefined) {
|
|
33
|
+
if (input.profile.kind !== 'amount')
|
|
34
|
+
throw new Error('FORM money options require amount profile');
|
|
35
|
+
validateMoneyOptions(input.profile.money);
|
|
36
|
+
}
|
|
37
|
+
validateFormExhaustion(input.on_exhausted);
|
|
38
|
+
if (input.max_attempts !== undefined && (!Number.isInteger(input.max_attempts) || input.max_attempts < 1))
|
|
39
|
+
throw new Error('FORM max_attempts must be a positive integer');
|
|
40
|
+
if (input.any_of)
|
|
41
|
+
input.any_of = input.any_of.map(member => resolve({ ...member,
|
|
42
|
+
depends_on: [...new Set([...(input.depends_on || []), ...(member.depends_on || [])])],
|
|
43
|
+
reset_on: [...new Set([...(input.reset_on || []), ...(member.reset_on || [])])],
|
|
44
|
+
when: { ...(input.when || {}), ...(member.when || {}) } }));
|
|
45
|
+
if (!input.form && !input.profile && !input.any_of && !input.bind)
|
|
46
|
+
throw new Error(`FORM input ${input.name} has no reader or worker`);
|
|
47
|
+
return input;
|
|
48
|
+
};
|
|
49
|
+
const resolved = inputs.map(resolve);
|
|
50
|
+
for (const input of resolved)
|
|
51
|
+
if (input.activate !== undefined || input.activate_on !== undefined)
|
|
52
|
+
validateFormActivation(input, resolved);
|
|
53
|
+
return resolved;
|
|
54
|
+
}
|
|
55
|
+
/** Dormant entry is explicit: named descendant fields may propose local controllers, never ambient state. */
|
|
56
|
+
function validateFormActivation(input, specs) {
|
|
57
|
+
if (!input.form || !input.activate || typeof input.activate !== 'object' || Array.isArray(input.activate) || !Object.keys(input.activate).length || !Array.isArray(input.activate_on) || !input.activate_on.length)
|
|
58
|
+
throw new Error(`FORM activation ${input.name} needs a subform, controllers and activate_on fields`);
|
|
59
|
+
for (const [name, value] of Object.entries(input.activate)) {
|
|
60
|
+
const controller = specs.flatMap(s => s.any_of || [s]).find(s => s.name === name);
|
|
61
|
+
if (!controller?.profile || controller.form || controller.bind || name === input.name || !hasFormValue(value) || typeof value === 'object')
|
|
62
|
+
throw new Error(`FORM activation ${input.name} has invalid controller ${name}`);
|
|
63
|
+
const reading = controller.profile.kind === 'choice' && (controller.profile.choices || []).some(choice => choice.key === value) ? { value } : readInputProfile({ ...controller.profile, marked: false }, value, true);
|
|
64
|
+
if (!reading || !own(reading, 'value') || !hasFormValue(reading.value) || !formValuesEqual(reading.value, value))
|
|
65
|
+
throw new Error(`FORM activation ${input.name} has invalid controller value ${name}`);
|
|
66
|
+
}
|
|
67
|
+
for (const path of input.activate_on) {
|
|
68
|
+
if (typeof path !== 'string' || !path || path.split('.').some(name => !name || forbidden.has(name)))
|
|
69
|
+
throw new Error(`FORM activation ${input.name} has invalid entry field`);
|
|
70
|
+
if (input.schema) {
|
|
71
|
+
let inputs = input.schema.inputs, entry;
|
|
72
|
+
for (const name of path.split('.')) {
|
|
73
|
+
const group = inputs?.find(s => s.name === name || s.any_of?.some(i => i.name === name));
|
|
74
|
+
entry = group?.any_of?.find(i => i.name === name) || group;
|
|
75
|
+
if (entry?.prepare || group?.prepare)
|
|
76
|
+
throw new Error(`FORM activation ${input.name} entry ${path} requires preparation`);
|
|
77
|
+
inputs = entry?.schema?.inputs;
|
|
78
|
+
}
|
|
79
|
+
if (!entry?.profile || entry.form)
|
|
80
|
+
throw new Error(`FORM activation ${input.name} has unknown entry ${path}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function dormantActivation(spec, state, cargo) {
|
|
85
|
+
if (spec.activate === undefined && spec.activate_on === undefined)
|
|
86
|
+
return null;
|
|
87
|
+
validateFormActivation(spec, state.specs);
|
|
88
|
+
return formCondition(spec, { ...state.values, ...spec.activate }, cargo, state) === true ? { form: state.id, input: spec.name, assignments: spec.activate } : null;
|
|
89
|
+
}
|
|
90
|
+
/** ONE condition language for selection, matching, unwinding and presentation. */
|
|
91
|
+
function formCondition(input, values = {}, cargo = {}, state = {}) {
|
|
92
|
+
const value = key => key.startsWith('cargo.') || key.startsWith('form.') ? formPath({ cargo, form: state }, key) : values[key];
|
|
93
|
+
if ((input.unless || []).some(key => hasFormValue(value(key)) && value(key) !== false))
|
|
94
|
+
return false;
|
|
95
|
+
const conditions = Object.entries(input.when || {}).map(([key, wanted]) => {
|
|
96
|
+
const have = String(value(key) ?? '');
|
|
97
|
+
const list = [].concat(wanted).map(String);
|
|
98
|
+
const yes = list.filter(v => !v.startsWith('!'));
|
|
99
|
+
const matches = (!yes.length || yes.includes(have)) && list.filter(v => v.startsWith('!')).every(v => have !== v.slice(1));
|
|
100
|
+
return matches ? true : !hasFormValue(value(key)) ? null : false;
|
|
101
|
+
});
|
|
102
|
+
return conditions.includes(false) ? false : conditions.includes(null) ? null : true;
|
|
103
|
+
}
|
|
104
|
+
export function formInputApplies(input, values = {}, cargo = {}, state = {}) {
|
|
105
|
+
return formCondition(input, values, cargo, state) === true;
|
|
106
|
+
}
|
|
107
|
+
export function formInputFilled(input, state, cargo = {}, checking = new Set()) {
|
|
108
|
+
if (checking.has(input.name))
|
|
109
|
+
return false;
|
|
110
|
+
checking = new Set(checking);
|
|
111
|
+
checking.add(input.name);
|
|
112
|
+
for (const name of input.depends_on || []) {
|
|
113
|
+
if (name.startsWith('cargo.') || name.startsWith('form.')) {
|
|
114
|
+
if (!hasFormValue(formPath({ cargo, form: state }, name)))
|
|
115
|
+
return false;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const dependency = state.specs.find(s => s.name === name || s.any_of?.some(i => i.name === name));
|
|
119
|
+
if (!dependency || !formInputFilled(dependency, state, cargo, checking))
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (input.bind) {
|
|
123
|
+
const v = formPath({ cargo }, input.bind);
|
|
124
|
+
return hasFormValue(v) && v !== false;
|
|
125
|
+
}
|
|
126
|
+
const filled = name => !state.errors?.[name] && !state.pending?.[name] && hasFormValue(state.values[name]);
|
|
127
|
+
return input.any_of ? input.any_of.some(i => formInputApplies(i, state.values, cargo, state) && formInputFilled(i, state, cargo, checking)) : filled(input.name);
|
|
128
|
+
}
|
|
129
|
+
export function nextFormInput(state, cargo = {}) {
|
|
130
|
+
if (!state)
|
|
131
|
+
return '';
|
|
132
|
+
const requirement = input => input.required_when === undefined ? true : formCondition({ when: input.required_when }, state.values, cargo, state);
|
|
133
|
+
const relevant = (state.specs || []).filter(input => formCondition(input, state.values, cargo, state) !== false && requirement(input) !== false);
|
|
134
|
+
const missing = relevant.filter(input => formInputApplies(input, state.values, cargo, state) && requirement(input) === true && !formInputFilled(input, state, cargo));
|
|
135
|
+
const ready = missing.find(input => (input.depends_on || []).every(name => name.startsWith('cargo.') || name.startsWith('form.') ? hasFormValue(formPath({ cargo, form: state }, name)) : formInputFilled(state.specs.find(s => s.name === name) || { name }, state, cargo)));
|
|
136
|
+
return ready?.name || (missing.length || relevant.some(input => formCondition(input, state.values, cargo, state) === null || requirement(input) === null) ? '__blocked__' : '');
|
|
137
|
+
}
|
|
138
|
+
export function formInputChain(forms, id, cargo = {}) {
|
|
139
|
+
const out = [], seen = new Set();
|
|
140
|
+
const walk = (target, scope, prefix = '', prefixEs = '', activation = null, entries = null) => {
|
|
141
|
+
if (!forms[target] || seen.has(target))
|
|
142
|
+
return;
|
|
143
|
+
seen.add(target);
|
|
144
|
+
const state = forms[target];
|
|
145
|
+
for (const spec of state.specs || []) {
|
|
146
|
+
const condition = formCondition(spec, state.values, cargo, state);
|
|
147
|
+
const dormant = condition !== true && spec.form && spec.schema ? dormantActivation(spec, state, cargo) : null;
|
|
148
|
+
if (condition === false && !dormant)
|
|
149
|
+
continue;
|
|
150
|
+
if (entries && !entries.some(path => path === spec.name || path.startsWith(spec.name + '.') || spec.any_of?.some(i => path === i.name)))
|
|
151
|
+
continue;
|
|
152
|
+
if (spec.form && spec.schema) {
|
|
153
|
+
// A dormant ancestor nominates only these entries; nested inactive branches need their own contract.
|
|
154
|
+
if (activation && condition === false)
|
|
155
|
+
continue;
|
|
156
|
+
const child = openFormInstance(cargo, spec.form, spec.schema.inputs, { parent: target, input: spec.name, providers: spec.schema.provides, questions: spec.schema.questions, on_exhausted: spec.schema.on_exhausted });
|
|
157
|
+
const childState = forms[child];
|
|
158
|
+
if (state.values[spec.name] && typeof state.values[spec.name] === 'object' && !Object.keys(childState.values).length) {
|
|
159
|
+
childState.values = structuredClone(state.values[spec.name]);
|
|
160
|
+
const snapshot = state.childResults?.[spec.name];
|
|
161
|
+
childState.data = structuredClone(snapshot?.data || {});
|
|
162
|
+
childState.prepared = structuredClone(snapshot?.prepared || {});
|
|
163
|
+
childState.revision = Number(snapshot?.revision || 0);
|
|
164
|
+
childState.accepted = structuredClone(snapshot?.accepted || Object.fromEntries(Object.keys(childState.values).map(name => [name, { origin: 'child', revision: 0 }])));
|
|
165
|
+
}
|
|
166
|
+
const childEntries = entries ? entries.filter(path => path.startsWith(spec.name + '.')).map(path => path.slice(spec.name.length + 1)) : null;
|
|
167
|
+
walk(child, scope, spec.label || prefix, spec.label_es || spec.label || prefixEs, activation || dormant, dormant ? spec.activate_on : childEntries);
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
for (const input of spec.any_of || [spec]) {
|
|
171
|
+
if (entries && !entries.includes(input.name))
|
|
172
|
+
continue;
|
|
173
|
+
if (formCondition(input, state.values, cargo, state) !== false && !input.form && input.profile)
|
|
174
|
+
out.push({ ...input,
|
|
175
|
+
...(prefix ? { label: `${prefix}: ${input.label || input.name}`, label_es: `${prefixEs || prefix}: ${input.label_es || input.label || input.name}` } : {}),
|
|
176
|
+
...(input.choices_from && Array.isArray(state.data?.[input.choices_from]) ? { profile: { ...input.profile, choices: state.data[input.choices_from] } } : {}),
|
|
177
|
+
...(activation ? { activation } : {}), form: target, scope, ...(spec.any_of ? { group: spec.name, exclusive: spec.exclusive === true } : {}), value: state.values[input.name] });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
while (id && forms[id] && !seen.has(id)) {
|
|
182
|
+
const state = forms[id];
|
|
183
|
+
walk(id, id);
|
|
184
|
+
id = state.parent;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/** Attach the canonical field contract to every claim, including independent ambiguity groups. */
|
|
189
|
+
export function enrichFormMatches(matches, chain, form, names = [], asked = '') {
|
|
190
|
+
const enrich = h => {
|
|
191
|
+
const spec = chain.find(c => c.form === h.form && c.name === h.name) || {};
|
|
192
|
+
return { ...h, label: h.option_label || spec.label || h.name, label_es: h.option_label_es || spec.label_es || spec.label || h.name,
|
|
193
|
+
validator: spec.validator || '', after: spec.after || '', validate_always: spec.validate_always === true, ...(spec.activation ? { activation: spec.activation } : {}), pii: spec.pii === true, current: h.form === form && names.includes(h.name), asked };
|
|
194
|
+
};
|
|
195
|
+
return { ...matches, hits: matches.hits.map(enrich), ambiguous: matches.ambiguous.map(enrich), remaining: (matches.remaining || []).map(group => group.map(enrich)) };
|
|
196
|
+
}
|
|
197
|
+
/** Expand inferred controllers before normal ordering/validation. A slot's entry and controllers form one commit unit. */
|
|
198
|
+
export function expandFormActivations(cargo, hits = []) {
|
|
199
|
+
const result = hits.map(h => ({ ...h })), groups = new Map();
|
|
200
|
+
for (const hit of result)
|
|
201
|
+
if (hit.activation && !hit.activation_group) {
|
|
202
|
+
const a = hit.activation, key = `${a.form}/${a.input}`;
|
|
203
|
+
if (!groups.has(key))
|
|
204
|
+
groups.set(key, []);
|
|
205
|
+
groups.get(key).push(hit);
|
|
206
|
+
}
|
|
207
|
+
for (const [key, entries] of groups) {
|
|
208
|
+
const a = entries[0].activation, state = cargo.forms?.[a.form], slot = state?.specs.find(s => s.name === a.input);
|
|
209
|
+
if (!state || !slot)
|
|
210
|
+
throw new Error('FORM activation owner no longer exists');
|
|
211
|
+
validateFormActivation(slot, state.specs);
|
|
212
|
+
const controllers = [];
|
|
213
|
+
let conflict = false;
|
|
214
|
+
for (const [name, value] of Object.entries(slot.activate)) {
|
|
215
|
+
let hit = result.find(h => h.form === state.id && h.name === name && !h.activation_inferred);
|
|
216
|
+
if (hit && !formValuesEqual(hit.value, value)) {
|
|
217
|
+
conflict = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
if (!hit) {
|
|
221
|
+
const spec = state.specs.flatMap(s => s.any_of || [s]).find(s => s.name === name);
|
|
222
|
+
hit = { ...spec, form: state.id, value, raw: String(value), utterance: entries[0].utterance, origin: 'inferred', activation_inferred: true, current: false, silent: true };
|
|
223
|
+
controllers.push(hit);
|
|
224
|
+
}
|
|
225
|
+
else
|
|
226
|
+
controllers.push(hit);
|
|
227
|
+
}
|
|
228
|
+
if (conflict) {
|
|
229
|
+
for (const hit of entries) {
|
|
230
|
+
hit.invalid = true;
|
|
231
|
+
hit.activation_conflict = true;
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const members = entries.concat(controllers).map(h => `${h.form}/${h.name}`);
|
|
236
|
+
for (const hit of entries.concat(controllers)) {
|
|
237
|
+
hit.activation_group = key;
|
|
238
|
+
hit.activation_members = members;
|
|
239
|
+
if (hit.activation_inferred)
|
|
240
|
+
hit.activation_entry = { form: entries[0].form, name: entries[0].name, label: entries[0].label, label_es: entries[0].label_es, asked: entries[0].asked };
|
|
241
|
+
}
|
|
242
|
+
for (const hit of controllers)
|
|
243
|
+
if (!result.includes(hit))
|
|
244
|
+
result.push(hit);
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
/** No partial activation: validation failure suppresses inferred changes; pending prerequisites retain the complete group. */
|
|
249
|
+
export function settleFormActivations(batch = {}, cargo = null) {
|
|
250
|
+
const accepted = [...(batch.accepted || [])], rejected = [...(batch.rejected || [])], pending = [...(batch.pending || [])];
|
|
251
|
+
const all = accepted.concat(rejected, pending), keys = new Set(all.map(h => h.activation_group).filter(Boolean));
|
|
252
|
+
const held = new Set(), discarded = new Set();
|
|
253
|
+
for (const key of keys) {
|
|
254
|
+
const group = all.filter(h => h.activation_group === key), members = new Set(group.map(h => `${h.form}/${h.name}`));
|
|
255
|
+
if (rejected.some(h => h.activation_group === key) || (group[0].activation_members || []).some(name => !members.has(name)))
|
|
256
|
+
discarded.add(key);
|
|
257
|
+
else if (pending.some(h => h.activation_group === key))
|
|
258
|
+
held.add(key);
|
|
259
|
+
}
|
|
260
|
+
if (cargo && discarded.size)
|
|
261
|
+
for (const state of Object.values(cargo.forms || {}))
|
|
262
|
+
for (const [name, hit] of Object.entries(state.pending || {}))
|
|
263
|
+
if (discarded.has(hit.activation_group))
|
|
264
|
+
delete state.pending[name];
|
|
265
|
+
return { accepted: accepted.filter(h => !held.has(h.activation_group) && !discarded.has(h.activation_group)),
|
|
266
|
+
rejected: rejected.map(h => h.activation_inferred ? { ...h, ...h.activation_entry, activation_inferred: false } : h), pending: pending.concat(accepted.filter(h => held.has(h.activation_group))).filter(h => !discarded.has(h.activation_group)).map(h => held.has(h.activation_group) ? { ...h, ok: undefined, outcome: 'pending', silent: true } : h) };
|
|
267
|
+
}
|
|
268
|
+
/** Explicit ownership: unrelated tasks and sibling inputs never share an instance by definition id. */
|
|
269
|
+
export function openFormInstance(cargo, definition, specs, { parent = null, input = '', resume = '', task = '', providers = null, questions = null, context = false, incomplete_outcome = undefined, on_exhausted = undefined } = {}) {
|
|
270
|
+
const incomplete = validateFormIncompleteOutcome(incomplete_outcome);
|
|
271
|
+
validateFormExhaustion(on_exhausted);
|
|
272
|
+
cargo.forms ||= {};
|
|
273
|
+
const existing = resume && cargo.forms[resume];
|
|
274
|
+
if (resume && !existing)
|
|
275
|
+
throw new Error('FORM resume instance no longer exists');
|
|
276
|
+
if (existing) {
|
|
277
|
+
if (existing.definition !== definition || existing.parent !== parent || existing.result_input !== input || !!existing.context_only !== !!context || (task && existing.task !== task))
|
|
278
|
+
throw new Error('FORM resume ownership mismatch');
|
|
279
|
+
if (incomplete_outcome !== undefined)
|
|
280
|
+
existing.incomplete_outcome = incomplete;
|
|
281
|
+
if (on_exhausted !== undefined)
|
|
282
|
+
existing.on_exhausted = on_exhausted;
|
|
283
|
+
return resume;
|
|
284
|
+
}
|
|
285
|
+
if (parent && !cargo.forms[parent])
|
|
286
|
+
throw new Error('FORM parent does not exist');
|
|
287
|
+
const owner = parent ? cargo.forms[parent] : null;
|
|
288
|
+
if (owner) {
|
|
289
|
+
const child = context ? owner.contexts?.[definition] : owner.children?.[input];
|
|
290
|
+
if (child && cargo.forms[child]) {
|
|
291
|
+
if (cargo.forms[child].definition !== definition)
|
|
292
|
+
throw new Error('FORM child definition changed without invalidation');
|
|
293
|
+
if (incomplete_outcome !== undefined)
|
|
294
|
+
cargo.forms[child].incomplete_outcome = incomplete;
|
|
295
|
+
if (on_exhausted !== undefined)
|
|
296
|
+
cargo.forms[child].on_exhausted = on_exhausted;
|
|
297
|
+
return child;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
cargo.formSequence = Number(cargo.formSequence || 0) + 1;
|
|
301
|
+
const id = `${definition}:${cargo.formSequence}`;
|
|
302
|
+
cargo.forms[id] = { id, definition, task: owner?.task || task || id, parent, result_input: input,
|
|
303
|
+
on_exhausted: on_exhausted || '', incomplete_outcome: incomplete, context_only: !!context, parent_revision: owner?.revision ?? null, specs, values: {}, accepted: {}, errors: {}, pending: {}, seeds: {}, attempts: {}, prepared: {}, data: {}, arguments: {}, contexts: {}, children: {}, childResults: {}, revision: 0,
|
|
304
|
+
providers: providers || owner?.providers || null, questions: questions?.length ? questions : owner?.questions || [], status: 'collecting' };
|
|
305
|
+
if (owner) {
|
|
306
|
+
if (context) {
|
|
307
|
+
owner.contexts ||= {};
|
|
308
|
+
owner.contexts[definition] = id;
|
|
309
|
+
}
|
|
310
|
+
else
|
|
311
|
+
owner.children[input] = id;
|
|
312
|
+
}
|
|
313
|
+
return id;
|
|
314
|
+
}
|
|
315
|
+
/** An incomplete optional follow-up may preserve an explicitly completed task outcome. */
|
|
316
|
+
export function validateFormIncompleteOutcome(value) {
|
|
317
|
+
if (value === undefined || value === null)
|
|
318
|
+
return null;
|
|
319
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['outcome', 'reason'].includes(key)) || !['resolved', 'unresolved', 'deflected'].includes(value.outcome) || typeof value.reason !== 'string' || !value.reason.trim())
|
|
320
|
+
throw new Error('FORM incomplete_outcome requires a valid outcome and a nonempty reason');
|
|
321
|
+
return { outcome: value.outcome, reason: value.reason.trim() };
|
|
322
|
+
}
|
|
323
|
+
/** Only actual owned ancestors may provide a fallback; another task's outcome is irrelevant. */
|
|
324
|
+
export function formIncompleteOutcome(cargo, id, fallback = { outcome: 'unresolved', reason: 'form_incomplete' }) {
|
|
325
|
+
const seen = new Set();
|
|
326
|
+
while (id && cargo.forms?.[id] && !seen.has(id)) {
|
|
327
|
+
seen.add(id);
|
|
328
|
+
const state = cargo.forms[id], declared = validateFormIncompleteOutcome(state.incomplete_outcome);
|
|
329
|
+
if (declared)
|
|
330
|
+
return { ...declared, owner: id };
|
|
331
|
+
id = state.parent;
|
|
332
|
+
}
|
|
333
|
+
return { ...validateFormIncompleteOutcome(fallback), owner: '' };
|
|
334
|
+
}
|
|
335
|
+
/** Explicit worker references only; recovery cannot contain an inline effect or caller expression. */
|
|
336
|
+
export function validateFormExhaustion(value) {
|
|
337
|
+
if (value === undefined || value === null || value === '')
|
|
338
|
+
return '';
|
|
339
|
+
if (typeof value !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(value))
|
|
340
|
+
throw new Error('FORM on_exhausted must name a worker');
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
/** Resolve nearest declared owner, release only its exhausted input branch, and withdraw old consent.
|
|
344
|
+
* A reboot to this worker unwinds the collector; no incomplete value is handed to an ancestor.
|
|
345
|
+
* Recovery must deliberately collect another choice before resuming the preserved owning draft.
|
|
346
|
+
*/
|
|
347
|
+
export function exhaustFormInput(cargo, failed_form, failed_input = '') {
|
|
348
|
+
const seen = new Set();
|
|
349
|
+
let id = failed_form, input = failed_input;
|
|
350
|
+
while (id && cargo.forms?.[id] && !seen.has(id)) {
|
|
351
|
+
seen.add(id);
|
|
352
|
+
const state = cargo.forms[id];
|
|
353
|
+
const group = (state.specs || []).find(s => s.name === input || s.any_of?.some(i => i.name === input));
|
|
354
|
+
const member = group?.any_of?.find(i => i.name === input);
|
|
355
|
+
const flow = validateFormExhaustion(member?.on_exhausted || group?.on_exhausted || state.on_exhausted);
|
|
356
|
+
if (flow) {
|
|
357
|
+
const result = { flow, form: id, failed_form, input, failed_input, reason: 'attempts_exhausted' };
|
|
358
|
+
if (input) {
|
|
359
|
+
resetFormInputs(cargo, id, [input]);
|
|
360
|
+
delete state.attempts[input];
|
|
361
|
+
for (const member of group?.any_of || [])
|
|
362
|
+
delete state.attempts[member.name];
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
resetFormInputs(cargo, id, state.specs.map(spec => spec.name));
|
|
366
|
+
state.attempts = {};
|
|
367
|
+
}
|
|
368
|
+
state.validationAttempts = 0;
|
|
369
|
+
state.confirmation = null;
|
|
370
|
+
delete cargo.formConfirm?.[id];
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
input = state.context_only ? '' : state.result_input;
|
|
374
|
+
id = state.parent;
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
/** Activation values are queued for the same validator flow as conversational readings. */
|
|
379
|
+
export function rememberFormArguments(state, supplied, names, resume = false) {
|
|
380
|
+
state.arguments ||= {};
|
|
381
|
+
if (!resume)
|
|
382
|
+
for (const name of names)
|
|
383
|
+
if (hasFormValue(supplied[name]))
|
|
384
|
+
state.arguments[name] = structuredClone(supplied[name]);
|
|
385
|
+
return state.arguments;
|
|
386
|
+
}
|
|
387
|
+
export function formInitialReadings(state, supplied = {}, cargo = {}) {
|
|
388
|
+
const hits = [];
|
|
389
|
+
// Named initial fields have no ambiguity to resolve. Conditions are evaluated against validated
|
|
390
|
+
// predecessors by the common batch, so a new controller is never hidden by its old committed value.
|
|
391
|
+
for (const group of state.specs || [])
|
|
392
|
+
for (const input of group.any_of || [group]) {
|
|
393
|
+
if (!own(supplied, input.name) || !hasFormValue(supplied[input.name]) || !input.profile)
|
|
394
|
+
continue;
|
|
395
|
+
const value = supplied[input.name];
|
|
396
|
+
const reading = typeof value === 'object' ? null : input.profile.kind === 'choice' && (input.profile.choices || []).some(c => c.key === String(value))
|
|
397
|
+
? { value: String(value) } : readInputProfile({ ...input.profile, marked: false }, value, true);
|
|
398
|
+
const valid = reading && own(reading, 'value') && hasFormValue(reading.value);
|
|
399
|
+
hits.push({ ...input, label: input.label || input.name, label_es: input.label_es || input.label || input.name, form: state.id,
|
|
400
|
+
value: valid ? reading.value : value, ...(reading?.refused === true ? { refused: true } : {}), invalid: !valid, known: false, current: true, origin: 'activation', raw: String(value), utterance: String(supplied.userInput || '') });
|
|
401
|
+
}
|
|
402
|
+
const projected = proposedFormValues(cargo, state.id, hits.filter(h => !h.invalid));
|
|
403
|
+
for (const group of state.specs || []) {
|
|
404
|
+
const nested = supplied[group.name];
|
|
405
|
+
if (!group.form || !nested || typeof nested !== 'object' || Array.isArray(nested))
|
|
406
|
+
continue;
|
|
407
|
+
const condition = formCondition(group, projected, cargo, state), inactive = formCondition(group, state.values, cargo, state) !== true;
|
|
408
|
+
const nominated = group.activate_on?.some(path => hasFormValue(formPath(nested, path)));
|
|
409
|
+
const activation = inactive && nominated ? dormantActivation(group, { ...state, values: projected }, cargo) : null;
|
|
410
|
+
if (!group.schema || condition === false && !activation || inactive && group.activate && !activation && condition !== true) {
|
|
411
|
+
state.seeds[group.name] = nested;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const child = openFormInstance(cargo, group.form, group.schema.inputs, { parent: state.id, input: group.name, providers: group.schema.provides, questions: group.schema.questions, on_exhausted: group.schema.on_exhausted });
|
|
415
|
+
const readings = formInitialReadings(cargo.forms[child], { ...nested, userInput: supplied.userInput || nested.userInput || '' }, cargo);
|
|
416
|
+
// Every explicitly supplied descendant participates in the same atomic subform change. A
|
|
417
|
+
// nominated entry is still required to propose controllers; bare ancillary values cannot do so.
|
|
418
|
+
hits.push(...readings.map(hit => activation ? { ...hit, activation } : hit));
|
|
419
|
+
}
|
|
420
|
+
return hits;
|
|
421
|
+
}
|
|
422
|
+
/** One exclusive group is clarified at a time; independent readings continue through the same protocol. */
|
|
423
|
+
export function exclusiveFormReadings(cargo, hits = []) {
|
|
424
|
+
for (const hit of hits) {
|
|
425
|
+
const group = cargo.forms?.[hit.form]?.specs.find(s => s.exclusive && s.any_of?.some(i => i.name === hit.name));
|
|
426
|
+
if (!group)
|
|
427
|
+
continue;
|
|
428
|
+
const members = new Set(group.any_of.map(i => i.name));
|
|
429
|
+
const ambiguous = hits.filter(h => h.form === hit.form && members.has(h.name));
|
|
430
|
+
if (new Set(ambiguous.map(h => h.name)).size > 1)
|
|
431
|
+
return { ambiguous, hits: hits.filter(h => !ambiguous.includes(h)) };
|
|
432
|
+
}
|
|
433
|
+
return { ambiguous: [], hits };
|
|
434
|
+
}
|
|
435
|
+
export function orderFormReadings(cargo, hits = []) {
|
|
436
|
+
const result = [], pending = hits.slice();
|
|
437
|
+
const dependencies = h => {
|
|
438
|
+
const spec = cargo.forms?.[h.form]?.specs.flatMap(s => s.any_of || [s]).find(s => s.name === h.name) || {};
|
|
439
|
+
return [...formReadingDependencies(cargo, h), ...(spec.reset_on || []), ...Object.keys(spec.when || {}).filter(k => !k.startsWith('cargo.') && !k.startsWith('form.'))];
|
|
440
|
+
};
|
|
441
|
+
const blocks = (h, other) => {
|
|
442
|
+
if (other.form === h.form && other.name !== h.name && dependencies(h).includes(other.name))
|
|
443
|
+
return true;
|
|
444
|
+
let state = cargo.forms?.[h.form];
|
|
445
|
+
const seen = new Set();
|
|
446
|
+
while (state?.parent && !seen.has(state.parent)) {
|
|
447
|
+
seen.add(state.parent);
|
|
448
|
+
const parent = cargo.forms[state.parent];
|
|
449
|
+
const spec = parent?.specs.find(s => s.name === state.result_input);
|
|
450
|
+
if (parent && other.form === parent.id && [...(spec?.depends_on || []), ...(spec?.reset_on || []), ...Object.keys(spec?.when || {})].includes(other.name))
|
|
451
|
+
return true;
|
|
452
|
+
state = parent;
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
};
|
|
456
|
+
while (pending.length) {
|
|
457
|
+
const i = pending.findIndex(h => !pending.some(other => blocks(h, other)));
|
|
458
|
+
if (i < 0)
|
|
459
|
+
throw new Error('FORM candidate dependencies contain a cycle');
|
|
460
|
+
result.push(pending.splice(i, 1)[0]);
|
|
461
|
+
}
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
export function formReadingDependencies(cargo, hit) {
|
|
465
|
+
const spec = cargo.forms?.[hit.form]?.specs.flatMap(s => s.any_of || [s]).find(s => s.name === hit.name) || {};
|
|
466
|
+
return spec.depends_on || [];
|
|
467
|
+
}
|
|
468
|
+
export function rejectFormReadings(cargo, hits = []) {
|
|
469
|
+
for (const hit of hits) {
|
|
470
|
+
const state = cargo.forms?.[hit.form];
|
|
471
|
+
if (!state)
|
|
472
|
+
throw new Error('FORM rejection requires an existing instance');
|
|
473
|
+
state.errors ||= {};
|
|
474
|
+
state.errors[hit.name] = hit.message || 'invalid';
|
|
475
|
+
delete state.pending[hit.name];
|
|
476
|
+
state.confirmation = null;
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
export function proposedFormValues(cargo, id, accepted = []) {
|
|
481
|
+
return Object.assign({}, cargo.forms?.[id]?.values, ...accepted.filter(h => h.form === id).map(h => ({ [h.name]: h.value })));
|
|
482
|
+
}
|
|
483
|
+
export function formReadingRelevant(cargo, hit, accepted = []) {
|
|
484
|
+
let state = cargo.forms?.[hit.form];
|
|
485
|
+
if (!state)
|
|
486
|
+
return false;
|
|
487
|
+
const input = state.specs.flatMap(s => s.any_of || [s]).find(s => s.name === hit.name);
|
|
488
|
+
if (!input || formCondition(input, proposedFormValues(cargo, state.id, accepted), cargo, state) === false)
|
|
489
|
+
return false;
|
|
490
|
+
const seen = new Set();
|
|
491
|
+
while (state.parent && !seen.has(state.parent)) {
|
|
492
|
+
seen.add(state.parent);
|
|
493
|
+
const parent = cargo.forms[state.parent];
|
|
494
|
+
if (state.context_only) {
|
|
495
|
+
if (!parent)
|
|
496
|
+
return false;
|
|
497
|
+
state = parent;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const slot = parent?.specs.find(s => s.name === state.result_input);
|
|
501
|
+
if (!parent || !slot || formCondition(slot, proposedFormValues(cargo, parent.id, accepted), cargo, parent) === false)
|
|
502
|
+
return false;
|
|
503
|
+
state = parent;
|
|
504
|
+
}
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
export function formReadingReady(cargo, hit, accepted = [], rejected = []) {
|
|
508
|
+
const state = cargo.forms?.[hit.form];
|
|
509
|
+
if (!state)
|
|
510
|
+
return false;
|
|
511
|
+
const spec = state.specs.flatMap(s => s.any_of || [s]).find(s => s.name === hit.name);
|
|
512
|
+
if (!spec)
|
|
513
|
+
return false;
|
|
514
|
+
const values = proposedFormValues(cargo, hit.form, accepted);
|
|
515
|
+
const errors = { ...state.errors }, pending = { ...state.pending };
|
|
516
|
+
for (const h of accepted.filter(h => h.form === hit.form)) {
|
|
517
|
+
delete errors[h.name];
|
|
518
|
+
delete pending[h.name];
|
|
519
|
+
}
|
|
520
|
+
for (const h of rejected.filter(h => h.form === hit.form))
|
|
521
|
+
errors[h.name] = 'invalid';
|
|
522
|
+
const projected = { ...state, values, errors, pending };
|
|
523
|
+
const dependencies = spec.depends_on || [];
|
|
524
|
+
const ready = (input, owner) => formInputApplies(input, owner.values, cargo, owner) && (input.depends_on || []).every(name => {
|
|
525
|
+
if (name.startsWith('cargo.') || name.startsWith('form.'))
|
|
526
|
+
return hasFormValue(formPath({ cargo, form: owner }, name));
|
|
527
|
+
const dependency = owner.specs.find(s => s.name === name || s.any_of?.some(i => i.name === name));
|
|
528
|
+
return !!dependency && formInputFilled(dependency, owner, cargo);
|
|
529
|
+
});
|
|
530
|
+
if (!ready(spec, projected))
|
|
531
|
+
return false;
|
|
532
|
+
// Preparations (for example sending an OTP) observe committed prerequisites only.
|
|
533
|
+
const group = state.specs.find(s => s.any_of?.some(i => i.name === hit.name));
|
|
534
|
+
if (spec.prepare || group?.prepare) {
|
|
535
|
+
if (!ready(spec, state))
|
|
536
|
+
return false;
|
|
537
|
+
const context = [...dependencies, ...(spec.reset_on || []), ...Object.keys(spec.when || {})];
|
|
538
|
+
if (accepted.some(h => h.form === state.id && context.includes(h.name) && !formValuesEqual(state.values[h.name], h.value)))
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
let ancestor = state;
|
|
542
|
+
const seen = new Set();
|
|
543
|
+
while (ancestor.parent && !seen.has(ancestor.parent)) {
|
|
544
|
+
seen.add(ancestor.parent);
|
|
545
|
+
const parent = cargo.forms[ancestor.parent];
|
|
546
|
+
if (!parent)
|
|
547
|
+
return false;
|
|
548
|
+
if (ancestor.context_only) {
|
|
549
|
+
ancestor = parent;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const parentSpec = parent.specs.find(s => s.name === ancestor.result_input);
|
|
553
|
+
if (!parentSpec)
|
|
554
|
+
return false;
|
|
555
|
+
const activation = hit.activation_group === `${parent.id}/${parentSpec.name}`;
|
|
556
|
+
const projectedParent = activation ? { ...parent, values: proposedFormValues(cargo, parent.id, accepted), errors: { ...parent.errors }, pending: { ...parent.pending } } : parent;
|
|
557
|
+
if (activation)
|
|
558
|
+
for (const h of accepted.filter(h => h.form === parent.id && h.activation_group === hit.activation_group)) {
|
|
559
|
+
delete projectedParent.errors[h.name];
|
|
560
|
+
delete projectedParent.pending[h.name];
|
|
561
|
+
}
|
|
562
|
+
if (!ready(parentSpec, projectedParent) || activation && (parentSpec.prepare && !parent.prepared[parentSpec.name] || (spec.prepare || group?.prepare) && accepted.some(h => h.form === parent.id && h.activation_group === hit.activation_group && !formValuesEqual(parent.values[h.name], h.value))))
|
|
563
|
+
return false;
|
|
564
|
+
const context = [...(parentSpec.depends_on || []), ...(parentSpec.reset_on || []), ...Object.keys(parentSpec.when || {})];
|
|
565
|
+
if (accepted.concat(rejected).some(h => h.form === parent.id && context.includes(h.name) && (!h.ok || !formValuesEqual(parent.values[h.name], h.value)) && !(activation && h.ok && h.activation_group === hit.activation_group && own(parentSpec.activate, h.name))))
|
|
566
|
+
return false;
|
|
567
|
+
ancestor = parent;
|
|
568
|
+
}
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
export function formPreparation(cargo, id, input) {
|
|
572
|
+
const state = cargo.forms?.[id], group = state?.specs.find(s => s.name === input || s.any_of?.some(i => i.name === input));
|
|
573
|
+
const member = group?.any_of?.find(i => i.name === input);
|
|
574
|
+
const spec = member?.prepare ? member : group;
|
|
575
|
+
return spec?.prepare && !state.prepared[spec.name] ? { flow: spec.prepare, input: spec.name } : null;
|
|
576
|
+
}
|
|
577
|
+
export function queueFormReadings(cargo, hits = []) {
|
|
578
|
+
for (const hit of hits) {
|
|
579
|
+
const state = cargo.forms?.[hit.form];
|
|
580
|
+
if (!state)
|
|
581
|
+
throw new Error('Pending candidate has no owning form');
|
|
582
|
+
state.pending[hit.name] = { ...hit, outcome: 'pending' };
|
|
583
|
+
}
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
export function pendingFormReadings(cargo, id) {
|
|
587
|
+
const out = [], seen = new Set();
|
|
588
|
+
const walk = key => {
|
|
589
|
+
const state = cargo.forms?.[key];
|
|
590
|
+
if (!state || seen.has(key))
|
|
591
|
+
return;
|
|
592
|
+
seen.add(key);
|
|
593
|
+
for (const [slot, seed] of Object.entries(state.seeds || {})) {
|
|
594
|
+
const spec = state.specs.find(s => s.name === slot);
|
|
595
|
+
if (spec?.schema) {
|
|
596
|
+
const readings = formInitialReadings(state, { [slot]: seed }, cargo);
|
|
597
|
+
if (readings.length) {
|
|
598
|
+
queueFormReadings(cargo, expandFormActivations(cargo, readings));
|
|
599
|
+
delete state.seeds[slot];
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
for (const h of Object.values(state.pending || {}))
|
|
604
|
+
if (!h.activation_group && formReadingReady(cargo, h))
|
|
605
|
+
out.push({ ...h, fromPending: true });
|
|
606
|
+
for (const child of Object.values(state.children || {}).concat(Object.values(state.contexts || {})))
|
|
607
|
+
walk(child);
|
|
608
|
+
};
|
|
609
|
+
walk(id);
|
|
610
|
+
const task = cargo.forms?.[id]?.task, groups = new Map();
|
|
611
|
+
for (const state of Object.values(cargo.forms || {}))
|
|
612
|
+
if (state.task === task)
|
|
613
|
+
for (const h of Object.values(state.pending || {}))
|
|
614
|
+
if (h.activation_group) {
|
|
615
|
+
if (!groups.has(h.activation_group))
|
|
616
|
+
groups.set(h.activation_group, []);
|
|
617
|
+
groups.get(h.activation_group).push(h);
|
|
618
|
+
}
|
|
619
|
+
for (const group of groups.values()) {
|
|
620
|
+
const proposed = group.filter(h => h.activation_inferred).map(h => ({ ...h, ok: true }));
|
|
621
|
+
if (group.every(h => formReadingReady(cargo, h, proposed)))
|
|
622
|
+
out.push(...group.map(h => ({ ...h, fromPending: true })));
|
|
623
|
+
}
|
|
624
|
+
return orderFormReadings(cargo, out);
|
|
625
|
+
}
|
|
626
|
+
export function formWorkLimit(cargo, id) {
|
|
627
|
+
const ancestors = new Set();
|
|
628
|
+
while (cargo.forms?.[id]?.parent && !ancestors.has(id)) {
|
|
629
|
+
ancestors.add(id);
|
|
630
|
+
id = cargo.forms[id].parent;
|
|
631
|
+
}
|
|
632
|
+
const seen = new Set();
|
|
633
|
+
let fields = 0;
|
|
634
|
+
const count = specs => { for (const spec of specs || []) {
|
|
635
|
+
fields += (spec.any_of || [spec]).length;
|
|
636
|
+
if (spec.schema)
|
|
637
|
+
count(spec.schema.inputs);
|
|
638
|
+
} };
|
|
639
|
+
const walk = key => {
|
|
640
|
+
const state = cargo.forms?.[key];
|
|
641
|
+
if (!state || seen.has(key))
|
|
642
|
+
return;
|
|
643
|
+
seen.add(key);
|
|
644
|
+
for (const spec of state.specs) {
|
|
645
|
+
fields += (spec.any_of || [spec]).length;
|
|
646
|
+
const child = state.children[spec.name];
|
|
647
|
+
if (child)
|
|
648
|
+
walk(child);
|
|
649
|
+
else if (spec.schema)
|
|
650
|
+
count(spec.schema.inputs);
|
|
651
|
+
}
|
|
652
|
+
for (const context of Object.values(state.contexts || {}))
|
|
653
|
+
walk(context);
|
|
654
|
+
};
|
|
655
|
+
walk(id);
|
|
656
|
+
return 3 * Math.max(1, fields) + 3;
|
|
657
|
+
}
|
|
658
|
+
export function applyFormValidation(cargo, id, result) {
|
|
659
|
+
const state = cargo.forms?.[id];
|
|
660
|
+
if (!state)
|
|
661
|
+
return 'failed';
|
|
662
|
+
if (result?.ok === true) {
|
|
663
|
+
state.validationAttempts = 0;
|
|
664
|
+
return 'valid';
|
|
665
|
+
}
|
|
666
|
+
if (result?.ok !== false || !Array.isArray(result.invalid) || !result.invalid.length)
|
|
667
|
+
return 'failed';
|
|
668
|
+
const names = new Set(state.specs.flatMap(s => s.any_of || [s]).map(s => s.name));
|
|
669
|
+
if (result.invalid.some(i => !names.has(i.name)))
|
|
670
|
+
return 'failed';
|
|
671
|
+
rejectFormReadings(cargo, result.invalid.map(i => ({ ...i, form: id })));
|
|
672
|
+
state.validationAttempts = Number(state.validationAttempts || 0) + 1;
|
|
673
|
+
return state.validationAttempts > 3 ? 'failed' : 'retry';
|
|
674
|
+
}
|
|
675
|
+
/** Invalidation is transitive and independent of declaration order. */
|
|
676
|
+
export function invalidateFormInputs(cargo, id, changed, preserve = [], preserveNodes = []) {
|
|
677
|
+
const state = cargo.forms?.[id];
|
|
678
|
+
if (!state)
|
|
679
|
+
return [];
|
|
680
|
+
const invalid = new Set(), pending = new Set(changed), kept = new Set(preserve);
|
|
681
|
+
for (;;) {
|
|
682
|
+
let added = false;
|
|
683
|
+
for (const spec of state.specs || []) {
|
|
684
|
+
if (!invalid.has(spec.name) && [...(spec.reset_on || []), ...(spec.depends_on || [])].some(name => pending.has(name))) {
|
|
685
|
+
invalid.add(spec.name);
|
|
686
|
+
pending.add(spec.name);
|
|
687
|
+
added = true;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (!added)
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
for (const name of invalid) {
|
|
694
|
+
if (kept.has(name))
|
|
695
|
+
continue;
|
|
696
|
+
const spec = state.specs.find(s => s.name === name);
|
|
697
|
+
for (const input of spec?.any_of || [{ name }]) {
|
|
698
|
+
delete state.values[input.name];
|
|
699
|
+
delete state.accepted[input.name];
|
|
700
|
+
delete state.prepared[input.name];
|
|
701
|
+
delete state.pending[input.name];
|
|
702
|
+
}
|
|
703
|
+
delete state.prepared[name];
|
|
704
|
+
delete state.pending[name];
|
|
705
|
+
delete state.seeds[name];
|
|
706
|
+
delete state.childResults[name];
|
|
707
|
+
const child = state.children[name];
|
|
708
|
+
if (child) {
|
|
709
|
+
const ownsCandidate = key => preserveNodes.some(target => { let s = cargo.forms[target], seen = new Set(); while (s && !seen.has(s.id)) {
|
|
710
|
+
seen.add(s.id);
|
|
711
|
+
if (s.id === key)
|
|
712
|
+
return true;
|
|
713
|
+
s = cargo.forms[s.parent];
|
|
714
|
+
} return false; });
|
|
715
|
+
const reset = key => {
|
|
716
|
+
const childState = cargo.forms[key];
|
|
717
|
+
if (!childState)
|
|
718
|
+
return;
|
|
719
|
+
for (const nested of Object.values(childState.children || {}))
|
|
720
|
+
if (ownsCandidate(nested))
|
|
721
|
+
reset(nested);
|
|
722
|
+
else
|
|
723
|
+
closeFormInstance(cargo, nested, false);
|
|
724
|
+
Object.assign(childState, { values: {}, accepted: {}, errors: {}, pending: {}, seeds: {}, prepared: {}, data: {}, childResults: {}, confirmation: null, revision: childState.revision + 1 });
|
|
725
|
+
};
|
|
726
|
+
if (ownsCandidate(child))
|
|
727
|
+
reset(child);
|
|
728
|
+
else
|
|
729
|
+
closeFormInstance(cargo, child, false);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (changed.length) {
|
|
733
|
+
state.revision++;
|
|
734
|
+
state.confirmation = null;
|
|
735
|
+
}
|
|
736
|
+
return [...invalid];
|
|
737
|
+
}
|
|
738
|
+
/** An intentional retry resets the named inputs as well as all of their dependent state. Unknown actions stay held. */
|
|
739
|
+
export function resetFormInputs(cargo, id, names = []) {
|
|
740
|
+
const state = cargo.forms?.[id];
|
|
741
|
+
if (!state)
|
|
742
|
+
throw new Error('FORM reset requires an instance');
|
|
743
|
+
const changed = [];
|
|
744
|
+
for (const name of [].concat(names)) {
|
|
745
|
+
const spec = state.specs.find(s => s.name === name || s.any_of?.some(member => member.name === name));
|
|
746
|
+
if (!spec)
|
|
747
|
+
throw new Error(`Unknown FORM reset input ${name}`);
|
|
748
|
+
const members = spec.name === name ? spec.any_of || [spec] : spec.any_of.filter(member => member.name === name);
|
|
749
|
+
for (const input of members) {
|
|
750
|
+
changed.push(input.name);
|
|
751
|
+
delete state.values[input.name];
|
|
752
|
+
delete state.accepted[input.name];
|
|
753
|
+
delete state.errors[input.name];
|
|
754
|
+
delete state.pending[input.name];
|
|
755
|
+
delete state.prepared[input.name];
|
|
756
|
+
}
|
|
757
|
+
delete state.prepared[spec.name];
|
|
758
|
+
delete state.seeds[name];
|
|
759
|
+
delete state.childResults[name];
|
|
760
|
+
if (state.children[name])
|
|
761
|
+
closeFormInstance(cargo, state.children[name], false);
|
|
762
|
+
}
|
|
763
|
+
invalidateFormInputs(cargo, id, changed);
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
/** Commit only validated readings. Callers batch dependency-related candidates before invoking this transition. */
|
|
767
|
+
export function acceptFormReadings(cargo, hits, additionalCandidates = []) {
|
|
768
|
+
const groups = new Map();
|
|
769
|
+
for (const hit of hits || []) {
|
|
770
|
+
const state = cargo.forms?.[hit.form];
|
|
771
|
+
if (!state || hit.ok !== true)
|
|
772
|
+
throw new Error('FORM acceptance requires an existing instance and a validated value');
|
|
773
|
+
const input = state.specs.flatMap(s => s.any_of || [s]).find(s => s.name === hit.name);
|
|
774
|
+
if (!input || !hasFormValue(hit.value))
|
|
775
|
+
throw new Error('FORM acceptance names an unknown input or empty value');
|
|
776
|
+
if (!groups.has(hit.form))
|
|
777
|
+
groups.set(hit.form, []);
|
|
778
|
+
groups.get(hit.form).push(hit);
|
|
779
|
+
}
|
|
780
|
+
for (const [id, readings] of groups) {
|
|
781
|
+
const state = cargo.forms[id];
|
|
782
|
+
const cleared = [];
|
|
783
|
+
for (const group of state.specs.filter(s => s.exclusive && s.any_of)) {
|
|
784
|
+
const selected = readings.filter(h => group.any_of.some(i => i.name === h.name));
|
|
785
|
+
if (new Set(selected.map(h => h.name)).size > 1)
|
|
786
|
+
throw new Error('Exclusive FORM alternatives require clarification');
|
|
787
|
+
if (selected.length)
|
|
788
|
+
for (const member of group.any_of) {
|
|
789
|
+
if (member.name !== selected[0].name) {
|
|
790
|
+
if (hasFormValue(state.values[member.name]))
|
|
791
|
+
cleared.push(member.name);
|
|
792
|
+
delete state.values[member.name];
|
|
793
|
+
delete state.accepted[member.name];
|
|
794
|
+
delete state.pending[member.name];
|
|
795
|
+
delete state.errors[member.name];
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const changed = [...cleared, ...readings.filter(h => !formValuesEqual(state.values[h.name], h.value)).map(h => h.name)];
|
|
800
|
+
const invalidating = changed.filter(name => cleared.includes(name) || hasFormValue(state.values[name]));
|
|
801
|
+
invalidateFormInputs(cargo, id, invalidating, readings.map(h => h.name), hits.concat(additionalCandidates).map(h => h.form));
|
|
802
|
+
if (changed.length && !invalidating.length) {
|
|
803
|
+
state.revision++;
|
|
804
|
+
state.confirmation = null;
|
|
805
|
+
}
|
|
806
|
+
for (const hit of readings) {
|
|
807
|
+
state.values[hit.name] = hit.value;
|
|
808
|
+
if (state.errors)
|
|
809
|
+
delete state.errors[hit.name];
|
|
810
|
+
delete state.pending[hit.name];
|
|
811
|
+
state.acceptanceSequence = Number(state.acceptanceSequence || 0) + 1;
|
|
812
|
+
state.accepted[hit.name] = { revision: state.revision, sequence: state.acceptanceSequence, origin: hit.origin || 'answer' };
|
|
813
|
+
}
|
|
814
|
+
if (changed.length) {
|
|
815
|
+
let child = state;
|
|
816
|
+
const seen = new Set();
|
|
817
|
+
while (child.parent && cargo.forms[child.parent] && !seen.has(child.parent)) {
|
|
818
|
+
seen.add(child.parent);
|
|
819
|
+
const parent = cargo.forms[child.parent], slot = child.result_input;
|
|
820
|
+
if (child.context_only) {
|
|
821
|
+
child = parent;
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
delete parent.values[slot];
|
|
825
|
+
delete parent.accepted[slot];
|
|
826
|
+
invalidateFormInputs(cargo, parent.id, [slot], [], hits.concat(additionalCandidates).map(h => h.form));
|
|
827
|
+
child = parent;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
export function formInstanceApplies(cargo, id) {
|
|
834
|
+
const state = cargo.forms?.[id];
|
|
835
|
+
if (!state)
|
|
836
|
+
return false;
|
|
837
|
+
if (!state.parent)
|
|
838
|
+
return true;
|
|
839
|
+
const parent = cargo.forms[state.parent];
|
|
840
|
+
if (state.context_only)
|
|
841
|
+
return !!parent && formInstanceApplies(cargo, parent.id);
|
|
842
|
+
const input = parent?.specs.find(i => i.name === state.result_input);
|
|
843
|
+
return !!input && formInputApplies(input, parent.values, cargo, parent) && !formInputFilled(input, parent, cargo);
|
|
844
|
+
}
|
|
845
|
+
export function closeFormInstance(cargo, id, handOn = true) {
|
|
846
|
+
const state = cargo.forms?.[id];
|
|
847
|
+
if (!state)
|
|
848
|
+
return null;
|
|
849
|
+
for (const child of Object.values(state.children || {}).concat(Object.values(state.contexts || {})))
|
|
850
|
+
closeFormInstance(cargo, child, false);
|
|
851
|
+
const result = { ...state.values };
|
|
852
|
+
const parent = state.parent && cargo.forms[state.parent];
|
|
853
|
+
if (parent) {
|
|
854
|
+
if (state.context_only)
|
|
855
|
+
delete parent.contexts?.[state.definition];
|
|
856
|
+
else
|
|
857
|
+
delete parent.children[state.result_input];
|
|
858
|
+
if (handOn && !state.context_only) {
|
|
859
|
+
acceptFormReadings(cargo, [{ form: parent.id, name: state.result_input, value: result, ok: true, origin: 'child' }]);
|
|
860
|
+
parent.childResults[state.result_input] = { accepted: structuredClone(state.accepted), data: structuredClone(state.data || {}), prepared: structuredClone(state.prepared || {}), revision: state.revision };
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
cargo.formResults ||= {};
|
|
864
|
+
if (handOn)
|
|
865
|
+
cargo.formResults[id] = result;
|
|
866
|
+
if (cargo.formTasks?.[state.definition] === id)
|
|
867
|
+
delete cargo.formTasks[state.definition];
|
|
868
|
+
delete cargo.forms[id];
|
|
869
|
+
return result;
|
|
870
|
+
}
|