@goodandready/dsh-cron 0.2.42 → 0.2.44

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/client.js CHANGED
@@ -1499,7 +1499,7 @@ window.__ModuleLoader__.load({
1499
1499
 
1500
1500
  const actionInProgressRef = React.useRef(new Set());
1501
1501
 
1502
- const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1502
+ const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1503
1503
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1504
1504
  const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
1505
1505
  try {
@@ -1507,9 +1507,18 @@ window.__ModuleLoader__.load({
1507
1507
  ...options,
1508
1508
  signal: controller ? controller.signal : options.signal,
1509
1509
  });
1510
- return res;
1510
+ let data = {};
1511
+ try {
1512
+ data = await res.json();
1513
+ } catch (jsonErr) {
1514
+ // Re-throw if body reading aborted/timed out (#242)
1515
+ if (jsonErr && (jsonErr.name === 'AbortError' || controller?.signal.aborted)) {
1516
+ throw jsonErr;
1517
+ }
1518
+ }
1519
+ return { res, data };
1511
1520
  } catch (err) {
1512
- if (err && err.name === 'AbortError') {
1521
+ if (err && (err.name === 'AbortError' || controller?.signal.aborted)) {
1513
1522
  const timeoutErr = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
1514
1523
  timeoutErr.isTimeout = true;
1515
1524
  throw timeoutErr;
@@ -1519,13 +1528,13 @@ window.__ModuleLoader__.load({
1519
1528
  if (timer) clearTimeout(timer);
1520
1529
  }
1521
1530
  };
1531
+ const fetchWithTimeout = fetchJsonWithTimeout;
1522
1532
 
1523
1533
  const handleToggleTask = async (id) => {
1524
1534
  if (actionInProgressRef.current.has(id)) return;
1525
1535
  actionInProgressRef.current.add(id);
1526
1536
  try {
1527
- const res = await fetchWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' }, 15000);
1528
- const data = await res.json().catch(() => ({}));
1537
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' }, 15000);
1529
1538
  if (!res.ok || (data && data.ok === false)) {
1530
1539
  const msg = (data && data.error) || `Toggle failed (HTTP ${res.status})`;
1531
1540
  alert(msg);
@@ -1547,8 +1556,7 @@ window.__ModuleLoader__.load({
1547
1556
  const prevStatus = prevTask ? prevTask.lastStatus : null;
1548
1557
  setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
1549
1558
  try {
1550
- const res = await fetchWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' }, 15000);
1551
- const data = await res.json().catch(() => ({}));
1559
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' }, 15000);
1552
1560
  if (!res.ok || (data && data.ok === false)) {
1553
1561
  setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
1554
1562
  const msg = (data && data.error) || `Run failed (HTTP ${res.status})`;
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { bestEffort } from './best-effort.js';
3
4
  import { setLogger, logger } from './logger.js';
4
5
  import { registerPluginUpdater } from './updater.js';
@@ -79,6 +80,7 @@ import {
79
80
  sanitizeSettingsPayload,
80
81
  unwrapConfig,
81
82
  unwrapConfigValue,
83
+ plainConfig,
82
84
  } from './settings.js';
83
85
  import { registerRoutes } from './routes.js';
84
86
 
@@ -89,6 +91,7 @@ export {
89
91
  sanitizeSettingsPayload,
90
92
  unwrapConfig,
91
93
  unwrapConfigValue,
94
+ plainConfig,
92
95
  };
93
96
 
94
97
  export const name = '@goodandready/dsh-cron';
@@ -118,8 +121,9 @@ const pkgVersion = JSON.parse(fs.readFileSync(pkgUrl, 'utf8')).version || '0.2.2
118
121
 
119
122
  export function apply(ctx, rawConfig) {
120
123
  if (ctx && ctx.logger) setLogger(ctx.logger);
121
- const config = unwrapConfig(rawConfig);
122
- const store = new TaskStore();
124
+ const config = plainConfig(rawConfig);
125
+ const storePath = (config && (config.cronDir || config.storePath)) ? (config.storePath || path.join(config.cronDir, 'tasks.json')) : undefined;
126
+ const store = new TaskStore(storePath);
123
127
 
124
128
  // Sync settings declared in the profile config into the store (#184)
125
129
  if (config && typeof config === 'object') {
@@ -210,7 +214,8 @@ export function apply(ctx, rawConfig) {
210
214
  let activeVolatileConfig = {};
211
215
  function syncVolatileConfig(updatedConfig) {
212
216
  try {
213
- const cfg = updatedConfig || ctx.config || config || {};
217
+ const isPlainConfigObject = updatedConfig && typeof updatedConfig === 'object' && !Array.isArray(updatedConfig);
218
+ const cfg = isPlainConfigObject ? { ...(ctx.config || config || {}), ...updatedConfig } : (ctx.config || config || {});
214
219
  const unwrapped = unwrapConfig(cfg);
215
220
  activeVolatileConfig = { ...activeVolatileConfig, ...unwrapped };
216
221
  if (typeof unwrapped.maxConcurrent === 'number') {
package/lib/runner.js CHANGED
@@ -449,8 +449,13 @@ export class SessionRunner {
449
449
  }
450
450
  }
451
451
 
452
- if (turnEvents.length > 0 && !hasTerminalEvent) {
453
- const err = new Error('Agent turn ended without terminal completion event');
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
+ );
454
459
  err.turnFailed = true;
455
460
  err.usage = usage;
456
461
  err.costUsd = costUsd;
package/lib/settings.js CHANGED
@@ -53,28 +53,21 @@ export const Config = z.object({
53
53
 
54
54
 
55
55
  /**
56
- * Unwrap a volatile config reference or wrap a config object in a transparent Proxy.
56
+ * Deeply unwrap Cosmokit/Schemastery Volatile boxes into plain primitives/objects/arrays (#282).
57
57
  */
58
- export function unwrapConfigValue(val) {
59
- if (val && typeof val === 'object' && typeof val.get === 'function') {
60
- return val.get();
58
+ export function plainConfig(value) {
59
+ if (value === null || value === undefined) return value;
60
+ if (typeof value === 'function') return plainConfig(value());
61
+ if (typeof value.get === 'function') return plainConfig(value.get());
62
+ if (Array.isArray(value)) return value.map(plainConfig);
63
+ if (typeof value === 'object') {
64
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]));
61
65
  }
62
- return val;
66
+ return value;
63
67
  }
64
68
 
65
- export function unwrapConfig(cfg) {
66
- if (!cfg || typeof cfg !== 'object') return cfg;
67
- if (typeof cfg.get === 'function') return unwrapConfig(cfg.get());
68
- return new Proxy(cfg, {
69
- get(target, prop, receiver) {
70
- const val = Reflect.get(target, prop, receiver);
71
- if (val && typeof val === 'object' && typeof val.get === 'function') {
72
- return val.get();
73
- }
74
- return val;
75
- }
76
- });
77
- }
69
+ export const unwrapConfigValue = plainConfig;
70
+ export const unwrapConfig = plainConfig;
78
71
 
79
72
  export const SETTINGS_SYNC_KEYS = [
80
73
  'botToken', 'botTokenRef', 'telegramWebhookSecret', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
package/lib/store.js CHANGED
@@ -179,7 +179,7 @@ export class TaskStore {
179
179
  };
180
180
 
181
181
  const writeOp = async () => {
182
- if ((this._diskVersion || 0) >= version) {
182
+ if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
183
183
  return;
184
184
  }
185
185
  const dir = path.dirname(this.filePath);
@@ -188,37 +188,34 @@ export class TaskStore {
188
188
 
189
189
  const rand = Math.random().toString(36).slice(2, 8);
190
190
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
191
- 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);
192
195
  await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
193
196
  bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
194
197
 
195
- if ((this._diskVersion || 0) >= version || this._saveVersion > version) {
198
+ if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
196
199
  await fs.promises.unlink(tmp).catch(() => {});
200
+ if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
197
201
  return;
198
202
  }
199
203
 
200
- await fs.promises.rename(tmp, this.filePath);
201
- bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
202
-
203
- // Monotonic versioning & sync save interleaving repair (#244)
204
- if ((this._diskVersion || 0) > version) {
205
- const syncData = {
206
- version: 1,
207
- updatedAt: new Date().toISOString(),
208
- tasks: Array.from(this.tasks.values()),
209
- history: Object.fromEntries(this.history.entries()),
210
- settings: this.settings || {},
211
- };
212
- const randRep = Math.random().toString(36).slice(2, 8);
213
- const tmpRep = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${randRep}`;
214
- fs.writeFileSync(tmpRep, JSON.stringify(syncData, null, 2), { encoding: 'utf-8', mode: 0o600 });
215
- fs.renameSync(tmpRep, this.filePath);
216
- return;
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 only in that case (#244)
213
+ if (err.code === 'ENOENT' && (this._lastSyncVersion || 0) >= version) {
214
+ return;
215
+ }
216
+ throw err;
217
217
  }
218
218
 
219
- this._diskVersion = version;
220
- this._lastWrittenVersion = version;
221
-
222
219
  const now = Date.now();
223
220
  if (now - this._lastBackupTime > 60000) {
224
221
  this._lastBackupTime = now;
@@ -242,6 +239,14 @@ export class TaskStore {
242
239
  this._saveTimer = null;
243
240
  }
244
241
  const version = ++this._saveVersion;
242
+ this._lastSyncVersion = version;
243
+
244
+ // Immediately cancel and unlink any in-flight async tmp file so async rename cannot touch disk (#244)
245
+ if (this._currentAsyncTmp) {
246
+ const inFlightTmp = this._currentAsyncTmp;
247
+ this._currentAsyncTmp = null;
248
+ bestEffort('unlink-async-tmp', () => fs.unlinkSync(inFlightTmp));
249
+ }
245
250
  try {
246
251
  const dir = path.dirname(this.filePath);
247
252
  if (!fs.existsSync(dir)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.42",
3
+ "version": "0.2.44",
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",