jw-automator 4.0.0 → 6.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 +0 -0
- package/README.md +392 -99
- package/docs/11a-macro-fix.md +56 -0
- package/docs/ARCHITECTURE.md +88 -73
- package/docs/MIGRATION.md +218 -54
- package/docs/QUICKSTART.md +63 -48
- package/docs/defensive-defaults.md +9 -9
- package/examples/basic-example.js +24 -9
- package/examples/hello-world.js +2 -2
- package/examples/iot-sensor-example.js +6 -6
- package/examples/seed-example.js +6 -6
- package/index.js +0 -0
- package/package.json +2 -2
- package/src/Automator.js +558 -313
- package/src/core/CoreEngine.js +103 -159
- package/src/core/RecurrenceEngine.js +67 -6
- package/src/host/SchedulerHost.js +60 -14
- package/src/storage/FileStorage.js +0 -59
- package/src/storage/MemoryStorage.js +0 -27
package/src/Automator.js
CHANGED
|
@@ -1,33 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Automator.js
|
|
3
3
|
*
|
|
4
|
-
* Main API class for jw-automator
|
|
4
|
+
* Main API class for jw-automator v5
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
7
9
|
const SchedulerHost = require('./host/SchedulerHost');
|
|
8
10
|
const CoreEngine = require('./core/CoreEngine');
|
|
9
|
-
const FileStorage = require('./storage/FileStorage');
|
|
10
|
-
const MemoryStorage = require('./storage/MemoryStorage');
|
|
11
11
|
|
|
12
12
|
class Automator {
|
|
13
13
|
constructor(options = {}) {
|
|
14
14
|
this.options = {
|
|
15
|
-
|
|
15
|
+
storageFile: options.storageFile || null,
|
|
16
16
|
autoSave: options.autoSave !== false, // default true
|
|
17
|
-
saveInterval: options.saveInterval ||
|
|
17
|
+
saveInterval: options.saveInterval || 15000, // 15 seconds (disk wear mitigation)
|
|
18
|
+
bootMode: options.bootMode !== false, // default true
|
|
19
|
+
defaultCatchUpMode: options.defaultCatchUpMode || 'default',
|
|
18
20
|
...options
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
this.host = new SchedulerHost();
|
|
22
24
|
this.nextId = 1;
|
|
23
|
-
|
|
25
|
+
|
|
26
|
+
// Create the save manager, which encapsulates all persistence logic.
|
|
27
|
+
this._saveManager = this._createSaveManager(
|
|
28
|
+
() => this._performSave(),
|
|
29
|
+
this.options.saveInterval
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Disable boot mode immediately if configured
|
|
33
|
+
if (this.options.bootMode === false) {
|
|
34
|
+
this.host.bootMode = false;
|
|
35
|
+
}
|
|
24
36
|
|
|
25
37
|
// Forward events from host
|
|
26
38
|
this.host.on('ready', (...args) => this._emit('ready', ...args));
|
|
27
|
-
this.host.on('
|
|
39
|
+
this.host.on('task', (...args) => {
|
|
40
|
+
// Task execution: ask to save (respects moratorium)
|
|
41
|
+
this._requestSave(false);
|
|
42
|
+
this._emit('task', ...args);
|
|
43
|
+
});
|
|
28
44
|
this.host.on('error', (...args) => this._emit('error', ...args));
|
|
29
45
|
this.host.on('debug', (...args) => this._emit('debug', ...args));
|
|
30
46
|
|
|
47
|
+
// Save state after boot completes
|
|
48
|
+
this.host.on('boot-complete', () => {
|
|
49
|
+
if (this.options.autoSave) {
|
|
50
|
+
this._requestSave(true);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
31
54
|
// Event listeners
|
|
32
55
|
this.listeners = new Map();
|
|
33
56
|
|
|
@@ -36,30 +59,34 @@ class Automator {
|
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
/**
|
|
39
|
-
* Seed the automator with initial
|
|
62
|
+
* Seed the automator with initial tasks (runs only on first use)
|
|
40
63
|
*
|
|
41
64
|
* @param {Function} callback - Function to execute when database is empty
|
|
42
|
-
* @returns {
|
|
65
|
+
* @returns {Object} - Result object with success/error
|
|
43
66
|
*/
|
|
44
67
|
seed(callback) {
|
|
45
68
|
if (typeof callback !== 'function') {
|
|
46
|
-
|
|
69
|
+
return this._error(
|
|
70
|
+
'seed() requires a callback function',
|
|
71
|
+
'INVALID_CALLBACK',
|
|
72
|
+
{ providedType: typeof callback }
|
|
73
|
+
);
|
|
47
74
|
}
|
|
48
75
|
|
|
49
76
|
const state = this.host.getState();
|
|
50
77
|
|
|
51
78
|
// Check if already populated
|
|
52
|
-
if (state.
|
|
53
|
-
return false;
|
|
79
|
+
if (state.tasks && state.tasks.length > 0) {
|
|
80
|
+
return this._success({ seeded: false, message: 'Database already populated' });
|
|
54
81
|
}
|
|
55
82
|
|
|
56
83
|
// Execute seeding callback
|
|
57
84
|
callback(this);
|
|
58
85
|
|
|
59
|
-
//
|
|
60
|
-
this.
|
|
86
|
+
// Save immediately
|
|
87
|
+
this._requestSave(true);
|
|
61
88
|
|
|
62
|
-
return true;
|
|
89
|
+
return this._success({ seeded: true, message: 'Database seeded successfully' });
|
|
63
90
|
}
|
|
64
91
|
|
|
65
92
|
/**
|
|
@@ -67,11 +94,7 @@ class Automator {
|
|
|
67
94
|
*/
|
|
68
95
|
start() {
|
|
69
96
|
this.host.start();
|
|
70
|
-
|
|
71
|
-
// Start auto-save if enabled
|
|
72
|
-
if (this.options.autoSave) {
|
|
73
|
-
this._startAutoSave();
|
|
74
|
-
}
|
|
97
|
+
// No periodic timer needed - moratorium state machine handles saves
|
|
75
98
|
}
|
|
76
99
|
|
|
77
100
|
/**
|
|
@@ -80,15 +103,10 @@ class Automator {
|
|
|
80
103
|
stop() {
|
|
81
104
|
this.host.stop();
|
|
82
105
|
|
|
83
|
-
//
|
|
84
|
-
if (this.saveTimer) {
|
|
85
|
-
clearInterval(this.saveTimer);
|
|
86
|
-
this.saveTimer = null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Final save
|
|
106
|
+
// On shutdown, flush any pending saves and cancel any future timers.
|
|
90
107
|
if (this.options.autoSave) {
|
|
91
|
-
this.
|
|
108
|
+
this._saveManager.flush();
|
|
109
|
+
this._saveManager.cancel();
|
|
92
110
|
}
|
|
93
111
|
}
|
|
94
112
|
|
|
@@ -107,265 +125,309 @@ class Automator {
|
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
/**
|
|
110
|
-
* Add
|
|
128
|
+
* Add a task
|
|
129
|
+
* @returns {Object} - Result object with success/error
|
|
111
130
|
*/
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
this.
|
|
131
|
+
addTask(taskSpec) {
|
|
132
|
+
// Normalize catch-up settings using the helper
|
|
133
|
+
const { catchUpWindow, catchUpLimit } = this._normalizeCatchUpSettings(taskSpec);
|
|
115
134
|
|
|
116
|
-
//
|
|
117
|
-
const
|
|
135
|
+
// Validate task (returns error result if invalid)
|
|
136
|
+
const validationResult = this._validateTask(taskSpec);
|
|
137
|
+
if (!validationResult.success) {
|
|
138
|
+
return validationResult;
|
|
139
|
+
}
|
|
118
140
|
|
|
119
141
|
// Defensive: Default missing date to 5 seconds from now
|
|
120
142
|
let startDate;
|
|
121
|
-
if (!
|
|
143
|
+
if (!taskSpec.date) {
|
|
122
144
|
startDate = new Date(Date.now() + 5000);
|
|
123
145
|
this._emit('debug', {
|
|
124
146
|
type: 'debug',
|
|
125
147
|
message: 'No date provided - defaulting to 5 seconds from now'
|
|
126
148
|
});
|
|
127
149
|
} else {
|
|
128
|
-
startDate = new Date(
|
|
150
|
+
startDate = new Date(taskSpec.date);
|
|
129
151
|
}
|
|
130
152
|
|
|
131
|
-
// Create
|
|
132
|
-
const
|
|
153
|
+
// Create task with state
|
|
154
|
+
const task = {
|
|
133
155
|
id: this.nextId++,
|
|
134
|
-
name:
|
|
135
|
-
cmd:
|
|
136
|
-
payload:
|
|
156
|
+
name: taskSpec.name || null,
|
|
157
|
+
cmd: taskSpec.cmd,
|
|
158
|
+
payload: taskSpec.payload !== undefined ? taskSpec.payload : null,
|
|
137
159
|
date: startDate,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
repeat:
|
|
160
|
+
catchUpWindow: catchUpWindow, // Use normalized value
|
|
161
|
+
catchUpLimit: catchUpLimit, // Use normalized value
|
|
162
|
+
repeat: taskSpec.repeat ? { ...taskSpec.repeat } : null,
|
|
141
163
|
count: 0
|
|
142
164
|
};
|
|
143
165
|
|
|
144
166
|
// Set default dstPolicy if not specified
|
|
145
|
-
if (
|
|
146
|
-
|
|
167
|
+
if (task.repeat && !task.repeat.dstPolicy) {
|
|
168
|
+
task.repeat.dstPolicy = 'once';
|
|
147
169
|
}
|
|
148
170
|
|
|
149
|
-
this.host.
|
|
171
|
+
this.host.addTask(task);
|
|
150
172
|
|
|
151
173
|
this._emit('update', {
|
|
152
174
|
type: 'update',
|
|
153
175
|
operation: 'add',
|
|
154
|
-
|
|
176
|
+
task: { ...task }
|
|
155
177
|
});
|
|
156
178
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
179
|
+
// Save immediately
|
|
180
|
+
this._requestSave(true);
|
|
160
181
|
|
|
161
|
-
return
|
|
182
|
+
return this._success({ id: task.id });
|
|
162
183
|
}
|
|
163
184
|
|
|
164
185
|
/**
|
|
165
|
-
* Update
|
|
186
|
+
* Update a task by ID
|
|
187
|
+
* @returns {Object} - Result object with success/error
|
|
166
188
|
*/
|
|
167
|
-
|
|
189
|
+
updateTaskByID(id, updates) {
|
|
168
190
|
const state = this.host.getState();
|
|
169
|
-
const
|
|
191
|
+
const task = state.tasks.find(t => t.id === id);
|
|
170
192
|
|
|
171
|
-
if (!
|
|
172
|
-
|
|
193
|
+
if (!task) {
|
|
194
|
+
return this._error(
|
|
195
|
+
`Task with id ${id} not found`,
|
|
196
|
+
'TASK_NOT_FOUND',
|
|
197
|
+
{ taskId: id }
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Normalize catch-up settings using the helper, passing existing task for context
|
|
202
|
+
const { catchUpWindow, catchUpLimit } = this._normalizeCatchUpSettings(updates, task);
|
|
203
|
+
|
|
204
|
+
// Apply resolved catchUp values to updates object
|
|
205
|
+
updates.catchUpWindow = catchUpWindow;
|
|
206
|
+
updates.catchUpLimit = catchUpLimit;
|
|
207
|
+
|
|
208
|
+
// Validate catchUpWindow and catchUpLimit if being updated
|
|
209
|
+
if ('catchUpWindow' in updates) {
|
|
210
|
+
const result = this._validateCatchUpWindow(updates.catchUpWindow);
|
|
211
|
+
if (!result.success) return result;
|
|
212
|
+
}
|
|
213
|
+
if ('catchUpLimit' in updates) {
|
|
214
|
+
const result = this._validateCatchUpLimit(updates.catchUpLimit);
|
|
215
|
+
if (!result.success) return result;
|
|
173
216
|
}
|
|
174
217
|
|
|
175
218
|
// Update allowed fields
|
|
176
|
-
const allowedUpdates = ['name', 'cmd', 'payload', 'date', '
|
|
219
|
+
const allowedUpdates = ['name', 'cmd', 'payload', 'date', 'catchUpWindow', 'catchUpLimit', 'repeat', 'count'];
|
|
177
220
|
|
|
178
221
|
for (const key of allowedUpdates) {
|
|
179
222
|
if (key in updates) {
|
|
180
223
|
if (key === 'date' && updates[key]) {
|
|
181
|
-
|
|
224
|
+
task[key] = new Date(updates[key]);
|
|
182
225
|
} else if (key === 'repeat' && updates[key]) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
226
|
+
// Merge repeat object instead of overwriting, to preserve existing fields
|
|
227
|
+
task[key] = { ...(task[key] || {}), ...updates[key] };
|
|
228
|
+
if (!task[key].dstPolicy) {
|
|
229
|
+
task[key].dstPolicy = 'once';
|
|
186
230
|
}
|
|
187
231
|
} else {
|
|
188
|
-
|
|
232
|
+
task[key] = updates[key];
|
|
189
233
|
}
|
|
190
234
|
}
|
|
191
235
|
}
|
|
192
236
|
|
|
193
|
-
// Re-normalize catchUpWindow if unBuffered or catchUpWindow was updated
|
|
194
|
-
if ('unBuffered' in updates || 'catchUpWindow' in updates) {
|
|
195
|
-
action.catchUpWindow = this._normalizeCatchUpWindow(action);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
237
|
this._emit('update', {
|
|
199
238
|
type: 'update',
|
|
200
239
|
operation: 'update',
|
|
201
|
-
|
|
202
|
-
|
|
240
|
+
taskId: id,
|
|
241
|
+
task: { ...task }
|
|
203
242
|
});
|
|
204
243
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
244
|
+
// Save immediately
|
|
245
|
+
this._requestSave(true);
|
|
246
|
+
|
|
247
|
+
return this._success({ id, task: { ...task } });
|
|
208
248
|
}
|
|
209
249
|
|
|
210
250
|
/**
|
|
211
|
-
* Update
|
|
251
|
+
* Update tasks by name
|
|
252
|
+
* @returns {Object} - Result object with success/error
|
|
212
253
|
*/
|
|
213
|
-
|
|
254
|
+
updateTaskByName(name, updates) {
|
|
214
255
|
const state = this.host.getState();
|
|
215
|
-
const toUpdate = state.
|
|
256
|
+
const toUpdate = state.tasks.filter(t => t.name === name);
|
|
216
257
|
|
|
217
258
|
if (toUpdate.length === 0) {
|
|
218
|
-
|
|
219
|
-
// However, it might be more convenient to simply return 0.
|
|
220
|
-
// Let's return 0 for now.
|
|
221
|
-
return 0;
|
|
259
|
+
return this._success({ count: 0 }); // Not an error, just no matches
|
|
222
260
|
}
|
|
223
261
|
|
|
224
|
-
|
|
262
|
+
// Create a mutable copy of updates for each task so _normalizeCatchUpSettings can modify it
|
|
263
|
+
const clonedUpdates = { ...updates };
|
|
264
|
+
// Normalize catch-up settings using the helper, passing existing task for context
|
|
265
|
+
// This needs to be done for each task because 'auto' mode might depend on task.repeat
|
|
266
|
+
const normalizedCatchUp = this._normalizeCatchUpSettings(clonedUpdates, toUpdate[0]); // Normalize once, assumes all tasks of same name have similar repeat structure for 'auto' mode
|
|
225
267
|
|
|
226
|
-
|
|
268
|
+
// Apply resolved catchUp values to clonedUpdates object
|
|
269
|
+
clonedUpdates.catchUpWindow = normalizedCatchUp.catchUpWindow;
|
|
270
|
+
clonedUpdates.catchUpLimit = normalizedCatchUp.catchUpLimit;
|
|
271
|
+
|
|
272
|
+
// Validate catchUpWindow and catchUpLimit if being updated
|
|
273
|
+
if ('catchUpWindow' in clonedUpdates) {
|
|
274
|
+
const result = this._validateCatchUpWindow(clonedUpdates.catchUpWindow);
|
|
275
|
+
if (!result.success) return result;
|
|
276
|
+
}
|
|
277
|
+
if ('catchUpLimit' in clonedUpdates) {
|
|
278
|
+
const result = this._validateCatchUpLimit(clonedUpdates.catchUpLimit);
|
|
279
|
+
if (!result.success) return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const allowedUpdates = ['cmd', 'payload', 'date', 'catchUpWindow', 'catchUpLimit', 'repeat', 'count'];
|
|
283
|
+
|
|
284
|
+
for (const task of toUpdate) {
|
|
227
285
|
for (const key of allowedUpdates) {
|
|
228
|
-
if (key in
|
|
229
|
-
if (key === 'date' &&
|
|
230
|
-
|
|
231
|
-
} else if (key === 'repeat' &&
|
|
232
|
-
|
|
233
|
-
if (!
|
|
234
|
-
|
|
286
|
+
if (key in clonedUpdates) { // Use clonedUpdates here
|
|
287
|
+
if (key === 'date' && clonedUpdates[key]) {
|
|
288
|
+
task[key] = new Date(clonedUpdates[key]);
|
|
289
|
+
} else if (key === 'repeat' && clonedUpdates[key]) {
|
|
290
|
+
task[key] = { ...(task[key] || {}), ...clonedUpdates[key] };
|
|
291
|
+
if (!task[key].dstPolicy) {
|
|
292
|
+
task[key].dstPolicy = 'once';
|
|
235
293
|
}
|
|
236
294
|
} else {
|
|
237
|
-
|
|
295
|
+
task[key] = clonedUpdates[key];
|
|
238
296
|
}
|
|
239
297
|
}
|
|
240
298
|
}
|
|
241
299
|
|
|
242
|
-
// Re-normalize catchUpWindow if unBuffered or catchUpWindow was updated
|
|
243
|
-
if ('unBuffered' in updates || 'catchUpWindow' in updates) {
|
|
244
|
-
action.catchUpWindow = this._normalizeCatchUpWindow(action);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
300
|
this._emit('update', {
|
|
248
301
|
type: 'update',
|
|
249
302
|
operation: 'update',
|
|
250
|
-
|
|
251
|
-
|
|
303
|
+
taskId: task.id,
|
|
304
|
+
task: { ...task }
|
|
252
305
|
});
|
|
253
306
|
}
|
|
254
307
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
308
|
+
// Save immediately
|
|
309
|
+
this._requestSave(true);
|
|
258
310
|
|
|
259
|
-
return toUpdate.length;
|
|
311
|
+
return this._success({ count: toUpdate.length });
|
|
260
312
|
}
|
|
261
313
|
|
|
262
314
|
/**
|
|
263
|
-
* Remove
|
|
315
|
+
* Remove task by ID
|
|
316
|
+
* @returns {Object} - Result object with success/error
|
|
264
317
|
*/
|
|
265
|
-
|
|
318
|
+
removeTaskByID(id) {
|
|
266
319
|
const state = this.host.getState();
|
|
267
|
-
const index = state.
|
|
320
|
+
const index = state.tasks.findIndex(t => t.id === id);
|
|
268
321
|
|
|
269
322
|
if (index === -1) {
|
|
270
|
-
|
|
323
|
+
return this._error(
|
|
324
|
+
`Task with id ${id} not found`,
|
|
325
|
+
'TASK_NOT_FOUND',
|
|
326
|
+
{ taskId: id }
|
|
327
|
+
);
|
|
271
328
|
}
|
|
272
329
|
|
|
273
|
-
const removed = state.
|
|
330
|
+
const removed = state.tasks.splice(index, 1)[0];
|
|
274
331
|
|
|
275
332
|
this._emit('update', {
|
|
276
333
|
type: 'update',
|
|
277
334
|
operation: 'remove',
|
|
278
|
-
|
|
279
|
-
|
|
335
|
+
taskId: id,
|
|
336
|
+
task: removed
|
|
280
337
|
});
|
|
281
338
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
339
|
+
// Save immediately
|
|
340
|
+
this._requestSave(true);
|
|
341
|
+
|
|
342
|
+
return this._success({ id, task: removed });
|
|
285
343
|
}
|
|
286
344
|
|
|
287
345
|
/**
|
|
288
|
-
* Remove
|
|
346
|
+
* Remove tasks by name
|
|
347
|
+
* @returns {Object} - Result object with success/error
|
|
289
348
|
*/
|
|
290
|
-
|
|
349
|
+
removeTaskByName(name) {
|
|
291
350
|
const state = this.host.getState();
|
|
292
|
-
const toRemove = state.
|
|
351
|
+
const toRemove = state.tasks.filter(t => t.name === name);
|
|
293
352
|
|
|
294
353
|
if (toRemove.length === 0) {
|
|
295
|
-
|
|
354
|
+
return this._error(
|
|
355
|
+
`No tasks found with name: ${name}`,
|
|
356
|
+
'NO_TASKS_FOUND',
|
|
357
|
+
{ taskName: name }
|
|
358
|
+
);
|
|
296
359
|
}
|
|
297
360
|
|
|
298
|
-
for (const
|
|
299
|
-
const index = state.
|
|
361
|
+
for (const task of toRemove) {
|
|
362
|
+
const index = state.tasks.indexOf(task);
|
|
300
363
|
if (index !== -1) {
|
|
301
|
-
state.
|
|
364
|
+
state.tasks.splice(index, 1);
|
|
302
365
|
|
|
303
366
|
this._emit('update', {
|
|
304
367
|
type: 'update',
|
|
305
368
|
operation: 'remove',
|
|
306
|
-
|
|
307
|
-
|
|
369
|
+
taskId: task.id,
|
|
370
|
+
task
|
|
308
371
|
});
|
|
309
372
|
}
|
|
310
373
|
}
|
|
311
374
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
375
|
+
// Save immediately
|
|
376
|
+
this._requestSave(true);
|
|
315
377
|
|
|
316
|
-
return toRemove.length;
|
|
378
|
+
return this._success({ count: toRemove.length });
|
|
317
379
|
}
|
|
318
380
|
|
|
319
381
|
/**
|
|
320
|
-
* Deep clone
|
|
382
|
+
* Deep clone a task
|
|
321
383
|
*/
|
|
322
|
-
|
|
323
|
-
const cloned = { ...
|
|
384
|
+
_cloneTask(task) {
|
|
385
|
+
const cloned = { ...task };
|
|
324
386
|
|
|
325
387
|
// Deep copy nested objects
|
|
326
|
-
if (
|
|
327
|
-
cloned.repeat = { ...
|
|
388
|
+
if (task.repeat) {
|
|
389
|
+
cloned.repeat = { ...task.repeat };
|
|
328
390
|
}
|
|
329
391
|
|
|
330
392
|
// Clone Date objects properly
|
|
331
|
-
if (
|
|
332
|
-
cloned.date = new Date(
|
|
393
|
+
if (task.date) {
|
|
394
|
+
cloned.date = new Date(task.date);
|
|
333
395
|
}
|
|
334
396
|
|
|
335
397
|
return cloned;
|
|
336
398
|
}
|
|
337
399
|
|
|
338
400
|
/**
|
|
339
|
-
* Get all
|
|
401
|
+
* Get all tasks (deep copy)
|
|
340
402
|
*/
|
|
341
|
-
|
|
403
|
+
getTasks() {
|
|
342
404
|
const state = this.host.getState();
|
|
343
|
-
return state.
|
|
405
|
+
return state.tasks.map(t => this._cloneTask(t));
|
|
344
406
|
}
|
|
345
407
|
|
|
346
408
|
/**
|
|
347
|
-
* Get
|
|
409
|
+
* Get tasks by name
|
|
348
410
|
*/
|
|
349
|
-
|
|
411
|
+
getTasksByName(name) {
|
|
350
412
|
const state = this.host.getState();
|
|
351
|
-
return state.
|
|
352
|
-
.filter(
|
|
353
|
-
.map(
|
|
413
|
+
return state.tasks
|
|
414
|
+
.filter(t => t.name === name)
|
|
415
|
+
.map(t => this._cloneTask(t));
|
|
354
416
|
}
|
|
355
417
|
|
|
356
418
|
/**
|
|
357
|
-
* Get
|
|
419
|
+
* Get task by ID
|
|
358
420
|
*/
|
|
359
|
-
|
|
421
|
+
getTaskByID(id) {
|
|
360
422
|
const state = this.host.getState();
|
|
361
|
-
const
|
|
362
|
-
return
|
|
423
|
+
const task = state.tasks.find(t => t.id === id);
|
|
424
|
+
return task ? this._cloneTask(task) : null;
|
|
363
425
|
}
|
|
364
426
|
|
|
365
427
|
/**
|
|
366
|
-
* Get
|
|
428
|
+
* Get tasks scheduled in a time range
|
|
367
429
|
*/
|
|
368
|
-
|
|
430
|
+
getTasksInRange(startDate, endDate, callback) {
|
|
369
431
|
const start = new Date(startDate);
|
|
370
432
|
const end = new Date(endDate);
|
|
371
433
|
|
|
@@ -380,43 +442,44 @@ class Automator {
|
|
|
380
442
|
}
|
|
381
443
|
|
|
382
444
|
/**
|
|
383
|
-
* Simulate range (alias for
|
|
445
|
+
* Simulate range (alias for getTasksInRange)
|
|
384
446
|
*/
|
|
385
447
|
simulateRange(startDate, endDate) {
|
|
386
|
-
return this.
|
|
448
|
+
return this.getTasksInRange(startDate, endDate);
|
|
387
449
|
}
|
|
388
450
|
|
|
389
451
|
/**
|
|
390
|
-
* Describe
|
|
452
|
+
* Describe a task in human-readable format
|
|
391
453
|
*/
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
if (!
|
|
454
|
+
describeTask(id) {
|
|
455
|
+
const task = this.getTaskByID(id);
|
|
456
|
+
if (!task) {
|
|
395
457
|
return null;
|
|
396
458
|
}
|
|
397
459
|
|
|
398
|
-
let description = `
|
|
399
|
-
if (
|
|
400
|
-
description += ` - ${
|
|
460
|
+
let description = `Task #${task.id}`;
|
|
461
|
+
if (task.name) {
|
|
462
|
+
description += ` - ${task.name}`;
|
|
401
463
|
}
|
|
402
464
|
|
|
403
|
-
description += `\n Command: ${
|
|
404
|
-
description += `\n Next run: ${
|
|
405
|
-
description += `\n Executions: ${
|
|
406
|
-
description += `\n
|
|
465
|
+
description += `\n Command: ${task.cmd}`;
|
|
466
|
+
description += `\n Next run: ${task.date ? task.date.toLocaleString() : 'None'}`;
|
|
467
|
+
description += `\n Executions: ${task.count}`;
|
|
468
|
+
description += `\n Catch-up Window: ${task.catchUpWindow === 'unlimited' ? 'unlimited' : `${task.catchUpWindow}ms`}`;
|
|
469
|
+
description += `\n Catch-up Limit: ${task.catchUpLimit === 'all' ? 'all' : task.catchUpLimit}`;
|
|
407
470
|
|
|
408
|
-
if (
|
|
409
|
-
description += `\n Recurrence: ${
|
|
410
|
-
if (
|
|
411
|
-
description += ` (every ${
|
|
471
|
+
if (task.repeat) {
|
|
472
|
+
description += `\n Recurrence: ${task.repeat.type}`;
|
|
473
|
+
if (task.repeat.interval > 1) {
|
|
474
|
+
description += ` (every ${task.repeat.interval})`;
|
|
412
475
|
}
|
|
413
|
-
if (
|
|
414
|
-
description += `\n Limit: ${
|
|
476
|
+
if (task.repeat.limit) {
|
|
477
|
+
description += `\n Limit: ${task.repeat.limit}`;
|
|
415
478
|
}
|
|
416
|
-
if (
|
|
417
|
-
description += `\n End date: ${new Date(
|
|
479
|
+
if (task.repeat.endDate) {
|
|
480
|
+
description += `\n End date: ${new Date(task.repeat.endDate).toLocaleString()}`;
|
|
418
481
|
}
|
|
419
|
-
description += `\n DST policy: ${
|
|
482
|
+
description += `\n DST policy: ${task.repeat.dstPolicy}`;
|
|
420
483
|
} else {
|
|
421
484
|
description += `\n Recurrence: One-time`;
|
|
422
485
|
}
|
|
@@ -424,6 +487,73 @@ class Automator {
|
|
|
424
487
|
return description;
|
|
425
488
|
}
|
|
426
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Creates a self-contained save manager that replicates the moratorium logic.
|
|
492
|
+
* This avoids external dependencies while still encapsulating the complex state.
|
|
493
|
+
* @param {Function} func The save function to wrap.
|
|
494
|
+
* @param {number} interval The cooldown interval in milliseconds.
|
|
495
|
+
* @returns {Function} A manager function with .flush() and .cancel() methods.
|
|
496
|
+
* @private
|
|
497
|
+
*/
|
|
498
|
+
_createSaveManager(func, interval) {
|
|
499
|
+
let moratoriumActive = false;
|
|
500
|
+
let stateDirty = false;
|
|
501
|
+
let moratoriumTimer = null;
|
|
502
|
+
|
|
503
|
+
const manager = (force = false) => {
|
|
504
|
+
if (force) {
|
|
505
|
+
// A forced call immediately saves, marks as clean, and starts a new cooldown.
|
|
506
|
+
func();
|
|
507
|
+
stateDirty = false;
|
|
508
|
+
startMoratorium();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// A non-forced call is an "ask". Mark as dirty.
|
|
513
|
+
stateDirty = true;
|
|
514
|
+
// If in a cooldown, do nothing more. The trailing call will handle it.
|
|
515
|
+
if (moratoriumActive) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// If not in a cooldown, perform the leading-edge save.
|
|
520
|
+
func();
|
|
521
|
+
stateDirty = false;
|
|
522
|
+
startMoratorium();
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
function startMoratorium() {
|
|
526
|
+
moratoriumActive = true;
|
|
527
|
+
// Clear any existing timer to restart the cooldown period.
|
|
528
|
+
if (moratoriumTimer) {
|
|
529
|
+
clearTimeout(moratoriumTimer);
|
|
530
|
+
}
|
|
531
|
+
moratoriumTimer = setTimeout(onMoratoriumEnd, interval);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function onMoratoriumEnd() {
|
|
535
|
+
moratoriumActive = false;
|
|
536
|
+
moratoriumTimer = null;
|
|
537
|
+
// If changes occurred during the cooldown, perform the trailing-edge save.
|
|
538
|
+
if (stateDirty) {
|
|
539
|
+
manager(false);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Method to force an immediate save.
|
|
544
|
+
manager.flush = () => manager(true);
|
|
545
|
+
|
|
546
|
+
// Method to clear any pending timers, e.g., on shutdown.
|
|
547
|
+
manager.cancel = () => {
|
|
548
|
+
if (moratoriumTimer) {
|
|
549
|
+
clearTimeout(moratoriumTimer);
|
|
550
|
+
moratoriumTimer = null;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
return manager;
|
|
555
|
+
}
|
|
556
|
+
|
|
427
557
|
/**
|
|
428
558
|
* Event listener management
|
|
429
559
|
*/
|
|
@@ -459,248 +589,363 @@ class Automator {
|
|
|
459
589
|
}
|
|
460
590
|
}
|
|
461
591
|
|
|
592
|
+
/**
|
|
593
|
+
* Create a success result object
|
|
594
|
+
* @private
|
|
595
|
+
*/
|
|
596
|
+
_success(data) {
|
|
597
|
+
return { success: true, ...data };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Create an error result object and emit error event
|
|
602
|
+
* @private
|
|
603
|
+
*/
|
|
604
|
+
_error(message, code, additionalData = {}) {
|
|
605
|
+
const result = {
|
|
606
|
+
success: false,
|
|
607
|
+
error: message,
|
|
608
|
+
code,
|
|
609
|
+
...additionalData
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// Still emit error event for logging/monitoring
|
|
613
|
+
this._emit('error', {
|
|
614
|
+
type: 'validation_error',
|
|
615
|
+
message,
|
|
616
|
+
code,
|
|
617
|
+
...additionalData
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
|
|
462
623
|
/**
|
|
463
624
|
* Load state from storage
|
|
464
625
|
*/
|
|
465
626
|
_loadState() {
|
|
627
|
+
if (!this.options.storageFile) {
|
|
628
|
+
// Memory-only: start with empty state
|
|
629
|
+
this.host.setState({ tasks: [] });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
466
633
|
try {
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
// Normalize catchUpWindow for existing actions (for backwards compatibility)
|
|
470
|
-
if (state.actions && state.actions.length > 0) {
|
|
471
|
-
state.actions = state.actions.map(action => ({
|
|
472
|
-
...action,
|
|
473
|
-
catchUpWindow: action.catchUpWindow !== undefined
|
|
474
|
-
? action.catchUpWindow
|
|
475
|
-
: this._normalizeCatchUpWindow(action)
|
|
476
|
-
}));
|
|
477
|
-
|
|
478
|
-
const maxId = Math.max(...state.actions.map(a => a.id || 0));
|
|
479
|
-
this.nextId = maxId + 1;
|
|
480
|
-
}
|
|
634
|
+
const filePath = path.resolve(this.options.storageFile);
|
|
481
635
|
|
|
482
|
-
|
|
636
|
+
if (fs.existsSync(filePath)) {
|
|
637
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
638
|
+
const state = JSON.parse(data);
|
|
639
|
+
|
|
640
|
+
// Deserialize Date objects
|
|
641
|
+
if (state.tasks) {
|
|
642
|
+
state.tasks = state.tasks.map(task => {
|
|
643
|
+
if (task.date) {
|
|
644
|
+
task.date = new Date(task.date);
|
|
645
|
+
}
|
|
646
|
+
if (task.repeat && task.repeat.endDate) {
|
|
647
|
+
task.repeat.endDate = new Date(task.repeat.endDate);
|
|
648
|
+
}
|
|
649
|
+
return task;
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Set nextId from loaded state
|
|
654
|
+
if (state.tasks && state.tasks.length > 0) {
|
|
655
|
+
const maxId = Math.max(...state.tasks.map(t => t.id || 0));
|
|
656
|
+
this.nextId = maxId + 1;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
this.host.setState(state);
|
|
660
|
+
} else {
|
|
661
|
+
// File doesn't exist yet
|
|
662
|
+
this.host.setState({ tasks: [] });
|
|
663
|
+
}
|
|
483
664
|
} catch (error) {
|
|
484
665
|
this._emit('error', {
|
|
485
666
|
type: 'error',
|
|
486
667
|
message: `Failed to load state: ${error.message}`,
|
|
487
668
|
error
|
|
488
669
|
});
|
|
670
|
+
// Defensive: continue with empty state
|
|
671
|
+
this.host.setState({ tasks: [] });
|
|
489
672
|
}
|
|
490
673
|
}
|
|
491
674
|
|
|
492
675
|
/**
|
|
493
|
-
*
|
|
676
|
+
* Perform actual save to storage (called by state machine)
|
|
677
|
+
* @private
|
|
494
678
|
*/
|
|
495
|
-
|
|
679
|
+
_performSave() {
|
|
680
|
+
if (!this.options.storageFile) {
|
|
681
|
+
// Memory-only: no-op
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
496
685
|
try {
|
|
686
|
+
const filePath = path.resolve(this.options.storageFile);
|
|
497
687
|
const state = this.host.getState();
|
|
498
|
-
|
|
688
|
+
const data = JSON.stringify(state, null, 2);
|
|
689
|
+
|
|
690
|
+
fs.writeFileSync(filePath, data, 'utf8');
|
|
499
691
|
} catch (error) {
|
|
500
692
|
this._emit('error', {
|
|
501
693
|
type: 'error',
|
|
502
694
|
message: `Failed to save state: ${error.message}`,
|
|
503
695
|
error
|
|
504
696
|
});
|
|
697
|
+
// Keep dirty=true on error so we retry later
|
|
505
698
|
}
|
|
506
699
|
}
|
|
507
700
|
|
|
508
701
|
/**
|
|
509
|
-
*
|
|
702
|
+
* Request a save, which is handled by the save manager.
|
|
703
|
+
* @param {boolean} force - true to force an immediate save, false to make a normal request.
|
|
704
|
+
* @private
|
|
510
705
|
*/
|
|
511
|
-
|
|
512
|
-
if (this.
|
|
706
|
+
_requestSave(force = false) {
|
|
707
|
+
if (!this.options.autoSave) {
|
|
513
708
|
return;
|
|
514
709
|
}
|
|
515
|
-
|
|
516
|
-
this.
|
|
517
|
-
this._saveState();
|
|
518
|
-
}, this.options.saveInterval);
|
|
710
|
+
// Delegate directly to the save manager.
|
|
711
|
+
this._saveManager(force);
|
|
519
712
|
}
|
|
520
713
|
|
|
521
|
-
|
|
522
714
|
/**
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
715
|
+
* Normalizes catch-up settings (catchUpWindow, catchUpLimit) from a task specification.
|
|
716
|
+
* This handles cascading defaults: explicit on taskSpec > taskSpec.catchUpMode > existingTask > automator.defaultCatchUpMode > hardcoded defaults.
|
|
717
|
+
* Also deletes `catchUpMode` from the incomingSpec after processing.
|
|
526
718
|
*
|
|
527
|
-
* @param {
|
|
528
|
-
* @
|
|
719
|
+
* @param {Object} incomingSpec - The task specification or update object (e.g., from addTask or updateTask).
|
|
720
|
+
* @param {Object} [existingTask=null] - The current state of the task, if an update operation.
|
|
721
|
+
* @returns {{catchUpWindow: number|string, catchUpLimit: number|string}} The resolved catch-up settings.
|
|
529
722
|
* @private
|
|
530
723
|
*/
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
724
|
+
_normalizeCatchUpSettings(incomingSpec, existingTask = null) {
|
|
725
|
+
let resolvedWindow;
|
|
726
|
+
let resolvedLimit;
|
|
727
|
+
|
|
728
|
+
// A. Check for instructions in the new spec first (highest precedence)
|
|
729
|
+
const hasExplicitWindow = incomingSpec.catchUpWindow !== undefined;
|
|
730
|
+
const hasExplicitLimit = incomingSpec.catchUpLimit !== undefined;
|
|
731
|
+
const hasMode = incomingSpec.catchUpMode !== undefined;
|
|
732
|
+
|
|
733
|
+
if (hasExplicitWindow || hasExplicitLimit) {
|
|
734
|
+
// Use explicit values directly. If one is missing, use the other from existing or default to 0.
|
|
735
|
+
resolvedWindow = hasExplicitWindow ? incomingSpec.catchUpWindow : (existingTask ? existingTask.catchUpWindow : 0);
|
|
736
|
+
resolvedLimit = hasExplicitLimit ? incomingSpec.catchUpLimit : (existingTask ? existingTask.catchUpLimit : 0);
|
|
737
|
+
} else if (hasMode) {
|
|
738
|
+
// Apply the mode from the incoming spec
|
|
739
|
+
switch (incomingSpec.catchUpMode) {
|
|
740
|
+
case 'default':
|
|
741
|
+
resolvedWindow = 500;
|
|
742
|
+
resolvedLimit = 1;
|
|
743
|
+
break;
|
|
744
|
+
case 'realtime':
|
|
745
|
+
resolvedWindow = 0;
|
|
746
|
+
resolvedLimit = 0;
|
|
747
|
+
break;
|
|
748
|
+
case 'auto':
|
|
749
|
+
const MIN_WINDOW = 500; // 0.5 seconds
|
|
750
|
+
const MAX_WINDOW = 900000; // 15 minutes
|
|
751
|
+
|
|
752
|
+
resolvedLimit = 1; // 'auto' mode always catches up one missed instance.
|
|
753
|
+
const repeat = incomingSpec.repeat || (existingTask ? existingTask.repeat : null);
|
|
754
|
+
|
|
755
|
+
if (repeat && repeat.type && repeat.interval) {
|
|
756
|
+
let intervalMs = 0;
|
|
757
|
+
switch (repeat.type) {
|
|
758
|
+
case 'second': intervalMs = repeat.interval * 1000; break;
|
|
759
|
+
case 'minute': intervalMs = repeat.interval * 60 * 1000; break;
|
|
760
|
+
case 'hour': intervalMs = repeat.interval * 60 * 60 * 1000; break;
|
|
761
|
+
case 'day': intervalMs = repeat.interval * 24 * 60 * 60 * 1000; break;
|
|
762
|
+
// week, weekend, weekday, month, year are more complex and less predictable in duration.
|
|
763
|
+
// Default to a safe, medium-sized window for these.
|
|
764
|
+
default: intervalMs = 60000; // Assume 1 minute for complex types
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Calculate the elastic window (25% of interval)
|
|
768
|
+
const elasticWindow = Math.floor(intervalMs * 0.25);
|
|
769
|
+
|
|
770
|
+
// Apply the min and max caps
|
|
771
|
+
let finalWindow = Math.max(MIN_WINDOW, elasticWindow);
|
|
772
|
+
finalWindow = Math.min(finalWindow, MAX_WINDOW);
|
|
773
|
+
resolvedWindow = finalWindow;
|
|
774
|
+
} else {
|
|
775
|
+
// If no repeat info, 'auto' falls back to the same as 'default'
|
|
776
|
+
resolvedWindow = 500;
|
|
777
|
+
resolvedLimit = 1;
|
|
778
|
+
}
|
|
779
|
+
break;
|
|
780
|
+
default:
|
|
781
|
+
this._emit('warning', {
|
|
782
|
+
type: 'warning',
|
|
783
|
+
message: `Unknown catchUpMode "${incomingSpec.catchUpMode}" - defaulting to 'default'`,
|
|
784
|
+
taskSpec: incomingSpec
|
|
785
|
+
});
|
|
786
|
+
resolvedWindow = 500;
|
|
787
|
+
resolvedLimit = 1;
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
} else if (existingTask) {
|
|
791
|
+
// B. No new instructions, so preserve the existing task's values
|
|
792
|
+
resolvedWindow = existingTask.catchUpWindow;
|
|
793
|
+
resolvedLimit = existingTask.catchUpLimit;
|
|
794
|
+
} else {
|
|
795
|
+
// C. No new instructions and no existing task (i.e., addTask), so use automator default
|
|
796
|
+
const automatorDefaultMode = this.options.defaultCatchUpMode || 'default';
|
|
797
|
+
switch (automatorDefaultMode) {
|
|
798
|
+
case 'realtime':
|
|
799
|
+
resolvedWindow = 0;
|
|
800
|
+
resolvedLimit = 0;
|
|
801
|
+
break;
|
|
802
|
+
case 'default':
|
|
803
|
+
default:
|
|
804
|
+
resolvedWindow = 500;
|
|
805
|
+
resolvedLimit = 1;
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
555
808
|
}
|
|
809
|
+
|
|
810
|
+
// Always delete catchUpMode from the incoming spec as it's a transient macro
|
|
811
|
+
delete incomingSpec.catchUpMode;
|
|
812
|
+
|
|
813
|
+
return { catchUpWindow: resolvedWindow, catchUpLimit: resolvedLimit };
|
|
556
814
|
}
|
|
557
815
|
|
|
816
|
+
|
|
817
|
+
|
|
558
818
|
/**
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* Priority:
|
|
562
|
-
* 1. catchUpWindow specified → normalize it (coerce Infinity to "unlimited" if needed)
|
|
563
|
-
* 2. unBuffered specified → convert to catchUpWindow equivalent
|
|
564
|
-
* 3. Neither specified → default to "unlimited" (catch up everything)
|
|
565
|
-
*
|
|
566
|
-
* @param {Object} spec - Action specification
|
|
567
|
-
* @returns {string|number} - Normalized catchUpWindow value ("unlimited" or milliseconds)
|
|
819
|
+
* Validate catchUpWindow value (returns result object)
|
|
820
|
+
* @private
|
|
568
821
|
*/
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (spec.catchUpWindow !== undefined) {
|
|
572
|
-
// Coerce Infinity to "unlimited" (backwards compatibility)
|
|
573
|
-
if (spec.catchUpWindow === Infinity) {
|
|
574
|
-
this._emit('debug', {
|
|
575
|
-
type: 'debug',
|
|
576
|
-
message: 'Coercing catchUpWindow: Infinity → "unlimited"'
|
|
577
|
-
});
|
|
578
|
-
return "unlimited";
|
|
579
|
-
}
|
|
580
|
-
return spec.catchUpWindow;
|
|
581
|
-
}
|
|
822
|
+
_validateCatchUpWindow(value) {
|
|
823
|
+
if (value === undefined) return this._success({});
|
|
582
824
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
825
|
+
const isValidString = value === "unlimited";
|
|
826
|
+
const isValidNumber = typeof value === 'number' && !isNaN(value) && value >= 0 && value !== Infinity;
|
|
827
|
+
|
|
828
|
+
if (!isValidString && !isValidNumber) {
|
|
829
|
+
return this._error(
|
|
830
|
+
`Invalid catchUpWindow "${value}". Must be "unlimited" or a non-negative number (not Infinity).`,
|
|
831
|
+
'INVALID_CATCHUP_WINDOW',
|
|
832
|
+
{ field: 'catchUpWindow', provided: value }
|
|
833
|
+
);
|
|
586
834
|
}
|
|
587
835
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
836
|
+
return this._success({});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Validate catchUpLimit value (returns result object)
|
|
841
|
+
* @private
|
|
842
|
+
*/
|
|
843
|
+
_validateCatchUpLimit(value) {
|
|
844
|
+
if (value === undefined) return this._success({});
|
|
845
|
+
|
|
846
|
+
const isValidString = value === "all";
|
|
847
|
+
const isValidNumber = typeof value === 'number' && !isNaN(value) && value >= 0 && Number.isInteger(value);
|
|
848
|
+
|
|
849
|
+
if (!isValidString && !isValidNumber) {
|
|
850
|
+
return this._error(
|
|
851
|
+
`Invalid catchUpLimit "${value}". Must be "all" or a non-negative integer.`,
|
|
852
|
+
'INVALID_CATCHUP_LIMIT',
|
|
853
|
+
{ field: 'catchUpLimit', provided: value }
|
|
854
|
+
);
|
|
591
855
|
}
|
|
592
856
|
|
|
593
|
-
|
|
594
|
-
return 0;
|
|
857
|
+
return this._success({});
|
|
595
858
|
}
|
|
596
859
|
|
|
597
860
|
/**
|
|
598
|
-
* Validate and normalize
|
|
599
|
-
* Philosophy: "Fail loudly, run defensively"
|
|
600
|
-
* -
|
|
601
|
-
* - Emit DEBUG events for auto-corrections
|
|
602
|
-
* - Never
|
|
861
|
+
* Validate and normalize task specification
|
|
862
|
+
* Philosophy: "Fail loudly, run defensively" - but return errors instead of throwing
|
|
863
|
+
* - Return error results for invalid catch-up values and critical fields
|
|
864
|
+
* - Emit DEBUG/WARNING events for auto-corrections
|
|
865
|
+
* - Never throw - always return result objects
|
|
603
866
|
*/
|
|
604
|
-
|
|
605
|
-
if (!
|
|
606
|
-
|
|
867
|
+
_validateTask(task) {
|
|
868
|
+
if (!task.cmd) {
|
|
869
|
+
return this._error(
|
|
870
|
+
'Task must have a cmd property',
|
|
871
|
+
'MISSING_CMD',
|
|
872
|
+
{ field: 'cmd' }
|
|
873
|
+
);
|
|
607
874
|
}
|
|
608
875
|
|
|
609
|
-
if (
|
|
876
|
+
if (task.repeat) {
|
|
610
877
|
const validTypes = ['second', 'minute', 'hour', 'day', 'weekday', 'weekend', 'week', 'month', 'year'];
|
|
611
878
|
|
|
612
879
|
// CRITICAL: An invalid repeat.type is a fatal error, as intent is lost.
|
|
613
|
-
if (!
|
|
614
|
-
|
|
880
|
+
if (!task.repeat.type || !validTypes.includes(task.repeat.type)) {
|
|
881
|
+
return this._error(
|
|
882
|
+
`Invalid repeat.type "${task.repeat.type}". Must be one of: ${validTypes.join(', ')}`,
|
|
883
|
+
'INVALID_REPEAT_TYPE',
|
|
884
|
+
{ field: 'repeat.type', provided: task.repeat.type }
|
|
885
|
+
);
|
|
615
886
|
}
|
|
616
887
|
|
|
617
888
|
// Defensive: Coerce invalid interval to Math.max(1, Math.floor(value))
|
|
618
|
-
if (
|
|
619
|
-
const original =
|
|
889
|
+
if (task.repeat.interval !== undefined) {
|
|
890
|
+
const original = task.repeat.interval;
|
|
620
891
|
const coerced = Math.max(1, Math.floor(original));
|
|
621
892
|
if (original !== coerced) {
|
|
622
893
|
this._emit('warning', {
|
|
623
894
|
type: 'warning',
|
|
624
895
|
message: `Invalid repeat.interval ${original} - coerced to ${coerced}`,
|
|
625
|
-
|
|
896
|
+
taskSpec: task
|
|
626
897
|
});
|
|
627
|
-
|
|
898
|
+
task.repeat.interval = coerced;
|
|
628
899
|
}
|
|
629
900
|
}
|
|
630
901
|
|
|
631
902
|
// Defensive: Validate dstPolicy
|
|
632
|
-
if (
|
|
903
|
+
if (task.repeat.dstPolicy && !['once', 'twice'].includes(task.repeat.dstPolicy)) {
|
|
633
904
|
this._emit('warning', {
|
|
634
905
|
type: 'warning',
|
|
635
|
-
message: `Invalid dstPolicy "${
|
|
636
|
-
|
|
906
|
+
message: `Invalid dstPolicy "${task.repeat.dstPolicy}" - defaulting to "once"`,
|
|
907
|
+
taskSpec: task
|
|
637
908
|
});
|
|
638
|
-
|
|
909
|
+
task.repeat.dstPolicy = 'once';
|
|
639
910
|
}
|
|
640
911
|
|
|
641
912
|
// Defensive: Coerce invalid repeat.limit to null (unlimited) with WARNING event
|
|
642
|
-
if (
|
|
643
|
-
if (typeof
|
|
913
|
+
if (task.repeat.limit !== undefined && task.repeat.limit !== null) {
|
|
914
|
+
if (typeof task.repeat.limit !== 'number' || task.repeat.limit < 1) {
|
|
644
915
|
this._emit('warning', {
|
|
645
916
|
type: 'warning',
|
|
646
|
-
message: `Invalid repeat.limit ${
|
|
647
|
-
|
|
917
|
+
message: `Invalid repeat.limit ${task.repeat.limit} - defaulting to null (unlimited)`,
|
|
918
|
+
taskSpec: task
|
|
648
919
|
});
|
|
649
|
-
|
|
920
|
+
task.repeat.limit = null;
|
|
650
921
|
}
|
|
651
922
|
}
|
|
652
923
|
|
|
653
924
|
// Defensive: Validate repeat.endDate
|
|
654
|
-
if (
|
|
925
|
+
if (task.repeat.endDate !== undefined && task.repeat.endDate !== null) {
|
|
655
926
|
try {
|
|
656
|
-
new Date(
|
|
927
|
+
new Date(task.repeat.endDate);
|
|
657
928
|
} catch (e) {
|
|
658
929
|
this._emit('warning', {
|
|
659
930
|
type: 'warning',
|
|
660
931
|
message: `Invalid repeat.endDate - ignoring`,
|
|
661
|
-
|
|
932
|
+
taskSpec: task
|
|
662
933
|
});
|
|
663
|
-
|
|
934
|
+
task.repeat.endDate = null;
|
|
664
935
|
}
|
|
665
936
|
}
|
|
666
937
|
}
|
|
667
938
|
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
const isNumber = typeof action.catchUpWindow === 'number';
|
|
672
|
-
const isInfinity = action.catchUpWindow === Infinity; // Allow for backwards compatibility
|
|
939
|
+
// Strict validation for catch-up properties (advanced feature)
|
|
940
|
+
const windowResult = this._validateCatchUpWindow(task.catchUpWindow);
|
|
941
|
+
if (!windowResult.success) return windowResult;
|
|
673
942
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
this._emit('warning', {
|
|
677
|
-
type: 'warning',
|
|
678
|
-
message: `Negative catchUpWindow ${action.catchUpWindow} - coerced to 0`,
|
|
679
|
-
actionSpec: action
|
|
680
|
-
});
|
|
681
|
-
action.catchUpWindow = 0;
|
|
682
|
-
}
|
|
683
|
-
// Then validate it's a valid value
|
|
684
|
-
else if (!isValidString && !isNumber && !isInfinity) {
|
|
685
|
-
this._emit('warning', {
|
|
686
|
-
type: 'warning',
|
|
687
|
-
message: `Invalid catchUpWindow "${action.catchUpWindow}" - defaulting to "unlimited"`,
|
|
688
|
-
actionSpec: action
|
|
689
|
-
});
|
|
690
|
-
action.catchUpWindow = "unlimited";
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
}
|
|
943
|
+
const limitResult = this._validateCatchUpLimit(task.catchUpLimit);
|
|
944
|
+
if (!limitResult.success) return limitResult;
|
|
694
945
|
|
|
695
|
-
|
|
696
|
-
* Static storage factory methods
|
|
697
|
-
*/
|
|
698
|
-
static get storage() {
|
|
699
|
-
return {
|
|
700
|
-
file: (filePath) => new FileStorage(filePath),
|
|
701
|
-
memory: () => new MemoryStorage()
|
|
702
|
-
};
|
|
946
|
+
return this._success({});
|
|
703
947
|
}
|
|
948
|
+
|
|
704
949
|
}
|
|
705
950
|
|
|
706
951
|
module.exports = Automator;
|