@goodandready/dsh-cron 0.3.24 → 0.3.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/lib/api-actions.js +1 -317
  2. package/lib/api-collection.js +1 -250
  3. package/lib/api-helpers.js +1 -100
  4. package/lib/api-import-export.js +1 -155
  5. package/lib/api-item.js +1 -190
  6. package/lib/api-task-webhook.js +1 -247
  7. package/lib/api-webhook.js +1 -183
  8. package/lib/api.js +1 -178
  9. package/lib/artifacts.js +1 -200
  10. package/lib/chain-checkpoints.js +1 -223
  11. package/lib/client-src/00-open.js +62 -0
  12. package/lib/client-src/10-locales.js +484 -0
  13. package/lib/client-src/20-styles.js +64 -0
  14. package/lib/client-src/30-utils.js +248 -0
  15. package/lib/client-src/40-delivery-settings.js +96 -0
  16. package/lib/client-src/45-dag-graph-view.js +287 -0
  17. package/lib/client-src/46-live-logs-modal.js +108 -0
  18. package/lib/client-src/47-analytics-digest-modal.js +166 -0
  19. package/lib/client-src/48-timeline-heatmap-view.js +176 -0
  20. package/lib/client-src/50-cron-screen-state.js +447 -0
  21. package/lib/client-src/52-cron-screen-actions.js +330 -0
  22. package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
  23. package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
  24. package/lib/client-src/55-cron-screen-view.js +378 -0
  25. package/lib/client-src/60-modal-task-form-head.js +580 -0
  26. package/lib/client-src/62-modal-task-form-tail.js +466 -0
  27. package/lib/client-src/65-modal-dialogs.js +350 -0
  28. package/lib/client-src/70-settings-card.js +420 -0
  29. package/lib/client-src/80-sidebar-jobs.js +200 -0
  30. package/lib/client-src/90-close.js +200 -0
  31. package/lib/client.js +48 -3
  32. package/lib/commands.js +200 -0
  33. package/lib/durable-journal.js +1 -188
  34. package/lib/external-api.js +1 -74
  35. package/lib/index.js +17 -0
  36. package/lib/log-streamer.js +10 -2
  37. package/lib/metrics.js +1 -125
  38. package/lib/routes/api-actions.js +317 -0
  39. package/lib/routes/api-collection.js +250 -0
  40. package/lib/routes/api-helpers.js +100 -0
  41. package/lib/routes/api-import-export.js +155 -0
  42. package/lib/routes/api-item.js +191 -0
  43. package/lib/routes/api-task-webhook.js +247 -0
  44. package/lib/routes/api-webhook.js +183 -0
  45. package/lib/routes/api.js +178 -0
  46. package/lib/routes/external-api.js +74 -0
  47. package/lib/routes/index.js +11 -0
  48. package/lib/routes/metrics.js +125 -0
  49. package/lib/routes/routes.js +549 -0
  50. package/lib/routes.js +1 -549
  51. package/lib/runner-external.js +1 -352
  52. package/lib/runner-worktree.js +1 -90
  53. package/lib/runner.js +1 -881
  54. package/lib/runtimes/index.js +4 -0
  55. package/lib/runtimes/runner-external.js +352 -0
  56. package/lib/runtimes/runner-worktree.js +90 -0
  57. package/lib/runtimes/runner.js +881 -0
  58. package/lib/runtimes/runtimes.js +198 -0
  59. package/lib/runtimes.js +1 -198
  60. package/lib/scheduler/index.js +6 -0
  61. package/lib/scheduler/scheduler-assists.js +142 -0
  62. package/lib/scheduler/scheduler-completion.js +417 -0
  63. package/lib/scheduler/scheduler-execution.js +360 -0
  64. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  65. package/lib/scheduler/scheduler-preflight.js +195 -0
  66. package/lib/scheduler/scheduler.js +751 -0
  67. package/lib/scheduler-assists.js +1 -142
  68. package/lib/scheduler-completion.js +1 -417
  69. package/lib/scheduler-execution.js +1 -360
  70. package/lib/scheduler-lifecycle.js +1 -157
  71. package/lib/scheduler-preflight.js +1 -195
  72. package/lib/scheduler.js +1 -751
  73. package/lib/store/artifacts.js +200 -0
  74. package/lib/store/chain-checkpoints.js +223 -0
  75. package/lib/store/durable-journal.js +188 -0
  76. package/lib/store/index.js +6 -0
  77. package/lib/store/store.js +1179 -0
  78. package/lib/store/task-schema.js +180 -0
  79. package/lib/store/task-transfer.js +310 -0
  80. package/lib/store.js +2 -1179
  81. package/lib/task-schema.js +1 -180
  82. package/lib/task-transfer.js +1 -310
  83. package/lib/tui-settings.js +157 -0
  84. package/package.json +6 -3
@@ -1,74 +1 @@
1
- /**
2
- * External REST surface under /dsh-cron/api/* (#54, ADR-0001).
3
- *
4
- * Everything a browser panel needs stays on the local, cross-origin-protected
5
- * routes; this prefix exists for CI and host-side automation, so it is the only
6
- * surface guarded by a bearer token. The token lives in the plugin settings as
7
- * a masked value: an unconfigured token disables the surface (503) instead of
8
- * leaving it open, and a wrong token is answered with 401.
9
- *
10
- * The operations themselves are shared with the panel routes (lib/api.js), so
11
- * the confirmation gate for code-executing tasks and the config-owned refusals
12
- * apply here exactly as they do over HTTP from the UI.
13
- */
14
-
15
- import { timingSafeEqual } from 'node:crypto';
16
- import { sendJson } from './http-utils.js';
17
- import { handleExternalTaskRequest } from './api.js';
18
-
19
- /** Settings key that holds the bearer token. */
20
- export const API_TOKEN_SETTING_KEY = 'apiToken';
21
-
22
- /** Route prefix owned by this surface. */
23
- export const EXTERNAL_API_PREFIX = '/dsh-cron/api';
24
-
25
- /**
26
- * Constant-time token comparison. Length is compared first because
27
- * timingSafeEqual throws on buffers of different sizes; a length mismatch is
28
- * not a secret worth hiding for a fixed-length token.
29
- */
30
- export function tokenMatches(expected, provided) {
31
- if (!expected || !provided) return false;
32
- const a = Buffer.from(String(expected), 'utf8');
33
- const b = Buffer.from(String(provided), 'utf8');
34
- if (a.length !== b.length) return false;
35
- return timingSafeEqual(a, b);
36
- }
37
-
38
- /** Bearer token of a request, or an empty string when there is none. */
39
- export function readBearerToken(req) {
40
- const header = String((req.headers && req.headers.authorization) || '');
41
- const match = header.match(/^Bearer\s+(.+)$/i);
42
- return match ? match[1].trim() : '';
43
- }
44
-
45
- /**
46
- * Guard the external prefix, then hand the request to the shared task
47
- * operations. `getToken` is read per request so a settings change applies
48
- * without a restart.
49
- */
50
- export function createExternalApiHandler({ store, scheduler, getToken }) {
51
- return async function handleExternalApi(req, res) {
52
- const expected = typeof getToken === 'function' ? getToken() : '';
53
- if (!expected) {
54
- sendJson(res, 503, {
55
- ok: false,
56
- error: 'External API is disabled: set the "' + API_TOKEN_SETTING_KEY + '" setting to enable it',
57
- });
58
- return;
59
- }
60
- if (!tokenMatches(expected, readBearerToken(req))) {
61
- sendJson(res, 401, { ok: false, error: 'Unauthorized' });
62
- return;
63
- }
64
- const url = new URL(req.url, 'http://127.0.0.1');
65
- // `url` must be inside the try: a malformed body (an unparseable schedule,
66
- // for example) surfaces as a thrown error from the shared handlers, and an
67
- // unhandled rejection here would answer nothing at all.
68
- try {
69
- await handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken: expected });
70
- } catch (err) {
71
- sendJson(res, (err && err.statusCode) || 500, { ok: false, error: (err && err.message) || String(err) });
72
- }
73
- };
74
- }
1
+ export * from './routes/external-api.js';
package/lib/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { applyTuiSettings, buildTuiSection } from './tui-settings.js';
2
+ import { registerCommands, executeCronCommand } from './commands.js';
3
+ export { applyTuiSettings, buildTuiSection, registerCommands, executeCronCommand };
1
4
  import {
2
5
  TaskSchema,
3
6
  validateTaskRecord,
@@ -376,6 +379,20 @@ export function apply(ctx, rawConfig) {
376
379
  }), 'dsh-cron: /settings');
377
380
 
378
381
  // 2. Tools: unified background automation runner (Issue #196: 9 tools -> 1)
382
+ // TUI settings section (#387)
383
+ applyTuiSettings(ctx, {
384
+ store,
385
+ scheduler,
386
+ live: () => ({
387
+ ...unwrapConfig(getContextConfig(ctx, rawConfig)),
388
+ ...activeVolatileConfig,
389
+ ...store.getSettings(),
390
+ }),
391
+ });
392
+
393
+ // Slash commands (#388)
394
+ registerCommands(ctx, { store, scheduler });
395
+
379
396
  ctx.tools.register(defineTool({
380
397
  name: 'cron',
381
398
  description: 'Background automation runner for DSH: isolated agent runs, script/node/python/http/ssh/docker runtimes, cost guard, notifications, heartbeats. For simple reminders in the current chat, use the built-in schedule_create tool instead. Actions: create, list, get, update, pause, resume, run, delete.',
@@ -50,12 +50,15 @@ export class LogStreamer extends EventEmitter {
50
50
  ts: Date.now(),
51
51
  };
52
52
 
53
+ entry.eventSeq = (entry.eventSeq || 0) + 1;
54
+ item.id = entry.eventSeq;
55
+
53
56
  entry.buffer.push(item);
54
57
  if (entry.buffer.length > MAX_BUFFER_LINES) {
55
58
  entry.buffer.shift();
56
59
  }
57
60
 
58
- const ssePayload = `event: log\ndata: ${JSON.stringify(item)}\n\n`;
61
+ const ssePayload = `id: ${item.id}\nevent: log\ndata: ${JSON.stringify(item)}\n\n`;
59
62
  for (const res of entry.subscribers) {
60
63
  try {
61
64
  res.write(ssePayload);
@@ -111,12 +114,17 @@ export class LogStreamer extends EventEmitter {
111
114
 
112
115
  const entry = this.streams.get(taskId);
113
116
 
117
+ // Support W3C Last-Event-ID for reconnection (#392)
118
+ const lastEventIdStr = req?.headers ? req.headers['last-event-id'] : undefined;
119
+ const lastEventId = lastEventIdStr ? parseInt(lastEventIdStr, 10) : 0;
120
+ const effectiveLogs = (entry?.buffer || []).filter((item) => !lastEventId || (item.id && item.id > lastEventId));
121
+
114
122
  // Initial event with current state and buffered logs
115
123
  const initData = {
116
124
  taskId,
117
125
  runId: entry?.runId || null,
118
126
  status: entry ? (entry.completed ? 'completed' : 'running') : 'idle',
119
- bufferedLogs: entry?.buffer || [],
127
+ bufferedLogs: effectiveLogs,
120
128
  };
121
129
  res.write(`event: init\ndata: ${JSON.stringify(initData)}\n\n`);
122
130
 
package/lib/metrics.js CHANGED
@@ -1,125 +1 @@
1
- import { rejectCrossOrigin } from './http-utils.js';
2
- /**
3
- * Prometheus exposition of the scheduler (#53).
4
- *
5
- * The renderer is hand-rolled on purpose: the profile has no prom-client and
6
- * adding a production dependency for a handful of counters is not justified.
7
- * Only counts, statuses and durations are exported — never prompts, run output
8
- * or task configuration, so the endpoint can be scraped without leaking work
9
- * content.
10
- */
11
-
12
- export const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
13
-
14
- /** Run statuses the scheduler can finish with. */
15
- export const RUN_STATUSES = ['success', 'error', 'timeout', 'skipped', 'missed'];
16
-
17
- const NL = String.fromCharCode(10);
18
- const BACKSLASH = String.fromCharCode(92);
19
- const QUOTE = String.fromCharCode(34);
20
-
21
- /** Escape a label value for the Prometheus text format. */
22
- export function escapeLabel(value) {
23
- return String(value)
24
- .split(BACKSLASH).join(BACKSLASH + BACKSLASH)
25
- .split(QUOTE).join(BACKSLASH + QUOTE)
26
- .split(NL).join(BACKSLASH + 'n');
27
- }
28
-
29
- function sample(name, labels, value) {
30
- const rendered = Object.keys(labels)
31
- .map((key) => key + '=' + QUOTE + escapeLabel(labels[key]) + QUOTE)
32
- .join(',');
33
- return name + (rendered ? '{' + rendered + '}' : '') + ' ' + value;
34
- }
35
-
36
- /**
37
- * Render the current scheduler state as Prometheus text exposition.
38
- * Pure function over plain data so it is testable without a store.
39
- */
40
- export function renderMetrics({ tasks = [], stats = {}, runCounters = {}, scheduler } = {}) {
41
- const lines = [];
42
- const byStatus = {};
43
- for (const task of tasks) {
44
- const status = task.status || 'unknown';
45
- byStatus[status] = (byStatus[status] || 0) + 1;
46
- }
47
-
48
- lines.push('# HELP dsh_cron_tasks_total Scheduled tasks by status.');
49
- // A gauge despite the name: this is a snapshot of how many tasks are in each
50
- // state, not a monotonically growing total.
51
- lines.push('# TYPE dsh_cron_tasks_total gauge');
52
- for (const status of Object.keys(byStatus).sort()) {
53
- lines.push(sample('dsh_cron_tasks_total', { status }, byStatus[status]));
54
- }
55
-
56
- lines.push('# HELP dsh_cron_task_last_duration_seconds Duration of the last finished run of a task.');
57
- lines.push('# TYPE dsh_cron_task_last_duration_seconds gauge');
58
- for (const task of tasks) {
59
- // lastRunAt marks a finished run: a task that never ran still carries
60
- // lastDurationMs = 0, and exporting that would show instant runs in
61
- // monitoring.
62
- if (!Number.isFinite(task.lastDurationMs) || !task.lastRunAt) continue;
63
- lines.push(sample('dsh_cron_task_last_duration_seconds', { task: task.id }, task.lastDurationMs / 1000));
64
- }
65
-
66
- lines.push('# HELP dsh_cron_runs_total Finished runs since the plugin started, by status.');
67
- lines.push('# TYPE dsh_cron_runs_total counter');
68
- for (const status of RUN_STATUSES) {
69
- lines.push(sample('dsh_cron_runs_total', { status }, Number(runCounters[status]) || 0));
70
- }
71
-
72
- lines.push('# HELP dsh_cron_run_records Run records currently kept in memory.');
73
- lines.push('# TYPE dsh_cron_run_records gauge');
74
- lines.push('dsh_cron_run_records ' + (Number(stats.totalRuns) || 0));
75
-
76
- if (scheduler && typeof scheduler.runningCount === 'function') {
77
- lines.push('# HELP dsh_cron_concurrent_running Number of currently executing tasks.');
78
- lines.push('# TYPE dsh_cron_concurrent_running gauge');
79
- lines.push('dsh_cron_concurrent_running ' + (Number(scheduler.runningCount()) || 0));
80
- }
81
-
82
- lines.push('# HELP dsh_cron_task_tokens_total Cumulative tokens consumed by task.');
83
- lines.push('# TYPE dsh_cron_task_tokens_total counter');
84
- for (const task of tasks) {
85
- if (Number(task.totalTokens) > 0) {
86
- lines.push(sample('dsh_cron_task_tokens_total', { task: task.id }, task.totalTokens));
87
- }
88
- }
89
-
90
- lines.push('# HELP dsh_cron_task_cost_usd_total Estimated cumulative LLM cost in USD.');
91
- lines.push('# TYPE dsh_cron_task_cost_usd_total counter');
92
- for (const task of tasks) {
93
- if (Number(task.totalCostUsd) > 0) {
94
- lines.push(sample('dsh_cron_task_cost_usd_total', { task: task.id }, task.totalCostUsd));
95
- }
96
- }
97
-
98
- return lines.join(NL) + NL;
99
- }
100
-
101
- /**
102
- * GET /dsh-cron/metrics — read-only. A scraper is a plain local client, so no
103
- * cross-origin check applies, and nothing sensitive is exposed.
104
- */
105
- export function createMetricsHandler({ store, scheduler }) {
106
- return function handleMetrics(req, res) {
107
- if (req.method !== 'GET' && req.method !== 'HEAD') {
108
- res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
109
- res.end('Method not allowed' + NL);
110
- return;
111
- }
112
- const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
113
- const apiToken = settings.apiToken;
114
- if (rejectCrossOrigin(req, res, { apiToken })) return;
115
-
116
- const body = renderMetrics({
117
- tasks: store.list({ status: 'all' }),
118
- stats: store.getAggregatedStats(),
119
- runCounters: typeof scheduler.getRunCounters === 'function' ? scheduler.getRunCounters() : {},
120
- scheduler,
121
- });
122
- res.writeHead(200, { 'Content-Type': METRICS_CONTENT_TYPE, 'Cache-Control': 'no-store' });
123
- res.end(req.method === 'HEAD' ? undefined : body);
124
- };
125
- }
1
+ export * from './routes/metrics.js';
@@ -0,0 +1,317 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { captureGitDiff, discardGitChanges, applyGitDiff } from '../git-diff.js';
3
+ import { applyDoctorFix, revertDoctorFix, diagnoseFailure } from '../doctor.js';
4
+ import { rollbackPromptVersion } from '../prompt-evolver.js';
5
+ import { approvalManager } from '../approval.js';
6
+ import { MANAGED_BY_CONFIG } from '../config-jobs.js';
7
+ import { sendJson, readBody, redactTaskSecrets } from '../http-utils.js';
8
+ import { buildDuplicateTask } from '../task-transfer.js';
9
+ import { refuseConfigOwned, NOT_FOUND } from './api-collection.js';
10
+
11
+ export async function handleItemPost({ store, scheduler, req, res, url, id, action, subAction }) {
12
+ if (action === 'git-diff' && (subAction === 'discard' || subAction === 'revert')) {
13
+ const task = store.get(id);
14
+ if (!task) {
15
+ sendJson(res, 404, NOT_FOUND);
16
+ return true;
17
+ }
18
+ const targetDir = task.cwd;
19
+ if (!targetDir) {
20
+ sendJson(res, 400, { ok: false, error: 'Task does not define a working directory (cwd)' });
21
+ return true;
22
+ }
23
+ const result = await discardGitChanges(targetDir);
24
+ sendJson(res, result.ok ? 200 : 500, result);
25
+ return true;
26
+ }
27
+
28
+ if (action === 'git-diff' && subAction === 'apply') {
29
+ const task = store.get(id);
30
+ if (!task) {
31
+ sendJson(res, 404, NOT_FOUND);
32
+ return true;
33
+ }
34
+ const body = await readBody(req, res);
35
+ const bodyData = (body && body.body) || {};
36
+ let patch = bodyData.diff || bodyData.patch;
37
+ if (!patch) {
38
+ const runs = store.getHistory(id, 5);
39
+ const targetRun = runs.find((r) => r.gitDiff?.diff);
40
+ patch = targetRun?.gitDiff?.diff;
41
+ }
42
+ if (!patch) {
43
+ sendJson(res, 400, { ok: false, error: 'No diff patch provided or found in run history' });
44
+ return true;
45
+ }
46
+ const targetDir = task.cwd;
47
+ if (!targetDir) {
48
+ sendJson(res, 400, { ok: false, error: 'Task does not define a working directory (cwd)' });
49
+ return true;
50
+ }
51
+ const result = await applyGitDiff(targetDir, patch);
52
+ sendJson(res, result.ok ? 200 : 500, result);
53
+ return true;
54
+ }
55
+
56
+ if ((action === 'doctor' && subAction === 'apply') || action === 'doctor-apply') {
57
+ const task = store.get(id);
58
+ if (!task) {
59
+ sendJson(res, 404, NOT_FOUND);
60
+ return true;
61
+ }
62
+ const body = await readBody(req, res);
63
+ const bodyData = (body && body.body) || {};
64
+ let fix = bodyData.fix || bodyData.doctorReport;
65
+ if (!fix) {
66
+ const runs = store.getHistory(id, 5);
67
+ const reportRun = runs.find((r) => r.doctorReport);
68
+ fix = reportRun?.doctorReport;
69
+ }
70
+ if (!fix || !fix.suggestedFix) {
71
+ sendJson(res, 400, { ok: false, error: 'No doctor fix found to apply' });
72
+ return true;
73
+ }
74
+ try {
75
+ const result = await applyDoctorFix(task, fix, { store, user: bodyData.user || 'operator' });
76
+ if (!result.ok) {
77
+ sendJson(res, 400, { ok: false, error: result.error });
78
+ return true;
79
+ }
80
+ sendJson(res, 200, {
81
+ ok: true,
82
+ taskId: id,
83
+ task: redactTaskSecrets(task),
84
+ revision: result.revision,
85
+ });
86
+ } catch (err) {
87
+ sendJson(res, 500, { ok: false, error: err.message });
88
+ }
89
+ return true;
90
+ }
91
+
92
+ if ((action === 'doctor' && subAction === 'revert') || action === 'doctor-revert') {
93
+ const task = store.get(id);
94
+ if (!task) {
95
+ sendJson(res, 404, NOT_FOUND);
96
+ return true;
97
+ }
98
+ const body = await readBody(req, res);
99
+ const bodyData = (body && body.body) || {};
100
+ try {
101
+ const result = await revertDoctorFix(task, { store, user: bodyData.user || 'operator' });
102
+ if (!result.ok) {
103
+ sendJson(res, 400, { ok: false, error: result.error });
104
+ return true;
105
+ }
106
+ sendJson(res, 200, {
107
+ ok: true,
108
+ taskId: id,
109
+ task: redactTaskSecrets(task),
110
+ revertedCode: result.revertedCode,
111
+ });
112
+ } catch (err) {
113
+ sendJson(res, 500, { ok: false, error: err.message });
114
+ }
115
+ return true;
116
+ }
117
+
118
+ if ((action === 'doctor' && subAction === 'diagnose') || action === 'doctor-diagnose') {
119
+ const task = store.get(id);
120
+ if (!task) {
121
+ sendJson(res, 404, NOT_FOUND);
122
+ return true;
123
+ }
124
+ const body = await readBody(req, res);
125
+ const bodyData = (body && body.body) || {};
126
+ const runId = bodyData.runId;
127
+ const runs = store.getHistory(id, 10);
128
+ const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
129
+ if (!targetRun) {
130
+ sendJson(res, 404, { ok: false, error: 'No run history found to diagnose' });
131
+ return true;
132
+ }
133
+ try {
134
+ const doctorReport = await diagnoseFailure({
135
+ task,
136
+ runInfo: targetRun,
137
+ ask: scheduler?.askModel,
138
+ });
139
+ targetRun.doctorReport = doctorReport;
140
+ sendJson(res, 200, { ok: true, taskId: id, doctorReport });
141
+ } catch (err) {
142
+ sendJson(res, 500, { ok: false, error: err.message });
143
+ }
144
+ return true;
145
+ }
146
+ if (action === 'prompt-rollback') {
147
+ const task = store.get(id);
148
+ if (!task) {
149
+ sendJson(res, 404, NOT_FOUND);
150
+ return true;
151
+ }
152
+ const body = await readBodyJson(req).catch(() => ({}));
153
+ const targetVersion = Number(body.version || url.searchParams.get('version'));
154
+ if (!targetVersion || targetVersion < 1) {
155
+ sendJson(res, 400, { ok: false, error: 'A valid target prompt version is required' });
156
+ return true;
157
+ }
158
+ try {
159
+ rollbackPromptVersion(task, targetVersion);
160
+ store.set(task);
161
+ sendJson(res, 200, {
162
+ ok: true,
163
+ task: redactTaskSecrets(task),
164
+ promptVersion: task.promptVersion,
165
+ promptHistory: task.promptHistory,
166
+ });
167
+ } catch (err) {
168
+ sendJson(res, 400, { ok: false, error: err.message });
169
+ }
170
+ return true;
171
+ }
172
+ if (action === 'retry-delivery') {
173
+ const task = store.get(id);
174
+ if (!task) {
175
+ sendJson(res, 404, NOT_FOUND);
176
+ return true;
177
+ }
178
+ const body = await readBodyJson(req).catch(() => ({}));
179
+ const runId = body.runId || url.searchParams.get('runId');
180
+ const runs = store.history.get(id) || [];
181
+ const run = runId ? runs.find(r => r.id === runId) : runs[0];
182
+ if (!run) {
183
+ sendJson(res, 404, { ok: false, error: 'Run not found' });
184
+ return true;
185
+ }
186
+ const delivery = await scheduler.deliverNotifications(task, run);
187
+ run.delivery = delivery;
188
+ if (Array.isArray(run.stages)) {
189
+ const deliveryStage = run.stages.find(s => s.name === 'delivery');
190
+ if (deliveryStage) {
191
+ deliveryStage.status = delivery?.status || 'delivered';
192
+ deliveryStage.delivered = delivery?.delivered;
193
+ deliveryStage.failures = delivery?.failures;
194
+ }
195
+ }
196
+ store.scheduleSave();
197
+ sendJson(res, 200, { ok: true, delivery, run });
198
+ return true;
199
+ }
200
+ if (action === 'approve' || action === 'reject') {
201
+ const task = store.get(id);
202
+ if (!task) {
203
+ sendJson(res, 404, NOT_FOUND);
204
+ return true;
205
+ }
206
+ const body = await readBodyJson(req).catch(() => ({}));
207
+ const proposalId = body.proposalId || url.searchParams.get('proposalId');
208
+ const proposal = proposalId
209
+ ? approvalManager.getProposal(proposalId)
210
+ : approvalManager.getPendingProposalForTask(id);
211
+
212
+ if (!proposal) {
213
+ sendJson(res, 404, { ok: false, error: 'No pending approval proposal found for this task' });
214
+ return true;
215
+ }
216
+
217
+ const decision = action === 'approve' ? 'approve' : 'reject';
218
+ const result = approvalManager.decide(proposal.id, decision, {
219
+ decidedBy: body.decidedBy || 'web-user',
220
+ reason: body.reason || '',
221
+ });
222
+
223
+ if (!result.ok) {
224
+ sendJson(res, 400, result);
225
+ return true;
226
+ }
227
+
228
+ if (decision === 'approve') {
229
+ scheduler.runTask(id, { approvedBy: body.decidedBy || 'web-user' }).catch(() => {});
230
+ }
231
+
232
+ sendJson(res, 200, { ok: true, proposal: result.proposal });
233
+ return true;
234
+ }
235
+ if (action === 'resolve-uncertain') {
236
+ const task = store.get(id);
237
+ if (!task) {
238
+ sendJson(res, 404, NOT_FOUND);
239
+ return true;
240
+ }
241
+ const body = await readBody(req).then(b => JSON.parse(b || '{}')).catch(() => ({}));
242
+ const runId = body.runId || url.searchParams.get('runId');
243
+ if (!runId) {
244
+ sendJson(res, 400, { ok: false, error: 'runId is required' });
245
+ return true;
246
+ }
247
+ const result = store.durableJournal.resolveUncertain(id, runId, {
248
+ decision: body.decision || 'mark_failed',
249
+ note: body.note || '',
250
+ resolvedBy: body.resolvedBy || 'user',
251
+ });
252
+ if (!result.ok) {
253
+ sendJson(res, 400, result);
254
+ return true;
255
+ }
256
+ sendJson(res, 200, result);
257
+ return true;
258
+ }
259
+ if (action === 'run') {
260
+ if (!store.get(id)) {
261
+ sendJson(res, 404, NOT_FOUND);
262
+ return true;
263
+ }
264
+ await scheduler.triggerManualRun(id);
265
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(store.get(id)) });
266
+ return true;
267
+ }
268
+ if (action === 'duplicate') {
269
+ const source = store.get(id);
270
+ if (!source) {
271
+ sendJson(res, 404, NOT_FOUND);
272
+ return true;
273
+ }
274
+ const copy = buildDuplicateTask(source, { id: randomUUID() });
275
+ try {
276
+ store.set(copy);
277
+ } catch (err) {
278
+ sendJson(res, 500, { ok: false, error: 'Failed to persist duplicate task: ' + err.message });
279
+ return true;
280
+ }
281
+ // A copy starts paused: it must never fire on its own before the user
282
+ // reviews the schedule (a duplicated one-shot may point at a past time).
283
+ scheduler.pauseTask(copy.id);
284
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(copy) });
285
+ return true;
286
+ }
287
+ if (action === 'pause' || action === 'resume') {
288
+ if (store.get(id) && store.get(id).managedBy === MANAGED_BY_CONFIG) {
289
+ refuseConfigOwned(res, id);
290
+ return true;
291
+ }
292
+ const task = action === 'pause' ? scheduler.pauseTask(id) : scheduler.resumeTask(id);
293
+ if (!task) {
294
+ sendJson(res, 404, NOT_FOUND);
295
+ return true;
296
+ }
297
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
298
+ return true;
299
+ }
300
+ if (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle')) {
301
+ const stored = store.get(id);
302
+ if (!stored) {
303
+ sendJson(res, 404, NOT_FOUND);
304
+ return true;
305
+ }
306
+ if (stored.managedBy === MANAGED_BY_CONFIG) {
307
+ refuseConfigOwned(res, id);
308
+ return true;
309
+ }
310
+ const task = scheduler.toggleTask(id);
311
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
312
+ return true;
313
+ }
314
+ return false;
315
+ }
316
+
317
+