@goodandready/dsh-cron 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -6
- package/docs/README.ru.md +147 -5
- package/docs/README.zh.md +144 -6
- package/lib/api.js +533 -0
- package/lib/channels.js +6 -2
- package/lib/client.js +185 -12
- package/lib/config-jobs.js +206 -0
- package/lib/external-api.js +74 -0
- package/lib/failure-inspector.js +107 -0
- package/lib/http-utils.js +24 -0
- package/lib/index.js +176 -388
- package/lib/llm-ask.js +141 -0
- package/lib/metrics.js +97 -0
- package/lib/recipes.js +238 -0
- package/lib/runner.js +210 -171
- package/lib/scheduler.js +373 -192
- package/lib/silent-rule.js +100 -0
- package/lib/store.js +14 -3
- package/lib/task-patch.js +98 -0
- package/lib/task-transfer.js +4 -0
- package/lib/templates.js +4 -0
- package/package.json +3 -2
- package/docs/design/DESIGN.md +0 -81
- package/docs/plans/0.2.5-ui-block.md +0 -50
package/lib/index.js
CHANGED
|
@@ -52,6 +52,20 @@ export {
|
|
|
52
52
|
IMPORT_STRATEGIES,
|
|
53
53
|
};
|
|
54
54
|
import { deliverRun, CHANNEL_IDS, resolveDeliveryTimeoutMs, MIN_DELIVERY_TIMEOUT_MS } from './channels.js';
|
|
55
|
+
import { sendJson, rejectCrossOrigin, readBody } from './http-utils.js';
|
|
56
|
+
import { recipeRecommendations, recipesByCategory } from './recipes.js';
|
|
57
|
+
import { applyTaskPatch, describeTaskPatch } from './task-patch.js';
|
|
58
|
+
import { makeAsk } from './silent-rule.js';
|
|
59
|
+
|
|
60
|
+
// The REST surface lives in lib/api.js. It is IMPORTED (the plugin body calls
|
|
61
|
+
// it) and re-exported for tests and tools that import it from the entry point —
|
|
62
|
+
// a bare re-export creates no local binding and the plugin then fails to load
|
|
63
|
+
// with "createCronApiHandler is not defined".
|
|
64
|
+
import { createCronApiHandler } from './api.js';
|
|
65
|
+
import { createMetricsHandler } from './metrics.js';
|
|
66
|
+
import { applyConfigSync, isConfigOwned, configOwnedMessage } from './config-jobs.js';
|
|
67
|
+
import { createExternalApiHandler, EXTERNAL_API_PREFIX } from './external-api.js';
|
|
68
|
+
export { createCronApiHandler };
|
|
55
69
|
|
|
56
70
|
export const name = '@goodandready/dsh-cron';
|
|
57
71
|
export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
|
|
@@ -70,6 +84,8 @@ export const Config = z.object({
|
|
|
70
84
|
// --- Delivery channels (#20-#23, #26, #28, #47) ---
|
|
71
85
|
template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
|
|
72
86
|
channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
|
|
87
|
+
silentRuleModel: z.string().default('').description('Model used to evaluate a task silent rule (empty = the task model)'),
|
|
88
|
+
inspectorModel: z.string().default('').description('Model used to diagnose failed runs (empty = the task model)'),
|
|
73
89
|
deliveryTimeoutMs: z.number().min(MIN_DELIVERY_TIMEOUT_MS).default(15000).description('Per-channel delivery timeout in ms (minimum 1000); a slower endpoint is recorded as a failure and does not block other channels or the next tick'),
|
|
74
90
|
discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
|
|
75
91
|
slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
|
|
@@ -84,35 +100,22 @@ export const Config = z.object({
|
|
|
84
100
|
giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
|
|
85
101
|
giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
|
|
86
102
|
giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
|
|
103
|
+
// --- Declarative jobs (#50, ADR-0001) ---
|
|
104
|
+
// The array is intentionally permissive: each entry is validated on its own
|
|
105
|
+
// with a precise message (see lib/config-jobs.js), so one broken entry cannot
|
|
106
|
+
// reject the whole profile config. Declared jobs are owned by the config:
|
|
107
|
+
// they are created or updated at startup, and removed when they disappear
|
|
108
|
+
// from the file. Tasks created in the UI, over the API or by an agent tool
|
|
109
|
+
// are never touched.
|
|
110
|
+
jobs: z.array(z.any()).default([]).description('Static jobs owned by the config; each entry needs id, title, schedule and (for agent types) prompt'),
|
|
111
|
+
// --- External REST API (#54, ADR-0001) ---
|
|
112
|
+
apiToken: z.string().role('secret').default('').description('Bearer token for the external /dsh-cron/api/* surface (empty = the surface answers 503)'),
|
|
87
113
|
});
|
|
88
114
|
|
|
89
115
|
|
|
90
|
-
function sendJson(res, statusCode, data) {
|
|
91
|
-
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
92
|
-
res.end(JSON.stringify(data));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function rejectCrossOrigin(req, res) {
|
|
96
|
-
if (isCrossOrigin(req)) {
|
|
97
|
-
sendJson(res, 403, { ok: false, error: 'Cross-origin request rejected' });
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function readBody(req, res) {
|
|
104
|
-
try {
|
|
105
|
-
return { body: await parseJsonBody(req) };
|
|
106
|
-
} catch (err) {
|
|
107
|
-
sendJson(res, err.statusCode || 400, { ok: false, error: err.message });
|
|
108
|
-
return { error: true };
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Settings keys synchronized between the store and the settings service. */
|
|
113
116
|
export const SETTINGS_SYNC_KEYS = [
|
|
114
117
|
'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
|
|
115
|
-
'template', 'channelTemplates', 'deliveryTimeoutMs',
|
|
118
|
+
'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
|
|
116
119
|
'discordWebhookUrl', 'slackWebhookUrl',
|
|
117
120
|
'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
|
|
118
121
|
'barkServerUrl', 'barkKey',
|
|
@@ -120,6 +123,7 @@ export const SETTINGS_SYNC_KEYS = [
|
|
|
120
123
|
'pushplusUrl',
|
|
121
124
|
'ttsBaseUrl',
|
|
122
125
|
'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
|
|
126
|
+
'apiToken',
|
|
123
127
|
];
|
|
124
128
|
|
|
125
129
|
/**
|
|
@@ -181,341 +185,6 @@ export function sanitizeSettingsPayload(body) {
|
|
|
181
185
|
return payload;
|
|
182
186
|
}
|
|
183
187
|
|
|
184
|
-
/**
|
|
185
|
-
* Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
|
|
186
|
-
* route families — the #66 alias is kept alive by the same code path instead
|
|
187
|
-
* of a duplicated handler.
|
|
188
|
-
*
|
|
189
|
-
* IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
|
|
190
|
-
* with one, the core degrades matching to exact-path equality and every
|
|
191
|
-
* deeper path unrouted (#96). Exported for unit tests.
|
|
192
|
-
*/
|
|
193
|
-
export function createCronApiHandler(store, scheduler, collection) {
|
|
194
|
-
return async function handleCronApi(req, res) {
|
|
195
|
-
try {
|
|
196
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
197
|
-
const parts = url.pathname.split('/').filter(Boolean);
|
|
198
|
-
const scope = parts[1]; // 'tasks' | 'action'
|
|
199
|
-
const id = parts[2];
|
|
200
|
-
const action = parts[3];
|
|
201
|
-
|
|
202
|
-
// Collection level: /dsh-cron/tasks (list + create)
|
|
203
|
-
if (!id) {
|
|
204
|
-
if (scope === 'action' || !collection) {
|
|
205
|
-
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
if (req.method === 'GET') {
|
|
209
|
-
const status = url.searchParams.get('status') || 'all';
|
|
210
|
-
const query = url.searchParams.get('query') || '';
|
|
211
|
-
const list = store.list({ status, query }).map((t) => ({
|
|
212
|
-
...t,
|
|
213
|
-
running: scheduler.isRunning(t.id),
|
|
214
|
-
runningSince: scheduler.runningSince(t.id),
|
|
215
|
-
}));
|
|
216
|
-
const stats = store.getAggregatedStats();
|
|
217
|
-
sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
if (req.method === 'POST') {
|
|
221
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
222
|
-
const { body, error } = await readBody(req, res);
|
|
223
|
-
if (error) return;
|
|
224
|
-
if (!body.title || !body.schedule || !body.prompt) {
|
|
225
|
-
sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
const taskType = normalizeTaskType(body.type);
|
|
229
|
-
if (CODE_EXECUTING_TYPES.includes(taskType) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
230
|
-
sendJson(res, 403, { ok: false, error: `Creating ${taskType} tasks over HTTP requires the x-dsh-cron-confirm: script header` });
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
const typeError = validateTaskType(taskType, body);
|
|
234
|
-
if (typeError) {
|
|
235
|
-
sendJson(res, 400, { ok: false, error: typeError });
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
const parsed = parseScheduleExpression(body.schedule);
|
|
239
|
-
// A client-supplied id must not shadow the collection routes under
|
|
240
|
-
// /dsh-cron/tasks, otherwise that task could never be fetched or
|
|
241
|
-
// deleted again (#42 review finding).
|
|
242
|
-
if (body.id && RESERVED_TASK_IDS.includes(String(body.id))) {
|
|
243
|
-
sendJson(res, 400, { ok: false, error: `Task id "${body.id}" is reserved` });
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
const current = body.id ? store.get(body.id) : null;
|
|
247
|
-
const resolvedStatus = body.status || (current ? current.status : 'active');
|
|
248
|
-
|
|
249
|
-
const task = store.set({
|
|
250
|
-
id: body.id,
|
|
251
|
-
title: body.title,
|
|
252
|
-
schedule: parsed.cronPattern || body.schedule,
|
|
253
|
-
scheduleText: body.scheduleText || parsed.humanText,
|
|
254
|
-
prompt: body.prompt,
|
|
255
|
-
type: taskType,
|
|
256
|
-
delivery: body.delivery || 'current',
|
|
257
|
-
status: resolvedStatus,
|
|
258
|
-
provider: body.provider || undefined,
|
|
259
|
-
model: body.model || undefined,
|
|
260
|
-
notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
|
|
261
|
-
onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
|
|
262
|
-
timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
|
|
263
|
-
overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
|
|
264
|
-
kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
|
|
265
|
-
timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
|
|
266
|
-
misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
|
|
267
|
-
maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
|
|
268
|
-
retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
|
|
269
|
-
permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
|
|
270
|
-
env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
|
|
271
|
-
cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
|
|
272
|
-
channels: Array.isArray(body.channels)
|
|
273
|
-
? body.channels.filter((c) => CHANNEL_IDS.includes(c))
|
|
274
|
-
: (current ? current.channels : undefined),
|
|
275
|
-
template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
|
|
276
|
-
workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
|
|
277
|
-
worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
|
|
278
|
-
keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
|
|
279
|
-
httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
|
|
280
|
-
httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
|
|
281
|
-
httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
|
|
282
|
-
httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
|
|
283
|
-
sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
|
|
284
|
-
sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
|
|
285
|
-
sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
|
|
286
|
-
sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
|
|
287
|
-
dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
|
|
288
|
-
pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
|
|
289
|
-
nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
|
|
290
|
-
skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
|
|
291
|
-
workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
|
|
292
|
-
oneShot: Boolean(parsed.isOneShot || body.oneShot),
|
|
293
|
-
});
|
|
294
|
-
if (task.status === 'active') {
|
|
295
|
-
scheduler.scheduleTask(task);
|
|
296
|
-
} else {
|
|
297
|
-
scheduler.pauseTask(task.id);
|
|
298
|
-
}
|
|
299
|
-
sendJson(res, 200, { ok: true, task });
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Item level: /dsh-cron/{tasks|action}/:id[/:action]
|
|
307
|
-
if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
|
|
308
|
-
|
|
309
|
-
// Export/import are collection operations that live under /tasks/:id/…,
|
|
310
|
-
// so they are matched before the generic id handling below.
|
|
311
|
-
if (id === 'export' && req.method === 'GET') {
|
|
312
|
-
const doc = buildTaskExport(store.list({ status: 'all' }));
|
|
313
|
-
sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length });
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (id === 'import' && req.method === 'POST') {
|
|
318
|
-
const { body, error } = await readBody(req, res);
|
|
319
|
-
if (error) return;
|
|
320
|
-
const strategy = IMPORT_STRATEGIES.includes(body && body.strategy) ? body.strategy : 'skip';
|
|
321
|
-
const checked = validateImportDocument(body && body.document);
|
|
322
|
-
if (!checked.ok) {
|
|
323
|
-
sendJson(res, 400, { ok: false, error: checked.error });
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
// The import file is external input: importing code-executing tasks
|
|
327
|
-
// needs the same explicit confirmation that creating one over HTTP
|
|
328
|
-
// needs, otherwise a hand-edited file would bypass that gate (#86).
|
|
329
|
-
if (hasCodeExecutingTask(checked.tasks) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
330
|
-
sendJson(res, 403, {
|
|
331
|
-
ok: false,
|
|
332
|
-
error: `Importing code-executing tasks over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`,
|
|
333
|
-
});
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
const existingIds = store.list({ status: 'all' }).map((x) => x.id);
|
|
337
|
-
const plan = planImport(checked.tasks, existingIds, strategy);
|
|
338
|
-
if (body && body.dryRun) {
|
|
339
|
-
sendJson(res, 200, {
|
|
340
|
-
ok: true,
|
|
341
|
-
dryRun: true,
|
|
342
|
-
strategy: plan.strategy,
|
|
343
|
-
summary: { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length },
|
|
344
|
-
codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
|
|
345
|
-
});
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
// Every imported task is paused (the file's status is ignored) and every
|
|
349
|
-
// id that replaced an existing task is disarmed: writing "paused" to the
|
|
350
|
-
// store does not stop an already-armed croner job, so the scheduler must
|
|
351
|
-
// be told explicitly. The writes are rolled back if one of them throws,
|
|
352
|
-
// so a failed import cannot leave the store half-updated.
|
|
353
|
-
const imported = [];
|
|
354
|
-
const rollback = [];
|
|
355
|
-
const disarm = [];
|
|
356
|
-
try {
|
|
357
|
-
for (const task of plan.add) {
|
|
358
|
-
const before = store.get(task.id);
|
|
359
|
-
const created = store.set({ ...task, id: task.id || randomUUID(), status: 'paused', updatedAt: Date.now() });
|
|
360
|
-
imported.push(created);
|
|
361
|
-
rollback.push({ id: created.id, previous: before || null });
|
|
362
|
-
disarm.push(created.id);
|
|
363
|
-
}
|
|
364
|
-
for (const task of plan.replace) {
|
|
365
|
-
const before = store.get(task.id);
|
|
366
|
-
if (!before) continue;
|
|
367
|
-
const replaced = store.set({ ...task, id: task.id, status: 'paused', updatedAt: Date.now() });
|
|
368
|
-
imported.push(replaced);
|
|
369
|
-
rollback.push({ id: replaced.id, previous: before });
|
|
370
|
-
disarm.push(replaced.id);
|
|
371
|
-
}
|
|
372
|
-
} catch (writeErr) {
|
|
373
|
-
for (const entry of rollback.reverse()) {
|
|
374
|
-
try {
|
|
375
|
-
if (entry.previous) store.set(entry.previous);
|
|
376
|
-
else store.delete(entry.id);
|
|
377
|
-
} catch (_) {}
|
|
378
|
-
}
|
|
379
|
-
sendJson(res, 500, { ok: false, error: `Import failed and was rolled back: ${writeErr.message}` });
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
// Disarm outside the write transaction: pauseTask is idempotent and a
|
|
383
|
-
// failure here must not undo a successful import.
|
|
384
|
-
for (const id of disarm) {
|
|
385
|
-
try { scheduler.pauseTask(id); } catch (err) { console.error('[dsh-cron] import: could not pause task', id + ':', err.message); }
|
|
386
|
-
}
|
|
387
|
-
sendJson(res, 200, {
|
|
388
|
-
ok: true,
|
|
389
|
-
imported: imported.length,
|
|
390
|
-
summary: { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length },
|
|
391
|
-
tasks: imported,
|
|
392
|
-
});
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
if (req.method === 'GET' && (action === 'history' || !action)) {
|
|
397
|
-
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
398
|
-
sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (req.method === 'POST' && action === 'run') {
|
|
403
|
-
if (!store.get(id)) {
|
|
404
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
await scheduler.triggerManualRun(id);
|
|
408
|
-
sendJson(res, 200, { ok: true, task: store.get(id) });
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (req.method === 'POST' && action === 'duplicate') {
|
|
413
|
-
const source = store.get(id);
|
|
414
|
-
if (!source) {
|
|
415
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
const copy = buildDuplicateTask(source, { id: randomUUID() });
|
|
419
|
-
store.set(copy);
|
|
420
|
-
// A copy starts paused: it must never fire on its own before the user
|
|
421
|
-
// reviews the schedule (a duplicated one-shot may point at a past time).
|
|
422
|
-
scheduler.pauseTask(copy.id);
|
|
423
|
-
sendJson(res, 200, { ok: true, task: copy });
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
if (req.method === 'POST' && action === 'pause') {
|
|
428
|
-
const task = scheduler.pauseTask(id);
|
|
429
|
-
if (!task) {
|
|
430
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
sendJson(res, 200, { ok: true, task });
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (req.method === 'POST' && action === 'resume') {
|
|
438
|
-
const task = scheduler.resumeTask(id);
|
|
439
|
-
if (!task) {
|
|
440
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
sendJson(res, 200, { ok: true, task });
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (req.method === 'POST' && (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle'))) {
|
|
448
|
-
const current = store.get(id);
|
|
449
|
-
if (!current) {
|
|
450
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
const task = scheduler.toggleTask(id);
|
|
454
|
-
sendJson(res, 200, { ok: true, task });
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
if (req.method === 'PATCH') {
|
|
459
|
-
const current = store.get(id);
|
|
460
|
-
if (!current) {
|
|
461
|
-
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
const { body, error } = await readBody(req, res);
|
|
465
|
-
if (error) return;
|
|
466
|
-
// Whitelist patchable fields so stats/timestamps stay server-owned (#90)
|
|
467
|
-
const patchData = pickPatchableFields(body, PATCHABLE_TASK_FIELDS);
|
|
468
|
-
if (patchData.type !== undefined) {
|
|
469
|
-
patchData.type = normalizeTaskType(patchData.type);
|
|
470
|
-
const switchingToCode = CODE_EXECUTING_TYPES.includes(patchData.type)
|
|
471
|
-
&& !CODE_EXECUTING_TYPES.includes(current.type);
|
|
472
|
-
if (switchingToCode && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
473
|
-
sendJson(res, 403, { ok: false, error: `Switching a task to the ${patchData.type} type over HTTP requires the x-dsh-cron-confirm: script header` });
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
const typeError = validateTaskType(patchData.type, { ...current, ...patchData });
|
|
477
|
-
if (typeError) {
|
|
478
|
-
sendJson(res, 400, { ok: false, error: typeError });
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
if (patchData.schedule) {
|
|
483
|
-
const parsed = parseScheduleExpression(patchData.schedule);
|
|
484
|
-
patchData.schedule = parsed.cronPattern || patchData.schedule;
|
|
485
|
-
patchData.scheduleText = body.scheduleText || parsed.humanText;
|
|
486
|
-
patchData.oneShot = Boolean(parsed.isOneShot || body.oneShot);
|
|
487
|
-
}
|
|
488
|
-
const task = store.set({ ...current, ...patchData, id });
|
|
489
|
-
if (task.status === 'active') {
|
|
490
|
-
scheduler.scheduleTask(task);
|
|
491
|
-
} else {
|
|
492
|
-
scheduler.pauseTask(id);
|
|
493
|
-
}
|
|
494
|
-
sendJson(res, 200, { ok: true, task });
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
|
|
499
|
-
const current = store.get(id);
|
|
500
|
-
if (current) {
|
|
501
|
-
scheduler.pauseTask(id);
|
|
502
|
-
store.delete(id);
|
|
503
|
-
}
|
|
504
|
-
sendJson(res, 200, { ok: true });
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
509
|
-
} catch (err) {
|
|
510
|
-
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Single implementation behind the cron_create_task / cron_schedule_task
|
|
517
|
-
* alias pair (#70, #92). Exported for unit tests.
|
|
518
|
-
*/
|
|
519
188
|
export function executeCreateTask(store, scheduler, args) {
|
|
520
189
|
const taskType = normalizeTaskType(args.type);
|
|
521
190
|
const typeError = validateTaskType(taskType, args);
|
|
@@ -578,6 +247,10 @@ export const createTaskParameters = {
|
|
|
578
247
|
type: { type: 'string', enum: TASK_TYPES, description: 'Task type: llm | script | node | python | http | ssh | docker | skill | workflow' },
|
|
579
248
|
delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
|
|
580
249
|
provider: { type: 'string', description: 'Model provider (optional)' },
|
|
250
|
+
inspectOnFailure: { type: 'boolean', description: 'Diagnose failed runs with a model and store the diagnosis with the run (agent tasks)' },
|
|
251
|
+
silentRule: { type: 'string', description: 'Plain-language condition for staying silent on a successful run (script/node/python/http tasks)' },
|
|
252
|
+
fallbackModel: { type: 'string', description: 'Model tried once more when the primary model fails (llm/skill/workflow tasks)' },
|
|
253
|
+
fallbackProvider: { type: 'string', description: 'Provider for the fallback model (defaults to the task provider)' },
|
|
581
254
|
model: { type: 'string', description: 'Model (optional)' },
|
|
582
255
|
timezone: { type: 'string', description: 'IANA time zone for the schedule, e.g. "Europe/Berlin" (optional, default = server local)' },
|
|
583
256
|
notifyTelegram: { type: 'boolean', description: 'Send the run report to Telegram' },
|
|
@@ -631,6 +304,7 @@ export function apply(ctx, config) {
|
|
|
631
304
|
maxConcurrent: config && config.maxConcurrent,
|
|
632
305
|
defaultTimezone: config && config.defaultTimezone,
|
|
633
306
|
resolveSecrets: (settings) => resolveTelegramSecrets(ctx, settings),
|
|
307
|
+
askModel: makeAsk(ctx),
|
|
634
308
|
deliver: (task, runInfo, settings) => deliverRun({
|
|
635
309
|
task,
|
|
636
310
|
runInfo,
|
|
@@ -667,6 +341,18 @@ export function apply(ctx, config) {
|
|
|
667
341
|
}
|
|
668
342
|
});
|
|
669
343
|
|
|
344
|
+
// #50: jobs declared in the profile config belong to the config. The sync
|
|
345
|
+
// runs before the scheduler starts and also when the section is gone: a
|
|
346
|
+
// config that dropped its jobs must still retire the tasks it used to own.
|
|
347
|
+
// A broken entry is reported and skipped, never fatal.
|
|
348
|
+
try {
|
|
349
|
+
const configJobs = Array.isArray(config && config.jobs) ? config.jobs : [];
|
|
350
|
+
const summary = applyConfigSync({ store, scheduler, entries: configJobs });
|
|
351
|
+
console.log('[dsh-cron] config jobs synced: ' + JSON.stringify(summary));
|
|
352
|
+
} catch (err) {
|
|
353
|
+
console.error('[dsh-cron] config job sync failed:', err.message);
|
|
354
|
+
}
|
|
355
|
+
|
|
670
356
|
scheduler.start();
|
|
671
357
|
|
|
672
358
|
// Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
|
|
@@ -688,35 +374,8 @@ export function apply(ctx, config) {
|
|
|
688
374
|
};
|
|
689
375
|
}, 'dsh-cron: lifecycle');
|
|
690
376
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
id: 'rec_daily_digest',
|
|
694
|
-
title: 'Daily digest',
|
|
695
|
-
schedule: '0 8 * * 1-5',
|
|
696
|
-
scheduleText: 'Weekdays at 08:00',
|
|
697
|
-
prompt: 'Prepare a brief morning digest: review the list of active tasks, open Gitea tickets and outline the key priorities for today.',
|
|
698
|
-
description: 'Start every weekday with a summary of calendar, unread mail and priorities',
|
|
699
|
-
icon: 'bell'
|
|
700
|
-
},
|
|
701
|
-
{
|
|
702
|
-
id: 'rec_weekly_review',
|
|
703
|
-
title: 'Weekly review',
|
|
704
|
-
schedule: '0 16 * * 5',
|
|
705
|
-
scheduleText: 'Fridays at 16:00',
|
|
706
|
-
prompt: 'Prepare a weekly report on the work done: the list of closed tasks, created PRs and the current status of ongoing projects.',
|
|
707
|
-
description: 'Every Friday create a short report on the completed work',
|
|
708
|
-
icon: 'clipboard'
|
|
709
|
-
},
|
|
710
|
-
{
|
|
711
|
-
id: 'rec_monitor_status',
|
|
712
|
-
title: 'Follow-up monitor',
|
|
713
|
-
schedule: '0 9 * * 1-5',
|
|
714
|
-
scheduleText: 'Weekdays at 09:00',
|
|
715
|
-
prompt: 'Review recent project activity, build statuses and flag everything that requires developer attention.',
|
|
716
|
-
description: 'Review recent project activity and flag anything that needs your attention',
|
|
717
|
-
icon: 'activity'
|
|
718
|
-
}
|
|
719
|
-
];
|
|
377
|
+
// #48: the recipe hub is the single source of the panel's suggestions.
|
|
378
|
+
const recommendations = recipeRecommendations();
|
|
720
379
|
|
|
721
380
|
// 1. REST API — collection (list/create) + item actions in one prefix
|
|
722
381
|
// handler per route family; registered WITHOUT the trailing slash (#96)
|
|
@@ -733,6 +392,27 @@ export function apply(ctx, config) {
|
|
|
733
392
|
handler: createCronApiHandler(store, scheduler, null)
|
|
734
393
|
}), 'dsh-cron: /action/:id/:action');
|
|
735
394
|
|
|
395
|
+
// GET /dsh-cron/metrics (#53) — Prometheus text exposition
|
|
396
|
+
ctx.effect(() => ctx.webServer.register({
|
|
397
|
+
kind: 'exact',
|
|
398
|
+
path: '/dsh-cron/metrics',
|
|
399
|
+
handler: createMetricsHandler({ store, scheduler })
|
|
400
|
+
}), 'dsh-cron: /metrics');
|
|
401
|
+
|
|
402
|
+
// External REST API under /dsh-cron/api/* (#54, ADR-0001): the only surface
|
|
403
|
+
// guarded by a bearer token, so CI and host automation can drive the
|
|
404
|
+
// scheduler without a browser session. The token is read per request, so a
|
|
405
|
+
// settings change applies without a restart.
|
|
406
|
+
ctx.effect(() => ctx.webServer.register({
|
|
407
|
+
kind: 'prefix',
|
|
408
|
+
path: EXTERNAL_API_PREFIX,
|
|
409
|
+
handler: createExternalApiHandler({
|
|
410
|
+
store,
|
|
411
|
+
scheduler,
|
|
412
|
+
getToken: () => store.getSettings().apiToken || ''
|
|
413
|
+
})
|
|
414
|
+
}), 'dsh-cron: /api[...]');
|
|
415
|
+
|
|
736
416
|
// GET /dsh-cron/models
|
|
737
417
|
ctx.effect(() => ctx.webServer.register({
|
|
738
418
|
kind: 'exact',
|
|
@@ -783,6 +463,25 @@ export function apply(ctx, config) {
|
|
|
783
463
|
}
|
|
784
464
|
}), 'dsh-cron: /settings');
|
|
785
465
|
|
|
466
|
+
// GET /dsh-cron/recipes (#48): the built-in recipe catalog.
|
|
467
|
+
ctx.effect(() => ctx.webServer.register({
|
|
468
|
+
kind: 'exact',
|
|
469
|
+
path: '/dsh-cron/recipes',
|
|
470
|
+
handler: async (req, res) => {
|
|
471
|
+
try {
|
|
472
|
+
if (req.method !== 'GET') {
|
|
473
|
+
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const categories = recipesByCategory();
|
|
477
|
+
const recipes = categories.reduce((acc, category) => acc.concat(category.recipes), []);
|
|
478
|
+
sendJson(res, 200, { ok: true, categories, recipes });
|
|
479
|
+
} catch (err) {
|
|
480
|
+
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}), 'dsh-cron: /recipes');
|
|
484
|
+
|
|
786
485
|
// GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
|
|
787
486
|
ctx.effect(() => ctx.webServer.register({
|
|
788
487
|
kind: 'exact',
|
|
@@ -943,6 +642,10 @@ export function apply(ctx, config) {
|
|
|
943
642
|
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
944
643
|
},
|
|
945
644
|
execute: async (args) => {
|
|
645
|
+
// A config-owned task cannot be paused from here either: the next start
|
|
646
|
+
// would resume it, and the agent would have reported a change that never
|
|
647
|
+
// happened (#50 review finding).
|
|
648
|
+
if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
|
|
946
649
|
const task = scheduler.pauseTask(args.id);
|
|
947
650
|
if (!task) return { success: false, message: 'Task not found' };
|
|
948
651
|
return { success: true, message: `Task "${task.title}" paused` };
|
|
@@ -967,6 +670,7 @@ export function apply(ctx, config) {
|
|
|
967
670
|
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
968
671
|
},
|
|
969
672
|
execute: async (args) => {
|
|
673
|
+
if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
|
|
970
674
|
const task = scheduler.resumeTask(args.id);
|
|
971
675
|
if (!task) return { success: false, message: 'Task not found' };
|
|
972
676
|
return { success: true, message: `Task "${task.title}" resumed (${task.scheduleText})` };
|
|
@@ -991,12 +695,96 @@ export function apply(ctx, config) {
|
|
|
991
695
|
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
992
696
|
},
|
|
993
697
|
execute: async (args) => {
|
|
698
|
+
// Deleting a config-owned task would also drop its run history, and the
|
|
699
|
+
// task would come back at the next start without it.
|
|
700
|
+
if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
|
|
994
701
|
scheduler.pauseTask(args.id);
|
|
995
702
|
const ok = store.delete(args.id);
|
|
996
703
|
return { success: ok, message: ok ? 'Task deleted' : 'Task not found' };
|
|
997
704
|
}
|
|
998
705
|
}));
|
|
999
706
|
|
|
707
|
+
ctx.tools.register(defineTool({
|
|
708
|
+
name: 'cron_get_task',
|
|
709
|
+
description: 'Read the full configuration of one scheduled task, including fields that are not visible in the task list',
|
|
710
|
+
parameters: {
|
|
711
|
+
id: { type: 'string', description: 'Task identifier', required: true }
|
|
712
|
+
},
|
|
713
|
+
output: {
|
|
714
|
+
schema: {
|
|
715
|
+
type: 'object',
|
|
716
|
+
additionalProperties: true,
|
|
717
|
+
properties: {
|
|
718
|
+
success: { type: 'boolean' },
|
|
719
|
+
message: { type: 'string' },
|
|
720
|
+
task: { type: 'object', additionalProperties: true }
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
724
|
+
},
|
|
725
|
+
execute: async (args) => {
|
|
726
|
+
const task = store.get(args.id);
|
|
727
|
+
if (!task) return { success: false, message: 'Task not found' };
|
|
728
|
+
const summary = [
|
|
729
|
+
`Task "${task.title}" (${task.id})`,
|
|
730
|
+
`type: ${task.type || 'llm'}`,
|
|
731
|
+
`schedule: ${task.scheduleText || task.schedule}`,
|
|
732
|
+
`status: ${task.status}`,
|
|
733
|
+
task.type === 'llm' || !task.type ? `model: ${task.model || '(default)'}${task.fallbackModel ? ` (fallback: ${task.fallbackModel})` : ''}` : null,
|
|
734
|
+
`channels: ${Array.isArray(task.channels) && task.channels.length ? task.channels.join(', ') : '(legacy flags)'}`,
|
|
735
|
+
`prompt: ${String(task.prompt || '').slice(0, 500)}`,
|
|
736
|
+
].filter(Boolean).join(String.fromCharCode(10));
|
|
737
|
+
return { success: true, message: summary, task };
|
|
738
|
+
}
|
|
739
|
+
}));
|
|
740
|
+
|
|
741
|
+
ctx.tools.register(defineTool({
|
|
742
|
+
name: 'cron_update_task',
|
|
743
|
+
description: 'Change an existing scheduled task. Ask the user what to change first. Switching a task to a code-executing type (script/node/python/ssh/docker) is refused unless confirmCodeSwitch is set, which requires the user to have agreed explicitly. Only the documented task fields can be changed.',
|
|
744
|
+
parameters: {
|
|
745
|
+
id: { type: 'string', description: 'Task identifier', required: true },
|
|
746
|
+
confirmCodeSwitch: { type: 'boolean', description: 'Set to true only after the user explicitly agreed to switch this task into a type that executes code' },
|
|
747
|
+
title: { type: 'string', description: 'New title' },
|
|
748
|
+
schedule: { type: 'string', description: 'New schedule (cron expression or interval)' },
|
|
749
|
+
prompt: { type: 'string', description: 'New prompt or command' },
|
|
750
|
+
type: { type: 'string', enum: TASK_TYPES, description: 'New execution type' },
|
|
751
|
+
model: { type: 'string', description: 'New model for agent tasks' },
|
|
752
|
+
fallbackModel: { type: 'string', description: 'New fallback model' },
|
|
753
|
+
provider: { type: 'string', description: 'New provider' },
|
|
754
|
+
channels: { type: 'array', items: { type: 'string', enum: CHANNEL_IDS }, description: 'New delivery channels' },
|
|
755
|
+
template: { type: 'string', description: 'New message template' },
|
|
756
|
+
onlyOnFailure: { type: 'boolean', description: 'Deliver only on failures' },
|
|
757
|
+
timeoutSeconds: { type: 'number', description: 'New execution timeout in seconds' },
|
|
758
|
+
overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'New overlap policy' },
|
|
759
|
+
maxRetries: { type: 'number', description: 'New retry count' },
|
|
760
|
+
kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'New Kanban policy' },
|
|
761
|
+
timezone: { type: 'string', description: 'New IANA time zone' },
|
|
762
|
+
cwd: { type: 'string', description: 'New working directory' },
|
|
763
|
+
env: { type: 'object', additionalProperties: true, description: 'New environment variables (KEY: value)' }
|
|
764
|
+
},
|
|
765
|
+
output: {
|
|
766
|
+
schema: {
|
|
767
|
+
type: 'object',
|
|
768
|
+
additionalProperties: true,
|
|
769
|
+
properties: {
|
|
770
|
+
success: { type: 'boolean' },
|
|
771
|
+
message: { type: 'string' }
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
775
|
+
},
|
|
776
|
+
execute: async (args) => {
|
|
777
|
+
const { id, confirmCodeSwitch, ...patch } = args || {};
|
|
778
|
+
if (!id) return { success: false, message: 'A task id is required' };
|
|
779
|
+
const result = applyTaskPatch({ store, scheduler, id, body: patch, allowCodeSwitch: confirmCodeSwitch === true });
|
|
780
|
+
if (!result.ok) return { success: false, message: result.error };
|
|
781
|
+
return {
|
|
782
|
+
success: true,
|
|
783
|
+
message: `Task "${result.task.title}" updated (${result.task.scheduleText || result.task.schedule}). Changed: ${describeTaskPatch(result.task, result.patch)}`,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
}));
|
|
787
|
+
|
|
1000
788
|
ctx.tools.register(defineTool({
|
|
1001
789
|
name: 'cron_run_task',
|
|
1002
790
|
description: 'Trigger a task immediately, out of band',
|