@orcalang/orca-runtime-ts 0.1.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/dist/bus.d.ts +46 -0
- package/dist/bus.d.ts.map +1 -0
- package/dist/bus.js +148 -0
- package/dist/bus.js.map +1 -0
- package/dist/effects.d.ts +13 -0
- package/dist/effects.d.ts.map +1 -0
- package/dist/effects.js +33 -0
- package/dist/effects.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/logging.d.ts +46 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +59 -0
- package/dist/logging.js.map +1 -0
- package/dist/machine.d.ts +119 -0
- package/dist/machine.d.ts.map +1 -0
- package/dist/machine.js +785 -0
- package/dist/machine.js.map +1 -0
- package/dist/parser.d.ts +25 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +737 -0
- package/dist/parser.js.map +1 -0
- package/dist/persistence.d.ts +20 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +34 -0
- package/dist/persistence.js.map +1 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +95 -0
- package/dist/types.js.map +1 -0
- package/package.json +36 -0
package/dist/machine.js
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orca state machine runtime.
|
|
3
|
+
*
|
|
4
|
+
* Async state machine that executes Orca machine definitions,
|
|
5
|
+
* publishing state changes to an event bus and executing effects
|
|
6
|
+
* via registered handlers.
|
|
7
|
+
*/
|
|
8
|
+
import { StateValue, EffectStatus } from "./types.js";
|
|
9
|
+
import { getEventBus } from "./bus.js";
|
|
10
|
+
export class OrcaMachine {
|
|
11
|
+
definition;
|
|
12
|
+
eventBus;
|
|
13
|
+
context;
|
|
14
|
+
onTransition;
|
|
15
|
+
state;
|
|
16
|
+
active = false;
|
|
17
|
+
actionHandlers = new Map();
|
|
18
|
+
timeoutTimer = null;
|
|
19
|
+
childMachines = new Map();
|
|
20
|
+
siblingMachines;
|
|
21
|
+
constructor(definition, eventBus, context, onTransition) {
|
|
22
|
+
this.definition = definition;
|
|
23
|
+
this.eventBus = eventBus ?? getEventBus();
|
|
24
|
+
this.context = context ?? { ...definition.context };
|
|
25
|
+
this.onTransition = onTransition;
|
|
26
|
+
this.state = new StateValue(this.getInitialState());
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Register sibling machines available for invocation.
|
|
30
|
+
* Call this before start() when using multi-machine files.
|
|
31
|
+
*/
|
|
32
|
+
registerMachines(machines) {
|
|
33
|
+
this.siblingMachines = machines;
|
|
34
|
+
}
|
|
35
|
+
registerAction(name, handler) {
|
|
36
|
+
this.actionHandlers.set(name, handler);
|
|
37
|
+
}
|
|
38
|
+
unregisterAction(name) {
|
|
39
|
+
this.actionHandlers.delete(name);
|
|
40
|
+
}
|
|
41
|
+
getInitialState() {
|
|
42
|
+
for (const state of this.definition.states) {
|
|
43
|
+
if (state.isInitial) {
|
|
44
|
+
return state.name;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (this.definition.states.length > 0) {
|
|
48
|
+
return this.definition.states[0].name;
|
|
49
|
+
}
|
|
50
|
+
return "unknown";
|
|
51
|
+
}
|
|
52
|
+
get currentState() {
|
|
53
|
+
return this.state;
|
|
54
|
+
}
|
|
55
|
+
get isActive() {
|
|
56
|
+
return this.active;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Capture the current machine state as a serializable snapshot.
|
|
60
|
+
* The snapshot includes state value, context, timestamp, and child machine snapshots.
|
|
61
|
+
* Action handlers are NOT included — re-register them after restore.
|
|
62
|
+
*/
|
|
63
|
+
snapshot() {
|
|
64
|
+
const children = {};
|
|
65
|
+
for (const [stateName, child] of this.childMachines) {
|
|
66
|
+
children[stateName] = child.snapshot();
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
state: typeof this.state.value === 'string'
|
|
70
|
+
? this.state.value
|
|
71
|
+
: JSON.parse(JSON.stringify(this.state.value)),
|
|
72
|
+
context: JSON.parse(JSON.stringify(this.context)),
|
|
73
|
+
children: Object.keys(children).length > 0 ? children : undefined,
|
|
74
|
+
timestamp: Date.now(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Restore machine state from a previously captured snapshot.
|
|
79
|
+
* The machine must be stopped before restoring. After restore,
|
|
80
|
+
* call start() to resume. Action handlers must be re-registered.
|
|
81
|
+
*/
|
|
82
|
+
async restore(snap) {
|
|
83
|
+
// Cancel any active timeout
|
|
84
|
+
this.cancelTimeout();
|
|
85
|
+
// Restore state and context
|
|
86
|
+
this.state = new StateValue(typeof snap.state === 'string'
|
|
87
|
+
? snap.state
|
|
88
|
+
: JSON.parse(JSON.stringify(snap.state)));
|
|
89
|
+
this.context = JSON.parse(JSON.stringify(snap.context));
|
|
90
|
+
// If machine was active, restart timeouts for current leaf states
|
|
91
|
+
if (this.active) {
|
|
92
|
+
for (const leaf of this.state.leaves()) {
|
|
93
|
+
this.startTimeoutForState(leaf);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Cold-boot from a snapshot without re-running on_entry handlers.
|
|
99
|
+
* Use this to resume a previously persisted run.
|
|
100
|
+
* Distinct from restore() which operates on a live machine.
|
|
101
|
+
*/
|
|
102
|
+
async resume(snap) {
|
|
103
|
+
if (this.active)
|
|
104
|
+
return;
|
|
105
|
+
this.state = new StateValue(typeof snap.state === 'string'
|
|
106
|
+
? snap.state
|
|
107
|
+
: JSON.parse(JSON.stringify(snap.state)));
|
|
108
|
+
this.context = JSON.parse(JSON.stringify(snap.context));
|
|
109
|
+
this.active = true;
|
|
110
|
+
await this.eventBus.publish({
|
|
111
|
+
type: "orca.machine.started",
|
|
112
|
+
source: this.definition.name,
|
|
113
|
+
timestamp: new Date(),
|
|
114
|
+
payload: {
|
|
115
|
+
machine: this.definition.name,
|
|
116
|
+
initial_state: this.state.toString(),
|
|
117
|
+
resumed: true,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
for (const leaf of this.state.leaves()) {
|
|
121
|
+
this.startTimeoutForState(leaf);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async start() {
|
|
125
|
+
if (this.active) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.active = true;
|
|
129
|
+
await this.eventBus.publish({
|
|
130
|
+
type: "orca.machine.started",
|
|
131
|
+
source: this.definition.name,
|
|
132
|
+
timestamp: new Date(),
|
|
133
|
+
payload: {
|
|
134
|
+
machine: this.definition.name,
|
|
135
|
+
initial_state: this.state.toString(),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
// Execute entry actions for initial state
|
|
139
|
+
await this.executeEntryActions(this.state.leaf());
|
|
140
|
+
// Start timeout for initial state if defined
|
|
141
|
+
this.startTimeoutForState(this.state.leaf());
|
|
142
|
+
}
|
|
143
|
+
async stop() {
|
|
144
|
+
if (!this.active) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.cancelTimeout();
|
|
148
|
+
// Stop all child machines
|
|
149
|
+
for (const [stateName, child] of this.childMachines) {
|
|
150
|
+
await child.stop();
|
|
151
|
+
}
|
|
152
|
+
this.childMachines.clear();
|
|
153
|
+
this.active = false;
|
|
154
|
+
await this.eventBus.publish({
|
|
155
|
+
type: "orca.machine.stopped",
|
|
156
|
+
source: this.definition.name,
|
|
157
|
+
timestamp: new Date(),
|
|
158
|
+
payload: {},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async send(event, payload) {
|
|
162
|
+
if (!this.active) {
|
|
163
|
+
return {
|
|
164
|
+
taken: false,
|
|
165
|
+
fromState: this.state.toString(),
|
|
166
|
+
error: "Machine is not active",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// Normalize to Event
|
|
170
|
+
let evt;
|
|
171
|
+
if (typeof event === "string") {
|
|
172
|
+
evt = {
|
|
173
|
+
type: this.findEventType(event),
|
|
174
|
+
source: this.definition.name,
|
|
175
|
+
eventName: event,
|
|
176
|
+
timestamp: new Date(),
|
|
177
|
+
payload: payload ?? {},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
evt = event;
|
|
182
|
+
}
|
|
183
|
+
// Check if event is explicitly ignored in current state
|
|
184
|
+
const eventKey = evt.eventName ?? evt.type;
|
|
185
|
+
if (this.isEventIgnored(eventKey)) {
|
|
186
|
+
return {
|
|
187
|
+
taken: false,
|
|
188
|
+
fromState: this.state.toString(),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// Find matching transition
|
|
192
|
+
const transition = this.findTransition(evt);
|
|
193
|
+
if (!transition) {
|
|
194
|
+
return {
|
|
195
|
+
taken: false,
|
|
196
|
+
fromState: this.state.toString(),
|
|
197
|
+
error: `No transition for event ${eventKey} from ${this.state.leaf()}`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
// Evaluate guard if present
|
|
201
|
+
if (transition.guard) {
|
|
202
|
+
const guardPassed = await this.evaluateGuard(transition.guard);
|
|
203
|
+
if (!guardPassed) {
|
|
204
|
+
return {
|
|
205
|
+
taken: false,
|
|
206
|
+
fromState: this.state.toString(),
|
|
207
|
+
guardFailed: true,
|
|
208
|
+
error: `Guard '${transition.guard}' failed`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Execute the transition
|
|
213
|
+
const oldState = new StateValue(this.state.value);
|
|
214
|
+
const newStateName = transition.target;
|
|
215
|
+
// Cancel any active timeout from the old state
|
|
216
|
+
this.cancelTimeout();
|
|
217
|
+
// Execute exit actions
|
|
218
|
+
await this.executeExitActions(oldState.leaf());
|
|
219
|
+
// Execute transition action
|
|
220
|
+
if (transition.action) {
|
|
221
|
+
await this.executeAction(transition.action, evt.payload);
|
|
222
|
+
}
|
|
223
|
+
// Update state
|
|
224
|
+
if (this.isParallelState(newStateName)) {
|
|
225
|
+
this.state = new StateValue(this.buildParallelStateValue(newStateName));
|
|
226
|
+
}
|
|
227
|
+
else if (this.isCompoundState(newStateName)) {
|
|
228
|
+
const initialChild = this.getInitialChild(newStateName);
|
|
229
|
+
this.state = new StateValue({ [newStateName]: { [initialChild]: {} } });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// Check if we're inside a parallel state and need to update just one region
|
|
233
|
+
const updatedInRegion = this.tryUpdateParallelRegion(newStateName);
|
|
234
|
+
if (!updatedInRegion) {
|
|
235
|
+
this.state = new StateValue(newStateName);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Publish transition started
|
|
239
|
+
await this.eventBus.publish({
|
|
240
|
+
type: "orca.transition.started",
|
|
241
|
+
source: this.definition.name,
|
|
242
|
+
timestamp: new Date(),
|
|
243
|
+
payload: {
|
|
244
|
+
from: oldState.toString(),
|
|
245
|
+
to: newStateName,
|
|
246
|
+
trigger: evt.eventName ?? evt.type,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
// Execute entry actions for new state
|
|
250
|
+
if (this.isParallelState(newStateName)) {
|
|
251
|
+
// Execute entry actions for all region initial states
|
|
252
|
+
const stateDef = this.findStateDefDeep(newStateName);
|
|
253
|
+
if (stateDef?.parallel) {
|
|
254
|
+
for (const region of stateDef.parallel.regions) {
|
|
255
|
+
const initialChild = region.states.find(s => s.isInitial) || region.states[0];
|
|
256
|
+
await this.executeEntryActions(initialChild.name);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
await this.executeEntryActions(newStateName);
|
|
262
|
+
}
|
|
263
|
+
// Start timeout for new state if defined
|
|
264
|
+
for (const leaf of this.state.leaves()) {
|
|
265
|
+
this.startTimeoutForState(leaf);
|
|
266
|
+
}
|
|
267
|
+
// Check parallel sync condition
|
|
268
|
+
await this.checkParallelSync();
|
|
269
|
+
// Notify callback
|
|
270
|
+
if (this.onTransition) {
|
|
271
|
+
await this.onTransition(oldState, this.state);
|
|
272
|
+
}
|
|
273
|
+
// Publish transition completed
|
|
274
|
+
await this.eventBus.publish({
|
|
275
|
+
type: "orca.transition.completed",
|
|
276
|
+
source: this.definition.name,
|
|
277
|
+
timestamp: new Date(),
|
|
278
|
+
payload: {
|
|
279
|
+
from: oldState.toString(),
|
|
280
|
+
to: this.state.toString(),
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
taken: true,
|
|
285
|
+
fromState: oldState.toString(),
|
|
286
|
+
toState: this.state.toString(),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
findEventType(eventName) {
|
|
290
|
+
const eventTypeMap = {
|
|
291
|
+
"state_changed": "orca.state.changed",
|
|
292
|
+
"transition_started": "orca.transition.started",
|
|
293
|
+
"transition_completed": "orca.transition.completed",
|
|
294
|
+
"effect_executing": "orca.effect.executing",
|
|
295
|
+
"effect_completed": "orca.effect.completed",
|
|
296
|
+
"effect_failed": "orca.effect.failed",
|
|
297
|
+
"machine_started": "orca.machine.started",
|
|
298
|
+
"machine_stopped": "orca.machine.stopped",
|
|
299
|
+
};
|
|
300
|
+
const normalized = eventName.toLowerCase().replace(/-/g, "_");
|
|
301
|
+
if (normalized in eventTypeMap) {
|
|
302
|
+
return eventTypeMap[normalized];
|
|
303
|
+
}
|
|
304
|
+
for (const definedEvent of this.definition.events) {
|
|
305
|
+
if (definedEvent.toLowerCase().replace(/-/g, "_") === normalized) {
|
|
306
|
+
return "orca.state.changed";
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return "orca.state.changed";
|
|
310
|
+
}
|
|
311
|
+
findTransition(event) {
|
|
312
|
+
const eventKey = event.eventName ?? event.type;
|
|
313
|
+
// Check all active leaf states (important for parallel states)
|
|
314
|
+
for (const current of this.state.leaves()) {
|
|
315
|
+
// Try direct match on current leaf state
|
|
316
|
+
for (const t of this.definition.transitions) {
|
|
317
|
+
if (t.source === current && t.event === eventKey) {
|
|
318
|
+
return t;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// For compound states, also check parent's transitions
|
|
322
|
+
let parent = this.getParentState(current);
|
|
323
|
+
while (parent) {
|
|
324
|
+
for (const t of this.definition.transitions) {
|
|
325
|
+
if (t.source === parent && t.event === eventKey) {
|
|
326
|
+
return t;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
parent = this.getParentState(parent);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
getParentState(stateName) {
|
|
335
|
+
const search = (states, parentName) => {
|
|
336
|
+
for (const state of states) {
|
|
337
|
+
if (state.name === stateName) {
|
|
338
|
+
return state.parent ?? parentName ?? null;
|
|
339
|
+
}
|
|
340
|
+
if (state.contains && state.contains.length > 0) {
|
|
341
|
+
for (const child of state.contains) {
|
|
342
|
+
if (child.name === stateName)
|
|
343
|
+
return state.name;
|
|
344
|
+
}
|
|
345
|
+
const found = search(state.contains, state.name);
|
|
346
|
+
if (found)
|
|
347
|
+
return found;
|
|
348
|
+
}
|
|
349
|
+
if (state.parallel) {
|
|
350
|
+
for (const region of state.parallel.regions) {
|
|
351
|
+
for (const child of region.states) {
|
|
352
|
+
if (child.name === stateName)
|
|
353
|
+
return state.name;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
};
|
|
360
|
+
return search(this.definition.states);
|
|
361
|
+
}
|
|
362
|
+
isCompoundState(stateName) {
|
|
363
|
+
const state = this.findStateDefDeep(stateName);
|
|
364
|
+
if (!state)
|
|
365
|
+
return false;
|
|
366
|
+
return (state.contains !== undefined && state.contains.length > 0) || !!state.parallel;
|
|
367
|
+
}
|
|
368
|
+
isParallelState(stateName) {
|
|
369
|
+
const state = this.findStateDefDeep(stateName);
|
|
370
|
+
return !!state?.parallel;
|
|
371
|
+
}
|
|
372
|
+
getInitialChild(parentName) {
|
|
373
|
+
const state = this.findStateDefDeep(parentName);
|
|
374
|
+
if (state?.contains && state.contains.length > 0) {
|
|
375
|
+
for (const child of state.contains) {
|
|
376
|
+
if (child.isInitial)
|
|
377
|
+
return child.name;
|
|
378
|
+
}
|
|
379
|
+
return state.contains[0].name;
|
|
380
|
+
}
|
|
381
|
+
return parentName;
|
|
382
|
+
}
|
|
383
|
+
/** Search all states including nested and parallel region states */
|
|
384
|
+
findStateDefDeep(stateName) {
|
|
385
|
+
const search = (states) => {
|
|
386
|
+
for (const state of states) {
|
|
387
|
+
if (state.name === stateName)
|
|
388
|
+
return state;
|
|
389
|
+
if (state.contains && state.contains.length > 0) {
|
|
390
|
+
const found = search(state.contains);
|
|
391
|
+
if (found)
|
|
392
|
+
return found;
|
|
393
|
+
}
|
|
394
|
+
if (state.parallel) {
|
|
395
|
+
for (const region of state.parallel.regions) {
|
|
396
|
+
const found = search(region.states);
|
|
397
|
+
if (found)
|
|
398
|
+
return found;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
};
|
|
404
|
+
return search(this.definition.states);
|
|
405
|
+
}
|
|
406
|
+
/** Build the StateValue for entering a parallel state */
|
|
407
|
+
buildParallelStateValue(stateName) {
|
|
408
|
+
const state = this.findStateDefDeep(stateName);
|
|
409
|
+
if (!state?.parallel)
|
|
410
|
+
return { [stateName]: {} };
|
|
411
|
+
const regions = {};
|
|
412
|
+
for (const region of state.parallel.regions) {
|
|
413
|
+
const initialChild = region.states.find(s => s.isInitial) || region.states[0];
|
|
414
|
+
regions[region.name] = { [initialChild.name]: {} };
|
|
415
|
+
}
|
|
416
|
+
return { [stateName]: regions };
|
|
417
|
+
}
|
|
418
|
+
/** Check if all regions of a parallel state have reached their final states */
|
|
419
|
+
allRegionsFinal(stateName) {
|
|
420
|
+
const state = this.findStateDefDeep(stateName);
|
|
421
|
+
if (!state?.parallel)
|
|
422
|
+
return false;
|
|
423
|
+
const currentLeaves = this.state.leaves();
|
|
424
|
+
for (const region of state.parallel.regions) {
|
|
425
|
+
const finalStates = region.states.filter(s => s.isFinal).map(s => s.name);
|
|
426
|
+
const regionHasFinal = currentLeaves.some(leaf => finalStates.includes(leaf));
|
|
427
|
+
if (!regionHasFinal)
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
/** Check if any region of a parallel state has reached a final state */
|
|
433
|
+
anyRegionFinal(stateName) {
|
|
434
|
+
const state = this.findStateDefDeep(stateName);
|
|
435
|
+
if (!state?.parallel)
|
|
436
|
+
return false;
|
|
437
|
+
const currentLeaves = this.state.leaves();
|
|
438
|
+
for (const region of state.parallel.regions) {
|
|
439
|
+
const finalStates = region.states.filter(s => s.isFinal).map(s => s.name);
|
|
440
|
+
if (currentLeaves.some(leaf => finalStates.includes(leaf)))
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* When transitioning to a state inside a parallel region,
|
|
447
|
+
* update only that region's sub-state in the current StateValue.
|
|
448
|
+
*/
|
|
449
|
+
tryUpdateParallelRegion(targetStateName) {
|
|
450
|
+
if (typeof this.state.value !== "object")
|
|
451
|
+
return false;
|
|
452
|
+
// Find which parallel state/region contains the target
|
|
453
|
+
for (const topState of this.definition.states) {
|
|
454
|
+
if (!topState.parallel)
|
|
455
|
+
continue;
|
|
456
|
+
for (const region of topState.parallel.regions) {
|
|
457
|
+
const inRegion = region.states.some(s => s.name === targetStateName);
|
|
458
|
+
if (inRegion) {
|
|
459
|
+
// Update just this region in the compound state value
|
|
460
|
+
const stateObj = this.state.value;
|
|
461
|
+
if (stateObj[topState.name]) {
|
|
462
|
+
stateObj[topState.name][region.name] = { [targetStateName]: {} };
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
/** Check if any parallel state's sync condition is met and transition via onDone */
|
|
471
|
+
async checkParallelSync() {
|
|
472
|
+
for (const state of this.definition.states) {
|
|
473
|
+
if (!state.parallel || !state.onDone)
|
|
474
|
+
continue;
|
|
475
|
+
const sync = state.parallel.sync ?? 'all-final';
|
|
476
|
+
let shouldTransition = false;
|
|
477
|
+
if (sync === 'all-final') {
|
|
478
|
+
shouldTransition = this.allRegionsFinal(state.name);
|
|
479
|
+
}
|
|
480
|
+
else if (sync === 'any-final') {
|
|
481
|
+
shouldTransition = this.anyRegionFinal(state.name);
|
|
482
|
+
}
|
|
483
|
+
// 'custom' — no automatic transition
|
|
484
|
+
if (shouldTransition) {
|
|
485
|
+
const oldState = new StateValue(this.state.value);
|
|
486
|
+
this.cancelTimeout();
|
|
487
|
+
this.state = new StateValue(state.onDone);
|
|
488
|
+
await this.executeEntryActions(state.onDone);
|
|
489
|
+
this.startTimeoutForState(this.state.leaf());
|
|
490
|
+
if (this.onTransition) {
|
|
491
|
+
await this.onTransition(oldState, this.state);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async evaluateGuard(guardName) {
|
|
497
|
+
if (!(guardName in this.definition.guards)) {
|
|
498
|
+
return true; // Unknown guard = allow
|
|
499
|
+
}
|
|
500
|
+
const guardExpr = this.definition.guards[guardName];
|
|
501
|
+
return this.evalGuard(guardExpr);
|
|
502
|
+
}
|
|
503
|
+
async evalGuard(expr) {
|
|
504
|
+
switch (expr.kind) {
|
|
505
|
+
case "true":
|
|
506
|
+
return true;
|
|
507
|
+
case "false":
|
|
508
|
+
return false;
|
|
509
|
+
case "not":
|
|
510
|
+
return !(await this.evalGuard(expr.expr));
|
|
511
|
+
case "and":
|
|
512
|
+
return (await this.evalGuard(expr.left)) && (await this.evalGuard(expr.right));
|
|
513
|
+
case "or":
|
|
514
|
+
return (await this.evalGuard(expr.left)) || (await this.evalGuard(expr.right));
|
|
515
|
+
case "compare":
|
|
516
|
+
return this.evalCompare(expr.op, expr.left, expr.right);
|
|
517
|
+
case "nullcheck":
|
|
518
|
+
return this.evalNullcheck(expr.expr, expr.isNull);
|
|
519
|
+
default:
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
resolveVariable(ref) {
|
|
524
|
+
let current = this.context;
|
|
525
|
+
for (const part of ref.path) {
|
|
526
|
+
// Skip "ctx" or "context" prefix — the context is already the root
|
|
527
|
+
if (part === "ctx" || part === "context")
|
|
528
|
+
continue;
|
|
529
|
+
if (current === null || current === undefined)
|
|
530
|
+
return undefined;
|
|
531
|
+
current = current[part];
|
|
532
|
+
}
|
|
533
|
+
return current;
|
|
534
|
+
}
|
|
535
|
+
resolveValue(ref) {
|
|
536
|
+
return ref.value;
|
|
537
|
+
}
|
|
538
|
+
evalCompare(op, left, right) {
|
|
539
|
+
const lhs = this.resolveVariable(left);
|
|
540
|
+
const rhs = this.resolveValue(right);
|
|
541
|
+
// Coerce to number for numeric comparisons if both sides are numeric
|
|
542
|
+
const lNum = typeof lhs === "number" ? lhs : Number(lhs);
|
|
543
|
+
const rNum = typeof rhs === "number" ? rhs : Number(rhs);
|
|
544
|
+
const bothNumeric = !isNaN(lNum) && !isNaN(rNum);
|
|
545
|
+
switch (op) {
|
|
546
|
+
case "eq":
|
|
547
|
+
// eslint-disable-next-line eqeqeq
|
|
548
|
+
return lhs == rhs;
|
|
549
|
+
case "ne":
|
|
550
|
+
// eslint-disable-next-line eqeqeq
|
|
551
|
+
return lhs != rhs;
|
|
552
|
+
case "lt":
|
|
553
|
+
return bothNumeric ? lNum < rNum : String(lhs) < String(rhs);
|
|
554
|
+
case "gt":
|
|
555
|
+
return bothNumeric ? lNum > rNum : String(lhs) > String(rhs);
|
|
556
|
+
case "le":
|
|
557
|
+
return bothNumeric ? lNum <= rNum : String(lhs) <= String(rhs);
|
|
558
|
+
case "ge":
|
|
559
|
+
return bothNumeric ? lNum >= rNum : String(lhs) >= String(rhs);
|
|
560
|
+
default:
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
evalNullcheck(expr, isNull) {
|
|
565
|
+
const val = this.resolveVariable(expr);
|
|
566
|
+
const valueIsNull = val === null || val === undefined;
|
|
567
|
+
return isNull ? valueIsNull : !valueIsNull;
|
|
568
|
+
}
|
|
569
|
+
async executeEntryActions(stateName) {
|
|
570
|
+
const stateDef = this.findStateDef(stateName);
|
|
571
|
+
if (!stateDef) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
// Handle machine invocation
|
|
575
|
+
if (stateDef.invoke) {
|
|
576
|
+
await this.startChildMachine(stateName, stateDef.invoke);
|
|
577
|
+
return; // Invoke replaces entry action
|
|
578
|
+
}
|
|
579
|
+
if (!stateDef.onEntry) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const actionDef = this.findActionDef(stateDef.onEntry);
|
|
583
|
+
if (actionDef?.hasEffect) {
|
|
584
|
+
const effect = {
|
|
585
|
+
type: actionDef.effectType ?? "Effect",
|
|
586
|
+
payload: {
|
|
587
|
+
action: stateDef.onEntry,
|
|
588
|
+
context: this.context,
|
|
589
|
+
event: null,
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
await this.eventBus.publish({
|
|
593
|
+
type: "orca.effect.executing",
|
|
594
|
+
source: this.definition.name,
|
|
595
|
+
timestamp: new Date(),
|
|
596
|
+
payload: { effect: effect.type },
|
|
597
|
+
});
|
|
598
|
+
const result = await this.eventBus.executeEffect(effect);
|
|
599
|
+
if (result.status === EffectStatus.SUCCESS) {
|
|
600
|
+
await this.eventBus.publish({
|
|
601
|
+
type: "orca.effect.completed",
|
|
602
|
+
source: this.definition.name,
|
|
603
|
+
timestamp: new Date(),
|
|
604
|
+
payload: { effect: effect.type, result: result.data },
|
|
605
|
+
});
|
|
606
|
+
if (result.data && typeof result.data === "object") {
|
|
607
|
+
Object.assign(this.context, result.data);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
await this.eventBus.publish({
|
|
612
|
+
type: "orca.effect.failed",
|
|
613
|
+
source: this.definition.name,
|
|
614
|
+
timestamp: new Date(),
|
|
615
|
+
payload: { effect: effect.type, error: result.error },
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
else if (actionDef) {
|
|
620
|
+
await this.executeAction(actionDef.name);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
async executeExitActions(stateName) {
|
|
624
|
+
const stateDef = this.findStateDef(stateName);
|
|
625
|
+
if (!stateDef) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
// Stop any child machine associated with this state
|
|
629
|
+
if (stateDef.invoke) {
|
|
630
|
+
await this.stopChildMachine(stateName);
|
|
631
|
+
}
|
|
632
|
+
if (stateDef.onExit) {
|
|
633
|
+
await this.executeAction(stateDef.onExit);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async executeAction(actionName, eventPayload) {
|
|
637
|
+
const handler = this.actionHandlers.get(actionName);
|
|
638
|
+
if (!handler) {
|
|
639
|
+
return; // No handler registered — skip silently
|
|
640
|
+
}
|
|
641
|
+
const result = await handler(this.context, eventPayload);
|
|
642
|
+
if (result && typeof result === "object") {
|
|
643
|
+
Object.assign(this.context, result);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
startTimeoutForState(stateName) {
|
|
647
|
+
const stateDef = this.findStateDef(stateName);
|
|
648
|
+
if (!stateDef?.timeout) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const durationMs = parseInt(stateDef.timeout.duration, 10) * 1000;
|
|
652
|
+
const target = stateDef.timeout.target;
|
|
653
|
+
this.timeoutTimer = setTimeout(() => {
|
|
654
|
+
this.timeoutTimer = null;
|
|
655
|
+
// Fire a synthetic timeout transition
|
|
656
|
+
if (this.active && this.state.leaf() === stateName) {
|
|
657
|
+
this.executeTimeoutTransition(stateName, target);
|
|
658
|
+
}
|
|
659
|
+
}, durationMs);
|
|
660
|
+
}
|
|
661
|
+
cancelTimeout() {
|
|
662
|
+
if (this.timeoutTimer !== null) {
|
|
663
|
+
clearTimeout(this.timeoutTimer);
|
|
664
|
+
this.timeoutTimer = null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async executeTimeoutTransition(fromState, target) {
|
|
668
|
+
const oldState = new StateValue(this.state.value);
|
|
669
|
+
// Execute exit actions
|
|
670
|
+
await this.executeExitActions(fromState);
|
|
671
|
+
// Update state
|
|
672
|
+
if (this.isCompoundState(target)) {
|
|
673
|
+
const initialChild = this.getInitialChild(target);
|
|
674
|
+
this.state = new StateValue({ [target]: { [initialChild]: {} } });
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
this.state = new StateValue(target);
|
|
678
|
+
}
|
|
679
|
+
await this.eventBus.publish({
|
|
680
|
+
type: "orca.transition.started",
|
|
681
|
+
source: this.definition.name,
|
|
682
|
+
timestamp: new Date(),
|
|
683
|
+
payload: {
|
|
684
|
+
from: oldState.toString(),
|
|
685
|
+
to: target,
|
|
686
|
+
trigger: "timeout",
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
await this.executeEntryActions(target);
|
|
690
|
+
// Start timeout for the new state
|
|
691
|
+
this.startTimeoutForState(this.state.leaf());
|
|
692
|
+
if (this.onTransition) {
|
|
693
|
+
await this.onTransition(oldState, this.state);
|
|
694
|
+
}
|
|
695
|
+
await this.eventBus.publish({
|
|
696
|
+
type: "orca.transition.completed",
|
|
697
|
+
source: this.definition.name,
|
|
698
|
+
timestamp: new Date(),
|
|
699
|
+
payload: {
|
|
700
|
+
from: oldState.toString(),
|
|
701
|
+
to: this.state.toString(),
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Start a child machine when entering an invoke state.
|
|
707
|
+
*/
|
|
708
|
+
async startChildMachine(stateName, invokeDef) {
|
|
709
|
+
if (!this.siblingMachines) {
|
|
710
|
+
throw new Error(`Cannot invoke machine '${invokeDef.machine}': no sibling machines registered. Call registerMachines() first.`);
|
|
711
|
+
}
|
|
712
|
+
const childDef = this.siblingMachines.get(invokeDef.machine);
|
|
713
|
+
if (!childDef) {
|
|
714
|
+
throw new Error(`Cannot invoke machine '${invokeDef.machine}': machine not found in sibling machines.`);
|
|
715
|
+
}
|
|
716
|
+
// Map input from parent context
|
|
717
|
+
const childContext = {};
|
|
718
|
+
if (invokeDef.input) {
|
|
719
|
+
for (const [key, value] of Object.entries(invokeDef.input)) {
|
|
720
|
+
// value is like "ctx.order_id" - extract field name
|
|
721
|
+
const fieldName = value.replace(/^ctx\./, '');
|
|
722
|
+
childContext[key] = this.context[fieldName] ?? null;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// Create child machine with callback to handle completion
|
|
726
|
+
const parentMachine = this;
|
|
727
|
+
const childMachine = new OrcaMachine(childDef, this.eventBus, childContext,
|
|
728
|
+
// onTransition callback - parent handles when child completes
|
|
729
|
+
async (from, to) => {
|
|
730
|
+
// Check if child reached a final state
|
|
731
|
+
const childStateDef = childMachine.definition.states.find(s => s.name === to.toString());
|
|
732
|
+
if (childStateDef?.isFinal) {
|
|
733
|
+
// Child completed - emit onDone to self
|
|
734
|
+
if (invokeDef.onDone) {
|
|
735
|
+
await parentMachine.send(invokeDef.onDone, {
|
|
736
|
+
finalState: to.toString(),
|
|
737
|
+
context: childMachine.snapshot().context,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
this.childMachines.set(stateName, childMachine);
|
|
743
|
+
// Start the child machine
|
|
744
|
+
await childMachine.start();
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Stop a child machine when exiting an invoke state.
|
|
748
|
+
*/
|
|
749
|
+
async stopChildMachine(stateName) {
|
|
750
|
+
const child = this.childMachines.get(stateName);
|
|
751
|
+
if (child) {
|
|
752
|
+
await child.stop();
|
|
753
|
+
this.childMachines.delete(stateName);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
findStateDef(stateName) {
|
|
757
|
+
return this.findStateDefDeep(stateName);
|
|
758
|
+
}
|
|
759
|
+
isEventIgnored(eventName) {
|
|
760
|
+
const current = this.state.leaf();
|
|
761
|
+
const stateDef = this.findStateDef(current);
|
|
762
|
+
if (stateDef?.ignoredEvents.includes(eventName)) {
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
// Also check parent state's ignored events
|
|
766
|
+
let parent = this.getParentState(current);
|
|
767
|
+
while (parent) {
|
|
768
|
+
const parentDef = this.findStateDef(parent);
|
|
769
|
+
if (parentDef?.ignoredEvents.includes(eventName)) {
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
772
|
+
parent = this.getParentState(parent);
|
|
773
|
+
}
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
findActionDef(actionName) {
|
|
777
|
+
for (const action of this.definition.actions) {
|
|
778
|
+
if (action.name === actionName) {
|
|
779
|
+
return action;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
//# sourceMappingURL=machine.js.map
|