@goodandready/dsh-cron 0.3.25 → 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.
- package/lib/api-actions.js +1 -317
- package/lib/api-collection.js +1 -250
- package/lib/api-helpers.js +1 -100
- package/lib/api-import-export.js +1 -155
- package/lib/api-item.js +1 -191
- package/lib/api-task-webhook.js +1 -247
- package/lib/api-webhook.js +1 -183
- package/lib/api.js +1 -178
- package/lib/artifacts.js +1 -200
- package/lib/chain-checkpoints.js +1 -223
- package/lib/client-src/00-open.js +62 -0
- package/lib/client-src/10-locales.js +484 -0
- package/lib/client-src/20-styles.js +64 -0
- package/lib/client-src/30-utils.js +248 -0
- package/lib/client-src/40-delivery-settings.js +96 -0
- package/lib/client-src/45-dag-graph-view.js +287 -0
- package/lib/client-src/46-live-logs-modal.js +108 -0
- package/lib/client-src/47-analytics-digest-modal.js +166 -0
- package/lib/client-src/48-timeline-heatmap-view.js +176 -0
- package/lib/client-src/50-cron-screen-state.js +447 -0
- package/lib/client-src/52-cron-screen-actions.js +330 -0
- package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
- package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
- package/lib/client-src/55-cron-screen-view.js +378 -0
- package/lib/client-src/60-modal-task-form-head.js +580 -0
- package/lib/client-src/62-modal-task-form-tail.js +466 -0
- package/lib/client-src/65-modal-dialogs.js +350 -0
- package/lib/client-src/70-settings-card.js +420 -0
- package/lib/client-src/80-sidebar-jobs.js +200 -0
- package/lib/client-src/90-close.js +200 -0
- package/lib/commands.js +200 -0
- package/lib/durable-journal.js +1 -188
- package/lib/external-api.js +1 -74
- package/lib/index.js +17 -0
- package/lib/metrics.js +1 -125
- package/lib/routes/api-actions.js +317 -0
- package/lib/routes/api-collection.js +250 -0
- package/lib/routes/api-helpers.js +100 -0
- package/lib/routes/api-import-export.js +155 -0
- package/lib/routes/api-item.js +191 -0
- package/lib/routes/api-task-webhook.js +247 -0
- package/lib/routes/api-webhook.js +183 -0
- package/lib/routes/api.js +178 -0
- package/lib/routes/external-api.js +74 -0
- package/lib/routes/index.js +11 -0
- package/lib/routes/metrics.js +125 -0
- package/lib/routes/routes.js +549 -0
- package/lib/routes.js +1 -549
- package/lib/runner-external.js +1 -352
- package/lib/runner-worktree.js +1 -90
- package/lib/runner.js +1 -881
- package/lib/runtimes/index.js +4 -0
- package/lib/runtimes/runner-external.js +352 -0
- package/lib/runtimes/runner-worktree.js +90 -0
- package/lib/runtimes/runner.js +881 -0
- package/lib/runtimes/runtimes.js +198 -0
- package/lib/runtimes.js +1 -198
- package/lib/scheduler/index.js +6 -0
- package/lib/scheduler/scheduler-assists.js +142 -0
- package/lib/scheduler/scheduler-completion.js +417 -0
- package/lib/scheduler/scheduler-execution.js +360 -0
- package/lib/scheduler/scheduler-lifecycle.js +157 -0
- package/lib/scheduler/scheduler-preflight.js +195 -0
- package/lib/scheduler/scheduler.js +751 -0
- package/lib/scheduler-assists.js +1 -142
- package/lib/scheduler-completion.js +1 -417
- package/lib/scheduler-execution.js +1 -360
- package/lib/scheduler-lifecycle.js +1 -157
- package/lib/scheduler-preflight.js +1 -195
- package/lib/scheduler.js +1 -751
- package/lib/store/artifacts.js +200 -0
- package/lib/store/chain-checkpoints.js +223 -0
- package/lib/store/durable-journal.js +188 -0
- package/lib/store/index.js +6 -0
- package/lib/store/store.js +1179 -0
- package/lib/store/task-schema.js +180 -0
- package/lib/store/task-transfer.js +310 -0
- package/lib/store.js +2 -1179
- package/lib/task-schema.js +1 -180
- package/lib/task-transfer.js +1 -310
- package/lib/tui-settings.js +157 -0
- package/package.json +2 -1
package/lib/store.js
CHANGED
|
@@ -1,1179 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|