jw-automator 3.1.0 → 3.2.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 +47 -0
- package/README.md +70 -33
- package/docs/ARCHITECTURE.md +0 -0
- package/docs/MIGRATION.md +0 -0
- package/docs/QUICKSTART.md +0 -0
- package/examples/basic-example.js +48 -45
- package/examples/hello-world.js +0 -0
- package/examples/iot-sensor-example.js +0 -0
- package/examples/seed-example.js +147 -0
- package/index.js +0 -0
- package/package.json +1 -1
- package/src/Automator.js +215 -16
- package/src/core/CoreEngine.js +108 -4
- package/src/core/RecurrenceEngine.js +0 -0
- package/src/host/SchedulerHost.js +0 -0
- package/src/storage/FileStorage.js +0 -0
- package/src/storage/MemoryStorage.js +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,53 @@ All notable changes to jw-automator will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.2.0] - 2025-11-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`seed()` Method**: New bootstrapping method that runs initialization logic only when the database is empty
|
|
13
|
+
- Solves the "Bootstrapping Problem" by safely initializing actions without resetting schedules on restart
|
|
14
|
+
- Returns `true` if seeding ran, `false` if skipped
|
|
15
|
+
- Automatically saves state after seeding
|
|
16
|
+
- Perfect for system tasks that should be added once but preserved forever
|
|
17
|
+
|
|
18
|
+
- **`catchUpWindow` Property**: Time-based validity window for missed executions (replaces binary buffered/unBuffered)
|
|
19
|
+
- `catchUpWindow: "unlimited"` - Catch up ALL missed executions (like old `unBuffered: false`)
|
|
20
|
+
- `catchUpWindow: 0` - Skip ALL missed executions (like old `unBuffered: true`)
|
|
21
|
+
- `catchUpWindow: 5000` - Hybrid: tolerate 5s lag, skip if offline for hours
|
|
22
|
+
- Solves the "Thundering Herd Problem" by preventing thousands of queued executions after long downtime
|
|
23
|
+
- Fast-forward optimization uses mathematical projection to instantly advance high-frequency tasks
|
|
24
|
+
- Uses `"unlimited"` string literal instead of `Infinity` for clean JSON serialization
|
|
25
|
+
|
|
26
|
+
- **Defensive Validation**: "Fail loudly, run defensively" philosophy
|
|
27
|
+
- Invalid `repeat.type` → defaults to `'day'` with ERROR event
|
|
28
|
+
- Invalid `repeat.interval` → coerced to `Math.max(1, Math.floor(value))` with ERROR event
|
|
29
|
+
- Invalid `repeat.limit` → defaults to `null` (unlimited) with ERROR event
|
|
30
|
+
- Invalid `repeat.endDate` → ignored with ERROR event
|
|
31
|
+
- Invalid `catchUpWindow` → defaults to `"unlimited"` with ERROR event
|
|
32
|
+
- Negative `catchUpWindow` → coerced to `0` with ERROR event
|
|
33
|
+
- Missing `date` → defaults to 5 seconds from now with DEBUG event
|
|
34
|
+
- Unregistered commands → emit DEBUG event, keep trying (never remove action)
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **Backwards Compatibility**: `unBuffered` property is now a maintained alias that maps to `catchUpWindow`
|
|
39
|
+
- `unBuffered: true` → `catchUpWindow: 0`
|
|
40
|
+
- `unBuffered: false` → `catchUpWindow: "unlimited"`
|
|
41
|
+
- `catchUpWindow: Infinity` → automatically coerced to `"unlimited"` with DEBUG event
|
|
42
|
+
- Both properties supported indefinitely with zero breaking changes
|
|
43
|
+
- `catchUpWindow` takes precedence if both are specified
|
|
44
|
+
|
|
45
|
+
### Improved
|
|
46
|
+
|
|
47
|
+
- Clean JSON serialization: `"unlimited"` is a string, no special JSON handling required
|
|
48
|
+
- Enhanced action validation with comprehensive error/debug event emissions
|
|
49
|
+
- All invalid values coerced to sensible defaults - system keeps running
|
|
50
|
+
- Updated action loading to normalize `catchUpWindow` for backwards compatibility
|
|
51
|
+
- Comprehensive test coverage for both new features and defensive validation
|
|
52
|
+
- Updated examples to demonstrate `seed()` and `catchUpWindow` usage
|
|
53
|
+
- Updated documentation with detailed usage patterns and migration guidance
|
|
54
|
+
|
|
8
55
|
## [3.0.0] - 2025-11-17
|
|
9
56
|
|
|
10
57
|
### Added (v3 Complete Rewrite)
|
package/README.md
CHANGED
|
@@ -58,20 +58,22 @@ automator.addFunction('turnLightOn', function(payload) {
|
|
|
58
58
|
console.log('Turning light on');
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
automator.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
61
|
+
// Seed initial actions (runs only on first use)
|
|
62
|
+
automator.seed((auto) => {
|
|
63
|
+
auto.addAction({
|
|
64
|
+
name: 'Morning Lights',
|
|
65
|
+
cmd: 'turnLightOn',
|
|
66
|
+
date: new Date('2025-05-01T07:00:00'),
|
|
67
|
+
payload: null,
|
|
68
|
+
catchUpWindow: 60000, // Tolerate 1 minute of lag
|
|
69
|
+
repeat: {
|
|
70
|
+
type: 'day',
|
|
71
|
+
interval: 1,
|
|
72
|
+
limit: null,
|
|
73
|
+
endDate: null,
|
|
74
|
+
dstPolicy: 'once'
|
|
75
|
+
}
|
|
76
|
+
});
|
|
75
77
|
});
|
|
76
78
|
|
|
77
79
|
// Start the scheduler
|
|
@@ -137,25 +139,35 @@ This avoids cron's silent-but-surprising behaviors.
|
|
|
137
139
|
|
|
138
140
|
---
|
|
139
141
|
|
|
140
|
-
### 4. **Resilient Offline Catch-Up**
|
|
142
|
+
### 4. **Resilient Offline Catch-Up with Time Windows**
|
|
141
143
|
|
|
142
|
-
|
|
144
|
+
When the device is offline or delayed, you can control exactly how far back to catch up using the `catchUpWindow` property:
|
|
143
145
|
|
|
144
146
|
```js
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
catchUpWindow: "unlimited" // Catch up ALL missed executions (default)
|
|
148
|
+
catchUpWindow: 0 // Skip ALL missed executions (real-time only)
|
|
149
|
+
catchUpWindow: 5000 // Catch up if missed by ≤5 seconds, skip if older
|
|
147
150
|
```
|
|
148
151
|
|
|
149
|
-
|
|
152
|
+
**How it works:**
|
|
153
|
+
|
|
154
|
+
* If an action is missed by less than `catchUpWindow` milliseconds, it executes (recovers from brief glitches)
|
|
155
|
+
* If missed by more than `catchUpWindow`, it's skipped and fast-forwarded (prevents thundering herd)
|
|
156
|
+
* The fast-forward optimization uses mathematical projection to instantly advance high-frequency tasks
|
|
150
157
|
|
|
151
|
-
|
|
152
|
-
* A sequence of per-second readings will "compress" naturally after a delay.
|
|
158
|
+
**Example scenarios:**
|
|
153
159
|
|
|
154
|
-
|
|
160
|
+
* **Billing tasks:** `catchUpWindow: "unlimited"` - Never miss a charge
|
|
161
|
+
* **Real-time alerts:** `catchUpWindow: 0` - Only relevant "now"
|
|
162
|
+
* **Sensor readings:** `catchUpWindow: 5000` - Tolerate 5s lag, skip if system was down for hours
|
|
155
163
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
**Backwards compatibility:**
|
|
165
|
+
|
|
166
|
+
The legacy `unBuffered` property is still fully supported:
|
|
167
|
+
```js
|
|
168
|
+
unBuffered: false // equivalent to catchUpWindow: "unlimited"
|
|
169
|
+
unBuffered: true // equivalent to catchUpWindow: 0
|
|
170
|
+
```
|
|
159
171
|
|
|
160
172
|
---
|
|
161
173
|
|
|
@@ -213,6 +225,30 @@ Options:
|
|
|
213
225
|
|
|
214
226
|
### Methods
|
|
215
227
|
|
|
228
|
+
#### `seed(callback)`
|
|
229
|
+
Seed the automator with initial actions. Runs only when the database is empty (first use).
|
|
230
|
+
|
|
231
|
+
**Returns:** `boolean` - `true` if seeding ran, `false` if skipped
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
automator.seed((auto) => {
|
|
235
|
+
auto.addAction({
|
|
236
|
+
name: 'Daily Report',
|
|
237
|
+
cmd: 'generateReport',
|
|
238
|
+
date: new Date('2025-01-01T09:00:00'),
|
|
239
|
+
catchUpWindow: "unlimited",
|
|
240
|
+
repeat: { type: 'day', interval: 1 }
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Why use seed()?**
|
|
246
|
+
|
|
247
|
+
* Solves the bootstrapping problem: safely initialize actions without resetting the schedule on every restart
|
|
248
|
+
* Preserves user-modified schedules perfectly
|
|
249
|
+
* Runs initialization logic only once in the application lifecycle
|
|
250
|
+
* Automatically saves state after seeding
|
|
251
|
+
|
|
216
252
|
#### `start()`
|
|
217
253
|
Start the scheduler.
|
|
218
254
|
|
|
@@ -413,14 +449,15 @@ npm run test:coverage
|
|
|
413
449
|
|
|
414
450
|
### Top-level action fields:
|
|
415
451
|
|
|
416
|
-
| Field
|
|
417
|
-
|
|
|
418
|
-
| `id`
|
|
419
|
-
| `name`
|
|
420
|
-
| `cmd`
|
|
421
|
-
| `payload`
|
|
422
|
-
| `date`
|
|
423
|
-
| `
|
|
452
|
+
| Field | Description |
|
|
453
|
+
| --------------- | --------------------------------------------------------------------- |
|
|
454
|
+
| `id` | Unique internal identifier (auto-generated) |
|
|
455
|
+
| `name` | User label (optional) |
|
|
456
|
+
| `cmd` | Name of registered function to execute |
|
|
457
|
+
| `payload` | Data passed to the command |
|
|
458
|
+
| `date` | Next scheduled run time (local `Date`) |
|
|
459
|
+
| `catchUpWindow` | Time window for catching up missed executions (default: `"unlimited"`, or milliseconds number) |
|
|
460
|
+
| `unBuffered` | Legacy: Skip missed events (`true`) or catch up (`false`) |
|
|
424
461
|
|
|
425
462
|
### Repeat block:
|
|
426
463
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
File without changes
|
package/docs/MIGRATION.md
CHANGED
|
File without changes
|
package/docs/QUICKSTART.md
CHANGED
|
File without changes
|
|
@@ -50,51 +50,54 @@ automator.on('error', (event) => {
|
|
|
50
50
|
console.error('Error:', event.message);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
nextSunday
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
53
|
+
// Seed initial actions (runs only on first use)
|
|
54
|
+
automator.seed((auto) => {
|
|
55
|
+
console.log('Seeding initial actions...\n');
|
|
56
|
+
|
|
57
|
+
// 1. Every 10 seconds - demo message
|
|
58
|
+
auto.addAction({
|
|
59
|
+
name: 'Demo Message',
|
|
60
|
+
cmd: 'logMessage',
|
|
61
|
+
date: new Date(Date.now() + 5000), // Start in 5 seconds
|
|
62
|
+
payload: { message: 'This is a recurring message every 10 seconds' },
|
|
63
|
+
catchUpWindow: 30000, // Tolerate 30 seconds of lag
|
|
64
|
+
repeat: {
|
|
65
|
+
type: 'second',
|
|
66
|
+
interval: 10,
|
|
67
|
+
limit: 6, // Run 6 times then stop
|
|
68
|
+
dstPolicy: 'once'
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 2. Daily morning routine at 7:00 AM
|
|
73
|
+
auto.addAction({
|
|
74
|
+
name: 'Morning Routine',
|
|
75
|
+
cmd: 'morningRoutine',
|
|
76
|
+
date: new Date(new Date().setHours(7, 0, 0, 0)),
|
|
77
|
+
catchUpWindow: "unlimited", // Never miss a morning routine
|
|
78
|
+
repeat: {
|
|
79
|
+
type: 'day',
|
|
80
|
+
interval: 1,
|
|
81
|
+
dstPolicy: 'once'
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// 3. Weekly backup every Sunday at 2:00 AM
|
|
86
|
+
const nextSunday = new Date();
|
|
87
|
+
nextSunday.setDate(nextSunday.getDate() + (7 - nextSunday.getDay()) % 7);
|
|
88
|
+
nextSunday.setHours(2, 0, 0, 0);
|
|
89
|
+
|
|
90
|
+
auto.addAction({
|
|
91
|
+
name: 'Weekly Backup',
|
|
92
|
+
cmd: 'weeklyBackup',
|
|
93
|
+
date: nextSunday,
|
|
94
|
+
catchUpWindow: "unlimited", // Always run backups, even if delayed
|
|
95
|
+
repeat: {
|
|
96
|
+
type: 'week',
|
|
97
|
+
interval: 1,
|
|
98
|
+
dstPolicy: 'once'
|
|
99
|
+
}
|
|
100
|
+
});
|
|
98
101
|
});
|
|
99
102
|
|
|
100
103
|
// Demonstrate simulation - what will happen in the next 24 hours?
|
package/examples/hello-world.js
CHANGED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed Example - jw-automator v3
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates the seed() method and catchUpWindow property.
|
|
5
|
+
* This example shows how to bootstrap a persistent scheduler that:
|
|
6
|
+
* - Initializes actions only on first run
|
|
7
|
+
* - Preserves user-modified schedules on subsequent runs
|
|
8
|
+
* - Uses catchUpWindow for smart catch-up behavior
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const Automator = require('../index');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
|
|
14
|
+
// Storage file path
|
|
15
|
+
const STORAGE_FILE = './seed-example-actions.json';
|
|
16
|
+
|
|
17
|
+
// Clean up old storage file for demo purposes
|
|
18
|
+
if (fs.existsSync(STORAGE_FILE)) {
|
|
19
|
+
console.log('Note: Removing existing storage file for clean demo');
|
|
20
|
+
fs.unlinkSync(STORAGE_FILE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Create automator with file-based persistence
|
|
24
|
+
const automator = new Automator({
|
|
25
|
+
storage: Automator.storage.file(STORAGE_FILE),
|
|
26
|
+
autoSave: true,
|
|
27
|
+
saveInterval: 3000
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Register command functions
|
|
31
|
+
automator.addFunction('criticalTask', function(payload, event) {
|
|
32
|
+
console.log(`[CRITICAL] ${payload.message}`);
|
|
33
|
+
console.log(` Scheduled: ${event.scheduledTime.toLocaleTimeString()}`);
|
|
34
|
+
console.log(` Executed: ${event.actualTime.toLocaleTimeString()}`);
|
|
35
|
+
console.log(` Execution #${event.count + 1}`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
automator.addFunction('realtimeAlert', function(payload, event) {
|
|
39
|
+
console.log(`[REALTIME] ${payload.message}`);
|
|
40
|
+
console.log(` Only executes if "now" - skips if missed`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
automator.addFunction('flexibleTask', function(payload, event) {
|
|
44
|
+
const lag = event.actualTime - event.scheduledTime;
|
|
45
|
+
console.log(`[FLEXIBLE] ${payload.message}`);
|
|
46
|
+
console.log(` Lag: ${lag}ms (tolerates up to 5 seconds)`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Listen to events
|
|
50
|
+
automator.on('ready', () => {
|
|
51
|
+
console.log('\n=== Automator Ready ===');
|
|
52
|
+
console.log(`Actions loaded: ${automator.getActions().length}`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// SEED: Initialize actions (runs only on first use)
|
|
56
|
+
const didSeed = automator.seed((auto) => {
|
|
57
|
+
console.log('\n=== SEEDING: First Run Detected ===');
|
|
58
|
+
console.log('Creating initial system tasks...\n');
|
|
59
|
+
|
|
60
|
+
// Task 1: Critical billing task - NEVER miss an execution
|
|
61
|
+
auto.addAction({
|
|
62
|
+
name: 'Billing Task',
|
|
63
|
+
cmd: 'criticalTask',
|
|
64
|
+
date: new Date(Date.now() + 2000),
|
|
65
|
+
payload: { message: 'Process billing (catchUpWindow: "unlimited")' },
|
|
66
|
+
catchUpWindow: "unlimited", // Catch up ALL missed executions
|
|
67
|
+
repeat: {
|
|
68
|
+
type: 'second',
|
|
69
|
+
interval: 10,
|
|
70
|
+
limit: 3
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Task 2: Real-time alert - only relevant "now"
|
|
75
|
+
auto.addAction({
|
|
76
|
+
name: 'Realtime Alert',
|
|
77
|
+
cmd: 'realtimeAlert',
|
|
78
|
+
date: new Date(Date.now() + 5000),
|
|
79
|
+
payload: { message: 'Temperature spike detected!' },
|
|
80
|
+
catchUpWindow: 0, // Skip ALL missed executions
|
|
81
|
+
repeat: {
|
|
82
|
+
type: 'second',
|
|
83
|
+
interval: 15,
|
|
84
|
+
limit: 3
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Task 3: Flexible sensor reading - tolerate brief lag
|
|
89
|
+
auto.addAction({
|
|
90
|
+
name: 'Sensor Reading',
|
|
91
|
+
cmd: 'flexibleTask',
|
|
92
|
+
date: new Date(Date.now() + 8000),
|
|
93
|
+
payload: { message: 'Read temperature sensor' },
|
|
94
|
+
catchUpWindow: 5000, // Tolerate up to 5 seconds of lag
|
|
95
|
+
repeat: {
|
|
96
|
+
type: 'second',
|
|
97
|
+
interval: 12,
|
|
98
|
+
limit: 3
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
console.log('✅ Initial tasks created!\n');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (didSeed) {
|
|
106
|
+
console.log('✨ Seeding completed - tasks initialized for the first time');
|
|
107
|
+
} else {
|
|
108
|
+
console.log('⏭️ Seeding skipped - existing schedule preserved');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log('\n=== Current Schedule ===');
|
|
112
|
+
automator.getActions().forEach((action) => {
|
|
113
|
+
console.log(`\n${action.name}:`);
|
|
114
|
+
console.log(` catchUpWindow: ${action.catchUpWindow === "unlimited" ? '"unlimited"' : action.catchUpWindow + 'ms'}`);
|
|
115
|
+
console.log(` Next run: ${action.date.toLocaleTimeString()}`);
|
|
116
|
+
console.log(` Executions so far: ${action.count}`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
console.log('\n=== Starting Scheduler ===\n');
|
|
120
|
+
automator.start();
|
|
121
|
+
|
|
122
|
+
// Simulate a brief delay after 15 seconds to demonstrate catch-up behavior
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
console.log('\n⏸️ Simulating 3-second system pause...\n');
|
|
125
|
+
const start = Date.now();
|
|
126
|
+
while (Date.now() - start < 3000) {
|
|
127
|
+
// Block the event loop
|
|
128
|
+
}
|
|
129
|
+
console.log('▶️ Resumed! Watch how different catchUpWindow values behave:\n');
|
|
130
|
+
}, 15000);
|
|
131
|
+
|
|
132
|
+
// Auto-stop after 40 seconds
|
|
133
|
+
setTimeout(() => {
|
|
134
|
+
console.log('\n=== Shutting Down ===');
|
|
135
|
+
automator.stop();
|
|
136
|
+
console.log('\n💡 Tip: Run this example again to see seeding skipped!');
|
|
137
|
+
console.log(`Storage file: ${STORAGE_FILE}`);
|
|
138
|
+
console.log('Goodbye!\n');
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}, 40000);
|
|
141
|
+
|
|
142
|
+
// Graceful shutdown on Ctrl+C
|
|
143
|
+
process.on('SIGINT', () => {
|
|
144
|
+
console.log('\n\nShutting down...');
|
|
145
|
+
automator.stop();
|
|
146
|
+
process.exit(0);
|
|
147
|
+
});
|
package/index.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
package/src/Automator.js
CHANGED
|
@@ -35,6 +35,33 @@ class Automator {
|
|
|
35
35
|
this._loadState();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Seed the automator with initial actions (runs only on first use)
|
|
40
|
+
*
|
|
41
|
+
* @param {Function} callback - Function to execute when database is empty
|
|
42
|
+
* @returns {boolean} - True if seeding ran, false if skipped
|
|
43
|
+
*/
|
|
44
|
+
seed(callback) {
|
|
45
|
+
if (typeof callback !== 'function') {
|
|
46
|
+
throw new Error('seed() requires a callback function');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const state = this.host.getState();
|
|
50
|
+
|
|
51
|
+
// Check if already populated
|
|
52
|
+
if (state.actions && state.actions.length > 0) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Execute seeding callback
|
|
57
|
+
callback(this);
|
|
58
|
+
|
|
59
|
+
// Immediately save the seeded state
|
|
60
|
+
this._saveState();
|
|
61
|
+
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
/**
|
|
39
66
|
* Start the automator
|
|
40
67
|
*/
|
|
@@ -83,17 +110,33 @@ class Automator {
|
|
|
83
110
|
* Add an action
|
|
84
111
|
*/
|
|
85
112
|
addAction(actionSpec) {
|
|
86
|
-
// Validate action
|
|
113
|
+
// Validate action (includes defensive coercion)
|
|
87
114
|
this._validateAction(actionSpec);
|
|
88
115
|
|
|
116
|
+
// Normalize catchUpWindow (handles backwards compatibility with unBuffered)
|
|
117
|
+
const catchUpWindow = this._normalizeCatchUpWindow(actionSpec);
|
|
118
|
+
|
|
119
|
+
// Defensive: Default missing date to 5 seconds from now
|
|
120
|
+
let startDate;
|
|
121
|
+
if (!actionSpec.date) {
|
|
122
|
+
startDate = new Date(Date.now() + 5000);
|
|
123
|
+
this._emit('debug', {
|
|
124
|
+
type: 'debug',
|
|
125
|
+
message: 'No date provided - defaulting to 5 seconds from now'
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
startDate = new Date(actionSpec.date);
|
|
129
|
+
}
|
|
130
|
+
|
|
89
131
|
// Create action with state
|
|
90
132
|
const action = {
|
|
91
133
|
id: this.nextId++,
|
|
92
134
|
name: actionSpec.name || null,
|
|
93
135
|
cmd: actionSpec.cmd,
|
|
94
136
|
payload: actionSpec.payload !== undefined ? actionSpec.payload : null,
|
|
95
|
-
date:
|
|
96
|
-
unBuffered: actionSpec.unBuffered
|
|
137
|
+
date: startDate,
|
|
138
|
+
unBuffered: actionSpec.unBuffered !== undefined ? actionSpec.unBuffered : false,
|
|
139
|
+
catchUpWindow: catchUpWindow,
|
|
97
140
|
repeat: actionSpec.repeat ? { ...actionSpec.repeat } : null,
|
|
98
141
|
count: 0
|
|
99
142
|
};
|
|
@@ -130,7 +173,7 @@ class Automator {
|
|
|
130
173
|
}
|
|
131
174
|
|
|
132
175
|
// Update allowed fields
|
|
133
|
-
const allowedUpdates = ['name', 'cmd', 'payload', 'date', 'unBuffered', 'repeat', 'count'];
|
|
176
|
+
const allowedUpdates = ['name', 'cmd', 'payload', 'date', 'unBuffered', 'catchUpWindow', 'repeat', 'count'];
|
|
134
177
|
|
|
135
178
|
for (const key of allowedUpdates) {
|
|
136
179
|
if (key in updates) {
|
|
@@ -147,6 +190,14 @@ class Automator {
|
|
|
147
190
|
}
|
|
148
191
|
}
|
|
149
192
|
|
|
193
|
+
// Re-normalize catchUpWindow if unBuffered or catchUpWindow was updated
|
|
194
|
+
if ('unBuffered' in updates || 'catchUpWindow' in updates) {
|
|
195
|
+
action.catchUpWindow = this._normalizeCatchUpWindow({
|
|
196
|
+
catchUpWindow: action.catchUpWindow,
|
|
197
|
+
unBuffered: action.unBuffered
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
150
201
|
this._emit('update', {
|
|
151
202
|
type: 'update',
|
|
152
203
|
operation: 'update',
|
|
@@ -173,7 +224,7 @@ class Automator {
|
|
|
173
224
|
return 0;
|
|
174
225
|
}
|
|
175
226
|
|
|
176
|
-
const allowedUpdates = ['cmd', 'payload', 'date', 'unBuffered', 'repeat', 'count'];
|
|
227
|
+
const allowedUpdates = ['cmd', 'payload', 'date', 'unBuffered', 'catchUpWindow', 'repeat', 'count'];
|
|
177
228
|
|
|
178
229
|
for (const action of toUpdate) {
|
|
179
230
|
for (const key of allowedUpdates) {
|
|
@@ -191,6 +242,14 @@ class Automator {
|
|
|
191
242
|
}
|
|
192
243
|
}
|
|
193
244
|
|
|
245
|
+
// Re-normalize catchUpWindow if unBuffered or catchUpWindow was updated
|
|
246
|
+
if ('unBuffered' in updates || 'catchUpWindow' in updates) {
|
|
247
|
+
action.catchUpWindow = this._normalizeCatchUpWindow({
|
|
248
|
+
catchUpWindow: action.catchUpWindow,
|
|
249
|
+
unBuffered: action.unBuffered
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
194
253
|
this._emit('update', {
|
|
195
254
|
type: 'update',
|
|
196
255
|
operation: 'update',
|
|
@@ -263,12 +322,31 @@ class Automator {
|
|
|
263
322
|
return toRemove.length;
|
|
264
323
|
}
|
|
265
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Deep clone an action
|
|
327
|
+
*/
|
|
328
|
+
_cloneAction(action) {
|
|
329
|
+
const cloned = { ...action };
|
|
330
|
+
|
|
331
|
+
// Deep copy nested objects
|
|
332
|
+
if (action.repeat) {
|
|
333
|
+
cloned.repeat = { ...action.repeat };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Clone Date objects properly
|
|
337
|
+
if (action.date) {
|
|
338
|
+
cloned.date = new Date(action.date);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return cloned;
|
|
342
|
+
}
|
|
343
|
+
|
|
266
344
|
/**
|
|
267
345
|
* Get all actions (deep copy)
|
|
268
346
|
*/
|
|
269
347
|
getActions() {
|
|
270
348
|
const state = this.host.getState();
|
|
271
|
-
return
|
|
349
|
+
return state.actions.map(a => this._cloneAction(a));
|
|
272
350
|
}
|
|
273
351
|
|
|
274
352
|
/**
|
|
@@ -278,7 +356,7 @@ class Automator {
|
|
|
278
356
|
const state = this.host.getState();
|
|
279
357
|
return state.actions
|
|
280
358
|
.filter(a => a.name === name)
|
|
281
|
-
.map(a =>
|
|
359
|
+
.map(a => this._cloneAction(a));
|
|
282
360
|
}
|
|
283
361
|
|
|
284
362
|
/**
|
|
@@ -287,7 +365,7 @@ class Automator {
|
|
|
287
365
|
getActionByID(id) {
|
|
288
366
|
const state = this.host.getState();
|
|
289
367
|
const action = state.actions.find(a => a.id === id);
|
|
290
|
-
return action ?
|
|
368
|
+
return action ? this._cloneAction(action) : null;
|
|
291
369
|
}
|
|
292
370
|
|
|
293
371
|
/**
|
|
@@ -393,13 +471,21 @@ class Automator {
|
|
|
393
471
|
_loadState() {
|
|
394
472
|
try {
|
|
395
473
|
const state = this.options.storage.load();
|
|
396
|
-
this.host.setState(state);
|
|
397
474
|
|
|
398
|
-
//
|
|
475
|
+
// Normalize catchUpWindow for existing actions (for backwards compatibility)
|
|
399
476
|
if (state.actions && state.actions.length > 0) {
|
|
477
|
+
state.actions = state.actions.map(action => ({
|
|
478
|
+
...action,
|
|
479
|
+
catchUpWindow: action.catchUpWindow !== undefined
|
|
480
|
+
? action.catchUpWindow
|
|
481
|
+
: this._normalizeCatchUpWindow(action)
|
|
482
|
+
}));
|
|
483
|
+
|
|
400
484
|
const maxId = Math.max(...state.actions.map(a => a.id || 0));
|
|
401
485
|
this.nextId = maxId + 1;
|
|
402
486
|
}
|
|
487
|
+
|
|
488
|
+
this.host.setState(state);
|
|
403
489
|
} catch (error) {
|
|
404
490
|
this._emit('error', {
|
|
405
491
|
type: 'error',
|
|
@@ -439,7 +525,45 @@ class Automator {
|
|
|
439
525
|
}
|
|
440
526
|
|
|
441
527
|
/**
|
|
442
|
-
*
|
|
528
|
+
* Normalize catchUpWindow property (handles backwards compatibility with unBuffered)
|
|
529
|
+
*
|
|
530
|
+
* Priority:
|
|
531
|
+
* 1. catchUpWindow specified → normalize it (coerce Infinity to "unlimited" if needed)
|
|
532
|
+
* 2. unBuffered specified → convert to catchUpWindow equivalent
|
|
533
|
+
* 3. Neither specified → default to "unlimited" (catch up everything)
|
|
534
|
+
*
|
|
535
|
+
* @param {Object} spec - Action specification
|
|
536
|
+
* @returns {string|number} - Normalized catchUpWindow value ("unlimited" or milliseconds)
|
|
537
|
+
*/
|
|
538
|
+
_normalizeCatchUpWindow(spec) {
|
|
539
|
+
// New property takes precedence
|
|
540
|
+
if (spec.catchUpWindow !== undefined) {
|
|
541
|
+
// Coerce Infinity to "unlimited" (backwards compatibility)
|
|
542
|
+
if (spec.catchUpWindow === Infinity) {
|
|
543
|
+
this._emit('debug', {
|
|
544
|
+
type: 'debug',
|
|
545
|
+
message: 'Coercing catchUpWindow: Infinity → "unlimited"'
|
|
546
|
+
});
|
|
547
|
+
return "unlimited";
|
|
548
|
+
}
|
|
549
|
+
return spec.catchUpWindow;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Backwards compatibility mapping
|
|
553
|
+
if (spec.unBuffered !== undefined) {
|
|
554
|
+
return spec.unBuffered ? 0 : "unlimited";
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Default: catch up everything (current buffered behavior)
|
|
558
|
+
return "unlimited";
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Validate and normalize action specification
|
|
563
|
+
* Philosophy: "Fail loudly, run defensively"
|
|
564
|
+
* - Emit ERROR events for serious issues but coerce to reasonable defaults
|
|
565
|
+
* - Emit DEBUG events for auto-corrections
|
|
566
|
+
* - Never silently fail
|
|
443
567
|
*/
|
|
444
568
|
_validateAction(action) {
|
|
445
569
|
if (!action.cmd) {
|
|
@@ -448,16 +572,91 @@ class Automator {
|
|
|
448
572
|
|
|
449
573
|
if (action.repeat) {
|
|
450
574
|
const validTypes = ['second', 'minute', 'hour', 'day', 'weekday', 'weekend', 'week', 'month', 'year'];
|
|
451
|
-
|
|
452
|
-
|
|
575
|
+
|
|
576
|
+
// Defensive: Coerce invalid repeat.type to 'day' with ERROR event
|
|
577
|
+
if (!action.repeat.type || !validTypes.includes(action.repeat.type)) {
|
|
578
|
+
this._emit('error', {
|
|
579
|
+
type: 'error',
|
|
580
|
+
message: `Invalid repeat.type "${action.repeat.type}" - defaulting to "day"`,
|
|
581
|
+
actionSpec: action
|
|
582
|
+
});
|
|
583
|
+
action.repeat.type = 'day';
|
|
453
584
|
}
|
|
454
585
|
|
|
455
|
-
|
|
456
|
-
|
|
586
|
+
// Defensive: Coerce invalid interval to Math.max(1, Math.floor(value))
|
|
587
|
+
if (action.repeat.interval !== undefined) {
|
|
588
|
+
const original = action.repeat.interval;
|
|
589
|
+
const coerced = Math.max(1, Math.floor(original));
|
|
590
|
+
if (original !== coerced) {
|
|
591
|
+
this._emit('error', {
|
|
592
|
+
type: 'error',
|
|
593
|
+
message: `Invalid repeat.interval ${original} - coerced to ${coerced}`,
|
|
594
|
+
actionSpec: action
|
|
595
|
+
});
|
|
596
|
+
action.repeat.interval = coerced;
|
|
597
|
+
}
|
|
457
598
|
}
|
|
458
599
|
|
|
600
|
+
// Defensive: Validate dstPolicy
|
|
459
601
|
if (action.repeat.dstPolicy && !['once', 'twice'].includes(action.repeat.dstPolicy)) {
|
|
460
|
-
|
|
602
|
+
this._emit('error', {
|
|
603
|
+
type: 'error',
|
|
604
|
+
message: `Invalid dstPolicy "${action.repeat.dstPolicy}" - defaulting to "once"`,
|
|
605
|
+
actionSpec: action
|
|
606
|
+
});
|
|
607
|
+
action.repeat.dstPolicy = 'once';
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Defensive: Coerce invalid repeat.limit to null (unlimited) with ERROR event
|
|
611
|
+
if (action.repeat.limit !== undefined && action.repeat.limit !== null) {
|
|
612
|
+
if (typeof action.repeat.limit !== 'number' || action.repeat.limit < 1) {
|
|
613
|
+
this._emit('error', {
|
|
614
|
+
type: 'error',
|
|
615
|
+
message: `Invalid repeat.limit ${action.repeat.limit} - defaulting to null (unlimited)`,
|
|
616
|
+
actionSpec: action
|
|
617
|
+
});
|
|
618
|
+
action.repeat.limit = null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Defensive: Validate repeat.endDate
|
|
623
|
+
if (action.repeat.endDate !== undefined && action.repeat.endDate !== null) {
|
|
624
|
+
try {
|
|
625
|
+
new Date(action.repeat.endDate);
|
|
626
|
+
} catch (e) {
|
|
627
|
+
this._emit('error', {
|
|
628
|
+
type: 'error',
|
|
629
|
+
message: `Invalid repeat.endDate - ignoring`,
|
|
630
|
+
actionSpec: action
|
|
631
|
+
});
|
|
632
|
+
action.repeat.endDate = null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Defensive: Validate catchUpWindow if provided
|
|
638
|
+
if (action.catchUpWindow !== undefined) {
|
|
639
|
+
const isValidString = action.catchUpWindow === "unlimited";
|
|
640
|
+
const isNumber = typeof action.catchUpWindow === 'number';
|
|
641
|
+
const isInfinity = action.catchUpWindow === Infinity; // Allow for backwards compatibility
|
|
642
|
+
|
|
643
|
+
// Coerce negative numbers to 0 FIRST
|
|
644
|
+
if (isNumber && action.catchUpWindow < 0) {
|
|
645
|
+
this._emit('error', {
|
|
646
|
+
type: 'error',
|
|
647
|
+
message: `Negative catchUpWindow ${action.catchUpWindow} - coerced to 0`,
|
|
648
|
+
actionSpec: action
|
|
649
|
+
});
|
|
650
|
+
action.catchUpWindow = 0;
|
|
651
|
+
}
|
|
652
|
+
// Then validate it's a valid value
|
|
653
|
+
else if (!isValidString && !isNumber && !isInfinity) {
|
|
654
|
+
this._emit('error', {
|
|
655
|
+
type: 'error',
|
|
656
|
+
message: `Invalid catchUpWindow "${action.catchUpWindow}" - defaulting to "unlimited"`,
|
|
657
|
+
actionSpec: action
|
|
658
|
+
});
|
|
659
|
+
action.catchUpWindow = "unlimited";
|
|
461
660
|
}
|
|
462
661
|
}
|
|
463
662
|
}
|
package/src/core/CoreEngine.js
CHANGED
|
@@ -52,14 +52,47 @@ class CoreEngine {
|
|
|
52
52
|
let iterationCount = 0;
|
|
53
53
|
let currentNextRun = nextRun;
|
|
54
54
|
|
|
55
|
+
// Get catchUpWindow (defaults to "unlimited" for backwards compatibility)
|
|
56
|
+
const catchUpWindow = action.catchUpWindow !== undefined ? action.catchUpWindow : "unlimited";
|
|
57
|
+
|
|
58
|
+
// Fast-forward optimization: if we're far outside the catch-up window,
|
|
59
|
+
// jump directly to near the current time using mathematical projection
|
|
60
|
+
if (catchUpWindow !== "unlimited" && action.repeat) {
|
|
61
|
+
const lag = now.getTime() - currentNextRun.getTime();
|
|
62
|
+
|
|
63
|
+
if (lag > catchUpWindow * 2) {
|
|
64
|
+
// We're deep in the "Dead Zone" - use fast-forward
|
|
65
|
+
const fastForwardResult = this._fastForwardAction(action, now, catchUpWindow);
|
|
66
|
+
|
|
67
|
+
if (fastForwardResult) {
|
|
68
|
+
currentNextRun = fastForwardResult.nextRun;
|
|
69
|
+
action.date = currentNextRun;
|
|
70
|
+
action.count = fastForwardResult.count;
|
|
71
|
+
|
|
72
|
+
// After fast-forward, if still outside window, just advance without executing
|
|
73
|
+
if (currentNextRun <= now) {
|
|
74
|
+
const finalLag = now.getTime() - currentNextRun.getTime();
|
|
75
|
+
if (finalLag > catchUpWindow) {
|
|
76
|
+
// Still outside window after fast-forward, skip to next viable occurrence
|
|
77
|
+
this._advanceAction(action);
|
|
78
|
+
currentNextRun = action.date ? new Date(action.date) : null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
while (currentNextRun <= now && iterationCount < maxIterations) {
|
|
56
86
|
iterationCount++;
|
|
57
87
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
88
|
+
const lag = now.getTime() - currentNextRun.getTime();
|
|
89
|
+
|
|
90
|
+
// Determine if we should execute this occurrence based on catchUpWindow
|
|
61
91
|
const isInCurrentTick = currentNextRun > lastTick && currentNextRun <= now;
|
|
62
|
-
const
|
|
92
|
+
const isWithinWindow = catchUpWindow === "unlimited" || lag <= catchUpWindow;
|
|
93
|
+
|
|
94
|
+
// Legacy unBuffered behavior (maps to catchUpWindow: 0 or Infinity)
|
|
95
|
+
const shouldExecute = isWithinWindow;
|
|
63
96
|
|
|
64
97
|
if (shouldExecute) {
|
|
65
98
|
const event = this._executeAction(action, currentNextRun);
|
|
@@ -168,6 +201,77 @@ class CoreEngine {
|
|
|
168
201
|
}
|
|
169
202
|
}
|
|
170
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Fast-forward an action to near the current time using mathematical projection
|
|
206
|
+
* This avoids iterating through thousands/millions of occurrences for high-frequency tasks
|
|
207
|
+
*
|
|
208
|
+
* @param {Object} action - The action to fast-forward
|
|
209
|
+
* @param {Date} now - Current time
|
|
210
|
+
* @param {number} catchUpWindow - The catch-up window in milliseconds
|
|
211
|
+
* @returns {Object|null} - { nextRun, count } or null if not applicable
|
|
212
|
+
*/
|
|
213
|
+
static _fastForwardAction(action, now, catchUpWindow) {
|
|
214
|
+
if (!action.repeat || !action.date) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { type, interval = 1 } = action.repeat;
|
|
219
|
+
const currentTime = new Date(action.date);
|
|
220
|
+
const lag = now.getTime() - currentTime.getTime();
|
|
221
|
+
|
|
222
|
+
// Only applicable for simple time-based recurrence (not weekday/weekend)
|
|
223
|
+
let stepMilliseconds = 0;
|
|
224
|
+
|
|
225
|
+
switch (type) {
|
|
226
|
+
case 'second':
|
|
227
|
+
stepMilliseconds = interval * 1000;
|
|
228
|
+
break;
|
|
229
|
+
case 'minute':
|
|
230
|
+
stepMilliseconds = interval * 60 * 1000;
|
|
231
|
+
break;
|
|
232
|
+
case 'hour':
|
|
233
|
+
stepMilliseconds = interval * 60 * 60 * 1000;
|
|
234
|
+
break;
|
|
235
|
+
case 'day':
|
|
236
|
+
stepMilliseconds = interval * 24 * 60 * 60 * 1000;
|
|
237
|
+
break;
|
|
238
|
+
case 'week':
|
|
239
|
+
stepMilliseconds = interval * 7 * 24 * 60 * 60 * 1000;
|
|
240
|
+
break;
|
|
241
|
+
default:
|
|
242
|
+
// Complex recurrence (weekday, weekend, month, year) - can't fast-forward easily
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (stepMilliseconds === 0) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Calculate how many steps we can skip
|
|
251
|
+
// We want to jump to just before the catch-up window starts
|
|
252
|
+
const targetLag = catchUpWindow;
|
|
253
|
+
const timeToSkip = lag - targetLag;
|
|
254
|
+
|
|
255
|
+
if (timeToSkip <= 0) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const stepsToSkip = Math.floor(timeToSkip / stepMilliseconds);
|
|
260
|
+
|
|
261
|
+
if (stepsToSkip <= 0) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Project forward
|
|
266
|
+
const newTime = new Date(currentTime.getTime() + (stepsToSkip * stepMilliseconds));
|
|
267
|
+
const newCount = (action.count || 0) + stepsToSkip;
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
nextRun: newTime,
|
|
271
|
+
count: newCount
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
171
275
|
/**
|
|
172
276
|
* Simulate actions in a time range without mutating state
|
|
173
277
|
*/
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|