@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/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
- 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' });
@@ -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 chainedTarget = outcome.status === 'success' ? task.onSuccess : task.onFailure;
740
- const chainRunId = options.chainRunId || (chainedTarget ? ('chain_' + Math.random().toString(36).slice(2, 10)) : null);
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 nextTaskId = isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null);
847
- if (nextTaskId && typeof nextTaskId === 'string' && nextTaskId.trim()) {
848
- const currentDepth = options.chainDepth || 0;
849
- if (currentDepth < 4) {
850
- const targetId = nextTaskId.trim();
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
 
@@ -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);
@@ -77,6 +77,8 @@ export const PATCHABLE_TASK_FIELDS = [
77
77
  'validators',
78
78
  'requireApproval',
79
79
  'fileWatch',
80
+ 'dependsOn',
81
+ 'joinPolicy',
80
82
  ];
81
83
 
82
84
  /** Fields a duplicate inherits; anything else is runtime state, not config. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.1",
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",