@goodandready/dsh-cron 0.2.50 → 0.2.52
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 +66 -0
- package/lib/approval.js +143 -0
- package/lib/channels.js +4 -2
- package/lib/client.js +141 -90
- package/lib/cron-tool.js +24 -0
- package/lib/global-budgets.js +137 -0
- package/lib/index.js +4 -1
- package/lib/recipes-catalog.js +107 -0
- package/lib/routes.js +3 -1
- package/lib/scheduler-execution.js +132 -6
- package/lib/scheduler.js +12 -0
- package/lib/settings.js +6 -1
- package/lib/store.js +10 -1
- package/lib/task-create.js +3 -0
- package/lib/task-patch.js +7 -0
- package/lib/task-transfer.js +2 -0
- package/lib/telegram.js +1 -1
- package/lib/validators.js +192 -0
- package/package.json +1 -1
|
@@ -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.52",
|
|
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",
|