@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.
@@ -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