@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.
- package/lib/api-actions.js +1 -317
- package/lib/api-collection.js +1 -250
- package/lib/api-helpers.js +1 -100
- package/lib/api-import-export.js +1 -155
- package/lib/api-item.js +1 -190
- package/lib/api-task-webhook.js +1 -247
- package/lib/api-webhook.js +1 -183
- package/lib/api.js +1 -178
- package/lib/artifacts.js +1 -200
- package/lib/chain-checkpoints.js +1 -223
- package/lib/client-src/00-open.js +62 -0
- package/lib/client-src/10-locales.js +484 -0
- package/lib/client-src/20-styles.js +64 -0
- package/lib/client-src/30-utils.js +248 -0
- package/lib/client-src/40-delivery-settings.js +96 -0
- package/lib/client-src/45-dag-graph-view.js +287 -0
- package/lib/client-src/46-live-logs-modal.js +108 -0
- package/lib/client-src/47-analytics-digest-modal.js +166 -0
- package/lib/client-src/48-timeline-heatmap-view.js +176 -0
- package/lib/client-src/50-cron-screen-state.js +447 -0
- package/lib/client-src/52-cron-screen-actions.js +330 -0
- package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
- package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
- package/lib/client-src/55-cron-screen-view.js +378 -0
- package/lib/client-src/60-modal-task-form-head.js +580 -0
- package/lib/client-src/62-modal-task-form-tail.js +466 -0
- package/lib/client-src/65-modal-dialogs.js +350 -0
- package/lib/client-src/70-settings-card.js +420 -0
- package/lib/client-src/80-sidebar-jobs.js +200 -0
- package/lib/client-src/90-close.js +200 -0
- package/lib/client.js +48 -3
- package/lib/commands.js +200 -0
- package/lib/durable-journal.js +1 -188
- package/lib/external-api.js +1 -74
- package/lib/index.js +17 -0
- package/lib/log-streamer.js +10 -2
- package/lib/metrics.js +1 -125
- package/lib/routes/api-actions.js +317 -0
- package/lib/routes/api-collection.js +250 -0
- package/lib/routes/api-helpers.js +100 -0
- package/lib/routes/api-import-export.js +155 -0
- package/lib/routes/api-item.js +191 -0
- package/lib/routes/api-task-webhook.js +247 -0
- package/lib/routes/api-webhook.js +183 -0
- package/lib/routes/api.js +178 -0
- package/lib/routes/external-api.js +74 -0
- package/lib/routes/index.js +11 -0
- package/lib/routes/metrics.js +125 -0
- package/lib/routes/routes.js +549 -0
- package/lib/routes.js +1 -549
- package/lib/runner-external.js +1 -352
- package/lib/runner-worktree.js +1 -90
- package/lib/runner.js +1 -881
- package/lib/runtimes/index.js +4 -0
- package/lib/runtimes/runner-external.js +352 -0
- package/lib/runtimes/runner-worktree.js +90 -0
- package/lib/runtimes/runner.js +881 -0
- package/lib/runtimes/runtimes.js +198 -0
- package/lib/runtimes.js +1 -198
- package/lib/scheduler/index.js +6 -0
- package/lib/scheduler/scheduler-assists.js +142 -0
- package/lib/scheduler/scheduler-completion.js +417 -0
- package/lib/scheduler/scheduler-execution.js +360 -0
- package/lib/scheduler/scheduler-lifecycle.js +157 -0
- package/lib/scheduler/scheduler-preflight.js +195 -0
- package/lib/scheduler/scheduler.js +751 -0
- package/lib/scheduler-assists.js +1 -142
- package/lib/scheduler-completion.js +1 -417
- package/lib/scheduler-execution.js +1 -360
- package/lib/scheduler-lifecycle.js +1 -157
- package/lib/scheduler-preflight.js +1 -195
- package/lib/scheduler.js +1 -751
- package/lib/store/artifacts.js +200 -0
- package/lib/store/chain-checkpoints.js +223 -0
- package/lib/store/durable-journal.js +188 -0
- package/lib/store/index.js +6 -0
- package/lib/store/store.js +1179 -0
- package/lib/store/task-schema.js +180 -0
- package/lib/store/task-transfer.js +310 -0
- package/lib/store.js +2 -1179
- package/lib/task-schema.js +1 -180
- package/lib/task-transfer.js +1 -310
- package/lib/tui-settings.js +157 -0
- 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
|
+
|