@goodandready/dsh-cron 0.2.3 → 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,12 +14,16 @@ 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'),
@@ -28,6 +32,30 @@ export const Config = z.object({
28
32
  maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
29
33
  heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
30
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'),
31
59
  });
32
60
 
33
61
  const PATCHABLE_TASK_FIELDS = [
@@ -44,15 +72,62 @@ const PATCHABLE_TASK_FIELDS = [
44
72
  'maxRetries',
45
73
  'retryBackoffMs',
46
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',
47
93
  'notifyTelegram',
48
94
  'onlyOnFailure',
49
95
  'timeoutSeconds',
50
96
  'overlapPolicy',
51
97
  'kanbanMode',
98
+ 'channels',
99
+ 'template',
52
100
  'status',
53
101
  'oneShot',
54
102
  ];
55
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
+
56
131
  function sendJson(res, statusCode, data) {
57
132
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
58
133
  res.end(JSON.stringify(data));
@@ -75,6 +150,20 @@ async function readBody(req, res) {
75
150
  }
76
151
  }
77
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
+
78
167
  /**
79
168
  * Apply settings writes through the registered settings scope so the core
80
169
  * snapshot stays authoritative (#102). Returns null when the settings
@@ -83,7 +172,7 @@ async function readBody(req, res) {
83
172
  export async function applySettingsToScope(settingsScope, body) {
84
173
  if (!settingsScope || typeof settingsScope.set !== 'function') return null;
85
174
  const errors = [];
86
- for (const key of ['botToken', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl']) {
175
+ for (const key of SETTINGS_SYNC_KEYS) {
87
176
  if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
88
177
  try {
89
178
  await settingsScope.set(key, body[key]);
@@ -95,13 +184,29 @@ export async function applySettingsToScope(settingsScope, body) {
95
184
  }
96
185
 
97
186
  /**
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.
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.
100
196
  */
101
197
  export function sanitizeSettingsPayload(body) {
102
198
  const payload = Object.assign({}, body);
103
- if (typeof payload.botToken === 'string' && (payload.botToken.includes('••') || payload.botToken.includes('****'))) {
104
- 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
+ }
105
210
  }
106
211
  return payload;
107
212
  }
@@ -150,9 +255,14 @@ export function createCronApiHandler(store, scheduler, collection) {
150
255
  sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
151
256
  return;
152
257
  }
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' });
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 });
156
266
  return;
157
267
  }
158
268
  const parsed = parseScheduleExpression(body.schedule);
@@ -180,6 +290,28 @@ export function createCronApiHandler(store, scheduler, collection) {
180
290
  maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
181
291
  retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
182
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 : ''),
183
315
  oneShot: Boolean(parsed.isOneShot || body.oneShot),
184
316
  });
185
317
  if (task.status === 'active') {
@@ -254,9 +386,19 @@ export function createCronApiHandler(store, scheduler, collection) {
254
386
  if (error) return;
255
387
  // Whitelist patchable fields so stats/timestamps stay server-owned (#90)
256
388
  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;
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
+ }
260
402
  }
261
403
  if (patchData.schedule) {
262
404
  const parsed = parseScheduleExpression(patchData.schedule);
@@ -296,7 +438,9 @@ export function createCronApiHandler(store, scheduler, collection) {
296
438
  * alias pair (#70, #92). Exported for unit tests.
297
439
  */
298
440
  export function executeCreateTask(store, scheduler, args) {
299
- 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);
300
444
  const parsed = parseScheduleExpression(args.schedule);
301
445
  const task = store.set({
302
446
  title: args.title,
@@ -318,6 +462,26 @@ export function executeCreateTask(store, scheduler, args) {
318
462
  retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
319
463
  permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
320
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() : '',
321
485
  oneShot: Boolean(parsed.isOneShot),
322
486
  });
323
487
  scheduler.scheduleTask(task);
@@ -328,11 +492,11 @@ export function executeCreateTask(store, scheduler, args) {
328
492
  };
329
493
  }
330
494
 
331
- const createTaskParameters = {
495
+ export const createTaskParameters = {
332
496
  title: { type: 'string', description: 'Short task name', required: true },
333
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 },
334
498
  prompt: { type: 'string', description: 'Prompt/instruction for the agent at run time', required: true },
335
- 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' },
336
500
  delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
337
501
  provider: { type: 'string', description: 'Model provider (optional)' },
338
502
  model: { type: 'string', description: 'Model (optional)' },
@@ -346,6 +510,26 @@ const createTaskParameters = {
346
510
  retryBackoffMs: { type: 'number', description: 'Base backoff between retries in ms, doubled per attempt' },
347
511
  permissionPreset: { type: 'string', enum: ['default', 'read-only', 'workspace-write', 'full'], description: 'Permission preset applied to the task session' },
348
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' },
349
533
  };
350
534
 
351
535
  const createTaskOutput = {
@@ -367,6 +551,16 @@ export function apply(ctx, config) {
367
551
  const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts), {
368
552
  maxConcurrent: config && config.maxConcurrent,
369
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
+ }),
370
564
  });
371
565
 
372
566
  // 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,
package/lib/runner.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { estimateTokenCost } from './integrations.js';
2
+ import {
3
+ EXTERNAL_RUNTIMES,
4
+ normalizeTaskType,
5
+ buildInvocation,
6
+ } from './runtimes.js';
2
7
 
3
8
  /**
4
9
  * Runner executing scheduled tasks in DeepSeek Harness sessions.
@@ -43,35 +48,164 @@ export class SessionRunner {
43
48
  }
44
49
  }
45
50
 
46
- async _run(task, signal) {
47
- console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
51
+ /**
52
+ * Resolve the working directory for a task: explicit cwd, a bound
53
+ * workspace id resolved through the harness, or the process cwd (#31).
54
+ */
55
+ resolveCwd(task) {
56
+ if (task.cwd) return String(task.cwd);
57
+ if (task.workspaceId && this.ctx && typeof this.ctx.get === 'function') {
58
+ try {
59
+ const workspaces = this.ctx.get('workspaces');
60
+ const ws = workspaces && (typeof workspaces.get === 'function'
61
+ ? workspaces.get(task.workspaceId)
62
+ : (workspaces[task.workspaceId] || null));
63
+ const resolved = ws && (ws.path || ws.cwd || ws.root || ws.dir);
64
+ if (resolved) return String(resolved);
65
+ } catch {}
66
+ return String(task.workspaceId);
67
+ }
68
+ return process.cwd();
69
+ }
48
70
 
49
- // 1. Direct shell / script task fallback (NO-LLM)
50
- if (task.type === 'script' || task.mode === 'no-llm') {
51
- const { exec } = await import('node:child_process');
52
- return new Promise((resolve, reject) => {
53
- const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
54
- const child = exec(task.prompt, {
55
- cwd: process.cwd(),
56
- signal,
57
- maxBuffer: 10 * 1024 * 1024,
58
- shell: shellPath,
59
- env: { ...process.env },
60
- }, (err, stdout, stderr) => {
61
- if (err) {
62
- if (signal && signal.aborted) {
63
- return reject(signal.reason || new Error('Command aborted'));
64
- }
65
- return reject(err);
71
+ /** Create an isolated git worktree for a code-modifying task (#29). */
72
+ async createTaskWorktree(cwd, task) {
73
+ const { execFile } = await import('node:child_process');
74
+ const path = await import('node:path');
75
+ const wtPath = path.join(cwd, '.dsh-cron-worktrees', `${task.id}-${Date.now()}`);
76
+ await new Promise((resolve, reject) => {
77
+ execFile('git', ['-C', cwd, 'worktree', 'add', '--detach', wtPath, 'HEAD'], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
78
+ if (err) reject(new Error(String(stderr || err.message).trim()));
79
+ else resolve(stdout);
80
+ });
81
+ });
82
+ return wtPath;
83
+ }
84
+
85
+ /**
86
+ * Remove a task worktree; never throws (best-effort cleanup).
87
+ * Plain removal only — no force flags anywhere (workspace rule). A dirty
88
+ * worktree is left in place and reported instead of being force-deleted.
89
+ */
90
+ async removeTaskWorktree(cwd, wtPath) {
91
+ const { execFile } = await import('node:child_process');
92
+ await new Promise((resolve) => {
93
+ execFile('git', ['-C', cwd, 'worktree', 'remove', wtPath], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
94
+ if (err) {
95
+ console.warn(`[dsh-cron] task worktree kept (not removable without force): ${wtPath} — ${String(stderr || err.message).trim()}`);
96
+ }
97
+ execFile('git', ['-C', cwd, 'worktree', 'prune'], () => resolve());
98
+ });
99
+ });
100
+ }
101
+
102
+ /** HTTP trigger runtime (#7). */
103
+ async _runHttp(task, request, signal) {
104
+ const res = await fetch(request.url, {
105
+ method: request.method,
106
+ headers: request.headers,
107
+ body: request.body,
108
+ signal,
109
+ });
110
+ const text = await res.text().catch(() => '');
111
+ const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
112
+ if (!res.ok) {
113
+ throw new Error(`${summary}\n${String(text).slice(0, 500)}`);
114
+ }
115
+ return {
116
+ output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
117
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
118
+ costUsd: 0,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * #9 reuse-first path: when the task names a dsh-remote-workspace profile,
124
+ * execute through that plugin's `remoteSsh` service so host settings and
125
+ * credentials stay owned by it (we only reference the profile id).
126
+ * Returns null when the service or the profile is unavailable so the caller
127
+ * can fall back to a direct ssh invocation.
128
+ */
129
+ async _runViaRemoteWorkspace(task, signal) {
130
+ if (!task.sshProfileId || !this.ctx || typeof this.ctx.get !== 'function') return null;
131
+ let svc = null;
132
+ try {
133
+ svc = this.ctx.get('remoteSsh');
134
+ } catch {}
135
+ if (!svc || typeof svc.exec !== 'function') return null;
136
+
137
+ let profile = null;
138
+ try {
139
+ const settings = this.ctx.get('settings');
140
+ if (settings && typeof settings.get === 'function') {
141
+ const snap = settings.get('dsh-remote-workspace');
142
+ const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
143
+ profile = profiles.find((p) => p && p.id === task.sshProfileId) || null;
144
+ }
145
+ } catch {}
146
+ if (!profile) {
147
+ throw new Error(`remote-workspace profile "${task.sshProfileId}" was not found — check dsh-remote-workspace settings, or use sshTarget instead`);
148
+ }
149
+ const res = await svc.exec(profile, String(task.prompt || 'true'), task.cwd || profile.remoteWorkspace || undefined);
150
+ const output = [res && res.stdout, res && res.stderr].filter((x) => x && String(x).trim()).join('\n').trim();
151
+ const code = res && typeof res.code === 'number' ? res.code : 0;
152
+ if (code !== 0) {
153
+ throw new Error(`Remote command exited with code ${code}\n${output}`.slice(0, 4000));
154
+ }
155
+ return {
156
+ output: output || 'Remote command completed with code 0',
157
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
158
+ costUsd: 0,
159
+ };
160
+ }
161
+
162
+ /** Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9). */
163
+ async _runExternal(task, runtime, signal) {
164
+ if (runtime === 'ssh' && task.sshProfileId) {
165
+ const viaService = await this._runViaRemoteWorkspace(task, signal);
166
+ if (viaService) return viaService;
167
+ if (!task.sshTarget) {
168
+ throw new Error('SSH task needs either a working dsh-remote-workspace profile (sshProfileId) or an sshTarget');
169
+ }
170
+ }
171
+ const cwd = this.resolveCwd(task);
172
+ const invocation = buildInvocation({ ...task, type: runtime }, cwd);
173
+ if (invocation.http) return this._runHttp(task, invocation.http, signal);
174
+
175
+ const { exec, execFile } = await import('node:child_process');
176
+ return new Promise((resolve, reject) => {
177
+ const done = (err, stdout, stderr) => {
178
+ if (err) {
179
+ if (signal && signal.aborted) {
180
+ return reject(signal.reason || new Error('Command aborted'));
66
181
  }
67
- const out = (stdout || stderr || '').trim();
68
- resolve({
69
- output: out || 'Script completed with code 0',
70
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
71
- costUsd: 0
72
- });
182
+ const detail = String(stderr || '').trim();
183
+ return reject(new Error(detail ? `${err.message}\n${detail}` : err.message));
184
+ }
185
+ const out = (stdout || stderr || '').trim();
186
+ resolve({
187
+ output: out || `${runtime} task completed with code 0`,
188
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
189
+ costUsd: 0,
73
190
  });
74
- });
191
+ };
192
+ const options = { cwd, signal, maxBuffer: 10 * 1024 * 1024, env: invocation.env };
193
+ if (invocation.file) {
194
+ execFile(invocation.file, invocation.args || [], options, done);
195
+ } else {
196
+ const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
197
+ exec(invocation.shellCommand, { ...options, shell: shellPath }, done);
198
+ }
199
+ });
200
+ }
201
+
202
+ async _run(task, signal) {
203
+ console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
204
+
205
+ // 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
206
+ const runtime = normalizeTaskType(task.type);
207
+ if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
208
+ return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal);
75
209
  }
76
210
 
77
211
  // 2. If ctx.agents is available, run through agent session (LLM)
@@ -121,7 +255,26 @@ export class SessionRunner {
121
255
  ]);
122
256
  };
123
257
 
124
- const cwd = process.cwd();
258
+ const baseCwd = this.resolveCwd(task);
259
+ // #29: code-modifying tasks may run in an isolated git worktree.
260
+ let cwd = baseCwd;
261
+ let taskWorktree = null;
262
+ if (task.worktree) {
263
+ try {
264
+ taskWorktree = await this.createTaskWorktree(baseCwd, task);
265
+ cwd = taskWorktree;
266
+ } catch (wtErr) {
267
+ console.warn('[dsh-cron] task worktree not created, running in the workspace:', wtErr.message);
268
+ }
269
+ }
270
+ // #6/#10: skill- and workflow-driven tasks are agent-mediated with a
271
+ // shaped prompt naming the skill/workflow.
272
+ let agentPrompt = String(task.prompt || '');
273
+ if (runtime === 'skill' && task.skillName) {
274
+ agentPrompt = `Use the DSH skill "${task.skillName}" for this task:\n${agentPrompt}`;
275
+ } else if (runtime === 'workflow' && task.workflowName) {
276
+ agentPrompt = `Execute this through the DSH workflow "${task.workflowName}" step by step:\n${agentPrompt}`;
277
+ }
125
278
  let handle = null;
126
279
  try {
127
280
  handle = await this.ctx.agents.create({
@@ -144,7 +297,7 @@ export class SessionRunner {
144
297
 
145
298
  await raceAbort(handle.agent.whenIdle());
146
299
  handle.agent.followup(createUserMessage({
147
- content: [{ type: 'text', text: task.prompt }],
300
+ content: [{ type: 'text', text: agentPrompt }],
148
301
  source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
149
302
  }));
150
303
 
@@ -199,6 +352,10 @@ export class SessionRunner {
199
352
  }
200
353
  } catch {}
201
354
  }
355
+ // #29: dispose the isolated worktree (kept when keepWorktree is set).
356
+ if (taskWorktree && !task.keepWorktree) {
357
+ await this.removeTaskWorktree(baseCwd, taskWorktree);
358
+ }
202
359
  }
203
360
  } catch (err) {
204
361
  console.error(`[dsh-cron] agent execution failed:`, err.message);