@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/api.js +32 -2
- package/lib/channels.js +4 -2
- package/lib/client.js +246 -107
- package/lib/cron-tool.js +21 -1
- package/lib/index.js +4 -1
- package/lib/scheduler-execution.js +99 -5
- package/lib/scheduler.js +12 -0
- package/lib/settings.js +1 -1
- package/lib/store.js +10 -1
- package/lib/task-create.js +4 -2
- package/lib/task-patch.js +9 -1
- package/lib/task-transfer.js +1 -0
- package/lib/telegram.js +1 -1
- package/lib/validators.js +192 -0
- package/package.json +1 -1
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,
|
|
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:
|
|
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('
|
|
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.
|
package/lib/task-create.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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) {
|
package/lib/task-transfer.js
CHANGED
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.
|
|
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",
|