@goodandready/dsh-cron 0.2.39 → 0.2.41
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-import-export.js +12 -3
- package/lib/api.js +3 -3
- package/lib/channels.js +58 -18
- package/lib/client.js +57 -17
- package/lib/index.js +35 -9
- package/lib/runtimes.js +3 -2
- package/lib/scheduler-execution.js +55 -18
- package/lib/settings.js +2 -0
- package/lib/store.js +98 -14
- package/lib/task-create.js +2 -2
- package/lib/task-transfer.js +23 -1
- package/package.json +1 -1
package/lib/api-import-export.js
CHANGED
|
@@ -15,9 +15,18 @@ import {
|
|
|
15
15
|
} from './http-utils.js';
|
|
16
16
|
import { MANAGED_BY_CONFIG } from './config-jobs.js';
|
|
17
17
|
|
|
18
|
-
export function handleTaskExport({ store, res }) {
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
export function handleTaskExport({ store, req, res }) {
|
|
19
|
+
let sanitize = false;
|
|
20
|
+
if (req && req.url) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = new URL(req.url, 'http://127.0.0.1');
|
|
23
|
+
sanitize = parsed.searchParams.get('sanitize') === 'true' || parsed.searchParams.get('redact') === 'true';
|
|
24
|
+
} catch (urlErr) {
|
|
25
|
+
logger.debug('[dsh-cron] export url parse fallback:', urlErr.message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const doc = buildTaskExport(store.list({ status: 'all' }), { sanitize });
|
|
29
|
+
sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length, sanitized: sanitize });
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
export async function handleTaskImport({ store, scheduler, req, res }) {
|
package/lib/api.js
CHANGED
|
@@ -101,7 +101,7 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
101
101
|
sendJson(res, 405, NOT_ALLOWED);
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
104
|
-
handleTaskExport({ store, res });
|
|
104
|
+
handleTaskExport({ store, req, res });
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
107
|
if (id === 'import') {
|
|
@@ -256,8 +256,8 @@ function buildTaskRecord(store, body, taskType, parsed) {
|
|
|
256
256
|
model: body.model || undefined,
|
|
257
257
|
fallbackProvider: body.fallbackProvider || undefined,
|
|
258
258
|
fallbackModel: body.fallbackModel || undefined,
|
|
259
|
-
notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram :
|
|
260
|
-
onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure :
|
|
259
|
+
notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : undefined),
|
|
260
|
+
onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : undefined),
|
|
261
261
|
timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
|
|
262
262
|
overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
|
|
263
263
|
kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
|
package/lib/channels.js
CHANGED
|
@@ -18,11 +18,17 @@ export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'b
|
|
|
18
18
|
export const isFailureStatus = (status) => status === 'error' || status === 'timeout' || status === 'missed';
|
|
19
19
|
export const isFailed = (status) => isFailureStatus(status);
|
|
20
20
|
|
|
21
|
-
/** The message text for a channel:
|
|
22
|
-
function messageTextFor(channelId, task, runInfo, settings = {}) {
|
|
21
|
+
/** The message text for a channel: task template -> channel template -> global template -> built-in defaults (#233). */
|
|
22
|
+
export function messageTextFor(channelId, task, runInfo, settings = {}) {
|
|
23
23
|
const templates = (settings && settings.channelTemplates) || {};
|
|
24
|
-
const
|
|
25
|
-
|
|
24
|
+
const custom = (task && task.template) || templates[channelId] || (settings && settings.template) || '';
|
|
25
|
+
if (custom) {
|
|
26
|
+
return resolveTemplateText({ template: custom, task, runInfo });
|
|
27
|
+
}
|
|
28
|
+
if (channelId === 'telegram') {
|
|
29
|
+
return formatTaskTelegramMessage(task, runInfo);
|
|
30
|
+
}
|
|
31
|
+
return resolveTemplateText({ template: '', task, runInfo });
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
/**
|
|
@@ -56,17 +62,27 @@ export function unknownChannels(task) {
|
|
|
56
62
|
return unknownChannelIds(task && task.channels);
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
/** Failure/only-on-failure filtering, per channel. */
|
|
65
|
+
/** Failure/only-on-failure filtering, per channel (#219). Explicit task channels win over legacy flags. */
|
|
60
66
|
export function shouldSendToChannel(channelId, task, runInfo, settings = {}) {
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
const isExplicit = Array.isArray(task && task.channels) && task.channels.includes(channelId);
|
|
68
|
+
if (!isExplicit) {
|
|
69
|
+
if (channelId === 'kanban') return shouldCreateKanbanCard(task, runInfo);
|
|
70
|
+
if (channelId === 'telegram') {
|
|
71
|
+
if (task && task.notifyTelegram === false) return false;
|
|
72
|
+
const taskForTelegram = Object.assign({}, task, { notifyTelegram: true });
|
|
73
|
+
return shouldNotifyTask(taskForTelegram, runInfo, settings);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Explicit selection (or default event filtering for all channels):
|
|
78
|
+
if (channelId === 'kanban') {
|
|
79
|
+
if (task && task.kanbanMode === 'on_failure') return isFailed(runInfo.status);
|
|
80
|
+
if (task && task.kanbanMode === 'always') return true;
|
|
66
81
|
}
|
|
67
|
-
|
|
82
|
+
|
|
83
|
+
const onlyOnFail = (task && task.onlyOnFailure !== undefined)
|
|
68
84
|
? Boolean(task.onlyOnFailure)
|
|
69
|
-
: Boolean(settings.onlyOnFailure);
|
|
85
|
+
: Boolean(settings && settings.onlyOnFailure);
|
|
70
86
|
if (onlyOnFail && !isFailed(runInfo.status)) return false;
|
|
71
87
|
return true;
|
|
72
88
|
}
|
|
@@ -133,8 +149,34 @@ export function buildPushplusRequest({ settings = {}, text, task, token }) {
|
|
|
133
149
|
};
|
|
134
150
|
}
|
|
135
151
|
|
|
136
|
-
export function buildGiteaIssuePayload({ task, runInfo }) {
|
|
152
|
+
export function buildGiteaIssuePayload({ task, runInfo, settings = {} }) {
|
|
137
153
|
const failed = isFailed(runInfo.status);
|
|
154
|
+
const defaultNames = failed ? ['cron', 'bug', 'alert'] : ['cron', 'auto'];
|
|
155
|
+
|
|
156
|
+
// Resolve labels to integer IDs per Gitea Swagger (array<int64>) (#224)
|
|
157
|
+
const mapping = settings.giteaLabelIds || {};
|
|
158
|
+
let candidateIds = [];
|
|
159
|
+
|
|
160
|
+
if (Array.isArray(task && task.giteaLabelIds)) {
|
|
161
|
+
candidateIds = task.giteaLabelIds;
|
|
162
|
+
} else if (Array.isArray(settings.giteaLabels)) {
|
|
163
|
+
candidateIds = settings.giteaLabels;
|
|
164
|
+
} else if (Array.isArray(settings.giteaLabelIds)) {
|
|
165
|
+
candidateIds = settings.giteaLabelIds;
|
|
166
|
+
} else if (Array.isArray(task && task.labels)) {
|
|
167
|
+
candidateIds = task.labels.map((item) => {
|
|
168
|
+
const num = Number(item);
|
|
169
|
+
if (Number.isInteger(num) && num > 0) return num;
|
|
170
|
+
return typeof mapping === 'object' && mapping !== null ? mapping[item] : undefined;
|
|
171
|
+
});
|
|
172
|
+
} else if (typeof mapping === 'object' && mapping !== null && Object.keys(mapping).length > 0) {
|
|
173
|
+
candidateIds = defaultNames.map((name) => mapping[name]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const labelIds = candidateIds
|
|
177
|
+
.map((id) => Number(id))
|
|
178
|
+
.filter((id) => Number.isInteger(id) && id > 0);
|
|
179
|
+
|
|
138
180
|
return {
|
|
139
181
|
title: `${failed ? '[Cron failure]' : '[Cron run]'} ${truncateText(task.title || 'Task', 200)}`,
|
|
140
182
|
body: [
|
|
@@ -145,7 +187,7 @@ export function buildGiteaIssuePayload({ task, runInfo }) {
|
|
|
145
187
|
runInfo.error ? `\n**Error:**\n\`\`\`\n${truncateText(runInfo.error, 1000)}\n\`\`\`` : '',
|
|
146
188
|
runInfo.output ? `\n**Output:**\n\`\`\`\n${truncateText(runInfo.output, 1000)}\n\`\`\`` : '',
|
|
147
189
|
].filter(Boolean).join('\n\n'),
|
|
148
|
-
labels:
|
|
190
|
+
labels: labelIds,
|
|
149
191
|
};
|
|
150
192
|
}
|
|
151
193
|
|
|
@@ -248,9 +290,7 @@ const CHANNEL_HANDLERS = {
|
|
|
248
290
|
const botToken = secrets.botToken || '';
|
|
249
291
|
const chatId = String(settings.chatId || '').trim();
|
|
250
292
|
if (!botToken || !chatId) throw new Error('telegram: botToken or chatId is not configured');
|
|
251
|
-
const text = settings
|
|
252
|
-
? messageTextFor('telegram', task, runInfo, settings)
|
|
253
|
-
: formatTaskTelegramMessage(task, runInfo);
|
|
293
|
+
const text = messageTextFor('telegram', task, runInfo, settings);
|
|
254
294
|
try {
|
|
255
295
|
const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
|
|
256
296
|
await sendTelegramMessage({ botToken, chatId, text, replyMarkup, fetchFn: http, signal });
|
|
@@ -321,7 +361,7 @@ const CHANNEL_HANDLERS = {
|
|
|
321
361
|
if (!base || !repo) throw new Error('gitea: giteaBaseUrl and giteaRepo are required');
|
|
322
362
|
const token = settings.giteaTokenRef ? await resolveSecret(settings.giteaTokenRef) : '';
|
|
323
363
|
if (!token) throw new Error(`gitea: credential "${settings.giteaTokenRef || ''}" did not resolve`);
|
|
324
|
-
const payload = buildGiteaIssuePayload({ task, runInfo });
|
|
364
|
+
const payload = buildGiteaIssuePayload({ task, runInfo, settings });
|
|
325
365
|
const issue = await postJson(
|
|
326
366
|
http,
|
|
327
367
|
`${base}/api/v1/repos/${repo}/issues`,
|
package/lib/client.js
CHANGED
|
@@ -88,7 +88,8 @@ window.__ModuleLoader__.load({
|
|
|
88
88
|
'transfer.exported': 'Exported {count} task(s).',
|
|
89
89
|
'transfer.exportFailed': 'Export failed: {error}',
|
|
90
90
|
'transfer.importTitleModal': 'Import tasks',
|
|
91
|
-
'transfer.importHint': 'Exported task configuration only: no run history, no counters.
|
|
91
|
+
'transfer.importHint': 'Exported task configuration only: no run history, no counters. Channel configurations reference credentials by name; note that raw exports retain custom env variables and HTTP request headers.',
|
|
92
|
+
'transfer.exportPrompt': 'Choose export mode:\nOK = Sanitized (removes custom env vars & headers)\nCancel = Full raw export',
|
|
92
93
|
'transfer.strategyAdd': 'Add as new tasks (new ids, existing ones stay untouched)',
|
|
93
94
|
'transfer.strategyReplace': 'Replace tasks with the same id',
|
|
94
95
|
'transfer.strategySkip': 'Skip tasks whose id already exists',
|
|
@@ -390,7 +391,8 @@ window.__ModuleLoader__.load({
|
|
|
390
391
|
'transfer.exported': '已成功导出 {count} 个任务。',
|
|
391
392
|
'transfer.exportFailed': '导出失败: {error}',
|
|
392
393
|
'transfer.importTitleModal': '导入任务',
|
|
393
|
-
'transfer.importHint': '
|
|
394
|
+
'transfer.importHint': '仅导出任务静态配置:不包含历史记录与计数器。渠道通过凭据名称引用;注意完整导出保留自定义环境变量和 HTTP 请求头。',
|
|
395
|
+
'transfer.exportPrompt': '请选择导出模式:\n确定 = 净化导出(移除自定义环境变量与请求头)\n取消 = 完整原始导出',
|
|
394
396
|
'transfer.strategyAdd': '作为新任务导入(分配新 ID)',
|
|
395
397
|
'transfer.strategyReplace': '覆盖同 ID 任务',
|
|
396
398
|
'transfer.strategySkip': '跳过已存在的同 ID 任务',
|
|
@@ -1383,12 +1385,16 @@ window.__ModuleLoader__.load({
|
|
|
1383
1385
|
setTimeout(() => { try { node.classList.remove('dsh-cron-task-highlight'); } catch (err) { /* node unmounted */ } }, 2500);
|
|
1384
1386
|
}, [tasks]);
|
|
1385
1387
|
|
|
1388
|
+
const fetchGenRef = React.useRef(0);
|
|
1389
|
+
|
|
1386
1390
|
const fetchTasks = async () => {
|
|
1391
|
+
const gen = ++fetchGenRef.current;
|
|
1387
1392
|
setLoading(true);
|
|
1388
1393
|
setFetchError(null);
|
|
1389
1394
|
try {
|
|
1390
1395
|
const res = await fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query));
|
|
1391
1396
|
const data = await res.json();
|
|
1397
|
+
if (gen !== fetchGenRef.current) return;
|
|
1392
1398
|
if (data && data.ok) {
|
|
1393
1399
|
setTasks(data.tasks || []);
|
|
1394
1400
|
setRecs(data.recommendations || []);
|
|
@@ -1397,10 +1403,13 @@ window.__ModuleLoader__.load({
|
|
|
1397
1403
|
setFetchError(data && data.error ? data.error : T_KEY(t, 'list.loadErrorPrefix'));
|
|
1398
1404
|
}
|
|
1399
1405
|
} catch (err) {
|
|
1406
|
+
if (gen !== fetchGenRef.current) return;
|
|
1400
1407
|
console.error('[dsh-cron] fetch tasks error:', err);
|
|
1401
1408
|
setFetchError(err.message || T_KEY(t, 'list.loadErrorPrefix'));
|
|
1402
1409
|
} finally {
|
|
1403
|
-
|
|
1410
|
+
if (gen === fetchGenRef.current) {
|
|
1411
|
+
setLoading(false);
|
|
1412
|
+
}
|
|
1404
1413
|
}
|
|
1405
1414
|
};
|
|
1406
1415
|
|
|
@@ -1488,22 +1497,50 @@ window.__ModuleLoader__.load({
|
|
|
1488
1497
|
}, [tab, query]);
|
|
1489
1498
|
React.useEffect(() => { loadModels(); }, []);
|
|
1490
1499
|
|
|
1500
|
+
const actionInProgressRef = React.useRef(new Set());
|
|
1501
|
+
|
|
1491
1502
|
const handleToggleTask = async (id) => {
|
|
1503
|
+
if (actionInProgressRef.current.has(id)) return;
|
|
1504
|
+
actionInProgressRef.current.add(id);
|
|
1492
1505
|
try {
|
|
1493
|
-
await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' });
|
|
1494
|
-
|
|
1506
|
+
const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' });
|
|
1507
|
+
const data = await res.json().catch(() => ({}));
|
|
1508
|
+
if (!res.ok || (data && data.ok === false)) {
|
|
1509
|
+
const msg = (data && data.error) || `Toggle failed (HTTP ${res.status})`;
|
|
1510
|
+
alert(msg);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
await fetchTasks();
|
|
1495
1514
|
} catch (err) {
|
|
1496
|
-
console.
|
|
1515
|
+
console.error('[dsh-cron] toggle failed', err);
|
|
1516
|
+
alert('Toggle request failed: ' + err.message);
|
|
1517
|
+
} finally {
|
|
1518
|
+
actionInProgressRef.current.delete(id);
|
|
1497
1519
|
}
|
|
1498
1520
|
};
|
|
1499
1521
|
|
|
1500
1522
|
const handleRunNow = async (id) => {
|
|
1523
|
+
if (actionInProgressRef.current.has(id)) return;
|
|
1524
|
+
actionInProgressRef.current.add(id);
|
|
1525
|
+
const prevTask = tasks.find(t => t.id === id);
|
|
1526
|
+
const prevStatus = prevTask ? prevTask.lastStatus : null;
|
|
1527
|
+
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
|
|
1501
1528
|
try {
|
|
1502
|
-
|
|
1503
|
-
|
|
1529
|
+
const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' });
|
|
1530
|
+
const data = await res.json().catch(() => ({}));
|
|
1531
|
+
if (!res.ok || (data && data.ok === false)) {
|
|
1532
|
+
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
|
|
1533
|
+
const msg = (data && data.error) || `Run failed (HTTP ${res.status})`;
|
|
1534
|
+
alert(msg);
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1504
1537
|
setTimeout(() => fetchTasks(), 1500);
|
|
1505
1538
|
} catch (err) {
|
|
1506
1539
|
console.error('[dsh-cron] run now error:', err);
|
|
1540
|
+
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
|
|
1541
|
+
alert('Run request failed: ' + err.message);
|
|
1542
|
+
} finally {
|
|
1543
|
+
actionInProgressRef.current.delete(id);
|
|
1507
1544
|
}
|
|
1508
1545
|
};
|
|
1509
1546
|
|
|
@@ -1527,7 +1564,7 @@ window.__ModuleLoader__.load({
|
|
|
1527
1564
|
const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/archive?limit=15&offset=' + offset);
|
|
1528
1565
|
const data = await res.json();
|
|
1529
1566
|
if (data.ok) {
|
|
1530
|
-
setArchiveRuns(data.runs || []);
|
|
1567
|
+
setArchiveRuns(prev => offset === 0 ? (data.runs || []) : [...prev, ...(data.runs || [])]);
|
|
1531
1568
|
setArchiveTotal(data.total || 0);
|
|
1532
1569
|
}
|
|
1533
1570
|
} catch (err) {
|
|
@@ -1592,11 +1629,14 @@ window.__ModuleLoader__.load({
|
|
|
1592
1629
|
return headers;
|
|
1593
1630
|
};
|
|
1594
1631
|
|
|
1595
|
-
/** #42: download the task configuration
|
|
1632
|
+
/** #42, #240: download the task configuration with honest disclosure and sanitized export choice. */
|
|
1596
1633
|
const handleExport = async () => {
|
|
1597
1634
|
setTransferBusy(true);
|
|
1598
1635
|
try {
|
|
1599
|
-
const
|
|
1636
|
+
const promptMsg = T_KEY(t, 'transfer.exportPrompt') || 'Choose export mode:\nOK = Sanitized (removes custom env vars & headers)\nCancel = Full raw export';
|
|
1637
|
+
const wantSanitized = confirm(promptMsg);
|
|
1638
|
+
const exportUrl = '/dsh-cron/tasks/export' + (wantSanitized ? '?sanitize=true' : '');
|
|
1639
|
+
const res = await fetch(exportUrl);
|
|
1600
1640
|
const data = await res.json();
|
|
1601
1641
|
if (!data.ok) {
|
|
1602
1642
|
alert(T_KEY(t, 'transfer.exportFailed', { error: (data && data.error) || '' }));
|
|
@@ -1607,7 +1647,7 @@ window.__ModuleLoader__.load({
|
|
|
1607
1647
|
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
1608
1648
|
const anchor = document.createElement('a');
|
|
1609
1649
|
anchor.href = url;
|
|
1610
|
-
anchor.download = `dsh-cron-tasks-${stamp}.json`;
|
|
1650
|
+
anchor.download = `dsh-cron-tasks-${stamp}${data.sanitized ? '-sanitized' : ''}.json`;
|
|
1611
1651
|
document.body.appendChild(anchor);
|
|
1612
1652
|
anchor.click();
|
|
1613
1653
|
document.body.removeChild(anchor);
|
|
@@ -3145,9 +3185,9 @@ window.__ModuleLoader__.load({
|
|
|
3145
3185
|
}, '✕')
|
|
3146
3186
|
),
|
|
3147
3187
|
React.createElement('div', { style: { marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '13px' } },
|
|
3148
|
-
React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.status') + ': '), React.createElement('span', { style: { color: dryRunResult.ok ? 'var(--dsh-cron-success)' : 'var(--dsh-cron-danger)' } }, dryRunResult.ok ? (dryRunResult.result && dryRunResult.result.status || 'success') : 'failed')),
|
|
3149
|
-
dryRunResult.result && dryRunResult.result.preflight && React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.preflight') + ': '), React.createElement('span', null, dryRunResult.result.preflight.passed ? '✓ Passed' : '✗ Failed (' + (dryRunResult.result.preflight.reason || '') + ')')),
|
|
3150
|
-
|
|
3188
|
+
React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.status') + ': '), React.createElement('span', { style: { color: dryRunResult.ok ? 'var(--dsh-cron-success)' : 'var(--dsh-cron-danger)' } }, dryRunResult.ok ? ((dryRunResult.result && dryRunResult.result.status) || dryRunResult.status || 'success') : 'failed')),
|
|
3189
|
+
((dryRunResult.result && dryRunResult.result.preflight) || dryRunResult.preflight) && React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.preflight') + ': '), React.createElement('span', null, (((dryRunResult.result && dryRunResult.result.preflight) || dryRunResult.preflight).passed) ? '✓ Passed' : '✗ Failed (' + ((((dryRunResult.result && dryRunResult.result.preflight) || dryRunResult.preflight).reason) || '') + ')')),
|
|
3190
|
+
React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.duration') + ': '), React.createElement('span', null, (((dryRunResult.result && dryRunResult.result.durationMs) !== undefined ? dryRunResult.result.durationMs : dryRunResult.durationMs) || 0) + 'ms')),
|
|
3151
3191
|
React.createElement('div', { style: { marginTop: '6px' } }, React.createElement('strong', null, T_KEY(t, 'dryRun.output') + ':')),
|
|
3152
3192
|
React.createElement('pre', {
|
|
3153
3193
|
style: {
|
|
@@ -3162,7 +3202,7 @@ window.__ModuleLoader__.load({
|
|
|
3162
3202
|
wordBreak: 'break-word',
|
|
3163
3203
|
color: 'var(--dsw-alias-label-primary)'
|
|
3164
3204
|
}
|
|
3165
|
-
}, (dryRunResult.result && dryRunResult.result.output)
|
|
3205
|
+
}, ((dryRunResult.result && dryRunResult.result.output) !== undefined ? dryRunResult.result.output : (dryRunResult.output !== undefined ? dryRunResult.output : ((dryRunResult.result && dryRunResult.result.error) || dryRunResult.error || '(no output)'))))
|
|
3166
3206
|
),
|
|
3167
3207
|
React.createElement('div', { className: 'dsh-cron-modal-foot', style: { marginTop: '16px' } },
|
|
3168
3208
|
React.createElement('button', {
|
|
@@ -3229,7 +3269,7 @@ window.__ModuleLoader__.load({
|
|
|
3229
3269
|
type: 'button',
|
|
3230
3270
|
className: 'dsh-cron-btn-secondary',
|
|
3231
3271
|
onClick: () => {
|
|
3232
|
-
const nextOffset =
|
|
3272
|
+
const nextOffset = archiveRuns.length;
|
|
3233
3273
|
if (archiveTask) openArchiveModal(archiveTask, nextOffset);
|
|
3234
3274
|
}
|
|
3235
3275
|
}, T_KEY(t, 'archive.loadMore', { shown: archiveRuns.length, total: archiveTotal })) : React.createElement('div', null),
|
package/lib/index.js
CHANGED
|
@@ -185,14 +185,23 @@ export function apply(ctx, rawConfig) {
|
|
|
185
185
|
// Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
|
|
186
186
|
// external monitor can alert when this scheduler stops responding.
|
|
187
187
|
let heartbeatTimer = null;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
188
|
+
function updateHeartbeat(url, intervalSec) {
|
|
189
|
+
if (heartbeatTimer) {
|
|
190
|
+
clearInterval(heartbeatTimer);
|
|
191
|
+
heartbeatTimer = null;
|
|
192
|
+
}
|
|
193
|
+
const cleanUrl = String(url || '').trim();
|
|
194
|
+
const cleanSec = Number(intervalSec) || 0;
|
|
195
|
+
if (cleanUrl && cleanSec > 0) {
|
|
196
|
+
heartbeatTimer = setInterval(() => {
|
|
197
|
+
fetch(cleanUrl, { method: 'GET' }).catch((err) => {
|
|
198
|
+
logger.warn('[dsh-cron] heartbeat ping failed:', err.message);
|
|
199
|
+
});
|
|
200
|
+
}, cleanSec * 1000);
|
|
201
|
+
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
|
|
202
|
+
}
|
|
195
203
|
}
|
|
204
|
+
updateHeartbeat(config && config.heartbeatUrl, config && config.heartbeatIntervalSec);
|
|
196
205
|
|
|
197
206
|
ctx.effect(() => {
|
|
198
207
|
return () => {
|
|
@@ -236,12 +245,29 @@ export function apply(ctx, rawConfig) {
|
|
|
236
245
|
const { body, error } = await readBody(req, res);
|
|
237
246
|
if (error) return;
|
|
238
247
|
const payload = sanitizeSettingsPayload(body);
|
|
239
|
-
store.saveSettings(payload);
|
|
240
248
|
const scopeErrors = await applySettingsToScope(settingsService, payload);
|
|
249
|
+
if (scopeErrors && scopeErrors.length > 0) {
|
|
250
|
+
sendJson(res, 500, {
|
|
251
|
+
ok: false,
|
|
252
|
+
error: `Settings scope update failed: ${scopeErrors.join('; ')}`,
|
|
253
|
+
scopeErrors,
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
store.saveSettings(payload);
|
|
258
|
+
if (payload.maxConcurrent !== undefined && Number.isFinite(Number(payload.maxConcurrent))) {
|
|
259
|
+
scheduler.maxConcurrent = Number(payload.maxConcurrent);
|
|
260
|
+
}
|
|
261
|
+
if (payload.defaultTimezone !== undefined) {
|
|
262
|
+
scheduler.defaultTimezone = String(payload.defaultTimezone || '').trim();
|
|
263
|
+
}
|
|
264
|
+
if (payload.heartbeatUrl !== undefined || payload.heartbeatIntervalSec !== undefined) {
|
|
265
|
+
const current = store.getSettings();
|
|
266
|
+
updateHeartbeat(current.heartbeatUrl, current.heartbeatIntervalSec);
|
|
267
|
+
}
|
|
241
268
|
sendJson(res, 200, {
|
|
242
269
|
ok: true,
|
|
243
270
|
settings: { ...store.getClientSettings(), version: pkgVersion },
|
|
244
|
-
scopeErrors: (scopeErrors && scopeErrors.length > 0) ? scopeErrors : undefined
|
|
245
271
|
});
|
|
246
272
|
return;
|
|
247
273
|
}
|
package/lib/runtimes.js
CHANGED
|
@@ -46,14 +46,15 @@ function looksLikeScriptFile(token, extensions) {
|
|
|
46
46
|
|
|
47
47
|
/** #5: resolve a Node.js invocation (inline code or a .js/.mjs/.cjs file). */
|
|
48
48
|
export function buildNodeInvocation(task, cwd) {
|
|
49
|
+
const nodeExecutable = task.nodePath || process.execPath;
|
|
49
50
|
const { path: filePath, args } = splitCommandLine(task.prompt);
|
|
50
51
|
if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
|
|
51
|
-
return { file:
|
|
52
|
+
return { file: nodeExecutable, args: [filePath, ...args] };
|
|
52
53
|
}
|
|
53
54
|
const code = String(task.prompt || '');
|
|
54
55
|
const isEsm = /\bimport\s|\bexport\s/.test(code);
|
|
55
56
|
const nodeArgs = isEsm ? ['--input-type=module', '-e', code] : ['-e', code];
|
|
56
|
-
return { file:
|
|
57
|
+
return { file: nodeExecutable, args: nodeArgs };
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
|
|
@@ -261,6 +261,48 @@ export async function executeTask(scheduler, taskId, options = {}) {
|
|
|
261
261
|
const task = scheduler.store.get(taskId);
|
|
262
262
|
if (!task) return null;
|
|
263
263
|
|
|
264
|
+
// Dry-run path: read-only evaluation without mutating store, history, attempts, or triggering alerts (#229)
|
|
265
|
+
if (options.dryRun) {
|
|
266
|
+
const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
|
|
267
|
+
const guard = checkTaskBudgetLimits(task, runs);
|
|
268
|
+
if (guard.exceeded) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
dryRun: true,
|
|
272
|
+
status: 'error',
|
|
273
|
+
error: guard.reason,
|
|
274
|
+
preflightBlocked: true,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const preflight = await executePreflight(task);
|
|
278
|
+
if (!preflight.ok) {
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
dryRun: true,
|
|
282
|
+
status: 'skipped',
|
|
283
|
+
error: `Preflight check skipped: ${preflight.reason}`,
|
|
284
|
+
preflightBlocked: true,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const start = Date.now();
|
|
288
|
+
const outcome = await scheduler.executeWithFallback(
|
|
289
|
+
task,
|
|
290
|
+
AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)),
|
|
291
|
+
options
|
|
292
|
+
);
|
|
293
|
+
return {
|
|
294
|
+
ok: outcome.status !== 'error' && outcome.status !== 'timeout',
|
|
295
|
+
dryRun: true,
|
|
296
|
+
status: outcome.status,
|
|
297
|
+
output: outcome.output,
|
|
298
|
+
error: outcome.error,
|
|
299
|
+
durationMs: Date.now() - start,
|
|
300
|
+
usage: outcome.usage,
|
|
301
|
+
costUsd: outcome.costUsd,
|
|
302
|
+
model: outcome.model || task.model || '',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
264
306
|
// Pre-flight Burn Guard check (#181): prevent executing task if budget already breached
|
|
265
307
|
const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
|
|
266
308
|
const guard = checkTaskBudgetLimits(task, runs);
|
|
@@ -277,22 +319,6 @@ export async function executeTask(scheduler, taskId, options = {}) {
|
|
|
277
319
|
scheduler.store.set(task);
|
|
278
320
|
}
|
|
279
321
|
|
|
280
|
-
if (options.dryRun) {
|
|
281
|
-
const start = Date.now();
|
|
282
|
-
const outcome = await scheduler.executeWithFallback(task, AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)), options);
|
|
283
|
-
return {
|
|
284
|
-
ok: true,
|
|
285
|
-
dryRun: true,
|
|
286
|
-
status: outcome.status,
|
|
287
|
-
output: outcome.output,
|
|
288
|
-
error: outcome.error,
|
|
289
|
-
durationMs: Date.now() - start,
|
|
290
|
-
usage: outcome.usage,
|
|
291
|
-
costUsd: outcome.costUsd,
|
|
292
|
-
model: outcome.model || task.model || '',
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
|
|
296
322
|
const currentRun = scheduler.beginRun(task, taskId, options);
|
|
297
323
|
if (currentRun === null) return null;
|
|
298
324
|
|
|
@@ -377,12 +403,23 @@ export async function applyModelAssists(task, runInfo, options) {
|
|
|
377
403
|
/**
|
|
378
404
|
* Deliver a finished run to every configured channel (#26).
|
|
379
405
|
*/
|
|
380
|
-
export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver }) {
|
|
406
|
+
export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver, secretTimeoutMs = 5000 }) {
|
|
381
407
|
try {
|
|
382
408
|
let settings = typeof store?.getSettings === 'function' ? store.getSettings() : {};
|
|
383
409
|
if (resolveSecrets) {
|
|
384
410
|
try {
|
|
385
|
-
|
|
411
|
+
let timer;
|
|
412
|
+
const deadline = new Promise((_, reject) => {
|
|
413
|
+
timer = setTimeout(() => reject(new Error(`credential resolution timed out after ${secretTimeoutMs}ms`)), secretTimeoutMs);
|
|
414
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
415
|
+
});
|
|
416
|
+
const resolved = await Promise.race([
|
|
417
|
+
Promise.resolve(resolveSecrets(settings)).finally(() => {
|
|
418
|
+
if (timer) clearTimeout(timer);
|
|
419
|
+
}),
|
|
420
|
+
deadline,
|
|
421
|
+
]);
|
|
422
|
+
settings = { ...settings, ...(resolved || {}) };
|
|
386
423
|
} catch (secErr) {
|
|
387
424
|
logger.warn('[dsh-cron] credential resolution failed:', secErr.message);
|
|
388
425
|
}
|
package/lib/settings.js
CHANGED
|
@@ -24,6 +24,7 @@ export const Config = z.object({
|
|
|
24
24
|
maxConcurrent: z.number().default(2).description('Max parallel task runs (0 = unlimited, default 2)').volatile(),
|
|
25
25
|
heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval').volatile(),
|
|
26
26
|
heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)').volatile(),
|
|
27
|
+
llmActionsEnabled: z.boolean().default(false).description('Enable LLM actions execution').volatile(),
|
|
27
28
|
// --- Delivery channels (#20-#23, #26, #28, #47) ---
|
|
28
29
|
template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}').volatile(),
|
|
29
30
|
channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id').volatile(),
|
|
@@ -77,6 +78,7 @@ export function unwrapConfig(cfg) {
|
|
|
77
78
|
|
|
78
79
|
export const SETTINGS_SYNC_KEYS = [
|
|
79
80
|
'botToken', 'botTokenRef', 'telegramWebhookSecret', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
|
|
81
|
+
'defaultTimezone', 'maxConcurrent', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled',
|
|
80
82
|
'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
|
|
81
83
|
'discordWebhookUrl', 'slackWebhookUrl',
|
|
82
84
|
'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
|
package/lib/store.js
CHANGED
|
@@ -38,14 +38,17 @@ export class TaskStore {
|
|
|
38
38
|
this._lastBackupTime = 0;
|
|
39
39
|
this._saveTimer = null;
|
|
40
40
|
this._savePromise = Promise.resolve();
|
|
41
|
+
this._saveVersion = 0;
|
|
42
|
+
this._lastWrittenVersion = 0;
|
|
41
43
|
this.init();
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
init() {
|
|
45
47
|
const dir = path.dirname(this.filePath);
|
|
46
48
|
if (!fs.existsSync(dir)) {
|
|
47
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
48
50
|
}
|
|
51
|
+
bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
|
|
49
52
|
const bakPath = `${this.filePath}.bak`;
|
|
50
53
|
|
|
51
54
|
const loadData = (raw) => {
|
|
@@ -84,22 +87,30 @@ export class TaskStore {
|
|
|
84
87
|
|
|
85
88
|
if (fs.existsSync(this.filePath)) {
|
|
86
89
|
try {
|
|
90
|
+
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
87
91
|
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
88
92
|
loadData(raw);
|
|
89
93
|
if (!fs.existsSync(bakPath)) {
|
|
90
|
-
bestEffort('backup-initial', () =>
|
|
94
|
+
bestEffort('backup-initial', () => {
|
|
95
|
+
fs.copyFileSync(this.filePath, bakPath);
|
|
96
|
+
fs.chmodSync(bakPath, 0o600);
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
|
|
91
100
|
}
|
|
92
101
|
} catch (err) {
|
|
93
102
|
logger.error('[dsh-cron] store read error (corrupted file):', err.message);
|
|
94
103
|
try {
|
|
95
104
|
const corruptedPath = `${this.filePath}.corrupted.${Date.now()}`;
|
|
96
105
|
fs.copyFileSync(this.filePath, corruptedPath);
|
|
106
|
+
bestEffort('chmod-corrupted', () => fs.chmodSync(corruptedPath, 0o600));
|
|
97
107
|
logger.warn(`[dsh-cron] corrupted store backed up to ${corruptedPath}`);
|
|
98
108
|
} catch (snapErr) {
|
|
99
109
|
logger.error('[dsh-cron] failed to snapshot corrupted store:', snapErr.message);
|
|
100
110
|
}
|
|
101
111
|
if (fs.existsSync(bakPath)) {
|
|
102
112
|
try {
|
|
113
|
+
bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
|
|
103
114
|
const rawBak = fs.readFileSync(bakPath, 'utf-8');
|
|
104
115
|
loadData(rawBak);
|
|
105
116
|
logger.warn(`[dsh-cron] successfully recovered tasks store from ${bakPath}`);
|
|
@@ -110,9 +121,13 @@ export class TaskStore {
|
|
|
110
121
|
}
|
|
111
122
|
} else if (fs.existsSync(bakPath)) {
|
|
112
123
|
try {
|
|
124
|
+
bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
|
|
113
125
|
const rawBak = fs.readFileSync(bakPath, 'utf-8');
|
|
114
126
|
loadData(rawBak);
|
|
115
|
-
bestEffort('restore-from-bak', () =>
|
|
127
|
+
bestEffort('restore-from-bak', () => {
|
|
128
|
+
fs.copyFileSync(bakPath, this.filePath);
|
|
129
|
+
fs.chmodSync(this.filePath, 0o600);
|
|
130
|
+
});
|
|
116
131
|
logger.warn(`[dsh-cron] restored missing tasks store from ${bakPath}`);
|
|
117
132
|
} catch (bakErr) {
|
|
118
133
|
logger.error('[dsh-cron] failed to restore tasks store from .bak:', bakErr.message);
|
|
@@ -121,17 +136,16 @@ export class TaskStore {
|
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
/**
|
|
124
|
-
* Schedule a debounced save
|
|
139
|
+
* Schedule a debounced save using the asynchronous write queue to avoid
|
|
140
|
+
* blocking the event loop on large task stores (#244).
|
|
125
141
|
*/
|
|
126
142
|
scheduleSave(delay = 50) {
|
|
127
143
|
if (this._saveTimer) return;
|
|
128
144
|
this._saveTimer = setTimeout(() => {
|
|
129
145
|
this._saveTimer = null;
|
|
130
|
-
|
|
131
|
-
this.save();
|
|
132
|
-
} catch (err) {
|
|
146
|
+
this.saveAsync().catch((err) => {
|
|
133
147
|
logger.error('[dsh-cron] debounced save failed:', err.message);
|
|
134
|
-
}
|
|
148
|
+
});
|
|
135
149
|
}, delay);
|
|
136
150
|
if (typeof this._saveTimer.unref === 'function') {
|
|
137
151
|
this._saveTimer.unref();
|
|
@@ -149,16 +163,72 @@ export class TaskStore {
|
|
|
149
163
|
this.save();
|
|
150
164
|
}
|
|
151
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Asynchronously serialize and write tasks store to disk using fs.promises queue (#244).
|
|
168
|
+
* Safe mode 0600 on files and 0700 on dir (#222).
|
|
169
|
+
*/
|
|
170
|
+
async saveAsync() {
|
|
171
|
+
const version = ++this._saveVersion;
|
|
172
|
+
const data = {
|
|
173
|
+
version: 1,
|
|
174
|
+
updatedAt: new Date().toISOString(),
|
|
175
|
+
tasks: Array.from(this.tasks.values()),
|
|
176
|
+
history: Object.fromEntries(this.history.entries()),
|
|
177
|
+
settings: this.settings || {},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const writeOp = async () => {
|
|
181
|
+
if (this._lastWrittenVersion >= version) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const dir = path.dirname(this.filePath);
|
|
185
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
186
|
+
bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
|
|
187
|
+
|
|
188
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
189
|
+
const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
190
|
+
const payload = JSON.stringify(data, null, 2);
|
|
191
|
+
await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
|
|
192
|
+
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
193
|
+
|
|
194
|
+
if (this._lastWrittenVersion >= version) {
|
|
195
|
+
await fs.promises.unlink(tmp).catch(() => {});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await fs.promises.rename(tmp, this.filePath);
|
|
200
|
+
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
201
|
+
this._lastWrittenVersion = version;
|
|
202
|
+
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
if (now - this._lastBackupTime > 60000) {
|
|
205
|
+
this._lastBackupTime = now;
|
|
206
|
+
try {
|
|
207
|
+
const bakPath = `${this.filePath}.bak`;
|
|
208
|
+
await fs.promises.copyFile(this.filePath, bakPath);
|
|
209
|
+
bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
|
|
210
|
+
} catch (bakErr) {
|
|
211
|
+
logger.warn('[dsh-cron] async backup copy failed:', bakErr.message);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
this._savePromise = this._savePromise.then(writeOp, writeOp);
|
|
217
|
+
return this._savePromise;
|
|
218
|
+
}
|
|
219
|
+
|
|
152
220
|
save() {
|
|
153
221
|
if (this._saveTimer) {
|
|
154
222
|
clearTimeout(this._saveTimer);
|
|
155
223
|
this._saveTimer = null;
|
|
156
224
|
}
|
|
225
|
+
const version = ++this._saveVersion;
|
|
157
226
|
try {
|
|
158
227
|
const dir = path.dirname(this.filePath);
|
|
159
228
|
if (!fs.existsSync(dir)) {
|
|
160
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
229
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
161
230
|
}
|
|
231
|
+
bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
|
|
162
232
|
const data = {
|
|
163
233
|
version: 1,
|
|
164
234
|
updatedAt: new Date().toISOString(),
|
|
@@ -169,13 +239,21 @@ export class TaskStore {
|
|
|
169
239
|
// Unique tmp file per save call with process PID and random token to eliminate file collision
|
|
170
240
|
const rand = Math.random().toString(36).slice(2, 8);
|
|
171
241
|
const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
172
|
-
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
242
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
243
|
+
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
173
244
|
fs.renameSync(tmp, this.filePath);
|
|
245
|
+
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
246
|
+
this._lastWrittenVersion = version;
|
|
247
|
+
|
|
174
248
|
// Throttled backup (.bak): avoid expensive copyFileSync on every micro-save
|
|
175
249
|
const now = Date.now();
|
|
176
250
|
if (now - this._lastBackupTime > 60000) {
|
|
177
251
|
this._lastBackupTime = now;
|
|
178
|
-
bestEffort('backup-on-save', () =>
|
|
252
|
+
bestEffort('backup-on-save', () => {
|
|
253
|
+
const bakPath = `${this.filePath}.bak`;
|
|
254
|
+
fs.copyFileSync(this.filePath, bakPath);
|
|
255
|
+
fs.chmodSync(bakPath, 0o600);
|
|
256
|
+
});
|
|
179
257
|
}
|
|
180
258
|
} catch (err) {
|
|
181
259
|
logger.error('[dsh-cron] store save error:', err.message);
|
|
@@ -333,8 +411,12 @@ export class TaskStore {
|
|
|
333
411
|
delivery: task.delivery !== undefined ? task.delivery : (prev.delivery || 'isolated'),
|
|
334
412
|
provider: task.provider !== undefined ? task.provider : prev.provider,
|
|
335
413
|
model: task.model !== undefined ? task.model : prev.model,
|
|
336
|
-
notifyTelegram: task.notifyTelegram !== undefined
|
|
337
|
-
|
|
414
|
+
notifyTelegram: task.notifyTelegram !== undefined
|
|
415
|
+
? (task.notifyTelegram === null || task.notifyTelegram === 'inherit' ? undefined : Boolean(task.notifyTelegram))
|
|
416
|
+
: prev.notifyTelegram,
|
|
417
|
+
onlyOnFailure: task.onlyOnFailure !== undefined
|
|
418
|
+
? (task.onlyOnFailure === null || task.onlyOnFailure === 'inherit' ? undefined : Boolean(task.onlyOnFailure))
|
|
419
|
+
: prev.onlyOnFailure,
|
|
338
420
|
timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
|
|
339
421
|
overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
|
|
340
422
|
kanbanMode: task.kanbanMode !== undefined ? String(task.kanbanMode) : (prev.kanbanMode || 'none'), // 'none' | 'on_failure' | 'always'
|
|
@@ -505,8 +587,10 @@ export class TaskStore {
|
|
|
505
587
|
// Write atomically with tmp file using compact JSON (avoiding mega-lines and null, 2 serialization bloat)
|
|
506
588
|
const rand = Math.random().toString(36).slice(2, 8);
|
|
507
589
|
const tmp = `${archivePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
508
|
-
fs.writeFileSync(tmp, JSON.stringify(archive), 'utf-8');
|
|
590
|
+
fs.writeFileSync(tmp, JSON.stringify(archive), { encoding: 'utf-8', mode: 0o600 });
|
|
591
|
+
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
509
592
|
fs.renameSync(tmp, archivePath);
|
|
593
|
+
bestEffort('chmod-archive', () => fs.chmodSync(archivePath, 0o600));
|
|
510
594
|
} catch (archiveErr) {
|
|
511
595
|
logger.error('[dsh-cron] history archive error:', archiveErr.message);
|
|
512
596
|
}
|
package/lib/task-create.js
CHANGED
|
@@ -35,8 +35,8 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
35
35
|
priority: Number(args.priority) || 5,
|
|
36
36
|
selfHealingCommand: args.selfHealingCommand ? String(args.selfHealingCommand).trim() : '',
|
|
37
37
|
autoDiagnose: Boolean(args.autoDiagnose),
|
|
38
|
-
notifyTelegram: Boolean(args.notifyTelegram),
|
|
39
|
-
onlyOnFailure: Boolean(args.onlyOnFailure),
|
|
38
|
+
notifyTelegram: args.notifyTelegram !== undefined ? Boolean(args.notifyTelegram) : undefined,
|
|
39
|
+
onlyOnFailure: args.onlyOnFailure !== undefined ? Boolean(args.onlyOnFailure) : undefined,
|
|
40
40
|
timeoutSeconds: Number(args.timeoutSeconds) || 1800,
|
|
41
41
|
overlapPolicy: args.overlapPolicy || 'skip',
|
|
42
42
|
timezone: args.timezone ? String(args.timezone).trim() : '',
|
package/lib/task-transfer.js
CHANGED
|
@@ -162,16 +162,18 @@ export const IMPORT_STRATEGIES = ['add', 'replace', 'skip'];
|
|
|
162
162
|
* `env` and headers can contain hand-typed secrets, so the caller must not
|
|
163
163
|
* claim the file is secret-free.
|
|
164
164
|
*/
|
|
165
|
-
export function buildTaskExport(tasks, { now = Date.now() } = {}) {
|
|
165
|
+
export function buildTaskExport(tasks, { now = Date.now(), sanitize = false } = {}) {
|
|
166
166
|
const list = Array.isArray(tasks) ? tasks : [];
|
|
167
167
|
return {
|
|
168
168
|
kind: TASK_EXPORT_KIND,
|
|
169
169
|
version: TASK_EXPORT_VERSION,
|
|
170
170
|
exportedAt: new Date(now).toISOString(),
|
|
171
|
+
sanitized: Boolean(sanitize),
|
|
171
172
|
tasks: list.map((task) => {
|
|
172
173
|
const out = {};
|
|
173
174
|
for (const key of EXPORT_TASK_FIELDS) {
|
|
174
175
|
if (task[key] === undefined || key === 'status') continue;
|
|
176
|
+
if (sanitize && EXPORT_SECRET_BEARING_FIELDS.includes(key)) continue;
|
|
175
177
|
out[key] = Array.isArray(task[key]) ? [...task[key]] : task[key];
|
|
176
178
|
}
|
|
177
179
|
return out;
|
|
@@ -215,6 +217,26 @@ export function validateImportDocument(doc) {
|
|
|
215
217
|
for (const key of DUPLICATE_TASK_FIELDS) {
|
|
216
218
|
if (raw[key] !== undefined) clean[key] = Array.isArray(raw[key]) ? [...raw[key]] : raw[key];
|
|
217
219
|
}
|
|
220
|
+
if (clean.env && typeof clean.env === 'object') {
|
|
221
|
+
const filteredEnv = {};
|
|
222
|
+
for (const [k, v] of Object.entries(clean.env)) {
|
|
223
|
+
if (typeof v === 'string' && (v.includes('••••') || v.trim() === '********')) {
|
|
224
|
+
continue; // Masked secret placeholder from UI/doc must never become literal secret (#240)
|
|
225
|
+
}
|
|
226
|
+
filteredEnv[k] = v;
|
|
227
|
+
}
|
|
228
|
+
clean.env = filteredEnv;
|
|
229
|
+
}
|
|
230
|
+
if (clean.httpHeaders && typeof clean.httpHeaders === 'object') {
|
|
231
|
+
const filteredHeaders = {};
|
|
232
|
+
for (const [k, v] of Object.entries(clean.httpHeaders)) {
|
|
233
|
+
if (typeof v === 'string' && (v.includes('••••') || v.trim() === '********')) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
filteredHeaders[k] = v;
|
|
237
|
+
}
|
|
238
|
+
clean.httpHeaders = filteredHeaders;
|
|
239
|
+
}
|
|
218
240
|
if (id) clean.id = id;
|
|
219
241
|
if (channels) clean.channels = channels;
|
|
220
242
|
tasks.push(clean);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.41",
|
|
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",
|