@goodandready/dsh-cron 0.1.24 → 0.2.1

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/index.js CHANGED
@@ -8,126 +8,131 @@ import { createKanbanCard } from './integrations.js';
8
8
  import { defineTool } from '@deepseek-ai/dsh-tools';
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import z from '@deepseek-ai/schemastery';
11
+ import {
12
+ parseJsonBody,
13
+ isCrossOrigin,
14
+ pickPatchableFields,
15
+ SCRIPT_CONFIRM_HEADER,
16
+ } from './http-utils.js';
11
17
 
12
18
  export const name = '@goodandready/dsh-cron';
13
19
  export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel'];
14
20
 
15
21
  export const Config = z.object({
16
- botToken: z.string().role('secret').default('').description('Telegram Bot Token для отправки отчетов'),
17
- chatId: z.string().default('').description('Telegram Chat ID получателя'),
18
- notifyTelegram: z.boolean().default(false).description('Включить глобальную отправку отчетов в Telegram'),
19
- onlyOnFailure: z.boolean().default(false).description('Отправлять отчеты в Telegram только при ошибках'),
20
- kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Базовый URL Kanban API'),
22
+ botToken: z.string().role('secret').default('').description('Telegram Bot API token used to deliver task reports'),
23
+ chatId: z.string().default('').description('Telegram chat ID that receives task reports'),
24
+ notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
25
+ onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
26
+ kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API'),
21
27
  });
22
28
 
23
- function parseJsonBody(req) {
24
- return new Promise((resolve, reject) => {
25
- let data = '';
26
- req.on('data', (chunk) => { data += chunk; });
27
- req.on('end', () => {
28
- try {
29
- resolve(data ? JSON.parse(data) : {});
30
- } catch (err) {
31
- reject(err);
32
- }
33
- });
34
- req.on('error', reject);
35
- });
36
- }
29
+ const PATCHABLE_TASK_FIELDS = [
30
+ 'title',
31
+ 'schedule',
32
+ 'scheduleText',
33
+ 'prompt',
34
+ 'type',
35
+ 'delivery',
36
+ 'provider',
37
+ 'model',
38
+ 'notifyTelegram',
39
+ 'onlyOnFailure',
40
+ 'timeoutSeconds',
41
+ 'overlapPolicy',
42
+ 'kanbanMode',
43
+ 'status',
44
+ 'oneShot',
45
+ ];
37
46
 
38
47
  function sendJson(res, statusCode, data) {
39
48
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
40
49
  res.end(JSON.stringify(data));
41
50
  }
42
51
 
43
- export function apply(ctx, config) {
44
- const store = new TaskStore();
45
- const runner = new SessionRunner(ctx);
46
- const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts));
52
+ function rejectCrossOrigin(req, res) {
53
+ if (isCrossOrigin(req)) {
54
+ sendJson(res, 403, { ok: false, error: 'Cross-origin request rejected' });
55
+ return true;
56
+ }
57
+ return false;
58
+ }
47
59
 
48
- // Register settings in Harness settings service with Config schema and initial state from store (#71)
49
- ctx.inject(['settings'], (sctx) => {
60
+ async function readBody(req, res) {
61
+ try {
62
+ return { body: await parseJsonBody(req) };
63
+ } catch (err) {
64
+ sendJson(res, err.statusCode || 400, { ok: false, error: err.message });
65
+ return { error: true };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Apply settings writes through the registered settings scope so the core
71
+ * snapshot stays authoritative (#102). Returns null when the settings
72
+ * service is unavailable (headless profiles fall back to store-only writes).
73
+ */
74
+ async function applySettingsToScope(settingsScope, body) {
75
+ if (!settingsScope || typeof settingsScope.set !== 'function') return null;
76
+ const errors = [];
77
+ for (const key of ['botToken', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl']) {
78
+ if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
50
79
  try {
51
- const scope = sctx.settings?.register?.('dsh-cron', Config, {
52
- base: store.getSettings()
53
- });
54
- if (scope && typeof scope.watch === 'function') {
55
- scope.watch((updated) => {
56
- if (updated && typeof updated === 'object') {
57
- store.saveSettings(updated);
58
- }
59
- });
60
- }
80
+ await settingsScope.set(key, body[key]);
61
81
  } catch (e) {
62
- console.warn('[dsh-cron] settings registration warning:', e.message);
63
- }
64
- });
65
-
66
- scheduler.start();
67
-
68
- ctx.effect(() => {
69
- return () => {
70
- scheduler.stopAll();
71
- };
72
- }, 'dsh-cron: lifecycle');
73
-
74
- const recommendations = [
75
- {
76
- id: 'rec_daily_digest',
77
- title: 'Ежедневная сводка',
78
- schedule: '0 8 * * 1-5',
79
- scheduleText: 'В будние дни в 8:00',
80
- prompt: 'Сделай краткую утреннюю сводку: проверь список активных задач, открытые тикеты в Gitea и наметь ключевые приоритеты на сегодня.',
81
- description: 'Начинайте каждый будний день со сводки календаря, непрочитанных писем и приоритетов',
82
- icon: 'bell'
83
- },
84
- {
85
- id: 'rec_weekly_review',
86
- title: 'Еженедельный обзор',
87
- schedule: '0 16 * * 5',
88
- scheduleText: 'Пятницы в 16:00',
89
- prompt: 'Подготовь еженедельный отчет о проделанной работе: список закрытых задач, созданных PR и актуальный статус текущих проектов.',
90
- description: 'Каждую пятницу создавайте краткий отчет о проделанной работе',
91
- icon: 'clipboard'
92
- },
93
- {
94
- id: 'rec_monitor_status',
95
- title: 'Мониторинг дальнейших действий',
96
- schedule: '0 9 * * 1-5',
97
- scheduleText: 'В будние дни в 9:00',
98
- prompt: 'Проверь недавнюю активность в проектах, статус сборок и отметь всё, что требует внимания разработчика.',
99
- description: 'Проверяйте недавнюю активность в проектах и отмечайте всё, что требует вашего внимания',
100
- icon: 'activity'
82
+ errors.push(key + ': ' + ((e && e.message) || e));
101
83
  }
102
- ];
84
+ }
85
+ return errors;
86
+ }
103
87
 
104
- // 1. REST API
105
- // GET & POST /dsh-cron/tasks
106
- ctx.effect(() => ctx.webServer.register({
107
- kind: 'exact',
108
- path: '/dsh-cron/tasks',
109
- handler: async (req, res) => {
110
- try {
88
+ /**
89
+ * Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
90
+ * route families — the #66 alias is kept alive by the same code path instead
91
+ * of a duplicated handler.
92
+ *
93
+ * IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
94
+ * with one, the core degrades matching to exact-path equality and every
95
+ * deeper path unrouted (#96).
96
+ */
97
+ function createCronApiHandler(store, scheduler, collection) {
98
+ return async function handleCronApi(req, res) {
99
+ try {
100
+ const url = new URL(req.url, 'http://127.0.0.1');
101
+ const parts = url.pathname.split('/').filter(Boolean);
102
+ const scope = parts[1]; // 'tasks' | 'action'
103
+ const id = parts[2];
104
+ const action = parts[3];
105
+
106
+ // Collection level: /dsh-cron/tasks (list + create)
107
+ if (!id) {
108
+ if (scope === 'action' || !collection) {
109
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
110
+ return;
111
+ }
111
112
  if (req.method === 'GET') {
112
- const url = new URL(req.url, 'http://127.0.0.1');
113
113
  const status = url.searchParams.get('status') || 'all';
114
114
  const query = url.searchParams.get('query') || '';
115
115
  const list = store.list({ status, query });
116
116
  const stats = store.getAggregatedStats();
117
- sendJson(res, 200, { ok: true, tasks: list, recommendations, stats });
117
+ sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
118
118
  return;
119
119
  }
120
-
121
120
  if (req.method === 'POST') {
122
- const body = await parseJsonBody(req);
121
+ if (rejectCrossOrigin(req, res)) return;
122
+ const { body, error } = await readBody(req, res);
123
+ if (error) return;
123
124
  if (!body.title || !body.schedule || !body.prompt) {
124
- sendJson(res, 400, { ok: false, error: 'Поля title, schedule и prompt обязательны' });
125
+ sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
126
+ return;
127
+ }
128
+ const taskType = body.type === 'script' ? 'script' : 'llm';
129
+ if (taskType === 'script' && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
130
+ sendJson(res, 403, { ok: false, error: 'Creating script tasks over HTTP requires the x-dsh-cron-confirm: script header' });
125
131
  return;
126
132
  }
127
133
  const parsed = parseScheduleExpression(body.schedule);
128
134
  const current = body.id ? store.get(body.id) : null;
129
135
  const resolvedStatus = body.status || (current ? current.status : 'active');
130
- const taskType = body.type === 'script' ? 'script' : 'llm';
131
136
 
132
137
  const task = store.set({
133
138
  id: body.id,
@@ -155,13 +160,242 @@ export function apply(ctx, config) {
155
160
  sendJson(res, 200, { ok: true, task });
156
161
  return;
157
162
  }
158
-
159
163
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
160
- } catch (err) {
161
- sendJson(res, 500, { ok: false, error: err.message });
164
+ return;
162
165
  }
166
+
167
+ // Item level: /dsh-cron/{tasks|action}/:id[/:action]
168
+ if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
169
+
170
+ if (req.method === 'GET' && (action === 'history' || !action)) {
171
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
172
+ sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
173
+ return;
174
+ }
175
+
176
+ if (req.method === 'POST' && action === 'run') {
177
+ await scheduler.triggerManualRun(id);
178
+ sendJson(res, 200, { ok: true, task: store.get(id) });
179
+ return;
180
+ }
181
+
182
+ if (req.method === 'POST' && action === 'pause') {
183
+ const task = scheduler.pauseTask(id);
184
+ if (!task) {
185
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
186
+ return;
187
+ }
188
+ sendJson(res, 200, { ok: true, task });
189
+ return;
190
+ }
191
+
192
+ if (req.method === 'POST' && action === 'resume') {
193
+ const task = scheduler.resumeTask(id);
194
+ if (!task) {
195
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
196
+ return;
197
+ }
198
+ sendJson(res, 200, { ok: true, task });
199
+ return;
200
+ }
201
+
202
+ if (req.method === 'POST' && (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle'))) {
203
+ const current = store.get(id);
204
+ if (!current) {
205
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
206
+ return;
207
+ }
208
+ const task = scheduler.toggleTask(id);
209
+ sendJson(res, 200, { ok: true, task });
210
+ return;
211
+ }
212
+
213
+ if (req.method === 'PATCH') {
214
+ const current = store.get(id);
215
+ if (!current) {
216
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
217
+ return;
218
+ }
219
+ const { body, error } = await readBody(req, res);
220
+ if (error) return;
221
+ // Whitelist patchable fields so stats/timestamps stay server-owned (#90)
222
+ const patchData = pickPatchableFields(body, PATCHABLE_TASK_FIELDS);
223
+ if (patchData.type === 'script' && current.type !== 'script' && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
224
+ sendJson(res, 403, { ok: false, error: 'Switching a task to the script type over HTTP requires the x-dsh-cron-confirm: script header' });
225
+ return;
226
+ }
227
+ if (patchData.schedule) {
228
+ const parsed = parseScheduleExpression(patchData.schedule);
229
+ patchData.schedule = parsed.cronPattern || patchData.schedule;
230
+ patchData.scheduleText = body.scheduleText || parsed.humanText;
231
+ patchData.oneShot = Boolean(parsed.isOneShot || body.oneShot);
232
+ }
233
+ const task = store.set({ ...current, ...patchData, id });
234
+ if (task.status === 'active') {
235
+ scheduler.scheduleTask(task);
236
+ } else {
237
+ scheduler.pauseTask(id);
238
+ }
239
+ sendJson(res, 200, { ok: true, task });
240
+ return;
241
+ }
242
+
243
+ if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
244
+ const current = store.get(id);
245
+ if (current) {
246
+ scheduler.pauseTask(id);
247
+ store.delete(id);
248
+ }
249
+ sendJson(res, 200, { ok: true });
250
+ return;
251
+ }
252
+
253
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
254
+ } catch (err) {
255
+ sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
256
+ }
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Single implementation behind the cron_create_task / cron_schedule_task
262
+ * alias pair (#70, #92).
263
+ */
264
+ function executeCreateTask(store, scheduler, args) {
265
+ const taskType = args.type === 'script' ? 'script' : 'llm';
266
+ const parsed = parseScheduleExpression(args.schedule);
267
+ const task = store.set({
268
+ title: args.title,
269
+ schedule: parsed.cronPattern || args.schedule,
270
+ scheduleText: parsed.humanText,
271
+ prompt: args.prompt,
272
+ type: taskType,
273
+ delivery: args.delivery || 'isolated',
274
+ status: 'active',
275
+ provider: args.provider,
276
+ model: args.model,
277
+ notifyTelegram: Boolean(args.notifyTelegram),
278
+ onlyOnFailure: Boolean(args.onlyOnFailure),
279
+ timeoutSeconds: Number(args.timeoutSeconds) || 1800,
280
+ overlapPolicy: args.overlapPolicy || 'skip',
281
+ kanbanMode: args.kanbanMode || 'none',
282
+ oneShot: Boolean(parsed.isOneShot),
283
+ });
284
+ scheduler.scheduleTask(task);
285
+ return {
286
+ success: true,
287
+ message: `Task "${task.title}" scheduled successfully (${task.scheduleText})`,
288
+ task
289
+ };
290
+ }
291
+
292
+ const createTaskParameters = {
293
+ title: { type: 'string', description: 'Short task name', required: true },
294
+ schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
295
+ prompt: { type: 'string', description: 'Prompt/instruction for the agent at run time', required: true },
296
+ type: { type: 'string', enum: ['prompt', 'script'], description: 'Task type' },
297
+ delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
298
+ provider: { type: 'string', description: 'Model provider (optional)' },
299
+ model: { type: 'string', description: 'Model (optional)' },
300
+ notifyTelegram: { type: 'boolean', description: 'Send the run report to Telegram' },
301
+ onlyOnFailure: { type: 'boolean', description: 'Send reports only for failures' },
302
+ timeoutSeconds: { type: 'number', description: 'Execution time limit in seconds' },
303
+ overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Overlap policy' },
304
+ kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Create a dsh-kanban card for runs' },
305
+ };
306
+
307
+ const createTaskOutput = {
308
+ schema: {
309
+ type: 'object',
310
+ additionalProperties: true,
311
+ properties: {
312
+ success: { type: 'boolean' },
313
+ message: { type: 'string' },
314
+ task: { type: 'object', additionalProperties: true }
315
+ }
316
+ },
317
+ render: (_args, val) => [{ type: 'text', text: val.message || JSON.stringify(val) }]
318
+ };
319
+
320
+ export function apply(ctx, config) {
321
+ const store = new TaskStore();
322
+ const runner = new SessionRunner(ctx);
323
+ const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts));
324
+
325
+ // Register settings in the Harness settings service (#71, #102): the scope
326
+ // is the source of truth for the core, scope.watch mirrors values into the
327
+ // plugin store, and REST writes go through scope.set so nothing bypasses
328
+ // the settings service.
329
+ let cronSettingsScope = null;
330
+ ctx.inject(['settings'], (sctx) => {
331
+ try {
332
+ cronSettingsScope = sctx.settings.register('dsh-cron', Config, {
333
+ base: store.getSettings()
334
+ });
335
+ if (cronSettingsScope && typeof cronSettingsScope.watch === 'function') {
336
+ cronSettingsScope.watch((updated) => {
337
+ if (updated && typeof updated === 'object') {
338
+ store.saveSettings(updated);
339
+ }
340
+ });
341
+ }
342
+ } catch (e) {
343
+ console.warn('[dsh-cron] settings registration warning:', e.message);
344
+ }
345
+ });
346
+
347
+ scheduler.start();
348
+
349
+ ctx.effect(() => {
350
+ return () => {
351
+ scheduler.stopAll();
352
+ };
353
+ }, 'dsh-cron: lifecycle');
354
+
355
+ const recommendations = [
356
+ {
357
+ id: 'rec_daily_digest',
358
+ title: 'Daily digest',
359
+ schedule: '0 8 * * 1-5',
360
+ scheduleText: 'Weekdays at 08:00',
361
+ prompt: 'Prepare a brief morning digest: review the list of active tasks, open Gitea tickets and outline the key priorities for today.',
362
+ description: 'Start every weekday with a summary of calendar, unread mail and priorities',
363
+ icon: 'bell'
364
+ },
365
+ {
366
+ id: 'rec_weekly_review',
367
+ title: 'Weekly review',
368
+ schedule: '0 16 * * 5',
369
+ scheduleText: 'Fridays at 16:00',
370
+ prompt: 'Prepare a weekly report on the work done: the list of closed tasks, created PRs and the current status of ongoing projects.',
371
+ description: 'Every Friday create a short report on the completed work',
372
+ icon: 'clipboard'
373
+ },
374
+ {
375
+ id: 'rec_monitor_status',
376
+ title: 'Follow-up monitor',
377
+ schedule: '0 9 * * 1-5',
378
+ scheduleText: 'Weekdays at 09:00',
379
+ prompt: 'Review recent project activity, build statuses and flag everything that requires developer attention.',
380
+ description: 'Review recent project activity and flag anything that needs your attention',
381
+ icon: 'activity'
163
382
  }
164
- }), 'dsh-cron: /tasks');
383
+ ];
384
+
385
+ // 1. REST API — collection (list/create) + item actions in one prefix
386
+ // handler per route family; registered WITHOUT the trailing slash (#96)
387
+ ctx.effect(() => ctx.webServer.register({
388
+ kind: 'prefix',
389
+ path: '/dsh-cron/tasks',
390
+ handler: createCronApiHandler(store, scheduler, { recommendations })
391
+ }), 'dsh-cron: /tasks[...]');
392
+
393
+ // Alias route /dsh-cron/action/:id/:action for backward compatibility (#66)
394
+ ctx.effect(() => ctx.webServer.register({
395
+ kind: 'prefix',
396
+ path: '/dsh-cron/action',
397
+ handler: createCronApiHandler(store, scheduler, null)
398
+ }), 'dsh-cron: /action/:id/:action');
165
399
 
166
400
  // GET /dsh-cron/models
167
401
  ctx.effect(() => ctx.webServer.register({
@@ -177,6 +411,7 @@ export function apply(ctx, config) {
177
411
  kind: 'exact',
178
412
  path: '/dsh-cron/chat/start',
179
413
  handler: async (req, res) => {
414
+ if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
180
415
  await chatStartHandler(ctx, req, res, parseJsonBody, sendJson, randomUUID);
181
416
  }
182
417
  }), 'dsh-cron: /chat/start');
@@ -192,14 +427,25 @@ export function apply(ctx, config) {
192
427
  return;
193
428
  }
194
429
  if (req.method === 'POST') {
195
- const body = await parseJsonBody(req);
196
- store.saveSettings(body);
197
- sendJson(res, 200, { ok: true, settings: store.getClientSettings() });
430
+ if (rejectCrossOrigin(req, res)) return;
431
+ const { body, error } = await readBody(req, res);
432
+ if (error) return;
433
+ // Masked secrets echoed back by the client must never overwrite the
434
+ // stored token (store.saveSettings guards this too, #86-era).
435
+ const payload = Object.assign({}, body);
436
+ if (typeof payload.botToken === 'string' && (payload.botToken.includes('••') || payload.botToken.includes('****'))) {
437
+ delete payload.botToken;
438
+ }
439
+ const scopeErrors = await applySettingsToScope(cronSettingsScope, payload);
440
+ if (scopeErrors === null) {
441
+ store.saveSettings(payload);
442
+ }
443
+ sendJson(res, 200, { ok: true, settings: store.getClientSettings(), scopeErrors: scopeErrors || undefined });
198
444
  return;
199
445
  }
200
446
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
201
447
  } catch (err) {
202
- sendJson(res, 500, { ok: false, error: err.message });
448
+ sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
203
449
  }
204
450
  }
205
451
  }), 'dsh-cron: /settings');
@@ -214,19 +460,21 @@ export function apply(ctx, config) {
214
460
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
215
461
  return;
216
462
  }
217
- const body = await parseJsonBody(req);
463
+ if (rejectCrossOrigin(req, res)) return;
464
+ const { body, error } = await readBody(req, res);
465
+ if (error) return;
218
466
  const settings = store.getSettings();
219
467
  const botToken = body.botToken || settings.botToken;
220
468
  const chatId = body.chatId || settings.chatId;
221
469
 
222
470
  if (!botToken || !chatId) {
223
- sendJson(res, 400, { ok: false, error: 'Не указан botToken или chatId' });
471
+ sendJson(res, 400, { ok: false, error: 'botToken or chatId is not configured' });
224
472
  return;
225
473
  }
226
474
 
227
- const text = `🔔 *Тестовое оповещение dsh-cron*\n\nСвязь между DSH Cron и Telegram успешно настроена! Проверка времени: \`${new Date().toISOString()}\``;
475
+ const text = `🔔 *dsh-cron test notification*\n\nConnection between DSH Cron and Telegram is working! Checked at: \`${new Date().toISOString()}\``;
228
476
  await sendTelegramMessage({ botToken, chatId, text });
229
- sendJson(res, 200, { ok: true, message: 'Тестовое сообщение успешно отправлено' });
477
+ sendJson(res, 200, { ok: true, message: 'Test message sent successfully' });
230
478
  } catch (err) {
231
479
  sendJson(res, 500, { ok: false, error: err.message });
232
480
  }
@@ -243,13 +491,15 @@ export function apply(ctx, config) {
243
491
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
244
492
  return;
245
493
  }
246
- const body = await parseJsonBody(req);
494
+ if (rejectCrossOrigin(req, res)) return;
495
+ const { body, error } = await readBody(req, res);
496
+ if (error) return;
247
497
  const settings = store.getSettings();
248
498
  const kanbanBaseUrl = body.kanbanBaseUrl || settings.kanbanBaseUrl || 'http://127.0.0.1:3000';
249
499
 
250
500
  const result = await createKanbanCard({
251
- title: `[Тест] Проверка связи DSH Cron -> Kanban`,
252
- body: `Тестовая карточка создана автоматически из модуля \`dsh-cron\` в ${new Date().toLocaleString('ru-RU')}.`,
501
+ title: '[Test] dsh-cron -> Kanban connectivity check',
502
+ body: `Test card created automatically by the \`dsh-cron\` module at ${new Date().toISOString()}.`,
253
503
  board: 'main',
254
504
  column: 'backlog',
255
505
  labels: ['cron', 'test'],
@@ -267,289 +517,29 @@ export function apply(ctx, config) {
267
517
  }
268
518
  }), 'dsh-cron: /kanban/test');
269
519
 
270
- // PATCH & DELETE /dsh-cron/tasks/:id
271
- ctx.effect(() => ctx.webServer.register({
272
- kind: 'prefix',
273
- path: '/dsh-cron/tasks/',
274
- handler: async (req, res) => {
275
- try {
276
- const url = new URL(req.url, 'http://127.0.0.1');
277
- const parts = url.pathname.split('/').filter(Boolean);
278
- const id = parts[2];
279
- const action = parts[3];
280
-
281
- if (!id) {
282
- sendJson(res, 400, { ok: false, error: 'ID задачи не указан' });
283
- return;
284
- }
285
-
286
- if (req.method === 'GET' && action === 'history') {
287
- const limit = parseInt(url.searchParams.get('limit') || '20', 10);
288
- const history = store.getHistory(id, limit);
289
- sendJson(res, 200, { ok: true, history });
290
- return;
291
- }
292
-
293
- if (req.method === 'POST' && action === 'run') {
294
- await scheduler.triggerManualRun(id);
295
- const task = store.get(id);
296
- sendJson(res, 200, { ok: true, task });
297
- return;
298
- }
299
-
300
- if (req.method === 'POST' && action === 'pause') {
301
- const task = scheduler.pauseTask(id);
302
- if (!task) {
303
- sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
304
- return;
305
- }
306
- sendJson(res, 200, { ok: true, task });
307
- return;
308
- }
309
-
310
- if (req.method === 'POST' && action === 'resume') {
311
- const task = scheduler.resumeTask(id);
312
- if (!task) {
313
- sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
314
- return;
315
- }
316
- sendJson(res, 200, { ok: true, task });
317
- return;
318
- }
319
-
320
- if (req.method === 'POST' && (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle'))) {
321
- const current = store.get(id);
322
- if (!current) {
323
- sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
324
- return;
325
- }
326
- const task = scheduler.toggleTask(id);
327
- sendJson(res, 200, { ok: true, task });
328
- return;
329
- }
330
-
331
- if (req.method === 'PATCH') {
332
- const body = await parseJsonBody(req);
333
- const current = store.get(id);
334
- if (!current) {
335
- sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
336
- return;
337
- }
338
- let patchData = { ...body };
339
- if (body.schedule) {
340
- const parsed = parseScheduleExpression(body.schedule);
341
- patchData.schedule = parsed.cronPattern || body.schedule;
342
- patchData.scheduleText = body.scheduleText || parsed.humanText;
343
- patchData.oneShot = Boolean(parsed.isOneShot || body.oneShot);
344
- }
345
- const task = store.set({ ...current, ...patchData, id });
346
- if (task.status === 'active') {
347
- scheduler.scheduleTask(task);
348
- } else {
349
- scheduler.pauseTask(id);
350
- }
351
- sendJson(res, 200, { ok: true, task });
352
- return;
353
- }
354
-
355
- if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
356
- const current = store.get(id);
357
- if (current) {
358
- scheduler.pauseTask(id);
359
- store.delete(id);
360
- }
361
- sendJson(res, 200, { ok: true });
362
- return;
363
- }
364
-
365
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
366
- } catch (err) {
367
- sendJson(res, 500, { ok: false, error: err.message });
368
- }
369
- }
370
- }), 'dsh-cron: /tasks/:id');
371
-
372
- // Alias route /dsh-cron/action/:id/:action for backward compatibility (#66)
373
- ctx.effect(() => ctx.webServer.register({
374
- kind: 'prefix',
375
- path: '/dsh-cron/action/',
376
- handler: async (req, res) => {
377
- try {
378
- const url = new URL(req.url, 'http://127.0.0.1');
379
- const parts = url.pathname.split('/').filter(Boolean);
380
- const id = parts[2];
381
- const action = parts[3];
382
-
383
- if (!id) {
384
- sendJson(res, 400, { ok: false, error: 'ID задачи не указан' });
385
- return;
386
- }
387
-
388
- if (action === 'history' || (req.method === 'GET' && !action)) {
389
- const limit = parseInt(url.searchParams.get('limit') || '20', 10);
390
- const history = store.getHistory(id, limit);
391
- sendJson(res, 200, { ok: true, history });
392
- return;
393
- }
394
-
395
- if (action === 'run') {
396
- await scheduler.triggerManualRun(id);
397
- const task = store.get(id);
398
- sendJson(res, 200, { ok: true, task });
399
- return;
400
- }
401
-
402
- if (action === 'toggle') {
403
- const current = store.get(id);
404
- if (!current) {
405
- sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
406
- return;
407
- }
408
- const task = scheduler.toggleTask(id);
409
- sendJson(res, 200, { ok: true, task });
410
- return;
411
- }
412
-
413
- if (action === 'delete') {
414
- const current = store.get(id);
415
- if (current) {
416
- scheduler.pauseTask(id);
417
- store.delete(id);
418
- }
419
- sendJson(res, 200, { ok: true });
420
- return;
421
- }
422
-
423
- sendJson(res, 405, { ok: false, error: 'Action not supported' });
424
- } catch (err) {
425
- sendJson(res, 500, { ok: false, error: err.message });
426
- }
427
- }
428
- }), 'dsh-cron: /action/:id/:action');
429
-
430
520
  // 2. Tools
431
521
  ctx.tools.register(defineTool({
432
522
  name: 'cron_create_task',
433
- description: 'Создать новую запланированную задачу cron (запуск по расписанию или разово через "at: ISO" или "in 20m")',
434
- parameters: {
435
- title: { type: 'string', description: 'Краткое название задачи', required: true },
436
- schedule: { type: 'string', description: 'Расписание: cron ("0 9 * * *"), интервал ("every 2h") или разово ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
437
- prompt: { type: 'string', description: 'Промпт/инструкция для агента при запуске', required: true },
438
- type: { type: 'string', enum: ['prompt', 'script'], description: 'Тип задачи' },
439
- delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Режим запуска: current (в текущем чате) или isolated (изолированная сессия)' },
440
- provider: { type: 'string', description: 'Провайдер модели (опционально)' },
441
- model: { type: 'string', description: 'Модель (опционально)' },
442
- notifyTelegram: { type: 'boolean', description: 'Отправлять отчет в Telegram' },
443
- onlyOnFailure: { type: 'boolean', description: 'Отправлять отчет только при сбоях' },
444
- timeoutSeconds: { type: 'number', description: 'Лимит времени выполнения в секундах' },
445
- overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Политика перекрытия' },
446
- kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Создавать карточку в Kanban' },
447
- },
448
- output: {
449
- schema: {
450
- type: 'object',
451
- additionalProperties: true,
452
- properties: {
453
- success: { type: 'boolean' },
454
- message: { type: 'string' },
455
- task: { type: 'object', additionalProperties: true }
456
- }
457
- },
458
- render: (_args, val) => [{ type: 'text', text: val.message || JSON.stringify(val) }]
459
- },
460
- execute: async (args) => {
461
- const taskType = args.type === 'script' ? 'script' : 'llm';
462
- const parsed = parseScheduleExpression(args.schedule);
463
- const task = store.set({
464
- title: args.title,
465
- schedule: parsed.cronPattern || args.schedule,
466
- scheduleText: parsed.humanText,
467
- prompt: args.prompt,
468
- type: taskType,
469
- delivery: args.delivery || 'isolated',
470
- status: 'active',
471
- provider: args.provider,
472
- model: args.model,
473
- notifyTelegram: Boolean(args.notifyTelegram),
474
- onlyOnFailure: Boolean(args.onlyOnFailure),
475
- timeoutSeconds: Number(args.timeoutSeconds) || 1800,
476
- overlapPolicy: args.overlapPolicy || 'skip',
477
- kanbanMode: args.kanbanMode || 'none',
478
- oneShot: Boolean(parsed.isOneShot),
479
- });
480
- scheduler.scheduleTask(task);
481
- return {
482
- success: true,
483
- message: `Задача "${task.title}" успешно запланирована (${task.scheduleText})`,
484
- task
485
- };
486
- }
523
+ description: 'Create a new scheduled cron task (cron schedule, or one-shot via "at: <ISO timestamp>" or "in 20m")',
524
+ parameters: createTaskParameters,
525
+ output: createTaskOutput,
526
+ execute: async (args) => executeCreateTask(store, scheduler, args)
487
527
  }));
488
528
 
489
529
  // Alias tool cron_schedule_task for compatibility with agent prompts (#70)
490
530
  ctx.tools.register(defineTool({
491
531
  name: 'cron_schedule_task',
492
- description: 'Алиас для cron_create_task: создать новую запланированную задачу cron',
493
- parameters: {
494
- title: { type: 'string', description: 'Краткое название задачи', required: true },
495
- schedule: { type: 'string', description: 'Расписание: cron, интервал или разово', required: true },
496
- prompt: { type: 'string', description: 'Промпт/инструкция для агента при запуске', required: true },
497
- type: { type: 'string', enum: ['llm', 'prompt', 'script'], description: 'Тип задачи' },
498
- delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Режим запуска' },
499
- provider: { type: 'string', description: 'Провайдер модели (опционально)' },
500
- model: { type: 'string', description: 'Модель (опционально)' },
501
- notifyTelegram: { type: 'boolean', description: 'Отправлять отчет в Telegram' },
502
- onlyOnFailure: { type: 'boolean', description: 'Отправлять отчет только при сбоях' },
503
- timeoutSeconds: { type: 'number', description: 'Лимит времени выполнения в секундах' },
504
- overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Политика перекрытия' },
505
- kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Создавать карточку в Kanban' },
506
- },
507
- output: {
508
- schema: {
509
- type: 'object',
510
- additionalProperties: true,
511
- properties: {
512
- success: { type: 'boolean' },
513
- message: { type: 'string' },
514
- task: { type: 'object', additionalProperties: true }
515
- }
516
- },
517
- render: (_args, val) => [{ type: 'text', text: val.message || JSON.stringify(val) }]
518
- },
519
- execute: async (args) => {
520
- const taskType = args.type === 'script' ? 'script' : 'llm';
521
- const parsed = parseScheduleExpression(args.schedule);
522
- const task = store.set({
523
- title: args.title,
524
- schedule: parsed.cronPattern || args.schedule,
525
- scheduleText: parsed.humanText,
526
- prompt: args.prompt,
527
- type: taskType,
528
- delivery: args.delivery || 'isolated',
529
- status: 'active',
530
- provider: args.provider,
531
- model: args.model,
532
- notifyTelegram: Boolean(args.notifyTelegram),
533
- onlyOnFailure: Boolean(args.onlyOnFailure),
534
- timeoutSeconds: Number(args.timeoutSeconds) || 1800,
535
- overlapPolicy: args.overlapPolicy || 'skip',
536
- kanbanMode: args.kanbanMode || 'none',
537
- oneShot: Boolean(parsed.isOneShot),
538
- });
539
- scheduler.scheduleTask(task);
540
- return {
541
- success: true,
542
- message: `Задача "${task.title}" успешно запланирована (${task.scheduleText})`,
543
- task
544
- };
545
- }
532
+ description: 'Alias for cron_create_task: create a new scheduled cron task',
533
+ parameters: createTaskParameters,
534
+ output: createTaskOutput,
535
+ execute: async (args) => executeCreateTask(store, scheduler, args)
546
536
  }));
547
537
 
548
538
  ctx.tools.register(defineTool({
549
539
  name: 'cron_list_tasks',
550
- description: 'Получить список всех запланированных задач cron',
540
+ description: 'List all scheduled cron tasks',
551
541
  parameters: {
552
- status: { type: 'string', enum: ['all', 'active', 'paused', 'completed'], description: 'Фильтр по статусу' }
542
+ status: { type: 'string', enum: ['all', 'active', 'paused', 'completed'], description: 'Filter by status' }
553
543
  },
554
544
  output: {
555
545
  schema: {
@@ -560,7 +550,7 @@ export function apply(ctx, config) {
560
550
  tasks: { type: 'array', items: { type: 'object', additionalProperties: true } }
561
551
  }
562
552
  },
563
- render: (_args, val) => [{ type: 'text', text: `Всего задач: ${val.count}` }]
553
+ render: (_args, val) => [{ type: 'text', text: `Total tasks: ${val.count}` }]
564
554
  },
565
555
  execute: async (args) => {
566
556
  const tasks = store.list({ status: args.status || 'all' });
@@ -583,9 +573,9 @@ export function apply(ctx, config) {
583
573
 
584
574
  ctx.tools.register(defineTool({
585
575
  name: 'cron_pause_task',
586
- description: 'Приостановить выполнение задачи по её ID',
576
+ description: 'Pause a task by its ID',
587
577
  parameters: {
588
- id: { type: 'string', description: 'Идентификатор задачи', required: true }
578
+ id: { type: 'string', description: 'Task identifier', required: true }
589
579
  },
590
580
  output: {
591
581
  schema: {
@@ -600,16 +590,16 @@ export function apply(ctx, config) {
600
590
  },
601
591
  execute: async (args) => {
602
592
  const task = scheduler.pauseTask(args.id);
603
- if (!task) return { success: false, message: 'Задача не найдена' };
604
- return { success: true, message: `Задача "${task.title}" приостановлена` };
593
+ if (!task) return { success: false, message: 'Task not found' };
594
+ return { success: true, message: `Task "${task.title}" paused` };
605
595
  }
606
596
  }));
607
597
 
608
598
  ctx.tools.register(defineTool({
609
599
  name: 'cron_resume_task',
610
- description: 'Возобновить выполнение приостановленной задачи',
600
+ description: 'Resume a paused task',
611
601
  parameters: {
612
- id: { type: 'string', description: 'Идентификатор задачи', required: true }
602
+ id: { type: 'string', description: 'Task identifier', required: true }
613
603
  },
614
604
  output: {
615
605
  schema: {
@@ -624,16 +614,16 @@ export function apply(ctx, config) {
624
614
  },
625
615
  execute: async (args) => {
626
616
  const task = scheduler.resumeTask(args.id);
627
- if (!task) return { success: false, message: 'Задача не найдена' };
628
- return { success: true, message: `Задача "${task.title}" возобновлена (${task.scheduleText})` };
617
+ if (!task) return { success: false, message: 'Task not found' };
618
+ return { success: true, message: `Task "${task.title}" resumed (${task.scheduleText})` };
629
619
  }
630
620
  }));
631
621
 
632
622
  ctx.tools.register(defineTool({
633
623
  name: 'cron_delete_task',
634
- description: 'Удалить задачу по её ID',
624
+ description: 'Delete a task by its ID',
635
625
  parameters: {
636
- id: { type: 'string', description: 'Идентификатор задачи', required: true }
626
+ id: { type: 'string', description: 'Task identifier', required: true }
637
627
  },
638
628
  output: {
639
629
  schema: {
@@ -649,15 +639,15 @@ export function apply(ctx, config) {
649
639
  execute: async (args) => {
650
640
  scheduler.pauseTask(args.id);
651
641
  const ok = store.delete(args.id);
652
- return { success: ok, message: ok ? 'Задача удалена' : 'Задача не найдена' };
642
+ return { success: ok, message: ok ? 'Task deleted' : 'Task not found' };
653
643
  }
654
644
  }));
655
645
 
656
646
  ctx.tools.register(defineTool({
657
647
  name: 'cron_run_task',
658
- description: 'Запустить задачу немедленно вне очереди',
648
+ description: 'Trigger a task immediately, out of band',
659
649
  parameters: {
660
- id: { type: 'string', description: 'Идентификатор задачи', required: true }
650
+ id: { type: 'string', description: 'Task identifier', required: true }
661
651
  },
662
652
  output: {
663
653
  schema: {
@@ -672,9 +662,9 @@ export function apply(ctx, config) {
672
662
  },
673
663
  execute: async (args) => {
674
664
  const task = store.get(args.id);
675
- if (!task) return { success: false, message: 'Задача не найдена' };
665
+ if (!task) return { success: false, message: 'Task not found' };
676
666
  scheduler.triggerManualRun(args.id);
677
- return { success: true, message: `Запущен внеплановый старт задачи "${task.title}"` };
667
+ return { success: true, message: `Manual run started for task "${task.title}"` };
678
668
  }
679
669
  }));
680
670
  }