jw-automator 2.0.0 → 3.0.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/CHANGELOG.md +76 -0
- package/README.md +366 -192
- package/docs/ARCHITECTURE.md +342 -0
- package/docs/MIGRATION.md +407 -0
- package/docs/QUICKSTART.md +350 -0
- package/examples/basic-example.js +135 -0
- package/examples/hello-world.js +40 -0
- package/examples/iot-sensor-example.js +149 -0
- package/index.js +7 -0
- package/package.json +53 -19
- package/src/Automator.js +429 -0
- package/src/core/CoreEngine.js +210 -0
- package/src/core/RecurrenceEngine.js +237 -0
- package/src/host/SchedulerHost.js +174 -0
- package/src/storage/FileStorage.js +59 -0
- package/src/storage/MemoryStorage.js +27 -0
- package/.actions.json +0 -1
- package/.jshintrc +0 -16
- package/.vscode/settings.json +0 -6
- package/LICENSE +0 -674
- package/automator.js +0 -696
- package/demo.js +0 -76
package/package.json
CHANGED
|
@@ -1,19 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "jw-automator",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"main": "
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "jw-automator",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "A resilient, local-time, 1-second precision automation scheduler for Node.js",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"src/",
|
|
8
|
+
"examples/",
|
|
9
|
+
"docs/",
|
|
10
|
+
"index.js",
|
|
11
|
+
"README.md",
|
|
12
|
+
"CHANGELOG.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "jest",
|
|
16
|
+
"test:watch": "jest --watch",
|
|
17
|
+
"test:coverage": "jest --coverage"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"scheduler",
|
|
21
|
+
"automation",
|
|
22
|
+
"cron",
|
|
23
|
+
"task",
|
|
24
|
+
"timer",
|
|
25
|
+
"schedule",
|
|
26
|
+
"local-time",
|
|
27
|
+
"dst",
|
|
28
|
+
"recurrence",
|
|
29
|
+
"iot",
|
|
30
|
+
"raspberry-pi",
|
|
31
|
+
"home-automation"
|
|
32
|
+
],
|
|
33
|
+
"author": "Jon Wyett",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/jonwyett/jw-automator.git"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=12.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"jest": "^29.0.0"
|
|
44
|
+
},
|
|
45
|
+
"jest": {
|
|
46
|
+
"testEnvironment": "node",
|
|
47
|
+
"coverageDirectory": "coverage",
|
|
48
|
+
"collectCoverageFrom": [
|
|
49
|
+
"src/**/*.js",
|
|
50
|
+
"!src/**/*.test.js"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/Automator.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automator.js
|
|
3
|
+
*
|
|
4
|
+
* Main API class for jw-automator v3
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const SchedulerHost = require('./host/SchedulerHost');
|
|
8
|
+
const CoreEngine = require('./core/CoreEngine');
|
|
9
|
+
const FileStorage = require('./storage/FileStorage');
|
|
10
|
+
const MemoryStorage = require('./storage/MemoryStorage');
|
|
11
|
+
|
|
12
|
+
class Automator {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.options = {
|
|
15
|
+
storage: options.storage || new MemoryStorage(),
|
|
16
|
+
autoSave: options.autoSave !== false, // default true
|
|
17
|
+
saveInterval: options.saveInterval || 5000, // 5 seconds
|
|
18
|
+
...options
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
this.host = new SchedulerHost();
|
|
22
|
+
this.nextId = 1;
|
|
23
|
+
this.saveTimer = null;
|
|
24
|
+
|
|
25
|
+
// Forward events from host
|
|
26
|
+
this.host.on('ready', (...args) => this._emit('ready', ...args));
|
|
27
|
+
this.host.on('action', (...args) => this._emit('action', ...args));
|
|
28
|
+
this.host.on('error', (...args) => this._emit('error', ...args));
|
|
29
|
+
this.host.on('debug', (...args) => this._emit('debug', ...args));
|
|
30
|
+
|
|
31
|
+
// Event listeners
|
|
32
|
+
this.listeners = new Map();
|
|
33
|
+
|
|
34
|
+
// Load initial state
|
|
35
|
+
this._loadState();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Start the automator
|
|
40
|
+
*/
|
|
41
|
+
start() {
|
|
42
|
+
this.host.start();
|
|
43
|
+
|
|
44
|
+
// Start auto-save if enabled
|
|
45
|
+
if (this.options.autoSave) {
|
|
46
|
+
this._startAutoSave();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Stop the automator
|
|
52
|
+
*/
|
|
53
|
+
stop() {
|
|
54
|
+
this.host.stop();
|
|
55
|
+
|
|
56
|
+
// Stop auto-save
|
|
57
|
+
if (this.saveTimer) {
|
|
58
|
+
clearInterval(this.saveTimer);
|
|
59
|
+
this.saveTimer = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Final save
|
|
63
|
+
if (this.options.autoSave) {
|
|
64
|
+
this._saveState();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Register a command function
|
|
70
|
+
*/
|
|
71
|
+
addFunction(name, fn) {
|
|
72
|
+
this.host.addFunction(name, fn);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Remove a command function
|
|
77
|
+
*/
|
|
78
|
+
removeFunction(name) {
|
|
79
|
+
this.host.removeFunction(name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Add an action
|
|
84
|
+
*/
|
|
85
|
+
addAction(actionSpec) {
|
|
86
|
+
// Validate action
|
|
87
|
+
this._validateAction(actionSpec);
|
|
88
|
+
|
|
89
|
+
// Create action with state
|
|
90
|
+
const action = {
|
|
91
|
+
id: this.nextId++,
|
|
92
|
+
name: actionSpec.name || null,
|
|
93
|
+
cmd: actionSpec.cmd,
|
|
94
|
+
payload: actionSpec.payload !== undefined ? actionSpec.payload : null,
|
|
95
|
+
date: actionSpec.date ? new Date(actionSpec.date) : new Date(),
|
|
96
|
+
unBuffered: actionSpec.unBuffered || false,
|
|
97
|
+
repeat: actionSpec.repeat ? { ...actionSpec.repeat } : null,
|
|
98
|
+
count: 0
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Set default dstPolicy if not specified
|
|
102
|
+
if (action.repeat && !action.repeat.dstPolicy) {
|
|
103
|
+
action.repeat.dstPolicy = 'once';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.host.addAction(action);
|
|
107
|
+
|
|
108
|
+
this._emit('update', {
|
|
109
|
+
type: 'update',
|
|
110
|
+
operation: 'add',
|
|
111
|
+
action: { ...action }
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (this.options.autoSave) {
|
|
115
|
+
this._saveState();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return action.id;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Update an action by ID
|
|
123
|
+
*/
|
|
124
|
+
updateActionByID(id, updates) {
|
|
125
|
+
const state = this.host.getState();
|
|
126
|
+
const action = state.actions.find(a => a.id === id);
|
|
127
|
+
|
|
128
|
+
if (!action) {
|
|
129
|
+
throw new Error(`Action with id ${id} not found`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Update allowed fields
|
|
133
|
+
const allowedUpdates = ['name', 'cmd', 'payload', 'date', 'unBuffered', 'repeat', 'count'];
|
|
134
|
+
|
|
135
|
+
for (const key of allowedUpdates) {
|
|
136
|
+
if (key in updates) {
|
|
137
|
+
if (key === 'date' && updates[key]) {
|
|
138
|
+
action[key] = new Date(updates[key]);
|
|
139
|
+
} else if (key === 'repeat' && updates[key]) {
|
|
140
|
+
action[key] = { ...updates[key] };
|
|
141
|
+
if (!action[key].dstPolicy) {
|
|
142
|
+
action[key].dstPolicy = 'once';
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
action[key] = updates[key];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this._emit('update', {
|
|
151
|
+
type: 'update',
|
|
152
|
+
operation: 'update',
|
|
153
|
+
actionId: id,
|
|
154
|
+
action: { ...action }
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (this.options.autoSave) {
|
|
158
|
+
this._saveState();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Remove action by ID
|
|
164
|
+
*/
|
|
165
|
+
removeActionByID(id) {
|
|
166
|
+
const state = this.host.getState();
|
|
167
|
+
const index = state.actions.findIndex(a => a.id === id);
|
|
168
|
+
|
|
169
|
+
if (index === -1) {
|
|
170
|
+
throw new Error(`Action with id ${id} not found`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const removed = state.actions.splice(index, 1)[0];
|
|
174
|
+
|
|
175
|
+
this._emit('update', {
|
|
176
|
+
type: 'update',
|
|
177
|
+
operation: 'remove',
|
|
178
|
+
actionId: id,
|
|
179
|
+
action: removed
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (this.options.autoSave) {
|
|
183
|
+
this._saveState();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Remove actions by name
|
|
189
|
+
*/
|
|
190
|
+
removeActionByName(name) {
|
|
191
|
+
const state = this.host.getState();
|
|
192
|
+
const toRemove = state.actions.filter(a => a.name === name);
|
|
193
|
+
|
|
194
|
+
if (toRemove.length === 0) {
|
|
195
|
+
throw new Error(`No actions found with name: ${name}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const action of toRemove) {
|
|
199
|
+
const index = state.actions.indexOf(action);
|
|
200
|
+
if (index !== -1) {
|
|
201
|
+
state.actions.splice(index, 1);
|
|
202
|
+
|
|
203
|
+
this._emit('update', {
|
|
204
|
+
type: 'update',
|
|
205
|
+
operation: 'remove',
|
|
206
|
+
actionId: action.id,
|
|
207
|
+
action
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (this.options.autoSave) {
|
|
213
|
+
this._saveState();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return toRemove.length;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get all actions (deep copy)
|
|
221
|
+
*/
|
|
222
|
+
getActions() {
|
|
223
|
+
const state = this.host.getState();
|
|
224
|
+
return JSON.parse(JSON.stringify(state.actions));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get actions by name
|
|
229
|
+
*/
|
|
230
|
+
getActionsByName(name) {
|
|
231
|
+
const state = this.host.getState();
|
|
232
|
+
return state.actions
|
|
233
|
+
.filter(a => a.name === name)
|
|
234
|
+
.map(a => JSON.parse(JSON.stringify(a)));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get action by ID
|
|
239
|
+
*/
|
|
240
|
+
getActionByID(id) {
|
|
241
|
+
const state = this.host.getState();
|
|
242
|
+
const action = state.actions.find(a => a.id === id);
|
|
243
|
+
return action ? JSON.parse(JSON.stringify(action)) : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get actions scheduled in a time range
|
|
248
|
+
*/
|
|
249
|
+
getActionsInRange(startDate, endDate, callback) {
|
|
250
|
+
const start = new Date(startDate);
|
|
251
|
+
const end = new Date(endDate);
|
|
252
|
+
|
|
253
|
+
const state = this.host.getState();
|
|
254
|
+
const events = CoreEngine.simulate(state, start, end);
|
|
255
|
+
|
|
256
|
+
if (callback && typeof callback === 'function') {
|
|
257
|
+
callback(events);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return events;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Simulate range (alias for getActionsInRange)
|
|
265
|
+
*/
|
|
266
|
+
simulateRange(startDate, endDate) {
|
|
267
|
+
return this.getActionsInRange(startDate, endDate);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Describe an action in human-readable format
|
|
272
|
+
*/
|
|
273
|
+
describeAction(id) {
|
|
274
|
+
const action = this.getActionByID(id);
|
|
275
|
+
if (!action) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let description = `Action #${action.id}`;
|
|
280
|
+
if (action.name) {
|
|
281
|
+
description += ` - ${action.name}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
description += `\n Command: ${action.cmd}`;
|
|
285
|
+
description += `\n Next run: ${action.date ? action.date.toLocaleString() : 'None'}`;
|
|
286
|
+
description += `\n Executions: ${action.count}`;
|
|
287
|
+
description += `\n Buffered: ${!action.unBuffered}`;
|
|
288
|
+
|
|
289
|
+
if (action.repeat) {
|
|
290
|
+
description += `\n Recurrence: ${action.repeat.type}`;
|
|
291
|
+
if (action.repeat.interval > 1) {
|
|
292
|
+
description += ` (every ${action.repeat.interval})`;
|
|
293
|
+
}
|
|
294
|
+
if (action.repeat.limit) {
|
|
295
|
+
description += `\n Limit: ${action.repeat.limit}`;
|
|
296
|
+
}
|
|
297
|
+
if (action.repeat.endDate) {
|
|
298
|
+
description += `\n End date: ${new Date(action.repeat.endDate).toLocaleString()}`;
|
|
299
|
+
}
|
|
300
|
+
description += `\n DST policy: ${action.repeat.dstPolicy}`;
|
|
301
|
+
} else {
|
|
302
|
+
description += `\n Recurrence: One-time`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return description;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Event listener management
|
|
310
|
+
*/
|
|
311
|
+
on(event, listener) {
|
|
312
|
+
if (!this.listeners.has(event)) {
|
|
313
|
+
this.listeners.set(event, []);
|
|
314
|
+
}
|
|
315
|
+
this.listeners.get(event).push(listener);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
off(event, listener) {
|
|
319
|
+
if (!this.listeners.has(event)) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const list = this.listeners.get(event);
|
|
323
|
+
const index = list.indexOf(listener);
|
|
324
|
+
if (index !== -1) {
|
|
325
|
+
list.splice(index, 1);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_emit(event, ...args) {
|
|
330
|
+
if (!this.listeners.has(event)) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const list = this.listeners.get(event);
|
|
334
|
+
for (const listener of list) {
|
|
335
|
+
try {
|
|
336
|
+
listener(...args);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
console.error(`Error in ${event} listener:`, error);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Load state from storage
|
|
345
|
+
*/
|
|
346
|
+
_loadState() {
|
|
347
|
+
try {
|
|
348
|
+
const state = this.options.storage.load();
|
|
349
|
+
this.host.setState(state);
|
|
350
|
+
|
|
351
|
+
// Update nextId to be higher than any existing ID
|
|
352
|
+
if (state.actions && state.actions.length > 0) {
|
|
353
|
+
const maxId = Math.max(...state.actions.map(a => a.id || 0));
|
|
354
|
+
this.nextId = maxId + 1;
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
this._emit('error', {
|
|
358
|
+
type: 'error',
|
|
359
|
+
message: `Failed to load state: ${error.message}`,
|
|
360
|
+
error
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Save state to storage
|
|
367
|
+
*/
|
|
368
|
+
_saveState() {
|
|
369
|
+
try {
|
|
370
|
+
const state = this.host.getState();
|
|
371
|
+
this.options.storage.save(state);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
this._emit('error', {
|
|
374
|
+
type: 'error',
|
|
375
|
+
message: `Failed to save state: ${error.message}`,
|
|
376
|
+
error
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Start auto-save timer
|
|
383
|
+
*/
|
|
384
|
+
_startAutoSave() {
|
|
385
|
+
if (this.saveTimer) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
this.saveTimer = setInterval(() => {
|
|
390
|
+
this._saveState();
|
|
391
|
+
}, this.options.saveInterval);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Validate action specification
|
|
396
|
+
*/
|
|
397
|
+
_validateAction(action) {
|
|
398
|
+
if (!action.cmd) {
|
|
399
|
+
throw new Error('Action must have a cmd property');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (action.repeat) {
|
|
403
|
+
const validTypes = ['second', 'minute', 'hour', 'day', 'weekday', 'weekend', 'week', 'month', 'year'];
|
|
404
|
+
if (!validTypes.includes(action.repeat.type)) {
|
|
405
|
+
throw new Error(`Invalid repeat type: ${action.repeat.type}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (action.repeat.interval !== undefined && action.repeat.interval < 1) {
|
|
409
|
+
throw new Error('Repeat interval must be >= 1');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (action.repeat.dstPolicy && !['once', 'twice'].includes(action.repeat.dstPolicy)) {
|
|
413
|
+
throw new Error('DST policy must be "once" or "twice"');
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Static storage factory methods
|
|
420
|
+
*/
|
|
421
|
+
static get storage() {
|
|
422
|
+
return {
|
|
423
|
+
file: (filePath) => new FileStorage(filePath),
|
|
424
|
+
memory: () => new MemoryStorage()
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
module.exports = Automator;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoreEngine.js
|
|
3
|
+
*
|
|
4
|
+
* Pure state machine for scheduling.
|
|
5
|
+
* Deterministic step function: step(state, lastTick, now) -> { newState, events }
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const RecurrenceEngine = require('./RecurrenceEngine');
|
|
9
|
+
|
|
10
|
+
class CoreEngine {
|
|
11
|
+
/**
|
|
12
|
+
* Process one scheduling step
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} state - Current scheduler state (actions array)
|
|
15
|
+
* @param {Date} lastTick - Previous tick time
|
|
16
|
+
* @param {Date} now - Current tick time
|
|
17
|
+
* @param {number} maxIterations - Safety limit for catch-up loops
|
|
18
|
+
* @returns {Object} - { newState, events }
|
|
19
|
+
*/
|
|
20
|
+
static step(state, lastTick, now, maxIterations = 10000) {
|
|
21
|
+
const events = [];
|
|
22
|
+
const newState = this._cloneState(state);
|
|
23
|
+
|
|
24
|
+
// Process each action
|
|
25
|
+
for (let i = 0; i < newState.actions.length; i++) {
|
|
26
|
+
const action = newState.actions[i];
|
|
27
|
+
|
|
28
|
+
if (!action.date) {
|
|
29
|
+
// No scheduled time - run immediately
|
|
30
|
+
const event = this._executeAction(action, now);
|
|
31
|
+
events.push(event);
|
|
32
|
+
|
|
33
|
+
// Advance to next occurrence
|
|
34
|
+
this._advanceAction(action);
|
|
35
|
+
|
|
36
|
+
// Check if action should be removed
|
|
37
|
+
if (RecurrenceEngine.shouldStop(action)) {
|
|
38
|
+
newState.actions.splice(i, 1);
|
|
39
|
+
i--;
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const nextRun = new Date(action.date);
|
|
45
|
+
|
|
46
|
+
// Safety: prevent processing actions in the future
|
|
47
|
+
if (nextRun > now) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Catch-up loop: process all missed occurrences
|
|
52
|
+
let iterationCount = 0;
|
|
53
|
+
let currentNextRun = nextRun;
|
|
54
|
+
|
|
55
|
+
while (currentNextRun <= now && iterationCount < maxIterations) {
|
|
56
|
+
iterationCount++;
|
|
57
|
+
|
|
58
|
+
// Determine if we should execute this occurrence
|
|
59
|
+
// UnBuffered: only execute if this is the actual current tick (not catching up)
|
|
60
|
+
// Buffered: execute all missed occurrences
|
|
61
|
+
const isInCurrentTick = currentNextRun > lastTick && currentNextRun <= now;
|
|
62
|
+
const shouldExecute = action.unBuffered ? isInCurrentTick : true;
|
|
63
|
+
|
|
64
|
+
if (shouldExecute) {
|
|
65
|
+
const event = this._executeAction(action, currentNextRun);
|
|
66
|
+
events.push(event);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Advance to next occurrence
|
|
70
|
+
const prevTime = currentNextRun.getTime();
|
|
71
|
+
this._advanceAction(action);
|
|
72
|
+
|
|
73
|
+
// Check if action should stop
|
|
74
|
+
if (RecurrenceEngine.shouldStop(action)) {
|
|
75
|
+
newState.actions.splice(i, 1);
|
|
76
|
+
i--;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Update currentNextRun for next iteration
|
|
81
|
+
if (action.date) {
|
|
82
|
+
currentNextRun = new Date(action.date);
|
|
83
|
+
|
|
84
|
+
// SAFETY: Ensure monotonic progression
|
|
85
|
+
if (currentNextRun.getTime() <= prevTime) {
|
|
86
|
+
events.push({
|
|
87
|
+
type: 'error',
|
|
88
|
+
message: `Action ${action.id} failed to advance time monotonically`,
|
|
89
|
+
actionId: action.id
|
|
90
|
+
});
|
|
91
|
+
// Force advancement
|
|
92
|
+
currentNextRun = new Date(prevTime + 1000);
|
|
93
|
+
action.date = currentNextRun;
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
// No more occurrences
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Safety check: iteration limit reached
|
|
102
|
+
if (iterationCount >= maxIterations) {
|
|
103
|
+
events.push({
|
|
104
|
+
type: 'error',
|
|
105
|
+
message: `Action ${action.id} exceeded max iterations (${maxIterations}) - possible infinite loop`,
|
|
106
|
+
actionId: action.id
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { newState, events };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Clone state for immutability
|
|
116
|
+
*/
|
|
117
|
+
static _cloneState(state) {
|
|
118
|
+
return {
|
|
119
|
+
actions: state.actions.map(action => ({ ...action }))
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if a time falls within the current tick window
|
|
125
|
+
*/
|
|
126
|
+
static _isCurrentTick(time, lastTick, now) {
|
|
127
|
+
return time > lastTick && time <= now;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create an action execution event
|
|
132
|
+
*/
|
|
133
|
+
static _executeAction(action, scheduledTime) {
|
|
134
|
+
return {
|
|
135
|
+
type: 'action',
|
|
136
|
+
actionId: action.id,
|
|
137
|
+
name: action.name,
|
|
138
|
+
cmd: action.cmd,
|
|
139
|
+
payload: action.payload,
|
|
140
|
+
scheduledTime: new Date(scheduledTime),
|
|
141
|
+
actualTime: new Date(),
|
|
142
|
+
count: action.count || 0
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Advance an action to its next occurrence
|
|
148
|
+
*/
|
|
149
|
+
static _advanceAction(action) {
|
|
150
|
+
// Increment count
|
|
151
|
+
action.count = (action.count || 0) + 1;
|
|
152
|
+
|
|
153
|
+
// Calculate next run time
|
|
154
|
+
if (action.repeat) {
|
|
155
|
+
const currentTime = action.date ? new Date(action.date) : new Date();
|
|
156
|
+
const dstPolicy = action.repeat.dstPolicy || 'once';
|
|
157
|
+
|
|
158
|
+
const nextTime = RecurrenceEngine.getNextOccurrence(
|
|
159
|
+
currentTime,
|
|
160
|
+
action.repeat,
|
|
161
|
+
dstPolicy
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
action.date = nextTime;
|
|
165
|
+
} else {
|
|
166
|
+
// One-time action - clear date
|
|
167
|
+
action.date = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Simulate actions in a time range without mutating state
|
|
173
|
+
*/
|
|
174
|
+
static simulate(state, startDate, endDate, maxIterations = 100000) {
|
|
175
|
+
const simulatedEvents = [];
|
|
176
|
+
const simulatedState = this._cloneState(state);
|
|
177
|
+
|
|
178
|
+
// Start simulation from the earliest action time or startDate
|
|
179
|
+
let currentTime = new Date(startDate);
|
|
180
|
+
|
|
181
|
+
// Align to second boundary
|
|
182
|
+
currentTime.setMilliseconds(0);
|
|
183
|
+
|
|
184
|
+
const endTime = new Date(endDate);
|
|
185
|
+
let lastTick = new Date(currentTime.getTime() - 1000);
|
|
186
|
+
|
|
187
|
+
let iterationCount = 0;
|
|
188
|
+
const maxTotalIterations = maxIterations * 10;
|
|
189
|
+
|
|
190
|
+
// Step through time second by second
|
|
191
|
+
while (currentTime <= endTime && iterationCount < maxTotalIterations) {
|
|
192
|
+
iterationCount++;
|
|
193
|
+
|
|
194
|
+
const { newState, events } = this.step(simulatedState, lastTick, currentTime, maxIterations);
|
|
195
|
+
simulatedState.actions = newState.actions;
|
|
196
|
+
|
|
197
|
+
// Collect action events (filter out errors for cleaner simulation output)
|
|
198
|
+
const actionEvents = events.filter(e => e.type === 'action');
|
|
199
|
+
simulatedEvents.push(...actionEvents);
|
|
200
|
+
|
|
201
|
+
// Advance time by 1 second
|
|
202
|
+
lastTick = currentTime;
|
|
203
|
+
currentTime = new Date(currentTime.getTime() + 1000);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return simulatedEvents;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = CoreEngine;
|