@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/README.md +23 -0
- package/lib/adaptive.js +151 -0
- package/lib/api.js +8 -0
- package/lib/chain-checkpoints.js +109 -11
- package/lib/client.js +4505 -4301
- package/lib/dag.js +330 -0
- package/lib/log-streamer.js +153 -0
- package/lib/prompt-interpolation.js +29 -3
- package/lib/routes.js +22 -0
- package/lib/runner-external.js +10 -0
- package/lib/runner.js +18 -1
- package/lib/scheduler-execution.js +108 -15
- package/lib/scheduler.js +38 -0
- package/lib/task-create.js +13 -1
- package/lib/task-patch.js +17 -0
- package/lib/task-transfer.js +4 -0
- package/package.json +1 -1
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
|
-
|
|
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}}
|
|
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
|
-
|
|
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' });
|
package/lib/runner-external.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 {
|
|
@@ -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
|
-
|
|
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
|
}
|