@goodandready/dsh-cron 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -15
- package/docs/README.ru.md +69 -14
- package/docs/README.zh.md +74 -14
- package/docs/design/DESIGN.md +18 -8
- package/docs/plans/0.2.5-ui-block.md +50 -0
- package/lib/channels.js +412 -0
- package/lib/client.js +1134 -30
- package/lib/index.js +311 -38
- 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/task-transfer.js +226 -0
- 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,51 @@ import {
|
|
|
14
14
|
pickPatchableFields,
|
|
15
15
|
SCRIPT_CONFIRM_HEADER,
|
|
16
16
|
} from './http-utils.js';
|
|
17
|
+
import { TASK_TYPES, CODE_EXECUTING_TYPES, normalizeTaskType } from './runtimes.js';
|
|
18
|
+
import { resolveTelegramSecrets, resolveCredentialValue } from './secrets.js';
|
|
19
|
+
import {
|
|
20
|
+
PATCHABLE_TASK_FIELDS,
|
|
21
|
+
DUPLICATE_TASK_FIELDS,
|
|
22
|
+
RESERVED_TASK_IDS,
|
|
23
|
+
EXPORT_SECRET_BEARING_FIELDS,
|
|
24
|
+
validateTaskType,
|
|
25
|
+
buildDuplicateTask,
|
|
26
|
+
buildTaskExport,
|
|
27
|
+
validateImportDocument,
|
|
28
|
+
hasCodeExecutingTask,
|
|
29
|
+
planImport,
|
|
30
|
+
TASK_EXPORT_KIND,
|
|
31
|
+
TASK_EXPORT_VERSION,
|
|
32
|
+
EXPORT_TASK_FIELDS,
|
|
33
|
+
IMPORT_STRATEGIES,
|
|
34
|
+
} from './task-transfer.js';
|
|
35
|
+
|
|
36
|
+
// Re-exported for backward compatibility: tests and tools import these names
|
|
37
|
+
// from lib/index.js.
|
|
38
|
+
export {
|
|
39
|
+
PATCHABLE_TASK_FIELDS,
|
|
40
|
+
DUPLICATE_TASK_FIELDS,
|
|
41
|
+
RESERVED_TASK_IDS,
|
|
42
|
+
EXPORT_SECRET_BEARING_FIELDS,
|
|
43
|
+
validateTaskType,
|
|
44
|
+
buildDuplicateTask,
|
|
45
|
+
buildTaskExport,
|
|
46
|
+
validateImportDocument,
|
|
47
|
+
hasCodeExecutingTask,
|
|
48
|
+
planImport,
|
|
49
|
+
TASK_EXPORT_KIND,
|
|
50
|
+
TASK_EXPORT_VERSION,
|
|
51
|
+
EXPORT_TASK_FIELDS,
|
|
52
|
+
IMPORT_STRATEGIES,
|
|
53
|
+
};
|
|
54
|
+
import { deliverRun, CHANNEL_IDS, resolveDeliveryTimeoutMs, MIN_DELIVERY_TIMEOUT_MS } from './channels.js';
|
|
17
55
|
|
|
18
56
|
export const name = '@goodandready/dsh-cron';
|
|
19
|
-
export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel'];
|
|
57
|
+
export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
|
|
20
58
|
|
|
21
59
|
export const Config = z.object({
|
|
22
|
-
botToken: z.string().role('secret').default('').description('Telegram Bot API token
|
|
60
|
+
botToken: z.string().role('secret').default('').description('Legacy Telegram Bot API token (prefer botTokenRef with the DSH credentials service)'),
|
|
61
|
+
botTokenRef: z.string().default('').description('Name of the DSH credential that holds the Telegram bot token'),
|
|
23
62
|
chatId: z.string().default('').description('Telegram chat ID that receives task reports'),
|
|
24
63
|
notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
|
|
25
64
|
onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
|
|
@@ -28,30 +67,25 @@ export const Config = z.object({
|
|
|
28
67
|
maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
|
|
29
68
|
heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
|
|
30
69
|
heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)'),
|
|
70
|
+
// --- Delivery channels (#20-#23, #26, #28, #47) ---
|
|
71
|
+
template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
|
|
72
|
+
channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
|
|
73
|
+
deliveryTimeoutMs: z.number().min(MIN_DELIVERY_TIMEOUT_MS).default(15000).description('Per-channel delivery timeout in ms (minimum 1000); a slower endpoint is recorded as a failure and does not block other channels or the next tick'),
|
|
74
|
+
discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
|
|
75
|
+
slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
|
|
76
|
+
ntfyUrl: z.string().default('https://ntfy.sh').description('ntfy server base URL'),
|
|
77
|
+
ntfyTopic: z.string().default('').description('ntfy topic for push notifications'),
|
|
78
|
+
ntfyTokenRef: z.string().default('').description('Credential name for the ntfy access token'),
|
|
79
|
+
barkServerUrl: z.string().default('https://api.day.app').description('Bark server base URL'),
|
|
80
|
+
barkKey: z.string().default('').description('Bark device key'),
|
|
81
|
+
pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token'),
|
|
82
|
+
pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)'),
|
|
83
|
+
ttsBaseUrl: z.string().default('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route'),
|
|
84
|
+
giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
|
|
85
|
+
giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
|
|
86
|
+
giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
|
|
31
87
|
});
|
|
32
88
|
|
|
33
|
-
const PATCHABLE_TASK_FIELDS = [
|
|
34
|
-
'title',
|
|
35
|
-
'schedule',
|
|
36
|
-
'scheduleText',
|
|
37
|
-
'prompt',
|
|
38
|
-
'type',
|
|
39
|
-
'delivery',
|
|
40
|
-
'provider',
|
|
41
|
-
'model',
|
|
42
|
-
'timezone',
|
|
43
|
-
'misfirePolicy',
|
|
44
|
-
'maxRetries',
|
|
45
|
-
'retryBackoffMs',
|
|
46
|
-
'permissionPreset',
|
|
47
|
-
'notifyTelegram',
|
|
48
|
-
'onlyOnFailure',
|
|
49
|
-
'timeoutSeconds',
|
|
50
|
-
'overlapPolicy',
|
|
51
|
-
'kanbanMode',
|
|
52
|
-
'status',
|
|
53
|
-
'oneShot',
|
|
54
|
-
];
|
|
55
89
|
|
|
56
90
|
function sendJson(res, statusCode, data) {
|
|
57
91
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
@@ -75,6 +109,19 @@ async function readBody(req, res) {
|
|
|
75
109
|
}
|
|
76
110
|
}
|
|
77
111
|
|
|
112
|
+
/** Settings keys synchronized between the store and the settings service. */
|
|
113
|
+
export const SETTINGS_SYNC_KEYS = [
|
|
114
|
+
'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
|
|
115
|
+
'template', 'channelTemplates', 'deliveryTimeoutMs',
|
|
116
|
+
'discordWebhookUrl', 'slackWebhookUrl',
|
|
117
|
+
'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
|
|
118
|
+
'barkServerUrl', 'barkKey',
|
|
119
|
+
'pushplusTokenRef',
|
|
120
|
+
'pushplusUrl',
|
|
121
|
+
'ttsBaseUrl',
|
|
122
|
+
'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
|
|
123
|
+
];
|
|
124
|
+
|
|
78
125
|
/**
|
|
79
126
|
* Apply settings writes through the registered settings scope so the core
|
|
80
127
|
* snapshot stays authoritative (#102). Returns null when the settings
|
|
@@ -83,7 +130,7 @@ async function readBody(req, res) {
|
|
|
83
130
|
export async function applySettingsToScope(settingsScope, body) {
|
|
84
131
|
if (!settingsScope || typeof settingsScope.set !== 'function') return null;
|
|
85
132
|
const errors = [];
|
|
86
|
-
for (const key of
|
|
133
|
+
for (const key of SETTINGS_SYNC_KEYS) {
|
|
87
134
|
if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
|
|
88
135
|
try {
|
|
89
136
|
await settingsScope.set(key, body[key]);
|
|
@@ -95,13 +142,41 @@ export async function applySettingsToScope(settingsScope, body) {
|
|
|
95
142
|
}
|
|
96
143
|
|
|
97
144
|
/**
|
|
98
|
-
* Drop values that must never re-enter the store from the client:
|
|
99
|
-
*
|
|
145
|
+
* Drop values that must never re-enter the store from the client:
|
|
146
|
+
*
|
|
147
|
+
* - a masked secret echoed back by the settings UI would overwrite the real
|
|
148
|
+
* value (bot token, webhook URLs, Bark key);
|
|
149
|
+
* - the raw credential keys are refused outright — the *Ref fields are the
|
|
150
|
+
* supported way to configure them.
|
|
151
|
+
*
|
|
152
|
+
* Applied before both the settings scope and the store, because the scope is
|
|
153
|
+
* the source of truth and would otherwise persist a raw or masked value.
|
|
100
154
|
*/
|
|
101
155
|
export function sanitizeSettingsPayload(body) {
|
|
102
156
|
const payload = Object.assign({}, body);
|
|
103
|
-
|
|
104
|
-
|
|
157
|
+
for (const key of TaskStore.MASKED_SETTING_KEYS) {
|
|
158
|
+
const val = payload[key];
|
|
159
|
+
if (typeof val === 'string' && (val.includes('••') || val.includes('****'))) {
|
|
160
|
+
delete payload[key];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
|
|
164
|
+
if (key in payload) {
|
|
165
|
+
console.warn(`[dsh-cron] refusing raw secret "${key}" from the client — use a credential reference`);
|
|
166
|
+
delete payload[key];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// #115: normalise the delivery deadline here, before it reaches the settings
|
|
170
|
+
// scope, so a value like 1 ms can never be persisted and then fail every
|
|
171
|
+
// delivery before the request is even sent.
|
|
172
|
+
if ('deliveryTimeoutMs' in payload) {
|
|
173
|
+
const raw = payload.deliveryTimeoutMs;
|
|
174
|
+
const n = Number(raw);
|
|
175
|
+
if (raw === '' || raw === null || !Number.isFinite(n) || n <= 0) {
|
|
176
|
+
delete payload.deliveryTimeoutMs;
|
|
177
|
+
} else {
|
|
178
|
+
payload.deliveryTimeoutMs = resolveDeliveryTimeoutMs(n);
|
|
179
|
+
}
|
|
105
180
|
}
|
|
106
181
|
return payload;
|
|
107
182
|
}
|
|
@@ -150,12 +225,24 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
150
225
|
sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
|
|
151
226
|
return;
|
|
152
227
|
}
|
|
153
|
-
const taskType = body.type
|
|
154
|
-
if (taskType
|
|
155
|
-
sendJson(res, 403, { ok: false, error:
|
|
228
|
+
const taskType = normalizeTaskType(body.type);
|
|
229
|
+
if (CODE_EXECUTING_TYPES.includes(taskType) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
230
|
+
sendJson(res, 403, { ok: false, error: `Creating ${taskType} tasks over HTTP requires the x-dsh-cron-confirm: script header` });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const typeError = validateTaskType(taskType, body);
|
|
234
|
+
if (typeError) {
|
|
235
|
+
sendJson(res, 400, { ok: false, error: typeError });
|
|
156
236
|
return;
|
|
157
237
|
}
|
|
158
238
|
const parsed = parseScheduleExpression(body.schedule);
|
|
239
|
+
// A client-supplied id must not shadow the collection routes under
|
|
240
|
+
// /dsh-cron/tasks, otherwise that task could never be fetched or
|
|
241
|
+
// deleted again (#42 review finding).
|
|
242
|
+
if (body.id && RESERVED_TASK_IDS.includes(String(body.id))) {
|
|
243
|
+
sendJson(res, 400, { ok: false, error: `Task id "${body.id}" is reserved` });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
159
246
|
const current = body.id ? store.get(body.id) : null;
|
|
160
247
|
const resolvedStatus = body.status || (current ? current.status : 'active');
|
|
161
248
|
|
|
@@ -180,6 +267,28 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
180
267
|
maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
|
|
181
268
|
retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
|
|
182
269
|
permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
|
|
270
|
+
env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
|
|
271
|
+
cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
|
|
272
|
+
channels: Array.isArray(body.channels)
|
|
273
|
+
? body.channels.filter((c) => CHANNEL_IDS.includes(c))
|
|
274
|
+
: (current ? current.channels : undefined),
|
|
275
|
+
template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
|
|
276
|
+
workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
|
|
277
|
+
worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
|
|
278
|
+
keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
|
|
279
|
+
httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
|
|
280
|
+
httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
|
|
281
|
+
httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
|
|
282
|
+
httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
|
|
283
|
+
sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
|
|
284
|
+
sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
|
|
285
|
+
sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
|
|
286
|
+
sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
|
|
287
|
+
dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
|
|
288
|
+
pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
|
|
289
|
+
nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
|
|
290
|
+
skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
|
|
291
|
+
workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
|
|
183
292
|
oneShot: Boolean(parsed.isOneShot || body.oneShot),
|
|
184
293
|
});
|
|
185
294
|
if (task.status === 'active') {
|
|
@@ -197,6 +306,93 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
197
306
|
// Item level: /dsh-cron/{tasks|action}/:id[/:action]
|
|
198
307
|
if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
|
|
199
308
|
|
|
309
|
+
// Export/import are collection operations that live under /tasks/:id/…,
|
|
310
|
+
// so they are matched before the generic id handling below.
|
|
311
|
+
if (id === 'export' && req.method === 'GET') {
|
|
312
|
+
const doc = buildTaskExport(store.list({ status: 'all' }));
|
|
313
|
+
sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (id === 'import' && req.method === 'POST') {
|
|
318
|
+
const { body, error } = await readBody(req, res);
|
|
319
|
+
if (error) return;
|
|
320
|
+
const strategy = IMPORT_STRATEGIES.includes(body && body.strategy) ? body.strategy : 'skip';
|
|
321
|
+
const checked = validateImportDocument(body && body.document);
|
|
322
|
+
if (!checked.ok) {
|
|
323
|
+
sendJson(res, 400, { ok: false, error: checked.error });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// The import file is external input: importing code-executing tasks
|
|
327
|
+
// needs the same explicit confirmation that creating one over HTTP
|
|
328
|
+
// needs, otherwise a hand-edited file would bypass that gate (#86).
|
|
329
|
+
if (hasCodeExecutingTask(checked.tasks) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
330
|
+
sendJson(res, 403, {
|
|
331
|
+
ok: false,
|
|
332
|
+
error: `Importing code-executing tasks over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`,
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const existingIds = store.list({ status: 'all' }).map((x) => x.id);
|
|
337
|
+
const plan = planImport(checked.tasks, existingIds, strategy);
|
|
338
|
+
if (body && body.dryRun) {
|
|
339
|
+
sendJson(res, 200, {
|
|
340
|
+
ok: true,
|
|
341
|
+
dryRun: true,
|
|
342
|
+
strategy: plan.strategy,
|
|
343
|
+
summary: { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length },
|
|
344
|
+
codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
|
|
345
|
+
});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// Every imported task is paused (the file's status is ignored) and every
|
|
349
|
+
// id that replaced an existing task is disarmed: writing "paused" to the
|
|
350
|
+
// store does not stop an already-armed croner job, so the scheduler must
|
|
351
|
+
// be told explicitly. The writes are rolled back if one of them throws,
|
|
352
|
+
// so a failed import cannot leave the store half-updated.
|
|
353
|
+
const imported = [];
|
|
354
|
+
const rollback = [];
|
|
355
|
+
const disarm = [];
|
|
356
|
+
try {
|
|
357
|
+
for (const task of plan.add) {
|
|
358
|
+
const before = store.get(task.id);
|
|
359
|
+
const created = store.set({ ...task, id: task.id || randomUUID(), status: 'paused', updatedAt: Date.now() });
|
|
360
|
+
imported.push(created);
|
|
361
|
+
rollback.push({ id: created.id, previous: before || null });
|
|
362
|
+
disarm.push(created.id);
|
|
363
|
+
}
|
|
364
|
+
for (const task of plan.replace) {
|
|
365
|
+
const before = store.get(task.id);
|
|
366
|
+
if (!before) continue;
|
|
367
|
+
const replaced = store.set({ ...task, id: task.id, status: 'paused', updatedAt: Date.now() });
|
|
368
|
+
imported.push(replaced);
|
|
369
|
+
rollback.push({ id: replaced.id, previous: before });
|
|
370
|
+
disarm.push(replaced.id);
|
|
371
|
+
}
|
|
372
|
+
} catch (writeErr) {
|
|
373
|
+
for (const entry of rollback.reverse()) {
|
|
374
|
+
try {
|
|
375
|
+
if (entry.previous) store.set(entry.previous);
|
|
376
|
+
else store.delete(entry.id);
|
|
377
|
+
} catch (_) {}
|
|
378
|
+
}
|
|
379
|
+
sendJson(res, 500, { ok: false, error: `Import failed and was rolled back: ${writeErr.message}` });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
// Disarm outside the write transaction: pauseTask is idempotent and a
|
|
383
|
+
// failure here must not undo a successful import.
|
|
384
|
+
for (const id of disarm) {
|
|
385
|
+
try { scheduler.pauseTask(id); } catch (err) { console.error('[dsh-cron] import: could not pause task', id + ':', err.message); }
|
|
386
|
+
}
|
|
387
|
+
sendJson(res, 200, {
|
|
388
|
+
ok: true,
|
|
389
|
+
imported: imported.length,
|
|
390
|
+
summary: { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length },
|
|
391
|
+
tasks: imported,
|
|
392
|
+
});
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
200
396
|
if (req.method === 'GET' && (action === 'history' || !action)) {
|
|
201
397
|
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
202
398
|
sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
|
|
@@ -213,6 +409,21 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
213
409
|
return;
|
|
214
410
|
}
|
|
215
411
|
|
|
412
|
+
if (req.method === 'POST' && action === 'duplicate') {
|
|
413
|
+
const source = store.get(id);
|
|
414
|
+
if (!source) {
|
|
415
|
+
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const copy = buildDuplicateTask(source, { id: randomUUID() });
|
|
419
|
+
store.set(copy);
|
|
420
|
+
// A copy starts paused: it must never fire on its own before the user
|
|
421
|
+
// reviews the schedule (a duplicated one-shot may point at a past time).
|
|
422
|
+
scheduler.pauseTask(copy.id);
|
|
423
|
+
sendJson(res, 200, { ok: true, task: copy });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
216
427
|
if (req.method === 'POST' && action === 'pause') {
|
|
217
428
|
const task = scheduler.pauseTask(id);
|
|
218
429
|
if (!task) {
|
|
@@ -254,9 +465,19 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
254
465
|
if (error) return;
|
|
255
466
|
// Whitelist patchable fields so stats/timestamps stay server-owned (#90)
|
|
256
467
|
const patchData = pickPatchableFields(body, PATCHABLE_TASK_FIELDS);
|
|
257
|
-
if (patchData.type
|
|
258
|
-
|
|
259
|
-
|
|
468
|
+
if (patchData.type !== undefined) {
|
|
469
|
+
patchData.type = normalizeTaskType(patchData.type);
|
|
470
|
+
const switchingToCode = CODE_EXECUTING_TYPES.includes(patchData.type)
|
|
471
|
+
&& !CODE_EXECUTING_TYPES.includes(current.type);
|
|
472
|
+
if (switchingToCode && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
473
|
+
sendJson(res, 403, { ok: false, error: `Switching a task to the ${patchData.type} type over HTTP requires the x-dsh-cron-confirm: script header` });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const typeError = validateTaskType(patchData.type, { ...current, ...patchData });
|
|
477
|
+
if (typeError) {
|
|
478
|
+
sendJson(res, 400, { ok: false, error: typeError });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
260
481
|
}
|
|
261
482
|
if (patchData.schedule) {
|
|
262
483
|
const parsed = parseScheduleExpression(patchData.schedule);
|
|
@@ -296,7 +517,9 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
296
517
|
* alias pair (#70, #92). Exported for unit tests.
|
|
297
518
|
*/
|
|
298
519
|
export function executeCreateTask(store, scheduler, args) {
|
|
299
|
-
const taskType = args.type
|
|
520
|
+
const taskType = normalizeTaskType(args.type);
|
|
521
|
+
const typeError = validateTaskType(taskType, args);
|
|
522
|
+
if (typeError) throw new Error(typeError);
|
|
300
523
|
const parsed = parseScheduleExpression(args.schedule);
|
|
301
524
|
const task = store.set({
|
|
302
525
|
title: args.title,
|
|
@@ -318,6 +541,26 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
318
541
|
retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
|
|
319
542
|
permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
|
|
320
543
|
kanbanMode: args.kanbanMode || 'none',
|
|
544
|
+
channels: Array.isArray(args.channels) ? args.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined,
|
|
545
|
+
template: args.template ? String(args.template) : '',
|
|
546
|
+
env: args.env && typeof args.env === 'object' ? args.env : undefined,
|
|
547
|
+
cwd: args.cwd ? String(args.cwd).trim() : '',
|
|
548
|
+
workspaceId: args.workspaceId ? String(args.workspaceId).trim() : '',
|
|
549
|
+
worktree: Boolean(args.worktree),
|
|
550
|
+
keepWorktree: Boolean(args.keepWorktree),
|
|
551
|
+
httpMethod: args.httpMethod ? String(args.httpMethod).toUpperCase() : 'GET',
|
|
552
|
+
httpUrl: args.httpUrl ? String(args.httpUrl).trim() : '',
|
|
553
|
+
httpHeaders: args.httpHeaders,
|
|
554
|
+
httpBody: args.httpBody !== undefined ? String(args.httpBody) : '',
|
|
555
|
+
sshProfileId: args.sshProfileId ? String(args.sshProfileId).trim() : '',
|
|
556
|
+
sshTarget: args.sshTarget ? String(args.sshTarget).trim() : '',
|
|
557
|
+
sshPort: Number(args.sshPort) || 0,
|
|
558
|
+
sshKeyPath: args.sshKeyPath ? String(args.sshKeyPath).trim() : '',
|
|
559
|
+
dockerImage: args.dockerImage ? String(args.dockerImage).trim() : '',
|
|
560
|
+
pythonPath: args.pythonPath ? String(args.pythonPath).trim() : '',
|
|
561
|
+
nodePath: args.nodePath ? String(args.nodePath).trim() : '',
|
|
562
|
+
skillName: args.skillName ? String(args.skillName).trim() : '',
|
|
563
|
+
workflowName: args.workflowName ? String(args.workflowName).trim() : '',
|
|
321
564
|
oneShot: Boolean(parsed.isOneShot),
|
|
322
565
|
});
|
|
323
566
|
scheduler.scheduleTask(task);
|
|
@@ -328,11 +571,11 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
328
571
|
};
|
|
329
572
|
}
|
|
330
573
|
|
|
331
|
-
const createTaskParameters = {
|
|
574
|
+
export const createTaskParameters = {
|
|
332
575
|
title: { type: 'string', description: 'Short task name', required: true },
|
|
333
576
|
schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), shorthand ("@daily", "@every 30m"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
|
|
334
577
|
prompt: { type: 'string', description: 'Prompt/instruction for the agent at run time', required: true },
|
|
335
|
-
type: { type: 'string', enum:
|
|
578
|
+
type: { type: 'string', enum: TASK_TYPES, description: 'Task type: llm | script | node | python | http | ssh | docker | skill | workflow' },
|
|
336
579
|
delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
|
|
337
580
|
provider: { type: 'string', description: 'Model provider (optional)' },
|
|
338
581
|
model: { type: 'string', description: 'Model (optional)' },
|
|
@@ -346,6 +589,26 @@ const createTaskParameters = {
|
|
|
346
589
|
retryBackoffMs: { type: 'number', description: 'Base backoff between retries in ms, doubled per attempt' },
|
|
347
590
|
permissionPreset: { type: 'string', enum: ['default', 'read-only', 'workspace-write', 'full'], description: 'Permission preset applied to the task session' },
|
|
348
591
|
kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Create a dsh-kanban card for runs' },
|
|
592
|
+
channels: { type: 'array', items: { type: 'string', enum: CHANNEL_IDS }, description: 'Notification channels for this task; empty = legacy telegram/kanban flags' },
|
|
593
|
+
template: { type: 'string', description: 'Notification template with {title} {status} {output} {error} {duration} placeholders; empty = built-in text' },
|
|
594
|
+
env: { type: 'object', additionalProperties: true, description: 'Environment variables for external runtimes (KEY: value); do not store secrets here' },
|
|
595
|
+
cwd: { type: 'string', description: 'Working directory for the run (external runtimes and agent sessions)' },
|
|
596
|
+
workspaceId: { type: 'string', description: 'Workspace bound to the task; resolved through the harness' },
|
|
597
|
+
worktree: { type: 'boolean', description: 'Run code-modifying agent tasks in an isolated git worktree' },
|
|
598
|
+
keepWorktree: { type: 'boolean', description: 'Keep the created worktree after the run' },
|
|
599
|
+
nodePath: { type: 'string', description: 'Node.js binary for node tasks (default: the harness runtime)' },
|
|
600
|
+
pythonPath: { type: 'string', description: 'Python interpreter for python tasks (default: auto-detected venv)' },
|
|
601
|
+
httpMethod: { type: 'string', description: 'HTTP method for http tasks' },
|
|
602
|
+
httpUrl: { type: 'string', description: 'URL for http tasks' },
|
|
603
|
+
httpHeaders: { type: 'object', additionalProperties: true, description: 'Headers for http tasks' },
|
|
604
|
+
httpBody: { type: 'string', description: 'Request body for http tasks' },
|
|
605
|
+
sshProfileId: { type: 'string', description: 'dsh-remote-workspace profile id that provides host and credentials' },
|
|
606
|
+
sshTarget: { type: 'string', description: 'Fallback user@host when no remote-workspace profile is used' },
|
|
607
|
+
sshPort: { type: 'number', description: 'SSH port (with sshTarget fallback)' },
|
|
608
|
+
sshKeyPath: { type: 'string', description: 'Private key path (with sshTarget fallback)' },
|
|
609
|
+
dockerImage: { type: 'string', description: 'Container image for docker tasks' },
|
|
610
|
+
skillName: { type: 'string', description: 'DSH skill name for skill tasks' },
|
|
611
|
+
workflowName: { type: 'string', description: 'DSH workflow name for workflow tasks' },
|
|
349
612
|
};
|
|
350
613
|
|
|
351
614
|
const createTaskOutput = {
|
|
@@ -367,6 +630,16 @@ export function apply(ctx, config) {
|
|
|
367
630
|
const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts), {
|
|
368
631
|
maxConcurrent: config && config.maxConcurrent,
|
|
369
632
|
defaultTimezone: config && config.defaultTimezone,
|
|
633
|
+
resolveSecrets: (settings) => resolveTelegramSecrets(ctx, settings),
|
|
634
|
+
deliver: (task, runInfo, settings) => deliverRun({
|
|
635
|
+
task,
|
|
636
|
+
runInfo,
|
|
637
|
+
settings,
|
|
638
|
+
secrets: {
|
|
639
|
+
botToken: settings.botToken || '',
|
|
640
|
+
resolveSecret: (ref) => resolveCredentialValue(ctx, ref),
|
|
641
|
+
},
|
|
642
|
+
}),
|
|
370
643
|
});
|
|
371
644
|
|
|
372
645
|
// Register settings in the Harness settings service (#71, #102): the scope
|
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,
|