@goodandready/dsh-cron 0.3.22 → 0.3.24

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/chat-start.js CHANGED
@@ -18,7 +18,12 @@ export async function chatStartHandler(ctx, req, res, parseJsonBody, sendJson, r
18
18
  return;
19
19
  }
20
20
 
21
- const agents = ctx.agents;
21
+ let agents = null;
22
+ try {
23
+ agents = (ctx && typeof ctx.get === 'function' ? ctx.get('agents') : null) || (ctx && ctx.agents);
24
+ } catch {
25
+ agents = null;
26
+ }
22
27
  if (!agents || typeof agents.create !== 'function') {
23
28
  sendJson(res, 500, { ok: false, error: 'The agents service is unavailable in DSH' });
24
29
  return;
@@ -45,7 +50,13 @@ export async function chatStartHandler(ctx, req, res, parseJsonBody, sendJson, r
45
50
  (ctx.logger || console).debug?.('[dsh-cron] @deepseek-ai/dsh-session SessionId unavailable, using string fallback:', err?.message || err);
46
51
  }
47
52
 
48
- const defaultSel = ctx.get('agentDefaultModel')?.currentSelection?.();
53
+ let defaultSel = null;
54
+ try {
55
+ const adm = (ctx && typeof ctx.get === 'function' ? ctx.get('agentDefaultModel') : null) || (ctx && ctx.agentDefaultModel);
56
+ defaultSel = adm?.currentSelection?.();
57
+ } catch {
58
+ defaultSel = null;
59
+ }
49
60
  const provider = body.provider || defaultSel?.provider;
50
61
  const model = body.model || defaultSel?.model;
51
62
 
package/lib/index.js CHANGED
@@ -1,10 +1,24 @@
1
+ import {
2
+ TaskSchema,
3
+ validateTaskRecord,
4
+ safeValidateTaskRecord,
5
+ validateTaskPatch,
6
+ } from './task-schema.js';
7
+
8
+ export {
9
+ TaskSchema,
10
+ validateTaskRecord,
11
+ safeValidateTaskRecord,
12
+ validateTaskPatch,
13
+ };
1
14
  import fs from 'node:fs';
2
15
  import path from 'node:path';
3
16
  import { bestEffort } from './best-effort.js';
4
17
  import { setLogger, logger } from './logger.js';
5
18
  import { registerPluginUpdater } from './updater.js';
6
19
  import { credentialRefStatus } from './secrets.js';
7
- import { TaskStore } from './store.js';
20
+ import { TaskStore, CURRENT_SCHEMA_VERSION, runStoreMigrations } from './store.js';
21
+ export { TaskStore, CURRENT_SCHEMA_VERSION, runStoreMigrations };
8
22
  import { TaskScheduler, parseScheduleExpression } from './scheduler.js';
9
23
  import { SessionRunner } from './runner.js';
10
24
  import { getModelsHandler } from './models-handler.js';
@@ -95,7 +109,7 @@ export {
95
109
  };
96
110
 
97
111
  export const name = '@goodandready/dsh-cron';
98
- export const inject = ['tools', 'webServer', 'llm', 'agents', 'agentDefaultModel', 'credentials', 'workspaceRegistry'];
112
+ export const inject = ['tools', 'webServer'];
99
113
 
100
114
  import { executeCreateTask } from './task-create.js';
101
115
  import {
@@ -3,11 +3,18 @@ export async function getModelsHandler(ctx, req, res, sendJson) {
3
3
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
4
4
  return;
5
5
  }
6
- const current = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null;
6
+ let current = null;
7
+ try {
8
+ const adm = (ctx && typeof ctx.get === 'function' ? ctx.get('agentDefaultModel') : null) || (ctx && ctx.agentDefaultModel);
9
+ current = adm?.currentSelection?.() ?? null;
10
+ } catch {
11
+ current = null;
12
+ }
7
13
  let providers = [];
8
14
  try {
9
- if (ctx.llm && typeof ctx.llm.listProviders === 'function') {
10
- providers = (ctx.llm.listProviders() ?? []).map((p) => ({ id: p.id, name: p.name ?? p.id }));
15
+ const llm = (ctx && typeof ctx.get === 'function' ? ctx.get('llm') : null) || (ctx && ctx.llm);
16
+ if (llm && typeof llm.listProviders === 'function') {
17
+ providers = (llm.listProviders() ?? []).map((p) => ({ id: p.id, name: p.name ?? p.id }));
11
18
  }
12
19
  } catch {
13
20
  providers = [];
@@ -22,8 +29,9 @@ export async function getModelsHandler(ctx, req, res, sendJson) {
22
29
 
23
30
  try {
24
31
  let rows = [];
25
- if (ctx.llm && typeof ctx.llm.listModels === 'function') {
26
- rows = await ctx.llm.listModels(wanted);
32
+ const llm = (ctx && typeof ctx.get === 'function' ? ctx.get('llm') : null) || (ctx && ctx.llm);
33
+ if (llm && typeof llm.listModels === 'function') {
34
+ rows = await llm.listModels(wanted);
27
35
  }
28
36
  sendJson(res, 200, {
29
37
  current,
@@ -33,4 +41,4 @@ export async function getModelsHandler(ctx, req, res, sendJson) {
33
41
  } catch {
34
42
  sendJson(res, 200, { current, providers, models: [] });
35
43
  }
36
- }
44
+ }
package/lib/routes.js CHANGED
@@ -22,6 +22,80 @@ const LOCALES_JSON = readFileSync(new URL('./locales.json', import.meta.url), 'u
22
22
 
23
23
 
24
24
  export function registerRoutes(ctx, { store, scheduler, recommendations }) {
25
+ // Monthly history archives collection route (#391)
26
+ ctx.effect(() => ctx.webServer.register({
27
+ kind: 'exact',
28
+ path: '/dsh-cron/history/archives',
29
+ handler: (req, res) => {
30
+ if (req.method !== 'GET') {
31
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
32
+ res.end('GET only');
33
+ return;
34
+ }
35
+ res.writeHead(200, {
36
+ 'content-type': 'application/json; charset=utf-8',
37
+ 'cache-control': 'no-store',
38
+ });
39
+ const archives = typeof store?.listMonthlyArchives === 'function' ? store.listMonthlyArchives() : [];
40
+ res.end(JSON.stringify({ ok: true, archives }));
41
+ }
42
+ }), 'dsh-cron: /history/archives');
43
+
44
+ // Specific monthly history archive route (#391)
45
+ ctx.effect(() => ctx.webServer.register({
46
+ kind: 'prefix',
47
+ path: '/dsh-cron/history/archive/',
48
+ handler: (req, res) => {
49
+ if (req.method !== 'GET') {
50
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
51
+ res.end('GET only');
52
+ return;
53
+ }
54
+ const url = new URL(req.url, 'http://127.0.0.1');
55
+ const month = url.pathname.replace(/^\/dsh-cron\/history\/archive\//, '').replace(/\/$/, '');
56
+ if (!/^\d{4}-\d{2}$/.test(month)) {
57
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' });
58
+ res.end(JSON.stringify({ ok: false, error: 'Invalid month format, expected YYYY-MM' }));
59
+ return;
60
+ }
61
+ const archive = typeof store?.getMonthlyArchive === 'function' ? store.getMonthlyArchive(month) : null;
62
+ if (!archive) {
63
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
64
+ res.end(JSON.stringify({ ok: false, error: `Archive for month "${month}" not found` }));
65
+ return;
66
+ }
67
+ res.writeHead(200, {
68
+ 'content-type': 'application/json; charset=utf-8',
69
+ 'cache-control': 'public, max-age=3600',
70
+ });
71
+ res.end(JSON.stringify({ ok: true, month, archive }));
72
+ }
73
+ }), 'dsh-cron: /history/archive/:month');
74
+
75
+ // Health probe route (#384)
76
+ ctx.effect(() => ctx.webServer.register({
77
+ kind: 'exact',
78
+ path: '/dsh-cron/health',
79
+ handler: (req, res) => {
80
+ if (req.method !== 'GET') {
81
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
82
+ res.end('GET only');
83
+ return;
84
+ }
85
+ res.writeHead(200, {
86
+ 'content-type': 'application/json; charset=utf-8',
87
+ 'cache-control': 'no-store'
88
+ });
89
+ const tasks = typeof store?.list === 'function' ? store.list().length : 0;
90
+ res.end(JSON.stringify({
91
+ status: 'ok',
92
+ plugin: '@goodandready/dsh-cron',
93
+ tasks,
94
+ uptimeSec: Math.floor(process.uptime()),
95
+ }));
96
+ }
97
+ }), 'dsh-cron: /health');
98
+
25
99
  // Client locales route (#374)
26
100
  ctx.effect(() => ctx.webServer.register({
27
101
  kind: 'exact',
@@ -176,11 +176,22 @@ export async function runExternal(task, runtime, signal, execOptions = {}, { ctx
176
176
  return new Promise((resolve, reject) => {
177
177
  let child = null;
178
178
  let settled = false;
179
+ let timedOut = false;
179
180
  let stdout = '';
180
181
  let stderr = '';
181
- const maxBuffer = 10 * 1024 * 1024;
182
+ let truncated = false;
183
+ // Issue #393: maxLogBytesPerRun cap (default 5MB)
184
+ const maxLogBytes = Number(task.maxLogBytesPerRun) > 0 ? Number(task.maxLogBytesPerRun) : 5 * 1024 * 1024;
182
185
  const isPosix = process.platform !== 'win32';
183
186
 
187
+ // Issue #393: Execution timeout guardrail (default 15 minutes = 900,000ms, or task.executionTimeoutMs / timeoutSeconds)
188
+ const effectiveTimeoutMs = Number(task.executionTimeoutMs) > 0
189
+ ? Number(task.executionTimeoutMs)
190
+ : (Number(task.timeoutSeconds) > 0 ? Number(task.timeoutSeconds) * 1000 : 15 * 60 * 1000);
191
+
192
+ let timeoutTimer = null;
193
+ let forceKillTimer = null;
194
+
184
195
  const killProcessTree = (sig = 'SIGTERM') => {
185
196
  terminateProcessTree(child, sig, {
186
197
  isPosix,
@@ -190,13 +201,37 @@ export async function runExternal(task, runtime, signal, execOptions = {}, { ctx
190
201
  });
191
202
  };
192
203
 
204
+ const cleanupTimers = () => {
205
+ if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
206
+ if (forceKillTimer) { clearTimeout(forceKillTimer); forceKillTimer = null; }
207
+ };
208
+
209
+ if (effectiveTimeoutMs > 0) {
210
+ timeoutTimer = setTimeout(() => {
211
+ if (settled) return;
212
+ timedOut = true;
213
+ logger.warn(`[dsh-cron] task "${task.title || task.id}" timed out after ${effectiveTimeoutMs}ms, sending SIGTERM`);
214
+ killProcessTree('SIGTERM');
215
+ forceKillTimer = setTimeout(() => {
216
+ if (!settled) {
217
+ logger.warn(`[dsh-cron] task "${task.title || task.id}" force killing with SIGKILL`);
218
+ killProcessTree('SIGKILL');
219
+ }
220
+ }, 3000);
221
+ if (typeof forceKillTimer.unref === 'function') forceKillTimer.unref();
222
+ }, effectiveTimeoutMs);
223
+ if (typeof timeoutTimer.unref === 'function') timeoutTimer.unref();
224
+ }
225
+
193
226
  const abortHandler = () => {
227
+ cleanupTimers();
194
228
  killProcessTree('SIGTERM');
195
229
  setTimeout(() => killProcessTree('SIGKILL'), 300);
196
230
  };
197
231
 
198
232
  if (signal) {
199
233
  if (signal.aborted) {
234
+ cleanupTimers();
200
235
  return reject(signal.reason || new Error('Command aborted'));
201
236
  }
202
237
  signal.addEventListener('abort', abortHandler, { once: true });
@@ -218,23 +253,67 @@ export async function runExternal(task, runtime, signal, execOptions = {}, { ctx
218
253
  if (invocation.file) {
219
254
  child = spawn(invocation.file, invocation.args || [], options);
220
255
  } else {
256
+ let command = invocation.shellCommand;
257
+ if (isPosix && Number(task.maxMemoryMb) > 0) {
258
+ command = `ulimit -v ${Number(task.maxMemoryMb) * 1024} 2>/dev/null || true; ${command}`;
259
+ }
221
260
  const shellPath = isPosix ? (process.env.SHELL || '/bin/bash') : (process.env.ComSpec || 'cmd.exe');
222
- const shellArgs = isPosix ? ['-c', invocation.shellCommand] : ['/d', '/s', '/c', invocation.shellCommand];
261
+ const shellArgs = isPosix ? ['-c', command] : ['/d', '/s', '/c', command];
223
262
  child = spawn(shellPath, shellArgs, options);
224
263
  }
225
264
 
226
265
  child.stdout.on('data', (chunk) => {
227
- if (stdout.length < maxBuffer) stdout += chunk;
228
- logStreamer.appendLog(task.id, chunk.toString('utf-8'), 'stdout');
266
+ const remaining = maxLogBytes - (stdout.length + stderr.length);
267
+ const text = chunk.toString('utf-8');
268
+ if (remaining > 0) {
269
+ if (text.length <= remaining) {
270
+ stdout += text;
271
+ if (!truncated) logStreamer.appendLog(task.id, text, 'stdout');
272
+ } else {
273
+ stdout += text.slice(0, remaining);
274
+ if (!truncated) {
275
+ truncated = true;
276
+ const note = `\n[TRUNCATED: log output exceeded maxLogBytesPerRun (${Math.round(maxLogBytes / 1024 / 1024)}MB)]`;
277
+ stdout += note;
278
+ logStreamer.appendLog(task.id, text.slice(0, remaining) + note, 'stdout');
279
+ }
280
+ }
281
+ } else if (!truncated) {
282
+ truncated = true;
283
+ const note = `\n[TRUNCATED: log output exceeded maxLogBytesPerRun (${Math.round(maxLogBytes / 1024 / 1024)}MB)]`;
284
+ stdout += note;
285
+ logStreamer.appendLog(task.id, note, 'stdout');
286
+ }
229
287
  });
288
+
230
289
  child.stderr.on('data', (chunk) => {
231
- if (stderr.length < maxBuffer) stderr += chunk;
232
- logStreamer.appendLog(task.id, chunk.toString('utf-8'), 'stderr');
290
+ const remaining = maxLogBytes - (stdout.length + stderr.length);
291
+ const text = chunk.toString('utf-8');
292
+ if (remaining > 0) {
293
+ if (text.length <= remaining) {
294
+ stderr += text;
295
+ if (!truncated) logStreamer.appendLog(task.id, text, 'stderr');
296
+ } else {
297
+ stderr += text.slice(0, remaining);
298
+ if (!truncated) {
299
+ truncated = true;
300
+ const note = `\n[TRUNCATED: log output exceeded maxLogBytesPerRun (${Math.round(maxLogBytes / 1024 / 1024)}MB)]`;
301
+ stderr += note;
302
+ logStreamer.appendLog(task.id, text.slice(0, remaining) + note, 'stderr');
303
+ }
304
+ }
305
+ } else if (!truncated) {
306
+ truncated = true;
307
+ const note = `\n[TRUNCATED: log output exceeded maxLogBytesPerRun (${Math.round(maxLogBytes / 1024 / 1024)}MB)]`;
308
+ stderr += note;
309
+ logStreamer.appendLog(task.id, note, 'stderr');
310
+ }
233
311
  });
234
312
 
235
313
  child.on('error', (err) => {
236
314
  if (settled) return;
237
315
  settled = true;
316
+ cleanupTimers();
238
317
  if (signal) signal.removeEventListener('abort', abortHandler);
239
318
  logStreamer.completeStream(task.id, { status: 'error', error: err.message, exitCode: 1 });
240
319
  reject(err);
@@ -243,8 +322,14 @@ export async function runExternal(task, runtime, signal, execOptions = {}, { ctx
243
322
  child.on('close', (code, sig) => {
244
323
  if (settled) return;
245
324
  settled = true;
325
+ cleanupTimers();
246
326
  if (signal) signal.removeEventListener('abort', abortHandler);
247
327
 
328
+ if (timedOut) {
329
+ logStreamer.completeStream(task.id, { status: 'timeout', error: `Execution timed out after ${effectiveTimeoutMs}ms`, exitCode: 124 });
330
+ return reject(new Error(`Execution timed out after ${effectiveTimeoutMs}ms (SIGTERM/SIGKILL sent)`));
331
+ }
332
+
248
333
  if (signal && signal.aborted) {
249
334
  return reject(signal.reason || new Error('Command aborted'));
250
335
  }
package/lib/runner.js CHANGED
@@ -79,6 +79,40 @@ export class SessionRunner {
79
79
  this.store = store;
80
80
  }
81
81
 
82
+ _getAgentsService() {
83
+ if (!this.ctx) return null;
84
+ try {
85
+ if (typeof this.ctx.get === 'function') {
86
+ const svc = this.ctx.get('agents');
87
+ if (svc) return svc;
88
+ }
89
+ } catch (_err) {
90
+ // service unavailable via ctx.get
91
+ }
92
+ try {
93
+ return this.ctx.agents || null;
94
+ } catch (_err) {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ _getSessionService() {
100
+ if (!this.ctx) return null;
101
+ try {
102
+ if (typeof this.ctx.get === 'function') {
103
+ const svc = this.ctx.get('session');
104
+ if (svc) return svc;
105
+ }
106
+ } catch (_err) {
107
+ // service unavailable via ctx.get
108
+ }
109
+ try {
110
+ return this.ctx.session || null;
111
+ } catch (_err) {
112
+ return null;
113
+ }
114
+ }
115
+
82
116
  async execute(task, options = {}) {
83
117
  const timeoutSeconds = Number(task.timeoutSeconds) > 0 ? Number(task.timeoutSeconds) : 1800;
84
118
  const timeoutMs = timeoutSeconds * 1000;
@@ -234,12 +268,14 @@ export class SessionRunner {
234
268
  }
235
269
 
236
270
  // 2. Agent session, when the harness exposes one
237
- if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
271
+ const agentsSvc = this._getAgentsService();
272
+ if (agentsSvc && typeof agentsSvc.create === 'function') {
238
273
  return this._runAgentSession(effectiveTask, runtime, signal, options);
239
274
  }
240
275
 
241
276
  // 3. Legacy session service
242
- if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
277
+ const sessionSvc = this._getSessionService();
278
+ if (sessionSvc && typeof sessionSvc.create === 'function') {
243
279
  return this._runLegacySession(effectiveTask, options);
244
280
  }
245
281
 
@@ -270,12 +306,14 @@ export class SessionRunner {
270
306
  }
271
307
 
272
308
  // 2. Agent session, when the harness exposes one
273
- if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
309
+ const agentsSvc2 = this._getAgentsService();
310
+ if (agentsSvc2 && typeof agentsSvc2.create === 'function') {
274
311
  return this._runAgentSession(task, runtime, signal, options);
275
312
  }
276
313
 
277
314
  // 3. Legacy session service
278
- if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
315
+ const sessionSvc2 = this._getSessionService();
316
+ if (sessionSvc2 && typeof sessionSvc2.create === 'function') {
279
317
  return this._runLegacySession(task, options);
280
318
  }
281
319
 
@@ -484,9 +522,10 @@ export class SessionRunner {
484
522
 
485
523
  // If targetSessionId is set, attempt to resume the existing session (#143, #346, GH #9)
486
524
  let resumeFailure = null;
487
- if (prep.targetSessionId && this.ctx.agents && typeof this.ctx.agents.resume === 'function') {
525
+ const agents = this._getAgentsService();
526
+ if (prep.targetSessionId && agents && typeof agents.resume === 'function') {
488
527
  try {
489
- handle = await this.ctx.agents.resume({
528
+ handle = await agents.resume({
490
529
  sessionId: prep.targetSessionId,
491
530
  resumeSessionId: prep.targetSessionId,
492
531
  meta: {
@@ -508,7 +547,7 @@ export class SessionRunner {
508
547
  // Create new session if not resumed
509
548
  if (!handle) {
510
549
  try {
511
- handle = await this.ctx.agents.create({
550
+ handle = await agents.create({
512
551
  sessionId: prep.mintSessionId(),
513
552
  meta: {
514
553
  cwd: prep.cwd,
package/lib/runtimes.js CHANGED
@@ -48,12 +48,16 @@ function looksLikeScriptFile(token, extensions) {
48
48
  export function buildNodeInvocation(task, cwd) {
49
49
  const nodeExecutable = task.nodePath || process.execPath;
50
50
  const { path: filePath, args } = splitCommandLine(task.prompt);
51
+ const flags = [];
52
+ if (Number(task.maxMemoryMb) > 0) {
53
+ flags.push(`--max-old-space-size=${Number(task.maxMemoryMb)}`);
54
+ }
51
55
  if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
52
- return { file: nodeExecutable, args: [filePath, ...args] };
56
+ return { file: nodeExecutable, args: [...flags, filePath, ...args] };
53
57
  }
54
58
  const code = String(task.prompt || '');
55
59
  const isEsm = /\bimport\s|\bexport\s/.test(code);
56
- const nodeArgs = isEsm ? ['--input-type=module', '-e', code] : ['-e', code];
60
+ const nodeArgs = isEsm ? [...flags, '--input-type=module', '-e', code] : [...flags, '-e', code];
57
61
  return { file: nodeExecutable, args: nodeArgs };
58
62
  }
59
63
 
@@ -147,6 +151,8 @@ export function buildDockerInvocation(task, explicitEnv) {
147
151
  if (value === null || value === undefined) continue;
148
152
  args.push('-e', `${key}=${value}`);
149
153
  }
154
+ if (Number(task.maxMemoryMb) > 0) args.push(`--memory=${Number(task.maxMemoryMb)}m`);
155
+ if (Number(task.cpus) > 0) args.push(`--cpus=${Number(task.cpus)}`);
150
156
  if (task.cwd) args.push('-v', `${task.cwd}:/workspace`, '-w', '/workspace');
151
157
  args.push(image, 'sh', '-lc', String(task.prompt || 'true'));
152
158
  return { file: 'docker', args, env: envMap };
package/lib/store.js CHANGED
@@ -4,6 +4,7 @@ import { ArtifactManager } from './artifacts.js';
4
4
  import { logger } from './logger.js';
5
5
  import { bestEffort } from './best-effort.js';
6
6
  import fs from 'node:fs';
7
+ import { gzipSync, gunzipSync } from 'node:zlib';
7
8
  import { pipeline } from 'node:stream/promises';
8
9
  import path from 'node:path';
9
10
  import { getDshDefaultTelegramCredentials } from './telegram.js';
@@ -17,6 +18,36 @@ import { getDshDefaultTelegramCredentials } from './telegram.js';
17
18
  * home's data directory, which is how an isolated test cycle once leaked a
18
19
  * task into the wrong profile.
19
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
+
20
51
  export function getDefaultStorePath() {
21
52
  const base = process.env.DSH_DATA_DIR
22
53
  || (process.env.DSH_HOME
@@ -64,7 +95,21 @@ export class TaskStore {
64
95
  const bakPath = `${this.filePath}.bak`;
65
96
 
66
97
  const loadData = (raw) => {
67
- const data = JSON.parse(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
+ }
68
113
  this.tasks.clear();
69
114
  this.history.clear();
70
115
  if (Array.isArray(data.tasks)) {
@@ -151,6 +196,11 @@ export class TaskStore {
151
196
  logger.error('[dsh-cron] failed to restore tasks store from .bak:', bakErr.message);
152
197
  }
153
198
  }
199
+ if (this._needsMigrationFlush) {
200
+ this._needsMigrationFlush = false;
201
+ this.scheduleSave(100);
202
+ }
203
+ bestEffort('prune-old-archives', () => this._pruneOldMonthlyArchives(dir, 6));
154
204
  }
155
205
 
156
206
  /**
@@ -201,6 +251,7 @@ export class TaskStore {
201
251
  }
202
252
  return {
203
253
  version: 1,
254
+ schemaVersion: CURRENT_SCHEMA_VERSION,
204
255
  updatedAt: new Date().toISOString(),
205
256
  tasks: tasksSnapshot,
206
257
  history: historySnapshot,
@@ -358,6 +409,7 @@ export class TaskStore {
358
409
  }
359
410
 
360
411
  yield '{"version":' + JSON.stringify(data.version || 1);
412
+ yield ',"schemaVersion":' + JSON.stringify(data.schemaVersion || CURRENT_SCHEMA_VERSION);
361
413
  yield ',"updatedAt":' + JSON.stringify(data.updatedAt || new Date().toISOString());
362
414
  yield ',"settings":' + JSON.stringify(data.settings || {});
363
415
 
@@ -651,6 +703,11 @@ export class TaskStore {
651
703
  ? (task.onlyOnFailure === null || task.onlyOnFailure === 'inherit' ? undefined : Boolean(task.onlyOnFailure))
652
704
  : prev.onlyOnFailure,
653
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),
654
711
  overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
655
712
  kanbanMode: task.kanbanMode !== undefined ? String(task.kanbanMode) : (prev.kanbanMode || 'none'), // 'none' | 'on_failure' | 'always'
656
713
  onSuccess: task.onSuccess !== undefined ? String(task.onSuccess).trim() : (prev.onSuccess || ''),
@@ -922,11 +979,124 @@ export class TaskStore {
922
979
  bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
923
980
  fs.renameSync(tmp, archivePath);
924
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);
925
1024
  } catch (archiveErr) {
926
1025
  logger.error('[dsh-cron] history archive error:', archiveErr.message);
927
1026
  }
928
1027
  }
929
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
+
930
1100
  getHistory(id, limit = 20) {
931
1101
  const runs = this.history.get(id) || [];
932
1102
  return runs.slice(0, limit);
@@ -1,3 +1,4 @@
1
+ import { validateTaskRecord } from './task-schema.js';
1
2
  import { sanitizeSwarmConfig } from './swarm.js';
2
3
  import { sanitizeBranchRules } from './branching.js';
3
4
  import { sanitizePromptHistory } from './prompt-evolver.js';
@@ -132,7 +133,8 @@ export function executeCreateTask(store, scheduler, args) {
132
133
  throw new Error(dagCheck.error);
133
134
  }
134
135
 
135
- const task = store.set(taskData);
136
+ const validTask = validateTaskRecord(taskData);
137
+ const task = store.set(validTask);
136
138
  scheduler.scheduleTask(task);
137
139
  return {
138
140
  success: true,
@@ -0,0 +1,180 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { normalizeTaskType } from './runtimes.js';
3
+ import { validateTaskType, PATCHABLE_TASK_FIELDS } from './task-transfer.js';
4
+ import { CHANNEL_IDS } from './channels.js';
5
+
6
+ export const TaskSchema = z.object({
7
+ id: z.string(),
8
+ title: z.string().required(),
9
+ schedule: z.string().required(),
10
+ scheduleText: z.string(),
11
+ type: z.string().default('llm'),
12
+ prompt: z.string().default(''),
13
+ delivery: z.string().default('isolated'),
14
+ status: z.string().default('active'),
15
+ provider: z.string(),
16
+ model: z.string(),
17
+ fallbackProvider: z.string(),
18
+ fallbackModel: z.string(),
19
+ silentRule: z.string().default(''),
20
+ inspectOnFailure: z.boolean().default(false),
21
+ agentPreset: z.string().default(''),
22
+ targetSessionId: z.string().default(''),
23
+ targetSessionReset: z.string().default('never'),
24
+ sessionTitle: z.string().default(''),
25
+ timezone: z.string().default(''),
26
+ misfirePolicy: z.string().default('skip'),
27
+ maxRetries: z.number().default(0),
28
+ retryBackoffMs: z.number().default(30000),
29
+ permissionPreset: z.string().default('default'),
30
+ captureGitDiff: z.boolean(),
31
+ kanbanMode: z.string().default('none'),
32
+ channels: z.array(z.string()).default([]),
33
+ template: z.string().default(''),
34
+ env: z.any(),
35
+ cwd: z.string().default(''),
36
+ workspaceId: z.string().default(''),
37
+ worktree: z.boolean().default(false),
38
+ keepWorktree: z.boolean().default(false),
39
+ httpMethod: z.string().default('GET'),
40
+ httpUrl: z.string().default(''),
41
+ httpHeaders: z.any(),
42
+ httpBody: z.string().default(''),
43
+ sshProfileId: z.string().default(''),
44
+ sshTarget: z.string().default(''),
45
+ sshPort: z.number().default(0),
46
+ sshKeyPath: z.string().default(''),
47
+ dockerImage: z.string().default(''),
48
+ pythonPath: z.string().default(''),
49
+ nodePath: z.string().default(''),
50
+ skillName: z.string().default(''),
51
+ workflowName: z.string().default(''),
52
+ webhookEnabled: z.boolean().default(false),
53
+ webhookToken: z.string(),
54
+ webhookSecret: z.string(),
55
+ webhookDebounceMs: z.number(),
56
+ webhookTrigger: z.any(),
57
+ onSuccess: z.string().default(''),
58
+ onFailure: z.string().default(''),
59
+ heartbeatIntervalSeconds: z.number().default(0),
60
+ gracePeriodSeconds: z.number().default(300),
61
+ preflightType: z.string().default('none'),
62
+ preflightTarget: z.string().default(''),
63
+ preflightCommand: z.string(),
64
+ preflightScript: z.string(),
65
+ concurrencyGroup: z.string().default('default'),
66
+ priority: z.number().default(5),
67
+ selfHealingCommand: z.string().default(''),
68
+ autoDiagnose: z.boolean().default(false),
69
+ notifyTelegram: z.boolean(),
70
+ onlyOnFailure: z.boolean(),
71
+ timeoutSeconds: z.number().default(1800),
72
+ overlapPolicy: z.string().default('skip'),
73
+ oneShot: z.boolean().default(false),
74
+ targetTimestamp: z.number(),
75
+ nextRunAt: z.number(),
76
+ costLimitUsd: z.number(),
77
+ dailyCostLimitUsd: z.number(),
78
+ tokenLimit: z.number(),
79
+ pausedReason: z.string(),
80
+ validators: z.any(),
81
+ requireApproval: z.boolean().default(false),
82
+ fileWatch: z.any(),
83
+ dependsOn: z.array(z.string()).default([]),
84
+ joinPolicy: z.string().default('all_success'),
85
+ adaptive: z.any(),
86
+ adaptiveState: z.any(),
87
+ promptVersion: z.number(),
88
+ promptHistory: z.any(),
89
+ branchRules: z.any(),
90
+ elseTask: z.string(),
91
+ autoHeal: z.boolean(),
92
+ selfHealing: z.any(),
93
+ revisions: z.any(),
94
+ previousPrompt: z.string(),
95
+ previousScript: z.string(),
96
+ swarm: z.any(),
97
+ gitDiff: z.any(),
98
+ maxMemoryMb: z.number().min(0).default(0),
99
+ executionTimeoutMs: z.number().min(0).default(900000),
100
+ maxLogBytesPerRun: z.number().min(0).default(5 * 1024 * 1024),
101
+ cpus: z.number().min(0).default(0),
102
+ maxArchiveMonths: z.number().min(1).default(6),
103
+ schemaVersion: z.number().default(2),
104
+ createdAt: z.number(),
105
+ updatedAt: z.number(),
106
+ });
107
+
108
+ export function validateTaskRecord(data) {
109
+ if (!data || typeof data !== 'object') {
110
+ throw new TypeError('Task record must be an object');
111
+ }
112
+ if (data.title !== undefined && typeof data.title !== 'string') {
113
+ throw new Error('Task schema validation failed: title must be a string');
114
+ }
115
+ const title = String(data.title || '').trim();
116
+ if (!title) {
117
+ throw new Error('Task title cannot be empty');
118
+ }
119
+ if (data.schedule !== undefined && typeof data.schedule !== 'string') {
120
+ throw new Error('Task schema validation failed: schedule must be a string');
121
+ }
122
+ const schedule = String(data.schedule || '').trim();
123
+ if (!schedule) {
124
+ throw new Error('Task schedule cannot be empty');
125
+ }
126
+
127
+ const cleanType = normalizeTaskType(data.type);
128
+ const typeError = validateTaskType(cleanType, data);
129
+ if (typeError) {
130
+ throw new Error(typeError);
131
+ }
132
+
133
+ let validated;
134
+ try {
135
+ validated = TaskSchema({
136
+ ...data,
137
+ title,
138
+ schedule,
139
+ type: cleanType,
140
+ });
141
+ } catch (err) {
142
+ throw new Error(`Task schema validation failed: ${err.message}`);
143
+ }
144
+
145
+ // Filter channels to known channel ids if present
146
+ if (Array.isArray(validated.channels)) {
147
+ validated.channels = validated.channels.filter((c) => CHANNEL_IDS.includes(c));
148
+ }
149
+
150
+ // Strip undefined keys
151
+ for (const k of Object.keys(validated)) {
152
+ if (validated[k] === undefined) {
153
+ delete validated[k];
154
+ }
155
+ }
156
+
157
+ return validated;
158
+ }
159
+
160
+ export function safeValidateTaskRecord(data) {
161
+ try {
162
+ const task = validateTaskRecord(data);
163
+ return { ok: true, task };
164
+ } catch (err) {
165
+ return { ok: false, error: err.message };
166
+ }
167
+ }
168
+
169
+ export function validateTaskPatch(patch) {
170
+ if (!patch || typeof patch !== 'object') {
171
+ return { ok: false, error: 'Task patch must be an object' };
172
+ }
173
+ const clean = {};
174
+ for (const [key, value] of Object.entries(patch)) {
175
+ if (PATCHABLE_TASK_FIELDS.includes(key)) {
176
+ clean[key] = value;
177
+ }
178
+ }
179
+ return { ok: true, patch: clean };
180
+ }
@@ -98,6 +98,11 @@ export const PATCHABLE_TASK_FIELDS = [
98
98
  'previousPrompt',
99
99
  'previousScript',
100
100
  'swarm',
101
+ 'maxMemoryMb',
102
+ 'executionTimeoutMs',
103
+ 'maxLogBytesPerRun',
104
+ 'cpus',
105
+ 'maxArchiveMonths',
101
106
  'gitDiff',
102
107
  ];
103
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -22,7 +22,8 @@
22
22
  "test": "node --test test/*.test.mjs && node scripts/check-pack-size.mjs",
23
23
  "build:client": "node scripts/build-client.mjs",
24
24
  "pretest": "node scripts/build-client.mjs",
25
- "preflight": "node scripts/ci-preflight.mjs"
25
+ "preflight": "node scripts/ci-preflight.mjs",
26
+ "test:engine": "node scripts/test-engines.mjs"
26
27
  },
27
28
  "keywords": [
28
29
  "dsh",
@@ -51,6 +52,14 @@
51
52
  "@deepseek-ai/dsh-client-locale",
52
53
  "@deepseek-ai/dsh-client-ui-slots"
53
54
  ]
55
+ },
56
+ "compatibility": {
57
+ "dshReleases": {
58
+ "0.2.1-rc.2": "compatible",
59
+ "0.2.1-rc.3": "compatible",
60
+ "0.2.1-rc.4": "compatible"
61
+ },
62
+ "dsh": ">=0.2.1-rc.2 <0.2.2-0"
54
63
  }
55
64
  },
56
65
  "dependencies": {