@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/README.md +99 -14
- package/docs/README.ru.md +96 -15
- package/docs/README.zh.md +99 -14
- package/docs/design/DESIGN.md +13 -7
- package/lib/channels.js +422 -0
- package/lib/client.js +697 -26
- package/lib/index.js +278 -19
- package/lib/integrations.js +3 -1
- package/lib/runner.js +214 -29
- package/lib/runtimes.js +174 -0
- package/lib/scheduler.js +179 -73
- package/lib/secrets.js +123 -0
- package/lib/store.js +92 -21
- package/lib/telegram.js +20 -4
- package/lib/templates.js +88 -0
- package/package.json +1 -1
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
|
|
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
|
|
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:
|
|
90
|
-
*
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
141
|
-
if (taskType
|
|
142
|
-
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 });
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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:
|
|
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',
|
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,
|