@goodandready/dsh-cron 0.3.25 → 0.3.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api-actions.js +1 -317
- package/lib/api-collection.js +1 -250
- package/lib/api-helpers.js +1 -100
- package/lib/api-import-export.js +1 -155
- package/lib/api-item.js +1 -191
- package/lib/api-task-webhook.js +1 -247
- package/lib/api-webhook.js +1 -183
- package/lib/api.js +1 -178
- package/lib/artifacts.js +1 -200
- package/lib/chain-checkpoints.js +1 -223
- package/lib/client-src/00-open.js +62 -0
- package/lib/client-src/10-locales.js +484 -0
- package/lib/client-src/20-styles.js +64 -0
- package/lib/client-src/30-utils.js +248 -0
- package/lib/client-src/40-delivery-settings.js +96 -0
- package/lib/client-src/45-dag-graph-view.js +287 -0
- package/lib/client-src/46-live-logs-modal.js +108 -0
- package/lib/client-src/47-analytics-digest-modal.js +166 -0
- package/lib/client-src/48-timeline-heatmap-view.js +176 -0
- package/lib/client-src/50-cron-screen-state.js +447 -0
- package/lib/client-src/52-cron-screen-actions.js +330 -0
- package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
- package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
- package/lib/client-src/55-cron-screen-view.js +378 -0
- package/lib/client-src/60-modal-task-form-head.js +580 -0
- package/lib/client-src/62-modal-task-form-tail.js +466 -0
- package/lib/client-src/65-modal-dialogs.js +350 -0
- package/lib/client-src/70-settings-card.js +420 -0
- package/lib/client-src/80-sidebar-jobs.js +200 -0
- package/lib/client-src/90-close.js +200 -0
- package/lib/commands.js +200 -0
- package/lib/durable-journal.js +1 -188
- package/lib/external-api.js +1 -74
- package/lib/index.js +17 -0
- package/lib/metrics.js +1 -125
- package/lib/routes/api-actions.js +317 -0
- package/lib/routes/api-collection.js +250 -0
- package/lib/routes/api-helpers.js +100 -0
- package/lib/routes/api-import-export.js +155 -0
- package/lib/routes/api-item.js +191 -0
- package/lib/routes/api-task-webhook.js +247 -0
- package/lib/routes/api-webhook.js +183 -0
- package/lib/routes/api.js +178 -0
- package/lib/routes/external-api.js +74 -0
- package/lib/routes/index.js +11 -0
- package/lib/routes/metrics.js +125 -0
- package/lib/routes/routes.js +549 -0
- package/lib/routes.js +1 -549
- package/lib/runner-external.js +1 -352
- package/lib/runner-worktree.js +1 -90
- package/lib/runner.js +1 -881
- package/lib/runtimes/index.js +4 -0
- package/lib/runtimes/runner-external.js +352 -0
- package/lib/runtimes/runner-worktree.js +90 -0
- package/lib/runtimes/runner.js +881 -0
- package/lib/runtimes/runtimes.js +198 -0
- package/lib/runtimes.js +1 -198
- package/lib/scheduler/index.js +6 -0
- package/lib/scheduler/scheduler-assists.js +142 -0
- package/lib/scheduler/scheduler-completion.js +417 -0
- package/lib/scheduler/scheduler-execution.js +360 -0
- package/lib/scheduler/scheduler-lifecycle.js +157 -0
- package/lib/scheduler/scheduler-preflight.js +195 -0
- package/lib/scheduler/scheduler.js +751 -0
- package/lib/scheduler-assists.js +1 -142
- package/lib/scheduler-completion.js +1 -417
- package/lib/scheduler-execution.js +1 -360
- package/lib/scheduler-lifecycle.js +1 -157
- package/lib/scheduler-preflight.js +1 -195
- package/lib/scheduler.js +1 -751
- package/lib/store/artifacts.js +200 -0
- package/lib/store/chain-checkpoints.js +223 -0
- package/lib/store/durable-journal.js +188 -0
- package/lib/store/index.js +6 -0
- package/lib/store/store.js +1179 -0
- package/lib/store/task-schema.js +180 -0
- package/lib/store/task-transfer.js +310 -0
- package/lib/store.js +2 -1179
- package/lib/task-schema.js +1 -180
- package/lib/task-transfer.js +1 -310
- package/lib/tui-settings.js +157 -0
- package/package.json +2 -1
package/lib/api-import-export.js
CHANGED
|
@@ -1,155 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
buildTaskExport,
|
|
5
|
-
validateImportDocument,
|
|
6
|
-
planImport,
|
|
7
|
-
hasCodeExecutingTask,
|
|
8
|
-
IMPORT_STRATEGIES,
|
|
9
|
-
} from './task-transfer.js';
|
|
10
|
-
import { unknownChannelIds } from './channels.js';
|
|
11
|
-
import {
|
|
12
|
-
sendJson,
|
|
13
|
-
readBody,
|
|
14
|
-
SCRIPT_CONFIRM_HEADER,
|
|
15
|
-
} from './http-utils.js';
|
|
16
|
-
import { MANAGED_BY_CONFIG } from './config-jobs.js';
|
|
17
|
-
|
|
18
|
-
export function handleTaskExport({ store, req, res }) {
|
|
19
|
-
let sanitize = false;
|
|
20
|
-
if (req && req.url) {
|
|
21
|
-
try {
|
|
22
|
-
const parsed = new URL(req.url, 'http://127.0.0.1');
|
|
23
|
-
sanitize = parsed.searchParams.get('sanitize') === 'true' || parsed.searchParams.get('redact') === 'true';
|
|
24
|
-
} catch (urlErr) {
|
|
25
|
-
logger.debug('[dsh-cron] export url parse fallback:', urlErr.message);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
const doc = buildTaskExport(store.list({ status: 'all' }), { sanitize });
|
|
29
|
-
sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length, sanitized: sanitize });
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function handleTaskImport({ store, scheduler, req, res }) {
|
|
33
|
-
const { body, error } = await readBody(req, res);
|
|
34
|
-
if (error) return;
|
|
35
|
-
const strategy = IMPORT_STRATEGIES.includes(body && body.strategy) ? body.strategy : 'skip';
|
|
36
|
-
const checked = validateImportDocument(body && body.document);
|
|
37
|
-
if (!checked.ok) {
|
|
38
|
-
sendJson(res, 400, { ok: false, error: checked.error });
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
// The import file is external input: importing code-executing tasks needs
|
|
42
|
-
// the same explicit confirmation that creating one over HTTP needs,
|
|
43
|
-
// otherwise a hand-edited file would bypass that gate (#86).
|
|
44
|
-
if (hasCodeExecutingTask(checked.tasks) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
45
|
-
sendJson(res, 403, {
|
|
46
|
-
ok: false,
|
|
47
|
-
error: `Importing code-executing tasks over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`,
|
|
48
|
-
});
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
// #121: an imported file may name a channel this build no longer has. The
|
|
52
|
-
// import stays tolerant by design (#42), but the loss is reported instead of
|
|
53
|
-
// being invisible.
|
|
54
|
-
const rawTasks = Array.isArray(body.document && body.document.tasks) ? body.document.tasks : [];
|
|
55
|
-
const droppedChannels = [];
|
|
56
|
-
for (const item of rawTasks) {
|
|
57
|
-
for (const id of unknownChannelIds(item.channels)) {
|
|
58
|
-
if (!droppedChannels.includes(id)) droppedChannels.push(id);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
if (droppedChannels.length) {
|
|
62
|
-
logger.warn('[dsh-cron] import: unknown channel ids were dropped: ' + droppedChannels.join(', '));
|
|
63
|
-
}
|
|
64
|
-
const existingIds = store.list({ status: 'all' }).map((x) => x.id);
|
|
65
|
-
const plan = planImport(checked.tasks, existingIds, strategy);
|
|
66
|
-
// Replacing a task the config owns would be undone at the next start, and
|
|
67
|
-
// the id could then hold two different tasks (#50).
|
|
68
|
-
const configConflicts = plan.replace
|
|
69
|
-
.map((item) => item.id)
|
|
70
|
-
.filter((taskId) => {
|
|
71
|
-
const stored = store.get(taskId);
|
|
72
|
-
return stored && stored.managedBy === MANAGED_BY_CONFIG;
|
|
73
|
-
});
|
|
74
|
-
if (configConflicts.length) {
|
|
75
|
-
sendJson(res, 409, {
|
|
76
|
-
ok: false,
|
|
77
|
-
error: 'These tasks are declared in the profile config and cannot be replaced by an import: ' + configConflicts.join(', '),
|
|
78
|
-
});
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const summary = { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length };
|
|
82
|
-
if (body && body.dryRun) {
|
|
83
|
-
sendJson(res, 200, {
|
|
84
|
-
ok: true,
|
|
85
|
-
dryRun: true,
|
|
86
|
-
strategy: plan.strategy,
|
|
87
|
-
summary,
|
|
88
|
-
codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
|
|
89
|
-
unknownChannels: droppedChannels,
|
|
90
|
-
});
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
const applied = applyImportPlan({ store, scheduler, plan });
|
|
94
|
-
if (applied.error) {
|
|
95
|
-
sendJson(res, 500, { ok: false, error: applied.error });
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
sendJson(res, 200, {
|
|
99
|
-
ok: true,
|
|
100
|
-
imported: applied.imported.length,
|
|
101
|
-
summary,
|
|
102
|
-
unknownChannels: droppedChannels,
|
|
103
|
-
tasks: applied.imported,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Write an import plan. Every imported task is paused (the file's status is
|
|
109
|
-
* ignored) and every touched id is disarmed explicitly, because writing
|
|
110
|
-
* "paused" does not stop an already-armed croner job. A failed write rolls the
|
|
111
|
-
* store back so a partial import cannot survive.
|
|
112
|
-
*/
|
|
113
|
-
export function applyImportPlan({ store, scheduler, plan }) {
|
|
114
|
-
const imported = [];
|
|
115
|
-
const rollback = [];
|
|
116
|
-
const disarm = [];
|
|
117
|
-
try {
|
|
118
|
-
for (const task of plan.add) {
|
|
119
|
-
const before = store.get(task.id);
|
|
120
|
-
const created = store.set({ ...task, id: task.id || randomUUID(), status: 'paused', updatedAt: Date.now() });
|
|
121
|
-
imported.push(created);
|
|
122
|
-
rollback.push({ id: created.id, previous: before || null });
|
|
123
|
-
disarm.push(created.id);
|
|
124
|
-
}
|
|
125
|
-
for (const task of plan.replace) {
|
|
126
|
-
const before = store.get(task.id);
|
|
127
|
-
if (!before) continue;
|
|
128
|
-
const replaced = store.set({ ...task, id: task.id, status: 'paused', updatedAt: Date.now() });
|
|
129
|
-
imported.push(replaced);
|
|
130
|
-
rollback.push({ id: replaced.id, previous: before });
|
|
131
|
-
disarm.push(replaced.id);
|
|
132
|
-
}
|
|
133
|
-
} catch (writeErr) {
|
|
134
|
-
for (const entry of rollback.reverse()) {
|
|
135
|
-
try {
|
|
136
|
-
if (entry.previous) store.set(entry.previous);
|
|
137
|
-
else store.delete(entry.id);
|
|
138
|
-
} catch (rollbackErr) {
|
|
139
|
-
logger.warn(`[dsh-cron] import rollback failed for task "${entry.id}":`, rollbackErr?.message || rollbackErr);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return { imported: [], error: `Import failed and was rolled back: ${writeErr.message}` };
|
|
143
|
-
}
|
|
144
|
-
// Disarm outside the write transaction: pauseTask is idempotent and a
|
|
145
|
-
// failure here must not undo a successful import.
|
|
146
|
-
for (const id of disarm) {
|
|
147
|
-
try {
|
|
148
|
-
scheduler.pauseTask(id);
|
|
149
|
-
} catch (err) {
|
|
150
|
-
logger.error('[dsh-cron] import: could not pause task', id + ':', err.message);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return { imported };
|
|
154
|
-
}
|
|
155
|
-
|
|
1
|
+
export * from './routes/api-import-export.js';
|
package/lib/api-item.js
CHANGED
|
@@ -1,191 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { applyTaskPatch } from './task-patch.js';
|
|
3
|
-
import { MANAGED_BY_CONFIG } from './config-jobs.js';
|
|
4
|
-
import { sendJson, rejectCrossOrigin, readBody, SCRIPT_CONFIRM_HEADER, redactTaskSecrets } from './http-utils.js';
|
|
5
|
-
import { normalizeTaskType } from './runtimes.js';
|
|
6
|
-
import { handleHeartbeatPing, handleDryRunTask } from './api-helpers.js';
|
|
7
|
-
import { handleItemPost } from './api-actions.js';
|
|
8
|
-
import { refuseConfigOwned, NOT_FOUND, NOT_ALLOWED } from './api-collection.js';
|
|
9
|
-
|
|
10
|
-
// ------------------------------------------------------------ single task
|
|
11
|
-
|
|
12
|
-
export async function handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx }) {
|
|
13
|
-
if (action === 'heartbeat') {
|
|
14
|
-
handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
if (req.method === 'POST' && action === 'dry-run') {
|
|
19
|
-
await handleDryRunTask({ store, scheduler, req, res, id, apiToken, ctx });
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (req.method === 'GET') {
|
|
24
|
-
// Both /dsh-cron/tasks/:id/stream and /dsh-cron/tasks/:id/logs/stream (#392)
|
|
25
|
-
if (action === 'stream' || (action === 'logs' && subAction === 'stream')) {
|
|
26
|
-
logStreamer.handleSseRequest(id, req, res);
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
30
|
-
if (action === 'archive') {
|
|
31
|
-
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
32
|
-
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
33
|
-
const search = url.searchParams.get('search') || '';
|
|
34
|
-
const result = typeof store.getArchivedRuns === 'function'
|
|
35
|
-
? store.getArchivedRuns(id, { limit, offset, search })
|
|
36
|
-
: { runs: [], total: 0 };
|
|
37
|
-
sendJson(res, 200, { ok: true, taskId: id, ...result });
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
if (action === 'stats') {
|
|
41
|
-
const stats = typeof store.getTaskStats === 'function' ? store.getTaskStats(id) : null;
|
|
42
|
-
sendJson(res, 200, { ok: true, taskId: id, stats });
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
if (action === 'prompt-history') {
|
|
46
|
-
const task = store.get(id);
|
|
47
|
-
if (!task) {
|
|
48
|
-
sendJson(res, 404, NOT_FOUND);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
sendJson(res, 200, {
|
|
52
|
-
ok: true,
|
|
53
|
-
taskId: id,
|
|
54
|
-
promptVersion: task.promptVersion || 1,
|
|
55
|
-
promptHistory: task.promptHistory || [],
|
|
56
|
-
});
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
if (action === 'history') {
|
|
60
|
-
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
61
|
-
sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (action === 'git-diff') {
|
|
65
|
-
const task = store.get(id);
|
|
66
|
-
if (!task) {
|
|
67
|
-
sendJson(res, 404, NOT_FOUND);
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
const runs = store.getHistory(id, 10);
|
|
71
|
-
const runId = url.searchParams.get('runId') || subAction;
|
|
72
|
-
const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
|
|
73
|
-
const diffData = targetRun?.gitDiff || null;
|
|
74
|
-
sendJson(res, 200, {
|
|
75
|
-
ok: true,
|
|
76
|
-
taskId: id,
|
|
77
|
-
runId: targetRun?.id || null,
|
|
78
|
-
gitDiff: diffData,
|
|
79
|
-
});
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
if (action === 'doctor') {
|
|
83
|
-
const task = store.get(id);
|
|
84
|
-
if (!task) {
|
|
85
|
-
sendJson(res, 404, NOT_FOUND);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
const runs = store.getHistory(id, 5);
|
|
89
|
-
const latestRun = runs[0] || null;
|
|
90
|
-
const reportRun = runs.find((r) => r.doctorReport) || latestRun;
|
|
91
|
-
sendJson(res, 200, {
|
|
92
|
-
ok: true,
|
|
93
|
-
taskId: id,
|
|
94
|
-
doctorReport: reportRun?.doctorReport || null,
|
|
95
|
-
revisions: task.revisions || [],
|
|
96
|
-
previousPrompt: task.previousPrompt || null,
|
|
97
|
-
autoHeal: Boolean(task.autoHeal || task.selfHealing?.autoApply),
|
|
98
|
-
});
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
if (!action) {
|
|
102
|
-
const task = store.get(id);
|
|
103
|
-
if (!task) {
|
|
104
|
-
sendJson(res, 404, NOT_FOUND);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
sendJson(res, 200, {
|
|
108
|
-
ok: true,
|
|
109
|
-
task: {
|
|
110
|
-
...redactTaskSecrets(task),
|
|
111
|
-
running: scheduler.isRunning(task.id),
|
|
112
|
-
runningSince: scheduler.runningSince(task.id),
|
|
113
|
-
},
|
|
114
|
-
});
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (req.method === 'POST') {
|
|
119
|
-
const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
|
|
120
|
-
if (handled) return;
|
|
121
|
-
}
|
|
122
|
-
if (req.method === 'PATCH') {
|
|
123
|
-
await patchTask({ store, scheduler, req, res, id });
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
|
|
127
|
-
deleteTask({ store, scheduler, res, id });
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
sendJson(res, 405, NOT_ALLOWED);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Task actions that are plain POSTs. Returns true when one matched. */
|
|
134
|
-
|
|
135
|
-
export async function patchTask({ store, scheduler, req, res, id }) {
|
|
136
|
-
const current = store.get(id);
|
|
137
|
-
if (!current) {
|
|
138
|
-
sendJson(res, 404, NOT_FOUND);
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
if (current.managedBy === MANAGED_BY_CONFIG) {
|
|
142
|
-
refuseConfigOwned(res, id);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
const { body, error } = await readBody(req, res);
|
|
146
|
-
if (error) return;
|
|
147
|
-
const result = applyTaskPatch({
|
|
148
|
-
store,
|
|
149
|
-
scheduler,
|
|
150
|
-
id,
|
|
151
|
-
body,
|
|
152
|
-
allowCodeSwitch: req.headers[SCRIPT_CONFIRM_HEADER] === 'script',
|
|
153
|
-
});
|
|
154
|
-
if (!result.ok) {
|
|
155
|
-
const status = result.status || (result.notFound ? 404 : (result.needsConfirmation ? 403 : 400));
|
|
156
|
-
const error = result.needsConfirmation
|
|
157
|
-
? `Switching a task to the ${normalizeTaskType(body.type)} type over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`
|
|
158
|
-
: result.error;
|
|
159
|
-
sendJson(res, status, {
|
|
160
|
-
ok: false,
|
|
161
|
-
error,
|
|
162
|
-
...(result.unknownChannels ? { unknownChannels: result.unknownChannels } : {}),
|
|
163
|
-
});
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
sendJson(res, 200, { ok: true, task: redactTaskSecrets(result.task) });
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
export function deleteTask({ store, scheduler, res, id }) {
|
|
171
|
-
const current = store.get(id);
|
|
172
|
-
if (current && current.managedBy === MANAGED_BY_CONFIG) {
|
|
173
|
-
refuseConfigOwned(res, id);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
if (current) {
|
|
177
|
-
if (scheduler && typeof scheduler.removeTask === 'function') {
|
|
178
|
-
scheduler.removeTask(id);
|
|
179
|
-
} else if (scheduler) {
|
|
180
|
-
scheduler.pauseTask(id);
|
|
181
|
-
}
|
|
182
|
-
try {
|
|
183
|
-
store.delete(id);
|
|
184
|
-
} catch (err) {
|
|
185
|
-
sendJson(res, 500, { ok: false, error: 'Failed to delete task: ' + err.message });
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
sendJson(res, 200, { ok: true });
|
|
190
|
-
}
|
|
191
|
-
|
|
1
|
+
export * from './routes/api-item.js';
|
package/lib/api-task-webhook.js
CHANGED
|
@@ -1,247 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* lib/api-task-webhook.js — Incoming HTTP Webhook Triggers (#348).
|
|
3
|
-
*
|
|
4
|
-
* Implements POST /dsh-cron/webhook/:taskId and POST /dsh-cron/api/webhook/:taskId.
|
|
5
|
-
* Authenticates via HMAC SHA-256 signature (GitHub/Stripe style), per-task token,
|
|
6
|
-
* or master apiToken, applies debouncing, parses JSON/text payload, and triggers
|
|
7
|
-
* the task with variable interpolation.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import crypto from 'node:crypto';
|
|
11
|
-
import { logger } from './logger.js';
|
|
12
|
-
import { sendJson } from './http-utils.js';
|
|
13
|
-
|
|
14
|
-
const MAX_WEBHOOK_BYTES = 512 * 1024; // 512 KiB limit
|
|
15
|
-
const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
|
|
16
|
-
const lastWebhookRuns = new Map();
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Constant-time string/buffer comparison.
|
|
20
|
-
*/
|
|
21
|
-
export function safeCompare(a, b) {
|
|
22
|
-
if (!a || !b) return false;
|
|
23
|
-
const bufA = Buffer.from(String(a), 'utf8');
|
|
24
|
-
const bufB = Buffer.from(String(b), 'utf8');
|
|
25
|
-
if (bufA.length !== bufB.length) return false;
|
|
26
|
-
return crypto.timingSafeEqual(bufA, bufB);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Verify HMAC SHA-256 signature from headers.
|
|
31
|
-
* Supports x-hub-signature-256 (sha256=...), x-signature-256, or x-dsh-cron-signature.
|
|
32
|
-
*/
|
|
33
|
-
export function verifyHmacSignature(rawBuffer, secret, signatureHeader) {
|
|
34
|
-
if (!secret || !signatureHeader) return false;
|
|
35
|
-
const expectedHex = crypto.createHmac('sha256', secret).update(rawBuffer).digest('hex');
|
|
36
|
-
const provided = String(signatureHeader).replace(/^sha256=/i, '').trim();
|
|
37
|
-
return safeCompare(expectedHex, provided);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Verify token from header or Bearer auth.
|
|
42
|
-
* Query parameter authentication is strictly rejected to prevent token leakage in proxy logs and browser history (#358).
|
|
43
|
-
*/
|
|
44
|
-
export function verifyToken(expectedToken, req, url) {
|
|
45
|
-
if (!expectedToken) return false;
|
|
46
|
-
const headers = req?.headers || {};
|
|
47
|
-
const headerToken = headers['x-dsh-cron-webhook-token'] || headers['x-webhook-token'];
|
|
48
|
-
const authHeader = headers['authorization'];
|
|
49
|
-
let bearerToken = '';
|
|
50
|
-
if (authHeader && /^Bearer\s+/i.test(authHeader)) {
|
|
51
|
-
bearerToken = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
52
|
-
}
|
|
53
|
-
const candidate = headerToken || bearerToken;
|
|
54
|
-
if (!candidate) return false;
|
|
55
|
-
return safeCompare(expectedToken, candidate);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Read request body with raw Buffer preserved for signature verification.
|
|
60
|
-
*/
|
|
61
|
-
export function readRawBody(req, limit = MAX_WEBHOOK_BYTES) {
|
|
62
|
-
return new Promise((resolve, reject) => {
|
|
63
|
-
const chunks = [];
|
|
64
|
-
let bytes = 0;
|
|
65
|
-
let settled = false;
|
|
66
|
-
|
|
67
|
-
const fail = (err) => {
|
|
68
|
-
if (settled) return;
|
|
69
|
-
settled = true;
|
|
70
|
-
reject(err);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
req.on('data', (chunk) => {
|
|
74
|
-
if (settled) return;
|
|
75
|
-
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
76
|
-
bytes += buf.length;
|
|
77
|
-
if (bytes > limit) {
|
|
78
|
-
fail(Object.assign(new Error('Webhook payload exceeds 512 KiB limit'), { statusCode: 413 }));
|
|
79
|
-
req.destroy();
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
chunks.push(buf);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
req.on('end', () => {
|
|
86
|
-
if (settled) return;
|
|
87
|
-
settled = true;
|
|
88
|
-
const raw = chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0);
|
|
89
|
-
let parsed = null;
|
|
90
|
-
const text = raw.toString('utf8').trim();
|
|
91
|
-
if (text) {
|
|
92
|
-
try {
|
|
93
|
-
parsed = JSON.parse(text);
|
|
94
|
-
} catch {
|
|
95
|
-
parsed = text;
|
|
96
|
-
}
|
|
97
|
-
} else {
|
|
98
|
-
parsed = {};
|
|
99
|
-
}
|
|
100
|
-
resolve({ raw, parsed });
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
req.on('error', (err) => {
|
|
104
|
-
fail(Object.assign(err, { statusCode: 400 }));
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Handle incoming webhook request for a task.
|
|
111
|
-
*/
|
|
112
|
-
export async function handleTaskWebhookRoute({ store, scheduler, req, res, taskId: explicitTaskId }) {
|
|
113
|
-
if (req.method !== 'POST') {
|
|
114
|
-
sendJson(res, 405, NOT_ALLOWED);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
119
|
-
let taskId = explicitTaskId;
|
|
120
|
-
if (!taskId) {
|
|
121
|
-
const parts = url.pathname.split('/').filter(Boolean);
|
|
122
|
-
// /dsh-cron/webhook/:taskId or /dsh-cron/api/webhook/:taskId
|
|
123
|
-
const webhookIdx = parts.indexOf('webhook');
|
|
124
|
-
if (webhookIdx !== -1 && parts[webhookIdx + 1]) {
|
|
125
|
-
taskId = parts[webhookIdx + 1];
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (!taskId) {
|
|
130
|
-
sendJson(res, 400, { ok: false, error: 'Task ID is required in webhook URL path' });
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const task = store.get(taskId);
|
|
135
|
-
if (!task) {
|
|
136
|
-
sendJson(res, 404, { ok: false, error: `Task "${taskId}" not found` });
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (task.status !== 'active') {
|
|
141
|
-
sendJson(res, 400, { ok: false, error: `Task "${taskId}" is not active (current status: ${task.status})` });
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const isEnabled = Boolean(
|
|
146
|
-
task.webhookEnabled ||
|
|
147
|
-
(task.webhookTrigger && task.webhookTrigger.enabled)
|
|
148
|
-
);
|
|
149
|
-
if (!isEnabled) {
|
|
150
|
-
logger.warn(`[dsh-cron] Webhook received for task "${taskId}" but webhooks are not enabled for this task`);
|
|
151
|
-
sendJson(res, 403, { ok: false, error: `Webhooks are not enabled for task "${taskId}"` });
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Read raw body
|
|
156
|
-
let bodyData;
|
|
157
|
-
try {
|
|
158
|
-
bodyData = await readRawBody(req);
|
|
159
|
-
} catch (err) {
|
|
160
|
-
sendJson(res, err.statusCode || 400, { ok: false, error: err.message });
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const { raw, parsed } = bodyData;
|
|
165
|
-
const headers = req.headers || {};
|
|
166
|
-
const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
167
|
-
|
|
168
|
-
// Authentication check (fail-closed)
|
|
169
|
-
const taskSecret = task.webhookSecret || (task.webhookTrigger && task.webhookTrigger.secret);
|
|
170
|
-
const taskToken = task.webhookToken || (task.webhookTrigger && task.webhookTrigger.token);
|
|
171
|
-
const masterToken = settings.apiToken;
|
|
172
|
-
|
|
173
|
-
let authenticated = false;
|
|
174
|
-
|
|
175
|
-
if (taskSecret) {
|
|
176
|
-
const sigHeader = headers['x-hub-signature-256'] || headers['x-signature-256'] || headers['x-dsh-cron-signature'];
|
|
177
|
-
if (sigHeader && verifyHmacSignature(raw, taskSecret, sigHeader)) {
|
|
178
|
-
authenticated = true;
|
|
179
|
-
} else {
|
|
180
|
-
logger.warn(`[dsh-cron] Invalid HMAC signature for webhook task "${taskId}"`);
|
|
181
|
-
sendJson(res, 401, { ok: false, error: 'Invalid HMAC signature' });
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
} else if (taskToken) {
|
|
185
|
-
if (verifyToken(taskToken, req, url)) {
|
|
186
|
-
authenticated = true;
|
|
187
|
-
} else {
|
|
188
|
-
logger.warn(`[dsh-cron] Invalid webhook token for task "${taskId}"`);
|
|
189
|
-
sendJson(res, 401, { ok: false, error: 'Invalid webhook token' });
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
} else if (masterToken) {
|
|
193
|
-
if (verifyToken(masterToken, req, url)) {
|
|
194
|
-
authenticated = true;
|
|
195
|
-
} else {
|
|
196
|
-
logger.warn(`[dsh-cron] Invalid API token for webhook task "${taskId}"`);
|
|
197
|
-
sendJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid token' });
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
} else {
|
|
201
|
-
logger.warn(`[dsh-cron] Webhook task "${taskId}" rejected: no secret or token configured (fail-closed)`);
|
|
202
|
-
sendJson(res, 403, { ok: false, error: 'Webhook secret or token must be configured for security' });
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Debounce check
|
|
207
|
-
const debounceMs = Math.max(0, Number(task.webhookDebounceMs || (task.webhookTrigger && task.webhookTrigger.debounceMs) || 0));
|
|
208
|
-
if (debounceMs > 0) {
|
|
209
|
-
const now = Date.now();
|
|
210
|
-
const last = lastWebhookRuns.get(taskId) || 0;
|
|
211
|
-
if (now - last < debounceMs) {
|
|
212
|
-
logger.info(`[dsh-cron] Webhook for task "${taskId}" debounced (${now - last}ms < ${debounceMs}ms)`);
|
|
213
|
-
sendJson(res, 200, { ok: true, debounced: true, message: `Webhook debounced (${debounceMs}ms cooldown)` });
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
lastWebhookRuns.set(taskId, now);
|
|
217
|
-
} else {
|
|
218
|
-
lastWebhookRuns.set(taskId, Date.now());
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const query = Object.fromEntries(url.searchParams.entries());
|
|
222
|
-
const webhookPayload = {
|
|
223
|
-
body: parsed,
|
|
224
|
-
raw: raw.toString('utf8'),
|
|
225
|
-
headers,
|
|
226
|
-
query,
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
logger.info(`[dsh-cron] Webhook accepted for task "${taskId}", triggering execution`);
|
|
230
|
-
|
|
231
|
-
try {
|
|
232
|
-
const runPromise = scheduler.triggerManualRun(taskId, {
|
|
233
|
-
webhook: webhookPayload,
|
|
234
|
-
triggeredBy: 'webhook',
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
sendJson(res, 200, {
|
|
238
|
-
ok: true,
|
|
239
|
-
triggered: true,
|
|
240
|
-
taskId,
|
|
241
|
-
message: `Webhook received and task "${task.title}" triggered`,
|
|
242
|
-
});
|
|
243
|
-
} catch (runErr) {
|
|
244
|
-
logger.error(`[dsh-cron] Failed to trigger task "${taskId}" from webhook: ${runErr.message}`);
|
|
245
|
-
sendJson(res, 500, { ok: false, error: `Failed to trigger task: ${runErr.message}` });
|
|
246
|
-
}
|
|
247
|
-
}
|
|
1
|
+
export * from './routes/api-task-webhook.js';
|