@goodandready/dsh-cron 0.2.46 → 0.2.47

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.
Files changed (2) hide show
  1. package/lib/store.js +151 -149
  2. package/package.json +1 -1
package/lib/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { logger } from './logger.js';
2
2
  import { bestEffort } from './best-effort.js';
3
3
  import fs from 'node:fs';
4
+ import { pipeline } from 'node:stream/promises';
4
5
  import path from 'node:path';
5
6
  import { getDshDefaultTelegramCredentials } from './telegram.js';
6
7
 
@@ -168,15 +169,36 @@ export class TaskStore {
168
169
  * Asynchronously serialize and write tasks store to disk using fs.promises queue (#244).
169
170
  * Safe mode 0600 on files and 0700 on dir (#222).
170
171
  */
171
- async saveAsync() {
172
- const version = ++this._saveVersion;
173
- const data = {
172
+ /**
173
+ * Create an isolated deep snapshot of tasks, history, and settings (#301).
174
+ * Prevents concurrent in-memory mutations (e.g. recordRun during streamed save)
175
+ * from corrupting serialized JSON state or causing duplicate / skipped run records.
176
+ */
177
+ _createSnapshot() {
178
+ const tasksSnapshot = Array.from(this.tasks.values()).map((t) => ({
179
+ ...t,
180
+ costLedger: Array.isArray(t.costLedger) ? t.costLedger.slice() : t.costLedger,
181
+ }));
182
+ const historySnapshot = {};
183
+ for (const [id, runs] of this.history.entries()) {
184
+ historySnapshot[id] = Array.isArray(runs) ? runs.slice() : runs;
185
+ }
186
+ return {
174
187
  version: 1,
175
188
  updatedAt: new Date().toISOString(),
176
- tasks: Array.from(this.tasks.values()),
177
- history: Object.fromEntries(this.history.entries()),
178
- settings: this.settings || {},
189
+ tasks: tasksSnapshot,
190
+ history: historySnapshot,
191
+ settings: this.settings ? { ...this.settings } : {},
179
192
  };
193
+ }
194
+
195
+ /**
196
+ * Asynchronously serialize and write tasks store to disk using fs.promises queue (#244, #301, #303).
197
+ * Safe mode 0600 on files and 0700 on dir (#222).
198
+ */
199
+ async saveAsync() {
200
+ const version = ++this._saveVersion;
201
+ const data = this._createSnapshot();
180
202
 
181
203
  const writeOp = async () => {
182
204
  if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
@@ -244,116 +266,98 @@ export class TaskStore {
244
266
  * Stream data incrementally to disk with fine-grained setImmediate yields (#244).
245
267
  * Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
246
268
  */
247
- _writeStoreStream(filePath, data, version) {
248
- return new Promise((resolve, reject) => {
249
- let aborted = false;
250
- const ws = fs.createWriteStream(filePath, { encoding: 'utf-8', mode: 0o600 });
251
-
252
- const cleanupListeners = () => {
253
- ws.removeAllListeners('error');
254
- ws.removeAllListeners('drain');
255
- };
256
-
257
- ws.on('error', (err) => {
258
- aborted = true;
259
- cleanupListeners();
260
- reject(err);
261
- });
269
+ /**
270
+ * Stream data incrementally to disk with fine-grained setImmediate yields (#244, #303).
271
+ * Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
272
+ * Uses canonical Node stream/promises pipeline for error-handling and backpressure hygiene (#303).
273
+ */
274
+ async _writeStoreStream(filePath, data, version) {
275
+ const isCanceled = () => {
276
+ return (
277
+ (this._diskVersion || 0) >= version ||
278
+ (this._lastSyncVersion || 0) >= version ||
279
+ this._saveVersion > version
280
+ );
281
+ };
282
+
283
+ if (isCanceled()) {
284
+ return;
285
+ }
286
+
287
+ const ac = new AbortController();
288
+ let ws;
289
+ try {
290
+ ws = fs.createWriteStream(filePath, { encoding: 'utf-8', mode: 0o600 });
291
+ } catch (createErr) {
292
+ if (isCanceled()) return;
293
+ throw createErr;
294
+ }
295
+
296
+ async function* generateChunks() {
297
+ await new Promise((r) => setImmediate(r));
298
+ if (isCanceled()) {
299
+ ac.abort();
300
+ return;
301
+ }
302
+
303
+ yield '{"version":' + JSON.stringify(data.version || 1);
304
+ yield ',"updatedAt":' + JSON.stringify(data.updatedAt || new Date().toISOString());
305
+ yield ',"settings":' + JSON.stringify(data.settings || {});
262
306
 
263
- const isCanceled = () => {
264
- if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
265
- aborted = true;
266
- cleanupListeners();
267
- ws.destroy();
268
- return true;
307
+ yield ',"tasks":[';
308
+ const tasks = data.tasks || [];
309
+ for (let i = 0; i < tasks.length; i++) {
310
+ if (isCanceled()) {
311
+ ac.abort();
312
+ return;
269
313
  }
270
- return false;
271
- };
272
-
273
- const writeChunk = (chunk) => {
274
- if (aborted) return Promise.reject(new Error('Store write aborted'));
275
- if (!ws.write(chunk)) {
276
- return new Promise((res, rej) => {
277
- const onDrain = () => {
278
- ws.removeListener('drain', onDrain);
279
- ws.removeListener('error', onError);
280
- res();
281
- };
282
- const onError = (e) => {
283
- ws.removeListener('drain', onDrain);
284
- ws.removeListener('error', onError);
285
- rej(e);
286
- };
287
- ws.once('drain', onDrain);
288
- ws.once('error', onError);
289
- });
314
+ if (i > 0) yield ',';
315
+ yield JSON.stringify(tasks[i]);
316
+ if ((i + 1) % 10 === 0) {
317
+ await new Promise((r) => setImmediate(r));
290
318
  }
291
- return Promise.resolve();
292
- };
319
+ }
320
+ yield ']';
293
321
 
294
- const run = async () => {
295
- try {
296
- await new Promise((r) => setImmediate(r));
297
- if (isCanceled()) return resolve();
298
-
299
- await writeChunk('{"version":' + JSON.stringify(data.version || 1));
300
- await writeChunk(',"updatedAt":' + JSON.stringify(data.updatedAt || new Date().toISOString()));
301
- await writeChunk(',"settings":' + JSON.stringify(data.settings || {}));
302
-
303
- await writeChunk(',"tasks":[');
304
- const tasks = data.tasks || [];
305
- for (let i = 0; i < tasks.length; i++) {
306
- if (isCanceled()) return resolve();
307
- if (i > 0) await writeChunk(',');
308
- await writeChunk(JSON.stringify(tasks[i]));
309
- if ((i + 1) % 10 === 0) {
310
- await new Promise((r) => setImmediate(r));
322
+ yield ',"history":{';
323
+ const historyEntries = Object.entries(data.history || {});
324
+ for (let i = 0; i < historyEntries.length; i++) {
325
+ if (isCanceled()) {
326
+ ac.abort();
327
+ return;
328
+ }
329
+ if (i > 0) yield ',';
330
+ const [taskId, runs] = historyEntries[i];
331
+ yield JSON.stringify(taskId) + ':[';
332
+ if (Array.isArray(runs)) {
333
+ for (let j = 0; j < runs.length; j++) {
334
+ if (isCanceled()) {
335
+ ac.abort();
336
+ return;
311
337
  }
312
- }
313
- await writeChunk(']');
314
-
315
- await writeChunk(',"history":{');
316
- const historyEntries = Object.entries(data.history || {});
317
- for (let i = 0; i < historyEntries.length; i++) {
318
- if (isCanceled()) return resolve();
319
- if (i > 0) await writeChunk(',');
320
- const [taskId, runs] = historyEntries[i];
321
- await writeChunk(JSON.stringify(taskId) + ':[');
322
- if (Array.isArray(runs)) {
323
- for (let j = 0; j < runs.length; j++) {
324
- if (isCanceled()) return resolve();
325
- if (j > 0) await writeChunk(',');
326
- await writeChunk(JSON.stringify(runs[j]));
327
- if ((j + 1) % 15 === 0) {
328
- await new Promise((r) => setImmediate(r));
329
- }
330
- }
331
- } else {
332
- await writeChunk(JSON.stringify(runs));
338
+ if (j > 0) yield ',';
339
+ yield JSON.stringify(runs[j]);
340
+ if ((j + 1) % 15 === 0) {
341
+ await new Promise((r) => setImmediate(r));
333
342
  }
334
- await writeChunk(']');
335
- await new Promise((r) => setImmediate(r));
336
- }
337
- await writeChunk('}}');
338
-
339
- if (isCanceled()) return resolve();
340
-
341
- ws.end(() => {
342
- cleanupListeners();
343
- resolve();
344
- });
345
- } catch (err) {
346
- if (!aborted) {
347
- aborted = true;
348
- cleanupListeners();
349
- ws.destroy();
350
- reject(err);
351
343
  }
344
+ } else {
345
+ yield JSON.stringify(runs);
352
346
  }
353
- };
347
+ yield ']';
348
+ await new Promise((r) => setImmediate(r));
349
+ }
350
+ yield '}}';
351
+ }
354
352
 
355
- run();
356
- });
353
+ try {
354
+ await pipeline(generateChunks(), ws, { signal: ac.signal });
355
+ } catch (err) {
356
+ if (ac.signal.aborted || isCanceled()) {
357
+ return;
358
+ }
359
+ throw err;
360
+ }
357
361
  }
358
362
 
359
363
  save() {
@@ -376,13 +380,7 @@ export class TaskStore {
376
380
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
377
381
  }
378
382
  bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
379
- const data = {
380
- version: 1,
381
- updatedAt: new Date().toISOString(),
382
- tasks: Array.from(this.tasks.values()),
383
- history: Object.fromEntries(this.history.entries()),
384
- settings: this.settings || {},
385
- };
383
+ const data = this._createSnapshot();
386
384
  // Unique tmp file per save call with process PID and random token to eliminate file collision
387
385
  const rand = Math.random().toString(36).slice(2, 8);
388
386
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
@@ -670,42 +668,46 @@ export class TaskStore {
670
668
  task.updatedAt = Date.now();
671
669
 
672
670
  // Rolling 24h cost ledger preserved across archive rotation and restarts (#221)
673
- if (!Array.isArray(task.costLedger)) {
674
- task.costLedger = [];
675
- }
676
- task.costLedger.push({
677
- at: task.lastRunAt,
678
- costUsd,
679
- tokens: runTokens,
680
- });
671
+ const existingLedger = Array.isArray(task.costLedger) ? task.costLedger : [];
681
672
  const cutoff24h = Date.now() - 86400000;
682
- task.costLedger = task.costLedger.filter(e => e && typeof e.at === 'number' && e.at >= cutoff24h);
683
-
684
- const runs = this.history.get(id) || [];
685
- runs.unshift({
686
- id: 'run_' + Math.random().toString(36).slice(2, 9),
687
- at: task.lastRunAt,
688
- status: task.lastStatus,
689
- durationMs: task.lastDurationMs,
690
- output: (runInfo.output || '').slice(0, 4000),
691
- error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
692
- usage,
693
- costUsd,
694
- sessionId: runInfo.sessionId || null,
695
- // #45: which model produced the run, and whether it only finished thanks
696
- // to the configured fallback.
697
- model: runInfo.model || '',
698
- fallback: Boolean(runInfo.fallback),
699
- // #44: this run was suppressed by its silent rule, with the reason.
700
- silentSkip: Boolean(runInfo.silentSkip),
701
- silentReason: runInfo.silentSkip ? String(runInfo.silentReason || '').slice(0, 500) : '',
702
- // #43: the failure diagnosis and the prompt change it proposes.
703
- diagnosis: runInfo.diagnosis ? String(runInfo.diagnosis).slice(0, 1000) : '',
704
- suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
705
- selfHealing: runInfo.selfHealing || null,
706
- autoDiagnosis: runInfo.autoDiagnosis ? String(runInfo.autoDiagnosis).slice(0, 1000) : '',
707
- confidence: runInfo.confidence || '',
708
- });
673
+ task.costLedger = [
674
+ ...existingLedger.filter(e => e && typeof e.at === 'number' && e.at >= cutoff24h),
675
+ {
676
+ at: task.lastRunAt,
677
+ costUsd,
678
+ tokens: runTokens,
679
+ },
680
+ ];
681
+
682
+ // Immutable copy-on-write history update (#301)
683
+ const prevRuns = this.history.get(id) || [];
684
+ const runs = [
685
+ {
686
+ id: 'run_' + Math.random().toString(36).slice(2, 9),
687
+ at: task.lastRunAt,
688
+ status: task.lastStatus,
689
+ durationMs: task.lastDurationMs,
690
+ output: (runInfo.output || '').slice(0, 4000),
691
+ error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
692
+ usage,
693
+ costUsd,
694
+ sessionId: runInfo.sessionId || null,
695
+ // #45: which model produced the run, and whether it only finished thanks
696
+ // to the configured fallback.
697
+ model: runInfo.model || '',
698
+ fallback: Boolean(runInfo.fallback),
699
+ // #44: this run was suppressed by its silent rule, with the reason.
700
+ silentSkip: Boolean(runInfo.silentSkip),
701
+ silentReason: runInfo.silentSkip ? String(runInfo.silentReason || '').slice(0, 500) : '',
702
+ // #43: the failure diagnosis and the prompt change it proposes.
703
+ diagnosis: runInfo.diagnosis ? String(runInfo.diagnosis).slice(0, 1000) : '',
704
+ suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
705
+ selfHealing: runInfo.selfHealing || null,
706
+ autoDiagnosis: runInfo.autoDiagnosis ? String(runInfo.autoDiagnosis).slice(0, 1000) : '',
707
+ confidence: runInfo.confidence || '',
708
+ },
709
+ ...prevRuns,
710
+ ];
709
711
  // Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
710
712
  if (runs.length > 100) {
711
713
  const overflow = runs.splice(100);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.46",
3
+ "version": "0.2.47",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",