@goodandready/dsh-cron 0.2.19 → 0.2.21

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/client.js CHANGED
@@ -11,6 +11,9 @@ window.__ModuleLoader__.load({
11
11
  // namespace registered on the server ('dsh-cron', Issue #85).
12
12
  const PLUGIN_ID = '@goodandready/dsh-cron';
13
13
  const NS = 'dsh-cron';
14
+ // Plugins page row seat (DSH 0.1.6-alpha.2): key = '<package name>#<row id>'.
15
+ const ROW_ID = 'dsh-cron';
16
+ const ROW_CONFIG_KEY = PLUGIN_ID + '#' + ROW_ID;
14
17
  const PANEL = 'cron';
15
18
  const ACTIVATE_EVENT = 'dsh-panel-activate';
16
19
  const ACTIVE_ATTR = 'data-dsh-cron-active';
@@ -3529,17 +3532,24 @@ window.__ModuleLoader__.load({
3529
3532
  );
3530
3533
  }
3531
3534
 
3532
- return React.createElement('div', { className: 'dsh-cron-card' },
3535
+ // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
3536
+ // padding, so the summary is a one-liner and the page drops our card chrome.
3537
+ if (props && props.view === 'summary') {
3538
+ return React.createElement('span', { className: 'dsh-cron-card-desc' }, T_KEY(t, 'settings.cardDesc'));
3539
+ }
3540
+ const page = !!(props && props.view === 'page');
3541
+ return React.createElement('div', { className: page ? 'dsh-cron-page' : 'dsh-cron-card' },
3533
3542
  React.createElement('button', {
3534
3543
  type: 'button',
3535
3544
  className: 'dsh-cron-card-head-btn',
3536
- 'aria-expanded': cardOpen ? 'true' : 'false',
3545
+ style: page ? { display: 'none' } : undefined,
3546
+ 'aria-expanded': (page || cardOpen) ? 'true' : 'false',
3537
3547
  onClick: () => setCardOpen(!cardOpen)
3538
3548
  },
3539
3549
  React.createElement('span', { className: 'dsh-cron-card-title' }, T_KEY(t, 'settings.cardTitle')),
3540
3550
  React.createElement('span', { className: 'dsh-cron-chev' + (cardOpen ? ' dsh-cron-chev-open' : '') }, chevronNode())
3541
3551
  ),
3542
- cardOpen && React.createElement('div', { className: 'dsh-cron-card-body' },
3552
+ (page || cardOpen) && React.createElement('div', { className: 'dsh-cron-card-body' },
3543
3553
  React.createElement('div', { className: 'dsh-cron-card-desc' }, T_KEY(t, 'settings.cardDesc')),
3544
3554
  React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' } },
3545
3555
  React.createElement('button', {
@@ -4042,6 +4052,29 @@ window.__ModuleLoader__.load({
4042
4052
  function SettingsCardWithBoundary(props) {
4043
4053
  return React.createElement(ErrorBoundary, null, CronSettingsCard(props));
4044
4054
  }
4055
+ // List seat (plugins.item): the seat the Plugins page renders as the plugin's own
4056
+ // page with its configuration. The label is a static string on purpose — it is
4057
+ // resolved while the page renders, and a locale lookup there would take the whole
4058
+ // client batch down with it.
4059
+ const itemOk = registerIntoMount(ctx, 'plugins.item', {
4060
+ name: 'plugins.item',
4061
+ id: ROW_ID,
4062
+ order: 60,
4063
+ label: () => 'Cron',
4064
+ locale: NS,
4065
+ inject: () => ({ ctx, toggle }),
4066
+ }, SettingsCardWithBoundary);
4067
+ if (!itemOk) {
4068
+ console.warn('[dsh-cron] plugins.item slot unavailable — plugin page card not registered');
4069
+ }
4070
+ // Row seat and the legacy settings.plugin.item seat stay as fallbacks
4071
+ // (#102: still no top-level section fallback).
4072
+ const rowOk = registerIntoMount(ctx, 'plugins.row.config', {
4073
+ name: 'plugins.row.config',
4074
+ key: ROW_CONFIG_KEY,
4075
+ locale: NS,
4076
+ inject: () => ({ ctx, toggle }),
4077
+ }, SettingsCardWithBoundary);
4045
4078
  const cardOk = registerIntoMount(ctx, 'settings.plugin.item', {
4046
4079
  name: 'settings.plugin.item',
4047
4080
  key: NS,
package/lib/index.js CHANGED
@@ -69,188 +69,26 @@ import { applyConfigSync, isConfigOwned, configOwnedMessage } from './config-job
69
69
  import { createExternalApiHandler, EXTERNAL_API_PREFIX } from './external-api.js';
70
70
  export { createCronApiHandler };
71
71
 
72
- export const name = '@goodandready/dsh-cron';
73
- export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
74
-
75
- export const Config = z.object({
76
- botToken: z.string().role('secret').default('').description('Legacy Telegram Bot API token (prefer botTokenRef with the DSH credentials service)'),
77
- botTokenRef: z.string().default('').description('Name of the DSH credential that holds the Telegram bot token'),
78
- chatId: z.string().default('').description('Telegram chat ID that receives task reports'),
79
- notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
80
- onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
81
- kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API'),
82
- defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)'),
83
- maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
84
- heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
85
- heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)'),
86
- // --- Delivery channels (#20-#23, #26, #28, #47) ---
87
- template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
88
- channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
89
- silentRuleModel: z.string().default('').description('Model used to evaluate a task silent rule (empty = the task model)'),
90
- inspectorModel: z.string().default('').description('Model used to diagnose failed runs (empty = the task model)'),
91
- deliveryTimeoutMs: z.number().min(MIN_DELIVERY_TIMEOUT_MS).default(15000).description('Per-channel delivery timeout in ms (minimum 1000); a slower endpoint is recorded as a failure and does not block other channels or the next tick'),
92
- discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
93
- slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
94
- ntfyUrl: z.string().default('https://ntfy.sh').description('ntfy server base URL'),
95
- ntfyTopic: z.string().default('').description('ntfy topic for push notifications'),
96
- ntfyTokenRef: z.string().default('').description('Credential name for the ntfy access token'),
97
- barkServerUrl: z.string().default('https://api.day.app').description('Bark server base URL'),
98
- barkKey: z.string().default('').description('Bark device key'),
99
- pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token'),
100
- pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)'),
101
- ttsBaseUrl: z.string().default('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route'),
102
- giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
103
- giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
104
- giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
105
- prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)'),
106
- // --- Declarative jobs (#50, ADR-0001) ---
107
- // The array is intentionally permissive: each entry is validated on its own
108
- // with a precise message (see lib/config-jobs.js), so one broken entry cannot
109
- // reject the whole profile config. Declared jobs are owned by the config:
110
- // they are created or updated at startup, and removed when they disappear
111
- // from the file. Tasks created in the UI, over the API or by an agent tool
112
- // are never touched.
113
- jobs: z.array(z.any()).default([]).description('Static jobs owned by the config; each entry needs id, title, schedule and (for agent types) prompt'),
114
- // --- External REST API (#54, ADR-0001) ---
115
- apiToken: z.string().role('secret').default('').description('Bearer token for the external /dsh-cron/api/* surface (empty = the surface answers 503)'),
116
- });
117
-
118
-
119
- export const SETTINGS_SYNC_KEYS = [
120
- 'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
121
- 'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
122
- 'discordWebhookUrl', 'slackWebhookUrl',
123
- 'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
124
- 'barkServerUrl', 'barkKey',
125
- 'pushplusTokenRef',
126
- 'pushplusUrl',
127
- 'ttsBaseUrl',
128
- 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'prReviewerEnabled',
129
- 'apiToken',
130
- ];
72
+ import {
73
+ Config,
74
+ SETTINGS_SYNC_KEYS,
75
+ applySettingsToScope,
76
+ sanitizeSettingsPayload,
77
+ } from './settings.js';
78
+ import { registerRoutes } from './routes.js';
131
79
 
132
- /**
133
- * Apply settings writes through the registered settings scope so the core
134
- * snapshot stays authoritative (#102). Returns null when the settings
135
- * service is unavailable (headless profiles fall back to store-only writes).
136
- */
137
- export async function applySettingsToScope(settingsScope, body) {
138
- if (!settingsScope || typeof settingsScope.set !== 'function') return null;
139
- const errors = [];
140
- for (const key of SETTINGS_SYNC_KEYS) {
141
- if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
142
- try {
143
- await settingsScope.set(key, body[key]);
144
- } catch (e) {
145
- errors.push(key + ': ' + ((e && e.message) || e));
146
- }
147
- }
148
- return errors;
149
- }
80
+ export {
81
+ Config,
82
+ SETTINGS_SYNC_KEYS,
83
+ applySettingsToScope,
84
+ sanitizeSettingsPayload,
85
+ };
150
86
 
151
- /**
152
- * Drop values that must never re-enter the store from the client:
153
- *
154
- * - a masked secret echoed back by the settings UI would overwrite the real
155
- * value (bot token, webhook URLs, Bark key);
156
- * - the raw credential keys are refused outright — the *Ref fields are the
157
- * supported way to configure them.
158
- *
159
- * Applied before both the settings scope and the store, because the scope is
160
- * the source of truth and would otherwise persist a raw or masked value.
161
- */
162
- export function sanitizeSettingsPayload(body) {
163
- const payload = Object.assign({}, body);
164
- for (const key of TaskStore.MASKED_SETTING_KEYS) {
165
- const val = payload[key];
166
- if (typeof val === 'string' && (val.includes('••') || val.includes('****'))) {
167
- delete payload[key];
168
- }
169
- }
170
- for (const key of ['botTokenRef', 'barkKeyRef', 'giteaTokenRef']) {
171
- if (key in payload && typeof payload[key] === 'string' && payload[key].trim()) {
172
- const refCheck = credentialRefStatus(payload[key]);
173
- if (!refCheck.ok) {
174
- console.warn(`[dsh-cron] invalid credential reference in "${key}": ${refCheck.error}`);
175
- delete payload[key];
176
- }
177
- }
178
- }
179
- for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
180
- if (key in payload) {
181
- console.warn(`[dsh-cron] refusing raw secret "${key}" from the client — use a credential reference`);
182
- delete payload[key];
183
- }
184
- }
185
- // #115: normalise the delivery deadline here, before it reaches the settings
186
- // scope, so a value like 1 ms can never be persisted and then fail every
187
- // delivery before the request is even sent.
188
- if ('deliveryTimeoutMs' in payload) {
189
- const raw = payload.deliveryTimeoutMs;
190
- const n = Number(raw);
191
- if (raw === '' || raw === null || !Number.isFinite(n) || n <= 0) {
192
- delete payload.deliveryTimeoutMs;
193
- } else {
194
- payload.deliveryTimeoutMs = resolveDeliveryTimeoutMs(n);
195
- }
196
- }
197
- return payload;
198
- }
87
+ export const name = '@goodandready/dsh-cron';
88
+ export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
199
89
 
200
- export function executeCreateTask(store, scheduler, args) {
201
- const taskType = normalizeTaskType(args.type);
202
- const typeError = validateTaskType(taskType, args);
203
- if (typeError) throw new Error(typeError);
204
- const parsed = parseScheduleExpression(args.schedule);
205
- const task = store.set({
206
- title: args.title,
207
- schedule: parsed.cronPattern || args.schedule,
208
- scheduleText: parsed.humanText,
209
- prompt: args.prompt,
210
- type: taskType,
211
- delivery: args.delivery || 'isolated',
212
- status: 'active',
213
- provider: args.provider,
214
- model: args.model,
215
- notifyTelegram: Boolean(args.notifyTelegram),
216
- onlyOnFailure: Boolean(args.onlyOnFailure),
217
- timeoutSeconds: Number(args.timeoutSeconds) || 1800,
218
- overlapPolicy: args.overlapPolicy || 'skip',
219
- timezone: args.timezone ? String(args.timezone).trim() : '',
220
- misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
221
- maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
222
- retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
223
- permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
224
- kanbanMode: args.kanbanMode || 'none',
225
- channels: Array.isArray(args.channels) ? args.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined,
226
- template: args.template ? String(args.template) : '',
227
- env: args.env && typeof args.env === 'object' ? args.env : undefined,
228
- cwd: args.cwd ? String(args.cwd).trim() : '',
229
- workspaceId: args.workspaceId ? String(args.workspaceId).trim() : '',
230
- worktree: Boolean(args.worktree),
231
- keepWorktree: Boolean(args.keepWorktree),
232
- httpMethod: args.httpMethod ? String(args.httpMethod).toUpperCase() : 'GET',
233
- httpUrl: args.httpUrl ? String(args.httpUrl).trim() : '',
234
- httpHeaders: args.httpHeaders,
235
- httpBody: args.httpBody !== undefined ? String(args.httpBody) : '',
236
- sshProfileId: args.sshProfileId ? String(args.sshProfileId).trim() : '',
237
- sshTarget: args.sshTarget ? String(args.sshTarget).trim() : '',
238
- sshPort: Number(args.sshPort) || 0,
239
- sshKeyPath: args.sshKeyPath ? String(args.sshKeyPath).trim() : '',
240
- dockerImage: args.dockerImage ? String(args.dockerImage).trim() : '',
241
- pythonPath: args.pythonPath ? String(args.pythonPath).trim() : '',
242
- nodePath: args.nodePath ? String(args.nodePath).trim() : '',
243
- skillName: args.skillName ? String(args.skillName).trim() : '',
244
- workflowName: args.workflowName ? String(args.workflowName).trim() : '',
245
- oneShot: Boolean(parsed.isOneShot),
246
- });
247
- scheduler.scheduleTask(task);
248
- return {
249
- success: true,
250
- message: `Task "${task.title}" scheduled successfully (${task.scheduleText})`,
251
- task
252
- };
253
- }
90
+ import { executeCreateTask } from './task-create.js';
91
+ export { executeCreateTask };
254
92
 
255
93
  export const createTaskParameters = {
256
94
  title: { type: 'string', description: 'Short task name', required: true },
@@ -397,79 +235,15 @@ export function apply(ctx, config) {
397
235
  }), 'dsh-cron: /api/dsh-cron/update');
398
236
 
399
237
  // 1. REST API — collection (list/create) + item actions in one prefix
400
- // handler per route family; registered WITHOUT the trailing slash (#96)
401
- ctx.effect(() => ctx.webServer.register({
402
- kind: 'prefix',
403
- path: '/dsh-cron/tasks',
404
- handler: createCronApiHandler(store, scheduler, { recommendations })
405
- }), 'dsh-cron: /tasks[...]');
406
-
407
- // Alias route /dsh-cron/action/:id/:action for backward compatibility (#66)
408
- ctx.effect(() => ctx.webServer.register({
409
- kind: 'prefix',
410
- path: '/dsh-cron/action',
411
- handler: createCronApiHandler(store, scheduler, null)
412
- }), 'dsh-cron: /action/:id/:action');
413
-
414
- ctx.effect(() => ctx.webServer.register({
415
- kind: 'prefix',
416
- path: '/dsh-cron/telegram',
417
- handler: createCronApiHandler(store, scheduler, null)
418
- }), 'dsh-cron: /telegram[...]');
419
-
420
- ctx.effect(() => ctx.webServer.register({
421
- kind: 'prefix',
422
- path: '/dsh-cron/heartbeat-ping',
423
- handler: createCronApiHandler(store, scheduler, null)
424
- }), 'dsh-cron: /heartbeat-ping[...]');
425
-
426
- ctx.effect(() => ctx.webServer.register({
427
- kind: 'prefix',
428
- path: '/dsh-cron/schedule',
429
- handler: createCronApiHandler(store, scheduler, null)
430
- }), 'dsh-cron: /schedule[...]');
431
-
432
- // GET /dsh-cron/metrics (#53) — Prometheus text exposition
433
- ctx.effect(() => ctx.webServer.register({
434
- kind: 'exact',
435
- path: '/dsh-cron/metrics',
436
- handler: createMetricsHandler({ store, scheduler })
437
- }), 'dsh-cron: /metrics');
438
-
439
- // External REST API under /dsh-cron/api/* (#54, ADR-0001): the only surface
440
- // guarded by a bearer token, so CI and host automation can drive the
441
- // scheduler without a browser session. The token is read per request, so a
442
- // settings change applies without a restart.
443
- ctx.effect(() => ctx.webServer.register({
444
- kind: 'prefix',
445
- path: EXTERNAL_API_PREFIX,
446
- handler: createExternalApiHandler({
447
- store,
448
- scheduler,
449
- getToken: () => store.getSettings().apiToken || ''
450
- })
451
- }), 'dsh-cron: /api[...]');
452
-
453
- // GET /dsh-cron/models
454
- ctx.effect(() => ctx.webServer.register({
455
- kind: 'exact',
456
- path: '/dsh-cron/models',
457
- handler: async (req, res) => {
458
- await getModelsHandler(ctx, req, res, sendJson);
459
- }
460
- }), 'dsh-cron: /models');
461
-
462
- // POST /dsh-cron/chat/start
463
- ctx.effect(() => ctx.webServer.register({
464
- kind: 'exact',
465
- path: '/dsh-cron/chat/start',
466
- handler: async (req, res) => {
467
- if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
468
- await chatStartHandler(ctx, req, res, parseJsonBody, sendJson, randomUUID);
469
- }
470
- }), 'dsh-cron: /chat/start');
238
+ // 1. Web server routes (extracted to ./routes.js)
239
+ registerRoutes(ctx, {
240
+ store,
241
+ scheduler,
242
+ getCronSettingsScope: () => cronSettingsScope,
243
+ recommendations,
244
+ });
471
245
 
472
- // GET & POST /dsh-cron/settings
246
+ // GET & POST /dsh-cron/settings (#102)
473
247
  ctx.effect(() => ctx.webServer.register({
474
248
  kind: 'exact',
475
249
  path: '/dsh-cron/settings',
@@ -483,8 +257,6 @@ export function apply(ctx, config) {
483
257
  if (rejectCrossOrigin(req, res)) return;
484
258
  const { body, error } = await readBody(req, res);
485
259
  if (error) return;
486
- // Masked secrets echoed back by the client must never overwrite the
487
- // stored token (store.saveSettings guards this too, #86-era).
488
260
  const payload = sanitizeSettingsPayload(body);
489
261
  const scopeErrors = await applySettingsToScope(cronSettingsScope, payload);
490
262
  if (scopeErrors === null) {
@@ -500,114 +272,6 @@ export function apply(ctx, config) {
500
272
  }
501
273
  }), 'dsh-cron: /settings');
502
274
 
503
- // GET /dsh-cron/recipes (#48): the built-in recipe catalog.
504
- ctx.effect(() => ctx.webServer.register({
505
- kind: 'exact',
506
- path: '/dsh-cron/recipes',
507
- handler: async (req, res) => {
508
- try {
509
- if (req.method !== 'GET') {
510
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
511
- return;
512
- }
513
- const categories = recipesByCategory();
514
- const recipes = categories.reduce((acc, category) => acc.concat(category.recipes), []);
515
- sendJson(res, 200, { ok: true, categories, recipes });
516
- } catch (err) {
517
- sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
518
- }
519
- }
520
- }), 'dsh-cron: /recipes');
521
-
522
- // GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
523
- ctx.effect(() => ctx.webServer.register({
524
- kind: 'exact',
525
- path: '/dsh-cron/heartbeat',
526
- handler: async (req, res) => {
527
- try {
528
- const tasks = store.list({ status: 'active' });
529
- const lastRuns = tasks.map((t) => t.lastRunAt || 0);
530
- sendJson(res, 200, {
531
- ok: true,
532
- activeTasks: tasks.length,
533
- running: scheduler.runningCount(),
534
- lastRunAt: lastRuns.length ? Math.max(...lastRuns) : null,
535
- serverTime: Date.now(),
536
- });
537
- } catch (err) {
538
- sendJson(res, 500, { ok: false, error: err.message });
539
- }
540
- }
541
- }), 'dsh-cron: /heartbeat');
542
-
543
- // POST /dsh-cron/telegram/test
544
- ctx.effect(() => ctx.webServer.register({
545
- kind: 'exact',
546
- path: '/dsh-cron/telegram/test',
547
- handler: async (req, res) => {
548
- try {
549
- if (req.method !== 'POST') {
550
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
551
- return;
552
- }
553
- if (rejectCrossOrigin(req, res)) return;
554
- const { body, error } = await readBody(req, res);
555
- if (error) return;
556
- const settings = store.getSettings();
557
- const botToken = body.botToken || settings.botToken;
558
- const chatId = body.chatId || settings.chatId;
559
-
560
- if (!botToken || !chatId) {
561
- sendJson(res, 400, { ok: false, error: 'botToken or chatId is not configured' });
562
- return;
563
- }
564
-
565
- const text = `🔔 *dsh-cron test notification*\n\nConnection between DSH Cron and Telegram is working! Checked at: \`${new Date().toISOString()}\``;
566
- const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
567
- await sendTelegramMessage({ botToken, chatId, text, replyMarkup });
568
- sendJson(res, 200, { ok: true, message: 'Test message sent successfully' });
569
- } catch (err) {
570
- sendJson(res, 500, { ok: false, error: err.message });
571
- }
572
- }
573
- }), 'dsh-cron: /telegram/test');
574
-
575
- // POST /dsh-cron/kanban/test
576
- ctx.effect(() => ctx.webServer.register({
577
- kind: 'exact',
578
- path: '/dsh-cron/kanban/test',
579
- handler: async (req, res) => {
580
- try {
581
- if (req.method !== 'POST') {
582
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
583
- return;
584
- }
585
- if (rejectCrossOrigin(req, res)) return;
586
- const { body, error } = await readBody(req, res);
587
- if (error) return;
588
- const settings = store.getSettings();
589
- const kanbanBaseUrl = body.kanbanBaseUrl || settings.kanbanBaseUrl || 'http://127.0.0.1:3000';
590
-
591
- const result = await createKanbanCard({
592
- title: '[Test] dsh-cron -> Kanban connectivity check',
593
- body: `Test card created automatically by the \`dsh-cron\` module at ${new Date().toISOString()}.`,
594
- board: 'main',
595
- column: 'backlog',
596
- labels: ['cron', 'test'],
597
- kanbanBaseUrl
598
- });
599
-
600
- if (result.success) {
601
- sendJson(res, 200, { ok: true, task: result.task });
602
- } else {
603
- sendJson(res, 500, { ok: false, error: result.error });
604
- }
605
- } catch (err) {
606
- sendJson(res, 500, { ok: false, error: err.message });
607
- }
608
- }
609
- }), 'dsh-cron: /kanban/test');
610
-
611
275
  // 2. Tools
612
276
  ctx.tools.register(defineTool({
613
277
  name: 'cron_create_task',