jsfe 1.7.2 → 1.7.3
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/dist/forms/contracts.d.ts.map +1 -1
- package/dist/forms/contracts.js +14 -4
- package/dist/forms/flows.d.ts +3 -155
- package/dist/forms/flows.d.ts.map +1 -1
- package/dist/forms/flows.js +54 -11
- package/dist/forms/proposal-runtime.d.ts +5 -0
- package/dist/forms/proposal-runtime.d.ts.map +1 -0
- package/dist/forms/proposal-runtime.js +9 -0
- package/dist/helpers/form-proposals.d.ts +17 -0
- package/dist/helpers/form-proposals.d.ts.map +1 -0
- package/dist/helpers/form-proposals.js +292 -0
- package/dist/helpers/form-state.d.ts +1 -0
- package/dist/helpers/form-state.d.ts.map +1 -1
- package/dist/helpers/form-state.js +7 -1
- package/dist/helpers/index.d.ts +25 -1
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/index.js +4 -1
- package/dist/helpers/match-inputs.js +1 -1
- package/dist/helpers/proposal-contract.d.ts +7 -0
- package/dist/helpers/proposal-contract.d.ts.map +1 -0
- package/dist/helpers/proposal-contract.js +95 -0
- package/dist/host/capabilities.d.ts.map +1 -1
- package/dist/host/capabilities.js +6 -0
- package/dist/runtime-identity.js +1 -1
- package/docs/development-release.md +6 -0
- package/docs/forms.md +23 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../../src/forms/contracts.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../../src/forms/contracts.js"],"names":[],"mappings":"AAcA;;;IAGC;AACD;;;;;EAGC;AACD,qGAAqG;AACrG;;QAqCC;AAgBD;;;QAEC;AACD;;;;;;;QAMC;AACD;;;QAGC;AACD;;;;;;;QA8BC;AAED,mHAAmH;AACnH,kDAgBC;AAED,+GAA+G;AAC/G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwEC"}
|
package/dist/forms/contracts.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Build-time contracts for conversational flows. Transport operations remain registered tools. */
|
|
2
|
+
import { INPUT_PROPOSALS_REQUIREMENT, hasInputProposals, proposalRuntimeSteps } from './proposal-runtime.js';
|
|
2
3
|
import { ACTION_TOOLS } from './action-tools.js';
|
|
3
4
|
import { normalizeHostRequirements } from '../host/capabilities.js';
|
|
4
5
|
import { validateActionProjection, actionProjectionExpression } from './action-projection.js';
|
|
@@ -66,7 +67,9 @@ export function linkFormSchemas(flows, { externalFlows = [] } = {}) {
|
|
|
66
67
|
parameters.push(input.schema ? { name: input.name, type: 'object', jsonSchema: objectSchema(input.schema.inputs), description: input.description || input.label || input.name }
|
|
67
68
|
: { name: input.name, type: 'string', ...(input.profile.kind === 'choice' && !input.profile.accept_other ? { enum: input.profile.choices.map(c => c.key) } : {}), description: input.description || input.label || input.name });
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
+
const linked = { ...flow, parameters, variables: { ...flow.variables, inputs: { ...flow.variables.inputs, value: inputs } } };
|
|
71
|
+
// Descendant schemas are linked after declaration; propagate their runtime requirement to the root entry.
|
|
72
|
+
return flow.variables?.contract ? declared(linked, flow.variables.contract.value) : { ...linked, steps: proposalRuntimeSteps(flow.id, linked.steps, hasInputProposals(inputs) ? [INPUT_PROPOSALS_REQUIREMENT] : []) };
|
|
70
73
|
});
|
|
71
74
|
}
|
|
72
75
|
function declared(flow, contract) {
|
|
@@ -79,12 +82,13 @@ function declared(flow, contract) {
|
|
|
79
82
|
...(step.type === 'FLOW' && step.callType === 'replace' ? { initializeDefaults: step.initializeDefaults ?? true } : {}),
|
|
80
83
|
...(step.branches ? { branches: Object.fromEntries(Object.entries(step.branches).map(([key, branch]) => [key, replacementDefaults(branch)])) } : {}),
|
|
81
84
|
...(step.onFail ? { onFail: replacementDefaults(step.onFail) } : {}) });
|
|
85
|
+
const requirements = [...normalizeHostRequirements(contract.requires, flow.id), ...(hasInputProposals(flow.variables?.inputs?.value) ? [INPUT_PROPOSALS_REQUIREMENT] : [])];
|
|
82
86
|
const metadata = { version: 1, ...contract, parametersFrom: 'parameters' };
|
|
83
|
-
if (contract.requires !== undefined)
|
|
87
|
+
if (requirements.length || contract.requires !== undefined)
|
|
84
88
|
Object.defineProperty(metadata, 'requires', {
|
|
85
|
-
value: normalizeHostRequirements(
|
|
89
|
+
value: normalizeHostRequirements(requirements, flow.id), enumerable: true, writable: false, configurable: false,
|
|
86
90
|
});
|
|
87
|
-
return { ...flow, steps: flow.steps.map(replacementDefaults), variables: { ...flow.variables, contract: value(metadata) } };
|
|
91
|
+
return { ...flow, steps: proposalRuntimeSteps(flow.id, flow.steps.map(replacementDefaults), requirements), variables: { ...flow.variables, contract: value(metadata) } };
|
|
88
92
|
}
|
|
89
93
|
export function defineWorker({ contract, ...flow }) {
|
|
90
94
|
return declared({ scope: 'isolated', version: '1.0.0', ...flow }, contract);
|
|
@@ -187,10 +191,16 @@ export function validateFlowContracts(flows, tools = [], { externalFlows = [], r
|
|
|
187
191
|
for (const name of deps)
|
|
188
192
|
if (!name.startsWith('cargo.') && !name.startsWith('form.') && !fieldNames.has(name))
|
|
189
193
|
fail(flow.id, `unknown dependency ${name}`);
|
|
194
|
+
for (const name of [...Object.keys(spec.proposal?.when || {}), ...(spec.proposal?.unless || [])])
|
|
195
|
+
if (!name.startsWith('cargo.') && !name.startsWith('form.') && !fieldNames.has(name))
|
|
196
|
+
fail(flow.id, `unknown proposal condition ${name}`);
|
|
190
197
|
graph.set(spec.name, deps);
|
|
191
198
|
for (const key of ['form', 'validator', 'prepare', 'refresh', 'after', 'answer_precheck', 'answer_validator', 'on_exhausted'])
|
|
192
199
|
if (spec[key] && !ids.has(spec[key]))
|
|
193
200
|
fail(flow.id, `unknown ${key} worker ${spec[key]}`);
|
|
201
|
+
for (const reference of [spec.proposal?.prepare, spec.proposal?.on_declined, ...(spec.proposal?.interrupts || []).map(event => event.flow)])
|
|
202
|
+
if (reference && !ids.has(reference))
|
|
203
|
+
fail(flow.id, `unknown proposal worker ${reference}`);
|
|
194
204
|
}
|
|
195
205
|
const visited = new Set(), active = new Set();
|
|
196
206
|
const visit = name => { if (active.has(name))
|
package/dist/forms/flows.d.ts
CHANGED
|
@@ -417,8 +417,8 @@ export const FLOWS: ({
|
|
|
417
417
|
};
|
|
418
418
|
})[];
|
|
419
419
|
} | {
|
|
420
|
+
steps: any;
|
|
420
421
|
variables: any;
|
|
421
|
-
steps: any[];
|
|
422
422
|
prompt?: undefined;
|
|
423
423
|
prompt_es?: undefined;
|
|
424
424
|
primary?: boolean | undefined;
|
|
@@ -432,6 +432,7 @@ export const FLOWS: ({
|
|
|
432
432
|
};
|
|
433
433
|
parameters: any[];
|
|
434
434
|
} | {
|
|
435
|
+
steps: any;
|
|
435
436
|
variables: any;
|
|
436
437
|
id: string;
|
|
437
438
|
name: string;
|
|
@@ -442,136 +443,8 @@ export const FLOWS: ({
|
|
|
442
443
|
type: string;
|
|
443
444
|
description: string;
|
|
444
445
|
}[];
|
|
445
|
-
steps: ({
|
|
446
|
-
id: any;
|
|
447
|
-
type: string;
|
|
448
|
-
variable: any;
|
|
449
|
-
value: any;
|
|
450
|
-
} | {
|
|
451
|
-
parameters?: any;
|
|
452
|
-
initializeDefaults?: boolean | undefined;
|
|
453
|
-
id: any;
|
|
454
|
-
type: string;
|
|
455
|
-
value: any;
|
|
456
|
-
callType: string;
|
|
457
|
-
} | {
|
|
458
|
-
id: any;
|
|
459
|
-
type: string;
|
|
460
|
-
branches: {
|
|
461
|
-
[x: string]: any;
|
|
462
|
-
default: {
|
|
463
|
-
id: any;
|
|
464
|
-
type: string;
|
|
465
|
-
variable: any;
|
|
466
|
-
value: any;
|
|
467
|
-
};
|
|
468
|
-
};
|
|
469
|
-
} | {
|
|
470
|
-
id: string;
|
|
471
|
-
type: string;
|
|
472
|
-
branches: {
|
|
473
|
-
'condition: rf_next_form': {
|
|
474
|
-
parameters?: any;
|
|
475
|
-
initializeDefaults?: boolean | undefined;
|
|
476
|
-
id: any;
|
|
477
|
-
type: string;
|
|
478
|
-
value: any;
|
|
479
|
-
callType: string;
|
|
480
|
-
};
|
|
481
|
-
default: {
|
|
482
|
-
parameters?: any;
|
|
483
|
-
initializeDefaults?: boolean | undefined;
|
|
484
|
-
id: any;
|
|
485
|
-
type: string;
|
|
486
|
-
value: any;
|
|
487
|
-
callType: string;
|
|
488
|
-
};
|
|
489
|
-
};
|
|
490
|
-
})[];
|
|
491
|
-
} | {
|
|
492
|
-
variables: any;
|
|
493
|
-
id: string;
|
|
494
|
-
name: string;
|
|
495
|
-
version: string;
|
|
496
|
-
scope: string;
|
|
497
|
-
description: string;
|
|
498
|
-
parameters: {
|
|
499
|
-
name: string;
|
|
500
|
-
type: string;
|
|
501
|
-
description: string;
|
|
502
|
-
}[];
|
|
503
|
-
steps: ({
|
|
504
|
-
id: any;
|
|
505
|
-
type: string;
|
|
506
|
-
variable: any;
|
|
507
|
-
value: any;
|
|
508
|
-
} | {
|
|
509
|
-
parameters?: any;
|
|
510
|
-
initializeDefaults?: boolean | undefined;
|
|
511
|
-
id: any;
|
|
512
|
-
type: string;
|
|
513
|
-
value: any;
|
|
514
|
-
callType: string;
|
|
515
|
-
} | {
|
|
516
|
-
id: any;
|
|
517
|
-
type: string;
|
|
518
|
-
branches: {
|
|
519
|
-
[x: string]: any;
|
|
520
|
-
default: {
|
|
521
|
-
id: any;
|
|
522
|
-
type: string;
|
|
523
|
-
variable: any;
|
|
524
|
-
value: any;
|
|
525
|
-
};
|
|
526
|
-
};
|
|
527
|
-
} | {
|
|
528
|
-
id: string;
|
|
529
|
-
type: string;
|
|
530
|
-
branches: {
|
|
531
|
-
'condition: pm_keys.length > 0': {
|
|
532
|
-
parameters?: any;
|
|
533
|
-
initializeDefaults?: boolean | undefined;
|
|
534
|
-
id: any;
|
|
535
|
-
type: string;
|
|
536
|
-
value: any;
|
|
537
|
-
callType: string;
|
|
538
|
-
};
|
|
539
|
-
'condition: pm_flow': {
|
|
540
|
-
parameters?: any;
|
|
541
|
-
initializeDefaults?: boolean | undefined;
|
|
542
|
-
id: any;
|
|
543
|
-
type: string;
|
|
544
|
-
value: any;
|
|
545
|
-
callType: string;
|
|
546
|
-
};
|
|
547
|
-
"condition: pm_key === 'done'": {
|
|
548
|
-
id: string;
|
|
549
|
-
type: string;
|
|
550
|
-
value: string;
|
|
551
|
-
value_es: string;
|
|
552
|
-
outcome: string;
|
|
553
|
-
};
|
|
554
|
-
"condition: /[a-z\u00E1\u00E9\u00ED\u00F3\u00FA\u00FC\u00F1]/i.test(String(cargo.choiceText || ''))": {
|
|
555
|
-
id: string;
|
|
556
|
-
type: string;
|
|
557
|
-
};
|
|
558
|
-
"condition: retry !== '1'": {
|
|
559
|
-
parameters?: any;
|
|
560
|
-
initializeDefaults?: boolean | undefined;
|
|
561
|
-
id: any;
|
|
562
|
-
type: string;
|
|
563
|
-
value: any;
|
|
564
|
-
callType: string;
|
|
565
|
-
};
|
|
566
|
-
default: {
|
|
567
|
-
id: string;
|
|
568
|
-
type: string;
|
|
569
|
-
value: string;
|
|
570
|
-
value_es: string;
|
|
571
|
-
};
|
|
572
|
-
};
|
|
573
|
-
})[];
|
|
574
446
|
} | {
|
|
447
|
+
steps: any;
|
|
575
448
|
variables: any;
|
|
576
449
|
id: string;
|
|
577
450
|
name: string;
|
|
@@ -582,30 +455,5 @@ export const FLOWS: ({
|
|
|
582
455
|
name: string;
|
|
583
456
|
type: string;
|
|
584
457
|
}[];
|
|
585
|
-
steps: ({
|
|
586
|
-
id: any;
|
|
587
|
-
type: string;
|
|
588
|
-
variable: any;
|
|
589
|
-
value: any;
|
|
590
|
-
} | {
|
|
591
|
-
parameters?: any;
|
|
592
|
-
initializeDefaults?: boolean | undefined;
|
|
593
|
-
id: any;
|
|
594
|
-
type: string;
|
|
595
|
-
value: any;
|
|
596
|
-
callType: string;
|
|
597
|
-
} | {
|
|
598
|
-
id: any;
|
|
599
|
-
type: string;
|
|
600
|
-
branches: {
|
|
601
|
-
[x: string]: any;
|
|
602
|
-
default: {
|
|
603
|
-
id: any;
|
|
604
|
-
type: string;
|
|
605
|
-
variable: any;
|
|
606
|
-
value: any;
|
|
607
|
-
};
|
|
608
|
-
};
|
|
609
|
-
})[];
|
|
610
458
|
})[];
|
|
611
459
|
//# sourceMappingURL=flows.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AA0FA;;;;;;GAMG;AACH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA6DC;AACD;;;GAGG;AACH;;;;;;EAGC;AA8VD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AAjgBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkrB9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
|
package/dist/forms/flows.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* state that survives DISPATCH and reboot, so a caller who leaves a form resumes it. Results of finished forms:
|
|
17
17
|
* cargo.formResults[<form id>].
|
|
18
18
|
*/
|
|
19
|
+
import { INPUT_PROPOSALS_REQUIREMENT, hasInputProposals, proposalRuntimeSteps } from './proposal-runtime.js';
|
|
19
20
|
import { resolveFormInputs, validateFormIncompleteOutcome, validateFormExhaustion } from '../helpers/form-state.js';
|
|
20
21
|
// ── step helpers ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
21
22
|
const SET = (id, variable, value) => ({ id, type: 'SET', variable, value });
|
|
@@ -114,6 +115,7 @@ export function formFlow({ id, name, version, description, inputs, after = [], e
|
|
|
114
115
|
id, name, version, description, scope, metadata: { formProtocol: 2 }, parameters: [...parameters, { name: 'form_instance', type: 'string', internal: true, description: 'Internal instance to resume' }], ...(primary ? { primary: true } : {}), ...(prompt ? { prompt, prompt_es } : {}),
|
|
115
116
|
variables: vars({ form_on_exhausted: on_exhausted, form_incomplete_outcome: incompleteFrom ? null : incomplete_outcome, form_instance: '', form_internal_resume: false, form_resume_owner: '', form_arguments: {}, form_exhaustion: null, form_values: {}, form_validation: '', initial_readings: [], inputs: resolveFormInputs(inputs, FIELD_PRESETS), parent_form: '', result_input: '', questions: [], provides: null, exit_context: '', exit_context_es: '', form_confirm_q: '', form_confirm_q_es: '', form_confirmation: confirm || {}, form_should_confirm: false, ...extraVars }),
|
|
116
117
|
steps: [
|
|
118
|
+
...proposalRuntimeSteps(id, [], hasInputProposals(inputs) ? [INPUT_PROPOSALS_REQUIREMENT] : []),
|
|
117
119
|
SET(`${id}_resume_kind`, 'form_internal_resume', '!!form_instance'),
|
|
118
120
|
SET(`${id}_open`, 'form_instance', `openFormInstance(cargo, ${JSON.stringify(id)}, inputs, { parent: parent_form || null, input: result_input || '', context:!!parent_form && !result_input, resume: form_instance || (!parent_form ? (cargo.formTasks || {})[${JSON.stringify(id)}] || '' : ''), providers: provides, questions: questions, incomplete_outcome: ${incompleteExpression}, on_exhausted: form_on_exhausted })`),
|
|
119
121
|
SIDE(`${id}_task`, `(!parent_form ? (cargo.formTasks = cargo.formTasks || {}, cargo.formTasks[${JSON.stringify(id)}] = form_instance) : true, true)`),
|
|
@@ -131,7 +133,7 @@ export function formFlow({ id, name, version, description, inputs, after = [], e
|
|
|
131
133
|
WHEN(`${id}_parent_resume`, "rf_status === 'parent_changed'", FLOW(`${id}_parent_reenter`, 'resume-form', 'reboot', { form: '{{form_resume_owner}}' })),
|
|
132
134
|
] : []),
|
|
133
135
|
...(close_on_stop ? [WHEN(`${id}_close_stopped`, "rf_status === 'stopped'", SIDE(`${id}_stop_close`, '(closeFormInstance(cargo, form_instance, false), true)'))] : []),
|
|
134
|
-
WHEN(`${id}_left`, "rf_status !== 'complete'", END(`${id}_left_end`)),
|
|
136
|
+
WHEN(`${id}_left`, "rf_status !== 'complete' || !cargo.forms[form_instance]", END(`${id}_left_end`)),
|
|
135
137
|
...(validator ? [
|
|
136
138
|
SET(`${id}_values`, 'form_values', 'cargo.forms[form_instance].values'),
|
|
137
139
|
SIDE(`${id}_validation_reset`, '(cargo.formCheck = null, true)'),
|
|
@@ -166,7 +168,7 @@ const runForm = {
|
|
|
166
168
|
id: 'run-form', name: 'RunForm', version: '1.0.0',
|
|
167
169
|
description: "Worker (shared scope, called by a form flow): the FORM loop. Merges the form's activation parameters into cargo.forms[rf_form] once, then each pass finds the first missing input whose `when` holds, collects it (collect-input) or runs its sub-form, and replaces itself until nothing is missing. Sets rf_status: 'complete' | 'stopped' (a path in rf_stop was set) | 'unwound'. Reads the form's `inputs`, `parent_form`, `result_input`, `questions`, `provides`, `exit_context(_es)`.",
|
|
168
170
|
parameters: [{ name: 'rf_resume_parent', type: 'boolean', description: 'Yield a context collector when its owning form accepts a change' }, { name: 'rf_form', type: 'string', description: 'The form id (the calling form flow id)' }, { name: 'rf_resume', type: 'string', description: "'true' on the loop's own replays" }, { name: 'rf_stop', type: 'string', description: 'A cargo path (or list) that ends the form when set' }],
|
|
169
|
-
variables: vars({ rf_stop: '', rf_resume: '', rf_resume_parent: false, rf_status: '', rf_next: '', rf_next_form: '', rf_questions: [], rf_provides: null, rf_max: 0, rf_pending: [], rf_deferred: [] }),
|
|
171
|
+
variables: vars({ rf_stop: '', rf_resume: '', rf_resume_parent: false, rf_status: '', rf_next: '', rf_next_form: '', rf_questions: [], rf_provides: null, rf_max: 0, rf_pending: [], rf_deferred: [], rf_proposal: null, rf_proposal_readings: [] }),
|
|
170
172
|
steps: [
|
|
171
173
|
SIDE('rf_pass_count', '(cargo.forms[rf_form] ? (cargo.forms[rf_form].replays = Number(cargo.forms[rf_form].replays || 0) + 1) : true, true)'),
|
|
172
174
|
SET('rf_pass', 'rf_status', "formInstanceApplies(cargo, rf_form) ? '' : (closeFormInstance(cargo, rf_form, false), 'unwound')"),
|
|
@@ -182,6 +184,12 @@ const runForm = {
|
|
|
182
184
|
SET('rf_pending', 'rf_pending', 'pendingFormReadings(cargo, rf_form)'),
|
|
183
185
|
WHEN('rf_pending_accept', 'rf_pending.length > 0', FLOW('rf_pending_batch', 'collect-input-accept', 'call', { hits: '{{rf_pending}}' })),
|
|
184
186
|
WHEN('rf_pending_repeat', 'rf_pending.length > 0', FLOW('rf_pending_again', 'run-form', 'replace', { rf_form: '{{rf_form}}', rf_resume: 'true', rf_stop: '{{rf_stop}}' })),
|
|
187
|
+
SET('rf_proposal_readings', 'rf_proposal_readings', 'formProposalReadings(cargo,rf_form)'),
|
|
188
|
+
WHEN('rf_proposal_revalidate', 'rf_proposal_readings.length > 0', FLOW('rf_proposal_batch', 'collect-input-accept', 'call', { hits: '{{rf_proposal_readings}}' })),
|
|
189
|
+
WHEN('rf_proposal_owner_closed', '!cargo.forms[rf_form]', END('rf_proposal_closed_end')),
|
|
190
|
+
SET('rf_proposal', 'rf_proposal', 'nextFormProposal(cargo,rf_form)'),
|
|
191
|
+
WHEN('rf_proposal_ask', 'rf_proposal', FLOW('rf_proposal_confirm', 'confirm-input-proposal', 'call', { parent_form: '{{rf_form}}', input: '{{rf_proposal.input}}' })),
|
|
192
|
+
WHEN('rf_proposal_repeat', 'rf_proposal || rf_proposal_readings.length > 0', FLOW('rf_proposal_again', 'run-form', 'replace', { rf_form: '{{rf_form}}', rf_resume: 'true', rf_stop: '{{rf_stop}}', rf_resume_parent: '{{rf_resume_parent}}' })),
|
|
185
193
|
SET('rf_find', 'rf_next', 'nextFormInput(cargo.forms[rf_form], cargo)'),
|
|
186
194
|
WHEN('rf_blocked', "rf_next === '__blocked__'", incompleteDispatch('rf_blocked_end', 'rf_form', "'form_dependencies_blocked'")),
|
|
187
195
|
SET('rf_find_form', 'rf_next_form', "rf_next ? ((cargo.forms[rf_form].specs || []).filter(function (i) { return i.name === rf_next; })[0] || {}).form || '' : ''"),
|
|
@@ -275,14 +283,15 @@ const prepareInput = {
|
|
|
275
283
|
const validateBatch = {
|
|
276
284
|
id: 'validate-input-batch', name: 'ValidateInputBatch', version: '2.0.0', scope: 'isolated',
|
|
277
285
|
description: 'Worker: validate dependency-ordered candidates; return accepted, rejected and pending readings before a single commit.',
|
|
278
|
-
parameters: ['hits', 'accepted', 'rejected', 'pending'].map(name => ({ name, type: 'array' })),
|
|
279
|
-
variables: vars({ accepted: [], rejected: [], pending: [], vb_h: null, vb_rest: [], vb_values: {}, vb_known: false, vb_ready: false, vb_relevant: false, vb_outcome: '' }),
|
|
286
|
+
parameters: ['hits', 'accepted', 'rejected', 'pending', 'ignored'].map(name => ({ name, type: 'array' })),
|
|
287
|
+
variables: vars({ accepted: [], rejected: [], pending: [], ignored: [], vb_h: null, vb_rest: [], vb_values: {}, vb_known: false, vb_ready: false, vb_relevant: false, vb_outcome: '', vb_candidate: null, vb_proposal_values: {}, vb_proposal: null, vb_proposal_ok: true }),
|
|
280
288
|
steps: [
|
|
281
289
|
SET('vb_init_accepted', 'accepted', 'Array.isArray(accepted) ? accepted : []'),
|
|
282
290
|
SET('vb_init_rejected', 'rejected', 'Array.isArray(rejected) ? rejected : []'),
|
|
283
291
|
SET('vb_init_pending', 'pending', 'Array.isArray(pending) ? pending : []'),
|
|
292
|
+
SET('vb_init_ignored', 'ignored', 'Array.isArray(ignored) ? ignored : []'),
|
|
284
293
|
SET('vb_first', 'vb_h', 'hits.length ? hits[0] : null'),
|
|
285
|
-
WHEN('vb_done', '!vb_h', SIDE('vb_result', '(cargo.inputBatch = { accepted: accepted, rejected: rejected, pending: pending }, true)')),
|
|
294
|
+
WHEN('vb_done', '!vb_h', SIDE('vb_result', '(cargo.inputBatch = { accepted: accepted, rejected: rejected, pending: pending, ignored: ignored }, true)')),
|
|
286
295
|
WHEN('vb_end', '!vb_h', END('vb_end_done')),
|
|
287
296
|
SET('vb_rest', 'vb_rest', 'hits.slice(1)'),
|
|
288
297
|
SET('vb_relevant', 'vb_relevant', 'formReadingRelevant(cargo, vb_h, accepted)'),
|
|
@@ -294,8 +303,15 @@ const validateBatch = {
|
|
|
294
303
|
SIDE('vb_reset', "(cargo.inputCheck = vb_h.invalid ? { ok: false } : (vb_h.validator && !vb_known ? null : { ok: true, value: vb_h.value }), true)"),
|
|
295
304
|
WHEN('vb_validator', 'vb_ready === true && vb_h.validator && !vb_known && !vb_h.invalid', FLOW('vb_validate', '{{vb_h.validator}}', 'call', { form: '{{vb_h.form}}', input: '{{vb_h.name}}', value: '{{vb_h.value}}', values: '{{vb_values}}', reading: '{{vb_h}}' })),
|
|
296
305
|
WHEN('vb_refused', 'vb_h.refused === true', SIDE('vb_withhold_refusal', "(cargo.inputCheck=Object.assign({},cargo.inputCheck || {},{ok:false}),true)")),
|
|
297
|
-
|
|
298
|
-
|
|
306
|
+
SET('vb_candidate', 'vb_candidate', "Object.assign({},vb_h,{ok:!!(cargo.inputCheck && cargo.inputCheck.ok === true),value:cargo.inputCheck && cargo.inputCheck.value !== undefined && cargo.inputCheck.value !== null ? cargo.inputCheck.value : vb_h.value,message:cargo.inputCheck && cargo.inputCheck.message,message_es:cargo.inputCheck && cargo.inputCheck.message_es})"),
|
|
307
|
+
SET('vb_proposal_policy', 'vb_proposal', 'vb_relevant === true && vb_ready === true && vb_candidate.ok && !vb_known ? formProposalPolicy(cargo,vb_candidate,cargo.inputCheck,accepted) : null'),
|
|
308
|
+
SET('vb_proposal_values', 'vb_proposal_values', 'proposedFormValues(cargo,vb_h.form,accepted.concat([vb_candidate]))'),
|
|
309
|
+
SIDE('vb_proposal_reset', '(cargo.proposalCheck=undefined,true)'),
|
|
310
|
+
WHEN('vb_proposal_prepare', 'vb_proposal && vb_proposal.prepare', FLOW('vb_proposal_formatter', '{{vb_proposal.prepare}}', 'call', { form: '{{vb_candidate.form}}', input: '{{vb_candidate.name}}', value: '{{vb_candidate.value}}', values: '{{vb_proposal_values}}', reading: '{{vb_candidate}}' })),
|
|
311
|
+
SET('vb_proposal_gate', 'vb_proposal_ok', 'vb_relevant !== true || vb_ready !== true || !vb_candidate.ok || vb_known ? true : stageFormProposal(cargo,vb_candidate,formProposalDescriptor(vb_proposal,vb_proposal && vb_proposal.prepare ? cargo.proposalCheck : undefined))'),
|
|
312
|
+
{ ...SET('vb_proposal_gate_valid', 'noop', "typeof vb_proposal_ok === 'boolean'"), requireTrue: true },
|
|
313
|
+
SIDE('vb_take', "(vb_relevant !== true ? ignored.push(vb_h) : vb_ready !== true ? pending.push(Object.assign({}, vb_h, {outcome:'pending'})) : vb_candidate.ok === true ? (vb_proposal_ok === true ? accepted.push(Object.assign({}, vb_candidate, { outcome: vb_outcome })) : null) : rejected.push(Object.assign({}, vb_candidate, { outcome: 'rejected' })), true)"),
|
|
314
|
+
FLOW('vb_next', 'validate-input-batch', 'replace', { hits: '{{vb_rest}}', accepted: '{{accepted}}', rejected: '{{rejected}}', pending: '{{pending}}', ignored: '{{ignored}}' }),
|
|
299
315
|
],
|
|
300
316
|
};
|
|
301
317
|
const accept = {
|
|
@@ -311,7 +327,9 @@ const accept = {
|
|
|
311
327
|
SET('ca_order', 'ca_ordered', 'orderFormReadings(cargo, hits)'),
|
|
312
328
|
FLOW('ca_validate', 'validate-input-batch', 'call', { hits: '{{ca_ordered}}' }),
|
|
313
329
|
SIDE('ca_settle', '(cargo.inputBatch = settleFormActivations(cargo.inputBatch, cargo), true)'),
|
|
330
|
+
{ ...SIDE('ca_proposal_validation', '(settleFormProposalValidation(cargo,cargo.inputBatch),true)'), requireTrue: true },
|
|
314
331
|
SIDE('ca_rejections', 'rejectFormReadings(cargo, cargo.inputBatch.rejected)'),
|
|
332
|
+
{ ...SIDE('ca_proposal_commit', 'commitFormProposals(cargo,cargo.inputBatch.accepted)'), requireTrue: true },
|
|
315
333
|
SIDE('ca_commit', 'acceptFormReadings(cargo, cargo.inputBatch.accepted, cargo.inputBatch.pending)'),
|
|
316
334
|
SIDE('ca_pending', 'queueFormReadings(cargo, cargo.inputBatch.pending)'),
|
|
317
335
|
SET('ca_results', 'ca_results', 'cargo.inputBatch.accepted.concat(cargo.inputBatch.rejected, cargo.inputBatch.pending)'),
|
|
@@ -372,7 +390,7 @@ const confirmForm = {
|
|
|
372
390
|
id: 'confirm-form', name: 'ConfirmForm', version: '1.0.0', scope: 'isolated',
|
|
373
391
|
description: "Worker: ask a form's confirmation (exact YES / NO; keys 1 and 2). A correction instead ('no, make it $40') is read against the form chain and stored, and the result is 'changed' so the form runs again; an unclear short answer is asked again, a long unrelated one is DISPATCHed. Result: cargo.formConfirm[form] = 'yes' | 'no' | 'changed'. Params: form, question(_es), exit_context(_es).",
|
|
374
392
|
parameters: ['form', 'question', 'question_es', 'exit_context', 'exit_context_es', 'words', 'interrupts', 'no_keys', 'clarifications'].map((n) => ({ name: n, type: ['words', 'interrupts', 'no_keys', 'clarifications'].includes(n) ? 'array' : 'string', description: n })),
|
|
375
|
-
variables: vars({ cf_presets: FIELD_PRESETS, cf_chain: [], cf_m: null, cf_result: '', cf_prompt: '', cf_prompt_es: '', cf_words: 0, cf_tries: 0, words: [], clarifications: [], no_keys: ['2'], interrupts: [], cf_choices: [], cf_event: '', cf_amount: false, cf_questions: [], cf_provides: null, cf_skip_gate: '' }),
|
|
393
|
+
variables: vars({ cf_presets: FIELD_PRESETS, cf_chain: [], cf_m: null, cf_current: null, cf_result: '', cf_prompt: '', cf_prompt_es: '', cf_words: 0, cf_tries: 0, words: [], clarifications: [], no_keys: ['2'], interrupts: [], cf_choices: [], cf_event: '', cf_amount: false, cf_questions: [], cf_provides: null, cf_skip_gate: '' }),
|
|
376
394
|
steps: [
|
|
377
395
|
SIDE('cf_reset', "(cargo.formConfirm = cargo.formConfirm || {}, cargo.formConfirm[form] = '', true)"),
|
|
378
396
|
SET('cf_choices', 'cf_choices', "[{key:'yes',words:['1'].concat(Array.isArray(words)?words:[]),exact:true},{key:'no',words:Array.isArray(no_keys)?no_keys:['2']}].concat((Array.isArray(interrupts)?interrupts:[]).map(function(event){return {key:'event:'+event.key,words:event.words,exact:event.exact!==false};}))"),
|
|
@@ -389,7 +407,8 @@ const confirmForm = {
|
|
|
389
407
|
SET('cf_question_catalog', 'cf_questions', 'cargo.forms[form].questions || []'),
|
|
390
408
|
SET('cf_provider_catalog', 'cf_provides', 'cargo.forms[form].providers'),
|
|
391
409
|
SET('cf_gate_policy', 'cf_skip_gate', "!cf_provides || !cf_provides.gate || [].concat(cf_provides.gate.verified || []).every(function(p){return !!formPath({cargo:cargo},p);}) ? 'true' : ''"),
|
|
392
|
-
SET('
|
|
410
|
+
SET('cf_current', 'cf_current', "cargo.forms[form].proposalPrompt ? {form:cargo.forms[form].parent,name:cargo.forms[form].proposalPrompt.input} : {form:form,name:''}"),
|
|
411
|
+
SET('cf_match', 'cf_m', "cargo.choice === 'yes' || cargo.choice === 'no' && pickChoice(cargo.choiceText, [{key:'no',words:Array.isArray(no_keys)?no_keys:['2'],exact:true}], false) === 'no' ? { hits: [], ambiguous: [], questions:[] } : readFormAnswer(cargo.choiceText, cf_chain, cf_current, '',cf_questions)"),
|
|
393
412
|
SET('cf_enrich', 'cf_m', "(function(m){m.hits=m.hits.map(function(h){return Object.assign({},h,{silent:true});});return m;})(enrichFormMatches(cf_m, cf_chain, form))"),
|
|
394
413
|
SET('cf_count_words', 'cf_words', "String(cargo.choiceText || '').split(/\\s+/).filter(function (w) { return /[a-záéíóúüñ]/i.test(w); }).length"),
|
|
395
414
|
WHEN('cf_question_fields', 'cf_m.questions.length > 0 && cf_m.hits.length > 0', FLOW('cf_question_accept', 'collect-input-accept', 'call', { hits: '{{cf_m.hits}}' })),
|
|
@@ -412,6 +431,29 @@ const confirmForm = {
|
|
|
412
431
|
SIDE('cf_store', "(cargo.formConfirm[form] = cf_result === 'unclear' || cf_result === 'clarification' ? 'changed' : cf_result, true)"),
|
|
413
432
|
],
|
|
414
433
|
};
|
|
434
|
+
// ── proposed input: owned confirmation before canonical acceptance or callbacks ────────────────────────────
|
|
435
|
+
const confirmInputProposal = formFlow({ id: 'confirm-input-proposal', name: 'ConfirmInputProposal', version: '1.0.0', description: 'Confirm an owned validated input candidate; retain it on yield and authorize only normal input acceptance.',
|
|
436
|
+
parameters: [{ name: 'input', type: 'string', description: 'Owning input awaiting proposal confirmation' }], extraVars: { input: '', proposal: null, proposal_readings: [] }, inputs: [],
|
|
437
|
+
before: [
|
|
438
|
+
SET('proposal_refresh_readings', 'proposal_readings', 'formProposalReadings(cargo,parent_form,input,false)'),
|
|
439
|
+
WHEN('proposal_refresh', 'proposal_readings.length > 0', FLOW('proposal_refresh_batch', 'collect-input-accept', 'call', { hits: '{{proposal_readings}}' })),
|
|
440
|
+
SET('proposal_current', 'proposal', 'cargo.forms[parent_form] && cargo.forms[parent_form].inputProposals && cargo.forms[parent_form].inputProposals[input] || null'),
|
|
441
|
+
WHEN('proposal_missing', "!proposal || !['pending','confirmed'].includes(proposal.status) || proposal.committed", FLOW('proposal_missing_close', 'form-done', 'replace', { form: '{{form_instance}}' })),
|
|
442
|
+
{ ...SIDE('proposal_bind', '(bindFormProposal(cargo,parent_form,input,form_instance),true)'), requireTrue: true },
|
|
443
|
+
SET('proposal_confirmation', 'form_confirmation', 'Object.assign({},proposal.descriptor)'),
|
|
444
|
+
],
|
|
445
|
+
confirm: { question: '{{proposal.descriptor.question}}', question_es: '{{proposal.descriptor.question_es || proposal.descriptor.question}}' },
|
|
446
|
+
after: [{ ...SIDE('proposal_confirmed', "decideFormProposal(cargo,parent_form,input,form_instance,'confirmed')"), requireTrue: true }],
|
|
447
|
+
declined: [NOOP('proposal_declined_marker')],
|
|
448
|
+
});
|
|
449
|
+
const proposalDeclined = { id: 'confirm-input-proposal-declined', name: 'ConfirmInputProposalDeclined', version: '1.0.0', scope: 'isolated', description: 'Reject an owned input proposal without accepting its candidate or handing incomplete data to its parent.', parameters: [{ name: 'form_instance', type: 'string' }], variables: vars({ owner: '', input: '', proposal: null }), steps: [
|
|
450
|
+
SET('proposal_decline_owner', 'owner', 'cargo.forms[form_instance].parent'),
|
|
451
|
+
SET('proposal_decline_input', 'input', 'cargo.forms[form_instance].proposalPrompt.input'),
|
|
452
|
+
SET('proposal_decline_record', 'proposal', 'cargo.forms[owner].inputProposals[input]'),
|
|
453
|
+
{ ...SIDE('proposal_decline_decision', "decideFormProposal(cargo,owner,input,form_instance,'declined')"), requireTrue: true },
|
|
454
|
+
SIDE('proposal_decline_close', '(closeFormInstance(cargo,form_instance,false),true)'),
|
|
455
|
+
WHEN('proposal_decline_callback', 'proposal.descriptor.on_declined', FLOW('proposal_decline_invoke', '{{proposal.descriptor.on_declined}}', 'call', { form: '{{owner}}', input: '{{input}}', value: '{{proposal.value}}', reading: '{{proposal.reading}}' })),
|
|
456
|
+
] };
|
|
415
457
|
// ── form-done: the result goes to the parent (or cargo.formResults); the form is cleared ─────────────────────────────
|
|
416
458
|
const formDone = {
|
|
417
459
|
id: 'form-done', name: 'FormDone', version: '1.0.0', scope: 'isolated',
|
|
@@ -667,8 +709,9 @@ const resumeForm = { id: 'resume-form', name: 'ResumeForm', version: '1.0.0', sc
|
|
|
667
709
|
SET('resume_definition', 'target', 'cargo.forms[form].definition'), SET('resume_parent', 'parent', "cargo.forms[form].parent || ''"), SET('resume_slot', 'slot', "cargo.forms[form].result_input || ''"),
|
|
668
710
|
FLOW('resume_owned_form', '{{target}}', 'replace', { form_instance: '{{form}}', parent_form: '{{parent}}', result_input: '{{slot}}' })
|
|
669
711
|
] };
|
|
670
|
-
const LIBRARY_FLOWS = [resumeForm, performAction, sendTextAction, prepareInput, runForm, collectInput, validateBatch, accept, acceptedEffects, disambiguate, confirmForm, formDone, providerMenu, ...validators, ...fieldCollectors, ...subForms, provideData, provideQueuedData, loadSource, answerFacts];
|
|
712
|
+
const LIBRARY_FLOWS = [confirmInputProposal, proposalDeclined, resumeForm, performAction, sendTextAction, prepareInput, runForm, collectInput, validateBatch, accept, acceptedEffects, disambiguate, confirmForm, formDone, providerMenu, ...validators, ...fieldCollectors, ...subForms, provideData, provideQueuedData, loadSource, answerFacts];
|
|
671
713
|
const LIBRARY_CONTRACTS = {
|
|
714
|
+
'confirm-input-proposal': { role: 'collector', requires: [INPUT_PROPOSALS_REQUIREMENT] }, 'confirm-input-proposal-declined': { role: 'worker', requires: [INPUT_PROPOSALS_REQUIREMENT] },
|
|
672
715
|
'resume-form': { role: 'worker' },
|
|
673
716
|
'perform-action': { role: 'worker', tools: ['jsfe:action-claim', 'jsfe:action-complete'], result: { path: 'cargo.actionResult', type: 'object' } },
|
|
674
717
|
'send-text-action': { role: 'worker', result: { path: 'cargo.actionOutcome', type: 'object' } },
|
|
@@ -693,6 +736,6 @@ export const FLOWS = LIBRARY_FLOWS.map(flow => {
|
|
|
693
736
|
throw new Error('Shared worker needs an explicit contract: ' + flow.id);
|
|
694
737
|
if ((declaration.role === 'collector') !== (flow.metadata?.formProtocol === 2))
|
|
695
738
|
throw new Error('Shared collector contract mismatch: ' + flow.id);
|
|
696
|
-
return { ...flow, variables: { ...flow.variables, contract: { type: 'object', value: { version: 1, summary: flow.description,
|
|
739
|
+
return { ...flow, steps: proposalRuntimeSteps(flow.id, flow.steps, declaration.requires), variables: { ...flow.variables, contract: { type: 'object', value: { version: 1, summary: flow.description,
|
|
697
740
|
parametersFrom: 'parameters', ...declaration, ...(declaration.role === 'collector' ? { form: { inputs: 'inputs' } } : {}) } } } };
|
|
698
741
|
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function proposalRuntimeSteps(id: any, steps: any, requirements?: any[]): any;
|
|
2
|
+
/** Versioned engine support; declaring proposal consent must never become a no-op on older runtimes. */
|
|
3
|
+
export const INPUT_PROPOSALS_REQUIREMENT: "jsfe:input-proposals:v1";
|
|
4
|
+
export function hasInputProposals(inputs: any): any;
|
|
5
|
+
//# sourceMappingURL=proposal-runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proposal-runtime.d.ts","sourceRoot":"","sources":["../../src/forms/proposal-runtime.js"],"names":[],"mappings":"AAEA,qFAIC;AAND,wGAAwG;AACxG,0CAA2C,yBAAyB,CAAC;AAM9D,oDAA4K"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Versioned engine support; declaring proposal consent must never become a no-op on older runtimes. */
|
|
2
|
+
export const INPUT_PROPOSALS_REQUIREMENT = 'jsfe:input-proposals:v1';
|
|
3
|
+
export function proposalRuntimeSteps(id, steps, requirements = []) {
|
|
4
|
+
if (!requirements.includes(INPUT_PROPOSALS_REQUIREMENT))
|
|
5
|
+
return steps;
|
|
6
|
+
const guard = { id: id + '_proposal_protocol', type: 'SET', variable: 'noop', value: 'formProposalProtocol() === 1', requireTrue: true };
|
|
7
|
+
return steps[0]?.type === 'SET' && steps[0]?.value === guard.value && steps[0]?.requireTrue === true ? steps : [guard, ...steps];
|
|
8
|
+
}
|
|
9
|
+
export const hasInputProposals = inputs => (inputs || []).some(input => input.proposal !== undefined || hasInputProposals(input.any_of) || hasInputProposals(input.schema?.inputs));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Static policy selectors use this batch's proposed values; a validator's explicit descriptor independently applies. */
|
|
2
|
+
export function formProposalPolicy(cargo: any, reading: any, check: any, accepted?: any[]): any;
|
|
3
|
+
/** A null descriptor admits ordinary input, but cannot silently waive the same retained candidate's consent. */
|
|
4
|
+
export function stageFormProposal(cargo: any, reading: any, descriptor: any): any;
|
|
5
|
+
/** Only actionable records enter the confirmation worker; dependency-deferred records await the normal batch. */
|
|
6
|
+
export function nextFormProposal(cargo: any, owner: any): any;
|
|
7
|
+
/** Owner replay issues approval only for confirmed records; named child refresh never invents approval. */
|
|
8
|
+
export function formProposalReadings(cargo: any, owner: any, input?: string, approve?: boolean): any[];
|
|
9
|
+
/** Bind only a real context child; ordinary child slots and unrelated owners cannot collect consent. */
|
|
10
|
+
export function bindFormProposal(cargo: any, owner: any, input: any, childId: any): any;
|
|
11
|
+
/** Exact YES must have passed the ordinary confirm-form revision check; NO never admits the candidate. */
|
|
12
|
+
export function decideFormProposal(cargo: any, owner: any, input: any, childId: any, decision: any): boolean;
|
|
13
|
+
/** Validate the entire final batch before consuming any proof; invoke immediately before acceptFormReadings. */
|
|
14
|
+
export function commitFormProposals(cargo: any, accepted?: any[]): boolean;
|
|
15
|
+
/** Failed validation withdraws the candidate; unmet prerequisites wait for the normal dependency replay. */
|
|
16
|
+
export function settleFormProposalValidation(cargo: any, batch?: {}): {};
|
|
17
|
+
//# sourceMappingURL=form-proposals.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"form-proposals.d.ts","sourceRoot":"","sources":["../../src/helpers/form-proposals.js"],"names":[],"mappings":"AAgEA,yHAAyH;AACzH,gGASC;AACD,gHAAgH;AAChH,kFAkCC;AACD,iHAAiH;AACjH,8DAOC;AACD,2GAA2G;AAC3G,uGAYC;AACD,wGAAwG;AACxG,wFAMC;AACD,0GAA0G;AAC1G,6GASC;AACD,gHAAgH;AAChH,2EAmBC;AACD,4GAA4G;AAC5G,yEAIC"}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/** Owned input proposals. Candidates and their consent remain separate from canonical values and action proposals. */
|
|
2
|
+
import { formInputApplies, formValuesEqual, hasFormValue } from './form-state.js';
|
|
3
|
+
import { formSignature, actionConfirmationValid } from './form-actions.js';
|
|
4
|
+
import { validateFormProposalPolicy, validateFormProposalDescriptor, validateProposalData } from './proposal-contract.js';
|
|
5
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key);
|
|
6
|
+
const statuses = new Set(['pending', 'confirmed', 'declined', 'superseded']);
|
|
7
|
+
const fail = message => { throw new Error('FORM input proposal ' + message); };
|
|
8
|
+
const text = value => typeof value === 'string' && !!value.trim();
|
|
9
|
+
function data(value) {
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
const inspect = item => {
|
|
12
|
+
if (!item || typeof item !== 'object')
|
|
13
|
+
return;
|
|
14
|
+
if (seen.has(item))
|
|
15
|
+
fail('requires acyclic JSON data');
|
|
16
|
+
seen.add(item);
|
|
17
|
+
if (Object.getOwnPropertySymbols(item).length)
|
|
18
|
+
fail('requires JSON data without symbols');
|
|
19
|
+
for (const [key, property] of Object.entries(Object.getOwnPropertyDescriptors(item))) {
|
|
20
|
+
if (Array.isArray(item) && key === 'length')
|
|
21
|
+
continue;
|
|
22
|
+
if (Array.isArray(item) && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= item.length))
|
|
23
|
+
fail('requires JSON arrays without extra properties');
|
|
24
|
+
if (property.get || property.set || !property.enumerable)
|
|
25
|
+
fail('requires enumerable JSON data properties');
|
|
26
|
+
inspect(property.value);
|
|
27
|
+
}
|
|
28
|
+
seen.delete(item);
|
|
29
|
+
};
|
|
30
|
+
inspect(value);
|
|
31
|
+
validateProposalData(value);
|
|
32
|
+
return structuredClone(value);
|
|
33
|
+
}
|
|
34
|
+
function inputOwner(cargo, id, name) {
|
|
35
|
+
const state = cargo?.forms?.[id], spec = state?.specs?.flatMap(input => input.any_of || [input]).find(input => input.name === name);
|
|
36
|
+
if (!state || state.id !== id || !spec || ['__proto__', 'prototype', 'constructor'].includes(name))
|
|
37
|
+
fail('requires a live owning input');
|
|
38
|
+
if (!Number.isSafeInteger(state.revision) || state.revision < 0 || !text(state.definition) || !text(state.task))
|
|
39
|
+
fail('has invalid owner identity or revision');
|
|
40
|
+
return { state, spec };
|
|
41
|
+
}
|
|
42
|
+
function candidate(cargo, reading) {
|
|
43
|
+
if (!reading || reading.ok !== true || !hasFormValue(reading.value))
|
|
44
|
+
fail('requires a validated nonempty candidate');
|
|
45
|
+
const owner = inputOwner(cargo, reading.form, reading.name);
|
|
46
|
+
data(reading.value);
|
|
47
|
+
return owner;
|
|
48
|
+
}
|
|
49
|
+
const identity = state => ({ id: state.id, definition: state.definition, task: state.task });
|
|
50
|
+
const signature = (owner, input, value, revision, descriptor) => formSignature({ owner, input, value, revision, binding: descriptor.binding ?? null, descriptor });
|
|
51
|
+
const token = (value, record) => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => ['id', 'signature'].includes(key)) && value.id === record?.id && value.signature === record?.signature;
|
|
52
|
+
const sourceReading = reading => Object.fromEntries(['raw', 'utterance', 'origin', 'current', 'asked'].filter(key => reading[key] !== undefined).map(key => [key, data(reading[key])]));
|
|
53
|
+
function recordFor(cargo, state, name) {
|
|
54
|
+
const record = state.inputProposals?.[name];
|
|
55
|
+
if (!record)
|
|
56
|
+
return null;
|
|
57
|
+
if (!text(record.id) || record.form !== state.id || record.input !== name || !statuses.has(record.status) || typeof record.committed !== 'boolean' || !Number.isSafeInteger(record.owner_revision) || record.owner_revision < 0 || !Number.isSafeInteger(record.revision) || record.revision < 1 || typeof record.child !== 'string' || !hasFormValue(record.value) || !record.reading || typeof record.reading !== 'object' || Array.isArray(record.reading))
|
|
58
|
+
fail('has invalid retained state');
|
|
59
|
+
data(record.value);
|
|
60
|
+
data(record.reading);
|
|
61
|
+
const descriptor = validateFormProposalDescriptor(record.descriptor);
|
|
62
|
+
if (!formValuesEqual(record.owner, identity(state)) || !formValuesEqual(record.binding, descriptor.binding ?? null) || record.signature !== signature(record.owner, name, record.value, record.owner_revision, descriptor))
|
|
63
|
+
fail('retained identity or binding changed');
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
66
|
+
const active = record => !!record && !record.committed && ['pending', 'confirmed'].includes(record.status);
|
|
67
|
+
function contextChild(cargo, state, id) {
|
|
68
|
+
const child = cargo.forms?.[id];
|
|
69
|
+
return child && child.id === id && child.context_only === true && child.parent === state.id && child.task === state.task && state.contexts?.[child.definition] === id ? child : null;
|
|
70
|
+
}
|
|
71
|
+
function withdraw(cargo, state, record) {
|
|
72
|
+
const child = contextChild(cargo, state, record.child);
|
|
73
|
+
if (child && child.proposalPrompt?.id === record.id && child.proposalPrompt?.signature === record.signature && child.proposalPrompt?.input === record.input) {
|
|
74
|
+
child.confirmation = null;
|
|
75
|
+
delete child.proposalPrompt;
|
|
76
|
+
}
|
|
77
|
+
delete record.approval;
|
|
78
|
+
}
|
|
79
|
+
const current = (state, record) => record.owner_revision === state.revision && record.signature === signature(identity(state), record.input, record.value, state.revision, record.descriptor);
|
|
80
|
+
function proofValid(cargo, state, record) {
|
|
81
|
+
if (!record || record.status !== 'confirmed' || record.committed || record.deferred || !current(state, record) || !record.approval)
|
|
82
|
+
return false;
|
|
83
|
+
const proof = record.approval;
|
|
84
|
+
if (proof.id !== record.id || proof.signature !== record.signature || proof.child !== record.child || !Number.isSafeInteger(proof.revision) || proof.confirmation?.revision !== proof.revision)
|
|
85
|
+
return false;
|
|
86
|
+
const child = cargo.forms?.[record.child];
|
|
87
|
+
return !child || !!contextChild(cargo, state, record.child) && child.proposalPrompt?.id === record.id && child.proposalPrompt?.signature === record.signature && child.revision === proof.revision && formValuesEqual(child.confirmation, proof.confirmation) && actionConfirmationValid(cargo, child.id, { signature: child.proposal?.signature || null });
|
|
88
|
+
}
|
|
89
|
+
function approved(cargo, state, record, reading) {
|
|
90
|
+
return token(reading.proposalResume, record) && token(reading.proposalApproval, record) && formValuesEqual(reading.value, record.value) && proofValid(cargo, state, record);
|
|
91
|
+
}
|
|
92
|
+
/** Static policy selectors use this batch's proposed values; a validator's explicit descriptor independently applies. */
|
|
93
|
+
export function formProposalPolicy(cargo, reading, check, accepted = []) {
|
|
94
|
+
const { state, spec } = inputOwner(cargo, reading.form, reading.name);
|
|
95
|
+
const declared = spec.proposal === undefined ? null : validateFormProposalPolicy(spec.proposal);
|
|
96
|
+
const dynamic = check && own(check, 'proposal') && check.proposal !== undefined ? validateFormProposalDescriptor(check.proposal) : null;
|
|
97
|
+
if (check?.ok === false)
|
|
98
|
+
return null;
|
|
99
|
+
const record = recordFor(cargo, state, reading.name), values = { ...state.values, ...Object.fromEntries(accepted.filter(hit => hit.form === state.id && hit.ok === true).map(hit => [hit.name, hit.value])), [reading.name]: reading.value };
|
|
100
|
+
const origin = reading.origin || 'answer';
|
|
101
|
+
const matches = declared && formInputApplies(declared, values, cargo, state) && (!declared.origins || declared.origins.includes(origin) || active(record));
|
|
102
|
+
return matches || dynamic ? { ...(matches ? declared : {}), ...(dynamic || {}) } : null;
|
|
103
|
+
}
|
|
104
|
+
/** A null descriptor admits ordinary input, but cannot silently waive the same retained candidate's consent. */
|
|
105
|
+
export function stageFormProposal(cargo, reading, descriptor) {
|
|
106
|
+
const { state } = candidate(cargo, reading), decision = descriptor === null ? null : validateFormProposalDescriptor(descriptor);
|
|
107
|
+
let old = recordFor(cargo, state, reading.name);
|
|
108
|
+
const resumed = own(reading, 'proposalResume');
|
|
109
|
+
if (resumed && !token(reading.proposalResume, old))
|
|
110
|
+
return false;
|
|
111
|
+
if (old?.committed && resumed)
|
|
112
|
+
return false;
|
|
113
|
+
if (decision === null) {
|
|
114
|
+
if (!old || old.status === 'superseded' || old.committed)
|
|
115
|
+
return !own(reading, 'proposalApproval');
|
|
116
|
+
if (old.status === 'declined') {
|
|
117
|
+
if (!resumed && !own(reading, 'proposalApproval')) {
|
|
118
|
+
withdraw(cargo, state, old);
|
|
119
|
+
old.status = 'superseded';
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (formValuesEqual(old.value, reading.value)) {
|
|
125
|
+
if (approved(cargo, state, old, reading))
|
|
126
|
+
return true;
|
|
127
|
+
withdraw(cargo, state, old);
|
|
128
|
+
old.status = 'superseded';
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (resumed)
|
|
132
|
+
return false;
|
|
133
|
+
withdraw(cargo, state, old);
|
|
134
|
+
old.status = 'superseded';
|
|
135
|
+
return !own(reading, 'proposalApproval');
|
|
136
|
+
}
|
|
137
|
+
const binding = data(decision.binding ?? null), value = data(reading.value), nextSignature = signature(identity(state), reading.name, value, state.revision, decision);
|
|
138
|
+
const same = old && !old.committed && ['pending', 'confirmed', 'declined'].includes(old.status) && formValuesEqual(old.value, value) && formValuesEqual(old.binding, binding);
|
|
139
|
+
if (same && old.status === 'declined' && resumed)
|
|
140
|
+
return false;
|
|
141
|
+
if (!same || old.status === 'declined') {
|
|
142
|
+
if (old) {
|
|
143
|
+
withdraw(cargo, state, old);
|
|
144
|
+
old.status = 'superseded';
|
|
145
|
+
}
|
|
146
|
+
const sequence = state.inputProposalSequence ?? 0;
|
|
147
|
+
if (!Number.isSafeInteger(sequence) || sequence < 0 || !Number.isSafeInteger(sequence + 1))
|
|
148
|
+
fail('has an invalid proposal sequence');
|
|
149
|
+
state.inputProposalSequence = sequence + 1;
|
|
150
|
+
old = { id: `${state.id}:proposal:${state.inputProposalSequence}`, form: state.id, input: reading.name, owner: identity(state), value, reading: sourceReading(reading), descriptor: decision, binding, signature: nextSignature, owner_revision: state.revision, revision: 1, status: 'pending', committed: false, deferred: false, child: '' };
|
|
151
|
+
state.inputProposals ||= {};
|
|
152
|
+
state.inputProposals[reading.name] = old;
|
|
153
|
+
}
|
|
154
|
+
else if (old.signature !== nextSignature) {
|
|
155
|
+
withdraw(cargo, state, old);
|
|
156
|
+
Object.assign(old, { value, descriptor: decision, binding, signature: nextSignature, owner_revision: state.revision, revision: old.revision + 1, status: 'pending' });
|
|
157
|
+
}
|
|
158
|
+
old.deferred = false;
|
|
159
|
+
return approved(cargo, state, old, reading);
|
|
160
|
+
}
|
|
161
|
+
/** Only actionable records enter the confirmation worker; dependency-deferred records await the normal batch. */
|
|
162
|
+
export function nextFormProposal(cargo, owner) {
|
|
163
|
+
const state = cargo.forms?.[owner];
|
|
164
|
+
if (!state)
|
|
165
|
+
return null;
|
|
166
|
+
for (const spec of state.specs.flatMap(input => input.any_of || [input])) {
|
|
167
|
+
const record = recordFor(cargo, state, spec.name);
|
|
168
|
+
if (active(record) && !record.deferred && formInputApplies(spec, { ...state.values, [spec.name]: record.value }, cargo, state))
|
|
169
|
+
return record;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
/** Owner replay issues approval only for confirmed records; named child refresh never invents approval. */
|
|
174
|
+
export function formProposalReadings(cargo, owner, input = '', approve = true) {
|
|
175
|
+
const state = cargo.forms?.[owner];
|
|
176
|
+
if (!state)
|
|
177
|
+
return [];
|
|
178
|
+
const names = input ? [input] : Object.keys(state.inputProposals || {}), out = [];
|
|
179
|
+
for (const name of names) {
|
|
180
|
+
const { spec } = inputOwner(cargo, owner, name), record = recordFor(cargo, state, name);
|
|
181
|
+
if (!record || record.committed || record.status === 'superseded' || record.deferred || approve && record.status !== 'confirmed' || !input && record.status === 'declined')
|
|
182
|
+
continue;
|
|
183
|
+
const hit = { ...spec, ...data(record.reading), form: owner, name, value: data(record.value), revalidate: true, proposalResume: { id: record.id, signature: record.signature } };
|
|
184
|
+
delete hit.proposalApproval;
|
|
185
|
+
if (approve && proofValid(cargo, state, record))
|
|
186
|
+
hit.proposalApproval = { id: record.id, signature: record.signature };
|
|
187
|
+
out.push(hit);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
/** Bind only a real context child; ordinary child slots and unrelated owners cannot collect consent. */
|
|
192
|
+
export function bindFormProposal(cargo, owner, input, childId) {
|
|
193
|
+
const { state } = inputOwner(cargo, owner, input), record = recordFor(cargo, state, input), child = contextChild(cargo, state, childId);
|
|
194
|
+
if (!active(record) || record.deferred || !current(state, record) || !child)
|
|
195
|
+
fail('cannot bind a stale proposal or foreign confirmation child');
|
|
196
|
+
if (record.child && record.child !== childId)
|
|
197
|
+
withdraw(cargo, state, record);
|
|
198
|
+
if (child.proposalPrompt?.id !== record.id || child.proposalPrompt?.signature !== record.signature)
|
|
199
|
+
child.confirmation = null;
|
|
200
|
+
record.child = childId;
|
|
201
|
+
child.proposalPrompt = { id: record.id, signature: record.signature, input };
|
|
202
|
+
return record;
|
|
203
|
+
}
|
|
204
|
+
/** Exact YES must have passed the ordinary confirm-form revision check; NO never admits the candidate. */
|
|
205
|
+
export function decideFormProposal(cargo, owner, input, childId, decision) {
|
|
206
|
+
if (!['confirmed', 'declined'].includes(decision))
|
|
207
|
+
fail('has an invalid decision');
|
|
208
|
+
const { state } = inputOwner(cargo, owner, input), record = recordFor(cargo, state, input), child = contextChild(cargo, state, childId);
|
|
209
|
+
if (!active(record) || record.deferred || !current(state, record) || !child || record.child !== childId || child.proposalPrompt?.id !== record.id || child.proposalPrompt?.signature !== record.signature || child.proposalPrompt?.input !== input)
|
|
210
|
+
return false;
|
|
211
|
+
if (decision === 'confirmed' && !actionConfirmationValid(cargo, childId, { signature: child.proposal?.signature || null }))
|
|
212
|
+
return false;
|
|
213
|
+
record.status = decision;
|
|
214
|
+
if (decision === 'confirmed')
|
|
215
|
+
record.approval = { id: record.id, signature: record.signature, child: childId, revision: child.revision, confirmation: data(child.confirmation) };
|
|
216
|
+
else {
|
|
217
|
+
withdraw(cargo, state, record);
|
|
218
|
+
child.confirmation = null;
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/** Validate the entire final batch before consuming any proof; invoke immediately before acceptFormReadings. */
|
|
223
|
+
export function commitFormProposals(cargo, accepted = []) {
|
|
224
|
+
const changes = [], readings = [];
|
|
225
|
+
for (const hit of accepted) {
|
|
226
|
+
const { state } = candidate(cargo, hit);
|
|
227
|
+
readings.push({ state, hit });
|
|
228
|
+
const record = recordFor(cargo, state, hit.name);
|
|
229
|
+
if (!record) {
|
|
230
|
+
if (own(hit, 'proposalResume') || own(hit, 'proposalApproval'))
|
|
231
|
+
fail('approval has no live record');
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const tagged = own(hit, 'proposalResume') || own(hit, 'proposalApproval');
|
|
235
|
+
if (tagged) {
|
|
236
|
+
if (!approved(cargo, state, record, hit))
|
|
237
|
+
fail('requires current unconsumed approval before commitment');
|
|
238
|
+
if (changes.some(change => change.record === record))
|
|
239
|
+
fail('approval cannot be consumed twice in one batch');
|
|
240
|
+
changes.push({ state, record, commit: true });
|
|
241
|
+
}
|
|
242
|
+
else if (!record.committed && record.status !== 'superseded' && formValuesEqual(record.value, hit.value))
|
|
243
|
+
fail('cannot commit a retained candidate without approval');
|
|
244
|
+
else if (!formValuesEqual(record.value, hit.value) && record.status !== 'superseded')
|
|
245
|
+
changes.push({ state, record, commit: false });
|
|
246
|
+
}
|
|
247
|
+
// Canonical exclusive selection also replaces uncommitted peer candidates, even if this input has no proposal.
|
|
248
|
+
// Plan every withdrawal after validation, so an invalid later reading cannot partially consume consent.
|
|
249
|
+
for (const { state, hit } of readings)
|
|
250
|
+
for (const group of state.specs.filter(spec => spec.exclusive === true && spec.any_of?.some(member => member.name === hit.name))) {
|
|
251
|
+
const peers = group.any_of.filter(member => member.name !== hit.name);
|
|
252
|
+
if (readings.some(other => other.state.id === state.id && peers.some(peer => peer.name === other.hit.name)))
|
|
253
|
+
fail('exclusive alternatives require clarification before commitment');
|
|
254
|
+
for (const peer of peers) {
|
|
255
|
+
const record = recordFor(cargo, state, peer.name);
|
|
256
|
+
if (active(record) && !changes.some(change => change.record === record))
|
|
257
|
+
changes.push({ state, record, commit: false });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const { state, record, commit } of changes) {
|
|
261
|
+
if (commit) {
|
|
262
|
+
record.committed = true;
|
|
263
|
+
record.deferred = false;
|
|
264
|
+
}
|
|
265
|
+
else
|
|
266
|
+
record.status = 'superseded';
|
|
267
|
+
withdraw(cargo, state, record);
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
/** Failed validation withdraws the candidate; unmet prerequisites wait for the normal dependency replay. */
|
|
272
|
+
export function settleFormProposalValidation(cargo, batch = {}) {
|
|
273
|
+
for (const hit of [...(batch.rejected || []), ...(batch.ignored || [])]) {
|
|
274
|
+
const state = cargo.forms?.[hit.form];
|
|
275
|
+
if (!state)
|
|
276
|
+
continue;
|
|
277
|
+
const record = recordFor(cargo, state, hit.name);
|
|
278
|
+
if (active(record)) {
|
|
279
|
+
withdraw(cargo, state, record);
|
|
280
|
+
record.status = 'superseded';
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const hit of batch.pending || []) {
|
|
284
|
+
const state = cargo.forms?.[hit.form];
|
|
285
|
+
if (!state)
|
|
286
|
+
continue;
|
|
287
|
+
const record = recordFor(cargo, state, hit.name);
|
|
288
|
+
if (active(record))
|
|
289
|
+
record.deferred = true;
|
|
290
|
+
}
|
|
291
|
+
return batch;
|
|
292
|
+
}
|
|
@@ -12,6 +12,7 @@ export function enrichFormMatches(matches: any, chain: any, form: any, names?: a
|
|
|
12
12
|
export function expandFormActivations(cargo: any, hits?: any[]): any[];
|
|
13
13
|
/** No partial activation: validation failure suppresses inferred changes; pending prerequisites retain the complete group. */
|
|
14
14
|
export function settleFormActivations(batch?: {}, cargo?: null): {
|
|
15
|
+
ignored: any[];
|
|
15
16
|
accepted: any[];
|
|
16
17
|
rejected: any[];
|
|
17
18
|
pending: any[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"form-state.d.ts","sourceRoot":"","sources":["../../src/helpers/form-state.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"form-state.d.ts","sourceRoot":"","sources":["../../src/helpers/form-state.js"],"names":[],"mappings":"AAOA,qDAKC;AAED,oDAEC;AAED,qFAAqF;AACrF,oEA2BC;AAuCD,2FAEC;AACD,8FAcC;AACD,2DAOC;AACD,uEAuCC;AAED,kGAAkG;AAClG,2GAOC;AACD,0HAA0H;AAC1H,uEAwBC;AACD,8HAA8H;AAC9H;;;;;EAWC;AACD,uGAAuG;AACvG;;;;;;;;;;QA8BC;AAED,0FAA0F;AAC1F;;;SAIC;AACD,gGAAgG;AAChG;;;;;;;;;;;EAQC;AAED,sGAAsG;AACtG,2DAIC;AACD;;;GAGG;AACH;;;;;;;SAkBC;AAED,2FAA2F;AAC3F,oGAIC;AACD,gFA4BC;AAED,2GAA2G;AAC3G;;;EASC;AACD,mEAoBC;AACD,mEAGC;AACD,sEAUC;AACD,+EAEC;AACD,qFASC;AACD,oGAoCC;AACD;;;SAKC;AACD,qEAGC;AACD,gEAgBC;AACD,2DAQC;AACD,oGAUC;AAED,uEAAuE;AACvE,wHA+BC;AAED,uHAAuH;AACvH,6EAaC;AACD,mHAAmH;AACnH,iGA4CC;AACD,8DAQC;AACD,8EAeC;AAvjBM,8CAAmE"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateFormProposalPolicy } from './proposal-contract.js';
|
|
1
2
|
/** Shared FORM semantics. Pure readers plus explicit mutations of the supplied session container; no host I/O. */
|
|
2
3
|
import { readInputProfile } from './match-inputs.js';
|
|
3
4
|
import { validateMoneyOptions } from './money.js';
|
|
@@ -35,6 +36,11 @@ export function resolveFormInputs(inputs, presets = {}) {
|
|
|
35
36
|
validateMoneyOptions(input.profile.money);
|
|
36
37
|
}
|
|
37
38
|
validateFormExhaustion(input.on_exhausted);
|
|
39
|
+
if (input.proposal !== undefined) {
|
|
40
|
+
if (!input.profile || input.form || input.any_of || input.bind)
|
|
41
|
+
throw new Error('FORM proposal requires a readable leaf input; declare it on each group member or inside the subform');
|
|
42
|
+
validateFormProposalPolicy(input.proposal);
|
|
43
|
+
}
|
|
38
44
|
if (input.max_misses !== undefined && (!Number.isInteger(input.max_misses) || input.max_misses < 1))
|
|
39
45
|
throw new Error('FORM max_misses must be a positive integer');
|
|
40
46
|
if (input.max_attempts !== undefined && (!Number.isInteger(input.max_attempts) || input.max_attempts < 1))
|
|
@@ -264,7 +270,7 @@ export function settleFormActivations(batch = {}, cargo = null) {
|
|
|
264
270
|
for (const [name, hit] of Object.entries(state.pending || {}))
|
|
265
271
|
if (discarded.has(hit.activation_group))
|
|
266
272
|
delete state.pending[name];
|
|
267
|
-
return { accepted: accepted.filter(h => !held.has(h.activation_group) && !discarded.has(h.activation_group)),
|
|
273
|
+
return { ignored: [...(batch.ignored || [])], accepted: accepted.filter(h => !held.has(h.activation_group) && !discarded.has(h.activation_group)),
|
|
268
274
|
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) };
|
|
269
275
|
}
|
|
270
276
|
/** Explicit ownership: unrelated tasks and sibling inputs never share an instance by definition id. */
|
package/dist/helpers/index.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
export namespace STANDARD_FUNCTIONS {
|
|
2
|
+
export { formProposalPolicy };
|
|
3
|
+
export { stageFormProposal };
|
|
4
|
+
export { nextFormProposal };
|
|
5
|
+
export { formProposalReadings };
|
|
6
|
+
export { bindFormProposal };
|
|
7
|
+
export { decideFormProposal };
|
|
8
|
+
export { commitFormProposals };
|
|
9
|
+
export { settleFormProposalValidation };
|
|
10
|
+
export { formProposalProtocol };
|
|
11
|
+
export { validateFormProposalPolicy };
|
|
12
|
+
export { validateFormProposalDescriptor };
|
|
13
|
+
export { formProposalDescriptor };
|
|
2
14
|
export { clearFormMisses };
|
|
3
15
|
export { missFormInput };
|
|
4
16
|
export { formInputPrompt };
|
|
@@ -93,6 +105,18 @@ export namespace STANDARD_FUNCTIONS {
|
|
|
93
105
|
export { urlToSpeech };
|
|
94
106
|
export { textWithUrlToSpeech };
|
|
95
107
|
}
|
|
108
|
+
import { formProposalPolicy } from './form-proposals.js';
|
|
109
|
+
import { stageFormProposal } from './form-proposals.js';
|
|
110
|
+
import { nextFormProposal } from './form-proposals.js';
|
|
111
|
+
import { formProposalReadings } from './form-proposals.js';
|
|
112
|
+
import { bindFormProposal } from './form-proposals.js';
|
|
113
|
+
import { decideFormProposal } from './form-proposals.js';
|
|
114
|
+
import { commitFormProposals } from './form-proposals.js';
|
|
115
|
+
import { settleFormProposalValidation } from './form-proposals.js';
|
|
116
|
+
import { formProposalProtocol } from './proposal-contract.js';
|
|
117
|
+
import { validateFormProposalPolicy } from './proposal-contract.js';
|
|
118
|
+
import { validateFormProposalDescriptor } from './proposal-contract.js';
|
|
119
|
+
import { formProposalDescriptor } from './proposal-contract.js';
|
|
96
120
|
import { clearFormMisses } from './form-conversation.js';
|
|
97
121
|
import { missFormInput } from './form-conversation.js';
|
|
98
122
|
import { formInputPrompt } from './form-conversation.js';
|
|
@@ -191,5 +215,5 @@ import { validatePhone } from './validators.js';
|
|
|
191
215
|
import { validateEmail } from './validators.js';
|
|
192
216
|
import { urlToSpeech } from './url-speech.js';
|
|
193
217
|
import { textWithUrlToSpeech } from './url-speech.js';
|
|
194
|
-
export { clearFormMisses, missFormInput, formInputPrompt, consumeFormFeedback, formConversationOutcome, readMoneyAmount, isMoneyAmount, moneyIntentAllows, validateMoneyOptions, takeChoiceOffer, matchesChoice, ANSWER_WORDS, CONFIRM_WORDS, isAnswer, isAgentRequest, isOtherRequest, isSpelledFragment, isAmountAnswer, amountInAnswer, pickChoice, readEmail, readPhone, readInternationalPhone, readIdentifier, last4, maskEmail, callerTextNumber, textDestination, callerIsInternational, normalizeAndFindCapture, anythingElse, goodbye, sayName, dateToWords, amountToSpeech, validateCardNumber, validateExpiration, consumeActionReceipt, authorizeFormActionRetry, formSignature, selectFormAction, confirmFormRevision, prepareFormAction, actionConfirmationValid, beginFormAction, finishFormAction, actionGateVerified, sourceNeedsLoad, beginSourceRefresh, completeSourceRefresh, pickProviderQuestion, matchInputs, readFormAnswer, readInputProfile, resolveFormInputs, formInputApplies, formInputFilled, nextFormInput, formInputChain, openFormInstance, validateFormIncompleteOutcome, formIncompleteOutcome, validateFormExhaustion, exhaustFormInput, formInitialReadings, orderFormReadings, proposedFormValues, applyFormValidation, formReadingDependencies, exclusiveFormReadings, enrichFormMatches, expandFormActivations, settleFormActivations, rememberFormArguments, resetFormInputs, hasFormValue, formPath, formValuesEqual, formReadingReady, formReadingRelevant, formPreparation, queueFormReadings, pendingFormReadings, formWorkLimit, rejectFormReadings, invalidateFormInputs, acceptFormReadings, formInstanceApplies, closeFormInstance, checkOtp, maskDestination, validateOTP, OTP_EXPIRY_MS, validateDigits, validatePhone, validateEmail, urlToSpeech, textWithUrlToSpeech };
|
|
218
|
+
export { formProposalPolicy, stageFormProposal, nextFormProposal, formProposalReadings, bindFormProposal, decideFormProposal, commitFormProposals, settleFormProposalValidation, formProposalProtocol, validateFormProposalPolicy, validateFormProposalDescriptor, formProposalDescriptor, clearFormMisses, missFormInput, formInputPrompt, consumeFormFeedback, formConversationOutcome, readMoneyAmount, isMoneyAmount, moneyIntentAllows, validateMoneyOptions, takeChoiceOffer, matchesChoice, ANSWER_WORDS, CONFIRM_WORDS, isAnswer, isAgentRequest, isOtherRequest, isSpelledFragment, isAmountAnswer, amountInAnswer, pickChoice, readEmail, readPhone, readInternationalPhone, readIdentifier, last4, maskEmail, callerTextNumber, textDestination, callerIsInternational, normalizeAndFindCapture, anythingElse, goodbye, sayName, dateToWords, amountToSpeech, validateCardNumber, validateExpiration, consumeActionReceipt, authorizeFormActionRetry, formSignature, selectFormAction, confirmFormRevision, prepareFormAction, actionConfirmationValid, beginFormAction, finishFormAction, actionGateVerified, sourceNeedsLoad, beginSourceRefresh, completeSourceRefresh, pickProviderQuestion, matchInputs, readFormAnswer, readInputProfile, resolveFormInputs, formInputApplies, formInputFilled, nextFormInput, formInputChain, openFormInstance, validateFormIncompleteOutcome, formIncompleteOutcome, validateFormExhaustion, exhaustFormInput, formInitialReadings, orderFormReadings, proposedFormValues, applyFormValidation, formReadingDependencies, exclusiveFormReadings, enrichFormMatches, expandFormActivations, settleFormActivations, rememberFormArguments, resetFormInputs, hasFormValue, formPath, formValuesEqual, formReadingReady, formReadingRelevant, formPreparation, queueFormReadings, pendingFormReadings, formWorkLimit, rejectFormReadings, invalidateFormInputs, acceptFormReadings, formInstanceApplies, closeFormInstance, checkOtp, maskDestination, validateOTP, OTP_EXPIRY_MS, validateDigits, validatePhone, validateEmail, urlToSpeech, textWithUrlToSpeech };
|
|
195
219
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/helpers/index.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/helpers/index.js"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mCAA8K,qBAAqB;kCAArB,qBAAqB;iCAArB,qBAAqB;qCAArB,qBAAqB;iCAArB,qBAAqB;mCAArB,qBAAqB;oCAArB,qBAAqB;6CAArB,qBAAqB;qCAC/E,wBAAwB;2CAAxB,wBAAwB;+CAAxB,wBAAwB;uCAAxB,wBAAwB;gCACpC,wBAAwB;8BAAxB,wBAAwB;gCAAxB,wBAAwB;oCAAxB,wBAAwB;wCAAxB,wBAAwB;gCAmB7C,YAAY;8BAAZ,YAAY;kCAAZ,YAAY;qCAAZ,YAAY;gCAlB/D,mBAAmB;8BAiBrB,qBAAqB;6BAEkG,aAAa;8BAAb,aAAa;yBAAb,aAAa;+BAAb,aAAa;+BAAb,aAAa;kCAAb,aAAa;+BAAb,aAAa;+BAAb,aAAa;2BAAb,aAAa;0BACT,cAAc;0BAAd,cAAc;uCAAd,cAAc;+BAAd,cAAc;sBAAd,cAAc;0BAAd,cAAc;iCAAd,cAAc;gCAAd,cAAc;sCAAd,cAAc;wCAC/H,cAAc;6BACP,cAAc;wBAAd,cAAc;wBAAd,cAAc;4BACjC,iBAAiB;+BACd,uBAAuB;mCACC,kBAAkB;mCAAlB,kBAAkB;qCAxB+I,mBAAmB;yCAAnB,mBAAmB;8BAAnB,mBAAmB;iCAAnB,mBAAmB;oCAAnB,mBAAmB;kCAAnB,mBAAmB;wCAAnB,mBAAmB;gCAAnB,mBAAmB;iCAAnB,mBAAmB;mCAAnB,mBAAmB;gCAe1I,mBAAmB;mCAAnB,mBAAmB;sCAAnB,mBAAmB;qCAAnB,mBAAmB;4BAUtD,mBAAmB;+BAAnB,mBAAmB;iCAAnB,mBAAmB;kCAC6mB,iBAAiB;iCAAjB,iBAAiB;gCAAjB,iBAAiB;8BAAjB,iBAAiB;+BAAjB,iBAAiB;iCAAjB,iBAAiB;8CAAjB,iBAAiB;sCAAjB,iBAAiB;uCAAjB,iBAAiB;iCAAjB,iBAAiB;oCAAjB,iBAAiB;kCAAjB,iBAAiB;mCAAjB,iBAAiB;oCAAjB,iBAAiB;wCAAjB,iBAAiB;sCAAjB,iBAAiB;kCAAjB,iBAAiB;sCAAjB,iBAAiB;sCAAjB,iBAAiB;sCAAjB,iBAAiB;gCAAjB,iBAAiB;6BAAjB,iBAAiB;yBAAjB,iBAAiB;gCAAjB,iBAAiB;iCAAjB,iBAAiB;oCAAjB,iBAAiB;gCAAjB,iBAAiB;kCAAjB,iBAAiB;oCAAjB,iBAAiB;8BAAjB,iBAAiB;mCAAjB,iBAAiB;qCAAjB,iBAAiB;mCAAjB,iBAAiB;oCAAjB,iBAAiB;kCAAjB,iBAAiB;yBACzoB,gBAAgB;gCAAhB,gBAAgB;4BAAhB,gBAAgB;8BAAhB,gBAAgB;+BACzB,iBAAiB;8BAAjB,iBAAiB;8BAAjB,iBAAiB;4BAC7B,iBAAiB;oCAAjB,iBAAiB"}
|
package/dist/helpers/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { formProposalPolicy, stageFormProposal, nextFormProposal, formProposalReadings, bindFormProposal, decideFormProposal, commitFormProposals, settleFormProposalValidation } from './form-proposals.js';
|
|
2
|
+
import { formProposalProtocol, validateFormProposalPolicy, validateFormProposalDescriptor, formProposalDescriptor } from './proposal-contract.js';
|
|
1
3
|
import { clearFormMisses, missFormInput, formInputPrompt, consumeFormFeedback, formConversationOutcome } from './form-conversation.js';
|
|
2
4
|
import { takeChoiceOffer } from './choice-offer.js';
|
|
3
5
|
import { consumeActionReceipt, authorizeFormActionRetry, formSignature, selectFormAction, confirmFormRevision, prepareFormAction, actionConfirmationValid, beginFormAction, finishFormAction, actionGateVerified } from './form-actions.js';
|
|
@@ -30,9 +32,10 @@ import { resolveFormInputs, formInputApplies, formInputFilled, nextFormInput, fo
|
|
|
30
32
|
import { checkOtp, maskDestination, validateOTP, OTP_EXPIRY_MS } from './otp-check.js';
|
|
31
33
|
import { validateDigits, validatePhone, validateEmail } from './validators.js';
|
|
32
34
|
import { urlToSpeech, textWithUrlToSpeech } from './url-speech.js';
|
|
33
|
-
export { clearFormMisses, missFormInput, formInputPrompt, consumeFormFeedback, formConversationOutcome, readMoneyAmount, isMoneyAmount, moneyIntentAllows, validateMoneyOptions, takeChoiceOffer, matchesChoice, ANSWER_WORDS, CONFIRM_WORDS, isAnswer, isAgentRequest, isOtherRequest, isSpelledFragment, isAmountAnswer, amountInAnswer, pickChoice, readEmail, readPhone, readInternationalPhone, readIdentifier, last4, maskEmail, callerTextNumber, textDestination, callerIsInternational, normalizeAndFindCapture, anythingElse, goodbye, sayName, dateToWords, amountToSpeech, validateCardNumber, validateExpiration, consumeActionReceipt, authorizeFormActionRetry, formSignature, selectFormAction, confirmFormRevision, prepareFormAction, actionConfirmationValid, beginFormAction, finishFormAction, actionGateVerified, sourceNeedsLoad, beginSourceRefresh, completeSourceRefresh, pickProviderQuestion, matchInputs, readFormAnswer, readInputProfile, resolveFormInputs, formInputApplies, formInputFilled, nextFormInput, formInputChain, openFormInstance, validateFormIncompleteOutcome, formIncompleteOutcome, validateFormExhaustion, exhaustFormInput, formInitialReadings, orderFormReadings, proposedFormValues, applyFormValidation, formReadingDependencies, exclusiveFormReadings, enrichFormMatches, expandFormActivations, settleFormActivations, rememberFormArguments, resetFormInputs, hasFormValue, formPath, formValuesEqual, formReadingReady, formReadingRelevant, formPreparation, queueFormReadings, pendingFormReadings, formWorkLimit, rejectFormReadings, invalidateFormInputs, acceptFormReadings, formInstanceApplies, closeFormInstance, checkOtp, maskDestination, validateOTP, OTP_EXPIRY_MS, validateDigits, validatePhone, validateEmail, urlToSpeech, textWithUrlToSpeech, };
|
|
35
|
+
export { formProposalPolicy, stageFormProposal, nextFormProposal, formProposalReadings, bindFormProposal, decideFormProposal, commitFormProposals, settleFormProposalValidation, formProposalProtocol, validateFormProposalPolicy, validateFormProposalDescriptor, formProposalDescriptor, clearFormMisses, missFormInput, formInputPrompt, consumeFormFeedback, formConversationOutcome, readMoneyAmount, isMoneyAmount, moneyIntentAllows, validateMoneyOptions, takeChoiceOffer, matchesChoice, ANSWER_WORDS, CONFIRM_WORDS, isAnswer, isAgentRequest, isOtherRequest, isSpelledFragment, isAmountAnswer, amountInAnswer, pickChoice, readEmail, readPhone, readInternationalPhone, readIdentifier, last4, maskEmail, callerTextNumber, textDestination, callerIsInternational, normalizeAndFindCapture, anythingElse, goodbye, sayName, dateToWords, amountToSpeech, validateCardNumber, validateExpiration, consumeActionReceipt, authorizeFormActionRetry, formSignature, selectFormAction, confirmFormRevision, prepareFormAction, actionConfirmationValid, beginFormAction, finishFormAction, actionGateVerified, sourceNeedsLoad, beginSourceRefresh, completeSourceRefresh, pickProviderQuestion, matchInputs, readFormAnswer, readInputProfile, resolveFormInputs, formInputApplies, formInputFilled, nextFormInput, formInputChain, openFormInstance, validateFormIncompleteOutcome, formIncompleteOutcome, validateFormExhaustion, exhaustFormInput, formInitialReadings, orderFormReadings, proposedFormValues, applyFormValidation, formReadingDependencies, exclusiveFormReadings, enrichFormMatches, expandFormActivations, settleFormActivations, rememberFormArguments, resetFormInputs, hasFormValue, formPath, formValuesEqual, formReadingReady, formReadingRelevant, formPreparation, queueFormReadings, pendingFormReadings, formWorkLimit, rejectFormReadings, invalidateFormInputs, acceptFormReadings, formInstanceApplies, closeFormInstance, checkOtp, maskDestination, validateOTP, OTP_EXPIRY_MS, validateDigits, validatePhone, validateEmail, urlToSpeech, textWithUrlToSpeech, };
|
|
34
36
|
/** The functions flows call by name, ready to spread into a host's approved functions. */
|
|
35
37
|
export const STANDARD_FUNCTIONS = {
|
|
38
|
+
formProposalPolicy, stageFormProposal, nextFormProposal, formProposalReadings, bindFormProposal, decideFormProposal, commitFormProposals, settleFormProposalValidation, formProposalProtocol, validateFormProposalPolicy, validateFormProposalDescriptor, formProposalDescriptor,
|
|
36
39
|
clearFormMisses, missFormInput, formInputPrompt, consumeFormFeedback, formConversationOutcome,
|
|
37
40
|
readMoneyAmount, isMoneyAmount, moneyIntentAllows, validateMoneyOptions, takeChoiceOffer, matchesChoice, isAnswer, isAgentRequest, isOtherRequest, isSpelledFragment, isAmountAnswer, amountInAnswer, pickChoice,
|
|
38
41
|
readEmail, readPhone, readInternationalPhone, readIdentifier, last4, maskEmail, callerTextNumber, textDestination, callerIsInternational,
|
|
@@ -232,7 +232,7 @@ export function readFormAnswer(answer, candidates, current, choiceKey, questions
|
|
|
232
232
|
let consumed = false;
|
|
233
233
|
const remaining = parts.map(part => {
|
|
234
234
|
const text = part.trim();
|
|
235
|
-
if (!/^[¿\s]*(?:how\b|what\b|when\b|where\b|which\b|why\b|who\b|cu[aá]nt[oa]s?\b|qu[eé]\
|
|
235
|
+
if (!/^[¿\s]*(?:how\b|what\b|when\b|where\b|which\b|why\b|who\b|cu[aá]nt[oa]s?\b|cu[aá]l(?:es)?\b|qu[eé](?=$|[^\p{L}\p{N}_])|cu[aá]ndo\b|d[oó]nde\b|c[oó]mo\b|(?:can|could) you (?:tell|show)\b|(?:i (?:want|would like) to know|tell me|show me|quiero saber|quisiera saber|me gustar[ií]a saber|d[ií]game|dime|mu[eé]strame)\b)/iu.test(text))
|
|
236
236
|
return part;
|
|
237
237
|
const key = pickProviderQuestion(text, questions);
|
|
238
238
|
if (!key || key.startsWith('__'))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function validateProposalData(value: any, seen?: Set<any>): any;
|
|
2
|
+
export function validateFormProposalPolicy(value: any): any;
|
|
3
|
+
export function validateFormProposalDescriptor(value: any): any;
|
|
4
|
+
/** Keep policy selectors out of the runtime decision descriptor; null is an explicit opt-out. */
|
|
5
|
+
export function formProposalDescriptor(policy: any, prepared?: undefined): any;
|
|
6
|
+
export function formProposalProtocol(): number;
|
|
7
|
+
//# sourceMappingURL=proposal-contract.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proposal-contract.d.ts","sourceRoot":"","sources":["../../src/helpers/proposal-contract.js"],"names":[],"mappings":"AAMA,uEAcC;AAUD,4DAWC;AACD,gEAIC;AACD,iGAAiG;AACjG,+EASC;AAzDM,+CAAoC"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export const formProposalProtocol = () => 1;
|
|
2
|
+
/** Data-only authoring and runtime contract for input proposal confirmation. */
|
|
3
|
+
const safeName = value => typeof value === 'string' && /^[A-Za-z0-9][\w.:-]*$/.test(value);
|
|
4
|
+
const plain = value => !!value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
5
|
+
const string = value => typeof value === 'string' && !!value.trim();
|
|
6
|
+
const fail = message => { throw new Error('FORM proposal ' + message); };
|
|
7
|
+
export function validateProposalData(value, seen = new Set()) {
|
|
8
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value))
|
|
9
|
+
return value;
|
|
10
|
+
if (!value || typeof value !== 'object' || seen.has(value) || !Array.isArray(value) && !plain(value))
|
|
11
|
+
fail('binding must be finite, acyclic JSON data');
|
|
12
|
+
seen.add(value);
|
|
13
|
+
const array = Array.isArray(value), keys = Reflect.ownKeys(value);
|
|
14
|
+
for (const key of keys) {
|
|
15
|
+
const property = Object.getOwnPropertyDescriptor(value, key);
|
|
16
|
+
if (typeof key !== 'string' || !Object.hasOwn(property, 'value') || !property.enumerable && !(array && key === 'length'))
|
|
17
|
+
fail('must contain only enumerable JSON data properties');
|
|
18
|
+
if (array && key === 'length')
|
|
19
|
+
continue;
|
|
20
|
+
if (['__proto__', 'prototype', 'constructor'].includes(key) || array && !/^(0|[1-9]\d*)$/.test(key))
|
|
21
|
+
fail('binding contains an unsafe key');
|
|
22
|
+
validateProposalData(property.value, seen);
|
|
23
|
+
}
|
|
24
|
+
if (array && keys.length !== value.length + 1)
|
|
25
|
+
fail('binding cannot contain array holes');
|
|
26
|
+
seen.delete(value);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
const confirmationKeys = ['question', 'question_es', 'binding', 'words', 'interrupts', 'no_keys', 'clarifications', 'on_declined'];
|
|
30
|
+
function confirmation(value, required) {
|
|
31
|
+
if (required && !string(value.question))
|
|
32
|
+
fail('needs a nonempty question');
|
|
33
|
+
for (const key of ['question', 'question_es'])
|
|
34
|
+
if (value[key] !== undefined && !string(value[key]))
|
|
35
|
+
fail(key + ' must be nonempty text');
|
|
36
|
+
if (value.on_declined !== undefined && !safeName(value.on_declined))
|
|
37
|
+
fail('on_declined needs a static worker id');
|
|
38
|
+
for (const key of ['words', 'no_keys', 'clarifications'])
|
|
39
|
+
if (value[key] !== undefined && (!Array.isArray(value[key]) || !value[key].every(string)))
|
|
40
|
+
fail(key + ' must be an array of nonempty words');
|
|
41
|
+
if (value.interrupts !== undefined) {
|
|
42
|
+
if (!Array.isArray(value.interrupts))
|
|
43
|
+
fail('interrupts must be an array');
|
|
44
|
+
const keys = new Set();
|
|
45
|
+
for (const event of value.interrupts) {
|
|
46
|
+
if (!plain(event) || Object.keys(event).some(k => !['key', 'words', 'flow', 'exact'].includes(k)) || !safeName(event.key) || keys.has(event.key) || !safeName(event.flow) || !Array.isArray(event.words) || !event.words.length || !event.words.every(string) || event.exact !== undefined && typeof event.exact !== 'boolean')
|
|
47
|
+
fail('invalid interrupt declaration');
|
|
48
|
+
keys.add(event.key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (value.binding !== undefined)
|
|
52
|
+
validateProposalData(value.binding);
|
|
53
|
+
}
|
|
54
|
+
export function validateFormProposalPolicy(value) {
|
|
55
|
+
validateProposalData(value);
|
|
56
|
+
if (!plain(value) || Object.keys(value).some(key => !['prepare', 'origins', 'when', 'unless', ...confirmationKeys].includes(key)))
|
|
57
|
+
fail('invalid input policy');
|
|
58
|
+
if (value.prepare !== undefined && !safeName(value.prepare))
|
|
59
|
+
fail('prepare needs a static worker id');
|
|
60
|
+
if (!value.prepare && !string(value.question))
|
|
61
|
+
fail('policy needs a prepare worker or question');
|
|
62
|
+
if (value.origins !== undefined && (!Array.isArray(value.origins) || !value.origins.length || new Set(value.origins).size !== value.origins.length || !value.origins.every(origin => ['activation', 'answer'].includes(origin))))
|
|
63
|
+
fail('invalid origins');
|
|
64
|
+
const path = key => typeof key === 'string' && key.split('.').every(part => /^[A-Za-z_$][\w$-]*$/.test(part) && !['__proto__', 'prototype', 'constructor'].includes(part));
|
|
65
|
+
const scalar = item => item === null || typeof item === 'string' || typeof item === 'boolean' || typeof item === 'number' && Number.isFinite(item);
|
|
66
|
+
if (value.when !== undefined && (!plain(value.when) || Object.entries(value.when).some(([key, wanted]) => !path(key) || !(Array.isArray(wanted) ? wanted.length && wanted.every(scalar) : scalar(wanted)))))
|
|
67
|
+
fail('when must map safe paths to scalar conditions or nonempty scalar arrays');
|
|
68
|
+
if (value.unless !== undefined && (!Array.isArray(value.unless) || !value.unless.every(path)))
|
|
69
|
+
fail('unless must be an array of safe paths');
|
|
70
|
+
confirmation(value, false);
|
|
71
|
+
return structuredClone(value);
|
|
72
|
+
}
|
|
73
|
+
export function validateFormProposalDescriptor(value) {
|
|
74
|
+
validateProposalData(value);
|
|
75
|
+
if (!plain(value) || Object.keys(value).some(key => !confirmationKeys.includes(key)))
|
|
76
|
+
fail('invalid decision descriptor');
|
|
77
|
+
confirmation(value, true);
|
|
78
|
+
return structuredClone(value);
|
|
79
|
+
}
|
|
80
|
+
/** Keep policy selectors out of the runtime decision descriptor; null is an explicit opt-out. */
|
|
81
|
+
export function formProposalDescriptor(policy, prepared = undefined) {
|
|
82
|
+
if (!policy)
|
|
83
|
+
return null;
|
|
84
|
+
validateFormProposalPolicy(policy);
|
|
85
|
+
if (policy.prepare && prepared === undefined)
|
|
86
|
+
fail('prepare must explicitly return a descriptor or null');
|
|
87
|
+
if (prepared === null)
|
|
88
|
+
return null;
|
|
89
|
+
if (prepared !== undefined)
|
|
90
|
+
validateProposalData(prepared);
|
|
91
|
+
if (prepared !== undefined && (!prepared || typeof prepared !== 'object' || Array.isArray(prepared)))
|
|
92
|
+
fail('prepare must return a descriptor or null');
|
|
93
|
+
const base = Object.fromEntries(confirmationKeys.filter(key => policy[key] !== undefined).map(key => [key, policy[key]]));
|
|
94
|
+
return validateFormProposalDescriptor({ ...base, ...prepared });
|
|
95
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capabilities.d.ts","sourceRoot":"","sources":["../../src/host/capabilities.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"capabilities.d.ts","sourceRoot":"","sources":["../../src/host/capabilities.js"],"names":[],"mappings":"AAYA,8FAA8F;AAC9F,0FASC;AAED,2FAA2F;AAC3F,iFAKC;AAED,gHAAgH;AAChH,mFA6BC;AA7DD,kGAAkG;AAClG;IACG,uDAMC;IAHE,aAA2C;IAC3C,YAA0B;IAC1B,gBAAkC;CAEvC"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { INPUT_PROPOSALS_REQUIREMENT } from '../forms/proposal-runtime.js';
|
|
1
2
|
/** Declarative flow admission. No engine imports, I/O, or implicit host capability activation. */
|
|
2
3
|
export class JSFEHostCapabilityError extends Error {
|
|
3
4
|
constructor(flowId, capability, reason) {
|
|
@@ -40,6 +41,11 @@ export function assertFlowHostCapabilities(flows, capabilities = {}) {
|
|
|
40
41
|
for (const flow of flows) {
|
|
41
42
|
const requirements = normalizeHostRequirements(flow?.variables?.contract?.value?.requires, flow?.id);
|
|
42
43
|
for (const capability of requirements) {
|
|
44
|
+
// Intrinsic runtime support, not a host option or an implicit host permission.
|
|
45
|
+
if (capability === INPUT_PROPOSALS_REQUIREMENT)
|
|
46
|
+
continue;
|
|
47
|
+
if (capability.startsWith('jsfe:'))
|
|
48
|
+
throw new JSFEHostCapabilityError(flow?.id, capability, 'unsupported intrinsic runtime contract');
|
|
43
49
|
if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) {
|
|
44
50
|
throw new JSFEHostCapabilityError(flow?.id, capability, 'explicit host capabilities must be an object');
|
|
45
51
|
}
|
package/dist/runtime-identity.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const JSFE_RUNTIME_FINGERPRINT = "sha256:
|
|
1
|
+
export const JSFE_RUNTIME_FINGERPRINT = "sha256:258637780da663b142f69744745b18173dfadebefac54d7b71fa5717ff12823d";
|
|
@@ -29,3 +29,9 @@ Publishing the package, deploying the host or uploading tenant/system flow sets
|
|
|
29
29
|
Native contextual misses default to one localized re-ask then DISPATCH of the second current utterance. Validation attempts and declared exhaustion callbacks are independent. Dynamic question refresh, explicit localized retries, nested/out-of-order input, provider detours and recognized clarification questions require behavioral assertions. A required draft yields pending; a failed service stays unresolved; a completed optional follow-up stays completed; unknown effects remain held. The host must consume explicit recovery rather than infer restartability from `unresolved`. Unannotated legacy JSON retains its historical path.
|
|
30
30
|
|
|
31
31
|
Acceptance must include real engine-produced states through the actual host outcome and consume-once guidance adapter, not only helper snapshots. Verify English/Spanish and voice/text, “imposible” never confirms, exact price-reference utterance handoff, same root/accepted values on resume, fresh consent after correction, validator-specific limits, completed receipts, unknown-action holds, matched dispatch suppression and next-turn clearing. Run full engine and tenant matrices and clean consumer installs; Shopify behavior is outside this release and its source/artifact bytes must remain unchanged.
|
|
32
|
+
|
|
33
|
+
## 1.7.3 input-proposal confirmation
|
|
34
|
+
|
|
35
|
+
The shared proposal contract is opt-in through input declarations or validator descriptors. It retains candidates before canonical acceptance, uses an owned exact-confirmation context and revalidates proof before commit. No host or website changes are part of this implementation. The contract is included in JSFE 1.7.3. Publishing and pinning the package do not activate regenerated flow definitions or deploy a host.
|
|
36
|
+
|
|
37
|
+
`contract.requires:['jsfe:input-proposals:v1']` describes intrinsic runtime support, not host configuration. Current JSFE admits that version directly; unknown `jsfe:*` contracts fail closed. Static and linked child declarations derive the requirement and strict entry guard; dynamic proposal workers declare it explicitly. Older runtimes must reject the matched definitions before tenant behavior. Release/pin the accepted runtime before activating regenerated shared and tenant sets. Both consumers must pin the same exact published runtime before the matched shared and tenant flow definitions are activated.
|
package/docs/forms.md
CHANGED
|
@@ -51,6 +51,29 @@ The common collector passes `form_context:{form,current,names,questions}` to `as
|
|
|
51
51
|
|
|
52
52
|
Private provider questions during collection preserve the owner and pending verification. Repeated questions join the pending provider request instead of stacking another authentication flow. Independent field clauses are accepted before a provider suspends for verification. Questions cannot become invalid OTP attempts or arbitrary text answers.
|
|
53
53
|
|
|
54
|
+
## Input proposal confirmation (unpublished candidate)
|
|
55
|
+
|
|
56
|
+
Use `input.proposal` when an inferred or ambiguous **field value** needs the caller's approval before acceptance. This is independent of a performer's final action confirmation: accepting a payment option never authorizes a charge. Declare it on readable leaf inputs, including members of an `any_of` group; group, bound-value and subform declarations reject this attribute instead of silently ignoring it. Ordinary inputs without this declaration retain their existing path.
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
{
|
|
60
|
+
name:'reason', profile:{kind:'choice',choices:[/* canonical choices */]},
|
|
61
|
+
proposal:{prepare:'describe-reason',origins:['activation'],when:{reason:['lost','stolen']}}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
An optional pure `prepare` worker receives `{form,input,value,values,reading}`. `value` is validator-normalized; `values` includes validated peers and this candidate. It writes `cargo.proposalCheck={question,question_es,binding?,on_declined?,words?,no_keys?,clarifications?,interrupts?}`, or explicitly `null` when no proposal is required. A static policy may supply the question directly. An ordinary validator can independently return `cargo.inputCheck={ok:true,value,proposal:{question,question_es,binding,...}}`. Missing or malformed descriptors fail closed. Declare dynamic proposal workers with `contract.requires:['jsfe:input-proposals:v1']`; static policies and linked descendant schemas derive that requirement automatically.
|
|
66
|
+
|
|
67
|
+
Formatters must be pure apart from their output slot: no authentication, business updates or external effects. Bind the semantic subject and facts that make the question true (account, option, plan, prior receipt). Avoid volatile unrelated history. `origins` restricts initial triggering to `activation` or `answer`; corrections to a pending proposal still require a fresh applicable decision. `when`/`unless` use normal form conditions against candidate values.
|
|
68
|
+
|
|
69
|
+
JSFE owns `owner.inputProposals[input]`, separate from canonical `values` and the existing **action** `proposal`. Candidates survive contextual yield and bare reentry. An owned `confirm-input-proposal` context uses ordinary exact confirmation and treats the proposed field as current for corrections. Independent inputs commit normally. Accepting a member of an exclusive `any_of` group withdraws pending or confirmed proposals for its peer members. YES is provisional proof bound to owner identity, revision, candidate, prompt and business binding; ordinary validation reruns before canonical acceptance and post-commit callbacks. A changed candidate, policy, subject or binding revokes old proof. Rejected/inapplicable candidates are withdrawn; unmet prerequisites defer them. Approval is consumed once. Do not set proposal state or approval tokens in tenant expressions.
|
|
70
|
+
|
|
71
|
+
NO never commits the candidate or hands an incomplete value to a parent. It closes the confirmation context and optionally calls `on_declined` with `{form,input,value,reading}` once; this worker chooses the business alternative and may close its owner. Yield retains the proposal and has no decline effect. Do not add separate yes/no collector children, cargo permission flags or ad hoc stack restoration.
|
|
72
|
+
|
|
73
|
+
The versioned intrinsic requirement is supported by JSFE itself, with no host feature switch. Unknown `jsfe:*` versions reject admission even if a caller supplies a matching boolean. Compiled proposal entries also include a strict `formProposalProtocol() === 1` guard, so an older direct engine cannot silently skip consent. Deploy matched runtime, shared workers and tenant declarations; these candidate artifacts must not be activated with published 1.7.2.
|
|
74
|
+
|
|
75
|
+
Test actual EN/ES voice/text conversations: activation, unreadable-answer yield, no-argument root reentry, explicit NO, mixed YES plus correction, provider detours, independent fields, nested/foreign owners, stale bindings and consumed proof. Assert canonical values, callbacks, authentication and effects, then run the complete engine and tenant acceptance matrices against installed package bytes.
|
|
76
|
+
|
|
54
77
|
## Providers
|
|
55
78
|
|
|
56
79
|
Declare a `provides` catalog: subject, gate (`flow`, verified paths), source (`flow`, result path, subject/freshness policy), facts and fact sets. Each fact has a key, phrasings, source path or dedicated worker, rendering rules and explicit missing-data text. A private catalog requires an explicit gate. Derive `asked` and mid-form question choices with the builder; do not maintain another list.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsfe",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"description": "TypeScript workflow engine for conversational/automation apps: multi-flow orchestration, interrupt/resume, stack-based control, robust errors, and flexible tool/REST integration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ron Pinkas",
|