@goodandready/dsh-cron 0.2.2 → 0.2.4

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,16 +14,48 @@ 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 { deliverRun, CHANNEL_IDS } from './channels.js';
17
20
 
18
21
  export const name = '@goodandready/dsh-cron';
19
- export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel'];
22
+ export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
20
23
 
21
24
  export const Config = z.object({
22
- botToken: z.string().role('secret').default('').description('Telegram Bot API token used to deliver task reports'),
25
+ botToken: z.string().role('secret').default('').description('Legacy Telegram Bot API token (prefer botTokenRef with the DSH credentials service)'),
26
+ botTokenRef: z.string().default('').description('Name of the DSH credential that holds the Telegram bot token'),
23
27
  chatId: z.string().default('').description('Telegram chat ID that receives task reports'),
24
28
  notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
25
29
  onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
26
30
  kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API'),
31
+ defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)'),
32
+ maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
33
+ heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
34
+ heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)'),
35
+ // --- Delivery channels (#20-#23, #26, #28, #47) ---
36
+ template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
37
+ channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
38
+ deliveryTimeoutMs: z.number().default(15000).description('Per-channel delivery timeout in ms; a slower endpoint is recorded as a failure and does not block other channels or the next tick'),
39
+ discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
40
+ slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
41
+ ntfyUrl: z.string().default('https://ntfy.sh').description('ntfy server base URL'),
42
+ ntfyTopic: z.string().default('').description('ntfy topic for push notifications'),
43
+ ntfyTokenRef: z.string().default('').description('Credential name for the ntfy access token'),
44
+ barkServerUrl: z.string().default('https://api.day.app').description('Bark server base URL'),
45
+ barkKey: z.string().default('').description('Bark device key'),
46
+ pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token'),
47
+ pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)'),
48
+ smtpHost: z.string().default('').description('SMTP host for email notifications'),
49
+ smtpPort: z.number().default(587).description('SMTP port'),
50
+ smtpSecure: z.boolean().default(false).description('Use implicit TLS for SMTP'),
51
+ smtpUser: z.string().default('').description('SMTP user name'),
52
+ smtpPasswordRef: z.string().default('').description('Credential name for the SMTP password'),
53
+ smtpFrom: z.string().default('').description('From address for email notifications'),
54
+ smtpTo: z.string().default('').description('Comma-separated recipient addresses'),
55
+ ttsBaseUrl: z.string().default('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route'),
56
+ giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
57
+ giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
58
+ giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
27
59
  });
28
60
 
29
61
  const PATCHABLE_TASK_FIELDS = [
@@ -35,15 +67,67 @@ const PATCHABLE_TASK_FIELDS = [
35
67
  'delivery',
36
68
  'provider',
37
69
  'model',
70
+ 'timezone',
71
+ 'misfirePolicy',
72
+ 'maxRetries',
73
+ 'retryBackoffMs',
74
+ 'permissionPreset',
75
+ 'env',
76
+ 'cwd',
77
+ 'workspaceId',
78
+ 'worktree',
79
+ 'keepWorktree',
80
+ 'httpMethod',
81
+ 'httpUrl',
82
+ 'httpHeaders',
83
+ 'httpBody',
84
+ 'sshTarget',
85
+ 'sshPort',
86
+ 'sshKeyPath',
87
+ 'sshProfileId',
88
+ 'dockerImage',
89
+ 'pythonPath',
90
+ 'nodePath',
91
+ 'skillName',
92
+ 'workflowName',
38
93
  'notifyTelegram',
39
94
  'onlyOnFailure',
40
95
  'timeoutSeconds',
41
96
  'overlapPolicy',
42
97
  'kanbanMode',
98
+ 'channels',
99
+ 'template',
43
100
  'status',
44
101
  'oneShot',
45
102
  ];
46
103
 
104
+ /**
105
+ * Validate runtime-specific task configuration. Returns an error string or
106
+ * null when the payload is usable for the given type.
107
+ */
108
+ export function validateTaskType(type, payload) {
109
+ const t = normalizeTaskType(type);
110
+ if (t === 'http') {
111
+ const raw = String((payload && (payload.httpUrl || payload.prompt)) || '').trim();
112
+ if (!raw) return 'HTTP tasks require a URL (httpUrl or prompt)';
113
+ try {
114
+ // eslint-disable-next-line no-new
115
+ new URL(raw);
116
+ } catch {
117
+ return `Invalid HTTP URL: ${raw}`;
118
+ }
119
+ }
120
+ if (t === 'ssh') {
121
+ const hasProfile = Boolean(payload && String(payload.sshProfileId || '').trim());
122
+ const hasTarget = Boolean(payload && String(payload.sshTarget || '').trim());
123
+ if (!hasProfile && !hasTarget) return 'SSH tasks require sshProfileId (remote-workspace profile) or sshTarget (user@host)';
124
+ }
125
+ if (t === 'docker' && !String((payload && payload.dockerImage) || '').trim()) {
126
+ return 'Docker tasks require dockerImage';
127
+ }
128
+ return null;
129
+ }
130
+
47
131
  function sendJson(res, statusCode, data) {
48
132
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
49
133
  res.end(JSON.stringify(data));
@@ -66,6 +150,20 @@ async function readBody(req, res) {
66
150
  }
67
151
  }
68
152
 
153
+ /** Settings keys synchronized between the store and the settings service. */
154
+ export const SETTINGS_SYNC_KEYS = [
155
+ 'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
156
+ 'template', 'channelTemplates', 'deliveryTimeoutMs',
157
+ 'discordWebhookUrl', 'slackWebhookUrl',
158
+ 'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
159
+ 'barkServerUrl', 'barkKey',
160
+ 'pushplusTokenRef',
161
+ 'pushplusUrl',
162
+ 'smtpHost', 'smtpPort', 'smtpSecure', 'smtpUser', 'smtpPasswordRef', 'smtpFrom', 'smtpTo',
163
+ 'ttsBaseUrl',
164
+ 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
165
+ ];
166
+
69
167
  /**
70
168
  * Apply settings writes through the registered settings scope so the core
71
169
  * snapshot stays authoritative (#102). Returns null when the settings
@@ -74,7 +172,7 @@ async function readBody(req, res) {
74
172
  export async function applySettingsToScope(settingsScope, body) {
75
173
  if (!settingsScope || typeof settingsScope.set !== 'function') return null;
76
174
  const errors = [];
77
- for (const key of ['botToken', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl']) {
175
+ for (const key of SETTINGS_SYNC_KEYS) {
78
176
  if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
79
177
  try {
80
178
  await settingsScope.set(key, body[key]);
@@ -86,13 +184,29 @@ export async function applySettingsToScope(settingsScope, body) {
86
184
  }
87
185
 
88
186
  /**
89
- * Drop values that must never re-enter the store from the client: a masked
90
- * secret echoed back by the settings UI would overwrite the real token.
187
+ * Drop values that must never re-enter the store from the client:
188
+ *
189
+ * - a masked secret echoed back by the settings UI would overwrite the real
190
+ * value (bot token, SMTP password, webhook URLs, Bark key);
191
+ * - the raw credential keys are refused outright — the *Ref fields are the
192
+ * supported way to configure them.
193
+ *
194
+ * Applied before both the settings scope and the store, because the scope is
195
+ * the source of truth and would otherwise persist a raw or masked value.
91
196
  */
92
197
  export function sanitizeSettingsPayload(body) {
93
198
  const payload = Object.assign({}, body);
94
- if (typeof payload.botToken === 'string' && (payload.botToken.includes('••') || payload.botToken.includes('****'))) {
95
- delete payload.botToken;
199
+ for (const key of TaskStore.MASKED_SETTING_KEYS) {
200
+ const val = payload[key];
201
+ if (typeof val === 'string' && (val.includes('••') || val.includes('****'))) {
202
+ delete payload[key];
203
+ }
204
+ }
205
+ for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
206
+ if (key in payload) {
207
+ console.warn(`[dsh-cron] refusing raw secret "${key}" from the client — use a credential reference`);
208
+ delete payload[key];
209
+ }
96
210
  }
97
211
  return payload;
98
212
  }
@@ -124,7 +238,11 @@ export function createCronApiHandler(store, scheduler, collection) {
124
238
  if (req.method === 'GET') {
125
239
  const status = url.searchParams.get('status') || 'all';
126
240
  const query = url.searchParams.get('query') || '';
127
- const list = store.list({ status, query });
241
+ const list = store.list({ status, query }).map((t) => ({
242
+ ...t,
243
+ running: scheduler.isRunning(t.id),
244
+ runningSince: scheduler.runningSince(t.id),
245
+ }));
128
246
  const stats = store.getAggregatedStats();
129
247
  sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
130
248
  return;
@@ -137,9 +255,14 @@ export function createCronApiHandler(store, scheduler, collection) {
137
255
  sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
138
256
  return;
139
257
  }
140
- const taskType = body.type === 'script' ? 'script' : 'llm';
141
- if (taskType === 'script' && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
142
- sendJson(res, 403, { ok: false, error: 'Creating script tasks over HTTP requires the x-dsh-cron-confirm: script header' });
258
+ const taskType = normalizeTaskType(body.type);
259
+ if (CODE_EXECUTING_TYPES.includes(taskType) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
260
+ sendJson(res, 403, { ok: false, error: `Creating ${taskType} tasks over HTTP requires the x-dsh-cron-confirm: script header` });
261
+ return;
262
+ }
263
+ const typeError = validateTaskType(taskType, body);
264
+ if (typeError) {
265
+ sendJson(res, 400, { ok: false, error: typeError });
143
266
  return;
144
267
  }
145
268
  const parsed = parseScheduleExpression(body.schedule);
@@ -162,6 +285,33 @@ export function createCronApiHandler(store, scheduler, collection) {
162
285
  timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
163
286
  overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
164
287
  kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
288
+ timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
289
+ misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
290
+ maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
291
+ retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
292
+ permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
293
+ env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
294
+ cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
295
+ channels: Array.isArray(body.channels)
296
+ ? body.channels.filter((c) => CHANNEL_IDS.includes(c))
297
+ : (current ? current.channels : undefined),
298
+ template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
299
+ workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
300
+ worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
301
+ keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
302
+ httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
303
+ httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
304
+ httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
305
+ httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
306
+ sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
307
+ sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
308
+ sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
309
+ sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
310
+ dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
311
+ pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
312
+ nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
313
+ skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
314
+ workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
165
315
  oneShot: Boolean(parsed.isOneShot || body.oneShot),
166
316
  });
167
317
  if (task.status === 'active') {
@@ -236,9 +386,19 @@ export function createCronApiHandler(store, scheduler, collection) {
236
386
  if (error) return;
237
387
  // Whitelist patchable fields so stats/timestamps stay server-owned (#90)
238
388
  const patchData = pickPatchableFields(body, PATCHABLE_TASK_FIELDS);
239
- if (patchData.type === 'script' && current.type !== 'script' && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
240
- sendJson(res, 403, { ok: false, error: 'Switching a task to the script type over HTTP requires the x-dsh-cron-confirm: script header' });
241
- return;
389
+ if (patchData.type !== undefined) {
390
+ patchData.type = normalizeTaskType(patchData.type);
391
+ const switchingToCode = CODE_EXECUTING_TYPES.includes(patchData.type)
392
+ && !CODE_EXECUTING_TYPES.includes(current.type);
393
+ if (switchingToCode && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
394
+ sendJson(res, 403, { ok: false, error: `Switching a task to the ${patchData.type} type over HTTP requires the x-dsh-cron-confirm: script header` });
395
+ return;
396
+ }
397
+ const typeError = validateTaskType(patchData.type, { ...current, ...patchData });
398
+ if (typeError) {
399
+ sendJson(res, 400, { ok: false, error: typeError });
400
+ return;
401
+ }
242
402
  }
243
403
  if (patchData.schedule) {
244
404
  const parsed = parseScheduleExpression(patchData.schedule);
@@ -278,7 +438,9 @@ export function createCronApiHandler(store, scheduler, collection) {
278
438
  * alias pair (#70, #92). Exported for unit tests.
279
439
  */
280
440
  export function executeCreateTask(store, scheduler, args) {
281
- const taskType = args.type === 'script' ? 'script' : 'llm';
441
+ const taskType = normalizeTaskType(args.type);
442
+ const typeError = validateTaskType(taskType, args);
443
+ if (typeError) throw new Error(typeError);
282
444
  const parsed = parseScheduleExpression(args.schedule);
283
445
  const task = store.set({
284
446
  title: args.title,
@@ -294,7 +456,32 @@ export function executeCreateTask(store, scheduler, args) {
294
456
  onlyOnFailure: Boolean(args.onlyOnFailure),
295
457
  timeoutSeconds: Number(args.timeoutSeconds) || 1800,
296
458
  overlapPolicy: args.overlapPolicy || 'skip',
459
+ timezone: args.timezone ? String(args.timezone).trim() : '',
460
+ misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
461
+ maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
462
+ retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
463
+ permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
297
464
  kanbanMode: args.kanbanMode || 'none',
465
+ channels: Array.isArray(args.channels) ? args.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined,
466
+ template: args.template ? String(args.template) : '',
467
+ env: args.env && typeof args.env === 'object' ? args.env : undefined,
468
+ cwd: args.cwd ? String(args.cwd).trim() : '',
469
+ workspaceId: args.workspaceId ? String(args.workspaceId).trim() : '',
470
+ worktree: Boolean(args.worktree),
471
+ keepWorktree: Boolean(args.keepWorktree),
472
+ httpMethod: args.httpMethod ? String(args.httpMethod).toUpperCase() : 'GET',
473
+ httpUrl: args.httpUrl ? String(args.httpUrl).trim() : '',
474
+ httpHeaders: args.httpHeaders,
475
+ httpBody: args.httpBody !== undefined ? String(args.httpBody) : '',
476
+ sshProfileId: args.sshProfileId ? String(args.sshProfileId).trim() : '',
477
+ sshTarget: args.sshTarget ? String(args.sshTarget).trim() : '',
478
+ sshPort: Number(args.sshPort) || 0,
479
+ sshKeyPath: args.sshKeyPath ? String(args.sshKeyPath).trim() : '',
480
+ dockerImage: args.dockerImage ? String(args.dockerImage).trim() : '',
481
+ pythonPath: args.pythonPath ? String(args.pythonPath).trim() : '',
482
+ nodePath: args.nodePath ? String(args.nodePath).trim() : '',
483
+ skillName: args.skillName ? String(args.skillName).trim() : '',
484
+ workflowName: args.workflowName ? String(args.workflowName).trim() : '',
298
485
  oneShot: Boolean(parsed.isOneShot),
299
486
  });
300
487
  scheduler.scheduleTask(task);
@@ -305,19 +492,44 @@ export function executeCreateTask(store, scheduler, args) {
305
492
  };
306
493
  }
307
494
 
308
- const createTaskParameters = {
495
+ export const createTaskParameters = {
309
496
  title: { type: 'string', description: 'Short task name', required: true },
310
- schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
497
+ 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 },
311
498
  prompt: { type: 'string', description: 'Prompt/instruction for the agent at run time', required: true },
312
- type: { type: 'string', enum: ['prompt', 'script'], description: 'Task type' },
499
+ type: { type: 'string', enum: TASK_TYPES, description: 'Task type: llm | script | node | python | http | ssh | docker | skill | workflow' },
313
500
  delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
314
501
  provider: { type: 'string', description: 'Model provider (optional)' },
315
502
  model: { type: 'string', description: 'Model (optional)' },
503
+ timezone: { type: 'string', description: 'IANA time zone for the schedule, e.g. "Europe/Berlin" (optional, default = server local)' },
316
504
  notifyTelegram: { type: 'boolean', description: 'Send the run report to Telegram' },
317
505
  onlyOnFailure: { type: 'boolean', description: 'Send reports only for failures' },
318
506
  timeoutSeconds: { type: 'number', description: 'Execution time limit in seconds' },
319
507
  overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Overlap policy' },
508
+ misfirePolicy: { type: 'string', enum: ['skip', 'runOnce', 'catchUpAll'], description: 'What to do with a run missed during downtime' },
509
+ maxRetries: { type: 'number', description: 'Automatic retry attempts on failure (0 = off)' },
510
+ retryBackoffMs: { type: 'number', description: 'Base backoff between retries in ms, doubled per attempt' },
511
+ permissionPreset: { type: 'string', enum: ['default', 'read-only', 'workspace-write', 'full'], description: 'Permission preset applied to the task session' },
320
512
  kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Create a dsh-kanban card for runs' },
513
+ channels: { type: 'array', items: { type: 'string', enum: CHANNEL_IDS }, description: 'Notification channels for this task; empty = legacy telegram/kanban flags' },
514
+ template: { type: 'string', description: 'Notification template with {title} {status} {output} {error} {duration} placeholders; empty = built-in text' },
515
+ env: { type: 'object', additionalProperties: true, description: 'Environment variables for external runtimes (KEY: value); do not store secrets here' },
516
+ cwd: { type: 'string', description: 'Working directory for the run (external runtimes and agent sessions)' },
517
+ workspaceId: { type: 'string', description: 'Workspace bound to the task; resolved through the harness' },
518
+ worktree: { type: 'boolean', description: 'Run code-modifying agent tasks in an isolated git worktree' },
519
+ keepWorktree: { type: 'boolean', description: 'Keep the created worktree after the run' },
520
+ nodePath: { type: 'string', description: 'Node.js binary for node tasks (default: the harness runtime)' },
521
+ pythonPath: { type: 'string', description: 'Python interpreter for python tasks (default: auto-detected venv)' },
522
+ httpMethod: { type: 'string', description: 'HTTP method for http tasks' },
523
+ httpUrl: { type: 'string', description: 'URL for http tasks' },
524
+ httpHeaders: { type: 'object', additionalProperties: true, description: 'Headers for http tasks' },
525
+ httpBody: { type: 'string', description: 'Request body for http tasks' },
526
+ sshProfileId: { type: 'string', description: 'dsh-remote-workspace profile id that provides host and credentials' },
527
+ sshTarget: { type: 'string', description: 'Fallback user@host when no remote-workspace profile is used' },
528
+ sshPort: { type: 'number', description: 'SSH port (with sshTarget fallback)' },
529
+ sshKeyPath: { type: 'string', description: 'Private key path (with sshTarget fallback)' },
530
+ dockerImage: { type: 'string', description: 'Container image for docker tasks' },
531
+ skillName: { type: 'string', description: 'DSH skill name for skill tasks' },
532
+ workflowName: { type: 'string', description: 'DSH workflow name for workflow tasks' },
321
533
  };
322
534
 
323
535
  const createTaskOutput = {
@@ -336,7 +548,20 @@ const createTaskOutput = {
336
548
  export function apply(ctx, config) {
337
549
  const store = new TaskStore();
338
550
  const runner = new SessionRunner(ctx);
339
- const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts));
551
+ const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts), {
552
+ maxConcurrent: config && config.maxConcurrent,
553
+ defaultTimezone: config && config.defaultTimezone,
554
+ resolveSecrets: (settings) => resolveTelegramSecrets(ctx, settings),
555
+ deliver: (task, runInfo, settings) => deliverRun({
556
+ task,
557
+ runInfo,
558
+ settings,
559
+ secrets: {
560
+ botToken: settings.botToken || '',
561
+ resolveSecret: (ref) => resolveCredentialValue(ctx, ref),
562
+ },
563
+ }),
564
+ });
340
565
 
341
566
  // Register settings in the Harness settings service (#71, #102): the scope
342
567
  // is the source of truth for the core, scope.watch mirrors values into the
@@ -365,8 +590,21 @@ export function apply(ctx, config) {
365
590
 
366
591
  scheduler.start();
367
592
 
593
+ // Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
594
+ // external monitor can alert when this scheduler stops responding.
595
+ let heartbeatTimer = null;
596
+ if (config && config.heartbeatUrl && Number(config.heartbeatIntervalSec) > 0) {
597
+ const intervalMs = Number(config.heartbeatIntervalSec) * 1000;
598
+ heartbeatTimer = setInterval(() => {
599
+ fetch(config.heartbeatUrl, { method: 'GET' }).catch((err) => {
600
+ console.warn('[dsh-cron] heartbeat ping failed:', err.message);
601
+ });
602
+ }, intervalMs);
603
+ }
604
+
368
605
  ctx.effect(() => {
369
606
  return () => {
607
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
370
608
  scheduler.stopAll();
371
609
  };
372
610
  }, 'dsh-cron: lifecycle');
@@ -466,6 +704,27 @@ export function apply(ctx, config) {
466
704
  }
467
705
  }), 'dsh-cron: /settings');
468
706
 
707
+ // GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
708
+ ctx.effect(() => ctx.webServer.register({
709
+ kind: 'exact',
710
+ path: '/dsh-cron/heartbeat',
711
+ handler: async (req, res) => {
712
+ try {
713
+ const tasks = store.list({ status: 'active' });
714
+ const lastRuns = tasks.map((t) => t.lastRunAt || 0);
715
+ sendJson(res, 200, {
716
+ ok: true,
717
+ activeTasks: tasks.length,
718
+ running: scheduler.runningCount(),
719
+ lastRunAt: lastRuns.length ? Math.max(...lastRuns) : null,
720
+ serverTime: Date.now(),
721
+ });
722
+ } catch (err) {
723
+ sendJson(res, 500, { ok: false, error: err.message });
724
+ }
725
+ }
726
+ }), 'dsh-cron: /heartbeat');
727
+
469
728
  // POST /dsh-cron/telegram/test
470
729
  ctx.effect(() => ctx.webServer.register({
471
730
  kind: 'exact',
@@ -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,