@goodandready/dsh-cron 0.2.5 → 0.2.7

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/api.js ADDED
@@ -0,0 +1,533 @@
1
+ /**
2
+ * REST surface of the plugin: the /dsh-cron/tasks and /dsh-cron/action route
3
+ * families. Extracted from index.js and split into per-scope handlers so each
4
+ * piece stays reviewable (#97); route contracts and response bodies are
5
+ * unchanged.
6
+ */
7
+
8
+ import { randomUUID } from 'node:crypto';
9
+ import { parseScheduleExpression } from './scheduler.js';
10
+ import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
11
+ import { CHANNEL_IDS, unknownChannelIds } from './channels.js';
12
+ import { applyTaskPatch } from './task-patch.js';
13
+ import { MANAGED_BY_CONFIG, configOwnedMessage } from './config-jobs.js';
14
+ import { sendJson, rejectCrossOrigin, readBody, pickPatchableFields, SCRIPT_CONFIRM_HEADER } from './http-utils.js';
15
+ import {
16
+ validateTaskType,
17
+ buildDuplicateTask,
18
+ buildTaskExport,
19
+ validateImportDocument,
20
+ planImport,
21
+ hasCodeExecutingTask,
22
+ IMPORT_STRATEGIES,
23
+ RESERVED_TASK_IDS,
24
+ PATCHABLE_TASK_FIELDS,
25
+ } from './task-transfer.js';
26
+
27
+ const NOT_FOUND = { ok: false, error: 'Task not found' };
28
+ const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
29
+
30
+ /**
31
+ * A task declared in the profile config is owned by that file (#50): the next
32
+ * plugin start would revert any change made here, so every mutating route
33
+ * refuses it and says where the task really lives.
34
+ */
35
+ function refuseConfigOwned(res, id) {
36
+ sendJson(res, 409, { ok: false, error: configOwnedMessage(id) });
37
+ }
38
+
39
+ /**
40
+ * Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
41
+ * route families — the #66 alias is kept alive by the same code path instead
42
+ * of a duplicated handler.
43
+ *
44
+ * IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
45
+ * with one, the core degrades matching to exact-path equality and every
46
+ * deeper path unrouted (#96). Exported for unit tests.
47
+ */
48
+ export function createCronApiHandler(store, scheduler, collection) {
49
+ return async function handleCronApi(req, res) {
50
+ try {
51
+ const url = new URL(req.url, 'http://127.0.0.1');
52
+ const parts = url.pathname.split('/').filter(Boolean);
53
+ const scope = parts[1]; // 'tasks' | 'action'
54
+ const id = parts[2];
55
+ const action = parts[3];
56
+
57
+ if (!id) {
58
+ await handleTaskCollection({ store, scheduler, collection, req, res, url, scope });
59
+ return;
60
+ }
61
+
62
+ if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
63
+
64
+ // Export/import are collection operations that live under /tasks/:id/…,
65
+ // so they are matched before the generic id handling below.
66
+ if (id === 'export' && req.method === 'GET') {
67
+ handleTaskExport({ store, res });
68
+ return;
69
+ }
70
+ if (id === 'import' && req.method === 'POST') {
71
+ await handleTaskImport({ store, scheduler, req, res });
72
+ return;
73
+ }
74
+
75
+ await handleTaskItem({ store, scheduler, req, res, url, id, action });
76
+ } catch (err) {
77
+ sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
78
+ }
79
+ };
80
+ }
81
+
82
+ // ------------------------------------------------------------- collection
83
+
84
+ async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope }) {
85
+ // The alias family only exists for individual tasks.
86
+ if (scope === 'action' || !collection) {
87
+ sendJson(res, 405, NOT_ALLOWED);
88
+ return;
89
+ }
90
+ if (req.method === 'GET') {
91
+ listTasks({ store, scheduler, collection, res, url });
92
+ return;
93
+ }
94
+ if (req.method === 'POST') {
95
+ await createOrUpdateTask({ store, scheduler, req, res });
96
+ return;
97
+ }
98
+ sendJson(res, 405, NOT_ALLOWED);
99
+ }
100
+
101
+ function listTasks({ store, scheduler, collection, res, url }) {
102
+ const status = url.searchParams.get('status') || 'all';
103
+ const query = url.searchParams.get('query') || '';
104
+ const list = store.list({ status, query }).map((t) => ({
105
+ ...t,
106
+ running: scheduler.isRunning(t.id),
107
+ runningSince: scheduler.runningSince(t.id),
108
+ }));
109
+ const stats = store.getAggregatedStats();
110
+ sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
111
+ }
112
+
113
+ async function createOrUpdateTask({ store, scheduler, req, res }) {
114
+ if (rejectCrossOrigin(req, res)) return;
115
+ const { body, error } = await readBody(req, res);
116
+ if (error) return;
117
+ if (!body.title || !body.schedule || !body.prompt) {
118
+ sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
119
+ return;
120
+ }
121
+ const unknownChannels = unknownChannelIds(body.channels);
122
+ if (unknownChannels.length) {
123
+ sendJson(res, 400, {
124
+ ok: false,
125
+ error: 'Unknown channel ids: ' + unknownChannels.join(', '),
126
+ unknownChannels,
127
+ });
128
+ return;
129
+ }
130
+ const taskType = normalizeTaskType(body.type);
131
+ if (CODE_EXECUTING_TYPES.includes(taskType) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
132
+ sendJson(res, 403, { ok: false, error: `Creating ${taskType} tasks over HTTP requires the x-dsh-cron-confirm: script header` });
133
+ return;
134
+ }
135
+ const typeError = validateTaskType(taskType, body);
136
+ if (typeError) {
137
+ sendJson(res, 400, { ok: false, error: typeError });
138
+ return;
139
+ }
140
+ const parsed = parseScheduleExpression(body.schedule);
141
+ // A client-supplied id must not shadow the collection routes under
142
+ // /dsh-cron/tasks, otherwise that task could never be fetched or deleted
143
+ // again (#42 review finding).
144
+ if (body.id && RESERVED_TASK_IDS.includes(String(body.id))) {
145
+ sendJson(res, 400, { ok: false, error: `Task id "${body.id}" is reserved` });
146
+ return;
147
+ }
148
+ // Create-or-update with an existing id is an update too, so the config-owned
149
+ // refusal must apply here as well — otherwise the POST route would be a way
150
+ // around the guard on PATCH (#50 review finding).
151
+ if (body.id) {
152
+ const existing = store.get(body.id);
153
+ if (existing && existing.managedBy === MANAGED_BY_CONFIG) {
154
+ refuseConfigOwned(res, body.id);
155
+ return;
156
+ }
157
+ }
158
+ const task = store.set(buildTaskRecord(store, body, taskType, parsed));
159
+ if (task.status === 'active') {
160
+ scheduler.scheduleTask(task);
161
+ } else {
162
+ scheduler.pauseTask(task.id);
163
+ }
164
+ sendJson(res, 200, { ok: true, task });
165
+ }
166
+
167
+ /** Merge a create/update payload onto the stored task, if any. */
168
+ function buildTaskRecord(store, body, taskType, parsed) {
169
+ const current = body.id ? store.get(body.id) : null;
170
+ const resolvedStatus = body.status || (current ? current.status : 'active');
171
+ return {
172
+ id: body.id,
173
+ title: body.title,
174
+ schedule: parsed.cronPattern || body.schedule,
175
+ scheduleText: body.scheduleText || parsed.humanText,
176
+ prompt: body.prompt,
177
+ type: taskType,
178
+ delivery: body.delivery || 'current',
179
+ status: resolvedStatus,
180
+ provider: body.provider || undefined,
181
+ model: body.model || undefined,
182
+ fallbackProvider: body.fallbackProvider || undefined,
183
+ fallbackModel: body.fallbackModel || undefined,
184
+ notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
185
+ onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
186
+ timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
187
+ overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
188
+ kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
189
+ timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
190
+ misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
191
+ maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
192
+ retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
193
+ permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
194
+ ...mergeExecutionFields(current, body, parsed),
195
+ };
196
+ }
197
+
198
+ // --------------------------------------------------------- export / import
199
+
200
+ function handleTaskExport({ store, res }) {
201
+ const doc = buildTaskExport(store.list({ status: 'all' }));
202
+ sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length });
203
+ }
204
+
205
+ async function handleTaskImport({ store, scheduler, req, res }) {
206
+ const { body, error } = await readBody(req, res);
207
+ if (error) return;
208
+ const strategy = IMPORT_STRATEGIES.includes(body && body.strategy) ? body.strategy : 'skip';
209
+ const checked = validateImportDocument(body && body.document);
210
+ if (!checked.ok) {
211
+ sendJson(res, 400, { ok: false, error: checked.error });
212
+ return;
213
+ }
214
+ // The import file is external input: importing code-executing tasks needs
215
+ // the same explicit confirmation that creating one over HTTP needs,
216
+ // otherwise a hand-edited file would bypass that gate (#86).
217
+ if (hasCodeExecutingTask(checked.tasks) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
218
+ sendJson(res, 403, {
219
+ ok: false,
220
+ error: `Importing code-executing tasks over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`,
221
+ });
222
+ return;
223
+ }
224
+ // #121: an imported file may name a channel this build no longer has. The
225
+ // import stays tolerant by design (#42), but the loss is reported instead of
226
+ // being invisible.
227
+ const rawTasks = Array.isArray(body.document && body.document.tasks) ? body.document.tasks : [];
228
+ const droppedChannels = [];
229
+ for (const item of rawTasks) {
230
+ for (const id of unknownChannelIds(item.channels)) {
231
+ if (!droppedChannels.includes(id)) droppedChannels.push(id);
232
+ }
233
+ }
234
+ if (droppedChannels.length) {
235
+ console.warn('[dsh-cron] import: unknown channel ids were dropped: ' + droppedChannels.join(', '));
236
+ }
237
+ const existingIds = store.list({ status: 'all' }).map((x) => x.id);
238
+ const plan = planImport(checked.tasks, existingIds, strategy);
239
+ // Replacing a task the config owns would be undone at the next start, and
240
+ // the id could then hold two different tasks (#50).
241
+ const configConflicts = plan.replace
242
+ .map((item) => item.id)
243
+ .filter((taskId) => {
244
+ const stored = store.get(taskId);
245
+ return stored && stored.managedBy === MANAGED_BY_CONFIG;
246
+ });
247
+ if (configConflicts.length) {
248
+ sendJson(res, 409, {
249
+ ok: false,
250
+ error: 'These tasks are declared in the profile config and cannot be replaced by an import: ' + configConflicts.join(', '),
251
+ });
252
+ return;
253
+ }
254
+ const summary = { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length };
255
+ if (body && body.dryRun) {
256
+ sendJson(res, 200, {
257
+ ok: true,
258
+ dryRun: true,
259
+ strategy: plan.strategy,
260
+ summary,
261
+ codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
262
+ unknownChannels: droppedChannels,
263
+ });
264
+ return;
265
+ }
266
+ const applied = applyImportPlan({ store, scheduler, plan });
267
+ if (applied.error) {
268
+ sendJson(res, 500, { ok: false, error: applied.error });
269
+ return;
270
+ }
271
+ sendJson(res, 200, {
272
+ ok: true,
273
+ imported: applied.imported.length,
274
+ summary,
275
+ unknownChannels: droppedChannels,
276
+ tasks: applied.imported,
277
+ });
278
+ }
279
+
280
+ /**
281
+ * Write an import plan. Every imported task is paused (the file's status is
282
+ * ignored) and every touched id is disarmed explicitly, because writing
283
+ * "paused" does not stop an already-armed croner job. A failed write rolls the
284
+ * store back so a partial import cannot survive.
285
+ */
286
+ function applyImportPlan({ store, scheduler, plan }) {
287
+ const imported = [];
288
+ const rollback = [];
289
+ const disarm = [];
290
+ try {
291
+ for (const task of plan.add) {
292
+ const before = store.get(task.id);
293
+ const created = store.set({ ...task, id: task.id || randomUUID(), status: 'paused', updatedAt: Date.now() });
294
+ imported.push(created);
295
+ rollback.push({ id: created.id, previous: before || null });
296
+ disarm.push(created.id);
297
+ }
298
+ for (const task of plan.replace) {
299
+ const before = store.get(task.id);
300
+ if (!before) continue;
301
+ const replaced = store.set({ ...task, id: task.id, status: 'paused', updatedAt: Date.now() });
302
+ imported.push(replaced);
303
+ rollback.push({ id: replaced.id, previous: before });
304
+ disarm.push(replaced.id);
305
+ }
306
+ } catch (writeErr) {
307
+ for (const entry of rollback.reverse()) {
308
+ try {
309
+ if (entry.previous) store.set(entry.previous);
310
+ else store.delete(entry.id);
311
+ } catch (_) {}
312
+ }
313
+ return { imported: [], error: `Import failed and was rolled back: ${writeErr.message}` };
314
+ }
315
+ // Disarm outside the write transaction: pauseTask is idempotent and a
316
+ // failure here must not undo a successful import.
317
+ for (const id of disarm) {
318
+ try {
319
+ scheduler.pauseTask(id);
320
+ } catch (err) {
321
+ console.error('[dsh-cron] import: could not pause task', id + ':', err.message);
322
+ }
323
+ }
324
+ return { imported };
325
+ }
326
+
327
+ // ------------------------------------------------------------ single task
328
+
329
+ async function handleTaskItem({ store, scheduler, req, res, url, id, action }) {
330
+ if (req.method === 'GET' && (action === 'history' || !action)) {
331
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
332
+ sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
333
+ return;
334
+ }
335
+ if (req.method === 'POST') {
336
+ const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
337
+ if (handled) return;
338
+ }
339
+ if (req.method === 'PATCH') {
340
+ await patchTask({ store, scheduler, req, res, id });
341
+ return;
342
+ }
343
+ if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
344
+ deleteTask({ store, scheduler, res, id });
345
+ return;
346
+ }
347
+ sendJson(res, 405, NOT_ALLOWED);
348
+ }
349
+
350
+ /** Task actions that are plain POSTs. Returns true when one matched. */
351
+ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
352
+ if (action === 'run') {
353
+ if (!store.get(id)) {
354
+ sendJson(res, 404, NOT_FOUND);
355
+ return true;
356
+ }
357
+ await scheduler.triggerManualRun(id);
358
+ sendJson(res, 200, { ok: true, task: store.get(id) });
359
+ return true;
360
+ }
361
+ if (action === 'duplicate') {
362
+ const source = store.get(id);
363
+ if (!source) {
364
+ sendJson(res, 404, NOT_FOUND);
365
+ return true;
366
+ }
367
+ const copy = buildDuplicateTask(source, { id: randomUUID() });
368
+ store.set(copy);
369
+ // A copy starts paused: it must never fire on its own before the user
370
+ // reviews the schedule (a duplicated one-shot may point at a past time).
371
+ scheduler.pauseTask(copy.id);
372
+ sendJson(res, 200, { ok: true, task: copy });
373
+ return true;
374
+ }
375
+ if (action === 'pause' || action === 'resume') {
376
+ if (store.get(id) && store.get(id).managedBy === MANAGED_BY_CONFIG) {
377
+ refuseConfigOwned(res, id);
378
+ return true;
379
+ }
380
+ const task = action === 'pause' ? scheduler.pauseTask(id) : scheduler.resumeTask(id);
381
+ if (!task) {
382
+ sendJson(res, 404, NOT_FOUND);
383
+ return true;
384
+ }
385
+ sendJson(res, 200, { ok: true, task });
386
+ return true;
387
+ }
388
+ if (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle')) {
389
+ const stored = store.get(id);
390
+ if (!stored) {
391
+ sendJson(res, 404, NOT_FOUND);
392
+ return true;
393
+ }
394
+ if (stored.managedBy === MANAGED_BY_CONFIG) {
395
+ refuseConfigOwned(res, id);
396
+ return true;
397
+ }
398
+ const task = scheduler.toggleTask(id);
399
+ sendJson(res, 200, { ok: true, task });
400
+ return true;
401
+ }
402
+ return false;
403
+ }
404
+
405
+ async function patchTask({ store, scheduler, req, res, id }) {
406
+ const current = store.get(id);
407
+ if (!current) {
408
+ sendJson(res, 404, NOT_FOUND);
409
+ return;
410
+ }
411
+ if (current.managedBy === MANAGED_BY_CONFIG) {
412
+ refuseConfigOwned(res, id);
413
+ return;
414
+ }
415
+ const { body, error } = await readBody(req, res);
416
+ if (error) return;
417
+ const result = applyTaskPatch({
418
+ store,
419
+ scheduler,
420
+ id,
421
+ body,
422
+ allowCodeSwitch: req.headers[SCRIPT_CONFIRM_HEADER] === 'script',
423
+ });
424
+ if (!result.ok) {
425
+ const status = result.notFound ? 404 : (result.needsConfirmation ? 403 : 400);
426
+ const error = result.needsConfirmation
427
+ ? `Switching a task to the ${normalizeTaskType(body.type)} type over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`
428
+ : result.error;
429
+ sendJson(res, status, {
430
+ ok: false,
431
+ error,
432
+ ...(result.unknownChannels ? { unknownChannels: result.unknownChannels } : {}),
433
+ });
434
+ return;
435
+ }
436
+ sendJson(res, 200, { ok: true, task: result.task });
437
+ }
438
+
439
+ function deleteTask({ store, scheduler, res, id }) {
440
+ const current = store.get(id);
441
+ if (current && current.managedBy === MANAGED_BY_CONFIG) {
442
+ refuseConfigOwned(res, id);
443
+ return;
444
+ }
445
+ if (current) {
446
+ scheduler.pauseTask(id);
447
+ store.delete(id);
448
+ }
449
+ sendJson(res, 200, { ok: true });
450
+ }
451
+
452
+ /**
453
+ * Runtime and execution settings of a task, merged onto the stored one. Split
454
+ * out of buildTaskRecord so both stay readable.
455
+ */
456
+ function mergeExecutionFields(current, body, parsed) {
457
+ return {
458
+ env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
459
+ cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
460
+ channels: Array.isArray(body.channels)
461
+ ? body.channels.filter((c) => CHANNEL_IDS.includes(c))
462
+ : (current ? current.channels : undefined),
463
+ template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
464
+ silentRule: body.silentRule !== undefined ? String(body.silentRule) : (current ? current.silentRule : ''),
465
+ inspectOnFailure: body.inspectOnFailure !== undefined ? Boolean(body.inspectOnFailure) : (current ? Boolean(current.inspectOnFailure) : false),
466
+ workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
467
+ worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
468
+ keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
469
+ httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
470
+ httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
471
+ httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
472
+ httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
473
+ sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
474
+ sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
475
+ sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
476
+ sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
477
+ dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
478
+ pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
479
+ nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
480
+ skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
481
+ workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
482
+ oneShot: Boolean((parsed && parsed.isOneShot) || (body && body.oneShot)),
483
+ };
484
+ }
485
+
486
+ // --------------------------------------------------------- external surface
487
+
488
+ /**
489
+ * Task operations for the token-guarded /dsh-cron/api/* prefix (#54). The shape
490
+ * is deliberately small — list, read, create or update, delete, manual run —
491
+ * and reuses the handlers above, so the confirmation gate for code-executing
492
+ * tasks and the config-owned refusals are the same code, not a second copy.
493
+ */
494
+ export async function handleExternalTaskRequest({ store, scheduler, req, res, url }) {
495
+ const parts = url.pathname.split('/').filter(Boolean); // dsh-cron, api, tasks, :id?, :action?
496
+ const id = parts[3];
497
+ const action = parts[4];
498
+
499
+ if (!id) {
500
+ if (req.method === 'GET') {
501
+ const list = store
502
+ .list({ status: url.searchParams.get('status') || 'all', query: url.searchParams.get('query') || '' })
503
+ .map((task) => ({ ...task, running: scheduler.isRunning(task.id) }));
504
+ sendJson(res, 200, { ok: true, tasks: list });
505
+ return;
506
+ }
507
+ if (req.method === 'POST') {
508
+ await createOrUpdateTask({ store, scheduler, req, res });
509
+ return;
510
+ }
511
+ sendJson(res, 405, NOT_ALLOWED);
512
+ return;
513
+ }
514
+
515
+ if (req.method === 'POST') {
516
+ const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
517
+ if (handled) return;
518
+ }
519
+ if (req.method === 'GET' && !action) {
520
+ const task = store.get(id);
521
+ if (!task) {
522
+ sendJson(res, 404, NOT_FOUND);
523
+ return;
524
+ }
525
+ sendJson(res, 200, { ok: true, task: { ...task, running: scheduler.isRunning(id) } });
526
+ return;
527
+ }
528
+ if (req.method === 'DELETE' && !action) {
529
+ deleteTask({ store, scheduler, res, id });
530
+ return;
531
+ }
532
+ sendJson(res, 405, NOT_ALLOWED);
533
+ }
package/lib/channels.js CHANGED
@@ -56,9 +56,13 @@ export function resolveChannels(task, settings = {}) {
56
56
  * reported explicitly — a silently dropped notification is worse than a
57
57
  * visible failure.
58
58
  */
59
+ export function unknownChannelIds(list) {
60
+ if (!Array.isArray(list)) return [];
61
+ return list.filter((id) => !CHANNEL_IDS.includes(id));
62
+ }
63
+
59
64
  export function unknownChannels(task) {
60
- if (!Array.isArray(task.channels)) return [];
61
- return task.channels.filter((id) => !CHANNEL_IDS.includes(id));
65
+ return unknownChannelIds(task && task.channels);
62
66
  }
63
67
 
64
68
  /** Failure/only-on-failure filtering, per channel. */