@goodandready/dsh-cron 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -6
- package/docs/README.ru.md +21 -5
- package/docs/README.zh.md +18 -6
- package/docs/design/DESIGN.md +6 -4
- package/docs/plans/0.2.6-economy-block.md +61 -0
- package/lib/api.js +399 -0
- package/lib/client.js +154 -4
- package/lib/failure-inspector.js +107 -0
- package/lib/http-utils.js +24 -0
- package/lib/index.js +121 -388
- package/lib/llm-ask.js +141 -0
- package/lib/recipes.js +238 -0
- package/lib/runner.js +210 -171
- package/lib/scheduler.js +226 -80
- package/lib/silent-rule.js +100 -0
- package/lib/store.js +11 -0
- package/lib/task-patch.js +81 -0
- package/lib/task-transfer.js +4 -0
- package/lib/templates.js +4 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -52,6 +52,17 @@ export {
|
|
|
52
52
|
IMPORT_STRATEGIES,
|
|
53
53
|
};
|
|
54
54
|
import { deliverRun, CHANNEL_IDS, resolveDeliveryTimeoutMs, MIN_DELIVERY_TIMEOUT_MS } from './channels.js';
|
|
55
|
+
import { sendJson, rejectCrossOrigin, readBody } from './http-utils.js';
|
|
56
|
+
import { recipeRecommendations, recipesByCategory } from './recipes.js';
|
|
57
|
+
import { applyTaskPatch, describeTaskPatch } from './task-patch.js';
|
|
58
|
+
import { makeAsk } from './silent-rule.js';
|
|
59
|
+
|
|
60
|
+
// The REST surface lives in lib/api.js. It is IMPORTED (the plugin body calls
|
|
61
|
+
// it) and re-exported for tests and tools that import it from the entry point —
|
|
62
|
+
// a bare re-export creates no local binding and the plugin then fails to load
|
|
63
|
+
// with "createCronApiHandler is not defined".
|
|
64
|
+
import { createCronApiHandler } from './api.js';
|
|
65
|
+
export { createCronApiHandler };
|
|
55
66
|
|
|
56
67
|
export const name = '@goodandready/dsh-cron';
|
|
57
68
|
export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
|
|
@@ -70,6 +81,8 @@ export const Config = z.object({
|
|
|
70
81
|
// --- Delivery channels (#20-#23, #26, #28, #47) ---
|
|
71
82
|
template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
|
|
72
83
|
channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
|
|
84
|
+
silentRuleModel: z.string().default('').description('Model used to evaluate a task silent rule (empty = the task model)'),
|
|
85
|
+
inspectorModel: z.string().default('').description('Model used to diagnose failed runs (empty = the task model)'),
|
|
73
86
|
deliveryTimeoutMs: z.number().min(MIN_DELIVERY_TIMEOUT_MS).default(15000).description('Per-channel delivery timeout in ms (minimum 1000); a slower endpoint is recorded as a failure and does not block other channels or the next tick'),
|
|
74
87
|
discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
|
|
75
88
|
slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
|
|
@@ -87,32 +100,9 @@ export const Config = z.object({
|
|
|
87
100
|
});
|
|
88
101
|
|
|
89
102
|
|
|
90
|
-
function sendJson(res, statusCode, data) {
|
|
91
|
-
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
92
|
-
res.end(JSON.stringify(data));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function rejectCrossOrigin(req, res) {
|
|
96
|
-
if (isCrossOrigin(req)) {
|
|
97
|
-
sendJson(res, 403, { ok: false, error: 'Cross-origin request rejected' });
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function readBody(req, res) {
|
|
104
|
-
try {
|
|
105
|
-
return { body: await parseJsonBody(req) };
|
|
106
|
-
} catch (err) {
|
|
107
|
-
sendJson(res, err.statusCode || 400, { ok: false, error: err.message });
|
|
108
|
-
return { error: true };
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Settings keys synchronized between the store and the settings service. */
|
|
113
103
|
export const SETTINGS_SYNC_KEYS = [
|
|
114
104
|
'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
|
|
115
|
-
'template', 'channelTemplates', 'deliveryTimeoutMs',
|
|
105
|
+
'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
|
|
116
106
|
'discordWebhookUrl', 'slackWebhookUrl',
|
|
117
107
|
'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
|
|
118
108
|
'barkServerUrl', 'barkKey',
|
|
@@ -181,341 +171,6 @@ export function sanitizeSettingsPayload(body) {
|
|
|
181
171
|
return payload;
|
|
182
172
|
}
|
|
183
173
|
|
|
184
|
-
/**
|
|
185
|
-
* Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
|
|
186
|
-
* route families — the #66 alias is kept alive by the same code path instead
|
|
187
|
-
* of a duplicated handler.
|
|
188
|
-
*
|
|
189
|
-
* IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
|
|
190
|
-
* with one, the core degrades matching to exact-path equality and every
|
|
191
|
-
* deeper path unrouted (#96). Exported for unit tests.
|
|
192
|
-
*/
|
|
193
|
-
export function createCronApiHandler(store, scheduler, collection) {
|
|
194
|
-
return async function handleCronApi(req, res) {
|
|
195
|
-
try {
|
|
196
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
197
|
-
const parts = url.pathname.split('/').filter(Boolean);
|
|
198
|
-
const scope = parts[1]; // 'tasks' | 'action'
|
|
199
|
-
const id = parts[2];
|
|
200
|
-
const action = parts[3];
|
|
201
|
-
|
|
202
|
-
// Collection level: /dsh-cron/tasks (list + create)
|
|
203
|
-
if (!id) {
|
|
204
|
-
if (scope === 'action' || !collection) {
|
|
205
|
-
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
if (req.method === 'GET') {
|
|
209
|
-
const status = url.searchParams.get('status') || 'all';
|
|
210
|
-
const query = url.searchParams.get('query') || '';
|
|
211
|
-
const list = store.list({ status, query }).map((t) => ({
|
|
212
|
-
...t,
|
|
213
|
-
running: scheduler.isRunning(t.id),
|
|
214
|
-
runningSince: scheduler.runningSince(t.id),
|
|
215
|
-
}));
|
|
216
|
-
const stats = store.getAggregatedStats();
|
|
217
|
-
sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
if (req.method === 'POST') {
|
|
221
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
222
|
-
const { body, error } = await readBody(req, res);
|
|
223
|
-
if (error) return;
|
|
224
|
-
if (!body.title || !body.schedule || !body.prompt) {
|
|
225
|
-
sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
const taskType = normalizeTaskType(body.type);
|
|
229
|
-
if (CODE_EXECUTING_TYPES.includes(taskType) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
230
|
-
sendJson(res, 403, { ok: false, error: `Creating ${taskType} tasks over HTTP requires the x-dsh-cron-confirm: script header` });
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
const typeError = validateTaskType(taskType, body);
|
|
234
|
-
if (typeError) {
|
|
235
|
-
sendJson(res, 400, { ok: false, error: typeError });
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
const parsed = parseScheduleExpression(body.schedule);
|
|
239
|
-
// A client-supplied id must not shadow the collection routes under
|
|
240
|
-
// /dsh-cron/tasks, otherwise that task could never be fetched or
|
|
241
|
-
// deleted again (#42 review finding).
|
|
242
|
-
if (body.id && RESERVED_TASK_IDS.includes(String(body.id))) {
|
|
243
|
-
sendJson(res, 400, { ok: false, error: `Task id "${body.id}" is reserved` });
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
const current = body.id ? store.get(body.id) : null;
|
|
247
|
-
const resolvedStatus = body.status || (current ? current.status : 'active');
|
|
248
|
-
|
|
249
|
-
const task = store.set({
|
|
250
|
-
id: body.id,
|
|
251
|
-
title: body.title,
|
|
252
|
-
schedule: parsed.cronPattern || body.schedule,
|
|
253
|
-
scheduleText: body.scheduleText || parsed.humanText,
|
|
254
|
-
prompt: body.prompt,
|
|
255
|
-
type: taskType,
|
|
256
|
-
delivery: body.delivery || 'current',
|
|
257
|
-
status: resolvedStatus,
|
|
258
|
-
provider: body.provider || undefined,
|
|
259
|
-
model: body.model || undefined,
|
|
260
|
-
notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
|
|
261
|
-
onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
|
|
262
|
-
timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
|
|
263
|
-
overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
|
|
264
|
-
kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
|
|
265
|
-
timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
|
|
266
|
-
misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
|
|
267
|
-
maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
|
|
268
|
-
retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
|
|
269
|
-
permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
|
|
270
|
-
env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
|
|
271
|
-
cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
|
|
272
|
-
channels: Array.isArray(body.channels)
|
|
273
|
-
? body.channels.filter((c) => CHANNEL_IDS.includes(c))
|
|
274
|
-
: (current ? current.channels : undefined),
|
|
275
|
-
template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
|
|
276
|
-
workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
|
|
277
|
-
worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
|
|
278
|
-
keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
|
|
279
|
-
httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
|
|
280
|
-
httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
|
|
281
|
-
httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
|
|
282
|
-
httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
|
|
283
|
-
sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
|
|
284
|
-
sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
|
|
285
|
-
sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
|
|
286
|
-
sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
|
|
287
|
-
dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
|
|
288
|
-
pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
|
|
289
|
-
nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
|
|
290
|
-
skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
|
|
291
|
-
workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
|
|
292
|
-
oneShot: Boolean(parsed.isOneShot || body.oneShot),
|
|
293
|
-
});
|
|
294
|
-
if (task.status === 'active') {
|
|
295
|
-
scheduler.scheduleTask(task);
|
|
296
|
-
} else {
|
|
297
|
-
scheduler.pauseTask(task.id);
|
|
298
|
-
}
|
|
299
|
-
sendJson(res, 200, { ok: true, task });
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Item level: /dsh-cron/{tasks|action}/:id[/:action]
|
|
307
|
-
if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
|
|
308
|
-
|
|
309
|
-
// Export/import are collection operations that live under /tasks/:id/…,
|
|
310
|
-
// so they are matched before the generic id handling below.
|
|
311
|
-
if (id === 'export' && req.method === 'GET') {
|
|
312
|
-
const doc = buildTaskExport(store.list({ status: 'all' }));
|
|
313
|
-
sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length });
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (id === 'import' && req.method === 'POST') {
|
|
318
|
-
const { body, error } = await readBody(req, res);
|
|
319
|
-
if (error) return;
|
|
320
|
-
const strategy = IMPORT_STRATEGIES.includes(body && body.strategy) ? body.strategy : 'skip';
|
|
321
|
-
const checked = validateImportDocument(body && body.document);
|
|
322
|
-
if (!checked.ok) {
|
|
323
|
-
sendJson(res, 400, { ok: false, error: checked.error });
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
// The import file is external input: importing code-executing tasks
|
|
327
|
-
// needs the same explicit confirmation that creating one over HTTP
|
|
328
|
-
// needs, otherwise a hand-edited file would bypass that gate (#86).
|
|
329
|
-
if (hasCodeExecutingTask(checked.tasks) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
330
|
-
sendJson(res, 403, {
|
|
331
|
-
ok: false,
|
|
332
|
-
error: `Importing code-executing tasks over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`,
|
|
333
|
-
});
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
const existingIds = store.list({ status: 'all' }).map((x) => x.id);
|
|
337
|
-
const plan = planImport(checked.tasks, existingIds, strategy);
|
|
338
|
-
if (body && body.dryRun) {
|
|
339
|
-
sendJson(res, 200, {
|
|
340
|
-
ok: true,
|
|
341
|
-
dryRun: true,
|
|
342
|
-
strategy: plan.strategy,
|
|
343
|
-
summary: { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length },
|
|
344
|
-
codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
|
|
345
|
-
});
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
// Every imported task is paused (the file's status is ignored) and every
|
|
349
|
-
// id that replaced an existing task is disarmed: writing "paused" to the
|
|
350
|
-
// store does not stop an already-armed croner job, so the scheduler must
|
|
351
|
-
// be told explicitly. The writes are rolled back if one of them throws,
|
|
352
|
-
// so a failed import cannot leave the store half-updated.
|
|
353
|
-
const imported = [];
|
|
354
|
-
const rollback = [];
|
|
355
|
-
const disarm = [];
|
|
356
|
-
try {
|
|
357
|
-
for (const task of plan.add) {
|
|
358
|
-
const before = store.get(task.id);
|
|
359
|
-
const created = store.set({ ...task, id: task.id || randomUUID(), status: 'paused', updatedAt: Date.now() });
|
|
360
|
-
imported.push(created);
|
|
361
|
-
rollback.push({ id: created.id, previous: before || null });
|
|
362
|
-
disarm.push(created.id);
|
|
363
|
-
}
|
|
364
|
-
for (const task of plan.replace) {
|
|
365
|
-
const before = store.get(task.id);
|
|
366
|
-
if (!before) continue;
|
|
367
|
-
const replaced = store.set({ ...task, id: task.id, status: 'paused', updatedAt: Date.now() });
|
|
368
|
-
imported.push(replaced);
|
|
369
|
-
rollback.push({ id: replaced.id, previous: before });
|
|
370
|
-
disarm.push(replaced.id);
|
|
371
|
-
}
|
|
372
|
-
} catch (writeErr) {
|
|
373
|
-
for (const entry of rollback.reverse()) {
|
|
374
|
-
try {
|
|
375
|
-
if (entry.previous) store.set(entry.previous);
|
|
376
|
-
else store.delete(entry.id);
|
|
377
|
-
} catch (_) {}
|
|
378
|
-
}
|
|
379
|
-
sendJson(res, 500, { ok: false, error: `Import failed and was rolled back: ${writeErr.message}` });
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
// Disarm outside the write transaction: pauseTask is idempotent and a
|
|
383
|
-
// failure here must not undo a successful import.
|
|
384
|
-
for (const id of disarm) {
|
|
385
|
-
try { scheduler.pauseTask(id); } catch (err) { console.error('[dsh-cron] import: could not pause task', id + ':', err.message); }
|
|
386
|
-
}
|
|
387
|
-
sendJson(res, 200, {
|
|
388
|
-
ok: true,
|
|
389
|
-
imported: imported.length,
|
|
390
|
-
summary: { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length },
|
|
391
|
-
tasks: imported,
|
|
392
|
-
});
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
if (req.method === 'GET' && (action === 'history' || !action)) {
|
|
397
|
-
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
398
|
-
sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (req.method === 'POST' && action === 'run') {
|
|
403
|
-
if (!store.get(id)) {
|
|
404
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
await scheduler.triggerManualRun(id);
|
|
408
|
-
sendJson(res, 200, { ok: true, task: store.get(id) });
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (req.method === 'POST' && action === 'duplicate') {
|
|
413
|
-
const source = store.get(id);
|
|
414
|
-
if (!source) {
|
|
415
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
const copy = buildDuplicateTask(source, { id: randomUUID() });
|
|
419
|
-
store.set(copy);
|
|
420
|
-
// A copy starts paused: it must never fire on its own before the user
|
|
421
|
-
// reviews the schedule (a duplicated one-shot may point at a past time).
|
|
422
|
-
scheduler.pauseTask(copy.id);
|
|
423
|
-
sendJson(res, 200, { ok: true, task: copy });
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
if (req.method === 'POST' && action === 'pause') {
|
|
428
|
-
const task = scheduler.pauseTask(id);
|
|
429
|
-
if (!task) {
|
|
430
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
sendJson(res, 200, { ok: true, task });
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (req.method === 'POST' && action === 'resume') {
|
|
438
|
-
const task = scheduler.resumeTask(id);
|
|
439
|
-
if (!task) {
|
|
440
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
sendJson(res, 200, { ok: true, task });
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (req.method === 'POST' && (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle'))) {
|
|
448
|
-
const current = store.get(id);
|
|
449
|
-
if (!current) {
|
|
450
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
const task = scheduler.toggleTask(id);
|
|
454
|
-
sendJson(res, 200, { ok: true, task });
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
if (req.method === 'PATCH') {
|
|
459
|
-
const current = store.get(id);
|
|
460
|
-
if (!current) {
|
|
461
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
const { body, error } = await readBody(req, res);
|
|
465
|
-
if (error) return;
|
|
466
|
-
// Whitelist patchable fields so stats/timestamps stay server-owned (#90)
|
|
467
|
-
const patchData = pickPatchableFields(body, PATCHABLE_TASK_FIELDS);
|
|
468
|
-
if (patchData.type !== undefined) {
|
|
469
|
-
patchData.type = normalizeTaskType(patchData.type);
|
|
470
|
-
const switchingToCode = CODE_EXECUTING_TYPES.includes(patchData.type)
|
|
471
|
-
&& !CODE_EXECUTING_TYPES.includes(current.type);
|
|
472
|
-
if (switchingToCode && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
473
|
-
sendJson(res, 403, { ok: false, error: `Switching a task to the ${patchData.type} type over HTTP requires the x-dsh-cron-confirm: script header` });
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
const typeError = validateTaskType(patchData.type, { ...current, ...patchData });
|
|
477
|
-
if (typeError) {
|
|
478
|
-
sendJson(res, 400, { ok: false, error: typeError });
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
if (patchData.schedule) {
|
|
483
|
-
const parsed = parseScheduleExpression(patchData.schedule);
|
|
484
|
-
patchData.schedule = parsed.cronPattern || patchData.schedule;
|
|
485
|
-
patchData.scheduleText = body.scheduleText || parsed.humanText;
|
|
486
|
-
patchData.oneShot = Boolean(parsed.isOneShot || body.oneShot);
|
|
487
|
-
}
|
|
488
|
-
const task = store.set({ ...current, ...patchData, id });
|
|
489
|
-
if (task.status === 'active') {
|
|
490
|
-
scheduler.scheduleTask(task);
|
|
491
|
-
} else {
|
|
492
|
-
scheduler.pauseTask(id);
|
|
493
|
-
}
|
|
494
|
-
sendJson(res, 200, { ok: true, task });
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
|
|
499
|
-
const current = store.get(id);
|
|
500
|
-
if (current) {
|
|
501
|
-
scheduler.pauseTask(id);
|
|
502
|
-
store.delete(id);
|
|
503
|
-
}
|
|
504
|
-
sendJson(res, 200, { ok: true });
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
509
|
-
} catch (err) {
|
|
510
|
-
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Single implementation behind the cron_create_task / cron_schedule_task
|
|
517
|
-
* alias pair (#70, #92). Exported for unit tests.
|
|
518
|
-
*/
|
|
519
174
|
export function executeCreateTask(store, scheduler, args) {
|
|
520
175
|
const taskType = normalizeTaskType(args.type);
|
|
521
176
|
const typeError = validateTaskType(taskType, args);
|
|
@@ -578,6 +233,10 @@ export const createTaskParameters = {
|
|
|
578
233
|
type: { type: 'string', enum: TASK_TYPES, description: 'Task type: llm | script | node | python | http | ssh | docker | skill | workflow' },
|
|
579
234
|
delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
|
|
580
235
|
provider: { type: 'string', description: 'Model provider (optional)' },
|
|
236
|
+
inspectOnFailure: { type: 'boolean', description: 'Diagnose failed runs with a model and store the diagnosis with the run (agent tasks)' },
|
|
237
|
+
silentRule: { type: 'string', description: 'Plain-language condition for staying silent on a successful run (script/node/python/http tasks)' },
|
|
238
|
+
fallbackModel: { type: 'string', description: 'Model tried once more when the primary model fails (llm/skill/workflow tasks)' },
|
|
239
|
+
fallbackProvider: { type: 'string', description: 'Provider for the fallback model (defaults to the task provider)' },
|
|
581
240
|
model: { type: 'string', description: 'Model (optional)' },
|
|
582
241
|
timezone: { type: 'string', description: 'IANA time zone for the schedule, e.g. "Europe/Berlin" (optional, default = server local)' },
|
|
583
242
|
notifyTelegram: { type: 'boolean', description: 'Send the run report to Telegram' },
|
|
@@ -631,6 +290,7 @@ export function apply(ctx, config) {
|
|
|
631
290
|
maxConcurrent: config && config.maxConcurrent,
|
|
632
291
|
defaultTimezone: config && config.defaultTimezone,
|
|
633
292
|
resolveSecrets: (settings) => resolveTelegramSecrets(ctx, settings),
|
|
293
|
+
askModel: makeAsk(ctx),
|
|
634
294
|
deliver: (task, runInfo, settings) => deliverRun({
|
|
635
295
|
task,
|
|
636
296
|
runInfo,
|
|
@@ -688,35 +348,8 @@ export function apply(ctx, config) {
|
|
|
688
348
|
};
|
|
689
349
|
}, 'dsh-cron: lifecycle');
|
|
690
350
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
id: 'rec_daily_digest',
|
|
694
|
-
title: 'Daily digest',
|
|
695
|
-
schedule: '0 8 * * 1-5',
|
|
696
|
-
scheduleText: 'Weekdays at 08:00',
|
|
697
|
-
prompt: 'Prepare a brief morning digest: review the list of active tasks, open Gitea tickets and outline the key priorities for today.',
|
|
698
|
-
description: 'Start every weekday with a summary of calendar, unread mail and priorities',
|
|
699
|
-
icon: 'bell'
|
|
700
|
-
},
|
|
701
|
-
{
|
|
702
|
-
id: 'rec_weekly_review',
|
|
703
|
-
title: 'Weekly review',
|
|
704
|
-
schedule: '0 16 * * 5',
|
|
705
|
-
scheduleText: 'Fridays at 16:00',
|
|
706
|
-
prompt: 'Prepare a weekly report on the work done: the list of closed tasks, created PRs and the current status of ongoing projects.',
|
|
707
|
-
description: 'Every Friday create a short report on the completed work',
|
|
708
|
-
icon: 'clipboard'
|
|
709
|
-
},
|
|
710
|
-
{
|
|
711
|
-
id: 'rec_monitor_status',
|
|
712
|
-
title: 'Follow-up monitor',
|
|
713
|
-
schedule: '0 9 * * 1-5',
|
|
714
|
-
scheduleText: 'Weekdays at 09:00',
|
|
715
|
-
prompt: 'Review recent project activity, build statuses and flag everything that requires developer attention.',
|
|
716
|
-
description: 'Review recent project activity and flag anything that needs your attention',
|
|
717
|
-
icon: 'activity'
|
|
718
|
-
}
|
|
719
|
-
];
|
|
351
|
+
// #48: the recipe hub is the single source of the panel's suggestions.
|
|
352
|
+
const recommendations = recipeRecommendations();
|
|
720
353
|
|
|
721
354
|
// 1. REST API — collection (list/create) + item actions in one prefix
|
|
722
355
|
// handler per route family; registered WITHOUT the trailing slash (#96)
|
|
@@ -783,6 +416,25 @@ export function apply(ctx, config) {
|
|
|
783
416
|
}
|
|
784
417
|
}), 'dsh-cron: /settings');
|
|
785
418
|
|
|
419
|
+
// GET /dsh-cron/recipes (#48): the built-in recipe catalog.
|
|
420
|
+
ctx.effect(() => ctx.webServer.register({
|
|
421
|
+
kind: 'exact',
|
|
422
|
+
path: '/dsh-cron/recipes',
|
|
423
|
+
handler: async (req, res) => {
|
|
424
|
+
try {
|
|
425
|
+
if (req.method !== 'GET') {
|
|
426
|
+
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const categories = recipesByCategory();
|
|
430
|
+
const recipes = categories.reduce((acc, category) => acc.concat(category.recipes), []);
|
|
431
|
+
sendJson(res, 200, { ok: true, categories, recipes });
|
|
432
|
+
} catch (err) {
|
|
433
|
+
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}), 'dsh-cron: /recipes');
|
|
437
|
+
|
|
786
438
|
// GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
|
|
787
439
|
ctx.effect(() => ctx.webServer.register({
|
|
788
440
|
kind: 'exact',
|
|
@@ -997,6 +649,87 @@ export function apply(ctx, config) {
|
|
|
997
649
|
}
|
|
998
650
|
}));
|
|
999
651
|
|
|
652
|
+
ctx.tools.register(defineTool({
|
|
653
|
+
name: 'cron_get_task',
|
|
654
|
+
description: 'Read the full configuration of one scheduled task, including fields that are not visible in the task list',
|
|
655
|
+
parameters: {
|
|
656
|
+
id: { type: 'string', description: 'Task identifier', required: true }
|
|
657
|
+
},
|
|
658
|
+
output: {
|
|
659
|
+
schema: {
|
|
660
|
+
type: 'object',
|
|
661
|
+
additionalProperties: true,
|
|
662
|
+
properties: {
|
|
663
|
+
success: { type: 'boolean' },
|
|
664
|
+
message: { type: 'string' },
|
|
665
|
+
task: { type: 'object', additionalProperties: true }
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
669
|
+
},
|
|
670
|
+
execute: async (args) => {
|
|
671
|
+
const task = store.get(args.id);
|
|
672
|
+
if (!task) return { success: false, message: 'Task not found' };
|
|
673
|
+
const summary = [
|
|
674
|
+
`Task "${task.title}" (${task.id})`,
|
|
675
|
+
`type: ${task.type || 'llm'}`,
|
|
676
|
+
`schedule: ${task.scheduleText || task.schedule}`,
|
|
677
|
+
`status: ${task.status}`,
|
|
678
|
+
task.type === 'llm' || !task.type ? `model: ${task.model || '(default)'}${task.fallbackModel ? ` (fallback: ${task.fallbackModel})` : ''}` : null,
|
|
679
|
+
`channels: ${Array.isArray(task.channels) && task.channels.length ? task.channels.join(', ') : '(legacy flags)'}`,
|
|
680
|
+
`prompt: ${String(task.prompt || '').slice(0, 500)}`,
|
|
681
|
+
].filter(Boolean).join(String.fromCharCode(10));
|
|
682
|
+
return { success: true, message: summary, task };
|
|
683
|
+
}
|
|
684
|
+
}));
|
|
685
|
+
|
|
686
|
+
ctx.tools.register(defineTool({
|
|
687
|
+
name: 'cron_update_task',
|
|
688
|
+
description: 'Change an existing scheduled task. Ask the user what to change first. Switching a task to a code-executing type (script/node/python/ssh/docker) is refused unless confirmCodeSwitch is set, which requires the user to have agreed explicitly. Only the documented task fields can be changed.',
|
|
689
|
+
parameters: {
|
|
690
|
+
id: { type: 'string', description: 'Task identifier', required: true },
|
|
691
|
+
confirmCodeSwitch: { type: 'boolean', description: 'Set to true only after the user explicitly agreed to switch this task into a type that executes code' },
|
|
692
|
+
title: { type: 'string', description: 'New title' },
|
|
693
|
+
schedule: { type: 'string', description: 'New schedule (cron expression or interval)' },
|
|
694
|
+
prompt: { type: 'string', description: 'New prompt or command' },
|
|
695
|
+
type: { type: 'string', enum: TASK_TYPES, description: 'New execution type' },
|
|
696
|
+
model: { type: 'string', description: 'New model for agent tasks' },
|
|
697
|
+
fallbackModel: { type: 'string', description: 'New fallback model' },
|
|
698
|
+
provider: { type: 'string', description: 'New provider' },
|
|
699
|
+
channels: { type: 'array', items: { type: 'string', enum: CHANNEL_IDS }, description: 'New delivery channels' },
|
|
700
|
+
template: { type: 'string', description: 'New message template' },
|
|
701
|
+
onlyOnFailure: { type: 'boolean', description: 'Deliver only on failures' },
|
|
702
|
+
timeoutSeconds: { type: 'number', description: 'New execution timeout in seconds' },
|
|
703
|
+
overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'New overlap policy' },
|
|
704
|
+
maxRetries: { type: 'number', description: 'New retry count' },
|
|
705
|
+
kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'New Kanban policy' },
|
|
706
|
+
timezone: { type: 'string', description: 'New IANA time zone' },
|
|
707
|
+
cwd: { type: 'string', description: 'New working directory' },
|
|
708
|
+
env: { type: 'object', additionalProperties: true, description: 'New environment variables (KEY: value)' }
|
|
709
|
+
},
|
|
710
|
+
output: {
|
|
711
|
+
schema: {
|
|
712
|
+
type: 'object',
|
|
713
|
+
additionalProperties: true,
|
|
714
|
+
properties: {
|
|
715
|
+
success: { type: 'boolean' },
|
|
716
|
+
message: { type: 'string' }
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
720
|
+
},
|
|
721
|
+
execute: async (args) => {
|
|
722
|
+
const { id, confirmCodeSwitch, ...patch } = args || {};
|
|
723
|
+
if (!id) return { success: false, message: 'A task id is required' };
|
|
724
|
+
const result = applyTaskPatch({ store, scheduler, id, body: patch, allowCodeSwitch: confirmCodeSwitch === true });
|
|
725
|
+
if (!result.ok) return { success: false, message: result.error };
|
|
726
|
+
return {
|
|
727
|
+
success: true,
|
|
728
|
+
message: `Task "${result.task.title}" updated (${result.task.scheduleText || result.task.schedule}). Changed: ${describeTaskPatch(result.task, result.patch)}`,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
}));
|
|
732
|
+
|
|
1000
733
|
ctx.tools.register(defineTool({
|
|
1001
734
|
name: 'cron_run_task',
|
|
1002
735
|
description: 'Trigger a task immediately, out of band',
|