@goodandready/dsh-cron 0.2.4 → 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 +9 -15
- package/docs/README.ru.md +10 -15
- package/docs/README.zh.md +10 -15
- package/docs/design/DESIGN.md +8 -4
- package/docs/plans/0.2.5-ui-block.md +50 -0
- package/lib/channels.js +51 -61
- package/lib/client.js +573 -40
- package/lib/index.js +159 -80
- package/lib/store.js +5 -5
- package/lib/task-transfer.js +226 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -16,7 +16,42 @@ import {
|
|
|
16
16
|
} from './http-utils.js';
|
|
17
17
|
import { TASK_TYPES, CODE_EXECUTING_TYPES, normalizeTaskType } from './runtimes.js';
|
|
18
18
|
import { resolveTelegramSecrets, resolveCredentialValue } from './secrets.js';
|
|
19
|
-
import {
|
|
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';
|
|
20
55
|
|
|
21
56
|
export const name = '@goodandready/dsh-cron';
|
|
22
57
|
export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
|
|
@@ -35,7 +70,7 @@ export const Config = z.object({
|
|
|
35
70
|
// --- Delivery channels (#20-#23, #26, #28, #47) ---
|
|
36
71
|
template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
|
|
37
72
|
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'),
|
|
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'),
|
|
39
74
|
discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
|
|
40
75
|
slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
|
|
41
76
|
ntfyUrl: z.string().default('https://ntfy.sh').description('ntfy server base URL'),
|
|
@@ -45,88 +80,12 @@ export const Config = z.object({
|
|
|
45
80
|
barkKey: z.string().default('').description('Bark device key'),
|
|
46
81
|
pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token'),
|
|
47
82
|
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
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'),
|
|
56
84
|
giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
|
|
57
85
|
giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
|
|
58
86
|
giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
|
|
59
87
|
});
|
|
60
88
|
|
|
61
|
-
const PATCHABLE_TASK_FIELDS = [
|
|
62
|
-
'title',
|
|
63
|
-
'schedule',
|
|
64
|
-
'scheduleText',
|
|
65
|
-
'prompt',
|
|
66
|
-
'type',
|
|
67
|
-
'delivery',
|
|
68
|
-
'provider',
|
|
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',
|
|
93
|
-
'notifyTelegram',
|
|
94
|
-
'onlyOnFailure',
|
|
95
|
-
'timeoutSeconds',
|
|
96
|
-
'overlapPolicy',
|
|
97
|
-
'kanbanMode',
|
|
98
|
-
'channels',
|
|
99
|
-
'template',
|
|
100
|
-
'status',
|
|
101
|
-
'oneShot',
|
|
102
|
-
];
|
|
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
89
|
|
|
131
90
|
function sendJson(res, statusCode, data) {
|
|
132
91
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
@@ -159,7 +118,6 @@ export const SETTINGS_SYNC_KEYS = [
|
|
|
159
118
|
'barkServerUrl', 'barkKey',
|
|
160
119
|
'pushplusTokenRef',
|
|
161
120
|
'pushplusUrl',
|
|
162
|
-
'smtpHost', 'smtpPort', 'smtpSecure', 'smtpUser', 'smtpPasswordRef', 'smtpFrom', 'smtpTo',
|
|
163
121
|
'ttsBaseUrl',
|
|
164
122
|
'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
|
|
165
123
|
];
|
|
@@ -187,7 +145,7 @@ export async function applySettingsToScope(settingsScope, body) {
|
|
|
187
145
|
* Drop values that must never re-enter the store from the client:
|
|
188
146
|
*
|
|
189
147
|
* - a masked secret echoed back by the settings UI would overwrite the real
|
|
190
|
-
* value (bot token,
|
|
148
|
+
* value (bot token, webhook URLs, Bark key);
|
|
191
149
|
* - the raw credential keys are refused outright — the *Ref fields are the
|
|
192
150
|
* supported way to configure them.
|
|
193
151
|
*
|
|
@@ -208,6 +166,18 @@ export function sanitizeSettingsPayload(body) {
|
|
|
208
166
|
delete payload[key];
|
|
209
167
|
}
|
|
210
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
|
+
}
|
|
180
|
+
}
|
|
211
181
|
return payload;
|
|
212
182
|
}
|
|
213
183
|
|
|
@@ -266,6 +236,13 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
266
236
|
return;
|
|
267
237
|
}
|
|
268
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
|
+
}
|
|
269
246
|
const current = body.id ? store.get(body.id) : null;
|
|
270
247
|
const resolvedStatus = body.status || (current ? current.status : 'active');
|
|
271
248
|
|
|
@@ -329,6 +306,93 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
329
306
|
// Item level: /dsh-cron/{tasks|action}/:id[/:action]
|
|
330
307
|
if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
|
|
331
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
|
+
|
|
332
396
|
if (req.method === 'GET' && (action === 'history' || !action)) {
|
|
333
397
|
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
334
398
|
sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
|
|
@@ -345,6 +409,21 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
345
409
|
return;
|
|
346
410
|
}
|
|
347
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
|
+
|
|
348
427
|
if (req.method === 'POST' && action === 'pause') {
|
|
349
428
|
const task = scheduler.pauseTask(id);
|
|
350
429
|
if (!task) {
|
package/lib/store.js
CHANGED
|
@@ -129,17 +129,17 @@ export class TaskStore {
|
|
|
129
129
|
* these channels.
|
|
130
130
|
*/
|
|
131
131
|
static get FORBIDDEN_SETTING_KEYS() {
|
|
132
|
-
return ['
|
|
132
|
+
return ['giteaToken', 'ntfyToken', 'pushplusToken', 'discordToken', 'slackToken'];
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
|
-
* Settings that hold a secret and are therefore masked on read: tokens
|
|
137
|
-
*
|
|
136
|
+
* Settings that hold a secret and are therefore masked on read: tokens and
|
|
137
|
+
* webhook URLs (the URL embeds the auth token), plus the Bark device key.
|
|
138
138
|
* These are stored in the plugin settings file; the credential-reference
|
|
139
|
-
* fields (botTokenRef,
|
|
139
|
+
* fields (botTokenRef, ntfyTokenRef, …) take precedence over them.
|
|
140
140
|
*/
|
|
141
141
|
static get MASKED_SETTING_KEYS() {
|
|
142
|
-
return ['botToken', '
|
|
142
|
+
return ['botToken', 'discordWebhookUrl', 'slackWebhookUrl', 'barkKey'];
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
saveSettings(newSettings = {}) {
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task transfer helpers: duplication, export and import (#41, #42).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions over plain task objects with no I/O, so the whole
|
|
5
|
+
* configuration-transfer contract is unit-testable and the HTTP layer stays a
|
|
6
|
+
* thin adapter. Kept out of index.js both for cohesion and to keep that file
|
|
7
|
+
* from growing further (see issue #97).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { normalizeTaskType } from './runtimes.js';
|
|
12
|
+
import { CHANNEL_IDS } from './channels.js';
|
|
13
|
+
|
|
14
|
+
/** Task fields a client may patch; the single source of truth below. */
|
|
15
|
+
export const PATCHABLE_TASK_FIELDS = [
|
|
16
|
+
'title',
|
|
17
|
+
'schedule',
|
|
18
|
+
'scheduleText',
|
|
19
|
+
'prompt',
|
|
20
|
+
'type',
|
|
21
|
+
'delivery',
|
|
22
|
+
'provider',
|
|
23
|
+
'model',
|
|
24
|
+
'timezone',
|
|
25
|
+
'misfirePolicy',
|
|
26
|
+
'maxRetries',
|
|
27
|
+
'retryBackoffMs',
|
|
28
|
+
'permissionPreset',
|
|
29
|
+
'env',
|
|
30
|
+
'cwd',
|
|
31
|
+
'workspaceId',
|
|
32
|
+
'worktree',
|
|
33
|
+
'keepWorktree',
|
|
34
|
+
'httpMethod',
|
|
35
|
+
'httpUrl',
|
|
36
|
+
'httpHeaders',
|
|
37
|
+
'httpBody',
|
|
38
|
+
'sshTarget',
|
|
39
|
+
'sshPort',
|
|
40
|
+
'sshKeyPath',
|
|
41
|
+
'sshProfileId',
|
|
42
|
+
'dockerImage',
|
|
43
|
+
'pythonPath',
|
|
44
|
+
'nodePath',
|
|
45
|
+
'skillName',
|
|
46
|
+
'workflowName',
|
|
47
|
+
'notifyTelegram',
|
|
48
|
+
'onlyOnFailure',
|
|
49
|
+
'timeoutSeconds',
|
|
50
|
+
'overlapPolicy',
|
|
51
|
+
'kanbanMode',
|
|
52
|
+
'channels',
|
|
53
|
+
'template',
|
|
54
|
+
'status',
|
|
55
|
+
'oneShot',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** Fields a duplicate inherits; anything else is runtime state, not config. */
|
|
59
|
+
export const DUPLICATE_TASK_FIELDS = PATCHABLE_TASK_FIELDS.filter((key) => key !== 'status');
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Task ids that would shadow the collection routes under /dsh-cron/tasks.
|
|
63
|
+
* A task may not use them, otherwise it could never be fetched, paused or
|
|
64
|
+
* deleted again (#42 review finding).
|
|
65
|
+
*/
|
|
66
|
+
export const RESERVED_TASK_IDS = ['export', 'import'];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Task-level fields that can carry a secret the user typed in by hand
|
|
70
|
+
* (`env` values, authorization headers). The export includes them, so the UI
|
|
71
|
+
* and docs must say so instead of promising a secret-free file.
|
|
72
|
+
*/
|
|
73
|
+
export const EXPORT_SECRET_BEARING_FIELDS = ['env', 'httpHeaders', 'httpBody'];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate runtime-specific task configuration. Returns an error string or
|
|
77
|
+
* null when the payload is usable for the given type.
|
|
78
|
+
*/
|
|
79
|
+
export function validateTaskType(type, payload) {
|
|
80
|
+
const t = normalizeTaskType(type);
|
|
81
|
+
if (t === 'http') {
|
|
82
|
+
const raw = String((payload && (payload.httpUrl || payload.prompt)) || '').trim();
|
|
83
|
+
if (!raw) return 'HTTP tasks require a URL (httpUrl or prompt)';
|
|
84
|
+
try {
|
|
85
|
+
// eslint-disable-next-line no-new
|
|
86
|
+
new URL(raw);
|
|
87
|
+
} catch {
|
|
88
|
+
return `Invalid HTTP URL: ${raw}`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (t === 'ssh') {
|
|
92
|
+
const hasProfile = Boolean(payload && String(payload.sshProfileId || '').trim());
|
|
93
|
+
const hasTarget = Boolean(payload && String(payload.sshTarget || '').trim());
|
|
94
|
+
if (!hasProfile && !hasTarget) return 'SSH tasks require sshProfileId (remote-workspace profile) or sshTarget (user@host)';
|
|
95
|
+
}
|
|
96
|
+
if (t === 'docker' && !String((payload && payload.dockerImage) || '').trim()) {
|
|
97
|
+
return 'Docker tasks require dockerImage';
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build a paused copy of a task (#41).
|
|
104
|
+
*
|
|
105
|
+
* Configuration is copied, execution state is not: the copy starts paused with
|
|
106
|
+
* no history, no token/cost totals, no last-run info and no retry counter, so a
|
|
107
|
+
* duplicate can be reviewed before it ever runs. A duplicated one-shot keeps
|
|
108
|
+
* its original schedule string and stays inert until the user resumes it.
|
|
109
|
+
*/
|
|
110
|
+
export function buildDuplicateTask(source, { id } = {}) {
|
|
111
|
+
const copy = {
|
|
112
|
+
id: id || randomUUID(),
|
|
113
|
+
title: `${source.title || 'Task'} (copy)`,
|
|
114
|
+
status: 'paused',
|
|
115
|
+
createdAt: Date.now(),
|
|
116
|
+
updatedAt: Date.now(),
|
|
117
|
+
};
|
|
118
|
+
for (const key of DUPLICATE_TASK_FIELDS) {
|
|
119
|
+
if (key === 'title') continue;
|
|
120
|
+
if (source[key] !== undefined) copy[key] = Array.isArray(source[key]) ? [...source[key]] : source[key];
|
|
121
|
+
}
|
|
122
|
+
return copy;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------- export / import (#42)
|
|
126
|
+
|
|
127
|
+
/** Document kind marker so a random JSON file is not mistaken for an export. */
|
|
128
|
+
export const TASK_EXPORT_KIND = 'dsh-cron-tasks';
|
|
129
|
+
export const TASK_EXPORT_VERSION = 1;
|
|
130
|
+
|
|
131
|
+
/** Fields an exported task carries: identity + configuration, never run state. */
|
|
132
|
+
export const EXPORT_TASK_FIELDS = ['id', 'title', ...DUPLICATE_TASK_FIELDS.filter((k) => k !== 'title')];
|
|
133
|
+
|
|
134
|
+
/** Import strategies: add (new ids), replace (overwrite by id), skip (leave existing). */
|
|
135
|
+
export const IMPORT_STRATEGIES = ['add', 'replace', 'skip'];
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Serialise tasks for transfer/backup. Run state (history, token and cost
|
|
139
|
+
* totals, last-run info) is deliberately excluded — the file is a
|
|
140
|
+
* configuration artefact. Channels reference credentials by name, but task-level
|
|
141
|
+
* `env` and headers can contain hand-typed secrets, so the caller must not
|
|
142
|
+
* claim the file is secret-free.
|
|
143
|
+
*/
|
|
144
|
+
export function buildTaskExport(tasks, { now = Date.now() } = {}) {
|
|
145
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
146
|
+
return {
|
|
147
|
+
kind: TASK_EXPORT_KIND,
|
|
148
|
+
version: TASK_EXPORT_VERSION,
|
|
149
|
+
exportedAt: new Date(now).toISOString(),
|
|
150
|
+
tasks: list.map((task) => {
|
|
151
|
+
const out = {};
|
|
152
|
+
for (const key of EXPORT_TASK_FIELDS) {
|
|
153
|
+
if (task[key] === undefined || key === 'status') continue;
|
|
154
|
+
out[key] = Array.isArray(task[key]) ? [...task[key]] : task[key];
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Validate an import document and return sanitised tasks.
|
|
163
|
+
*
|
|
164
|
+
* An import file is external input, so it is treated as untrusted: only
|
|
165
|
+
* whitelisted configuration fields survive, the file's `status` is ignored
|
|
166
|
+
* (imported tasks always start paused, matching the documented behaviour), and
|
|
167
|
+
* a document that would shadow a collection route id is refused. A malformed
|
|
168
|
+
* file is rejected as a whole so nothing is written.
|
|
169
|
+
*/
|
|
170
|
+
export function validateImportDocument(doc) {
|
|
171
|
+
if (!doc || typeof doc !== 'object') return { ok: false, error: 'Import file is not a JSON object' };
|
|
172
|
+
if (doc.kind !== TASK_EXPORT_KIND) return { ok: false, error: `Not a ${TASK_EXPORT_KIND} export (missing kind marker)` };
|
|
173
|
+
if (Number(doc.version) > TASK_EXPORT_VERSION) {
|
|
174
|
+
return { ok: false, error: `Export version ${doc.version} is newer than this plugin understands (${TASK_EXPORT_VERSION})` };
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(doc.tasks)) return { ok: false, error: 'Export has no tasks array' };
|
|
177
|
+
const tasks = [];
|
|
178
|
+
for (const [index, raw] of doc.tasks.entries()) {
|
|
179
|
+
if (!raw || typeof raw !== 'object') return { ok: false, error: `Task #${index + 1} is not an object` };
|
|
180
|
+
if (!raw.title || !String(raw.title).trim()) return { ok: false, error: `Task #${index + 1} has no title` };
|
|
181
|
+
if (!raw.schedule || !String(raw.schedule).trim()) return { ok: false, error: `Task #${index + 1} has no schedule` };
|
|
182
|
+
if (!raw.prompt || !String(raw.prompt).trim()) return { ok: false, error: `Task #${index + 1} has no prompt` };
|
|
183
|
+
const id = raw.id === undefined ? undefined : String(raw.id);
|
|
184
|
+
if (id && RESERVED_TASK_IDS.includes(id)) {
|
|
185
|
+
return { ok: false, error: `Task #${index + 1} uses the reserved id "${id}" which would shadow a task route` };
|
|
186
|
+
}
|
|
187
|
+
const type = normalizeTaskType(raw.type);
|
|
188
|
+
const typeError = validateTaskType(type, raw);
|
|
189
|
+
if (typeError) return { ok: false, error: `Task #${index + 1} ("${raw.title}"): ${typeError}` };
|
|
190
|
+
const channels = Array.isArray(raw.channels) ? raw.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined;
|
|
191
|
+
// Whitelist: drop anything the file tried to smuggle in (counters, history
|
|
192
|
+
// pointers, unknown keys) and never inherit the file's status.
|
|
193
|
+
const clean = { status: 'paused', type };
|
|
194
|
+
for (const key of DUPLICATE_TASK_FIELDS) {
|
|
195
|
+
if (raw[key] !== undefined) clean[key] = Array.isArray(raw[key]) ? [...raw[key]] : raw[key];
|
|
196
|
+
}
|
|
197
|
+
if (id) clean.id = id;
|
|
198
|
+
if (channels) clean.channels = channels;
|
|
199
|
+
tasks.push(clean);
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, tasks };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** True when any task in a validated batch executes code on the host. */
|
|
205
|
+
export function hasCodeExecutingTask(tasks) {
|
|
206
|
+
const CODE_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
207
|
+
return (tasks || []).some((task) => CODE_TYPES.includes(normalizeTaskType(task.type)));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Decide what an import would do, without touching anything. Existing ids are
|
|
212
|
+
* compared against tasks already in the store.
|
|
213
|
+
*/
|
|
214
|
+
export function planImport(tasks, existingIds, strategy = 'skip') {
|
|
215
|
+
const mode = IMPORT_STRATEGIES.includes(strategy) ? strategy : 'skip';
|
|
216
|
+
const existing = new Set(existingIds || []);
|
|
217
|
+
const plan = { strategy: mode, add: [], replace: [], skip: [] };
|
|
218
|
+
for (const task of tasks || []) {
|
|
219
|
+
const hasId = Boolean(task.id) && existing.has(task.id);
|
|
220
|
+
if (!hasId) plan.add.push(task);
|
|
221
|
+
else if (mode === 'replace') plan.replace.push(task);
|
|
222
|
+
else if (mode === 'skip') plan.skip.push(task);
|
|
223
|
+
else plan.add.push({ ...task, id: undefined });
|
|
224
|
+
}
|
|
225
|
+
return plan;
|
|
226
|
+
}
|