@goodandready/dsh-cron 0.2.22 → 0.2.24

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/lib/burn-guard.js CHANGED
@@ -132,7 +132,7 @@ export async function checkAndApplyBurnGuard(scheduler, taskId, outcome) {
132
132
  scheduler.pauseTask(taskId, guard.reason);
133
133
 
134
134
  const alertMessage = formatBurnGuardAlert(task, guard);
135
- try {
135
+ await bestEffort('burn-guard-notify', async () => {
136
136
  if (typeof scheduler.deliverNotifications === 'function') {
137
137
  const alertRunInfo = {
138
138
  output: alertMessage,
@@ -144,9 +144,7 @@ export async function checkAndApplyBurnGuard(scheduler, taskId, outcome) {
144
144
  };
145
145
  await scheduler.deliverNotifications({ ...task, status: 'paused', pausedReason: guard.reason }, alertRunInfo);
146
146
  }
147
- } catch (err) {
148
- bestEffort('burn-guard-notify', () => {}, scheduler.logger);
149
- }
147
+ }, scheduler.logger);
150
148
 
151
149
  return guard;
152
150
  }
package/lib/client.js CHANGED
@@ -3380,7 +3380,7 @@ window.__ModuleLoader__.load({
3380
3380
  const [updateState, setUpdateState] = React.useState({
3381
3381
  checking: false,
3382
3382
  updating: false,
3383
- currentVersion: '0.2.22',
3383
+ currentVersion: '0.2.24',
3384
3384
  latestVersion: undefined,
3385
3385
  updateAvailable: false,
3386
3386
  notice: null,
@@ -3901,7 +3901,19 @@ window.__ModuleLoader__.load({
3901
3901
  const state = { toggle, openTaskSignal, root: undefined, listEl: undefined, headEl: undefined, open: false };
3902
3902
  try { state.open = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (err) { /* localStorage read failed */ }
3903
3903
 
3904
- const observer = new MutationObserver(() => placeSidebarJobs(state, label));
3904
+ let rafPending = false;
3905
+ const schedulePlace = () => {
3906
+ if (state.root !== undefined && state.root.isConnected) return;
3907
+ if (rafPending) return;
3908
+ rafPending = true;
3909
+ const scheduleFn = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => setTimeout(cb, 16);
3910
+ scheduleFn(() => {
3911
+ rafPending = false;
3912
+ placeSidebarJobs(state, label);
3913
+ });
3914
+ };
3915
+
3916
+ const observer = new MutationObserver(schedulePlace);
3905
3917
  observer.observe(document.body, { childList: true, subtree: true });
3906
3918
  placeSidebarJobs(state, label);
3907
3919
  loadSidebarJobs(state);
package/lib/index.js CHANGED
@@ -74,6 +74,8 @@ import {
74
74
  SETTINGS_SYNC_KEYS,
75
75
  applySettingsToScope,
76
76
  sanitizeSettingsPayload,
77
+ unwrapConfig,
78
+ unwrapConfigValue,
77
79
  } from './settings.js';
78
80
  import { registerRoutes } from './routes.js';
79
81
 
@@ -82,10 +84,12 @@ export {
82
84
  SETTINGS_SYNC_KEYS,
83
85
  applySettingsToScope,
84
86
  sanitizeSettingsPayload,
87
+ unwrapConfig,
88
+ unwrapConfigValue,
85
89
  };
86
90
 
87
91
  export const name = '@goodandready/dsh-cron';
88
- export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
92
+ export const inject = ['tools', 'webServer', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
89
93
 
90
94
  import { executeCreateTask } from './task-create.js';
91
95
  export { executeCreateTask };
@@ -147,8 +151,23 @@ const createTaskOutput = {
147
151
  render: (_args, val) => [{ type: 'text', text: val.message || JSON.stringify(val) }]
148
152
  };
149
153
 
150
- export function apply(ctx, config) {
154
+ export function apply(ctx, rawConfig) {
155
+ const config = unwrapConfig(rawConfig);
151
156
  const store = new TaskStore();
157
+
158
+ // Sync settings declared in the profile config into the store (#184)
159
+ if (config && typeof config === 'object') {
160
+ const fromConfig = {};
161
+ for (const key of SETTINGS_SYNC_KEYS) {
162
+ const val = config[key];
163
+ if (val !== undefined && val !== null && val !== '') {
164
+ fromConfig[key] = val;
165
+ }
166
+ }
167
+ if (Object.keys(fromConfig).length > 0) {
168
+ store.saveSettings(fromConfig);
169
+ }
170
+ }
152
171
  const runner = new SessionRunner(ctx, store);
153
172
  const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts), {
154
173
  maxConcurrent: config && config.maxConcurrent,
@@ -166,29 +185,22 @@ export function apply(ctx, config) {
166
185
  }),
167
186
  });
168
187
 
169
- // Register settings in the Harness settings service (#71, #102): the scope
170
- // is the source of truth for the core, scope.watch mirrors values into the
171
- // plugin store, and REST writes go through scope.set so nothing bypasses
172
- // the settings service.
173
- let cronSettingsScope = null;
188
+ // Settings integration for DSH 0.1.7+ (#184):
189
+ // DSH 0.1.7 removed legacy settings.register and uses volatile Config fields.
190
+ // We attach to settings optionally, configure presentation, and synchronize writes.
191
+ let settingsService = null;
174
192
  ctx.inject(['settings'], (sctx) => {
193
+ settingsService = sctx.settings;
175
194
  try {
176
- cronSettingsScope = sctx.settings.register('dsh-cron', Config, {
177
- base: store.getSettings()
178
- });
179
- if (cronSettingsScope && typeof cronSettingsScope.watch === 'function') {
180
- cronSettingsScope.watch((updated) => {
181
- if (updated && typeof updated === 'object') {
182
- store.saveSettings(updated);
183
- }
184
- });
195
+ if (settingsService && typeof settingsService.configure === 'function') {
196
+ sctx.effect(() => settingsService.configure({ auto: false }, ctx.fiber));
185
197
  }
186
- sctx.effect(() => () => {
187
- cronSettingsScope = null;
188
- });
189
- } catch (e) {
190
- console.warn('[dsh-cron] settings registration warning:', e.message);
198
+ } catch {
199
+ // ignore presentation policy errors
191
200
  }
201
+ sctx.effect(() => () => {
202
+ settingsService = null;
203
+ });
192
204
  });
193
205
 
194
206
  // #50: jobs declared in the profile config belong to the config. The sync
@@ -239,7 +251,7 @@ export function apply(ctx, config) {
239
251
  registerRoutes(ctx, {
240
252
  store,
241
253
  scheduler,
242
- getCronSettingsScope: () => cronSettingsScope,
254
+ getCronSettingsScope: () => settingsService,
243
255
  recommendations,
244
256
  });
245
257
 
@@ -258,11 +270,13 @@ export function apply(ctx, config) {
258
270
  const { body, error } = await readBody(req, res);
259
271
  if (error) return;
260
272
  const payload = sanitizeSettingsPayload(body);
261
- const scopeErrors = await applySettingsToScope(cronSettingsScope, payload);
262
- if (scopeErrors === null) {
263
- store.saveSettings(payload);
264
- }
265
- sendJson(res, 200, { ok: true, settings: store.getClientSettings(), scopeErrors: scopeErrors || undefined });
273
+ store.saveSettings(payload);
274
+ const scopeErrors = await applySettingsToScope(settingsService, payload);
275
+ sendJson(res, 200, {
276
+ ok: true,
277
+ settings: store.getClientSettings(),
278
+ scopeErrors: (scopeErrors && scopeErrors.length > 0) ? scopeErrors : undefined
279
+ });
266
280
  return;
267
281
  }
268
282
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
@@ -1,4 +1,4 @@
1
- import { checkAndApplyBurnGuard } from './burn-guard.js';
1
+ import { checkAndApplyBurnGuard, checkTaskBudgetLimits } from './burn-guard.js';
2
2
  import { resolveTaskForExecution } from './prompt-interpolation.js';
3
3
  import { bestEffort } from './best-effort.js';
4
4
  import { exec } from 'node:child_process';
@@ -66,8 +66,8 @@ export async function executePreflight(task) {
66
66
  }
67
67
  return { ok: true };
68
68
  } catch (err) {
69
- bestEffort('statfsSync', () => {}, null);
70
- return { ok: true }; // Fail-open
69
+ // Fail-open if statfsSync is unavailable or throws
70
+ return { ok: true };
71
71
  }
72
72
  }
73
73
 
@@ -185,6 +185,17 @@ export async function executeTask(scheduler, taskId, options = {}) {
185
185
  const task = scheduler.store.get(taskId);
186
186
  if (!task) return null;
187
187
 
188
+ // Pre-flight Burn Guard check (#181): prevent executing task if budget already breached
189
+ const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
190
+ const guard = checkTaskBudgetLimits(task, runs);
191
+ if (guard.exceeded) {
192
+ console.warn(`[dsh-cron] Burn Guard pre-flight blocked task "${task.title}" (${taskId}): ${guard.reason}`);
193
+ scheduler.pauseTask(taskId, guard.reason);
194
+ scheduler.recordSkipped(taskId, `Burn Guard pre-flight pause: ${guard.reason}`);
195
+ checkAndApplyBurnGuard(scheduler, taskId, { status: 'error', costUsd: 0, durationMs: 0 }).catch(() => {});
196
+ return { ok: false, status: 'error', error: guard.reason, preflightBlocked: true };
197
+ }
198
+
188
199
  if (!options.isRetry && task.attempts) {
189
200
  task.attempts = 0;
190
201
  scheduler.store.set(task);
package/lib/settings.js CHANGED
@@ -3,43 +3,76 @@ import { credentialRefStatus } from './secrets.js';
3
3
  import { TaskStore } from './store.js';
4
4
  import { MIN_DELIVERY_TIMEOUT_MS, resolveDeliveryTimeoutMs } from './channels.js';
5
5
 
6
+ // Polyfill Schema.prototype.volatile for schemastery < 3.18.3 environments
7
+ if (typeof z.prototype?.volatile !== 'function') {
8
+ z.prototype.volatile = function volatile() {
9
+ if (this.meta && this.meta.volatile) throw new TypeError('volatile schema is already wrapped');
10
+ return typeof this.extra === 'function' ? this.extra('volatile', true) : this;
11
+ };
12
+ }
13
+
6
14
  export const Config = z.object({
7
- botToken: z.string().role('secret').default('').description('Legacy Telegram Bot API token (prefer botTokenRef with the DSH credentials service)'),
8
- botTokenRef: z.string().default('').description('Name of the DSH credential that holds the Telegram bot token'),
9
- chatId: z.string().default('').description('Telegram chat ID that receives task reports'),
10
- notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
11
- onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
12
- kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API'),
13
- defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)'),
14
- maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
15
- heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
16
- heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)'),
15
+ botToken: z.string().role('secret').default('').description('Legacy Telegram Bot API token (prefer botTokenRef with the DSH credentials service)').volatile(),
16
+ botTokenRef: z.string().default('').description('Name of the DSH credential that holds the Telegram bot token').volatile(),
17
+ chatId: z.string().default('').description('Telegram chat ID that receives task reports').volatile(),
18
+ notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task').volatile(),
19
+ onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs').volatile(),
20
+ kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API').volatile(),
21
+ defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)').volatile(),
22
+ maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)').volatile(),
23
+ heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval').volatile(),
24
+ heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)').volatile(),
17
25
  // --- Delivery channels (#20-#23, #26, #28, #47) ---
18
- template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
19
- channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
20
- silentRuleModel: z.string().default('').description('Model used to evaluate a task silent rule (empty = the task model)'),
21
- inspectorModel: z.string().default('').description('Model used to diagnose failed runs (empty = the task model)'),
22
- deliveryTimeoutMs: z.number().min(MIN_DELIVERY_TIMEOUT_MS).default(15000).description('Per-channel delivery timeout in ms (minimum 1000); a slower endpoint is recorded as a failure and does not block other channels or the next tick'),
23
- discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
24
- slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
25
- ntfyUrl: z.string().default('https://ntfy.sh').description('ntfy server base URL'),
26
- ntfyTopic: z.string().default('').description('ntfy topic for push notifications'),
27
- ntfyTokenRef: z.string().default('').description('Credential name for the ntfy access token'),
28
- barkServerUrl: z.string().default('https://api.day.app').description('Bark server base URL'),
29
- barkKey: z.string().default('').description('Bark device key'),
30
- pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token'),
31
- pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)'),
32
- ttsBaseUrl: z.string().default('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route'),
33
- giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
34
- giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
35
- giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
36
- prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)'),
26
+ template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}').volatile(),
27
+ channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id').volatile(),
28
+ silentRuleModel: z.string().default('').description('Model used to evaluate a task silent rule (empty = the task model)').volatile(),
29
+ inspectorModel: z.string().default('').description('Model used to diagnose failed runs (empty = the task model)').volatile(),
30
+ deliveryTimeoutMs: z.number().min(MIN_DELIVERY_TIMEOUT_MS).default(15000).description('Per-channel delivery timeout in ms (minimum 1000); a slower endpoint is recorded as a failure and does not block other channels or the next tick').volatile(),
31
+ discordWebhookUrl: z.string().default('').description('Discord webhook URL').volatile(),
32
+ slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL').volatile(),
33
+ ntfyUrl: z.string().default('https://ntfy.sh').description('ntfy server base URL').volatile(),
34
+ ntfyTopic: z.string().default('').description('ntfy topic for push notifications').volatile(),
35
+ ntfyTokenRef: z.string().default('').description('Credential name for the ntfy access token').volatile(),
36
+ barkServerUrl: z.string().default('https://api.day.app').description('Bark server base URL').volatile(),
37
+ barkKey: z.string().default('').description('Bark device key').volatile(),
38
+ pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token').volatile(),
39
+ pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)').volatile(),
40
+ ttsBaseUrl: z.string().default('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route').volatile(),
41
+ giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues').volatile(),
42
+ giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues').volatile(),
43
+ giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token').volatile(),
44
+ prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)').volatile(),
37
45
  // --- Declarative jobs (#50, ADR-0001) ---
38
46
  jobs: z.array(z.any()).default([]).description('Static jobs owned by the config; each entry needs id, title, schedule and (for agent types) prompt'),
39
47
  // --- External REST API (#54, ADR-0001) ---
40
- apiToken: z.string().role('secret').default('').description('Bearer token for the external /dsh-cron/api/* surface (empty = the surface answers 503)'),
48
+ apiToken: z.string().role('secret').default('').description('Bearer token for the external /dsh-cron/api/* surface (empty = the surface answers 503)').volatile(),
41
49
  });
42
50
 
51
+
52
+ /**
53
+ * Unwrap a volatile config reference or wrap a config object in a transparent Proxy.
54
+ */
55
+ export function unwrapConfigValue(val) {
56
+ if (val && typeof val === 'object' && typeof val.get === 'function') {
57
+ return val.get();
58
+ }
59
+ return val;
60
+ }
61
+
62
+ export function unwrapConfig(cfg) {
63
+ if (!cfg || typeof cfg !== 'object') return cfg;
64
+ if (typeof cfg.get === 'function') return unwrapConfig(cfg.get());
65
+ return new Proxy(cfg, {
66
+ get(target, prop, receiver) {
67
+ const val = Reflect.get(target, prop, receiver);
68
+ if (val && typeof val === 'object' && typeof val.get === 'function') {
69
+ return val.get();
70
+ }
71
+ return val;
72
+ }
73
+ });
74
+ }
75
+
43
76
  export const SETTINGS_SYNC_KEYS = [
44
77
  'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
45
78
  'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
@@ -58,18 +91,37 @@ export const SETTINGS_SYNC_KEYS = [
58
91
  * snapshot stays authoritative (#102). Returns null when the settings
59
92
  * service is unavailable (headless profiles fall back to store-only writes).
60
93
  */
61
- export async function applySettingsToScope(settingsScope, body) {
62
- if (!settingsScope || typeof settingsScope.set !== 'function') return null;
63
- const errors = [];
64
- for (const key of SETTINGS_SYNC_KEYS) {
65
- if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
94
+ export async function applySettingsToScope(settingsTarget, body) {
95
+ if (!settingsTarget) return null;
96
+ // DSH 0.1.7: SettingsForms service has update(ns, patch)
97
+ if (typeof settingsTarget.update === 'function') {
66
98
  try {
67
- await settingsScope.set(key, body[key]);
99
+ const patch = {};
100
+ for (const key of SETTINGS_SYNC_KEYS) {
101
+ if (Object.prototype.hasOwnProperty.call(body, key)) {
102
+ patch[key] = body[key];
103
+ }
104
+ }
105
+ await settingsTarget.update('dsh-cron', patch);
106
+ return [];
68
107
  } catch (e) {
69
- errors.push(key + ': ' + ((e && e.message) || e));
108
+ return ['dsh-cron: ' + ((e && e.message) || e)];
109
+ }
110
+ }
111
+ // Legacy / mock scope with set(key, value)
112
+ if (typeof settingsTarget.set === 'function') {
113
+ const errors = [];
114
+ for (const key of SETTINGS_SYNC_KEYS) {
115
+ if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
116
+ try {
117
+ await settingsTarget.set(key, body[key]);
118
+ } catch (e) {
119
+ errors.push(key + ': ' + ((e && e.message) || e));
120
+ }
70
121
  }
122
+ return errors;
71
123
  }
72
- return errors;
124
+ return null;
73
125
  }
74
126
 
75
127
  /**
package/lib/store.js CHANGED
@@ -34,6 +34,8 @@ export class TaskStore {
34
34
  this.tasks = new Map();
35
35
  this.history = new Map();
36
36
  this.settings = {};
37
+ this._lastBackupTime = 0;
38
+ this._saveTimer = null;
37
39
  this._savePromise = Promise.resolve();
38
40
  this.init();
39
41
  }
@@ -102,7 +104,36 @@ export class TaskStore {
102
104
  }
103
105
  }
104
106
 
107
+ /**
108
+ * Schedule a debounced save to avoid synchronous event loop blocking on high-frequency run completions.
109
+ */
110
+ scheduleSave(delay = 50) {
111
+ if (this._saveTimer) return;
112
+ this._saveTimer = setTimeout(() => {
113
+ this._saveTimer = null;
114
+ this.save();
115
+ }, delay);
116
+ if (typeof this._saveTimer.unref === 'function') {
117
+ this._saveTimer.unref();
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Synchronously flush any pending debounced save to disk.
123
+ */
124
+ flushSync() {
125
+ if (this._saveTimer) {
126
+ clearTimeout(this._saveTimer);
127
+ this._saveTimer = null;
128
+ }
129
+ this.save();
130
+ }
131
+
105
132
  save() {
133
+ if (this._saveTimer) {
134
+ clearTimeout(this._saveTimer);
135
+ this._saveTimer = null;
136
+ }
106
137
  try {
107
138
  const dir = path.dirname(this.filePath);
108
139
  if (!fs.existsSync(dir)) {
@@ -120,7 +151,12 @@ export class TaskStore {
120
151
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
121
152
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
122
153
  fs.renameSync(tmp, this.filePath);
123
- bestEffort('backup-on-save', () => fs.copyFileSync(this.filePath, `${this.filePath}.bak`));
154
+ // Throttled backup (.bak): avoid expensive copyFileSync on every micro-save
155
+ const now = Date.now();
156
+ if (now - this._lastBackupTime > 60000) {
157
+ this._lastBackupTime = now;
158
+ bestEffort('backup-on-save', () => fs.copyFileSync(this.filePath, `${this.filePath}.bak`));
159
+ }
124
160
  } catch (err) {
125
161
  console.error('[dsh-cron] store save error:', err.message);
126
162
  }
@@ -290,8 +326,8 @@ export class TaskStore {
290
326
  agentPreset: task.agentPreset !== undefined ? String(task.agentPreset).trim() : (prev.agentPreset || ''),
291
327
  targetSessionId: task.targetSessionId !== undefined ? String(task.targetSessionId).trim() : (prev.targetSessionId || ''),
292
328
  targetSessionReset: task.targetSessionReset !== undefined ? String(task.targetSessionReset).trim() : (prev.targetSessionReset || 'never'),
293
- totalTokens: prev.totalTokens || 0,
294
- totalCostUsd: prev.totalCostUsd || 0,
329
+ totalTokens: task.totalTokens !== undefined ? Number(task.totalTokens) : (prev.totalTokens || 0),
330
+ totalCostUsd: task.totalCostUsd !== undefined ? Number(task.totalCostUsd) : (prev.totalCostUsd || 0),
295
331
  createdAt: prev.createdAt || task.createdAt || now,
296
332
  updatedAt: now,
297
333
  lastRunAt: task.lastRunAt !== undefined ? task.lastRunAt : (prev.lastRunAt || null),
@@ -379,7 +415,7 @@ export class TaskStore {
379
415
  this._archiveHistoryRuns(id, overflow);
380
416
  }
381
417
  this.history.set(id, runs);
382
- this.save();
418
+ this.scheduleSave();
383
419
  }
384
420
 
385
421
  /**
@@ -399,10 +435,10 @@ export class TaskStore {
399
435
  if (archive[id].length > 1000) {
400
436
  archive[id] = archive[id].slice(-1000);
401
437
  }
402
- // Write atomically with tmp file
438
+ // Write atomically with tmp file using compact JSON (avoiding mega-lines and null, 2 serialization bloat)
403
439
  const rand = Math.random().toString(36).slice(2, 8);
404
440
  const tmp = `${archivePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
405
- fs.writeFileSync(tmp, JSON.stringify(archive, null, 2), 'utf-8');
441
+ fs.writeFileSync(tmp, JSON.stringify(archive), 'utf-8');
406
442
  fs.renameSync(tmp, archivePath);
407
443
  } catch (archiveErr) {
408
444
  console.error('[dsh-cron] history archive error:', archiveErr.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",