@goodandready/dsh-cron 0.3.12 → 0.3.14

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 CHANGED
@@ -90,6 +90,30 @@ graph TD
90
90
 
91
91
  ## ✨ Features & Capabilities
92
92
 
93
+ ### 🐝 Swarm Delegation & Parallel Multi-Agent Execution (#350)
94
+ - **Controlled Worker Pool**: Configure `concurrencyLimit` (1..10, default 3) to execute independent subtasks in parallel without overloading system resources.
95
+ - **Fault-Tolerant Decomposition**: Subtask failures are isolated; the swarm aggregates all successful results and flags partial errors gracefully.
96
+ - **Automated Synthesis**: Aggregates worker outputs into a structured Markdown digest or delegates synthesis to a designated synthesizer model.
97
+ - **Swarm Metrics & History**: Run history records total, succeeded, failed counts, and duration alongside detailed per-worker execution logs.
98
+
99
+ ### 🔍 Git Diff & Interactive Patch Review (#351)
100
+ - **Automatic Diff Capture**: Tasks running in Git worktrees capture modified files, stat summary, and unified diffs (up to 256 KiB) automatically.
101
+ - **1-Click Discard**: Revert uncommitted working directory changes safely with a single click (`git reset --hard HEAD && git clean -fd`).
102
+ - **1-Click Apply**: Re-apply previously captured diffs directly to the repository (`git apply --whitespace=fix`).
103
+ - **Interactive Run Preview**: Color-coded diff inspector embedded directly into the execution history drawer.
104
+
105
+ ### 🔀 Decision Nodes & Conditional IF/ELSE Branching (#352)
106
+ - **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`).
107
+ - **Fallback Else**: Optional `elseTask` triggers when no branch condition matches.
108
+ - **Deadlock-Free Synchronization**: Untriggered branch tasks are safely recorded with `status: 'skipped'`, unblocking downstream join barriers (`joinPolicy: 'all_success'`).
109
+ - **Graph Visualization**: DAG Graph View displays decision badges (`🔀 Decision`) and condition labels on connecting edges.
110
+
111
+ ### 🩺 Autonomous Self-Healing Doctor (#353)
112
+ - **Failure Classification**: Automatic detection of 8 failure categories: `syntax_error`, `dependency_missing`, `rate_limit`, `auth_failed`, `schema_change`, `timeout`, `network_error`, `runtime_error`.
113
+ - **Unified Diff**: Clean line-by-line diff preview of proposed fixes before application.
114
+ - **1-Click Apply & Revert**: Dedicated `/doctor/apply` and `/doctor/revert` endpoints with full revision backup (up to 10 versions preserved in `task.revisions`).
115
+ - **Guarded Autonomous Auto-Healing**: When `autoHeal: true`, high-confidence fixes are applied automatically with a strict 24-hour rate limit to prevent repair loops.
116
+
93
117
  ### 1. Visual Task Manager & Sidebar Action
94
118
  Click the clock icon in the DSH sidebar (positioned next to the new-session button) to open the management panel:
95
119
  * **Status Filter Tabs**: toggle between **All**, **Active**, **Paused**, and **Completed** tasks.
package/README.ru.md CHANGED
@@ -90,6 +90,30 @@ graph TD
90
90
 
91
91
  ## ✨ Возможности
92
92
 
93
+ ### 🐝 Роевая декомпозиция задач и мультиагентный запуск (Swarm Delegation) (#350)
94
+ - **Управляемый пул воркеров**: Настройка `concurrencyLimit` (1..10, по умолчанию 3) для параллельного выполнения независимых подзадач без перегрузки системы.
95
+ - **Отказоустойчивая декомпозиция**: Ошибки отдельных воркеров изолированы; рой объединяет успешные результаты и корректно фиксирует частичные сбои.
96
+ - **Автоматический синтез**: Сведение результатов всех воркеров в структурированный Markdown-дайджест или через выделенную модель-синтезатор.
97
+ - **Метрики и история роя**: Сохранение количества успешных/сбойных задач, времени выполнения и детальных логов каждого воркера.
98
+
99
+ ### 🔍 Просмотр Git Diff и интерактивное подтверждение патчей (#351)
100
+ - **Автоматический захват диффов**: Задачи в Git-каталогах автоматически сохраняют список файлов, сводку изменений и унифицированный diff (до 256 КиБ).
101
+ - **1-Click Discard**: Безопасный откат незакоммиченных изменений в один клик (`git reset --hard HEAD && git clean -fd`).
102
+ - **1-Click Apply**: Повторное наложение сохранённого патча на рабочий каталог (`git apply --whitespace=fix`).
103
+ - **Интерактивный просмотр**: Цветовая подсветка строк diff в карточке истории запусков.
104
+
105
+ ### 🔀 Решающие узлы и условные развилки IF/ELSE (#352)
106
+ - **Маршрутизация по условию**: Задачи могут задавать `branchRules` для проверки результата (`exitCode`, `output`, `output.json.<path>`, `status`) через операторы (`equals`, `notequals`, `contains`, `gt`, `gte`, `lt`, `lte`, `regex`, `exists`).
107
+ - **Ветка Else**: Резервная задача `elseTask` запускается, если ни одно из правил не совпало.
108
+ - **Защита от взаимных блокировок**: Невыбранные ветви автоматически регистрируются со статусом `skipped`, предотвращая зависание барьеров синхронизации (`joinPolicy: 'all_success'`).
109
+ - **Визуализация в графе**: DAG Graph View отображает бейджи решающих узлов (`🔀 Decision`) и условия на соединительных линиях.
110
+
111
+ ### 🩺 Автономный Доктор самовосстановления (Self-Healing Doctor) (#353)
112
+ - **Классификация сбоев**: Автоматическое распознавание 8 типов ошибок (`syntax_error`, `dependency_missing`, `rate_limit`, `auth_failed`, `schema_change`, `timeout`, `network_error`, `runtime_error`).
113
+ - **Unified Diff**: Наглядный просмотр диффа предлагаемого исправления до его применения.
114
+ - **1-Click применение и откат**: API эндпоинты `/doctor/apply` и `/doctor/revert` с сохранением до 10 ревизий в `task.revisions`.
115
+ - **Безопасная авто-починка**: При включённом `autoHeal: true` патчи с высокой уверенностью (`high`) применяются автоматически с лимитом не чаще 1 раза в сутки.
116
+
93
117
  ### 1. Визуальный менеджер задач
94
118
  Нажмите на иконку-часы в сайдбаре DSH (рядом с кнопкой новой сессии), чтобы открыть панель:
95
119
  * **Табы фильтрации**: **Все**, **Активные**, **На паузе**, **Завершённые**.
package/README.zh.md CHANGED
@@ -90,6 +90,30 @@ graph TD
90
90
 
91
91
  ## ✨ 功能与能力
92
92
 
93
+ ### 🐝 群蚁协同委托与多智能体并行执行 (Swarm Delegation) (#350)
94
+ - **受控工作池**:配置并发限制 `concurrencyLimit`(1..10,默认 3),在不耗尽系统资源的前提下并行处理独立子任务。
95
+ - **高容错分解**:单个子任务异常完全隔离,群体自动聚合所有成功输出,并在存在异常时清晰上报。
96
+ - **智能结果汇编**:支持将所有工作节点的执行输出聚合成结构化 Markdown 报告或交由综合模型提炼总结。
97
+ - **群体指标与历史**:运行历史完整记录成功/失败统计、耗时及每个子节点的执行快照与日志。
98
+
99
+ ### 🔍 Git Diff 变更查看与交互式补丁审查 (#351)
100
+ - **自动 Diff 捕获**:在 Git 工作区执行的任务会自动捕获变更文件列表、状态统计及完整 Unified Diff(最高 256 KiB)。
101
+ - **一键丢弃 (Discard)**:一键清空工作区未提交改动,安全重置仓库状态(`git reset --hard HEAD && git clean -fd`)。
102
+ - **一键应用 (Apply)**:将保存的 Diff 补丁直接重新注入并应用到工作区(`git apply --whitespace=fix`)。
103
+ - **交互式审阅面板**:在运行详情抽屉中直接查看带有色彩区分的高亮 Diff 变更对比。
104
+
105
+ ### 🔀 决策节点与条件分支 IF/ELSE (#352)
106
+ - **条件路由**:任务可配置 `branchRules`(评估 `exitCode`、`output`、`output.json.<path>` 等字段),支持 `equals`、`notequals`、`contains`、`gt`、`lt`、`regex` 等操作符。
107
+ - **回退分支**:若无匹配规则,自动执行 `elseTask`。
108
+ - **死锁防护**:未触发的分支自动标记为 `skipped`,确保后续汇聚栅栏(`joinPolicy: 'all_success'`)正常通过。
109
+ - **DAG 可视化**:图谱界面直观展示决策标识(`🔀 Decision`)与连线判断条件。
110
+
111
+ ### 🩺 自愈医生(Self-Healing Doctor)(#353)
112
+ - **故障诊断分类**:自动分类 8 类运行故障(语法错误、缺少依赖、频率限制、认证失效、架构变更、超时、网络错误、运行时异常)。
113
+ - **统一代码差分**:生成直观的标准 Unified Diff 对比。
114
+ - **一键应用与回滚**:提供 `/doctor/apply` 与 `/doctor/revert` 接口,自动保留多达 10 个历史版本(`task.revisions`)。
115
+ - **受控自主修复**:启用 `autoHeal: true` 时,高可信度(`high`)修复自动生效,且强制 24 小时频次限制防止死循环。
116
+
93
117
  ### 1. 可视化任务管理器
94
118
  点击 DSH 侧边栏中的时钟图标(位于“新会话”按钮旁)打开管理面板:
95
119
  * **状态过滤标签**:**全部**、**活跃**、**已暂停**、**已完成**。
package/lib/api.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { captureGitDiff, discardGitChanges, applyGitDiff } from './git-diff.js';
2
+ import { applyDoctorFix, revertDoctorFix, diagnoseFailure } from './doctor.js';
1
3
  import { handleTaskWebhookRoute } from './api-task-webhook.js';
2
4
  import { rollbackPromptVersion, sanitizePromptHistory } from './prompt-evolver.js';
3
5
  import { sanitizeAdaptiveConfig } from './adaptive.js';
@@ -81,6 +83,7 @@ export function createCronApiHandler(store, scheduler, collection) {
81
83
  const scope = parts[1]; // 'tasks' | 'action' | 'heartbeat-ping'
82
84
  const id = parts[2];
83
85
  const action = parts[3];
86
+ const subAction = parts[4];
84
87
 
85
88
  if (scope === 'telegram' && id === 'webhook') {
86
89
  await handleTelegramWebhook({ store, scheduler, req, res });
@@ -131,7 +134,7 @@ export function createCronApiHandler(store, scheduler, collection) {
131
134
  return;
132
135
  }
133
136
 
134
- await handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken, ctx });
137
+ await handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx });
135
138
  } catch (err) {
136
139
  sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
137
140
  }
@@ -293,7 +296,7 @@ function buildTaskRecord(store, body, taskType, parsed) {
293
296
 
294
297
  // ------------------------------------------------------------ single task
295
298
 
296
- async function handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken, ctx }) {
299
+ async function handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx }) {
297
300
  if (action === 'heartbeat') {
298
301
  handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
299
302
  return;
@@ -344,6 +347,43 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
344
347
  sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
345
348
  return;
346
349
  }
350
+ if (action === 'git-diff') {
351
+ const task = store.get(id);
352
+ if (!task) {
353
+ sendJson(res, 404, NOT_FOUND);
354
+ return;
355
+ }
356
+ const runs = store.getHistory(id, 10);
357
+ const runId = url.searchParams.get('runId') || subAction;
358
+ const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
359
+ const diffData = targetRun?.gitDiff || null;
360
+ sendJson(res, 200, {
361
+ ok: true,
362
+ taskId: id,
363
+ runId: targetRun?.id || null,
364
+ gitDiff: diffData,
365
+ });
366
+ return;
367
+ }
368
+ if (action === 'doctor') {
369
+ const task = store.get(id);
370
+ if (!task) {
371
+ sendJson(res, 404, NOT_FOUND);
372
+ return;
373
+ }
374
+ const runs = store.getHistory(id, 5);
375
+ const latestRun = runs[0] || null;
376
+ const reportRun = runs.find((r) => r.doctorReport) || latestRun;
377
+ sendJson(res, 200, {
378
+ ok: true,
379
+ taskId: id,
380
+ doctorReport: reportRun?.doctorReport || null,
381
+ revisions: task.revisions || [],
382
+ previousPrompt: task.previousPrompt || null,
383
+ autoHeal: Boolean(task.autoHeal || task.selfHealing?.autoApply),
384
+ });
385
+ return;
386
+ }
347
387
  if (!action) {
348
388
  const task = store.get(id);
349
389
  if (!task) {
@@ -362,7 +402,7 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
362
402
  }
363
403
  }
364
404
  if (req.method === 'POST') {
365
- const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
405
+ const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
366
406
  if (handled) return;
367
407
  }
368
408
  if (req.method === 'PATCH') {
@@ -377,7 +417,141 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
377
417
  }
378
418
 
379
419
  /** Task actions that are plain POSTs. Returns true when one matched. */
380
- async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
420
+ async function handleItemPost({ store, scheduler, req, res, url, id, action, subAction }) {
421
+ if (action === 'git-diff' && (subAction === 'discard' || subAction === 'revert')) {
422
+ const task = store.get(id);
423
+ if (!task) {
424
+ sendJson(res, 404, NOT_FOUND);
425
+ return true;
426
+ }
427
+ const targetDir = task.cwd;
428
+ if (!targetDir) {
429
+ sendJson(res, 400, { ok: false, error: 'Task does not define a working directory (cwd)' });
430
+ return true;
431
+ }
432
+ const result = await discardGitChanges(targetDir);
433
+ sendJson(res, result.ok ? 200 : 500, result);
434
+ return true;
435
+ }
436
+
437
+ if (action === 'git-diff' && subAction === 'apply') {
438
+ const task = store.get(id);
439
+ if (!task) {
440
+ sendJson(res, 404, NOT_FOUND);
441
+ return true;
442
+ }
443
+ const body = await readBody(req, res);
444
+ const bodyData = (body && body.body) || {};
445
+ let patch = bodyData.diff || bodyData.patch;
446
+ if (!patch) {
447
+ const runs = store.getHistory(id, 5);
448
+ const targetRun = runs.find((r) => r.gitDiff?.diff);
449
+ patch = targetRun?.gitDiff?.diff;
450
+ }
451
+ if (!patch) {
452
+ sendJson(res, 400, { ok: false, error: 'No diff patch provided or found in run history' });
453
+ return true;
454
+ }
455
+ const targetDir = task.cwd;
456
+ if (!targetDir) {
457
+ sendJson(res, 400, { ok: false, error: 'Task does not define a working directory (cwd)' });
458
+ return true;
459
+ }
460
+ const result = await applyGitDiff(targetDir, patch);
461
+ sendJson(res, result.ok ? 200 : 500, result);
462
+ return true;
463
+ }
464
+
465
+ if ((action === 'doctor' && subAction === 'apply') || action === 'doctor-apply') {
466
+ const task = store.get(id);
467
+ if (!task) {
468
+ sendJson(res, 404, NOT_FOUND);
469
+ return true;
470
+ }
471
+ const body = await readBody(req, res);
472
+ const bodyData = (body && body.body) || {};
473
+ let fix = bodyData.fix || bodyData.doctorReport;
474
+ if (!fix) {
475
+ const runs = store.getHistory(id, 5);
476
+ const reportRun = runs.find((r) => r.doctorReport);
477
+ fix = reportRun?.doctorReport;
478
+ }
479
+ if (!fix || !fix.suggestedFix) {
480
+ sendJson(res, 400, { ok: false, error: 'No doctor fix found to apply' });
481
+ return true;
482
+ }
483
+ try {
484
+ const result = await applyDoctorFix(task, fix, { store, user: bodyData.user || 'operator' });
485
+ if (!result.ok) {
486
+ sendJson(res, 400, { ok: false, error: result.error });
487
+ return true;
488
+ }
489
+ sendJson(res, 200, {
490
+ ok: true,
491
+ taskId: id,
492
+ task: redactTaskSecrets(task),
493
+ revision: result.revision,
494
+ });
495
+ } catch (err) {
496
+ sendJson(res, 500, { ok: false, error: err.message });
497
+ }
498
+ return true;
499
+ }
500
+
501
+ if ((action === 'doctor' && subAction === 'revert') || action === 'doctor-revert') {
502
+ const task = store.get(id);
503
+ if (!task) {
504
+ sendJson(res, 404, NOT_FOUND);
505
+ return true;
506
+ }
507
+ const body = await readBody(req, res);
508
+ const bodyData = (body && body.body) || {};
509
+ try {
510
+ const result = await revertDoctorFix(task, { store, user: bodyData.user || 'operator' });
511
+ if (!result.ok) {
512
+ sendJson(res, 400, { ok: false, error: result.error });
513
+ return true;
514
+ }
515
+ sendJson(res, 200, {
516
+ ok: true,
517
+ taskId: id,
518
+ task: redactTaskSecrets(task),
519
+ revertedCode: result.revertedCode,
520
+ });
521
+ } catch (err) {
522
+ sendJson(res, 500, { ok: false, error: err.message });
523
+ }
524
+ return true;
525
+ }
526
+
527
+ if ((action === 'doctor' && subAction === 'diagnose') || action === 'doctor-diagnose') {
528
+ const task = store.get(id);
529
+ if (!task) {
530
+ sendJson(res, 404, NOT_FOUND);
531
+ return true;
532
+ }
533
+ const body = await readBody(req, res);
534
+ const bodyData = (body && body.body) || {};
535
+ const runId = bodyData.runId;
536
+ const runs = store.getHistory(id, 10);
537
+ const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
538
+ if (!targetRun) {
539
+ sendJson(res, 404, { ok: false, error: 'No run history found to diagnose' });
540
+ return true;
541
+ }
542
+ try {
543
+ const doctorReport = await diagnoseFailure({
544
+ task,
545
+ runInfo: targetRun,
546
+ ask: scheduler?.askModel,
547
+ });
548
+ targetRun.doctorReport = doctorReport;
549
+ sendJson(res, 200, { ok: true, taskId: id, doctorReport });
550
+ } catch (err) {
551
+ sendJson(res, 500, { ok: false, error: err.message });
552
+ }
553
+ return true;
554
+ }
381
555
  if (action === 'prompt-rollback') {
382
556
  const task = store.get(id);
383
557
  if (!task) {
@@ -698,6 +872,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
698
872
  }
699
873
  const id = parts[3];
700
874
  const action = parts[4];
875
+ const subAction = parts[5];
701
876
 
702
877
  if (!id) {
703
878
  if (req.method === 'GET') {
@@ -716,7 +891,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
716
891
  }
717
892
 
718
893
  if (req.method === 'POST') {
719
- const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
894
+ const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
720
895
  if (handled) return;
721
896
  }
722
897
  if (req.method === 'GET' && !action) {
@@ -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
+