jsfe 1.5.0 → 1.7.0
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 +45 -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/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 +12 -0
- package/dist/host/capabilities.d.ts.map +1 -0
- package/dist/host/capabilities.js +43 -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 +7 -0
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +7 -0
- package/dist/host/intent-stages.d.ts +155 -0
- package/dist/host/intent-stages.d.ts.map +1 -0
- package/dist/host/intent-stages.js +256 -0
- 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 +75 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +239 -46
- 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 +23 -0
- package/docs/forms.md +122 -0
- package/package.json +15 -4
package/dist/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { ACTION_TOOLS } from './forms/action-tools.js';
|
|
2
|
+
import { resumableActionSession, restoreActionSession, projectActionRecoverySession } from './forms/action-recovery.js';
|
|
3
|
+
import { createActionRecoveryIdentity, actionRecoveryIdentityMatches } from './host/recovery-identity.js';
|
|
1
4
|
/**
|
|
2
5
|
* Copyright (c) 2025 InstantAIguru.com
|
|
3
6
|
|
|
@@ -16,6 +19,7 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FO
|
|
|
16
19
|
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
17
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
18
21
|
**/
|
|
22
|
+
import { buildFlowStage, buildParameterStage, hasStageParameters } from './host/intent-stages.js';
|
|
19
23
|
/*
|
|
20
24
|
// Deploy new version to NPM:
|
|
21
25
|
npm version patch -m "chore(release): %s"
|
|
@@ -107,6 +111,8 @@ const DEFAULT_MESSAGE_REGISTRY = {
|
|
|
107
111
|
flow_resumed_with_guidance: "Resuming {{flowPrompt}} - Type 'cancel flow' or 'help' for options.",
|
|
108
112
|
flow_completed: "{{flowPrompt}} completed successfully.",
|
|
109
113
|
flow_completed_generic: "Flow completed.",
|
|
114
|
+
action_recovery_incompatible: "I cannot safely finish checking your earlier request right now. I have not repeated it or processed your latest message.",
|
|
115
|
+
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.",
|
|
110
116
|
flow_cancelled: "Cancelled {{flowPrompt}}.",
|
|
111
117
|
flow_help_general: "Processing {{flowPrompt}} - You can also 'cancel flow' or request 'help'.",
|
|
112
118
|
flow_help_payment: "Processing {{flowPrompt}} - You can also 'cancel flow' or request 'help'.",
|
|
@@ -150,6 +156,8 @@ const DEFAULT_MESSAGE_REGISTRY = {
|
|
|
150
156
|
flow_resumed_with_guidance: "Continuando {{flowPrompt}} - Escriba 'cancelar flujo' o 'ayuda' para opciones.",
|
|
151
157
|
flow_completed: "{{flowPrompt}} completado exitosamente.",
|
|
152
158
|
flow_completed_generic: "Flujo completado.",
|
|
159
|
+
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.",
|
|
160
|
+
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.",
|
|
153
161
|
flow_cancelled: "{{flowPrompt}} cancelado.",
|
|
154
162
|
flow_help_general: "Procesando {{flowPrompt}} - También puede 'cancelar flujo' o solicitar 'ayuda'.",
|
|
155
163
|
flow_help_payment: "Procesando {{flowPrompt}} - También puede 'cancelar flujo' o solicitar 'ayuda'.",
|
|
@@ -2010,6 +2018,28 @@ function switchToPreviousStack(engine) {
|
|
|
2010
2018
|
throw error;
|
|
2011
2019
|
}
|
|
2012
2020
|
}
|
|
2021
|
+
// Mutable execution data must belong to its activation, never the registered template.
|
|
2022
|
+
// Plain programmatic definitions may contain callbacks; preserve those references while
|
|
2023
|
+
// cloning their surrounding data rather than rejecting them with structuredClone.
|
|
2024
|
+
function cloneFlowValue(value, seen = new WeakMap()) {
|
|
2025
|
+
if (value === null || typeof value !== 'object')
|
|
2026
|
+
return value;
|
|
2027
|
+
if (seen.has(value))
|
|
2028
|
+
return seen.get(value);
|
|
2029
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2030
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
|
|
2031
|
+
return structuredClone(value);
|
|
2032
|
+
}
|
|
2033
|
+
const copy = Array.isArray(value) ? new Array(value.length) : Object.create(prototype);
|
|
2034
|
+
seen.set(value, copy);
|
|
2035
|
+
for (const key of Object.keys(value)) {
|
|
2036
|
+
Object.defineProperty(copy, key, {
|
|
2037
|
+
value: cloneFlowValue(value[key], seen),
|
|
2038
|
+
enumerable: true, writable: true, configurable: true
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
return copy;
|
|
2042
|
+
}
|
|
2013
2043
|
function getInitialVariables(engine, flowDefinition, inheritedVariables) {
|
|
2014
2044
|
let variables;
|
|
2015
2045
|
if (inheritedVariables) {
|
|
@@ -2019,7 +2049,7 @@ function getInitialVariables(engine, flowDefinition, inheritedVariables) {
|
|
|
2019
2049
|
if (flowDefinition?.variables) {
|
|
2020
2050
|
for (const [varName, varDef] of Object.entries(flowDefinition.variables)) {
|
|
2021
2051
|
if (varDef.value !== undefined) {
|
|
2022
|
-
variables[varName] = varDef.value; //
|
|
2052
|
+
variables[varName] = cloneFlowValue(varDef.value); // Fresh defaults; the caller's variable container stays shared
|
|
2023
2053
|
}
|
|
2024
2054
|
}
|
|
2025
2055
|
}
|
|
@@ -2031,7 +2061,7 @@ function getInitialVariables(engine, flowDefinition, inheritedVariables) {
|
|
|
2031
2061
|
if (flowDefinition?.variables) {
|
|
2032
2062
|
for (const [varName, varDef] of Object.entries(flowDefinition.variables)) {
|
|
2033
2063
|
if (varDef.value !== undefined) {
|
|
2034
|
-
variables[varName] = varDef.value;
|
|
2064
|
+
variables[varName] = cloneFlowValue(varDef.value);
|
|
2035
2065
|
}
|
|
2036
2066
|
}
|
|
2037
2067
|
}
|
|
@@ -2122,7 +2152,7 @@ async function isFlowActivated(input, engine, userId = 'anonymous') {
|
|
|
2122
2152
|
flowName: flow.name,
|
|
2123
2153
|
flowId: flow.id,
|
|
2124
2154
|
flowVersion: flow.version,
|
|
2125
|
-
flowStepsStack:
|
|
2155
|
+
flowStepsStack: cloneFlowValue(flow.steps).reverse(),
|
|
2126
2156
|
contextStack: [{ role: 'user', content: sanitizeInput(input), timestamp: Date.now() }], // Keep activation input in context for reference
|
|
2127
2157
|
inputStack: [], // Start empty - only SAY-GET responses go here
|
|
2128
2158
|
variables, // Start with global variables + flow definition variables + extracted parameters
|
|
@@ -2675,8 +2705,14 @@ function enforceParameterTypes(flow, parameters) {
|
|
|
2675
2705
|
const n = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN);
|
|
2676
2706
|
converted = Number.isFinite(n) && (type === 'number' || Number.isInteger(n)) ? n : undefined;
|
|
2677
2707
|
}
|
|
2708
|
+
else if (p.jsonSchema && type === 'object') {
|
|
2709
|
+
converted = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
2710
|
+
}
|
|
2711
|
+
else if (p.jsonSchema && type === 'array') {
|
|
2712
|
+
converted = Array.isArray(value) ? value : undefined;
|
|
2713
|
+
}
|
|
2678
2714
|
else {
|
|
2679
|
-
converted = value; //
|
|
2715
|
+
converted = value; // Legacy parameters without an extraction schema retain their existing behavior
|
|
2680
2716
|
}
|
|
2681
2717
|
if (converted !== undefined) {
|
|
2682
2718
|
result[p.name] = converted;
|
|
@@ -2711,11 +2747,60 @@ function buildDetectFlowRequest(input, lastChatTurn, flows) {
|
|
|
2711
2747
|
name: p.name,
|
|
2712
2748
|
description: p.description,
|
|
2713
2749
|
...(p.type !== undefined ? { type: p.type } : {}),
|
|
2714
|
-
...(Array.isArray(p.enum) ? { enum: [...p.enum] } : {})
|
|
2750
|
+
...(Array.isArray(p.enum) ? { enum: [...p.enum] } : {}),
|
|
2751
|
+
...(p.jsonSchema ? { jsonSchema: structuredClone(p.jsonSchema) } : {})
|
|
2715
2752
|
}))
|
|
2716
2753
|
}))
|
|
2717
2754
|
};
|
|
2718
2755
|
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Two-stage intent detection (engine.intentDetection = 'two-stage'): stage 1 picks the flow, stage 2 extracts the
|
|
2758
|
+
* selected flow's parameters. Each stage is ONE aiCallback call whose positional arguments are the LLM instructions and
|
|
2759
|
+
* whose 4th argument carries both executors' (jsfe/host intent-stages); the host replies with the canonical JSON
|
|
2760
|
+
* ({ flowName } / { parameters }). A stage-1 failure reaches the host as before; a stage-2 failure starts the flow
|
|
2761
|
+
* without its parameters (it asks for them) rather than losing the turn.
|
|
2762
|
+
*/
|
|
2763
|
+
async function detectFlowInTwoStages(input, engine, flows) {
|
|
2764
|
+
const { input: cleanInput, conversation } = buildDetectFlowRequest(input, engine.lastChatTurn, []);
|
|
2765
|
+
const stage1 = buildFlowStage(cleanInput, conversation, flows.map(f => ({ id: f.id, name: f.name, description: String(f.description ?? '') })));
|
|
2766
|
+
const reply1 = parseStageReply(await fetchAiResponse(stage1.llm.system, stage1.llm.user, engine.aiCallback, engine.aiTimeOut, stageSchema('detect_flow', stage1.llm.schema), stage1));
|
|
2767
|
+
const said = String(reply1.flowName ?? 'None');
|
|
2768
|
+
// only an offered (primary) flow is ever activated; it calls its workers with the arguments it holds
|
|
2769
|
+
const flow = /^(none|null)$/i.test(said) ? null : flows.find(f => f.name.toLowerCase() === said.toLowerCase()) || null;
|
|
2770
|
+
if (!flow) {
|
|
2771
|
+
if (!/^(none|null)$/i.test(said))
|
|
2772
|
+
logger.error(`Flow "${said}" not found in flows menu`);
|
|
2773
|
+
else
|
|
2774
|
+
logger.info(`No flow activated for input: "${input}"`);
|
|
2775
|
+
return null;
|
|
2776
|
+
}
|
|
2777
|
+
else if (!hasStageParameters(flow.parameters)) {
|
|
2778
|
+
return { flow, parameters: {} };
|
|
2779
|
+
}
|
|
2780
|
+
else {
|
|
2781
|
+
try {
|
|
2782
|
+
const stage2 = buildParameterStage(cleanInput, conversation, flow);
|
|
2783
|
+
const reply2 = parseStageReply(await fetchAiResponse(stage2.llm.system, stage2.llm.user, engine.aiCallback, engine.aiTimeOut, stageSchema('extract_parameters', stage2.llm.schema), stage2));
|
|
2784
|
+
const given = reply2.parameters && typeof reply2.parameters === 'object' ? reply2.parameters : {};
|
|
2785
|
+
return { flow, parameters: enforceParameterEnums(flow, enforceParameterTypes(flow, ownParametersOnly(flow, given))) };
|
|
2786
|
+
}
|
|
2787
|
+
catch (error) {
|
|
2788
|
+
logger.warn(`detectFlowWithParameters: stage 2 failed for "${flow.name}" (${error.message}) - the flow starts without its parameters`);
|
|
2789
|
+
return { flow, parameters: {} };
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
/** The OpenAI-compatible response_format wrapper a host already reads from the 3rd argument. */
|
|
2794
|
+
function stageSchema(name, schema) {
|
|
2795
|
+
return JSON.stringify({ type: 'json_schema', json_schema: { name, strict: true, schema } });
|
|
2796
|
+
}
|
|
2797
|
+
function parseStageReply(text) {
|
|
2798
|
+
const t = String(text ?? '').replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
|
|
2799
|
+
const parsed = JSON.parse((t.match(/\{[\s\S]*\}/) || [t])[0]);
|
|
2800
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
2801
|
+
throw new Error('intent stage: the reply is not a JSON object');
|
|
2802
|
+
return parsed;
|
|
2803
|
+
}
|
|
2719
2804
|
export async function getFlowForInput(input, engine) {
|
|
2720
2805
|
const result = await detectFlowWithParameters(input, engine);
|
|
2721
2806
|
return result ? result.flow : null;
|
|
@@ -2740,6 +2825,22 @@ export async function detectFlowWithParameters(input, engine) {
|
|
|
2740
2825
|
const primaryFlows = flowsMenu.filter(flow => flow.primary === true);
|
|
2741
2826
|
const flowsForIntentDetection = primaryFlows.length > 0 ? primaryFlows : flowsMenu;
|
|
2742
2827
|
logger.debug(`Using ${flowsForIntentDetection.length} flows for intent detection (${primaryFlows.length} primary flows available)`);
|
|
2828
|
+
// A host start (ContextEntry.start) answers detection for this turn: that flow, with its arguments.
|
|
2829
|
+
if (engine.hostStart) {
|
|
2830
|
+
const wanted = engine.hostStart;
|
|
2831
|
+
const flow = flowsMenu.find(f => f.id === wanted.flow || f.name === wanted.flow);
|
|
2832
|
+
if (!flow) {
|
|
2833
|
+
logger.warn(`Host start: flow "${wanted.flow}" is not in the flows menu`);
|
|
2834
|
+
return null;
|
|
2835
|
+
}
|
|
2836
|
+
else {
|
|
2837
|
+
const given = wanted.arguments && typeof wanted.arguments === 'object' ? wanted.arguments : {};
|
|
2838
|
+
return { flow, parameters: enforceParameterEnums(flow, enforceParameterTypes(flow, ownParametersOnly(flow, given))) };
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
else {
|
|
2842
|
+
// no host start: intent detection decides
|
|
2843
|
+
}
|
|
2743
2844
|
// First try direct name or id matching (case-insensitive) - check all flows
|
|
2744
2845
|
const directMatch = flowsMenu.find(flow => flow.name.toLowerCase() === input.toLowerCase() || (flow.id.toLowerCase() === input.toLowerCase()));
|
|
2745
2846
|
if (directMatch) {
|
|
@@ -2751,6 +2852,12 @@ export async function detectFlowWithParameters(input, engine) {
|
|
|
2751
2852
|
logger.info(`No flow matched for input: "${input}" (no AI)`);
|
|
2752
2853
|
return null;
|
|
2753
2854
|
}
|
|
2855
|
+
if (engine.intentDetection === 'two-stage') {
|
|
2856
|
+
return await detectFlowInTwoStages(input, engine, flowsForIntentDetection);
|
|
2857
|
+
}
|
|
2858
|
+
else {
|
|
2859
|
+
// the original single call, below
|
|
2860
|
+
}
|
|
2754
2861
|
// If AI callback is present, use AI for intent detection with primary flows only
|
|
2755
2862
|
const task = "Considering the chat history when available and applicable, decide if the user input should trigger any available flow. If a flow is triggered, extract any relevant parameters defined in the flow's 'parameters' section.";
|
|
2756
2863
|
const rules = `- Return the exact flow name if a match is found
|
|
@@ -3280,7 +3387,7 @@ async function handleToolStep(currentFlowFrame, engine) {
|
|
|
3280
3387
|
flowName: rebootFlow.name,
|
|
3281
3388
|
flowId: rebootFlow.id,
|
|
3282
3389
|
flowVersion: rebootFlow.version,
|
|
3283
|
-
flowStepsStack:
|
|
3390
|
+
flowStepsStack: cloneFlowValue(rebootFlow.steps).reverse(),
|
|
3284
3391
|
contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
|
|
3285
3392
|
inputStack: [input],
|
|
3286
3393
|
variables: variables, // Fresh variables for reboot flow (with injected params)
|
|
@@ -3337,7 +3444,7 @@ async function handleToolStep(currentFlowFrame, engine) {
|
|
|
3337
3444
|
flowName: onFailFlow.name,
|
|
3338
3445
|
flowId: onFailFlow.id,
|
|
3339
3446
|
flowVersion: onFailFlow.version,
|
|
3340
|
-
flowStepsStack:
|
|
3447
|
+
flowStepsStack: cloneFlowValue(onFailFlow.steps).reverse(),
|
|
3341
3448
|
contextStack: [...currentFlowFrame.contextStack], // Inherit context
|
|
3342
3449
|
inputStack: [...currentFlowFrame.inputStack],
|
|
3343
3450
|
variables: variables,
|
|
@@ -3651,6 +3758,11 @@ function handleSetStep(currentFlowFrame, engine) {
|
|
|
3651
3758
|
returnType: 'auto'
|
|
3652
3759
|
}, engine)
|
|
3653
3760
|
: step.value;
|
|
3761
|
+
if (step.requireTrue === true && interpolatedValue !== true) {
|
|
3762
|
+
const error = new Error(`Required SET step ${step.id || ''} did not complete successfully`);
|
|
3763
|
+
error.name = 'JSFERequiredSetFailed';
|
|
3764
|
+
throw error;
|
|
3765
|
+
}
|
|
3654
3766
|
if (currentFlowFrame && currentFlowFrame.variables !== undefined) {
|
|
3655
3767
|
currentFlowFrame.variables[step.variable] = interpolatedValue;
|
|
3656
3768
|
logger.info(`SET step: stored '${interpolatedValue}' in variable '${step.variable}'`);
|
|
@@ -3961,7 +4073,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
|
|
|
3961
4073
|
flowName: subFlow.name,
|
|
3962
4074
|
flowId: subFlow.id,
|
|
3963
4075
|
flowVersion: subFlow.version,
|
|
3964
|
-
flowStepsStack:
|
|
4076
|
+
flowStepsStack: cloneFlowValue(subFlow.steps).reverse(),
|
|
3965
4077
|
contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
|
|
3966
4078
|
inputStack: [input],
|
|
3967
4079
|
variables, // Fresh variables for reboot flow
|
|
@@ -3981,7 +4093,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
|
|
|
3981
4093
|
currentFlowFrame.flowName = subFlow.name;
|
|
3982
4094
|
currentFlowFrame.flowId = subFlow.id;
|
|
3983
4095
|
currentFlowFrame.flowVersion = subFlow.version;
|
|
3984
|
-
currentFlowFrame.flowStepsStack =
|
|
4096
|
+
currentFlowFrame.flowStepsStack = cloneFlowValue(subFlow.steps).reverse();
|
|
3985
4097
|
addToContextStack(currentFlowFrame.contextStack, 'user', input);
|
|
3986
4098
|
currentFlowFrame.inputStack.push(input);
|
|
3987
4099
|
// Inject parameters into current variables — or, for an isolated flow, start this frame's own scope
|
|
@@ -3989,13 +4101,18 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
|
|
|
3989
4101
|
currentFlowFrame.variables = Object.assign(getInitialVariables(engine, subFlow), flowParameters);
|
|
3990
4102
|
currentFlowFrame.isolatedScope = true;
|
|
3991
4103
|
}
|
|
3992
|
-
else if (Object.keys(flowParameters).length > 0) {
|
|
3993
|
-
logger.info(`Injecting parameters into variables for flow ${subFlow.name}`, flowParameters);
|
|
3994
|
-
Object.assign(currentFlowFrame.variables, flowParameters);
|
|
3995
|
-
logger.info(`Variables after injection:`, currentFlowFrame.variables);
|
|
3996
|
-
}
|
|
3997
4104
|
else {
|
|
3998
|
-
//
|
|
4105
|
+
// Legacy shared replacement preserves the current variable container. New workers may
|
|
4106
|
+
// explicitly initialize target defaults that are still undefined; accepted progress stays.
|
|
4107
|
+
currentFlowFrame.variables ||= {};
|
|
4108
|
+
if (step.initializeDefaults === true) {
|
|
4109
|
+
for (const [name, definition] of Object.entries(subFlow.variables || {})) {
|
|
4110
|
+
if (currentFlowFrame.variables[name] === undefined && definition.value !== undefined) {
|
|
4111
|
+
currentFlowFrame.variables[name] = cloneFlowValue(definition.value);
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
Object.assign(currentFlowFrame.variables, flowParameters);
|
|
3999
4116
|
}
|
|
4000
4117
|
// Update transaction
|
|
4001
4118
|
TransactionManager.fail(currentFlowFrame.transaction, `Replaced by flow ${subFlow.name}`);
|
|
@@ -4015,7 +4132,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
|
|
|
4015
4132
|
flowName: subFlow.name,
|
|
4016
4133
|
flowId: subFlow.id,
|
|
4017
4134
|
flowVersion: subFlow.version,
|
|
4018
|
-
flowStepsStack:
|
|
4135
|
+
flowStepsStack: cloneFlowValue(subFlow.steps).reverse(),
|
|
4019
4136
|
contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
|
|
4020
4137
|
inputStack: [input],
|
|
4021
4138
|
variables, // Inherit + merge flow definition variables (isolated: its own)
|
|
@@ -5136,32 +5253,19 @@ function evaluateJavaScriptExpression(expression, context, preserveObjects = fal
|
|
|
5136
5253
|
}
|
|
5137
5254
|
return value;
|
|
5138
5255
|
});
|
|
5139
|
-
//
|
|
5140
|
-
|
|
5141
|
-
const functionBody = preserveObjects ? `
|
|
5142
|
-
"use strict";
|
|
5256
|
+
// Keep evaluator temporaries outside the generated function: a flow may legitimately declare `result`.
|
|
5257
|
+
const evaluatorFunction = new Function(...validParamNames, `"use strict"; return (${expression});`);
|
|
5143
5258
|
try {
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5259
|
+
const evaluated = evaluatorFunction(...paramValues);
|
|
5260
|
+
if (!preserveObjects && typeof evaluated === 'object' && evaluated !== null &&
|
|
5261
|
+
!(evaluated.__userInput === true && evaluated.__literal === true)) {
|
|
5262
|
+
return JSON.stringify(evaluated);
|
|
5263
|
+
}
|
|
5264
|
+
return evaluated;
|
|
5265
|
+
}
|
|
5266
|
+
catch (error) {
|
|
5267
|
+
throw new Error('Expression evaluation failed: ' + error.message);
|
|
5147
5268
|
}
|
|
5148
|
-
` : `
|
|
5149
|
-
"use strict";
|
|
5150
|
-
try {
|
|
5151
|
-
const result = (${expression});
|
|
5152
|
-
// Auto-stringify complex data structures when used in string contexts
|
|
5153
|
-
if (typeof result === 'object' && result !== null &&
|
|
5154
|
-
!(result.__userInput === true && result.__literal === true)) {
|
|
5155
|
-
return JSON.stringify(result);
|
|
5156
|
-
}
|
|
5157
|
-
return result;
|
|
5158
|
-
} catch (e) {
|
|
5159
|
-
throw new Error('Expression evaluation failed: ' + e.message);
|
|
5160
|
-
}
|
|
5161
|
-
`;
|
|
5162
|
-
// Create and execute function with only valid parameter names
|
|
5163
|
-
const evaluatorFunction = new Function(...validParamNames, functionBody);
|
|
5164
|
-
return evaluatorFunction(...paramValues);
|
|
5165
5269
|
}
|
|
5166
5270
|
/**
|
|
5167
5271
|
* Security-aware variable setter for user input with AI-powered voice cleanup
|
|
@@ -5511,7 +5615,7 @@ async function handlePendingInterruptionSwitch(engine, userId) {
|
|
|
5511
5615
|
flowName: targetFlow,
|
|
5512
5616
|
flowId: targetFlowDefinition.id,
|
|
5513
5617
|
flowVersion: targetFlowDefinition.version || '1.0',
|
|
5514
|
-
flowStepsStack:
|
|
5618
|
+
flowStepsStack: cloneFlowValue(targetFlowDefinition.steps).reverse(),
|
|
5515
5619
|
contextStack: [],
|
|
5516
5620
|
inputStack: [],
|
|
5517
5621
|
variables: getInitialVariables(engine, targetFlowDefinition), // Fresh variables for first flow activation
|
|
@@ -5576,7 +5680,7 @@ async function handleRegularFlowInterruption(intentAnalysis, engine, userId) {
|
|
|
5576
5680
|
flowName: targetFlow,
|
|
5577
5681
|
flowId: targetFlowDefinition.id,
|
|
5578
5682
|
flowVersion: targetFlowDefinition.version || '1.0',
|
|
5579
|
-
flowStepsStack:
|
|
5683
|
+
flowStepsStack: cloneFlowValue(targetFlowDefinition.steps).reverse(),
|
|
5580
5684
|
contextStack: [], // Start with empty context - don't use interruption input
|
|
5581
5685
|
inputStack: [], // Start with empty input stack - new flow will get proper input next
|
|
5582
5686
|
variables: getInitialVariables(engine, targetFlowDefinition), // Fresh variables for interrupting flow activation
|
|
@@ -5885,6 +5989,21 @@ export function applyWraps(flowsMenu) {
|
|
|
5885
5989
|
}
|
|
5886
5990
|
}
|
|
5887
5991
|
export class WorkflowEngine {
|
|
5992
|
+
/** Host-injected durable action journal. Without it common performers fail closed before effects. */
|
|
5993
|
+
actionJournal;
|
|
5994
|
+
actionRecoveryIdentityPromise;
|
|
5995
|
+
/** Loaded definitions are immutable after activation. Hot reload constructs a new engine. */
|
|
5996
|
+
async getActionRecoveryIdentity() {
|
|
5997
|
+
if (this.actionJournal) {
|
|
5998
|
+
for (const method of ['claim', 'complete', 'checkpoint', 'pending']) {
|
|
5999
|
+
if (typeof this.actionJournal[method] !== 'function')
|
|
6000
|
+
throw new Error('Durable action journal requires ' + method);
|
|
6001
|
+
}
|
|
6002
|
+
}
|
|
6003
|
+
if (!this.actionRecoveryIdentityPromise)
|
|
6004
|
+
this.actionRecoveryIdentityPromise = createActionRecoveryIdentity({ flows: this.flowsMenu, tools: this.toolsRegistry });
|
|
6005
|
+
return this.actionRecoveryIdentityPromise;
|
|
6006
|
+
}
|
|
5888
6007
|
cargo;
|
|
5889
6008
|
flowsMenu;
|
|
5890
6009
|
toolsRegistry;
|
|
@@ -5903,6 +6022,15 @@ export class WorkflowEngine {
|
|
|
5903
6022
|
* warning on a mismatch. Diagnostic only — a result is never altered or withheld because of it.
|
|
5904
6023
|
*/
|
|
5905
6024
|
validateToolReturns = false;
|
|
6025
|
+
/**
|
|
6026
|
+
* Opt-in (default 'single-call'): how intent detection asks aiCallback. 'two-stage': stage 1 picks the flow
|
|
6027
|
+
* (from the primaries' descriptions only), stage 2 extracts the SELECTED flow's parameters; each call carries
|
|
6028
|
+
* both executors' instructions (`llm`, `systemOne`) as its 4th argument — see jsfe/host intent-stages.
|
|
6029
|
+
* 'single-call': the original one call that picks a flow and extracts parameters for every flow at once.
|
|
6030
|
+
*/
|
|
6031
|
+
intentDetection = 'single-call';
|
|
6032
|
+
/** This turn's host start (ContextEntry.start), or null. */
|
|
6033
|
+
hostStart = null;
|
|
5906
6034
|
/**
|
|
5907
6035
|
* Opt-in (default false): log one warning when a tool returns a failure (`success: false` or a
|
|
5908
6036
|
* non-zero `error`), with the tool and its message, digit runs masked. Diagnostic only.
|
|
@@ -5985,8 +6113,18 @@ export class WorkflowEngine {
|
|
|
5985
6113
|
logger = withPiiRedaction(hostLogger); // Assign to global logger (a SAY-GET's `pii` answer never reaches it)
|
|
5986
6114
|
this.aiCallback = aiCallback;
|
|
5987
6115
|
this.flowsMenu = applyWraps(flowsMenu);
|
|
5988
|
-
|
|
5989
|
-
|
|
6116
|
+
if (toolsRegistry.some(tool => ACTION_TOOLS.some(internal => internal.id === tool.id)))
|
|
6117
|
+
throw new Error('Internal JSFE action tool identities are reserved');
|
|
6118
|
+
this.toolsRegistry = [...toolsRegistry, ...ACTION_TOOLS];
|
|
6119
|
+
const receipt = (reply) => reply;
|
|
6120
|
+
this.APPROVED_FUNCTIONS = { ...APPROVED_FUNCTIONS,
|
|
6121
|
+
jsfeClaimAction: async ({ record, action }) => receipt(this.actionJournal
|
|
6122
|
+
? await this.actionJournal.claim({ sessionId: this.sessionId, record, action, checkpoint: resumableActionSession(this.sessionContext, this.flowsMenu, await this.getActionRecoveryIdentity()) })
|
|
6123
|
+
: { status: 'hold', outcome: { status: 'unknown', reason: 'durable_journal_unavailable' } }),
|
|
6124
|
+
jsfeCompleteAction: async ({ record, action, outcome }) => receipt(this.actionJournal
|
|
6125
|
+
? await this.actionJournal.complete({ sessionId: this.sessionId, record, action, outcome, checkpoint: resumableActionSession(this.sessionContext, this.flowsMenu, await this.getActionRecoveryIdentity()) })
|
|
6126
|
+
: { status: 'unknown', outcome: { status: 'unknown', reason: 'durable_journal_unavailable' } }),
|
|
6127
|
+
};
|
|
5990
6128
|
this.globalVariables = globalVariables ? { ...globalVariables } : {}; // Copy global variables to prevent external mutations
|
|
5991
6129
|
this.aiTimeOut = aiTimeOut ?? 2000; // Default to 2000ms (2 seconds)
|
|
5992
6130
|
// if validateOnInit is not provided, default to true
|
|
@@ -6050,7 +6188,8 @@ export class WorkflowEngine {
|
|
|
6050
6188
|
async updateActivity(contextEntry, engineSessionContext) {
|
|
6051
6189
|
try {
|
|
6052
6190
|
this.cargo = engineSessionContext.cargo || {};
|
|
6053
|
-
|
|
6191
|
+
// a security code under any key that names one (a form stores it as `card_cvv`)
|
|
6192
|
+
logger.info(`Received Cargo: ${JSON.stringify(this.cargo, (k, v) => (/(cvv|cvc|card_?code|security_?code)/i.test(k) && v ? '•••' : v))}`);
|
|
6054
6193
|
this.sessionId = engineSessionContext.sessionId;
|
|
6055
6194
|
this.createdAt = engineSessionContext.createdAt;
|
|
6056
6195
|
// Store reference to session context - no copying needed! Engine works directly with session data
|
|
@@ -6065,6 +6204,8 @@ export class WorkflowEngine {
|
|
|
6065
6204
|
this.sessionContext.lastTurnToolCalls = [];
|
|
6066
6205
|
// Same one-shot semantics for a DISPATCH record (see recordDispatch).
|
|
6067
6206
|
delete this.sessionContext.lastTurnDispatch;
|
|
6207
|
+
// A host start belongs to this turn only (ContextEntry.start).
|
|
6208
|
+
this.hostStart = contextEntry && contextEntry.role === 'user' && contextEntry.start && typeof contextEntry.start.flow === 'string' ? contextEntry.start : null;
|
|
6068
6209
|
// DIAGNOSTIC: Log flowStepsStack state from loaded session (before any processing)
|
|
6069
6210
|
if (engineSessionContext.flowStacks) {
|
|
6070
6211
|
for (let si = 0; si < engineSessionContext.flowStacks.length; si++) {
|
|
@@ -6112,6 +6253,50 @@ export class WorkflowEngine {
|
|
|
6112
6253
|
if (engineSessionContext) {
|
|
6113
6254
|
engineSessionContext.lastActivity = this.lastActivity;
|
|
6114
6255
|
}
|
|
6256
|
+
// Recover durable effects before the new message can mutate an older saved session.
|
|
6257
|
+
// A successfully saved session carries its observed sequence even when acknowledgement failed.
|
|
6258
|
+
delete engineSessionContext.lastTurnRecovery;
|
|
6259
|
+
let recoveredAction = false;
|
|
6260
|
+
let suspendedRecoveryStacks;
|
|
6261
|
+
if (contextEntry.role === 'user' && this.actionJournal) {
|
|
6262
|
+
const recoveryIdentity = await this.getActionRecoveryIdentity();
|
|
6263
|
+
const pending = await this.actionJournal.pending({ sessionId: this.sessionId });
|
|
6264
|
+
const applied = (this.cargo.actionJournalApplied || {});
|
|
6265
|
+
// A journal sequence orders writes, not execution progress. Track consumption per attempt.
|
|
6266
|
+
const record = (pending?.records || []).filter((row) => row.checkpoint && Number(row.sequence) > Number(applied[row.key] || 0))
|
|
6267
|
+
.sort((a, b) => Number(a.sequence) - Number(b.sequence)).at(-1);
|
|
6268
|
+
const recovery = record ? { key: record.key, sequence: record.sequence, status: record.status, session: record.checkpoint, record } : null;
|
|
6269
|
+
if (recovery) {
|
|
6270
|
+
if (!actionRecoveryIdentityMatches(recoveryIdentity, recovery.session.actionRecoveryIdentity)) {
|
|
6271
|
+
engineSessionContext.lastTurnToolCalls = [];
|
|
6272
|
+
delete engineSessionContext.lastFlowOutcome;
|
|
6273
|
+
delete engineSessionContext.lastTurnDispatch;
|
|
6274
|
+
this.hostStart = null;
|
|
6275
|
+
engineSessionContext.lastTurnRecovery = { key: record.key, sequence: record.sequence, status: record.status, hold: true, reason: 'incompatible_recovery_identity', deferredInput: contextEntry };
|
|
6276
|
+
engineSessionContext.response = getSystemMessage(this, 'action_recovery_incompatible');
|
|
6277
|
+
return engineSessionContext;
|
|
6278
|
+
}
|
|
6279
|
+
const snapshotProgress = recovery.session.actionRecoveryProgress;
|
|
6280
|
+
const changedEpoch = (engineSessionContext.actionRecoveryEpoch || null) !== (recovery.session.actionRecoveryEpoch || null);
|
|
6281
|
+
const alreadyAdvanced = Number.isSafeInteger(snapshotProgress) && Number(engineSessionContext.actionRecoveryProgress || 0) >= Number(snapshotProgress);
|
|
6282
|
+
const snapshotApplied = recovery.session.cargo?.actionJournalApplied || {};
|
|
6283
|
+
const hasNewerApplied = Object.entries(applied).some(([key, sequence]) => Number(sequence) > Number(snapshotApplied[key] || 0));
|
|
6284
|
+
const invalidatedSinceCheckpoint = Number(engineSessionContext.actionRecoveryPolicy?.revision || 0) > Number(recovery.session.actionRecoveryPolicy?.revision || 0);
|
|
6285
|
+
if (changedEpoch || alreadyAdvanced || hasNewerApplied || invalidatedSinceCheckpoint) {
|
|
6286
|
+
suspendedRecoveryStacks = projectActionRecoverySession(engineSessionContext, recovery, contextEntry, this.flowsMenu, recoveryIdentity);
|
|
6287
|
+
}
|
|
6288
|
+
else {
|
|
6289
|
+
restoreActionSession(engineSessionContext, recovery, contextEntry, recoveryIdentity);
|
|
6290
|
+
}
|
|
6291
|
+
this.cargo = engineSessionContext.cargo;
|
|
6292
|
+
this.reconstructVariableReferences();
|
|
6293
|
+
engineSessionContext.lastTurnToolCalls = [];
|
|
6294
|
+
delete engineSessionContext.lastFlowOutcome;
|
|
6295
|
+
delete engineSessionContext.lastTurnDispatch;
|
|
6296
|
+
this.hostStart = null;
|
|
6297
|
+
recoveredAction = true;
|
|
6298
|
+
}
|
|
6299
|
+
}
|
|
6115
6300
|
// Role-based processing logic
|
|
6116
6301
|
if (contextEntry.role === 'user') {
|
|
6117
6302
|
// A sensitive answer (SAY-GET `pii`) is kept out of every log line of this turn, raw and as its digits
|
|
@@ -6122,10 +6307,16 @@ export class WorkflowEngine {
|
|
|
6122
6307
|
// Detect intent to activate a flow or switch flows
|
|
6123
6308
|
let responseOrNull;
|
|
6124
6309
|
try {
|
|
6125
|
-
|
|
6310
|
+
if (!recoveredAction && this.actionJournal)
|
|
6311
|
+
engineSessionContext.actionRecoveryProgress = Number(engineSessionContext.actionRecoveryProgress || 0) + 1;
|
|
6312
|
+
responseOrNull = recoveredAction ? await playFlowFrame(this) : await processActivity(said, userId, this);
|
|
6126
6313
|
}
|
|
6127
6314
|
finally {
|
|
6128
6315
|
piiInputs = [];
|
|
6316
|
+
if (suspendedRecoveryStacks) {
|
|
6317
|
+
engineSessionContext.flowStacks = suspendedRecoveryStacks;
|
|
6318
|
+
this.reconstructVariableReferences();
|
|
6319
|
+
}
|
|
6129
6320
|
}
|
|
6130
6321
|
// Anomaly guard: a flow is STILL ACTIVE but produced no response.
|
|
6131
6322
|
// Detect activity by frame EXISTENCE, not by the response value: a frame
|
|
@@ -6155,6 +6346,8 @@ export class WorkflowEngine {
|
|
|
6155
6346
|
err.name = 'JSFEActiveFlowEmptyResponse';
|
|
6156
6347
|
throw err;
|
|
6157
6348
|
}
|
|
6349
|
+
if (recoveredAction)
|
|
6350
|
+
responseOrNull = [responseOrNull, getSystemMessage(this, 'action_recovery_input_deferred')].filter(Boolean).join('\n\n');
|
|
6158
6351
|
// Store user turn in lastChatTurn if not in a flow
|
|
6159
6352
|
if (responseOrNull === null) {
|
|
6160
6353
|
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:c17204fa29bf43386d3e2433a775108403a9999f7f3d63a9da4761f685c1ecfd";
|
|
@@ -0,0 +1,23 @@
|
|
|
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 include `contract.requires: ['durableActions']`. `assertFlowHostCapabilities` checks each declared requirement against an explicitly supplied boolean capability; it never enables one. 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
|
+
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.
|