@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/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 { deliverRun, CHANNEL_IDS } from './channels.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';
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, SMTP password, webhook URLs, Bark key);
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 ['smtpPassword', 'giteaToken', 'ntfyToken', 'pushplusToken', 'discordToken', 'slackToken'];
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
- * passwords, webhook URLs (the URL embeds the auth token) and the Bark key.
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, smtpPasswordRef, …) take precedence over them.
139
+ * fields (botTokenRef, ntfyTokenRef, …) take precedence over them.
140
140
  */
141
141
  static get MASKED_SETTING_KEYS() {
142
- return ['botToken', 'smtpPassword', 'discordWebhookUrl', 'slackWebhookUrl', 'barkKey'];
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",