@goodandready/dsh-cron 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/dag.js ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Directed Acyclic Graph (DAG) Engine for Chained Tasks (#328, #334)
3
+ * Provides cycle detection, topological sorting, dependency resolution,
4
+ * and graph visualization modeling for multi-step task workflows.
5
+ */
6
+
7
+ /**
8
+ * Normalizes dependency specifications into an array of string task IDs.
9
+ * Accepts arrays, comma/space separated strings, or single task IDs.
10
+ *
11
+ * @param {string|string[]|undefined|null} deps
12
+ * @returns {string[]} Deduplicated array of valid task IDs
13
+ */
14
+ export function sanitizeDependsOn(deps) {
15
+ if (!deps) return [];
16
+ let rawList = [];
17
+ if (Array.isArray(deps)) {
18
+ rawList = deps;
19
+ } else if (typeof deps === 'string') {
20
+ rawList = deps.split(/[,;\s]+/);
21
+ }
22
+ const result = [];
23
+ const seen = new Set();
24
+ for (const item of rawList) {
25
+ if (typeof item !== 'string') continue;
26
+ const trimmed = item.trim();
27
+ if (trimmed && !seen.has(trimmed)) {
28
+ seen.add(trimmed);
29
+ result.push(trimmed);
30
+ }
31
+ }
32
+ return result;
33
+ }
34
+
35
+ /**
36
+ * Validates join policy string.
37
+ *
38
+ * @param {string|undefined} policy
39
+ * @returns {'all_success'|'any_success'}
40
+ */
41
+ export function sanitizeJoinPolicy(policy) {
42
+ if (policy === 'any_success') return 'any_success';
43
+ return 'all_success';
44
+ }
45
+
46
+ /**
47
+ * Parses and returns a list of target task IDs for chaining (onSuccess / onFailure).
48
+ * Supports single ID, comma-separated IDs, or arrays of IDs.
49
+ *
50
+ * @param {string|string[]|undefined|null} target
51
+ * @returns {string[]}
52
+ */
53
+ export function parseChainTargets(target) {
54
+ if (!target) return [];
55
+ if (Array.isArray(target)) {
56
+ return target.map((t) => String(t || '').trim()).filter(Boolean);
57
+ }
58
+ if (typeof target === 'string') {
59
+ return target.split(/[,;\s]+/).map((t) => t.trim()).filter(Boolean);
60
+ }
61
+ return [];
62
+ }
63
+
64
+ /**
65
+ * Validates that adding or updating a task does not introduce cyclic dependencies.
66
+ * Analyzes both `dependsOn` relationships and `onSuccess` chain links.
67
+ *
68
+ * @param {Array<Object>|Map<string, Object>|Object} allTasks All current tasks
69
+ * @param {Object} [pendingTask] Task being created or updated
70
+ * @returns {{ ok: boolean, error?: string, cycle?: string[] }}
71
+ */
72
+ export function validateDAG(allTasks, pendingTask = null) {
73
+ // Build task dictionary
74
+ const taskMap = new Map();
75
+ if (Array.isArray(allTasks)) {
76
+ for (const t of allTasks) {
77
+ if (t && t.id) taskMap.set(t.id, t);
78
+ }
79
+ } else if (allTasks instanceof Map) {
80
+ for (const [k, v] of allTasks.entries()) {
81
+ if (v && v.id) taskMap.set(v.id, v);
82
+ }
83
+ } else if (allTasks && typeof allTasks === 'object') {
84
+ for (const k of Object.keys(allTasks)) {
85
+ const t = allTasks[k];
86
+ if (t && t.id) taskMap.set(t.id, t);
87
+ }
88
+ }
89
+
90
+ // Merge pending task if provided
91
+ if (pendingTask && pendingTask.id) {
92
+ taskMap.set(pendingTask.id, { ...(taskMap.get(pendingTask.id) || {}), ...pendingTask });
93
+ }
94
+
95
+ // Self-dependency check
96
+ for (const [id, task] of taskMap.entries()) {
97
+ const deps = sanitizeDependsOn(task.dependsOn);
98
+ if (deps.includes(id)) {
99
+ return { ok: false, error: `Task "${task.title || id}" cannot depend on itself`, cycle: [id, id] };
100
+ }
101
+ const chainTargets = parseChainTargets(task.onSuccess);
102
+ if (chainTargets.includes(id)) {
103
+ return { ok: false, error: `Task "${task.title || id}" cannot trigger itself as next step`, cycle: [id, id] };
104
+ }
105
+ }
106
+
107
+ // Construct adjacency list: Edge from U to V means U must complete before V runs.
108
+ // Sources of edges:
109
+ // 1) dependsOn: If V depends on U, then U -> V
110
+ // 2) onSuccess: If U has onSuccess V, then U -> V
111
+ const adj = new Map();
112
+ for (const id of taskMap.keys()) {
113
+ adj.set(id, new Set());
114
+ }
115
+
116
+ for (const [id, task] of taskMap.entries()) {
117
+ // 1) dependsOn (deps -> id)
118
+ const deps = sanitizeDependsOn(task.dependsOn);
119
+ for (const depId of deps) {
120
+ if (!adj.has(depId)) adj.set(depId, new Set());
121
+ adj.get(depId).add(id);
122
+ }
123
+
124
+ // 2) onSuccess (id -> target)
125
+ const targets = parseChainTargets(task.onSuccess);
126
+ for (const targetId of targets) {
127
+ if (!adj.has(id)) adj.set(id, new Set());
128
+ adj.get(id).add(targetId);
129
+ }
130
+ }
131
+
132
+ // DFS Cycle Detection (3 colors: 0=unvisited, 1=visiting, 2=visited)
133
+ const state = new Map(); // id -> 0 | 1 | 2
134
+ const path = [];
135
+
136
+ for (const node of adj.keys()) {
137
+ state.set(node, 0);
138
+ }
139
+
140
+ function dfs(u) {
141
+ state.set(u, 1);
142
+ path.push(u);
143
+
144
+ const neighbors = adj.get(u) || new Set();
145
+ for (const v of neighbors) {
146
+ const vState = state.get(v) || 0;
147
+ if (vState === 1) {
148
+ // Cycle detected
149
+ const cycleStartIndex = path.indexOf(v);
150
+ const cyclePath = path.slice(cycleStartIndex).concat(v);
151
+ return { ok: false, error: `Cyclic dependency detected: ${cyclePath.join(' -> ')}`, cycle: cyclePath };
152
+ }
153
+ if (vState === 0) {
154
+ const res = dfs(v);
155
+ if (!res.ok) return res;
156
+ }
157
+ }
158
+
159
+ path.pop();
160
+ state.set(u, 2);
161
+ return { ok: true };
162
+ }
163
+
164
+ for (const node of adj.keys()) {
165
+ if ((state.get(node) || 0) === 0) {
166
+ const res = dfs(node);
167
+ if (!res.ok) return res;
168
+ }
169
+ }
170
+
171
+ return { ok: true };
172
+ }
173
+
174
+ /**
175
+ * Returns topological sort of task IDs or throws error if cycle exists.
176
+ *
177
+ * @param {Array<Object>|Map<string, Object>} tasks
178
+ * @returns {string[]} Ordered task IDs
179
+ */
180
+ export function topologicalSort(tasks) {
181
+ const check = validateDAG(tasks);
182
+ if (!check.ok) {
183
+ throw new Error(check.error || 'Cyclic dependency in task DAG');
184
+ }
185
+
186
+ const taskMap = new Map();
187
+ if (Array.isArray(tasks)) {
188
+ for (const t of tasks) if (t?.id) taskMap.set(t.id, t);
189
+ } else if (tasks instanceof Map) {
190
+ for (const [k, v] of tasks.entries()) if (v?.id) taskMap.set(v.id, v);
191
+ }
192
+
193
+ // In-degree calculation
194
+ const inDegree = new Map();
195
+ const adj = new Map();
196
+ for (const id of taskMap.keys()) {
197
+ inDegree.set(id, 0);
198
+ adj.set(id, new Set());
199
+ }
200
+
201
+ for (const [id, task] of taskMap.entries()) {
202
+ const deps = sanitizeDependsOn(task.dependsOn);
203
+ for (const depId of deps) {
204
+ if (taskMap.has(depId)) {
205
+ adj.get(depId).add(id);
206
+ inDegree.set(id, (inDegree.get(id) || 0) + 1);
207
+ }
208
+ }
209
+ const targets = parseChainTargets(task.onSuccess);
210
+ for (const targetId of targets) {
211
+ if (taskMap.has(targetId)) {
212
+ adj.get(id).add(targetId);
213
+ inDegree.set(targetId, (inDegree.get(targetId) || 0) + 1);
214
+ }
215
+ }
216
+ }
217
+
218
+ const queue = [];
219
+ for (const [id, deg] of inDegree.entries()) {
220
+ if (deg === 0) queue.push(id);
221
+ }
222
+
223
+ const order = [];
224
+ while (queue.length > 0) {
225
+ const u = queue.shift();
226
+ order.push(u);
227
+ for (const v of adj.get(u) || []) {
228
+ const newDeg = (inDegree.get(v) || 0) - 1;
229
+ inDegree.set(v, newDeg);
230
+ if (newDeg === 0) queue.push(v);
231
+ }
232
+ }
233
+
234
+ return order;
235
+ }
236
+
237
+ /**
238
+ * Builds a visual graph model (nodes and edges) for UI rendering (#334).
239
+ *
240
+ * @param {Array<Object>} tasks All relevant tasks
241
+ * @param {Array<Object>} [snapshots] Checkpoints for the chain run
242
+ * @param {string} [focusTaskId] Task ID to center or highlight
243
+ * @returns {{ nodes: Array<Object>, edges: Array<Object>, hasFailure: boolean }}
244
+ */
245
+ export function buildTaskGraphModel(tasks = [], snapshots = [], focusTaskId = null) {
246
+ const taskMap = new Map();
247
+ for (const t of tasks) {
248
+ if (t?.id) taskMap.set(t.id, t);
249
+ }
250
+
251
+ const snapshotMap = new Map();
252
+ for (const s of snapshots) {
253
+ if (s?.stepTaskId) snapshotMap.set(s.stepTaskId, s);
254
+ }
255
+
256
+ const nodes = [];
257
+ const edges = [];
258
+ let hasFailure = false;
259
+
260
+ for (const [id, task] of taskMap.entries()) {
261
+ const snap = snapshotMap.get(id);
262
+ let nodeStatus = 'pending';
263
+ let durationMs = 0;
264
+ let outputSnippet = '';
265
+ let retryCount = 0;
266
+
267
+ if (snap) {
268
+ nodeStatus = snap.status === 'completed' || snap.status === 'success' ? 'success' : 'failed';
269
+ durationMs = snap.durationMs || 0;
270
+ retryCount = snap.retryCount || 0;
271
+ if (snap.output) {
272
+ outputSnippet = String(snap.output).slice(0, 200);
273
+ }
274
+ if (nodeStatus === 'failed') {
275
+ hasFailure = true;
276
+ }
277
+ } else if (task.status === 'running') {
278
+ nodeStatus = 'running';
279
+ }
280
+
281
+ nodes.push({
282
+ id,
283
+ title: task.title || id,
284
+ type: task.type || 'prompt',
285
+ status: nodeStatus,
286
+ durationMs,
287
+ retryCount,
288
+ outputSnippet,
289
+ isFocus: Boolean(focusTaskId && id === focusTaskId),
290
+ isRetryable: nodeStatus === 'failed',
291
+ dependsOn: sanitizeDependsOn(task.dependsOn),
292
+ joinPolicy: sanitizeJoinPolicy(task.joinPolicy),
293
+ });
294
+
295
+ // Edges from dependsOn
296
+ const deps = sanitizeDependsOn(task.dependsOn);
297
+ for (const depId of deps) {
298
+ edges.push({
299
+ from: depId,
300
+ to: id,
301
+ type: 'dependsOn',
302
+ label: task.joinPolicy === 'any_success' ? 'join:any' : 'join:all',
303
+ });
304
+ }
305
+
306
+ // Edges from onSuccess
307
+ const targets = parseChainTargets(task.onSuccess);
308
+ for (const targetId of targets) {
309
+ edges.push({
310
+ from: id,
311
+ to: targetId,
312
+ type: 'onSuccess',
313
+ label: 'onSuccess',
314
+ });
315
+ }
316
+
317
+ // Edges from onFailure
318
+ const failTargets = parseChainTargets(task.onFailure);
319
+ for (const failId of failTargets) {
320
+ edges.push({
321
+ from: id,
322
+ to: failId,
323
+ type: 'onFailure',
324
+ label: 'onFailure',
325
+ });
326
+ }
327
+ }
328
+
329
+ return { nodes, edges, hasFailure };
330
+ }
@@ -25,7 +25,12 @@ export function buildPromptContext(task = {}, options = {}) {
25
25
  const prevDuration = formatDuration(options.prevDurationMs);
26
26
  const prevCost = options.prevCostUsd != null ? `$${Number(options.prevCostUsd || 0).toFixed(4)}` : '';
27
27
 
28
- return {
28
+ const filePath = options.filePath != null ? String(options.filePath) : '';
29
+ const fileName = options.fileName != null ? String(options.fileName) : '';
30
+ const fileSize = options.fileSize != null ? String(options.fileSize) : '';
31
+ const fileEvent = options.fileEvent != null ? String(options.fileEvent) : '';
32
+
33
+ const ctx = {
29
34
  // Chaining context
30
35
  'prev.output': prevOutput,
31
36
  'prev_output': prevOutput,
@@ -39,6 +44,20 @@ export function buildPromptContext(task = {}, options = {}) {
39
44
  'prev.cost': prevCost,
40
45
  'prev_cost': prevCost,
41
46
 
47
+ // File watcher context (#327)
48
+ 'filePath': filePath,
49
+ 'file.path': filePath,
50
+ 'file_path': filePath,
51
+ 'fileName': fileName,
52
+ 'file.name': fileName,
53
+ 'file_name': fileName,
54
+ 'fileSize': fileSize,
55
+ 'file.size': fileSize,
56
+ 'file_size': fileSize,
57
+ 'fileEvent': fileEvent,
58
+ 'file.event': fileEvent,
59
+ 'file_event': fileEvent,
60
+
42
61
  // Time context
43
62
  'date': formatDate(now),
44
63
  'time': formatTime(now),
@@ -53,17 +72,41 @@ export function buildPromptContext(task = {}, options = {}) {
53
72
  'task.title': task.title || '',
54
73
  'task_title': task.title || '',
55
74
  };
75
+
76
+ // DAG / Fan-In predecessor outputs (#328)
77
+ if (options.prevOutputs && typeof options.prevOutputs === 'object') {
78
+ for (const [k, v] of Object.entries(options.prevOutputs)) {
79
+ const valStr = v != null ? String(v) : '';
80
+ ctx[`prevOutputs.${k}`] = valStr;
81
+ ctx[`outputs.${k}`] = valStr;
82
+ ctx[`step.${k}.output`] = valStr;
83
+ ctx[`prevOutputs['${k}']`] = valStr;
84
+ ctx[`prevOutputs["${k}"]`] = valStr;
85
+ }
86
+ }
87
+
88
+ return ctx;
56
89
  }
57
90
 
58
91
  /**
59
92
  * Replace {{var}} or {var} placeholders with values from context.
60
- * Supports dot notation like {{prev.output}}. Unknown variables are left untouched.
93
+ * Supports dot notation like {{prev.output}} and bracket notation like {{prevOutputs['step1']}}.
61
94
  */
62
95
  export function interpolateTaskPrompt(text, context = {}) {
63
96
  if (typeof text !== 'string') return text;
64
97
  if (!text) return '';
65
98
 
66
- return text.replace(/\{\{([a-zA-Z0-9_.]+)\}\}/g, (match, varName) => {
99
+ // 1. Bracket notation: {{prevOutputs['step1']}} or {prevOutputs['step1']} or with double quotes
100
+ let result = text.replace(/\{\{?prevOutputs\[['"]([^'"]+)['"]\]\}?\}/g, (match, stepId) => {
101
+ const key = `prevOutputs.${stepId}`;
102
+ if (Object.prototype.hasOwnProperty.call(context, key)) {
103
+ return context[key];
104
+ }
105
+ return match;
106
+ });
107
+
108
+ // 2. Standard {{var}} and {var}
109
+ result = result.replace(/\{\{([a-zA-Z0-9_.]+)\}\}/g, (match, varName) => {
67
110
  if (Object.prototype.hasOwnProperty.call(context, varName)) {
68
111
  const val = context[varName];
69
112
  return val == null ? '' : String(val);
@@ -76,6 +119,8 @@ export function interpolateTaskPrompt(text, context = {}) {
76
119
  }
77
120
  return match;
78
121
  });
122
+
123
+ return result;
79
124
  }
80
125
 
81
126
  /**
package/lib/routes.js CHANGED
@@ -10,6 +10,7 @@ import { recipesByCategory } from './recipes.js';
10
10
  import { sendTelegramMessage, createTelegramTaskKeyboard } from './telegram.js';
11
11
  import { createKanbanCard } from './integrations.js';
12
12
  import { sanitizeSettingsPayload, applySettingsToScope } from './settings.js';
13
+ import { buildTaskGraphModel } from './dag.js';
13
14
 
14
15
  export function registerRoutes(ctx, { store, scheduler, recommendations }) {
15
16
  // handler per route family; registered WITHOUT the trailing slash (#96)
@@ -220,6 +221,27 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
220
221
  export async function handleChainRoutes(store, scheduler, req, res) {
221
222
  if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
222
223
  const url = new URL(req.url, 'http://localhost');
224
+
225
+ // GET /dsh-cron/chains/:chainRunId (#334)
226
+ const getMatch = url.pathname.match(/^\/dsh-cron\/chains\/([^\/]+)$/);
227
+ if (getMatch && req.method === 'GET') {
228
+ const chainRunId = getMatch[1];
229
+ const snapshots = store.chainCheckpointManager ? store.chainCheckpointManager.getChainSnapshots(chainRunId) : [];
230
+ const relevantTasks = [];
231
+ const stepIds = new Set(snapshots.map((s) => s.stepTaskId));
232
+ if (store.tasks) {
233
+ for (const t of Object.values(store.tasks)) {
234
+ if (t && (stepIds.has(t.id) || t.onSuccess || t.onFailure || t.dependsOn)) {
235
+ relevantTasks.push(t);
236
+ }
237
+ }
238
+ }
239
+ const graph = buildTaskGraphModel(relevantTasks, snapshots);
240
+ sendJson(res, 200, { ok: true, chainRunId, snapshots, graph });
241
+ return;
242
+ }
243
+
244
+ // POST /dsh-cron/chains/:chainRunId/retry-from/:stepTaskId (#319, #328)
223
245
  const m = url.pathname.match(/^\/dsh-cron\/chains\/([^\/]+)\/retry-from\/([^\/]+)$/);
224
246
  if (!m) {
225
247
  sendJson(res, 404, { ok: false, error: 'Route not found' });
package/lib/runner.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { logger } from './logger.js';
2
2
  import { bestEffort } from './best-effort.js';
3
3
  import { estimateTokenCost } from './integrations.js';
4
+ import { resolveDynamicCredentialsInText, resolveDynamicCredentialsInObject } from './secrets.js';
4
5
  import {
5
6
  EXTERNAL_RUNTIMES,
6
7
  normalizeTaskType,
@@ -144,7 +145,104 @@ export class SessionRunner {
144
145
  async _run(task, signal, options = {}) {
145
146
  logger.info(`[dsh-cron] executing task "${task.title}" (${task.id})`);
146
147
 
147
- // 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
148
+ // Dynamic credentials resolution in-memory (#335)
149
+ let effectiveTask = task;
150
+ const resolvedSecrets = [];
151
+ const settings = (this.store && typeof this.store.getSettings === 'function')
152
+ ? this.store.getSettings()
153
+ : {};
154
+
155
+ if (settings.enableDynamicCredentials) {
156
+ effectiveTask = { ...task };
157
+ if (typeof effectiveTask.prompt === 'string') {
158
+ const res = await resolveDynamicCredentialsInText(effectiveTask.prompt, this.ctx);
159
+ effectiveTask.prompt = res.text;
160
+ resolvedSecrets.push(...res.resolvedSecrets);
161
+ }
162
+ if (typeof effectiveTask.script === 'string') {
163
+ const res = await resolveDynamicCredentialsInText(effectiveTask.script, this.ctx);
164
+ effectiveTask.script = res.text;
165
+ resolvedSecrets.push(...res.resolvedSecrets);
166
+ }
167
+ if (typeof effectiveTask.command === 'string') {
168
+ const res = await resolveDynamicCredentialsInText(effectiveTask.command, this.ctx);
169
+ effectiveTask.command = res.text;
170
+ resolvedSecrets.push(...res.resolvedSecrets);
171
+ }
172
+ if (typeof effectiveTask.httpUrl === 'string') {
173
+ const res = await resolveDynamicCredentialsInText(effectiveTask.httpUrl, this.ctx);
174
+ effectiveTask.httpUrl = res.text;
175
+ resolvedSecrets.push(...res.resolvedSecrets);
176
+ }
177
+ if (typeof effectiveTask.httpBody === 'string') {
178
+ const res = await resolveDynamicCredentialsInText(effectiveTask.httpBody, this.ctx);
179
+ effectiveTask.httpBody = res.text;
180
+ resolvedSecrets.push(...res.resolvedSecrets);
181
+ }
182
+ if (effectiveTask.httpHeaders) {
183
+ const res = await resolveDynamicCredentialsInObject(effectiveTask.httpHeaders, this.ctx);
184
+ effectiveTask.httpHeaders = res.data;
185
+ resolvedSecrets.push(...res.resolvedSecrets);
186
+ }
187
+ if (effectiveTask.env) {
188
+ const res = await resolveDynamicCredentialsInObject(effectiveTask.env, this.ctx);
189
+ effectiveTask.env = res.data;
190
+ resolvedSecrets.push(...res.resolvedSecrets);
191
+ }
192
+ }
193
+
194
+ const maskSecrets = (result) => {
195
+ if (!result || typeof result !== 'object') return result;
196
+ if (resolvedSecrets.length > 0 && typeof result.output === 'string') {
197
+ let out = result.output;
198
+ for (const secret of resolvedSecrets) {
199
+ if (secret && String(secret).length >= 4 && out.includes(secret)) {
200
+ out = out.replaceAll(secret, '[REDACTED_CREDENTIAL]');
201
+ }
202
+ }
203
+ result.output = out;
204
+ }
205
+ return result;
206
+ };
207
+
208
+ const runInternal = async () => {
209
+ // 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
210
+ const runtime = normalizeTaskType(effectiveTask.type);
211
+ if (EXTERNAL_RUNTIMES.includes(runtime) || effectiveTask.mode === 'no-llm') {
212
+ return this._runExternal(effectiveTask, runtime === 'llm' ? 'script' : runtime, signal, options);
213
+ }
214
+
215
+ // 2. Agent session, when the harness exposes one
216
+ if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
217
+ return this._runAgentSession(effectiveTask, runtime, signal, options);
218
+ }
219
+
220
+ // 3. Legacy session service
221
+ if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
222
+ return this._runLegacySession(effectiveTask, options);
223
+ }
224
+
225
+ // 4. Nothing to run through: report the prompt as triggered
226
+ return this._runBare(effectiveTask, options);
227
+ };
228
+
229
+ try {
230
+ const outcome = await runInternal();
231
+ return maskSecrets(outcome);
232
+ } catch (err) {
233
+ if (resolvedSecrets.length > 0 && err && err.message) {
234
+ for (const secret of resolvedSecrets) {
235
+ if (secret && String(secret).length >= 4 && err.message.includes(secret)) {
236
+ err.message = err.message.replaceAll(secret, '[REDACTED_CREDENTIAL]');
237
+ }
238
+ }
239
+ }
240
+ throw err;
241
+ }
242
+ }
243
+
244
+ // Legacy dispatch replaced above; original runner helpers remain unchanged below:
245
+ async _runOriginalDispatch(task, signal, options = {}) {
148
246
  const runtime = normalizeTaskType(task.type);
149
247
  if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
150
248
  return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal, options);