@goodandready/dsh-cron 0.2.3 → 0.2.5

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
@@ -14,12 +14,51 @@ import {
14
14
  pickPatchableFields,
15
15
  SCRIPT_CONFIRM_HEADER,
16
16
  } from './http-utils.js';
17
+ import { TASK_TYPES, CODE_EXECUTING_TYPES, normalizeTaskType } from './runtimes.js';
18
+ import { resolveTelegramSecrets, resolveCredentialValue } from './secrets.js';
19
+ import {
20
+ PATCHABLE_TASK_FIELDS,
21
+ DUPLICATE_TASK_FIELDS,
22
+ RESERVED_TASK_IDS,
23
+ EXPORT_SECRET_BEARING_FIELDS,
24
+ validateTaskType,
25
+ buildDuplicateTask,
26
+ buildTaskExport,
27
+ validateImportDocument,
28
+ hasCodeExecutingTask,
29
+ planImport,
30
+ TASK_EXPORT_KIND,
31
+ TASK_EXPORT_VERSION,
32
+ EXPORT_TASK_FIELDS,
33
+ IMPORT_STRATEGIES,
34
+ } from './task-transfer.js';
35
+
36
+ // Re-exported for backward compatibility: tests and tools import these names
37
+ // from lib/index.js.
38
+ export {
39
+ PATCHABLE_TASK_FIELDS,
40
+ DUPLICATE_TASK_FIELDS,
41
+ RESERVED_TASK_IDS,
42
+ EXPORT_SECRET_BEARING_FIELDS,
43
+ validateTaskType,
44
+ buildDuplicateTask,
45
+ buildTaskExport,
46
+ validateImportDocument,
47
+ hasCodeExecutingTask,
48
+ planImport,
49
+ TASK_EXPORT_KIND,
50
+ TASK_EXPORT_VERSION,
51
+ EXPORT_TASK_FIELDS,
52
+ IMPORT_STRATEGIES,
53
+ };
54
+ import { deliverRun, CHANNEL_IDS, resolveDeliveryTimeoutMs, MIN_DELIVERY_TIMEOUT_MS } from './channels.js';
17
55
 
18
56
  export const name = '@goodandready/dsh-cron';
19
- export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel'];
57
+ export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
20
58
 
21
59
  export const Config = z.object({
22
- botToken: z.string().role('secret').default('').description('Telegram Bot API token used to deliver task reports'),
60
+ botToken: z.string().role('secret').default('').description('Legacy Telegram Bot API token (prefer botTokenRef with the DSH credentials service)'),
61
+ botTokenRef: z.string().default('').description('Name of the DSH credential that holds the Telegram bot token'),
23
62
  chatId: z.string().default('').description('Telegram chat ID that receives task reports'),
24
63
  notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
25
64
  onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
@@ -28,30 +67,25 @@ export const Config = z.object({
28
67
  maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
29
68
  heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
30
69
  heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)'),
70
+ // --- Delivery channels (#20-#23, #26, #28, #47) ---
71
+ template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
72
+ channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
73
+ 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
+ discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
75
+ slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
76
+ ntfyUrl: z.string().default('https://ntfy.sh').description('ntfy server base URL'),
77
+ ntfyTopic: z.string().default('').description('ntfy topic for push notifications'),
78
+ ntfyTokenRef: z.string().default('').description('Credential name for the ntfy access token'),
79
+ barkServerUrl: z.string().default('https://api.day.app').description('Bark server base URL'),
80
+ barkKey: z.string().default('').description('Bark device key'),
81
+ pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token'),
82
+ pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)'),
83
+ ttsBaseUrl: z.string().default('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route'),
84
+ giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
85
+ giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
86
+ giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
31
87
  });
32
88
 
33
- const PATCHABLE_TASK_FIELDS = [
34
- 'title',
35
- 'schedule',
36
- 'scheduleText',
37
- 'prompt',
38
- 'type',
39
- 'delivery',
40
- 'provider',
41
- 'model',
42
- 'timezone',
43
- 'misfirePolicy',
44
- 'maxRetries',
45
- 'retryBackoffMs',
46
- 'permissionPreset',
47
- 'notifyTelegram',
48
- 'onlyOnFailure',
49
- 'timeoutSeconds',
50
- 'overlapPolicy',
51
- 'kanbanMode',
52
- 'status',
53
- 'oneShot',
54
- ];
55
89
 
56
90
  function sendJson(res, statusCode, data) {
57
91
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
@@ -75,6 +109,19 @@ async function readBody(req, res) {
75
109
  }
76
110
  }
77
111
 
112
+ /** Settings keys synchronized between the store and the settings service. */
113
+ export const SETTINGS_SYNC_KEYS = [
114
+ 'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
115
+ 'template', 'channelTemplates', 'deliveryTimeoutMs',
116
+ 'discordWebhookUrl', 'slackWebhookUrl',
117
+ 'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
118
+ 'barkServerUrl', 'barkKey',
119
+ 'pushplusTokenRef',
120
+ 'pushplusUrl',
121
+ 'ttsBaseUrl',
122
+ 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
123
+ ];
124
+
78
125
  /**
79
126
  * Apply settings writes through the registered settings scope so the core
80
127
  * snapshot stays authoritative (#102). Returns null when the settings
@@ -83,7 +130,7 @@ async function readBody(req, res) {
83
130
  export async function applySettingsToScope(settingsScope, body) {
84
131
  if (!settingsScope || typeof settingsScope.set !== 'function') return null;
85
132
  const errors = [];
86
- for (const key of ['botToken', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl']) {
133
+ for (const key of SETTINGS_SYNC_KEYS) {
87
134
  if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
88
135
  try {
89
136
  await settingsScope.set(key, body[key]);
@@ -95,13 +142,41 @@ export async function applySettingsToScope(settingsScope, body) {
95
142
  }
96
143
 
97
144
  /**
98
- * Drop values that must never re-enter the store from the client: a masked
99
- * secret echoed back by the settings UI would overwrite the real token.
145
+ * Drop values that must never re-enter the store from the client:
146
+ *
147
+ * - a masked secret echoed back by the settings UI would overwrite the real
148
+ * value (bot token, webhook URLs, Bark key);
149
+ * - the raw credential keys are refused outright — the *Ref fields are the
150
+ * supported way to configure them.
151
+ *
152
+ * Applied before both the settings scope and the store, because the scope is
153
+ * the source of truth and would otherwise persist a raw or masked value.
100
154
  */
101
155
  export function sanitizeSettingsPayload(body) {
102
156
  const payload = Object.assign({}, body);
103
- if (typeof payload.botToken === 'string' && (payload.botToken.includes('••') || payload.botToken.includes('****'))) {
104
- delete payload.botToken;
157
+ for (const key of TaskStore.MASKED_SETTING_KEYS) {
158
+ const val = payload[key];
159
+ if (typeof val === 'string' && (val.includes('••') || val.includes('****'))) {
160
+ delete payload[key];
161
+ }
162
+ }
163
+ for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
164
+ if (key in payload) {
165
+ console.warn(`[dsh-cron] refusing raw secret "${key}" from the client — use a credential reference`);
166
+ delete payload[key];
167
+ }
168
+ }
169
+ // #115: normalise the delivery deadline here, before it reaches the settings
170
+ // scope, so a value like 1 ms can never be persisted and then fail every
171
+ // delivery before the request is even sent.
172
+ if ('deliveryTimeoutMs' in payload) {
173
+ const raw = payload.deliveryTimeoutMs;
174
+ const n = Number(raw);
175
+ if (raw === '' || raw === null || !Number.isFinite(n) || n <= 0) {
176
+ delete payload.deliveryTimeoutMs;
177
+ } else {
178
+ payload.deliveryTimeoutMs = resolveDeliveryTimeoutMs(n);
179
+ }
105
180
  }
106
181
  return payload;
107
182
  }
@@ -150,12 +225,24 @@ export function createCronApiHandler(store, scheduler, collection) {
150
225
  sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
151
226
  return;
152
227
  }
153
- const taskType = body.type === 'script' ? 'script' : 'llm';
154
- if (taskType === 'script' && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
155
- sendJson(res, 403, { ok: false, error: 'Creating script tasks over HTTP requires the x-dsh-cron-confirm: script header' });
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 });
156
236
  return;
157
237
  }
158
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
+ }
159
246
  const current = body.id ? store.get(body.id) : null;
160
247
  const resolvedStatus = body.status || (current ? current.status : 'active');
161
248
 
@@ -180,6 +267,28 @@ export function createCronApiHandler(store, scheduler, collection) {
180
267
  maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
181
268
  retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
182
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 : ''),
183
292
  oneShot: Boolean(parsed.isOneShot || body.oneShot),
184
293
  });
185
294
  if (task.status === 'active') {
@@ -197,6 +306,93 @@ export function createCronApiHandler(store, scheduler, collection) {
197
306
  // Item level: /dsh-cron/{tasks|action}/:id[/:action]
198
307
  if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
199
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
+
200
396
  if (req.method === 'GET' && (action === 'history' || !action)) {
201
397
  const limit = parseInt(url.searchParams.get('limit') || '20', 10);
202
398
  sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
@@ -213,6 +409,21 @@ export function createCronApiHandler(store, scheduler, collection) {
213
409
  return;
214
410
  }
215
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
+
216
427
  if (req.method === 'POST' && action === 'pause') {
217
428
  const task = scheduler.pauseTask(id);
218
429
  if (!task) {
@@ -254,9 +465,19 @@ export function createCronApiHandler(store, scheduler, collection) {
254
465
  if (error) return;
255
466
  // Whitelist patchable fields so stats/timestamps stay server-owned (#90)
256
467
  const patchData = pickPatchableFields(body, PATCHABLE_TASK_FIELDS);
257
- if (patchData.type === 'script' && current.type !== 'script' && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
258
- sendJson(res, 403, { ok: false, error: 'Switching a task to the script type over HTTP requires the x-dsh-cron-confirm: script header' });
259
- return;
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
+ }
260
481
  }
261
482
  if (patchData.schedule) {
262
483
  const parsed = parseScheduleExpression(patchData.schedule);
@@ -296,7 +517,9 @@ export function createCronApiHandler(store, scheduler, collection) {
296
517
  * alias pair (#70, #92). Exported for unit tests.
297
518
  */
298
519
  export function executeCreateTask(store, scheduler, args) {
299
- const taskType = args.type === 'script' ? 'script' : 'llm';
520
+ const taskType = normalizeTaskType(args.type);
521
+ const typeError = validateTaskType(taskType, args);
522
+ if (typeError) throw new Error(typeError);
300
523
  const parsed = parseScheduleExpression(args.schedule);
301
524
  const task = store.set({
302
525
  title: args.title,
@@ -318,6 +541,26 @@ export function executeCreateTask(store, scheduler, args) {
318
541
  retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
319
542
  permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
320
543
  kanbanMode: args.kanbanMode || 'none',
544
+ channels: Array.isArray(args.channels) ? args.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined,
545
+ template: args.template ? String(args.template) : '',
546
+ env: args.env && typeof args.env === 'object' ? args.env : undefined,
547
+ cwd: args.cwd ? String(args.cwd).trim() : '',
548
+ workspaceId: args.workspaceId ? String(args.workspaceId).trim() : '',
549
+ worktree: Boolean(args.worktree),
550
+ keepWorktree: Boolean(args.keepWorktree),
551
+ httpMethod: args.httpMethod ? String(args.httpMethod).toUpperCase() : 'GET',
552
+ httpUrl: args.httpUrl ? String(args.httpUrl).trim() : '',
553
+ httpHeaders: args.httpHeaders,
554
+ httpBody: args.httpBody !== undefined ? String(args.httpBody) : '',
555
+ sshProfileId: args.sshProfileId ? String(args.sshProfileId).trim() : '',
556
+ sshTarget: args.sshTarget ? String(args.sshTarget).trim() : '',
557
+ sshPort: Number(args.sshPort) || 0,
558
+ sshKeyPath: args.sshKeyPath ? String(args.sshKeyPath).trim() : '',
559
+ dockerImage: args.dockerImage ? String(args.dockerImage).trim() : '',
560
+ pythonPath: args.pythonPath ? String(args.pythonPath).trim() : '',
561
+ nodePath: args.nodePath ? String(args.nodePath).trim() : '',
562
+ skillName: args.skillName ? String(args.skillName).trim() : '',
563
+ workflowName: args.workflowName ? String(args.workflowName).trim() : '',
321
564
  oneShot: Boolean(parsed.isOneShot),
322
565
  });
323
566
  scheduler.scheduleTask(task);
@@ -328,11 +571,11 @@ export function executeCreateTask(store, scheduler, args) {
328
571
  };
329
572
  }
330
573
 
331
- const createTaskParameters = {
574
+ export const createTaskParameters = {
332
575
  title: { type: 'string', description: 'Short task name', required: true },
333
576
  schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), shorthand ("@daily", "@every 30m"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
334
577
  prompt: { type: 'string', description: 'Prompt/instruction for the agent at run time', required: true },
335
- type: { type: 'string', enum: ['prompt', 'script'], description: 'Task type' },
578
+ type: { type: 'string', enum: TASK_TYPES, description: 'Task type: llm | script | node | python | http | ssh | docker | skill | workflow' },
336
579
  delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
337
580
  provider: { type: 'string', description: 'Model provider (optional)' },
338
581
  model: { type: 'string', description: 'Model (optional)' },
@@ -346,6 +589,26 @@ const createTaskParameters = {
346
589
  retryBackoffMs: { type: 'number', description: 'Base backoff between retries in ms, doubled per attempt' },
347
590
  permissionPreset: { type: 'string', enum: ['default', 'read-only', 'workspace-write', 'full'], description: 'Permission preset applied to the task session' },
348
591
  kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Create a dsh-kanban card for runs' },
592
+ channels: { type: 'array', items: { type: 'string', enum: CHANNEL_IDS }, description: 'Notification channels for this task; empty = legacy telegram/kanban flags' },
593
+ template: { type: 'string', description: 'Notification template with {title} {status} {output} {error} {duration} placeholders; empty = built-in text' },
594
+ env: { type: 'object', additionalProperties: true, description: 'Environment variables for external runtimes (KEY: value); do not store secrets here' },
595
+ cwd: { type: 'string', description: 'Working directory for the run (external runtimes and agent sessions)' },
596
+ workspaceId: { type: 'string', description: 'Workspace bound to the task; resolved through the harness' },
597
+ worktree: { type: 'boolean', description: 'Run code-modifying agent tasks in an isolated git worktree' },
598
+ keepWorktree: { type: 'boolean', description: 'Keep the created worktree after the run' },
599
+ nodePath: { type: 'string', description: 'Node.js binary for node tasks (default: the harness runtime)' },
600
+ pythonPath: { type: 'string', description: 'Python interpreter for python tasks (default: auto-detected venv)' },
601
+ httpMethod: { type: 'string', description: 'HTTP method for http tasks' },
602
+ httpUrl: { type: 'string', description: 'URL for http tasks' },
603
+ httpHeaders: { type: 'object', additionalProperties: true, description: 'Headers for http tasks' },
604
+ httpBody: { type: 'string', description: 'Request body for http tasks' },
605
+ sshProfileId: { type: 'string', description: 'dsh-remote-workspace profile id that provides host and credentials' },
606
+ sshTarget: { type: 'string', description: 'Fallback user@host when no remote-workspace profile is used' },
607
+ sshPort: { type: 'number', description: 'SSH port (with sshTarget fallback)' },
608
+ sshKeyPath: { type: 'string', description: 'Private key path (with sshTarget fallback)' },
609
+ dockerImage: { type: 'string', description: 'Container image for docker tasks' },
610
+ skillName: { type: 'string', description: 'DSH skill name for skill tasks' },
611
+ workflowName: { type: 'string', description: 'DSH workflow name for workflow tasks' },
349
612
  };
350
613
 
351
614
  const createTaskOutput = {
@@ -367,6 +630,16 @@ export function apply(ctx, config) {
367
630
  const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts), {
368
631
  maxConcurrent: config && config.maxConcurrent,
369
632
  defaultTimezone: config && config.defaultTimezone,
633
+ resolveSecrets: (settings) => resolveTelegramSecrets(ctx, settings),
634
+ deliver: (task, runInfo, settings) => deliverRun({
635
+ task,
636
+ runInfo,
637
+ settings,
638
+ secrets: {
639
+ botToken: settings.botToken || '',
640
+ resolveSecret: (ref) => resolveCredentialValue(ctx, ref),
641
+ },
642
+ }),
370
643
  });
371
644
 
372
645
  // Register settings in the Harness settings service (#71, #102): the scope
@@ -49,7 +49,8 @@ export async function createKanbanCard({
49
49
  column = 'backlog',
50
50
  labels = ['cron', 'alert'],
51
51
  fetchFn = globalThis.fetch,
52
- kanbanBaseUrl = 'http://127.0.0.1:3000'
52
+ kanbanBaseUrl = 'http://127.0.0.1:3000',
53
+ signal
53
54
  }) {
54
55
  try {
55
56
  const url = `${kanbanBaseUrl.replace(/\/$/, '')}/dsh-kanban/task`;
@@ -58,6 +59,7 @@ export async function createKanbanCard({
58
59
  headers: {
59
60
  'Content-Type': 'application/json',
60
61
  },
62
+ signal,
61
63
  body: JSON.stringify({
62
64
  title,
63
65
  body,