@goodandready/dsh-cron 0.3.1 → 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/chain-checkpoints.js +109 -11
- package/lib/client.js +4350 -4199
- package/lib/dag.js +330 -0
- package/lib/prompt-interpolation.js +29 -3
- package/lib/routes.js +22 -0
- package/lib/scheduler-execution.js +82 -15
- package/lib/task-create.js +11 -1
- package/lib/task-patch.js +13 -0
- package/lib/task-transfer.js +2 -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
|
+
}
|
|
@@ -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' });
|
|
@@ -10,6 +10,7 @@ import fs from 'node:fs';
|
|
|
10
10
|
import { parseLlmActionDirectives, executeLlmActionDirectives } from './llm-actions.js';
|
|
11
11
|
import { applySilentRule } from './silent-rule.js';
|
|
12
12
|
import { inspectFailure } from './failure-inspector.js';
|
|
13
|
+
import { parseChainTargets, sanitizeDependsOn } from './dag.js';
|
|
13
14
|
|
|
14
15
|
/** Task types that talk to a model and can therefore use a fallback model. */
|
|
15
16
|
export const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
|
|
@@ -735,15 +736,24 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
735
736
|
runInfo.artifacts = scheduler.store.getArtifactsForRun(runInfo.runId);
|
|
736
737
|
}
|
|
737
738
|
|
|
738
|
-
// Chain Checkpoints (#319)
|
|
739
|
-
const
|
|
740
|
-
|
|
739
|
+
// Chain Checkpoints (#319, #328)
|
|
740
|
+
const chainedTargets = parseChainTargets(outcome.status === 'success' ? task.onSuccess : task.onFailure);
|
|
741
|
+
let hasDependents = false;
|
|
742
|
+
if (scheduler.store?.tasks) {
|
|
743
|
+
for (const t of Object.values(scheduler.store.tasks)) {
|
|
744
|
+
if (t && sanitizeDependsOn(t.dependsOn).includes(taskId)) {
|
|
745
|
+
hasDependents = true;
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const chainRunId = options.chainRunId || ((chainedTargets.length > 0 || hasDependents) ? ('chain_' + Math.random().toString(36).slice(2, 10)) : null);
|
|
741
751
|
if (chainRunId) {
|
|
742
752
|
runInfo.chainRunId = chainRunId;
|
|
743
753
|
if (scheduler.store.chainCheckpointManager) {
|
|
744
754
|
scheduler.store.chainCheckpointManager.recordStepSnapshot(chainRunId, taskId, {
|
|
745
755
|
status: outcome.status,
|
|
746
|
-
input: { prompt: task.prompt, prevOutput: options.prevOutput || options.chainPrevOutput || '' },
|
|
756
|
+
input: { prompt: task.prompt, prevOutput: options.prevOutput || options.chainPrevOutput || '', prevOutputs: options.prevOutputs || {} },
|
|
747
757
|
output: outcome.output || outcome.error || '',
|
|
748
758
|
durationMs: executionMs,
|
|
749
759
|
});
|
|
@@ -842,14 +852,42 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
842
852
|
}
|
|
843
853
|
}
|
|
844
854
|
|
|
845
|
-
// 2. Task Chaining (#137)
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
855
|
+
// 2. Task Chaining (#137, #328 Fan-Out / Fan-In)
|
|
856
|
+
const targets = parseChainTargets(isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null));
|
|
857
|
+
const currentDepth = options.chainDepth || 0;
|
|
858
|
+
|
|
859
|
+
if (currentDepth < 4) {
|
|
860
|
+
// 2.1 Trigger explicit targets (Fan-Out)
|
|
861
|
+
for (const targetId of targets) {
|
|
851
862
|
const nextTask = scheduler.store.get(targetId);
|
|
852
|
-
if (nextTask) {
|
|
863
|
+
if (!nextTask) {
|
|
864
|
+
logger.warn(`[dsh-cron] chained task target not found: ${targetId}`);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
const deps = sanitizeDependsOn(nextTask.dependsOn);
|
|
868
|
+
if (deps.length > 0 && scheduler.store.chainCheckpointManager) {
|
|
869
|
+
const barrier = scheduler.store.chainCheckpointManager.checkJoinBarrier(chainRunId, nextTask);
|
|
870
|
+
if (barrier.ready && scheduler.store.chainCheckpointManager.canTriggerBarrier(chainRunId, nextTask.id)) {
|
|
871
|
+
scheduler.store.chainCheckpointManager.markBarrierTriggered(chainRunId, nextTask.id);
|
|
872
|
+
logger.info(`[dsh-cron] triggering joined task "${nextTask.title}" (${targetId}) (barrier satisfied)`);
|
|
873
|
+
scheduler.runNow(targetId, {
|
|
874
|
+
chainRunId,
|
|
875
|
+
chainDepth: currentDepth + 1,
|
|
876
|
+
prevOutputs: barrier.outputs,
|
|
877
|
+
prevOutput: barrier.latestOutput,
|
|
878
|
+
prevTaskId: task.id,
|
|
879
|
+
prevStatus: outcome.status,
|
|
880
|
+
prevDurationMs: Date.now() - start,
|
|
881
|
+
prevCostUsd: outcome.costUsd || 0,
|
|
882
|
+
}).catch((err) => {
|
|
883
|
+
logger.warn(`[dsh-cron] chained joined task ${targetId} failed to trigger:`, err.message);
|
|
884
|
+
});
|
|
885
|
+
} else if (barrier.pending) {
|
|
886
|
+
logger.info(`[dsh-cron] task "${nextTask.title}" (${targetId}) waiting for dependencies: ${barrier.waitingFor?.join(', ')}`);
|
|
887
|
+
} else if (barrier.failed) {
|
|
888
|
+
logger.warn(`[dsh-cron] task "${nextTask.title}" (${targetId}) barrier failed due to dependency: ${barrier.failedDep}`);
|
|
889
|
+
}
|
|
890
|
+
} else {
|
|
853
891
|
logger.info(`[dsh-cron] triggering chained task "${nextTask.title}" (${targetId}) from "${task.title}" (depth ${currentDepth + 1})`);
|
|
854
892
|
scheduler.runNow(targetId, {
|
|
855
893
|
chainRunId,
|
|
@@ -862,12 +900,41 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
862
900
|
}).catch((err) => {
|
|
863
901
|
logger.warn(`[dsh-cron] chained task ${targetId} failed to trigger:`, err.message);
|
|
864
902
|
});
|
|
865
|
-
} else {
|
|
866
|
-
logger.warn(`[dsh-cron] chained task target not found: ${targetId}`);
|
|
867
903
|
}
|
|
868
|
-
} else {
|
|
869
|
-
logger.warn(`[dsh-cron] chained task depth limit (5) reached for task ${task.id} -> ${nextTaskId}. Loop prevented.`);
|
|
870
904
|
}
|
|
905
|
+
|
|
906
|
+
// 2.2 Trigger any other tasks in store that depend on this task via dependsOn (Fan-In join step)
|
|
907
|
+
if (chainRunId && scheduler.store?.tasks && scheduler.store.chainCheckpointManager) {
|
|
908
|
+
for (const candidate of Object.values(scheduler.store.tasks)) {
|
|
909
|
+
if (!candidate || candidate.id === taskId || targets.includes(candidate.id)) continue;
|
|
910
|
+
const deps = sanitizeDependsOn(candidate.dependsOn);
|
|
911
|
+
if (deps.includes(taskId)) {
|
|
912
|
+
const barrier = scheduler.store.chainCheckpointManager.checkJoinBarrier(chainRunId, candidate);
|
|
913
|
+
if (barrier.ready && scheduler.store.chainCheckpointManager.canTriggerBarrier(chainRunId, candidate.id)) {
|
|
914
|
+
scheduler.store.chainCheckpointManager.markBarrierTriggered(chainRunId, candidate.id);
|
|
915
|
+
logger.info(`[dsh-cron] triggering dependent joined task "${candidate.title}" (${candidate.id}) (barrier satisfied)`);
|
|
916
|
+
scheduler.runNow(candidate.id, {
|
|
917
|
+
chainRunId,
|
|
918
|
+
chainDepth: currentDepth + 1,
|
|
919
|
+
prevOutputs: barrier.outputs,
|
|
920
|
+
prevOutput: barrier.latestOutput,
|
|
921
|
+
prevTaskId: task.id,
|
|
922
|
+
prevStatus: outcome.status,
|
|
923
|
+
prevDurationMs: Date.now() - start,
|
|
924
|
+
prevCostUsd: outcome.costUsd || 0,
|
|
925
|
+
}).catch((err) => {
|
|
926
|
+
logger.warn(`[dsh-cron] dependent joined task ${candidate.id} failed to trigger:`, err.message);
|
|
927
|
+
});
|
|
928
|
+
} else if (barrier.pending) {
|
|
929
|
+
logger.info(`[dsh-cron] task "${candidate.title}" (${candidate.id}) waiting for dependencies: ${barrier.waitingFor?.join(', ')}`);
|
|
930
|
+
} else if (barrier.failed) {
|
|
931
|
+
logger.warn(`[dsh-cron] task "${candidate.title}" (${candidate.id}) barrier failed due to dependency: ${barrier.failedDep}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
logger.warn(`[dsh-cron] chained task depth limit (5) reached for task ${task.id}. Loop prevented.`);
|
|
871
938
|
}
|
|
872
939
|
}
|
|
873
940
|
|
package/lib/task-create.js
CHANGED
|
@@ -4,6 +4,7 @@ import { validateTaskType } from './task-transfer.js';
|
|
|
4
4
|
import { parseScheduleExpression } from './scheduler.js';
|
|
5
5
|
import { CHANNEL_IDS } from './channels.js';
|
|
6
6
|
import { redactTaskSecrets } from './http-utils.js';
|
|
7
|
+
import { sanitizeDependsOn, sanitizeJoinPolicy, validateDAG } from './dag.js';
|
|
7
8
|
|
|
8
9
|
export function sanitizeFileWatch(fw) {
|
|
9
10
|
if (!fw || typeof fw !== 'object') return undefined;
|
|
@@ -45,6 +46,8 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
45
46
|
targetSessionReset: args.targetSessionReset ? String(args.targetSessionReset).trim() : 'never',
|
|
46
47
|
onSuccess: args.onSuccess ? String(args.onSuccess).trim() : '',
|
|
47
48
|
onFailure: args.onFailure ? String(args.onFailure).trim() : '',
|
|
49
|
+
dependsOn: args.dependsOn !== undefined ? sanitizeDependsOn(args.dependsOn) : undefined,
|
|
50
|
+
joinPolicy: args.joinPolicy !== undefined ? sanitizeJoinPolicy(args.joinPolicy) : undefined,
|
|
48
51
|
heartbeatIntervalSeconds: Number(args.heartbeatIntervalSeconds) || 0,
|
|
49
52
|
gracePeriodSeconds: Number(args.gracePeriodSeconds) || 300,
|
|
50
53
|
preflightType: args.preflightType ? String(args.preflightType).trim().toLowerCase() : 'none',
|
|
@@ -93,12 +96,20 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
93
96
|
requireApproval: Boolean(args.requireApproval),
|
|
94
97
|
fileWatch: args.fileWatch && typeof args.fileWatch === 'object' ? sanitizeFileWatch(args.fileWatch) : undefined,
|
|
95
98
|
};
|
|
99
|
+
|
|
96
100
|
// Issue #310 / GH #5: strip any undefined properties before store.set()
|
|
97
101
|
for (const k of Object.keys(taskData)) {
|
|
98
102
|
if (taskData[k] === undefined) {
|
|
99
103
|
delete taskData[k];
|
|
100
104
|
}
|
|
101
105
|
}
|
|
106
|
+
|
|
107
|
+
// DAG cycle validation (#328)
|
|
108
|
+
const dagCheck = validateDAG(store?.tasks || {}, taskData);
|
|
109
|
+
if (!dagCheck.ok) {
|
|
110
|
+
throw new Error(dagCheck.error);
|
|
111
|
+
}
|
|
112
|
+
|
|
102
113
|
const task = store.set(taskData);
|
|
103
114
|
scheduler.scheduleTask(task);
|
|
104
115
|
return {
|
|
@@ -107,4 +118,3 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
107
118
|
task: redactTaskSecrets(task),
|
|
108
119
|
};
|
|
109
120
|
}
|
|
110
|
-
|
package/lib/task-patch.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sanitizeValidators } from './validators.js';
|
|
2
2
|
import { sanitizeFileWatch } from './task-create.js';
|
|
3
|
+
import { sanitizeDependsOn, sanitizeJoinPolicy, validateDAG } from './dag.js';
|
|
3
4
|
/**
|
|
4
5
|
* Partial task updates, shared by the HTTP PATCH route and the cron tool (action: 'update')
|
|
5
6
|
* tool (#49).
|
|
@@ -91,6 +92,12 @@ export function buildTaskPatch(current, body) {
|
|
|
91
92
|
if (patch.requireApproval !== undefined) {
|
|
92
93
|
patch.requireApproval = Boolean(patch.requireApproval);
|
|
93
94
|
}
|
|
95
|
+
if (patch.dependsOn !== undefined) {
|
|
96
|
+
patch.dependsOn = sanitizeDependsOn(patch.dependsOn);
|
|
97
|
+
}
|
|
98
|
+
if (patch.joinPolicy !== undefined) {
|
|
99
|
+
patch.joinPolicy = sanitizeJoinPolicy(patch.joinPolicy);
|
|
100
|
+
}
|
|
94
101
|
if (patch.fileWatch !== undefined) {
|
|
95
102
|
patch.fileWatch = patch.fileWatch && typeof patch.fileWatch === 'object' ? sanitizeFileWatch(patch.fileWatch) : null;
|
|
96
103
|
}
|
|
@@ -149,6 +156,12 @@ export function applyTaskPatch({ store, scheduler, id, body, allowCodeSwitch = f
|
|
|
149
156
|
}
|
|
150
157
|
const built = buildTaskPatch(current, body);
|
|
151
158
|
if (!built.ok) return built;
|
|
159
|
+
if (built.patch.dependsOn !== undefined || built.patch.onSuccess !== undefined) {
|
|
160
|
+
const dagCheck = validateDAG(store?.tasks || {}, { ...current, ...built.patch, id });
|
|
161
|
+
if (!dagCheck.ok) {
|
|
162
|
+
return { ok: false, error: dagCheck.error };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
152
165
|
try {
|
|
153
166
|
const task = store.set({ ...current, ...built.patch, id });
|
|
154
167
|
if (task.status === 'active') scheduler.scheduleTask(task);
|
package/lib/task-transfer.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|