jw-automator 1.0.2 → 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 +452 -1
- 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 -694
- package/demo.js +0 -76
|
@@ -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}]
|
package/.jshintrc
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"multistr": true,
|
|
3
|
-
"undef": true,
|
|
4
|
-
"unused": false,
|
|
5
|
-
"validthis": true,
|
|
6
|
-
"node": true,
|
|
7
|
-
"loopfunc": true,
|
|
8
|
-
"globals": {
|
|
9
|
-
"$": true,
|
|
10
|
-
"document": true,
|
|
11
|
-
"jQuery": true,
|
|
12
|
-
"window": true,
|
|
13
|
-
"jwf": true,
|
|
14
|
-
"performance": true
|
|
15
|
-
}
|
|
16
|
-
}
|