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/src/Automator.js CHANGED
@@ -1,33 +1,56 @@
1
1
  /**
2
2
  * Automator.js
3
3
  *
4
- * Main API class for jw-automator v3
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
- storage: options.storage || new MemoryStorage(),
15
+ storageFile: options.storageFile || null,
16
16
  autoSave: options.autoSave !== false, // default true
17
- saveInterval: options.saveInterval || 5000, // 5 seconds
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
- this.saveTimer = null;
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('action', (...args) => this._emit('action', ...args));
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 actions (runs only on first use)
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 {boolean} - True if seeding ran, false if skipped
65
+ * @returns {Object} - Result object with success/error
43
66
  */
44
67
  seed(callback) {
45
68
  if (typeof callback !== 'function') {
46
- throw new Error('seed() requires a callback function');
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.actions && state.actions.length > 0) {
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
- // Immediately save the seeded state
60
- this._saveState();
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
- // Stop auto-save
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._saveState();
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 an action
128
+ * Add a task
129
+ * @returns {Object} - Result object with success/error
111
130
  */
112
- addAction(actionSpec) {
113
- // Validate action (includes defensive coercion)
114
- this._validateAction(actionSpec);
131
+ addTask(taskSpec) {
132
+ // Normalize catch-up settings using the helper
133
+ const { catchUpWindow, catchUpLimit } = this._normalizeCatchUpSettings(taskSpec);
115
134
 
116
- // Normalize catchUpWindow (handles backwards compatibility with unBuffered)
117
- const catchUpWindow = this._normalizeCatchUpWindow(actionSpec);
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 (!actionSpec.date) {
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(actionSpec.date);
150
+ startDate = new Date(taskSpec.date);
129
151
  }
130
152
 
131
- // Create action with state
132
- const action = {
153
+ // Create task with state
154
+ const task = {
133
155
  id: this.nextId++,
134
- name: actionSpec.name || null,
135
- cmd: actionSpec.cmd,
136
- payload: actionSpec.payload !== undefined ? actionSpec.payload : null,
156
+ name: taskSpec.name || null,
157
+ cmd: taskSpec.cmd,
158
+ payload: taskSpec.payload !== undefined ? taskSpec.payload : null,
137
159
  date: startDate,
138
- unBuffered: actionSpec.unBuffered !== undefined ? actionSpec.unBuffered : false,
139
- catchUpWindow: catchUpWindow,
140
- repeat: actionSpec.repeat ? { ...actionSpec.repeat } : null,
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 (action.repeat && !action.repeat.dstPolicy) {
146
- action.repeat.dstPolicy = 'once';
167
+ if (task.repeat && !task.repeat.dstPolicy) {
168
+ task.repeat.dstPolicy = 'once';
147
169
  }
148
170
 
149
- this.host.addAction(action);
171
+ this.host.addTask(task);
150
172
 
151
173
  this._emit('update', {
152
174
  type: 'update',
153
175
  operation: 'add',
154
- action: { ...action }
176
+ task: { ...task }
155
177
  });
156
178
 
157
- if (this.options.autoSave) {
158
- this._saveState();
159
- }
179
+ // Save immediately
180
+ this._requestSave(true);
160
181
 
161
- return action.id;
182
+ return this._success({ id: task.id });
162
183
  }
163
184
 
164
185
  /**
165
- * Update an action by ID
186
+ * Update a task by ID
187
+ * @returns {Object} - Result object with success/error
166
188
  */
167
- updateActionByID(id, updates) {
189
+ updateTaskByID(id, updates) {
168
190
  const state = this.host.getState();
169
- const action = state.actions.find(a => a.id === id);
191
+ const task = state.tasks.find(t => t.id === id);
170
192
 
171
- if (!action) {
172
- throw new Error(`Action with id ${id} not found`);
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', 'unBuffered', 'catchUpWindow', 'repeat', 'count'];
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
- action[key] = new Date(updates[key]);
224
+ task[key] = new Date(updates[key]);
182
225
  } else if (key === 'repeat' && updates[key]) {
183
- action[key] = { ...updates[key] };
184
- if (!action[key].dstPolicy) {
185
- action[key].dstPolicy = 'once';
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
- action[key] = updates[key];
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
- actionId: id,
202
- action: { ...action }
240
+ taskId: id,
241
+ task: { ...task }
203
242
  });
204
243
 
205
- if (this.options.autoSave) {
206
- this._saveState();
207
- }
244
+ // Save immediately
245
+ this._requestSave(true);
246
+
247
+ return this._success({ id, task: { ...task } });
208
248
  }
209
249
 
210
250
  /**
211
- * Update actions by name
251
+ * Update tasks by name
252
+ * @returns {Object} - Result object with success/error
212
253
  */
213
- updateActionByName(name, updates) {
254
+ updateTaskByName(name, updates) {
214
255
  const state = this.host.getState();
215
- const toUpdate = state.actions.filter(a => a.name === name);
256
+ const toUpdate = state.tasks.filter(t => t.name === name);
216
257
 
217
258
  if (toUpdate.length === 0) {
218
- // For consistency with removeActionByName, we could throw an error.
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
- const allowedUpdates = ['cmd', 'payload', 'date', 'unBuffered', 'catchUpWindow', 'repeat', 'count'];
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
- for (const action of toUpdate) {
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 updates) {
229
- if (key === 'date' && updates[key]) {
230
- action[key] = new Date(updates[key]);
231
- } else if (key === 'repeat' && updates[key]) {
232
- action[key] = { ...action.repeat, ...updates[key] };
233
- if (!action[key].dstPolicy) {
234
- action[key].dstPolicy = 'once';
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
- action[key] = updates[key];
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
- actionId: action.id,
251
- action: { ...action }
303
+ taskId: task.id,
304
+ task: { ...task }
252
305
  });
253
306
  }
254
307
 
255
- if (this.options.autoSave) {
256
- this._saveState();
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 action by ID
315
+ * Remove task by ID
316
+ * @returns {Object} - Result object with success/error
264
317
  */
265
- removeActionByID(id) {
318
+ removeTaskByID(id) {
266
319
  const state = this.host.getState();
267
- const index = state.actions.findIndex(a => a.id === id);
320
+ const index = state.tasks.findIndex(t => t.id === id);
268
321
 
269
322
  if (index === -1) {
270
- throw new Error(`Action with id ${id} not found`);
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.actions.splice(index, 1)[0];
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
- actionId: id,
279
- action: removed
335
+ taskId: id,
336
+ task: removed
280
337
  });
281
338
 
282
- if (this.options.autoSave) {
283
- this._saveState();
284
- }
339
+ // Save immediately
340
+ this._requestSave(true);
341
+
342
+ return this._success({ id, task: removed });
285
343
  }
286
344
 
287
345
  /**
288
- * Remove actions by name
346
+ * Remove tasks by name
347
+ * @returns {Object} - Result object with success/error
289
348
  */
290
- removeActionByName(name) {
349
+ removeTaskByName(name) {
291
350
  const state = this.host.getState();
292
- const toRemove = state.actions.filter(a => a.name === name);
351
+ const toRemove = state.tasks.filter(t => t.name === name);
293
352
 
294
353
  if (toRemove.length === 0) {
295
- throw new Error(`No actions found with name: ${name}`);
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 action of toRemove) {
299
- const index = state.actions.indexOf(action);
361
+ for (const task of toRemove) {
362
+ const index = state.tasks.indexOf(task);
300
363
  if (index !== -1) {
301
- state.actions.splice(index, 1);
364
+ state.tasks.splice(index, 1);
302
365
 
303
366
  this._emit('update', {
304
367
  type: 'update',
305
368
  operation: 'remove',
306
- actionId: action.id,
307
- action
369
+ taskId: task.id,
370
+ task
308
371
  });
309
372
  }
310
373
  }
311
374
 
312
- if (this.options.autoSave) {
313
- this._saveState();
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 an action
382
+ * Deep clone a task
321
383
  */
322
- _cloneAction(action) {
323
- const cloned = { ...action };
384
+ _cloneTask(task) {
385
+ const cloned = { ...task };
324
386
 
325
387
  // Deep copy nested objects
326
- if (action.repeat) {
327
- cloned.repeat = { ...action.repeat };
388
+ if (task.repeat) {
389
+ cloned.repeat = { ...task.repeat };
328
390
  }
329
391
 
330
392
  // Clone Date objects properly
331
- if (action.date) {
332
- cloned.date = new Date(action.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 actions (deep copy)
401
+ * Get all tasks (deep copy)
340
402
  */
341
- getActions() {
403
+ getTasks() {
342
404
  const state = this.host.getState();
343
- return state.actions.map(a => this._cloneAction(a));
405
+ return state.tasks.map(t => this._cloneTask(t));
344
406
  }
345
407
 
346
408
  /**
347
- * Get actions by name
409
+ * Get tasks by name
348
410
  */
349
- getActionsByName(name) {
411
+ getTasksByName(name) {
350
412
  const state = this.host.getState();
351
- return state.actions
352
- .filter(a => a.name === name)
353
- .map(a => this._cloneAction(a));
413
+ return state.tasks
414
+ .filter(t => t.name === name)
415
+ .map(t => this._cloneTask(t));
354
416
  }
355
417
 
356
418
  /**
357
- * Get action by ID
419
+ * Get task by ID
358
420
  */
359
- getActionByID(id) {
421
+ getTaskByID(id) {
360
422
  const state = this.host.getState();
361
- const action = state.actions.find(a => a.id === id);
362
- return action ? this._cloneAction(action) : null;
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 actions scheduled in a time range
428
+ * Get tasks scheduled in a time range
367
429
  */
368
- getActionsInRange(startDate, endDate, callback) {
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 getActionsInRange)
445
+ * Simulate range (alias for getTasksInRange)
384
446
  */
385
447
  simulateRange(startDate, endDate) {
386
- return this.getActionsInRange(startDate, endDate);
448
+ return this.getTasksInRange(startDate, endDate);
387
449
  }
388
450
 
389
451
  /**
390
- * Describe an action in human-readable format
452
+ * Describe a task in human-readable format
391
453
  */
392
- describeAction(id) {
393
- const action = this.getActionByID(id);
394
- if (!action) {
454
+ describeTask(id) {
455
+ const task = this.getTaskByID(id);
456
+ if (!task) {
395
457
  return null;
396
458
  }
397
459
 
398
- let description = `Action #${action.id}`;
399
- if (action.name) {
400
- description += ` - ${action.name}`;
460
+ let description = `Task #${task.id}`;
461
+ if (task.name) {
462
+ description += ` - ${task.name}`;
401
463
  }
402
464
 
403
- description += `\n Command: ${action.cmd}`;
404
- description += `\n Next run: ${action.date ? action.date.toLocaleString() : 'None'}`;
405
- description += `\n Executions: ${action.count}`;
406
- description += `\n Buffered: ${!action.unBuffered}`;
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 (action.repeat) {
409
- description += `\n Recurrence: ${action.repeat.type}`;
410
- if (action.repeat.interval > 1) {
411
- description += ` (every ${action.repeat.interval})`;
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 (action.repeat.limit) {
414
- description += `\n Limit: ${action.repeat.limit}`;
476
+ if (task.repeat.limit) {
477
+ description += `\n Limit: ${task.repeat.limit}`;
415
478
  }
416
- if (action.repeat.endDate) {
417
- description += `\n End date: ${new Date(action.repeat.endDate).toLocaleString()}`;
479
+ if (task.repeat.endDate) {
480
+ description += `\n End date: ${new Date(task.repeat.endDate).toLocaleString()}`;
418
481
  }
419
- description += `\n DST policy: ${action.repeat.dstPolicy}`;
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 state = this.options.storage.load();
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
- this.host.setState(state);
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
- * Save state to storage
676
+ * Perform actual save to storage (called by state machine)
677
+ * @private
494
678
  */
495
- _saveState() {
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
- this.options.storage.save(state);
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
- * Start auto-save timer
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
- _startAutoSave() {
512
- if (this.saveTimer) {
706
+ _requestSave(force = false) {
707
+ if (!this.options.autoSave) {
513
708
  return;
514
709
  }
515
-
516
- this.saveTimer = setInterval(() => {
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
- * Calculate interval in milliseconds from a repeat spec.
524
- * Note: Uses approximations for month/year, as this is for a default
525
- * catch-up window, not for precise scheduling.
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 {object} repeat
528
- * @returns {number}
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
- _getIntervalMilliseconds(repeat) {
532
- const interval = repeat.interval || 1;
533
- switch (repeat.type) {
534
- case 'second':
535
- return interval * 1000;
536
- case 'minute':
537
- return interval * 60 * 1000;
538
- case 'hour':
539
- return interval * 60 * 60 * 1000;
540
- case 'day':
541
- case 'weekday': // Approximated as 1 day for catch-up purposes
542
- case 'weekend': // Approximated as 1 day for catch-up purposes
543
- return interval * 24 * 60 * 60 * 1000;
544
- case 'week':
545
- return interval * 7 * 24 * 60 * 60 * 1000;
546
- case 'month':
547
- // A reasonable approximation for a default is 30 days
548
- return interval * 30 * 24 * 60 * 60 * 1000;
549
- case 'year':
550
- // A reasonable approximation for a default is 365 days
551
- return interval * 365 * 24 * 60 * 60 * 1000;
552
- default:
553
- // Fallback for an invalid type that slipped past validation
554
- return 60000; // 1 minute
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
- * Normalize catchUpWindow property (handles backwards compatibility with unBuffered)
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
- _normalizeCatchUpWindow(spec) {
570
- // 1. New property takes precedence
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
- // 2. Backwards compatibility mapping for legacy 'unBuffered'
584
- if (spec.unBuffered !== undefined) {
585
- return spec.unBuffered ? 0 : "unlimited";
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
- // 3. For recurring actions, default to the interval duration
589
- if (spec.repeat) {
590
- return this._getIntervalMilliseconds(spec.repeat);
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
- // 4. For one-time actions, default to 0 (no catch-up)
594
- return 0;
857
+ return this._success({});
595
858
  }
596
859
 
597
860
  /**
598
- * Validate and normalize action specification
599
- * Philosophy: "Fail loudly, run defensively"
600
- * - Emit ERROR events for serious issues but coerce to reasonable defaults
601
- * - Emit DEBUG events for auto-corrections
602
- * - Never silently fail
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
- _validateAction(action) {
605
- if (!action.cmd) {
606
- throw new Error('Action must have a cmd property');
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 (action.repeat) {
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 (!action.repeat.type || !validTypes.includes(action.repeat.type)) {
614
- throw new Error(`Invalid repeat.type "${action.repeat.type}". Must be one of: ${validTypes.join(', ')}`);
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 (action.repeat.interval !== undefined) {
619
- const original = action.repeat.interval;
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
- actionSpec: action
896
+ taskSpec: task
626
897
  });
627
- action.repeat.interval = coerced;
898
+ task.repeat.interval = coerced;
628
899
  }
629
900
  }
630
901
 
631
902
  // Defensive: Validate dstPolicy
632
- if (action.repeat.dstPolicy && !['once', 'twice'].includes(action.repeat.dstPolicy)) {
903
+ if (task.repeat.dstPolicy && !['once', 'twice'].includes(task.repeat.dstPolicy)) {
633
904
  this._emit('warning', {
634
905
  type: 'warning',
635
- message: `Invalid dstPolicy "${action.repeat.dstPolicy}" - defaulting to "once"`,
636
- actionSpec: action
906
+ message: `Invalid dstPolicy "${task.repeat.dstPolicy}" - defaulting to "once"`,
907
+ taskSpec: task
637
908
  });
638
- action.repeat.dstPolicy = 'once';
909
+ task.repeat.dstPolicy = 'once';
639
910
  }
640
911
 
641
912
  // Defensive: Coerce invalid repeat.limit to null (unlimited) with WARNING event
642
- if (action.repeat.limit !== undefined && action.repeat.limit !== null) {
643
- if (typeof action.repeat.limit !== 'number' || action.repeat.limit < 1) {
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 ${action.repeat.limit} - defaulting to null (unlimited)`,
647
- actionSpec: action
917
+ message: `Invalid repeat.limit ${task.repeat.limit} - defaulting to null (unlimited)`,
918
+ taskSpec: task
648
919
  });
649
- action.repeat.limit = null;
920
+ task.repeat.limit = null;
650
921
  }
651
922
  }
652
923
 
653
924
  // Defensive: Validate repeat.endDate
654
- if (action.repeat.endDate !== undefined && action.repeat.endDate !== null) {
925
+ if (task.repeat.endDate !== undefined && task.repeat.endDate !== null) {
655
926
  try {
656
- new Date(action.repeat.endDate);
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
- actionSpec: action
932
+ taskSpec: task
662
933
  });
663
- action.repeat.endDate = null;
934
+ task.repeat.endDate = null;
664
935
  }
665
936
  }
666
937
  }
667
938
 
668
- // Defensive: Validate catchUpWindow if provided
669
- if (action.catchUpWindow !== undefined) {
670
- const isValidString = action.catchUpWindow === "unlimited";
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
- // Coerce negative numbers to 0 FIRST
675
- if (isNumber && action.catchUpWindow < 0) {
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;