jw-automator 2.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +375 -192
- package/docs/ARCHITECTURE.md +342 -0
- package/docs/MIGRATION.md +411 -0
- package/docs/QUICKSTART.md +356 -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 +476 -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
|
@@ -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;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RecurrenceEngine.js
|
|
3
|
+
*
|
|
4
|
+
* Pure recurrence calculation logic.
|
|
5
|
+
* Handles all DST transitions, local-time semantics, and next-run computations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
class RecurrenceEngine {
|
|
9
|
+
/**
|
|
10
|
+
* Calculate the next occurrence time for an action based on its recurrence rule.
|
|
11
|
+
*
|
|
12
|
+
* CRITICAL INVARIANT: nextTime.getTime() > currentTime.getTime()
|
|
13
|
+
*
|
|
14
|
+
* @param {Date} currentTime - The current scheduled time (local)
|
|
15
|
+
* @param {Object} repeat - The repeat configuration
|
|
16
|
+
* @param {string} dstPolicy - 'once' or 'twice' for fall-back behavior
|
|
17
|
+
* @returns {Date} - The next scheduled time (always > currentTime in UTC milliseconds)
|
|
18
|
+
*/
|
|
19
|
+
static getNextOccurrence(currentTime, repeat, dstPolicy = 'once') {
|
|
20
|
+
if (!repeat || !repeat.type) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { type, interval = 1 } = repeat;
|
|
25
|
+
const current = new Date(currentTime);
|
|
26
|
+
let next = null;
|
|
27
|
+
|
|
28
|
+
switch (type) {
|
|
29
|
+
case 'second':
|
|
30
|
+
next = this._addSeconds(current, interval);
|
|
31
|
+
break;
|
|
32
|
+
case 'minute':
|
|
33
|
+
next = this._addMinutes(current, interval);
|
|
34
|
+
break;
|
|
35
|
+
case 'hour':
|
|
36
|
+
next = this._addHours(current, interval);
|
|
37
|
+
break;
|
|
38
|
+
case 'day':
|
|
39
|
+
next = this._addDays(current, interval);
|
|
40
|
+
break;
|
|
41
|
+
case 'weekday':
|
|
42
|
+
next = this._addWeekdays(current, interval);
|
|
43
|
+
break;
|
|
44
|
+
case 'weekend':
|
|
45
|
+
next = this._addWeekends(current, interval);
|
|
46
|
+
break;
|
|
47
|
+
case 'week':
|
|
48
|
+
next = this._addWeeks(current, interval);
|
|
49
|
+
break;
|
|
50
|
+
case 'month':
|
|
51
|
+
next = this._addMonths(current, interval);
|
|
52
|
+
break;
|
|
53
|
+
case 'year':
|
|
54
|
+
next = this._addYears(current, interval);
|
|
55
|
+
break;
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(`Unknown recurrence type: ${type}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// SAFETY: Ensure monotonic progression
|
|
61
|
+
if (next && next.getTime() <= current.getTime()) {
|
|
62
|
+
// Force forward by 1 second to prevent infinite loops
|
|
63
|
+
next = new Date(current.getTime() + 1000);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Handle DST transitions
|
|
67
|
+
next = this._handleDSTTransition(current, next, dstPolicy);
|
|
68
|
+
|
|
69
|
+
return next;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Add seconds to a date
|
|
74
|
+
*/
|
|
75
|
+
static _addSeconds(date, count) {
|
|
76
|
+
return new Date(date.getTime() + (count * 1000));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Add minutes to a date
|
|
81
|
+
*/
|
|
82
|
+
static _addMinutes(date, count) {
|
|
83
|
+
return new Date(date.getTime() + (count * 60 * 1000));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Add hours to a date
|
|
88
|
+
*/
|
|
89
|
+
static _addHours(date, count) {
|
|
90
|
+
return new Date(date.getTime() + (count * 60 * 60 * 1000));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Add days to a date (preserving local time across DST)
|
|
95
|
+
*/
|
|
96
|
+
static _addDays(date, count) {
|
|
97
|
+
const result = new Date(date);
|
|
98
|
+
result.setDate(result.getDate() + count);
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Add weekdays (Mon-Fri) to a date
|
|
104
|
+
*/
|
|
105
|
+
static _addWeekdays(date, count) {
|
|
106
|
+
const result = new Date(date);
|
|
107
|
+
let added = 0;
|
|
108
|
+
|
|
109
|
+
while (added < count) {
|
|
110
|
+
result.setDate(result.getDate() + 1);
|
|
111
|
+
const day = result.getDay();
|
|
112
|
+
// 0 = Sunday, 6 = Saturday
|
|
113
|
+
if (day !== 0 && day !== 6) {
|
|
114
|
+
added++;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Add weekend days (Sat-Sun) to a date
|
|
123
|
+
*/
|
|
124
|
+
static _addWeekends(date, count) {
|
|
125
|
+
const result = new Date(date);
|
|
126
|
+
let added = 0;
|
|
127
|
+
|
|
128
|
+
while (added < count) {
|
|
129
|
+
result.setDate(result.getDate() + 1);
|
|
130
|
+
const day = result.getDay();
|
|
131
|
+
// 0 = Sunday, 6 = Saturday
|
|
132
|
+
if (day === 0 || day === 6) {
|
|
133
|
+
added++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Add weeks to a date
|
|
142
|
+
*/
|
|
143
|
+
static _addWeeks(date, count) {
|
|
144
|
+
return this._addDays(date, count * 7);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Add months to a date (preserving day-of-month when possible)
|
|
149
|
+
*/
|
|
150
|
+
static _addMonths(date, count) {
|
|
151
|
+
const result = new Date(date);
|
|
152
|
+
const originalDay = result.getDate();
|
|
153
|
+
|
|
154
|
+
result.setMonth(result.getMonth() + count);
|
|
155
|
+
|
|
156
|
+
// Handle edge case: Jan 31 + 1 month should be Feb 28/29, not Mar 3
|
|
157
|
+
if (result.getDate() !== originalDay) {
|
|
158
|
+
result.setDate(0); // Set to last day of previous month
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Add years to a date
|
|
166
|
+
*/
|
|
167
|
+
static _addYears(date, count) {
|
|
168
|
+
const result = new Date(date);
|
|
169
|
+
result.setFullYear(result.getFullYear() + count);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Handle DST transitions between current and next time
|
|
175
|
+
*
|
|
176
|
+
* - Spring forward: missing hour handled naturally by Date constructor
|
|
177
|
+
* - Fall back: prevent duplicate execution based on dstPolicy
|
|
178
|
+
*/
|
|
179
|
+
static _handleDSTTransition(currentTime, nextTime, dstPolicy) {
|
|
180
|
+
if (!nextTime) return nextTime;
|
|
181
|
+
|
|
182
|
+
const currentOffset = currentTime.getTimezoneOffset();
|
|
183
|
+
const nextOffset = nextTime.getTimezoneOffset();
|
|
184
|
+
|
|
185
|
+
// No DST transition
|
|
186
|
+
if (currentOffset === nextOffset) {
|
|
187
|
+
return nextTime;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Spring forward (offset decreased - clock jumped ahead)
|
|
191
|
+
// The missing hour is handled naturally by Date arithmetic
|
|
192
|
+
// No special handling needed
|
|
193
|
+
|
|
194
|
+
// Fall back (offset increased - clock fell back)
|
|
195
|
+
if (nextOffset > currentOffset) {
|
|
196
|
+
// We've crossed into the repeated hour
|
|
197
|
+
if (dstPolicy === 'once') {
|
|
198
|
+
// Skip the second occurrence by adding the offset difference
|
|
199
|
+
const offsetDiff = (nextOffset - currentOffset) * 60 * 1000;
|
|
200
|
+
return new Date(nextTime.getTime() + offsetDiff);
|
|
201
|
+
}
|
|
202
|
+
// dstPolicy === 'twice': allow both occurrences (no adjustment needed)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return nextTime;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if an action should stop based on limit or endDate
|
|
210
|
+
*/
|
|
211
|
+
static shouldStop(action) {
|
|
212
|
+
const { repeat } = action;
|
|
213
|
+
|
|
214
|
+
if (!repeat) return true;
|
|
215
|
+
|
|
216
|
+
// Check count limit
|
|
217
|
+
if (repeat.limit !== null && repeat.limit !== undefined) {
|
|
218
|
+
const count = action.count || 0;
|
|
219
|
+
if (count >= repeat.limit) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check end date
|
|
225
|
+
if (repeat.endDate) {
|
|
226
|
+
const endDate = new Date(repeat.endDate);
|
|
227
|
+
const nextRun = new Date(action.date);
|
|
228
|
+
if (nextRun > endDate) {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = RecurrenceEngine;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SchedulerHost.js
|
|
3
|
+
*
|
|
4
|
+
* Manages real-time ticking with 1-second alignment.
|
|
5
|
+
* Wraps the core engine and drives it with wall-clock time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EventEmitter = require('events');
|
|
9
|
+
const CoreEngine = require('../core/CoreEngine');
|
|
10
|
+
|
|
11
|
+
class SchedulerHost extends EventEmitter {
|
|
12
|
+
constructor() {
|
|
13
|
+
super();
|
|
14
|
+
this.state = { actions: [] };
|
|
15
|
+
this.running = false;
|
|
16
|
+
this.lastTick = null;
|
|
17
|
+
this.timer = null;
|
|
18
|
+
this.functions = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Start the scheduler
|
|
23
|
+
*/
|
|
24
|
+
start() {
|
|
25
|
+
if (this.running) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.running = true;
|
|
30
|
+
this.lastTick = new Date();
|
|
31
|
+
this.lastTick.setMilliseconds(0);
|
|
32
|
+
|
|
33
|
+
this._scheduleTick();
|
|
34
|
+
this.emit('ready');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stop the scheduler
|
|
39
|
+
*/
|
|
40
|
+
stop() {
|
|
41
|
+
this.running = false;
|
|
42
|
+
if (this.timer) {
|
|
43
|
+
clearTimeout(this.timer);
|
|
44
|
+
this.timer = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register a command function
|
|
50
|
+
*/
|
|
51
|
+
addFunction(name, fn) {
|
|
52
|
+
if (typeof fn !== 'function') {
|
|
53
|
+
throw new Error(`Function ${name} must be a function`);
|
|
54
|
+
}
|
|
55
|
+
this.functions.set(name, fn);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Remove a command function
|
|
60
|
+
*/
|
|
61
|
+
removeFunction(name) {
|
|
62
|
+
this.functions.delete(name);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Schedule the next tick aligned to whole seconds
|
|
67
|
+
*/
|
|
68
|
+
_scheduleTick() {
|
|
69
|
+
if (!this.running) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const now = new Date();
|
|
74
|
+
const milliseconds = now.getMilliseconds();
|
|
75
|
+
|
|
76
|
+
// Calculate wait time to next whole second
|
|
77
|
+
const waitTime = 1000 - milliseconds;
|
|
78
|
+
|
|
79
|
+
this.timer = setTimeout(() => {
|
|
80
|
+
this._tick();
|
|
81
|
+
}, waitTime);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Execute one tick
|
|
86
|
+
*/
|
|
87
|
+
_tick() {
|
|
88
|
+
if (!this.running) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const now = new Date();
|
|
93
|
+
now.setMilliseconds(0); // Align to second boundary
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// Run the core engine step
|
|
97
|
+
const { newState, events } = CoreEngine.step(this.state, this.lastTick, now);
|
|
98
|
+
|
|
99
|
+
// Update state
|
|
100
|
+
this.state = newState;
|
|
101
|
+
this.lastTick = now;
|
|
102
|
+
|
|
103
|
+
// Process events
|
|
104
|
+
for (const event of events) {
|
|
105
|
+
if (event.type === 'action') {
|
|
106
|
+
this._executeActionEvent(event);
|
|
107
|
+
} else if (event.type === 'error') {
|
|
108
|
+
this.emit('error', event);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.emit('error', {
|
|
113
|
+
type: 'error',
|
|
114
|
+
message: `Tick error: ${error.message}`,
|
|
115
|
+
error
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Schedule next tick
|
|
120
|
+
this._scheduleTick();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Execute an action event by calling its registered function
|
|
125
|
+
*/
|
|
126
|
+
_executeActionEvent(event) {
|
|
127
|
+
// Emit the action event
|
|
128
|
+
this.emit('action', event);
|
|
129
|
+
|
|
130
|
+
// Execute the command function if registered
|
|
131
|
+
const fn = this.functions.get(event.cmd);
|
|
132
|
+
if (fn) {
|
|
133
|
+
try {
|
|
134
|
+
fn(event.payload, event);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
this.emit('error', {
|
|
137
|
+
type: 'error',
|
|
138
|
+
message: `Error executing command ${event.cmd}: ${error.message}`,
|
|
139
|
+
actionId: event.actionId,
|
|
140
|
+
error
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
this.emit('debug', {
|
|
145
|
+
type: 'debug',
|
|
146
|
+
message: `No function registered for command: ${event.cmd}`,
|
|
147
|
+
actionId: event.actionId
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Add an action to the state
|
|
154
|
+
*/
|
|
155
|
+
addAction(action) {
|
|
156
|
+
this.state.actions.push(action);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get current state
|
|
161
|
+
*/
|
|
162
|
+
getState() {
|
|
163
|
+
return this.state;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Set state
|
|
168
|
+
*/
|
|
169
|
+
setState(state) {
|
|
170
|
+
this.state = state;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = SchedulerHost;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileStorage.js
|
|
3
|
+
*
|
|
4
|
+
* Pluggable file-based storage adapter
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
class FileStorage {
|
|
11
|
+
constructor(filePath) {
|
|
12
|
+
this.filePath = path.resolve(filePath);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load state from file
|
|
17
|
+
*/
|
|
18
|
+
load() {
|
|
19
|
+
try {
|
|
20
|
+
if (fs.existsSync(this.filePath)) {
|
|
21
|
+
const data = fs.readFileSync(this.filePath, 'utf8');
|
|
22
|
+
const state = JSON.parse(data);
|
|
23
|
+
|
|
24
|
+
// Convert date strings back to Date objects
|
|
25
|
+
if (state.actions) {
|
|
26
|
+
state.actions = state.actions.map(action => {
|
|
27
|
+
if (action.date) {
|
|
28
|
+
action.date = new Date(action.date);
|
|
29
|
+
}
|
|
30
|
+
if (action.repeat && action.repeat.endDate) {
|
|
31
|
+
action.repeat.endDate = new Date(action.repeat.endDate);
|
|
32
|
+
}
|
|
33
|
+
return action;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return state;
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw new Error(`Failed to load from ${this.filePath}: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { actions: [] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Save state to file
|
|
48
|
+
*/
|
|
49
|
+
save(state) {
|
|
50
|
+
try {
|
|
51
|
+
const data = JSON.stringify(state, null, 2);
|
|
52
|
+
fs.writeFileSync(this.filePath, data, 'utf8');
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error(`Failed to save to ${this.filePath}: ${error.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = FileStorage;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryStorage.js
|
|
3
|
+
*
|
|
4
|
+
* In-memory storage adapter (no persistence)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class MemoryStorage {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.state = { actions: [] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load state from memory
|
|
14
|
+
*/
|
|
15
|
+
load() {
|
|
16
|
+
return JSON.parse(JSON.stringify(this.state));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Save state to memory
|
|
21
|
+
*/
|
|
22
|
+
save(state) {
|
|
23
|
+
this.state = JSON.parse(JSON.stringify(state));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = MemoryStorage;
|
package/.actions.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[{"name":"sec","date":"2019-07-18T16:05:38.000Z","cmd":"test","payload":"tick","unBuffered":null,"repeat":{"type":"second","interval":1,"count":34,"limit":null,"endDate":null,"executed":25},"id":1563465903653}]
|