@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 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) {
@@ -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
+