@goodandready/dsh-cron 0.2.8 → 0.2.9
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/README.md +8 -0
- package/lib/api.js +23 -4
- package/lib/client.js +51 -6
- package/lib/index.js +2 -1
- package/lib/recipes.js +13 -0
- package/lib/runner.js +124 -30
- package/lib/scheduler.js +1 -1
- package/lib/store.js +31 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -317,6 +317,14 @@ Environment used by the check: `DSH_WEB_BASE` (default `http://127.0.0.1:3080`),
|
|
|
317
317
|
### 20. Internal Refactor: Schedule Parsing and Arming (#97)
|
|
318
318
|
Developer-facing, no behaviour change. `parseScheduleExpression` was split into small functions that keep the same branch order — `parseAtExpression`, `parseRelativeOneShot`, `parseIntervalExpression`, `parseAliasExpression`, `parseCronExpression` — and `scheduleTask` into `clearScheduled`, `scheduleOneShot` and `scheduleCron`. The existing test suite passed unchanged and targeted tests were added for branch precedence and error messages.
|
|
319
319
|
|
|
320
|
+
### 21. Performance & Process Isolation Pack (Added in v0.2.9, #134)
|
|
321
|
+
- **Process-Tree Termination**: Shell and script tasks run in an isolated process group (`detached: true` on POSIX); aborts and timeouts send `SIGTERM` followed by `SIGKILL` to the entire group (`-child.pid`) to eliminate orphan and zombie processes.
|
|
322
|
+
- **Concurrency Throttling**: Default safe limit `maxConcurrent = 2` prevents CPU and memory spikes during overlapping scheduled runs.
|
|
323
|
+
- **Transient Error Retry**: Exponential backoff retry (up to 3 attempts) for transient network and provider errors (`429`, `502`, `503`, `504`, `ECONNRESET`).
|
|
324
|
+
- **Network & UI Optimization**: `GET /dsh-cron/tasks` provides `ETag` and responds with `304 Not Modified`; client UI adapts polling frequency (`visibilitychange`: 30s in background tabs, 8s in active tabs).
|
|
325
|
+
- **History Rotation & Archival**: Active store holds the latest 100 runs per task; older entries are automatically archived into `tasks-history-archive.json`.
|
|
326
|
+
- **Autonomous PR Reviewer Recipe (#33)**: Preconfigured recipe in Template Hub with optional toggle `prReviewerEnabled` in settings.
|
|
327
|
+
|
|
320
328
|
---
|
|
321
329
|
|
|
322
330
|
## 📦 Installation
|
package/lib/api.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* unchanged.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
9
9
|
import { parseScheduleExpression } from './scheduler.js';
|
|
10
10
|
import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
|
|
11
11
|
import { CHANNEL_IDS, unknownChannelIds } from './channels.js';
|
|
@@ -88,7 +88,7 @@ async function handleTaskCollection({ store, scheduler, collection, req, res, ur
|
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
90
90
|
if (req.method === 'GET') {
|
|
91
|
-
listTasks({ store, scheduler, collection, res, url });
|
|
91
|
+
listTasks({ store, scheduler, collection, req, res, url });
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
if (req.method === 'POST') {
|
|
@@ -98,7 +98,7 @@ async function handleTaskCollection({ store, scheduler, collection, req, res, ur
|
|
|
98
98
|
sendJson(res, 405, NOT_ALLOWED);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
function listTasks({ store, scheduler, collection, res, url }) {
|
|
101
|
+
function listTasks({ store, scheduler, collection, req, res, url }) {
|
|
102
102
|
const status = url.searchParams.get('status') || 'all';
|
|
103
103
|
const query = url.searchParams.get('query') || '';
|
|
104
104
|
const list = store.list({ status, query }).map((t) => ({
|
|
@@ -107,7 +107,26 @@ function listTasks({ store, scheduler, collection, res, url }) {
|
|
|
107
107
|
runningSince: scheduler.runningSince(t.id),
|
|
108
108
|
}));
|
|
109
109
|
const stats = store.getAggregatedStats();
|
|
110
|
-
|
|
110
|
+
const payload = { ok: true, tasks: list, recommendations: collection.recommendations, stats };
|
|
111
|
+
|
|
112
|
+
// Calculate ETag based on payload JSON representation (#134)
|
|
113
|
+
const bodyString = JSON.stringify(payload);
|
|
114
|
+
const etag = 'W/"' + createHash('sha1').update(bodyString).digest('hex') + '"';
|
|
115
|
+
|
|
116
|
+
if (req && req.headers) {
|
|
117
|
+
const ifNoneMatch = req.headers['if-none-match'];
|
|
118
|
+
if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === etag.slice(2))) {
|
|
119
|
+
res.writeHead(304, { 'ETag': etag, 'Cache-Control': 'no-cache' });
|
|
120
|
+
res.end();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (typeof res.setHeader === 'function') {
|
|
126
|
+
res.setHeader('ETag', etag);
|
|
127
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
128
|
+
}
|
|
129
|
+
sendJson(res, 200, payload);
|
|
111
130
|
}
|
|
112
131
|
|
|
113
132
|
async function createOrUpdateTask({ store, scheduler, req, res }) {
|
package/lib/client.js
CHANGED
|
@@ -1035,11 +1035,29 @@ window.__ModuleLoader__.load({
|
|
|
1035
1035
|
}
|
|
1036
1036
|
};
|
|
1037
1037
|
|
|
1038
|
+
const lastEtagRef = React.useRef('');
|
|
1039
|
+
|
|
1038
1040
|
React.useEffect(() => {
|
|
1039
1041
|
fetchTasks();
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1042
|
+
|
|
1043
|
+
let timerId = null;
|
|
1044
|
+
let isDestroyed = false;
|
|
1045
|
+
|
|
1046
|
+
const doPoll = () => {
|
|
1047
|
+
if (isDestroyed) return;
|
|
1048
|
+
const headers = {};
|
|
1049
|
+
if (lastEtagRef.current) headers['If-None-Match'] = lastEtagRef.current;
|
|
1050
|
+
|
|
1051
|
+
fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), { headers })
|
|
1052
|
+
.then(res => {
|
|
1053
|
+
if (res.status === 304) {
|
|
1054
|
+
// Not modified: skip parsing JSON
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
const etag = res.headers.get('ETag');
|
|
1058
|
+
if (etag) lastEtagRef.current = etag;
|
|
1059
|
+
return res.json();
|
|
1060
|
+
})
|
|
1043
1061
|
.then(data => {
|
|
1044
1062
|
if (data && data.ok) {
|
|
1045
1063
|
setTasks(data.tasks || []);
|
|
@@ -1047,9 +1065,36 @@ window.__ModuleLoader__.load({
|
|
|
1047
1065
|
setStats(data.stats || null);
|
|
1048
1066
|
}
|
|
1049
1067
|
})
|
|
1050
|
-
.catch(() => {})
|
|
1051
|
-
|
|
1052
|
-
|
|
1068
|
+
.catch(() => {})
|
|
1069
|
+
.finally(() => {
|
|
1070
|
+
if (isDestroyed) return;
|
|
1071
|
+
// Adaptive interval: 30s when tab is hidden, 8s when active (#134)
|
|
1072
|
+
const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
|
|
1073
|
+
timerId = setTimeout(doPoll, intervalMs);
|
|
1074
|
+
});
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
// Trigger immediate fetch when visibility returns
|
|
1078
|
+
const onVisibilityChange = () => {
|
|
1079
|
+
if (typeof document !== 'undefined' && !document.hidden) {
|
|
1080
|
+
if (timerId) clearTimeout(timerId);
|
|
1081
|
+
doPoll();
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
if (typeof document !== 'undefined') {
|
|
1086
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
timerId = setTimeout(doPoll, 8000);
|
|
1090
|
+
|
|
1091
|
+
return () => {
|
|
1092
|
+
isDestroyed = true;
|
|
1093
|
+
if (timerId) clearTimeout(timerId);
|
|
1094
|
+
if (typeof document !== 'undefined') {
|
|
1095
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1053
1098
|
}, [tab, query]);
|
|
1054
1099
|
React.useEffect(() => { loadModels(); }, []);
|
|
1055
1100
|
|
package/lib/index.js
CHANGED
|
@@ -100,6 +100,7 @@ export const Config = z.object({
|
|
|
100
100
|
giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
|
|
101
101
|
giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
|
|
102
102
|
giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
|
|
103
|
+
prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)'),
|
|
103
104
|
// --- Declarative jobs (#50, ADR-0001) ---
|
|
104
105
|
// The array is intentionally permissive: each entry is validated on its own
|
|
105
106
|
// with a precise message (see lib/config-jobs.js), so one broken entry cannot
|
|
@@ -122,7 +123,7 @@ export const SETTINGS_SYNC_KEYS = [
|
|
|
122
123
|
'pushplusTokenRef',
|
|
123
124
|
'pushplusUrl',
|
|
124
125
|
'ttsBaseUrl',
|
|
125
|
-
'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
|
|
126
|
+
'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'prReviewerEnabled',
|
|
126
127
|
'apiToken',
|
|
127
128
|
];
|
|
128
129
|
|
package/lib/recipes.js
CHANGED
|
@@ -19,6 +19,7 @@ export const RECIPE_CATEGORIES = [
|
|
|
19
19
|
{ id: 'storage', title: 'Storage' },
|
|
20
20
|
{ id: 'security', title: 'Security' },
|
|
21
21
|
{ id: 'maintenance', title: 'Maintenance windows' },
|
|
22
|
+
{ id: 'ci', title: 'CI & Code review' },
|
|
22
23
|
];
|
|
23
24
|
|
|
24
25
|
/** Commands a recipe must never contain, checked by the test suite. */
|
|
@@ -190,6 +191,18 @@ export const RECIPES = [
|
|
|
190
191
|
prompt: 'uptime && echo "---" && uname -r && echo "---" && df -h / | tail -n 1',
|
|
191
192
|
channels: ['telegram'],
|
|
192
193
|
},
|
|
194
|
+
{
|
|
195
|
+
id: 'recipe_pr_reviewer',
|
|
196
|
+
category: 'ci',
|
|
197
|
+
title: 'Autonomous PR Reviewer (#33)',
|
|
198
|
+
description: 'Periodic inspection of open Pull Requests in Gitea with agent review analysis.',
|
|
199
|
+
type: 'llm',
|
|
200
|
+
schedule: '*/30 * * * *',
|
|
201
|
+
scheduleText: 'Every 30 minutes',
|
|
202
|
+
prompt: 'Check open Pull Requests in Gitea repository. Inspect changed diffs, evaluate code quality, verify test coverage and post a concise review summary.',
|
|
203
|
+
channels: ['telegram', 'gitea'],
|
|
204
|
+
silentRule: 'Stay silent when there are no open PRs or no new commits to review.',
|
|
205
|
+
},
|
|
193
206
|
];
|
|
194
207
|
|
|
195
208
|
/** Detached copy of the catalog, safe to hand to the UI. */
|
package/lib/runner.js
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper to test if an error code or status is transient and eligible for retry (#134).
|
|
3
|
+
*/
|
|
4
|
+
export function isTransientError(errOrStatus) {
|
|
5
|
+
if (typeof errOrStatus === 'number') {
|
|
6
|
+
return errOrStatus === 429 || errOrStatus === 502 || errOrStatus === 503 || errOrStatus === 504;
|
|
7
|
+
}
|
|
8
|
+
const str = String(errOrStatus?.message || errOrStatus || '').toLowerCase();
|
|
9
|
+
return str.includes('429') || str.includes('too many requests') ||
|
|
10
|
+
str.includes('502') || str.includes('bad gateway') ||
|
|
11
|
+
str.includes('503') || str.includes('service unavailable') ||
|
|
12
|
+
str.includes('504') || str.includes('gateway timeout') ||
|
|
13
|
+
str.includes('econnreset') || str.includes('etimedout');
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
import { estimateTokenCost } from './integrations.js';
|
|
2
17
|
import {
|
|
3
18
|
EXTERNAL_RUNTIMES,
|
|
@@ -99,24 +114,44 @@ export class SessionRunner {
|
|
|
99
114
|
});
|
|
100
115
|
}
|
|
101
116
|
|
|
102
|
-
/** HTTP trigger runtime (#7). */
|
|
117
|
+
/** HTTP trigger runtime (#7) with transient retry (#134). */
|
|
103
118
|
async _runHttp(task, request, signal) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
let attempts = 0;
|
|
120
|
+
const maxAttempts = 3;
|
|
121
|
+
while (attempts < maxAttempts) {
|
|
122
|
+
attempts++;
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetch(request.url, {
|
|
125
|
+
method: request.method,
|
|
126
|
+
headers: request.headers,
|
|
127
|
+
body: request.body,
|
|
128
|
+
signal,
|
|
129
|
+
});
|
|
130
|
+
const text = await res.text().catch(() => '');
|
|
131
|
+
const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
const err = new Error(`${summary}\n${String(text).slice(0, 500)}`);
|
|
134
|
+
if (isTransientError(res.status) && attempts < maxAttempts && !signal?.aborted) {
|
|
135
|
+
console.warn(`[dsh-cron] HTTP request returned ${res.status}, retrying attempt ${attempts + 1}/${maxAttempts}...`);
|
|
136
|
+
await new Promise((r) => setTimeout(r, attempts * 1000));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
|
|
143
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
144
|
+
costUsd: 0,
|
|
145
|
+
};
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (isTransientError(err) && attempts < maxAttempts && !signal?.aborted) {
|
|
148
|
+
console.warn(`[dsh-cron] HTTP request failed (${err.message}), retrying attempt ${attempts + 1}/${maxAttempts}...`);
|
|
149
|
+
await new Promise((r) => setTimeout(r, attempts * 1000));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
114
154
|
}
|
|
115
|
-
return {
|
|
116
|
-
output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
|
|
117
|
-
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
118
|
-
costUsd: 0,
|
|
119
|
-
};
|
|
120
155
|
}
|
|
121
156
|
|
|
122
157
|
/**
|
|
@@ -172,30 +207,89 @@ export class SessionRunner {
|
|
|
172
207
|
const invocation = buildInvocation({ ...task, type: runtime }, cwd);
|
|
173
208
|
if (invocation.http) return this._runHttp(task, invocation.http, signal);
|
|
174
209
|
|
|
175
|
-
const {
|
|
210
|
+
const { spawn } = await import('node:child_process');
|
|
176
211
|
return new Promise((resolve, reject) => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
212
|
+
let child = null;
|
|
213
|
+
let settled = false;
|
|
214
|
+
let stdout = '';
|
|
215
|
+
let stderr = '';
|
|
216
|
+
const maxBuffer = 10 * 1024 * 1024;
|
|
217
|
+
const isPosix = process.platform !== 'win32';
|
|
218
|
+
|
|
219
|
+
const killProcessTree = (sig = 'SIGTERM') => {
|
|
220
|
+
if (!child || child.killed) return;
|
|
221
|
+
try {
|
|
222
|
+
if (isPosix && child.pid) {
|
|
223
|
+
try { process.kill(-child.pid, sig); } catch {}
|
|
224
|
+
} else if (child.pid) {
|
|
225
|
+
child.kill(sig);
|
|
181
226
|
}
|
|
227
|
+
} catch {}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const abortHandler = () => {
|
|
231
|
+
killProcessTree('SIGTERM');
|
|
232
|
+
setTimeout(() => killProcessTree('SIGKILL'), 300);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
if (signal) {
|
|
236
|
+
if (signal.aborted) {
|
|
237
|
+
return reject(signal.reason || new Error('Command aborted'));
|
|
238
|
+
}
|
|
239
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const options = {
|
|
243
|
+
cwd,
|
|
244
|
+
env: invocation.env,
|
|
245
|
+
detached: isPosix,
|
|
246
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
if (invocation.file) {
|
|
250
|
+
child = spawn(invocation.file, invocation.args || [], options);
|
|
251
|
+
} else {
|
|
252
|
+
const shellPath = isPosix ? (process.env.SHELL || '/bin/bash') : (process.env.ComSpec || 'cmd.exe');
|
|
253
|
+
const shellArgs = isPosix ? ['-c', invocation.shellCommand] : ['/d', '/s', '/c', invocation.shellCommand];
|
|
254
|
+
child = spawn(shellPath, shellArgs, options);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
child.stdout.on('data', (chunk) => {
|
|
258
|
+
if (stdout.length < maxBuffer) stdout += chunk;
|
|
259
|
+
});
|
|
260
|
+
child.stderr.on('data', (chunk) => {
|
|
261
|
+
if (stderr.length < maxBuffer) stderr += chunk;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
child.on('error', (err) => {
|
|
265
|
+
if (settled) return;
|
|
266
|
+
settled = true;
|
|
267
|
+
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
268
|
+
reject(err);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
child.on('close', (code, sig) => {
|
|
272
|
+
if (settled) return;
|
|
273
|
+
settled = true;
|
|
274
|
+
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
275
|
+
|
|
276
|
+
if (signal && signal.aborted) {
|
|
277
|
+
return reject(signal.reason || new Error('Command aborted'));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (code !== 0 && code !== null) {
|
|
182
281
|
const detail = String(stderr || '').trim();
|
|
183
|
-
|
|
282
|
+
const msg = `${runtime} task exited with code ${code}`;
|
|
283
|
+
return reject(new Error(detail ? `${msg}\n${detail}` : msg));
|
|
184
284
|
}
|
|
285
|
+
|
|
185
286
|
const out = (stdout || stderr || '').trim();
|
|
186
287
|
resolve({
|
|
187
288
|
output: out || `${runtime} task completed with code 0`,
|
|
188
289
|
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
189
290
|
costUsd: 0,
|
|
190
291
|
});
|
|
191
|
-
};
|
|
192
|
-
const options = { cwd, signal, maxBuffer: 10 * 1024 * 1024, env: invocation.env };
|
|
193
|
-
if (invocation.file) {
|
|
194
|
-
execFile(invocation.file, invocation.args || [], options, done);
|
|
195
|
-
} else {
|
|
196
|
-
const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
|
|
197
|
-
exec(invocation.shellCommand, { ...options, shell: shellPath }, done);
|
|
198
|
-
}
|
|
292
|
+
});
|
|
199
293
|
});
|
|
200
294
|
}
|
|
201
295
|
|
package/lib/scheduler.js
CHANGED
|
@@ -169,7 +169,7 @@ export class TaskScheduler {
|
|
|
169
169
|
constructor(store, executeFn, options = {}) {
|
|
170
170
|
this.store = store;
|
|
171
171
|
this.executeFn = executeFn;
|
|
172
|
-
this.maxConcurrent = Number(options.maxConcurrent)
|
|
172
|
+
this.maxConcurrent = options.maxConcurrent !== undefined && Number(options.maxConcurrent) >= 0 ? Number(options.maxConcurrent) : 2; // default 2 parallel runs (#134, #52)
|
|
173
173
|
this.defaultTimezone = options.defaultTimezone || ''; // #13
|
|
174
174
|
this.resolveSecrets = typeof options.resolveSecrets === 'function' ? options.resolveSecrets : null; // #51
|
|
175
175
|
this.deliver = typeof options.deliver === 'function' ? options.deliver : null; // #26 channel router
|
package/lib/store.js
CHANGED
|
@@ -295,11 +295,41 @@ export class TaskStore {
|
|
|
295
295
|
suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
|
|
296
296
|
confidence: runInfo.confidence || '',
|
|
297
297
|
});
|
|
298
|
-
|
|
298
|
+
// Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
|
|
299
|
+
if (runs.length > 100) {
|
|
300
|
+
const overflow = runs.splice(100);
|
|
301
|
+
this._archiveHistoryRuns(id, overflow);
|
|
302
|
+
}
|
|
299
303
|
this.history.set(id, runs);
|
|
300
304
|
this.save();
|
|
301
305
|
}
|
|
302
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Append rotated overflow history entries into tasks-history-archive.json (#134).
|
|
309
|
+
*/
|
|
310
|
+
_archiveHistoryRuns(id, overflowRuns) {
|
|
311
|
+
if (!Array.isArray(overflowRuns) || overflowRuns.length === 0) return;
|
|
312
|
+
try {
|
|
313
|
+
const dir = path.dirname(this.filePath);
|
|
314
|
+
const archivePath = path.join(dir, 'tasks-history-archive.json');
|
|
315
|
+
let archive = {};
|
|
316
|
+
if (fs.existsSync(archivePath)) {
|
|
317
|
+
try {
|
|
318
|
+
archive = JSON.parse(fs.readFileSync(archivePath, 'utf-8')) || {};
|
|
319
|
+
} catch {}
|
|
320
|
+
}
|
|
321
|
+
if (!Array.isArray(archive[id])) archive[id] = [];
|
|
322
|
+
archive[id].push(...overflowRuns);
|
|
323
|
+
// Write atomically with tmp file
|
|
324
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
325
|
+
const tmp = `${archivePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
326
|
+
fs.writeFileSync(tmp, JSON.stringify(archive, null, 2), 'utf-8');
|
|
327
|
+
fs.renameSync(tmp, archivePath);
|
|
328
|
+
} catch (archiveErr) {
|
|
329
|
+
console.error('[dsh-cron] history archive error:', archiveErr.message);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
303
333
|
getHistory(id, limit = 20) {
|
|
304
334
|
const runs = this.history.get(id) || [];
|
|
305
335
|
return runs.slice(0, limit);
|