@goodandready/dsh-cron 0.3.12 → 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 +12 -0
- package/README.ru.md +12 -0
- package/README.zh.md +12 -0
- package/lib/api.js +117 -5
- 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/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/README.md
CHANGED
|
@@ -90,6 +90,18 @@ graph TD
|
|
|
90
90
|
|
|
91
91
|
## ✨ Features & Capabilities
|
|
92
92
|
|
|
93
|
+
### 🔀 Decision Nodes & Conditional IF/ELSE Branching (#352)
|
|
94
|
+
- **Conditional Routing**: Tasks can define `branchRules` evaluating execution outcomes (`exitCode`, `output`, `output.json.<path>`, `status`) with operators (`equals`, `notequals`, `contains`, `gt`, `gte`, `lt`, `lte`, `regex`, `exists`).
|
|
95
|
+
- **Fallback Else**: Optional `elseTask` triggers when no branch condition matches.
|
|
96
|
+
- **Deadlock-Free Synchronization**: Untriggered branch tasks are safely recorded with `status: 'skipped'`, unblocking downstream join barriers (`joinPolicy: 'all_success'`).
|
|
97
|
+
- **Graph Visualization**: DAG Graph View displays decision badges (`🔀 Decision`) and condition labels on connecting edges.
|
|
98
|
+
|
|
99
|
+
### 🩺 Autonomous Self-Healing Doctor (#353)
|
|
100
|
+
- **Failure Classification**: Automatic detection of 8 failure categories: `syntax_error`, `dependency_missing`, `rate_limit`, `auth_failed`, `schema_change`, `timeout`, `network_error`, `runtime_error`.
|
|
101
|
+
- **Unified Diff**: Clean line-by-line diff preview of proposed fixes before application.
|
|
102
|
+
- **1-Click Apply & Revert**: Dedicated `/doctor/apply` and `/doctor/revert` endpoints with full revision backup (up to 10 versions preserved in `task.revisions`).
|
|
103
|
+
- **Guarded Autonomous Auto-Healing**: When `autoHeal: true`, high-confidence fixes are applied automatically with a strict 24-hour rate limit to prevent repair loops.
|
|
104
|
+
|
|
93
105
|
### 1. Visual Task Manager & Sidebar Action
|
|
94
106
|
Click the clock icon in the DSH sidebar (positioned next to the new-session button) to open the management panel:
|
|
95
107
|
* **Status Filter Tabs**: toggle between **All**, **Active**, **Paused**, and **Completed** tasks.
|
package/README.ru.md
CHANGED
|
@@ -90,6 +90,18 @@ graph TD
|
|
|
90
90
|
|
|
91
91
|
## ✨ Возможности
|
|
92
92
|
|
|
93
|
+
### 🔀 Решающие узлы и условные развилки IF/ELSE (#352)
|
|
94
|
+
- **Маршрутизация по условию**: Задачи могут задавать `branchRules` для проверки результата (`exitCode`, `output`, `output.json.<path>`, `status`) через операторы (`equals`, `notequals`, `contains`, `gt`, `gte`, `lt`, `lte`, `regex`, `exists`).
|
|
95
|
+
- **Ветка Else**: Резервная задача `elseTask` запускается, если ни одно из правил не совпало.
|
|
96
|
+
- **Защита от взаимных блокировок**: Невыбранные ветви автоматически регистрируются со статусом `skipped`, предотвращая зависание барьеров синхронизации (`joinPolicy: 'all_success'`).
|
|
97
|
+
- **Визуализация в графе**: DAG Graph View отображает бейджи решающих узлов (`🔀 Decision`) и условия на соединительных линиях.
|
|
98
|
+
|
|
99
|
+
### 🩺 Автономный Доктор самовосстановления (Self-Healing Doctor) (#353)
|
|
100
|
+
- **Классификация сбоев**: Автоматическое распознавание 8 типов ошибок (`syntax_error`, `dependency_missing`, `rate_limit`, `auth_failed`, `schema_change`, `timeout`, `network_error`, `runtime_error`).
|
|
101
|
+
- **Unified Diff**: Наглядный просмотр диффа предлагаемого исправления до его применения.
|
|
102
|
+
- **1-Click применение и откат**: API эндпоинты `/doctor/apply` и `/doctor/revert` с сохранением до 10 ревизий в `task.revisions`.
|
|
103
|
+
- **Безопасная авто-починка**: При включённом `autoHeal: true` патчи с высокой уверенностью (`high`) применяются автоматически с лимитом не чаще 1 раза в сутки.
|
|
104
|
+
|
|
93
105
|
### 1. Визуальный менеджер задач
|
|
94
106
|
Нажмите на иконку-часы в сайдбаре DSH (рядом с кнопкой новой сессии), чтобы открыть панель:
|
|
95
107
|
* **Табы фильтрации**: **Все**, **Активные**, **На паузе**, **Завершённые**.
|
package/README.zh.md
CHANGED
|
@@ -90,6 +90,18 @@ graph TD
|
|
|
90
90
|
|
|
91
91
|
## ✨ 功能与能力
|
|
92
92
|
|
|
93
|
+
### 🔀 决策节点与条件分支 IF/ELSE (#352)
|
|
94
|
+
- **条件路由**:任务可配置 `branchRules`(评估 `exitCode`、`output`、`output.json.<path>` 等字段),支持 `equals`、`notequals`、`contains`、`gt`、`lt`、`regex` 等操作符。
|
|
95
|
+
- **回退分支**:若无匹配规则,自动执行 `elseTask`。
|
|
96
|
+
- **死锁防护**:未触发的分支自动标记为 `skipped`,确保后续汇聚栅栏(`joinPolicy: 'all_success'`)正常通过。
|
|
97
|
+
- **DAG 可视化**:图谱界面直观展示决策标识(`🔀 Decision`)与连线判断条件。
|
|
98
|
+
|
|
99
|
+
### 🩺 自愈医生(Self-Healing Doctor)(#353)
|
|
100
|
+
- **故障诊断分类**:自动分类 8 类运行故障(语法错误、缺少依赖、频率限制、认证失效、架构变更、超时、网络错误、运行时异常)。
|
|
101
|
+
- **统一代码差分**:生成直观的标准 Unified Diff 对比。
|
|
102
|
+
- **一键应用与回滚**:提供 `/doctor/apply` 与 `/doctor/revert` 接口,自动保留多达 10 个历史版本(`task.revisions`)。
|
|
103
|
+
- **受控自主修复**:启用 `autoHeal: true` 时,高可信度(`high`)修复自动生效,且强制 24 小时频次限制防止死循环。
|
|
104
|
+
|
|
93
105
|
### 1. 可视化任务管理器
|
|
94
106
|
点击 DSH 侧边栏中的时钟图标(位于“新会话”按钮旁)打开管理面板:
|
|
95
107
|
* **状态过滤标签**:**全部**、**活跃**、**已暂停**、**已完成**。
|
package/lib/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyDoctorFix, revertDoctorFix, diagnoseFailure } from './doctor.js';
|
|
1
2
|
import { handleTaskWebhookRoute } from './api-task-webhook.js';
|
|
2
3
|
import { rollbackPromptVersion, sanitizePromptHistory } from './prompt-evolver.js';
|
|
3
4
|
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
@@ -81,6 +82,7 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
81
82
|
const scope = parts[1]; // 'tasks' | 'action' | 'heartbeat-ping'
|
|
82
83
|
const id = parts[2];
|
|
83
84
|
const action = parts[3];
|
|
85
|
+
const subAction = parts[4];
|
|
84
86
|
|
|
85
87
|
if (scope === 'telegram' && id === 'webhook') {
|
|
86
88
|
await handleTelegramWebhook({ store, scheduler, req, res });
|
|
@@ -131,7 +133,7 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
131
133
|
return;
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
await handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken, ctx });
|
|
136
|
+
await handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx });
|
|
135
137
|
} catch (err) {
|
|
136
138
|
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
137
139
|
}
|
|
@@ -293,7 +295,7 @@ function buildTaskRecord(store, body, taskType, parsed) {
|
|
|
293
295
|
|
|
294
296
|
// ------------------------------------------------------------ single task
|
|
295
297
|
|
|
296
|
-
async function handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken, ctx }) {
|
|
298
|
+
async function handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx }) {
|
|
297
299
|
if (action === 'heartbeat') {
|
|
298
300
|
handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
|
|
299
301
|
return;
|
|
@@ -344,6 +346,25 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
|
|
|
344
346
|
sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
|
|
345
347
|
return;
|
|
346
348
|
}
|
|
349
|
+
if (action === 'doctor') {
|
|
350
|
+
const task = store.get(id);
|
|
351
|
+
if (!task) {
|
|
352
|
+
sendJson(res, 404, NOT_FOUND);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const runs = store.getHistory(id, 5);
|
|
356
|
+
const latestRun = runs[0] || null;
|
|
357
|
+
const reportRun = runs.find((r) => r.doctorReport) || latestRun;
|
|
358
|
+
sendJson(res, 200, {
|
|
359
|
+
ok: true,
|
|
360
|
+
taskId: id,
|
|
361
|
+
doctorReport: reportRun?.doctorReport || null,
|
|
362
|
+
revisions: task.revisions || [],
|
|
363
|
+
previousPrompt: task.previousPrompt || null,
|
|
364
|
+
autoHeal: Boolean(task.autoHeal || task.selfHealing?.autoApply),
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
347
368
|
if (!action) {
|
|
348
369
|
const task = store.get(id);
|
|
349
370
|
if (!task) {
|
|
@@ -362,7 +383,7 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
|
|
|
362
383
|
}
|
|
363
384
|
}
|
|
364
385
|
if (req.method === 'POST') {
|
|
365
|
-
const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
|
|
386
|
+
const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
|
|
366
387
|
if (handled) return;
|
|
367
388
|
}
|
|
368
389
|
if (req.method === 'PATCH') {
|
|
@@ -377,7 +398,97 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
|
|
|
377
398
|
}
|
|
378
399
|
|
|
379
400
|
/** Task actions that are plain POSTs. Returns true when one matched. */
|
|
380
|
-
async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
|
|
401
|
+
async function handleItemPost({ store, scheduler, req, res, url, id, action, subAction }) {
|
|
402
|
+
if ((action === 'doctor' && subAction === 'apply') || action === 'doctor-apply') {
|
|
403
|
+
const task = store.get(id);
|
|
404
|
+
if (!task) {
|
|
405
|
+
sendJson(res, 404, NOT_FOUND);
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
const body = await readBody(req, res);
|
|
409
|
+
const bodyData = (body && body.body) || {};
|
|
410
|
+
let fix = bodyData.fix || bodyData.doctorReport;
|
|
411
|
+
if (!fix) {
|
|
412
|
+
const runs = store.getHistory(id, 5);
|
|
413
|
+
const reportRun = runs.find((r) => r.doctorReport);
|
|
414
|
+
fix = reportRun?.doctorReport;
|
|
415
|
+
}
|
|
416
|
+
if (!fix || !fix.suggestedFix) {
|
|
417
|
+
sendJson(res, 400, { ok: false, error: 'No doctor fix found to apply' });
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const result = await applyDoctorFix(task, fix, { store, user: bodyData.user || 'operator' });
|
|
422
|
+
if (!result.ok) {
|
|
423
|
+
sendJson(res, 400, { ok: false, error: result.error });
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
sendJson(res, 200, {
|
|
427
|
+
ok: true,
|
|
428
|
+
taskId: id,
|
|
429
|
+
task: redactTaskSecrets(task),
|
|
430
|
+
revision: result.revision,
|
|
431
|
+
});
|
|
432
|
+
} catch (err) {
|
|
433
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if ((action === 'doctor' && subAction === 'revert') || action === 'doctor-revert') {
|
|
439
|
+
const task = store.get(id);
|
|
440
|
+
if (!task) {
|
|
441
|
+
sendJson(res, 404, NOT_FOUND);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
const body = await readBody(req, res);
|
|
445
|
+
const bodyData = (body && body.body) || {};
|
|
446
|
+
try {
|
|
447
|
+
const result = await revertDoctorFix(task, { store, user: bodyData.user || 'operator' });
|
|
448
|
+
if (!result.ok) {
|
|
449
|
+
sendJson(res, 400, { ok: false, error: result.error });
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
sendJson(res, 200, {
|
|
453
|
+
ok: true,
|
|
454
|
+
taskId: id,
|
|
455
|
+
task: redactTaskSecrets(task),
|
|
456
|
+
revertedCode: result.revertedCode,
|
|
457
|
+
});
|
|
458
|
+
} catch (err) {
|
|
459
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
460
|
+
}
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if ((action === 'doctor' && subAction === 'diagnose') || action === 'doctor-diagnose') {
|
|
465
|
+
const task = store.get(id);
|
|
466
|
+
if (!task) {
|
|
467
|
+
sendJson(res, 404, NOT_FOUND);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
const body = await readBody(req, res);
|
|
471
|
+
const bodyData = (body && body.body) || {};
|
|
472
|
+
const runId = bodyData.runId;
|
|
473
|
+
const runs = store.getHistory(id, 10);
|
|
474
|
+
const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
|
|
475
|
+
if (!targetRun) {
|
|
476
|
+
sendJson(res, 404, { ok: false, error: 'No run history found to diagnose' });
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
const doctorReport = await diagnoseFailure({
|
|
481
|
+
task,
|
|
482
|
+
runInfo: targetRun,
|
|
483
|
+
ask: scheduler?.askModel,
|
|
484
|
+
});
|
|
485
|
+
targetRun.doctorReport = doctorReport;
|
|
486
|
+
sendJson(res, 200, { ok: true, taskId: id, doctorReport });
|
|
487
|
+
} catch (err) {
|
|
488
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
489
|
+
}
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
381
492
|
if (action === 'prompt-rollback') {
|
|
382
493
|
const task = store.get(id);
|
|
383
494
|
if (!task) {
|
|
@@ -698,6 +809,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
|
|
|
698
809
|
}
|
|
699
810
|
const id = parts[3];
|
|
700
811
|
const action = parts[4];
|
|
812
|
+
const subAction = parts[5];
|
|
701
813
|
|
|
702
814
|
if (!id) {
|
|
703
815
|
if (req.method === 'GET') {
|
|
@@ -716,7 +828,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
|
|
|
716
828
|
}
|
|
717
829
|
|
|
718
830
|
if (req.method === 'POST') {
|
|
719
|
-
const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
|
|
831
|
+
const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
|
|
720
832
|
if (handled) return;
|
|
721
833
|
}
|
|
722
834
|
if (req.method === 'GET' && !action) {
|
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
|
+
|