jsfe 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -4
- package/dist/forms/action-projection.d.ts +4 -0
- package/dist/forms/action-projection.d.ts.map +1 -0
- package/dist/forms/action-projection.js +21 -0
- package/dist/forms/action-recovery.d.ts +12 -0
- package/dist/forms/action-recovery.d.ts.map +1 -0
- package/dist/forms/action-recovery.js +125 -0
- package/dist/forms/action-tools.d.ts +29 -0
- package/dist/forms/action-tools.d.ts.map +1 -0
- package/dist/forms/action-tools.js +6 -0
- package/dist/forms/address-flows.d.ts +2 -0
- package/dist/forms/address-flows.d.ts.map +1 -0
- package/dist/forms/address-flows.js +39 -0
- package/dist/forms/choice-flow.d.ts +419 -0
- package/dist/forms/choice-flow.d.ts.map +1 -0
- package/dist/forms/choice-flow.js +707 -0
- package/dist/forms/contracts.d.ts +237 -0
- package/dist/forms/contracts.d.ts.map +1 -0
- package/dist/forms/contracts.js +278 -0
- package/dist/forms/flows.d.ts +611 -0
- package/dist/forms/flows.d.ts.map +1 -0
- package/dist/forms/flows.js +675 -0
- package/dist/forms/identity-flows.d.ts +17 -0
- package/dist/forms/identity-flows.d.ts.map +1 -0
- package/dist/forms/identity-flows.js +302 -0
- package/dist/forms/index.d.ts +7 -0
- package/dist/forms/index.d.ts.map +1 -0
- package/dist/forms/index.js +10 -0
- package/dist/forms/prompt-compiler.d.ts +3 -0
- package/dist/forms/prompt-compiler.d.ts.map +1 -0
- package/dist/forms/prompt-compiler.js +30 -0
- package/dist/forms/session-actions.d.ts +14 -0
- package/dist/forms/session-actions.d.ts.map +1 -0
- package/dist/forms/session-actions.js +40 -0
- package/dist/helpers/address.d.ts +27 -0
- package/dist/helpers/address.d.ts.map +1 -0
- package/dist/helpers/address.js +29 -0
- package/dist/helpers/answer.d.ts +5 -0
- package/dist/helpers/answer.d.ts.map +1 -1
- package/dist/helpers/answer.js +5 -3
- package/dist/helpers/choice-offer.d.ts +3 -0
- package/dist/helpers/choice-offer.d.ts.map +1 -0
- package/dist/helpers/choice-offer.js +6 -0
- package/dist/helpers/form-actions.d.ts +13 -0
- package/dist/helpers/form-actions.d.ts.map +1 -0
- package/dist/helpers/form-actions.js +144 -0
- package/dist/helpers/form-sources.d.ts +7 -0
- package/dist/helpers/form-sources.d.ts.map +1 -0
- package/dist/helpers/form-sources.js +47 -0
- package/dist/helpers/form-state.d.ts +94 -0
- package/dist/helpers/form-state.d.ts.map +1 -0
- package/dist/helpers/form-state.js +870 -0
- package/dist/helpers/index.d.ts +114 -1
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/index.js +9 -4
- package/dist/helpers/match-inputs.d.ts +14 -0
- package/dist/helpers/match-inputs.d.ts.map +1 -1
- package/dist/helpers/match-inputs.js +154 -41
- package/dist/helpers/money.d.ts +12 -0
- package/dist/helpers/money.d.ts.map +1 -0
- package/dist/helpers/money.js +54 -0
- package/dist/helpers/spoken-money.d.ts +20 -0
- package/dist/helpers/spoken-money.d.ts.map +1 -0
- package/dist/helpers/spoken-money.js +143 -0
- package/dist/host/action-journal.d.ts +98 -0
- package/dist/host/action-journal.d.ts.map +1 -0
- package/dist/host/action-journal.js +218 -0
- package/dist/host/action-session.d.ts +12 -0
- package/dist/host/action-session.d.ts.map +1 -0
- package/dist/host/action-session.js +29 -0
- package/dist/host/capabilities.d.ts +14 -0
- package/dist/host/capabilities.d.ts.map +1 -0
- package/dist/host/capabilities.js +60 -0
- package/dist/host/checkpoint-codec-error.d.ts +6 -0
- package/dist/host/checkpoint-codec-error.d.ts.map +1 -0
- package/dist/host/checkpoint-codec-error.js +4 -0
- package/dist/host/index.d.ts +6 -0
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +6 -0
- package/dist/host/intent-stages.d.ts +1 -0
- package/dist/host/intent-stages.d.ts.map +1 -1
- package/dist/host/intent-stages.js +3 -3
- package/dist/host/node.d.ts +17 -0
- package/dist/host/node.d.ts.map +1 -0
- package/dist/host/node.js +64 -0
- package/dist/host/recovery-identity.d.ts +16 -0
- package/dist/host/recovery-identity.d.ts.map +1 -0
- package/dist/host/recovery-identity.js +73 -0
- package/dist/host/recovery-policy.d.ts +5 -0
- package/dist/host/recovery-policy.d.ts.map +1 -0
- package/dist/host/recovery-policy.js +54 -0
- package/dist/host/runtime-manifest.d.ts +3 -0
- package/dist/host/runtime-manifest.d.ts.map +1 -0
- package/dist/host/runtime-manifest.js +38 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +186 -45
- package/dist/runtime-identity.d.ts +3 -0
- package/dist/runtime-identity.d.ts.map +1 -0
- package/dist/runtime-identity.js +1 -0
- package/docs/development-release.md +25 -0
- package/docs/forms.md +132 -0
- package/package.json +15 -4
package/dist/index.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { ACTION_TOOLS } from './forms/action-tools.js';
|
|
2
|
+
import { claimSessionAction, completeSessionAction } from './forms/session-actions.js';
|
|
3
|
+
import { actionPersistenceMode } from './host/capabilities.js';
|
|
4
|
+
import { resumableActionSession, restoreActionSession, projectActionRecoverySession } from './forms/action-recovery.js';
|
|
5
|
+
import { createActionRecoveryIdentity, actionRecoveryIdentityMatches } from './host/recovery-identity.js';
|
|
1
6
|
/**
|
|
2
7
|
* Copyright (c) 2025 InstantAIguru.com
|
|
3
8
|
|
|
@@ -108,6 +113,8 @@ const DEFAULT_MESSAGE_REGISTRY = {
|
|
|
108
113
|
flow_resumed_with_guidance: "Resuming {{flowPrompt}} - Type 'cancel flow' or 'help' for options.",
|
|
109
114
|
flow_completed: "{{flowPrompt}} completed successfully.",
|
|
110
115
|
flow_completed_generic: "Flow completed.",
|
|
116
|
+
action_recovery_incompatible: "I cannot safely finish checking your earlier request right now. I have not repeated it or processed your latest message.",
|
|
117
|
+
action_recovery_input_deferred: "I recovered the earlier result above. Your latest message was not processed; please send it again if you still need help.",
|
|
111
118
|
flow_cancelled: "Cancelled {{flowPrompt}}.",
|
|
112
119
|
flow_help_general: "Processing {{flowPrompt}} - You can also 'cancel flow' or request 'help'.",
|
|
113
120
|
flow_help_payment: "Processing {{flowPrompt}} - You can also 'cancel flow' or request 'help'.",
|
|
@@ -151,6 +158,8 @@ const DEFAULT_MESSAGE_REGISTRY = {
|
|
|
151
158
|
flow_resumed_with_guidance: "Continuando {{flowPrompt}} - Escriba 'cancelar flujo' o 'ayuda' para opciones.",
|
|
152
159
|
flow_completed: "{{flowPrompt}} completado exitosamente.",
|
|
153
160
|
flow_completed_generic: "Flujo completado.",
|
|
161
|
+
action_recovery_incompatible: "No puedo terminar de verificar su solicitud anterior de forma segura en este momento. No la he repetido ni he procesado su último mensaje.",
|
|
162
|
+
action_recovery_input_deferred: "Recuperé el resultado anterior que aparece arriba. Su último mensaje no se procesó; envíelo de nuevo si todavía necesita ayuda.",
|
|
154
163
|
flow_cancelled: "{{flowPrompt}} cancelado.",
|
|
155
164
|
flow_help_general: "Procesando {{flowPrompt}} - También puede 'cancelar flujo' o solicitar 'ayuda'.",
|
|
156
165
|
flow_help_payment: "Procesando {{flowPrompt}} - También puede 'cancelar flujo' o solicitar 'ayuda'.",
|
|
@@ -2011,6 +2020,28 @@ function switchToPreviousStack(engine) {
|
|
|
2011
2020
|
throw error;
|
|
2012
2021
|
}
|
|
2013
2022
|
}
|
|
2023
|
+
// Mutable execution data must belong to its activation, never the registered template.
|
|
2024
|
+
// Plain programmatic definitions may contain callbacks; preserve those references while
|
|
2025
|
+
// cloning their surrounding data rather than rejecting them with structuredClone.
|
|
2026
|
+
function cloneFlowValue(value, seen = new WeakMap()) {
|
|
2027
|
+
if (value === null || typeof value !== 'object')
|
|
2028
|
+
return value;
|
|
2029
|
+
if (seen.has(value))
|
|
2030
|
+
return seen.get(value);
|
|
2031
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2032
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
|
|
2033
|
+
return structuredClone(value);
|
|
2034
|
+
}
|
|
2035
|
+
const copy = Array.isArray(value) ? new Array(value.length) : Object.create(prototype);
|
|
2036
|
+
seen.set(value, copy);
|
|
2037
|
+
for (const key of Object.keys(value)) {
|
|
2038
|
+
Object.defineProperty(copy, key, {
|
|
2039
|
+
value: cloneFlowValue(value[key], seen),
|
|
2040
|
+
enumerable: true, writable: true, configurable: true
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
return copy;
|
|
2044
|
+
}
|
|
2014
2045
|
function getInitialVariables(engine, flowDefinition, inheritedVariables) {
|
|
2015
2046
|
let variables;
|
|
2016
2047
|
if (inheritedVariables) {
|
|
@@ -2020,7 +2051,7 @@ function getInitialVariables(engine, flowDefinition, inheritedVariables) {
|
|
|
2020
2051
|
if (flowDefinition?.variables) {
|
|
2021
2052
|
for (const [varName, varDef] of Object.entries(flowDefinition.variables)) {
|
|
2022
2053
|
if (varDef.value !== undefined) {
|
|
2023
|
-
variables[varName] = varDef.value; //
|
|
2054
|
+
variables[varName] = cloneFlowValue(varDef.value); // Fresh defaults; the caller's variable container stays shared
|
|
2024
2055
|
}
|
|
2025
2056
|
}
|
|
2026
2057
|
}
|
|
@@ -2032,7 +2063,7 @@ function getInitialVariables(engine, flowDefinition, inheritedVariables) {
|
|
|
2032
2063
|
if (flowDefinition?.variables) {
|
|
2033
2064
|
for (const [varName, varDef] of Object.entries(flowDefinition.variables)) {
|
|
2034
2065
|
if (varDef.value !== undefined) {
|
|
2035
|
-
variables[varName] = varDef.value;
|
|
2066
|
+
variables[varName] = cloneFlowValue(varDef.value);
|
|
2036
2067
|
}
|
|
2037
2068
|
}
|
|
2038
2069
|
}
|
|
@@ -2123,7 +2154,7 @@ async function isFlowActivated(input, engine, userId = 'anonymous') {
|
|
|
2123
2154
|
flowName: flow.name,
|
|
2124
2155
|
flowId: flow.id,
|
|
2125
2156
|
flowVersion: flow.version,
|
|
2126
|
-
flowStepsStack:
|
|
2157
|
+
flowStepsStack: cloneFlowValue(flow.steps).reverse(),
|
|
2127
2158
|
contextStack: [{ role: 'user', content: sanitizeInput(input), timestamp: Date.now() }], // Keep activation input in context for reference
|
|
2128
2159
|
inputStack: [], // Start empty - only SAY-GET responses go here
|
|
2129
2160
|
variables, // Start with global variables + flow definition variables + extracted parameters
|
|
@@ -2676,8 +2707,14 @@ function enforceParameterTypes(flow, parameters) {
|
|
|
2676
2707
|
const n = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN);
|
|
2677
2708
|
converted = Number.isFinite(n) && (type === 'number' || Number.isInteger(n)) ? n : undefined;
|
|
2678
2709
|
}
|
|
2710
|
+
else if (p.jsonSchema && type === 'object') {
|
|
2711
|
+
converted = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
2712
|
+
}
|
|
2713
|
+
else if (p.jsonSchema && type === 'array') {
|
|
2714
|
+
converted = Array.isArray(value) ? value : undefined;
|
|
2715
|
+
}
|
|
2679
2716
|
else {
|
|
2680
|
-
converted = value; //
|
|
2717
|
+
converted = value; // Legacy parameters without an extraction schema retain their existing behavior
|
|
2681
2718
|
}
|
|
2682
2719
|
if (converted !== undefined) {
|
|
2683
2720
|
result[p.name] = converted;
|
|
@@ -2712,7 +2749,8 @@ function buildDetectFlowRequest(input, lastChatTurn, flows) {
|
|
|
2712
2749
|
name: p.name,
|
|
2713
2750
|
description: p.description,
|
|
2714
2751
|
...(p.type !== undefined ? { type: p.type } : {}),
|
|
2715
|
-
...(Array.isArray(p.enum) ? { enum: [...p.enum] } : {})
|
|
2752
|
+
...(Array.isArray(p.enum) ? { enum: [...p.enum] } : {}),
|
|
2753
|
+
...(p.jsonSchema ? { jsonSchema: structuredClone(p.jsonSchema) } : {})
|
|
2716
2754
|
}))
|
|
2717
2755
|
}))
|
|
2718
2756
|
};
|
|
@@ -3351,7 +3389,7 @@ async function handleToolStep(currentFlowFrame, engine) {
|
|
|
3351
3389
|
flowName: rebootFlow.name,
|
|
3352
3390
|
flowId: rebootFlow.id,
|
|
3353
3391
|
flowVersion: rebootFlow.version,
|
|
3354
|
-
flowStepsStack:
|
|
3392
|
+
flowStepsStack: cloneFlowValue(rebootFlow.steps).reverse(),
|
|
3355
3393
|
contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
|
|
3356
3394
|
inputStack: [input],
|
|
3357
3395
|
variables: variables, // Fresh variables for reboot flow (with injected params)
|
|
@@ -3408,7 +3446,7 @@ async function handleToolStep(currentFlowFrame, engine) {
|
|
|
3408
3446
|
flowName: onFailFlow.name,
|
|
3409
3447
|
flowId: onFailFlow.id,
|
|
3410
3448
|
flowVersion: onFailFlow.version,
|
|
3411
|
-
flowStepsStack:
|
|
3449
|
+
flowStepsStack: cloneFlowValue(onFailFlow.steps).reverse(),
|
|
3412
3450
|
contextStack: [...currentFlowFrame.contextStack], // Inherit context
|
|
3413
3451
|
inputStack: [...currentFlowFrame.inputStack],
|
|
3414
3452
|
variables: variables,
|
|
@@ -3722,6 +3760,11 @@ function handleSetStep(currentFlowFrame, engine) {
|
|
|
3722
3760
|
returnType: 'auto'
|
|
3723
3761
|
}, engine)
|
|
3724
3762
|
: step.value;
|
|
3763
|
+
if (step.requireTrue === true && interpolatedValue !== true) {
|
|
3764
|
+
const error = new Error(`Required SET step ${step.id || ''} did not complete successfully`);
|
|
3765
|
+
error.name = 'JSFERequiredSetFailed';
|
|
3766
|
+
throw error;
|
|
3767
|
+
}
|
|
3725
3768
|
if (currentFlowFrame && currentFlowFrame.variables !== undefined) {
|
|
3726
3769
|
currentFlowFrame.variables[step.variable] = interpolatedValue;
|
|
3727
3770
|
logger.info(`SET step: stored '${interpolatedValue}' in variable '${step.variable}'`);
|
|
@@ -4032,7 +4075,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
|
|
|
4032
4075
|
flowName: subFlow.name,
|
|
4033
4076
|
flowId: subFlow.id,
|
|
4034
4077
|
flowVersion: subFlow.version,
|
|
4035
|
-
flowStepsStack:
|
|
4078
|
+
flowStepsStack: cloneFlowValue(subFlow.steps).reverse(),
|
|
4036
4079
|
contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
|
|
4037
4080
|
inputStack: [input],
|
|
4038
4081
|
variables, // Fresh variables for reboot flow
|
|
@@ -4052,7 +4095,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
|
|
|
4052
4095
|
currentFlowFrame.flowName = subFlow.name;
|
|
4053
4096
|
currentFlowFrame.flowId = subFlow.id;
|
|
4054
4097
|
currentFlowFrame.flowVersion = subFlow.version;
|
|
4055
|
-
currentFlowFrame.flowStepsStack =
|
|
4098
|
+
currentFlowFrame.flowStepsStack = cloneFlowValue(subFlow.steps).reverse();
|
|
4056
4099
|
addToContextStack(currentFlowFrame.contextStack, 'user', input);
|
|
4057
4100
|
currentFlowFrame.inputStack.push(input);
|
|
4058
4101
|
// Inject parameters into current variables — or, for an isolated flow, start this frame's own scope
|
|
@@ -4060,13 +4103,18 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
|
|
|
4060
4103
|
currentFlowFrame.variables = Object.assign(getInitialVariables(engine, subFlow), flowParameters);
|
|
4061
4104
|
currentFlowFrame.isolatedScope = true;
|
|
4062
4105
|
}
|
|
4063
|
-
else if (Object.keys(flowParameters).length > 0) {
|
|
4064
|
-
logger.info(`Injecting parameters into variables for flow ${subFlow.name}`, flowParameters);
|
|
4065
|
-
Object.assign(currentFlowFrame.variables, flowParameters);
|
|
4066
|
-
logger.info(`Variables after injection:`, currentFlowFrame.variables);
|
|
4067
|
-
}
|
|
4068
4106
|
else {
|
|
4069
|
-
//
|
|
4107
|
+
// Legacy shared replacement preserves the current variable container. New workers may
|
|
4108
|
+
// explicitly initialize target defaults that are still undefined; accepted progress stays.
|
|
4109
|
+
currentFlowFrame.variables ||= {};
|
|
4110
|
+
if (step.initializeDefaults === true) {
|
|
4111
|
+
for (const [name, definition] of Object.entries(subFlow.variables || {})) {
|
|
4112
|
+
if (currentFlowFrame.variables[name] === undefined && definition.value !== undefined) {
|
|
4113
|
+
currentFlowFrame.variables[name] = cloneFlowValue(definition.value);
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
4117
|
+
Object.assign(currentFlowFrame.variables, flowParameters);
|
|
4070
4118
|
}
|
|
4071
4119
|
// Update transaction
|
|
4072
4120
|
TransactionManager.fail(currentFlowFrame.transaction, `Replaced by flow ${subFlow.name}`);
|
|
@@ -4086,7 +4134,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
|
|
|
4086
4134
|
flowName: subFlow.name,
|
|
4087
4135
|
flowId: subFlow.id,
|
|
4088
4136
|
flowVersion: subFlow.version,
|
|
4089
|
-
flowStepsStack:
|
|
4137
|
+
flowStepsStack: cloneFlowValue(subFlow.steps).reverse(),
|
|
4090
4138
|
contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
|
|
4091
4139
|
inputStack: [input],
|
|
4092
4140
|
variables, // Inherit + merge flow definition variables (isolated: its own)
|
|
@@ -5207,32 +5255,19 @@ function evaluateJavaScriptExpression(expression, context, preserveObjects = fal
|
|
|
5207
5255
|
}
|
|
5208
5256
|
return value;
|
|
5209
5257
|
});
|
|
5210
|
-
//
|
|
5211
|
-
|
|
5212
|
-
const functionBody = preserveObjects ? `
|
|
5213
|
-
"use strict";
|
|
5258
|
+
// Keep evaluator temporaries outside the generated function: a flow may legitimately declare `result`.
|
|
5259
|
+
const evaluatorFunction = new Function(...validParamNames, `"use strict"; return (${expression});`);
|
|
5214
5260
|
try {
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5261
|
+
const evaluated = evaluatorFunction(...paramValues);
|
|
5262
|
+
if (!preserveObjects && typeof evaluated === 'object' && evaluated !== null &&
|
|
5263
|
+
!(evaluated.__userInput === true && evaluated.__literal === true)) {
|
|
5264
|
+
return JSON.stringify(evaluated);
|
|
5265
|
+
}
|
|
5266
|
+
return evaluated;
|
|
5267
|
+
}
|
|
5268
|
+
catch (error) {
|
|
5269
|
+
throw new Error('Expression evaluation failed: ' + error.message);
|
|
5218
5270
|
}
|
|
5219
|
-
` : `
|
|
5220
|
-
"use strict";
|
|
5221
|
-
try {
|
|
5222
|
-
const result = (${expression});
|
|
5223
|
-
// Auto-stringify complex data structures when used in string contexts
|
|
5224
|
-
if (typeof result === 'object' && result !== null &&
|
|
5225
|
-
!(result.__userInput === true && result.__literal === true)) {
|
|
5226
|
-
return JSON.stringify(result);
|
|
5227
|
-
}
|
|
5228
|
-
return result;
|
|
5229
|
-
} catch (e) {
|
|
5230
|
-
throw new Error('Expression evaluation failed: ' + e.message);
|
|
5231
|
-
}
|
|
5232
|
-
`;
|
|
5233
|
-
// Create and execute function with only valid parameter names
|
|
5234
|
-
const evaluatorFunction = new Function(...validParamNames, functionBody);
|
|
5235
|
-
return evaluatorFunction(...paramValues);
|
|
5236
5271
|
}
|
|
5237
5272
|
/**
|
|
5238
5273
|
* Security-aware variable setter for user input with AI-powered voice cleanup
|
|
@@ -5582,7 +5617,7 @@ async function handlePendingInterruptionSwitch(engine, userId) {
|
|
|
5582
5617
|
flowName: targetFlow,
|
|
5583
5618
|
flowId: targetFlowDefinition.id,
|
|
5584
5619
|
flowVersion: targetFlowDefinition.version || '1.0',
|
|
5585
|
-
flowStepsStack:
|
|
5620
|
+
flowStepsStack: cloneFlowValue(targetFlowDefinition.steps).reverse(),
|
|
5586
5621
|
contextStack: [],
|
|
5587
5622
|
inputStack: [],
|
|
5588
5623
|
variables: getInitialVariables(engine, targetFlowDefinition), // Fresh variables for first flow activation
|
|
@@ -5647,7 +5682,7 @@ async function handleRegularFlowInterruption(intentAnalysis, engine, userId) {
|
|
|
5647
5682
|
flowName: targetFlow,
|
|
5648
5683
|
flowId: targetFlowDefinition.id,
|
|
5649
5684
|
flowVersion: targetFlowDefinition.version || '1.0',
|
|
5650
|
-
flowStepsStack:
|
|
5685
|
+
flowStepsStack: cloneFlowValue(targetFlowDefinition.steps).reverse(),
|
|
5651
5686
|
contextStack: [], // Start with empty context - don't use interruption input
|
|
5652
5687
|
inputStack: [], // Start with empty input stack - new flow will get proper input next
|
|
5653
5688
|
variables: getInitialVariables(engine, targetFlowDefinition), // Fresh variables for interrupting flow activation
|
|
@@ -5956,6 +5991,30 @@ export function applyWraps(flowsMenu) {
|
|
|
5956
5991
|
}
|
|
5957
5992
|
}
|
|
5958
5993
|
export class WorkflowEngine {
|
|
5994
|
+
_actionPersistence = 'durable';
|
|
5995
|
+
/** Native actions require durable storage unless the host deliberately selects session-only guards. */
|
|
5996
|
+
get actionPersistence() { return this._actionPersistence; }
|
|
5997
|
+
set actionPersistence(mode) {
|
|
5998
|
+
actionPersistenceMode(mode);
|
|
5999
|
+
if (mode === 'session-only' && this.actionJournal)
|
|
6000
|
+
throw new Error('Session-only actions cannot attach a durable journal');
|
|
6001
|
+
this._actionPersistence = actionPersistenceMode(mode);
|
|
6002
|
+
}
|
|
6003
|
+
/** Host-injected durable action journal. Missing storage never implicitly enables session-only actions. */
|
|
6004
|
+
actionJournal;
|
|
6005
|
+
actionRecoveryIdentityPromise;
|
|
6006
|
+
/** Loaded definitions are immutable after activation. Hot reload constructs a new engine. */
|
|
6007
|
+
async getActionRecoveryIdentity() {
|
|
6008
|
+
if (this.actionJournal) {
|
|
6009
|
+
for (const method of ['claim', 'complete', 'checkpoint', 'pending']) {
|
|
6010
|
+
if (typeof this.actionJournal[method] !== 'function')
|
|
6011
|
+
throw new Error('Durable action journal requires ' + method);
|
|
6012
|
+
}
|
|
6013
|
+
}
|
|
6014
|
+
if (!this.actionRecoveryIdentityPromise)
|
|
6015
|
+
this.actionRecoveryIdentityPromise = createActionRecoveryIdentity({ flows: this.flowsMenu, tools: this.toolsRegistry });
|
|
6016
|
+
return this.actionRecoveryIdentityPromise;
|
|
6017
|
+
}
|
|
5959
6018
|
cargo;
|
|
5960
6019
|
flowsMenu;
|
|
5961
6020
|
toolsRegistry;
|
|
@@ -6065,8 +6124,22 @@ export class WorkflowEngine {
|
|
|
6065
6124
|
logger = withPiiRedaction(hostLogger); // Assign to global logger (a SAY-GET's `pii` answer never reaches it)
|
|
6066
6125
|
this.aiCallback = aiCallback;
|
|
6067
6126
|
this.flowsMenu = applyWraps(flowsMenu);
|
|
6068
|
-
|
|
6069
|
-
|
|
6127
|
+
if (toolsRegistry.some(tool => ACTION_TOOLS.some(internal => internal.id === tool.id)))
|
|
6128
|
+
throw new Error('Internal JSFE action tool identities are reserved');
|
|
6129
|
+
this.toolsRegistry = [...toolsRegistry, ...ACTION_TOOLS];
|
|
6130
|
+
const receipt = (reply) => reply;
|
|
6131
|
+
this.APPROVED_FUNCTIONS = { ...APPROVED_FUNCTIONS,
|
|
6132
|
+
jsfeClaimAction: async ({ record, action }) => receipt(this.actionPersistence === 'session-only'
|
|
6133
|
+
? claimSessionAction(this.cargo, record, action)
|
|
6134
|
+
: this.actionJournal
|
|
6135
|
+
? await this.actionJournal.claim({ sessionId: this.sessionId, record, action, checkpoint: resumableActionSession(this.sessionContext, this.flowsMenu, await this.getActionRecoveryIdentity()) })
|
|
6136
|
+
: { status: 'hold', outcome: { status: 'unknown', reason: 'durable_journal_unavailable' } }),
|
|
6137
|
+
jsfeCompleteAction: async ({ record, action, outcome }) => receipt(this.actionPersistence === 'session-only'
|
|
6138
|
+
? completeSessionAction(this.cargo, record, action, outcome)
|
|
6139
|
+
: this.actionJournal
|
|
6140
|
+
? await this.actionJournal.complete({ sessionId: this.sessionId, record, action, outcome, checkpoint: resumableActionSession(this.sessionContext, this.flowsMenu, await this.getActionRecoveryIdentity()) })
|
|
6141
|
+
: { status: 'unknown', outcome: { status: 'unknown', reason: 'durable_journal_unavailable' } }),
|
|
6142
|
+
};
|
|
6070
6143
|
this.globalVariables = globalVariables ? { ...globalVariables } : {}; // Copy global variables to prevent external mutations
|
|
6071
6144
|
this.aiTimeOut = aiTimeOut ?? 2000; // Default to 2000ms (2 seconds)
|
|
6072
6145
|
// if validateOnInit is not provided, default to true
|
|
@@ -6111,6 +6184,7 @@ export class WorkflowEngine {
|
|
|
6111
6184
|
*/
|
|
6112
6185
|
initSession(userId, sessionId, language) {
|
|
6113
6186
|
const engineSessionContext = {
|
|
6187
|
+
...(this.actionJournal || this.actionPersistence === 'session-only' ? { actionPersistence: this.actionPersistence } : {}),
|
|
6114
6188
|
sessionId: sessionId || globalThis.crypto.randomUUID(),
|
|
6115
6189
|
userId: userId,
|
|
6116
6190
|
createdAt: new Date(),
|
|
@@ -6129,6 +6203,21 @@ export class WorkflowEngine {
|
|
|
6129
6203
|
}
|
|
6130
6204
|
async updateActivity(contextEntry, engineSessionContext) {
|
|
6131
6205
|
try {
|
|
6206
|
+
if (engineSessionContext.actionPersistence && engineSessionContext.actionPersistence !== this.actionPersistence) {
|
|
6207
|
+
throw new Error('Action persistence cannot change within an existing conversation');
|
|
6208
|
+
}
|
|
6209
|
+
if (this.actionPersistence === 'session-only') {
|
|
6210
|
+
if (engineSessionContext.actionRecoveryProgress !== undefined || engineSessionContext.cargo?.actionJournalSequence !== undefined) {
|
|
6211
|
+
throw new Error('Existing durable recovery state cannot be migrated to session-only execution');
|
|
6212
|
+
}
|
|
6213
|
+
if (this.actionJournal)
|
|
6214
|
+
throw new Error('Session-only actions cannot attach a durable journal');
|
|
6215
|
+
const receipts = engineSessionContext.cargo?.actionJournalReceipts;
|
|
6216
|
+
if (receipts && (!Array.isArray(receipts) || receipts.length))
|
|
6217
|
+
throw new Error('Pending durable action receipts require their durable journal');
|
|
6218
|
+
}
|
|
6219
|
+
if (this.actionJournal || this.actionPersistence === 'session-only')
|
|
6220
|
+
engineSessionContext.actionPersistence = this.actionPersistence;
|
|
6132
6221
|
this.cargo = engineSessionContext.cargo || {};
|
|
6133
6222
|
// a security code under any key that names one (a form stores it as `card_cvv`)
|
|
6134
6223
|
logger.info(`Received Cargo: ${JSON.stringify(this.cargo, (k, v) => (/(cvv|cvc|card_?code|security_?code)/i.test(k) && v ? '•••' : v))}`);
|
|
@@ -6195,6 +6284,50 @@ export class WorkflowEngine {
|
|
|
6195
6284
|
if (engineSessionContext) {
|
|
6196
6285
|
engineSessionContext.lastActivity = this.lastActivity;
|
|
6197
6286
|
}
|
|
6287
|
+
// Recover durable effects before the new message can mutate an older saved session.
|
|
6288
|
+
// A successfully saved session carries its observed sequence even when acknowledgement failed.
|
|
6289
|
+
delete engineSessionContext.lastTurnRecovery;
|
|
6290
|
+
let recoveredAction = false;
|
|
6291
|
+
let suspendedRecoveryStacks;
|
|
6292
|
+
if (contextEntry.role === 'user' && this.actionJournal) {
|
|
6293
|
+
const recoveryIdentity = await this.getActionRecoveryIdentity();
|
|
6294
|
+
const pending = await this.actionJournal.pending({ sessionId: this.sessionId });
|
|
6295
|
+
const applied = (this.cargo.actionJournalApplied || {});
|
|
6296
|
+
// A journal sequence orders writes, not execution progress. Track consumption per attempt.
|
|
6297
|
+
const record = (pending?.records || []).filter((row) => row.checkpoint && Number(row.sequence) > Number(applied[row.key] || 0))
|
|
6298
|
+
.sort((a, b) => Number(a.sequence) - Number(b.sequence)).at(-1);
|
|
6299
|
+
const recovery = record ? { key: record.key, sequence: record.sequence, status: record.status, session: record.checkpoint, record } : null;
|
|
6300
|
+
if (recovery) {
|
|
6301
|
+
if (!actionRecoveryIdentityMatches(recoveryIdentity, recovery.session.actionRecoveryIdentity)) {
|
|
6302
|
+
engineSessionContext.lastTurnToolCalls = [];
|
|
6303
|
+
delete engineSessionContext.lastFlowOutcome;
|
|
6304
|
+
delete engineSessionContext.lastTurnDispatch;
|
|
6305
|
+
this.hostStart = null;
|
|
6306
|
+
engineSessionContext.lastTurnRecovery = { key: record.key, sequence: record.sequence, status: record.status, hold: true, reason: 'incompatible_recovery_identity', deferredInput: contextEntry };
|
|
6307
|
+
engineSessionContext.response = getSystemMessage(this, 'action_recovery_incompatible');
|
|
6308
|
+
return engineSessionContext;
|
|
6309
|
+
}
|
|
6310
|
+
const snapshotProgress = recovery.session.actionRecoveryProgress;
|
|
6311
|
+
const changedEpoch = (engineSessionContext.actionRecoveryEpoch || null) !== (recovery.session.actionRecoveryEpoch || null);
|
|
6312
|
+
const alreadyAdvanced = Number.isSafeInteger(snapshotProgress) && Number(engineSessionContext.actionRecoveryProgress || 0) >= Number(snapshotProgress);
|
|
6313
|
+
const snapshotApplied = recovery.session.cargo?.actionJournalApplied || {};
|
|
6314
|
+
const hasNewerApplied = Object.entries(applied).some(([key, sequence]) => Number(sequence) > Number(snapshotApplied[key] || 0));
|
|
6315
|
+
const invalidatedSinceCheckpoint = Number(engineSessionContext.actionRecoveryPolicy?.revision || 0) > Number(recovery.session.actionRecoveryPolicy?.revision || 0);
|
|
6316
|
+
if (changedEpoch || alreadyAdvanced || hasNewerApplied || invalidatedSinceCheckpoint) {
|
|
6317
|
+
suspendedRecoveryStacks = projectActionRecoverySession(engineSessionContext, recovery, contextEntry, this.flowsMenu, recoveryIdentity);
|
|
6318
|
+
}
|
|
6319
|
+
else {
|
|
6320
|
+
restoreActionSession(engineSessionContext, recovery, contextEntry, recoveryIdentity);
|
|
6321
|
+
}
|
|
6322
|
+
this.cargo = engineSessionContext.cargo;
|
|
6323
|
+
this.reconstructVariableReferences();
|
|
6324
|
+
engineSessionContext.lastTurnToolCalls = [];
|
|
6325
|
+
delete engineSessionContext.lastFlowOutcome;
|
|
6326
|
+
delete engineSessionContext.lastTurnDispatch;
|
|
6327
|
+
this.hostStart = null;
|
|
6328
|
+
recoveredAction = true;
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
6198
6331
|
// Role-based processing logic
|
|
6199
6332
|
if (contextEntry.role === 'user') {
|
|
6200
6333
|
// A sensitive answer (SAY-GET `pii`) is kept out of every log line of this turn, raw and as its digits
|
|
@@ -6205,10 +6338,16 @@ export class WorkflowEngine {
|
|
|
6205
6338
|
// Detect intent to activate a flow or switch flows
|
|
6206
6339
|
let responseOrNull;
|
|
6207
6340
|
try {
|
|
6208
|
-
|
|
6341
|
+
if (!recoveredAction && this.actionJournal)
|
|
6342
|
+
engineSessionContext.actionRecoveryProgress = Number(engineSessionContext.actionRecoveryProgress || 0) + 1;
|
|
6343
|
+
responseOrNull = recoveredAction ? await playFlowFrame(this) : await processActivity(said, userId, this);
|
|
6209
6344
|
}
|
|
6210
6345
|
finally {
|
|
6211
6346
|
piiInputs = [];
|
|
6347
|
+
if (suspendedRecoveryStacks) {
|
|
6348
|
+
engineSessionContext.flowStacks = suspendedRecoveryStacks;
|
|
6349
|
+
this.reconstructVariableReferences();
|
|
6350
|
+
}
|
|
6212
6351
|
}
|
|
6213
6352
|
// Anomaly guard: a flow is STILL ACTIVE but produced no response.
|
|
6214
6353
|
// Detect activity by frame EXISTENCE, not by the response value: a frame
|
|
@@ -6238,6 +6377,8 @@ export class WorkflowEngine {
|
|
|
6238
6377
|
err.name = 'JSFEActiveFlowEmptyResponse';
|
|
6239
6378
|
throw err;
|
|
6240
6379
|
}
|
|
6380
|
+
if (recoveredAction)
|
|
6381
|
+
responseOrNull = [responseOrNull, getSystemMessage(this, 'action_recovery_input_deferred')].filter(Boolean).join('\n\n');
|
|
6241
6382
|
// Store user turn in lastChatTurn if not in a flow
|
|
6242
6383
|
if (responseOrNull === null) {
|
|
6243
6384
|
this.sessionContext.lastChatTurn.user = contextEntry;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime-identity.d.ts","sourceRoot":"","sources":["../src/runtime-identity.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,eAAO,MAAM,wBAAwB,EAAE,MAAkB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const JSFE_RUNTIME_FINGERPRINT = "sha256:e22c79845d34c8eaf8fa259acaddd4bc3c5a8999202e1c7eee690187f475a760";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Reproducible native JSFE development candidate
|
|
2
|
+
|
|
3
|
+
The native form/performer implementation spans the engine, host and tenant flow generator. Testing an unversioned sibling symlink does not establish that a clean consumer installation will use the same engine.
|
|
4
|
+
|
|
5
|
+
A local candidate is an unpublished prerelease. Freeze and test its source, run `npm run clean`, `npm run build` and `npm test`, then use `npm pack --ignore-scripts` to create an immutable artifact. Never replace a candidate tarball with different bytes under the same version. If runtime source changes afterward, create the next prerelease version.
|
|
6
|
+
|
|
7
|
+
Both consumers pin the identical tarball under `vendor/jsfe/` using an exact `file:` dependency and lockfile integrity. Record the tarball SHA-256, package version, engine runtime fingerprint, source revisions and dirty source digest, and Node/npm versions. Verify each installed consumer's bytes with `flow-tests/lib/runtime-build.mjs`; matching package version labels alone are insufficient. Exercise a clean `npm ci` installation before accepting the candidate.
|
|
8
|
+
|
|
9
|
+
Acceptance includes engine tests, host journal/save-boundary/invalidation tests, tenant `npm run test:accept`, real-engine production-response-shape journeys, the independent designer-skill example and bilingual live intent/parameter probes. Include late completion after newer saved work, versioned receipt acknowledgment races, expiry/reset recovery, strict projector failure, optional follow-up outcomes and explicit confirmation clarification. Exercise the actual storage adapter and conversation helpers with full session checkpoints and high-entropy history, checking exact decoded round trips, complete item bounds and removal of acknowledged checkpoint payloads. Customer/API effects in these checks use local stubs. Model credential access is a separately authorized test operation. Do not retain credentials in reports.
|
|
10
|
+
|
|
11
|
+
The original `ai-ar` behavioral suites remain runnable in original mode. Native mode declares deliberate interaction changes explicitly and preserves independent payload, consent, subject, failure and retry expectations. There are no blanket exclusions for failed suites. Stale artifacts, crashed or empty selections and failed assertions fail acceptance.
|
|
12
|
+
|
|
13
|
+
Compatibility remains explicit. The published `amountInAnswer` and `isAmountAnswer` helpers retain their legacy defaults. USD grammar uses `readMoneyAmount`/`isMoneyAmount` with `{currency:'USD',cardinals:true,intent:'payment'}`, or a FORM amount profile with that object under `money`; cardinal parsing and payment intent are optional flags. Shared `FLOW` replacement retains its current variables unless the step declares `initializeDefaults:true`, which fills only undefined target defaults. Native builders opt in; loaded legacy flows are not rewritten. Mutable declared defaults and authored step data are private to each activation, while shared flow variables still use the caller's container. `SET requireTrue:true` requires a literal true result; absent or false retains ordinary SET behavior.
|
|
14
|
+
|
|
15
|
+
An absent `config.jsfe` group defaults to `{durableActions:false,sessionEncoding:'plain'}`; omitted keys default independently to those values. Unknown keys, invalid types and unsupported encodings reject configuration. `durableActions:true` attaches the action journal; legacy turns otherwise perform no journal reads. `sessionEncoding:'br-v1'` enables encoded conversation writes through Node-only `jsfe/host/node`; keep universal `jsfe/host` free of Node dependencies. Journal-internal encoding is separate from conversation policy. Codec bounds default to 16 MiB decoded JSON and 350 KiB encoded binary; the host additionally rejects complete DynamoDB items above its conservative 400 KiB bound. Unsupported/corrupt envelopes and codec limits raise `CheckpointCodecError`; complete-item overflow raises `JSFEStorageSizeLimit`. A failed decode must not replace the conversation, and a failed save must not acknowledge action receipts.
|
|
16
|
+
|
|
17
|
+
Deploy compatible readers across the fleet before enabling `br-v1` for a tenant. New readers accept historical plaintext and supported encoded rows; pre-reader hosts cannot read envelopes. Switching back to `plain` converts an existing encoded row only on a successful, size-bounded save, without trimming; dormant rows remain encoded and an oversized plaintext result blocks conversion. Before an older-reader rollback, stop new native starts, keep pending native continuations on capable routing, losslessly CAS-convert every target encoded row and verify none remain. Toggling policy or resetting sessions is not a migration.
|
|
18
|
+
|
|
19
|
+
Durable restore/project requires an exact identity match for `{protocol:1,runtime:'sha256:...',definitions:'<digest>'}`. Missing, invalid or different identities HOLD recovery: preserve current state, defer new input, and perform no projector, effect or acknowledgment. The definition digest includes canonical flows and nonsecret tool execution declarations; loaded definitions are immutable for an engine instance, so hot reload requires a new engine. This deliberately limits recovery across runtime or definition changes; it is not a production rollout approval.
|
|
20
|
+
|
|
21
|
+
Native performer declarations from 1.7.1 include `contract.requires: ['guardedActions']`. `assertFlowHostCapabilities` derives this requirement only from explicit `durableActions:true` (the default execution mode) or explicit `actionPersistence:'session-only'`. A bare `guardedActions:true` cannot bypass policy. Additional explicit `durableActions` requirements, including serialized 1.7.0 performers, remain strict; rebuild the set to select the new host policy. Other declared requirements still need an explicitly supplied boolean capability. The check never enables a capability. The host rejects unmet requirements before constructing an engine. Legacy flows without capability declarations keep the default-off behavior. The flow-set switch preflight uses the same helper; an explicit `--jsfe-policy` JSON file can merge approved policy keys in the same tenant configuration write as the flows and tools. Omitting the option preserves the current policy and rejects a native set when its requirements are unmet. Compression remains a separate storage decision.
|
|
22
|
+
|
|
23
|
+
Session-only opt-out (1.7.1) requires `config.jsfe.actionPersistence:'session-only'`, no attached journal, and `durableActions` absent or false. The engine defaults to durable and missing storage still fails closed. Session-only retains confirmation, subject, revision, gate and session-owned attempted/completed/unknown guards, but cannot recover an effect after its updated conversation save is lost. It creates no durable receipts or journal traffic. Action-enabled conversations pin their mode before the first effect; changing the mode of an existing conversation or abandoning durable recovery metadata is rejected. Apply the new mode to fresh conversations and keep existing conversations on their original routing. See [session-only semantics](forms.md#explicit-session-only-action-persistence-171).
|
|
24
|
+
|
|
25
|
+
Publishing the package, deploying the host or uploading tenant/system flow sets are separate release actions requiring the owner's authorization. A release should replace local tarball dependencies with the exact published package whose bytes were accepted, and deploy matched host/system/tenant contracts together. No deployment is implied by a passing local candidate.
|
package/docs/forms.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Native forms, providers and performers
|
|
2
|
+
|
|
3
|
+
Implemented locally on 2026-10-19 in `jsfe/src/forms` and `jsfe/src/helpers`. These changes are a local, unpublished development candidate. Record the package version and runtime byte fingerprint; a matching version string alone does not identify a build. No production deployment is implied.
|
|
4
|
+
|
|
5
|
+
This is the current authoring reference. Earlier `collect-input-pattern.md` and `manifests.md` preserve the original design discussion; their carry/lift migration recipe and definition-keyed state examples are superseded.
|
|
6
|
+
|
|
7
|
+
## Ownership
|
|
8
|
+
|
|
9
|
+
- JSFE owns generic matching, input validation protocol, form instances/dependencies, provider retrieval, confirmation, action identity and durable recovery policy. Import builders and shared declarations from `jsfe/forms`; expression helpers come from `jsfe/helpers`.
|
|
10
|
+
- The host owns credentialed transport, storage adapters and persistence acknowledgments. Shared behavior does not belong in tenant-local or host-local support copies.
|
|
11
|
+
- Tenant modules own catalogs, API payloads, business eligibility and presentation. Pure domain rules may be authored as source functions and embedded at build time. Do not read legacy generators or snapshots to construct runtime steps.
|
|
12
|
+
- `defineForm`, `defineProvider`, `definePerformer`, `defineWorker`, `operationWorkers` and `linkFormSchemas` compile native declarations. `validateFlowContracts` checks contracts and references; it does not prove behavioral parity.
|
|
13
|
+
|
|
14
|
+
## One instance per owned task
|
|
15
|
+
|
|
16
|
+
`cargo.forms[instance]` contains `definition`, `task`, `parent`, `result_input`, canonical `specs`, `values`, accepted proofs, pending readings, errors, preparation state, `data`, saved arguments and child instances. Instance IDs are opaque. `cargo.formTasks[definition]` identifies an active root; nested ownership is `(parent instance, input slot)`, allowing two address slots or independent tasks to coexist.
|
|
17
|
+
|
|
18
|
+
Use the supplied `form_instance` only for internal reentry. A form resumes its saved arguments and validated progress. A sub-form returns through the same acceptance/invalidation transition as a field. Context service forms use `parent_form` with no `result_input`; they inherit the owner’s task without fabricating a value slot.
|
|
19
|
+
|
|
20
|
+
Never mutate accepted values directly for ordinary input. Use the common acceptance protocol. Explicit business-derived values and service results must have declared owning paths and dependencies.
|
|
21
|
+
|
|
22
|
+
## Input declarations
|
|
23
|
+
|
|
24
|
+
Use presets (`field`), an owned sub-form (`form`), or a narrow inline `profile`. Include labels and prompts in both languages. Relevant attributes:
|
|
25
|
+
|
|
26
|
+
- `when` / `unless`: relevance and readability. Conditions can reference sibling values, `cargo.*`, or `form.data.*`. Unknown conditions wait; they do not silently count as false.
|
|
27
|
+
- `required_when`: whether a relevant field must be collected. False makes the field optional while retaining voluntary recognition. Unknown blocks completion until its premise is known. Omit for required fields.
|
|
28
|
+
- `depends_on`: preparation/validation waits for committed prerequisite evidence; changing the prerequisite invalidates the dependent transitively.
|
|
29
|
+
- `reset_on`: invalidation only. It does not add a validation prerequisite.
|
|
30
|
+
- `validator`: receives `{form,input,value,values,reading}` and writes `cargo.inputCheck={ok,value?,message?,message_es?}`. Missing result fails closed. Activation and conversational answers use this same path.
|
|
31
|
+
- `prepare`: runs once before first validation or prompt; `refresh` runs before each prompt. `choices_from` / `question_from` read owner `data`. Use `compileFormPrompts` for contextual template prompts; it preserves declarations and generates workers.
|
|
32
|
+
- `activate` / `activate_on`: nominate dormant child entry fields that may propose controller assignments, such as a card number selecting a new-card branch. The controller and nominated entry validate as one atomic group; rejection cannot flip the controller. Use narrow descendant paths, never broad matching of every child field.
|
|
33
|
+
- `validate_always:true`: revalidate even an unchanged value when its business assumptions must be refreshed. Validator `revalidate:true` provides the same explicit cache bypass at validator declaration.
|
|
34
|
+
- `after`: post-commit callback. `any_of` groups can be `exclusive`; conflicting members require clarification.
|
|
35
|
+
- `confirm_against`: explicitly requires clarification when two field interpretations overlap, including current-field priority exceptions.
|
|
36
|
+
- `choices[].keys`: whole-answer keypad aliases, recognized only for the current field. An explicit current keypad key wins an overlapping current-field claim, including an `any_of` group; `confirm_against` can still require clarification. Keys never become aliases for noncurrent fields. `words` are semantic aliases; `current_only` restricts a choice. Grouped digit aliases and marked currency must not steal each other’s spans. English and Spanish word amounts require explicit currency and follow the shared cardinal grammar; sequential card digits are not money.
|
|
37
|
+
|
|
38
|
+
A batch retains independent valid fields if another field fails. Dependent early values remain pending until prerequisites validate. An invalid prerequisite cannot authorize an effect. Structured submissions are read leaf by leaf; objects are never coerced into scalar values. Primary nested extraction schemas derive from the same input declarations.
|
|
39
|
+
|
|
40
|
+
## Resolution and reentry
|
|
41
|
+
|
|
42
|
+
Resolve overlapping evidence in this order: current field, one matching field at the nearest owning scope, then ancestors. Multiple competing claims at the same applicable scope are clarified. Independent spans fill independent fields. Typed evidence beats an unconstrained text fallback. `confirm_against` deliberately overrides ordinary priority.
|
|
43
|
+
|
|
44
|
+
The common loop asks the first ready missing required input, skips accepted data, unwinds inapplicable children and blocks unresolved prerequisites. A context service may use `stop` and `close_on_stop:true` to close itself without handing a value to its parent. With `resume_on_parent_change:true`, an accepted owner correction closes the stale context service and resumes the same owned form with its retained progress, requiring a fresh confirmation. `RETURN` ends the entire engine transaction; ordinary worker completion uses `END`.
|
|
45
|
+
|
|
46
|
+
Optional follow-ups may declare `incomplete_outcome:{outcome:'resolved',reason:'payment_made'}` to preserve an already completed task’s outcome when collection cannot finish or the user leaves. Accepted outcome values are `resolved`, `unresolved` and `deflected`, with a nonempty reason. A reusable form may instead use `incomplete_outcome:{from:'completed_outcome'}` with a declared object parameter; its runtime value must pass the same validation. The nearest actual owned ancestor supplies a fallback to a child. Another task’s outcome never does. The policy covers missing input specifications, blocked dependencies, exhausted validation/retries and unmatched collection/confirmation dispatch; it does not complete missing inputs or authorize effects. Omit it for ordinary required forms, which retain their existing unresolved outcomes and reasons.
|
|
47
|
+
|
|
48
|
+
For bounded collection recovery, declare `on_exhausted:'recovery-worker'` on an input or FORM. On attempt exhaustion, the resolver checks the failed input/member, its group, then its containing form before walking actual owned ancestors. The worker receives `{form,failed_form,input,failed_input,reason:'attempts_exhausted'}`: `form` and `input` identify the selected policy’s owner, while `failed_form` and `failed_input` identify the original failure. Shared recovery resets only the selected input branch, closes its child without handing off an incomplete value, withdraws confirmation and reboots to that worker. Independent draft fields remain. The callback must deliberately collect a new choice and obtain any required fresh confirmation before an effect; exhaustion is not consent. Worker names and references are validated. With no callback, the existing incomplete-outcome or give-up path applies.
|
|
49
|
+
|
|
50
|
+
The common collector passes `form_context:{form,current,names,questions}` to `ask-choice`. The reader checks declared current-field or unique same-scope form readings before classifying an answer as a stale verification-code echo. A valid ZIP, amount or current menu key must not be discarded merely because its digits occur in the prior OTP. Use this shared context instead of a tenant-specific code filter.
|
|
51
|
+
|
|
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
|
+
|
|
54
|
+
## Providers
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
`provide-data`, `load-source` and `answer-facts` retrieve and answer requested facts. An explicit fact request does not require a generic account summary. Source refresh clears old results first and records freshness only after success. Subject changes invalidate incompatible data and verification. `no_gate` may skip rerunning a gate only when its declared verification paths hold; it never bypasses their check.
|
|
59
|
+
|
|
60
|
+
## Performers
|
|
61
|
+
|
|
62
|
+
Declare each action’s name, effect, subject, gate, execution worker/tool, idempotency or reconciliation policy, exact confirmation where applicable, and outcomes. `definePerformer` gathers inputs, prepares an immutable request, selects one applicable action, confirms it, calls `perform-action`, runs its required pure projector, consumes the exact durable receipt, then presents the stored result. Presenter arguments are `{form,request,result}`; retain that explicit result across nested performers rather than rereading the shared last-result cargo slot.
|
|
63
|
+
|
|
64
|
+
Declare `project:[SET steps]` for deterministic session updates such as payment and case ledgers. Its expression locals are `{form,formSnapshot,request,result,key}`: `form` is the owned instance ID and `formSnapshot` is its state at the recorded action. Projection writes target current `cargo`; detached recovery does not replace current forms with that snapshot. Use the durable key for idempotence, the original result timestamp for ordering and explicit ownership guards to preserve newer task drafts or facts. The compiler accepts only SET expressions with local temporary variables and rejects flow/tool/message orchestration. Projectors must be effect-free; expression failure prevents receipt consumption. Presenters own conversation and optional subsequent performers. Ledger updates belong in `project`, because detached late recovery runs this projector without replaying the presenter, `after` steps or caller continuation.
|
|
65
|
+
|
|
66
|
+
Bind subjects to owner data, including composite identities where necessary: `{account,service}` for cancellation or `{contact,type,reason}` for CRM filing. Compare the prepared subject to the current verification evidence before execution. An old unknown result holds that action scope; it cannot capture unrelated requests.
|
|
67
|
+
|
|
68
|
+
Confirmation is separate from fields. It binds the current form revision, values and prepared request. A correction invalidates that binding. Use `confirm.no_keys` only when a declared menu needs different keypad semantics; default `2` remains NO. Confirmation events receive `{form,event,text}` and reenter through ordinary validation. A YES embedded in other content is not consent. `confirm.clarifications` may list exact whole-answer clarification phrases in both languages, such as `['what does that mean','qué significa eso']`. Matching ignores question punctuation and uses the bounded ordinary unclear-answer retry path. These phrases neither consent nor change the request; other unrelated requests still dispatch normally.
|
|
69
|
+
|
|
70
|
+
## Durable actions and host save boundary
|
|
71
|
+
|
|
72
|
+
Hosts inject `createActionJournal({storage,namespace})` from `jsfe/host`; storage implements atomic attempt/scope/session-index compare-and-swap. A journal must expose claim, complete, checkpoint and pending recovery. The built-in `jsfe:action-claim` and `jsfe:action-complete` ports are reserved. Missing storage fails closed before native effects unless the host explicitly selects session-only execution. In hy-rag-lambdas, `config.jsfe.durableActions` defaults to false: the journal stays unattached, and ordinary legacy turns make no journal reads or add native recovery progress. Only an explicitly opted-in tenant activates this policy.
|
|
73
|
+
|
|
74
|
+
`engine.getActionRecoveryIdentity()` privately caches a verified build stamp and a canonical digest of the loaded compiled flow and tool execution contracts. The stamp is verified before bundling and remains available inside the bundle; production recovery performs no runtime filesystem scan. Checkpoints carry `{protocol:1,runtime,definitions}`. Before full restoration or pure projection, JSFE requires an exact identity match. Missing, unsupported or different identities hold the pending outcome, preserve current task state, defer the incoming message and run no effect, projector or acknowledgment. A package version label is not a compatibility identity. Loaded definitions are immutable after activation; hot reload constructs a new engine, and execution counters/default data belong to individual frames.
|
|
75
|
+
|
|
76
|
+
The journal records a resumable engine checkpoint before an effect and the result afterward. For an opted-in native tenant, JSFE examines unconsumed versions per action key before consuming a new user message. Only an exact compatible recovery identity proceeds to continuation selection. A storage sequence orders journal writes, not workflow progress. A checkpoint from progress absent in the saved session restores its continuation; a late completion after saved progress runs only its pure projector against the current session, preserving current tasks and never replaying its presenter or caller tail. The arriving input is recorded as deferred recovery metadata. It never treats that message as retroactive consent. A higher journal sequence alone cannot justify restoring an older workflow; per-key applied versions, saved workflow progress, recovery epoch and host invalidation revision determine whether continuation restore is safe.
|
|
77
|
+
|
|
78
|
+
The host saves the resulting session first, then awaits `checkpointActionSession`. Failed or rejected saves never release action scopes. Versioned `{key,sequence}` receipts stay persisted for acknowledgment retry. Freeze those versions at the successful save boundary: an older attempted/unknown receipt must never acknowledge a later completion. Late tool callbacks return results without advancing the active session’s applied watermark. The performer advances it only after consuming and projecting the durable result. Acknowledging an unknown outcome clears its recovery checkpoint but retains its scope hold. No elapsed timeout resolves an uncertain financial action; a balance change alone is not transaction identity evidence. Explicit fresh consent may supersede an uncertain SEND only under a declared send retry policy.
|
|
79
|
+
|
|
80
|
+
Host invalidations use `retainRecoveryState(session,paths)` after changing or deleting host-owned cargo. Newer invalidations, including deletion tombstones, select pure result projection against the current session instead of restoring the older workflow or its private presenter. Explicit conversation resets rotate the persisted recovery epoch: old outcomes can be projected, but discarded workflows cannot resume. Before every fresh effect, `perform-action` reruns the declared `gate.flow` and checks current verification paths; a stale derived permission flag cannot replace that gate.
|
|
81
|
+
|
|
82
|
+
The host’s DynamoDB adapter shares the configured chats table with reserved internal keys. Chat entry points reject those keys. Do not deploy a performer in durable mode without configuring this port and testing the actual save boundary.
|
|
83
|
+
|
|
84
|
+
## Explicit session-only action persistence (1.7.1)
|
|
85
|
+
|
|
86
|
+
The engine defaults to `engine.actionPersistence = 'durable'`. A host can explicitly set `engine.actionPersistence = 'session-only'` and leave `engine.actionJournal` unattached. In hy-rag-lambdas, the tenant setting is `jsfe: {actionPersistence: 'session-only'}`; `durableActions` must be omitted or false. Selecting session-only with `durableActions: true` is a configuration error. Omitting the mode or disabling `durableActions` alone never opts out of durability for native performers. Storage encoding remains independent and defaults to plain.
|
|
87
|
+
|
|
88
|
+
Both modes use the same request ownership, input revision, authentication gate and exact confirmation checks. Session-only records the claim and first observed outcome in the form's action record. A saved attempted/unknown action cannot execute again on reentry, even after form replacement; saved completed requests replay their result while that task remains owned. Unknown charges cannot be automatically retried. An intentionally new task still requires its own confirmation. Session-only creates no durable receipts, journal reads/writes or cross-invocation recovery checkpoint.
|
|
89
|
+
|
|
90
|
+
The host must serialize turns and persist the complete returned session. If an external charge succeeds but that updated session is lost, a later invocation can repeat the effect from the older snapshot. Session-only therefore does not provide crash-safe double-charge prevention or protection against concurrently forked session snapshots. Such protection requires durable action storage and/or backend idempotency with a key recoverable after the failure. Acknowledgment is not evidence of business-level exactly-once processing in either mode.
|
|
91
|
+
|
|
92
|
+
The selected persistence mode is recorded in action-enabled conversations before the first effect. A mode change rejects processing instead of silently migrating a live conversation. Existing durable recovery metadata and receipts also reject session-only execution. Route existing conversations to their original mode; use the new policy for fresh conversations. Switching a policy flag is not a durable-state migration, especially for older snapshots that predate the policy marker.
|
|
93
|
+
|
|
94
|
+
## Checkpoint storage codec
|
|
95
|
+
|
|
96
|
+
The Node host storage boundary imports `encodeCheckpoint`, `decodeCheckpoint` and `isEncodedCheckpoint` from `jsfe/host/node`. This Node-only entry uses asynchronous Brotli; the universal `jsfe/host` entry remains free of Node imports. The version-1 envelope stores binary `Uint8Array` data, the original UTF-8 JSON byte count and a SHA-256 integrity digest. It losslessly round-trips JSON-compatible session data without trimming history, forms or action records.
|
|
97
|
+
|
|
98
|
+
The default limits are 16 MiB of decoded JSON and 350 KiB of encoded binary. Decompression is bounded before decoding, and the host separately checks the complete DynamoDB item against a conservative 400 KiB bound, including all metadata and other fields. Compression does not guarantee that arbitrary data fits; an oversized item fails before the database write.
|
|
99
|
+
|
|
100
|
+
The host journal adapter encodes only the attempt checkpoint and decodes it when reading; successful acknowledgment removes that checkpoint payload. Journal-internal storage remains separate from conversation storage. Conversation writers default to `config.jsfe.sessionEncoding:'plain'`; only an explicit `'br-v1'` enables binary `engineSession` writes. This capability is independent of `durableActions`. The configuration group and both keys may be omitted; malformed values and unknown keys reject configuration. Conversation readers always decode supported envelopes before constructing a session, including when writers are disabled. Receipt versions are frozen at the actual save boundary, and other host fields retain their normal representation.
|
|
101
|
+
|
|
102
|
+
Historical plaintext remains readable. Unsupported envelopes, corrupt data, integrity failures and codec limits raise `CheckpointCodecError`; whole-item overflow raises `JSFEStorageSizeLimit`. A corrupt saved conversation must not become a fresh session or overwrite its stored row. Failed writes never acknowledge action receipts. Disabling encoding converts a decoded row to plaintext only on its next successful size-bounded save; it does not convert dormant rows, and oversized plaintext fails without trimming or modifying the old envelope.
|
|
103
|
+
|
|
104
|
+
Roll out decoder-capable readers before enabling any tenant’s binary writer. Old workers in a mixed fleet cannot read new envelopes. A pre-reader rollback requires lossless, revision-checked conversion and verification of every affected row; a flag toggle alone is insufficient. Existing native continuations and pending/unknown actions must remain on a compatible recovery service when native starts are disabled. Never hand them to legacy execution or reset them to manufacture rollback compatibility.
|
|
105
|
+
|
|
106
|
+
## Explicit monetary grammar and compatibility
|
|
107
|
+
|
|
108
|
+
`amountInAnswer(text)` and `isAmountAnswer(text)` preserve the published JSFE 1.6 behavior. Installing native FORM support does not make existing production flows accept cardinal words or treat pesos as dollar markers.
|
|
109
|
+
|
|
110
|
+
Native inputs can declare enhanced grammar explicitly:
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
{ name: 'amount', profile: { kind: 'amount', min: 0.50,
|
|
114
|
+
money: { currency: 'USD', cardinals: true, intent: 'payment' } },
|
|
115
|
+
validator: 'validate-payment-amount' }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`readMoneyAmount(text, options)` returns a number or `NaN`; `isMoneyAmount` is true exactly for a finite result, including bare numeric input. Options require `currency: 'USD'`; `cardinals` defaults to false, and `intent` is optional. Currently USD is the only supported currency. Explicit pesos/MXN, euros and other named foreign currencies fail closed; no currency conversion or equivalence is inferred. The optional cardinal grammar supports complete English/Spanish dollar and cents phrases and preserves source spans. Unequal independent amounts, malformed cardinal phrases and signed negative amounts are rejected.
|
|
119
|
+
|
|
120
|
+
With `intent: 'payment'`, the shared `moneyIntentAllows` policy recognizes explicit English/Spanish payment-refusal, inability and deferral forms while allowing uncertainty followed by a positive correction (for example, “no entiendo, quiero pagar250”). The vocabulary is deterministic, not an exhaustive linguistic classifier. A recognized declined monetary instruction is not accepted as a numeric correction. Matching may pass an uncommittable `refused: true` candidate with raw text to a declared validator so that it can route to domain recovery. The common validation batch withholds that candidate even when a permissive validator returns success; activation and conversational paths use the same rule. Confirmation remains a separate, later action decision.
|
|
121
|
+
|
|
122
|
+
## Acceptance
|
|
123
|
+
|
|
124
|
+
Map every original behavior to native definitions or an explicit consolidation. Runtime import graphs must exclude carried JSON, lifted steps, source-renaming adapters and the old generator. Historical fixtures can remain outside the runtime graph. Counts and valid contracts alone do not establish completion.
|
|
125
|
+
|
|
126
|
+
Run independent rule tests, real-engine journeys with JSON session round trips, original behavioral suites, anonymized production response-shape journeys and intent/parameter probes in English and Spanish. Include nested out-of-order input, optional-field recognition, multiple ambiguity groups, corrections, subject changes, provider questions during OTP, duplicate turns, refused APIs, unknown effects and process loss before host save. Preserve intentional interaction differences explicitly rather than weakening effects or consent assertions.
|
|
127
|
+
|
|
128
|
+
Generation failures, stale artifacts, empty suite selections, crashed suites and failed expectations must fail acceptance. Record engine/build identity, source revisions, model configuration and exact executed evidence. Production deployment still requires Ron’s explicit authorization.
|
|
129
|
+
|
|
130
|
+
### Host capability admission
|
|
131
|
+
|
|
132
|
+
`definePerformer` declares `guardedActions` in its contract requirements. A host must call `assertFlowHostCapabilities(flows, capabilities)` from `jsfe/host` before activation. Durable remains the default: supply `durableActions: true` and attach the journal. An explicit `actionPersistence: 'session-only'` is the alternative; a bare `guardedActions: true` cannot enable it. An additional `contract.requires: ['durableActions']` insists on durable storage even when the host selects session-only. Existing serialized 1.7.0 declarations with that durable-only requirement remain strict; rebuild a flow set with 1.7.1 to use the host-selectable policy. The check does not enable a journal or choose a conversation encoding. Missing requirements reject activation before customer input or effects. Other flow builders may declare additional boolean host requirements through `contract.requires`.
|