@goodandready/dsh-cron 0.2.5 → 0.2.6

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,399 @@
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 } from './channels.js';
12
+ import { applyTaskPatch } from './task-patch.js';
13
+ import { sendJson, rejectCrossOrigin, readBody, pickPatchableFields, SCRIPT_CONFIRM_HEADER } from './http-utils.js';
14
+ import {
15
+ validateTaskType,
16
+ buildDuplicateTask,
17
+ buildTaskExport,
18
+ validateImportDocument,
19
+ planImport,
20
+ hasCodeExecutingTask,
21
+ IMPORT_STRATEGIES,
22
+ RESERVED_TASK_IDS,
23
+ PATCHABLE_TASK_FIELDS,
24
+ } from './task-transfer.js';
25
+
26
+ const NOT_FOUND = { ok: false, error: 'Task not found' };
27
+ const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
28
+
29
+ /**
30
+ * Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
31
+ * route families — the #66 alias is kept alive by the same code path instead
32
+ * of a duplicated handler.
33
+ *
34
+ * IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
35
+ * with one, the core degrades matching to exact-path equality and every
36
+ * deeper path unrouted (#96). Exported for unit tests.
37
+ */
38
+ export function createCronApiHandler(store, scheduler, collection) {
39
+ return async function handleCronApi(req, res) {
40
+ try {
41
+ const url = new URL(req.url, 'http://127.0.0.1');
42
+ const parts = url.pathname.split('/').filter(Boolean);
43
+ const scope = parts[1]; // 'tasks' | 'action'
44
+ const id = parts[2];
45
+ const action = parts[3];
46
+
47
+ if (!id) {
48
+ await handleTaskCollection({ store, scheduler, collection, req, res, url, scope });
49
+ return;
50
+ }
51
+
52
+ if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
53
+
54
+ // Export/import are collection operations that live under /tasks/:id/…,
55
+ // so they are matched before the generic id handling below.
56
+ if (id === 'export' && req.method === 'GET') {
57
+ handleTaskExport({ store, res });
58
+ return;
59
+ }
60
+ if (id === 'import' && req.method === 'POST') {
61
+ await handleTaskImport({ store, scheduler, req, res });
62
+ return;
63
+ }
64
+
65
+ await handleTaskItem({ store, scheduler, req, res, url, id, action });
66
+ } catch (err) {
67
+ sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
68
+ }
69
+ };
70
+ }
71
+
72
+ // ------------------------------------------------------------- collection
73
+
74
+ async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope }) {
75
+ // The alias family only exists for individual tasks.
76
+ if (scope === 'action' || !collection) {
77
+ sendJson(res, 405, NOT_ALLOWED);
78
+ return;
79
+ }
80
+ if (req.method === 'GET') {
81
+ listTasks({ store, scheduler, collection, res, url });
82
+ return;
83
+ }
84
+ if (req.method === 'POST') {
85
+ await createOrUpdateTask({ store, scheduler, req, res });
86
+ return;
87
+ }
88
+ sendJson(res, 405, NOT_ALLOWED);
89
+ }
90
+
91
+ function listTasks({ store, scheduler, collection, res, url }) {
92
+ const status = url.searchParams.get('status') || 'all';
93
+ const query = url.searchParams.get('query') || '';
94
+ const list = store.list({ status, query }).map((t) => ({
95
+ ...t,
96
+ running: scheduler.isRunning(t.id),
97
+ runningSince: scheduler.runningSince(t.id),
98
+ }));
99
+ const stats = store.getAggregatedStats();
100
+ sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
101
+ }
102
+
103
+ async function createOrUpdateTask({ store, scheduler, req, res }) {
104
+ if (rejectCrossOrigin(req, res)) return;
105
+ const { body, error } = await readBody(req, res);
106
+ if (error) return;
107
+ if (!body.title || !body.schedule || !body.prompt) {
108
+ sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
109
+ return;
110
+ }
111
+ const taskType = normalizeTaskType(body.type);
112
+ if (CODE_EXECUTING_TYPES.includes(taskType) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
113
+ sendJson(res, 403, { ok: false, error: `Creating ${taskType} tasks over HTTP requires the x-dsh-cron-confirm: script header` });
114
+ return;
115
+ }
116
+ const typeError = validateTaskType(taskType, body);
117
+ if (typeError) {
118
+ sendJson(res, 400, { ok: false, error: typeError });
119
+ return;
120
+ }
121
+ const parsed = parseScheduleExpression(body.schedule);
122
+ // A client-supplied id must not shadow the collection routes under
123
+ // /dsh-cron/tasks, otherwise that task could never be fetched or deleted
124
+ // again (#42 review finding).
125
+ if (body.id && RESERVED_TASK_IDS.includes(String(body.id))) {
126
+ sendJson(res, 400, { ok: false, error: `Task id "${body.id}" is reserved` });
127
+ return;
128
+ }
129
+ const task = store.set(buildTaskRecord(store, body, taskType, parsed));
130
+ if (task.status === 'active') {
131
+ scheduler.scheduleTask(task);
132
+ } else {
133
+ scheduler.pauseTask(task.id);
134
+ }
135
+ sendJson(res, 200, { ok: true, task });
136
+ }
137
+
138
+ /** Merge a create/update payload onto the stored task, if any. */
139
+ function buildTaskRecord(store, body, taskType, parsed) {
140
+ const current = body.id ? store.get(body.id) : null;
141
+ const resolvedStatus = body.status || (current ? current.status : 'active');
142
+ return {
143
+ id: body.id,
144
+ title: body.title,
145
+ schedule: parsed.cronPattern || body.schedule,
146
+ scheduleText: body.scheduleText || parsed.humanText,
147
+ prompt: body.prompt,
148
+ type: taskType,
149
+ delivery: body.delivery || 'current',
150
+ status: resolvedStatus,
151
+ provider: body.provider || undefined,
152
+ model: body.model || undefined,
153
+ fallbackProvider: body.fallbackProvider || undefined,
154
+ fallbackModel: body.fallbackModel || undefined,
155
+ notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
156
+ onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
157
+ timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
158
+ overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
159
+ kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
160
+ timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
161
+ misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
162
+ maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
163
+ retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
164
+ permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
165
+ ...mergeExecutionFields(current, body, parsed),
166
+ };
167
+ }
168
+
169
+ // --------------------------------------------------------- export / import
170
+
171
+ function handleTaskExport({ store, res }) {
172
+ const doc = buildTaskExport(store.list({ status: 'all' }));
173
+ sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length });
174
+ }
175
+
176
+ async function handleTaskImport({ store, scheduler, req, res }) {
177
+ const { body, error } = await readBody(req, res);
178
+ if (error) return;
179
+ const strategy = IMPORT_STRATEGIES.includes(body && body.strategy) ? body.strategy : 'skip';
180
+ const checked = validateImportDocument(body && body.document);
181
+ if (!checked.ok) {
182
+ sendJson(res, 400, { ok: false, error: checked.error });
183
+ return;
184
+ }
185
+ // The import file is external input: importing code-executing tasks needs
186
+ // the same explicit confirmation that creating one over HTTP needs,
187
+ // otherwise a hand-edited file would bypass that gate (#86).
188
+ if (hasCodeExecutingTask(checked.tasks) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
189
+ sendJson(res, 403, {
190
+ ok: false,
191
+ error: `Importing code-executing tasks over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`,
192
+ });
193
+ return;
194
+ }
195
+ const existingIds = store.list({ status: 'all' }).map((x) => x.id);
196
+ const plan = planImport(checked.tasks, existingIds, strategy);
197
+ const summary = { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length };
198
+ if (body && body.dryRun) {
199
+ sendJson(res, 200, {
200
+ ok: true,
201
+ dryRun: true,
202
+ strategy: plan.strategy,
203
+ summary,
204
+ codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
205
+ });
206
+ return;
207
+ }
208
+ const applied = applyImportPlan({ store, scheduler, plan });
209
+ if (applied.error) {
210
+ sendJson(res, 500, { ok: false, error: applied.error });
211
+ return;
212
+ }
213
+ sendJson(res, 200, { ok: true, imported: applied.imported.length, summary, tasks: applied.imported });
214
+ }
215
+
216
+ /**
217
+ * Write an import plan. Every imported task is paused (the file's status is
218
+ * ignored) and every touched id is disarmed explicitly, because writing
219
+ * "paused" does not stop an already-armed croner job. A failed write rolls the
220
+ * store back so a partial import cannot survive.
221
+ */
222
+ function applyImportPlan({ store, scheduler, plan }) {
223
+ const imported = [];
224
+ const rollback = [];
225
+ const disarm = [];
226
+ try {
227
+ for (const task of plan.add) {
228
+ const before = store.get(task.id);
229
+ const created = store.set({ ...task, id: task.id || randomUUID(), status: 'paused', updatedAt: Date.now() });
230
+ imported.push(created);
231
+ rollback.push({ id: created.id, previous: before || null });
232
+ disarm.push(created.id);
233
+ }
234
+ for (const task of plan.replace) {
235
+ const before = store.get(task.id);
236
+ if (!before) continue;
237
+ const replaced = store.set({ ...task, id: task.id, status: 'paused', updatedAt: Date.now() });
238
+ imported.push(replaced);
239
+ rollback.push({ id: replaced.id, previous: before });
240
+ disarm.push(replaced.id);
241
+ }
242
+ } catch (writeErr) {
243
+ for (const entry of rollback.reverse()) {
244
+ try {
245
+ if (entry.previous) store.set(entry.previous);
246
+ else store.delete(entry.id);
247
+ } catch (_) {}
248
+ }
249
+ return { imported: [], error: `Import failed and was rolled back: ${writeErr.message}` };
250
+ }
251
+ // Disarm outside the write transaction: pauseTask is idempotent and a
252
+ // failure here must not undo a successful import.
253
+ for (const id of disarm) {
254
+ try {
255
+ scheduler.pauseTask(id);
256
+ } catch (err) {
257
+ console.error('[dsh-cron] import: could not pause task', id + ':', err.message);
258
+ }
259
+ }
260
+ return { imported };
261
+ }
262
+
263
+ // ------------------------------------------------------------ single task
264
+
265
+ async function handleTaskItem({ store, scheduler, req, res, url, id, action }) {
266
+ if (req.method === 'GET' && (action === 'history' || !action)) {
267
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
268
+ sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
269
+ return;
270
+ }
271
+ if (req.method === 'POST') {
272
+ const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
273
+ if (handled) return;
274
+ }
275
+ if (req.method === 'PATCH') {
276
+ await patchTask({ store, scheduler, req, res, id });
277
+ return;
278
+ }
279
+ if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
280
+ deleteTask({ store, scheduler, res, id });
281
+ return;
282
+ }
283
+ sendJson(res, 405, NOT_ALLOWED);
284
+ }
285
+
286
+ /** Task actions that are plain POSTs. Returns true when one matched. */
287
+ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
288
+ if (action === 'run') {
289
+ if (!store.get(id)) {
290
+ sendJson(res, 404, NOT_FOUND);
291
+ return true;
292
+ }
293
+ await scheduler.triggerManualRun(id);
294
+ sendJson(res, 200, { ok: true, task: store.get(id) });
295
+ return true;
296
+ }
297
+ if (action === 'duplicate') {
298
+ const source = store.get(id);
299
+ if (!source) {
300
+ sendJson(res, 404, NOT_FOUND);
301
+ return true;
302
+ }
303
+ const copy = buildDuplicateTask(source, { id: randomUUID() });
304
+ store.set(copy);
305
+ // A copy starts paused: it must never fire on its own before the user
306
+ // reviews the schedule (a duplicated one-shot may point at a past time).
307
+ scheduler.pauseTask(copy.id);
308
+ sendJson(res, 200, { ok: true, task: copy });
309
+ return true;
310
+ }
311
+ if (action === 'pause' || action === 'resume') {
312
+ const task = action === 'pause' ? scheduler.pauseTask(id) : scheduler.resumeTask(id);
313
+ if (!task) {
314
+ sendJson(res, 404, NOT_FOUND);
315
+ return true;
316
+ }
317
+ sendJson(res, 200, { ok: true, task });
318
+ return true;
319
+ }
320
+ if (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle')) {
321
+ if (!store.get(id)) {
322
+ sendJson(res, 404, NOT_FOUND);
323
+ return true;
324
+ }
325
+ const task = scheduler.toggleTask(id);
326
+ sendJson(res, 200, { ok: true, task });
327
+ return true;
328
+ }
329
+ return false;
330
+ }
331
+
332
+ async function patchTask({ store, scheduler, req, res, id }) {
333
+ const current = store.get(id);
334
+ if (!current) {
335
+ sendJson(res, 404, NOT_FOUND);
336
+ return;
337
+ }
338
+ const { body, error } = await readBody(req, res);
339
+ if (error) return;
340
+ const result = applyTaskPatch({
341
+ store,
342
+ scheduler,
343
+ id,
344
+ body,
345
+ allowCodeSwitch: req.headers[SCRIPT_CONFIRM_HEADER] === 'script',
346
+ });
347
+ if (!result.ok) {
348
+ const status = result.notFound ? 404 : (result.needsConfirmation ? 403 : 400);
349
+ const error = result.needsConfirmation
350
+ ? `Switching a task to the ${normalizeTaskType(body.type)} type over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`
351
+ : result.error;
352
+ sendJson(res, status, { ok: false, error });
353
+ return;
354
+ }
355
+ sendJson(res, 200, { ok: true, task: result.task });
356
+ }
357
+
358
+ function deleteTask({ store, scheduler, res, id }) {
359
+ const current = store.get(id);
360
+ if (current) {
361
+ scheduler.pauseTask(id);
362
+ store.delete(id);
363
+ }
364
+ sendJson(res, 200, { ok: true });
365
+ }
366
+
367
+ /**
368
+ * Runtime and execution settings of a task, merged onto the stored one. Split
369
+ * out of buildTaskRecord so both stay readable.
370
+ */
371
+ function mergeExecutionFields(current, body, parsed) {
372
+ return {
373
+ env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
374
+ cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
375
+ channels: Array.isArray(body.channels)
376
+ ? body.channels.filter((c) => CHANNEL_IDS.includes(c))
377
+ : (current ? current.channels : undefined),
378
+ template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
379
+ silentRule: body.silentRule !== undefined ? String(body.silentRule) : (current ? current.silentRule : ''),
380
+ inspectOnFailure: body.inspectOnFailure !== undefined ? Boolean(body.inspectOnFailure) : (current ? Boolean(current.inspectOnFailure) : false),
381
+ workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
382
+ worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
383
+ keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
384
+ httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
385
+ httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
386
+ httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
387
+ httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
388
+ sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
389
+ sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
390
+ sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
391
+ sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
392
+ dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
393
+ pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
394
+ nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
395
+ skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
396
+ workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
397
+ oneShot: Boolean((parsed && parsed.isOneShot) || (body && body.oneShot)),
398
+ };
399
+ }