@goodandready/dsh-cron 0.3.24 → 0.3.26

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.
Files changed (84) hide show
  1. package/lib/api-actions.js +1 -317
  2. package/lib/api-collection.js +1 -250
  3. package/lib/api-helpers.js +1 -100
  4. package/lib/api-import-export.js +1 -155
  5. package/lib/api-item.js +1 -190
  6. package/lib/api-task-webhook.js +1 -247
  7. package/lib/api-webhook.js +1 -183
  8. package/lib/api.js +1 -178
  9. package/lib/artifacts.js +1 -200
  10. package/lib/chain-checkpoints.js +1 -223
  11. package/lib/client-src/00-open.js +62 -0
  12. package/lib/client-src/10-locales.js +484 -0
  13. package/lib/client-src/20-styles.js +64 -0
  14. package/lib/client-src/30-utils.js +248 -0
  15. package/lib/client-src/40-delivery-settings.js +96 -0
  16. package/lib/client-src/45-dag-graph-view.js +287 -0
  17. package/lib/client-src/46-live-logs-modal.js +108 -0
  18. package/lib/client-src/47-analytics-digest-modal.js +166 -0
  19. package/lib/client-src/48-timeline-heatmap-view.js +176 -0
  20. package/lib/client-src/50-cron-screen-state.js +447 -0
  21. package/lib/client-src/52-cron-screen-actions.js +330 -0
  22. package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
  23. package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
  24. package/lib/client-src/55-cron-screen-view.js +378 -0
  25. package/lib/client-src/60-modal-task-form-head.js +580 -0
  26. package/lib/client-src/62-modal-task-form-tail.js +466 -0
  27. package/lib/client-src/65-modal-dialogs.js +350 -0
  28. package/lib/client-src/70-settings-card.js +420 -0
  29. package/lib/client-src/80-sidebar-jobs.js +200 -0
  30. package/lib/client-src/90-close.js +200 -0
  31. package/lib/client.js +48 -3
  32. package/lib/commands.js +200 -0
  33. package/lib/durable-journal.js +1 -188
  34. package/lib/external-api.js +1 -74
  35. package/lib/index.js +17 -0
  36. package/lib/log-streamer.js +10 -2
  37. package/lib/metrics.js +1 -125
  38. package/lib/routes/api-actions.js +317 -0
  39. package/lib/routes/api-collection.js +250 -0
  40. package/lib/routes/api-helpers.js +100 -0
  41. package/lib/routes/api-import-export.js +155 -0
  42. package/lib/routes/api-item.js +191 -0
  43. package/lib/routes/api-task-webhook.js +247 -0
  44. package/lib/routes/api-webhook.js +183 -0
  45. package/lib/routes/api.js +178 -0
  46. package/lib/routes/external-api.js +74 -0
  47. package/lib/routes/index.js +11 -0
  48. package/lib/routes/metrics.js +125 -0
  49. package/lib/routes/routes.js +549 -0
  50. package/lib/routes.js +1 -549
  51. package/lib/runner-external.js +1 -352
  52. package/lib/runner-worktree.js +1 -90
  53. package/lib/runner.js +1 -881
  54. package/lib/runtimes/index.js +4 -0
  55. package/lib/runtimes/runner-external.js +352 -0
  56. package/lib/runtimes/runner-worktree.js +90 -0
  57. package/lib/runtimes/runner.js +881 -0
  58. package/lib/runtimes/runtimes.js +198 -0
  59. package/lib/runtimes.js +1 -198
  60. package/lib/scheduler/index.js +6 -0
  61. package/lib/scheduler/scheduler-assists.js +142 -0
  62. package/lib/scheduler/scheduler-completion.js +417 -0
  63. package/lib/scheduler/scheduler-execution.js +360 -0
  64. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  65. package/lib/scheduler/scheduler-preflight.js +195 -0
  66. package/lib/scheduler/scheduler.js +751 -0
  67. package/lib/scheduler-assists.js +1 -142
  68. package/lib/scheduler-completion.js +1 -417
  69. package/lib/scheduler-execution.js +1 -360
  70. package/lib/scheduler-lifecycle.js +1 -157
  71. package/lib/scheduler-preflight.js +1 -195
  72. package/lib/scheduler.js +1 -751
  73. package/lib/store/artifacts.js +200 -0
  74. package/lib/store/chain-checkpoints.js +223 -0
  75. package/lib/store/durable-journal.js +188 -0
  76. package/lib/store/index.js +6 -0
  77. package/lib/store/store.js +1179 -0
  78. package/lib/store/task-schema.js +180 -0
  79. package/lib/store/task-transfer.js +310 -0
  80. package/lib/store.js +2 -1179
  81. package/lib/task-schema.js +1 -180
  82. package/lib/task-transfer.js +1 -310
  83. package/lib/tui-settings.js +157 -0
  84. package/package.json +6 -3
@@ -0,0 +1,250 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { parseScheduleExpression } from '../scheduler.js';
3
+ import { normalizeTaskType, CODE_EXECUTING_TYPES, EXTERNAL_RUNTIMES } from '../runtimes.js';
4
+ import { CHANNEL_IDS, unknownChannelIds } from '../channels.js';
5
+ import { MANAGED_BY_CONFIG, configOwnedMessage } from '../config-jobs.js';
6
+ import { sendJson, rejectCrossOrigin, readBody, SCRIPT_CONFIRM_HEADER, redactTaskSecrets, restoreTaskSecrets } from '../http-utils.js';
7
+ import {
8
+ validateTaskType,
9
+ RESERVED_TASK_IDS,
10
+ isCodeExecutingTask,
11
+ } from '../task-transfer.js';
12
+ import { sanitizeAdaptiveConfig } from '../adaptive.js';
13
+ import { sanitizeValidators } from '../validators.js';
14
+
15
+ export const NOT_FOUND = { ok: false, error: 'Task not found' };
16
+ export const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
17
+
18
+ /**
19
+ * A task declared in the profile config is owned by that file (#50): the next
20
+ * plugin start would revert any change made here, so every mutating route
21
+ * refuses it and says where the task really lives.
22
+ */
23
+ export function refuseConfigOwned(res, id) {
24
+ sendJson(res, 409, { ok: false, error: configOwnedMessage(id) });
25
+ }
26
+
27
+ // ------------------------------------------------------------- collection
28
+
29
+ export async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx }) {
30
+ // The alias family only exists for individual tasks.
31
+ if (scope === 'action' || !collection) {
32
+ sendJson(res, 405, NOT_ALLOWED);
33
+ return;
34
+ }
35
+ if (req.method === 'GET') {
36
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
37
+ listTasks({ store, scheduler, collection, req, res, url });
38
+ return;
39
+ }
40
+ if (req.method === 'POST') {
41
+ await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
42
+ return;
43
+ }
44
+ sendJson(res, 405, NOT_ALLOWED);
45
+ }
46
+
47
+ export function listTasks({ store, scheduler, collection, req, res, url }) {
48
+ const status = url.searchParams.get('status') || 'all';
49
+ const query = url.searchParams.get('query') || '';
50
+ const list = store.list({ status, query }).map((t) => ({
51
+ ...redactTaskSecrets(t),
52
+ running: scheduler.isRunning(t.id),
53
+ runningSince: scheduler.runningSince(t.id),
54
+ }));
55
+ const stats = store.getAggregatedStats();
56
+ const payload = { ok: true, tasks: list, recommendations: collection.recommendations, stats };
57
+
58
+ // Calculate ETag based on payload JSON representation (#134)
59
+ const bodyString = JSON.stringify(payload);
60
+ const etag = 'W/"' + createHash('sha1').update(bodyString).digest('hex') + '"';
61
+
62
+ if (req && req.headers) {
63
+ const ifNoneMatch = req.headers['if-none-match'];
64
+ if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === etag.slice(2))) {
65
+ res.writeHead(304, { 'ETag': etag, 'Cache-Control': 'no-cache' });
66
+ res.end();
67
+ return;
68
+ }
69
+ }
70
+
71
+ if (typeof res.setHeader === 'function') {
72
+ res.setHeader('ETag', etag);
73
+ res.setHeader('Cache-Control', 'no-cache');
74
+ }
75
+ sendJson(res, 200, payload);
76
+ }
77
+
78
+ export async function createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx }) {
79
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
80
+ const { body: rawBody, error } = await readBody(req, res);
81
+ if (error) return;
82
+ const existing = rawBody.id ? store.get(rawBody.id) : null;
83
+ const body = existing ? restoreTaskSecrets(rawBody, existing) : rawBody;
84
+ if (!body.title || !body.schedule || !body.prompt) {
85
+ sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
86
+ return;
87
+ }
88
+ const unknownChannels = unknownChannelIds(body.channels);
89
+ if (unknownChannels.length) {
90
+ sendJson(res, 400, {
91
+ ok: false,
92
+ error: 'Unknown channel ids: ' + unknownChannels.join(', '),
93
+ unknownChannels,
94
+ });
95
+ return;
96
+ }
97
+ const taskType = normalizeTaskType(body.type);
98
+ if ((CODE_EXECUTING_TYPES.includes(taskType) || isCodeExecutingTask(body)) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
99
+ sendJson(res, 403, { ok: false, error: `Creating code-executing tasks over HTTP requires the x-dsh-cron-confirm: script header` });
100
+ return;
101
+ }
102
+ const typeError = validateTaskType(taskType, body);
103
+ if (typeError) {
104
+ sendJson(res, 400, { ok: false, error: typeError });
105
+ return;
106
+ }
107
+ let parsed;
108
+ try {
109
+ parsed = parseScheduleExpression(body.schedule);
110
+ } catch (err) {
111
+ sendJson(res, 400, { ok: false, error: err.message });
112
+ return;
113
+ }
114
+ // A client-supplied id must not shadow the collection routes under
115
+ // /dsh-cron/tasks, otherwise that task could never be fetched or deleted
116
+ // again (#42 review finding).
117
+ if (body.id && RESERVED_TASK_IDS.includes(String(body.id))) {
118
+ sendJson(res, 400, { ok: false, error: `Task id "${body.id}" is reserved` });
119
+ return;
120
+ }
121
+ // Create-or-update with an existing id is an update too, so the config-owned
122
+ // refusal must apply here as well — otherwise the POST route would be a way
123
+ // around the guard on PATCH (#50 review finding).
124
+ if (body.id) {
125
+ const existing = store.get(body.id);
126
+ if (existing && existing.managedBy === MANAGED_BY_CONFIG) {
127
+ refuseConfigOwned(res, body.id);
128
+ return;
129
+ }
130
+ }
131
+ let task;
132
+ try {
133
+ task = store.set(buildTaskRecord(store, body, taskType, parsed));
134
+ } catch (err) {
135
+ sendJson(res, 500, { ok: false, error: 'Failed to persist task: ' + err.message });
136
+ return;
137
+ }
138
+ if (task.status === 'active') {
139
+ scheduler.scheduleTask(task);
140
+ } else {
141
+ scheduler.pauseTask(task.id);
142
+ }
143
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
144
+ }
145
+
146
+ /** Merge a create/update payload onto the stored task, if any. */
147
+ export function buildTaskRecord(store, body, taskType, parsed) {
148
+ const current = body.id ? store.get(body.id) : null;
149
+ const resolvedStatus = body.status || (current ? current.status : 'active');
150
+ return {
151
+ id: body.id,
152
+ title: body.title,
153
+ schedule: parsed.cronPattern || body.schedule,
154
+ scheduleText: body.scheduleText || parsed.humanText,
155
+ prompt: body.prompt,
156
+ type: taskType,
157
+ delivery: body.delivery || 'current',
158
+ status: resolvedStatus,
159
+ provider: body.provider || undefined,
160
+ model: body.model || undefined,
161
+ fallbackProvider: body.fallbackProvider || undefined,
162
+ fallbackModel: body.fallbackModel || undefined,
163
+ notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : undefined),
164
+ onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : undefined),
165
+ timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
166
+ overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
167
+ kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
168
+ timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
169
+ misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
170
+ maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
171
+ retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
172
+ permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'danger-full-access', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current && !EXTERNAL_RUNTIMES.includes(taskType) ? (current.permissionPreset || 'default') : 'default'),
173
+ validators: Array.isArray(body.validators) ? sanitizeValidators(body.validators) : (current ? current.validators : undefined),
174
+ ...mergeExecutionFields(current, body, parsed),
175
+ };
176
+ }
177
+
178
+ // export/import logic moved to ./api-import-export.js
179
+
180
+ /**
181
+ * Runtime and execution settings of a task, merged onto the stored one. Split
182
+ * out of buildTaskRecord so both stay readable.
183
+ */
184
+ export function mergeExecutionFields(current, body, parsed) {
185
+ return {
186
+ env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
187
+ cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
188
+ channels: Array.isArray(body.channels)
189
+ ? body.channels.filter((c) => CHANNEL_IDS.includes(c))
190
+ : (current ? current.channels : undefined),
191
+ template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
192
+ silentRule: body.silentRule !== undefined ? String(body.silentRule) : (current ? current.silentRule : ''),
193
+ inspectOnFailure: body.inspectOnFailure !== undefined ? Boolean(body.inspectOnFailure) : (current ? Boolean(current.inspectOnFailure) : false),
194
+ workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
195
+ worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
196
+ keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
197
+ captureGitDiff: body.captureGitDiff !== undefined ? Boolean(body.captureGitDiff) : (body.captureDiff !== undefined ? Boolean(body.captureDiff) : (current ? current.captureGitDiff : undefined)),
198
+ httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
199
+ httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
200
+ httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
201
+ httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
202
+ sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
203
+ sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
204
+ sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
205
+ sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
206
+ dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
207
+ pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
208
+ nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
209
+ skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
210
+ workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
211
+ oneShot: Boolean((parsed && parsed.isOneShot) || (body && body.oneShot)),
212
+ targetTimestamp: parsed && parsed.isOneShot
213
+ ? (current && current.schedule === (parsed.cronPattern || body.schedule) ? (current.targetTimestamp || parsed.targetTimestamp) : parsed.targetTimestamp)
214
+ : (current ? current.targetTimestamp : undefined),
215
+ costLimitUsd: body.costLimitUsd !== undefined ? (body.costLimitUsd === null ? null : (Number(body.costLimitUsd) > 0 ? Number(body.costLimitUsd) : null)) : (current ? current.costLimitUsd : undefined),
216
+ dailyCostLimitUsd: body.dailyCostLimitUsd !== undefined ? (body.dailyCostLimitUsd === null ? null : (Number(body.dailyCostLimitUsd) > 0 ? Number(body.dailyCostLimitUsd) : null)) : (current ? current.dailyCostLimitUsd : undefined),
217
+ tokenLimit: body.tokenLimit !== undefined ? (body.tokenLimit === null ? null : (Number(body.tokenLimit) > 0 ? Number(body.tokenLimit) : null)) : (current ? current.tokenLimit : undefined),
218
+ pausedReason: body.pausedReason !== undefined ? (body.pausedReason ? String(body.pausedReason) : null) : (current ? current.pausedReason : undefined),
219
+ agentPreset: body.agentPreset !== undefined ? String(body.agentPreset).trim() : (current ? (current.agentPreset || '') : ''),
220
+ targetSessionId: body.targetSessionId !== undefined ? String(body.targetSessionId).trim() : (current ? (current.targetSessionId || '') : ''),
221
+ targetSessionReset: body.targetSessionReset !== undefined ? String(body.targetSessionReset).trim() : (current ? (current.targetSessionReset || 'never') : 'never'),
222
+ sessionTitle: body.sessionTitle !== undefined ? String(body.sessionTitle).trim() : (current ? (current.sessionTitle || '') : ''),
223
+ webhookEnabled: body.webhookEnabled !== undefined
224
+ ? Boolean(body.webhookEnabled)
225
+ : (body.webhookTrigger?.enabled !== undefined ? Boolean(body.webhookTrigger.enabled) : (current ? Boolean(current.webhookEnabled || current.webhookTrigger?.enabled) : false)),
226
+ webhookToken: body.webhookToken !== undefined
227
+ ? String(body.webhookToken).trim()
228
+ : (body.webhookTrigger?.token ? String(body.webhookTrigger.token).trim() : (current ? (current.webhookToken || current.webhookTrigger?.token || '') : '')),
229
+ webhookSecret: body.webhookSecret !== undefined
230
+ ? String(body.webhookSecret).trim()
231
+ : (body.webhookTrigger?.secret ? String(body.webhookTrigger.secret).trim() : (current ? (current.webhookSecret || current.webhookTrigger?.secret || '') : '')),
232
+ webhookDebounceMs: body.webhookDebounceMs !== undefined
233
+ ? (Number(body.webhookDebounceMs) || 0)
234
+ : (body.webhookTrigger?.debounceMs !== undefined ? (Number(body.webhookTrigger.debounceMs) || 0) : (current ? (Number(current.webhookDebounceMs || current.webhookTrigger?.debounceMs) || 0) : 0)),
235
+ onSuccess: body.onSuccess !== undefined ? String(body.onSuccess).trim() : (current ? (current.onSuccess || '') : ''),
236
+ onFailure: body.onFailure !== undefined ? String(body.onFailure).trim() : (current ? (current.onFailure || '') : ''),
237
+ heartbeatIntervalSeconds: body.heartbeatIntervalSeconds !== undefined ? Number(body.heartbeatIntervalSeconds) : (current ? (Number(current.heartbeatIntervalSeconds) || 0) : 0),
238
+ gracePeriodSeconds: body.gracePeriodSeconds !== undefined ? Number(body.gracePeriodSeconds) : (current ? (Number(current.gracePeriodSeconds) || 300) : 300),
239
+ preflightType: body.preflightType !== undefined ? String(body.preflightType).trim().toLowerCase() : (current ? (current.preflightType || 'none') : 'none'),
240
+ preflightTarget: body.preflightTarget !== undefined ? String(body.preflightTarget).trim() : (current ? (current.preflightTarget || '') : ''),
241
+ priority: body.priority !== undefined ? (Number(body.priority) || 5) : (current ? (Number(current.priority) || 5) : 5),
242
+ concurrencyGroup: body.concurrencyGroup !== undefined ? String(body.concurrencyGroup).trim() : (current ? (current.concurrencyGroup || 'default') : 'default'),
243
+ selfHealingCommand: body.selfHealingCommand !== undefined ? String(body.selfHealingCommand).trim() : (current ? (current.selfHealingCommand || '') : ''),
244
+ autoDiagnose: body.autoDiagnose !== undefined ? Boolean(body.autoDiagnose) : (current ? Boolean(current.autoDiagnose) : false),
245
+ adaptive: body.adaptive !== undefined ? sanitizeAdaptiveConfig(body.adaptive) : (current ? current.adaptive : undefined),
246
+ adaptiveState: current ? current.adaptiveState : undefined,
247
+ };
248
+ }
249
+
250
+
@@ -0,0 +1,100 @@
1
+ import { calculate24hTimeline } from '../timeline.js';
2
+ import { previewSchedule } from '../scheduler.js';
3
+ import { sendJson, readBody, rejectCrossOrigin } from '../http-utils.js';
4
+
5
+ const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
6
+
7
+ export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explicitToken, ctx }) {
8
+ if (req.method !== 'POST') {
9
+ sendJson(res, 405, NOT_ALLOWED);
10
+ return;
11
+ }
12
+ const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
13
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
14
+
15
+ const task = store.get(taskId);
16
+ if (!task) {
17
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
18
+ return;
19
+ }
20
+ const result = typeof store.recordHeartbeat === 'function' ? store.recordHeartbeat(taskId) : null;
21
+ if (!result) {
22
+ sendJson(res, 500, { ok: false, error: 'Could not record heartbeat' });
23
+ return;
24
+ }
25
+ sendJson(res, 200, result);
26
+ }
27
+
28
+ export async function handleSchedulePreview({ req, res, url, store, apiToken: explicitToken, ctx }) {
29
+ const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
30
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
31
+
32
+ let schedule = '';
33
+ let timezone = '';
34
+ let count = 5;
35
+
36
+ if (req.method === 'POST') {
37
+ const { body, error } = await readBody(req, res);
38
+ if (error) return;
39
+ schedule = body.schedule || '';
40
+ timezone = body.timezone || '';
41
+ count = body.count || 5;
42
+ } else if (req.method === 'GET') {
43
+ schedule = url.searchParams.get('schedule') || '';
44
+ timezone = url.searchParams.get('timezone') || '';
45
+ count = parseInt(url.searchParams.get('count') || '5', 10);
46
+ } else {
47
+ sendJson(res, 405, NOT_ALLOWED);
48
+ return;
49
+ }
50
+
51
+ if (!schedule) {
52
+ sendJson(res, 400, { ok: false, error: 'Field "schedule" is required' });
53
+ return;
54
+ }
55
+
56
+ try {
57
+ const preview = previewSchedule(schedule, { timezone, count });
58
+ sendJson(res, 200, { ok: true, ...preview });
59
+ } catch (err) {
60
+ sendJson(res, 400, { ok: false, error: err.message });
61
+ }
62
+ }
63
+
64
+ export async function handleDryRunTask({ store, scheduler, req, res, id, apiToken: explicitToken, ctx }) {
65
+ const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
66
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
67
+ const task = store.get(id);
68
+ if (!task) {
69
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
70
+ return;
71
+ }
72
+
73
+ try {
74
+ const outcome = await scheduler.runTask(id, { dryRun: true });
75
+ sendJson(res, 200, outcome || { ok: true, status: 'completed' });
76
+ } catch (err) {
77
+ sendJson(res, 500, { ok: false, error: err.message });
78
+ }
79
+ }
80
+
81
+ export async function handleScheduleTimeline({ req, res, url, store, scheduler, apiToken: explicitToken, ctx }) {
82
+ const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
83
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
84
+
85
+ if (req.method !== 'GET') {
86
+ sendJson(res, 405, NOT_ALLOWED);
87
+ return;
88
+ }
89
+
90
+ const hours = parseInt(url.searchParams.get('hours') || '24', 10);
91
+ const tasks = typeof store?.list === 'function' ? store.list() : [];
92
+ const maxConcurrent = store?.getSettings?.()?.maxConcurrent || 3;
93
+
94
+ try {
95
+ const timeline = calculate24hTimeline({ tasks, maxConcurrent, hoursCount: hours });
96
+ sendJson(res, 200, { ok: true, timeline });
97
+ } catch (err) {
98
+ sendJson(res, 500, { ok: false, error: err.message });
99
+ }
100
+ }
@@ -0,0 +1,155 @@
1
+ import { logger } from '../logger.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import {
4
+ buildTaskExport,
5
+ validateImportDocument,
6
+ planImport,
7
+ hasCodeExecutingTask,
8
+ IMPORT_STRATEGIES,
9
+ } from '../task-transfer.js';
10
+ import { unknownChannelIds } from '../channels.js';
11
+ import {
12
+ sendJson,
13
+ readBody,
14
+ SCRIPT_CONFIRM_HEADER,
15
+ } from '../http-utils.js';
16
+ import { MANAGED_BY_CONFIG } from '../config-jobs.js';
17
+
18
+ export function handleTaskExport({ store, req, res }) {
19
+ let sanitize = false;
20
+ if (req && req.url) {
21
+ try {
22
+ const parsed = new URL(req.url, 'http://127.0.0.1');
23
+ sanitize = parsed.searchParams.get('sanitize') === 'true' || parsed.searchParams.get('redact') === 'true';
24
+ } catch (urlErr) {
25
+ logger.debug('[dsh-cron] export url parse fallback:', urlErr.message);
26
+ }
27
+ }
28
+ const doc = buildTaskExport(store.list({ status: 'all' }), { sanitize });
29
+ sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length, sanitized: sanitize });
30
+ }
31
+
32
+ export async function handleTaskImport({ store, scheduler, req, res }) {
33
+ const { body, error } = await readBody(req, res);
34
+ if (error) return;
35
+ const strategy = IMPORT_STRATEGIES.includes(body && body.strategy) ? body.strategy : 'skip';
36
+ const checked = validateImportDocument(body && body.document);
37
+ if (!checked.ok) {
38
+ sendJson(res, 400, { ok: false, error: checked.error });
39
+ return;
40
+ }
41
+ // The import file is external input: importing code-executing tasks needs
42
+ // the same explicit confirmation that creating one over HTTP needs,
43
+ // otherwise a hand-edited file would bypass that gate (#86).
44
+ if (hasCodeExecutingTask(checked.tasks) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
45
+ sendJson(res, 403, {
46
+ ok: false,
47
+ error: `Importing code-executing tasks over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`,
48
+ });
49
+ return;
50
+ }
51
+ // #121: an imported file may name a channel this build no longer has. The
52
+ // import stays tolerant by design (#42), but the loss is reported instead of
53
+ // being invisible.
54
+ const rawTasks = Array.isArray(body.document && body.document.tasks) ? body.document.tasks : [];
55
+ const droppedChannels = [];
56
+ for (const item of rawTasks) {
57
+ for (const id of unknownChannelIds(item.channels)) {
58
+ if (!droppedChannels.includes(id)) droppedChannels.push(id);
59
+ }
60
+ }
61
+ if (droppedChannels.length) {
62
+ logger.warn('[dsh-cron] import: unknown channel ids were dropped: ' + droppedChannels.join(', '));
63
+ }
64
+ const existingIds = store.list({ status: 'all' }).map((x) => x.id);
65
+ const plan = planImport(checked.tasks, existingIds, strategy);
66
+ // Replacing a task the config owns would be undone at the next start, and
67
+ // the id could then hold two different tasks (#50).
68
+ const configConflicts = plan.replace
69
+ .map((item) => item.id)
70
+ .filter((taskId) => {
71
+ const stored = store.get(taskId);
72
+ return stored && stored.managedBy === MANAGED_BY_CONFIG;
73
+ });
74
+ if (configConflicts.length) {
75
+ sendJson(res, 409, {
76
+ ok: false,
77
+ error: 'These tasks are declared in the profile config and cannot be replaced by an import: ' + configConflicts.join(', '),
78
+ });
79
+ return;
80
+ }
81
+ const summary = { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length };
82
+ if (body && body.dryRun) {
83
+ sendJson(res, 200, {
84
+ ok: true,
85
+ dryRun: true,
86
+ strategy: plan.strategy,
87
+ summary,
88
+ codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
89
+ unknownChannels: droppedChannels,
90
+ });
91
+ return;
92
+ }
93
+ const applied = applyImportPlan({ store, scheduler, plan });
94
+ if (applied.error) {
95
+ sendJson(res, 500, { ok: false, error: applied.error });
96
+ return;
97
+ }
98
+ sendJson(res, 200, {
99
+ ok: true,
100
+ imported: applied.imported.length,
101
+ summary,
102
+ unknownChannels: droppedChannels,
103
+ tasks: applied.imported,
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Write an import plan. Every imported task is paused (the file's status is
109
+ * ignored) and every touched id is disarmed explicitly, because writing
110
+ * "paused" does not stop an already-armed croner job. A failed write rolls the
111
+ * store back so a partial import cannot survive.
112
+ */
113
+ export function applyImportPlan({ store, scheduler, plan }) {
114
+ const imported = [];
115
+ const rollback = [];
116
+ const disarm = [];
117
+ try {
118
+ for (const task of plan.add) {
119
+ const before = store.get(task.id);
120
+ const created = store.set({ ...task, id: task.id || randomUUID(), status: 'paused', updatedAt: Date.now() });
121
+ imported.push(created);
122
+ rollback.push({ id: created.id, previous: before || null });
123
+ disarm.push(created.id);
124
+ }
125
+ for (const task of plan.replace) {
126
+ const before = store.get(task.id);
127
+ if (!before) continue;
128
+ const replaced = store.set({ ...task, id: task.id, status: 'paused', updatedAt: Date.now() });
129
+ imported.push(replaced);
130
+ rollback.push({ id: replaced.id, previous: before });
131
+ disarm.push(replaced.id);
132
+ }
133
+ } catch (writeErr) {
134
+ for (const entry of rollback.reverse()) {
135
+ try {
136
+ if (entry.previous) store.set(entry.previous);
137
+ else store.delete(entry.id);
138
+ } catch (rollbackErr) {
139
+ logger.warn(`[dsh-cron] import rollback failed for task "${entry.id}":`, rollbackErr?.message || rollbackErr);
140
+ }
141
+ }
142
+ return { imported: [], error: `Import failed and was rolled back: ${writeErr.message}` };
143
+ }
144
+ // Disarm outside the write transaction: pauseTask is idempotent and a
145
+ // failure here must not undo a successful import.
146
+ for (const id of disarm) {
147
+ try {
148
+ scheduler.pauseTask(id);
149
+ } catch (err) {
150
+ logger.error('[dsh-cron] import: could not pause task', id + ':', err.message);
151
+ }
152
+ }
153
+ return { imported };
154
+ }
155
+