@goodandready/dsh-cron 0.3.11 → 0.3.13
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/README.md +14 -1
- package/README.ru.md +14 -1
- package/README.zh.md +23 -0
- package/lib/api-helpers.js +9 -9
- package/lib/api-task-webhook.js +4 -4
- package/lib/api.js +133 -19
- package/lib/branching.js +341 -0
- package/lib/client.js +870 -758
- package/lib/dag.js +88 -18
- package/lib/doctor.js +431 -0
- package/lib/http-utils.js +116 -35
- package/lib/index.js +2 -2
- package/lib/routes.js +19 -19
- package/lib/scheduler-execution.js +59 -6
- package/lib/task-create.js +9 -0
- package/lib/task-patch.js +18 -1
- package/lib/task-transfer.js +7 -0
- package/package.json +1 -1
package/lib/branching.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision Nodes and Branching Rules Engine (#352)
|
|
3
|
+
*
|
|
4
|
+
* Evaluates conditional IF/ELSE logic on task outcomes in DAG chains.
|
|
5
|
+
* Inspects exit codes, execution statuses, stdout/stderr, and deep JSON paths.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Safely resolves a nested field from the execution context.
|
|
10
|
+
*
|
|
11
|
+
* Supported paths:
|
|
12
|
+
* - Direct fields: 'status', 'exitCode', 'output', 'stdout', 'stderr', 'durationMs', 'costUsd'
|
|
13
|
+
* - JSON paths: 'output.json.severity', 'json.data.status', 'stdout.json.items[0].id'
|
|
14
|
+
* - Nested objects: 'outcome.status', 'runInfo.error', 'task.type'
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} context Execution context containing outcome, runInfo, and/or task
|
|
17
|
+
* @param {string} path Dot-separated property path
|
|
18
|
+
* @returns {any} Resolved value or undefined
|
|
19
|
+
*/
|
|
20
|
+
export function resolveField(context, path) {
|
|
21
|
+
if (!context || !path || typeof path !== 'string') return undefined;
|
|
22
|
+
|
|
23
|
+
const trimmedPath = path.trim();
|
|
24
|
+
if (!trimmedPath) return undefined;
|
|
25
|
+
|
|
26
|
+
// Flattened lookup root: blend context, outcome, runInfo
|
|
27
|
+
const root = {
|
|
28
|
+
...(context.outcome || {}),
|
|
29
|
+
...(context.runInfo || {}),
|
|
30
|
+
...(context.task ? { task: context.task } : {}),
|
|
31
|
+
...context,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// If path refers to stdout or stderr, fallback to output if not explicitly separated
|
|
35
|
+
if (trimmedPath === 'stdout' && root.stdout === undefined) {
|
|
36
|
+
return root.output;
|
|
37
|
+
}
|
|
38
|
+
if (trimmedPath === 'stderr' && root.stderr === undefined) {
|
|
39
|
+
return root.error;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Handle json sub-paths (e.g. output.json.foo, json.bar, stdout.json.baz)
|
|
43
|
+
if (trimmedPath.startsWith('output.json.') || trimmedPath.startsWith('stdout.json.') || trimmedPath.startsWith('json.')) {
|
|
44
|
+
let rawText = '';
|
|
45
|
+
let jsonSubPath = '';
|
|
46
|
+
|
|
47
|
+
if (trimmedPath.startsWith('output.json.')) {
|
|
48
|
+
rawText = root.output ?? root.stdout ?? '';
|
|
49
|
+
jsonSubPath = trimmedPath.slice('output.json.'.length);
|
|
50
|
+
} else if (trimmedPath.startsWith('stdout.json.')) {
|
|
51
|
+
rawText = root.stdout ?? root.output ?? '';
|
|
52
|
+
jsonSubPath = trimmedPath.slice('stdout.json.'.length);
|
|
53
|
+
} else {
|
|
54
|
+
rawText = root.output ?? root.stdout ?? '';
|
|
55
|
+
jsonSubPath = trimmedPath.slice('json.'.length);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let parsed = null;
|
|
59
|
+
if (typeof rawText === 'object' && rawText !== null) {
|
|
60
|
+
parsed = rawText;
|
|
61
|
+
} else if (typeof rawText === 'string') {
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(rawText.trim());
|
|
64
|
+
} catch {
|
|
65
|
+
// Attempt to extract JSON from markdown fences if present
|
|
66
|
+
const jsonMatch = rawText.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
67
|
+
if (jsonMatch) {
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(jsonMatch[1].trim());
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!parsed || typeof parsed !== 'object') return undefined;
|
|
80
|
+
return getNestedValue(parsed, jsonSubPath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return getNestedValue(root, trimmedPath);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Traverses an object using a dot-path, supporting simple array indexing like `items[0].id` or `items.0.id`.
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} obj Root object
|
|
90
|
+
* @param {string} path Dot path
|
|
91
|
+
* @returns {any}
|
|
92
|
+
*/
|
|
93
|
+
export function getNestedValue(obj, path) {
|
|
94
|
+
if (obj === null || obj === undefined || !path) return undefined;
|
|
95
|
+
|
|
96
|
+
// Normalize array notation `foo[0].bar` -> `foo.0.bar`
|
|
97
|
+
const normalizedPath = path.replace(/\[(\w+)\]/g, '.$1');
|
|
98
|
+
const parts = normalizedPath.split('.').filter(Boolean);
|
|
99
|
+
|
|
100
|
+
let current = obj;
|
|
101
|
+
for (const part of parts) {
|
|
102
|
+
if (current === null || current === undefined) return undefined;
|
|
103
|
+
current = current[part];
|
|
104
|
+
}
|
|
105
|
+
return current;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Evaluates a binary condition against an extracted value.
|
|
110
|
+
*
|
|
111
|
+
* Supported operators:
|
|
112
|
+
* - 'equals', '==', 'eq'
|
|
113
|
+
* - 'notEquals', '!=', 'neq'
|
|
114
|
+
* - 'contains'
|
|
115
|
+
* - 'notContains'
|
|
116
|
+
* - 'greaterThan', '>', 'gt'
|
|
117
|
+
* - 'greaterThanOrEqual', '>=', 'gte'
|
|
118
|
+
* - 'lessThan', '<', 'lt'
|
|
119
|
+
* - 'lessThanOrEqual', '<=', 'lte'
|
|
120
|
+
* - 'regex', 'matches'
|
|
121
|
+
* - 'exists'
|
|
122
|
+
* - 'notExists'
|
|
123
|
+
*
|
|
124
|
+
* @param {any} actual
|
|
125
|
+
* @param {string} operator
|
|
126
|
+
* @param {any} expected
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
export function evaluateCondition(actual, operator, expected) {
|
|
130
|
+
const op = String(operator || 'equals').trim().toLowerCase();
|
|
131
|
+
|
|
132
|
+
if (op === 'exists') {
|
|
133
|
+
return actual !== undefined && actual !== null && actual !== '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (op === 'notexists' || op === 'not_exists') {
|
|
137
|
+
return actual === undefined || actual === null || actual === '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (actual === undefined || actual === null) {
|
|
141
|
+
if (op === 'notequals' || op === '!=' || op === 'neq') {
|
|
142
|
+
return expected !== undefined && expected !== null;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Helper: check if value looks like a pure number
|
|
148
|
+
const isNumeric = (val) => typeof val === 'number' || (typeof val === 'string' && val.trim() !== '' && !Number.isNaN(Number(val)));
|
|
149
|
+
|
|
150
|
+
switch (op) {
|
|
151
|
+
case 'equals':
|
|
152
|
+
case '==':
|
|
153
|
+
case 'eq': {
|
|
154
|
+
if (isNumeric(actual) && isNumeric(expected)) {
|
|
155
|
+
return Number(actual) === Number(expected);
|
|
156
|
+
}
|
|
157
|
+
if (typeof actual === 'boolean' || typeof expected === 'boolean') {
|
|
158
|
+
return Boolean(actual) === (expected === true || String(expected).toLowerCase() === 'true');
|
|
159
|
+
}
|
|
160
|
+
return String(actual).trim().toLowerCase() === String(expected ?? '').trim().toLowerCase();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case 'notequals':
|
|
164
|
+
case '!=':
|
|
165
|
+
case 'neq': {
|
|
166
|
+
return !evaluateCondition(actual, 'equals', expected);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case 'contains': {
|
|
170
|
+
if (Array.isArray(actual)) {
|
|
171
|
+
return actual.some((item) => String(item).toLowerCase().includes(String(expected ?? '').toLowerCase()));
|
|
172
|
+
}
|
|
173
|
+
return String(actual).toLowerCase().includes(String(expected ?? '').toLowerCase());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case 'notcontains':
|
|
177
|
+
case 'not_contains': {
|
|
178
|
+
return !evaluateCondition(actual, 'contains', expected);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case 'greaterthan':
|
|
182
|
+
case '>':
|
|
183
|
+
case 'gt': {
|
|
184
|
+
const a = Number(actual);
|
|
185
|
+
const b = Number(expected);
|
|
186
|
+
return !Number.isNaN(a) && !Number.isNaN(b) && a > b;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
case 'greaterthanorequal':
|
|
190
|
+
case '>=':
|
|
191
|
+
case 'gte': {
|
|
192
|
+
const a = Number(actual);
|
|
193
|
+
const b = Number(expected);
|
|
194
|
+
return !Number.isNaN(a) && !Number.isNaN(b) && a >= b;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case 'lessthan':
|
|
198
|
+
case '<':
|
|
199
|
+
case 'lt': {
|
|
200
|
+
const a = Number(actual);
|
|
201
|
+
const b = Number(expected);
|
|
202
|
+
return !Number.isNaN(a) && !Number.isNaN(b) && a < b;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case 'lessthanorequal':
|
|
206
|
+
case '<=':
|
|
207
|
+
case 'lte': {
|
|
208
|
+
const a = Number(actual);
|
|
209
|
+
const b = Number(expected);
|
|
210
|
+
return !Number.isNaN(a) && !Number.isNaN(b) && a <= b;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case 'regex':
|
|
214
|
+
case 'matches': {
|
|
215
|
+
try {
|
|
216
|
+
const re = new RegExp(String(expected ?? ''), 'i');
|
|
217
|
+
return re.test(String(actual));
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
default:
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Sanitizes and validates branch rule configurations.
|
|
230
|
+
*
|
|
231
|
+
* @param {Array<Object>|any} rules
|
|
232
|
+
* @returns {Array<{ field: string, operator: string, value: any, targetTask: string, label?: string }>}
|
|
233
|
+
*/
|
|
234
|
+
export function sanitizeBranchRules(rules) {
|
|
235
|
+
if (!Array.isArray(rules)) return [];
|
|
236
|
+
|
|
237
|
+
const sanitized = [];
|
|
238
|
+
for (const r of rules) {
|
|
239
|
+
if (!r || typeof r !== 'object') continue;
|
|
240
|
+
|
|
241
|
+
const targetTask = String(r.targetTask || '').trim();
|
|
242
|
+
const field = String(r.field || '').trim();
|
|
243
|
+
const operator = String(r.operator || 'equals').trim().toLowerCase();
|
|
244
|
+
|
|
245
|
+
if (!targetTask || !field) continue;
|
|
246
|
+
|
|
247
|
+
sanitized.push({
|
|
248
|
+
field,
|
|
249
|
+
operator,
|
|
250
|
+
value: r.value !== undefined ? r.value : '',
|
|
251
|
+
targetTask,
|
|
252
|
+
label: r.label ? String(r.label).trim() : `[if: ${field} ${operator} ${r.value ?? ''}]`.trim(),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return sanitized;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Evaluates an ordered list of branch rules against the execution context.
|
|
261
|
+
* Returns the first matching target task, or the fallback elseTask if none match.
|
|
262
|
+
*
|
|
263
|
+
* @param {Array<Object>} rules Array of branch rule objects
|
|
264
|
+
* @param {string|null} [elseTask] Fallback target task ID
|
|
265
|
+
* @param {Object} context Execution context (outcome, runInfo, task)
|
|
266
|
+
* @returns {{ matched: boolean, targetTask: string|null, rule?: Object, isElse?: boolean, matchedIndex?: number }}
|
|
267
|
+
*/
|
|
268
|
+
export function evaluateBranchRules(rules, elseTask, context) {
|
|
269
|
+
const cleanRules = sanitizeBranchRules(rules);
|
|
270
|
+
|
|
271
|
+
for (let i = 0; i < cleanRules.length; i++) {
|
|
272
|
+
const rule = cleanRules[i];
|
|
273
|
+
const actual = resolveField(context, rule.field);
|
|
274
|
+
const matched = evaluateCondition(actual, rule.operator, rule.value);
|
|
275
|
+
|
|
276
|
+
if (matched) {
|
|
277
|
+
return {
|
|
278
|
+
matched: true,
|
|
279
|
+
targetTask: rule.targetTask,
|
|
280
|
+
rule,
|
|
281
|
+
matchedIndex: i,
|
|
282
|
+
actualValue: actual,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const cleanElse = elseTask ? String(elseTask).trim() : null;
|
|
288
|
+
if (cleanElse) {
|
|
289
|
+
return {
|
|
290
|
+
matched: false,
|
|
291
|
+
targetTask: cleanElse,
|
|
292
|
+
isElse: true,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
matched: false,
|
|
298
|
+
targetTask: null,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Evaluates branch rules on task completion and determines next targets for the scheduler.
|
|
304
|
+
* Returns null if no branch rules or elseTask are configured (allowing standard onSuccess/onFailure).
|
|
305
|
+
*
|
|
306
|
+
* @param {Object} task Current task definition
|
|
307
|
+
* @param {Object} outcome Task execution outcome
|
|
308
|
+
* @param {Object} runInfo History run record
|
|
309
|
+
* @returns {{ targets: string[], decision: Object } | null}
|
|
310
|
+
*/
|
|
311
|
+
export function resolveBranchTargets(task, outcome, runInfo) {
|
|
312
|
+
if (!task) return null;
|
|
313
|
+
|
|
314
|
+
const rules = sanitizeBranchRules(task.branchRules);
|
|
315
|
+
const elseTask = task.elseTask ? String(task.elseTask).trim() : null;
|
|
316
|
+
|
|
317
|
+
if (rules.length === 0 && !elseTask) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const context = {
|
|
322
|
+
outcome: outcome || {},
|
|
323
|
+
runInfo: runInfo || {},
|
|
324
|
+
task,
|
|
325
|
+
status: outcome?.status || runInfo?.status,
|
|
326
|
+
exitCode: outcome?.exitCode ?? runInfo?.exitCode,
|
|
327
|
+
output: outcome?.output ?? runInfo?.output,
|
|
328
|
+
error: outcome?.error ?? runInfo?.error,
|
|
329
|
+
stdout: outcome?.stdout ?? runInfo?.stdout,
|
|
330
|
+
stderr: outcome?.stderr ?? runInfo?.stderr,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const decision = evaluateBranchRules(rules, elseTask, context);
|
|
334
|
+
const targets = decision.targetTask ? [decision.targetTask] : [];
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
targets,
|
|
338
|
+
decision,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|