@goodandready/dsh-cron 0.2.5 → 0.2.7

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
@@ -52,6 +52,20 @@ 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
+ import { createMetricsHandler } from './metrics.js';
66
+ import { applyConfigSync, isConfigOwned, configOwnedMessage } from './config-jobs.js';
67
+ import { createExternalApiHandler, EXTERNAL_API_PREFIX } from './external-api.js';
68
+ export { createCronApiHandler };
55
69
 
56
70
  export const name = '@goodandready/dsh-cron';
57
71
  export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
@@ -70,6 +84,8 @@ export const Config = z.object({
70
84
  // --- Delivery channels (#20-#23, #26, #28, #47) ---
71
85
  template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
72
86
  channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
87
+ silentRuleModel: z.string().default('').description('Model used to evaluate a task silent rule (empty = the task model)'),
88
+ inspectorModel: z.string().default('').description('Model used to diagnose failed runs (empty = the task model)'),
73
89
  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
90
  discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
75
91
  slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
@@ -84,35 +100,22 @@ export const Config = z.object({
84
100
  giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
85
101
  giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
86
102
  giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
103
+ // --- Declarative jobs (#50, ADR-0001) ---
104
+ // The array is intentionally permissive: each entry is validated on its own
105
+ // with a precise message (see lib/config-jobs.js), so one broken entry cannot
106
+ // reject the whole profile config. Declared jobs are owned by the config:
107
+ // they are created or updated at startup, and removed when they disappear
108
+ // from the file. Tasks created in the UI, over the API or by an agent tool
109
+ // are never touched.
110
+ jobs: z.array(z.any()).default([]).description('Static jobs owned by the config; each entry needs id, title, schedule and (for agent types) prompt'),
111
+ // --- External REST API (#54, ADR-0001) ---
112
+ apiToken: z.string().role('secret').default('').description('Bearer token for the external /dsh-cron/api/* surface (empty = the surface answers 503)'),
87
113
  });
88
114
 
89
115
 
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
116
  export const SETTINGS_SYNC_KEYS = [
114
117
  'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
115
- 'template', 'channelTemplates', 'deliveryTimeoutMs',
118
+ 'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
116
119
  'discordWebhookUrl', 'slackWebhookUrl',
117
120
  'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
118
121
  'barkServerUrl', 'barkKey',
@@ -120,6 +123,7 @@ export const SETTINGS_SYNC_KEYS = [
120
123
  'pushplusUrl',
121
124
  'ttsBaseUrl',
122
125
  'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
126
+ 'apiToken',
123
127
  ];
124
128
 
125
129
  /**
@@ -181,341 +185,6 @@ export function sanitizeSettingsPayload(body) {
181
185
  return payload;
182
186
  }
183
187
 
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
188
  export function executeCreateTask(store, scheduler, args) {
520
189
  const taskType = normalizeTaskType(args.type);
521
190
  const typeError = validateTaskType(taskType, args);
@@ -578,6 +247,10 @@ export const createTaskParameters = {
578
247
  type: { type: 'string', enum: TASK_TYPES, description: 'Task type: llm | script | node | python | http | ssh | docker | skill | workflow' },
579
248
  delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
580
249
  provider: { type: 'string', description: 'Model provider (optional)' },
250
+ inspectOnFailure: { type: 'boolean', description: 'Diagnose failed runs with a model and store the diagnosis with the run (agent tasks)' },
251
+ silentRule: { type: 'string', description: 'Plain-language condition for staying silent on a successful run (script/node/python/http tasks)' },
252
+ fallbackModel: { type: 'string', description: 'Model tried once more when the primary model fails (llm/skill/workflow tasks)' },
253
+ fallbackProvider: { type: 'string', description: 'Provider for the fallback model (defaults to the task provider)' },
581
254
  model: { type: 'string', description: 'Model (optional)' },
582
255
  timezone: { type: 'string', description: 'IANA time zone for the schedule, e.g. "Europe/Berlin" (optional, default = server local)' },
583
256
  notifyTelegram: { type: 'boolean', description: 'Send the run report to Telegram' },
@@ -631,6 +304,7 @@ export function apply(ctx, config) {
631
304
  maxConcurrent: config && config.maxConcurrent,
632
305
  defaultTimezone: config && config.defaultTimezone,
633
306
  resolveSecrets: (settings) => resolveTelegramSecrets(ctx, settings),
307
+ askModel: makeAsk(ctx),
634
308
  deliver: (task, runInfo, settings) => deliverRun({
635
309
  task,
636
310
  runInfo,
@@ -667,6 +341,18 @@ export function apply(ctx, config) {
667
341
  }
668
342
  });
669
343
 
344
+ // #50: jobs declared in the profile config belong to the config. The sync
345
+ // runs before the scheduler starts and also when the section is gone: a
346
+ // config that dropped its jobs must still retire the tasks it used to own.
347
+ // A broken entry is reported and skipped, never fatal.
348
+ try {
349
+ const configJobs = Array.isArray(config && config.jobs) ? config.jobs : [];
350
+ const summary = applyConfigSync({ store, scheduler, entries: configJobs });
351
+ console.log('[dsh-cron] config jobs synced: ' + JSON.stringify(summary));
352
+ } catch (err) {
353
+ console.error('[dsh-cron] config job sync failed:', err.message);
354
+ }
355
+
670
356
  scheduler.start();
671
357
 
672
358
  // Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
@@ -688,35 +374,8 @@ export function apply(ctx, config) {
688
374
  };
689
375
  }, 'dsh-cron: lifecycle');
690
376
 
691
- const recommendations = [
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
- ];
377
+ // #48: the recipe hub is the single source of the panel's suggestions.
378
+ const recommendations = recipeRecommendations();
720
379
 
721
380
  // 1. REST API — collection (list/create) + item actions in one prefix
722
381
  // handler per route family; registered WITHOUT the trailing slash (#96)
@@ -733,6 +392,27 @@ export function apply(ctx, config) {
733
392
  handler: createCronApiHandler(store, scheduler, null)
734
393
  }), 'dsh-cron: /action/:id/:action');
735
394
 
395
+ // GET /dsh-cron/metrics (#53) — Prometheus text exposition
396
+ ctx.effect(() => ctx.webServer.register({
397
+ kind: 'exact',
398
+ path: '/dsh-cron/metrics',
399
+ handler: createMetricsHandler({ store, scheduler })
400
+ }), 'dsh-cron: /metrics');
401
+
402
+ // External REST API under /dsh-cron/api/* (#54, ADR-0001): the only surface
403
+ // guarded by a bearer token, so CI and host automation can drive the
404
+ // scheduler without a browser session. The token is read per request, so a
405
+ // settings change applies without a restart.
406
+ ctx.effect(() => ctx.webServer.register({
407
+ kind: 'prefix',
408
+ path: EXTERNAL_API_PREFIX,
409
+ handler: createExternalApiHandler({
410
+ store,
411
+ scheduler,
412
+ getToken: () => store.getSettings().apiToken || ''
413
+ })
414
+ }), 'dsh-cron: /api[...]');
415
+
736
416
  // GET /dsh-cron/models
737
417
  ctx.effect(() => ctx.webServer.register({
738
418
  kind: 'exact',
@@ -783,6 +463,25 @@ export function apply(ctx, config) {
783
463
  }
784
464
  }), 'dsh-cron: /settings');
785
465
 
466
+ // GET /dsh-cron/recipes (#48): the built-in recipe catalog.
467
+ ctx.effect(() => ctx.webServer.register({
468
+ kind: 'exact',
469
+ path: '/dsh-cron/recipes',
470
+ handler: async (req, res) => {
471
+ try {
472
+ if (req.method !== 'GET') {
473
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
474
+ return;
475
+ }
476
+ const categories = recipesByCategory();
477
+ const recipes = categories.reduce((acc, category) => acc.concat(category.recipes), []);
478
+ sendJson(res, 200, { ok: true, categories, recipes });
479
+ } catch (err) {
480
+ sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
481
+ }
482
+ }
483
+ }), 'dsh-cron: /recipes');
484
+
786
485
  // GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
787
486
  ctx.effect(() => ctx.webServer.register({
788
487
  kind: 'exact',
@@ -943,6 +642,10 @@ export function apply(ctx, config) {
943
642
  render: (_args, val) => [{ type: 'text', text: val.message }]
944
643
  },
945
644
  execute: async (args) => {
645
+ // A config-owned task cannot be paused from here either: the next start
646
+ // would resume it, and the agent would have reported a change that never
647
+ // happened (#50 review finding).
648
+ if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
946
649
  const task = scheduler.pauseTask(args.id);
947
650
  if (!task) return { success: false, message: 'Task not found' };
948
651
  return { success: true, message: `Task "${task.title}" paused` };
@@ -967,6 +670,7 @@ export function apply(ctx, config) {
967
670
  render: (_args, val) => [{ type: 'text', text: val.message }]
968
671
  },
969
672
  execute: async (args) => {
673
+ if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
970
674
  const task = scheduler.resumeTask(args.id);
971
675
  if (!task) return { success: false, message: 'Task not found' };
972
676
  return { success: true, message: `Task "${task.title}" resumed (${task.scheduleText})` };
@@ -991,12 +695,96 @@ export function apply(ctx, config) {
991
695
  render: (_args, val) => [{ type: 'text', text: val.message }]
992
696
  },
993
697
  execute: async (args) => {
698
+ // Deleting a config-owned task would also drop its run history, and the
699
+ // task would come back at the next start without it.
700
+ if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
994
701
  scheduler.pauseTask(args.id);
995
702
  const ok = store.delete(args.id);
996
703
  return { success: ok, message: ok ? 'Task deleted' : 'Task not found' };
997
704
  }
998
705
  }));
999
706
 
707
+ ctx.tools.register(defineTool({
708
+ name: 'cron_get_task',
709
+ description: 'Read the full configuration of one scheduled task, including fields that are not visible in the task list',
710
+ parameters: {
711
+ id: { type: 'string', description: 'Task identifier', required: true }
712
+ },
713
+ output: {
714
+ schema: {
715
+ type: 'object',
716
+ additionalProperties: true,
717
+ properties: {
718
+ success: { type: 'boolean' },
719
+ message: { type: 'string' },
720
+ task: { type: 'object', additionalProperties: true }
721
+ }
722
+ },
723
+ render: (_args, val) => [{ type: 'text', text: val.message }]
724
+ },
725
+ execute: async (args) => {
726
+ const task = store.get(args.id);
727
+ if (!task) return { success: false, message: 'Task not found' };
728
+ const summary = [
729
+ `Task "${task.title}" (${task.id})`,
730
+ `type: ${task.type || 'llm'}`,
731
+ `schedule: ${task.scheduleText || task.schedule}`,
732
+ `status: ${task.status}`,
733
+ task.type === 'llm' || !task.type ? `model: ${task.model || '(default)'}${task.fallbackModel ? ` (fallback: ${task.fallbackModel})` : ''}` : null,
734
+ `channels: ${Array.isArray(task.channels) && task.channels.length ? task.channels.join(', ') : '(legacy flags)'}`,
735
+ `prompt: ${String(task.prompt || '').slice(0, 500)}`,
736
+ ].filter(Boolean).join(String.fromCharCode(10));
737
+ return { success: true, message: summary, task };
738
+ }
739
+ }));
740
+
741
+ ctx.tools.register(defineTool({
742
+ name: 'cron_update_task',
743
+ 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.',
744
+ parameters: {
745
+ id: { type: 'string', description: 'Task identifier', required: true },
746
+ confirmCodeSwitch: { type: 'boolean', description: 'Set to true only after the user explicitly agreed to switch this task into a type that executes code' },
747
+ title: { type: 'string', description: 'New title' },
748
+ schedule: { type: 'string', description: 'New schedule (cron expression or interval)' },
749
+ prompt: { type: 'string', description: 'New prompt or command' },
750
+ type: { type: 'string', enum: TASK_TYPES, description: 'New execution type' },
751
+ model: { type: 'string', description: 'New model for agent tasks' },
752
+ fallbackModel: { type: 'string', description: 'New fallback model' },
753
+ provider: { type: 'string', description: 'New provider' },
754
+ channels: { type: 'array', items: { type: 'string', enum: CHANNEL_IDS }, description: 'New delivery channels' },
755
+ template: { type: 'string', description: 'New message template' },
756
+ onlyOnFailure: { type: 'boolean', description: 'Deliver only on failures' },
757
+ timeoutSeconds: { type: 'number', description: 'New execution timeout in seconds' },
758
+ overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'New overlap policy' },
759
+ maxRetries: { type: 'number', description: 'New retry count' },
760
+ kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'New Kanban policy' },
761
+ timezone: { type: 'string', description: 'New IANA time zone' },
762
+ cwd: { type: 'string', description: 'New working directory' },
763
+ env: { type: 'object', additionalProperties: true, description: 'New environment variables (KEY: value)' }
764
+ },
765
+ output: {
766
+ schema: {
767
+ type: 'object',
768
+ additionalProperties: true,
769
+ properties: {
770
+ success: { type: 'boolean' },
771
+ message: { type: 'string' }
772
+ }
773
+ },
774
+ render: (_args, val) => [{ type: 'text', text: val.message }]
775
+ },
776
+ execute: async (args) => {
777
+ const { id, confirmCodeSwitch, ...patch } = args || {};
778
+ if (!id) return { success: false, message: 'A task id is required' };
779
+ const result = applyTaskPatch({ store, scheduler, id, body: patch, allowCodeSwitch: confirmCodeSwitch === true });
780
+ if (!result.ok) return { success: false, message: result.error };
781
+ return {
782
+ success: true,
783
+ message: `Task "${result.task.title}" updated (${result.task.scheduleText || result.task.schedule}). Changed: ${describeTaskPatch(result.task, result.patch)}`,
784
+ };
785
+ }
786
+ }));
787
+
1000
788
  ctx.tools.register(defineTool({
1001
789
  name: 'cron_run_task',
1002
790
  description: 'Trigger a task immediately, out of band',