@goodandready/dsh-cron 0.2.41 → 0.2.43

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 CHANGED
@@ -550,7 +550,7 @@ dsh-cron:
550
550
  Notes:
551
551
 
552
552
  * Run history is capped at **50 entries per task** (fixed); each entry keeps up to 4000 characters of output.
553
- * Tasks run in the **server's local timezone**; cron expressions are evaluated by `croner` on the host clock.
553
+ * Tasks run in their configured **timezone** (`task.timezone`, falling back to `settings.defaultTimezone` or server local timezone); cron expressions are evaluated by `croner` against the resolved timezone.
554
554
  * Tasks persist in the DSH data directory (`cron/tasks.json`) and survive restarts; missed one-shots are detected on startup.
555
555
 
556
556
  ---
@@ -21,22 +21,26 @@ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
21
21
  const botToken = settings.botToken || defaults.botToken;
22
22
  const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
23
23
 
24
- // Secret token verification (Issue #208)
24
+ // Secret token verification (Issue #208) - fail-closed: secret MUST be configured
25
25
  const webhookSecret = settings.telegramWebhookSecret || settings.webhookSecret || settings.secretToken || process.env.TELEGRAM_WEBHOOK_SECRET;
26
- if (webhookSecret) {
27
- const receivedSecret = req.headers['x-telegram-bot-api-secret-token'];
28
- if (!receivedSecret || typeof receivedSecret !== 'string') {
29
- logger.warn('[dsh-cron] unauthorized telegram webhook: missing secret token header');
30
- sendJson(res, 401, { ok: false, error: 'Unauthorized: missing secret token' });
31
- return;
32
- }
33
- const secretBuf = Buffer.from(String(webhookSecret));
34
- const receivedBuf = Buffer.from(String(receivedSecret));
35
- if (secretBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(secretBuf, receivedBuf)) {
36
- logger.warn('[dsh-cron] unauthorized telegram webhook: invalid secret token');
37
- sendJson(res, 401, { ok: false, error: 'Unauthorized: invalid secret token' });
38
- return;
39
- }
26
+ if (!webhookSecret) {
27
+ logger.warn('[dsh-cron] unauthorized telegram webhook: webhook secret is not configured (fail-closed)');
28
+ sendJson(res, 401, { ok: false, error: 'Unauthorized: webhook secret is not configured' });
29
+ return;
30
+ }
31
+ const headers = req.headers || {};
32
+ const receivedSecret = headers['x-telegram-bot-api-secret-token'] || headers['X-Telegram-Bot-Api-Secret-Token'];
33
+ if (!receivedSecret || typeof receivedSecret !== 'string') {
34
+ logger.warn('[dsh-cron] unauthorized telegram webhook: missing secret token header');
35
+ sendJson(res, 401, { ok: false, error: 'Unauthorized: missing secret token' });
36
+ return;
37
+ }
38
+ const secretBuf = Buffer.from(String(webhookSecret));
39
+ const receivedBuf = Buffer.from(String(receivedSecret));
40
+ if (secretBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(secretBuf, receivedBuf)) {
41
+ logger.warn('[dsh-cron] unauthorized telegram webhook: invalid secret token');
42
+ sendJson(res, 401, { ok: false, error: 'Unauthorized: invalid secret token' });
43
+ return;
40
44
  }
41
45
 
42
46
  // Deduplicate updates by update_id (Issue #208)
package/lib/client.js CHANGED
@@ -1499,12 +1499,34 @@ window.__ModuleLoader__.load({
1499
1499
 
1500
1500
  const actionInProgressRef = React.useRef(new Set());
1501
1501
 
1502
+ const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1503
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1504
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
1505
+ try {
1506
+ const res = await fetch(url, {
1507
+ ...options,
1508
+ signal: controller ? controller.signal : options.signal,
1509
+ });
1510
+ const data = await res.json().catch(() => ({}));
1511
+ return { res, data };
1512
+ } catch (err) {
1513
+ if (err && err.name === 'AbortError') {
1514
+ const timeoutErr = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
1515
+ timeoutErr.isTimeout = true;
1516
+ throw timeoutErr;
1517
+ }
1518
+ throw err;
1519
+ } finally {
1520
+ if (timer) clearTimeout(timer);
1521
+ }
1522
+ };
1523
+ const fetchWithTimeout = fetchJsonWithTimeout;
1524
+
1502
1525
  const handleToggleTask = async (id) => {
1503
1526
  if (actionInProgressRef.current.has(id)) return;
1504
1527
  actionInProgressRef.current.add(id);
1505
1528
  try {
1506
- const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' });
1507
- const data = await res.json().catch(() => ({}));
1529
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' }, 15000);
1508
1530
  if (!res.ok || (data && data.ok === false)) {
1509
1531
  const msg = (data && data.error) || `Toggle failed (HTTP ${res.status})`;
1510
1532
  alert(msg);
@@ -1526,8 +1548,7 @@ window.__ModuleLoader__.load({
1526
1548
  const prevStatus = prevTask ? prevTask.lastStatus : null;
1527
1549
  setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
1528
1550
  try {
1529
- const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' });
1530
- const data = await res.json().catch(() => ({}));
1551
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' }, 15000);
1531
1552
  if (!res.ok || (data && data.ok === false)) {
1532
1553
  setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
1533
1554
  const msg = (data && data.error) || `Run failed (HTTP ${res.status})`;
package/lib/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { bestEffort } from './best-effort.js';
2
4
  import { setLogger, logger } from './logger.js';
3
5
  import { registerPluginUpdater } from './updater.js';
4
6
  import { credentialRefStatus } from './secrets.js';
@@ -118,7 +120,8 @@ const pkgVersion = JSON.parse(fs.readFileSync(pkgUrl, 'utf8')).version || '0.2.2
118
120
  export function apply(ctx, rawConfig) {
119
121
  if (ctx && ctx.logger) setLogger(ctx.logger);
120
122
  const config = unwrapConfig(rawConfig);
121
- const store = new TaskStore();
123
+ const storePath = (config && (config.cronDir || config.storePath)) ? (config.storePath || path.join(config.cronDir, 'tasks.json')) : undefined;
124
+ const store = new TaskStore(storePath);
122
125
 
123
126
  // Sync settings declared in the profile config into the store (#184)
124
127
  if (config && typeof config === 'object') {
@@ -182,6 +185,8 @@ export function apply(ctx, rawConfig) {
182
185
 
183
186
  scheduler.start();
184
187
 
188
+ // Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
189
+ // external monitor can alert when this scheduler stops responding.
185
190
  // Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
186
191
  // external monitor can alert when this scheduler stops responding.
187
192
  let heartbeatTimer = null;
@@ -203,6 +208,47 @@ export function apply(ctx, rawConfig) {
203
208
  }
204
209
  updateHeartbeat(config && config.heartbeatUrl, config && config.heartbeatIntervalSec);
205
210
 
211
+ // Synchronize volatile Config settings to runtime scheduler and store (#235)
212
+ let activeVolatileConfig = {};
213
+ function syncVolatileConfig(updatedConfig) {
214
+ try {
215
+ const isPlainConfigObject = updatedConfig && typeof updatedConfig === 'object' && !Array.isArray(updatedConfig);
216
+ const cfg = isPlainConfigObject ? { ...(ctx.config || config || {}), ...updatedConfig } : (ctx.config || config || {});
217
+ const unwrapped = unwrapConfig(cfg);
218
+ activeVolatileConfig = { ...activeVolatileConfig, ...unwrapped };
219
+ if (typeof unwrapped.maxConcurrent === 'number') {
220
+ scheduler.maxConcurrent = unwrapped.maxConcurrent;
221
+ }
222
+ if (typeof unwrapped.defaultTimezone === 'string') {
223
+ scheduler.defaultTimezone = unwrapped.defaultTimezone;
224
+ }
225
+ if (typeof unwrapped.llmActionsEnabled === 'boolean') {
226
+ scheduler.llmActionsEnabled = unwrapped.llmActionsEnabled;
227
+ }
228
+ if (unwrapped.heartbeatUrl !== undefined || unwrapped.heartbeatIntervalSec !== undefined) {
229
+ updateHeartbeat(unwrapped.heartbeatUrl, unwrapped.heartbeatIntervalSec);
230
+ }
231
+ const current = store.getSettings();
232
+ const patch = {};
233
+ for (const k of ['maxConcurrent', 'defaultTimezone', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled']) {
234
+ if (unwrapped[k] !== undefined && unwrapped[k] !== current[k]) {
235
+ patch[k] = unwrapped[k];
236
+ }
237
+ }
238
+ if (Object.keys(patch).length > 0) {
239
+ store.saveSettings(patch);
240
+ }
241
+ } catch (err) {
242
+ logger.warn('[dsh-cron] error syncing volatile config:', err.message);
243
+ }
244
+ }
245
+
246
+ if (typeof ctx.on === 'function') {
247
+ ctx.on('loader/volatile-update', (updatedConfig) => {
248
+ syncVolatileConfig(updatedConfig);
249
+ });
250
+ }
251
+
206
252
  ctx.effect(() => {
207
253
  return () => {
208
254
  if (heartbeatTimer) clearInterval(heartbeatTimer);
@@ -237,7 +283,17 @@ export function apply(ctx, rawConfig) {
237
283
  handler: async (req, res) => {
238
284
  try {
239
285
  if (req.method === 'GET') {
240
- sendJson(res, 200, { ok: true, settings: { ...store.getClientSettings(), version: pkgVersion } });
286
+ const unwrapped = {
287
+ ...unwrapConfig(ctx.config || config || {}),
288
+ ...activeVolatileConfig,
289
+ };
290
+ const clientSettings = store.getClientSettings();
291
+ for (const k of ['maxConcurrent', 'defaultTimezone', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled']) {
292
+ if (unwrapped[k] !== undefined) {
293
+ clientSettings[k] = unwrapped[k];
294
+ }
295
+ }
296
+ sendJson(res, 200, { ok: true, settings: { ...clientSettings, version: pkgVersion } });
241
297
  return;
242
298
  }
243
299
  if (req.method === 'POST') {
@@ -112,11 +112,29 @@ export async function runViaRemoteWorkspace(task, signal, ctx) {
112
112
 
113
113
  let profile = null;
114
114
  profile = bestEffort('get-remoteSsh-profile', () => {
115
+ const remoteProfilesSvc = ctx.get('remoteProfiles');
116
+ if (remoteProfilesSvc && typeof remoteProfilesSvc.list === 'function') {
117
+ const list = remoteProfilesSvc.list();
118
+ if (Array.isArray(list)) {
119
+ const found = list.find((p) => p && p.id === task.sshProfileId);
120
+ if (found) return found;
121
+ }
122
+ }
115
123
  const settings = ctx.get('settings');
116
- if (settings && typeof settings.get === 'function') {
117
- const snap = settings.get('dsh-remote-workspace');
118
- const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
119
- return profiles.find((p) => p && p.id === task.sshProfileId) || null;
124
+ if (settings) {
125
+ if (typeof settings.describe === 'function') {
126
+ const desc = settings.describe();
127
+ const entry = Array.isArray(desc) ? desc.find((r) => r && r.ns === 'dsh-remote-workspace') : null;
128
+ const snap = entry ? entry.value : null;
129
+ const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
130
+ const found = profiles.find((p) => p && p.id === task.sshProfileId);
131
+ if (found) return found;
132
+ }
133
+ if (typeof settings.get === 'function') {
134
+ const snap = settings.get('dsh-remote-workspace');
135
+ const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
136
+ return profiles.find((p) => p && p.id === task.sshProfileId) || null;
137
+ }
120
138
  }
121
139
  return null;
122
140
  });
package/lib/runner.js CHANGED
@@ -423,22 +423,46 @@ export class SessionRunner {
423
423
  turnEvents = handle.agent.log.slice(startSeq);
424
424
  }
425
425
 
426
- // 3. Terminal turn failure check (#227)
426
+ // Extract usage FIRST so failed turns and budget checks have accurate token and cost accounting (#228)
427
+ const usage = this._extractUsage(handle, turnEvents);
428
+ const costUsd = estimateTokenCost(prep.model, usage);
429
+
430
+ // 3. Terminal turn failure and settlement check (#227)
431
+ let hasTerminalEvent = false;
427
432
  for (const ev of turnEvents) {
428
433
  if (!ev) continue;
429
434
  if (ev.type === 'turn/end' || ev.type === 'turn/error' || ev.type === 'turn/failure') {
435
+ hasTerminalEvent = true;
430
436
  const reason = ev.data?.reason || ev.reason;
431
437
  const reasonKind = (reason && typeof reason === 'object') ? reason.kind : reason;
432
- if (['error', 'interrupted', 'failed', 'aborted'].includes(reasonKind) || ev.data?.error || ev.error) {
433
- const errMsg = ev.data?.error?.message || ev.error?.message || reason?.message || (typeof reason === 'string' ? reason : 'Agent turn ended with error');
438
+ if (['error', 'interrupted', 'failed', 'aborted', 'blocked'].includes(reasonKind) || ev.data?.error || ev.error) {
439
+ const errMsg = ev.data?.error?.message || ev.error?.message || reason?.message || (typeof reason === 'string' ? reason : `Agent turn ended with status: ${reasonKind || 'error'}`);
434
440
  const err = new Error(errMsg);
435
441
  err.reason = reason;
442
+ err.reasonKind = reasonKind;
436
443
  err.turnFailed = true;
444
+ err.usage = usage;
445
+ err.costUsd = costUsd;
446
+ err.sessionId = handle.agent.session?.id || null;
437
447
  throw err;
438
448
  }
439
449
  }
440
450
  }
441
451
 
452
+ const hasEventLog = typeof session?.snapshotEvents === 'function' || Array.isArray(session?.log) || Array.isArray(handle.agent?.log);
453
+ if (hasEventLog && !hasTerminalEvent) {
454
+ const err = new Error(
455
+ turnEvents.length === 0
456
+ ? 'Agent turn produced no events before quiescence'
457
+ : 'Agent turn ended without terminal completion event'
458
+ );
459
+ err.turnFailed = true;
460
+ err.usage = usage;
461
+ err.costUsd = costUsd;
462
+ err.sessionId = handle.agent.session?.id || null;
463
+ throw err;
464
+ }
465
+
442
466
  // 4. Extract assistant message text from turn events (#227)
443
467
  const messageTexts = [];
444
468
  const chunkTexts = [];
@@ -476,11 +500,10 @@ export class SessionRunner {
476
500
  outputText = `[dsh-cron] Agent finished turn for task "${task.title}" (${sessionNote})`;
477
501
  }
478
502
 
479
- const usage = this._extractUsage(handle, turnEvents);
480
503
  return {
481
504
  output: outputText,
482
505
  usage,
483
- costUsd: estimateTokenCost(prep.model, usage),
506
+ costUsd,
484
507
  sessionId: handle.agent.session?.id || null,
485
508
  };
486
509
  } finally {
@@ -541,27 +564,35 @@ export class SessionRunner {
541
564
  }
542
565
  }
543
566
 
544
- // assistant/attempt usage (e.g. paid failed attempts or retry attempts)
545
- if (ev.type === 'assistant/attempt' && ev.data?.usage) {
546
- const u = ev.data.usage;
547
- if (ev.data.failed || ev.data.error || ev.data.status === 'failed') {
548
- failedAttempts.push({
567
+ // assistant/attempt usage (e.g. paid failed attempts, retries, streaming attempts) (#228)
568
+ if (ev.type === 'assistant/attempt') {
569
+ let attemptUsage = null;
570
+ if (ev.data?.usage) {
571
+ const u = ev.data.usage;
572
+ attemptUsage = {
549
573
  inputTokens: Number(u.inputTokens || u.promptTokens || u.uncachedInputTokens) || 0,
550
574
  outputTokens: Number(u.outputTokens || u.completionTokens) || 0,
551
575
  cacheReadTokens: Number(u.cacheReadTokens || u.cachedTokens) || 0,
552
- });
553
- hasEventUsage = true;
554
- } else {
555
- const key = `${ev.data.turn || 0}:${ev.data.step || 0}`;
556
- if (!stepUsage.has(key)) {
557
- stepUsage.set(key, {
558
- inputTokens: Number(u.inputTokens || u.promptTokens || u.uncachedInputTokens) || 0,
559
- outputTokens: Number(u.outputTokens || u.completionTokens) || 0,
560
- cacheReadTokens: Number(u.cacheReadTokens || u.cachedTokens) || 0,
561
- });
562
- hasEventUsage = true;
576
+ };
577
+ }
578
+ // DSH 0.2.0-rc.2 AssistantStreamRecord: stream contains usage chunks
579
+ if (Array.isArray(ev.data?.stream)) {
580
+ for (const item of ev.data.stream) {
581
+ const u = item?.chunk?.usage || item?.usage;
582
+ if (u) {
583
+ if (!attemptUsage) {
584
+ attemptUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
585
+ }
586
+ attemptUsage.inputTokens += Number(u.inputTokens || u.promptTokens || u.uncachedInputTokens) || 0;
587
+ attemptUsage.outputTokens += Number(u.outputTokens || u.completionTokens) || 0;
588
+ attemptUsage.cacheReadTokens += Number(u.cacheReadTokens || u.cachedTokens) || 0;
589
+ }
563
590
  }
564
591
  }
592
+ if (attemptUsage) {
593
+ failedAttempts.push(attemptUsage);
594
+ hasEventUsage = true;
595
+ }
565
596
  }
566
597
  }
567
598
 
@@ -146,6 +146,9 @@ export async function executeOnce(scheduler, task, signal, options = {}) {
146
146
  result.error = err.message || String(err);
147
147
  const timedOut = result.error.toLowerCase().includes('timed out') || result.error.toLowerCase().includes('timeout');
148
148
  result.status = timedOut ? 'timeout' : 'error';
149
+ if (err.usage) result.usage = err.usage;
150
+ if (typeof err.costUsd === 'number') result.costUsd = err.costUsd;
151
+ if (err.sessionId) result.sessionId = err.sessionId;
149
152
  }
150
153
  return result;
151
154
  }
package/lib/store.js CHANGED
@@ -39,6 +39,7 @@ export class TaskStore {
39
39
  this._saveTimer = null;
40
40
  this._savePromise = Promise.resolve();
41
41
  this._saveVersion = 0;
42
+ this._diskVersion = 0;
42
43
  this._lastWrittenVersion = 0;
43
44
  this.init();
44
45
  }
@@ -178,7 +179,7 @@ export class TaskStore {
178
179
  };
179
180
 
180
181
  const writeOp = async () => {
181
- if (this._lastWrittenVersion >= version) {
182
+ if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
182
183
  return;
183
184
  }
184
185
  const dir = path.dirname(this.filePath);
@@ -187,18 +188,30 @@ export class TaskStore {
187
188
 
188
189
  const rand = Math.random().toString(36).slice(2, 8);
189
190
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
190
- const payload = JSON.stringify(data, null, 2);
191
+ this._currentAsyncTmp = tmp;
192
+
193
+ // Use compact serialization without whitespace indentation to eliminate event loop lag on large stores (#244)
194
+ const payload = JSON.stringify(data);
191
195
  await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
192
196
  bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
193
197
 
194
- if (this._lastWrittenVersion >= version) {
198
+ if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
195
199
  await fs.promises.unlink(tmp).catch(() => {});
200
+ if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
196
201
  return;
197
202
  }
198
203
 
199
- await fs.promises.rename(tmp, this.filePath);
200
- bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
201
- this._lastWrittenVersion = version;
204
+ try {
205
+ await fs.promises.rename(tmp, this.filePath);
206
+ bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
207
+ this._currentAsyncTmp = null;
208
+ this._diskVersion = version;
209
+ this._lastWrittenVersion = version;
210
+ } catch (err) {
211
+ this._currentAsyncTmp = null;
212
+ // If tmp was unlinked by newer sync save, rename will fail with ENOENT - safely ignore
213
+ if (err.code !== 'ENOENT') throw err;
214
+ }
202
215
 
203
216
  const now = Date.now();
204
217
  if (now - this._lastBackupTime > 60000) {
@@ -223,6 +236,14 @@ export class TaskStore {
223
236
  this._saveTimer = null;
224
237
  }
225
238
  const version = ++this._saveVersion;
239
+ this._lastSyncVersion = version;
240
+
241
+ // Immediately cancel and unlink any in-flight async tmp file so async rename cannot touch disk (#244)
242
+ if (this._currentAsyncTmp) {
243
+ const inFlightTmp = this._currentAsyncTmp;
244
+ this._currentAsyncTmp = null;
245
+ bestEffort('unlink-async-tmp', () => fs.unlinkSync(inFlightTmp));
246
+ }
226
247
  try {
227
248
  const dir = path.dirname(this.filePath);
228
249
  if (!fs.existsSync(dir)) {
@@ -243,7 +264,8 @@ export class TaskStore {
243
264
  bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
244
265
  fs.renameSync(tmp, this.filePath);
245
266
  bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
246
- this._lastWrittenVersion = version;
267
+ this._diskVersion = Math.max(this._diskVersion || 0, version);
268
+ this._lastWrittenVersion = this._diskVersion;
247
269
 
248
270
  // Throttled backup (.bak): avoid expensive copyFileSync on every micro-save
249
271
  const now = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.41",
3
+ "version": "0.2.43",
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",