@goodandready/dsh-cron 0.3.0 → 0.3.1

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/client.js CHANGED
@@ -288,14 +288,12 @@ window.__ModuleLoader__.load({
288
288
  'settings.kanbanUrlLabel': 'Kanban Base URL',
289
289
  'settings.globalNotify': 'Deliver reports of all tasks to Telegram globally',
290
290
  'settings.globalOnlyFailure': 'Global mode: only on failures (silent on success)',
291
- 'settings.testTelegram': '🔔 Test Telegram delivery',
292
- 'settings.testKanban': '📋 Test Kanban',
293
- 'settings.sending': 'Sending...',
294
- 'settings.checkingKanban': 'Checking Kanban...',
295
- 'settings.testOk': '✅ Test message delivered to Telegram!',
296
- 'settings.testFail': '❌ {error}',
297
- 'settings.kanbanOk': '✅ Kanban card created',
298
- 'settings.kanbanFail': '❌ {error}',
291
+ 'settings.dynamicCredentials': 'Dynamic Credentials Resolution (credential://...)',
292
+ 'settings.dynamicCredentialsHint': 'Resolve credential://... in memory from harness credentials at runtime (#335)',
293
+ 'settings.testTelegram': '🔔 Test Telegram delivery', 'settings.testKanban': '📋 Test Kanban',
294
+ 'settings.sending': 'Sending...', 'settings.checkingKanban': 'Checking Kanban...',
295
+ 'settings.testOk': '✅ Test message delivered to Telegram!', 'settings.testFail': '❌ {error}',
296
+ 'settings.kanbanOk': '✅ Kanban card created', 'settings.kanbanFail': '❌ {error}',
299
297
  'settings.save': 'Save settings',
300
298
  'settings.saved': '✓ Saved',
301
299
  'settings.saveError': 'Save failed: {error}',
@@ -590,14 +588,12 @@ window.__ModuleLoader__.load({
590
588
  'settings.kanbanUrlLabel': 'Kanban 服务基础地址',
591
589
  'settings.globalNotify': '全局开启所有任务结果推送至 Telegram',
592
590
  'settings.globalOnlyFailure': '全局模式:仅在失败时推送 (成功静默)',
593
- 'settings.testTelegram': '🔔 测试 Telegram 发送',
594
- 'settings.testKanban': '📋 测试 Kanban',
595
- 'settings.sending': '正在发送...',
596
- 'settings.checkingKanban': '正在连接 Kanban...',
597
- 'settings.testOk': '✅ 测试消息已成功发送至 Telegram!',
598
- 'settings.testFail': '❌ 发送失败: {error}',
599
- 'settings.kanbanOk': '✅ Kanban 卡片创建成功',
600
- 'settings.kanbanFail': '❌ {error}',
591
+ 'settings.dynamicCredentials': '动态凭据解析 (credential://...)',
592
+ 'settings.dynamicCredentialsHint': '运行时从凭据服务动态解析 credential://... 且不泄露到存储 (#335)',
593
+ 'settings.testTelegram': '🔔 测试 Telegram 发送', 'settings.testKanban': '📋 测试 Kanban',
594
+ 'settings.sending': '正在发送...', 'settings.checkingKanban': '正在连接 Kanban...',
595
+ 'settings.testOk': '✅ 测试消息已成功发送至 Telegram!', 'settings.testFail': '❌ 发送失败: {error}',
596
+ 'settings.kanbanOk': '✅ Kanban 卡片创建成功', 'settings.kanbanFail': '❌ {error}',
601
597
  'settings.save': '保存设置',
602
598
  'settings.saved': '✓ 已保存',
603
599
  'settings.saveError': '保存失败: {error}',
@@ -3798,6 +3794,7 @@ window.__ModuleLoader__.load({
3798
3794
  const [notify, setNotify] = React.useState(false);
3799
3795
  const [onlyFailure, setOnlyFailure] = React.useState(false);
3800
3796
  const [llmActionsEnabled, setLlmActionsEnabled] = React.useState(false);
3797
+ const [enableDynamicCredentials, setEnableDynamicCredentials] = React.useState(false);
3801
3798
  const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
3802
3799
  const [delivery, setDelivery] = React.useState({});
3803
3800
  const [sections, setSections] = React.useState({ channels: true });
@@ -3878,6 +3875,7 @@ window.__ModuleLoader__.load({
3878
3875
  setNotify(Boolean(data.settings.notifyTelegram));
3879
3876
  setOnlyFailure(Boolean(data.settings.onlyOnFailure));
3880
3877
  setLlmActionsEnabled(Boolean(data.settings.llmActionsEnabled));
3878
+ setEnableDynamicCredentials(Boolean(data.settings.enableDynamicCredentials));
3881
3879
  setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
3882
3880
  setDelivery(pickDeliverySettings(data.settings));
3883
3881
  setHasDefault(Boolean(data.settings.hasDefaultCredentials));
@@ -3905,6 +3903,7 @@ window.__ModuleLoader__.load({
3905
3903
  notifyTelegram: notify,
3906
3904
  onlyOnFailure: onlyFailure,
3907
3905
  llmActionsEnabled: llmActionsEnabled,
3906
+ enableDynamicCredentials: enableDynamicCredentials,
3908
3907
  kanbanBaseUrl: kanbanUrl
3909
3908
  }, delivery))
3910
3909
  });
@@ -4046,6 +4045,14 @@ window.__ModuleLoader__.load({
4046
4045
  onChange: (e) => setLlmActionsEnabled(e.target.checked)
4047
4046
  }),
4048
4047
  React.createElement('span', null, 'Enable Structured LLM Actions (trigger tasks, notify, create issues)')
4048
+ ),
4049
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
4050
+ React.createElement('input', {
4051
+ type: 'checkbox',
4052
+ checked: enableDynamicCredentials,
4053
+ onChange: (e) => setEnableDynamicCredentials(e.target.checked)
4054
+ }),
4055
+ React.createElement('span', null, T_KEY(t, 'settings.dynamicCredentials'))
4049
4056
  )
4050
4057
  ),
4051
4058
  ...renderDeliverySettings({
@@ -25,6 +25,11 @@ export function buildPromptContext(task = {}, options = {}) {
25
25
  const prevDuration = formatDuration(options.prevDurationMs);
26
26
  const prevCost = options.prevCostUsd != null ? `$${Number(options.prevCostUsd || 0).toFixed(4)}` : '';
27
27
 
28
+ const filePath = options.filePath != null ? String(options.filePath) : '';
29
+ const fileName = options.fileName != null ? String(options.fileName) : '';
30
+ const fileSize = options.fileSize != null ? String(options.fileSize) : '';
31
+ const fileEvent = options.fileEvent != null ? String(options.fileEvent) : '';
32
+
28
33
  return {
29
34
  // Chaining context
30
35
  'prev.output': prevOutput,
@@ -39,6 +44,20 @@ export function buildPromptContext(task = {}, options = {}) {
39
44
  'prev.cost': prevCost,
40
45
  'prev_cost': prevCost,
41
46
 
47
+ // File watcher context (#327)
48
+ 'filePath': filePath,
49
+ 'file.path': filePath,
50
+ 'file_path': filePath,
51
+ 'fileName': fileName,
52
+ 'file.name': fileName,
53
+ 'file_name': fileName,
54
+ 'fileSize': fileSize,
55
+ 'file.size': fileSize,
56
+ 'file_size': fileSize,
57
+ 'fileEvent': fileEvent,
58
+ 'file.event': fileEvent,
59
+ 'file_event': fileEvent,
60
+
42
61
  // Time context
43
62
  'date': formatDate(now),
44
63
  'time': formatTime(now),
package/lib/runner.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { logger } from './logger.js';
2
2
  import { bestEffort } from './best-effort.js';
3
3
  import { estimateTokenCost } from './integrations.js';
4
+ import { resolveDynamicCredentialsInText, resolveDynamicCredentialsInObject } from './secrets.js';
4
5
  import {
5
6
  EXTERNAL_RUNTIMES,
6
7
  normalizeTaskType,
@@ -144,7 +145,104 @@ export class SessionRunner {
144
145
  async _run(task, signal, options = {}) {
145
146
  logger.info(`[dsh-cron] executing task "${task.title}" (${task.id})`);
146
147
 
147
- // 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
148
+ // Dynamic credentials resolution in-memory (#335)
149
+ let effectiveTask = task;
150
+ const resolvedSecrets = [];
151
+ const settings = (this.store && typeof this.store.getSettings === 'function')
152
+ ? this.store.getSettings()
153
+ : {};
154
+
155
+ if (settings.enableDynamicCredentials) {
156
+ effectiveTask = { ...task };
157
+ if (typeof effectiveTask.prompt === 'string') {
158
+ const res = await resolveDynamicCredentialsInText(effectiveTask.prompt, this.ctx);
159
+ effectiveTask.prompt = res.text;
160
+ resolvedSecrets.push(...res.resolvedSecrets);
161
+ }
162
+ if (typeof effectiveTask.script === 'string') {
163
+ const res = await resolveDynamicCredentialsInText(effectiveTask.script, this.ctx);
164
+ effectiveTask.script = res.text;
165
+ resolvedSecrets.push(...res.resolvedSecrets);
166
+ }
167
+ if (typeof effectiveTask.command === 'string') {
168
+ const res = await resolveDynamicCredentialsInText(effectiveTask.command, this.ctx);
169
+ effectiveTask.command = res.text;
170
+ resolvedSecrets.push(...res.resolvedSecrets);
171
+ }
172
+ if (typeof effectiveTask.httpUrl === 'string') {
173
+ const res = await resolveDynamicCredentialsInText(effectiveTask.httpUrl, this.ctx);
174
+ effectiveTask.httpUrl = res.text;
175
+ resolvedSecrets.push(...res.resolvedSecrets);
176
+ }
177
+ if (typeof effectiveTask.httpBody === 'string') {
178
+ const res = await resolveDynamicCredentialsInText(effectiveTask.httpBody, this.ctx);
179
+ effectiveTask.httpBody = res.text;
180
+ resolvedSecrets.push(...res.resolvedSecrets);
181
+ }
182
+ if (effectiveTask.httpHeaders) {
183
+ const res = await resolveDynamicCredentialsInObject(effectiveTask.httpHeaders, this.ctx);
184
+ effectiveTask.httpHeaders = res.data;
185
+ resolvedSecrets.push(...res.resolvedSecrets);
186
+ }
187
+ if (effectiveTask.env) {
188
+ const res = await resolveDynamicCredentialsInObject(effectiveTask.env, this.ctx);
189
+ effectiveTask.env = res.data;
190
+ resolvedSecrets.push(...res.resolvedSecrets);
191
+ }
192
+ }
193
+
194
+ const maskSecrets = (result) => {
195
+ if (!result || typeof result !== 'object') return result;
196
+ if (resolvedSecrets.length > 0 && typeof result.output === 'string') {
197
+ let out = result.output;
198
+ for (const secret of resolvedSecrets) {
199
+ if (secret && String(secret).length >= 4 && out.includes(secret)) {
200
+ out = out.replaceAll(secret, '[REDACTED_CREDENTIAL]');
201
+ }
202
+ }
203
+ result.output = out;
204
+ }
205
+ return result;
206
+ };
207
+
208
+ const runInternal = async () => {
209
+ // 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
210
+ const runtime = normalizeTaskType(effectiveTask.type);
211
+ if (EXTERNAL_RUNTIMES.includes(runtime) || effectiveTask.mode === 'no-llm') {
212
+ return this._runExternal(effectiveTask, runtime === 'llm' ? 'script' : runtime, signal, options);
213
+ }
214
+
215
+ // 2. Agent session, when the harness exposes one
216
+ if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
217
+ return this._runAgentSession(effectiveTask, runtime, signal, options);
218
+ }
219
+
220
+ // 3. Legacy session service
221
+ if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
222
+ return this._runLegacySession(effectiveTask, options);
223
+ }
224
+
225
+ // 4. Nothing to run through: report the prompt as triggered
226
+ return this._runBare(effectiveTask, options);
227
+ };
228
+
229
+ try {
230
+ const outcome = await runInternal();
231
+ return maskSecrets(outcome);
232
+ } catch (err) {
233
+ if (resolvedSecrets.length > 0 && err && err.message) {
234
+ for (const secret of resolvedSecrets) {
235
+ if (secret && String(secret).length >= 4 && err.message.includes(secret)) {
236
+ err.message = err.message.replaceAll(secret, '[REDACTED_CREDENTIAL]');
237
+ }
238
+ }
239
+ }
240
+ throw err;
241
+ }
242
+ }
243
+
244
+ // Legacy dispatch replaced above; original runner helpers remain unchanged below:
245
+ async _runOriginalDispatch(task, signal, options = {}) {
148
246
  const runtime = normalizeTaskType(task.type);
149
247
  if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
150
248
  return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal, options);
@@ -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
  ];
@@ -5,6 +5,22 @@ import { parseScheduleExpression } from './scheduler.js';
5
5
  import { CHANNEL_IDS } from './channels.js';
6
6
  import { redactTaskSecrets } from './http-utils.js';
7
7
 
8
+ export function sanitizeFileWatch(fw) {
9
+ if (!fw || typeof fw !== 'object') return undefined;
10
+ const p = String(fw.path || '').trim();
11
+ if (!p) return undefined;
12
+ return {
13
+ enabled: Boolean(fw.enabled !== false),
14
+ path: p,
15
+ pattern: fw.pattern ? String(fw.pattern).trim() : '*',
16
+ events: Array.isArray(fw.events) && fw.events.length > 0
17
+ ? fw.events.map(String).filter((e) => ['create', 'modify', 'delete'].includes(e))
18
+ : ['create', 'modify'],
19
+ debounceMs: Math.max(50, Number(fw.debounceMs) || 500),
20
+ recursive: Boolean(fw.recursive),
21
+ };
22
+ }
23
+
8
24
  export function executeCreateTask(store, scheduler, args) {
9
25
  const taskType = normalizeTaskType(args.type);
10
26
  const typeError = validateTaskType(taskType, args);
@@ -75,6 +91,7 @@ export function executeCreateTask(store, scheduler, args) {
75
91
  tokenLimit: Number(args.tokenLimit) > 0 ? Number(args.tokenLimit) : undefined,
76
92
  validators: Array.isArray(args.validators) ? sanitizeValidators(args.validators) : undefined,
77
93
  requireApproval: Boolean(args.requireApproval),
94
+ fileWatch: args.fileWatch && typeof args.fileWatch === 'object' ? sanitizeFileWatch(args.fileWatch) : undefined,
78
95
  };
79
96
  // Issue #310 / GH #5: strip any undefined properties before store.set()
80
97
  for (const k of Object.keys(taskData)) {
package/lib/task-patch.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { sanitizeValidators } from './validators.js';
2
+ import { sanitizeFileWatch } from './task-create.js';
2
3
  /**
3
4
  * Partial task updates, shared by the HTTP PATCH route and the cron tool (action: 'update')
4
5
  * tool (#49).
@@ -90,6 +91,9 @@ export function buildTaskPatch(current, body) {
90
91
  if (patch.requireApproval !== undefined) {
91
92
  patch.requireApproval = Boolean(patch.requireApproval);
92
93
  }
94
+ if (patch.fileWatch !== undefined) {
95
+ patch.fileWatch = patch.fileWatch && typeof patch.fileWatch === 'object' ? sanitizeFileWatch(patch.fileWatch) : null;
96
+ }
93
97
  if (patch.validators !== undefined) {
94
98
  patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
95
99
  }
@@ -76,6 +76,7 @@ export const PATCHABLE_TASK_FIELDS = [
76
76
  'pausedReason',
77
77
  'validators',
78
78
  'requireApproval',
79
+ 'fileWatch',
79
80
  ];
80
81
 
81
82
  /** Fields a duplicate inherits; anything else is runtime state, not config. */
package/lib/watcher.js ADDED
@@ -0,0 +1,249 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { logger } from './logger.js';
4
+ import { bestEffort } from './best-effort.js';
5
+
6
+ const DISALLOWED_ROOTS = [
7
+ '/',
8
+ '/proc',
9
+ '/sys',
10
+ '/dev',
11
+ '/etc',
12
+ '/boot',
13
+ '/run',
14
+ '/var/run',
15
+ 'c:\\',
16
+ 'c:\\windows',
17
+ 'c:\\windows\\system32',
18
+ 'c:\\program files',
19
+ 'c:\\program files (x86)',
20
+ ];
21
+
22
+ /**
23
+ * FileSystemWatcherManager handles fs.watch instances for tasks configured with fileWatch (#327).
24
+ * Provides safe path validation, debounce event filtering, pattern glob matching, and task triggers.
25
+ */
26
+ export class FileSystemWatcherManager {
27
+ constructor(scheduler) {
28
+ this.scheduler = scheduler;
29
+ this.watchers = new Map(); // taskId -> { watcher, config, dir }
30
+ this.debounceTimers = new Map(); // `${taskId}:${filename}` -> timer
31
+ this.knownFiles = new Map(); // taskId -> Set(filename)
32
+ }
33
+
34
+ isSafePath(targetPath) {
35
+ if (!targetPath || typeof targetPath !== 'string') {
36
+ return { safe: false, error: 'Path must be a non-empty string' };
37
+ }
38
+ const resolved = path.resolve(targetPath.trim());
39
+ const lower = resolved.toLowerCase();
40
+
41
+ for (const root of DISALLOWED_ROOTS) {
42
+ if (lower === root || lower === root.replace(/\\/g, '/')) {
43
+ return { safe: false, error: `Watched path "${resolved}" is a disallowed system root directory` };
44
+ }
45
+ }
46
+
47
+ if (resolved === path.parse(resolved).root) {
48
+ return { safe: false, error: `Watched path "${resolved}" is a filesystem root` };
49
+ }
50
+
51
+ return { safe: true, resolved };
52
+ }
53
+
54
+ matchPattern(filename, pattern) {
55
+ if (!pattern || pattern.trim() === '' || pattern === '*') return true;
56
+ const parts = pattern.split(',').map((p) => p.trim()).filter(Boolean);
57
+ if (!parts.length) return true;
58
+
59
+ return parts.some((p) => {
60
+ const regexStr = '^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$';
61
+ try {
62
+ return new RegExp(regexStr, 'i').test(filename);
63
+ } catch {
64
+ return false;
65
+ }
66
+ });
67
+ }
68
+
69
+ watchTask(task) {
70
+ if (!task || !task.id) return;
71
+ const fw = task.fileWatch;
72
+
73
+ if (!fw || !fw.enabled || task.status !== 'active') {
74
+ this.unwatchTask(task.id);
75
+ return;
76
+ }
77
+
78
+ const check = this.isSafePath(fw.path);
79
+ if (!check.safe) {
80
+ logger.warn(`[dsh-cron] Unsafe fileWatch path for task "${task.title}" (${task.id}): ${check.error}`);
81
+ this.unwatchTask(task.id);
82
+ return;
83
+ }
84
+
85
+ const watchDir = check.resolved;
86
+ if (!fs.existsSync(watchDir)) {
87
+ logger.warn(`[dsh-cron] Watched directory does not exist for task "${task.title}" (${task.id}): ${watchDir}`);
88
+ this.unwatchTask(task.id);
89
+ return;
90
+ }
91
+
92
+ const current = this.watchers.get(task.id);
93
+ const configSig = JSON.stringify({
94
+ dir: watchDir,
95
+ pattern: fw.pattern || '*',
96
+ events: fw.events || ['create', 'modify'],
97
+ debounceMs: fw.debounceMs || 500,
98
+ recursive: Boolean(fw.recursive),
99
+ });
100
+
101
+ if (current && current.sig === configSig) {
102
+ return; // Already watching with exact configuration
103
+ }
104
+
105
+ this.unwatchTask(task.id);
106
+
107
+ // Initial snapshot of known files in directory
108
+ const known = new Set();
109
+ try {
110
+ const entries = fs.readdirSync(watchDir);
111
+ for (const entry of entries) {
112
+ known.add(entry);
113
+ }
114
+ } catch {
115
+ // ignore readdir errors
116
+ }
117
+ this.knownFiles.set(task.id, known);
118
+
119
+ try {
120
+ const watcher = fs.watch(watchDir, { recursive: Boolean(fw.recursive) }, (rawEvent, filename) => {
121
+ if (!filename) return;
122
+ // Ignore dotfiles / temp files unless pattern specifically targets them
123
+ if (filename.startsWith('.') && (!fw.pattern || !fw.pattern.startsWith('.'))) return;
124
+
125
+ const debounceMs = Math.max(50, Number(fw.debounceMs) || 500);
126
+ const timerKey = `${task.id}:${filename}`;
127
+
128
+ if (this.debounceTimers.has(timerKey)) {
129
+ clearTimeout(this.debounceTimers.get(timerKey));
130
+ }
131
+
132
+ const timer = setTimeout(() => {
133
+ this.debounceTimers.delete(timerKey);
134
+ this._handleEvent(task, watchDir, filename, rawEvent);
135
+ }, debounceMs);
136
+
137
+ if (timer.unref) timer.unref();
138
+ this.debounceTimers.set(timerKey, timer);
139
+ });
140
+
141
+ watcher.on('error', (err) => {
142
+ logger.error(`[dsh-cron] Watcher error for task "${task.title}" (${task.id}): ${err.message}`);
143
+ });
144
+
145
+ this.watchers.set(task.id, { watcher, sig: configSig, dir: watchDir });
146
+ logger.info(`[dsh-cron] File watcher active on "${watchDir}" for task "${task.title}" (${task.id})`);
147
+ } catch (err) {
148
+ logger.error(`[dsh-cron] Failed to start file watcher for task "${task.title}" (${task.id}): ${err.message}`);
149
+ }
150
+ }
151
+
152
+ _handleEvent(task, watchDir, filename, rawEvent) {
153
+ if (!this.scheduler || this.scheduler.isStopped) return;
154
+ const fw = task.fileWatch || {};
155
+ if (!fw.enabled) return;
156
+
157
+ if (!this.matchPattern(filename, fw.pattern)) {
158
+ return;
159
+ }
160
+
161
+ const fullFilePath = path.join(watchDir, filename);
162
+ const exists = fs.existsSync(fullFilePath);
163
+ let event = 'modify';
164
+ let fileSize = 0;
165
+
166
+ const taskKnown = this.knownFiles.get(task.id) || new Set();
167
+
168
+ if (!exists) {
169
+ event = 'delete';
170
+ fileSize = 0;
171
+ taskKnown.delete(filename);
172
+ } else {
173
+ try {
174
+ const stat = fs.statSync(fullFilePath);
175
+ if (stat.isDirectory()) return; // skip directories
176
+ fileSize = stat.size;
177
+ if (taskKnown.has(filename)) {
178
+ event = 'modify';
179
+ } else {
180
+ event = 'create';
181
+ taskKnown.add(filename);
182
+ }
183
+ } catch {
184
+ return; // Stat failed (e.g. temporary file disappeared)
185
+ }
186
+ }
187
+
188
+ const allowedEvents = Array.isArray(fw.events) && fw.events.length > 0
189
+ ? fw.events
190
+ : ['create', 'modify'];
191
+
192
+ if (!allowedEvents.includes(event)) {
193
+ return;
194
+ }
195
+
196
+ logger.info(`[dsh-cron] fileWatch triggered: "${event}" on "${filename}" for task "${task.title}" (${task.id})`);
197
+ this.scheduler.runTask(task.id, {
198
+ filePath: fullFilePath,
199
+ fileName: filename,
200
+ fileSize,
201
+ fileEvent: event,
202
+ trigger: 'fileWatch',
203
+ }).catch((err) => {
204
+ logger.error(`[dsh-cron] File watcher triggered execution error for task ${task.id}: ${err.message}`);
205
+ });
206
+ }
207
+
208
+ unwatchTask(taskId) {
209
+ const entry = this.watchers.get(taskId);
210
+ if (entry) {
211
+ bestEffort('watcher-close', () => entry.watcher.close());
212
+ this.watchers.delete(taskId);
213
+ }
214
+ this.knownFiles.delete(taskId);
215
+ for (const [key, timer] of this.debounceTimers.entries()) {
216
+ if (key.startsWith(`${taskId}:`)) {
217
+ clearTimeout(timer);
218
+ this.debounceTimers.delete(key);
219
+ }
220
+ }
221
+ }
222
+
223
+ stopAll() {
224
+ for (const [taskId] of this.watchers.entries()) {
225
+ this.unwatchTask(taskId);
226
+ }
227
+ this.watchers.clear();
228
+ for (const [, timer] of this.debounceTimers.entries()) {
229
+ clearTimeout(timer);
230
+ }
231
+ this.debounceTimers.clear();
232
+ this.knownFiles.clear();
233
+ }
234
+
235
+ syncAll(tasks = []) {
236
+ const activeTaskIds = new Set();
237
+ for (const task of tasks) {
238
+ if (task && task.id && task.status === 'active' && task.fileWatch?.enabled) {
239
+ activeTaskIds.add(task.id);
240
+ this.watchTask(task);
241
+ }
242
+ }
243
+ for (const [taskId] of this.watchers.entries()) {
244
+ if (!activeTaskIds.has(taskId)) {
245
+ this.unwatchTask(taskId);
246
+ }
247
+ }
248
+ }
249
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",