@goodandready/dsh-cron 0.1.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 ADDED
@@ -0,0 +1,349 @@
1
+ import { TaskStore } from './store.js';
2
+ import { TaskScheduler, parseScheduleExpression } from './scheduler.js';
3
+ import { SessionRunner } from './runner.js';
4
+ import { defineTool } from '@deepseek-ai/dsh-tools';
5
+
6
+ export const name = '@goodandready/dsh-cron';
7
+ export const inject = ['tools', 'webServer', 'settings'];
8
+
9
+ function parseJsonBody(req) {
10
+ return new Promise((resolve, reject) => {
11
+ let data = '';
12
+ req.on('data', (chunk) => { data += chunk; });
13
+ req.on('end', () => {
14
+ try {
15
+ resolve(data ? JSON.parse(data) : {});
16
+ } catch (err) {
17
+ reject(err);
18
+ }
19
+ });
20
+ req.on('error', reject);
21
+ });
22
+ }
23
+
24
+ function sendJson(res, statusCode, data) {
25
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
26
+ res.end(JSON.stringify(data));
27
+ }
28
+
29
+ export function apply(ctx, config) {
30
+ const store = new TaskStore();
31
+ const runner = new SessionRunner(ctx);
32
+ const scheduler = new TaskScheduler(store, (task) => runner.execute(task));
33
+
34
+ scheduler.start();
35
+
36
+ ctx.effect(() => {
37
+ return () => {
38
+ scheduler.stopAll();
39
+ };
40
+ }, 'dsh-cron: lifecycle');
41
+
42
+ const recommendations = [
43
+ {
44
+ id: 'rec_daily_digest',
45
+ title: 'Ежедневная сводка',
46
+ schedule: '0 8 * * 1-5',
47
+ scheduleText: 'В будние дни в 8:00',
48
+ prompt: 'Сделай краткую утреннюю сводку: проверь список активных задач, открытые тикеты в Gitea и наметь ключевые приоритеты на сегодня.',
49
+ description: 'Начинайте каждый будний день со сводки календаря, непрочитанных писем и приоритетов',
50
+ icon: 'bell'
51
+ },
52
+ {
53
+ id: 'rec_weekly_review',
54
+ title: 'Еженедельный обзор',
55
+ schedule: '0 16 * * 5',
56
+ scheduleText: 'Пятницы в 16:00',
57
+ prompt: 'Подготовь еженедельный отчет о проделанной работе: список закрытых задач, созданных PR и актуальный статус текущих проектов.',
58
+ description: 'Каждую пятницу создавайте краткий отчет о проделанной работе',
59
+ icon: 'clipboard'
60
+ },
61
+ {
62
+ id: 'rec_monitor_status',
63
+ title: 'Мониторинг дальнейших действий',
64
+ schedule: '0 9 * * 1-5',
65
+ scheduleText: 'В будние дни в 9:00',
66
+ prompt: 'Проверь недавнюю активность в проектах, статус сборок и отметь всё, что требует внимания разработчика.',
67
+ description: 'Проверяйте недавнюю активность в проектах и отмечайте всё, что требует вашего внимания',
68
+ icon: 'activity'
69
+ }
70
+ ];
71
+
72
+ // 1. REST API
73
+ // GET & POST /dsh-cron/tasks
74
+ ctx.effect(() => ctx.webServer.register({
75
+ kind: 'exact',
76
+ path: '/dsh-cron/tasks',
77
+ handler: async (req, res) => {
78
+ try {
79
+ if (req.method === 'GET') {
80
+ const url = new URL(req.url, 'http://127.0.0.1');
81
+ const status = url.searchParams.get('status') || 'all';
82
+ const query = url.searchParams.get('query') || '';
83
+ const list = store.list({ status, query });
84
+ sendJson(res, 200, { ok: true, tasks: list, recommendations });
85
+ return;
86
+ }
87
+
88
+ if (req.method === 'POST') {
89
+ const body = await parseJsonBody(req);
90
+ if (!body.title || !body.schedule || !body.prompt) {
91
+ sendJson(res, 400, { ok: false, error: 'Поля title, schedule и prompt обязательны' });
92
+ return;
93
+ }
94
+ const parsed = parseScheduleExpression(body.schedule);
95
+ const task = store.set({
96
+ id: body.id,
97
+ title: body.title,
98
+ schedule: parsed.cronPattern,
99
+ scheduleText: body.scheduleText || parsed.humanText,
100
+ prompt: body.prompt,
101
+ type: body.type || 'prompt',
102
+ delivery: body.delivery || 'current',
103
+ status: body.status || 'active',
104
+ });
105
+ scheduler.scheduleTask(task);
106
+ sendJson(res, 200, { ok: true, task });
107
+ return;
108
+ }
109
+
110
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
111
+ } catch (err) {
112
+ sendJson(res, 500, { ok: false, error: err.message });
113
+ }
114
+ }
115
+ }), 'dsh-cron: /tasks');
116
+
117
+ // Prefix routes for actions (/dsh-cron/action/*)
118
+ ctx.effect(() => ctx.webServer.register({
119
+ kind: 'prefix',
120
+ path: '/dsh-cron/action',
121
+ handler: async (req, res) => {
122
+ try {
123
+ const url = new URL(req.url, 'http://127.0.0.1');
124
+ const pathname = url.pathname;
125
+
126
+ const toggleMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/toggle$/);
127
+ if (req.method === 'POST' && toggleMatch) {
128
+ const taskId = toggleMatch[1];
129
+ const task = store.get(taskId);
130
+ if (!task) {
131
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
132
+ return;
133
+ }
134
+ const updated = task.status === 'active'
135
+ ? scheduler.pauseTask(taskId)
136
+ : scheduler.resumeTask(taskId);
137
+ sendJson(res, 200, { ok: true, task: updated });
138
+ return;
139
+ }
140
+
141
+ const runMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/run$/);
142
+ if (req.method === 'POST' && runMatch) {
143
+ const taskId = runMatch[1];
144
+ const task = store.get(taskId);
145
+ if (!task) {
146
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
147
+ return;
148
+ }
149
+ scheduler.runTask(taskId);
150
+ sendJson(res, 200, { ok: true, message: 'Запуск инициирован' });
151
+ return;
152
+ }
153
+
154
+ const deleteMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/delete$/);
155
+ if (req.method === 'POST' && deleteMatch) {
156
+ const taskId = deleteMatch[1];
157
+ const ok = scheduler.removeTask(taskId);
158
+ sendJson(res, 200, { ok });
159
+ return;
160
+ }
161
+
162
+ sendJson(res, 404, { ok: false, error: 'Action not found' });
163
+ } catch (err) {
164
+ sendJson(res, 500, { ok: false, error: err.message });
165
+ }
166
+ }
167
+ }), 'dsh-cron: /action');
168
+
169
+ // 2. AI Tools for Model Interaction
170
+ if (ctx.tools && typeof ctx.tools.register === 'function') {
171
+ ctx.tools.register(defineTool({
172
+ name: 'cron_schedule_task',
173
+ description: 'Запланировать автоматическую задачу по расписанию (cron).',
174
+ parameters: {
175
+ title: { type: 'string', required: true, description: 'Краткое название задачи' },
176
+ schedule: { type: 'string', required: true, description: 'Выражение cron или интервал (every 30m, 0 9 * * 1-5, daily)' },
177
+ prompt: { type: 'string', required: true, description: 'Инструкция для агента при запуске' },
178
+ delivery: { type: 'string', description: 'Режим запуска: current или isolated' }
179
+ },
180
+ output: {
181
+ schema: {
182
+ type: 'object',
183
+ additionalProperties: false,
184
+ properties: {
185
+ success: { type: 'boolean' },
186
+ message: { type: 'string' }
187
+ }
188
+ },
189
+ render(_args, value) {
190
+ return [{ type: 'text', text: value.message }];
191
+ }
192
+ },
193
+ execute: async ({ title, schedule, prompt, delivery }) => {
194
+ try {
195
+ const parsed = parseScheduleExpression(schedule);
196
+ const task = store.set({
197
+ title,
198
+ schedule: parsed.cronPattern,
199
+ scheduleText: parsed.humanText,
200
+ prompt,
201
+ delivery: delivery || 'isolated',
202
+ status: 'active'
203
+ });
204
+ scheduler.scheduleTask(task);
205
+ return {
206
+ success: true,
207
+ message: `Задача "${task.title}" запланирована (${parsed.humanText}). Следующий запуск: ${task.nextRunAt ? new Date(task.nextRunAt).toLocaleString() : 'скоро'}.`
208
+ };
209
+ } catch (err) {
210
+ return { success: false, message: 'Ошибка планирования: ' + err.message };
211
+ }
212
+ }
213
+ }));
214
+
215
+ ctx.tools.register(defineTool({
216
+ name: 'cron_list_tasks',
217
+ description: 'Получить список текущих запланированных cron-задач.',
218
+ parameters: {
219
+ status: { type: 'string', description: 'Фильтр статуса: all, active, paused, completed' }
220
+ },
221
+ output: {
222
+ schema: {
223
+ type: 'object',
224
+ additionalProperties: false,
225
+ properties: {
226
+ total: { type: 'integer' },
227
+ text: { type: 'string' }
228
+ }
229
+ },
230
+ render(_args, value) {
231
+ return [{ type: 'text', text: value.text }];
232
+ }
233
+ },
234
+ execute: async ({ status }) => {
235
+ const tasks = store.list({ status: status || 'all' });
236
+ const text = tasks.length === 0
237
+ ? 'Нет запланированных задач.'
238
+ : tasks.map(t => `• ${t.title} [${t.status}] — ${t.scheduleText}`).join('\n');
239
+ return {
240
+ total: tasks.length,
241
+ text
242
+ };
243
+ }
244
+ }));
245
+
246
+ ctx.tools.register(defineTool({
247
+ name: 'cron_toggle_task',
248
+ description: 'Приостановить, возобновить или запустить cron-задачу.',
249
+ parameters: {
250
+ taskId: { type: 'string', required: true, description: 'Идентификатор задачи' },
251
+ action: { type: 'string', required: true, description: 'pause, resume, run_now или delete' }
252
+ },
253
+ output: {
254
+ schema: {
255
+ type: 'object',
256
+ additionalProperties: false,
257
+ properties: {
258
+ success: { type: 'boolean' },
259
+ message: { type: 'string' }
260
+ }
261
+ },
262
+ render(_args, value) {
263
+ return [{ type: 'text', text: value.message }];
264
+ }
265
+ },
266
+ execute: async ({ taskId, action }) => {
267
+ if (action === 'pause') {
268
+ const t = scheduler.pauseTask(taskId);
269
+ return { success: !!t, message: t ? `Задача "${t.title}" приостановлена` : 'Задача не найдена' };
270
+ }
271
+ if (action === 'resume') {
272
+ const t = scheduler.resumeTask(taskId);
273
+ return { success: !!t, message: t ? `Задача "${t.title}" возобновлена` : 'Задача не найдена' };
274
+ }
275
+ if (action === 'run_now') {
276
+ scheduler.runTask(taskId);
277
+ return { success: true, message: `Задача ${taskId} запущена` };
278
+ }
279
+ if (action === 'delete') {
280
+ const ok = scheduler.removeTask(taskId);
281
+ return { success: ok, message: ok ? 'Задача удалена' : 'Задача не найдена' };
282
+ }
283
+ return { success: false, message: 'Неизвестное действие' };
284
+ }
285
+ }));
286
+ }
287
+
288
+ // 3. /cron Slash Command Handler
289
+ ctx.on('user/message', async (event) => {
290
+ const text = (event && event.text || event && event.message && event.message.content || '').trim();
291
+ if (!text.startsWith('/cron')) return;
292
+
293
+ const parts = text.slice(5).trim().split(/\s+/);
294
+ const subCmd = parts[0] ? parts[0].toLowerCase() : '';
295
+
296
+ if (!subCmd || subCmd === 'help') {
297
+ const helpMsg = `**⏰ DSH Cron — Управление запланированными задачами**\n\n` +
298
+ `• \`/cron list\` — показать активные задачи\n` +
299
+ `• \`/cron add <расписание> <промпт>\` — создать новую задачу\n` +
300
+ `• \`/cron pause <id>\` — приостановить задачу\n` +
301
+ `• \`/cron resume <id>\` — возобновить задачу\n` +
302
+ `• \`/cron run <id>\` — запустить сейчас\n\n` +
303
+ `_Открыть графический экран управления можно в боковой панели._`;
304
+
305
+ if (typeof event.reply === 'function') {
306
+ event.reply(helpMsg);
307
+ }
308
+ return;
309
+ }
310
+
311
+ if (subCmd === 'list') {
312
+ const list = store.list();
313
+ if (!list.length) {
314
+ if (typeof event.reply === 'function') event.reply('Нет запланированных задач. Создайте через `/cron add` или в UI.');
315
+ return;
316
+ }
317
+ const lines = list.map(t => `• **${t.title}** (${t.id}) — ${t.scheduleText} [${t.status}]`);
318
+ if (typeof event.reply === 'function') event.reply(`**Запланированные задачи (${list.length}):**\n\n` + lines.join('\n'));
319
+ return;
320
+ }
321
+
322
+ if (subCmd === 'add') {
323
+ const rest = text.slice(text.indexOf('add') + 3).trim();
324
+ const match = rest.match(/^"([^"]+)"\s+(.+)$/) || rest.match(/^(\S+)\s+(.+)$/);
325
+ if (!match) {
326
+ if (typeof event.reply === 'function') event.reply('Формат: `/cron add "<расписание>" <промпт>`');
327
+ return;
328
+ }
329
+ const [, scheduleStr, promptStr] = match;
330
+ try {
331
+ const parsed = parseScheduleExpression(scheduleStr);
332
+ const task = store.set({
333
+ title: promptStr.slice(0, 40),
334
+ schedule: parsed.cronPattern,
335
+ scheduleText: parsed.humanText,
336
+ prompt: promptStr,
337
+ status: 'active'
338
+ });
339
+ scheduler.scheduleTask(task);
340
+ if (typeof event.reply === 'function') {
341
+ event.reply(`✅ Создана задача: **${task.title}** (${parsed.humanText})`);
342
+ }
343
+ } catch (err) {
344
+ if (typeof event.reply === 'function') event.reply(`❌ Ошибка: ${err.message}`);
345
+ }
346
+ return;
347
+ }
348
+ });
349
+ }
package/lib/runner.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Runner executing scheduled tasks in DeepSeek Harness sessions.
3
+ */
4
+ export class SessionRunner {
5
+ constructor(ctx) {
6
+ this.ctx = ctx;
7
+ }
8
+
9
+ async execute(task) {
10
+ console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
11
+
12
+ // Check if session / agent service is present in DSH context
13
+ if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
14
+ try {
15
+ const session = await this.ctx.session.create({
16
+ title: `[Cron] ${task.title}`,
17
+ ephemeral: task.delivery === 'isolated',
18
+ });
19
+
20
+ if (session && typeof session.send === 'function') {
21
+ const result = await session.send(task.prompt);
22
+ return typeof result === 'string' ? result : JSON.stringify(result);
23
+ }
24
+ } catch (err) {
25
+ console.error(`[dsh-cron] session execution failed:`, err.message);
26
+ throw err;
27
+ }
28
+ }
29
+
30
+ // Direct shell / script task fallback
31
+ if (task.type === 'script' && task.prompt) {
32
+ const { exec } = await import('node:child_process');
33
+ const { promisify } = await import('node:util');
34
+ const execAsync = promisify(exec);
35
+ const { stdout, stderr } = await execAsync(task.prompt, { timeout: 60000 });
36
+ return stdout || stderr || 'Script completed with code 0';
37
+ }
38
+
39
+ return `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`;
40
+ }
41
+ }
@@ -0,0 +1,205 @@
1
+ import { Cron } from 'croner';
2
+
3
+ function pad(v) {
4
+ return String(v).padStart(2, '0');
5
+ }
6
+
7
+ export function describeCron(cronPattern) {
8
+ const parts = cronPattern.trim().split(/\s+/);
9
+ if (parts.length === 5) {
10
+ const [m, h, dom, mon, dow] = parts;
11
+ if (m.startsWith('*/')) return `Каждые ${m.slice(2)} минут`;
12
+ if (dow === '1-5' && dom === '*' && mon === '*') {
13
+ return `В будние дни в ${pad(h)}:${pad(m)}`;
14
+ }
15
+ if (dow === '*' && dom === '*' && mon === '*') {
16
+ return `Каждый день в ${pad(h)}:${pad(m)}`;
17
+ }
18
+ if (dow === '5' && dom === '*' && mon === '*') {
19
+ return `Пятницы в ${pad(h)}:${pad(m)}`;
20
+ }
21
+ }
22
+ return cronPattern;
23
+ }
24
+
25
+ export function parseScheduleExpression(input) {
26
+ const str = String(input || '').trim();
27
+ if (!str) throw new Error('Строка расписания не должна быть пустой');
28
+
29
+ const intervalMatch = str.match(/^every\s+(\d+)\s*(m|min|minute|minutes|h|hr|hour|hours|d|day|days)?$/i);
30
+ if (intervalMatch) {
31
+ const num = parseInt(intervalMatch[1], 10);
32
+ const unit = (intervalMatch[2] || 'm').toLowerCase();
33
+ if (unit.startsWith('m')) {
34
+ return {
35
+ cronPattern: `*/${num} * * * *`,
36
+ humanText: `Каждые ${num} минут`,
37
+ isInterval: true,
38
+ };
39
+ }
40
+ if (unit.startsWith('h')) {
41
+ return {
42
+ cronPattern: `0 */${num} * * *`,
43
+ humanText: `Каждые ${num} часов`,
44
+ isInterval: true,
45
+ };
46
+ }
47
+ if (unit.startsWith('d')) {
48
+ return {
49
+ cronPattern: `0 0 */${num} * *`,
50
+ humanText: `Каждые ${num} дней`,
51
+ isInterval: true,
52
+ };
53
+ }
54
+ }
55
+
56
+ const lower = str.toLowerCase();
57
+ if (lower === 'daily' || lower === 'каждый день') {
58
+ return { cronPattern: '0 9 * * *', humanText: 'Каждый день в 09:00' };
59
+ }
60
+ if (lower === 'weekdays' || lower === 'в будние дни' || lower === 'по будням') {
61
+ return { cronPattern: '0 9 * * 1-5', humanText: 'В будние дни в 09:00' };
62
+ }
63
+ if (lower === 'hourly' || lower === 'каждый час') {
64
+ return { cronPattern: '0 * * * *', humanText: 'Каждый час' };
65
+ }
66
+
67
+ try {
68
+ const testJob = new Cron(str);
69
+ const next = testJob.nextRun();
70
+ return {
71
+ cronPattern: str,
72
+ humanText: describeCron(str),
73
+ nextRun: next ? next.getTime() : null,
74
+ };
75
+ } catch (err) {
76
+ throw new Error('Некорректное расписание cron: ' + err.message);
77
+ }
78
+ }
79
+
80
+ export class TaskScheduler {
81
+ constructor(store, executeFn) {
82
+ this.store = store;
83
+ this.executeFn = executeFn;
84
+ this.jobs = new Map();
85
+ }
86
+
87
+ start() {
88
+ const tasks = this.store.list();
89
+ for (const task of tasks) {
90
+ if (task.status === 'active') {
91
+ this.scheduleTask(task);
92
+ }
93
+ }
94
+ }
95
+
96
+ stopAll() {
97
+ for (const [id, job] of this.jobs.entries()) {
98
+ try {
99
+ job.stop();
100
+ } catch {}
101
+ }
102
+ this.jobs.clear();
103
+ }
104
+
105
+ scheduleTask(task) {
106
+ if (this.jobs.has(task.id)) {
107
+ this.jobs.get(task.id).stop();
108
+ this.jobs.delete(task.id);
109
+ }
110
+
111
+ if (task.status !== 'active') return;
112
+
113
+ try {
114
+ const parsed = parseScheduleExpression(task.schedule);
115
+ const job = new Cron(parsed.cronPattern, { protect: true }, async () => {
116
+ await this.runTask(task.id);
117
+ });
118
+
119
+ this.jobs.set(task.id, job);
120
+ const next = job.nextRun();
121
+ if (next) {
122
+ task.nextRunAt = next.getTime();
123
+ this.store.set(task);
124
+ }
125
+ } catch (err) {
126
+ console.error(`[dsh-cron] failed to schedule task ${task.id}:`, err.message);
127
+ }
128
+ }
129
+
130
+ async runTask(taskId) {
131
+ const task = this.store.get(taskId);
132
+ if (!task) return;
133
+
134
+ const start = Date.now();
135
+ let status = 'success';
136
+ let output = '';
137
+ let error = null;
138
+
139
+ try {
140
+ if (typeof this.executeFn === 'function') {
141
+ output = await this.executeFn(task);
142
+ }
143
+ } catch (err) {
144
+ status = 'error';
145
+ error = err.message || String(err);
146
+ } finally {
147
+ const durationMs = Date.now() - start;
148
+ this.store.recordRun(taskId, {
149
+ at: start,
150
+ status,
151
+ durationMs,
152
+ output,
153
+ error,
154
+ });
155
+
156
+ const job = this.jobs.get(taskId);
157
+ if (job) {
158
+ const next = job.nextRun();
159
+ task.nextRunAt = next ? next.getTime() : null;
160
+ this.store.set(task);
161
+ }
162
+ }
163
+ }
164
+
165
+ pauseTask(taskId) {
166
+ const task = this.store.get(taskId);
167
+ if (!task) return null;
168
+ task.status = 'paused';
169
+ task.nextRunAt = null;
170
+ if (this.jobs.has(taskId)) {
171
+ this.jobs.get(taskId).stop();
172
+ this.jobs.delete(taskId);
173
+ }
174
+ this.store.set(task);
175
+ return task;
176
+ }
177
+
178
+ resumeTask(taskId) {
179
+ const task = this.store.get(taskId);
180
+ if (!task) return null;
181
+ task.status = 'active';
182
+ this.scheduleTask(task);
183
+ this.store.set(task);
184
+ return task;
185
+ }
186
+
187
+ removeTask(taskId) {
188
+ if (this.jobs.has(taskId)) {
189
+ this.jobs.get(taskId).stop();
190
+ this.jobs.delete(taskId);
191
+ }
192
+ return this.store.delete(taskId);
193
+ }
194
+
195
+ getNextRun(schedule) {
196
+ try {
197
+ const parsed = parseScheduleExpression(schedule);
198
+ const tempJob = new Cron(parsed.cronPattern);
199
+ const next = tempJob.nextRun();
200
+ return next ? next.getTime() : null;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+ }