@goodandready/dsh-cron 0.2.49 → 0.2.51

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/cron-tool.js CHANGED
@@ -150,7 +150,27 @@ export const cronToolParameters = {
150
150
  permissionPreset: {
151
151
  type: 'string',
152
152
  enum: ['default', 'read-only', 'workspace-write', 'full'],
153
- description: 'Permission preset applied to the task session',
153
+ description: 'Permission preset applied to the task session (only used for agent session tasks: llm, skill, workflow)',
154
+ },
155
+ validators: {
156
+ type: 'array',
157
+ description: 'Declarative post-execution validation checks (json, regex, text, exitCode, file)',
158
+ items: {
159
+ type: 'object',
160
+ properties: {
161
+ type: { type: 'string', enum: ['json', 'regex', 'text', 'exitCode', 'file'] },
162
+ pattern: { type: 'string' },
163
+ text: { type: 'string' },
164
+ negate: { type: 'boolean' },
165
+ statusKey: { type: 'string' },
166
+ expected: { type: 'string' },
167
+ path: { type: 'string' },
168
+ mustExist: { type: 'boolean' },
169
+ minBytes: { type: 'number' },
170
+ target: { type: 'string' },
171
+ },
172
+ additionalProperties: true,
173
+ },
154
174
  },
155
175
  kanbanMode: {
156
176
  type: 'string',
package/lib/index.js CHANGED
@@ -165,7 +165,10 @@ export function apply(ctx, rawConfig) {
165
165
  deliver: (task, runInfo, settings) => deliverRun({
166
166
  task,
167
167
  runInfo,
168
- settings,
168
+ settings: {
169
+ ...settings,
170
+ _webServerPort: ctx.webServer?.port || 3080,
171
+ },
169
172
  secrets: {
170
173
  botToken: settings.botToken || '',
171
174
  resolveSecret: (ref) => resolveCredentialValue(ctx, ref),
@@ -1,3 +1,4 @@
1
+ import { validateRunResult } from './validators.js';
1
2
  import { logger } from './logger.js';
2
3
  import { checkAndApplyBurnGuard, checkTaskBudgetLimits } from './burn-guard.js';
3
4
  import { resolveTaskForExecution } from './prompt-interpolation.js';
@@ -327,7 +328,9 @@ export async function executeTask(scheduler, taskId, options = {}) {
327
328
  const currentRun = scheduler.beginRun(task, taskId, options);
328
329
  if (currentRun === null) return null;
329
330
 
331
+ const preflightStart = Date.now();
330
332
  const preflight = await executePreflight(task);
333
+ const preflightDurationMs = Date.now() - preflightStart;
331
334
  if (!preflight.ok) {
332
335
  logger.info(`[dsh-cron] task "${task.title}" (${taskId}) skipped: ${preflight.reason}`);
333
336
  scheduler.running.delete(taskId);
@@ -337,7 +340,10 @@ export async function executeTask(scheduler, taskId, options = {}) {
337
340
 
338
341
  const start = Date.now();
339
342
  const outcome = await scheduler.executeWithFallback(task, currentRun.controller.signal, options);
340
- await scheduler.completeRun(task, taskId, currentRun, outcome, start, options);
343
+ await scheduler.completeRun(task, taskId, currentRun, outcome, start, {
344
+ ...options,
345
+ preflightDurationMs,
346
+ });
341
347
  return outcome;
342
348
  }
343
349
 
@@ -429,7 +435,7 @@ export async function deliverNotifications(task, runInfo, { store, resolveSecret
429
435
  logger.warn('[dsh-cron] credential resolution failed:', secErr.message);
430
436
  }
431
437
  }
432
- if (typeof deliver !== 'function') return;
438
+ if (typeof deliver !== 'function') return { status: 'skipped', delivered: [], failures: [] };
433
439
  const result = await deliver(task, runInfo, settings);
434
440
  if (result && Array.isArray(result.delivered) && result.delivered.length) {
435
441
  logger.info(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
@@ -437,8 +443,16 @@ export async function deliverNotifications(task, runInfo, { store, resolveSecret
437
443
  if (result && Array.isArray(result.failures) && result.failures.length) {
438
444
  logger.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
439
445
  }
446
+ const delivered = Array.isArray(result?.delivered) ? result.delivered : [];
447
+ const failures = Array.isArray(result?.failures) ? result.failures : [];
448
+ let status = 'skipped';
449
+ if (delivered.length > 0 && failures.length === 0) status = 'delivered';
450
+ else if (delivered.length > 0 && failures.length > 0) status = 'partial';
451
+ else if (delivered.length === 0 && failures.length > 0) status = 'failed';
452
+ return { status, delivered, failures };
440
453
  } catch (notifyErr) {
441
454
  logger.error('[dsh-cron] notification error:', notifyErr.message);
455
+ return { status: 'failed', delivered: [], failures: [{ channel: 'all', error: notifyErr.message }] };
442
456
  }
443
457
  }
444
458
 
@@ -522,13 +536,41 @@ export function finishRun(scheduler, taskSnapshot, taskId, status, queuedCount,
522
536
  /**
523
537
  * Handle complete run lifecycle: metrics, self-healing, diagnosis, silent rule, storage, delivery, chaining.
524
538
  */
539
+ function computeStoppedReason(status, valRes, error) {
540
+ if (status === 'validation_failed') return `Validation failed: ${valRes?.error || 'Check failed'}`;
541
+ if (status === 'validator_error') return `Validator configuration error: ${valRes?.error || 'Check failed'}`;
542
+ if (status === 'timeout') return 'Execution timed out';
543
+ if (status === 'error') return error ? `Execution error: ${error}` : 'Execution error';
544
+ if (status === 'success') return 'Completed successfully';
545
+ if (status === 'skipped') return error || 'Run skipped';
546
+ return null;
547
+ }
548
+
525
549
  export async function handleCompleteRun(scheduler, task, taskId, currentRun, outcome, start, options = {}) {
550
+ // Post-execution declarative validation (#316)
551
+ let valRes = { ok: true, skipped: true };
552
+ let valDurationMs = 0;
553
+ if (outcome.status === 'success' && Array.isArray(task.validators) && task.validators.length > 0) {
554
+ const vStart = Date.now();
555
+ valRes = validateRunResult(task, outcome, { cwd: task.cwd });
556
+ valDurationMs = Date.now() - vStart;
557
+ if (!valRes.ok) {
558
+ outcome.status = valRes.status === 'error' ? 'validator_error' : 'validation_failed';
559
+ outcome.error = valRes.error || 'Validation failed';
560
+ }
561
+ }
562
+
526
563
  const isSuccess = outcome.status === 'success';
527
- const isFailure = outcome.status === 'error' || outcome.status === 'timeout';
564
+ const isFailure = outcome.status === 'error' || outcome.status === 'timeout' || outcome.status === 'validation_failed' || outcome.status === 'validator_error';
565
+ const executionMs = Date.now() - start;
566
+ const queueWaitMs = (currentRun && currentRun.startedAt && currentRun.queuedAt) ? (currentRun.startedAt - currentRun.queuedAt) : 0;
567
+ const preflightMs = options.preflightDurationMs || 0;
568
+
528
569
  const runInfo = {
570
+ runId: currentRun?.id || ('run_' + Math.random().toString(36).slice(2, 9) + '_' + Date.now()),
529
571
  at: start,
530
572
  status: outcome.status,
531
- durationMs: Date.now() - start,
573
+ durationMs: executionMs,
532
574
  output: outcome.output,
533
575
  error: outcome.error,
534
576
  usage: outcome.usage,
@@ -536,6 +578,7 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
536
578
  sessionId: outcome.sessionId,
537
579
  model: outcome.model || task.model || '',
538
580
  fallback: Boolean(outcome.fallback),
581
+ validation: valRes.skipped ? null : valRes,
539
582
  };
540
583
 
541
584
  if (isFailure && task.selfHealingCommand) {
@@ -573,6 +616,58 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
573
616
 
574
617
  const silent = await scheduler.applyModelAssists(task, runInfo);
575
618
 
619
+ let deliveryResult = { status: 'skipped', delivered: [], failures: [] };
620
+ let delivStart = Date.now();
621
+ if (!silent.skipped) {
622
+ deliveryResult = (await scheduler.deliverNotifications(task, runInfo)) || deliveryResult;
623
+ }
624
+ const delivDurationMs = Date.now() - delivStart;
625
+
626
+ const stages = [
627
+ {
628
+ name: 'queue',
629
+ status: 'success',
630
+ durationMs: queueWaitMs,
631
+ },
632
+ {
633
+ name: 'preflight',
634
+ status: 'success',
635
+ durationMs: preflightMs,
636
+ error: null,
637
+ },
638
+ {
639
+ name: 'execution',
640
+ status: (outcome.status === 'error' || outcome.status === 'timeout') ? 'failed' : 'success',
641
+ durationMs: executionMs,
642
+ error: (outcome.status === 'error' || outcome.status === 'timeout') ? (outcome.error || null) : null,
643
+ },
644
+ {
645
+ name: 'validation',
646
+ status: valRes.skipped ? 'skipped' : (valRes.ok ? 'success' : 'failed'),
647
+ durationMs: valDurationMs,
648
+ error: valRes.ok ? null : (valRes.error || null),
649
+ },
650
+ {
651
+ name: 'delivery',
652
+ status: deliveryResult?.status || 'skipped',
653
+ durationMs: delivDurationMs,
654
+ error: (deliveryResult?.failures && deliveryResult.failures.length > 0)
655
+ ? deliveryResult.failures.map(f => `${f.channel}: ${f.error}`).join('; ')
656
+ : null,
657
+ delivered: deliveryResult?.delivered || [],
658
+ failures: deliveryResult?.failures || [],
659
+ },
660
+ ];
661
+
662
+ runInfo.stages = stages;
663
+ runInfo.delivery = deliveryResult;
664
+ runInfo.cost = {
665
+ amount: Number(outcome.costUsd) || 0,
666
+ currency: 'USD',
667
+ kind: (Number(outcome.costUsd) > 0) ? 'exact' : ((task.type === 'llm' || task.type === 'skill' || task.type === 'workflow') ? 'unknown' : 'exact'),
668
+ };
669
+ runInfo.stoppedReason = computeStoppedReason(outcome.status, valRes, outcome.error);
670
+
576
671
  try {
577
672
  scheduler.store.recordRun(taskId, runInfo);
578
673
  } catch (recordErr) {
@@ -585,7 +680,6 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
585
680
  const queuedRuns = isCurrentRun ? (finishedRun?.queuedRuns || []) : [];
586
681
  const queuedCount = isCurrentRun ? (finishedRun?.queueCount || 0) : 0;
587
682
 
588
- if (!silent.skipped) await scheduler.deliverNotifications(task, runInfo);
589
683
  scheduler.finishRun(task, taskId, outcome.status, queuedCount, {
590
684
  isCurrentRun,
591
685
  runId: currentRun?.id,
package/lib/scheduler.js CHANGED
@@ -548,7 +548,9 @@ export class TaskScheduler {
548
548
 
549
549
  recordSkipped(taskId, reason) {
550
550
  this.countRun('skipped');
551
+ const runId = 'run_' + Math.random().toString(36).slice(2, 9) + '_' + Date.now();
551
552
  this.store.recordRun(taskId, {
553
+ runId,
552
554
  at: Date.now(),
553
555
  status: 'skipped',
554
556
  durationMs: 0,
@@ -556,6 +558,16 @@ export class TaskScheduler {
556
558
  error: null,
557
559
  usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
558
560
  costUsd: 0,
561
+ stoppedReason: reason,
562
+ stages: [
563
+ { name: 'queue', status: 'skipped', durationMs: 0 },
564
+ { name: 'preflight', status: 'skipped', durationMs: 0, error: reason },
565
+ { name: 'execution', status: 'skipped', durationMs: 0 },
566
+ { name: 'validation', status: 'skipped', durationMs: 0 },
567
+ { name: 'delivery', status: 'skipped', durationMs: 0 },
568
+ ],
569
+ cost: { amount: 0, currency: 'USD', kind: 'exact' },
570
+ delivery: { status: 'skipped', delivered: [], failures: [] },
559
571
  });
560
572
  }
561
573
 
package/lib/settings.js CHANGED
@@ -40,7 +40,7 @@ export const Config = z.object({
40
40
  barkKey: z.string().default('').description('Bark device key').volatile(),
41
41
  pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token').volatile(),
42
42
  pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)').volatile(),
43
- ttsBaseUrl: z.string().default('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route').volatile(),
43
+ ttsBaseUrl: z.string().default('').description('Base URL of the harness used to reach the dsh-tts speak route (empty = auto-resolved from webServer port or http://127.0.0.1:3080)').volatile(),
44
44
  giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues').volatile(),
45
45
  giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues').volatile(),
46
46
  giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token').volatile(),
package/lib/store.js CHANGED
@@ -689,7 +689,7 @@ export class TaskStore {
689
689
  const prevRuns = this.history.get(id) || [];
690
690
  const runs = [
691
691
  {
692
- id: 'run_' + Math.random().toString(36).slice(2, 9),
692
+ id: runInfo.runId || runInfo.id || ('run_' + Math.random().toString(36).slice(2, 9)),
693
693
  at: task.lastRunAt,
694
694
  status: task.lastStatus,
695
695
  durationMs: task.lastDurationMs,
@@ -697,6 +697,15 @@ export class TaskStore {
697
697
  error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
698
698
  usage,
699
699
  costUsd,
700
+ stages: Array.isArray(runInfo.stages) ? runInfo.stages : null,
701
+ stoppedReason: runInfo.stoppedReason || null,
702
+ cost: runInfo.cost || {
703
+ amount: costUsd,
704
+ currency: 'USD',
705
+ kind: costUsd > 0 ? 'exact' : ((task.type === 'llm' || task.type === 'skill' || task.type === 'workflow') ? 'unknown' : 'exact')
706
+ },
707
+ delivery: runInfo.delivery || null,
708
+ validation: runInfo.validation || null,
700
709
  sessionId: runInfo.sessionId || null,
701
710
  // #45: which model produced the run, and whether it only finished thanks
702
711
  // to the configured fallback.
@@ -1,4 +1,5 @@
1
- import { normalizeTaskType } from './runtimes.js';
1
+ import { sanitizeValidators } from './validators.js';
2
+ import { normalizeTaskType, EXTERNAL_RUNTIMES } from './runtimes.js';
2
3
  import { validateTaskType } from './task-transfer.js';
3
4
  import { parseScheduleExpression } from './scheduler.js';
4
5
  import { CHANNEL_IDS } from './channels.js';
@@ -44,7 +45,7 @@ export function executeCreateTask(store, scheduler, args) {
44
45
  misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
45
46
  maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
46
47
  retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
47
- permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
48
+ permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
48
49
  kanbanMode: args.kanbanMode || 'none',
49
50
  channels: Array.isArray(args.channels) ? args.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined,
50
51
  template: args.template ? String(args.template) : '',
@@ -72,6 +73,7 @@ export function executeCreateTask(store, scheduler, args) {
72
73
  costLimitUsd: Number(args.costLimitUsd) > 0 ? Number(args.costLimitUsd) : undefined,
73
74
  dailyCostLimitUsd: Number(args.dailyCostLimitUsd) > 0 ? Number(args.dailyCostLimitUsd) : undefined,
74
75
  tokenLimit: Number(args.tokenLimit) > 0 ? Number(args.tokenLimit) : undefined,
76
+ validators: Array.isArray(args.validators) ? sanitizeValidators(args.validators) : undefined,
75
77
  };
76
78
  // Issue #310 / GH #5: strip any undefined properties before store.set()
77
79
  for (const k of Object.keys(taskData)) {
package/lib/task-patch.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sanitizeValidators } from './validators.js';
1
2
  /**
2
3
  * Partial task updates, shared by the HTTP PATCH route and the cron tool (action: 'update')
3
4
  * tool (#49).
@@ -9,7 +10,7 @@
9
10
  */
10
11
 
11
12
  import { parseScheduleExpression } from './scheduler.js';
12
- import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
13
+ import { normalizeTaskType, CODE_EXECUTING_TYPES, EXTERNAL_RUNTIMES } from './runtimes.js';
13
14
  import { PATCHABLE_TASK_FIELDS, validateTaskType } from './task-transfer.js';
14
15
  import { pickPatchableFields, restoreTaskSecrets } from './http-utils.js';
15
16
  import { unknownChannelIds } from './channels.js';
@@ -86,6 +87,9 @@ export function buildTaskPatch(current, body) {
86
87
  return { ok: false, error: err.message };
87
88
  }
88
89
  }
90
+ if (patch.validators !== undefined) {
91
+ patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
92
+ }
89
93
  if (patch.costLimitUsd !== undefined) {
90
94
  patch.costLimitUsd = patch.costLimitUsd === null ? null : (Number(patch.costLimitUsd) > 0 ? Number(patch.costLimitUsd) : null);
91
95
  }
@@ -95,6 +99,10 @@ export function buildTaskPatch(current, body) {
95
99
  if (patch.tokenLimit !== undefined) {
96
100
  patch.tokenLimit = patch.tokenLimit === null ? null : (Number(patch.tokenLimit) > 0 ? Number(patch.tokenLimit) : null);
97
101
  }
102
+ const effectiveType = patch.type !== undefined ? normalizeTaskType(patch.type) : current.type;
103
+ if (EXTERNAL_RUNTIMES.includes(effectiveType) && patch.permissionPreset !== undefined) {
104
+ patch.permissionPreset = 'default';
105
+ }
98
106
  // Issue #310 / GH #5: strip undefined properties from patch
99
107
  for (const k of Object.keys(patch)) {
100
108
  if (patch[k] === undefined) {
@@ -74,6 +74,7 @@ export const PATCHABLE_TASK_FIELDS = [
74
74
  'dailyCostLimitUsd',
75
75
  'tokenLimit',
76
76
  'pausedReason',
77
+ 'validators',
77
78
  ];
78
79
 
79
80
  /** Fields a duplicate inherits; anything else is runtime state, not config. */
package/lib/telegram.js CHANGED
@@ -210,7 +210,7 @@ export function shouldNotifyTask(task, runInfo, globalSettings = {}) {
210
210
  const isEnabled = task.notifyTelegram ?? globalSettings.notifyTelegram ?? false;
211
211
  if (!isEnabled) return false;
212
212
 
213
- const failed = runInfo.status === 'error' || runInfo.status === 'timeout' || runInfo.status === 'missed';
213
+ const failed = runInfo.status === 'error' || runInfo.status === 'timeout' || runInfo.status === 'missed' || runInfo.status === 'validation_failed' || runInfo.status === 'validator_error';
214
214
  const onlyOnFail = task.onlyOnFailure ?? globalSettings.onlyOnFailure ?? false;
215
215
  if (onlyOnFail && !failed) {
216
216
  return false;
@@ -0,0 +1,192 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const VALIDATOR_TYPES = ['json', 'regex', 'text', 'exitCode', 'file'];
5
+
6
+ /**
7
+ * Validates run output/result against declarative task validators.
8
+ * Pure and deterministic: never uses eval or dynamic code execution (#316).
9
+ *
10
+ * @param {object} task Task record (may have task.validators)
11
+ * @param {object} runInfo Run result ({ output, stdout, stderr, exitCode, error, status })
12
+ * @param {object} [options] Context options ({ cwd, workspaceDir })
13
+ * @returns {{ ok: boolean, status: 'passed' | 'failed' | 'error' | 'skipped', error?: string, details: Array<{ type: string, passed: boolean, message: string }> }}
14
+ */
15
+ export function validateRunResult(task, runInfo, options = {}) {
16
+ const validators = Array.isArray(task?.validators) ? task.validators : [];
17
+ if (validators.length === 0) {
18
+ return { ok: true, status: 'skipped', details: [] };
19
+ }
20
+
21
+ const details = [];
22
+ const baseDir = options.workspaceDir || options.cwd || task?.cwd || process.cwd();
23
+
24
+ for (let i = 0; i < validators.length; i++) {
25
+ const v = validators[i];
26
+ if (!v || typeof v !== 'object') continue;
27
+ const type = String(v.type || '').trim().toLowerCase();
28
+
29
+ try {
30
+ if (type === 'exitCode') {
31
+ const expected = Number(v.expected !== undefined ? v.expected : 0);
32
+ const actual = Number(runInfo.exitCode !== undefined ? runInfo.exitCode : 0);
33
+ const passed = actual === expected;
34
+ const message = passed
35
+ ? `Exit code matches expected (${expected})`
36
+ : `Expected exit code ${expected}, got ${actual}`;
37
+ details.push({ type: 'exitCode', passed, message });
38
+ if (!passed) {
39
+ return { ok: false, status: 'failed', error: message, details };
40
+ }
41
+ } else if (type === 'json') {
42
+ const text = String(runInfo.output || runInfo.stdout || '').trim();
43
+ let parsed = null;
44
+ try {
45
+ parsed = JSON.parse(text);
46
+ } catch (jsonErr) {
47
+ const message = `Output is not valid JSON: ${jsonErr.message}`;
48
+ details.push({ type: 'json', passed: false, message });
49
+ return { ok: false, status: 'failed', error: message, details };
50
+ }
51
+
52
+ if (v.statusKey) {
53
+ const val = parsed[v.statusKey];
54
+ const expected = v.expected;
55
+ const passed = expected !== undefined ? String(val) === String(expected) : Boolean(val);
56
+ const message = passed
57
+ ? `JSON field "${v.statusKey}" matches expected (${String(expected)})`
58
+ : `JSON field "${v.statusKey}" value (${JSON.stringify(val)}) does not match expected (${JSON.stringify(expected)})`;
59
+ details.push({ type: 'json', passed, message });
60
+ if (!passed) {
61
+ return { ok: false, status: 'failed', error: message, details };
62
+ }
63
+ }
64
+
65
+ if (v.path) {
66
+ const parts = String(v.path).split('.');
67
+ let cur = parsed;
68
+ let found = true;
69
+ for (const p of parts) {
70
+ if (cur && typeof cur === 'object' && p in cur) {
71
+ cur = cur[p];
72
+ } else {
73
+ found = false;
74
+ break;
75
+ }
76
+ }
77
+ if (v.mustExist && !found) {
78
+ const message = `JSON path "${v.path}" not found in output`;
79
+ details.push({ type: 'json', passed: false, message });
80
+ return { ok: false, status: 'failed', error: message, details };
81
+ }
82
+ if (v.minCount !== undefined && Array.isArray(cur) && cur.length < Number(v.minCount)) {
83
+ const message = `JSON path "${v.path}" array length (${cur.length}) is less than minCount (${v.minCount})`;
84
+ details.push({ type: 'json', passed: false, message });
85
+ return { ok: false, status: 'failed', error: message, details };
86
+ }
87
+ }
88
+
89
+ details.push({ type: 'json', passed: true, message: 'Valid JSON output' });
90
+ } else if (type === 'text') {
91
+ const targetStr = getTargetText(runInfo, v.target);
92
+ const sub = String(v.text || '');
93
+ const contains = targetStr.includes(sub);
94
+ const passed = v.negate ? !contains : contains;
95
+ const message = passed
96
+ ? `Text check passed for "${sub}"`
97
+ : (v.negate ? `Output unexpectedly contains forbidden text "${sub}"` : `Output does not contain required text "${sub}"`);
98
+ details.push({ type: 'text', passed, message });
99
+ if (!passed) {
100
+ return { ok: false, status: 'failed', error: message, details };
101
+ }
102
+ } else if (type === 'regex') {
103
+ const targetStr = getTargetText(runInfo, v.target);
104
+ const pattern = String(v.pattern || '');
105
+ let reg;
106
+ try {
107
+ reg = new RegExp(pattern, v.flags || 'i');
108
+ } catch (regErr) {
109
+ const message = `Invalid regex pattern "${pattern}": ${regErr.message}`;
110
+ details.push({ type: 'regex', passed: false, message });
111
+ return { ok: false, status: 'error', error: message, details };
112
+ }
113
+ const matches = reg.test(targetStr);
114
+ const passed = v.negate ? !matches : matches;
115
+ const message = passed
116
+ ? `Regex match passed for /${pattern}/`
117
+ : (v.negate ? `Output matches forbidden pattern /${pattern}/` : `Output does not match required pattern /${pattern}/`);
118
+ details.push({ type: 'regex', passed, message });
119
+ if (!passed) {
120
+ return { ok: false, status: 'failed', error: message, details };
121
+ }
122
+ } else if (type === 'file') {
123
+ const relPath = String(v.path || '').trim();
124
+ if (!relPath) {
125
+ details.push({ type: 'file', passed: false, message: 'File path cannot be empty' });
126
+ return { ok: false, status: 'failed', error: 'File path cannot be empty', details };
127
+ }
128
+ // Guard against directory traversal escaping baseDir
129
+ const resolvedPath = path.resolve(baseDir, relPath);
130
+ const resolvedBase = path.resolve(baseDir);
131
+ if (!resolvedPath.startsWith(resolvedBase)) {
132
+ const message = `File path "${relPath}" escapes allowed directory boundary`;
133
+ details.push({ type: 'file', passed: false, message });
134
+ return { ok: false, status: 'failed', error: message, details };
135
+ }
136
+
137
+ const exists = fs.existsSync(resolvedPath);
138
+ if (v.mustExist !== false && !exists) {
139
+ const message = `Required file not found: "${relPath}"`;
140
+ details.push({ type: 'file', passed: false, message });
141
+ return { ok: false, status: 'failed', error: message, details };
142
+ }
143
+
144
+ if (exists && v.minBytes !== undefined) {
145
+ const stats = fs.statSync(resolvedPath);
146
+ if (stats.size < Number(v.minBytes)) {
147
+ const message = `File "${relPath}" size (${stats.size} bytes) is below minimum (${v.minBytes} bytes)`;
148
+ details.push({ type: 'file', passed: false, message });
149
+ return { ok: false, status: 'failed', error: message, details };
150
+ }
151
+ }
152
+ details.push({ type: 'file', passed: true, message: `File check passed for "${relPath}"` });
153
+ } else {
154
+ const message = `Unknown validator type "${type}"`;
155
+ details.push({ type: 'unknown', passed: false, message });
156
+ return { ok: false, status: 'failed', error: message, details };
157
+ }
158
+ } catch (valErr) {
159
+ const message = `Validator error in ${type}: ${valErr.message}`;
160
+ details.push({ type, passed: false, message });
161
+ return { ok: false, status: 'error', error: message, details };
162
+ }
163
+ }
164
+
165
+ return { ok: true, status: 'passed', details };
166
+ }
167
+
168
+ function getTargetText(runInfo, target) {
169
+ if (target === 'stdout') return String(runInfo.stdout ?? runInfo.output ?? '');
170
+ if (target === 'stderr') return String(runInfo.stderr ?? '');
171
+ return String(runInfo.output ?? '');
172
+ }
173
+
174
+ /**
175
+ * Sanitize and validate validator list for persistence.
176
+ */
177
+ export function sanitizeValidators(validators) {
178
+ if (!Array.isArray(validators)) return [];
179
+ return validators.filter((v) => v && typeof v === 'object' && VALIDATOR_TYPES.includes(String(v.type).toLowerCase())).map((v) => ({
180
+ type: String(v.type).toLowerCase(),
181
+ ...(v.pattern !== undefined ? { pattern: String(v.pattern) } : {}),
182
+ ...(v.text !== undefined ? { text: String(v.text) } : {}),
183
+ ...(v.flags !== undefined ? { flags: String(v.flags) } : {}),
184
+ ...(v.negate !== undefined ? { negate: Boolean(v.negate) } : {}),
185
+ ...(v.statusKey !== undefined ? { statusKey: String(v.statusKey) } : {}),
186
+ ...(v.expected !== undefined ? { expected: v.expected } : {}),
187
+ ...(v.path !== undefined ? { path: String(v.path) } : {}),
188
+ ...(v.mustExist !== undefined ? { mustExist: Boolean(v.mustExist) } : {}),
189
+ ...(v.minBytes !== undefined ? { minBytes: Number(v.minBytes) } : {}),
190
+ ...(v.target !== undefined ? { target: String(v.target) } : {}),
191
+ }));
192
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.49",
3
+ "version": "0.2.51",
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",