jsfe 1.25.0 → 1.25.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
CHANGED
|
@@ -574,7 +574,7 @@ This architecture prevents validation errors for sub-flows that depend on variab
|
|
|
574
574
|
|
|
575
575
|
##### Flow traps: `validateFlow`, `validateAllFlows`, `findFlowTraps`
|
|
576
576
|
|
|
577
|
-
The validator reports each real trap once, as an ERROR: `stack_growth` (a loop holding a call) and `spin` (a loop of replaces with no question in reach). A flow replacing itself, reboot, DISPATCH, a `toParent` return to a `replaceTarget` flow and a shared worker in the path are not traps. A flow on a loop that a runtime limit ends declares it with `loopBound: "<the limit>"`, and a test must drive the loop to that limit: the static check cannot read the guard.
|
|
577
|
+
The validator reports each real trap once, as an ERROR: `stack_growth` (a loop holding a call) and `spin` (a loop of replaces, or one closed by a parent REPLACE, with no question in reach). A flow replacing itself, reboot, DISPATCH, a `toParent` return to a `replaceTarget` flow and a shared worker in the path are not traps. A flow on a loop that a runtime limit ends declares it with `loopBound: "<the limit>"`, and a test must drive the loop to that limit: the static check cannot read the guard.
|
|
578
578
|
|
|
579
579
|
##### Thin wrappers: `wraps`
|
|
580
580
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The traps of a set of flows: [{ kind: 'stack_growth'|'spin', flows, edges: ['a -call-> b', …] }], each once.
|
|
3
|
-
* `stack_growth`: a loop holding at least one call. `spin`: a loop of replaces where no flow can reach a question.
|
|
3
|
+
* `stack_growth`: a loop holding at least one call. `spin`: a loop of replaces, or one closed by a REPLACE of the parent, where no flow can reach a question.
|
|
4
4
|
*/
|
|
5
5
|
export function findFlowTraps(flows: any): {
|
|
6
6
|
kind: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"flow-traps.d.ts","sourceRoot":"","sources":["../../src/forms/flow-traps.js"],"names":[],"mappings":"AAuFA;;;GAGG;AACH;;;;
|
|
1
|
+
{"version":3,"file":"flow-traps.d.ts","sourceRoot":"","sources":["../../src/forms/flow-traps.js"],"names":[],"mappings":"AAuFA;;;GAGG;AACH;;;;IA2CC;AAED,mCAAmC;AACnC,kDAGC"}
|
package/dist/forms/flow-traps.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Flow traps: the flow-graph defects that are real (owner 2026-11-01). A call on a loop grows the stack every lap; a
|
|
3
3
|
* loop of replaces with no question in reach spins inside one turn. Not traps: a flow replacing itself (the frame is
|
|
4
4
|
* reused and a question is in reach), reboot and DISPATCH (both clear the stack), a `toParent` REPLACE of a
|
|
5
|
-
* `replaceTarget` flow (it returns to the waiting frame), a loop whose flow declares `loopBound`, and a worker in the path (it runs whichever
|
|
5
|
+
* `replaceTarget` flow with a question in reach (it returns to the waiting frame), a loop whose flow declares `loopBound`, and a worker in the path (it runs whichever
|
|
6
6
|
* form asked, so two forms sharing run-form are nesting, not a loop). A worker's own loops are not read here.
|
|
7
7
|
*
|
|
8
8
|
* Targets named in a declaration count like the FLOW step that will run them: form input callbacks and validators,
|
|
@@ -27,7 +27,7 @@ function walk(node, visit) {
|
|
|
27
27
|
visit(node);
|
|
28
28
|
Object.values(node).forEach((value) => walk(value, visit));
|
|
29
29
|
}
|
|
30
|
-
/** Every edge of one flow: { to, kind: 'call'|'replace', via } and whether the flow itself can put a question. */
|
|
30
|
+
/** Every edge of one flow: { to, kind: 'call'|'replace'|'return', via } and whether the flow itself can put a question. */
|
|
31
31
|
function flowEdges(flow, resolve, returning, isReplaceTarget) {
|
|
32
32
|
const edges = [];
|
|
33
33
|
let asks = false;
|
|
@@ -36,7 +36,7 @@ function flowEdges(flow, resolve, returning, isReplaceTarget) {
|
|
|
36
36
|
if (!id || WORKER_FLOW_IDS.has(id))
|
|
37
37
|
return;
|
|
38
38
|
if (kind === 'replace' && toParent && isReplaceTarget(id))
|
|
39
|
-
return; //
|
|
39
|
+
kind = 'return'; // to the waiting frame: no growth, still a lap
|
|
40
40
|
if (!edges.some((edge) => edge.to === id && edge.kind === kind))
|
|
41
41
|
edges.push({ to: id, kind, via });
|
|
42
42
|
};
|
|
@@ -117,7 +117,7 @@ function stronglyConnected(graph) {
|
|
|
117
117
|
}
|
|
118
118
|
/**
|
|
119
119
|
* The traps of a set of flows: [{ kind: 'stack_growth'|'spin', flows, edges: ['a -call-> b', …] }], each once.
|
|
120
|
-
* `stack_growth`: a loop holding at least one call. `spin`: a loop of replaces where no flow can reach a question.
|
|
120
|
+
* `stack_growth`: a loop holding at least one call. `spin`: a loop of replaces, or one closed by a REPLACE of the parent, where no flow can reach a question.
|
|
121
121
|
*/
|
|
122
122
|
export function findFlowTraps(flows) {
|
|
123
123
|
const byName = new Map();
|
|
@@ -137,9 +137,9 @@ export function findFlowTraps(flows) {
|
|
|
137
137
|
asks.set(flow.id, read.asks);
|
|
138
138
|
}
|
|
139
139
|
for (const { from, to } of returning) {
|
|
140
|
-
const target = byName.get(to);
|
|
141
|
-
if (target &&
|
|
142
|
-
graph.get(from).push({ to: target, kind
|
|
140
|
+
const target = byName.get(to), kind = markedTargets.has(target) ? 'return' : 'replace';
|
|
141
|
+
if (target && graph.has(from) && !graph.get(from).some((edge) => edge.to === target && edge.kind === kind))
|
|
142
|
+
graph.get(from).push({ to: target, kind, via: 'retry_flow' });
|
|
143
143
|
}
|
|
144
144
|
const reachesQuestion = (start) => {
|
|
145
145
|
const seen = new Set([start]), queue = [start];
|
|
@@ -155,20 +155,29 @@ export function findFlowTraps(flows) {
|
|
|
155
155
|
}
|
|
156
156
|
return false;
|
|
157
157
|
};
|
|
158
|
+
const describe = (edge) => `${edge.from} -${edge.kind === 'call' ? 'call' : edge.kind === 'return' ? 'replace-parent' : 'replace'}${edge.via === 'step' ? '' : `(${edge.via})`}-> ${edge.to}`;
|
|
159
|
+
const innerEdges = (group, edgesOf) => { const members = new Set(group); return group.flatMap((from) => edgesOf(from).filter((edge) => members.has(edge.to)).map((edge) => ({ from, ...edge }))); };
|
|
160
|
+
const framed = (from) => (graph.get(from) || []).filter((edge) => edge.kind !== 'return');
|
|
158
161
|
const traps = [];
|
|
159
|
-
for (const group of stronglyConnected(graph)) {
|
|
160
|
-
const
|
|
161
|
-
const inner = group.flatMap((from) => (graph.get(from) || []).filter((edge) => members.has(edge.to)).map((edge) => ({ from, ...edge })));
|
|
162
|
+
for (const group of stronglyConnected(new Map([...graph.keys()].map((from) => [from, framed(from)])))) {
|
|
163
|
+
const inner = innerEdges(group, framed);
|
|
162
164
|
if (!inner.length)
|
|
163
165
|
continue;
|
|
164
166
|
if (group.some((flow) => bounded.has(flow)))
|
|
165
167
|
continue; // a runtime limit ends this loop, and a test drives it there
|
|
166
|
-
const describe = (edge) => `${edge.from} -${edge.kind === 'call' ? 'call' : 'replace'}${edge.via === 'step' ? '' : `(${edge.via})`}-> ${edge.to}`;
|
|
167
168
|
if (inner.some((edge) => edge.kind === 'call'))
|
|
168
169
|
traps.push({ kind: 'stack_growth', flows: [...group].sort(), edges: inner.map(describe).sort() });
|
|
169
170
|
else if (!group.some(reachesQuestion))
|
|
170
171
|
traps.push({ kind: 'spin', flows: [...group].sort(), edges: inner.map(describe).sort() });
|
|
171
172
|
}
|
|
173
|
+
// A REPLACE of the parent keeps the stack flat, but it is still a lap: with no question in reach it never ends.
|
|
174
|
+
for (const group of stronglyConnected(graph)) {
|
|
175
|
+
const inner = innerEdges(group, (from) => graph.get(from) || []);
|
|
176
|
+
if (!inner.some((edge) => edge.kind === 'return') || group.some((flow) => bounded.has(flow)) || group.some(reachesQuestion))
|
|
177
|
+
continue;
|
|
178
|
+
if (!traps.some((trap) => trap.kind === 'spin' && trap.flows.some((flow) => group.includes(flow))))
|
|
179
|
+
traps.push({ kind: 'spin', flows: [...group].sort(), edges: inner.map(describe).sort() });
|
|
180
|
+
}
|
|
172
181
|
return traps.sort((a, b) => a.flows[0].localeCompare(b.flows[0]));
|
|
173
182
|
}
|
|
174
183
|
/** One validator line per trap. */
|
package/dist/runtime-identity.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const JSFE_RUNTIME_FINGERPRINT = "sha256:
|
|
1
|
+
export const JSFE_RUNTIME_FINGERPRINT = "sha256:f5b93873ae79e43362048a7a5d2627b4a4ea86f6ab84c6bb1096961d3b4fcc63";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsfe",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.1",
|
|
4
4
|
"description": "TypeScript workflow engine for conversational/automation apps: multi-flow orchestration, interrupt/resume, stack-based control, robust errors, and flexible tool/REST integration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ron Pinkas",
|