@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.
- package/lib/store.js +151 -149
- 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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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:
|
|
177
|
-
history:
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
return
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
292
|
-
|
|
319
|
+
}
|
|
320
|
+
yield ']';
|
|
293
321
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
for (let
|
|
306
|
-
if (isCanceled())
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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.
|
|
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",
|