@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,330 @@
|
|
|
1
|
+
const actionInProgressRef = React.useRef(new Set());
|
|
2
|
+
|
|
3
|
+
const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
|
|
4
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
5
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
6
|
+
if (options && options.signal && controller) {
|
|
7
|
+
if (options.signal.aborted) {
|
|
8
|
+
controller.abort();
|
|
9
|
+
} else {
|
|
10
|
+
options.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(url, {
|
|
15
|
+
...options,
|
|
16
|
+
signal: controller ? controller.signal : (options ? options.signal : undefined),
|
|
17
|
+
});
|
|
18
|
+
let data = {};
|
|
19
|
+
try {
|
|
20
|
+
data = await res.json();
|
|
21
|
+
} catch (jsonErr) {
|
|
22
|
+
// Re-throw if body reading aborted/timed out (#242)
|
|
23
|
+
if (jsonErr && (jsonErr.name === 'AbortError' || controller?.signal.aborted)) {
|
|
24
|
+
throw jsonErr;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { res, data };
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err && (err.name === 'AbortError' || controller?.signal.aborted)) {
|
|
30
|
+
const timeoutErr = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
31
|
+
timeoutErr.isTimeout = true;
|
|
32
|
+
throw timeoutErr;
|
|
33
|
+
}
|
|
34
|
+
throw err;
|
|
35
|
+
} finally {
|
|
36
|
+
if (timer) clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const fetchWithTimeout = fetchJsonWithTimeout;
|
|
40
|
+
|
|
41
|
+
const handleToggleTask = async (id) => {
|
|
42
|
+
if (actionInProgressRef.current.has(id)) return;
|
|
43
|
+
actionInProgressRef.current.add(id);
|
|
44
|
+
try {
|
|
45
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' }, 15000);
|
|
46
|
+
if (!res.ok || (data && data.ok === false)) {
|
|
47
|
+
const msg = (data && data.error) || `Toggle failed (HTTP ${res.status})`;
|
|
48
|
+
alert(msg);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await fetchTasks();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('[dsh-cron] toggle failed', err);
|
|
54
|
+
alert('Toggle request failed: ' + err.message);
|
|
55
|
+
} finally {
|
|
56
|
+
actionInProgressRef.current.delete(id);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleRunNow = async (id) => {
|
|
61
|
+
if (actionInProgressRef.current.has(id)) return;
|
|
62
|
+
actionInProgressRef.current.add(id);
|
|
63
|
+
const prevTask = tasks.find(t => t.id === id);
|
|
64
|
+
const prevStatus = prevTask ? prevTask.lastStatus : null;
|
|
65
|
+
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
|
|
66
|
+
try {
|
|
67
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' }, 15000);
|
|
68
|
+
if (!res.ok || (data && data.ok === false)) {
|
|
69
|
+
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
|
|
70
|
+
const msg = (data && data.error) || `Run failed (HTTP ${res.status})`;
|
|
71
|
+
alert(msg);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
setTimeout(() => fetchTasks(), 1500);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error('[dsh-cron] run now error:', err);
|
|
77
|
+
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
|
|
78
|
+
alert('Run request failed: ' + err.message);
|
|
79
|
+
} finally {
|
|
80
|
+
actionInProgressRef.current.delete(id);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** #41: duplicate a task; the server copies config, resets run state (#41). */
|
|
85
|
+
const handleDryRun = async (taskId) => {
|
|
86
|
+
try {
|
|
87
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/dry-run', { method: 'POST' });
|
|
88
|
+
setDryRunResult(data);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
alert('Dry run error: ' + err.message);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const openArchiveModal = async (task, offset = 0) => {
|
|
95
|
+
setArchiveTask(task);
|
|
96
|
+
setArchiveOffset(offset);
|
|
97
|
+
setArchiveLoading(true);
|
|
98
|
+
setArchiveModalOpen(true);
|
|
99
|
+
try {
|
|
100
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/archive?limit=15&offset=' + offset);
|
|
101
|
+
if (data.ok) {
|
|
102
|
+
setArchiveRuns(prev => offset === 0 ? (data.runs || []) : [...prev, ...(data.runs || [])]);
|
|
103
|
+
setArchiveTotal(data.total || 0);
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error('[dsh-cron] archive load error:', err);
|
|
107
|
+
} finally {
|
|
108
|
+
setArchiveLoading(false);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const handlePreviewSchedule = async (scheduleExpr) => {
|
|
113
|
+
try {
|
|
114
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/schedule/preview', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({ schedule: scheduleExpr, timezone: formTimezone })
|
|
118
|
+
});
|
|
119
|
+
if (data.ok && data.runs) {
|
|
120
|
+
setSchedulePreviewRuns(data.runs);
|
|
121
|
+
} else {
|
|
122
|
+
setSchedulePreviewRuns([]);
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
setSchedulePreviewRuns([]);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const handleDuplicate = async (task) => {
|
|
130
|
+
try {
|
|
131
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/duplicate', { method: 'POST' });
|
|
132
|
+
if (!data.ok) {
|
|
133
|
+
alert(T_KEY(t, 'duplicate.failed', { error: (data && data.error) || '' }));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await fetchTasks();
|
|
137
|
+
// A one-shot copy keeps its original schedule string, so warn instead
|
|
138
|
+
// of letting the user resume a task pointing at a past moment.
|
|
139
|
+
alert(task.oneShot ? T_KEY(t, 'duplicate.oneShotHint') : T_KEY(t, 'duplicate.done'));
|
|
140
|
+
} catch (err) {
|
|
141
|
+
alert(T_KEY(t, 'duplicate.failed', { error: err.message }));
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** A task id must not shadow a collection route (#42 review finding). */
|
|
146
|
+
const RESERVED_TASK_IDS = ['export', 'import'];
|
|
147
|
+
|
|
148
|
+
/** Import needs the script-confirm header when the file has code tasks. */
|
|
149
|
+
const isCodeTaskForm = (x) => {
|
|
150
|
+
if (!x || typeof x !== 'object') return false;
|
|
151
|
+
if (CODE_FORM_TYPES.includes(x.type)) return true;
|
|
152
|
+
if (x.command && String(x.command).trim()) return true;
|
|
153
|
+
if (x.preflightType === 'command' || (x.preflightCommand && String(x.preflightCommand).trim())) return true;
|
|
154
|
+
if (x.selfHealingCommand && String(x.selfHealingCommand).trim()) return true;
|
|
155
|
+
return false;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const importHeaders = (document_) => {
|
|
159
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
160
|
+
const tasks = (document_ && Array.isArray(document_.tasks)) ? document_.tasks : [];
|
|
161
|
+
if (tasks.some(isCodeTaskForm)) headers[SCRIPT_CONFIRM_HEADER] = 'script';
|
|
162
|
+
return headers;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** #42, #240: download the task configuration with honest disclosure and sanitized export choice. */
|
|
166
|
+
const handleExport = async () => {
|
|
167
|
+
setTransferBusy(true);
|
|
168
|
+
try {
|
|
169
|
+
const promptMsg = T_KEY(t, 'transfer.exportPrompt') || 'Choose export mode:\nOK = Sanitized (removes custom env vars & headers)\nCancel = Full raw export';
|
|
170
|
+
const wantSanitized = confirm(promptMsg);
|
|
171
|
+
const exportUrl = '/dsh-cron/tasks/export' + (wantSanitized ? '?sanitize=true' : '');
|
|
172
|
+
const { res, data } = await fetchJsonWithTimeout(exportUrl);
|
|
173
|
+
if (!data.ok) {
|
|
174
|
+
alert(T_KEY(t, 'transfer.exportFailed', { error: (data && data.error) || '' }));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const blob = new Blob([JSON.stringify(data.document, null, 2)], { type: 'application/json' });
|
|
178
|
+
const url = URL.createObjectURL(blob);
|
|
179
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
180
|
+
const anchor = document.createElement('a');
|
|
181
|
+
anchor.href = url;
|
|
182
|
+
anchor.download = `dsh-cron-tasks-${stamp}${data.sanitized ? '-sanitized' : ''}.json`;
|
|
183
|
+
document.body.appendChild(anchor);
|
|
184
|
+
anchor.click();
|
|
185
|
+
document.body.removeChild(anchor);
|
|
186
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
187
|
+
alert(T_KEY(t, 'transfer.exported', { count: data.count }));
|
|
188
|
+
} catch (err) {
|
|
189
|
+
alert(T_KEY(t, 'transfer.exportFailed', { error: err.message }));
|
|
190
|
+
} finally {
|
|
191
|
+
setTransferBusy(false);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** #42: read the picked file, validate it server-side and show the plan. */
|
|
196
|
+
const handleImportFile = async (e) => {
|
|
197
|
+
const file = e.target.files && e.target.files[0];
|
|
198
|
+
e.target.value = '';
|
|
199
|
+
if (!file) return;
|
|
200
|
+
setTransferBusy(true);
|
|
201
|
+
try {
|
|
202
|
+
const text = await file.text();
|
|
203
|
+
let document_;
|
|
204
|
+
try {
|
|
205
|
+
document_ = JSON.parse(text);
|
|
206
|
+
} catch (parseErr) {
|
|
207
|
+
alert(T_KEY(t, 'transfer.badFile', { error: parseErr.message }));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/import', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: importHeaders(document_),
|
|
213
|
+
body: JSON.stringify({ document: document_, dryRun: true, strategy: importStrategy })
|
|
214
|
+
});
|
|
215
|
+
if (!data.ok) {
|
|
216
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
setImportDoc(document_);
|
|
220
|
+
setImportSummary(data.summary);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: err.message }));
|
|
223
|
+
} finally {
|
|
224
|
+
setTransferBusy(false);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const handleImportConfirm = async () => {
|
|
229
|
+
if (!importDoc) return;
|
|
230
|
+
setTransferBusy(true);
|
|
231
|
+
try {
|
|
232
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/import', {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: importHeaders(importDoc),
|
|
235
|
+
body: JSON.stringify({ document: importDoc, strategy: importStrategy })
|
|
236
|
+
});
|
|
237
|
+
if (!data.ok) {
|
|
238
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
setImportDoc(null);
|
|
242
|
+
setImportSummary(null);
|
|
243
|
+
await fetchTasks();
|
|
244
|
+
alert(T_KEY(t, 'transfer.imported', { count: data.imported }));
|
|
245
|
+
} catch (err) {
|
|
246
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: err.message }));
|
|
247
|
+
} finally {
|
|
248
|
+
setTransferBusy(false);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** #49: tune an existing task in a dialogue instead of editing fields. */
|
|
253
|
+
const handleTuneWithDsh = async (task) => {
|
|
254
|
+
const lines = [
|
|
255
|
+
`Tune the existing scheduled task "${task.title}" (id: ${task.id}).`,
|
|
256
|
+
'Its current configuration is:',
|
|
257
|
+
`- type: ${task.type || 'llm'}`,
|
|
258
|
+
`- schedule: ${task.scheduleText || task.schedule} (${task.schedule})`,
|
|
259
|
+
`- status: ${task.status}`,
|
|
260
|
+
task.model ? `- model: ${task.model}${task.fallbackModel ? `, fallback: ${task.fallbackModel}` : ''}` : null,
|
|
261
|
+
Array.isArray(task.channels) && task.channels.length ? `- channels: ${task.channels.join(', ')}` : null,
|
|
262
|
+
`- prompt: ${String(task.prompt || '').slice(0, 400)}`,
|
|
263
|
+
'',
|
|
264
|
+
'Ask what should change, then apply it with the cron tool (action: "update", or action: "get" if you need the full configuration).',
|
|
265
|
+
'If the change makes the task execute code (script/node/python/ssh/docker), confirm it with me explicitly before applying it.',
|
|
266
|
+
].filter(Boolean);
|
|
267
|
+
try {
|
|
268
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/chat/start', {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
headers: { 'Content-Type': 'application/json' },
|
|
271
|
+
body: JSON.stringify({ prompt: lines.join(String.fromCharCode(10)) })
|
|
272
|
+
});
|
|
273
|
+
if (!data.ok || !data.sessionId) {
|
|
274
|
+
alert(T_KEY(t, 'tune.startFailed', { error: (data && data.error) || '' }));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (onClose) onClose();
|
|
278
|
+
else toggle.set(false);
|
|
279
|
+
await openSession(ctx, data.sessionId);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
alert(T_KEY(t, 'tune.startFailed', { error: err.message }));
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const handleApproveProposal = async (taskId, proposalId) => {
|
|
286
|
+
try {
|
|
287
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/approve', {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'Content-Type': 'application/json' },
|
|
290
|
+
body: JSON.stringify({ proposalId, decidedBy: 'web-user' })
|
|
291
|
+
});
|
|
292
|
+
if (data && data.ok) {
|
|
293
|
+
alert(T_KEY(t, 'approval.approved') || 'Approved');
|
|
294
|
+
setTimeout(() => fetchTasks(), 1000);
|
|
295
|
+
} else {
|
|
296
|
+
alert('Approval failed: ' + ((data && data.error) || ''));
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
alert('Approval request failed: ' + err.message);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const handleRejectProposal = async (taskId, proposalId) => {
|
|
304
|
+
try {
|
|
305
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/reject', {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
headers: { 'Content-Type': 'application/json' },
|
|
308
|
+
body: JSON.stringify({ proposalId, decidedBy: 'web-user' })
|
|
309
|
+
});
|
|
310
|
+
if (data && data.ok) {
|
|
311
|
+
alert(T_KEY(t, 'approval.rejected') || 'Rejected');
|
|
312
|
+
setTimeout(() => fetchTasks(), 1000);
|
|
313
|
+
} else {
|
|
314
|
+
alert('Rejection failed: ' + ((data && data.error) || ''));
|
|
315
|
+
}
|
|
316
|
+
} catch (err) {
|
|
317
|
+
alert('Rejection request failed: ' + err.message);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const handleDelete = async (id) => {
|
|
322
|
+
if (!confirm(T_KEY(t, 'confirm.delete'))) return;
|
|
323
|
+
try {
|
|
324
|
+
await fetchWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
325
|
+
fetchTasks();
|
|
326
|
+
} catch (err) {
|
|
327
|
+
console.debug('[dsh-cron] delete failed', err);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|