@kiberon-labs/behave-graph-suspendable 1.0.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 +13 -0
- package/package.json +41 -0
- package/src/engine.ts +237 -0
- package/src/fiber.ts +162 -0
- package/src/index.ts +1 -0
- package/src/types.ts +67 -0
- package/tests/asyncNodeSuspension.test.ts +124 -0
- package/tests/coreNodeSuspension.test.ts +143 -0
- package/tests/fixtures/testNodes.ts +222 -0
- package/tests/loopSuspension.test.ts +137 -0
- package/tests/nestedLoopSuspension.test.ts +125 -0
- package/tests/nodes/suspender.ts +31 -0
- package/tests/testUtils.ts +37 -0
- package/tests/tsconfig.json +15 -0
- package/tests/variableSerialization.test.ts +96 -0
- package/tsconfig.json +55 -0
- package/tsdown.config.ts +13 -0
- package/vitest.config.ts +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Behave-Graph Suspendable
|
|
2
|
+
|
|
3
|
+
This is an extension to behaviour graphs to implement a suspendable engine. While behaviour graphs are great, they have issues with serializing them during an existing execution. This implements a suspendable engine, which relies on nodes implementing the suspendable interface. The engine can be suspended mid execution, serialized and then unsuspended.
|
|
4
|
+
|
|
5
|
+
This is a work in progress as there are a number of edge cases that need to be accounted for, namely serialzing nodes that don't implement the required interface as well as hydration issues.
|
|
6
|
+
|
|
7
|
+
To use it you can do the following :
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kiberon-labs/behave-graph-suspendable",
|
|
3
|
+
"description": "An auxiliary engine for the behaviour graph that can be suspended mid execution.",
|
|
4
|
+
"version": "1.0.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"source": "./src/index.ts",
|
|
9
|
+
"author": "kiberonlabsdev",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsdown",
|
|
12
|
+
"check": "oxfmt --check",
|
|
13
|
+
"format": "oxfmt",
|
|
14
|
+
"test": "vitest --passWithNoTests",
|
|
15
|
+
"dev": "tsdown --watch src",
|
|
16
|
+
"lint": "oxlint",
|
|
17
|
+
"typecheck": "tsc"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/kiberon-labs/behave-graph",
|
|
22
|
+
"directory": "packages/scene"
|
|
23
|
+
},
|
|
24
|
+
"bugs": "https://github.com/kiberon-labs/behave-graph/issues",
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@kiberon-labs/behave-graph": "workspace:*",
|
|
27
|
+
"@types/node": "^24.10.1",
|
|
28
|
+
"oxfmt": "^0.14.0",
|
|
29
|
+
"oxlint": "^1.29.0",
|
|
30
|
+
"tsdown": "^0.16.5",
|
|
31
|
+
"vitest": "^4.0.10"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {},
|
|
34
|
+
"license": "ISC",
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@kiberon-labs/behave-graph": "workspace:*"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Assert,
|
|
3
|
+
Engine,
|
|
4
|
+
EventEmitter,
|
|
5
|
+
isAsyncNode,
|
|
6
|
+
isEventNode,
|
|
7
|
+
Link,
|
|
8
|
+
isFlowNode,
|
|
9
|
+
type FiberListenerInner,
|
|
10
|
+
type GraphInstance,
|
|
11
|
+
type INode,
|
|
12
|
+
type IRegistry
|
|
13
|
+
} from '@kiberon-labs/behave-graph';
|
|
14
|
+
import { SuspendableFiber } from './fiber';
|
|
15
|
+
import { isSuspendable, type SerializedSuspension } from './types';
|
|
16
|
+
|
|
17
|
+
type StatefulNode = INode & {
|
|
18
|
+
getState(): unknown;
|
|
19
|
+
setState(value: unknown): void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Concrete nodes carry getState/setState (from the Node base class) even
|
|
23
|
+
* though INode does not declare them. */
|
|
24
|
+
const hasNodeState = (node: INode): node is StatefulNode => {
|
|
25
|
+
const candidate = node as Partial<StatefulNode>;
|
|
26
|
+
return (
|
|
27
|
+
typeof candidate.getState === 'function' &&
|
|
28
|
+
typeof candidate.setState === 'function'
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class SuspendableEngine extends Engine {
|
|
33
|
+
protected override fiberQueue: SuspendableFiber[] = [];
|
|
34
|
+
public readonly onSuspension = new EventEmitter<SerializedSuspension>();
|
|
35
|
+
|
|
36
|
+
constructor(graph: GraphInstance, registry: IRegistry) {
|
|
37
|
+
super(graph, registry);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
//Note this is a weak point in the system to completely override the existing logic
|
|
41
|
+
override commitToNewFiber(
|
|
42
|
+
node: INode,
|
|
43
|
+
outputFlowSocketName: string,
|
|
44
|
+
fiberCompletedListener: FiberListenerInner = undefined
|
|
45
|
+
) {
|
|
46
|
+
try {
|
|
47
|
+
Assert.mustBeTrue(isEventNode(node) || isAsyncNode(node));
|
|
48
|
+
const outputSocket = node.outputs.find(
|
|
49
|
+
(socket) => socket.name === outputFlowSocketName
|
|
50
|
+
);
|
|
51
|
+
if (outputSocket === undefined) {
|
|
52
|
+
throw new Error(`no socket with the name ${outputFlowSocketName}`);
|
|
53
|
+
}
|
|
54
|
+
if (outputSocket.links.length > 1) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'invalid for an output flow socket to have multiple downstream links:' +
|
|
57
|
+
`${node.description.typeName}.${outputSocket.name} has ${outputSocket.links.length} downlinks`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (outputSocket.links.length === 1) {
|
|
61
|
+
const fiber = new SuspendableFiber(
|
|
62
|
+
this,
|
|
63
|
+
outputSocket.links[0]!,
|
|
64
|
+
fiberCompletedListener
|
|
65
|
+
);
|
|
66
|
+
this.onNodeCommit.emit({ node, socket: outputFlowSocketName });
|
|
67
|
+
|
|
68
|
+
this.fiberQueue.push(fiber);
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
this.onNodeExecutionError.emit({ node, error });
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
commitContinuedFiber(node: INode) {
|
|
77
|
+
const fiber = new SuspendableFiber(this, new Link(node.id, 'flow'));
|
|
78
|
+
this.fiberQueue.push(fiber);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
suspend(): SerializedSuspension {
|
|
82
|
+
const serializedNodes: Record<string, any> = {};
|
|
83
|
+
Object.entries(this.nodes).forEach(([id, node]) => {
|
|
84
|
+
if (isSuspendable(node)) {
|
|
85
|
+
serializedNodes[id] = node.suspend();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Flow nodes that keep their cursor in node state (e.g. flow/forLoop,
|
|
89
|
+
// flow/sequence, flow/counter) are captured generically. Event/async
|
|
90
|
+
// node state can hold live listeners or timers, so those must opt in
|
|
91
|
+
// via ISuspendable instead.
|
|
92
|
+
if (isFlowNode(node) && hasNodeState(node)) {
|
|
93
|
+
const state = node.getState();
|
|
94
|
+
if (state !== undefined) {
|
|
95
|
+
serializedNodes[id] = structuredClone(state);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const serializedFiberQueue = this.fiberQueue.map((fiber) =>
|
|
101
|
+
fiber.serialize()
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Snapshot the downstream links of each node's output sockets so that
|
|
105
|
+
// connections made at runtime survive the suspension round-trip.
|
|
106
|
+
const sockets: SerializedSuspension['sockets'] = {};
|
|
107
|
+
Object.entries(this.nodes).forEach(([id, node]) => {
|
|
108
|
+
const linkedOutputs: SerializedSuspension['sockets'][string] = {};
|
|
109
|
+
node.outputs.forEach((socket) => {
|
|
110
|
+
if (socket.links.length > 0) {
|
|
111
|
+
linkedOutputs[socket.name] = socket.links.map((link) => ({
|
|
112
|
+
nodeId: link.nodeId,
|
|
113
|
+
socketName: link.socketName
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
if (Object.keys(linkedOutputs).length > 0) {
|
|
118
|
+
sockets[id] = linkedOutputs;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Snapshot non-flow output socket values: resumed flows may read an
|
|
123
|
+
// upstream output (e.g. a loop's `index`) before that node re-executes.
|
|
124
|
+
const socketValues: SerializedSuspension['socketValues'] = {};
|
|
125
|
+
Object.entries(this.nodes).forEach(([id, node]) => {
|
|
126
|
+
const values: Record<string, any> = {};
|
|
127
|
+
node.outputs.forEach((socket) => {
|
|
128
|
+
if (socket.valueTypeName === 'flow' || socket.value === undefined) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const valueType = this.registry.values[socket.valueTypeName];
|
|
132
|
+
values[socket.name] = valueType
|
|
133
|
+
? valueType.serialize(socket.value)
|
|
134
|
+
: socket.value;
|
|
135
|
+
});
|
|
136
|
+
if (Object.keys(values).length > 0) {
|
|
137
|
+
socketValues[id] = values;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const variables: SerializedSuspension['variables'] = {};
|
|
142
|
+
Object.entries(this.graph.variables).forEach(([id, variable]) => {
|
|
143
|
+
const valueType = this.registry.values[variable.valueTypeName];
|
|
144
|
+
variables[id] = {
|
|
145
|
+
type: variable.valueTypeName,
|
|
146
|
+
value: valueType ? valueType.serialize(variable.get()) : variable.get()
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
fiberQueue: serializedFiberQueue,
|
|
152
|
+
nodes: serializedNodes,
|
|
153
|
+
sockets,
|
|
154
|
+
socketValues,
|
|
155
|
+
variables
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
unsuspend(suspension: SerializedSuspension, continuanceData: any) {
|
|
160
|
+
Object.entries(suspension.variables).forEach(([id, serialized]) => {
|
|
161
|
+
const variable = this.graph.variables[id];
|
|
162
|
+
if (!variable) {
|
|
163
|
+
throw new Error(`Could not find missing variable ${id}`);
|
|
164
|
+
}
|
|
165
|
+
const valueType = this.registry.values[serialized.type];
|
|
166
|
+
variable.set(
|
|
167
|
+
valueType ? valueType.deserialize(serialized.value) : serialized.value
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
Object.entries(suspension.sockets).forEach(([nodeId, linkedOutputs]) => {
|
|
172
|
+
const node = this.nodes[nodeId];
|
|
173
|
+
if (!node) {
|
|
174
|
+
throw new Error(`Could not find missing node ${nodeId}`);
|
|
175
|
+
}
|
|
176
|
+
Object.entries(linkedOutputs).forEach(([socketName, links]) => {
|
|
177
|
+
const socket = node.outputs.find((s) => s.name === socketName);
|
|
178
|
+
if (!socket) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Could not find missing socket ${socketName} on node ${nodeId}`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
socket.links.splice(
|
|
184
|
+
0,
|
|
185
|
+
socket.links.length,
|
|
186
|
+
...links.map((link) => new Link(link.nodeId, link.socketName))
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
Object.entries(suspension.socketValues ?? {}).forEach(
|
|
192
|
+
([nodeId, values]) => {
|
|
193
|
+
const node = this.nodes[nodeId];
|
|
194
|
+
if (!node) {
|
|
195
|
+
throw new Error(`Could not find missing node ${nodeId}`);
|
|
196
|
+
}
|
|
197
|
+
Object.entries(values).forEach(([socketName, serialized]) => {
|
|
198
|
+
const socket = node.outputs.find((s) => s.name === socketName);
|
|
199
|
+
if (!socket) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Could not find missing socket ${socketName} on node ${nodeId}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const valueType = this.registry.values[socket.valueTypeName];
|
|
205
|
+
socket.value = valueType
|
|
206
|
+
? valueType.deserialize(serialized)
|
|
207
|
+
: serialized;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
Object.entries(suspension.nodes).forEach(([id, serialized]) => {
|
|
213
|
+
const node = this.nodes[id];
|
|
214
|
+
if (!node) {
|
|
215
|
+
throw new Error(`Could not find missing node ${id}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (isSuspendable(node)) {
|
|
219
|
+
node.hydrate?.(serialized);
|
|
220
|
+
} else if (isFlowNode(node) && hasNodeState(node)) {
|
|
221
|
+
node.setState(serialized);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
//Recreate the fiberqueue
|
|
226
|
+
this.fiberQueue = suspension.fiberQueue.map((x) =>
|
|
227
|
+
SuspendableFiber.rehydrate(this, x)
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Only fibers suspended at an async node need an explicit continuation;
|
|
231
|
+
// fibers suspended between steps resume through normal execution.
|
|
232
|
+
const first = this.fiberQueue[0];
|
|
233
|
+
if (first?.canContinue()) {
|
|
234
|
+
first.continue(continuanceData);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/fiber.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Assert,
|
|
3
|
+
Fiber,
|
|
4
|
+
isAsyncNode,
|
|
5
|
+
Link,
|
|
6
|
+
type FiberListenerInner,
|
|
7
|
+
type IAsyncNode
|
|
8
|
+
} from '@kiberon-labs/behave-graph';
|
|
9
|
+
import type { SuspendableEngine } from './engine';
|
|
10
|
+
import { isSuspendable, type IAsyncSuspendable } from './types';
|
|
11
|
+
|
|
12
|
+
type SerializedLink = {
|
|
13
|
+
nodeId: string;
|
|
14
|
+
socketName: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type SerializedSuspendedFiber = {
|
|
18
|
+
curr?: SerializedLink;
|
|
19
|
+
link?: SerializedLink;
|
|
20
|
+
queue: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class SuspendableFiber extends Fiber {
|
|
24
|
+
protected currentLink?: Link;
|
|
25
|
+
declare public readonly engine: SuspendableEngine;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
engine: SuspendableEngine,
|
|
29
|
+
nextEval: Link | null,
|
|
30
|
+
fiberCompletedListener: FiberListenerInner = undefined
|
|
31
|
+
) {
|
|
32
|
+
super(engine, nextEval, fiberCompletedListener);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static rehydrate(
|
|
36
|
+
engine: SuspendableEngine,
|
|
37
|
+
serialized: SerializedSuspendedFiber
|
|
38
|
+
): SuspendableFiber {
|
|
39
|
+
const nextlink = serialized.link
|
|
40
|
+
? new Link(serialized.link.nodeId, serialized.link.socketName)
|
|
41
|
+
: null;
|
|
42
|
+
const fiber = new SuspendableFiber(engine, nextlink);
|
|
43
|
+
fiber.currentLink = serialized.curr
|
|
44
|
+
? new Link(serialized.curr.nodeId, serialized.curr.socketName)
|
|
45
|
+
: undefined;
|
|
46
|
+
|
|
47
|
+
//Rehydrate the listener stack
|
|
48
|
+
serialized.queue.forEach((nodeId) => {
|
|
49
|
+
fiber.fiberCompletedListenerStack.push({
|
|
50
|
+
nodeId,
|
|
51
|
+
cb: () => engine.commitContinuedFiber(engine.nodes[nodeId]!)
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return fiber;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
serialize(): SerializedSuspendedFiber {
|
|
59
|
+
return {
|
|
60
|
+
queue: this.fiberCompletedListenerStack
|
|
61
|
+
.map((l) => l.nodeId!)
|
|
62
|
+
.filter((id) => !!id),
|
|
63
|
+
curr: this.currentLink
|
|
64
|
+
? {
|
|
65
|
+
nodeId: this.currentLink.nodeId,
|
|
66
|
+
socketName: this.currentLink.socketName
|
|
67
|
+
}
|
|
68
|
+
: undefined,
|
|
69
|
+
link: this.nextEval
|
|
70
|
+
? {
|
|
71
|
+
nodeId: this.nextEval.nodeId,
|
|
72
|
+
socketName: this.nextEval.socketName
|
|
73
|
+
}
|
|
74
|
+
: undefined
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
clear() {
|
|
79
|
+
this.fiberCompletedListenerStack.splice(
|
|
80
|
+
0,
|
|
81
|
+
this.fiberCompletedListenerStack.length
|
|
82
|
+
);
|
|
83
|
+
this.currentLink = undefined;
|
|
84
|
+
this.nextEval = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether this fiber was suspended at an async node that must be resumed
|
|
88
|
+
* via `continue()`. Fibers suspended between steps have no current link and
|
|
89
|
+
* simply resume through normal engine execution. */
|
|
90
|
+
canContinue(): boolean {
|
|
91
|
+
return !!this.currentLink;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Track the link while executing an async suspendable node. Such nodes can
|
|
96
|
+
* park the fiber on a pending promise; if the engine is suspended while
|
|
97
|
+
* parked, `serialize()` must record where execution stopped so `continue()`
|
|
98
|
+
* can resume the node with continuance data. The link is cleared once the
|
|
99
|
+
* node completes normally.
|
|
100
|
+
*/
|
|
101
|
+
override async executeStep() {
|
|
102
|
+
const link = this.nextEval;
|
|
103
|
+
if (link) {
|
|
104
|
+
const node = this.nodes[link.nodeId];
|
|
105
|
+
if (node && isAsyncNode(node) && isSuspendable(node)) {
|
|
106
|
+
this.currentLink = link;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await super.executeStep();
|
|
111
|
+
|
|
112
|
+
if (link && this.currentLink === link) {
|
|
113
|
+
this.currentLink = undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
continue(continuanceData: any) {
|
|
118
|
+
Assert.mustBeTrue(
|
|
119
|
+
!!this.currentLink,
|
|
120
|
+
'Cannot continue fiber if no current link'
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const nodeID = this.currentLink?.nodeId;
|
|
124
|
+
const node = this.nodes[nodeID!] as IAsyncSuspendable;
|
|
125
|
+
|
|
126
|
+
if (!node) {
|
|
127
|
+
throw new Error(`Missing node for continuance: ${nodeID}`);
|
|
128
|
+
}
|
|
129
|
+
const socketName = this.currentLink?.socketName;
|
|
130
|
+
Assert.mustBeTrue(
|
|
131
|
+
!!socketName,
|
|
132
|
+
'Cannot continue fiber if no current socket name'
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
this.engine.onNodeExecutionStart.emit(node!);
|
|
136
|
+
return node.unsuspend(continuanceData, this.engine, socketName!, () => {
|
|
137
|
+
//Only on successful unsuspend do we clear the current link
|
|
138
|
+
//This is to allow a node to immediately re-suspend if needed
|
|
139
|
+
this.currentLink = undefined;
|
|
140
|
+
this.engine.onNodeExecutionEnd.emit(node);
|
|
141
|
+
|
|
142
|
+
//remove from the list of pending async nodes (it will not be present
|
|
143
|
+
//on a freshly rehydrated engine, where the node was never triggered)
|
|
144
|
+
const index = this.engine.asyncNodes.indexOf(
|
|
145
|
+
node as unknown as IAsyncNode
|
|
146
|
+
);
|
|
147
|
+
if (index >= 0) {
|
|
148
|
+
this.engine.asyncNodes.splice(index, 1);
|
|
149
|
+
}
|
|
150
|
+
this.executionSteps++;
|
|
151
|
+
|
|
152
|
+
//There might be pending callbacks on the queue
|
|
153
|
+
while (this.fiberCompletedListenerStack.length > 0) {
|
|
154
|
+
const awaitingCallback = this.fiberCompletedListenerStack.pop();
|
|
155
|
+
if (awaitingCallback === undefined) {
|
|
156
|
+
throw new Error('awaitingCallback is empty');
|
|
157
|
+
}
|
|
158
|
+
awaitingCallback.cb();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './engine';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { INode } from '@kiberon-labs/behave-graph';
|
|
2
|
+
import type { SuspendableEngine } from './engine';
|
|
3
|
+
import type { SerializedSuspendedFiber } from './fiber';
|
|
4
|
+
|
|
5
|
+
export interface IAsyncSuspendable<T = any, State = any>
|
|
6
|
+
extends ISuspendable<State> {
|
|
7
|
+
/**
|
|
8
|
+
* Continue from suspension
|
|
9
|
+
*/
|
|
10
|
+
unsuspend(
|
|
11
|
+
data: T,
|
|
12
|
+
engine: SuspendableEngine,
|
|
13
|
+
triggeringSocketName: string,
|
|
14
|
+
cb: () => void
|
|
15
|
+
): Promise<void>;
|
|
16
|
+
triggered(
|
|
17
|
+
engine: SuspendableEngine,
|
|
18
|
+
triggeringSocketName: string,
|
|
19
|
+
finished: () => void
|
|
20
|
+
): unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ISuspendable<T = any> extends INode {
|
|
24
|
+
/**
|
|
25
|
+
* Suspend and create a copy of your internal state
|
|
26
|
+
*/
|
|
27
|
+
suspend(): T;
|
|
28
|
+
/**
|
|
29
|
+
* Rehydrate from suspended data
|
|
30
|
+
*/
|
|
31
|
+
hydrate?(data: T): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isSuspendable(node: unknown): node is ISuspendable {
|
|
35
|
+
if (node == null || typeof node !== 'object') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const candidate = node as Record<string, unknown>;
|
|
39
|
+
// ISuspendable only requires suspend(); unsuspend() belongs to
|
|
40
|
+
// IAsyncSuspendable and is checked at the point of continuation.
|
|
41
|
+
return typeof candidate.suspend === 'function';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SerializedSuspension = {
|
|
45
|
+
fiberQueue: SerializedSuspendedFiber[];
|
|
46
|
+
/**
|
|
47
|
+
* Serialized state of the nodes
|
|
48
|
+
*/
|
|
49
|
+
nodes: Record<string, any>;
|
|
50
|
+
sockets: Record<
|
|
51
|
+
string,
|
|
52
|
+
Record<string, { nodeId: string; socketName: string }[]>
|
|
53
|
+
>;
|
|
54
|
+
/**
|
|
55
|
+
* Serialized output socket values per node. Resumed flows may read an
|
|
56
|
+
* upstream node's output (e.g. a loop's `index`) before that node is
|
|
57
|
+
* re-triggered, so the values present at suspension time must be restored.
|
|
58
|
+
*/
|
|
59
|
+
socketValues: Record<string, Record<string, any>>;
|
|
60
|
+
variables: Record<
|
|
61
|
+
string,
|
|
62
|
+
{
|
|
63
|
+
type: string;
|
|
64
|
+
value: any;
|
|
65
|
+
}
|
|
66
|
+
>;
|
|
67
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { GraphJSON } from '@kiberon-labs/behave-graph';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { makeRecorderDescription, WaitForSignal } from './fixtures/testNodes';
|
|
4
|
+
import { makeTestEngine } from './testUtils';
|
|
5
|
+
|
|
6
|
+
const COMPLETED = 999;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* onStart -> forLoop [0,1) -> waitForSignal (async, parks the fiber) ->
|
|
10
|
+
* recorder reading the delivered signal value. The orphan time/delay node is
|
|
11
|
+
* present only to assert async nodes are excluded from generic state capture.
|
|
12
|
+
*/
|
|
13
|
+
const waitGraph: GraphJSON = {
|
|
14
|
+
variables: [],
|
|
15
|
+
customEvents: [],
|
|
16
|
+
nodes: [
|
|
17
|
+
{
|
|
18
|
+
type: 'lifecycle/onStart',
|
|
19
|
+
id: '0',
|
|
20
|
+
flows: { flow: { nodeId: 'loop', socket: 'flow' } }
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: 'flow/forLoop',
|
|
24
|
+
id: 'loop',
|
|
25
|
+
parameters: {
|
|
26
|
+
startIndex: { value: 0 },
|
|
27
|
+
endIndex: { value: 1 }
|
|
28
|
+
},
|
|
29
|
+
flows: {
|
|
30
|
+
loopBody: { nodeId: 'wait', socket: 'flow' },
|
|
31
|
+
completed: { nodeId: 'done', socket: 'flow' }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
type: 'test/waitForSignal',
|
|
36
|
+
id: 'wait',
|
|
37
|
+
flows: { flow: { nodeId: 'rec', socket: 'flow' } }
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
type: 'test/recorder',
|
|
41
|
+
id: 'rec',
|
|
42
|
+
parameters: { index: { link: { nodeId: 'wait', socket: 'value' } } }
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: 'test/recorder',
|
|
46
|
+
id: 'done',
|
|
47
|
+
parameters: { index: { value: COMPLETED } }
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: 'time/delay',
|
|
51
|
+
id: 'orphanDelay'
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const makeWaitEngine = (record: (index: number) => void) =>
|
|
57
|
+
makeTestEngine(waitGraph, {
|
|
58
|
+
'test/recorder': makeRecorderDescription(record),
|
|
59
|
+
[WaitForSignal.Description.typeName]: WaitForSignal.Description
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('async node suspension (vs flow nodes)', () => {
|
|
63
|
+
it('parks the fiber at the async node and resumes via unsuspend with continuance data', async () => {
|
|
64
|
+
const firstRun: number[] = [];
|
|
65
|
+
const first = makeWaitEngine((i) => firstRun.push(i));
|
|
66
|
+
|
|
67
|
+
first.start();
|
|
68
|
+
// do not await: the async node parks execution on a pending promise
|
|
69
|
+
void first.engine.executeAllSync();
|
|
70
|
+
const waitNode = first.graph.nodes['wait'] as WaitForSignal;
|
|
71
|
+
await waitNode.parked;
|
|
72
|
+
|
|
73
|
+
// nothing downstream of the wait has run
|
|
74
|
+
expect(firstRun).toEqual([]);
|
|
75
|
+
|
|
76
|
+
const suspension = first.engine.suspend();
|
|
77
|
+
first.engine.dispose();
|
|
78
|
+
|
|
79
|
+
// ASYNC nodes are captured through ISuspendable.suspend()...
|
|
80
|
+
expect(suspension.nodes['wait']).toEqual({ waiting: true });
|
|
81
|
+
// ...and the fiber records the async node as its resume point, unlike
|
|
82
|
+
// flow-node suspensions which carry no current link
|
|
83
|
+
expect(suspension.fiberQueue[0]!.curr).toEqual({
|
|
84
|
+
nodeId: 'wait',
|
|
85
|
+
socketName: 'flow'
|
|
86
|
+
});
|
|
87
|
+
// the enclosing loop's continuation is preserved alongside it
|
|
88
|
+
expect(suspension.fiberQueue[0]!.queue).toEqual(['loop']);
|
|
89
|
+
|
|
90
|
+
const persisted = JSON.parse(JSON.stringify(suspension));
|
|
91
|
+
|
|
92
|
+
// resume on a fresh engine, delivering the awaited data as continuance
|
|
93
|
+
const secondRun: number[] = [];
|
|
94
|
+
const second = makeWaitEngine((i) => secondRun.push(i));
|
|
95
|
+
second.engine.unsuspend(persisted, 42);
|
|
96
|
+
await second.engine.executeAllSync();
|
|
97
|
+
|
|
98
|
+
// the delivered data flows downstream of the wait node, then the
|
|
99
|
+
// enclosing loop finishes without restarting
|
|
100
|
+
expect(secondRun).toEqual([42, COMPLETED]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('excludes async and event node state from generic capture', async () => {
|
|
104
|
+
const firstRun: number[] = [];
|
|
105
|
+
const first = makeWaitEngine((i) => firstRun.push(i));
|
|
106
|
+
|
|
107
|
+
first.start();
|
|
108
|
+
void first.engine.executeAllSync();
|
|
109
|
+
await (first.graph.nodes['wait'] as WaitForSignal).parked;
|
|
110
|
+
|
|
111
|
+
const suspension = first.engine.suspend();
|
|
112
|
+
first.engine.dispose();
|
|
113
|
+
|
|
114
|
+
// flow nodes are captured generically from node state
|
|
115
|
+
expect(suspension.nodes['loop']).toEqual({ nextIndex: 1 });
|
|
116
|
+
|
|
117
|
+
// event nodes hold live listeners — never serialized
|
|
118
|
+
expect(suspension.nodes['0']).toBeUndefined();
|
|
119
|
+
|
|
120
|
+
// async nodes can hold timers, so they are only captured when they opt in
|
|
121
|
+
// via ISuspendable. time/delay has node state but no suspend() — excluded.
|
|
122
|
+
expect(suspension.nodes['orphanDelay']).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
});
|