@goodandready/dsh-cron 0.2.18 → 0.2.20

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/secrets.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  /**
2
3
  * Credential handling for dsh-cron (#51).
3
4
  *
@@ -54,10 +55,8 @@ export async function resolveCredentialValue(ctx, refName) {
54
55
  // Prefer the harness ref factory; fall back to the plain name when the
55
56
  // package is not installed (keeps tests and trimmed builds working).
56
57
  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 {}
58
+ const mod = await bestEffort('import-credentials', () => import('@deepseek-ai/dsh-credentials'));
59
+ if (mod && typeof mod.credentialRef === 'function') ref = mod.credentialRef(name);
61
60
  const hit = await creds.resolve(ref);
62
61
  if (hit && hit.value) return String(hit.value);
63
62
  }
@@ -70,7 +69,7 @@ export async function resolveCredentialValue(ctx, refName) {
70
69
 
71
70
  /** Read the best-effort Telegram defaults from the messenger-gateway file. */
72
71
  function readGatewayToken() {
73
- try {
72
+ return bestEffort('read-gateway-token', () => {
74
73
  const p = getDshSettingsPath();
75
74
  if (!fs.existsSync(p)) return null;
76
75
  const text = fs.readFileSync(p, 'utf-8');
@@ -82,8 +81,8 @@ function readGatewayToken() {
82
81
  const hit = /^[ \t]+botToken:[ \t]*(.+?)[ \t]*$/.exec(line);
83
82
  if (hit) return hit[1].replace(/^['"]|['"]$/g, '').trim() || null;
84
83
  }
85
- } catch {}
86
- return null;
84
+ return null;
85
+ }) || null;
87
86
  }
88
87
 
89
88
  /**
@@ -0,0 +1,120 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { credentialRefStatus } from './secrets.js';
3
+ import { TaskStore } from './store.js';
4
+ import { MIN_DELIVERY_TIMEOUT_MS, resolveDeliveryTimeoutMs } from './channels.js';
5
+
6
+ export const Config = z.object({
7
+ botToken: z.string().role('secret').default('').description('Legacy Telegram Bot API token (prefer botTokenRef with the DSH credentials service)'),
8
+ botTokenRef: z.string().default('').description('Name of the DSH credential that holds the Telegram bot token'),
9
+ chatId: z.string().default('').description('Telegram chat ID that receives task reports'),
10
+ notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
11
+ onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
12
+ kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API'),
13
+ defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)'),
14
+ maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
15
+ heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
16
+ heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)'),
17
+ // --- Delivery channels (#20-#23, #26, #28, #47) ---
18
+ template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
19
+ channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
20
+ silentRuleModel: z.string().default('').description('Model used to evaluate a task silent rule (empty = the task model)'),
21
+ inspectorModel: z.string().default('').description('Model used to diagnose failed runs (empty = the task model)'),
22
+ deliveryTimeoutMs: z.number().min(MIN_DELIVERY_TIMEOUT_MS).default(15000).description('Per-channel delivery timeout in ms (minimum 1000); a slower endpoint is recorded as a failure and does not block other channels or the next tick'),
23
+ discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
24
+ slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
25
+ ntfyUrl: z.string().default('https://ntfy.sh').description('ntfy server base URL'),
26
+ ntfyTopic: z.string().default('').description('ntfy topic for push notifications'),
27
+ ntfyTokenRef: z.string().default('').description('Credential name for the ntfy access token'),
28
+ barkServerUrl: z.string().default('https://api.day.app').description('Bark server base URL'),
29
+ barkKey: z.string().default('').description('Bark device key'),
30
+ pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token'),
31
+ pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)'),
32
+ ttsBaseUrl: z.string().default('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route'),
33
+ giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
34
+ giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
35
+ giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
36
+ prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)'),
37
+ // --- Declarative jobs (#50, ADR-0001) ---
38
+ jobs: z.array(z.any()).default([]).description('Static jobs owned by the config; each entry needs id, title, schedule and (for agent types) prompt'),
39
+ // --- External REST API (#54, ADR-0001) ---
40
+ apiToken: z.string().role('secret').default('').description('Bearer token for the external /dsh-cron/api/* surface (empty = the surface answers 503)'),
41
+ });
42
+
43
+ export const SETTINGS_SYNC_KEYS = [
44
+ 'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
45
+ 'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
46
+ 'discordWebhookUrl', 'slackWebhookUrl',
47
+ 'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
48
+ 'barkServerUrl', 'barkKey',
49
+ 'pushplusTokenRef',
50
+ 'pushplusUrl',
51
+ 'ttsBaseUrl',
52
+ 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'prReviewerEnabled',
53
+ 'apiToken',
54
+ ];
55
+
56
+ /**
57
+ * Apply settings writes through the registered settings scope so the core
58
+ * snapshot stays authoritative (#102). Returns null when the settings
59
+ * service is unavailable (headless profiles fall back to store-only writes).
60
+ */
61
+ export async function applySettingsToScope(settingsScope, body) {
62
+ if (!settingsScope || typeof settingsScope.set !== 'function') return null;
63
+ const errors = [];
64
+ for (const key of SETTINGS_SYNC_KEYS) {
65
+ if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
66
+ try {
67
+ await settingsScope.set(key, body[key]);
68
+ } catch (e) {
69
+ errors.push(key + ': ' + ((e && e.message) || e));
70
+ }
71
+ }
72
+ return errors;
73
+ }
74
+
75
+ /**
76
+ * Drop values that must never re-enter the store from the client:
77
+ *
78
+ * - a masked secret echoed back by the settings UI would overwrite the real
79
+ * value (bot token, webhook URLs, Bark key);
80
+ * - the raw credential keys are refused outright — the *Ref fields are the
81
+ * supported way to configure them.
82
+ *
83
+ * Applied before both the settings scope and the store, because the scope is
84
+ * the source of truth and would otherwise persist a raw or masked value.
85
+ */
86
+ export function sanitizeSettingsPayload(body) {
87
+ const payload = Object.assign({}, body);
88
+ for (const key of TaskStore.MASKED_SETTING_KEYS) {
89
+ const val = payload[key];
90
+ if (typeof val === 'string' && (val.includes('••') || val.includes('****'))) {
91
+ delete payload[key];
92
+ }
93
+ }
94
+ for (const key of ['botTokenRef', 'barkKeyRef', 'giteaTokenRef']) {
95
+ if (key in payload && typeof payload[key] === 'string' && payload[key].trim()) {
96
+ const refCheck = credentialRefStatus(payload[key]);
97
+ if (!refCheck.ok) {
98
+ console.warn(`[dsh-cron] invalid credential reference in "${key}": ${refCheck.error}`);
99
+ delete payload[key];
100
+ }
101
+ }
102
+ }
103
+ for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
104
+ if (key in payload) {
105
+ console.warn(`[dsh-cron] refusing raw secret "${key}" from the client — use a credential reference`);
106
+ delete payload[key];
107
+ }
108
+ }
109
+ if ('deliveryTimeoutMs' in payload) {
110
+ const raw = payload.deliveryTimeoutMs;
111
+ const n = Number(raw);
112
+ if (raw === '' || raw === null || !Number.isFinite(n) || n <= 0) {
113
+ delete payload.deliveryTimeoutMs;
114
+ } else {
115
+ payload.deliveryTimeoutMs = resolveDeliveryTimeoutMs(n);
116
+ }
117
+ }
118
+ return payload;
119
+ }
120
+
package/lib/store.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { getDshDefaultTelegramCredentials } from './telegram.js';
@@ -68,7 +69,7 @@ export class TaskStore {
68
69
  const raw = fs.readFileSync(this.filePath, 'utf-8');
69
70
  loadData(raw);
70
71
  if (!fs.existsSync(bakPath)) {
71
- try { fs.copyFileSync(this.filePath, bakPath); } catch {}
72
+ bestEffort('backup-initial', () => fs.copyFileSync(this.filePath, bakPath));
72
73
  }
73
74
  } catch (err) {
74
75
  console.error('[dsh-cron] store read error (corrupted file):', err.message);
@@ -93,7 +94,7 @@ export class TaskStore {
93
94
  try {
94
95
  const rawBak = fs.readFileSync(bakPath, 'utf-8');
95
96
  loadData(rawBak);
96
- try { fs.copyFileSync(bakPath, this.filePath); } catch {}
97
+ bestEffort('restore-from-bak', () => fs.copyFileSync(bakPath, this.filePath));
97
98
  console.warn(`[dsh-cron] restored missing tasks store from ${bakPath}`);
98
99
  } catch (bakErr) {
99
100
  console.error('[dsh-cron] failed to restore tasks store from .bak:', bakErr.message);
@@ -119,9 +120,7 @@ export class TaskStore {
119
120
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
120
121
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
121
122
  fs.renameSync(tmp, this.filePath);
122
- try {
123
- fs.copyFileSync(this.filePath, `${this.filePath}.bak`);
124
- } catch {}
123
+ bestEffort('backup-on-save', () => fs.copyFileSync(this.filePath, `${this.filePath}.bak`));
125
124
  } catch (err) {
126
125
  console.error('[dsh-cron] store save error:', err.message);
127
126
  }
@@ -393,9 +392,7 @@ export class TaskStore {
393
392
  const archivePath = path.join(dir, 'tasks-history-archive.json');
394
393
  let archive = {};
395
394
  if (fs.existsSync(archivePath)) {
396
- try {
397
- archive = JSON.parse(fs.readFileSync(archivePath, 'utf-8')) || {};
398
- } catch {}
395
+ archive = bestEffort('parse-history-archive', () => JSON.parse(fs.readFileSync(archivePath, 'utf-8'))) || {};
399
396
  }
400
397
  if (!Array.isArray(archive[id])) archive[id] = [];
401
398
  archive[id].push(...overflowRuns);
@@ -0,0 +1,60 @@
1
+ import { normalizeTaskType } from './runtimes.js';
2
+ import { validateTaskType } from './task-transfer.js';
3
+ import { parseScheduleExpression } from './scheduler.js';
4
+ import { CHANNEL_IDS } from './channels.js';
5
+
6
+ export function executeCreateTask(store, scheduler, args) {
7
+ const taskType = normalizeTaskType(args.type);
8
+ const typeError = validateTaskType(taskType, args);
9
+ if (typeError) throw new Error(typeError);
10
+ const parsed = parseScheduleExpression(args.schedule);
11
+ const task = store.set({
12
+ title: args.title,
13
+ schedule: parsed.cronPattern || args.schedule,
14
+ scheduleText: parsed.humanText,
15
+ prompt: args.prompt,
16
+ type: taskType,
17
+ delivery: args.delivery || 'isolated',
18
+ status: 'active',
19
+ provider: args.provider,
20
+ model: args.model,
21
+ notifyTelegram: Boolean(args.notifyTelegram),
22
+ onlyOnFailure: Boolean(args.onlyOnFailure),
23
+ timeoutSeconds: Number(args.timeoutSeconds) || 1800,
24
+ overlapPolicy: args.overlapPolicy || 'skip',
25
+ timezone: args.timezone ? String(args.timezone).trim() : '',
26
+ misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
27
+ maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
28
+ retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
29
+ permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
30
+ kanbanMode: args.kanbanMode || 'none',
31
+ channels: Array.isArray(args.channels) ? args.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined,
32
+ template: args.template ? String(args.template) : '',
33
+ env: args.env && typeof args.env === 'object' ? args.env : undefined,
34
+ cwd: args.cwd ? String(args.cwd).trim() : '',
35
+ workspaceId: args.workspaceId ? String(args.workspaceId).trim() : '',
36
+ worktree: Boolean(args.worktree),
37
+ keepWorktree: Boolean(args.keepWorktree),
38
+ httpMethod: args.httpMethod ? String(args.httpMethod).toUpperCase() : 'GET',
39
+ httpUrl: args.httpUrl ? String(args.httpUrl).trim() : '',
40
+ httpHeaders: args.httpHeaders,
41
+ httpBody: args.httpBody !== undefined ? String(args.httpBody) : '',
42
+ sshProfileId: args.sshProfileId ? String(args.sshProfileId).trim() : '',
43
+ sshTarget: args.sshTarget ? String(args.sshTarget).trim() : '',
44
+ sshPort: Number(args.sshPort) || 0,
45
+ sshKeyPath: args.sshKeyPath ? String(args.sshKeyPath).trim() : '',
46
+ dockerImage: args.dockerImage ? String(args.dockerImage).trim() : '',
47
+ pythonPath: args.pythonPath ? String(args.pythonPath).trim() : '',
48
+ nodePath: args.nodePath ? String(args.nodePath).trim() : '',
49
+ skillName: args.skillName ? String(args.skillName).trim() : '',
50
+ workflowName: args.workflowName ? String(args.workflowName).trim() : '',
51
+ oneShot: Boolean(parsed.isOneShot),
52
+ });
53
+ scheduler.scheduleTask(task);
54
+ return {
55
+ success: true,
56
+ message: `Task "${task.title}" scheduled successfully (${task.scheduleText})`,
57
+ task
58
+ };
59
+ }
60
+
package/lib/templates.js CHANGED
@@ -51,7 +51,7 @@ export function buildTemplateVars(task, runInfo = {}) {
51
51
  const error = runInfo.error || (status === 'error' || status === 'timeout' ? runInfo.output || '' : '');
52
52
  const usage = runInfo.usage || {};
53
53
  const tokens = (usage.inputTokens || 0) + (usage.outputTokens || 0) + (usage.cacheReadTokens || 0);
54
- return {
54
+ const vars = {
55
55
  title: task.title || 'Task',
56
56
  id: task.id || '',
57
57
  status,
@@ -65,6 +65,10 @@ export function buildTemplateVars(task, runInfo = {}) {
65
65
  model: runInfo.model || '',
66
66
  diagnosis: truncateText(runInfo.diagnosis || ''),
67
67
  };
68
+ for (const key of TEMPLATE_VARIABLES) {
69
+ if (vars[key] === undefined) vars[key] = '';
70
+ }
71
+ return vars;
68
72
  }
69
73
 
70
74
  /** Replace {var} placeholders; unknown placeholders are left untouched. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -19,7 +19,8 @@
19
19
  "scripts": {
20
20
  "test": "node --test test/*.test.mjs",
21
21
  "build:client": "node scripts/build-client.mjs",
22
- "pretest": "node scripts/build-client.mjs"
22
+ "pretest": "node scripts/build-client.mjs",
23
+ "preflight": "node scripts/ci-preflight.mjs"
23
24
  },
24
25
  "keywords": [
25
26
  "dsh",
@@ -45,8 +46,8 @@
45
46
  "client": {
46
47
  "platform": "web",
47
48
  "inject": [
48
- "locale",
49
- "slots"
49
+ "@deepseek-ai/dsh-client-locale",
50
+ "@deepseek-ai/dsh-client-ui-slots"
50
51
  ]
51
52
  }
52
53
  },