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