xcomponent-ai 0.1.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/CONTRIBUTING.md +195 -0
- package/LICENSE +45 -0
- package/PERSISTENCE.md +774 -0
- package/README.md +594 -0
- package/dist/agents.d.ts +81 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +405 -0
- package/dist/agents.js.map +1 -0
- package/dist/api.d.ts +36 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +404 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +437 -0
- package/dist/cli.js.map +1 -0
- package/dist/component-registry.d.ts +190 -0
- package/dist/component-registry.d.ts.map +1 -0
- package/dist/component-registry.js +382 -0
- package/dist/component-registry.js.map +1 -0
- package/dist/fsm-runtime.d.ts +263 -0
- package/dist/fsm-runtime.d.ts.map +1 -0
- package/dist/fsm-runtime.js +1122 -0
- package/dist/fsm-runtime.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/monitoring.d.ts +68 -0
- package/dist/monitoring.d.ts.map +1 -0
- package/dist/monitoring.js +176 -0
- package/dist/monitoring.js.map +1 -0
- package/dist/persistence.d.ts +100 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +270 -0
- package/dist/persistence.js.map +1 -0
- package/dist/timer-wheel.d.ts +85 -0
- package/dist/timer-wheel.d.ts.map +1 -0
- package/dist/timer-wheel.js +181 -0
- package/dist/timer-wheel.js.map +1 -0
- package/dist/types.d.ts +404 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +40 -0
- package/dist/types.js.map +1 -0
- package/dist/websockets.d.ts +32 -0
- package/dist/websockets.d.ts.map +1 -0
- package/dist/websockets.js +117 -0
- package/dist/websockets.js.map +1 -0
- package/package.json +103 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* FSM Runtime Engine
|
|
4
|
+
* Implements XComponent-inspired state machine execution with multi-instance support
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.FSMRuntime = void 0;
|
|
8
|
+
exports.loadComponent = loadComponent;
|
|
9
|
+
const events_1 = require("events");
|
|
10
|
+
const uuid_1 = require("uuid");
|
|
11
|
+
const types_1 = require("./types");
|
|
12
|
+
const timer_wheel_1 = require("./timer-wheel");
|
|
13
|
+
const persistence_1 = require("./persistence");
|
|
14
|
+
/**
|
|
15
|
+
* Sender implementation for triggered methods
|
|
16
|
+
* Provides controlled access to runtime operations
|
|
17
|
+
* Supports both intra-component and cross-component communication
|
|
18
|
+
*/
|
|
19
|
+
class SenderImpl {
|
|
20
|
+
runtime;
|
|
21
|
+
registry;
|
|
22
|
+
constructor(runtime, registry) {
|
|
23
|
+
this.runtime = runtime;
|
|
24
|
+
this.registry = registry;
|
|
25
|
+
}
|
|
26
|
+
async sendTo(instanceId, event) {
|
|
27
|
+
return this.runtime.sendEvent(instanceId, event);
|
|
28
|
+
}
|
|
29
|
+
async sendToComponent(componentName, instanceId, event) {
|
|
30
|
+
if (!this.registry) {
|
|
31
|
+
throw new Error('Cross-component communication requires ComponentRegistry');
|
|
32
|
+
}
|
|
33
|
+
return this.registry.sendEventToComponent(componentName, instanceId, event);
|
|
34
|
+
}
|
|
35
|
+
async broadcast(machineName, currentState, event) {
|
|
36
|
+
return this.runtime.broadcastEvent(machineName, currentState, event);
|
|
37
|
+
}
|
|
38
|
+
async broadcastToComponent(componentName, machineName, currentState, event) {
|
|
39
|
+
if (!this.registry) {
|
|
40
|
+
throw new Error('Cross-component communication requires ComponentRegistry');
|
|
41
|
+
}
|
|
42
|
+
return this.registry.broadcastToComponent(componentName, machineName, currentState, event);
|
|
43
|
+
}
|
|
44
|
+
createInstance(machineName, initialContext) {
|
|
45
|
+
return this.runtime.createInstance(machineName, initialContext);
|
|
46
|
+
}
|
|
47
|
+
createInstanceInComponent(componentName, machineName, initialContext) {
|
|
48
|
+
if (!this.registry) {
|
|
49
|
+
throw new Error('Cross-component communication requires ComponentRegistry');
|
|
50
|
+
}
|
|
51
|
+
return this.registry.createInstanceInComponent(componentName, machineName, initialContext);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* FSM Runtime Engine
|
|
56
|
+
* Manages multiple FSM instances with event-driven execution
|
|
57
|
+
*/
|
|
58
|
+
class FSMRuntime extends events_1.EventEmitter {
|
|
59
|
+
instances;
|
|
60
|
+
machines;
|
|
61
|
+
timerWheel; // Performance: Single timer for all timeouts
|
|
62
|
+
timeoutTasks; // instanceId → taskIds (for cleanup)
|
|
63
|
+
persistence;
|
|
64
|
+
componentDef;
|
|
65
|
+
registry; // For cross-component communication
|
|
66
|
+
// Performance: Hash-based indexes for efficient property matching (XComponent pattern)
|
|
67
|
+
machineIndex; // machineName → Set<instanceId>
|
|
68
|
+
stateIndex; // "machineName:state" → Set<instanceId>
|
|
69
|
+
propertyIndex; // "machineName:propName:propValue" → Set<instanceId>
|
|
70
|
+
constructor(component, persistenceConfig) {
|
|
71
|
+
super();
|
|
72
|
+
this.instances = new Map();
|
|
73
|
+
this.machines = new Map();
|
|
74
|
+
// Timer wheel: 10ms ticks for high precision, 6000 buckets = 60s max
|
|
75
|
+
// Still O(1) with single timer - 10ms granularity is sufficient for most use cases
|
|
76
|
+
// For longer timeouts (>60s), tasks will wrap around (multi-lap)
|
|
77
|
+
this.timerWheel = new timer_wheel_1.TimerWheel(10, 6000);
|
|
78
|
+
this.timeoutTasks = new Map(); // Track tasks for cleanup
|
|
79
|
+
this.componentDef = component;
|
|
80
|
+
// Initialize indexes
|
|
81
|
+
this.machineIndex = new Map();
|
|
82
|
+
this.stateIndex = new Map();
|
|
83
|
+
this.propertyIndex = new Map();
|
|
84
|
+
// Start timer wheel
|
|
85
|
+
this.timerWheel.start();
|
|
86
|
+
// Setup persistence (optional)
|
|
87
|
+
if (persistenceConfig && (persistenceConfig.eventSourcing || persistenceConfig.snapshots)) {
|
|
88
|
+
const eventStore = persistenceConfig.eventStore || new persistence_1.InMemoryEventStore();
|
|
89
|
+
const snapshotStore = persistenceConfig.snapshotStore || new persistence_1.InMemorySnapshotStore();
|
|
90
|
+
this.persistence = new persistence_1.PersistenceManager(eventStore, snapshotStore, {
|
|
91
|
+
eventSourcing: persistenceConfig.eventSourcing,
|
|
92
|
+
snapshots: persistenceConfig.snapshots,
|
|
93
|
+
snapshotInterval: persistenceConfig.snapshotInterval,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
this.persistence = null;
|
|
98
|
+
}
|
|
99
|
+
// Index machines by name
|
|
100
|
+
component.stateMachines.forEach(machine => {
|
|
101
|
+
this.machines.set(machine.name, machine);
|
|
102
|
+
});
|
|
103
|
+
// Setup cascading rules engine (XComponent pattern)
|
|
104
|
+
this.setupCascadingEngine();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Create a new FSM instance
|
|
108
|
+
*
|
|
109
|
+
* If the state machine defines a publicMemberType (XComponent pattern),
|
|
110
|
+
* the instance will have separate publicMember and internalMember.
|
|
111
|
+
* Otherwise, it uses the simple context pattern.
|
|
112
|
+
*
|
|
113
|
+
* @param machineName State machine name
|
|
114
|
+
* @param initialContext Initial context or public member data
|
|
115
|
+
* @returns Instance ID
|
|
116
|
+
*/
|
|
117
|
+
createInstance(machineName, initialContext = {}) {
|
|
118
|
+
const machine = this.machines.get(machineName);
|
|
119
|
+
if (!machine) {
|
|
120
|
+
throw new Error(`Machine ${machineName} not found`);
|
|
121
|
+
}
|
|
122
|
+
const instanceId = (0, uuid_1.v4)();
|
|
123
|
+
// XComponent pattern: separate publicMember and internalMember
|
|
124
|
+
const instance = {
|
|
125
|
+
id: instanceId,
|
|
126
|
+
machineName,
|
|
127
|
+
currentState: machine.initialState,
|
|
128
|
+
context: machine.publicMemberType ? {} : initialContext,
|
|
129
|
+
publicMember: machine.publicMemberType ? initialContext : undefined,
|
|
130
|
+
internalMember: machine.publicMemberType ? {} : undefined,
|
|
131
|
+
createdAt: Date.now(),
|
|
132
|
+
updatedAt: Date.now(),
|
|
133
|
+
status: 'active',
|
|
134
|
+
};
|
|
135
|
+
this.instances.set(instanceId, instance);
|
|
136
|
+
// Add to indexes for fast lookups
|
|
137
|
+
this.addToIndex(instance);
|
|
138
|
+
this.emit('instance_created', instance);
|
|
139
|
+
// Setup timeout transitions if any from initial state
|
|
140
|
+
this.setupTimeouts(instanceId, machine.initialState);
|
|
141
|
+
// Setup auto-transitions if any from initial state
|
|
142
|
+
this.setupAutoTransitions(instanceId, machine.initialState);
|
|
143
|
+
return instanceId;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Send event to an instance
|
|
147
|
+
*/
|
|
148
|
+
async sendEvent(instanceId, event) {
|
|
149
|
+
const instance = this.instances.get(instanceId);
|
|
150
|
+
if (!instance) {
|
|
151
|
+
throw new Error(`Instance ${instanceId} not found`);
|
|
152
|
+
}
|
|
153
|
+
if (instance.status !== 'active') {
|
|
154
|
+
throw new Error(`Instance ${instanceId} is not active`);
|
|
155
|
+
}
|
|
156
|
+
const machine = this.machines.get(instance.machineName);
|
|
157
|
+
if (!machine) {
|
|
158
|
+
throw new Error(`Machine ${instance.machineName} not found`);
|
|
159
|
+
}
|
|
160
|
+
// Use publicMember if available (XComponent pattern), otherwise fallback to context
|
|
161
|
+
const instanceContext = instance.publicMember || instance.context;
|
|
162
|
+
// Find applicable transition
|
|
163
|
+
const transition = this.findTransition(machine, instance.currentState, event, instanceContext);
|
|
164
|
+
if (!transition) {
|
|
165
|
+
this.emit('event_ignored', { instanceId, event, currentState: instance.currentState });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Check guards
|
|
169
|
+
if (transition.guards && !this.evaluateGuards(transition.guards, event, instanceContext)) {
|
|
170
|
+
this.emit('guard_failed', { instanceId, event, transition });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const previousState = instance.currentState;
|
|
174
|
+
try {
|
|
175
|
+
// Execute transition
|
|
176
|
+
await this.executeTransition(instance, transition, event);
|
|
177
|
+
// Update instance
|
|
178
|
+
instance.currentState = transition.to;
|
|
179
|
+
instance.updatedAt = Date.now();
|
|
180
|
+
// Update indexes
|
|
181
|
+
this.updateIndexOnStateChange(instance, previousState, transition.to);
|
|
182
|
+
// Persist event (event sourcing)
|
|
183
|
+
let eventId = '';
|
|
184
|
+
if (this.persistence) {
|
|
185
|
+
eventId = await this.persistence.persistEvent(instanceId, instance.machineName, this.componentDef.name, event, previousState, transition.to);
|
|
186
|
+
// Set as current event for causality tracking
|
|
187
|
+
this.persistence.setCurrentEventId(eventId);
|
|
188
|
+
}
|
|
189
|
+
// Clear old timeouts
|
|
190
|
+
this.clearTimeouts(instanceId);
|
|
191
|
+
// Emit state change
|
|
192
|
+
this.emit('state_change', {
|
|
193
|
+
instanceId,
|
|
194
|
+
previousState,
|
|
195
|
+
newState: transition.to,
|
|
196
|
+
event,
|
|
197
|
+
eventId,
|
|
198
|
+
timestamp: Date.now(),
|
|
199
|
+
});
|
|
200
|
+
// Save snapshot if needed
|
|
201
|
+
// Note: With timer wheel, we don't pass pending timeouts map
|
|
202
|
+
// Timeouts are resynchronized during restore() based on elapsed time
|
|
203
|
+
if (this.persistence) {
|
|
204
|
+
await this.persistence.maybeSnapshot(instance, eventId, undefined);
|
|
205
|
+
}
|
|
206
|
+
// Check if final or error state
|
|
207
|
+
const targetState = machine.states.find(s => s.name === transition.to);
|
|
208
|
+
if (targetState && (targetState.type === types_1.StateType.FINAL || targetState.type === types_1.StateType.ERROR)) {
|
|
209
|
+
instance.status = targetState.type === types_1.StateType.FINAL ? 'completed' : 'error';
|
|
210
|
+
// Remove from indexes before disposing
|
|
211
|
+
this.removeFromIndex(instance);
|
|
212
|
+
this.emit('instance_disposed', instance);
|
|
213
|
+
this.instances.delete(instanceId);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Setup new timeouts
|
|
217
|
+
this.setupTimeouts(instanceId, transition.to);
|
|
218
|
+
// Setup auto-transitions
|
|
219
|
+
this.setupAutoTransitions(instanceId, transition.to);
|
|
220
|
+
// Handle inter-machine transitions
|
|
221
|
+
if (transition.type === types_1.TransitionType.INTER_MACHINE && transition.targetMachine) {
|
|
222
|
+
const newInstanceId = this.createInstance(transition.targetMachine, { ...instance.context });
|
|
223
|
+
this.emit('inter_machine_transition', {
|
|
224
|
+
sourceInstanceId: instanceId,
|
|
225
|
+
targetInstanceId: newInstanceId,
|
|
226
|
+
targetMachine: transition.targetMachine,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
instance.status = 'error';
|
|
232
|
+
// Remove from indexes before deleting
|
|
233
|
+
this.removeFromIndex(instance);
|
|
234
|
+
this.emit('instance_error', { instanceId, error: error.message });
|
|
235
|
+
this.instances.delete(instanceId);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Broadcast event to all matching instances (XComponent-style property matching)
|
|
240
|
+
*
|
|
241
|
+
* This method implements XComponent's property-based instance routing:
|
|
242
|
+
* - Finds all instances of the target machine in the specified state
|
|
243
|
+
* - Evaluates matching rules (property equality checks)
|
|
244
|
+
* - Routes event to ALL instances where matching rules pass
|
|
245
|
+
*
|
|
246
|
+
* Example:
|
|
247
|
+
* // 100 Order instances exist
|
|
248
|
+
* // Event: ExecutionInput { OrderId: 42, Quantity: 500 }
|
|
249
|
+
* // Matching rule: ExecutionInput.OrderId = Order.Id
|
|
250
|
+
* // → Automatically routes to Order instance with Id=42
|
|
251
|
+
*
|
|
252
|
+
* @param machineName Target state machine name
|
|
253
|
+
* @param currentState Current state filter (only instances in this state)
|
|
254
|
+
* @param event Event to broadcast
|
|
255
|
+
* @returns Number of instances that received the event
|
|
256
|
+
*/
|
|
257
|
+
async broadcastEvent(machineName, currentState, event) {
|
|
258
|
+
const machine = this.machines.get(machineName);
|
|
259
|
+
if (!machine) {
|
|
260
|
+
throw new Error(`Machine ${machineName} not found`);
|
|
261
|
+
}
|
|
262
|
+
// Find ALL transitions with matching rules for this state/event combination
|
|
263
|
+
const transitionsWithMatchingRules = machine.transitions.filter(t => t.from === currentState && t.event === event.type && t.matchingRules && t.matchingRules.length > 0);
|
|
264
|
+
if (transitionsWithMatchingRules.length === 0) {
|
|
265
|
+
throw new Error(`No transition with matching rules found for ${machineName}.${currentState} on event ${event.type}`);
|
|
266
|
+
}
|
|
267
|
+
let processedCount = 0;
|
|
268
|
+
const processedInstances = new Set();
|
|
269
|
+
// For each transition with matching rules, find and process matching instances
|
|
270
|
+
for (const transition of transitionsWithMatchingRules) {
|
|
271
|
+
// Find all instances that match this transition's rules
|
|
272
|
+
const matchingInstances = this.findMatchingInstances(machineName, currentState, event, transition.matchingRules);
|
|
273
|
+
// Send event to each matching instance (only once per instance)
|
|
274
|
+
for (const instance of matchingInstances) {
|
|
275
|
+
if (processedInstances.has(instance.id)) {
|
|
276
|
+
continue; // Already processed by another transition
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const stateBefore = instance.currentState;
|
|
280
|
+
await this.sendEvent(instance.id, event);
|
|
281
|
+
// Check if state actually changed (or instance was disposed)
|
|
282
|
+
const instanceAfter = this.instances.get(instance.id);
|
|
283
|
+
const transitioned = !instanceAfter || instanceAfter.currentState !== stateBefore;
|
|
284
|
+
if (transitioned) {
|
|
285
|
+
processedInstances.add(instance.id);
|
|
286
|
+
processedCount++;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
this.emit('broadcast_error', {
|
|
291
|
+
instanceId: instance.id,
|
|
292
|
+
event,
|
|
293
|
+
error: error.message,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
this.emit('broadcast_completed', {
|
|
299
|
+
machineName,
|
|
300
|
+
currentState,
|
|
301
|
+
event,
|
|
302
|
+
matchedCount: processedInstances.size,
|
|
303
|
+
processedCount,
|
|
304
|
+
});
|
|
305
|
+
return processedCount;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Find instances that match the property matching rules
|
|
309
|
+
*
|
|
310
|
+
* @param machineName Target machine name
|
|
311
|
+
* @param currentState Current state filter
|
|
312
|
+
* @param event Event to match against
|
|
313
|
+
* @param matchingRules Property matching rules
|
|
314
|
+
* @returns Array of matching instances
|
|
315
|
+
*/
|
|
316
|
+
findMatchingInstances(machineName, currentState, event, matchingRules) {
|
|
317
|
+
// Performance optimization: Use hash-based index for O(1) lookup
|
|
318
|
+
// Instead of iterating all instances, use state index
|
|
319
|
+
// Try to use property index for direct lookup (fastest path)
|
|
320
|
+
// If we have a single matching rule with === operator, we can use the property index
|
|
321
|
+
if (matchingRules.length === 1 && (!matchingRules[0].operator || matchingRules[0].operator === '===')) {
|
|
322
|
+
const rule = matchingRules[0];
|
|
323
|
+
const eventValue = this.getNestedProperty(event.payload, rule.eventProperty);
|
|
324
|
+
if (eventValue !== undefined) {
|
|
325
|
+
// Check if property is top-level (no dots) for direct index lookup
|
|
326
|
+
if (!rule.instanceProperty.includes('.')) {
|
|
327
|
+
const propKey = `${machineName}:${rule.instanceProperty}:${String(eventValue)}`;
|
|
328
|
+
const candidateIds = this.propertyIndex.get(propKey);
|
|
329
|
+
if (candidateIds) {
|
|
330
|
+
// Filter by state and status
|
|
331
|
+
return Array.from(candidateIds)
|
|
332
|
+
.map(id => this.instances.get(id))
|
|
333
|
+
.filter(instance => instance &&
|
|
334
|
+
instance.currentState === currentState &&
|
|
335
|
+
instance.status === 'active');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Fallback: Use state index (still faster than iterating all instances)
|
|
341
|
+
const stateKey = `${machineName}:${currentState}`;
|
|
342
|
+
const candidateIds = this.stateIndex.get(stateKey);
|
|
343
|
+
if (!candidateIds || candidateIds.size === 0) {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
// Get instances and filter by matching rules
|
|
347
|
+
const candidates = Array.from(candidateIds)
|
|
348
|
+
.map(id => this.instances.get(id))
|
|
349
|
+
.filter(instance => instance && instance.status === 'active');
|
|
350
|
+
// Filter by matching rules
|
|
351
|
+
return candidates.filter(instance => {
|
|
352
|
+
return this.evaluateMatchingRules(instance, event, matchingRules);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Evaluate matching rules for an instance
|
|
357
|
+
*
|
|
358
|
+
* @param instance FSM instance to check
|
|
359
|
+
* @param event Event to match against
|
|
360
|
+
* @param matchingRules Matching rules to evaluate
|
|
361
|
+
* @returns true if all matching rules pass
|
|
362
|
+
*/
|
|
363
|
+
evaluateMatchingRules(instance, event, matchingRules) {
|
|
364
|
+
return matchingRules.every(rule => {
|
|
365
|
+
const eventValue = this.getNestedProperty(event.payload, rule.eventProperty);
|
|
366
|
+
// Use publicMember if available (XComponent pattern), otherwise fallback to context
|
|
367
|
+
const instanceData = instance.publicMember || instance.context;
|
|
368
|
+
const instanceValue = this.getNestedProperty(instanceData, rule.instanceProperty);
|
|
369
|
+
// Handle undefined values
|
|
370
|
+
if (eventValue === undefined || instanceValue === undefined) {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
// Apply operator (default to strict equality)
|
|
374
|
+
// Semantics: instanceValue operator eventValue
|
|
375
|
+
// Example: balance > threshold means instanceValue (balance) > eventValue (threshold)
|
|
376
|
+
const operator = rule.operator || '===';
|
|
377
|
+
switch (operator) {
|
|
378
|
+
case '===':
|
|
379
|
+
return instanceValue === eventValue;
|
|
380
|
+
case '!==':
|
|
381
|
+
return instanceValue !== eventValue;
|
|
382
|
+
case '>':
|
|
383
|
+
return instanceValue > eventValue;
|
|
384
|
+
case '<':
|
|
385
|
+
return instanceValue < eventValue;
|
|
386
|
+
case '>=':
|
|
387
|
+
return instanceValue >= eventValue;
|
|
388
|
+
case '<=':
|
|
389
|
+
return instanceValue <= eventValue;
|
|
390
|
+
default:
|
|
391
|
+
return instanceValue === eventValue;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Get nested property value from object using dot notation
|
|
397
|
+
*
|
|
398
|
+
* Example: getNestedProperty({ customer: { id: 42 } }, "customer.id") → 42
|
|
399
|
+
*
|
|
400
|
+
* @param obj Object to get property from
|
|
401
|
+
* @param path Property path (dot notation)
|
|
402
|
+
* @returns Property value or undefined
|
|
403
|
+
*/
|
|
404
|
+
getNestedProperty(obj, path) {
|
|
405
|
+
return path.split('.').reduce((current, prop) => current?.[prop], obj);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Find applicable transition with support for specific triggering rules
|
|
409
|
+
*
|
|
410
|
+
* When multiple transitions exist from the same state with the same event,
|
|
411
|
+
* specific triggering rules differentiate them (XComponent pattern).
|
|
412
|
+
*
|
|
413
|
+
* @param machine State machine
|
|
414
|
+
* @param currentState Current state name
|
|
415
|
+
* @param event Event to match
|
|
416
|
+
* @param instanceContext Instance context for specific triggering rule evaluation
|
|
417
|
+
* @returns Matching transition or null
|
|
418
|
+
*/
|
|
419
|
+
findTransition(machine, currentState, event, instanceContext) {
|
|
420
|
+
// Find all candidate transitions
|
|
421
|
+
const candidates = machine.transitions.filter(t => t.from === currentState && t.event === event.type);
|
|
422
|
+
if (candidates.length === 0) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
// Single candidate - return it
|
|
426
|
+
if (candidates.length === 1) {
|
|
427
|
+
return candidates[0];
|
|
428
|
+
}
|
|
429
|
+
// Multiple candidates - try specific triggering rules first
|
|
430
|
+
for (const transition of candidates) {
|
|
431
|
+
if (transition.specificTriggeringRule) {
|
|
432
|
+
try {
|
|
433
|
+
const func = new Function('event', 'context', `return ${transition.specificTriggeringRule}`);
|
|
434
|
+
if (func(event, instanceContext)) {
|
|
435
|
+
return transition;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// Rule evaluation failed, skip this transition
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// If no specific triggering rules matched, try matching rules
|
|
445
|
+
// This handles cases where multiple transitions differentiate by matching rules (e.g., different operators)
|
|
446
|
+
for (const transition of candidates) {
|
|
447
|
+
if (transition.matchingRules && transition.matchingRules.length > 0) {
|
|
448
|
+
// Create a mock instance to evaluate matching rules
|
|
449
|
+
const mockInstance = {
|
|
450
|
+
id: 'temp',
|
|
451
|
+
machineName: machine.name,
|
|
452
|
+
currentState,
|
|
453
|
+
context: instanceContext,
|
|
454
|
+
publicMember: instanceContext,
|
|
455
|
+
createdAt: Date.now(),
|
|
456
|
+
updatedAt: Date.now(),
|
|
457
|
+
status: 'active',
|
|
458
|
+
};
|
|
459
|
+
if (this.evaluateMatchingRules(mockInstance, event, transition.matchingRules)) {
|
|
460
|
+
return transition;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// No rules matched - return first candidate (backward compatibility)
|
|
465
|
+
return candidates[0];
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Evaluate guards
|
|
469
|
+
*/
|
|
470
|
+
evaluateGuards(guards, event, context) {
|
|
471
|
+
return guards.every(guard => {
|
|
472
|
+
// Key matching
|
|
473
|
+
if (guard.keys) {
|
|
474
|
+
return guard.keys.every(key => event.payload[key] !== undefined);
|
|
475
|
+
}
|
|
476
|
+
// Contains check
|
|
477
|
+
if (guard.contains) {
|
|
478
|
+
return JSON.stringify(event.payload).includes(guard.contains);
|
|
479
|
+
}
|
|
480
|
+
// Custom function (evaluate as string - in production, use sandboxed eval)
|
|
481
|
+
if (guard.customFunction) {
|
|
482
|
+
try {
|
|
483
|
+
const func = new Function('event', 'context', `return ${guard.customFunction}`);
|
|
484
|
+
return func(event, context);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return true;
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Execute transition
|
|
495
|
+
*
|
|
496
|
+
* Creates a Sender instance and passes it to triggered methods,
|
|
497
|
+
* enabling cross-instance communication (XComponent pattern)
|
|
498
|
+
*/
|
|
499
|
+
async executeTransition(instance, transition, event) {
|
|
500
|
+
// In production, execute triggered methods here
|
|
501
|
+
if (transition.triggeredMethod) {
|
|
502
|
+
const sender = new SenderImpl(this, this.registry);
|
|
503
|
+
const instanceContext = instance.publicMember || instance.context;
|
|
504
|
+
this.emit('triggered_method', {
|
|
505
|
+
instanceId: instance.id,
|
|
506
|
+
method: transition.triggeredMethod,
|
|
507
|
+
event,
|
|
508
|
+
context: instanceContext,
|
|
509
|
+
sender,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Setup timeout transitions
|
|
515
|
+
*/
|
|
516
|
+
/**
|
|
517
|
+
* Setup timeout transitions using timer wheel (performance optimized)
|
|
518
|
+
*
|
|
519
|
+
* Instead of creating one setTimeout per instance (O(n) timers),
|
|
520
|
+
* use a single timer wheel that manages all timeouts (O(1) timer).
|
|
521
|
+
*/
|
|
522
|
+
setupTimeouts(instanceId, stateName) {
|
|
523
|
+
const instance = this.instances.get(instanceId);
|
|
524
|
+
if (!instance)
|
|
525
|
+
return;
|
|
526
|
+
const machine = this.machines.get(instance.machineName);
|
|
527
|
+
if (!machine)
|
|
528
|
+
return;
|
|
529
|
+
const timeoutTransitions = machine.transitions.filter(t => t.from === stateName && t.type === types_1.TransitionType.TIMEOUT);
|
|
530
|
+
timeoutTransitions.forEach(transition => {
|
|
531
|
+
if (transition.timeoutMs) {
|
|
532
|
+
const taskId = `${instanceId}-${stateName}-${transition.event}`;
|
|
533
|
+
// Track task for cleanup
|
|
534
|
+
if (!this.timeoutTasks.has(instanceId)) {
|
|
535
|
+
this.timeoutTasks.set(instanceId, []);
|
|
536
|
+
}
|
|
537
|
+
this.timeoutTasks.get(instanceId).push(taskId);
|
|
538
|
+
// Use timer wheel instead of setTimeout
|
|
539
|
+
this.timerWheel.addTimeout(taskId, transition.timeoutMs, () => {
|
|
540
|
+
// Check if instance still exists and is in the same state
|
|
541
|
+
const currentInstance = this.instances.get(instanceId);
|
|
542
|
+
if (currentInstance && currentInstance.currentState === stateName) {
|
|
543
|
+
this.sendEvent(instanceId, {
|
|
544
|
+
type: transition.event,
|
|
545
|
+
payload: { reason: 'timeout' },
|
|
546
|
+
timestamp: Date.now(),
|
|
547
|
+
}).catch(error => {
|
|
548
|
+
console.error(`Timeout event failed for ${instanceId}:`, error);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
// Remove taskId from tracking
|
|
552
|
+
const tasks = this.timeoutTasks.get(instanceId);
|
|
553
|
+
if (tasks) {
|
|
554
|
+
const index = tasks.indexOf(taskId);
|
|
555
|
+
if (index >= 0)
|
|
556
|
+
tasks.splice(index, 1);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Setup auto-transitions (XComponent-style automatic transitions)
|
|
564
|
+
*
|
|
565
|
+
* Auto-transitions are triggered automatically when entering a state,
|
|
566
|
+
* typically with timeoutMs: 0 for immediate execution.
|
|
567
|
+
*
|
|
568
|
+
* Example:
|
|
569
|
+
* - from: Validated
|
|
570
|
+
* to: Processing
|
|
571
|
+
* event: AUTO_PROCESS
|
|
572
|
+
* type: auto
|
|
573
|
+
* timeoutMs: 0
|
|
574
|
+
*/
|
|
575
|
+
setupAutoTransitions(instanceId, stateName) {
|
|
576
|
+
const instance = this.instances.get(instanceId);
|
|
577
|
+
if (!instance)
|
|
578
|
+
return;
|
|
579
|
+
const machine = this.machines.get(instance.machineName);
|
|
580
|
+
if (!machine)
|
|
581
|
+
return;
|
|
582
|
+
const autoTransitions = machine.transitions.filter(t => t.from === stateName && t.type === types_1.TransitionType.AUTO);
|
|
583
|
+
autoTransitions.forEach(transition => {
|
|
584
|
+
// Use publicMember if available (XComponent pattern), otherwise fallback to context
|
|
585
|
+
const instanceContext = instance.publicMember || instance.context;
|
|
586
|
+
// Check guards before scheduling auto-transition
|
|
587
|
+
if (transition.guards && !this.evaluateGuards(transition.guards, { type: transition.event, payload: {}, timestamp: Date.now() }, instanceContext)) {
|
|
588
|
+
return; // Guard failed, skip this auto-transition
|
|
589
|
+
}
|
|
590
|
+
const delay = transition.timeoutMs || 0; // Default to immediate (0ms)
|
|
591
|
+
const taskId = `${instanceId}-${stateName}-auto-${transition.event}`;
|
|
592
|
+
// Track task for cleanup
|
|
593
|
+
if (!this.timeoutTasks.has(instanceId)) {
|
|
594
|
+
this.timeoutTasks.set(instanceId, []);
|
|
595
|
+
}
|
|
596
|
+
this.timeoutTasks.get(instanceId).push(taskId);
|
|
597
|
+
// Use timer wheel for auto-transitions
|
|
598
|
+
this.timerWheel.addTimeout(taskId, delay, () => {
|
|
599
|
+
// Check if instance still exists and is in the same state
|
|
600
|
+
const currentInstance = this.instances.get(instanceId);
|
|
601
|
+
if (currentInstance && currentInstance.currentState === stateName) {
|
|
602
|
+
this.sendEvent(instanceId, {
|
|
603
|
+
type: transition.event,
|
|
604
|
+
payload: { reason: 'auto-transition' },
|
|
605
|
+
timestamp: Date.now(),
|
|
606
|
+
}).catch(error => {
|
|
607
|
+
console.error(`Auto-transition failed for ${instanceId}:`, error);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
// Remove taskId from tracking
|
|
611
|
+
const tasks = this.timeoutTasks.get(instanceId);
|
|
612
|
+
if (tasks) {
|
|
613
|
+
const index = tasks.indexOf(taskId);
|
|
614
|
+
if (index >= 0)
|
|
615
|
+
tasks.splice(index, 1);
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Setup cascading rules engine (XComponent pattern)
|
|
622
|
+
*
|
|
623
|
+
* Listens to state_change events and automatically triggers cross-machine updates
|
|
624
|
+
* based on cascading rules defined in state definitions.
|
|
625
|
+
*/
|
|
626
|
+
setupCascadingEngine() {
|
|
627
|
+
this.on('state_change', async (data) => {
|
|
628
|
+
const { instanceId, newState } = data;
|
|
629
|
+
const instance = this.instances.get(instanceId);
|
|
630
|
+
if (!instance)
|
|
631
|
+
return;
|
|
632
|
+
const machine = this.machines.get(instance.machineName);
|
|
633
|
+
if (!machine)
|
|
634
|
+
return;
|
|
635
|
+
// Find the state definition
|
|
636
|
+
const state = machine.states.find(s => s.name === newState);
|
|
637
|
+
if (!state || !state.cascadingRules || state.cascadingRules.length === 0) {
|
|
638
|
+
return; // No cascading rules for this state
|
|
639
|
+
}
|
|
640
|
+
// Process each cascading rule
|
|
641
|
+
for (const rule of state.cascadingRules) {
|
|
642
|
+
try {
|
|
643
|
+
await this.processCascadingRule(instance, rule);
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
this.emit('cascade_error', {
|
|
647
|
+
sourceInstanceId: instanceId,
|
|
648
|
+
rule,
|
|
649
|
+
error: error.message,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Add instance to indexes (for O(1) lookup)
|
|
657
|
+
* Performance optimization: XComponent hash-based matching
|
|
658
|
+
*/
|
|
659
|
+
addToIndex(instance) {
|
|
660
|
+
const { id, machineName, currentState, publicMember, context } = instance;
|
|
661
|
+
// Machine index
|
|
662
|
+
if (!this.machineIndex.has(machineName)) {
|
|
663
|
+
this.machineIndex.set(machineName, new Set());
|
|
664
|
+
}
|
|
665
|
+
this.machineIndex.get(machineName).add(id);
|
|
666
|
+
// State index
|
|
667
|
+
const stateKey = `${machineName}:${currentState}`;
|
|
668
|
+
if (!this.stateIndex.has(stateKey)) {
|
|
669
|
+
this.stateIndex.set(stateKey, new Set());
|
|
670
|
+
}
|
|
671
|
+
this.stateIndex.get(stateKey).add(id);
|
|
672
|
+
// Property index (for commonly matched properties)
|
|
673
|
+
const instanceData = publicMember || context;
|
|
674
|
+
if (instanceData) {
|
|
675
|
+
// Index all top-level properties for fast matching
|
|
676
|
+
for (const [propName, propValue] of Object.entries(instanceData)) {
|
|
677
|
+
if (propValue !== null && propValue !== undefined) {
|
|
678
|
+
const propKey = `${machineName}:${propName}:${String(propValue)}`;
|
|
679
|
+
if (!this.propertyIndex.has(propKey)) {
|
|
680
|
+
this.propertyIndex.set(propKey, new Set());
|
|
681
|
+
}
|
|
682
|
+
this.propertyIndex.get(propKey).add(id);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Remove instance from indexes
|
|
689
|
+
*/
|
|
690
|
+
removeFromIndex(instance) {
|
|
691
|
+
const { id, machineName, currentState, publicMember, context } = instance;
|
|
692
|
+
// Machine index
|
|
693
|
+
this.machineIndex.get(machineName)?.delete(id);
|
|
694
|
+
// State index
|
|
695
|
+
const stateKey = `${machineName}:${currentState}`;
|
|
696
|
+
this.stateIndex.get(stateKey)?.delete(id);
|
|
697
|
+
// Property index
|
|
698
|
+
const instanceData = publicMember || context;
|
|
699
|
+
if (instanceData) {
|
|
700
|
+
for (const [propName, propValue] of Object.entries(instanceData)) {
|
|
701
|
+
if (propValue !== null && propValue !== undefined) {
|
|
702
|
+
const propKey = `${machineName}:${propName}:${String(propValue)}`;
|
|
703
|
+
this.propertyIndex.get(propKey)?.delete(id);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Update state index when state changes
|
|
710
|
+
*/
|
|
711
|
+
updateIndexOnStateChange(instance, oldState, newState) {
|
|
712
|
+
const { id, machineName } = instance;
|
|
713
|
+
// Remove from old state index
|
|
714
|
+
const oldStateKey = `${machineName}:${oldState}`;
|
|
715
|
+
this.stateIndex.get(oldStateKey)?.delete(id);
|
|
716
|
+
// Add to new state index
|
|
717
|
+
const newStateKey = `${machineName}:${newState}`;
|
|
718
|
+
if (!this.stateIndex.has(newStateKey)) {
|
|
719
|
+
this.stateIndex.set(newStateKey, new Set());
|
|
720
|
+
}
|
|
721
|
+
this.stateIndex.get(newStateKey).add(id);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Process a single cascading rule
|
|
725
|
+
*
|
|
726
|
+
* Applies payload templating and broadcasts event to target instances
|
|
727
|
+
* If matchingRules exist, uses property-based routing
|
|
728
|
+
* Otherwise, sends to ALL instances in the target state
|
|
729
|
+
*/
|
|
730
|
+
async processCascadingRule(sourceInstance, rule) {
|
|
731
|
+
// Get source context (publicMember or context)
|
|
732
|
+
const sourceContext = sourceInstance.publicMember || sourceInstance.context;
|
|
733
|
+
// Apply payload templating
|
|
734
|
+
const payload = rule.payload ? this.applyPayloadTemplate(rule.payload, sourceContext) : {};
|
|
735
|
+
const event = {
|
|
736
|
+
type: rule.event,
|
|
737
|
+
payload,
|
|
738
|
+
timestamp: Date.now(),
|
|
739
|
+
};
|
|
740
|
+
let processedCount = 0;
|
|
741
|
+
if (rule.matchingRules && rule.matchingRules.length > 0) {
|
|
742
|
+
// Use property-based routing
|
|
743
|
+
processedCount = await this.broadcastEvent(rule.targetMachine, rule.targetState, event);
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
// No matching rules - send to ALL instances in target state
|
|
747
|
+
// Performance: Use state index instead of iterating all instances
|
|
748
|
+
const stateKey = `${rule.targetMachine}:${rule.targetState}`;
|
|
749
|
+
const candidateIds = this.stateIndex.get(stateKey);
|
|
750
|
+
if (candidateIds) {
|
|
751
|
+
for (const instanceId of candidateIds) {
|
|
752
|
+
const instance = this.instances.get(instanceId);
|
|
753
|
+
if (!instance || instance.status !== 'active')
|
|
754
|
+
continue;
|
|
755
|
+
try {
|
|
756
|
+
await this.sendEvent(instance.id, event);
|
|
757
|
+
processedCount++;
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
// Continue processing other instances even if one fails
|
|
761
|
+
this.emit('cascade_error', {
|
|
762
|
+
sourceInstanceId: sourceInstance.id,
|
|
763
|
+
targetInstanceId: instance.id,
|
|
764
|
+
error: error.message,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
this.emit('cascade_completed', {
|
|
771
|
+
sourceInstanceId: sourceInstance.id,
|
|
772
|
+
targetMachine: rule.targetMachine,
|
|
773
|
+
targetState: rule.targetState,
|
|
774
|
+
event: rule.event,
|
|
775
|
+
processedCount,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Apply payload template with {{property}} syntax
|
|
780
|
+
*
|
|
781
|
+
* Replaces {{property}} with actual values from source context
|
|
782
|
+
*
|
|
783
|
+
* Example:
|
|
784
|
+
* payload: { orderId: "{{Id}}", total: "{{Total}}" }
|
|
785
|
+
* context: { Id: 42, Total: 99.99 }
|
|
786
|
+
* result: { orderId: 42, total: 99.99 }
|
|
787
|
+
*/
|
|
788
|
+
applyPayloadTemplate(template, context) {
|
|
789
|
+
const result = {};
|
|
790
|
+
for (const [key, value] of Object.entries(template)) {
|
|
791
|
+
if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) {
|
|
792
|
+
// Extract property name: "{{Id}}" → "Id"
|
|
793
|
+
const propertyPath = value.slice(2, -2).trim();
|
|
794
|
+
result[key] = this.getNestedProperty(context, propertyPath);
|
|
795
|
+
}
|
|
796
|
+
else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
797
|
+
// Recursively apply template for nested objects
|
|
798
|
+
result[key] = this.applyPayloadTemplate(value, context);
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
// Use value as-is
|
|
802
|
+
result[key] = value;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return result;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Clear timeouts for instance (using timer wheel)
|
|
809
|
+
*/
|
|
810
|
+
clearTimeouts(instanceId) {
|
|
811
|
+
const taskIds = this.timeoutTasks.get(instanceId);
|
|
812
|
+
if (taskIds) {
|
|
813
|
+
// Remove all timeout tasks for this instance
|
|
814
|
+
taskIds.forEach(taskId => {
|
|
815
|
+
this.timerWheel.removeTimeout(taskId);
|
|
816
|
+
});
|
|
817
|
+
this.timeoutTasks.delete(instanceId);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Get instance
|
|
822
|
+
*/
|
|
823
|
+
getInstance(instanceId) {
|
|
824
|
+
return this.instances.get(instanceId);
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Get all instances
|
|
828
|
+
*/
|
|
829
|
+
getAllInstances() {
|
|
830
|
+
return Array.from(this.instances.values());
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Get instances by machine
|
|
834
|
+
*/
|
|
835
|
+
getInstancesByMachine(machineName) {
|
|
836
|
+
return Array.from(this.instances.values()).filter(i => i.machineName === machineName);
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Simulate FSM path
|
|
840
|
+
*/
|
|
841
|
+
simulatePath(machineName, events) {
|
|
842
|
+
const machine = this.machines.get(machineName);
|
|
843
|
+
if (!machine) {
|
|
844
|
+
return { success: false, path: [], error: `Machine ${machineName} not found` };
|
|
845
|
+
}
|
|
846
|
+
const path = [machine.initialState];
|
|
847
|
+
let currentState = machine.initialState;
|
|
848
|
+
const context = {};
|
|
849
|
+
for (const event of events) {
|
|
850
|
+
const transition = this.findTransition(machine, currentState, event, context);
|
|
851
|
+
if (!transition) {
|
|
852
|
+
return { success: false, path, error: `No transition from ${currentState} for event ${event.type}` };
|
|
853
|
+
}
|
|
854
|
+
if (transition.guards && !this.evaluateGuards(transition.guards, event, context)) {
|
|
855
|
+
return { success: false, path, error: `Guard failed for transition from ${currentState}` };
|
|
856
|
+
}
|
|
857
|
+
currentState = transition.to;
|
|
858
|
+
path.push(currentState);
|
|
859
|
+
const state = machine.states.find(s => s.name === currentState);
|
|
860
|
+
if (state && (state.type === types_1.StateType.FINAL || state.type === types_1.StateType.ERROR)) {
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return { success: true, path };
|
|
865
|
+
}
|
|
866
|
+
// ============================================================
|
|
867
|
+
// PHASE 4: PERSISTENCE & RESTORATION
|
|
868
|
+
// ============================================================
|
|
869
|
+
/**
|
|
870
|
+
* Restore all instances from snapshots (for long-running workflows)
|
|
871
|
+
*
|
|
872
|
+
* Called after restart to restore system state from persistence
|
|
873
|
+
*
|
|
874
|
+
* Example:
|
|
875
|
+
* const runtime = new FSMRuntime(component, { snapshots: true });
|
|
876
|
+
* await runtime.restore();
|
|
877
|
+
* // System is now in same state as before restart
|
|
878
|
+
*/
|
|
879
|
+
async restore() {
|
|
880
|
+
if (!this.persistence) {
|
|
881
|
+
throw new Error('Persistence is not enabled');
|
|
882
|
+
}
|
|
883
|
+
const snapshots = await this.persistence.getAllSnapshots();
|
|
884
|
+
let restored = 0;
|
|
885
|
+
let failed = 0;
|
|
886
|
+
for (const snapshot of snapshots) {
|
|
887
|
+
try {
|
|
888
|
+
const instance = snapshot.instance;
|
|
889
|
+
// Validate machine exists
|
|
890
|
+
if (!this.machines.has(instance.machineName)) {
|
|
891
|
+
failed++;
|
|
892
|
+
this.emit('restore_error', {
|
|
893
|
+
instanceId: instance.id,
|
|
894
|
+
error: `Machine ${instance.machineName} not found`,
|
|
895
|
+
});
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
// Restore instance
|
|
899
|
+
this.instances.set(instance.id, instance);
|
|
900
|
+
restored++;
|
|
901
|
+
this.emit('instance_restored', {
|
|
902
|
+
instanceId: instance.id,
|
|
903
|
+
machineName: instance.machineName,
|
|
904
|
+
currentState: instance.currentState,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
failed++;
|
|
909
|
+
this.emit('restore_error', {
|
|
910
|
+
instanceId: snapshot.instance.id,
|
|
911
|
+
error: error.message,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// Resynchronize timeouts after restore
|
|
916
|
+
if (restored > 0) {
|
|
917
|
+
await this.resynchronizeTimeouts();
|
|
918
|
+
}
|
|
919
|
+
return { restored, failed };
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Resynchronize timeouts after restart
|
|
923
|
+
*
|
|
924
|
+
* Recalculates timeout transitions based on current state and elapsed time
|
|
925
|
+
* Handles expired timeouts by triggering them immediately
|
|
926
|
+
*/
|
|
927
|
+
async resynchronizeTimeouts() {
|
|
928
|
+
let synced = 0;
|
|
929
|
+
let expired = 0;
|
|
930
|
+
for (const [instanceId, instance] of this.instances.entries()) {
|
|
931
|
+
if (instance.status !== 'active') {
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
const machine = this.machines.get(instance.machineName);
|
|
935
|
+
if (!machine) {
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
// Find timeout transitions from current state
|
|
939
|
+
const timeoutTransitions = machine.transitions.filter(t => t.from === instance.currentState && t.type === types_1.TransitionType.TIMEOUT);
|
|
940
|
+
for (const transition of timeoutTransitions) {
|
|
941
|
+
if (!transition.timeoutMs) {
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
// Calculate elapsed time since last update
|
|
945
|
+
const elapsedMs = Date.now() - instance.updatedAt;
|
|
946
|
+
const remainingMs = transition.timeoutMs - elapsedMs;
|
|
947
|
+
if (remainingMs <= 0) {
|
|
948
|
+
// Timeout already expired - trigger immediately
|
|
949
|
+
try {
|
|
950
|
+
await this.sendEvent(instanceId, {
|
|
951
|
+
type: transition.event,
|
|
952
|
+
payload: { reason: 'timeout_expired_during_restart' },
|
|
953
|
+
timestamp: Date.now(),
|
|
954
|
+
});
|
|
955
|
+
expired++;
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
this.emit('timeout_resync_error', {
|
|
959
|
+
instanceId,
|
|
960
|
+
error: error.message,
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
// Timeout still pending - reschedule with remaining time using timer wheel
|
|
966
|
+
const taskId = `${instanceId}-${instance.currentState}-${transition.event}`;
|
|
967
|
+
// Track task for cleanup
|
|
968
|
+
if (!this.timeoutTasks.has(instanceId)) {
|
|
969
|
+
this.timeoutTasks.set(instanceId, []);
|
|
970
|
+
}
|
|
971
|
+
this.timeoutTasks.get(instanceId).push(taskId);
|
|
972
|
+
this.timerWheel.addTimeout(taskId, remainingMs, () => {
|
|
973
|
+
const currentInstance = this.instances.get(instanceId);
|
|
974
|
+
if (currentInstance && currentInstance.currentState === instance.currentState) {
|
|
975
|
+
this.sendEvent(instanceId, {
|
|
976
|
+
type: transition.event,
|
|
977
|
+
payload: { reason: 'timeout' },
|
|
978
|
+
timestamp: Date.now(),
|
|
979
|
+
}).catch(error => {
|
|
980
|
+
console.error(`Timeout resync failed for ${instanceId}:`, error);
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
// Remove taskId from tracking
|
|
984
|
+
const tasks = this.timeoutTasks.get(instanceId);
|
|
985
|
+
if (tasks) {
|
|
986
|
+
const index = tasks.indexOf(taskId);
|
|
987
|
+
if (index >= 0)
|
|
988
|
+
tasks.splice(index, 1);
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
synced++;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// Resynchronize auto-transitions (should trigger immediately if not already transitioned)
|
|
995
|
+
const autoTransitions = machine.transitions.filter(t => t.from === instance.currentState && t.type === types_1.TransitionType.AUTO);
|
|
996
|
+
for (const transition of autoTransitions) {
|
|
997
|
+
// Use publicMember if available (XComponent pattern), otherwise fallback to context
|
|
998
|
+
const instanceContext = instance.publicMember || instance.context;
|
|
999
|
+
// Check guards before scheduling auto-transition
|
|
1000
|
+
if (transition.guards && !this.evaluateGuards(transition.guards, { type: transition.event, payload: {}, timestamp: Date.now() }, instanceContext)) {
|
|
1001
|
+
continue; // Guard failed, skip this auto-transition
|
|
1002
|
+
}
|
|
1003
|
+
const delay = transition.timeoutMs || 0;
|
|
1004
|
+
// Calculate elapsed time
|
|
1005
|
+
const elapsedMs = Date.now() - instance.updatedAt;
|
|
1006
|
+
const remainingMs = Math.max(0, delay - elapsedMs);
|
|
1007
|
+
const taskId = `${instanceId}-${instance.currentState}-auto-${transition.event}`;
|
|
1008
|
+
// Track task for cleanup
|
|
1009
|
+
if (!this.timeoutTasks.has(instanceId)) {
|
|
1010
|
+
this.timeoutTasks.set(instanceId, []);
|
|
1011
|
+
}
|
|
1012
|
+
this.timeoutTasks.get(instanceId).push(taskId);
|
|
1013
|
+
this.timerWheel.addTimeout(taskId, remainingMs, () => {
|
|
1014
|
+
const currentInstance = this.instances.get(instanceId);
|
|
1015
|
+
if (currentInstance && currentInstance.currentState === instance.currentState) {
|
|
1016
|
+
this.sendEvent(instanceId, {
|
|
1017
|
+
type: transition.event,
|
|
1018
|
+
payload: { reason: 'auto-transition' },
|
|
1019
|
+
timestamp: Date.now(),
|
|
1020
|
+
}).catch(error => {
|
|
1021
|
+
console.error(`Auto-transition resync failed for ${instanceId}:`, error);
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
// Remove taskId from tracking
|
|
1025
|
+
const tasks = this.timeoutTasks.get(instanceId);
|
|
1026
|
+
if (tasks) {
|
|
1027
|
+
const index = tasks.indexOf(taskId);
|
|
1028
|
+
if (index >= 0)
|
|
1029
|
+
tasks.splice(index, 1);
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
synced++;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return { synced, expired };
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Get persistence manager (for testing/inspection)
|
|
1039
|
+
*/
|
|
1040
|
+
getPersistenceManager() {
|
|
1041
|
+
return this.persistence;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Get instance event history (for audit/debug)
|
|
1045
|
+
*/
|
|
1046
|
+
async getInstanceHistory(instanceId) {
|
|
1047
|
+
if (!this.persistence) {
|
|
1048
|
+
return [];
|
|
1049
|
+
}
|
|
1050
|
+
return await this.persistence.getInstanceEvents(instanceId);
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Get all persisted events for this component
|
|
1054
|
+
*/
|
|
1055
|
+
async getAllPersistedEvents() {
|
|
1056
|
+
if (!this.persistence) {
|
|
1057
|
+
return [];
|
|
1058
|
+
}
|
|
1059
|
+
return await this.persistence.getAllEvents();
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Trace event causality chain (for debugging cascades/sender)
|
|
1063
|
+
*/
|
|
1064
|
+
async traceEventCausality(eventId) {
|
|
1065
|
+
if (!this.persistence) {
|
|
1066
|
+
return [];
|
|
1067
|
+
}
|
|
1068
|
+
return await this.persistence.traceEventCausality(eventId);
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Get component definition
|
|
1072
|
+
*/
|
|
1073
|
+
getComponent() {
|
|
1074
|
+
return this.componentDef;
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Set component registry for cross-component communication
|
|
1078
|
+
*/
|
|
1079
|
+
setRegistry(registry) {
|
|
1080
|
+
this.registry = registry;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Get available transitions from current state of an instance
|
|
1084
|
+
*/
|
|
1085
|
+
getAvailableTransitions(instanceId) {
|
|
1086
|
+
const instance = this.getInstance(instanceId);
|
|
1087
|
+
if (!instance) {
|
|
1088
|
+
return [];
|
|
1089
|
+
}
|
|
1090
|
+
const machine = this.machines.get(instance.machineName);
|
|
1091
|
+
if (!machine) {
|
|
1092
|
+
return [];
|
|
1093
|
+
}
|
|
1094
|
+
// Find all transitions from current state
|
|
1095
|
+
return machine.transitions.filter(t => t.from === instance.currentState);
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Stop the runtime and cleanup resources
|
|
1099
|
+
* Important: Call this when done to prevent memory leaks from timer wheel
|
|
1100
|
+
*/
|
|
1101
|
+
dispose() {
|
|
1102
|
+
// Stop timer wheel
|
|
1103
|
+
this.timerWheel.stop();
|
|
1104
|
+
// Clear all instances
|
|
1105
|
+
this.instances.clear();
|
|
1106
|
+
this.timeoutTasks.clear();
|
|
1107
|
+
// Clear indexes
|
|
1108
|
+
this.machineIndex.clear();
|
|
1109
|
+
this.stateIndex.clear();
|
|
1110
|
+
this.propertyIndex.clear();
|
|
1111
|
+
// Remove all event listeners
|
|
1112
|
+
this.removeAllListeners();
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
exports.FSMRuntime = FSMRuntime;
|
|
1116
|
+
/**
|
|
1117
|
+
* Load component from object
|
|
1118
|
+
*/
|
|
1119
|
+
function loadComponent(data) {
|
|
1120
|
+
return data;
|
|
1121
|
+
}
|
|
1122
|
+
//# sourceMappingURL=fsm-runtime.js.map
|