@goodandready/dsh-cron 0.2.2 → 0.2.4
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/README.md +99 -14
- package/docs/README.ru.md +96 -15
- package/docs/README.zh.md +99 -14
- package/docs/design/DESIGN.md +13 -7
- package/lib/channels.js +422 -0
- package/lib/client.js +697 -26
- package/lib/index.js +278 -19
- package/lib/integrations.js +3 -1
- package/lib/runner.js +214 -29
- package/lib/runtimes.js +174 -0
- package/lib/scheduler.js +179 -73
- package/lib/secrets.js +123 -0
- package/lib/store.js +92 -21
- package/lib/telegram.js +20 -4
- package/lib/templates.js +88 -0
- package/package.json +1 -1
package/lib/scheduler.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { Cron } from 'croner';
|
|
2
|
-
import { shouldNotifyTask, formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
|
|
3
|
-
import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
|
|
4
2
|
|
|
5
3
|
function pad(v) {
|
|
6
4
|
return String(v).padStart(2, '0');
|
|
@@ -99,8 +97,19 @@ export function parseScheduleExpression(input) {
|
|
|
99
97
|
}
|
|
100
98
|
}
|
|
101
99
|
|
|
102
|
-
// 4.
|
|
100
|
+
// 4. Standard @-shorthands (#15)
|
|
103
101
|
const lower = str.toLowerCase();
|
|
102
|
+
if (lower === '@hourly') return { cronPattern: '0 * * * *', humanText: 'Every hour' };
|
|
103
|
+
if (lower === '@daily' || lower === '@midnight') return { cronPattern: '0 0 * * *', humanText: 'Every day at 00:00' };
|
|
104
|
+
if (lower === '@weekly') return { cronPattern: '0 0 * * 0', humanText: 'Every week on Sunday at 00:00' };
|
|
105
|
+
if (lower === '@monthly') return { cronPattern: '0 0 1 * *', humanText: 'Every month on the 1st at 00:00' };
|
|
106
|
+
if (lower === '@yearly' || lower === '@annually') return { cronPattern: '0 0 1 1 *', humanText: 'Every year on Jan 1 at 00:00' };
|
|
107
|
+
const everyAlias = str.match(/^@every\s+(\d+)\s*(sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i);
|
|
108
|
+
if (everyAlias) {
|
|
109
|
+
return parseScheduleExpression(`every ${everyAlias[1]} ${everyAlias[2]}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 5. Friendly recurring aliases (English plus accepted Russian input aliases)
|
|
104
113
|
if (lower === 'daily' || lower === 'каждый день') {
|
|
105
114
|
return { cronPattern: '0 9 * * *', humanText: 'Every day at 09:00' };
|
|
106
115
|
}
|
|
@@ -126,35 +135,77 @@ export function parseScheduleExpression(input) {
|
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
export class TaskScheduler {
|
|
129
|
-
constructor(store, executeFn) {
|
|
138
|
+
constructor(store, executeFn, options = {}) {
|
|
130
139
|
this.store = store;
|
|
131
140
|
this.executeFn = executeFn;
|
|
141
|
+
this.maxConcurrent = Number(options.maxConcurrent) > 0 ? Number(options.maxConcurrent) : 0; // 0 = unlimited (#52)
|
|
142
|
+
this.defaultTimezone = options.defaultTimezone || ''; // #13
|
|
143
|
+
this.resolveSecrets = typeof options.resolveSecrets === 'function' ? options.resolveSecrets : null; // #51
|
|
144
|
+
this.deliver = typeof options.deliver === 'function' ? options.deliver : null; // #26 channel router
|
|
132
145
|
this.jobs = new Map(); // taskId -> Cron instance
|
|
133
146
|
this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
|
|
147
|
+
this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
|
|
134
148
|
this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
|
|
135
149
|
}
|
|
136
150
|
|
|
151
|
+
isRunning(taskId) {
|
|
152
|
+
return this.running.has(taskId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
runningCount() {
|
|
156
|
+
return this.running.size;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
runningSince(taskId) {
|
|
160
|
+
const run = this.running.get(taskId);
|
|
161
|
+
return run ? run.startedAt : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
clearRetryTimer(taskId) {
|
|
165
|
+
const timer = this.retryTimers.get(taskId);
|
|
166
|
+
if (timer !== undefined) {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
this.retryTimers.delete(taskId);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
start() {
|
|
138
173
|
const tasks = this.store.list();
|
|
139
174
|
const now = Date.now();
|
|
140
175
|
for (const task of tasks) {
|
|
141
|
-
if (task.status
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
176
|
+
if (task.status !== 'active') continue;
|
|
177
|
+
// Detect missed execution if daemon was offline (threshold: > 5 minutes)
|
|
178
|
+
const missed = task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000;
|
|
179
|
+
if (missed) {
|
|
180
|
+
const missedAt = task.nextRunAt;
|
|
181
|
+
const policy = task.misfirePolicy || 'skip'; // #12: skip | runOnce | catchUpAll
|
|
182
|
+
console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()} (misfire: ${policy})`);
|
|
183
|
+
this.store.recordRun(task.id, {
|
|
184
|
+
at: missedAt,
|
|
185
|
+
status: 'missed',
|
|
186
|
+
durationMs: 0,
|
|
187
|
+
output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
|
|
188
|
+
error: null,
|
|
189
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
190
|
+
costUsd: 0,
|
|
191
|
+
});
|
|
192
|
+
if (policy === 'skip') {
|
|
193
|
+
// A missed one-shot has no future slot left: retire it instead of
|
|
194
|
+
// arming a timer in the past.
|
|
195
|
+
if (task.oneShot) {
|
|
196
|
+
task.status = 'completed';
|
|
197
|
+
this.store.set(task);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
this.scheduleTask(task);
|
|
201
|
+
continue;
|
|
155
202
|
}
|
|
203
|
+
// runOnce / catchUpAll: run late exactly once, then resume the schedule.
|
|
156
204
|
this.scheduleTask(task);
|
|
205
|
+
setImmediate(() => this.runTask(task.id));
|
|
206
|
+
continue;
|
|
157
207
|
}
|
|
208
|
+
this.scheduleTask(task);
|
|
158
209
|
}
|
|
159
210
|
}
|
|
160
211
|
|
|
@@ -173,6 +224,13 @@ export class TaskScheduler {
|
|
|
173
224
|
}
|
|
174
225
|
this.timers.clear();
|
|
175
226
|
|
|
227
|
+
for (const [id, timer] of this.retryTimers.entries()) {
|
|
228
|
+
try {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
this.retryTimers.clear();
|
|
233
|
+
|
|
176
234
|
for (const [, run] of this.running.entries()) {
|
|
177
235
|
try {
|
|
178
236
|
run.controller.abort(new Error('Scheduler stopped'));
|
|
@@ -193,9 +251,11 @@ export class TaskScheduler {
|
|
|
193
251
|
}
|
|
194
252
|
|
|
195
253
|
if (task.status !== 'active') return;
|
|
254
|
+
this.clearRetryTimer(task.id);
|
|
196
255
|
|
|
197
256
|
try {
|
|
198
257
|
const parsed = parseScheduleExpression(task.schedule);
|
|
258
|
+
const timezone = task.timezone || this.defaultTimezone || undefined;
|
|
199
259
|
|
|
200
260
|
// Handle One-shot task
|
|
201
261
|
if (parsed.isOneShot) {
|
|
@@ -206,9 +266,11 @@ export class TaskScheduler {
|
|
|
206
266
|
this.store.set(task);
|
|
207
267
|
|
|
208
268
|
// If target is in the past, or when delay triggers:
|
|
209
|
-
const timer = setTimeout(
|
|
269
|
+
const timer = setTimeout(() => {
|
|
210
270
|
this.timers.delete(task.id);
|
|
211
|
-
|
|
271
|
+
this.runTask(task.id).catch((err) => {
|
|
272
|
+
console.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
|
|
273
|
+
});
|
|
212
274
|
}, delay);
|
|
213
275
|
|
|
214
276
|
this.timers.set(task.id, timer);
|
|
@@ -216,7 +278,11 @@ export class TaskScheduler {
|
|
|
216
278
|
}
|
|
217
279
|
|
|
218
280
|
// Handle Recurring Cron job
|
|
219
|
-
const job = new Cron(parsed.cronPattern, {
|
|
281
|
+
const job = new Cron(parsed.cronPattern, {
|
|
282
|
+
protect: true,
|
|
283
|
+
catch: (err) => console.error(`[dsh-cron] scheduled run of "${task.title}" (${task.id}) failed:`, (err && err.message) || err),
|
|
284
|
+
...(timezone ? { timezone } : {}),
|
|
285
|
+
}, async () => {
|
|
220
286
|
await this.runTask(task.id);
|
|
221
287
|
});
|
|
222
288
|
|
|
@@ -235,6 +301,23 @@ export class TaskScheduler {
|
|
|
235
301
|
const task = this.store.get(taskId);
|
|
236
302
|
if (!task) return;
|
|
237
303
|
|
|
304
|
+
// Global concurrency throttle (#52): a run that would exceed the limit is
|
|
305
|
+
// skipped and recorded; already-running tasks are never throttled against
|
|
306
|
+
// themselves.
|
|
307
|
+
if (!this.running.has(taskId) && this.maxConcurrent > 0 && this.running.size >= this.maxConcurrent) {
|
|
308
|
+
console.log(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency limit (${this.maxConcurrent})`);
|
|
309
|
+
this.store.recordRun(taskId, {
|
|
310
|
+
at: Date.now(),
|
|
311
|
+
status: 'skipped',
|
|
312
|
+
durationMs: 0,
|
|
313
|
+
output: `Skipped: concurrency limit reached (${this.maxConcurrent} parallel runs)`,
|
|
314
|
+
error: null,
|
|
315
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
316
|
+
costUsd: 0,
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
238
321
|
const overlapPolicy = task.overlapPolicy || 'skip';
|
|
239
322
|
|
|
240
323
|
// Check overlap with currently running task
|
|
@@ -278,6 +361,7 @@ export class TaskScheduler {
|
|
|
278
361
|
let error = null;
|
|
279
362
|
let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
|
|
280
363
|
let costUsd = 0;
|
|
364
|
+
let sessionId = null;
|
|
281
365
|
|
|
282
366
|
try {
|
|
283
367
|
if (typeof this.executeFn === 'function') {
|
|
@@ -286,6 +370,7 @@ export class TaskScheduler {
|
|
|
286
370
|
output = res.output || '';
|
|
287
371
|
usage = res.usage || usage;
|
|
288
372
|
costUsd = res.costUsd || 0;
|
|
373
|
+
sessionId = res.sessionId || null;
|
|
289
374
|
} else {
|
|
290
375
|
output = String(res || '');
|
|
291
376
|
}
|
|
@@ -307,8 +392,13 @@ export class TaskScheduler {
|
|
|
307
392
|
error,
|
|
308
393
|
usage,
|
|
309
394
|
costUsd,
|
|
395
|
+
sessionId,
|
|
310
396
|
};
|
|
311
|
-
|
|
397
|
+
try {
|
|
398
|
+
this.store.recordRun(taskId, runInfo);
|
|
399
|
+
} catch (recordErr) {
|
|
400
|
+
console.error(`[dsh-cron] failed to record the run of task ${taskId}:`, recordErr.message);
|
|
401
|
+
}
|
|
312
402
|
|
|
313
403
|
// Clean up running map
|
|
314
404
|
const finishedRun = this.running.get(taskId);
|
|
@@ -318,58 +408,48 @@ export class TaskScheduler {
|
|
|
318
408
|
|
|
319
409
|
const queuedCount = finishedRun?.queueCount || 0;
|
|
320
410
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const botToken = settings.botToken;
|
|
326
|
-
const chatId = settings.chatId;
|
|
327
|
-
if (botToken && chatId) {
|
|
328
|
-
const text = formatTaskTelegramMessage(task, runInfo);
|
|
329
|
-
sendTelegramMessage({ botToken, chatId, text }).catch((err) => {
|
|
330
|
-
console.error(`[dsh-cron] telegram delivery failed for task "${task.title}":`, err.message);
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
} catch (notifyErr) {
|
|
335
|
-
console.error('[dsh-cron] notification error:', notifyErr.message);
|
|
336
|
-
}
|
|
411
|
+
await this.deliverNotifications(task, runInfo);
|
|
412
|
+
this.finishRun(task, taskId, status, queuedCount);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
337
415
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
createKanbanCard({
|
|
354
|
-
title: cardTitle,
|
|
355
|
-
body: cardBody,
|
|
356
|
-
board: 'main',
|
|
357
|
-
column: isErr ? 'backlog' : 'done',
|
|
358
|
-
labels: isErr ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
|
|
359
|
-
kanbanBaseUrl: settings.kanbanBaseUrl || 'http://127.0.0.1:3000'
|
|
360
|
-
}).then((res) => {
|
|
361
|
-
if (res.success) {
|
|
362
|
-
console.log(`[dsh-cron] created kanban card for task "${task.title}"`);
|
|
363
|
-
}
|
|
364
|
-
}).catch((kErr) => {
|
|
365
|
-
console.error('[dsh-cron] kanban card error:', kErr.message);
|
|
366
|
-
});
|
|
416
|
+
/**
|
|
417
|
+
* Deliver a finished run to every configured channel (#26).
|
|
418
|
+
* Never throws: a broken or slow channel is reported, not propagated, so the
|
|
419
|
+
* cron callback (and therefore the next tick) is never blocked by delivery.
|
|
420
|
+
*/
|
|
421
|
+
async deliverNotifications(task, runInfo) {
|
|
422
|
+
try {
|
|
423
|
+
let settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
|
|
424
|
+
// #51: resolve delivery credentials through the credentials service
|
|
425
|
+
// (credential reference → legacy token → gateway file → env).
|
|
426
|
+
if (this.resolveSecrets) {
|
|
427
|
+
try {
|
|
428
|
+
settings = { ...settings, ...(await this.resolveSecrets(settings)) };
|
|
429
|
+
} catch (secErr) {
|
|
430
|
+
console.warn('[dsh-cron] credential resolution failed:', secErr.message);
|
|
367
431
|
}
|
|
368
|
-
} catch (kanbanErr) {
|
|
369
|
-
console.error('[dsh-cron] kanban error:', kanbanErr.message);
|
|
370
432
|
}
|
|
433
|
+
if (typeof this.deliver !== 'function') return;
|
|
434
|
+
const result = await this.deliver(task, runInfo, settings);
|
|
435
|
+
if (result && Array.isArray(result.delivered) && result.delivered.length) {
|
|
436
|
+
console.log(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
|
|
437
|
+
}
|
|
438
|
+
if (result && Array.isArray(result.failures) && result.failures.length) {
|
|
439
|
+
console.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
|
|
440
|
+
}
|
|
441
|
+
} catch (notifyErr) {
|
|
442
|
+
console.error('[dsh-cron] notification error:', notifyErr.message);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
371
445
|
|
|
372
|
-
|
|
446
|
+
/**
|
|
447
|
+
* Post-run bookkeeping: one-shot completion, next-run timestamp, retry
|
|
448
|
+
* backoff and draining a queued run. Each part is guarded so a store error
|
|
449
|
+
* cannot reject the cron callback.
|
|
450
|
+
*/
|
|
451
|
+
finishRun(task, taskId, status, queuedCount) {
|
|
452
|
+
try {
|
|
373
453
|
if (task.oneShot) {
|
|
374
454
|
task.status = 'completed';
|
|
375
455
|
task.nextRunAt = null;
|
|
@@ -382,12 +462,37 @@ export class TaskScheduler {
|
|
|
382
462
|
this.store.set(task);
|
|
383
463
|
}
|
|
384
464
|
}
|
|
465
|
+
} catch (err) {
|
|
466
|
+
console.error('[dsh-cron] failed to persist the next run of task', taskId + ':', err.message);
|
|
467
|
+
}
|
|
385
468
|
|
|
386
|
-
|
|
387
|
-
|
|
469
|
+
try {
|
|
470
|
+
// Retry with exponential backoff on failure (#18)
|
|
471
|
+
const failed = status === 'error' || status === 'timeout';
|
|
472
|
+
const maxRetries = Number(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
|
|
473
|
+
if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
|
|
474
|
+
task.attempts = (task.attempts || 0) + 1;
|
|
475
|
+
const backoffMs = (Number(task.retryBackoffMs) > 0 ? Number(task.retryBackoffMs) : 30000) * Math.pow(2, task.attempts - 1);
|
|
476
|
+
const attempt = task.attempts;
|
|
477
|
+
this.store.set(task);
|
|
478
|
+
this.clearRetryTimer(taskId);
|
|
479
|
+
this.retryTimers.set(taskId, setTimeout(() => {
|
|
480
|
+
this.retryTimers.delete(taskId);
|
|
388
481
|
this.runTask(taskId);
|
|
389
|
-
});
|
|
482
|
+
}, backoffMs));
|
|
483
|
+
console.log(`[dsh-cron] retry ${attempt}/${maxRetries} for task "${task.title}" in ${backoffMs}ms`);
|
|
484
|
+
} else if (!failed && task.attempts) {
|
|
485
|
+
task.attempts = 0;
|
|
486
|
+
this.store.set(task);
|
|
390
487
|
}
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.error('[dsh-cron] retry bookkeeping failed for task', taskId + ':', err.message);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (queuedCount > 0) {
|
|
493
|
+
setImmediate(() => {
|
|
494
|
+
this.runTask(taskId);
|
|
495
|
+
});
|
|
391
496
|
}
|
|
392
497
|
}
|
|
393
498
|
|
|
@@ -396,6 +501,7 @@ export class TaskScheduler {
|
|
|
396
501
|
if (!task) return null;
|
|
397
502
|
task.status = 'paused';
|
|
398
503
|
task.nextRunAt = null;
|
|
504
|
+
this.clearRetryTimer(taskId);
|
|
399
505
|
if (this.jobs.has(taskId)) {
|
|
400
506
|
this.jobs.get(taskId).stop();
|
|
401
507
|
this.jobs.delete(taskId);
|
package/lib/secrets.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential handling for dsh-cron (#51).
|
|
3
|
+
*
|
|
4
|
+
* Settings store only the NAME of a credential (an environment-variable-style
|
|
5
|
+
* reference); the value is resolved at send time through the DSH credentials
|
|
6
|
+
* service, with an environment fallback. This mirrors the pattern already
|
|
7
|
+
* used by dsh-clinebot and dsh-agentrouter.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import { getDshSettingsPath } from './telegram.js';
|
|
12
|
+
|
|
13
|
+
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
14
|
+
const TOKEN_LIKE = /^[A-Za-z0-9:_-]{24,}$/;
|
|
15
|
+
|
|
16
|
+
export function isCredentialRefName(value) {
|
|
17
|
+
return REF_PATTERN.test(String(value || '').trim());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Heuristic: a pasted secret instead of a credential name. */
|
|
21
|
+
export function looksLikeSecret(value) {
|
|
22
|
+
const s = String(value || '').trim();
|
|
23
|
+
if (!s || isCredentialRefName(s)) return false;
|
|
24
|
+
return TOKEN_LIKE.test(s);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate a credential-reference field. Returns { ok, name } or
|
|
29
|
+
* { ok: false, error } with actionable text for the settings card.
|
|
30
|
+
*/
|
|
31
|
+
export function credentialRefStatus(value) {
|
|
32
|
+
const name = String(value || '').trim();
|
|
33
|
+
if (!name) return { ok: true, name: '' };
|
|
34
|
+
if (looksLikeSecret(name)) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: 'That looks like a secret value. Store it in DSH credentials and enter only the credential name (for example CRON_TELEGRAM_BOT_TOKEN).',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (!isCredentialRefName(name)) {
|
|
41
|
+
return { ok: false, error: 'Credential name must look like an environment variable name, e.g. CRON_TELEGRAM_BOT_TOKEN.' };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, name };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve a credential through the harness service, then the environment. */
|
|
47
|
+
export async function resolveCredentialValue(ctx, refName) {
|
|
48
|
+
const name = String(refName || '').trim();
|
|
49
|
+
if (!name) return null;
|
|
50
|
+
try {
|
|
51
|
+
const creds = (ctx && typeof ctx.get === 'function' ? ctx.get('credentials') : null)
|
|
52
|
+
|| (ctx && ctx.credentials);
|
|
53
|
+
if (creds && typeof creds.resolve === 'function') {
|
|
54
|
+
// Prefer the harness ref factory; fall back to the plain name when the
|
|
55
|
+
// package is not installed (keeps tests and trimmed builds working).
|
|
56
|
+
let ref = name;
|
|
57
|
+
try {
|
|
58
|
+
const mod = await import('@deepseek-ai/dsh-credentials');
|
|
59
|
+
if (mod && typeof mod.credentialRef === 'function') ref = mod.credentialRef(name);
|
|
60
|
+
} catch {}
|
|
61
|
+
const hit = await creds.resolve(ref);
|
|
62
|
+
if (hit && hit.value) return String(hit.value);
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// credentials service unavailable — fall back to the environment
|
|
66
|
+
}
|
|
67
|
+
const fromEnv = process.env[name];
|
|
68
|
+
return fromEnv ? String(fromEnv) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read the best-effort Telegram defaults from the messenger-gateway file. */
|
|
72
|
+
function readGatewayToken() {
|
|
73
|
+
try {
|
|
74
|
+
const p = getDshSettingsPath();
|
|
75
|
+
if (!fs.existsSync(p)) return null;
|
|
76
|
+
const text = fs.readFileSync(p, 'utf-8');
|
|
77
|
+
const lines = text.split('\n');
|
|
78
|
+
let inside = false;
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
if (/^[^\s#]/.test(line)) inside = line.startsWith('dsh-messenger-gateway:');
|
|
81
|
+
if (!inside) continue;
|
|
82
|
+
const hit = /^[ \t]+botToken:[ \t]*(.+?)[ \t]*$/.exec(line);
|
|
83
|
+
if (hit) return hit[1].replace(/^['"]|['"]$/g, '').trim() || null;
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the Telegram delivery secrets for a run.
|
|
91
|
+
* Precedence: credential reference → legacy stored token → messenger-gateway
|
|
92
|
+
* settings file → environment variable.
|
|
93
|
+
*/
|
|
94
|
+
export async function resolveTelegramSecrets(ctx, settings = {}) {
|
|
95
|
+
const out = { botToken: '', chatId: '', tokenSource: 'none' };
|
|
96
|
+
|
|
97
|
+
const fromRef = await resolveCredentialValue(ctx, settings.botTokenRef);
|
|
98
|
+
if (fromRef) {
|
|
99
|
+
out.botToken = fromRef;
|
|
100
|
+
out.tokenSource = 'credential';
|
|
101
|
+
}
|
|
102
|
+
if (!out.botToken && settings.botToken) {
|
|
103
|
+
out.botToken = String(settings.botToken);
|
|
104
|
+
out.tokenSource = 'legacy-settings';
|
|
105
|
+
}
|
|
106
|
+
if (!out.botToken) {
|
|
107
|
+
const gateway = readGatewayToken();
|
|
108
|
+
if (gateway) {
|
|
109
|
+
out.botToken = gateway;
|
|
110
|
+
out.tokenSource = 'messenger-gateway';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!out.botToken) {
|
|
114
|
+
const fromEnv = await resolveCredentialValue(ctx, 'CRON_TELEGRAM_BOT_TOKEN');
|
|
115
|
+
if (fromEnv) {
|
|
116
|
+
out.botToken = fromEnv;
|
|
117
|
+
out.tokenSource = 'env';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
out.chatId = String(settings.chatId || '').trim();
|
|
122
|
+
return out;
|
|
123
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -2,11 +2,31 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getDshDefaultTelegramCredentials } from './telegram.js';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Where plugin data lives.
|
|
7
|
+
*
|
|
8
|
+
* DSH_DATA_DIR wins when the harness sets it. Otherwise the profile home
|
|
9
|
+
* (DSH_HOME) is authoritative: falling back to ~/.dsh would make an isolated
|
|
10
|
+
* profile — a test contour, a second profile — write its tasks into another
|
|
11
|
+
* home's data directory, which is how the MiniPC test cycle leaked a task into
|
|
12
|
+
* the non-test profile.
|
|
13
|
+
*/
|
|
5
14
|
export function getDefaultStorePath() {
|
|
6
|
-
const base = process.env.DSH_DATA_DIR
|
|
15
|
+
const base = process.env.DSH_DATA_DIR
|
|
16
|
+
|| (process.env.DSH_HOME
|
|
17
|
+
? path.join(process.env.DSH_HOME, 'data')
|
|
18
|
+
: path.join(process.env.HOME || '.', '.dsh', 'data'));
|
|
7
19
|
return path.join(base, 'cron', 'tasks.json');
|
|
8
20
|
}
|
|
9
21
|
|
|
22
|
+
/** Mask a secret for transport to the browser, keeping a readable hint. */
|
|
23
|
+
function maskSecretValue(value) {
|
|
24
|
+
const s = String(value == null ? '' : value);
|
|
25
|
+
if (!s) return '';
|
|
26
|
+
if (s.length <= 8) return '••••••••';
|
|
27
|
+
return s.slice(0, 4) + '••••••••' + s.slice(-3);
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
export class TaskStore {
|
|
11
31
|
constructor(filePath) {
|
|
12
32
|
this.filePath = filePath || getDefaultStorePath();
|
|
@@ -70,14 +90,15 @@ export class TaskStore {
|
|
|
70
90
|
|
|
71
91
|
getSettings() {
|
|
72
92
|
const defaults = getDshDefaultTelegramCredentials();
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
const base = { ...this.settings };
|
|
94
|
+
base.botToken = base.botToken !== undefined ? base.botToken : (defaults.botToken || '');
|
|
95
|
+
base.botTokenRef = base.botTokenRef || '';
|
|
96
|
+
base.chatId = base.chatId !== undefined ? base.chatId : (defaults.chatId || '');
|
|
97
|
+
base.notifyTelegram = Boolean(base.notifyTelegram);
|
|
98
|
+
base.onlyOnFailure = Boolean(base.onlyOnFailure);
|
|
99
|
+
base.kanbanBaseUrl = base.kanbanBaseUrl || 'http://127.0.0.1:3000';
|
|
100
|
+
base.hasDefaultCredentials = Boolean(defaults.botToken && defaults.chatId);
|
|
101
|
+
return base;
|
|
81
102
|
}
|
|
82
103
|
|
|
83
104
|
getClientSettings() {
|
|
@@ -85,38 +106,87 @@ export class TaskStore {
|
|
|
85
106
|
const isCustom = Boolean(this.settings.botToken);
|
|
86
107
|
const hasToken = Boolean(raw.botToken);
|
|
87
108
|
const masked = hasToken ? (raw.botToken.length > 8 ? raw.botToken.slice(0, 4) + '••••••••' + raw.botToken.slice(-3) : '••••••••') : '';
|
|
88
|
-
|
|
109
|
+
const { botToken, ...rest } = raw;
|
|
110
|
+
const out = {
|
|
111
|
+
...rest,
|
|
89
112
|
botToken: masked,
|
|
90
113
|
botTokenMasked: masked,
|
|
91
114
|
hasBotToken: hasToken,
|
|
92
115
|
hasCustomBotToken: isCustom,
|
|
93
|
-
chatId: raw.chatId,
|
|
94
|
-
notifyTelegram: raw.notifyTelegram,
|
|
95
|
-
onlyOnFailure: raw.onlyOnFailure,
|
|
96
|
-
hasDefaultCredentials: raw.hasDefaultCredentials,
|
|
97
|
-
kanbanBaseUrl: raw.kanbanBaseUrl,
|
|
98
116
|
};
|
|
117
|
+
// Webhook URLs and the Bark device key carry an embedded secret, so they
|
|
118
|
+
// are never sent to the browser in clear text either.
|
|
119
|
+
for (const key of TaskStore.MASKED_SETTING_KEYS) {
|
|
120
|
+
if (key === 'botToken') continue;
|
|
121
|
+
out[key] = maskSecretValue(raw[key]);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Raw secret values that must not be stored in plugin settings; the
|
|
128
|
+
* corresponding *Ref credential names are the supported way to configure
|
|
129
|
+
* these channels.
|
|
130
|
+
*/
|
|
131
|
+
static get FORBIDDEN_SETTING_KEYS() {
|
|
132
|
+
return ['smtpPassword', 'giteaToken', 'ntfyToken', 'pushplusToken', 'discordToken', 'slackToken'];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Settings that hold a secret and are therefore masked on read: tokens,
|
|
137
|
+
* passwords, webhook URLs (the URL embeds the auth token) and the Bark key.
|
|
138
|
+
* These are stored in the plugin settings file; the credential-reference
|
|
139
|
+
* fields (botTokenRef, smtpPasswordRef, …) take precedence over them.
|
|
140
|
+
*/
|
|
141
|
+
static get MASKED_SETTING_KEYS() {
|
|
142
|
+
return ['botToken', 'smtpPassword', 'discordWebhookUrl', 'slackWebhookUrl', 'barkKey'];
|
|
99
143
|
}
|
|
100
144
|
|
|
101
145
|
saveSettings(newSettings = {}) {
|
|
146
|
+
const patch = { ...newSettings };
|
|
147
|
+
|
|
148
|
+
for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
|
|
149
|
+
if (key in patch) {
|
|
150
|
+
console.warn(`[dsh-cron] refusing to store raw secret "${key}" in settings — use a credential reference instead`);
|
|
151
|
+
delete patch[key];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
102
155
|
let botTokenToSave = this.settings.botToken || '';
|
|
103
|
-
if (
|
|
104
|
-
const val = String(
|
|
156
|
+
if (patch.botToken !== undefined) {
|
|
157
|
+
const val = String(patch.botToken).trim();
|
|
105
158
|
// If client sends masked token or empty string while custom token existed, don't overwrite with dots
|
|
106
159
|
if (val && !val.includes('••') && !val.includes('****')) {
|
|
107
160
|
botTokenToSave = val;
|
|
108
161
|
} else if (val === '') {
|
|
109
162
|
botTokenToSave = '';
|
|
110
163
|
}
|
|
164
|
+
delete patch.botToken;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// A masked value echoed back by the UI must never overwrite the real one.
|
|
168
|
+
for (const key of TaskStore.MASKED_SETTING_KEYS) {
|
|
169
|
+
if (key === 'botToken') continue;
|
|
170
|
+
const val = patch[key];
|
|
171
|
+
if (typeof val === 'string' && (val.includes('••') || val.includes('****'))) {
|
|
172
|
+
delete patch[key];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
177
|
+
if (value === undefined) delete patch[key];
|
|
178
|
+
else if (typeof value === 'string') patch[key] = value.trim();
|
|
111
179
|
}
|
|
112
180
|
|
|
113
181
|
this.settings = {
|
|
114
182
|
...this.settings,
|
|
183
|
+
...patch,
|
|
115
184
|
botToken: botTokenToSave,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
185
|
+
kanbanBaseUrl: patch.kanbanBaseUrl !== undefined
|
|
186
|
+
? (patch.kanbanBaseUrl || 'http://127.0.0.1:3000')
|
|
187
|
+
: (this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000'),
|
|
188
|
+
notifyTelegram: patch.notifyTelegram !== undefined ? Boolean(patch.notifyTelegram) : Boolean(this.settings.notifyTelegram),
|
|
189
|
+
onlyOnFailure: patch.onlyOnFailure !== undefined ? Boolean(patch.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
|
|
120
190
|
};
|
|
121
191
|
this.save();
|
|
122
192
|
return this.getSettings();
|
|
@@ -212,6 +282,7 @@ export class TaskStore {
|
|
|
212
282
|
error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
|
|
213
283
|
usage,
|
|
214
284
|
costUsd,
|
|
285
|
+
sessionId: runInfo.sessionId || null,
|
|
215
286
|
});
|
|
216
287
|
if (runs.length > 50) runs.length = 50;
|
|
217
288
|
this.history.set(id, runs);
|