@goodandready/dsh-cron 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -92,6 +92,9 @@ export function pauseTask(scheduler, taskId, reason = null) {
92
92
  clearTimeout(scheduler.timers.get(taskId));
93
93
  scheduler.timers.delete(taskId);
94
94
  }
95
+ if (scheduler.watcherManager) {
96
+ scheduler.watcherManager.unwatchTask(taskId);
97
+ }
95
98
  const running = scheduler.running.get(taskId);
96
99
  if (running) {
97
100
  bestEffort('abort-paused-task', () => running.controller.abort(new Error('Task paused')), scheduler.logger);
@@ -115,6 +118,9 @@ export function removeTask(scheduler, taskId) {
115
118
  clearTimeout(scheduler.timers.get(taskId));
116
119
  scheduler.timers.delete(taskId);
117
120
  }
121
+ if (scheduler.watcherManager) {
122
+ scheduler.watcherManager.unwatchTask(taskId);
123
+ }
118
124
  const running = scheduler.running.get(taskId);
119
125
  if (running) {
120
126
  bestEffort('abort-deleted-task', () => running.controller.abort(new Error('Task deleted')), scheduler.logger);
package/lib/scheduler.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { logger } from './logger.js';
2
2
  import { bestEffort } from './best-effort.js';
3
3
  import { Cron } from 'croner';
4
+ import { FileSystemWatcherManager } from './watcher.js';
4
5
 
5
6
  const MAX_TIMEOUT_MS = 2147483647; // 2^31 - 1 ms (~24.85 days)
6
7
  import {
@@ -51,6 +52,7 @@ export {
51
52
  removeTask,
52
53
  resumeTask,
53
54
  toggleTask,
55
+ FileSystemWatcherManager,
54
56
  };
55
57
 
56
58
  function pad(v) {
@@ -320,6 +322,7 @@ export class TaskScheduler {
320
322
  this.queue = [];
321
323
  this.groupLimits = opts.groupLimits instanceof Map ? opts.groupLimits : new Map(Object.entries(opts.groupLimits || {}));
322
324
  this.heartbeatTimer = null;
325
+ this.watcherManager = new FileSystemWatcherManager(this);
323
326
  this.logger = opts.logger || null;
324
327
  this.isStopped = false;
325
328
  this.generation = 0;
@@ -416,12 +419,18 @@ export class TaskScheduler {
416
419
  this.scheduleTask(task);
417
420
  }
418
421
  this.startHeartbeatWatcher();
422
+ if (this.watcherManager) {
423
+ this.watcherManager.syncAll(tasks);
424
+ }
419
425
  }
420
426
 
421
427
  stopAll() {
422
428
  this.isStopped = true;
423
429
  this.generation = (this.generation || 0) + 1;
424
430
  this.stopHeartbeatWatcher();
431
+ if (this.watcherManager) {
432
+ this.watcherManager.stopAll();
433
+ }
425
434
 
426
435
  // Clear concurrency queue (#217)
427
436
  this.queue = [];
@@ -452,6 +461,9 @@ export class TaskScheduler {
452
461
  }
453
462
 
454
463
  clearScheduled(taskId) {
464
+ if (this.watcherManager) {
465
+ this.watcherManager.unwatchTask(taskId);
466
+ }
455
467
  if (this.jobs.has(taskId)) {
456
468
  this.jobs.get(taskId).stop();
457
469
  this.jobs.delete(taskId);
@@ -516,6 +528,9 @@ export class TaskScheduler {
516
528
  this.clearScheduled(task.id);
517
529
  if (task.status !== 'active') return;
518
530
  this.clearRetryTimer(task.id);
531
+ if (this.watcherManager) {
532
+ this.watcherManager.watchTask(task);
533
+ }
519
534
 
520
535
  try {
521
536
  const parsed = parseScheduleExpression(task.schedule);
package/lib/secrets.js CHANGED
@@ -120,3 +120,117 @@ export async function resolveTelegramSecrets(ctx, settings = {}) {
120
120
  out.chatId = String(settings.chatId || '').trim();
121
121
  return out;
122
122
  }
123
+
124
+
125
+ // --- Dynamic credential resolution (#335) ---
126
+ export const CREDENTIAL_URI_REGEX = /^credential:\/\/(?:([a-zA-Z0-9_-]+)\/)?([a-zA-Z0-9_.-]+)$/;
127
+ export const CREDENTIAL_URI_GLOBAL_REGEX = /credential:\/\/(?:[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9_.-]+/g;
128
+
129
+ /** Check if a string is a credential:// reference URI. */
130
+ export function isCredentialUri(value) {
131
+ return typeof value === 'string' && CREDENTIAL_URI_REGEX.test(value.trim());
132
+ }
133
+
134
+ /** Parse a credential://[domain/]<key> URI into domain and key components. */
135
+ export function parseCredentialUri(value) {
136
+ const match = String(value || '').trim().match(CREDENTIAL_URI_REGEX);
137
+ if (!match) return null;
138
+ return {
139
+ domain: match[1] || null,
140
+ key: match[2],
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Resolve a single credential:// URI using harness credentials service or environment.
146
+ * If not resolvable, returns the original URI.
147
+ */
148
+ export async function resolveCredentialReference(uri, ctx) {
149
+ if (typeof uri !== 'string') return uri;
150
+ const parsed = parseCredentialUri(uri);
151
+ if (!parsed) return uri;
152
+
153
+ try {
154
+ const creds = (ctx && typeof ctx.get === 'function' ? ctx.get('credentials') : null)
155
+ || (ctx && ctx.credentials);
156
+ if (creds && typeof creds.resolve === 'function') {
157
+ const hit = await creds.resolve(parsed.domain ? { domain: parsed.domain, name: parsed.key } : parsed.key);
158
+ if (hit && hit.value) return String(hit.value);
159
+ }
160
+ } catch {
161
+ // credentials service unavailable
162
+ }
163
+
164
+ // Fallback: resolve by key name
165
+ const fallback = await resolveCredentialValue(ctx, parsed.key);
166
+ if (fallback) return fallback;
167
+
168
+ // Fallback: check environment variable with DOMAIN_KEY
169
+ if (parsed.domain) {
170
+ const prefixedEnv = process.env[`${parsed.domain.toUpperCase()}_${parsed.key.toUpperCase()}`];
171
+ if (prefixedEnv) return String(prefixedEnv);
172
+ }
173
+
174
+ return uri;
175
+ }
176
+
177
+ /**
178
+ * Replace all credential://... references in a string with resolved secret values.
179
+ * Returns { text: string, resolvedSecrets: string[] }.
180
+ */
181
+ export async function resolveDynamicCredentialsInText(text, ctx) {
182
+ if (typeof text !== 'string' || !text.includes('credential://')) {
183
+ return { text, resolvedSecrets: [] };
184
+ }
185
+
186
+ const matches = text.match(CREDENTIAL_URI_GLOBAL_REGEX);
187
+ if (!matches || !matches.length) {
188
+ return { text, resolvedSecrets: [] };
189
+ }
190
+
191
+ let result = text;
192
+ const resolvedSecrets = [];
193
+
194
+ for (const match of matches) {
195
+ const resolved = await resolveCredentialReference(match, ctx);
196
+ if (resolved && resolved !== match) {
197
+ result = result.replaceAll(match, resolved);
198
+ resolvedSecrets.push(resolved);
199
+ }
200
+ }
201
+
202
+ return { text: result, resolvedSecrets };
203
+ }
204
+
205
+ /**
206
+ * Recursively resolve credential://... references in strings, arrays, or objects.
207
+ * Returns { data: any, resolvedSecrets: string[] }.
208
+ */
209
+ export async function resolveDynamicCredentialsInObject(obj, ctx) {
210
+ if (obj === null || obj === undefined) return { data: obj, resolvedSecrets: [] };
211
+ if (typeof obj === 'string') {
212
+ const textRes = await resolveDynamicCredentialsInText(obj, ctx);
213
+ return { data: textRes.text, resolvedSecrets: textRes.resolvedSecrets };
214
+ }
215
+ if (Array.isArray(obj)) {
216
+ const outArr = [];
217
+ const allSecrets = [];
218
+ for (const item of obj) {
219
+ const res = await resolveDynamicCredentialsInObject(item, ctx);
220
+ outArr.push(res.data);
221
+ allSecrets.push(...res.resolvedSecrets);
222
+ }
223
+ return { data: outArr, resolvedSecrets: allSecrets };
224
+ }
225
+ if (typeof obj === 'object') {
226
+ const outObj = {};
227
+ const allSecrets = [];
228
+ for (const [k, v] of Object.entries(obj)) {
229
+ const res = await resolveDynamicCredentialsInObject(v, ctx);
230
+ outObj[k] = res.data;
231
+ allSecrets.push(...res.resolvedSecrets);
232
+ }
233
+ return { data: outObj, resolvedSecrets: allSecrets };
234
+ }
235
+ return { data: obj, resolvedSecrets: [] };
236
+ }
package/lib/settings.js CHANGED
@@ -47,6 +47,8 @@ export const Config = z.object({
47
47
  giteaLabels: z.any().default([]).description('Gitea label names or IDs for alert issues').volatile(),
48
48
  giteaLabelIds: z.any().default({}).description('Mapping of label name to integer ID for Gitea issues').volatile(),
49
49
  prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)').volatile(),
50
+ // --- Dynamic credentials resolution (#335) ---
51
+ enableDynamicCredentials: z.boolean().default(false).description('Enable dynamic credential://... reference resolution at runtime (#335)').volatile(),
50
52
  // --- Global Budgets (#320) ---
51
53
  globalDailyCostLimitUsd: z.number().default(0).description('Global daily expenditure limit in USD across all tasks (0 = unlimited)').volatile(),
52
54
  providerHourlyRunLimits: z.object({}).default({}).description('Max runs per hour keyed by provider').volatile(),
@@ -85,7 +87,7 @@ export const SETTINGS_SYNC_KEYS = [
85
87
  'pushplusTokenRef',
86
88
  'pushplusUrl',
87
89
  'ttsBaseUrl',
88
- 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'giteaLabels', 'giteaLabelIds', 'prReviewerEnabled',
90
+ 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'giteaLabels', 'giteaLabelIds', 'prReviewerEnabled', 'enableDynamicCredentials',
89
91
  'apiToken',
90
92
  'globalDailyCostLimitUsd', 'providerHourlyRunLimits', 'modelHourlyRunLimits',
91
93
  ];
@@ -4,6 +4,23 @@ 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';
8
+
9
+ export function sanitizeFileWatch(fw) {
10
+ if (!fw || typeof fw !== 'object') return undefined;
11
+ const p = String(fw.path || '').trim();
12
+ if (!p) return undefined;
13
+ return {
14
+ enabled: Boolean(fw.enabled !== false),
15
+ path: p,
16
+ pattern: fw.pattern ? String(fw.pattern).trim() : '*',
17
+ events: Array.isArray(fw.events) && fw.events.length > 0
18
+ ? fw.events.map(String).filter((e) => ['create', 'modify', 'delete'].includes(e))
19
+ : ['create', 'modify'],
20
+ debounceMs: Math.max(50, Number(fw.debounceMs) || 500),
21
+ recursive: Boolean(fw.recursive),
22
+ };
23
+ }
7
24
 
8
25
  export function executeCreateTask(store, scheduler, args) {
9
26
  const taskType = normalizeTaskType(args.type);
@@ -29,6 +46,8 @@ export function executeCreateTask(store, scheduler, args) {
29
46
  targetSessionReset: args.targetSessionReset ? String(args.targetSessionReset).trim() : 'never',
30
47
  onSuccess: args.onSuccess ? String(args.onSuccess).trim() : '',
31
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,
32
51
  heartbeatIntervalSeconds: Number(args.heartbeatIntervalSeconds) || 0,
33
52
  gracePeriodSeconds: Number(args.gracePeriodSeconds) || 300,
34
53
  preflightType: args.preflightType ? String(args.preflightType).trim().toLowerCase() : 'none',
@@ -75,13 +94,22 @@ export function executeCreateTask(store, scheduler, args) {
75
94
  tokenLimit: Number(args.tokenLimit) > 0 ? Number(args.tokenLimit) : undefined,
76
95
  validators: Array.isArray(args.validators) ? sanitizeValidators(args.validators) : undefined,
77
96
  requireApproval: Boolean(args.requireApproval),
97
+ fileWatch: args.fileWatch && typeof args.fileWatch === 'object' ? sanitizeFileWatch(args.fileWatch) : undefined,
78
98
  };
99
+
79
100
  // Issue #310 / GH #5: strip any undefined properties before store.set()
80
101
  for (const k of Object.keys(taskData)) {
81
102
  if (taskData[k] === undefined) {
82
103
  delete taskData[k];
83
104
  }
84
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
+
85
113
  const task = store.set(taskData);
86
114
  scheduler.scheduleTask(task);
87
115
  return {
@@ -90,4 +118,3 @@ export function executeCreateTask(store, scheduler, args) {
90
118
  task: redactTaskSecrets(task),
91
119
  };
92
120
  }
93
-
package/lib/task-patch.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { sanitizeValidators } from './validators.js';
2
+ import { sanitizeFileWatch } from './task-create.js';
3
+ import { sanitizeDependsOn, sanitizeJoinPolicy, validateDAG } from './dag.js';
2
4
  /**
3
5
  * Partial task updates, shared by the HTTP PATCH route and the cron tool (action: 'update')
4
6
  * tool (#49).
@@ -90,6 +92,15 @@ export function buildTaskPatch(current, body) {
90
92
  if (patch.requireApproval !== undefined) {
91
93
  patch.requireApproval = Boolean(patch.requireApproval);
92
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
+ }
101
+ if (patch.fileWatch !== undefined) {
102
+ patch.fileWatch = patch.fileWatch && typeof patch.fileWatch === 'object' ? sanitizeFileWatch(patch.fileWatch) : null;
103
+ }
93
104
  if (patch.validators !== undefined) {
94
105
  patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
95
106
  }
@@ -145,6 +156,12 @@ export function applyTaskPatch({ store, scheduler, id, body, allowCodeSwitch = f
145
156
  }
146
157
  const built = buildTaskPatch(current, body);
147
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
+ }
148
165
  try {
149
166
  const task = store.set({ ...current, ...built.patch, id });
150
167
  if (task.status === 'active') scheduler.scheduleTask(task);
@@ -76,6 +76,9 @@ export const PATCHABLE_TASK_FIELDS = [
76
76
  'pausedReason',
77
77
  'validators',
78
78
  'requireApproval',
79
+ 'fileWatch',
80
+ 'dependsOn',
81
+ 'joinPolicy',
79
82
  ];
80
83
 
81
84
  /** Fields a duplicate inherits; anything else is runtime state, not config. */