@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/README.md +74 -14
- package/docs/README.ru.md +73 -13
- package/docs/README.zh.md +78 -13
- package/docs/design/DESIGN.md +13 -7
- package/lib/channels.js +422 -0
- package/lib/client.js +594 -23
- package/lib/index.js +210 -16
- package/lib/integrations.js +3 -1
- package/lib/runner.js +185 -28
- package/lib/runtimes.js +174 -0
- package/lib/scheduler.js +66 -60
- package/lib/secrets.js +123 -0
- package/lib/store.js +91 -21
- package/lib/telegram.js +20 -4
- package/lib/templates.js +88 -0
- package/package.json +1 -1
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
|
|
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
|
|
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:
|
|
99
|
-
*
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
154
|
-
if (taskType
|
|
155
|
-
sendJson(res, 403, { ok: false, error:
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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:
|
|
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
|
package/lib/integrations.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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:
|
|
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);
|