@goodandready/dsh-cron 0.2.40 → 0.2.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.js CHANGED
@@ -256,8 +256,8 @@ function buildTaskRecord(store, body, taskType, parsed) {
256
256
  model: body.model || undefined,
257
257
  fallbackProvider: body.fallbackProvider || undefined,
258
258
  fallbackModel: body.fallbackModel || undefined,
259
- notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
260
- onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
259
+ notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : undefined),
260
+ onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : undefined),
261
261
  timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
262
262
  overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
263
263
  kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
package/lib/channels.js CHANGED
@@ -18,11 +18,17 @@ export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'b
18
18
  export const isFailureStatus = (status) => status === 'error' || status === 'timeout' || status === 'missed';
19
19
  export const isFailed = (status) => isFailureStatus(status);
20
20
 
21
- /** The message text for a channel: custom template, else built-in defaults. */
22
- function messageTextFor(channelId, task, runInfo, settings = {}) {
21
+ /** The message text for a channel: task template -> channel template -> global template -> built-in defaults (#233). */
22
+ export function messageTextFor(channelId, task, runInfo, settings = {}) {
23
23
  const templates = (settings && settings.channelTemplates) || {};
24
- const template = templates[channelId] || settings.template || '';
25
- return resolveTemplateText({ template, task, runInfo });
24
+ const custom = (task && task.template) || templates[channelId] || (settings && settings.template) || '';
25
+ if (custom) {
26
+ return resolveTemplateText({ template: custom, task, runInfo });
27
+ }
28
+ if (channelId === 'telegram') {
29
+ return formatTaskTelegramMessage(task, runInfo);
30
+ }
31
+ return resolveTemplateText({ template: '', task, runInfo });
26
32
  }
27
33
 
28
34
  /**
@@ -56,17 +62,27 @@ export function unknownChannels(task) {
56
62
  return unknownChannelIds(task && task.channels);
57
63
  }
58
64
 
59
- /** Failure/only-on-failure filtering, per channel. */
65
+ /** Failure/only-on-failure filtering, per channel (#219). Explicit task channels win over legacy flags. */
60
66
  export function shouldSendToChannel(channelId, task, runInfo, settings = {}) {
61
- if (channelId === 'kanban') return shouldCreateKanbanCard(task, runInfo);
62
- if (channelId === 'telegram') {
63
- if (task && task.notifyTelegram === false) return false;
64
- const taskForTelegram = Object.assign({}, task, { notifyTelegram: true });
65
- return shouldNotifyTask(taskForTelegram, runInfo, settings);
67
+ const isExplicit = Array.isArray(task && task.channels) && task.channels.includes(channelId);
68
+ if (!isExplicit) {
69
+ if (channelId === 'kanban') return shouldCreateKanbanCard(task, runInfo);
70
+ if (channelId === 'telegram') {
71
+ if (task && task.notifyTelegram === false) return false;
72
+ const taskForTelegram = Object.assign({}, task, { notifyTelegram: true });
73
+ return shouldNotifyTask(taskForTelegram, runInfo, settings);
74
+ }
75
+ }
76
+
77
+ // Explicit selection (or default event filtering for all channels):
78
+ if (channelId === 'kanban') {
79
+ if (task && task.kanbanMode === 'on_failure') return isFailed(runInfo.status);
80
+ if (task && task.kanbanMode === 'always') return true;
66
81
  }
67
- const onlyOnFail = task.onlyOnFailure !== undefined
82
+
83
+ const onlyOnFail = (task && task.onlyOnFailure !== undefined)
68
84
  ? Boolean(task.onlyOnFailure)
69
- : Boolean(settings.onlyOnFailure);
85
+ : Boolean(settings && settings.onlyOnFailure);
70
86
  if (onlyOnFail && !isFailed(runInfo.status)) return false;
71
87
  return true;
72
88
  }
@@ -133,8 +149,34 @@ export function buildPushplusRequest({ settings = {}, text, task, token }) {
133
149
  };
134
150
  }
135
151
 
136
- export function buildGiteaIssuePayload({ task, runInfo }) {
152
+ export function buildGiteaIssuePayload({ task, runInfo, settings = {} }) {
137
153
  const failed = isFailed(runInfo.status);
154
+ const defaultNames = failed ? ['cron', 'bug', 'alert'] : ['cron', 'auto'];
155
+
156
+ // Resolve labels to integer IDs per Gitea Swagger (array<int64>) (#224)
157
+ const mapping = settings.giteaLabelIds || {};
158
+ let candidateIds = [];
159
+
160
+ if (Array.isArray(task && task.giteaLabelIds)) {
161
+ candidateIds = task.giteaLabelIds;
162
+ } else if (Array.isArray(settings.giteaLabels)) {
163
+ candidateIds = settings.giteaLabels;
164
+ } else if (Array.isArray(settings.giteaLabelIds)) {
165
+ candidateIds = settings.giteaLabelIds;
166
+ } else if (Array.isArray(task && task.labels)) {
167
+ candidateIds = task.labels.map((item) => {
168
+ const num = Number(item);
169
+ if (Number.isInteger(num) && num > 0) return num;
170
+ return typeof mapping === 'object' && mapping !== null ? mapping[item] : undefined;
171
+ });
172
+ } else if (typeof mapping === 'object' && mapping !== null && Object.keys(mapping).length > 0) {
173
+ candidateIds = defaultNames.map((name) => mapping[name]);
174
+ }
175
+
176
+ const labelIds = candidateIds
177
+ .map((id) => Number(id))
178
+ .filter((id) => Number.isInteger(id) && id > 0);
179
+
138
180
  return {
139
181
  title: `${failed ? '[Cron failure]' : '[Cron run]'} ${truncateText(task.title || 'Task', 200)}`,
140
182
  body: [
@@ -145,7 +187,7 @@ export function buildGiteaIssuePayload({ task, runInfo }) {
145
187
  runInfo.error ? `\n**Error:**\n\`\`\`\n${truncateText(runInfo.error, 1000)}\n\`\`\`` : '',
146
188
  runInfo.output ? `\n**Output:**\n\`\`\`\n${truncateText(runInfo.output, 1000)}\n\`\`\`` : '',
147
189
  ].filter(Boolean).join('\n\n'),
148
- labels: failed ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
190
+ labels: labelIds,
149
191
  };
150
192
  }
151
193
 
@@ -248,9 +290,7 @@ const CHANNEL_HANDLERS = {
248
290
  const botToken = secrets.botToken || '';
249
291
  const chatId = String(settings.chatId || '').trim();
250
292
  if (!botToken || !chatId) throw new Error('telegram: botToken or chatId is not configured');
251
- const text = settings.template
252
- ? messageTextFor('telegram', task, runInfo, settings)
253
- : formatTaskTelegramMessage(task, runInfo);
293
+ const text = messageTextFor('telegram', task, runInfo, settings);
254
294
  try {
255
295
  const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
256
296
  await sendTelegramMessage({ botToken, chatId, text, replyMarkup, fetchFn: http, signal });
@@ -321,7 +361,7 @@ const CHANNEL_HANDLERS = {
321
361
  if (!base || !repo) throw new Error('gitea: giteaBaseUrl and giteaRepo are required');
322
362
  const token = settings.giteaTokenRef ? await resolveSecret(settings.giteaTokenRef) : '';
323
363
  if (!token) throw new Error(`gitea: credential "${settings.giteaTokenRef || ''}" did not resolve`);
324
- const payload = buildGiteaIssuePayload({ task, runInfo });
364
+ const payload = buildGiteaIssuePayload({ task, runInfo, settings });
325
365
  const issue = await postJson(
326
366
  http,
327
367
  `${base}/api/v1/repos/${repo}/issues`,
package/lib/index.js CHANGED
@@ -185,14 +185,23 @@ export function apply(ctx, rawConfig) {
185
185
  // Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
186
186
  // external monitor can alert when this scheduler stops responding.
187
187
  let heartbeatTimer = null;
188
- if (config && config.heartbeatUrl && Number(config.heartbeatIntervalSec) > 0) {
189
- const intervalMs = Number(config.heartbeatIntervalSec) * 1000;
190
- heartbeatTimer = setInterval(() => {
191
- fetch(config.heartbeatUrl, { method: 'GET' }).catch((err) => {
192
- logger.warn('[dsh-cron] heartbeat ping failed:', err.message);
193
- });
194
- }, intervalMs);
188
+ function updateHeartbeat(url, intervalSec) {
189
+ if (heartbeatTimer) {
190
+ clearInterval(heartbeatTimer);
191
+ heartbeatTimer = null;
192
+ }
193
+ const cleanUrl = String(url || '').trim();
194
+ const cleanSec = Number(intervalSec) || 0;
195
+ if (cleanUrl && cleanSec > 0) {
196
+ heartbeatTimer = setInterval(() => {
197
+ fetch(cleanUrl, { method: 'GET' }).catch((err) => {
198
+ logger.warn('[dsh-cron] heartbeat ping failed:', err.message);
199
+ });
200
+ }, cleanSec * 1000);
201
+ if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
202
+ }
195
203
  }
204
+ updateHeartbeat(config && config.heartbeatUrl, config && config.heartbeatIntervalSec);
196
205
 
197
206
  ctx.effect(() => {
198
207
  return () => {
@@ -236,12 +245,29 @@ export function apply(ctx, rawConfig) {
236
245
  const { body, error } = await readBody(req, res);
237
246
  if (error) return;
238
247
  const payload = sanitizeSettingsPayload(body);
239
- store.saveSettings(payload);
240
248
  const scopeErrors = await applySettingsToScope(settingsService, payload);
249
+ if (scopeErrors && scopeErrors.length > 0) {
250
+ sendJson(res, 500, {
251
+ ok: false,
252
+ error: `Settings scope update failed: ${scopeErrors.join('; ')}`,
253
+ scopeErrors,
254
+ });
255
+ return;
256
+ }
257
+ store.saveSettings(payload);
258
+ if (payload.maxConcurrent !== undefined && Number.isFinite(Number(payload.maxConcurrent))) {
259
+ scheduler.maxConcurrent = Number(payload.maxConcurrent);
260
+ }
261
+ if (payload.defaultTimezone !== undefined) {
262
+ scheduler.defaultTimezone = String(payload.defaultTimezone || '').trim();
263
+ }
264
+ if (payload.heartbeatUrl !== undefined || payload.heartbeatIntervalSec !== undefined) {
265
+ const current = store.getSettings();
266
+ updateHeartbeat(current.heartbeatUrl, current.heartbeatIntervalSec);
267
+ }
241
268
  sendJson(res, 200, {
242
269
  ok: true,
243
270
  settings: { ...store.getClientSettings(), version: pkgVersion },
244
- scopeErrors: (scopeErrors && scopeErrors.length > 0) ? scopeErrors : undefined
245
271
  });
246
272
  return;
247
273
  }
package/lib/runtimes.js CHANGED
@@ -46,14 +46,15 @@ function looksLikeScriptFile(token, extensions) {
46
46
 
47
47
  /** #5: resolve a Node.js invocation (inline code or a .js/.mjs/.cjs file). */
48
48
  export function buildNodeInvocation(task, cwd) {
49
+ const nodeExecutable = task.nodePath || process.execPath;
49
50
  const { path: filePath, args } = splitCommandLine(task.prompt);
50
51
  if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
51
- return { file: process.execPath, args: [filePath, ...args] };
52
+ return { file: nodeExecutable, args: [filePath, ...args] };
52
53
  }
53
54
  const code = String(task.prompt || '');
54
55
  const isEsm = /\bimport\s|\bexport\s/.test(code);
55
56
  const nodeArgs = isEsm ? ['--input-type=module', '-e', code] : ['-e', code];
56
- return { file: task.nodePath || process.execPath, args: nodeArgs };
57
+ return { file: nodeExecutable, args: nodeArgs };
57
58
  }
58
59
 
59
60
  /** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
@@ -261,6 +261,48 @@ export async function executeTask(scheduler, taskId, options = {}) {
261
261
  const task = scheduler.store.get(taskId);
262
262
  if (!task) return null;
263
263
 
264
+ // Dry-run path: read-only evaluation without mutating store, history, attempts, or triggering alerts (#229)
265
+ if (options.dryRun) {
266
+ const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
267
+ const guard = checkTaskBudgetLimits(task, runs);
268
+ if (guard.exceeded) {
269
+ return {
270
+ ok: false,
271
+ dryRun: true,
272
+ status: 'error',
273
+ error: guard.reason,
274
+ preflightBlocked: true,
275
+ };
276
+ }
277
+ const preflight = await executePreflight(task);
278
+ if (!preflight.ok) {
279
+ return {
280
+ ok: false,
281
+ dryRun: true,
282
+ status: 'skipped',
283
+ error: `Preflight check skipped: ${preflight.reason}`,
284
+ preflightBlocked: true,
285
+ };
286
+ }
287
+ const start = Date.now();
288
+ const outcome = await scheduler.executeWithFallback(
289
+ task,
290
+ AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)),
291
+ options
292
+ );
293
+ return {
294
+ ok: outcome.status !== 'error' && outcome.status !== 'timeout',
295
+ dryRun: true,
296
+ status: outcome.status,
297
+ output: outcome.output,
298
+ error: outcome.error,
299
+ durationMs: Date.now() - start,
300
+ usage: outcome.usage,
301
+ costUsd: outcome.costUsd,
302
+ model: outcome.model || task.model || '',
303
+ };
304
+ }
305
+
264
306
  // Pre-flight Burn Guard check (#181): prevent executing task if budget already breached
265
307
  const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
266
308
  const guard = checkTaskBudgetLimits(task, runs);
@@ -277,22 +319,6 @@ export async function executeTask(scheduler, taskId, options = {}) {
277
319
  scheduler.store.set(task);
278
320
  }
279
321
 
280
- if (options.dryRun) {
281
- const start = Date.now();
282
- const outcome = await scheduler.executeWithFallback(task, AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)), options);
283
- return {
284
- ok: true,
285
- dryRun: true,
286
- status: outcome.status,
287
- output: outcome.output,
288
- error: outcome.error,
289
- durationMs: Date.now() - start,
290
- usage: outcome.usage,
291
- costUsd: outcome.costUsd,
292
- model: outcome.model || task.model || '',
293
- };
294
- }
295
-
296
322
  const currentRun = scheduler.beginRun(task, taskId, options);
297
323
  if (currentRun === null) return null;
298
324
 
@@ -377,12 +403,23 @@ export async function applyModelAssists(task, runInfo, options) {
377
403
  /**
378
404
  * Deliver a finished run to every configured channel (#26).
379
405
  */
380
- export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver }) {
406
+ export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver, secretTimeoutMs = 5000 }) {
381
407
  try {
382
408
  let settings = typeof store?.getSettings === 'function' ? store.getSettings() : {};
383
409
  if (resolveSecrets) {
384
410
  try {
385
- settings = { ...settings, ...(await resolveSecrets(settings)) };
411
+ let timer;
412
+ const deadline = new Promise((_, reject) => {
413
+ timer = setTimeout(() => reject(new Error(`credential resolution timed out after ${secretTimeoutMs}ms`)), secretTimeoutMs);
414
+ if (typeof timer.unref === 'function') timer.unref();
415
+ });
416
+ const resolved = await Promise.race([
417
+ Promise.resolve(resolveSecrets(settings)).finally(() => {
418
+ if (timer) clearTimeout(timer);
419
+ }),
420
+ deadline,
421
+ ]);
422
+ settings = { ...settings, ...(resolved || {}) };
386
423
  } catch (secErr) {
387
424
  logger.warn('[dsh-cron] credential resolution failed:', secErr.message);
388
425
  }
package/lib/settings.js CHANGED
@@ -24,6 +24,7 @@ export const Config = z.object({
24
24
  maxConcurrent: z.number().default(2).description('Max parallel task runs (0 = unlimited, default 2)').volatile(),
25
25
  heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval').volatile(),
26
26
  heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)').volatile(),
27
+ llmActionsEnabled: z.boolean().default(false).description('Enable LLM actions execution').volatile(),
27
28
  // --- Delivery channels (#20-#23, #26, #28, #47) ---
28
29
  template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}').volatile(),
29
30
  channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id').volatile(),
@@ -77,6 +78,7 @@ export function unwrapConfig(cfg) {
77
78
 
78
79
  export const SETTINGS_SYNC_KEYS = [
79
80
  'botToken', 'botTokenRef', 'telegramWebhookSecret', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
81
+ 'defaultTimezone', 'maxConcurrent', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled',
80
82
  'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
81
83
  'discordWebhookUrl', 'slackWebhookUrl',
82
84
  'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
package/lib/store.js CHANGED
@@ -411,8 +411,12 @@ export class TaskStore {
411
411
  delivery: task.delivery !== undefined ? task.delivery : (prev.delivery || 'isolated'),
412
412
  provider: task.provider !== undefined ? task.provider : prev.provider,
413
413
  model: task.model !== undefined ? task.model : prev.model,
414
- notifyTelegram: task.notifyTelegram !== undefined ? Boolean(task.notifyTelegram) : (prev.notifyTelegram || false),
415
- onlyOnFailure: task.onlyOnFailure !== undefined ? Boolean(task.onlyOnFailure) : (prev.onlyOnFailure || false),
414
+ notifyTelegram: task.notifyTelegram !== undefined
415
+ ? (task.notifyTelegram === null || task.notifyTelegram === 'inherit' ? undefined : Boolean(task.notifyTelegram))
416
+ : prev.notifyTelegram,
417
+ onlyOnFailure: task.onlyOnFailure !== undefined
418
+ ? (task.onlyOnFailure === null || task.onlyOnFailure === 'inherit' ? undefined : Boolean(task.onlyOnFailure))
419
+ : prev.onlyOnFailure,
416
420
  timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
417
421
  overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
418
422
  kanbanMode: task.kanbanMode !== undefined ? String(task.kanbanMode) : (prev.kanbanMode || 'none'), // 'none' | 'on_failure' | 'always'
@@ -35,8 +35,8 @@ export function executeCreateTask(store, scheduler, args) {
35
35
  priority: Number(args.priority) || 5,
36
36
  selfHealingCommand: args.selfHealingCommand ? String(args.selfHealingCommand).trim() : '',
37
37
  autoDiagnose: Boolean(args.autoDiagnose),
38
- notifyTelegram: Boolean(args.notifyTelegram),
39
- onlyOnFailure: Boolean(args.onlyOnFailure),
38
+ notifyTelegram: args.notifyTelegram !== undefined ? Boolean(args.notifyTelegram) : undefined,
39
+ onlyOnFailure: args.onlyOnFailure !== undefined ? Boolean(args.onlyOnFailure) : undefined,
40
40
  timeoutSeconds: Number(args.timeoutSeconds) || 1800,
41
41
  overlapPolicy: args.overlapPolicy || 'skip',
42
42
  timezone: args.timezone ? String(args.timezone).trim() : '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.40",
3
+ "version": "0.2.41",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",