@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
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.
|
|
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",
|