@goodandready/dsh-cron 0.1.24 → 0.2.0

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,38 +8,296 @@ 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
 
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
+ }
59
+
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
+ * Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
71
+ * route families — the #66 alias is kept alive by the same code path instead
72
+ * of a duplicated handler.
73
+ *
74
+ * IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
75
+ * with one, the core degrades matching to exact-path equality and every
76
+ * deeper path unrouted (#96).
77
+ */
78
+ function createCronApiHandler(store, scheduler, collection) {
79
+ return async function handleCronApi(req, res) {
80
+ try {
81
+ const url = new URL(req.url, 'http://127.0.0.1');
82
+ const parts = url.pathname.split('/').filter(Boolean);
83
+ const scope = parts[1]; // 'tasks' | 'action'
84
+ const id = parts[2];
85
+ const action = parts[3];
86
+
87
+ // Collection level: /dsh-cron/tasks (list + create)
88
+ if (!id) {
89
+ if (scope === 'action' || !collection) {
90
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
91
+ return;
92
+ }
93
+ if (req.method === 'GET') {
94
+ const status = url.searchParams.get('status') || 'all';
95
+ const query = url.searchParams.get('query') || '';
96
+ const list = store.list({ status, query });
97
+ const stats = store.getAggregatedStats();
98
+ sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
99
+ return;
100
+ }
101
+ if (req.method === 'POST') {
102
+ if (rejectCrossOrigin(req, res)) return;
103
+ const { body, error } = await readBody(req, res);
104
+ if (error) return;
105
+ if (!body.title || !body.schedule || !body.prompt) {
106
+ sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
107
+ return;
108
+ }
109
+ const taskType = body.type === 'script' ? 'script' : 'llm';
110
+ if (taskType === 'script' && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
111
+ sendJson(res, 403, { ok: false, error: 'Creating script tasks over HTTP requires the x-dsh-cron-confirm: script header' });
112
+ return;
113
+ }
114
+ const parsed = parseScheduleExpression(body.schedule);
115
+ const current = body.id ? store.get(body.id) : null;
116
+ const resolvedStatus = body.status || (current ? current.status : 'active');
117
+
118
+ const task = store.set({
119
+ id: body.id,
120
+ title: body.title,
121
+ schedule: parsed.cronPattern || body.schedule,
122
+ scheduleText: body.scheduleText || parsed.humanText,
123
+ prompt: body.prompt,
124
+ type: taskType,
125
+ delivery: body.delivery || 'current',
126
+ status: resolvedStatus,
127
+ provider: body.provider || undefined,
128
+ model: body.model || undefined,
129
+ notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
130
+ onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
131
+ timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
132
+ overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
133
+ kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
134
+ oneShot: Boolean(parsed.isOneShot || body.oneShot),
135
+ });
136
+ if (task.status === 'active') {
137
+ scheduler.scheduleTask(task);
138
+ } else {
139
+ scheduler.pauseTask(task.id);
140
+ }
141
+ sendJson(res, 200, { ok: true, task });
142
+ return;
143
+ }
144
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
145
+ return;
146
+ }
147
+
148
+ // Item level: /dsh-cron/{tasks|action}/:id[/:action]
149
+ if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
150
+
151
+ if (req.method === 'GET' && (action === 'history' || !action)) {
152
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
153
+ sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
154
+ return;
155
+ }
156
+
157
+ if (req.method === 'POST' && action === 'run') {
158
+ await scheduler.triggerManualRun(id);
159
+ sendJson(res, 200, { ok: true, task: store.get(id) });
160
+ return;
161
+ }
162
+
163
+ if (req.method === 'POST' && action === 'pause') {
164
+ const task = scheduler.pauseTask(id);
165
+ if (!task) {
166
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
167
+ return;
168
+ }
169
+ sendJson(res, 200, { ok: true, task });
170
+ return;
171
+ }
172
+
173
+ if (req.method === 'POST' && action === 'resume') {
174
+ const task = scheduler.resumeTask(id);
175
+ if (!task) {
176
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
177
+ return;
178
+ }
179
+ sendJson(res, 200, { ok: true, task });
180
+ return;
181
+ }
182
+
183
+ if (req.method === 'POST' && (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle'))) {
184
+ const current = store.get(id);
185
+ if (!current) {
186
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
187
+ return;
188
+ }
189
+ const task = scheduler.toggleTask(id);
190
+ sendJson(res, 200, { ok: true, task });
191
+ return;
192
+ }
193
+
194
+ if (req.method === 'PATCH') {
195
+ const current = store.get(id);
196
+ if (!current) {
197
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
198
+ return;
199
+ }
200
+ const { body, error } = await readBody(req, res);
201
+ if (error) return;
202
+ // Whitelist patchable fields so stats/timestamps stay server-owned (#90)
203
+ const patchData = pickPatchableFields(body, PATCHABLE_TASK_FIELDS);
204
+ if (patchData.type === 'script' && current.type !== 'script' && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
205
+ sendJson(res, 403, { ok: false, error: 'Switching a task to the script type over HTTP requires the x-dsh-cron-confirm: script header' });
206
+ return;
207
+ }
208
+ if (patchData.schedule) {
209
+ const parsed = parseScheduleExpression(patchData.schedule);
210
+ patchData.schedule = parsed.cronPattern || patchData.schedule;
211
+ patchData.scheduleText = body.scheduleText || parsed.humanText;
212
+ patchData.oneShot = Boolean(parsed.isOneShot || body.oneShot);
213
+ }
214
+ const task = store.set({ ...current, ...patchData, id });
215
+ if (task.status === 'active') {
216
+ scheduler.scheduleTask(task);
217
+ } else {
218
+ scheduler.pauseTask(id);
219
+ }
220
+ sendJson(res, 200, { ok: true, task });
221
+ return;
222
+ }
223
+
224
+ if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
225
+ const current = store.get(id);
226
+ if (current) {
227
+ scheduler.pauseTask(id);
228
+ store.delete(id);
229
+ }
230
+ sendJson(res, 200, { ok: true });
231
+ return;
232
+ }
233
+
234
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
235
+ } catch (err) {
236
+ sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
237
+ }
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Single implementation behind the cron_create_task / cron_schedule_task
243
+ * alias pair (#70, #92).
244
+ */
245
+ function executeCreateTask(store, scheduler, args) {
246
+ const taskType = args.type === 'script' ? 'script' : 'llm';
247
+ const parsed = parseScheduleExpression(args.schedule);
248
+ const task = store.set({
249
+ title: args.title,
250
+ schedule: parsed.cronPattern || args.schedule,
251
+ scheduleText: parsed.humanText,
252
+ prompt: args.prompt,
253
+ type: taskType,
254
+ delivery: args.delivery || 'isolated',
255
+ status: 'active',
256
+ provider: args.provider,
257
+ model: args.model,
258
+ notifyTelegram: Boolean(args.notifyTelegram),
259
+ onlyOnFailure: Boolean(args.onlyOnFailure),
260
+ timeoutSeconds: Number(args.timeoutSeconds) || 1800,
261
+ overlapPolicy: args.overlapPolicy || 'skip',
262
+ kanbanMode: args.kanbanMode || 'none',
263
+ oneShot: Boolean(parsed.isOneShot),
264
+ });
265
+ scheduler.scheduleTask(task);
266
+ return {
267
+ success: true,
268
+ message: `Task "${task.title}" scheduled successfully (${task.scheduleText})`,
269
+ task
270
+ };
271
+ }
272
+
273
+ const createTaskParameters = {
274
+ title: { type: 'string', description: 'Short task name', required: true },
275
+ schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
276
+ prompt: { type: 'string', description: 'Prompt/instruction for the agent at run time', required: true },
277
+ type: { type: 'string', enum: ['prompt', 'script'], description: 'Task type' },
278
+ delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
279
+ provider: { type: 'string', description: 'Model provider (optional)' },
280
+ model: { type: 'string', description: 'Model (optional)' },
281
+ notifyTelegram: { type: 'boolean', description: 'Send the run report to Telegram' },
282
+ onlyOnFailure: { type: 'boolean', description: 'Send reports only for failures' },
283
+ timeoutSeconds: { type: 'number', description: 'Execution time limit in seconds' },
284
+ overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Overlap policy' },
285
+ kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Create a dsh-kanban card for runs' },
286
+ };
287
+
288
+ const createTaskOutput = {
289
+ schema: {
290
+ type: 'object',
291
+ additionalProperties: true,
292
+ properties: {
293
+ success: { type: 'boolean' },
294
+ message: { type: 'string' },
295
+ task: { type: 'object', additionalProperties: true }
296
+ }
297
+ },
298
+ render: (_args, val) => [{ type: 'text', text: val.message || JSON.stringify(val) }]
299
+ };
300
+
43
301
  export function apply(ctx, config) {
44
302
  const store = new TaskStore();
45
303
  const runner = new SessionRunner(ctx);
@@ -74,94 +332,47 @@ export function apply(ctx, config) {
74
332
  const recommendations = [
75
333
  {
76
334
  id: 'rec_daily_digest',
77
- title: 'Ежедневная сводка',
335
+ title: 'Daily digest',
78
336
  schedule: '0 8 * * 1-5',
79
- scheduleText: 'В будние дни в 8:00',
80
- prompt: 'Сделай краткую утреннюю сводку: проверь список активных задач, открытые тикеты в Gitea и наметь ключевые приоритеты на сегодня.',
81
- description: 'Начинайте каждый будний день со сводки календаря, непрочитанных писем и приоритетов',
337
+ scheduleText: 'Weekdays at 08:00',
338
+ prompt: 'Prepare a brief morning digest: review the list of active tasks, open Gitea tickets and outline the key priorities for today.',
339
+ description: 'Start every weekday with a summary of calendar, unread mail and priorities',
82
340
  icon: 'bell'
83
341
  },
84
342
  {
85
343
  id: 'rec_weekly_review',
86
- title: 'Еженедельный обзор',
344
+ title: 'Weekly review',
87
345
  schedule: '0 16 * * 5',
88
- scheduleText: 'Пятницы в 16:00',
89
- prompt: 'Подготовь еженедельный отчет о проделанной работе: список закрытых задач, созданных PR и актуальный статус текущих проектов.',
90
- description: 'Каждую пятницу создавайте краткий отчет о проделанной работе',
346
+ scheduleText: 'Fridays at 16:00',
347
+ prompt: 'Prepare a weekly report on the work done: the list of closed tasks, created PRs and the current status of ongoing projects.',
348
+ description: 'Every Friday create a short report on the completed work',
91
349
  icon: 'clipboard'
92
350
  },
93
351
  {
94
352
  id: 'rec_monitor_status',
95
- title: 'Мониторинг дальнейших действий',
353
+ title: 'Follow-up monitor',
96
354
  schedule: '0 9 * * 1-5',
97
- scheduleText: 'В будние дни в 9:00',
98
- prompt: 'Проверь недавнюю активность в проектах, статус сборок и отметь всё, что требует внимания разработчика.',
99
- description: 'Проверяйте недавнюю активность в проектах и отмечайте всё, что требует вашего внимания',
355
+ scheduleText: 'Weekdays at 09:00',
356
+ prompt: 'Review recent project activity, build statuses and flag everything that requires developer attention.',
357
+ description: 'Review recent project activity and flag anything that needs your attention',
100
358
  icon: 'activity'
101
359
  }
102
360
  ];
103
361
 
104
- // 1. REST API
105
- // GET & POST /dsh-cron/tasks
362
+ // 1. REST API — collection (list/create) + item actions in one prefix
363
+ // handler per route family; registered WITHOUT the trailing slash (#96)
106
364
  ctx.effect(() => ctx.webServer.register({
107
- kind: 'exact',
365
+ kind: 'prefix',
108
366
  path: '/dsh-cron/tasks',
109
- handler: async (req, res) => {
110
- try {
111
- if (req.method === 'GET') {
112
- const url = new URL(req.url, 'http://127.0.0.1');
113
- const status = url.searchParams.get('status') || 'all';
114
- const query = url.searchParams.get('query') || '';
115
- const list = store.list({ status, query });
116
- const stats = store.getAggregatedStats();
117
- sendJson(res, 200, { ok: true, tasks: list, recommendations, stats });
118
- return;
119
- }
120
-
121
- if (req.method === 'POST') {
122
- const body = await parseJsonBody(req);
123
- if (!body.title || !body.schedule || !body.prompt) {
124
- sendJson(res, 400, { ok: false, error: 'Поля title, schedule и prompt обязательны' });
125
- return;
126
- }
127
- const parsed = parseScheduleExpression(body.schedule);
128
- const current = body.id ? store.get(body.id) : null;
129
- const resolvedStatus = body.status || (current ? current.status : 'active');
130
- const taskType = body.type === 'script' ? 'script' : 'llm';
131
-
132
- const task = store.set({
133
- id: body.id,
134
- title: body.title,
135
- schedule: parsed.cronPattern || body.schedule,
136
- scheduleText: body.scheduleText || parsed.humanText,
137
- prompt: body.prompt,
138
- type: taskType,
139
- delivery: body.delivery || 'current',
140
- status: resolvedStatus,
141
- provider: body.provider || undefined,
142
- model: body.model || undefined,
143
- notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
144
- onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
145
- timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
146
- overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
147
- kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
148
- oneShot: Boolean(parsed.isOneShot || body.oneShot),
149
- });
150
- if (task.status === 'active') {
151
- scheduler.scheduleTask(task);
152
- } else {
153
- scheduler.pauseTask(task.id);
154
- }
155
- sendJson(res, 200, { ok: true, task });
156
- return;
157
- }
367
+ handler: createCronApiHandler(store, scheduler, { recommendations })
368
+ }), 'dsh-cron: /tasks[...]');
158
369
 
159
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
160
- } catch (err) {
161
- sendJson(res, 500, { ok: false, error: err.message });
162
- }
163
- }
164
- }), 'dsh-cron: /tasks');
370
+ // Alias route /dsh-cron/action/:id/:action for backward compatibility (#66)
371
+ ctx.effect(() => ctx.webServer.register({
372
+ kind: 'prefix',
373
+ path: '/dsh-cron/action',
374
+ handler: createCronApiHandler(store, scheduler, null)
375
+ }), 'dsh-cron: /action/:id/:action');
165
376
 
166
377
  // GET /dsh-cron/models
167
378
  ctx.effect(() => ctx.webServer.register({
@@ -177,6 +388,7 @@ export function apply(ctx, config) {
177
388
  kind: 'exact',
178
389
  path: '/dsh-cron/chat/start',
179
390
  handler: async (req, res) => {
391
+ if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
180
392
  await chatStartHandler(ctx, req, res, parseJsonBody, sendJson, randomUUID);
181
393
  }
182
394
  }), 'dsh-cron: /chat/start');
@@ -192,14 +404,16 @@ export function apply(ctx, config) {
192
404
  return;
193
405
  }
194
406
  if (req.method === 'POST') {
195
- const body = await parseJsonBody(req);
407
+ if (rejectCrossOrigin(req, res)) return;
408
+ const { body, error } = await readBody(req, res);
409
+ if (error) return;
196
410
  store.saveSettings(body);
197
411
  sendJson(res, 200, { ok: true, settings: store.getClientSettings() });
198
412
  return;
199
413
  }
200
414
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
201
415
  } catch (err) {
202
- sendJson(res, 500, { ok: false, error: err.message });
416
+ sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
203
417
  }
204
418
  }
205
419
  }), 'dsh-cron: /settings');
@@ -214,19 +428,21 @@ export function apply(ctx, config) {
214
428
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
215
429
  return;
216
430
  }
217
- const body = await parseJsonBody(req);
431
+ if (rejectCrossOrigin(req, res)) return;
432
+ const { body, error } = await readBody(req, res);
433
+ if (error) return;
218
434
  const settings = store.getSettings();
219
435
  const botToken = body.botToken || settings.botToken;
220
436
  const chatId = body.chatId || settings.chatId;
221
437
 
222
438
  if (!botToken || !chatId) {
223
- sendJson(res, 400, { ok: false, error: 'Не указан botToken или chatId' });
439
+ sendJson(res, 400, { ok: false, error: 'botToken or chatId is not configured' });
224
440
  return;
225
441
  }
226
442
 
227
- const text = `🔔 *Тестовое оповещение dsh-cron*\n\nСвязь между DSH Cron и Telegram успешно настроена! Проверка времени: \`${new Date().toISOString()}\``;
443
+ const text = `🔔 *dsh-cron test notification*\n\nConnection between DSH Cron and Telegram is working! Checked at: \`${new Date().toISOString()}\``;
228
444
  await sendTelegramMessage({ botToken, chatId, text });
229
- sendJson(res, 200, { ok: true, message: 'Тестовое сообщение успешно отправлено' });
445
+ sendJson(res, 200, { ok: true, message: 'Test message sent successfully' });
230
446
  } catch (err) {
231
447
  sendJson(res, 500, { ok: false, error: err.message });
232
448
  }
@@ -243,13 +459,15 @@ export function apply(ctx, config) {
243
459
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
244
460
  return;
245
461
  }
246
- const body = await parseJsonBody(req);
462
+ if (rejectCrossOrigin(req, res)) return;
463
+ const { body, error } = await readBody(req, res);
464
+ if (error) return;
247
465
  const settings = store.getSettings();
248
466
  const kanbanBaseUrl = body.kanbanBaseUrl || settings.kanbanBaseUrl || 'http://127.0.0.1:3000';
249
467
 
250
468
  const result = await createKanbanCard({
251
- title: `[Тест] Проверка связи DSH Cron -> Kanban`,
252
- body: `Тестовая карточка создана автоматически из модуля \`dsh-cron\` в ${new Date().toLocaleString('ru-RU')}.`,
469
+ title: '[Test] dsh-cron -> Kanban connectivity check',
470
+ body: `Test card created automatically by the \`dsh-cron\` module at ${new Date().toISOString()}.`,
253
471
  board: 'main',
254
472
  column: 'backlog',
255
473
  labels: ['cron', 'test'],
@@ -267,289 +485,29 @@ export function apply(ctx, config) {
267
485
  }
268
486
  }), 'dsh-cron: /kanban/test');
269
487
 
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
488
  // 2. Tools
431
489
  ctx.tools.register(defineTool({
432
490
  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
- }
491
+ description: 'Create a new scheduled cron task (cron schedule, or one-shot via "at: <ISO timestamp>" or "in 20m")',
492
+ parameters: createTaskParameters,
493
+ output: createTaskOutput,
494
+ execute: async (args) => executeCreateTask(store, scheduler, args)
487
495
  }));
488
496
 
489
497
  // Alias tool cron_schedule_task for compatibility with agent prompts (#70)
490
498
  ctx.tools.register(defineTool({
491
499
  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
- }
500
+ description: 'Alias for cron_create_task: create a new scheduled cron task',
501
+ parameters: createTaskParameters,
502
+ output: createTaskOutput,
503
+ execute: async (args) => executeCreateTask(store, scheduler, args)
546
504
  }));
547
505
 
548
506
  ctx.tools.register(defineTool({
549
507
  name: 'cron_list_tasks',
550
- description: 'Получить список всех запланированных задач cron',
508
+ description: 'List all scheduled cron tasks',
551
509
  parameters: {
552
- status: { type: 'string', enum: ['all', 'active', 'paused', 'completed'], description: 'Фильтр по статусу' }
510
+ status: { type: 'string', enum: ['all', 'active', 'paused', 'completed'], description: 'Filter by status' }
553
511
  },
554
512
  output: {
555
513
  schema: {
@@ -560,7 +518,7 @@ export function apply(ctx, config) {
560
518
  tasks: { type: 'array', items: { type: 'object', additionalProperties: true } }
561
519
  }
562
520
  },
563
- render: (_args, val) => [{ type: 'text', text: `Всего задач: ${val.count}` }]
521
+ render: (_args, val) => [{ type: 'text', text: `Total tasks: ${val.count}` }]
564
522
  },
565
523
  execute: async (args) => {
566
524
  const tasks = store.list({ status: args.status || 'all' });
@@ -583,9 +541,9 @@ export function apply(ctx, config) {
583
541
 
584
542
  ctx.tools.register(defineTool({
585
543
  name: 'cron_pause_task',
586
- description: 'Приостановить выполнение задачи по её ID',
544
+ description: 'Pause a task by its ID',
587
545
  parameters: {
588
- id: { type: 'string', description: 'Идентификатор задачи', required: true }
546
+ id: { type: 'string', description: 'Task identifier', required: true }
589
547
  },
590
548
  output: {
591
549
  schema: {
@@ -600,16 +558,16 @@ export function apply(ctx, config) {
600
558
  },
601
559
  execute: async (args) => {
602
560
  const task = scheduler.pauseTask(args.id);
603
- if (!task) return { success: false, message: 'Задача не найдена' };
604
- return { success: true, message: `Задача "${task.title}" приостановлена` };
561
+ if (!task) return { success: false, message: 'Task not found' };
562
+ return { success: true, message: `Task "${task.title}" paused` };
605
563
  }
606
564
  }));
607
565
 
608
566
  ctx.tools.register(defineTool({
609
567
  name: 'cron_resume_task',
610
- description: 'Возобновить выполнение приостановленной задачи',
568
+ description: 'Resume a paused task',
611
569
  parameters: {
612
- id: { type: 'string', description: 'Идентификатор задачи', required: true }
570
+ id: { type: 'string', description: 'Task identifier', required: true }
613
571
  },
614
572
  output: {
615
573
  schema: {
@@ -624,16 +582,16 @@ export function apply(ctx, config) {
624
582
  },
625
583
  execute: async (args) => {
626
584
  const task = scheduler.resumeTask(args.id);
627
- if (!task) return { success: false, message: 'Задача не найдена' };
628
- return { success: true, message: `Задача "${task.title}" возобновлена (${task.scheduleText})` };
585
+ if (!task) return { success: false, message: 'Task not found' };
586
+ return { success: true, message: `Task "${task.title}" resumed (${task.scheduleText})` };
629
587
  }
630
588
  }));
631
589
 
632
590
  ctx.tools.register(defineTool({
633
591
  name: 'cron_delete_task',
634
- description: 'Удалить задачу по её ID',
592
+ description: 'Delete a task by its ID',
635
593
  parameters: {
636
- id: { type: 'string', description: 'Идентификатор задачи', required: true }
594
+ id: { type: 'string', description: 'Task identifier', required: true }
637
595
  },
638
596
  output: {
639
597
  schema: {
@@ -649,15 +607,15 @@ export function apply(ctx, config) {
649
607
  execute: async (args) => {
650
608
  scheduler.pauseTask(args.id);
651
609
  const ok = store.delete(args.id);
652
- return { success: ok, message: ok ? 'Задача удалена' : 'Задача не найдена' };
610
+ return { success: ok, message: ok ? 'Task deleted' : 'Task not found' };
653
611
  }
654
612
  }));
655
613
 
656
614
  ctx.tools.register(defineTool({
657
615
  name: 'cron_run_task',
658
- description: 'Запустить задачу немедленно вне очереди',
616
+ description: 'Trigger a task immediately, out of band',
659
617
  parameters: {
660
- id: { type: 'string', description: 'Идентификатор задачи', required: true }
618
+ id: { type: 'string', description: 'Task identifier', required: true }
661
619
  },
662
620
  output: {
663
621
  schema: {
@@ -672,9 +630,9 @@ export function apply(ctx, config) {
672
630
  },
673
631
  execute: async (args) => {
674
632
  const task = store.get(args.id);
675
- if (!task) return { success: false, message: 'Задача не найдена' };
633
+ if (!task) return { success: false, message: 'Task not found' };
676
634
  scheduler.triggerManualRun(args.id);
677
- return { success: true, message: `Запущен внеплановый старт задачи "${task.title}"` };
635
+ return { success: true, message: `Manual run started for task "${task.title}"` };
678
636
  }
679
637
  }));
680
638
  }