@goodandready/dsh-cron 0.3.24 → 0.3.26

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 (84) hide show
  1. package/lib/api-actions.js +1 -317
  2. package/lib/api-collection.js +1 -250
  3. package/lib/api-helpers.js +1 -100
  4. package/lib/api-import-export.js +1 -155
  5. package/lib/api-item.js +1 -190
  6. package/lib/api-task-webhook.js +1 -247
  7. package/lib/api-webhook.js +1 -183
  8. package/lib/api.js +1 -178
  9. package/lib/artifacts.js +1 -200
  10. package/lib/chain-checkpoints.js +1 -223
  11. package/lib/client-src/00-open.js +62 -0
  12. package/lib/client-src/10-locales.js +484 -0
  13. package/lib/client-src/20-styles.js +64 -0
  14. package/lib/client-src/30-utils.js +248 -0
  15. package/lib/client-src/40-delivery-settings.js +96 -0
  16. package/lib/client-src/45-dag-graph-view.js +287 -0
  17. package/lib/client-src/46-live-logs-modal.js +108 -0
  18. package/lib/client-src/47-analytics-digest-modal.js +166 -0
  19. package/lib/client-src/48-timeline-heatmap-view.js +176 -0
  20. package/lib/client-src/50-cron-screen-state.js +447 -0
  21. package/lib/client-src/52-cron-screen-actions.js +330 -0
  22. package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
  23. package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
  24. package/lib/client-src/55-cron-screen-view.js +378 -0
  25. package/lib/client-src/60-modal-task-form-head.js +580 -0
  26. package/lib/client-src/62-modal-task-form-tail.js +466 -0
  27. package/lib/client-src/65-modal-dialogs.js +350 -0
  28. package/lib/client-src/70-settings-card.js +420 -0
  29. package/lib/client-src/80-sidebar-jobs.js +200 -0
  30. package/lib/client-src/90-close.js +200 -0
  31. package/lib/client.js +48 -3
  32. package/lib/commands.js +200 -0
  33. package/lib/durable-journal.js +1 -188
  34. package/lib/external-api.js +1 -74
  35. package/lib/index.js +17 -0
  36. package/lib/log-streamer.js +10 -2
  37. package/lib/metrics.js +1 -125
  38. package/lib/routes/api-actions.js +317 -0
  39. package/lib/routes/api-collection.js +250 -0
  40. package/lib/routes/api-helpers.js +100 -0
  41. package/lib/routes/api-import-export.js +155 -0
  42. package/lib/routes/api-item.js +191 -0
  43. package/lib/routes/api-task-webhook.js +247 -0
  44. package/lib/routes/api-webhook.js +183 -0
  45. package/lib/routes/api.js +178 -0
  46. package/lib/routes/external-api.js +74 -0
  47. package/lib/routes/index.js +11 -0
  48. package/lib/routes/metrics.js +125 -0
  49. package/lib/routes/routes.js +549 -0
  50. package/lib/routes.js +1 -549
  51. package/lib/runner-external.js +1 -352
  52. package/lib/runner-worktree.js +1 -90
  53. package/lib/runner.js +1 -881
  54. package/lib/runtimes/index.js +4 -0
  55. package/lib/runtimes/runner-external.js +352 -0
  56. package/lib/runtimes/runner-worktree.js +90 -0
  57. package/lib/runtimes/runner.js +881 -0
  58. package/lib/runtimes/runtimes.js +198 -0
  59. package/lib/runtimes.js +1 -198
  60. package/lib/scheduler/index.js +6 -0
  61. package/lib/scheduler/scheduler-assists.js +142 -0
  62. package/lib/scheduler/scheduler-completion.js +417 -0
  63. package/lib/scheduler/scheduler-execution.js +360 -0
  64. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  65. package/lib/scheduler/scheduler-preflight.js +195 -0
  66. package/lib/scheduler/scheduler.js +751 -0
  67. package/lib/scheduler-assists.js +1 -142
  68. package/lib/scheduler-completion.js +1 -417
  69. package/lib/scheduler-execution.js +1 -360
  70. package/lib/scheduler-lifecycle.js +1 -157
  71. package/lib/scheduler-preflight.js +1 -195
  72. package/lib/scheduler.js +1 -751
  73. package/lib/store/artifacts.js +200 -0
  74. package/lib/store/chain-checkpoints.js +223 -0
  75. package/lib/store/durable-journal.js +188 -0
  76. package/lib/store/index.js +6 -0
  77. package/lib/store/store.js +1179 -0
  78. package/lib/store/task-schema.js +180 -0
  79. package/lib/store/task-transfer.js +310 -0
  80. package/lib/store.js +2 -1179
  81. package/lib/task-schema.js +1 -180
  82. package/lib/task-transfer.js +1 -310
  83. package/lib/tui-settings.js +157 -0
  84. package/package.json +6 -3
package/lib/store.js CHANGED
@@ -1,1179 +1,2 @@
1
- import { DurableJournal } from './durable-journal.js';
2
- import { ChainCheckpointManager } from './chain-checkpoints.js';
3
- import { ArtifactManager } from './artifacts.js';
4
- import { logger } from './logger.js';
5
- import { bestEffort } from './best-effort.js';
6
- import fs from 'node:fs';
7
- import { gzipSync, gunzipSync } from 'node:zlib';
8
- import { pipeline } from 'node:stream/promises';
9
- import path from 'node:path';
10
- import { getDshDefaultTelegramCredentials } from './telegram.js';
11
-
12
- /**
13
- * Where plugin data lives.
14
- *
15
- * DSH_DATA_DIR wins when the harness sets it. Otherwise the profile home
16
- * (DSH_HOME) is authoritative: falling back to ~/.dsh would make an isolated
17
- * profile — a test contour, a second profile — write its tasks into another
18
- * home's data directory, which is how an isolated test cycle once leaked a
19
- * task into the wrong profile.
20
- */
21
-
22
- export const CURRENT_SCHEMA_VERSION = 2;
23
-
24
- export function runStoreMigrations(data, fromVersion = 1, toVersion = CURRENT_SCHEMA_VERSION) {
25
- if (!data || typeof data !== 'object') return data;
26
- let current = { ...data };
27
-
28
- // Migration from version 1 to 2 (Issue #390 & #393)
29
- if (fromVersion < 2 && toVersion >= 2) {
30
- if (Array.isArray(current.tasks)) {
31
- current.tasks = current.tasks.map((task) => {
32
- if (!task || typeof task !== 'object') return task;
33
- return {
34
- ...task,
35
- maxMemoryMb: task.maxMemoryMb !== undefined ? Number(task.maxMemoryMb) : 0,
36
- executionTimeoutMs: task.executionTimeoutMs !== undefined
37
- ? Number(task.executionTimeoutMs)
38
- : (Number(task.timeoutSeconds) > 0 ? Number(task.timeoutSeconds) * 1000 : 900000),
39
- maxLogBytesPerRun: task.maxLogBytesPerRun !== undefined ? Number(task.maxLogBytesPerRun) : (5 * 1024 * 1024),
40
- cpus: task.cpus !== undefined ? Number(task.cpus) : 0,
41
- maxArchiveMonths: task.maxArchiveMonths !== undefined ? Number(task.maxArchiveMonths) : 6,
42
- };
43
- });
44
- }
45
- current.schemaVersion = 2;
46
- }
47
-
48
- return current;
49
- }
50
-
51
- export function getDefaultStorePath() {
52
- const base = process.env.DSH_DATA_DIR
53
- || (process.env.DSH_HOME
54
- ? path.join(process.env.DSH_HOME, 'data')
55
- : path.join(process.env.HOME || '.', '.dsh', 'data'));
56
- return path.join(base, 'cron', 'tasks.json');
57
- }
58
-
59
- /** Mask a secret for transport to the browser, keeping a readable hint. */
60
- function maskSecretValue(value) {
61
- const s = String(value == null ? '' : value);
62
- if (!s) return '';
63
- if (s.length <= 8) return '••••••••';
64
- return s.slice(0, 4) + '••••••••' + s.slice(-3);
65
- }
66
-
67
- export class TaskStore {
68
- constructor(filePath) {
69
- this.filePath = filePath || getDefaultStorePath();
70
- this.tasks = new Map();
71
- this.history = new Map();
72
- this.settings = {};
73
- this.chainCheckpoints = {};
74
- this.artifacts = {};
75
- const storeDir = path.dirname(this.filePath);
76
- this.durableJournal = new DurableJournal(this);
77
- this.chainCheckpointManager = new ChainCheckpointManager(this);
78
- this.artifactManager = new ArtifactManager(path.join(storeDir, "artifacts"));
79
- this._lastBackupTime = 0;
80
- this._saveTimer = null;
81
- this._savePromise = Promise.resolve();
82
- this._saveVersion = 0;
83
- this._diskVersion = 0;
84
- this._lastWrittenVersion = 0;
85
- this.init();
86
- }
87
-
88
- init() {
89
- const dir = path.dirname(this.filePath);
90
- if (!fs.existsSync(dir)) {
91
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
92
- }
93
- bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
94
- this._cleanOrphanedTmpFiles(dir);
95
- const bakPath = `${this.filePath}.bak`;
96
-
97
- const loadData = (raw) => {
98
- let data = JSON.parse(raw);
99
- const storedVersion = Number(data.schemaVersion) || 1;
100
- if (storedVersion < CURRENT_SCHEMA_VERSION) {
101
- // Pre-migration backup (#390)
102
- try {
103
- const preBakPath = `${this.filePath}.bak-v${storedVersion}-${Date.now()}`;
104
- fs.writeFileSync(preBakPath, raw, { encoding: 'utf-8', mode: 0o600 });
105
- bestEffort('chmod-pre-bak', () => fs.chmodSync(preBakPath, 0o600));
106
- logger.info(`[dsh-cron] pre-migration backup saved to ${preBakPath}`);
107
- } catch (bakErr) {
108
- logger.warn('[dsh-cron] failed to write pre-migration backup:', bakErr.message);
109
- }
110
- data = runStoreMigrations(data, storedVersion, CURRENT_SCHEMA_VERSION);
111
- this._needsMigrationFlush = true;
112
- }
113
- this.tasks.clear();
114
- this.history.clear();
115
- if (Array.isArray(data.tasks)) {
116
- const now = Date.now();
117
- const cutoff24h = now - 86400000;
118
- for (const t of data.tasks) {
119
- if (Array.isArray(t.costLedger) && t.costLedger.length > 0) {
120
- t.costLedger = t.costLedger.filter(e => e && typeof e.at === 'number' && e.at >= cutoff24h);
121
- } else {
122
- // Seed from active history if present (#221)
123
- const hist = (data.history && Array.isArray(data.history[t.id])) ? data.history[t.id] : [];
124
- t.costLedger = hist
125
- .filter(r => r && typeof r.at === 'number' && r.at >= cutoff24h)
126
- .map(r => ({
127
- at: r.at,
128
- costUsd: Number(r.costUsd) || 0,
129
- tokens: (r.usage?.inputTokens || 0) + (r.usage?.outputTokens || 0) + (r.usage?.cacheReadTokens || 0),
130
- }));
131
- }
132
- this.tasks.set(t.id, t);
133
- }
134
- }
135
- if (data.history && typeof data.history === 'object') {
136
- for (const [k, v] of Object.entries(data.history)) {
137
- this.history.set(k, Array.isArray(v) ? v : []);
138
- }
139
- }
140
- if (data.settings && typeof data.settings === 'object') {
141
- this.settings = data.settings;
142
- }
143
- if (data.chainCheckpoints && typeof data.chainCheckpoints === 'object') {
144
- this.chainCheckpoints = data.chainCheckpoints;
145
- }
146
- if (data.artifacts && typeof data.artifacts === 'object') {
147
- this.artifacts = data.artifacts;
148
- }
149
- };
150
-
151
- if (fs.existsSync(this.filePath)) {
152
- try {
153
- bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
154
- const raw = fs.readFileSync(this.filePath, 'utf-8');
155
- loadData(raw);
156
- if (!fs.existsSync(bakPath)) {
157
- bestEffort('backup-initial', () => {
158
- fs.copyFileSync(this.filePath, bakPath);
159
- fs.chmodSync(bakPath, 0o600);
160
- });
161
- } else {
162
- bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
163
- }
164
- } catch (err) {
165
- logger.error('[dsh-cron] store read error (corrupted file):', err.message);
166
- try {
167
- const corruptedPath = `${this.filePath}.corrupted.${Date.now()}`;
168
- fs.copyFileSync(this.filePath, corruptedPath);
169
- bestEffort('chmod-corrupted', () => fs.chmodSync(corruptedPath, 0o600));
170
- logger.warn(`[dsh-cron] corrupted store backed up to ${corruptedPath}`);
171
- } catch (snapErr) {
172
- logger.error('[dsh-cron] failed to snapshot corrupted store:', snapErr.message);
173
- }
174
- if (fs.existsSync(bakPath)) {
175
- try {
176
- bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
177
- const rawBak = fs.readFileSync(bakPath, 'utf-8');
178
- loadData(rawBak);
179
- logger.warn(`[dsh-cron] successfully recovered tasks store from ${bakPath}`);
180
- } catch (bakErr) {
181
- logger.error('[dsh-cron] failed to recover tasks store from .bak:', bakErr.message);
182
- }
183
- }
184
- }
185
- } else if (fs.existsSync(bakPath)) {
186
- try {
187
- bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
188
- const rawBak = fs.readFileSync(bakPath, 'utf-8');
189
- loadData(rawBak);
190
- bestEffort('restore-from-bak', () => {
191
- fs.copyFileSync(bakPath, this.filePath);
192
- fs.chmodSync(this.filePath, 0o600);
193
- });
194
- logger.warn(`[dsh-cron] restored missing tasks store from ${bakPath}`);
195
- } catch (bakErr) {
196
- logger.error('[dsh-cron] failed to restore tasks store from .bak:', bakErr.message);
197
- }
198
- }
199
- if (this._needsMigrationFlush) {
200
- this._needsMigrationFlush = false;
201
- this.scheduleSave(100);
202
- }
203
- bestEffort('prune-old-archives', () => this._pruneOldMonthlyArchives(dir, 6));
204
- }
205
-
206
- /**
207
- * Schedule a debounced save using the asynchronous write queue to avoid
208
- * blocking the event loop on large task stores (#244).
209
- */
210
- scheduleSave(delay = 50) {
211
- if (this._saveTimer) return;
212
- this._saveTimer = setTimeout(() => {
213
- this._saveTimer = null;
214
- this.saveAsync().catch((err) => {
215
- logger.error('[dsh-cron] debounced save failed:', err.message);
216
- });
217
- }, delay);
218
- if (typeof this._saveTimer.unref === 'function') {
219
- this._saveTimer.unref();
220
- }
221
- }
222
-
223
- /**
224
- * Synchronously flush any pending debounced save to disk.
225
- */
226
- flushSync() {
227
- if (this._saveTimer) {
228
- clearTimeout(this._saveTimer);
229
- this._saveTimer = null;
230
- }
231
- this.save();
232
- }
233
-
234
- /**
235
- * Asynchronously serialize and write tasks store to disk using fs.promises queue (#244).
236
- * Safe mode 0600 on files and 0700 on dir (#222).
237
- */
238
- /**
239
- * Create an isolated deep snapshot of tasks, history, and settings (#301).
240
- * Prevents concurrent in-memory mutations (e.g. recordRun during streamed save)
241
- * from corrupting serialized JSON state or causing duplicate / skipped run records.
242
- */
243
- _createSnapshot() {
244
- const tasksSnapshot = Array.from(this.tasks.values()).map((t) => ({
245
- ...t,
246
- costLedger: Array.isArray(t.costLedger) ? t.costLedger.slice() : t.costLedger,
247
- }));
248
- const historySnapshot = {};
249
- for (const [id, runs] of this.history.entries()) {
250
- historySnapshot[id] = Array.isArray(runs) ? runs.slice() : runs;
251
- }
252
- return {
253
- version: 1,
254
- schemaVersion: CURRENT_SCHEMA_VERSION,
255
- updatedAt: new Date().toISOString(),
256
- tasks: tasksSnapshot,
257
- history: historySnapshot,
258
- settings: this.settings ? { ...this.settings } : {},
259
- chainCheckpoints: this.chainCheckpoints ? { ...this.chainCheckpoints } : {},
260
- artifacts: this.artifacts ? { ...this.artifacts } : {},
261
- };
262
- }
263
-
264
- /**
265
- * Asynchronously serialize and write tasks store to disk using fs.promises queue (#244, #301, #303).
266
- * Safe mode 0600 on files and 0700 on dir (#222).
267
- */
268
- /**
269
- * Wait until diskVersion or lastSyncVersion is at least the target version (#372).
270
- */
271
- _waitForDiskVersion(version) {
272
- if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
273
- return Promise.resolve();
274
- }
275
- return new Promise((resolve, reject) => {
276
- if (!this._diskWaiters) this._diskWaiters = [];
277
- this._diskWaiters.push({ version, resolve, reject });
278
- });
279
- }
280
-
281
- _notifyDiskVersion(writtenVersion, err = null) {
282
- if (!this._diskWaiters || this._diskWaiters.length === 0) return;
283
- const remaining = [];
284
- for (const waiter of this._diskWaiters) {
285
- if (err) {
286
- waiter.reject(err);
287
- } else if ((this._diskVersion || 0) >= waiter.version || (this._lastSyncVersion || 0) >= waiter.version) {
288
- waiter.resolve();
289
- } else {
290
- remaining.push(waiter);
291
- }
292
- }
293
- this._diskWaiters = remaining;
294
- }
295
-
296
- async saveAsync() {
297
- if (this._saveTimer) {
298
- clearTimeout(this._saveTimer);
299
- this._saveTimer = null;
300
- }
301
- const version = ++this._saveVersion;
302
- const data = this._createSnapshot();
303
-
304
- const writeOp = async () => {
305
- if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
306
- this._notifyDiskVersion(version);
307
- return;
308
- }
309
- const dir = path.dirname(this.filePath);
310
- await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
311
- bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
312
-
313
- const rand = Math.random().toString(36).slice(2, 8);
314
- const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
315
- this._currentAsyncTmp = tmp;
316
-
317
- // Streamed chunked serialization with fine-grained event loop yields (#244).
318
- // Eliminates continuous CPU stalls on large stores (~21MB) by batching tasks and history runs
319
- // with setImmediate yields every 10 tasks and 15 runs, guaranteeing max event loop lag < 10ms.
320
- try {
321
- await this._writeStoreStream(tmp, data, version);
322
- bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
323
- } catch (writeErr) {
324
- await fs.promises.unlink(tmp).catch(() => {});
325
- if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
326
- this._notifyDiskVersion(null, writeErr);
327
- throw writeErr;
328
- }
329
-
330
- if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
331
- await fs.promises.unlink(tmp).catch(() => {});
332
- if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
333
- // Do not notify waiters yet: a newer queued saveOp will write to disk and notify (#372)
334
- return;
335
- }
336
-
337
- try {
338
- await fs.promises.rename(tmp, this.filePath);
339
- bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
340
- this._currentAsyncTmp = null;
341
- this._diskVersion = Math.max(this._diskVersion || 0, version);
342
- this._lastWrittenVersion = this._diskVersion;
343
- this._notifyDiskVersion(this._diskVersion);
344
- } catch (err) {
345
- this._currentAsyncTmp = null;
346
- // If tmp was unlinked by newer sync save, rename will fail with ENOENT - safely ignore only in that case (#244)
347
- if (err.code === 'ENOENT' && (this._lastSyncVersion || 0) >= version) {
348
- this._notifyDiskVersion(version);
349
- return;
350
- }
351
- this._notifyDiskVersion(null, err);
352
- throw err;
353
- }
354
-
355
- const now = Date.now();
356
- if (now - this._lastBackupTime > 60000) {
357
- this._lastBackupTime = now;
358
- try {
359
- const bakPath = `${this.filePath}.bak`;
360
- await fs.promises.copyFile(this.filePath, bakPath);
361
- bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
362
- } catch (bakErr) {
363
- logger.warn('[dsh-cron] async backup copy failed:', bakErr.message);
364
- }
365
- }
366
- };
367
-
368
- this._savePromise = this._savePromise.then(writeOp, writeOp);
369
- await this._savePromise;
370
- await this._waitForDiskVersion(version);
371
- }
372
-
373
- /**
374
- * Stream data incrementally to disk with fine-grained setImmediate yields (#244).
375
- * Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
376
- */
377
- /**
378
- * Stream data incrementally to disk with fine-grained setImmediate yields (#244, #303).
379
- * Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
380
- * Uses canonical Node stream/promises pipeline for error-handling and backpressure hygiene (#303).
381
- */
382
- async _writeStoreStream(filePath, data, version) {
383
- const isCanceled = () => {
384
- return (
385
- (this._diskVersion || 0) >= version ||
386
- (this._lastSyncVersion || 0) >= version ||
387
- this._saveVersion > version
388
- );
389
- };
390
-
391
- if (isCanceled()) {
392
- return;
393
- }
394
-
395
- const ac = new AbortController();
396
- let ws;
397
- try {
398
- ws = fs.createWriteStream(filePath, { encoding: 'utf-8', mode: 0o600 });
399
- } catch (createErr) {
400
- if (isCanceled()) return;
401
- throw createErr;
402
- }
403
-
404
- async function* generateChunks() {
405
- await new Promise((r) => setImmediate(r));
406
- if (isCanceled()) {
407
- ac.abort();
408
- return;
409
- }
410
-
411
- yield '{"version":' + JSON.stringify(data.version || 1);
412
- yield ',"schemaVersion":' + JSON.stringify(data.schemaVersion || CURRENT_SCHEMA_VERSION);
413
- yield ',"updatedAt":' + JSON.stringify(data.updatedAt || new Date().toISOString());
414
- yield ',"settings":' + JSON.stringify(data.settings || {});
415
-
416
- yield ',"tasks":[';
417
- const tasks = data.tasks || [];
418
- for (let i = 0; i < tasks.length; i++) {
419
- if (isCanceled()) {
420
- ac.abort();
421
- return;
422
- }
423
- if (i > 0) yield ',';
424
- yield JSON.stringify(tasks[i]);
425
- if ((i + 1) % 10 === 0) {
426
- await new Promise((r) => setImmediate(r));
427
- }
428
- }
429
- yield ']';
430
-
431
- yield ',"history":{';
432
- const historyEntries = Object.entries(data.history || {});
433
- for (let i = 0; i < historyEntries.length; i++) {
434
- if (isCanceled()) {
435
- ac.abort();
436
- return;
437
- }
438
- if (i > 0) yield ',';
439
- const [taskId, runs] = historyEntries[i];
440
- yield JSON.stringify(taskId) + ':[';
441
- if (Array.isArray(runs)) {
442
- for (let j = 0; j < runs.length; j++) {
443
- if (isCanceled()) {
444
- ac.abort();
445
- return;
446
- }
447
- if (j > 0) yield ',';
448
- yield JSON.stringify(runs[j]);
449
- if ((j + 1) % 15 === 0) {
450
- await new Promise((r) => setImmediate(r));
451
- }
452
- }
453
- } else {
454
- yield JSON.stringify(runs);
455
- }
456
- yield ']';
457
- await new Promise((r) => setImmediate(r));
458
- }
459
- yield '}';
460
- yield ',"chainCheckpoints":' + JSON.stringify(data.chainCheckpoints || {});
461
- yield ',"artifacts":' + JSON.stringify(data.artifacts || {});
462
- yield '}';
463
- }
464
-
465
- try {
466
- await pipeline(generateChunks(), ws, { signal: ac.signal });
467
- } catch (err) {
468
- if (ac.signal.aborted || isCanceled()) {
469
- return;
470
- }
471
- throw err;
472
- }
473
- }
474
-
475
- save() {
476
- if (this._saveTimer) {
477
- clearTimeout(this._saveTimer);
478
- this._saveTimer = null;
479
- }
480
- const version = ++this._saveVersion;
481
- this._lastSyncVersion = version;
482
-
483
- // Immediately cancel and unlink any in-flight async tmp file so async rename cannot touch disk (#244)
484
- if (this._currentAsyncTmp) {
485
- const inFlightTmp = this._currentAsyncTmp;
486
- this._currentAsyncTmp = null;
487
- bestEffort('unlink-async-tmp', () => fs.unlinkSync(inFlightTmp));
488
- }
489
- try {
490
- const dir = path.dirname(this.filePath);
491
- if (!fs.existsSync(dir)) {
492
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
493
- }
494
- bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
495
- const data = this._createSnapshot();
496
- // Unique tmp file per save call with process PID and random token to eliminate file collision
497
- const rand = Math.random().toString(36).slice(2, 8);
498
- const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
499
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
500
- bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
501
- fs.renameSync(tmp, this.filePath);
502
- bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
503
- this._diskVersion = Math.max(this._diskVersion || 0, version);
504
- this._lastWrittenVersion = this._diskVersion;
505
- this._notifyDiskVersion(this._diskVersion);
506
-
507
- // Throttled backup (.bak): avoid expensive copyFileSync on every micro-save
508
- const now = Date.now();
509
- if (now - this._lastBackupTime > 60000) {
510
- this._lastBackupTime = now;
511
- bestEffort('backup-on-save', () => {
512
- const bakPath = `${this.filePath}.bak`;
513
- fs.copyFileSync(this.filePath, bakPath);
514
- fs.chmodSync(bakPath, 0o600);
515
- });
516
- }
517
- } catch (err) {
518
- logger.error('[dsh-cron] store save error:', err.message);
519
- throw err;
520
- }
521
- }
522
-
523
- getRun(runId) {
524
- if (!runId) return null;
525
- for (const [taskId, runs] of this.history.entries()) {
526
- if (Array.isArray(runs)) {
527
- const found = runs.find((r) => r.id === runId);
528
- if (found) return { ...found, taskId };
529
- }
530
- }
531
- return null;
532
- }
533
-
534
- recordArtifact(runIdOrRecord, maybeRecord) {
535
- const runId = maybeRecord ? runIdOrRecord : (runIdOrRecord && runIdOrRecord.runId);
536
- const artifactRecord = maybeRecord ? maybeRecord : runIdOrRecord;
537
- if (!runId || !artifactRecord) return;
538
- this.artifacts = this.artifacts || {};
539
- this.artifacts[runId] = this.artifacts[runId] || [];
540
- this.artifacts[runId].push(artifactRecord);
541
- this.scheduleSave(50);
542
- }
543
-
544
- getArtifactsForRun(runId) {
545
- if (!runId || !this.artifacts) return [];
546
- return this.artifacts[runId] || [];
547
- }
548
-
549
- getSettings() {
550
- const defaults = getDshDefaultTelegramCredentials();
551
- const base = { ...this.settings };
552
- base.botToken = base.botToken !== undefined ? base.botToken : (defaults.botToken || '');
553
- base.botTokenRef = base.botTokenRef || '';
554
- base.chatId = base.chatId !== undefined ? base.chatId : (defaults.chatId || '');
555
- base.notifyTelegram = Boolean(base.notifyTelegram);
556
- base.onlyOnFailure = Boolean(base.onlyOnFailure);
557
- base.kanbanBaseUrl = base.kanbanBaseUrl || 'http://127.0.0.1:3000';
558
- base.hasDefaultCredentials = Boolean(defaults.botToken && defaults.chatId);
559
- base.llmActionsEnabled = Boolean(base.llmActionsEnabled);
560
- return base;
561
- }
562
-
563
- getClientSettings() {
564
- const raw = this.getSettings();
565
- const isCustom = Boolean(this.settings.botToken);
566
- const hasToken = Boolean(raw.botToken);
567
- const masked = hasToken ? (raw.botToken.length > 8 ? raw.botToken.slice(0, 4) + '••••••••' + raw.botToken.slice(-3) : '••••••••') : '';
568
- const { botToken, ...rest } = raw;
569
- const out = {
570
- ...rest,
571
- botToken: masked,
572
- botTokenMasked: masked,
573
- hasBotToken: hasToken,
574
- hasCustomBotToken: isCustom,
575
- };
576
- // Webhook URLs and the Bark device key carry an embedded secret, so they
577
- // are never sent to the browser in clear text either.
578
- for (const key of TaskStore.MASKED_SETTING_KEYS) {
579
- if (key === 'botToken') continue;
580
- out[key] = maskSecretValue(raw[key]);
581
- }
582
- return out;
583
- }
584
-
585
- /**
586
- * Raw secret values that must not be stored in plugin settings; the
587
- * corresponding *Ref credential names are the supported way to configure
588
- * these channels.
589
- */
590
- static get FORBIDDEN_SETTING_KEYS() {
591
- return ['giteaToken', 'ntfyToken', 'pushplusToken', 'discordToken', 'slackToken'];
592
- }
593
-
594
- /**
595
- * Settings that hold a secret and are therefore masked on read: tokens and
596
- * webhook URLs (the URL embeds the auth token), plus the Bark device key.
597
- * These are stored in the plugin settings file; the credential-reference
598
- * fields (botTokenRef, ntfyTokenRef, …) take precedence over them.
599
- */
600
- static get MASKED_SETTING_KEYS() {
601
- return ['botToken', 'discordWebhookUrl', 'slackWebhookUrl', 'barkKey', 'apiToken', 'telegramWebhookSecret'];
602
- }
603
-
604
- saveSettings(newSettings = {}) {
605
- const patch = { ...newSettings };
606
-
607
- for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
608
- if (key in patch) {
609
- logger.warn(`[dsh-cron] refusing to store raw secret "${key}" in settings — use a credential reference instead`);
610
- delete patch[key];
611
- }
612
- }
613
-
614
- let botTokenToSave = this.settings.botToken || '';
615
- if (patch.botToken !== undefined) {
616
- const val = String(patch.botToken).trim();
617
- // If client sends masked token or empty string while custom token existed, don't overwrite with dots
618
- if (val && !val.includes('••') && !val.includes('****')) {
619
- botTokenToSave = val;
620
- } else if (val === '') {
621
- botTokenToSave = '';
622
- }
623
- delete patch.botToken;
624
- }
625
-
626
- // A masked value echoed back by the UI must never overwrite the real one.
627
- for (const key of TaskStore.MASKED_SETTING_KEYS) {
628
- if (key === 'botToken') continue;
629
- const val = patch[key];
630
- if (typeof val === 'string' && (val.includes('••') || val.includes('****'))) {
631
- delete patch[key];
632
- }
633
- }
634
-
635
- for (const [key, value] of Object.entries(patch)) {
636
- if (value === undefined) delete patch[key];
637
- else if (typeof value === 'string') patch[key] = value.trim();
638
- }
639
-
640
- const prevSettings = this.settings;
641
- this.settings = {
642
- ...this.settings,
643
- ...patch,
644
- botToken: botTokenToSave,
645
- kanbanBaseUrl: patch.kanbanBaseUrl !== undefined
646
- ? (patch.kanbanBaseUrl || 'http://127.0.0.1:3000')
647
- : (this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000'),
648
- notifyTelegram: patch.notifyTelegram !== undefined ? Boolean(patch.notifyTelegram) : Boolean(this.settings.notifyTelegram),
649
- onlyOnFailure: patch.onlyOnFailure !== undefined ? Boolean(patch.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
650
- llmActionsEnabled: patch.llmActionsEnabled !== undefined ? Boolean(patch.llmActionsEnabled) : Boolean(this.settings.llmActionsEnabled),
651
- };
652
- try {
653
- this.save();
654
- } catch (err) {
655
- this.settings = prevSettings;
656
- throw err;
657
- }
658
- return this.getSettings();
659
- }
660
-
661
- list(filter = {}) {
662
- let all = Array.from(this.tasks.values());
663
- if (filter.status && filter.status !== 'all') {
664
- all = all.filter((t) => t.status === filter.status);
665
- }
666
- if (filter.query) {
667
- const q = filter.query.toLowerCase();
668
- all = all.filter((t) =>
669
- (t.title && t.title.toLowerCase().includes(q)) ||
670
- (t.prompt && t.prompt.toLowerCase().includes(q)) ||
671
- (t.schedule && t.schedule.toLowerCase().includes(q))
672
- );
673
- }
674
- return all.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
675
- }
676
-
677
- get(id) {
678
- return this.tasks.get(id);
679
- }
680
-
681
- set(task) {
682
- const now = Date.now();
683
- const id = task.id || ('cron_' + Math.random().toString(36).slice(2, 9));
684
- const existing = this.tasks.get(id);
685
- const prev = existing || {};
686
- const record = {
687
- ...prev,
688
- ...task,
689
- id,
690
- title: task.title !== undefined ? task.title : (prev.title || 'New task'),
691
- schedule: task.schedule !== undefined ? task.schedule : (prev.schedule || '0 9 * * *'),
692
- scheduleText: task.scheduleText !== undefined ? task.scheduleText : (prev.scheduleText || task.schedule),
693
- prompt: task.prompt !== undefined ? task.prompt : (prev.prompt || ''),
694
- type: task.type !== undefined ? task.type : (prev.type || 'llm'),
695
- status: task.status !== undefined ? task.status : (prev.status || 'active'),
696
- delivery: task.delivery !== undefined ? task.delivery : (prev.delivery || 'isolated'),
697
- provider: task.provider !== undefined ? task.provider : (prev.provider || null),
698
- model: task.model !== undefined ? task.model : (prev.model || null),
699
- notifyTelegram: task.notifyTelegram !== undefined
700
- ? (task.notifyTelegram === null || task.notifyTelegram === 'inherit' ? undefined : Boolean(task.notifyTelegram))
701
- : prev.notifyTelegram,
702
- onlyOnFailure: task.onlyOnFailure !== undefined
703
- ? (task.onlyOnFailure === null || task.onlyOnFailure === 'inherit' ? undefined : Boolean(task.onlyOnFailure))
704
- : prev.onlyOnFailure,
705
- timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
706
- maxMemoryMb: task.maxMemoryMb !== undefined ? Number(task.maxMemoryMb) : (prev.maxMemoryMb || 0),
707
- executionTimeoutMs: task.executionTimeoutMs !== undefined ? Number(task.executionTimeoutMs) : (prev.executionTimeoutMs || 0),
708
- maxLogBytesPerRun: task.maxLogBytesPerRun !== undefined ? Number(task.maxLogBytesPerRun) : (prev.maxLogBytesPerRun || (5 * 1024 * 1024)),
709
- cpus: task.cpus !== undefined ? Number(task.cpus) : (prev.cpus || 0),
710
- maxArchiveMonths: task.maxArchiveMonths !== undefined ? Number(task.maxArchiveMonths) : (prev.maxArchiveMonths || 6),
711
- overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
712
- kanbanMode: task.kanbanMode !== undefined ? String(task.kanbanMode) : (prev.kanbanMode || 'none'), // 'none' | 'on_failure' | 'always'
713
- onSuccess: task.onSuccess !== undefined ? String(task.onSuccess).trim() : (prev.onSuccess || ''),
714
- onFailure: task.onFailure !== undefined ? String(task.onFailure).trim() : (prev.onFailure || ''),
715
- oneShot: task.oneShot !== undefined ? Boolean(task.oneShot) : Boolean(prev.oneShot),
716
- heartbeatIntervalSeconds: task.heartbeatIntervalSeconds !== undefined ? Number(task.heartbeatIntervalSeconds) : (prev.heartbeatIntervalSeconds || 0),
717
- gracePeriodSeconds: task.gracePeriodSeconds !== undefined ? Number(task.gracePeriodSeconds) : (prev.gracePeriodSeconds || 300),
718
- lastPingAt: task.lastPingAt !== undefined ? task.lastPingAt : (prev.lastPingAt || null),
719
- heartbeatAlerted: task.heartbeatAlerted !== undefined ? Boolean(task.heartbeatAlerted) : (prev.heartbeatAlerted || false),
720
- preflightType: task.preflightType !== undefined ? String(task.preflightType) : (prev.preflightType || 'none'),
721
- preflightTarget: task.preflightTarget !== undefined ? String(task.preflightTarget).trim() : (prev.preflightTarget || ''),
722
- concurrencyGroup: task.concurrencyGroup !== undefined ? String(task.concurrencyGroup).trim() : (prev.concurrencyGroup || 'default'),
723
- priority: task.priority !== undefined ? Number(task.priority) : (prev.priority || 5),
724
- selfHealingCommand: task.selfHealingCommand !== undefined ? String(task.selfHealingCommand).trim() : (prev.selfHealingCommand || ''),
725
- autoDiagnose: task.autoDiagnose !== undefined ? Boolean(task.autoDiagnose) : (prev.autoDiagnose || false),
726
- agentPreset: task.agentPreset !== undefined ? String(task.agentPreset).trim() : (prev.agentPreset || ''),
727
- targetSessionId: task.targetSessionId !== undefined ? String(task.targetSessionId).trim() : (prev.targetSessionId || ''),
728
- targetSessionReset: task.targetSessionReset !== undefined ? String(task.targetSessionReset).trim() : (prev.targetSessionReset || 'never'),
729
- sessionTitle: task.sessionTitle !== undefined ? String(task.sessionTitle).trim() : (prev.sessionTitle || ''),
730
- webhookEnabled: task.webhookEnabled !== undefined
731
- ? Boolean(task.webhookEnabled)
732
- : (prev.webhookEnabled !== undefined ? Boolean(prev.webhookEnabled) : Boolean(task.webhookTrigger?.enabled || prev.webhookTrigger?.enabled || false)),
733
- webhookToken: task.webhookToken !== undefined ? String(task.webhookToken).trim() : (prev.webhookToken || (task.webhookTrigger?.token ? String(task.webhookTrigger.token).trim() : '')),
734
- webhookSecret: task.webhookSecret !== undefined ? String(task.webhookSecret).trim() : (prev.webhookSecret || (task.webhookTrigger?.secret ? String(task.webhookTrigger.secret).trim() : '')),
735
- webhookDebounceMs: task.webhookDebounceMs !== undefined ? Number(task.webhookDebounceMs) : (prev.webhookDebounceMs !== undefined ? Number(prev.webhookDebounceMs) : (Number(task.webhookTrigger?.debounceMs || prev.webhookTrigger?.debounceMs) || 0)),
736
- totalTokens: task.totalTokens !== undefined ? Number(task.totalTokens) : (prev.totalTokens || 0),
737
- totalCostUsd: task.totalCostUsd !== undefined ? Number(task.totalCostUsd) : (prev.totalCostUsd || 0),
738
- createdAt: prev.createdAt || task.createdAt || now,
739
- updatedAt: now,
740
- lastRunAt: task.lastRunAt !== undefined ? task.lastRunAt : (prev.lastRunAt || null),
741
- lastStatus: task.lastStatus !== undefined ? task.lastStatus : (prev.lastStatus || null),
742
- lastDurationMs: task.lastDurationMs !== undefined ? task.lastDurationMs : (prev.lastDurationMs || 0),
743
- nextRunAt: task.nextRunAt !== undefined ? task.nextRunAt : (prev.nextRunAt || null),
744
- };
745
- // Issue #310 / GH #5: strip any undefined properties to guarantee lossless JSON
746
- for (const key of Object.keys(record)) {
747
- if (record[key] === undefined) {
748
- delete record[key];
749
- }
750
- }
751
- this.tasks.set(id, record);
752
- try {
753
- this.save();
754
- } catch (err) {
755
- if (existing) this.tasks.set(id, existing);
756
- else this.tasks.delete(id);
757
- throw err;
758
- }
759
- return record;
760
- }
761
-
762
- recordHeartbeat(id) {
763
- const task = this.tasks.get(id);
764
- if (!task) return null;
765
- const now = Date.now();
766
- const prevPing = task.lastPingAt;
767
- const prevAlerted = task.heartbeatAlerted;
768
- const prevUpdated = task.updatedAt;
769
- const prevStatus = task.lastStatus;
770
- task.lastPingAt = now;
771
- task.heartbeatAlerted = false;
772
- task.updatedAt = now;
773
- if (task.lastStatus === 'missed') {
774
- task.lastStatus = 'active';
775
- }
776
- try {
777
- this.save();
778
- } catch (err) {
779
- task.lastPingAt = prevPing;
780
- task.heartbeatAlerted = prevAlerted;
781
- task.updatedAt = prevUpdated;
782
- task.lastStatus = prevStatus;
783
- throw err;
784
- }
785
- const interval = (task.heartbeatIntervalSeconds || 0) + (task.gracePeriodSeconds || 300);
786
- return {
787
- ok: true,
788
- id,
789
- taskId: id,
790
- lastPingAt: now,
791
- nextDeadline: now + interval * 1000,
792
- };
793
- }
794
-
795
- delete(id) {
796
- const existing = this.tasks.get(id);
797
- if (!existing) return false;
798
- const existingHistory = this.history.get(id);
799
- this.tasks.delete(id);
800
- this.history.delete(id);
801
- try {
802
- this.save();
803
- } catch (err) {
804
- this.tasks.set(id, existing);
805
- if (existingHistory) this.history.set(id, existingHistory);
806
- throw err;
807
- }
808
- return true;
809
- }
810
-
811
- recordRun(id, runInfo) {
812
- const task = this.tasks.get(id);
813
- if (!task) return;
814
- task.lastRunAt = runInfo.at || Date.now();
815
- task.lastStatus = runInfo.status || 'success';
816
- task.lastDurationMs = runInfo.durationMs || 0;
817
-
818
- // Track usage and cost
819
- const usage = runInfo.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
820
- const costUsd = Number(runInfo.costUsd) || 0;
821
- const runTokens = (usage.inputTokens || 0) + (usage.outputTokens || 0) + (usage.cacheReadTokens || 0);
822
-
823
- task.totalTokens = (task.totalTokens || 0) + runTokens;
824
- task.totalCostUsd = Number(((task.totalCostUsd || 0) + costUsd).toFixed(6));
825
- task.updatedAt = Date.now();
826
-
827
- // Rolling 24h cost ledger preserved across archive rotation and restarts (#221)
828
- const existingLedger = Array.isArray(task.costLedger) ? task.costLedger : [];
829
- const cutoff24h = Date.now() - 86400000;
830
- task.costLedger = [
831
- ...existingLedger.filter(e => e && typeof e.at === 'number' && e.at >= cutoff24h),
832
- {
833
- at: task.lastRunAt,
834
- costUsd,
835
- tokens: runTokens,
836
- },
837
- ];
838
-
839
- // Immutable copy-on-write history update (#301)
840
- const prevRuns = this.history.get(id) || [];
841
- const runs = [
842
- {
843
- id: runInfo.runId || runInfo.id || ('run_' + Math.random().toString(36).slice(2, 9)),
844
- at: task.lastRunAt,
845
- status: task.lastStatus,
846
- durationMs: task.lastDurationMs,
847
- output: (runInfo.output || '').slice(0, 4000),
848
- error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
849
- usage,
850
- costUsd,
851
- stages: Array.isArray(runInfo.stages) ? runInfo.stages : null,
852
- stoppedReason: runInfo.stoppedReason || null,
853
- cost: runInfo.cost || {
854
- amount: costUsd,
855
- currency: 'USD',
856
- kind: costUsd > 0 ? 'exact' : ((task.type === 'llm' || task.type === 'skill' || task.type === 'workflow') ? 'unknown' : 'exact')
857
- },
858
- delivery: runInfo.delivery || null,
859
- validation: runInfo.validation || null,
860
- idempotencyKey: runInfo.idempotencyKey || null,
861
- chainRunId: runInfo.chainRunId || null,
862
- artifacts: runInfo.artifacts || null,
863
- sessionId: runInfo.sessionId || null,
864
- // #45: which model produced the run, and whether it only finished thanks
865
- // to the configured fallback.
866
- model: runInfo.model || '',
867
- fallback: Boolean(runInfo.fallback),
868
- // #44: this run was suppressed by its silent rule, with the reason.
869
- silentSkip: Boolean(runInfo.silentSkip),
870
- silentReason: runInfo.silentSkip ? String(runInfo.silentReason || '').slice(0, 500) : '',
871
- // #43: the failure diagnosis and the prompt change it proposes.
872
- diagnosis: runInfo.diagnosis ? String(runInfo.diagnosis).slice(0, 1000) : '',
873
- suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
874
- selfHealing: runInfo.selfHealing || null,
875
- autoDiagnosis: runInfo.autoDiagnosis ? String(runInfo.autoDiagnosis).slice(0, 1000) : '',
876
- confidence: runInfo.confidence || '',
877
- doctorReport: runInfo.doctorReport || null,
878
- subtaskRuns: Array.isArray(runInfo.subtaskRuns) ? runInfo.subtaskRuns : null,
879
- swarmStats: runInfo.swarmStats || null,
880
- gitDiff: runInfo.gitDiff || null,
881
- },
882
- ...prevRuns,
883
- ];
884
- // Truncate raw diff in older active history entries to bound memory and JSON size (#379, GH #17)
885
- for (let i = 10; i < runs.length; i++) {
886
- const r = runs[i];
887
- if (r && r.gitDiff && r.gitDiff.diff && r.gitDiff.diff.length > 2000) {
888
- r.gitDiff = {
889
- ...r.gitDiff,
890
- diff: r.gitDiff.diff.slice(0, 2000) + '\n... [diff truncated in history]',
891
- truncated: true,
892
- };
893
- }
894
- }
895
- // Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
896
- if (runs.length > 100) {
897
- const overflow = runs.splice(100);
898
- this._archiveHistoryRuns(id, overflow);
899
- }
900
- this.history.set(id, runs);
901
- this.scheduleSave();
902
- }
903
-
904
- /**
905
- * Remove orphaned temporary write files older than 10 minutes (#379, GH #17).
906
- */
907
- _cleanOrphanedTmpFiles(dir) {
908
- try {
909
- if (!fs.existsSync(dir)) return;
910
- const entries = fs.readdirSync(dir);
911
- const now = Date.now();
912
- const tenMinutesMs = 10 * 60 * 1000;
913
- for (const entry of entries) {
914
- if (entry.includes('.tmp.')) {
915
- const fullPath = path.join(dir, entry);
916
- bestEffort('clean-tmp-file', () => {
917
- const stat = fs.statSync(fullPath);
918
- if (stat.isFile() && (now - stat.mtimeMs > tenMinutesMs)) {
919
- fs.unlinkSync(fullPath);
920
- }
921
- });
922
- }
923
- }
924
- } catch (dirErr) {
925
- bestEffort('log-clean-tmp-dir-error', () => logger.debug?.(`[dsh-cron] clean orphaned tmp dir error: ${dirErr?.message}`));
926
- }
927
- }
928
-
929
- /**
930
- * Append rotated overflow history entries into tasks-history-archive.json (#134, #379, GH #17).
931
- */
932
- _archiveHistoryRuns(id, overflowRuns) {
933
- if (!Array.isArray(overflowRuns) || overflowRuns.length === 0) return;
934
- try {
935
- const dir = path.dirname(this.filePath);
936
- const archivePath = path.join(dir, 'tasks-history-archive.json');
937
- let archive = {};
938
- if (fs.existsSync(archivePath)) {
939
- archive = bestEffort('parse-history-archive', () => JSON.parse(fs.readFileSync(archivePath, 'utf-8'))) || {};
940
- }
941
- if (!Array.isArray(archive[id])) archive[id] = [];
942
-
943
- // Strip full raw diff payload from archived overflow runs to prevent runaway JSON growth (#379, GH #17)
944
- const sanitizedOverflow = overflowRuns.map((r) => {
945
- if (!r || !r.gitDiff) return r;
946
- const { diff, ...gitDiffMeta } = r.gitDiff;
947
- return {
948
- ...r,
949
- gitDiff: {
950
- ...gitDiffMeta,
951
- diffExcerpt: diff ? diff.slice(0, 500) : '',
952
- hasChanges: Boolean(gitDiffMeta.hasChanges),
953
- archived: true,
954
- },
955
- };
956
- });
957
-
958
- archive[id].push(...sanitizedOverflow);
959
- if (archive[id].length > 1000) {
960
- archive[id] = archive[id].slice(-1000);
961
- }
962
-
963
- // Enforce total archive size budget (max 15MB) across tasks (#379, GH #17)
964
- const MAX_ARCHIVE_BYTES = 15 * 1024 * 1024;
965
- let serialized = JSON.stringify(archive);
966
- if (serialized.length > MAX_ARCHIVE_BYTES) {
967
- for (const taskId of Object.keys(archive)) {
968
- if (Array.isArray(archive[taskId]) && archive[taskId].length > 100) {
969
- archive[taskId] = archive[taskId].slice(-100);
970
- }
971
- }
972
- serialized = JSON.stringify(archive);
973
- }
974
-
975
- // Write atomically with tmp file using compact JSON (avoiding mega-lines and null, 2 serialization bloat)
976
- const rand = Math.random().toString(36).slice(2, 8);
977
- const tmp = `${archivePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
978
- fs.writeFileSync(tmp, serialized, { encoding: 'utf-8', mode: 0o600 });
979
- bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
980
- fs.renameSync(tmp, archivePath);
981
- bestEffort('chmod-archive', () => fs.chmodSync(archivePath, 0o600));
982
-
983
- // Partition runs into monthly buckets for compressed archives (#391)
984
- const monthlyBuckets = new Map();
985
- for (const run of sanitizedOverflow) {
986
- const at = run.at || run.timestamp || run.startedAt || Date.now();
987
- const date = new Date(at);
988
- const monthKey = !isNaN(date.getTime())
989
- ? `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`
990
- : new Date().toISOString().slice(0, 7);
991
- if (!monthlyBuckets.has(monthKey)) {
992
- monthlyBuckets.set(monthKey, []);
993
- }
994
- monthlyBuckets.get(monthKey).push(run);
995
- }
996
-
997
- for (const [monthKey, runsForMonth] of monthlyBuckets.entries()) {
998
- const gzPath = path.join(dir, `tasks-history-${monthKey}.json.gz`);
999
- let monthArchive = {};
1000
- if (fs.existsSync(gzPath)) {
1001
- try {
1002
- const rawGz = fs.readFileSync(gzPath);
1003
- const decompressed = gunzipSync(rawGz).toString('utf-8');
1004
- monthArchive = JSON.parse(decompressed) || {};
1005
- } catch (gzReadErr) {
1006
- logger.warn(`[dsh-cron] failed to read existing gzip archive for ${monthKey}:`, gzReadErr.message);
1007
- monthArchive = {};
1008
- }
1009
- }
1010
- if (!Array.isArray(monthArchive[id])) {
1011
- monthArchive[id] = [];
1012
- }
1013
- monthArchive[id].push(...runsForMonth);
1014
- const compressed = gzipSync(Buffer.from(JSON.stringify(monthArchive), 'utf-8'), { level: 9 });
1015
- const randGz = Math.random().toString(36).slice(2, 8);
1016
- const tmpGz = `${gzPath}.tmp.${process.pid}_${Date.now()}_${randGz}`;
1017
- fs.writeFileSync(tmpGz, compressed, { mode: 0o600 });
1018
- bestEffort('chmod-tmp-gz', () => fs.chmodSync(tmpGz, 0o600));
1019
- fs.renameSync(tmpGz, gzPath);
1020
- bestEffort('chmod-archive-gz', () => fs.chmodSync(gzPath, 0o600));
1021
- }
1022
-
1023
- this._pruneOldMonthlyArchives(dir, 6);
1024
- } catch (archiveErr) {
1025
- logger.error('[dsh-cron] history archive error:', archiveErr.message);
1026
- }
1027
- }
1028
-
1029
- listMonthlyArchives() {
1030
- try {
1031
- const dir = path.dirname(this.filePath);
1032
- if (!fs.existsSync(dir)) return [];
1033
- const files = fs.readdirSync(dir);
1034
- const archives = [];
1035
- for (const file of files) {
1036
- const match = file.match(/^tasks-history-(\d{4}-\d{2})\.json\.gz$/);
1037
- if (match) {
1038
- const month = match[1];
1039
- const fullPath = path.join(dir, file);
1040
- try {
1041
- const stat = fs.statSync(fullPath);
1042
- archives.push({
1043
- month,
1044
- filename: file,
1045
- sizeBytes: stat.size,
1046
- mtime: stat.mtime.toISOString(),
1047
- path: fullPath,
1048
- });
1049
- } catch (_err) {
1050
- // ignore unreadable stat
1051
- }
1052
- }
1053
- }
1054
- return archives.sort((a, b) => b.month.localeCompare(a.month));
1055
- } catch (err) {
1056
- logger.error('[dsh-cron] listMonthlyArchives error:', err.message);
1057
- return [];
1058
- }
1059
- }
1060
-
1061
- getMonthlyArchive(month) {
1062
- if (!month || !/^\d{4}-\d{2}$/.test(month)) return null;
1063
- try {
1064
- const dir = path.dirname(this.filePath);
1065
- const gzPath = path.join(dir, `tasks-history-${month}.json.gz`);
1066
- if (!fs.existsSync(gzPath)) return null;
1067
- const rawGz = fs.readFileSync(gzPath);
1068
- const decompressed = gunzipSync(rawGz).toString('utf-8');
1069
- return JSON.parse(decompressed);
1070
- } catch (err) {
1071
- logger.error(`[dsh-cron] getMonthlyArchive error for ${month}:`, err.message);
1072
- return null;
1073
- }
1074
- }
1075
-
1076
- _pruneOldMonthlyArchives(dir, maxMonths = 6) {
1077
- try {
1078
- if (!fs.existsSync(dir) || maxMonths < 0) return;
1079
- const files = fs.readdirSync(dir);
1080
- const monthlyFiles = [];
1081
- for (const file of files) {
1082
- const match = file.match(/^tasks-history-(\d{4}-\d{2})\.json\.gz$/);
1083
- if (match) {
1084
- monthlyFiles.push({ month: match[1], file, fullPath: path.join(dir, file) });
1085
- }
1086
- }
1087
- monthlyFiles.sort((a, b) => b.month.localeCompare(a.month));
1088
- if (monthlyFiles.length > maxMonths) {
1089
- const toDelete = monthlyFiles.slice(maxMonths);
1090
- for (const item of toDelete) {
1091
- bestEffort('delete-old-archive', () => fs.unlinkSync(item.fullPath));
1092
- logger.info(`[dsh-cron] pruned old monthly history archive: ${item.file}`);
1093
- }
1094
- }
1095
- } catch (err) {
1096
- bestEffort('log-prune-err', () => logger.debug?.(`[dsh-cron] prune old monthly archives error: ${err.message}`));
1097
- }
1098
- }
1099
-
1100
- getHistory(id, limit = 20) {
1101
- const runs = this.history.get(id) || [];
1102
- return runs.slice(0, limit);
1103
- }
1104
-
1105
- getArchivedRuns(id, { limit = 50, offset = 0, search = '' } = {}) {
1106
- try {
1107
- const dir = path.dirname(this.filePath);
1108
- const archivePath = path.join(dir, 'tasks-history-archive.json');
1109
- if (!fs.existsSync(archivePath)) return { runs: [], total: 0 };
1110
- const archive = JSON.parse(fs.readFileSync(archivePath, 'utf-8')) || {};
1111
- let runs = Array.isArray(archive[id]) ? [...archive[id]].sort((a, b) => ((b.at || b.timestamp || b.startedAt || 0) - (a.at || a.timestamp || a.startedAt || 0))) : [];
1112
- if (search && search.trim()) {
1113
- const q = search.toLowerCase();
1114
- runs = runs.filter(r => (r.output && r.output.toLowerCase().includes(q)) || (r.error && r.error.toLowerCase().includes(q)));
1115
- }
1116
- const total = runs.length;
1117
- const paged = runs.slice(offset, offset + limit);
1118
- return { runs: paged, total };
1119
- } catch (err) {
1120
- return { runs: [], total: 0, error: err.message };
1121
- }
1122
- }
1123
-
1124
- getTaskStats(id) {
1125
- const runs = this.history.get(id) || [];
1126
- const total = runs.length;
1127
- let success = 0;
1128
- let error = 0;
1129
- let timeout = 0;
1130
- const recentDurations = [];
1131
-
1132
- for (let i = 0; i < runs.length; i++) {
1133
- const r = runs[i];
1134
- if (r.status === 'success') success++;
1135
- else if (r.status === 'error') error++;
1136
- else if (r.status === 'timeout') timeout++;
1137
-
1138
- if (recentDurations.length < 10 && r.durationMs !== undefined) {
1139
- recentDurations.push({
1140
- at: r.at,
1141
- durationMs: r.durationMs,
1142
- status: r.status,
1143
- });
1144
- }
1145
- }
1146
-
1147
- const successRate = total > 0 ? Number((success / total).toFixed(2)) : 1.0;
1148
- return {
1149
- totalRuns: total,
1150
- successRuns: success,
1151
- errorRuns: error,
1152
- timeoutRuns: timeout,
1153
- successRate,
1154
- recentDurations,
1155
- };
1156
- }
1157
-
1158
- getAggregatedStats() {
1159
- let totalRuns = 0;
1160
- let totalTokens = 0;
1161
- let totalCostUsd = 0;
1162
- let activeTasks = 0;
1163
-
1164
- for (const t of this.tasks.values()) {
1165
- if (t.status === 'active') activeTasks++;
1166
- totalTokens += (t.totalTokens || 0);
1167
- totalCostUsd += (t.totalCostUsd || 0);
1168
- const h = this.history.get(t.id) || [];
1169
- totalRuns += h.length;
1170
- }
1171
-
1172
- return {
1173
- activeTasks,
1174
- totalRuns,
1175
- totalTokens,
1176
- totalCostUsd: Number(totalCostUsd.toFixed(4)),
1177
- };
1178
- }
1179
- }
1
+ export * from './store/store.js';
2
+ // FORBIDDEN_SETTING_KEYS