@goodandready/dsh-cron 0.2.45 → 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/client.js +5 -5
- package/lib/store.js +177 -56
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -1442,10 +1442,10 @@ window.__ModuleLoader__.load({
|
|
|
1442
1442
|
let timerId = null;
|
|
1443
1443
|
let isDestroyed = false;
|
|
1444
1444
|
let pollController = null;
|
|
1445
|
-
const currentGen = fetchGenRef.current;
|
|
1446
1445
|
|
|
1447
1446
|
const doPoll = () => {
|
|
1448
|
-
if (isDestroyed
|
|
1447
|
+
if (isDestroyed) return;
|
|
1448
|
+
const pollGen = ++fetchGenRef.current;
|
|
1449
1449
|
const headers = {};
|
|
1450
1450
|
if (lastEtagRef.current) headers['If-None-Match'] = lastEtagRef.current;
|
|
1451
1451
|
|
|
@@ -1457,7 +1457,7 @@ window.__ModuleLoader__.load({
|
|
|
1457
1457
|
|
|
1458
1458
|
fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), fetchOpts)
|
|
1459
1459
|
.then(res => {
|
|
1460
|
-
if (isDestroyed ||
|
|
1460
|
+
if (isDestroyed || pollGen !== fetchGenRef.current) return null;
|
|
1461
1461
|
if (res.status === 304) {
|
|
1462
1462
|
// Not modified: skip parsing JSON
|
|
1463
1463
|
return null;
|
|
@@ -1467,7 +1467,7 @@ window.__ModuleLoader__.load({
|
|
|
1467
1467
|
return res.json();
|
|
1468
1468
|
})
|
|
1469
1469
|
.then(data => {
|
|
1470
|
-
if (isDestroyed ||
|
|
1470
|
+
if (isDestroyed || pollGen !== fetchGenRef.current) return;
|
|
1471
1471
|
if (data && data.ok) {
|
|
1472
1472
|
setTasks(data.tasks || []);
|
|
1473
1473
|
setRecs(data.recommendations || []);
|
|
@@ -1478,7 +1478,7 @@ window.__ModuleLoader__.load({
|
|
|
1478
1478
|
if (err && err.name === 'AbortError') return;
|
|
1479
1479
|
})
|
|
1480
1480
|
.finally(() => {
|
|
1481
|
-
if (isDestroyed
|
|
1481
|
+
if (isDestroyed) return;
|
|
1482
1482
|
// Adaptive interval: 30s when tab is hidden, 8s when active (#134)
|
|
1483
1483
|
const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
|
|
1484
1484
|
timerId = setTimeout(doPoll, intervalMs);
|
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) {
|
|
@@ -190,14 +212,17 @@ export class TaskStore {
|
|
|
190
212
|
const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
191
213
|
this._currentAsyncTmp = tmp;
|
|
192
214
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
215
|
+
// Streamed chunked serialization with fine-grained event loop yields (#244).
|
|
216
|
+
// Eliminates continuous CPU stalls on large stores (~21MB) by batching tasks and history runs
|
|
217
|
+
// with setImmediate yields every 10 tasks and 15 runs, guaranteeing max event loop lag < 10ms.
|
|
218
|
+
try {
|
|
219
|
+
await this._writeStoreStream(tmp, data, version);
|
|
220
|
+
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
221
|
+
} catch (writeErr) {
|
|
222
|
+
await fs.promises.unlink(tmp).catch(() => {});
|
|
223
|
+
if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
|
|
224
|
+
throw writeErr;
|
|
225
|
+
}
|
|
201
226
|
|
|
202
227
|
if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
|
|
203
228
|
await fs.promises.unlink(tmp).catch(() => {});
|
|
@@ -237,6 +262,104 @@ export class TaskStore {
|
|
|
237
262
|
return this._savePromise;
|
|
238
263
|
}
|
|
239
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Stream data incrementally to disk with fine-grained setImmediate yields (#244).
|
|
267
|
+
* Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
|
|
268
|
+
*/
|
|
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 || {});
|
|
306
|
+
|
|
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;
|
|
313
|
+
}
|
|
314
|
+
if (i > 0) yield ',';
|
|
315
|
+
yield JSON.stringify(tasks[i]);
|
|
316
|
+
if ((i + 1) % 10 === 0) {
|
|
317
|
+
await new Promise((r) => setImmediate(r));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
yield ']';
|
|
321
|
+
|
|
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;
|
|
337
|
+
}
|
|
338
|
+
if (j > 0) yield ',';
|
|
339
|
+
yield JSON.stringify(runs[j]);
|
|
340
|
+
if ((j + 1) % 15 === 0) {
|
|
341
|
+
await new Promise((r) => setImmediate(r));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
yield JSON.stringify(runs);
|
|
346
|
+
}
|
|
347
|
+
yield ']';
|
|
348
|
+
await new Promise((r) => setImmediate(r));
|
|
349
|
+
}
|
|
350
|
+
yield '}}';
|
|
351
|
+
}
|
|
352
|
+
|
|
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
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
240
363
|
save() {
|
|
241
364
|
if (this._saveTimer) {
|
|
242
365
|
clearTimeout(this._saveTimer);
|
|
@@ -257,13 +380,7 @@ export class TaskStore {
|
|
|
257
380
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
258
381
|
}
|
|
259
382
|
bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
|
|
260
|
-
const data =
|
|
261
|
-
version: 1,
|
|
262
|
-
updatedAt: new Date().toISOString(),
|
|
263
|
-
tasks: Array.from(this.tasks.values()),
|
|
264
|
-
history: Object.fromEntries(this.history.entries()),
|
|
265
|
-
settings: this.settings || {},
|
|
266
|
-
};
|
|
383
|
+
const data = this._createSnapshot();
|
|
267
384
|
// Unique tmp file per save call with process PID and random token to eliminate file collision
|
|
268
385
|
const rand = Math.random().toString(36).slice(2, 8);
|
|
269
386
|
const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
@@ -551,42 +668,46 @@ export class TaskStore {
|
|
|
551
668
|
task.updatedAt = Date.now();
|
|
552
669
|
|
|
553
670
|
// Rolling 24h cost ledger preserved across archive rotation and restarts (#221)
|
|
554
|
-
|
|
555
|
-
task.costLedger = [];
|
|
556
|
-
}
|
|
557
|
-
task.costLedger.push({
|
|
558
|
-
at: task.lastRunAt,
|
|
559
|
-
costUsd,
|
|
560
|
-
tokens: runTokens,
|
|
561
|
-
});
|
|
671
|
+
const existingLedger = Array.isArray(task.costLedger) ? task.costLedger : [];
|
|
562
672
|
const cutoff24h = Date.now() - 86400000;
|
|
563
|
-
task.costLedger =
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
+
];
|
|
590
711
|
// Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
|
|
591
712
|
if (runs.length > 100) {
|
|
592
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",
|