@goodandready/dsh-cron 0.3.15 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.js CHANGED
@@ -1,32 +1,5 @@
1
- import { captureGitDiff, discardGitChanges, applyGitDiff } from './git-diff.js';
2
- import { applyDoctorFix, revertDoctorFix, diagnoseFailure } from './doctor.js';
3
1
  import { handleTaskWebhookRoute } from './api-task-webhook.js';
4
- import { rollbackPromptVersion, sanitizePromptHistory } from './prompt-evolver.js';
5
- import { sanitizeAdaptiveConfig } from './adaptive.js';
6
- import { logStreamer } from './log-streamer.js';
7
- import { approvalManager } from './approval.js';
8
- import { sanitizeValidators } from './validators.js';
9
- /**
10
- * REST surface of the plugin: the /dsh-cron/tasks and /dsh-cron/action route
11
- * families. Extracted from index.js and split into per-scope handlers so each
12
- * piece stays reviewable (#97); route contracts and response bodies are
13
- * unchanged.
14
- */
15
-
16
- import { randomUUID, createHash } from 'node:crypto';
17
- import { parseScheduleExpression } from './scheduler.js';
18
- import { normalizeTaskType, CODE_EXECUTING_TYPES, EXTERNAL_RUNTIMES } from './runtimes.js';
19
- import { CHANNEL_IDS, unknownChannelIds } from './channels.js';
20
- import { applyTaskPatch } from './task-patch.js';
21
- import { MANAGED_BY_CONFIG, configOwnedMessage } from './config-jobs.js';
22
- import { sendJson, rejectCrossOrigin, readBody, pickPatchableFields, SCRIPT_CONFIRM_HEADER, redactTaskSecrets, restoreTaskSecrets } from './http-utils.js';
23
- import {
24
- validateTaskType,
25
- buildDuplicateTask,
26
- RESERVED_TASK_IDS,
27
- PATCHABLE_TASK_FIELDS,
28
- isCodeExecutingTask,
29
- } from './task-transfer.js';
2
+ import { sendJson, rejectCrossOrigin, redactTaskSecrets } from './http-utils.js';
30
3
  import {
31
4
  handleTaskExport,
32
5
  handleTaskImport,
@@ -39,6 +12,15 @@ import {
39
12
  handleDryRunTask,
40
13
  handleScheduleTimeline,
41
14
  } from './api-helpers.js';
15
+ import {
16
+ refuseConfigOwned,
17
+ handleTaskCollection,
18
+ createOrUpdateTask,
19
+ NOT_ALLOWED,
20
+ NOT_FOUND,
21
+ } from './api-collection.js';
22
+ import { handleTaskItem, deleteTask } from './api-item.js';
23
+ import { handleItemPost } from './api-actions.js';
42
24
 
43
25
  export {
44
26
  handleTaskExport,
@@ -49,20 +31,9 @@ export {
49
31
  handleSchedulePreview,
50
32
  handleDryRunTask,
51
33
  handleScheduleTimeline,
34
+ refuseConfigOwned,
52
35
  };
53
36
 
54
- const NOT_FOUND = { ok: false, error: 'Task not found' };
55
- const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
56
-
57
- /**
58
- * A task declared in the profile config is owned by that file (#50): the next
59
- * plugin start would revert any change made here, so every mutating route
60
- * refuses it and says where the task really lives.
61
- */
62
- function refuseConfigOwned(res, id) {
63
- sendJson(res, 409, { ok: false, error: configOwnedMessage(id) });
64
- }
65
-
66
37
  /**
67
38
  * Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
68
39
  * route families — the #66 alias is kept alive by the same code path instead
@@ -141,713 +112,6 @@ export function createCronApiHandler(store, scheduler, collection) {
141
112
  };
142
113
  }
143
114
 
144
- // ------------------------------------------------------------- collection
145
-
146
- async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx }) {
147
- // The alias family only exists for individual tasks.
148
- if (scope === 'action' || !collection) {
149
- sendJson(res, 405, NOT_ALLOWED);
150
- return;
151
- }
152
- if (req.method === 'GET') {
153
- if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
154
- listTasks({ store, scheduler, collection, req, res, url });
155
- return;
156
- }
157
- if (req.method === 'POST') {
158
- await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
159
- return;
160
- }
161
- sendJson(res, 405, NOT_ALLOWED);
162
- }
163
-
164
- function listTasks({ store, scheduler, collection, req, res, url }) {
165
- const status = url.searchParams.get('status') || 'all';
166
- const query = url.searchParams.get('query') || '';
167
- const list = store.list({ status, query }).map((t) => ({
168
- ...redactTaskSecrets(t),
169
- running: scheduler.isRunning(t.id),
170
- runningSince: scheduler.runningSince(t.id),
171
- }));
172
- const stats = store.getAggregatedStats();
173
- const payload = { ok: true, tasks: list, recommendations: collection.recommendations, stats };
174
-
175
- // Calculate ETag based on payload JSON representation (#134)
176
- const bodyString = JSON.stringify(payload);
177
- const etag = 'W/"' + createHash('sha1').update(bodyString).digest('hex') + '"';
178
-
179
- if (req && req.headers) {
180
- const ifNoneMatch = req.headers['if-none-match'];
181
- if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === etag.slice(2))) {
182
- res.writeHead(304, { 'ETag': etag, 'Cache-Control': 'no-cache' });
183
- res.end();
184
- return;
185
- }
186
- }
187
-
188
- if (typeof res.setHeader === 'function') {
189
- res.setHeader('ETag', etag);
190
- res.setHeader('Cache-Control', 'no-cache');
191
- }
192
- sendJson(res, 200, payload);
193
- }
194
-
195
- async function createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx }) {
196
- if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
197
- const { body: rawBody, error } = await readBody(req, res);
198
- if (error) return;
199
- const existing = rawBody.id ? store.get(rawBody.id) : null;
200
- const body = existing ? restoreTaskSecrets(rawBody, existing) : rawBody;
201
- if (!body.title || !body.schedule || !body.prompt) {
202
- sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
203
- return;
204
- }
205
- const unknownChannels = unknownChannelIds(body.channels);
206
- if (unknownChannels.length) {
207
- sendJson(res, 400, {
208
- ok: false,
209
- error: 'Unknown channel ids: ' + unknownChannels.join(', '),
210
- unknownChannels,
211
- });
212
- return;
213
- }
214
- const taskType = normalizeTaskType(body.type);
215
- if ((CODE_EXECUTING_TYPES.includes(taskType) || isCodeExecutingTask(body)) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
216
- sendJson(res, 403, { ok: false, error: `Creating code-executing tasks over HTTP requires the x-dsh-cron-confirm: script header` });
217
- return;
218
- }
219
- const typeError = validateTaskType(taskType, body);
220
- if (typeError) {
221
- sendJson(res, 400, { ok: false, error: typeError });
222
- return;
223
- }
224
- let parsed;
225
- try {
226
- parsed = parseScheduleExpression(body.schedule);
227
- } catch (err) {
228
- sendJson(res, 400, { ok: false, error: err.message });
229
- return;
230
- }
231
- // A client-supplied id must not shadow the collection routes under
232
- // /dsh-cron/tasks, otherwise that task could never be fetched or deleted
233
- // again (#42 review finding).
234
- if (body.id && RESERVED_TASK_IDS.includes(String(body.id))) {
235
- sendJson(res, 400, { ok: false, error: `Task id "${body.id}" is reserved` });
236
- return;
237
- }
238
- // Create-or-update with an existing id is an update too, so the config-owned
239
- // refusal must apply here as well — otherwise the POST route would be a way
240
- // around the guard on PATCH (#50 review finding).
241
- if (body.id) {
242
- const existing = store.get(body.id);
243
- if (existing && existing.managedBy === MANAGED_BY_CONFIG) {
244
- refuseConfigOwned(res, body.id);
245
- return;
246
- }
247
- }
248
- let task;
249
- try {
250
- task = store.set(buildTaskRecord(store, body, taskType, parsed));
251
- } catch (err) {
252
- sendJson(res, 500, { ok: false, error: 'Failed to persist task: ' + err.message });
253
- return;
254
- }
255
- if (task.status === 'active') {
256
- scheduler.scheduleTask(task);
257
- } else {
258
- scheduler.pauseTask(task.id);
259
- }
260
- sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
261
- }
262
-
263
- /** Merge a create/update payload onto the stored task, if any. */
264
- function buildTaskRecord(store, body, taskType, parsed) {
265
- const current = body.id ? store.get(body.id) : null;
266
- const resolvedStatus = body.status || (current ? current.status : 'active');
267
- return {
268
- id: body.id,
269
- title: body.title,
270
- schedule: parsed.cronPattern || body.schedule,
271
- scheduleText: body.scheduleText || parsed.humanText,
272
- prompt: body.prompt,
273
- type: taskType,
274
- delivery: body.delivery || 'current',
275
- status: resolvedStatus,
276
- provider: body.provider || undefined,
277
- model: body.model || undefined,
278
- fallbackProvider: body.fallbackProvider || undefined,
279
- fallbackModel: body.fallbackModel || undefined,
280
- notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : undefined),
281
- onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : undefined),
282
- timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
283
- overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
284
- kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
285
- timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
286
- misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
287
- maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
288
- retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
289
- permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current && !EXTERNAL_RUNTIMES.includes(taskType) ? (current.permissionPreset || 'default') : 'default'),
290
- validators: Array.isArray(body.validators) ? sanitizeValidators(body.validators) : (current ? current.validators : undefined),
291
- ...mergeExecutionFields(current, body, parsed),
292
- };
293
- }
294
-
295
- // export/import logic moved to ./api-import-export.js
296
-
297
- // ------------------------------------------------------------ single task
298
-
299
- async function handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx }) {
300
- if (action === 'heartbeat') {
301
- handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
302
- return;
303
- }
304
-
305
- if (req.method === 'POST' && action === 'dry-run') {
306
- await handleDryRunTask({ store, scheduler, req, res, id, apiToken, ctx });
307
- return;
308
- }
309
-
310
- if (req.method === 'GET') {
311
- if (action === 'stream') {
312
- logStreamer.handleSseRequest(id, req, res);
313
- return;
314
- }
315
- if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
316
- if (action === 'archive') {
317
- const limit = parseInt(url.searchParams.get('limit') || '50', 10);
318
- const offset = parseInt(url.searchParams.get('offset') || '0', 10);
319
- const search = url.searchParams.get('search') || '';
320
- const result = typeof store.getArchivedRuns === 'function'
321
- ? store.getArchivedRuns(id, { limit, offset, search })
322
- : { runs: [], total: 0 };
323
- sendJson(res, 200, { ok: true, taskId: id, ...result });
324
- return;
325
- }
326
- if (action === 'stats') {
327
- const stats = typeof store.getTaskStats === 'function' ? store.getTaskStats(id) : null;
328
- sendJson(res, 200, { ok: true, taskId: id, stats });
329
- return;
330
- }
331
- if (action === 'prompt-history') {
332
- const task = store.get(id);
333
- if (!task) {
334
- sendJson(res, 404, NOT_FOUND);
335
- return;
336
- }
337
- sendJson(res, 200, {
338
- ok: true,
339
- taskId: id,
340
- promptVersion: task.promptVersion || 1,
341
- promptHistory: task.promptHistory || [],
342
- });
343
- return;
344
- }
345
- if (action === 'history') {
346
- const limit = parseInt(url.searchParams.get('limit') || '20', 10);
347
- sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
348
- return;
349
- }
350
- if (action === 'git-diff') {
351
- const task = store.get(id);
352
- if (!task) {
353
- sendJson(res, 404, NOT_FOUND);
354
- return;
355
- }
356
- const runs = store.getHistory(id, 10);
357
- const runId = url.searchParams.get('runId') || subAction;
358
- const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
359
- const diffData = targetRun?.gitDiff || null;
360
- sendJson(res, 200, {
361
- ok: true,
362
- taskId: id,
363
- runId: targetRun?.id || null,
364
- gitDiff: diffData,
365
- });
366
- return;
367
- }
368
- if (action === 'doctor') {
369
- const task = store.get(id);
370
- if (!task) {
371
- sendJson(res, 404, NOT_FOUND);
372
- return;
373
- }
374
- const runs = store.getHistory(id, 5);
375
- const latestRun = runs[0] || null;
376
- const reportRun = runs.find((r) => r.doctorReport) || latestRun;
377
- sendJson(res, 200, {
378
- ok: true,
379
- taskId: id,
380
- doctorReport: reportRun?.doctorReport || null,
381
- revisions: task.revisions || [],
382
- previousPrompt: task.previousPrompt || null,
383
- autoHeal: Boolean(task.autoHeal || task.selfHealing?.autoApply),
384
- });
385
- return;
386
- }
387
- if (!action) {
388
- const task = store.get(id);
389
- if (!task) {
390
- sendJson(res, 404, NOT_FOUND);
391
- return;
392
- }
393
- sendJson(res, 200, {
394
- ok: true,
395
- task: {
396
- ...redactTaskSecrets(task),
397
- running: scheduler.isRunning(task.id),
398
- runningSince: scheduler.runningSince(task.id),
399
- },
400
- });
401
- return;
402
- }
403
- }
404
- if (req.method === 'POST') {
405
- const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
406
- if (handled) return;
407
- }
408
- if (req.method === 'PATCH') {
409
- await patchTask({ store, scheduler, req, res, id });
410
- return;
411
- }
412
- if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
413
- deleteTask({ store, scheduler, res, id });
414
- return;
415
- }
416
- sendJson(res, 405, NOT_ALLOWED);
417
- }
418
-
419
- /** Task actions that are plain POSTs. Returns true when one matched. */
420
- async function handleItemPost({ store, scheduler, req, res, url, id, action, subAction }) {
421
- if (action === 'git-diff' && (subAction === 'discard' || subAction === 'revert')) {
422
- const task = store.get(id);
423
- if (!task) {
424
- sendJson(res, 404, NOT_FOUND);
425
- return true;
426
- }
427
- const targetDir = task.cwd;
428
- if (!targetDir) {
429
- sendJson(res, 400, { ok: false, error: 'Task does not define a working directory (cwd)' });
430
- return true;
431
- }
432
- const result = await discardGitChanges(targetDir);
433
- sendJson(res, result.ok ? 200 : 500, result);
434
- return true;
435
- }
436
-
437
- if (action === 'git-diff' && subAction === 'apply') {
438
- const task = store.get(id);
439
- if (!task) {
440
- sendJson(res, 404, NOT_FOUND);
441
- return true;
442
- }
443
- const body = await readBody(req, res);
444
- const bodyData = (body && body.body) || {};
445
- let patch = bodyData.diff || bodyData.patch;
446
- if (!patch) {
447
- const runs = store.getHistory(id, 5);
448
- const targetRun = runs.find((r) => r.gitDiff?.diff);
449
- patch = targetRun?.gitDiff?.diff;
450
- }
451
- if (!patch) {
452
- sendJson(res, 400, { ok: false, error: 'No diff patch provided or found in run history' });
453
- return true;
454
- }
455
- const targetDir = task.cwd;
456
- if (!targetDir) {
457
- sendJson(res, 400, { ok: false, error: 'Task does not define a working directory (cwd)' });
458
- return true;
459
- }
460
- const result = await applyGitDiff(targetDir, patch);
461
- sendJson(res, result.ok ? 200 : 500, result);
462
- return true;
463
- }
464
-
465
- if ((action === 'doctor' && subAction === 'apply') || action === 'doctor-apply') {
466
- const task = store.get(id);
467
- if (!task) {
468
- sendJson(res, 404, NOT_FOUND);
469
- return true;
470
- }
471
- const body = await readBody(req, res);
472
- const bodyData = (body && body.body) || {};
473
- let fix = bodyData.fix || bodyData.doctorReport;
474
- if (!fix) {
475
- const runs = store.getHistory(id, 5);
476
- const reportRun = runs.find((r) => r.doctorReport);
477
- fix = reportRun?.doctorReport;
478
- }
479
- if (!fix || !fix.suggestedFix) {
480
- sendJson(res, 400, { ok: false, error: 'No doctor fix found to apply' });
481
- return true;
482
- }
483
- try {
484
- const result = await applyDoctorFix(task, fix, { store, user: bodyData.user || 'operator' });
485
- if (!result.ok) {
486
- sendJson(res, 400, { ok: false, error: result.error });
487
- return true;
488
- }
489
- sendJson(res, 200, {
490
- ok: true,
491
- taskId: id,
492
- task: redactTaskSecrets(task),
493
- revision: result.revision,
494
- });
495
- } catch (err) {
496
- sendJson(res, 500, { ok: false, error: err.message });
497
- }
498
- return true;
499
- }
500
-
501
- if ((action === 'doctor' && subAction === 'revert') || action === 'doctor-revert') {
502
- const task = store.get(id);
503
- if (!task) {
504
- sendJson(res, 404, NOT_FOUND);
505
- return true;
506
- }
507
- const body = await readBody(req, res);
508
- const bodyData = (body && body.body) || {};
509
- try {
510
- const result = await revertDoctorFix(task, { store, user: bodyData.user || 'operator' });
511
- if (!result.ok) {
512
- sendJson(res, 400, { ok: false, error: result.error });
513
- return true;
514
- }
515
- sendJson(res, 200, {
516
- ok: true,
517
- taskId: id,
518
- task: redactTaskSecrets(task),
519
- revertedCode: result.revertedCode,
520
- });
521
- } catch (err) {
522
- sendJson(res, 500, { ok: false, error: err.message });
523
- }
524
- return true;
525
- }
526
-
527
- if ((action === 'doctor' && subAction === 'diagnose') || action === 'doctor-diagnose') {
528
- const task = store.get(id);
529
- if (!task) {
530
- sendJson(res, 404, NOT_FOUND);
531
- return true;
532
- }
533
- const body = await readBody(req, res);
534
- const bodyData = (body && body.body) || {};
535
- const runId = bodyData.runId;
536
- const runs = store.getHistory(id, 10);
537
- const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
538
- if (!targetRun) {
539
- sendJson(res, 404, { ok: false, error: 'No run history found to diagnose' });
540
- return true;
541
- }
542
- try {
543
- const doctorReport = await diagnoseFailure({
544
- task,
545
- runInfo: targetRun,
546
- ask: scheduler?.askModel,
547
- });
548
- targetRun.doctorReport = doctorReport;
549
- sendJson(res, 200, { ok: true, taskId: id, doctorReport });
550
- } catch (err) {
551
- sendJson(res, 500, { ok: false, error: err.message });
552
- }
553
- return true;
554
- }
555
- if (action === 'prompt-rollback') {
556
- const task = store.get(id);
557
- if (!task) {
558
- sendJson(res, 404, NOT_FOUND);
559
- return true;
560
- }
561
- const body = await readBodyJson(req).catch(() => ({}));
562
- const targetVersion = Number(body.version || url.searchParams.get('version'));
563
- if (!targetVersion || targetVersion < 1) {
564
- sendJson(res, 400, { ok: false, error: 'A valid target prompt version is required' });
565
- return true;
566
- }
567
- try {
568
- rollbackPromptVersion(task, targetVersion);
569
- store.set(task);
570
- sendJson(res, 200, {
571
- ok: true,
572
- task: redactTaskSecrets(task),
573
- promptVersion: task.promptVersion,
574
- promptHistory: task.promptHistory,
575
- });
576
- } catch (err) {
577
- sendJson(res, 400, { ok: false, error: err.message });
578
- }
579
- return true;
580
- }
581
- if (action === 'retry-delivery') {
582
- const task = store.get(id);
583
- if (!task) {
584
- sendJson(res, 404, NOT_FOUND);
585
- return true;
586
- }
587
- const body = await readBodyJson(req).catch(() => ({}));
588
- const runId = body.runId || url.searchParams.get('runId');
589
- const runs = store.history.get(id) || [];
590
- const run = runId ? runs.find(r => r.id === runId) : runs[0];
591
- if (!run) {
592
- sendJson(res, 404, { ok: false, error: 'Run not found' });
593
- return true;
594
- }
595
- const delivery = await scheduler.deliverNotifications(task, run);
596
- run.delivery = delivery;
597
- if (Array.isArray(run.stages)) {
598
- const deliveryStage = run.stages.find(s => s.name === 'delivery');
599
- if (deliveryStage) {
600
- deliveryStage.status = delivery?.status || 'delivered';
601
- deliveryStage.delivered = delivery?.delivered;
602
- deliveryStage.failures = delivery?.failures;
603
- }
604
- }
605
- store.scheduleSave();
606
- sendJson(res, 200, { ok: true, delivery, run });
607
- return true;
608
- }
609
- if (action === 'approve' || action === 'reject') {
610
- const task = store.get(id);
611
- if (!task) {
612
- sendJson(res, 404, NOT_FOUND);
613
- return true;
614
- }
615
- const body = await readBodyJson(req).catch(() => ({}));
616
- const proposalId = body.proposalId || url.searchParams.get('proposalId');
617
- const proposal = proposalId
618
- ? approvalManager.getProposal(proposalId)
619
- : approvalManager.getPendingProposalForTask(id);
620
-
621
- if (!proposal) {
622
- sendJson(res, 404, { ok: false, error: 'No pending approval proposal found for this task' });
623
- return true;
624
- }
625
-
626
- const decision = action === 'approve' ? 'approve' : 'reject';
627
- const result = approvalManager.decide(proposal.id, decision, {
628
- decidedBy: body.decidedBy || 'web-user',
629
- reason: body.reason || '',
630
- });
631
-
632
- if (!result.ok) {
633
- sendJson(res, 400, result);
634
- return true;
635
- }
636
-
637
- if (decision === 'approve') {
638
- scheduler.runTask(id, { approvedBy: body.decidedBy || 'web-user' }).catch(() => {});
639
- }
640
-
641
- sendJson(res, 200, { ok: true, proposal: result.proposal });
642
- return true;
643
- }
644
- if (action === 'resolve-uncertain') {
645
- const task = store.get(id);
646
- if (!task) {
647
- sendJson(res, 404, NOT_FOUND);
648
- return true;
649
- }
650
- const body = await readBody(req).then(b => JSON.parse(b || '{}')).catch(() => ({}));
651
- const runId = body.runId || url.searchParams.get('runId');
652
- if (!runId) {
653
- sendJson(res, 400, { ok: false, error: 'runId is required' });
654
- return true;
655
- }
656
- const result = store.durableJournal.resolveUncertain(id, runId, {
657
- decision: body.decision || 'mark_failed',
658
- note: body.note || '',
659
- resolvedBy: body.resolvedBy || 'user',
660
- });
661
- if (!result.ok) {
662
- sendJson(res, 400, result);
663
- return true;
664
- }
665
- sendJson(res, 200, result);
666
- return true;
667
- }
668
- if (action === 'run') {
669
- if (!store.get(id)) {
670
- sendJson(res, 404, NOT_FOUND);
671
- return true;
672
- }
673
- await scheduler.triggerManualRun(id);
674
- sendJson(res, 200, { ok: true, task: redactTaskSecrets(store.get(id)) });
675
- return true;
676
- }
677
- if (action === 'duplicate') {
678
- const source = store.get(id);
679
- if (!source) {
680
- sendJson(res, 404, NOT_FOUND);
681
- return true;
682
- }
683
- const copy = buildDuplicateTask(source, { id: randomUUID() });
684
- try {
685
- store.set(copy);
686
- } catch (err) {
687
- sendJson(res, 500, { ok: false, error: 'Failed to persist duplicate task: ' + err.message });
688
- return true;
689
- }
690
- // A copy starts paused: it must never fire on its own before the user
691
- // reviews the schedule (a duplicated one-shot may point at a past time).
692
- scheduler.pauseTask(copy.id);
693
- sendJson(res, 200, { ok: true, task: redactTaskSecrets(copy) });
694
- return true;
695
- }
696
- if (action === 'pause' || action === 'resume') {
697
- if (store.get(id) && store.get(id).managedBy === MANAGED_BY_CONFIG) {
698
- refuseConfigOwned(res, id);
699
- return true;
700
- }
701
- const task = action === 'pause' ? scheduler.pauseTask(id) : scheduler.resumeTask(id);
702
- if (!task) {
703
- sendJson(res, 404, NOT_FOUND);
704
- return true;
705
- }
706
- sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
707
- return true;
708
- }
709
- if (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle')) {
710
- const stored = store.get(id);
711
- if (!stored) {
712
- sendJson(res, 404, NOT_FOUND);
713
- return true;
714
- }
715
- if (stored.managedBy === MANAGED_BY_CONFIG) {
716
- refuseConfigOwned(res, id);
717
- return true;
718
- }
719
- const task = scheduler.toggleTask(id);
720
- sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
721
- return true;
722
- }
723
- return false;
724
- }
725
-
726
- async function patchTask({ store, scheduler, req, res, id }) {
727
- const current = store.get(id);
728
- if (!current) {
729
- sendJson(res, 404, NOT_FOUND);
730
- return;
731
- }
732
- if (current.managedBy === MANAGED_BY_CONFIG) {
733
- refuseConfigOwned(res, id);
734
- return;
735
- }
736
- const { body, error } = await readBody(req, res);
737
- if (error) return;
738
- const result = applyTaskPatch({
739
- store,
740
- scheduler,
741
- id,
742
- body,
743
- allowCodeSwitch: req.headers[SCRIPT_CONFIRM_HEADER] === 'script',
744
- });
745
- if (!result.ok) {
746
- const status = result.status || (result.notFound ? 404 : (result.needsConfirmation ? 403 : 400));
747
- const error = result.needsConfirmation
748
- ? `Switching a task to the ${normalizeTaskType(body.type)} type over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`
749
- : result.error;
750
- sendJson(res, status, {
751
- ok: false,
752
- error,
753
- ...(result.unknownChannels ? { unknownChannels: result.unknownChannels } : {}),
754
- });
755
- return;
756
- }
757
- sendJson(res, 200, { ok: true, task: redactTaskSecrets(result.task) });
758
- }
759
-
760
- function deleteTask({ store, scheduler, res, id }) {
761
- const current = store.get(id);
762
- if (current && current.managedBy === MANAGED_BY_CONFIG) {
763
- refuseConfigOwned(res, id);
764
- return;
765
- }
766
- if (current) {
767
- if (scheduler && typeof scheduler.removeTask === 'function') {
768
- scheduler.removeTask(id);
769
- } else if (scheduler) {
770
- scheduler.pauseTask(id);
771
- }
772
- try {
773
- store.delete(id);
774
- } catch (err) {
775
- sendJson(res, 500, { ok: false, error: 'Failed to delete task: ' + err.message });
776
- return;
777
- }
778
- }
779
- sendJson(res, 200, { ok: true });
780
- }
781
-
782
- /**
783
- * Runtime and execution settings of a task, merged onto the stored one. Split
784
- * out of buildTaskRecord so both stay readable.
785
- */
786
- function mergeExecutionFields(current, body, parsed) {
787
- return {
788
- env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
789
- cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
790
- channels: Array.isArray(body.channels)
791
- ? body.channels.filter((c) => CHANNEL_IDS.includes(c))
792
- : (current ? current.channels : undefined),
793
- template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
794
- silentRule: body.silentRule !== undefined ? String(body.silentRule) : (current ? current.silentRule : ''),
795
- inspectOnFailure: body.inspectOnFailure !== undefined ? Boolean(body.inspectOnFailure) : (current ? Boolean(current.inspectOnFailure) : false),
796
- workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
797
- worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
798
- keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
799
- httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
800
- httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
801
- httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
802
- httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
803
- sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
804
- sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
805
- sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
806
- sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
807
- dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
808
- pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
809
- nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
810
- skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
811
- workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
812
- oneShot: Boolean((parsed && parsed.isOneShot) || (body && body.oneShot)),
813
- targetTimestamp: parsed && parsed.isOneShot
814
- ? (current && current.schedule === (parsed.cronPattern || body.schedule) ? (current.targetTimestamp || parsed.targetTimestamp) : parsed.targetTimestamp)
815
- : (current ? current.targetTimestamp : undefined),
816
- costLimitUsd: body.costLimitUsd !== undefined ? (body.costLimitUsd === null ? null : (Number(body.costLimitUsd) > 0 ? Number(body.costLimitUsd) : null)) : (current ? current.costLimitUsd : undefined),
817
- dailyCostLimitUsd: body.dailyCostLimitUsd !== undefined ? (body.dailyCostLimitUsd === null ? null : (Number(body.dailyCostLimitUsd) > 0 ? Number(body.dailyCostLimitUsd) : null)) : (current ? current.dailyCostLimitUsd : undefined),
818
- tokenLimit: body.tokenLimit !== undefined ? (body.tokenLimit === null ? null : (Number(body.tokenLimit) > 0 ? Number(body.tokenLimit) : null)) : (current ? current.tokenLimit : undefined),
819
- pausedReason: body.pausedReason !== undefined ? (body.pausedReason ? String(body.pausedReason) : null) : (current ? current.pausedReason : undefined),
820
- agentPreset: body.agentPreset !== undefined ? String(body.agentPreset).trim() : (current ? (current.agentPreset || '') : ''),
821
- targetSessionId: body.targetSessionId !== undefined ? String(body.targetSessionId).trim() : (current ? (current.targetSessionId || '') : ''),
822
- targetSessionReset: body.targetSessionReset !== undefined ? String(body.targetSessionReset).trim() : (current ? (current.targetSessionReset || 'never') : 'never'),
823
- sessionTitle: body.sessionTitle !== undefined ? String(body.sessionTitle).trim() : (current ? (current.sessionTitle || '') : ''),
824
- webhookEnabled: body.webhookEnabled !== undefined
825
- ? Boolean(body.webhookEnabled)
826
- : (body.webhookTrigger?.enabled !== undefined ? Boolean(body.webhookTrigger.enabled) : (current ? Boolean(current.webhookEnabled || current.webhookTrigger?.enabled) : false)),
827
- webhookToken: body.webhookToken !== undefined
828
- ? String(body.webhookToken).trim()
829
- : (body.webhookTrigger?.token ? String(body.webhookTrigger.token).trim() : (current ? (current.webhookToken || current.webhookTrigger?.token || '') : '')),
830
- webhookSecret: body.webhookSecret !== undefined
831
- ? String(body.webhookSecret).trim()
832
- : (body.webhookTrigger?.secret ? String(body.webhookTrigger.secret).trim() : (current ? (current.webhookSecret || current.webhookTrigger?.secret || '') : '')),
833
- webhookDebounceMs: body.webhookDebounceMs !== undefined
834
- ? (Number(body.webhookDebounceMs) || 0)
835
- : (body.webhookTrigger?.debounceMs !== undefined ? (Number(body.webhookTrigger.debounceMs) || 0) : (current ? (Number(current.webhookDebounceMs || current.webhookTrigger?.debounceMs) || 0) : 0)),
836
- onSuccess: body.onSuccess !== undefined ? String(body.onSuccess).trim() : (current ? (current.onSuccess || '') : ''),
837
- onFailure: body.onFailure !== undefined ? String(body.onFailure).trim() : (current ? (current.onFailure || '') : ''),
838
- heartbeatIntervalSeconds: body.heartbeatIntervalSeconds !== undefined ? Number(body.heartbeatIntervalSeconds) : (current ? (Number(current.heartbeatIntervalSeconds) || 0) : 0),
839
- gracePeriodSeconds: body.gracePeriodSeconds !== undefined ? Number(body.gracePeriodSeconds) : (current ? (Number(current.gracePeriodSeconds) || 300) : 300),
840
- preflightType: body.preflightType !== undefined ? String(body.preflightType).trim().toLowerCase() : (current ? (current.preflightType || 'none') : 'none'),
841
- preflightTarget: body.preflightTarget !== undefined ? String(body.preflightTarget).trim() : (current ? (current.preflightTarget || '') : ''),
842
- priority: body.priority !== undefined ? (Number(body.priority) || 5) : (current ? (Number(current.priority) || 5) : 5),
843
- concurrencyGroup: body.concurrencyGroup !== undefined ? String(body.concurrencyGroup).trim() : (current ? (current.concurrencyGroup || 'default') : 'default'),
844
- selfHealingCommand: body.selfHealingCommand !== undefined ? String(body.selfHealingCommand).trim() : (current ? (current.selfHealingCommand || '') : ''),
845
- autoDiagnose: body.autoDiagnose !== undefined ? Boolean(body.autoDiagnose) : (current ? Boolean(current.autoDiagnose) : false),
846
- adaptive: body.adaptive !== undefined ? sanitizeAdaptiveConfig(body.adaptive) : (current ? current.adaptive : undefined),
847
- adaptiveState: current ? current.adaptiveState : undefined,
848
- };
849
- }
850
-
851
115
  // --------------------------------------------------------- external surface
852
116
 
853
117
  /**
@@ -911,3 +175,4 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
911
175
  }
912
176
 
913
177
  // webhook and helper endpoints moved to ./api-webhook.js and ./api-helpers.js
178
+