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/src/Automator.js CHANGED
@@ -1,33 +1,56 @@
1
1
  /**
2
2
  * Automator.js
3
3
  *
4
- * Main API class for jw-automator v4
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,266 +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
- catchUpWindow: catchUpWindow,
139
- 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,
140
163
  count: 0
141
164
  };
142
165
 
143
166
  // Set default dstPolicy if not specified
144
- if (action.repeat && !action.repeat.dstPolicy) {
145
- action.repeat.dstPolicy = 'once';
167
+ if (task.repeat && !task.repeat.dstPolicy) {
168
+ task.repeat.dstPolicy = 'once';
146
169
  }
147
170
 
148
- this.host.addAction(action);
171
+ this.host.addTask(task);
149
172
 
150
173
  this._emit('update', {
151
174
  type: 'update',
152
175
  operation: 'add',
153
- action: { ...action }
176
+ task: { ...task }
154
177
  });
155
178
 
156
- if (this.options.autoSave) {
157
- this._saveState();
158
- }
179
+ // Save immediately
180
+ this._requestSave(true);
159
181
 
160
- return action.id;
182
+ return this._success({ id: task.id });
161
183
  }
162
184
 
163
185
  /**
164
- * Update an action by ID
186
+ * Update a task by ID
187
+ * @returns {Object} - Result object with success/error
165
188
  */
166
- updateActionByID(id, updates) {
189
+ updateTaskByID(id, updates) {
167
190
  const state = this.host.getState();
168
- const action = state.actions.find(a => a.id === id);
191
+ const task = state.tasks.find(t => t.id === id);
169
192
 
170
- if (!action) {
171
- 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;
172
216
  }
173
217
 
174
218
  // Update allowed fields
175
- const allowedUpdates = ['name', 'cmd', 'payload', 'date', 'unBuffered', 'catchUpWindow', 'repeat', 'count'];
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
- action[key] = new Date(updates[key]);
224
+ task[key] = new Date(updates[key]);
181
225
  } else if (key === 'repeat' && updates[key]) {
182
- action[key] = { ...updates[key] };
183
- if (!action[key].dstPolicy) {
184
- 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';
185
230
  }
186
231
  } else {
187
- action[key] = updates[key];
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
- 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
- delete action.unBuffered; // Remove legacy property
246
- }
247
-
248
300
  this._emit('update', {
249
301
  type: 'update',
250
302
  operation: 'update',
251
- actionId: action.id,
252
- action: { ...action }
303
+ taskId: task.id,
304
+ task: { ...task }
253
305
  });
254
306
  }
255
307
 
256
- if (this.options.autoSave) {
257
- this._saveState();
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 action by ID
315
+ * Remove task by ID
316
+ * @returns {Object} - Result object with success/error
265
317
  */
266
- removeActionByID(id) {
318
+ removeTaskByID(id) {
267
319
  const state = this.host.getState();
268
- const index = state.actions.findIndex(a => a.id === id);
320
+ const index = state.tasks.findIndex(t => t.id === id);
269
321
 
270
322
  if (index === -1) {
271
- 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
+ );
272
328
  }
273
329
 
274
- const removed = state.actions.splice(index, 1)[0];
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
- actionId: id,
280
- action: removed
335
+ taskId: id,
336
+ task: removed
281
337
  });
282
338
 
283
- if (this.options.autoSave) {
284
- this._saveState();
285
- }
339
+ // Save immediately
340
+ this._requestSave(true);
341
+
342
+ return this._success({ id, task: removed });
286
343
  }
287
344
 
288
345
  /**
289
- * Remove actions by name
346
+ * Remove tasks by name
347
+ * @returns {Object} - Result object with success/error
290
348
  */
291
- removeActionByName(name) {
349
+ removeTaskByName(name) {
292
350
  const state = this.host.getState();
293
- const toRemove = state.actions.filter(a => a.name === name);
351
+ const toRemove = state.tasks.filter(t => t.name === name);
294
352
 
295
353
  if (toRemove.length === 0) {
296
- 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
+ );
297
359
  }
298
360
 
299
- for (const action of toRemove) {
300
- const index = state.actions.indexOf(action);
361
+ for (const task of toRemove) {
362
+ const index = state.tasks.indexOf(task);
301
363
  if (index !== -1) {
302
- state.actions.splice(index, 1);
364
+ state.tasks.splice(index, 1);
303
365
 
304
366
  this._emit('update', {
305
367
  type: 'update',
306
368
  operation: 'remove',
307
- actionId: action.id,
308
- action
369
+ taskId: task.id,
370
+ task
309
371
  });
310
372
  }
311
373
  }
312
374
 
313
- if (this.options.autoSave) {
314
- this._saveState();
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 an action
382
+ * Deep clone a task
322
383
  */
323
- _cloneAction(action) {
324
- const cloned = { ...action };
384
+ _cloneTask(task) {
385
+ const cloned = { ...task };
325
386
 
326
387
  // Deep copy nested objects
327
- if (action.repeat) {
328
- cloned.repeat = { ...action.repeat };
388
+ if (task.repeat) {
389
+ cloned.repeat = { ...task.repeat };
329
390
  }
330
391
 
331
392
  // Clone Date objects properly
332
- if (action.date) {
333
- cloned.date = new Date(action.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 actions (deep copy)
401
+ * Get all tasks (deep copy)
341
402
  */
342
- getActions() {
403
+ getTasks() {
343
404
  const state = this.host.getState();
344
- return state.actions.map(a => this._cloneAction(a));
405
+ return state.tasks.map(t => this._cloneTask(t));
345
406
  }
346
407
 
347
408
  /**
348
- * Get actions by name
409
+ * Get tasks by name
349
410
  */
350
- getActionsByName(name) {
411
+ getTasksByName(name) {
351
412
  const state = this.host.getState();
352
- return state.actions
353
- .filter(a => a.name === name)
354
- .map(a => this._cloneAction(a));
413
+ return state.tasks
414
+ .filter(t => t.name === name)
415
+ .map(t => this._cloneTask(t));
355
416
  }
356
417
 
357
418
  /**
358
- * Get action by ID
419
+ * Get task by ID
359
420
  */
360
- getActionByID(id) {
421
+ getTaskByID(id) {
361
422
  const state = this.host.getState();
362
- const action = state.actions.find(a => a.id === id);
363
- return action ? this._cloneAction(action) : null;
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 actions scheduled in a time range
428
+ * Get tasks scheduled in a time range
368
429
  */
369
- getActionsInRange(startDate, endDate, callback) {
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 getActionsInRange)
445
+ * Simulate range (alias for getTasksInRange)
385
446
  */
386
447
  simulateRange(startDate, endDate) {
387
- return this.getActionsInRange(startDate, endDate);
448
+ return this.getTasksInRange(startDate, endDate);
388
449
  }
389
450
 
390
451
  /**
391
- * Describe an action in human-readable format
452
+ * Describe a task in human-readable format
392
453
  */
393
- describeAction(id) {
394
- const action = this.getActionByID(id);
395
- if (!action) {
454
+ describeTask(id) {
455
+ const task = this.getTaskByID(id);
456
+ if (!task) {
396
457
  return null;
397
458
  }
398
459
 
399
- let description = `Action #${action.id}`;
400
- if (action.name) {
401
- description += ` - ${action.name}`;
460
+ let description = `Task #${task.id}`;
461
+ if (task.name) {
462
+ description += ` - ${task.name}`;
402
463
  }
403
464
 
404
- description += `\n Command: ${action.cmd}`;
405
- description += `\n Next run: ${action.date ? action.date.toLocaleString() : 'None'}`;
406
- description += `\n Executions: ${action.count}`;
407
- description += `\n Catch-up Window: ${action.catchUpWindow === 'unlimited' ? 'unlimited' : `${action.catchUpWindow}ms`}`;
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 (action.repeat) {
410
- description += `\n Recurrence: ${action.repeat.type}`;
411
- if (action.repeat.interval > 1) {
412
- 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})`;
413
475
  }
414
- if (action.repeat.limit) {
415
- description += `\n Limit: ${action.repeat.limit}`;
476
+ if (task.repeat.limit) {
477
+ description += `\n Limit: ${task.repeat.limit}`;
416
478
  }
417
- if (action.repeat.endDate) {
418
- 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()}`;
419
481
  }
420
- description += `\n DST policy: ${action.repeat.dstPolicy}`;
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 state = this.options.storage.load();
634
+ const filePath = path.resolve(this.options.storageFile);
469
635
 
470
- if (state.actions && state.actions.length > 0) {
471
- state.actions.forEach(action => {
472
- // Set catchUpWindow if missing, using legacy unBuffered if present
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
- const maxId = Math.max(...state.actions.map(a => a.id || 0));
481
- this.nextId = maxId + 1;
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
- this.host.setState(state);
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
- * Save state to storage
676
+ * Perform actual save to storage (called by state machine)
677
+ * @private
496
678
  */
497
- _saveState() {
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
- this.options.storage.save(state);
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
- * 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
512
705
  */
513
- _startAutoSave() {
514
- if (this.saveTimer) {
706
+ _requestSave(force = false) {
707
+ if (!this.options.autoSave) {
515
708
  return;
516
709
  }
517
-
518
- this.saveTimer = setInterval(() => {
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
- * Calculate interval in milliseconds from a repeat spec.
526
- * Note: Uses approximations for month/year, as this is for a default
527
- * 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.
528
718
  *
529
- * @param {object} repeat
530
- * @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.
531
722
  * @private
532
723
  */
533
- _getIntervalMilliseconds(repeat) {
534
- const interval = repeat.interval || 1;
535
- switch (repeat.type) {
536
- case 'second':
537
- return interval * 1000;
538
- case 'minute':
539
- return interval * 60 * 1000;
540
- case 'hour':
541
- return interval * 60 * 60 * 1000;
542
- case 'day':
543
- case 'weekday': // Approximated as 1 day for catch-up purposes
544
- case 'weekend': // Approximated as 1 day for catch-up purposes
545
- return interval * 24 * 60 * 60 * 1000;
546
- case 'week':
547
- return interval * 7 * 24 * 60 * 60 * 1000;
548
- case 'month':
549
- // A reasonable approximation for a default is 30 days
550
- return interval * 30 * 24 * 60 * 60 * 1000;
551
- case 'year':
552
- // A reasonable approximation for a default is 365 days
553
- return interval * 365 * 24 * 60 * 60 * 1000;
554
- default:
555
- // Fallback for an invalid type that slipped past validation
556
- 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
+ }
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
- * Normalize catchUpWindow property (handles backwards compatibility with unBuffered)
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
- _normalizeCatchUpWindow(spec) {
572
- // 1. New property takes precedence
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
- // 2. Backwards compatibility mapping for legacy 'unBuffered'
586
- if (spec.unBuffered !== undefined) {
587
- 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
+ );
588
834
  }
589
835
 
590
- // 3. For recurring actions, default to the interval duration
591
- if (spec.repeat) {
592
- 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
+ );
593
855
  }
594
856
 
595
- // 4. For one-time actions, default to 0 (no catch-up)
596
- return 0;
857
+ return this._success({});
597
858
  }
598
859
 
599
860
  /**
600
- * Validate and normalize action specification
601
- * Philosophy: "Fail loudly, run defensively"
602
- * - Emit ERROR events for serious issues but coerce to reasonable defaults
603
- * - Emit DEBUG events for auto-corrections
604
- * - 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
605
866
  */
606
- _validateAction(action) {
607
- if (!action.cmd) {
608
- 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
+ );
609
874
  }
610
875
 
611
- if (action.repeat) {
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 (!action.repeat.type || !validTypes.includes(action.repeat.type)) {
616
- 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
+ );
617
886
  }
618
887
 
619
888
  // Defensive: Coerce invalid interval to Math.max(1, Math.floor(value))
620
- if (action.repeat.interval !== undefined) {
621
- const original = action.repeat.interval;
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
- actionSpec: action
896
+ taskSpec: task
628
897
  });
629
- action.repeat.interval = coerced;
898
+ task.repeat.interval = coerced;
630
899
  }
631
900
  }
632
901
 
633
902
  // Defensive: Validate dstPolicy
634
- if (action.repeat.dstPolicy && !['once', 'twice'].includes(action.repeat.dstPolicy)) {
903
+ if (task.repeat.dstPolicy && !['once', 'twice'].includes(task.repeat.dstPolicy)) {
635
904
  this._emit('warning', {
636
905
  type: 'warning',
637
- message: `Invalid dstPolicy "${action.repeat.dstPolicy}" - defaulting to "once"`,
638
- actionSpec: action
906
+ message: `Invalid dstPolicy "${task.repeat.dstPolicy}" - defaulting to "once"`,
907
+ taskSpec: task
639
908
  });
640
- action.repeat.dstPolicy = 'once';
909
+ task.repeat.dstPolicy = 'once';
641
910
  }
642
911
 
643
912
  // Defensive: Coerce invalid repeat.limit to null (unlimited) with WARNING event
644
- if (action.repeat.limit !== undefined && action.repeat.limit !== null) {
645
- 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) {
646
915
  this._emit('warning', {
647
916
  type: 'warning',
648
- message: `Invalid repeat.limit ${action.repeat.limit} - defaulting to null (unlimited)`,
649
- actionSpec: action
917
+ message: `Invalid repeat.limit ${task.repeat.limit} - defaulting to null (unlimited)`,
918
+ taskSpec: task
650
919
  });
651
- action.repeat.limit = null;
920
+ task.repeat.limit = null;
652
921
  }
653
922
  }
654
923
 
655
924
  // Defensive: Validate repeat.endDate
656
- if (action.repeat.endDate !== undefined && action.repeat.endDate !== null) {
925
+ if (task.repeat.endDate !== undefined && task.repeat.endDate !== null) {
657
926
  try {
658
- new Date(action.repeat.endDate);
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
- actionSpec: action
932
+ taskSpec: task
664
933
  });
665
- action.repeat.endDate = null;
934
+ task.repeat.endDate = null;
666
935
  }
667
936
  }
668
937
  }
669
938
 
670
- // Defensive: Validate catchUpWindow if provided
671
- if (action.catchUpWindow !== undefined) {
672
- const isValidString = action.catchUpWindow === "unlimited";
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
- // Coerce negative numbers to 0 FIRST
677
- if (isNumber && action.catchUpWindow < 0) {
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;