jw-automator 4.0.1 → 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 +556 -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,266 +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
|
-
catchUpWindow: catchUpWindow,
|
|
139
|
-
|
|
160
|
+
catchUpWindow: catchUpWindow, // Use normalized value
|
|
161
|
+
catchUpLimit: catchUpLimit, // Use normalized value
|
|
162
|
+
repeat: taskSpec.repeat ? { ...taskSpec.repeat } : null,
|
|
140
163
|
count: 0
|
|
141
164
|
};
|
|
142
165
|
|
|
143
166
|
// Set default dstPolicy if not specified
|
|
144
|
-
if (
|
|
145
|
-
|
|
167
|
+
if (task.repeat && !task.repeat.dstPolicy) {
|
|
168
|
+
task.repeat.dstPolicy = 'once';
|
|
146
169
|
}
|
|
147
170
|
|
|
148
|
-
this.host.
|
|
171
|
+
this.host.addTask(task);
|
|
149
172
|
|
|
150
173
|
this._emit('update', {
|
|
151
174
|
type: 'update',
|
|
152
175
|
operation: 'add',
|
|
153
|
-
|
|
176
|
+
task: { ...task }
|
|
154
177
|
});
|
|
155
178
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
179
|
+
// Save immediately
|
|
180
|
+
this._requestSave(true);
|
|
159
181
|
|
|
160
|
-
return
|
|
182
|
+
return this._success({ id: task.id });
|
|
161
183
|
}
|
|
162
184
|
|
|
163
185
|
/**
|
|
164
|
-
* Update
|
|
186
|
+
* Update a task by ID
|
|
187
|
+
* @returns {Object} - Result object with success/error
|
|
165
188
|
*/
|
|
166
|
-
|
|
189
|
+
updateTaskByID(id, updates) {
|
|
167
190
|
const state = this.host.getState();
|
|
168
|
-
const
|
|
191
|
+
const task = state.tasks.find(t => t.id === id);
|
|
169
192
|
|
|
170
|
-
if (!
|
|
171
|
-
|
|
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;
|
|
172
216
|
}
|
|
173
217
|
|
|
174
218
|
// Update allowed fields
|
|
175
|
-
const allowedUpdates = ['name', 'cmd', 'payload', 'date', '
|
|
219
|
+
const allowedUpdates = ['name', 'cmd', 'payload', 'date', 'catchUpWindow', 'catchUpLimit', 'repeat', 'count'];
|
|
176
220
|
|
|
177
221
|
for (const key of allowedUpdates) {
|
|
178
222
|
if (key in updates) {
|
|
179
223
|
if (key === 'date' && updates[key]) {
|
|
180
|
-
|
|
224
|
+
task[key] = new Date(updates[key]);
|
|
181
225
|
} else if (key === 'repeat' && updates[key]) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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';
|
|
185
230
|
}
|
|
186
231
|
} else {
|
|
187
|
-
|
|
232
|
+
task[key] = updates[key];
|
|
188
233
|
}
|
|
189
234
|
}
|
|
190
235
|
}
|
|
191
236
|
|
|
192
|
-
// Re-normalize catchUpWindow if unBuffered or catchUpWindow was updated
|
|
193
|
-
if ('unBuffered' in updates || 'catchUpWindow' in updates) {
|
|
194
|
-
action.catchUpWindow = this._normalizeCatchUpWindow(action);
|
|
195
|
-
delete action.unBuffered; // Remove legacy property
|
|
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
|
-
delete action.unBuffered; // Remove legacy property
|
|
246
|
-
}
|
|
247
|
-
|
|
248
300
|
this._emit('update', {
|
|
249
301
|
type: 'update',
|
|
250
302
|
operation: 'update',
|
|
251
|
-
|
|
252
|
-
|
|
303
|
+
taskId: task.id,
|
|
304
|
+
task: { ...task }
|
|
253
305
|
});
|
|
254
306
|
}
|
|
255
307
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
308
|
+
// Save immediately
|
|
309
|
+
this._requestSave(true);
|
|
259
310
|
|
|
260
|
-
return toUpdate.length;
|
|
311
|
+
return this._success({ count: toUpdate.length });
|
|
261
312
|
}
|
|
262
313
|
|
|
263
314
|
/**
|
|
264
|
-
* Remove
|
|
315
|
+
* Remove task by ID
|
|
316
|
+
* @returns {Object} - Result object with success/error
|
|
265
317
|
*/
|
|
266
|
-
|
|
318
|
+
removeTaskByID(id) {
|
|
267
319
|
const state = this.host.getState();
|
|
268
|
-
const index = state.
|
|
320
|
+
const index = state.tasks.findIndex(t => t.id === id);
|
|
269
321
|
|
|
270
322
|
if (index === -1) {
|
|
271
|
-
|
|
323
|
+
return this._error(
|
|
324
|
+
`Task with id ${id} not found`,
|
|
325
|
+
'TASK_NOT_FOUND',
|
|
326
|
+
{ taskId: id }
|
|
327
|
+
);
|
|
272
328
|
}
|
|
273
329
|
|
|
274
|
-
const removed = state.
|
|
330
|
+
const removed = state.tasks.splice(index, 1)[0];
|
|
275
331
|
|
|
276
332
|
this._emit('update', {
|
|
277
333
|
type: 'update',
|
|
278
334
|
operation: 'remove',
|
|
279
|
-
|
|
280
|
-
|
|
335
|
+
taskId: id,
|
|
336
|
+
task: removed
|
|
281
337
|
});
|
|
282
338
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
339
|
+
// Save immediately
|
|
340
|
+
this._requestSave(true);
|
|
341
|
+
|
|
342
|
+
return this._success({ id, task: removed });
|
|
286
343
|
}
|
|
287
344
|
|
|
288
345
|
/**
|
|
289
|
-
* Remove
|
|
346
|
+
* Remove tasks by name
|
|
347
|
+
* @returns {Object} - Result object with success/error
|
|
290
348
|
*/
|
|
291
|
-
|
|
349
|
+
removeTaskByName(name) {
|
|
292
350
|
const state = this.host.getState();
|
|
293
|
-
const toRemove = state.
|
|
351
|
+
const toRemove = state.tasks.filter(t => t.name === name);
|
|
294
352
|
|
|
295
353
|
if (toRemove.length === 0) {
|
|
296
|
-
|
|
354
|
+
return this._error(
|
|
355
|
+
`No tasks found with name: ${name}`,
|
|
356
|
+
'NO_TASKS_FOUND',
|
|
357
|
+
{ taskName: name }
|
|
358
|
+
);
|
|
297
359
|
}
|
|
298
360
|
|
|
299
|
-
for (const
|
|
300
|
-
const index = state.
|
|
361
|
+
for (const task of toRemove) {
|
|
362
|
+
const index = state.tasks.indexOf(task);
|
|
301
363
|
if (index !== -1) {
|
|
302
|
-
state.
|
|
364
|
+
state.tasks.splice(index, 1);
|
|
303
365
|
|
|
304
366
|
this._emit('update', {
|
|
305
367
|
type: 'update',
|
|
306
368
|
operation: 'remove',
|
|
307
|
-
|
|
308
|
-
|
|
369
|
+
taskId: task.id,
|
|
370
|
+
task
|
|
309
371
|
});
|
|
310
372
|
}
|
|
311
373
|
}
|
|
312
374
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
375
|
+
// Save immediately
|
|
376
|
+
this._requestSave(true);
|
|
316
377
|
|
|
317
|
-
return toRemove.length;
|
|
378
|
+
return this._success({ count: toRemove.length });
|
|
318
379
|
}
|
|
319
380
|
|
|
320
381
|
/**
|
|
321
|
-
* Deep clone
|
|
382
|
+
* Deep clone a task
|
|
322
383
|
*/
|
|
323
|
-
|
|
324
|
-
const cloned = { ...
|
|
384
|
+
_cloneTask(task) {
|
|
385
|
+
const cloned = { ...task };
|
|
325
386
|
|
|
326
387
|
// Deep copy nested objects
|
|
327
|
-
if (
|
|
328
|
-
cloned.repeat = { ...
|
|
388
|
+
if (task.repeat) {
|
|
389
|
+
cloned.repeat = { ...task.repeat };
|
|
329
390
|
}
|
|
330
391
|
|
|
331
392
|
// Clone Date objects properly
|
|
332
|
-
if (
|
|
333
|
-
cloned.date = new Date(
|
|
393
|
+
if (task.date) {
|
|
394
|
+
cloned.date = new Date(task.date);
|
|
334
395
|
}
|
|
335
396
|
|
|
336
397
|
return cloned;
|
|
337
398
|
}
|
|
338
399
|
|
|
339
400
|
/**
|
|
340
|
-
* Get all
|
|
401
|
+
* Get all tasks (deep copy)
|
|
341
402
|
*/
|
|
342
|
-
|
|
403
|
+
getTasks() {
|
|
343
404
|
const state = this.host.getState();
|
|
344
|
-
return state.
|
|
405
|
+
return state.tasks.map(t => this._cloneTask(t));
|
|
345
406
|
}
|
|
346
407
|
|
|
347
408
|
/**
|
|
348
|
-
* Get
|
|
409
|
+
* Get tasks by name
|
|
349
410
|
*/
|
|
350
|
-
|
|
411
|
+
getTasksByName(name) {
|
|
351
412
|
const state = this.host.getState();
|
|
352
|
-
return state.
|
|
353
|
-
.filter(
|
|
354
|
-
.map(
|
|
413
|
+
return state.tasks
|
|
414
|
+
.filter(t => t.name === name)
|
|
415
|
+
.map(t => this._cloneTask(t));
|
|
355
416
|
}
|
|
356
417
|
|
|
357
418
|
/**
|
|
358
|
-
* Get
|
|
419
|
+
* Get task by ID
|
|
359
420
|
*/
|
|
360
|
-
|
|
421
|
+
getTaskByID(id) {
|
|
361
422
|
const state = this.host.getState();
|
|
362
|
-
const
|
|
363
|
-
return
|
|
423
|
+
const task = state.tasks.find(t => t.id === id);
|
|
424
|
+
return task ? this._cloneTask(task) : null;
|
|
364
425
|
}
|
|
365
426
|
|
|
366
427
|
/**
|
|
367
|
-
* Get
|
|
428
|
+
* Get tasks scheduled in a time range
|
|
368
429
|
*/
|
|
369
|
-
|
|
430
|
+
getTasksInRange(startDate, endDate, callback) {
|
|
370
431
|
const start = new Date(startDate);
|
|
371
432
|
const end = new Date(endDate);
|
|
372
433
|
|
|
@@ -381,43 +442,44 @@ class Automator {
|
|
|
381
442
|
}
|
|
382
443
|
|
|
383
444
|
/**
|
|
384
|
-
* Simulate range (alias for
|
|
445
|
+
* Simulate range (alias for getTasksInRange)
|
|
385
446
|
*/
|
|
386
447
|
simulateRange(startDate, endDate) {
|
|
387
|
-
return this.
|
|
448
|
+
return this.getTasksInRange(startDate, endDate);
|
|
388
449
|
}
|
|
389
450
|
|
|
390
451
|
/**
|
|
391
|
-
* Describe
|
|
452
|
+
* Describe a task in human-readable format
|
|
392
453
|
*/
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
if (!
|
|
454
|
+
describeTask(id) {
|
|
455
|
+
const task = this.getTaskByID(id);
|
|
456
|
+
if (!task) {
|
|
396
457
|
return null;
|
|
397
458
|
}
|
|
398
459
|
|
|
399
|
-
let description = `
|
|
400
|
-
if (
|
|
401
|
-
description += ` - ${
|
|
460
|
+
let description = `Task #${task.id}`;
|
|
461
|
+
if (task.name) {
|
|
462
|
+
description += ` - ${task.name}`;
|
|
402
463
|
}
|
|
403
464
|
|
|
404
|
-
description += `\n Command: ${
|
|
405
|
-
description += `\n Next run: ${
|
|
406
|
-
description += `\n Executions: ${
|
|
407
|
-
description += `\n Catch-up Window: ${
|
|
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}`;
|
|
408
470
|
|
|
409
|
-
if (
|
|
410
|
-
description += `\n Recurrence: ${
|
|
411
|
-
if (
|
|
412
|
-
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})`;
|
|
413
475
|
}
|
|
414
|
-
if (
|
|
415
|
-
description += `\n Limit: ${
|
|
476
|
+
if (task.repeat.limit) {
|
|
477
|
+
description += `\n Limit: ${task.repeat.limit}`;
|
|
416
478
|
}
|
|
417
|
-
if (
|
|
418
|
-
description += `\n End date: ${new Date(
|
|
479
|
+
if (task.repeat.endDate) {
|
|
480
|
+
description += `\n End date: ${new Date(task.repeat.endDate).toLocaleString()}`;
|
|
419
481
|
}
|
|
420
|
-
description += `\n DST policy: ${
|
|
482
|
+
description += `\n DST policy: ${task.repeat.dstPolicy}`;
|
|
421
483
|
} else {
|
|
422
484
|
description += `\n Recurrence: One-time`;
|
|
423
485
|
}
|
|
@@ -425,6 +487,73 @@ class Automator {
|
|
|
425
487
|
return description;
|
|
426
488
|
}
|
|
427
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
|
+
|
|
428
557
|
/**
|
|
429
558
|
* Event listener management
|
|
430
559
|
*/
|
|
@@ -460,249 +589,363 @@ class Automator {
|
|
|
460
589
|
}
|
|
461
590
|
}
|
|
462
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
|
+
|
|
463
623
|
/**
|
|
464
624
|
* Load state from storage
|
|
465
625
|
*/
|
|
466
626
|
_loadState() {
|
|
627
|
+
if (!this.options.storageFile) {
|
|
628
|
+
// Memory-only: start with empty state
|
|
629
|
+
this.host.setState({ tasks: [] });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
467
633
|
try {
|
|
468
|
-
const
|
|
634
|
+
const filePath = path.resolve(this.options.storageFile);
|
|
469
635
|
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if (action.catchUpWindow === undefined) {
|
|
474
|
-
action.catchUpWindow = this._normalizeCatchUpWindow(action);
|
|
475
|
-
}
|
|
476
|
-
// Remove legacy property after normalization
|
|
477
|
-
delete action.unBuffered;
|
|
478
|
-
});
|
|
636
|
+
if (fs.existsSync(filePath)) {
|
|
637
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
638
|
+
const state = JSON.parse(data);
|
|
479
639
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
}
|
|
483
658
|
|
|
484
|
-
|
|
659
|
+
this.host.setState(state);
|
|
660
|
+
} else {
|
|
661
|
+
// File doesn't exist yet
|
|
662
|
+
this.host.setState({ tasks: [] });
|
|
663
|
+
}
|
|
485
664
|
} catch (error) {
|
|
486
665
|
this._emit('error', {
|
|
487
666
|
type: 'error',
|
|
488
667
|
message: `Failed to load state: ${error.message}`,
|
|
489
668
|
error
|
|
490
669
|
});
|
|
670
|
+
// Defensive: continue with empty state
|
|
671
|
+
this.host.setState({ tasks: [] });
|
|
491
672
|
}
|
|
492
673
|
}
|
|
493
674
|
|
|
494
675
|
/**
|
|
495
|
-
*
|
|
676
|
+
* Perform actual save to storage (called by state machine)
|
|
677
|
+
* @private
|
|
496
678
|
*/
|
|
497
|
-
|
|
679
|
+
_performSave() {
|
|
680
|
+
if (!this.options.storageFile) {
|
|
681
|
+
// Memory-only: no-op
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
498
685
|
try {
|
|
686
|
+
const filePath = path.resolve(this.options.storageFile);
|
|
499
687
|
const state = this.host.getState();
|
|
500
|
-
|
|
688
|
+
const data = JSON.stringify(state, null, 2);
|
|
689
|
+
|
|
690
|
+
fs.writeFileSync(filePath, data, 'utf8');
|
|
501
691
|
} catch (error) {
|
|
502
692
|
this._emit('error', {
|
|
503
693
|
type: 'error',
|
|
504
694
|
message: `Failed to save state: ${error.message}`,
|
|
505
695
|
error
|
|
506
696
|
});
|
|
697
|
+
// Keep dirty=true on error so we retry later
|
|
507
698
|
}
|
|
508
699
|
}
|
|
509
700
|
|
|
510
701
|
/**
|
|
511
|
-
*
|
|
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
|
|
512
705
|
*/
|
|
513
|
-
|
|
514
|
-
if (this.
|
|
706
|
+
_requestSave(force = false) {
|
|
707
|
+
if (!this.options.autoSave) {
|
|
515
708
|
return;
|
|
516
709
|
}
|
|
517
|
-
|
|
518
|
-
this.
|
|
519
|
-
this._saveState();
|
|
520
|
-
}, this.options.saveInterval);
|
|
710
|
+
// Delegate directly to the save manager.
|
|
711
|
+
this._saveManager(force);
|
|
521
712
|
}
|
|
522
713
|
|
|
523
|
-
|
|
524
714
|
/**
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
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.
|
|
528
718
|
*
|
|
529
|
-
* @param {
|
|
530
|
-
* @
|
|
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.
|
|
531
722
|
* @private
|
|
532
723
|
*/
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
+
}
|
|
557
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 };
|
|
558
814
|
}
|
|
559
815
|
|
|
816
|
+
|
|
817
|
+
|
|
560
818
|
/**
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
* Priority:
|
|
564
|
-
* 1. catchUpWindow specified → normalize it (coerce Infinity to "unlimited" if needed)
|
|
565
|
-
* 2. unBuffered specified → convert to catchUpWindow equivalent
|
|
566
|
-
* 3. Neither specified → default to "unlimited" (catch up everything)
|
|
567
|
-
*
|
|
568
|
-
* @param {Object} spec - Action specification
|
|
569
|
-
* @returns {string|number} - Normalized catchUpWindow value ("unlimited" or milliseconds)
|
|
819
|
+
* Validate catchUpWindow value (returns result object)
|
|
820
|
+
* @private
|
|
570
821
|
*/
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
if (spec.catchUpWindow !== undefined) {
|
|
574
|
-
// Coerce Infinity to "unlimited" (backwards compatibility)
|
|
575
|
-
if (spec.catchUpWindow === Infinity) {
|
|
576
|
-
this._emit('debug', {
|
|
577
|
-
type: 'debug',
|
|
578
|
-
message: 'Coercing catchUpWindow: Infinity → "unlimited"'
|
|
579
|
-
});
|
|
580
|
-
return "unlimited";
|
|
581
|
-
}
|
|
582
|
-
return spec.catchUpWindow;
|
|
583
|
-
}
|
|
822
|
+
_validateCatchUpWindow(value) {
|
|
823
|
+
if (value === undefined) return this._success({});
|
|
584
824
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
+
);
|
|
588
834
|
}
|
|
589
835
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
+
);
|
|
593
855
|
}
|
|
594
856
|
|
|
595
|
-
|
|
596
|
-
return 0;
|
|
857
|
+
return this._success({});
|
|
597
858
|
}
|
|
598
859
|
|
|
599
860
|
/**
|
|
600
|
-
* Validate and normalize
|
|
601
|
-
* Philosophy: "Fail loudly, run defensively"
|
|
602
|
-
* -
|
|
603
|
-
* - Emit DEBUG events for auto-corrections
|
|
604
|
-
* - 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
|
|
605
866
|
*/
|
|
606
|
-
|
|
607
|
-
if (!
|
|
608
|
-
|
|
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
|
+
);
|
|
609
874
|
}
|
|
610
875
|
|
|
611
|
-
if (
|
|
876
|
+
if (task.repeat) {
|
|
612
877
|
const validTypes = ['second', 'minute', 'hour', 'day', 'weekday', 'weekend', 'week', 'month', 'year'];
|
|
613
878
|
|
|
614
879
|
// CRITICAL: An invalid repeat.type is a fatal error, as intent is lost.
|
|
615
|
-
if (!
|
|
616
|
-
|
|
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
|
+
);
|
|
617
886
|
}
|
|
618
887
|
|
|
619
888
|
// Defensive: Coerce invalid interval to Math.max(1, Math.floor(value))
|
|
620
|
-
if (
|
|
621
|
-
const original =
|
|
889
|
+
if (task.repeat.interval !== undefined) {
|
|
890
|
+
const original = task.repeat.interval;
|
|
622
891
|
const coerced = Math.max(1, Math.floor(original));
|
|
623
892
|
if (original !== coerced) {
|
|
624
893
|
this._emit('warning', {
|
|
625
894
|
type: 'warning',
|
|
626
895
|
message: `Invalid repeat.interval ${original} - coerced to ${coerced}`,
|
|
627
|
-
|
|
896
|
+
taskSpec: task
|
|
628
897
|
});
|
|
629
|
-
|
|
898
|
+
task.repeat.interval = coerced;
|
|
630
899
|
}
|
|
631
900
|
}
|
|
632
901
|
|
|
633
902
|
// Defensive: Validate dstPolicy
|
|
634
|
-
if (
|
|
903
|
+
if (task.repeat.dstPolicy && !['once', 'twice'].includes(task.repeat.dstPolicy)) {
|
|
635
904
|
this._emit('warning', {
|
|
636
905
|
type: 'warning',
|
|
637
|
-
message: `Invalid dstPolicy "${
|
|
638
|
-
|
|
906
|
+
message: `Invalid dstPolicy "${task.repeat.dstPolicy}" - defaulting to "once"`,
|
|
907
|
+
taskSpec: task
|
|
639
908
|
});
|
|
640
|
-
|
|
909
|
+
task.repeat.dstPolicy = 'once';
|
|
641
910
|
}
|
|
642
911
|
|
|
643
912
|
// Defensive: Coerce invalid repeat.limit to null (unlimited) with WARNING event
|
|
644
|
-
if (
|
|
645
|
-
if (typeof
|
|
913
|
+
if (task.repeat.limit !== undefined && task.repeat.limit !== null) {
|
|
914
|
+
if (typeof task.repeat.limit !== 'number' || task.repeat.limit < 1) {
|
|
646
915
|
this._emit('warning', {
|
|
647
916
|
type: 'warning',
|
|
648
|
-
message: `Invalid repeat.limit ${
|
|
649
|
-
|
|
917
|
+
message: `Invalid repeat.limit ${task.repeat.limit} - defaulting to null (unlimited)`,
|
|
918
|
+
taskSpec: task
|
|
650
919
|
});
|
|
651
|
-
|
|
920
|
+
task.repeat.limit = null;
|
|
652
921
|
}
|
|
653
922
|
}
|
|
654
923
|
|
|
655
924
|
// Defensive: Validate repeat.endDate
|
|
656
|
-
if (
|
|
925
|
+
if (task.repeat.endDate !== undefined && task.repeat.endDate !== null) {
|
|
657
926
|
try {
|
|
658
|
-
new Date(
|
|
927
|
+
new Date(task.repeat.endDate);
|
|
659
928
|
} catch (e) {
|
|
660
929
|
this._emit('warning', {
|
|
661
930
|
type: 'warning',
|
|
662
931
|
message: `Invalid repeat.endDate - ignoring`,
|
|
663
|
-
|
|
932
|
+
taskSpec: task
|
|
664
933
|
});
|
|
665
|
-
|
|
934
|
+
task.repeat.endDate = null;
|
|
666
935
|
}
|
|
667
936
|
}
|
|
668
937
|
}
|
|
669
938
|
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const isNumber = typeof action.catchUpWindow === 'number';
|
|
674
|
-
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;
|
|
675
942
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
this._emit('warning', {
|
|
679
|
-
type: 'warning',
|
|
680
|
-
message: `Negative catchUpWindow ${action.catchUpWindow} - coerced to 0`,
|
|
681
|
-
actionSpec: action
|
|
682
|
-
});
|
|
683
|
-
action.catchUpWindow = 0;
|
|
684
|
-
}
|
|
685
|
-
// Then validate it's a valid value
|
|
686
|
-
else if (!isValidString && !isNumber && !isInfinity) {
|
|
687
|
-
this._emit('warning', {
|
|
688
|
-
type: 'warning',
|
|
689
|
-
message: `Invalid catchUpWindow "${action.catchUpWindow}" - defaulting to "unlimited"`,
|
|
690
|
-
actionSpec: action
|
|
691
|
-
});
|
|
692
|
-
action.catchUpWindow = "unlimited";
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
}
|
|
943
|
+
const limitResult = this._validateCatchUpLimit(task.catchUpLimit);
|
|
944
|
+
if (!limitResult.success) return limitResult;
|
|
696
945
|
|
|
697
|
-
|
|
698
|
-
* Static storage factory methods
|
|
699
|
-
*/
|
|
700
|
-
static get storage() {
|
|
701
|
-
return {
|
|
702
|
-
file: (filePath) => new FileStorage(filePath),
|
|
703
|
-
memory: () => new MemoryStorage()
|
|
704
|
-
};
|
|
946
|
+
return this._success({});
|
|
705
947
|
}
|
|
948
|
+
|
|
706
949
|
}
|
|
707
950
|
|
|
708
951
|
module.exports = Automator;
|