@goodandready/dsh-cron 0.3.1 → 0.3.3

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
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Real-time Task Execution Log Streaming (SSE) (#336)
3
+ *
4
+ * Keeps an in-memory Ring Buffer of output chunks for active tasks and
5
+ * broadcasts them to connected Server-Sent Events (SSE) clients.
6
+ */
7
+
8
+ import { EventEmitter } from 'node:events';
9
+ import { logger } from './logger.js';
10
+
11
+ const MAX_BUFFER_LINES = 500;
12
+ const RETENTION_MS = 60000; // retain logs for 60s after completion for late inspectors
13
+
14
+ export class LogStreamer extends EventEmitter {
15
+ constructor() {
16
+ super();
17
+ this.streams = new Map(); // taskId -> StreamEntry
18
+ }
19
+
20
+ startStream(taskId, runId) {
21
+ if (!taskId) return;
22
+ const existing = this.streams.get(taskId);
23
+ if (existing && existing.cleanupTimer) {
24
+ clearTimeout(existing.cleanupTimer);
25
+ }
26
+ const entry = {
27
+ taskId,
28
+ runId: runId || `run_${Date.now()}`,
29
+ startTime: Date.now(),
30
+ buffer: [],
31
+ subscribers: new Set(),
32
+ completed: false,
33
+ outcome: null,
34
+ cleanupTimer: null,
35
+ };
36
+ this.streams.set(taskId, entry);
37
+ this.emit('start', { taskId, runId: entry.runId });
38
+ return entry;
39
+ }
40
+
41
+ appendLog(taskId, text, streamType = 'stdout') {
42
+ if (!taskId || text === undefined || text === null) return;
43
+ const entry = this.streams.get(taskId);
44
+ if (!entry) return;
45
+
46
+ const chunkStr = String(text);
47
+ const item = {
48
+ chunk: chunkStr,
49
+ stream: streamType,
50
+ ts: Date.now(),
51
+ };
52
+
53
+ entry.buffer.push(item);
54
+ if (entry.buffer.length > MAX_BUFFER_LINES) {
55
+ entry.buffer.shift();
56
+ }
57
+
58
+ const ssePayload = `event: log\ndata: ${JSON.stringify(item)}\n\n`;
59
+ for (const res of entry.subscribers) {
60
+ try {
61
+ res.write(ssePayload);
62
+ } catch (err) {
63
+ entry.subscribers.delete(res);
64
+ }
65
+ }
66
+ }
67
+
68
+ completeStream(taskId, outcome = {}) {
69
+ if (!taskId) return;
70
+ const entry = this.streams.get(taskId);
71
+ if (!entry) return;
72
+
73
+ entry.completed = true;
74
+ entry.outcome = {
75
+ status: outcome.status || 'success',
76
+ durationMs: Date.now() - entry.startTime,
77
+ error: outcome.error || null,
78
+ exitCode: outcome.exitCode !== undefined ? outcome.exitCode : (outcome.status === 'error' ? 1 : 0),
79
+ };
80
+
81
+ const ssePayload = `event: complete\ndata: ${JSON.stringify(entry.outcome)}\n\n`;
82
+ for (const res of entry.subscribers) {
83
+ try {
84
+ res.write(ssePayload);
85
+ res.end();
86
+ } catch (err) {
87
+ // client closed
88
+ }
89
+ }
90
+ entry.subscribers.clear();
91
+
92
+ // Schedule cleanup of memory after retention period
93
+ entry.cleanupTimer = setTimeout(() => {
94
+ this.streams.delete(taskId);
95
+ }, RETENTION_MS);
96
+ if (entry.cleanupTimer.unref) entry.cleanupTimer.unref();
97
+ }
98
+
99
+ getStream(taskId) {
100
+ return this.streams.get(taskId) || null;
101
+ }
102
+
103
+ handleSseRequest(taskId, req, res) {
104
+ res.writeHead(200, {
105
+ 'Content-Type': 'text/event-stream; charset=utf-8',
106
+ 'Cache-Control': 'no-cache, no-transform',
107
+ 'Connection': 'keep-alive',
108
+ 'X-Accel-Buffering': 'no',
109
+ 'Access-Control-Allow-Origin': '*',
110
+ });
111
+
112
+ const entry = this.streams.get(taskId);
113
+
114
+ // Initial event with current state and buffered logs
115
+ const initData = {
116
+ taskId,
117
+ runId: entry?.runId || null,
118
+ status: entry ? (entry.completed ? 'completed' : 'running') : 'idle',
119
+ bufferedLogs: entry?.buffer || [],
120
+ };
121
+ res.write(`event: init\ndata: ${JSON.stringify(initData)}\n\n`);
122
+
123
+ if (!entry || entry.completed) {
124
+ // Not running or already completed
125
+ if (entry?.completed) {
126
+ res.write(`event: complete\ndata: ${JSON.stringify(entry.outcome)}\n\n`);
127
+ }
128
+ res.end();
129
+ return;
130
+ }
131
+
132
+ // Subscribe to live events
133
+ entry.subscribers.add(res);
134
+
135
+ // Keepalive ping every 15 seconds
136
+ const pingTimer = setInterval(() => {
137
+ try {
138
+ res.write(': keepalive\n\n');
139
+ } catch (e) {
140
+ clearInterval(pingTimer);
141
+ entry.subscribers.delete(res);
142
+ }
143
+ }, 15000);
144
+ if (pingTimer.unref) pingTimer.unref();
145
+
146
+ req.on('close', () => {
147
+ clearInterval(pingTimer);
148
+ entry.subscribers.delete(res);
149
+ });
150
+ }
151
+ }
152
+
153
+ export const logStreamer = new LogStreamer();
@@ -30,7 +30,7 @@ export function buildPromptContext(task = {}, options = {}) {
30
30
  const fileSize = options.fileSize != null ? String(options.fileSize) : '';
31
31
  const fileEvent = options.fileEvent != null ? String(options.fileEvent) : '';
32
32
 
33
- return {
33
+ const ctx = {
34
34
  // Chaining context
35
35
  'prev.output': prevOutput,
36
36
  'prev_output': prevOutput,
@@ -72,17 +72,41 @@ export function buildPromptContext(task = {}, options = {}) {
72
72
  'task.title': task.title || '',
73
73
  'task_title': task.title || '',
74
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;
75
89
  }
76
90
 
77
91
  /**
78
92
  * Replace {{var}} or {var} placeholders with values from context.
79
- * Supports dot notation like {{prev.output}}. Unknown variables are left untouched.
93
+ * Supports dot notation like {{prev.output}} and bracket notation like {{prevOutputs['step1']}}.
80
94
  */
81
95
  export function interpolateTaskPrompt(text, context = {}) {
82
96
  if (typeof text !== 'string') return text;
83
97
  if (!text) return '';
84
98
 
85
- 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) => {
86
110
  if (Object.prototype.hasOwnProperty.call(context, varName)) {
87
111
  const val = context[varName];
88
112
  return val == null ? '' : String(val);
@@ -95,6 +119,8 @@ export function interpolateTaskPrompt(text, context = {}) {
95
119
  }
96
120
  return match;
97
121
  });
122
+
123
+ return result;
98
124
  }
99
125
 
100
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' });
@@ -1,3 +1,4 @@
1
+ import { logStreamer } from './log-streamer.js';
1
2
  import { logger } from './logger.js';
2
3
  import { bestEffort } from './best-effort.js';
3
4
  import {
@@ -59,6 +60,10 @@ export function isTransientError(errOrStatus) {
59
60
 
60
61
  /** HTTP trigger runtime (#7) with transient retry (#134). */
61
62
  export async function runHttp(task, request, signal) {
63
+ const streamRunId = `run_${Date.now()}`;
64
+ logStreamer.startStream(task.id, streamRunId);
65
+ logStreamer.appendLog(task.id, `> ${request.method} ${request.url}
66
+ `, 'stdout');
62
67
  let attempts = 0;
63
68
  const maxAttempts = 3;
64
69
  while (attempts < maxAttempts) {
@@ -201,6 +206,8 @@ export async function runExternal(task, runtime, signal, execOptions = {}, { ctx
201
206
  if (execOptions?.prevOutput !== undefined) {
202
207
  runEnv.DSH_PREV_OUTPUT = String(execOptions.prevOutput);
203
208
  }
209
+ const streamRunId = execOptions?.runId || `run_${Date.now()}`;
210
+ logStreamer.startStream(task.id, streamRunId);
204
211
  const options = {
205
212
  cwd,
206
213
  env: runEnv,
@@ -218,15 +225,18 @@ export async function runExternal(task, runtime, signal, execOptions = {}, { ctx
218
225
 
219
226
  child.stdout.on('data', (chunk) => {
220
227
  if (stdout.length < maxBuffer) stdout += chunk;
228
+ logStreamer.appendLog(task.id, chunk.toString('utf-8'), 'stdout');
221
229
  });
222
230
  child.stderr.on('data', (chunk) => {
223
231
  if (stderr.length < maxBuffer) stderr += chunk;
232
+ logStreamer.appendLog(task.id, chunk.toString('utf-8'), 'stderr');
224
233
  });
225
234
 
226
235
  child.on('error', (err) => {
227
236
  if (settled) return;
228
237
  settled = true;
229
238
  if (signal) signal.removeEventListener('abort', abortHandler);
239
+ logStreamer.completeStream(task.id, { status: 'error', error: err.message, exitCode: 1 });
230
240
  reject(err);
231
241
  });
232
242
 
package/lib/runner.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { logStreamer } from './log-streamer.js';
1
2
  import { logger } from './logger.js';
2
3
  import { bestEffort } from './best-effort.js';
3
4
  import { estimateTokenCost } from './integrations.js';
@@ -92,6 +93,11 @@ export class SessionRunner {
92
93
  }, { once: true });
93
94
  }
94
95
 
96
+ const streamRunId = options.runId || `run_${Date.now()}`;
97
+ logStreamer.startStream(task.id, streamRunId);
98
+ logStreamer.appendLog(task.id, `[dsh-cron] Starting task "${task.title}" (type: ${task.type || 'llm'})
99
+ `, 'stdout');
100
+
95
101
  const timeoutPromise = new Promise((_, reject) => {
96
102
  timeoutId = setTimeout(() => {
97
103
  const timeoutErr = new Error(`Task execution timed out after ${timeoutSeconds} seconds`);
@@ -103,7 +109,18 @@ export class SessionRunner {
103
109
  const executionPromise = this._run(task, controller.signal, options);
104
110
 
105
111
  try {
106
- return await Promise.race([executionPromise, timeoutPromise]);
112
+ const res = await Promise.race([executionPromise, timeoutPromise]);
113
+ logStreamer.appendLog(task.id, `
114
+ [dsh-cron] Task execution finished
115
+ `, 'stdout');
116
+ logStreamer.completeStream(task.id, { status: 'success' });
117
+ return res;
118
+ } catch (err) {
119
+ logStreamer.appendLog(task.id, `
120
+ [dsh-cron] Task execution error: ${err.message}
121
+ `, 'stderr');
122
+ logStreamer.completeStream(task.id, { status: 'error', error: err.message });
123
+ throw err;
107
124
  } finally {
108
125
  if (timeoutId) clearTimeout(timeoutId);
109
126
  }