@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.
- package/lib/chain-checkpoints.js +109 -11
- package/lib/client.js +4350 -4192
- package/lib/dag.js +330 -0
- package/lib/prompt-interpolation.js +48 -3
- package/lib/routes.js +22 -0
- package/lib/runner.js +99 -1
- package/lib/scheduler-execution.js +82 -15
- package/lib/scheduler-lifecycle.js +6 -0
- package/lib/scheduler.js +15 -0
- package/lib/secrets.js +114 -0
- package/lib/settings.js +3 -1
- package/lib/task-create.js +28 -1
- package/lib/task-patch.js +17 -0
- package/lib/task-transfer.js +3 -0
- package/lib/watcher.js +249 -0
- package/package.json +1 -1
|
@@ -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
|
|
|
@@ -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
|
];
|
package/lib/task-create.js
CHANGED
|
@@ -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);
|
package/lib/task-transfer.js
CHANGED