@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/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. Friendly recurring aliases (English plus accepted Russian input aliases)
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 === 'active') {
142
- // Detect missed execution if daemon was offline (threshold: > 5 minutes)
143
- if (task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000) {
144
- const missedAt = task.nextRunAt;
145
- console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()}`);
146
- this.store.recordRun(task.id, {
147
- at: missedAt,
148
- status: 'missed',
149
- durationMs: 0,
150
- output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
151
- error: null,
152
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
153
- costUsd: 0,
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(async () => {
269
+ const timer = setTimeout(() => {
210
270
  this.timers.delete(task.id);
211
- await this.runTask(task.id);
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, { protect: true }, async () => {
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
- this.store.recordRun(taskId, runInfo);
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
- // 1. Telegram notification delivery
322
- try {
323
- const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
324
- if (shouldNotifyTask(task, runInfo, settings)) {
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
- // 2. Kanban card creation
339
- try {
340
- if (shouldCreateKanbanCard(task, runInfo)) {
341
- const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
342
- const isErr = runInfo.status === 'error' || runInfo.status === 'timeout';
343
- const cardTitle = isErr ? `[Cron failure] ${task.title}` : `[Cron completed] ${task.title}`;
344
- const cardBody = [
345
- `**Cron task:** ${task.title} (\`${task.id}\`)`,
346
- `**Schedule:** ${task.scheduleText || task.schedule}`,
347
- `**Status:** ${runInfo.status}`,
348
- `**Started at:** ${new Date(runInfo.at).toISOString()}`,
349
- runInfo.error ? `\n**Error:**\n\`\`\`\n${runInfo.error}\n\`\`\`` : '',
350
- runInfo.output ? `\n**Log / Output:**\n\`\`\`\n${runInfo.output.slice(0, 1000)}\n\`\`\`` : '',
351
- ].filter(Boolean).join('\n\n');
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
- // 3. Reschedule or mark completed for one-shot
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
- if (queuedCount > 0) {
387
- setImmediate(() => {
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 || path.join(process.env.HOME || '.', '.dsh', 'data');
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
- return {
74
- botToken: this.settings.botToken !== undefined ? this.settings.botToken : (defaults.botToken || ''),
75
- chatId: this.settings.chatId !== undefined ? this.settings.chatId : (defaults.chatId || ''),
76
- notifyTelegram: Boolean(this.settings.notifyTelegram),
77
- onlyOnFailure: Boolean(this.settings.onlyOnFailure),
78
- hasDefaultCredentials: Boolean(defaults.botToken && defaults.chatId),
79
- kanbanBaseUrl: this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000',
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
- return {
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 (newSettings.botToken !== undefined) {
104
- const val = String(newSettings.botToken).trim();
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
- chatId: newSettings.chatId !== undefined ? String(newSettings.chatId).trim() : (this.settings.chatId || ''),
117
- notifyTelegram: newSettings.notifyTelegram !== undefined ? Boolean(newSettings.notifyTelegram) : Boolean(this.settings.notifyTelegram),
118
- onlyOnFailure: newSettings.onlyOnFailure !== undefined ? Boolean(newSettings.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
119
- kanbanBaseUrl: newSettings.kanbanBaseUrl !== undefined ? String(newSettings.kanbanBaseUrl).trim() : (this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000'),
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);