@goodandready/dsh-cron 0.2.48 → 0.2.50

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/api.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { randomUUID, createHash } from 'node:crypto';
9
9
  import { parseScheduleExpression } from './scheduler.js';
10
- import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
10
+ import { normalizeTaskType, CODE_EXECUTING_TYPES, EXTERNAL_RUNTIMES } from './runtimes.js';
11
11
  import { CHANNEL_IDS, unknownChannelIds } from './channels.js';
12
12
  import { applyTaskPatch } from './task-patch.js';
13
13
  import { MANAGED_BY_CONFIG, configOwnedMessage } from './config-jobs.js';
@@ -265,7 +265,7 @@ function buildTaskRecord(store, body, taskType, parsed) {
265
265
  misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
266
266
  maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
267
267
  retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
268
- permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
268
+ permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current && !EXTERNAL_RUNTIMES.includes(taskType) ? (current.permissionPreset || 'default') : 'default'),
269
269
  ...mergeExecutionFields(current, body, parsed),
270
270
  };
271
271
  }
package/lib/client.js CHANGED
@@ -137,6 +137,7 @@ window.__ModuleLoader__.load({
137
137
  'modal.params': 'Parameters',
138
138
  'modal.history': 'Run history ({count})',
139
139
  'modal.cancel': 'Cancel',
140
+ 'modal.confirmDiscard': 'Discard unsaved changes?',
140
141
  'modal.save': 'Save changes',
141
142
  'modal.create': 'Create task',
142
143
  'modal.close': 'Close',
@@ -492,6 +493,7 @@ window.__ModuleLoader__.load({
492
493
  'modal.params': '配置参数',
493
494
  'modal.history': '运行历史 ({count})',
494
495
  'modal.cancel': '取消',
496
+ 'modal.confirmDiscard': '放弃未保存的内容?',
495
497
  'modal.save': '保存修改',
496
498
  'modal.create': '创建任务',
497
499
  'history.loading': '正在加载历史记录...',
@@ -1102,6 +1104,54 @@ window.__ModuleLoader__.load({
1102
1104
  return (typeof t === 'function' ? t : translate)(key, vars);
1103
1105
  }
1104
1106
 
1107
+ /**
1108
+ * Issue #307: universal fetch with AbortController timeout (default 15s).
1109
+ * Connects with caller signal if supplied and fails closed on timeouts.
1110
+ */
1111
+ const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1112
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1113
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
1114
+ if (options && options.signal && controller) {
1115
+ if (options.signal.aborted) {
1116
+ controller.abort();
1117
+ } else {
1118
+ options.signal.addEventListener('abort', () => controller.abort(), { once: true });
1119
+ }
1120
+ }
1121
+ try {
1122
+ const res = await fetch(url, {
1123
+ ...options,
1124
+ signal: controller ? controller.signal : (options ? options.signal : undefined),
1125
+ });
1126
+ return res;
1127
+ } catch (err) {
1128
+ if (err && (err.name === 'AbortError' || controller?.signal?.aborted)) {
1129
+ const timeoutErr = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
1130
+ timeoutErr.isTimeout = true;
1131
+ throw timeoutErr;
1132
+ }
1133
+ throw err;
1134
+ } finally {
1135
+ if (timer) clearTimeout(timer);
1136
+ }
1137
+ };
1138
+
1139
+ /**
1140
+ * Issue #307: universal JSON fetch helper with timeout and aborted body protection.
1141
+ */
1142
+ const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1143
+ const res = await fetchWithTimeout(url, options, timeoutMs);
1144
+ let data = {};
1145
+ try {
1146
+ data = await res.json();
1147
+ } catch (jsonErr) {
1148
+ if (jsonErr && jsonErr.name === 'AbortError') {
1149
+ throw jsonErr;
1150
+ }
1151
+ }
1152
+ return { res, data };
1153
+ };
1154
+
1105
1155
  function pickDeliverySettings(source) {
1106
1156
  const out = {};
1107
1157
  for (const key of DELIVERY_SETTING_KEYS.concat(TEMPLATE_SETTING_KEYS)) {
@@ -1290,6 +1340,80 @@ window.__ModuleLoader__.load({
1290
1340
  const [formWorkflowName, setFormWorkflowName] = React.useState('');
1291
1341
  const [stats, setStats] = React.useState(null);
1292
1342
 
1343
+ const initialFormSnapshotRef = React.useRef(null);
1344
+
1345
+ const getFormSnapshot = () => JSON.stringify({
1346
+ title: String(formTitle || '').trim(),
1347
+ schedule: String(formSchedule || '').trim(),
1348
+ type: String(formType || 'llm'),
1349
+ prompt: String(formPrompt || '').trim(),
1350
+ provider: String(formProvider || ''),
1351
+ model: String(formModel || ''),
1352
+ fallbackModel: String(formFallbackModel || ''),
1353
+ silentRule: String(formSilentRule || ''),
1354
+ inspectOnFailure: Boolean(formInspectOnFailure),
1355
+ delivery: String(formDelivery || 'isolated'),
1356
+ notifyTelegram: Boolean(formNotifyTelegram),
1357
+ channels: Array.isArray(formChannels) ? [...formChannels].sort() : [],
1358
+ template: String(formTemplate || '').trim(),
1359
+ onlyOnFailure: Boolean(formOnlyOnFailure),
1360
+ timeoutSeconds: Number(formTimeoutSeconds) || 1800,
1361
+ overlapPolicy: String(formOverlapPolicy || 'skip'),
1362
+ kanbanMode: String(formKanbanMode || 'none'),
1363
+ onSuccess: String(formOnSuccess || '').trim(),
1364
+ onFailure: String(formOnFailure || '').trim(),
1365
+ heartbeatIntervalSeconds: Number(formHeartbeatIntervalSeconds) || 0,
1366
+ gracePeriodSeconds: Number(formGracePeriodSeconds) || 300,
1367
+ preflightType: String(formPreflightType || 'none'),
1368
+ preflightTarget: String(formPreflightTarget || '').trim(),
1369
+ priority: Number(formPriority) || 5,
1370
+ concurrencyGroup: String(formConcurrencyGroup || 'default').trim(),
1371
+ selfHealingCommand: String(formSelfHealingCommand || '').trim(),
1372
+ autoDiagnose: Boolean(formAutoDiagnose),
1373
+ timezone: String(formTimezone || '').trim(),
1374
+ misfirePolicy: String(formMisfirePolicy || 'skip'),
1375
+ maxRetries: Number(formMaxRetries) || 0,
1376
+ permissionPreset: String(formPermissionPreset || 'default'),
1377
+ agentPreset: String(formAgentPreset || '').trim(),
1378
+ targetSessionId: String(formTargetSessionId || '').trim(),
1379
+ targetSessionReset: String(formTargetSessionReset || 'never'),
1380
+ env: String(formEnv || '').trim(),
1381
+ cwd: String(formCwd || '').trim(),
1382
+ workspaceId: String(formWorkspaceId || '').trim(),
1383
+ worktree: Boolean(formWorktree),
1384
+ keepWorktree: Boolean(formKeepWorktree),
1385
+ httpMethod: String(formHttpMethod || 'GET'),
1386
+ httpUrl: String(formHttpUrl || '').trim(),
1387
+ httpHttpHeaders: String(formHttpHeaders || '').trim(),
1388
+ httpBody: String(formHttpBody || '').trim(),
1389
+ sshProfileId: String(formSshProfileId || '').trim(),
1390
+ sshTarget: String(formSshTarget || '').trim(),
1391
+ sshKeyPath: String(formSshKeyPath || '').trim(),
1392
+ dockerImage: String(formDockerImage || '').trim(),
1393
+ nodePath: String(formNodePath || '').trim(),
1394
+ pythonPath: String(formPythonPath || '').trim(),
1395
+ skillName: String(formSkillName || '').trim(),
1396
+ workflowName: String(formWorkflowName || '').trim(),
1397
+ });
1398
+
1399
+ const isFormDirty = () => {
1400
+ if (!manualModalOpen) return false;
1401
+ if (initialFormSnapshotRef.current !== null) {
1402
+ return getFormSnapshot() !== initialFormSnapshotRef.current;
1403
+ }
1404
+ return Boolean(formTitle.trim() || formPrompt.trim());
1405
+ };
1406
+
1407
+ const closeManualModalWithConfirm = () => {
1408
+ if (isFormDirty()) {
1409
+ if (!confirm(T_KEY(t, 'modal.confirmDiscard') || 'Discard unsaved changes?')) {
1410
+ return;
1411
+ }
1412
+ }
1413
+ initialFormSnapshotRef.current = null;
1414
+ setManualModalOpen(false);
1415
+ };
1416
+
1293
1417
  // Live execution indicator (#35): a light 1s tick only while some task
1294
1418
  // is actually running.
1295
1419
  const tasksRef = React.useRef([]);
@@ -1331,7 +1455,7 @@ window.__ModuleLoader__.load({
1331
1455
  const onKey = (e) => {
1332
1456
  if (e.key === 'Escape') {
1333
1457
  if (dropdownOpen) { setDropdownOpen(false); return; }
1334
- if (manualModalOpen) { setManualModalOpen(false); return; }
1458
+ if (manualModalOpen) { closeManualModalWithConfirm(); return; }
1335
1459
  if (settingsModalOpen) { setSettingsModalOpen(false); return; }
1336
1460
  if (dshModalOpen && !dshLoading) { setDshModalOpen(false); return; }
1337
1461
  if (onClose) onClose();
@@ -1391,8 +1515,12 @@ window.__ModuleLoader__.load({
1391
1515
  const gen = ++fetchGenRef.current;
1392
1516
  setLoading(true);
1393
1517
  setFetchError(null);
1518
+ const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
1519
+ const timer = controller ? setTimeout(() => controller.abort(), 15000) : null;
1394
1520
  try {
1395
- const res = await fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query));
1521
+ const res = await fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), {
1522
+ signal: controller ? controller.signal : undefined,
1523
+ });
1396
1524
  const data = await res.json();
1397
1525
  if (gen !== fetchGenRef.current) return;
1398
1526
  if (data && data.ok) {
@@ -1407,6 +1535,7 @@ window.__ModuleLoader__.load({
1407
1535
  console.error('[dsh-cron] fetch tasks error:', err);
1408
1536
  setFetchError(err.message || T_KEY(t, 'list.loadErrorPrefix'));
1409
1537
  } finally {
1538
+ if (timer) clearTimeout(timer);
1410
1539
  if (gen === fetchGenRef.current) {
1411
1540
  setLoading(false);
1412
1541
  }
@@ -1414,9 +1543,11 @@ window.__ModuleLoader__.load({
1414
1543
  };
1415
1544
 
1416
1545
  const loadModels = async (providerName) => {
1546
+ const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
1547
+ const timer = controller ? setTimeout(() => controller.abort(), 15000) : null;
1417
1548
  try {
1418
1549
  const url = providerName ? '/dsh-cron/models?provider=' + encodeURIComponent(providerName) : '/dsh-cron/models';
1419
- const res = await fetch(url);
1550
+ const res = await fetch(url, { signal: controller ? controller.signal : undefined });
1420
1551
  const data = await res.json();
1421
1552
  if (data && data.ok) {
1422
1553
  if (!providerName) {
@@ -1431,6 +1562,8 @@ window.__ModuleLoader__.load({
1431
1562
  }
1432
1563
  } catch (err) {
1433
1564
  console.error('[dsh-cron] load models error:', err);
1565
+ } finally {
1566
+ if (timer) clearTimeout(timer);
1434
1567
  }
1435
1568
  };
1436
1569
 
@@ -1455,6 +1588,7 @@ window.__ModuleLoader__.load({
1455
1588
  const fetchOpts = { headers };
1456
1589
  if (pollController) fetchOpts.signal = pollController.signal;
1457
1590
 
1591
+ const pollTimer = pollController ? setTimeout(() => { try { pollController.abort(); } catch (abortErr) { void abortErr; } }, 15000) : null;
1458
1592
  fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), fetchOpts)
1459
1593
  .then(res => {
1460
1594
  if (isDestroyed || pollGen !== fetchGenRef.current) return null;
@@ -1478,6 +1612,7 @@ window.__ModuleLoader__.load({
1478
1612
  if (err && err.name === 'AbortError') return;
1479
1613
  })
1480
1614
  .finally(() => {
1615
+ if (pollTimer) clearTimeout(pollTimer);
1481
1616
  if (isDestroyed) return;
1482
1617
  // Adaptive interval: 30s when tab is hidden, 8s when active (#134)
1483
1618
  const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
@@ -1515,10 +1650,17 @@ window.__ModuleLoader__.load({
1515
1650
  const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1516
1651
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1517
1652
  const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
1653
+ if (options && options.signal && controller) {
1654
+ if (options.signal.aborted) {
1655
+ controller.abort();
1656
+ } else {
1657
+ options.signal.addEventListener('abort', () => controller.abort(), { once: true });
1658
+ }
1659
+ }
1518
1660
  try {
1519
1661
  const res = await fetch(url, {
1520
1662
  ...options,
1521
- signal: controller ? controller.signal : options.signal,
1663
+ signal: controller ? controller.signal : (options ? options.signal : undefined),
1522
1664
  });
1523
1665
  let data = {};
1524
1666
  try {
@@ -1589,8 +1731,7 @@ window.__ModuleLoader__.load({
1589
1731
  /** #41: duplicate a task; the server copies config, resets run state (#41). */
1590
1732
  const handleDryRun = async (taskId) => {
1591
1733
  try {
1592
- const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/dry-run', { method: 'POST' });
1593
- const data = await res.json();
1734
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/dry-run', { method: 'POST' });
1594
1735
  setDryRunResult(data);
1595
1736
  } catch (err) {
1596
1737
  alert('Dry run error: ' + err.message);
@@ -1603,8 +1744,7 @@ window.__ModuleLoader__.load({
1603
1744
  setArchiveLoading(true);
1604
1745
  setArchiveModalOpen(true);
1605
1746
  try {
1606
- const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/archive?limit=15&offset=' + offset);
1607
- const data = await res.json();
1747
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/archive?limit=15&offset=' + offset);
1608
1748
  if (data.ok) {
1609
1749
  setArchiveRuns(prev => offset === 0 ? (data.runs || []) : [...prev, ...(data.runs || [])]);
1610
1750
  setArchiveTotal(data.total || 0);
@@ -1618,12 +1758,11 @@ window.__ModuleLoader__.load({
1618
1758
 
1619
1759
  const handlePreviewSchedule = async (scheduleExpr) => {
1620
1760
  try {
1621
- const res = await fetch('/dsh-cron/schedule/preview', {
1761
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/schedule/preview', {
1622
1762
  method: 'POST',
1623
1763
  headers: { 'Content-Type': 'application/json' },
1624
1764
  body: JSON.stringify({ schedule: scheduleExpr, timezone: formTimezone })
1625
1765
  });
1626
- const data = await res.json();
1627
1766
  if (data.ok && data.runs) {
1628
1767
  setSchedulePreviewRuns(data.runs);
1629
1768
  } else {
@@ -1636,8 +1775,7 @@ window.__ModuleLoader__.load({
1636
1775
 
1637
1776
  const handleDuplicate = async (task) => {
1638
1777
  try {
1639
- const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/duplicate', { method: 'POST' });
1640
- const data = await res.json();
1778
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/duplicate', { method: 'POST' });
1641
1779
  if (!data.ok) {
1642
1780
  alert(T_KEY(t, 'duplicate.failed', { error: (data && data.error) || '' }));
1643
1781
  return;
@@ -1678,8 +1816,7 @@ window.__ModuleLoader__.load({
1678
1816
  const promptMsg = T_KEY(t, 'transfer.exportPrompt') || 'Choose export mode:\nOK = Sanitized (removes custom env vars & headers)\nCancel = Full raw export';
1679
1817
  const wantSanitized = confirm(promptMsg);
1680
1818
  const exportUrl = '/dsh-cron/tasks/export' + (wantSanitized ? '?sanitize=true' : '');
1681
- const res = await fetch(exportUrl);
1682
- const data = await res.json();
1819
+ const { res, data } = await fetchJsonWithTimeout(exportUrl);
1683
1820
  if (!data.ok) {
1684
1821
  alert(T_KEY(t, 'transfer.exportFailed', { error: (data && data.error) || '' }));
1685
1822
  return;
@@ -1717,12 +1854,11 @@ window.__ModuleLoader__.load({
1717
1854
  alert(T_KEY(t, 'transfer.badFile', { error: parseErr.message }));
1718
1855
  return;
1719
1856
  }
1720
- const res = await fetch('/dsh-cron/tasks/import', {
1857
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/import', {
1721
1858
  method: 'POST',
1722
1859
  headers: importHeaders(document_),
1723
1860
  body: JSON.stringify({ document: document_, dryRun: true, strategy: importStrategy })
1724
1861
  });
1725
- const data = await res.json();
1726
1862
  if (!data.ok) {
1727
1863
  alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
1728
1864
  return;
@@ -1740,12 +1876,11 @@ window.__ModuleLoader__.load({
1740
1876
  if (!importDoc) return;
1741
1877
  setTransferBusy(true);
1742
1878
  try {
1743
- const res = await fetch('/dsh-cron/tasks/import', {
1879
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/import', {
1744
1880
  method: 'POST',
1745
1881
  headers: importHeaders(importDoc),
1746
1882
  body: JSON.stringify({ document: importDoc, strategy: importStrategy })
1747
1883
  });
1748
- const data = await res.json();
1749
1884
  if (!data.ok) {
1750
1885
  alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
1751
1886
  return;
@@ -1777,12 +1912,11 @@ window.__ModuleLoader__.load({
1777
1912
  'If the change makes the task execute code (script/node/python/ssh/docker), confirm it with me explicitly before applying it.',
1778
1913
  ].filter(Boolean);
1779
1914
  try {
1780
- const res = await fetch('/dsh-cron/chat/start', {
1915
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/chat/start', {
1781
1916
  method: 'POST',
1782
1917
  headers: { 'Content-Type': 'application/json' },
1783
1918
  body: JSON.stringify({ prompt: lines.join(String.fromCharCode(10)) })
1784
1919
  });
1785
- const data = await res.json();
1786
1920
  if (!data.ok || !data.sessionId) {
1787
1921
  alert(T_KEY(t, 'tune.startFailed', { error: (data && data.error) || '' }));
1788
1922
  return;
@@ -1798,7 +1932,7 @@ window.__ModuleLoader__.load({
1798
1932
  const handleDelete = async (id) => {
1799
1933
  if (!confirm(T_KEY(t, 'confirm.delete'))) return;
1800
1934
  try {
1801
- await fetch('/dsh-cron/tasks/' + encodeURIComponent(id), { method: 'DELETE' });
1935
+ await fetchWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id), { method: 'DELETE' });
1802
1936
  fetchTasks();
1803
1937
  } catch (err) {
1804
1938
  console.debug('[dsh-cron] delete failed', err);
@@ -1809,8 +1943,7 @@ window.__ModuleLoader__.load({
1809
1943
  setTestStatus(null);
1810
1944
  setSettingsModalOpen(true);
1811
1945
  try {
1812
- const res = await fetch('/dsh-cron/settings');
1813
- const data = await res.json();
1946
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/settings');
1814
1947
  if (data && data.ok && data.settings) {
1815
1948
  setSettingsBotToken(data.settings.botToken || '');
1816
1949
  setSettingsChatId(data.settings.chatId || '');
@@ -1829,7 +1962,7 @@ window.__ModuleLoader__.load({
1829
1962
  const handleSaveSettings = async (e) => {
1830
1963
  e.preventDefault();
1831
1964
  try {
1832
- const res = await fetch('/dsh-cron/settings', {
1965
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/settings', {
1833
1966
  method: 'POST',
1834
1967
  headers: { 'Content-Type': 'application/json' },
1835
1968
  body: JSON.stringify(Object.assign({
@@ -1840,7 +1973,6 @@ window.__ModuleLoader__.load({
1840
1973
  kanbanBaseUrl: settingsKanbanUrl,
1841
1974
  }, settingsDelivery))
1842
1975
  });
1843
- const data = await res.json();
1844
1976
  if (data && data.ok) {
1845
1977
  setSettingsModalOpen(false);
1846
1978
  } else {
@@ -1881,7 +2013,7 @@ window.__ModuleLoader__.load({
1881
2013
 
1882
2014
  const handleTestTelegram = async () => { setTestStatus({ loading: true, message: T_KEY(t, 'settings.sending') });
1883
2015
  try {
1884
- const res = await fetch('/dsh-cron/telegram/test', {
2016
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/telegram/test', {
1885
2017
  method: 'POST',
1886
2018
  headers: { 'Content-Type': 'application/json' },
1887
2019
  body: JSON.stringify({
@@ -1889,7 +2021,6 @@ window.__ModuleLoader__.load({
1889
2021
  chatId: settingsChatId
1890
2022
  })
1891
2023
  });
1892
- const data = await res.json();
1893
2024
  if (data && data.ok) {
1894
2025
  setTestStatus({ loading: false, success: true, message: T_KEY(t, 'settings.testOk') });
1895
2026
  } else {
@@ -1963,14 +2094,66 @@ window.__ModuleLoader__.load({
1963
2094
  setFormModel(defaultModelInfo?.model || '');
1964
2095
  if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
1965
2096
  }
2097
+ initialFormSnapshotRef.current = JSON.stringify({
2098
+ title: String((preset && preset.title) || '').trim(),
2099
+ schedule: String((preset && preset.schedule) || (preset ? '' : '0 9 * * 1-5')).trim(),
2100
+ type: String((preset && preset.type) || 'llm'),
2101
+ prompt: String((preset && preset.prompt) || '').trim(),
2102
+ provider: String(defaultModelInfo?.provider || ''),
2103
+ model: String(defaultModelInfo?.model || ''),
2104
+ fallbackModel: String((preset && preset.fallbackModel) || '').trim(),
2105
+ silentRule: String((preset && preset.silentRule) || '').trim(),
2106
+ inspectOnFailure: Boolean(preset && preset.inspectOnFailure),
2107
+ delivery: 'isolated',
2108
+ notifyTelegram: false,
2109
+ channels: Array.isArray(preset && preset.channels) ? [...preset.channels].filter((c) => DELIVERY_CHANNELS.includes(c)).sort() : [],
2110
+ template: '',
2111
+ onlyOnFailure: Boolean(preset && preset.onlyOnFailure),
2112
+ timeoutSeconds: 1800,
2113
+ overlapPolicy: 'skip',
2114
+ kanbanMode: 'none',
2115
+ onSuccess: '',
2116
+ onFailure: '',
2117
+ heartbeatIntervalSeconds: 0,
2118
+ gracePeriodSeconds: 300,
2119
+ preflightType: 'none',
2120
+ preflightTarget: '',
2121
+ priority: 5,
2122
+ concurrencyGroup: 'default',
2123
+ selfHealingCommand: '',
2124
+ autoDiagnose: false,
2125
+ timezone: '',
2126
+ misfirePolicy: 'skip',
2127
+ maxRetries: 0,
2128
+ permissionPreset: 'default',
2129
+ agentPreset: '',
2130
+ targetSessionId: '',
2131
+ targetSessionReset: 'never',
2132
+ env: '',
2133
+ cwd: '',
2134
+ workspaceId: '',
2135
+ worktree: false,
2136
+ keepWorktree: false,
2137
+ httpMethod: 'GET',
2138
+ httpUrl: '',
2139
+ httpHttpHeaders: '',
2140
+ httpBody: '',
2141
+ sshProfileId: '',
2142
+ sshTarget: '',
2143
+ sshKeyPath: '',
2144
+ dockerImage: '',
2145
+ nodePath: '',
2146
+ pythonPath: '',
2147
+ skillName: '',
2148
+ workflowName: '',
2149
+ });
1966
2150
  setManualModalOpen(true);
1967
2151
  };
1968
2152
 
1969
2153
  const loadTaskHistory = async (taskId) => {
1970
2154
  setHistoryLoading(true);
1971
2155
  try {
1972
- const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/history');
1973
- const data = await res.json();
2156
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/history');
1974
2157
  if (data.ok) {
1975
2158
  setTaskHistory(data.history || []);
1976
2159
  }
@@ -2077,7 +2260,7 @@ window.__ModuleLoader__.load({
2077
2260
  timezone: formTimezone.trim(),
2078
2261
  misfirePolicy: formMisfirePolicy,
2079
2262
  maxRetries: Number(formMaxRetries) || 0,
2080
- permissionPreset: formPermissionPreset,
2263
+ permissionPreset: isAgentType ? formPermissionPreset : 'default',
2081
2264
  agentPreset: formAgentPreset.trim() || undefined,
2082
2265
  targetSessionId: formTargetSessionId.trim() || undefined,
2083
2266
  targetSessionReset: formTargetSessionReset || 'never',
@@ -2119,13 +2302,13 @@ window.__ModuleLoader__.load({
2119
2302
  Boolean(payload.preflightCommand && payload.preflightCommand.trim()) ||
2120
2303
  Boolean(payload.selfHealingCommand && payload.selfHealingCommand.trim());
2121
2304
  if (hasCodeExec) headers[SCRIPT_CONFIRM_HEADER] = 'script';
2122
- const res = await fetch('/dsh-cron/tasks', {
2305
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks', {
2123
2306
  method: 'POST',
2124
2307
  headers,
2125
2308
  body: JSON.stringify(payload)
2126
2309
  });
2127
- const data = await res.json();
2128
2310
  if (!data.ok) { alert(data.error || T_KEY(t, 'settings.saveError', { error: '' })); return; }
2311
+ initialFormSnapshotRef.current = null;
2129
2312
  setManualModalOpen(false);
2130
2313
  fetchTasks();
2131
2314
  } catch (err) { alert(err.message); }
@@ -2143,12 +2326,11 @@ window.__ModuleLoader__.load({
2143
2326
 
2144
2327
  setDshLoading(true);
2145
2328
  try {
2146
- const res = await fetch('/dsh-cron/chat/start', {
2329
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/chat/start', {
2147
2330
  method: 'POST',
2148
2331
  headers: { 'Content-Type': 'application/json' },
2149
2332
  body: JSON.stringify({ prompt: text })
2150
2333
  });
2151
- const data = await res.json();
2152
2334
  if (!data.ok || !data.sessionId) {
2153
2335
  alert(T_KEY(t, 'dsh.startFailed', { error: data.error || '' }));
2154
2336
  setDshLoading(false);
@@ -2511,7 +2693,12 @@ window.__ModuleLoader__.load({
2511
2693
  ),
2512
2694
 
2513
2695
  // 1. Manual creation / Edit modal
2514
- manualModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setManualModalOpen(false) },
2696
+ manualModalOpen && React.createElement('div', {
2697
+ className: 'dsh-cron-modal-overlay',
2698
+ onClick: (e) => {
2699
+ if (e.target === e.currentTarget) closeManualModalWithConfirm();
2700
+ }
2701
+ },
2515
2702
  React.createElement('div', {
2516
2703
  className: 'dsh-cron-modal dsh-cron-modal-scroll',
2517
2704
  style: { maxWidth: '640px' },
@@ -2614,7 +2801,7 @@ window.__ModuleLoader__.load({
2614
2801
  React.createElement('button', {
2615
2802
  type: 'button',
2616
2803
  className: 'dsh-cron-btn-primary',
2617
- onClick: () => setManualModalOpen(false)
2804
+ onClick: closeManualModalWithConfirm
2618
2805
  }, T_KEY(t, 'modal.close'))
2619
2806
  )
2620
2807
  ) :
@@ -2728,7 +2915,32 @@ window.__ModuleLoader__.load({
2728
2915
  )
2729
2916
  )
2730
2917
  ),
2731
- React.createElement('div', { className: 'dsh-cron-form-row' },
2918
+ AGENT_FORM_TYPES.includes(formType) ? (
2919
+ React.createElement('div', { className: 'dsh-cron-form-row' },
2920
+ React.createElement('div', { className: 'dsh-cron-form-group' },
2921
+ React.createElement('label', null, T_KEY(t, 'form.retriesLabel')),
2922
+ React.createElement('input', {
2923
+ type: 'number',
2924
+ min: 0,
2925
+ value: formMaxRetries,
2926
+ placeholder: '0',
2927
+ onChange: (e) => setFormMaxRetries(e.target.value)
2928
+ })
2929
+ ),
2930
+ React.createElement('div', { className: 'dsh-cron-form-group' },
2931
+ React.createElement('label', null, T_KEY(t, 'form.permissionLabel')),
2932
+ React.createElement('select', {
2933
+ value: formPermissionPreset,
2934
+ onChange: (e) => setFormPermissionPreset(e.target.value)
2935
+ },
2936
+ React.createElement('option', { value: 'default' }, T_KEY(t, 'form.permissionDefault')),
2937
+ React.createElement('option', { value: 'read-only' }, T_KEY(t, 'form.permissionReadonly')),
2938
+ React.createElement('option', { value: 'workspace-write' }, T_KEY(t, 'form.permissionWrite')),
2939
+ React.createElement('option', { value: 'full' }, T_KEY(t, 'form.permissionFull'))
2940
+ )
2941
+ )
2942
+ )
2943
+ ) : (
2732
2944
  React.createElement('div', { className: 'dsh-cron-form-group' },
2733
2945
  React.createElement('label', null, T_KEY(t, 'form.retriesLabel')),
2734
2946
  React.createElement('input', {
@@ -2738,18 +2950,6 @@ window.__ModuleLoader__.load({
2738
2950
  placeholder: '0',
2739
2951
  onChange: (e) => setFormMaxRetries(e.target.value)
2740
2952
  })
2741
- ),
2742
- React.createElement('div', { className: 'dsh-cron-form-group' },
2743
- React.createElement('label', null, T_KEY(t, 'form.permissionLabel')),
2744
- React.createElement('select', {
2745
- value: formPermissionPreset,
2746
- onChange: (e) => setFormPermissionPreset(e.target.value)
2747
- },
2748
- React.createElement('option', { value: 'default' }, T_KEY(t, 'form.permissionDefault')),
2749
- React.createElement('option', { value: 'read-only' }, T_KEY(t, 'form.permissionReadonly')),
2750
- React.createElement('option', { value: 'workspace-write' }, T_KEY(t, 'form.permissionWrite')),
2751
- React.createElement('option', { value: 'full' }, T_KEY(t, 'form.permissionFull'))
2752
- )
2753
2953
  )
2754
2954
  ),
2755
2955
  React.createElement('div', { className: 'dsh-cron-form-group' },
@@ -3197,7 +3397,7 @@ window.__ModuleLoader__.load({
3197
3397
  React.createElement('button', {
3198
3398
  type: 'button',
3199
3399
  className: 'dsh-cron-btn-secondary',
3200
- onClick: () => setManualModalOpen(false)
3400
+ onClick: closeManualModalWithConfirm
3201
3401
  }, T_KEY(t, 'modal.cancel')),
3202
3402
  React.createElement('button', {
3203
3403
  type: 'submit',
@@ -3532,8 +3732,7 @@ window.__ModuleLoader__.load({
3532
3732
  const checkUpdates = async () => {
3533
3733
  setUpdateState(prev => ({ ...prev, checking: true, error: null, notice: null }));
3534
3734
  try {
3535
- const res = await fetch('/api/dsh-cron/update');
3536
- const data = await res.json();
3735
+ const { res, data } = await fetchJsonWithTimeout('/api/dsh-cron/update');
3537
3736
  if (data && data.currentVersion) {
3538
3737
  setUpdateState(prev => ({
3539
3738
  ...prev,
@@ -3554,11 +3753,10 @@ window.__ModuleLoader__.load({
3554
3753
  const runUpdate = async () => {
3555
3754
  setUpdateState(prev => ({ ...prev, updating: true, error: null, notice: null }));
3556
3755
  try {
3557
- const res = await fetch('/api/dsh-cron/update', {
3756
+ const { res, data } = await fetchJsonWithTimeout('/api/dsh-cron/update', {
3558
3757
  method: 'POST',
3559
3758
  headers: { 'Content-Type': 'application/json', 'x-dsh-plugin-update': '1' }
3560
3759
  });
3561
- const data = await res.json();
3562
3760
  if (data && data.updated) {
3563
3761
  setUpdateState(prev => ({
3564
3762
  ...prev,
@@ -3587,9 +3785,8 @@ window.__ModuleLoader__.load({
3587
3785
  React.useEffect(() => {
3588
3786
  let active = true;
3589
3787
  setLoadState('loading');
3590
- fetch('/dsh-cron/settings')
3591
- .then(r => r.json())
3592
- .then(data => {
3788
+ fetchJsonWithTimeout('/dsh-cron/settings')
3789
+ .then(({ data }) => {
3593
3790
  if (!active) return;
3594
3791
  if (data && data.ok && data.settings) {
3595
3792
  setToken(data.settings.botToken || '');
@@ -3615,7 +3812,7 @@ window.__ModuleLoader__.load({
3615
3812
  const saveSettings = async (e) => {
3616
3813
  if (e) e.preventDefault();
3617
3814
  try {
3618
- const res = await fetch('/dsh-cron/settings', {
3815
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/settings', {
3619
3816
  method: 'POST',
3620
3817
  headers: { 'Content-Type': 'application/json' },
3621
3818
  body: JSON.stringify(Object.assign({
@@ -3627,7 +3824,6 @@ window.__ModuleLoader__.load({
3627
3824
  kanbanBaseUrl: kanbanUrl
3628
3825
  }, delivery))
3629
3826
  });
3630
- const data = await res.json();
3631
3827
  if (data && data.ok) {
3632
3828
  setSaved(true);
3633
3829
  setTimeout(() => setSaved(false), 2500);
@@ -3640,12 +3836,11 @@ window.__ModuleLoader__.load({
3640
3836
  const testTelegram = async () => {
3641
3837
  setTestInfo({ loading: true, message: T_KEY(t, 'settings.sending') });
3642
3838
  try {
3643
- const res = await fetch('/dsh-cron/telegram/test', {
3839
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/telegram/test', {
3644
3840
  method: 'POST',
3645
3841
  headers: { 'Content-Type': 'application/json' },
3646
3842
  body: JSON.stringify({ botToken: token, chatId })
3647
3843
  });
3648
- const data = await res.json();
3649
3844
  if (data && data.ok) {
3650
3845
  setTestInfo({ success: true, message: T_KEY(t, 'settings.testOk') });
3651
3846
  } else {
@@ -3659,12 +3854,11 @@ window.__ModuleLoader__.load({
3659
3854
  const testKanban = async () => {
3660
3855
  setTestInfo({ loading: true, message: T_KEY(t, 'settings.checkingKanban') });
3661
3856
  try {
3662
- const res = await fetch('/dsh-cron/kanban/test', {
3857
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/kanban/test', {
3663
3858
  method: 'POST',
3664
3859
  headers: { 'Content-Type': 'application/json' },
3665
3860
  body: JSON.stringify({ kanbanBaseUrl: kanbanUrl })
3666
3861
  });
3667
- const data = await res.json();
3668
3862
  if (data && data.ok) {
3669
3863
  setTestInfo({ success: true, message: T_KEY(t, 'settings.kanbanOk') });
3670
3864
  } else {
@@ -3971,8 +4165,7 @@ window.__ModuleLoader__.load({
3971
4165
 
3972
4166
  async function loadSidebarJobs(state) {
3973
4167
  try {
3974
- const res = await fetch('/dsh-cron/tasks?status=active');
3975
- const data = await res.json();
4168
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks?status=active');
3976
4169
  if (data && data.ok) renderSidebarJobs(state, data.tasks || []);
3977
4170
  } catch (err) {
3978
4171
  console.debug('[dsh-cron] load sidebar jobs failed', err);
package/lib/cron-tool.js CHANGED
@@ -150,7 +150,7 @@ export const cronToolParameters = {
150
150
  permissionPreset: {
151
151
  type: 'string',
152
152
  enum: ['default', 'read-only', 'workspace-write', 'full'],
153
- description: 'Permission preset applied to the task session',
153
+ description: 'Permission preset applied to the task session (only used for agent session tasks: llm, skill, workflow)',
154
154
  },
155
155
  kanbanMode: {
156
156
  type: 'string',
package/lib/http-utils.js CHANGED
@@ -180,7 +180,13 @@ export async function readBody(req, res) {
180
180
  */
181
181
  export function redactTaskSecrets(task) {
182
182
  if (!task || typeof task !== 'object') return task;
183
- const clone = { ...task };
183
+ // Issue #310 / GH #5: sanitize to lossless JSON by omitting any undefined properties
184
+ const clone = {};
185
+ for (const [k, v] of Object.entries(task)) {
186
+ if (v !== undefined) {
187
+ clone[k] = v;
188
+ }
189
+ }
184
190
 
185
191
  if (clone.env && typeof clone.env === 'object') {
186
192
  const redactedEnv = {};
package/lib/runtimes.js CHANGED
@@ -124,8 +124,6 @@ export function buildSshInvocation(task) {
124
124
  return { file: 'ssh', args };
125
125
  }
126
126
 
127
- export const DOCKER_HOST_ENV_ALLOWLIST = ['TZ', 'LANG', 'LC_ALL'];
128
-
129
127
  /** #8/#226: build Docker run invocation without leaking host process secrets into container. */
130
128
  export function buildDockerInvocation(task, explicitEnv) {
131
129
  const image = String(task.dockerImage || '').trim();
package/lib/store.js CHANGED
@@ -555,8 +555,8 @@ export class TaskStore {
555
555
  type: task.type !== undefined ? task.type : (prev.type || 'llm'),
556
556
  status: task.status !== undefined ? task.status : (prev.status || 'active'),
557
557
  delivery: task.delivery !== undefined ? task.delivery : (prev.delivery || 'isolated'),
558
- provider: task.provider !== undefined ? task.provider : prev.provider,
559
- model: task.model !== undefined ? task.model : prev.model,
558
+ provider: task.provider !== undefined ? task.provider : (prev.provider || null),
559
+ model: task.model !== undefined ? task.model : (prev.model || null),
560
560
  notifyTelegram: task.notifyTelegram !== undefined
561
561
  ? (task.notifyTelegram === null || task.notifyTelegram === 'inherit' ? undefined : Boolean(task.notifyTelegram))
562
562
  : prev.notifyTelegram,
@@ -591,6 +591,12 @@ export class TaskStore {
591
591
  lastDurationMs: task.lastDurationMs !== undefined ? task.lastDurationMs : (prev.lastDurationMs || 0),
592
592
  nextRunAt: task.nextRunAt !== undefined ? task.nextRunAt : (prev.nextRunAt || null),
593
593
  };
594
+ // Issue #310 / GH #5: strip any undefined properties to guarantee lossless JSON
595
+ for (const key of Object.keys(record)) {
596
+ if (record[key] === undefined) {
597
+ delete record[key];
598
+ }
599
+ }
594
600
  this.tasks.set(id, record);
595
601
  try {
596
602
  this.save();
@@ -1,4 +1,4 @@
1
- import { normalizeTaskType } from './runtimes.js';
1
+ import { normalizeTaskType, EXTERNAL_RUNTIMES } from './runtimes.js';
2
2
  import { validateTaskType } from './task-transfer.js';
3
3
  import { parseScheduleExpression } from './scheduler.js';
4
4
  import { CHANNEL_IDS } from './channels.js';
@@ -9,7 +9,7 @@ export function executeCreateTask(store, scheduler, args) {
9
9
  const typeError = validateTaskType(taskType, args);
10
10
  if (typeError) throw new Error(typeError);
11
11
  const parsed = parseScheduleExpression(args.schedule);
12
- const task = store.set({
12
+ const taskData = {
13
13
  title: args.title,
14
14
  schedule: parsed.cronPattern || args.schedule,
15
15
  scheduleText: parsed.humanText,
@@ -44,7 +44,7 @@ export function executeCreateTask(store, scheduler, args) {
44
44
  misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
45
45
  maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
46
46
  retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
47
- permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
47
+ permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
48
48
  kanbanMode: args.kanbanMode || 'none',
49
49
  channels: Array.isArray(args.channels) ? args.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined,
50
50
  template: args.template ? String(args.template) : '',
@@ -72,7 +72,14 @@ export function executeCreateTask(store, scheduler, args) {
72
72
  costLimitUsd: Number(args.costLimitUsd) > 0 ? Number(args.costLimitUsd) : undefined,
73
73
  dailyCostLimitUsd: Number(args.dailyCostLimitUsd) > 0 ? Number(args.dailyCostLimitUsd) : undefined,
74
74
  tokenLimit: Number(args.tokenLimit) > 0 ? Number(args.tokenLimit) : undefined,
75
- });
75
+ };
76
+ // Issue #310 / GH #5: strip any undefined properties before store.set()
77
+ for (const k of Object.keys(taskData)) {
78
+ if (taskData[k] === undefined) {
79
+ delete taskData[k];
80
+ }
81
+ }
82
+ const task = store.set(taskData);
76
83
  scheduler.scheduleTask(task);
77
84
  return {
78
85
  success: true,
package/lib/task-patch.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { parseScheduleExpression } from './scheduler.js';
12
- import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
12
+ import { normalizeTaskType, CODE_EXECUTING_TYPES, EXTERNAL_RUNTIMES } from './runtimes.js';
13
13
  import { PATCHABLE_TASK_FIELDS, validateTaskType } from './task-transfer.js';
14
14
  import { pickPatchableFields, restoreTaskSecrets } from './http-utils.js';
15
15
  import { unknownChannelIds } from './channels.js';
@@ -80,7 +80,7 @@ export function buildTaskPatch(current, body) {
80
80
  patch.oneShot = Boolean(parsed.isOneShot || (body && body.oneShot));
81
81
  if (scheduleChanged) {
82
82
  patch.nextRunAt = parsed.isOneShot ? parsed.targetTimestamp : (parsed.nextRun || null);
83
- patch.targetTimestamp = parsed.isOneShot ? parsed.targetTimestamp : undefined;
83
+ patch.targetTimestamp = parsed.isOneShot ? parsed.targetTimestamp : null;
84
84
  }
85
85
  } catch (err) {
86
86
  return { ok: false, error: err.message };
@@ -95,6 +95,16 @@ export function buildTaskPatch(current, body) {
95
95
  if (patch.tokenLimit !== undefined) {
96
96
  patch.tokenLimit = patch.tokenLimit === null ? null : (Number(patch.tokenLimit) > 0 ? Number(patch.tokenLimit) : null);
97
97
  }
98
+ const effectiveType = patch.type !== undefined ? normalizeTaskType(patch.type) : current.type;
99
+ if (EXTERNAL_RUNTIMES.includes(effectiveType) && patch.permissionPreset !== undefined) {
100
+ patch.permissionPreset = 'default';
101
+ }
102
+ // Issue #310 / GH #5: strip undefined properties from patch
103
+ for (const k of Object.keys(patch)) {
104
+ if (patch[k] === undefined) {
105
+ delete patch[k];
106
+ }
107
+ }
98
108
  return { ok: true, patch };
99
109
  }
100
110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.48",
3
+ "version": "0.2.50",
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",