@goodandready/dsh-cron 0.2.40 → 0.2.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/api-webhook.js +19 -15
- package/lib/api.js +2 -2
- package/lib/channels.js +58 -18
- package/lib/client.js +23 -2
- package/lib/index.js +89 -10
- package/lib/runner-external.js +22 -4
- package/lib/runner.js +47 -21
- package/lib/runtimes.js +3 -2
- package/lib/scheduler-execution.js +58 -18
- package/lib/settings.js +2 -0
- package/lib/store.js +29 -5
- package/lib/task-create.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -550,7 +550,7 @@ dsh-cron:
|
|
|
550
550
|
Notes:
|
|
551
551
|
|
|
552
552
|
* Run history is capped at **50 entries per task** (fixed); each entry keeps up to 4000 characters of output.
|
|
553
|
-
* Tasks run in
|
|
553
|
+
* Tasks run in their configured **timezone** (`task.timezone`, falling back to `settings.defaultTimezone` or server local timezone); cron expressions are evaluated by `croner` against the resolved timezone.
|
|
554
554
|
* Tasks persist in the DSH data directory (`cron/tasks.json`) and survive restarts; missed one-shots are detected on startup.
|
|
555
555
|
|
|
556
556
|
---
|
package/lib/api-webhook.js
CHANGED
|
@@ -21,22 +21,26 @@ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
|
|
|
21
21
|
const botToken = settings.botToken || defaults.botToken;
|
|
22
22
|
const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
|
|
23
23
|
|
|
24
|
-
// Secret token verification (Issue #208)
|
|
24
|
+
// Secret token verification (Issue #208) - fail-closed: secret MUST be configured
|
|
25
25
|
const webhookSecret = settings.telegramWebhookSecret || settings.webhookSecret || settings.secretToken || process.env.TELEGRAM_WEBHOOK_SECRET;
|
|
26
|
-
if (webhookSecret) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
26
|
+
if (!webhookSecret) {
|
|
27
|
+
logger.warn('[dsh-cron] unauthorized telegram webhook: webhook secret is not configured (fail-closed)');
|
|
28
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized: webhook secret is not configured' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const headers = req.headers || {};
|
|
32
|
+
const receivedSecret = headers['x-telegram-bot-api-secret-token'] || headers['X-Telegram-Bot-Api-Secret-Token'];
|
|
33
|
+
if (!receivedSecret || typeof receivedSecret !== 'string') {
|
|
34
|
+
logger.warn('[dsh-cron] unauthorized telegram webhook: missing secret token header');
|
|
35
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized: missing secret token' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const secretBuf = Buffer.from(String(webhookSecret));
|
|
39
|
+
const receivedBuf = Buffer.from(String(receivedSecret));
|
|
40
|
+
if (secretBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(secretBuf, receivedBuf)) {
|
|
41
|
+
logger.warn('[dsh-cron] unauthorized telegram webhook: invalid secret token');
|
|
42
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized: invalid secret token' });
|
|
43
|
+
return;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
// Deduplicate updates by update_id (Issue #208)
|
package/lib/api.js
CHANGED
|
@@ -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
|
@@ -1499,11 +1499,32 @@ window.__ModuleLoader__.load({
|
|
|
1499
1499
|
|
|
1500
1500
|
const actionInProgressRef = React.useRef(new Set());
|
|
1501
1501
|
|
|
1502
|
+
const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
|
|
1503
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
1504
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
1505
|
+
try {
|
|
1506
|
+
const res = await fetch(url, {
|
|
1507
|
+
...options,
|
|
1508
|
+
signal: controller ? controller.signal : options.signal,
|
|
1509
|
+
});
|
|
1510
|
+
return res;
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
if (err && err.name === 'AbortError') {
|
|
1513
|
+
const timeoutErr = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
1514
|
+
timeoutErr.isTimeout = true;
|
|
1515
|
+
throw timeoutErr;
|
|
1516
|
+
}
|
|
1517
|
+
throw err;
|
|
1518
|
+
} finally {
|
|
1519
|
+
if (timer) clearTimeout(timer);
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1502
1523
|
const handleToggleTask = async (id) => {
|
|
1503
1524
|
if (actionInProgressRef.current.has(id)) return;
|
|
1504
1525
|
actionInProgressRef.current.add(id);
|
|
1505
1526
|
try {
|
|
1506
|
-
const res = await
|
|
1527
|
+
const res = await fetchWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' }, 15000);
|
|
1507
1528
|
const data = await res.json().catch(() => ({}));
|
|
1508
1529
|
if (!res.ok || (data && data.ok === false)) {
|
|
1509
1530
|
const msg = (data && data.error) || `Toggle failed (HTTP ${res.status})`;
|
|
@@ -1526,7 +1547,7 @@ window.__ModuleLoader__.load({
|
|
|
1526
1547
|
const prevStatus = prevTask ? prevTask.lastStatus : null;
|
|
1527
1548
|
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
|
|
1528
1549
|
try {
|
|
1529
|
-
const res = await
|
|
1550
|
+
const res = await fetchWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' }, 15000);
|
|
1530
1551
|
const data = await res.json().catch(() => ({}));
|
|
1531
1552
|
if (!res.ok || (data && data.ok === false)) {
|
|
1532
1553
|
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { bestEffort } from './best-effort.js';
|
|
2
3
|
import { setLogger, logger } from './logger.js';
|
|
3
4
|
import { registerPluginUpdater } from './updater.js';
|
|
4
5
|
import { credentialRefStatus } from './secrets.js';
|
|
@@ -182,16 +183,67 @@ export function apply(ctx, rawConfig) {
|
|
|
182
183
|
|
|
183
184
|
scheduler.start();
|
|
184
185
|
|
|
186
|
+
// Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
|
|
187
|
+
// external monitor can alert when this scheduler stops responding.
|
|
185
188
|
// Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
|
|
186
189
|
// external monitor can alert when this scheduler stops responding.
|
|
187
190
|
let heartbeatTimer = null;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
function updateHeartbeat(url, intervalSec) {
|
|
192
|
+
if (heartbeatTimer) {
|
|
193
|
+
clearInterval(heartbeatTimer);
|
|
194
|
+
heartbeatTimer = null;
|
|
195
|
+
}
|
|
196
|
+
const cleanUrl = String(url || '').trim();
|
|
197
|
+
const cleanSec = Number(intervalSec) || 0;
|
|
198
|
+
if (cleanUrl && cleanSec > 0) {
|
|
199
|
+
heartbeatTimer = setInterval(() => {
|
|
200
|
+
fetch(cleanUrl, { method: 'GET' }).catch((err) => {
|
|
201
|
+
logger.warn('[dsh-cron] heartbeat ping failed:', err.message);
|
|
202
|
+
});
|
|
203
|
+
}, cleanSec * 1000);
|
|
204
|
+
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
updateHeartbeat(config && config.heartbeatUrl, config && config.heartbeatIntervalSec);
|
|
208
|
+
|
|
209
|
+
// Synchronize volatile Config settings to runtime scheduler and store (#235)
|
|
210
|
+
let activeVolatileConfig = {};
|
|
211
|
+
function syncVolatileConfig(updatedConfig) {
|
|
212
|
+
try {
|
|
213
|
+
const cfg = updatedConfig || ctx.config || config || {};
|
|
214
|
+
const unwrapped = unwrapConfig(cfg);
|
|
215
|
+
activeVolatileConfig = { ...activeVolatileConfig, ...unwrapped };
|
|
216
|
+
if (typeof unwrapped.maxConcurrent === 'number') {
|
|
217
|
+
scheduler.maxConcurrent = unwrapped.maxConcurrent;
|
|
218
|
+
}
|
|
219
|
+
if (typeof unwrapped.defaultTimezone === 'string') {
|
|
220
|
+
scheduler.defaultTimezone = unwrapped.defaultTimezone;
|
|
221
|
+
}
|
|
222
|
+
if (typeof unwrapped.llmActionsEnabled === 'boolean') {
|
|
223
|
+
scheduler.llmActionsEnabled = unwrapped.llmActionsEnabled;
|
|
224
|
+
}
|
|
225
|
+
if (unwrapped.heartbeatUrl !== undefined || unwrapped.heartbeatIntervalSec !== undefined) {
|
|
226
|
+
updateHeartbeat(unwrapped.heartbeatUrl, unwrapped.heartbeatIntervalSec);
|
|
227
|
+
}
|
|
228
|
+
const current = store.getSettings();
|
|
229
|
+
const patch = {};
|
|
230
|
+
for (const k of ['maxConcurrent', 'defaultTimezone', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled']) {
|
|
231
|
+
if (unwrapped[k] !== undefined && unwrapped[k] !== current[k]) {
|
|
232
|
+
patch[k] = unwrapped[k];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (Object.keys(patch).length > 0) {
|
|
236
|
+
store.saveSettings(patch);
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
logger.warn('[dsh-cron] error syncing volatile config:', err.message);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (typeof ctx.on === 'function') {
|
|
244
|
+
ctx.on('loader/volatile-update', (updatedConfig) => {
|
|
245
|
+
syncVolatileConfig(updatedConfig);
|
|
246
|
+
});
|
|
195
247
|
}
|
|
196
248
|
|
|
197
249
|
ctx.effect(() => {
|
|
@@ -228,7 +280,17 @@ export function apply(ctx, rawConfig) {
|
|
|
228
280
|
handler: async (req, res) => {
|
|
229
281
|
try {
|
|
230
282
|
if (req.method === 'GET') {
|
|
231
|
-
|
|
283
|
+
const unwrapped = {
|
|
284
|
+
...unwrapConfig(ctx.config || config || {}),
|
|
285
|
+
...activeVolatileConfig,
|
|
286
|
+
};
|
|
287
|
+
const clientSettings = store.getClientSettings();
|
|
288
|
+
for (const k of ['maxConcurrent', 'defaultTimezone', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled']) {
|
|
289
|
+
if (unwrapped[k] !== undefined) {
|
|
290
|
+
clientSettings[k] = unwrapped[k];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
sendJson(res, 200, { ok: true, settings: { ...clientSettings, version: pkgVersion } });
|
|
232
294
|
return;
|
|
233
295
|
}
|
|
234
296
|
if (req.method === 'POST') {
|
|
@@ -236,12 +298,29 @@ export function apply(ctx, rawConfig) {
|
|
|
236
298
|
const { body, error } = await readBody(req, res);
|
|
237
299
|
if (error) return;
|
|
238
300
|
const payload = sanitizeSettingsPayload(body);
|
|
239
|
-
store.saveSettings(payload);
|
|
240
301
|
const scopeErrors = await applySettingsToScope(settingsService, payload);
|
|
302
|
+
if (scopeErrors && scopeErrors.length > 0) {
|
|
303
|
+
sendJson(res, 500, {
|
|
304
|
+
ok: false,
|
|
305
|
+
error: `Settings scope update failed: ${scopeErrors.join('; ')}`,
|
|
306
|
+
scopeErrors,
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
store.saveSettings(payload);
|
|
311
|
+
if (payload.maxConcurrent !== undefined && Number.isFinite(Number(payload.maxConcurrent))) {
|
|
312
|
+
scheduler.maxConcurrent = Number(payload.maxConcurrent);
|
|
313
|
+
}
|
|
314
|
+
if (payload.defaultTimezone !== undefined) {
|
|
315
|
+
scheduler.defaultTimezone = String(payload.defaultTimezone || '').trim();
|
|
316
|
+
}
|
|
317
|
+
if (payload.heartbeatUrl !== undefined || payload.heartbeatIntervalSec !== undefined) {
|
|
318
|
+
const current = store.getSettings();
|
|
319
|
+
updateHeartbeat(current.heartbeatUrl, current.heartbeatIntervalSec);
|
|
320
|
+
}
|
|
241
321
|
sendJson(res, 200, {
|
|
242
322
|
ok: true,
|
|
243
323
|
settings: { ...store.getClientSettings(), version: pkgVersion },
|
|
244
|
-
scopeErrors: (scopeErrors && scopeErrors.length > 0) ? scopeErrors : undefined
|
|
245
324
|
});
|
|
246
325
|
return;
|
|
247
326
|
}
|
package/lib/runner-external.js
CHANGED
|
@@ -112,11 +112,29 @@ export async function runViaRemoteWorkspace(task, signal, ctx) {
|
|
|
112
112
|
|
|
113
113
|
let profile = null;
|
|
114
114
|
profile = bestEffort('get-remoteSsh-profile', () => {
|
|
115
|
+
const remoteProfilesSvc = ctx.get('remoteProfiles');
|
|
116
|
+
if (remoteProfilesSvc && typeof remoteProfilesSvc.list === 'function') {
|
|
117
|
+
const list = remoteProfilesSvc.list();
|
|
118
|
+
if (Array.isArray(list)) {
|
|
119
|
+
const found = list.find((p) => p && p.id === task.sshProfileId);
|
|
120
|
+
if (found) return found;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
115
123
|
const settings = ctx.get('settings');
|
|
116
|
-
if (settings
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
if (settings) {
|
|
125
|
+
if (typeof settings.describe === 'function') {
|
|
126
|
+
const desc = settings.describe();
|
|
127
|
+
const entry = Array.isArray(desc) ? desc.find((r) => r && r.ns === 'dsh-remote-workspace') : null;
|
|
128
|
+
const snap = entry ? entry.value : null;
|
|
129
|
+
const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
|
|
130
|
+
const found = profiles.find((p) => p && p.id === task.sshProfileId);
|
|
131
|
+
if (found) return found;
|
|
132
|
+
}
|
|
133
|
+
if (typeof settings.get === 'function') {
|
|
134
|
+
const snap = settings.get('dsh-remote-workspace');
|
|
135
|
+
const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
|
|
136
|
+
return profiles.find((p) => p && p.id === task.sshProfileId) || null;
|
|
137
|
+
}
|
|
120
138
|
}
|
|
121
139
|
return null;
|
|
122
140
|
});
|
package/lib/runner.js
CHANGED
|
@@ -423,22 +423,41 @@ export class SessionRunner {
|
|
|
423
423
|
turnEvents = handle.agent.log.slice(startSeq);
|
|
424
424
|
}
|
|
425
425
|
|
|
426
|
-
//
|
|
426
|
+
// Extract usage FIRST so failed turns and budget checks have accurate token and cost accounting (#228)
|
|
427
|
+
const usage = this._extractUsage(handle, turnEvents);
|
|
428
|
+
const costUsd = estimateTokenCost(prep.model, usage);
|
|
429
|
+
|
|
430
|
+
// 3. Terminal turn failure and settlement check (#227)
|
|
431
|
+
let hasTerminalEvent = false;
|
|
427
432
|
for (const ev of turnEvents) {
|
|
428
433
|
if (!ev) continue;
|
|
429
434
|
if (ev.type === 'turn/end' || ev.type === 'turn/error' || ev.type === 'turn/failure') {
|
|
435
|
+
hasTerminalEvent = true;
|
|
430
436
|
const reason = ev.data?.reason || ev.reason;
|
|
431
437
|
const reasonKind = (reason && typeof reason === 'object') ? reason.kind : reason;
|
|
432
|
-
if (['error', 'interrupted', 'failed', 'aborted'].includes(reasonKind) || ev.data?.error || ev.error) {
|
|
433
|
-
const errMsg = ev.data?.error?.message || ev.error?.message || reason?.message || (typeof reason === 'string' ? reason :
|
|
438
|
+
if (['error', 'interrupted', 'failed', 'aborted', 'blocked'].includes(reasonKind) || ev.data?.error || ev.error) {
|
|
439
|
+
const errMsg = ev.data?.error?.message || ev.error?.message || reason?.message || (typeof reason === 'string' ? reason : `Agent turn ended with status: ${reasonKind || 'error'}`);
|
|
434
440
|
const err = new Error(errMsg);
|
|
435
441
|
err.reason = reason;
|
|
442
|
+
err.reasonKind = reasonKind;
|
|
436
443
|
err.turnFailed = true;
|
|
444
|
+
err.usage = usage;
|
|
445
|
+
err.costUsd = costUsd;
|
|
446
|
+
err.sessionId = handle.agent.session?.id || null;
|
|
437
447
|
throw err;
|
|
438
448
|
}
|
|
439
449
|
}
|
|
440
450
|
}
|
|
441
451
|
|
|
452
|
+
if (turnEvents.length > 0 && !hasTerminalEvent) {
|
|
453
|
+
const err = new Error('Agent turn ended without terminal completion event');
|
|
454
|
+
err.turnFailed = true;
|
|
455
|
+
err.usage = usage;
|
|
456
|
+
err.costUsd = costUsd;
|
|
457
|
+
err.sessionId = handle.agent.session?.id || null;
|
|
458
|
+
throw err;
|
|
459
|
+
}
|
|
460
|
+
|
|
442
461
|
// 4. Extract assistant message text from turn events (#227)
|
|
443
462
|
const messageTexts = [];
|
|
444
463
|
const chunkTexts = [];
|
|
@@ -476,11 +495,10 @@ export class SessionRunner {
|
|
|
476
495
|
outputText = `[dsh-cron] Agent finished turn for task "${task.title}" (${sessionNote})`;
|
|
477
496
|
}
|
|
478
497
|
|
|
479
|
-
const usage = this._extractUsage(handle, turnEvents);
|
|
480
498
|
return {
|
|
481
499
|
output: outputText,
|
|
482
500
|
usage,
|
|
483
|
-
costUsd
|
|
501
|
+
costUsd,
|
|
484
502
|
sessionId: handle.agent.session?.id || null,
|
|
485
503
|
};
|
|
486
504
|
} finally {
|
|
@@ -541,27 +559,35 @@ export class SessionRunner {
|
|
|
541
559
|
}
|
|
542
560
|
}
|
|
543
561
|
|
|
544
|
-
// assistant/attempt usage (e.g. paid failed attempts
|
|
545
|
-
if (ev.type === 'assistant/attempt'
|
|
546
|
-
|
|
547
|
-
if (ev.data
|
|
548
|
-
|
|
562
|
+
// assistant/attempt usage (e.g. paid failed attempts, retries, streaming attempts) (#228)
|
|
563
|
+
if (ev.type === 'assistant/attempt') {
|
|
564
|
+
let attemptUsage = null;
|
|
565
|
+
if (ev.data?.usage) {
|
|
566
|
+
const u = ev.data.usage;
|
|
567
|
+
attemptUsage = {
|
|
549
568
|
inputTokens: Number(u.inputTokens || u.promptTokens || u.uncachedInputTokens) || 0,
|
|
550
569
|
outputTokens: Number(u.outputTokens || u.completionTokens) || 0,
|
|
551
570
|
cacheReadTokens: Number(u.cacheReadTokens || u.cachedTokens) || 0,
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
// DSH 0.2.0-rc.2 AssistantStreamRecord: stream contains usage chunks
|
|
574
|
+
if (Array.isArray(ev.data?.stream)) {
|
|
575
|
+
for (const item of ev.data.stream) {
|
|
576
|
+
const u = item?.chunk?.usage || item?.usage;
|
|
577
|
+
if (u) {
|
|
578
|
+
if (!attemptUsage) {
|
|
579
|
+
attemptUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
|
|
580
|
+
}
|
|
581
|
+
attemptUsage.inputTokens += Number(u.inputTokens || u.promptTokens || u.uncachedInputTokens) || 0;
|
|
582
|
+
attemptUsage.outputTokens += Number(u.outputTokens || u.completionTokens) || 0;
|
|
583
|
+
attemptUsage.cacheReadTokens += Number(u.cacheReadTokens || u.cachedTokens) || 0;
|
|
584
|
+
}
|
|
563
585
|
}
|
|
564
586
|
}
|
|
587
|
+
if (attemptUsage) {
|
|
588
|
+
failedAttempts.push(attemptUsage);
|
|
589
|
+
hasEventUsage = true;
|
|
590
|
+
}
|
|
565
591
|
}
|
|
566
592
|
}
|
|
567
593
|
|
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. */
|
|
@@ -146,6 +146,9 @@ export async function executeOnce(scheduler, task, signal, options = {}) {
|
|
|
146
146
|
result.error = err.message || String(err);
|
|
147
147
|
const timedOut = result.error.toLowerCase().includes('timed out') || result.error.toLowerCase().includes('timeout');
|
|
148
148
|
result.status = timedOut ? 'timeout' : 'error';
|
|
149
|
+
if (err.usage) result.usage = err.usage;
|
|
150
|
+
if (typeof err.costUsd === 'number') result.costUsd = err.costUsd;
|
|
151
|
+
if (err.sessionId) result.sessionId = err.sessionId;
|
|
149
152
|
}
|
|
150
153
|
return result;
|
|
151
154
|
}
|
|
@@ -261,6 +264,48 @@ export async function executeTask(scheduler, taskId, options = {}) {
|
|
|
261
264
|
const task = scheduler.store.get(taskId);
|
|
262
265
|
if (!task) return null;
|
|
263
266
|
|
|
267
|
+
// Dry-run path: read-only evaluation without mutating store, history, attempts, or triggering alerts (#229)
|
|
268
|
+
if (options.dryRun) {
|
|
269
|
+
const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
|
|
270
|
+
const guard = checkTaskBudgetLimits(task, runs);
|
|
271
|
+
if (guard.exceeded) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
dryRun: true,
|
|
275
|
+
status: 'error',
|
|
276
|
+
error: guard.reason,
|
|
277
|
+
preflightBlocked: true,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const preflight = await executePreflight(task);
|
|
281
|
+
if (!preflight.ok) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
dryRun: true,
|
|
285
|
+
status: 'skipped',
|
|
286
|
+
error: `Preflight check skipped: ${preflight.reason}`,
|
|
287
|
+
preflightBlocked: true,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const start = Date.now();
|
|
291
|
+
const outcome = await scheduler.executeWithFallback(
|
|
292
|
+
task,
|
|
293
|
+
AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)),
|
|
294
|
+
options
|
|
295
|
+
);
|
|
296
|
+
return {
|
|
297
|
+
ok: outcome.status !== 'error' && outcome.status !== 'timeout',
|
|
298
|
+
dryRun: true,
|
|
299
|
+
status: outcome.status,
|
|
300
|
+
output: outcome.output,
|
|
301
|
+
error: outcome.error,
|
|
302
|
+
durationMs: Date.now() - start,
|
|
303
|
+
usage: outcome.usage,
|
|
304
|
+
costUsd: outcome.costUsd,
|
|
305
|
+
model: outcome.model || task.model || '',
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
264
309
|
// Pre-flight Burn Guard check (#181): prevent executing task if budget already breached
|
|
265
310
|
const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
|
|
266
311
|
const guard = checkTaskBudgetLimits(task, runs);
|
|
@@ -277,22 +322,6 @@ export async function executeTask(scheduler, taskId, options = {}) {
|
|
|
277
322
|
scheduler.store.set(task);
|
|
278
323
|
}
|
|
279
324
|
|
|
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
325
|
const currentRun = scheduler.beginRun(task, taskId, options);
|
|
297
326
|
if (currentRun === null) return null;
|
|
298
327
|
|
|
@@ -377,12 +406,23 @@ export async function applyModelAssists(task, runInfo, options) {
|
|
|
377
406
|
/**
|
|
378
407
|
* Deliver a finished run to every configured channel (#26).
|
|
379
408
|
*/
|
|
380
|
-
export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver }) {
|
|
409
|
+
export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver, secretTimeoutMs = 5000 }) {
|
|
381
410
|
try {
|
|
382
411
|
let settings = typeof store?.getSettings === 'function' ? store.getSettings() : {};
|
|
383
412
|
if (resolveSecrets) {
|
|
384
413
|
try {
|
|
385
|
-
|
|
414
|
+
let timer;
|
|
415
|
+
const deadline = new Promise((_, reject) => {
|
|
416
|
+
timer = setTimeout(() => reject(new Error(`credential resolution timed out after ${secretTimeoutMs}ms`)), secretTimeoutMs);
|
|
417
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
418
|
+
});
|
|
419
|
+
const resolved = await Promise.race([
|
|
420
|
+
Promise.resolve(resolveSecrets(settings)).finally(() => {
|
|
421
|
+
if (timer) clearTimeout(timer);
|
|
422
|
+
}),
|
|
423
|
+
deadline,
|
|
424
|
+
]);
|
|
425
|
+
settings = { ...settings, ...(resolved || {}) };
|
|
386
426
|
} catch (secErr) {
|
|
387
427
|
logger.warn('[dsh-cron] credential resolution failed:', secErr.message);
|
|
388
428
|
}
|
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
|
@@ -39,6 +39,7 @@ export class TaskStore {
|
|
|
39
39
|
this._saveTimer = null;
|
|
40
40
|
this._savePromise = Promise.resolve();
|
|
41
41
|
this._saveVersion = 0;
|
|
42
|
+
this._diskVersion = 0;
|
|
42
43
|
this._lastWrittenVersion = 0;
|
|
43
44
|
this.init();
|
|
44
45
|
}
|
|
@@ -178,7 +179,7 @@ export class TaskStore {
|
|
|
178
179
|
};
|
|
179
180
|
|
|
180
181
|
const writeOp = async () => {
|
|
181
|
-
if (this.
|
|
182
|
+
if ((this._diskVersion || 0) >= version) {
|
|
182
183
|
return;
|
|
183
184
|
}
|
|
184
185
|
const dir = path.dirname(this.filePath);
|
|
@@ -191,13 +192,31 @@ export class TaskStore {
|
|
|
191
192
|
await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
|
|
192
193
|
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
193
194
|
|
|
194
|
-
if (this.
|
|
195
|
+
if ((this._diskVersion || 0) >= version || this._saveVersion > version) {
|
|
195
196
|
await fs.promises.unlink(tmp).catch(() => {});
|
|
196
197
|
return;
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
await fs.promises.rename(tmp, this.filePath);
|
|
200
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;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this._diskVersion = version;
|
|
201
220
|
this._lastWrittenVersion = version;
|
|
202
221
|
|
|
203
222
|
const now = Date.now();
|
|
@@ -243,7 +262,8 @@ export class TaskStore {
|
|
|
243
262
|
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
244
263
|
fs.renameSync(tmp, this.filePath);
|
|
245
264
|
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
246
|
-
this.
|
|
265
|
+
this._diskVersion = Math.max(this._diskVersion || 0, version);
|
|
266
|
+
this._lastWrittenVersion = this._diskVersion;
|
|
247
267
|
|
|
248
268
|
// Throttled backup (.bak): avoid expensive copyFileSync on every micro-save
|
|
249
269
|
const now = Date.now();
|
|
@@ -411,8 +431,12 @@ export class TaskStore {
|
|
|
411
431
|
delivery: task.delivery !== undefined ? task.delivery : (prev.delivery || 'isolated'),
|
|
412
432
|
provider: task.provider !== undefined ? task.provider : prev.provider,
|
|
413
433
|
model: task.model !== undefined ? task.model : prev.model,
|
|
414
|
-
notifyTelegram: task.notifyTelegram !== undefined
|
|
415
|
-
|
|
434
|
+
notifyTelegram: task.notifyTelegram !== undefined
|
|
435
|
+
? (task.notifyTelegram === null || task.notifyTelegram === 'inherit' ? undefined : Boolean(task.notifyTelegram))
|
|
436
|
+
: prev.notifyTelegram,
|
|
437
|
+
onlyOnFailure: task.onlyOnFailure !== undefined
|
|
438
|
+
? (task.onlyOnFailure === null || task.onlyOnFailure === 'inherit' ? undefined : Boolean(task.onlyOnFailure))
|
|
439
|
+
: prev.onlyOnFailure,
|
|
416
440
|
timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
|
|
417
441
|
overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
|
|
418
442
|
kanbanMode: task.kanbanMode !== undefined ? String(task.kanbanMode) : (prev.kanbanMode || 'none'), // 'none' | 'on_failure' | 'always'
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.42",
|
|
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",
|