@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
@@ -0,0 +1,549 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { handleTaskWebhookRoute } from './api-task-webhook.js';
3
+ import { calculateCostHeatmap } from '../timeline.js';
4
+ import { buildAnalyticsDigest, formatDigestMarkdown, sendAnalyticsDigest } from '../analytics-digest.js';
5
+ import { listVerifiedRecipes } from '../recipes-catalog.js';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { createCronApiHandler } from './api.js';
8
+ import { createMetricsHandler } from './metrics.js';
9
+ import { createExternalApiHandler, EXTERNAL_API_PREFIX } from './external-api.js';
10
+ import { getModelsHandler } from '../models-handler.js';
11
+ import { chatStartHandler } from '../chat-start.js';
12
+ import { sendJson, rejectCrossOrigin, readBody, parseJsonBody } from '../http-utils.js';
13
+ import { recipesByCategory } from '../recipes.js';
14
+ import { sendTelegramMessage, createTelegramTaskKeyboard } from '../telegram.js';
15
+ import { createKanbanCard } from '../integrations.js';
16
+ import { sanitizeSettingsPayload, applySettingsToScope } from '../settings.js';
17
+ import { buildTaskGraphModel } from '../dag.js';
18
+
19
+
20
+ const CLIENT_CSS = readFileSync(new URL('../client.css', import.meta.url), 'utf8');
21
+ const LOCALES_JSON = readFileSync(new URL('../locales.json', import.meta.url), 'utf8');
22
+
23
+
24
+ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
25
+ // Monthly history archives collection route (#391)
26
+ ctx.effect(() => ctx.webServer.register({
27
+ kind: 'exact',
28
+ path: '/dsh-cron/history/archives',
29
+ handler: (req, res) => {
30
+ if (req.method !== 'GET') {
31
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
32
+ res.end('GET only');
33
+ return;
34
+ }
35
+ res.writeHead(200, {
36
+ 'content-type': 'application/json; charset=utf-8',
37
+ 'cache-control': 'no-store',
38
+ });
39
+ const archives = typeof store?.listMonthlyArchives === 'function' ? store.listMonthlyArchives() : [];
40
+ res.end(JSON.stringify({ ok: true, archives }));
41
+ }
42
+ }), 'dsh-cron: /history/archives');
43
+
44
+ // Specific monthly history archive route (#391)
45
+ ctx.effect(() => ctx.webServer.register({
46
+ kind: 'prefix',
47
+ path: '/dsh-cron/history/archive/',
48
+ handler: (req, res) => {
49
+ if (req.method !== 'GET') {
50
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
51
+ res.end('GET only');
52
+ return;
53
+ }
54
+ const url = new URL(req.url, 'http://127.0.0.1');
55
+ const month = url.pathname.replace(/^\/dsh-cron\/history\/archive\//, '').replace(/\/$/, '');
56
+ if (!/^\d{4}-\d{2}$/.test(month)) {
57
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' });
58
+ res.end(JSON.stringify({ ok: false, error: 'Invalid month format, expected YYYY-MM' }));
59
+ return;
60
+ }
61
+ const archive = typeof store?.getMonthlyArchive === 'function' ? store.getMonthlyArchive(month) : null;
62
+ if (!archive) {
63
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
64
+ res.end(JSON.stringify({ ok: false, error: `Archive for month "${month}" not found` }));
65
+ return;
66
+ }
67
+ res.writeHead(200, {
68
+ 'content-type': 'application/json; charset=utf-8',
69
+ 'cache-control': 'public, max-age=3600',
70
+ });
71
+ res.end(JSON.stringify({ ok: true, month, archive }));
72
+ }
73
+ }), 'dsh-cron: /history/archive/:month');
74
+
75
+ // Health probe route (#384)
76
+ ctx.effect(() => ctx.webServer.register({
77
+ kind: 'exact',
78
+ path: '/dsh-cron/health',
79
+ handler: (req, res) => {
80
+ if (req.method !== 'GET') {
81
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
82
+ res.end('GET only');
83
+ return;
84
+ }
85
+ res.writeHead(200, {
86
+ 'content-type': 'application/json; charset=utf-8',
87
+ 'cache-control': 'no-store'
88
+ });
89
+ const tasks = typeof store?.list === 'function' ? store.list().length : 0;
90
+ res.end(JSON.stringify({
91
+ status: 'ok',
92
+ plugin: '@goodandready/dsh-cron',
93
+ tasks,
94
+ uptimeSec: Math.floor(process.uptime()),
95
+ }));
96
+ }
97
+ }), 'dsh-cron: /health');
98
+
99
+ // Client locales route (#374)
100
+ ctx.effect(() => ctx.webServer.register({
101
+ kind: 'exact',
102
+ path: '/dsh-cron/locales.json',
103
+ handler: (req, res) => {
104
+ if (req.method !== 'GET') {
105
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
106
+ res.end('GET only');
107
+ return;
108
+ }
109
+ res.writeHead(200, {
110
+ 'content-type': 'application/json; charset=utf-8',
111
+ 'cache-control': 'public, max-age=300'
112
+ });
113
+ res.end(LOCALES_JSON);
114
+ }
115
+ }), 'dsh-cron: /locales.json');
116
+
117
+ // Client CSS stylesheet route (#374)
118
+ ctx.effect(() => ctx.webServer.register({
119
+ kind: 'exact',
120
+ path: '/dsh-cron/client.css',
121
+ handler: (req, res) => {
122
+ if (req.method !== 'GET') {
123
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
124
+ res.end('GET only');
125
+ return;
126
+ }
127
+ res.writeHead(200, {
128
+ 'content-type': 'text/css; charset=utf-8',
129
+ 'cache-control': 'public, max-age=300'
130
+ });
131
+ res.end(CLIENT_CSS);
132
+ }
133
+ }), 'dsh-cron: /client.css');
134
+
135
+ // handler per route family; registered WITHOUT the trailing slash (#96)
136
+ ctx.effect(() => ctx.webServer.register({
137
+ kind: 'prefix',
138
+ path: '/dsh-cron/tasks',
139
+ handler: createCronApiHandler(store, scheduler, { recommendations, ctx })
140
+ }), 'dsh-cron: /tasks[...]');
141
+
142
+ // Alias route /dsh-cron/action/:id/:action for backward compatibility (#66)
143
+ ctx.effect(() => ctx.webServer.register({
144
+ kind: 'prefix',
145
+ path: '/dsh-cron/action',
146
+ handler: createCronApiHandler(store, scheduler, { ctx })
147
+ }), 'dsh-cron: /action/:id/:action');
148
+
149
+ ctx.effect(() => ctx.webServer.register({
150
+ kind: 'prefix',
151
+ path: '/dsh-cron/telegram',
152
+ handler: createCronApiHandler(store, scheduler, { ctx })
153
+ }), 'dsh-cron: /telegram[...]');
154
+
155
+ // Incoming HTTP Webhook triggers (#348)
156
+ ctx.effect(() => ctx.webServer.register({
157
+ kind: 'prefix',
158
+ path: '/dsh-cron/webhook',
159
+ handler: (req, res) => handleTaskWebhookRoute({ store, scheduler, req, res })
160
+ }), 'dsh-cron: /webhook[...]');
161
+
162
+ ctx.effect(() => ctx.webServer.register({
163
+ kind: 'prefix',
164
+ path: '/dsh-cron/heartbeat-ping',
165
+ handler: createCronApiHandler(store, scheduler, { ctx })
166
+ }), 'dsh-cron: /heartbeat-ping[...]');
167
+
168
+ ctx.effect(() => ctx.webServer.register({
169
+ kind: 'prefix',
170
+ path: '/dsh-cron/schedule',
171
+ handler: createCronApiHandler(store, scheduler, { ctx })
172
+ }), 'dsh-cron: /schedule[...]');
173
+
174
+
175
+ // Chain retry from failed step (#319)
176
+ ctx.effect(() => ctx.webServer.register({
177
+ kind: 'prefix',
178
+ path: '/dsh-cron/chains',
179
+ handler: (req, res) => handleChainRoutes(store, scheduler, req, res, ctx)
180
+ }), 'dsh-cron: /chains[...]');
181
+
182
+ // Run artifacts listing, download and safe preview (#321)
183
+ ctx.effect(() => ctx.webServer.register({
184
+ kind: 'prefix',
185
+ path: '/dsh-cron/runs',
186
+ handler: (req, res) => handleRunArtifactRoutes(store, req, res, ctx)
187
+ }), 'dsh-cron: /runs[...]');
188
+
189
+ // GET /dsh-cron/metrics (#53) — Prometheus text exposition
190
+ ctx.effect(() => ctx.webServer.register({
191
+ kind: 'exact',
192
+ path: '/dsh-cron/metrics',
193
+ handler: createMetricsHandler({ store, scheduler })
194
+ }), 'dsh-cron: /metrics');
195
+
196
+ // External REST API under /dsh-cron/api/* (#54, ADR-0001): the only surface
197
+ // guarded by a bearer token, so CI and host automation can drive the
198
+ // scheduler without a browser session. The token is read per request, so a
199
+ // settings change applies without a restart.
200
+ ctx.effect(() => ctx.webServer.register({
201
+ kind: 'prefix',
202
+ path: EXTERNAL_API_PREFIX,
203
+ handler: createExternalApiHandler({
204
+ store,
205
+ scheduler,
206
+ getToken: () => store.getSettings().apiToken || ''
207
+ })
208
+ }), 'dsh-cron: /api[...]');
209
+
210
+ // GET /dsh-cron/models
211
+ ctx.effect(() => ctx.webServer.register({
212
+ kind: 'exact',
213
+ path: '/dsh-cron/models',
214
+ handler: async (req, res) => {
215
+ const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
216
+ const apiToken = settings.apiToken;
217
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
218
+ await getModelsHandler(ctx, req, res, sendJson);
219
+ }
220
+ }), 'dsh-cron: /models');
221
+
222
+ // POST /dsh-cron/chat/start
223
+ ctx.effect(() => ctx.webServer.register({
224
+ kind: 'exact',
225
+ path: '/dsh-cron/chat/start',
226
+ handler: async (req, res) => {
227
+ if (req.method === 'POST' && rejectCrossOrigin(req, res, { ctx })) return;
228
+ await chatStartHandler(ctx, req, res, parseJsonBody, sendJson, randomUUID);
229
+ }
230
+ }), 'dsh-cron: /chat/start');
231
+
232
+ // Analytics digest routes (#330)
233
+ ctx.effect(() => ctx.webServer.register({
234
+ kind: 'prefix',
235
+ path: '/dsh-cron/analytics',
236
+ handler: async (req, res) => {
237
+ try {
238
+ const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
239
+ const apiToken = settings.apiToken;
240
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
241
+
242
+ const url = new URL(req.url, 'http://127.0.0.1');
243
+ const path = url.pathname;
244
+
245
+ if (req.method === 'GET') {
246
+ if (path === '/dsh-cron/analytics/heatmap') {
247
+ const modelFilter = url.searchParams.get('model') || null;
248
+ const providerFilter = url.searchParams.get('provider') || null;
249
+ const costLedger = store.costLedger || [];
250
+ let history = [];
251
+ if (store.history) {
252
+ if (typeof store.history.values === 'function') {
253
+ for (const runs of store.history.values()) {
254
+ if (Array.isArray(runs)) history.push(...runs);
255
+ }
256
+ } else if (typeof store.history === 'object') {
257
+ for (const runs of Object.values(store.history)) {
258
+ if (Array.isArray(runs)) history.push(...runs);
259
+ }
260
+ }
261
+ }
262
+ const heatmap = calculateCostHeatmap({ costLedger, history, modelFilter, providerFilter });
263
+ sendJson(res, 200, { ok: true, heatmap });
264
+ return;
265
+ }
266
+ const days = parseInt(url.searchParams.get('days') || '7', 10);
267
+ const tasks = store.list ? store.list() : Object.values(store.tasks || {});
268
+ let history = [];
269
+ if (store.history) {
270
+ if (typeof store.history.values === 'function') {
271
+ for (const runs of store.history.values()) {
272
+ if (Array.isArray(runs)) history.push(...runs);
273
+ }
274
+ } else if (typeof store.history === 'object') {
275
+ for (const runs of Object.values(store.history)) {
276
+ if (Array.isArray(runs)) history.push(...runs);
277
+ }
278
+ }
279
+ }
280
+ const costLedger = store.costLedger || [];
281
+ const digest = buildAnalyticsDigest({ tasks, history, costLedger, periodDays: days });
282
+ const markdown = formatDigestMarkdown(digest);
283
+ sendJson(res, 200, { ok: true, digest, markdown });
284
+ return;
285
+ }
286
+
287
+ if (req.method === 'POST') {
288
+ if (rejectCrossOrigin(req, res, { ctx })) return;
289
+ const { body } = await readBody(req, res);
290
+ const days = parseInt(body?.days || url.searchParams.get('days') || '7', 10);
291
+ const tasks = store.list ? store.list() : Object.values(store.tasks || {});
292
+ let history = [];
293
+ if (store.history) {
294
+ if (typeof store.history.values === 'function') {
295
+ for (const runs of store.history.values()) {
296
+ if (Array.isArray(runs)) history.push(...runs);
297
+ }
298
+ } else if (typeof store.history === 'object') {
299
+ for (const runs of Object.values(store.history)) {
300
+ if (Array.isArray(runs)) history.push(...runs);
301
+ }
302
+ }
303
+ }
304
+ const costLedger = store.costLedger || [];
305
+ const digest = buildAnalyticsDigest({ tasks, history, costLedger, periodDays: days });
306
+ const settings = store.getSettings ? store.getSettings() : {};
307
+ const deliveryConfig = {
308
+ telegram: body?.telegram !== false && Boolean(settings.botToken && settings.chatId),
309
+ webhookUrl: body?.webhookUrl || settings.webhookUrl,
310
+ };
311
+ const channels = {
312
+ sendTelegram: async (text) => sendTelegramMessage({ botToken: settings.botToken, chatId: settings.chatId, text }),
313
+ sendWebhook: async (targetUrl, payload) => {
314
+ const r = await fetch(targetUrl, {
315
+ method: 'POST',
316
+ headers: { 'Content-Type': 'application/json' },
317
+ body: JSON.stringify(payload),
318
+ });
319
+ return { ok: r.ok, status: r.status };
320
+ },
321
+ };
322
+ const delivery = await sendAnalyticsDigest({ digest, channels, deliveryConfig });
323
+ sendJson(res, 200, { ok: true, digest, delivery });
324
+ return;
325
+ }
326
+
327
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
328
+ } catch (err) {
329
+ sendJson(res, 500, { ok: false, error: err.message });
330
+ }
331
+ }
332
+ }), 'dsh-cron: /analytics');
333
+
334
+ // GET /dsh-cron/recipes (#48): the built-in recipe catalog.
335
+ ctx.effect(() => ctx.webServer.register({
336
+ kind: 'exact',
337
+ path: '/dsh-cron/recipes',
338
+ handler: async (req, res) => {
339
+ try {
340
+ if ((req.method || 'GET') !== 'GET') {
341
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
342
+ return;
343
+ }
344
+ const settings = store.getSettings ? store.getSettings() : {};
345
+ const apiToken = settings.apiToken;
346
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
347
+ const categories = recipesByCategory();
348
+ const recipes = listVerifiedRecipes(settings);
349
+ sendJson(res, 200, { ok: true, categories, recipes });
350
+ } catch (err) {
351
+ sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
352
+ }
353
+ }
354
+ }), 'dsh-cron: /recipes');
355
+
356
+ // GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
357
+ ctx.effect(() => ctx.webServer.register({
358
+ kind: 'exact',
359
+ path: '/dsh-cron/heartbeat',
360
+ handler: async (req, res) => {
361
+ try {
362
+ if ((req.method || 'GET') !== 'GET') {
363
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
364
+ return;
365
+ }
366
+ const settings = store.getSettings ? store.getSettings() : {};
367
+ const apiToken = settings.apiToken;
368
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
369
+
370
+ const tasks = store.list({ status: 'active' });
371
+ const lastRuns = tasks.map((t) => t.lastRunAt || 0);
372
+ sendJson(res, 200, {
373
+ ok: true,
374
+ activeTasks: tasks.length,
375
+ running: scheduler.runningCount(),
376
+ lastRunAt: lastRuns.length ? Math.max(...lastRuns) : null,
377
+ serverTime: Date.now(),
378
+ });
379
+ } catch (err) {
380
+ sendJson(res, 500, { ok: false, error: err.message });
381
+ }
382
+ }
383
+ }), 'dsh-cron: /heartbeat');
384
+
385
+ // POST /dsh-cron/telegram/test
386
+ ctx.effect(() => ctx.webServer.register({
387
+ kind: 'exact',
388
+ path: '/dsh-cron/telegram/test',
389
+ handler: async (req, res) => {
390
+ try {
391
+ if (req.method !== 'POST') {
392
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
393
+ return;
394
+ }
395
+ if (rejectCrossOrigin(req, res, { ctx })) return;
396
+ const { body, error } = await readBody(req, res);
397
+ if (error) return;
398
+ const settings = store.getSettings();
399
+ const botToken = body.botToken || settings.botToken;
400
+ const chatId = body.chatId || settings.chatId;
401
+
402
+ if (!botToken || !chatId) {
403
+ sendJson(res, 400, { ok: false, error: 'botToken or chatId is not configured' });
404
+ return;
405
+ }
406
+
407
+ const text = `🔔 *dsh-cron test notification*\n\nConnection between DSH Cron and Telegram is working! Checked at: \`${new Date().toISOString()}\``;
408
+ const replyMarkup = createTelegramTaskKeyboard(undefined, undefined);
409
+ await sendTelegramMessage({ botToken, chatId, text, replyMarkup });
410
+ sendJson(res, 200, { ok: true, message: 'Test message sent successfully' });
411
+ } catch (err) {
412
+ sendJson(res, 500, { ok: false, error: err.message });
413
+ }
414
+ }
415
+ }), 'dsh-cron: /telegram/test');
416
+
417
+ // POST /dsh-cron/kanban/test
418
+ ctx.effect(() => ctx.webServer.register({
419
+ kind: 'exact',
420
+ path: '/dsh-cron/kanban/test',
421
+ handler: async (req, res) => {
422
+ try {
423
+ if (req.method !== 'POST') {
424
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
425
+ return;
426
+ }
427
+ if (rejectCrossOrigin(req, res, { ctx })) return;
428
+ const { body, error } = await readBody(req, res);
429
+ if (error) return;
430
+ const settings = store.getSettings();
431
+ const kanbanBaseUrl = body.kanbanBaseUrl || settings.kanbanBaseUrl || 'http://127.0.0.1:3000';
432
+
433
+ const result = await createKanbanCard({
434
+ title: '[Test] dsh-cron -> Kanban connectivity check',
435
+ body: `Test card created automatically by the \`dsh-cron\` module at ${new Date().toISOString()}.`,
436
+ board: 'main',
437
+ column: 'backlog',
438
+ labels: ['cron', 'test'],
439
+ kanbanBaseUrl
440
+ });
441
+
442
+ if (result.success) {
443
+ sendJson(res, 200, { ok: true, task: result.task });
444
+ } else {
445
+ sendJson(res, 500, { ok: false, error: result.error });
446
+ }
447
+ } catch (err) {
448
+ sendJson(res, 500, { ok: false, error: err.message });
449
+ }
450
+ }
451
+ }), 'dsh-cron: /kanban/test');
452
+ }
453
+
454
+
455
+ /**
456
+ * Handler for chain step retries (#319)
457
+ */
458
+ export async function handleChainRoutes(store, scheduler, req, res, ctx) {
459
+ const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
460
+ const apiToken = settings.apiToken;
461
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
462
+ const url = new URL(req.url, 'http://localhost');
463
+
464
+ // GET /dsh-cron/chains/:chainRunId (#334)
465
+ const getMatch = url.pathname.match(/^\/dsh-cron\/chains\/([^\/]+)$/);
466
+ if (getMatch && req.method === 'GET') {
467
+ const chainRunId = getMatch[1];
468
+ const snapshots = store.chainCheckpointManager ? store.chainCheckpointManager.getChainSnapshots(chainRunId) : [];
469
+ const relevantTasks = [];
470
+ const stepIds = new Set(snapshots.map((s) => s.stepTaskId));
471
+ if (store.tasks) {
472
+ for (const t of Object.values(store.tasks)) {
473
+ if (t && (stepIds.has(t.id) || t.onSuccess || t.onFailure || t.dependsOn)) {
474
+ relevantTasks.push(t);
475
+ }
476
+ }
477
+ }
478
+ const graph = buildTaskGraphModel(relevantTasks, snapshots);
479
+ sendJson(res, 200, { ok: true, chainRunId, snapshots, graph });
480
+ return;
481
+ }
482
+
483
+ // POST /dsh-cron/chains/:chainRunId/retry-from/:stepTaskId (#319, #328)
484
+ const m = url.pathname.match(/^\/dsh-cron\/chains\/([^\/]+)\/retry-from\/([^\/]+)$/);
485
+ if (!m) {
486
+ sendJson(res, 404, { ok: false, error: 'Route not found' });
487
+ return;
488
+ }
489
+ if (req.method !== 'POST') {
490
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
491
+ return;
492
+ }
493
+ const [, chainRunId, stepTaskId] = m;
494
+ const result = await store.chainCheckpointManager.retryChainFromStep(scheduler, chainRunId, stepTaskId);
495
+ if (!result.ok) {
496
+ sendJson(res, 400, result);
497
+ return;
498
+ }
499
+ sendJson(res, 200, result);
500
+ }
501
+
502
+ /**
503
+ * Handler for run artifacts (#321)
504
+ */
505
+ export async function handleRunArtifactRoutes(store, req, res, ctx) {
506
+ const url = new URL(req.url, 'http://localhost');
507
+ const m = url.pathname.match(/^\/dsh-cron\/runs\/([^\/]+)\/artifacts(?:\/([^\/]+)(?:\/(preview))?)?$/);
508
+ if (!m) {
509
+ sendJson(res, 404, { ok: false, error: 'Route not found' });
510
+ return;
511
+ }
512
+ if (req.method !== 'GET') {
513
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
514
+ return;
515
+ }
516
+ const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
517
+ const apiToken = settings.apiToken;
518
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
519
+
520
+ const [, runId, artifactId, action] = m;
521
+ if (!artifactId) {
522
+ const artifacts = store.getArtifactsForRun(runId);
523
+ sendJson(res, 200, { ok: true, runId, artifacts });
524
+ return;
525
+ }
526
+ const artifacts = store.getArtifactsForRun(runId);
527
+ const record = artifacts.find((a) => a.id === artifactId);
528
+ if (!record) {
529
+ sendJson(res, 404, { ok: false, error: 'Artifact record not found' });
530
+ return;
531
+ }
532
+ if (action === 'preview') {
533
+ const preview = store.artifactManager.generatePreview(artifactId, record.name);
534
+ sendJson(res, preview.ok ? 200 : 404, preview);
535
+ return;
536
+ }
537
+ const buf = store.artifactManager.readArtifact(artifactId, record.name);
538
+ if (!buf) {
539
+ sendJson(res, 404, { ok: false, error: 'Artifact file missing from disk (expired or removed)' });
540
+ return;
541
+ }
542
+ res.writeHead(200, {
543
+ 'Content-Type': record.mimeType || 'application/octet-stream',
544
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(record.name)}"`,
545
+ 'Content-Length': buf.length,
546
+ 'X-Content-Type-Options': 'nosniff',
547
+ });
548
+ res.end(buf);
549
+ }