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/package.json CHANGED
@@ -1,19 +1,53 @@
1
- {
2
- "name": "jw-automator",
3
- "version": "1.0.2",
4
- "description": "Run various functions at proscribed intervals",
5
- "main": "automator.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/jonwyett/jw-automator.git"
12
- },
13
- "author": "Jonathan Wyett",
14
- "license": "ISC",
15
- "bugs": {
16
- "url": "https://github.com/jonwyett/jw-automator/issues"
17
- },
18
- "homepage": "https://github.com/jonwyett/jw-automator#readme"
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
+ }
@@ -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;