@goodandready/dsh-cron 0.2.41 → 0.2.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/api-webhook.js +19 -15
- package/lib/client.js +25 -4
- package/lib/index.js +58 -2
- package/lib/runner-external.js +22 -4
- package/lib/runner.js +52 -21
- package/lib/scheduler-execution.js +3 -0
- package/lib/store.js +29 -7
- 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/client.js
CHANGED
|
@@ -1499,12 +1499,34 @@ window.__ModuleLoader__.load({
|
|
|
1499
1499
|
|
|
1500
1500
|
const actionInProgressRef = React.useRef(new Set());
|
|
1501
1501
|
|
|
1502
|
+
const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
|
|
1503
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
1504
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
1505
|
+
try {
|
|
1506
|
+
const res = await fetch(url, {
|
|
1507
|
+
...options,
|
|
1508
|
+
signal: controller ? controller.signal : options.signal,
|
|
1509
|
+
});
|
|
1510
|
+
const data = await res.json().catch(() => ({}));
|
|
1511
|
+
return { res, data };
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
if (err && err.name === 'AbortError') {
|
|
1514
|
+
const timeoutErr = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
1515
|
+
timeoutErr.isTimeout = true;
|
|
1516
|
+
throw timeoutErr;
|
|
1517
|
+
}
|
|
1518
|
+
throw err;
|
|
1519
|
+
} finally {
|
|
1520
|
+
if (timer) clearTimeout(timer);
|
|
1521
|
+
}
|
|
1522
|
+
};
|
|
1523
|
+
const fetchWithTimeout = fetchJsonWithTimeout;
|
|
1524
|
+
|
|
1502
1525
|
const handleToggleTask = async (id) => {
|
|
1503
1526
|
if (actionInProgressRef.current.has(id)) return;
|
|
1504
1527
|
actionInProgressRef.current.add(id);
|
|
1505
1528
|
try {
|
|
1506
|
-
const res = await
|
|
1507
|
-
const data = await res.json().catch(() => ({}));
|
|
1529
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' }, 15000);
|
|
1508
1530
|
if (!res.ok || (data && data.ok === false)) {
|
|
1509
1531
|
const msg = (data && data.error) || `Toggle failed (HTTP ${res.status})`;
|
|
1510
1532
|
alert(msg);
|
|
@@ -1526,8 +1548,7 @@ window.__ModuleLoader__.load({
|
|
|
1526
1548
|
const prevStatus = prevTask ? prevTask.lastStatus : null;
|
|
1527
1549
|
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
|
|
1528
1550
|
try {
|
|
1529
|
-
const res = await
|
|
1530
|
-
const data = await res.json().catch(() => ({}));
|
|
1551
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' }, 15000);
|
|
1531
1552
|
if (!res.ok || (data && data.ok === false)) {
|
|
1532
1553
|
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
|
|
1533
1554
|
const msg = (data && data.error) || `Run failed (HTTP ${res.status})`;
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { bestEffort } from './best-effort.js';
|
|
2
4
|
import { setLogger, logger } from './logger.js';
|
|
3
5
|
import { registerPluginUpdater } from './updater.js';
|
|
4
6
|
import { credentialRefStatus } from './secrets.js';
|
|
@@ -118,7 +120,8 @@ const pkgVersion = JSON.parse(fs.readFileSync(pkgUrl, 'utf8')).version || '0.2.2
|
|
|
118
120
|
export function apply(ctx, rawConfig) {
|
|
119
121
|
if (ctx && ctx.logger) setLogger(ctx.logger);
|
|
120
122
|
const config = unwrapConfig(rawConfig);
|
|
121
|
-
const
|
|
123
|
+
const storePath = (config && (config.cronDir || config.storePath)) ? (config.storePath || path.join(config.cronDir, 'tasks.json')) : undefined;
|
|
124
|
+
const store = new TaskStore(storePath);
|
|
122
125
|
|
|
123
126
|
// Sync settings declared in the profile config into the store (#184)
|
|
124
127
|
if (config && typeof config === 'object') {
|
|
@@ -182,6 +185,8 @@ export function apply(ctx, rawConfig) {
|
|
|
182
185
|
|
|
183
186
|
scheduler.start();
|
|
184
187
|
|
|
188
|
+
// Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
|
|
189
|
+
// external monitor can alert when this scheduler stops responding.
|
|
185
190
|
// Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
|
|
186
191
|
// external monitor can alert when this scheduler stops responding.
|
|
187
192
|
let heartbeatTimer = null;
|
|
@@ -203,6 +208,47 @@ export function apply(ctx, rawConfig) {
|
|
|
203
208
|
}
|
|
204
209
|
updateHeartbeat(config && config.heartbeatUrl, config && config.heartbeatIntervalSec);
|
|
205
210
|
|
|
211
|
+
// Synchronize volatile Config settings to runtime scheduler and store (#235)
|
|
212
|
+
let activeVolatileConfig = {};
|
|
213
|
+
function syncVolatileConfig(updatedConfig) {
|
|
214
|
+
try {
|
|
215
|
+
const isPlainConfigObject = updatedConfig && typeof updatedConfig === 'object' && !Array.isArray(updatedConfig);
|
|
216
|
+
const cfg = isPlainConfigObject ? { ...(ctx.config || config || {}), ...updatedConfig } : (ctx.config || config || {});
|
|
217
|
+
const unwrapped = unwrapConfig(cfg);
|
|
218
|
+
activeVolatileConfig = { ...activeVolatileConfig, ...unwrapped };
|
|
219
|
+
if (typeof unwrapped.maxConcurrent === 'number') {
|
|
220
|
+
scheduler.maxConcurrent = unwrapped.maxConcurrent;
|
|
221
|
+
}
|
|
222
|
+
if (typeof unwrapped.defaultTimezone === 'string') {
|
|
223
|
+
scheduler.defaultTimezone = unwrapped.defaultTimezone;
|
|
224
|
+
}
|
|
225
|
+
if (typeof unwrapped.llmActionsEnabled === 'boolean') {
|
|
226
|
+
scheduler.llmActionsEnabled = unwrapped.llmActionsEnabled;
|
|
227
|
+
}
|
|
228
|
+
if (unwrapped.heartbeatUrl !== undefined || unwrapped.heartbeatIntervalSec !== undefined) {
|
|
229
|
+
updateHeartbeat(unwrapped.heartbeatUrl, unwrapped.heartbeatIntervalSec);
|
|
230
|
+
}
|
|
231
|
+
const current = store.getSettings();
|
|
232
|
+
const patch = {};
|
|
233
|
+
for (const k of ['maxConcurrent', 'defaultTimezone', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled']) {
|
|
234
|
+
if (unwrapped[k] !== undefined && unwrapped[k] !== current[k]) {
|
|
235
|
+
patch[k] = unwrapped[k];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (Object.keys(patch).length > 0) {
|
|
239
|
+
store.saveSettings(patch);
|
|
240
|
+
}
|
|
241
|
+
} catch (err) {
|
|
242
|
+
logger.warn('[dsh-cron] error syncing volatile config:', err.message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (typeof ctx.on === 'function') {
|
|
247
|
+
ctx.on('loader/volatile-update', (updatedConfig) => {
|
|
248
|
+
syncVolatileConfig(updatedConfig);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
206
252
|
ctx.effect(() => {
|
|
207
253
|
return () => {
|
|
208
254
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
@@ -237,7 +283,17 @@ export function apply(ctx, rawConfig) {
|
|
|
237
283
|
handler: async (req, res) => {
|
|
238
284
|
try {
|
|
239
285
|
if (req.method === 'GET') {
|
|
240
|
-
|
|
286
|
+
const unwrapped = {
|
|
287
|
+
...unwrapConfig(ctx.config || config || {}),
|
|
288
|
+
...activeVolatileConfig,
|
|
289
|
+
};
|
|
290
|
+
const clientSettings = store.getClientSettings();
|
|
291
|
+
for (const k of ['maxConcurrent', 'defaultTimezone', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled']) {
|
|
292
|
+
if (unwrapped[k] !== undefined) {
|
|
293
|
+
clientSettings[k] = unwrapped[k];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
sendJson(res, 200, { ok: true, settings: { ...clientSettings, version: pkgVersion } });
|
|
241
297
|
return;
|
|
242
298
|
}
|
|
243
299
|
if (req.method === 'POST') {
|
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,46 @@ 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
|
+
const hasEventLog = typeof session?.snapshotEvents === 'function' || Array.isArray(session?.log) || Array.isArray(handle.agent?.log);
|
|
453
|
+
if (hasEventLog && !hasTerminalEvent) {
|
|
454
|
+
const err = new Error(
|
|
455
|
+
turnEvents.length === 0
|
|
456
|
+
? 'Agent turn produced no events before quiescence'
|
|
457
|
+
: 'Agent turn ended without terminal completion event'
|
|
458
|
+
);
|
|
459
|
+
err.turnFailed = true;
|
|
460
|
+
err.usage = usage;
|
|
461
|
+
err.costUsd = costUsd;
|
|
462
|
+
err.sessionId = handle.agent.session?.id || null;
|
|
463
|
+
throw err;
|
|
464
|
+
}
|
|
465
|
+
|
|
442
466
|
// 4. Extract assistant message text from turn events (#227)
|
|
443
467
|
const messageTexts = [];
|
|
444
468
|
const chunkTexts = [];
|
|
@@ -476,11 +500,10 @@ export class SessionRunner {
|
|
|
476
500
|
outputText = `[dsh-cron] Agent finished turn for task "${task.title}" (${sessionNote})`;
|
|
477
501
|
}
|
|
478
502
|
|
|
479
|
-
const usage = this._extractUsage(handle, turnEvents);
|
|
480
503
|
return {
|
|
481
504
|
output: outputText,
|
|
482
505
|
usage,
|
|
483
|
-
costUsd
|
|
506
|
+
costUsd,
|
|
484
507
|
sessionId: handle.agent.session?.id || null,
|
|
485
508
|
};
|
|
486
509
|
} finally {
|
|
@@ -541,27 +564,35 @@ export class SessionRunner {
|
|
|
541
564
|
}
|
|
542
565
|
}
|
|
543
566
|
|
|
544
|
-
// assistant/attempt usage (e.g. paid failed attempts
|
|
545
|
-
if (ev.type === 'assistant/attempt'
|
|
546
|
-
|
|
547
|
-
if (ev.data
|
|
548
|
-
|
|
567
|
+
// assistant/attempt usage (e.g. paid failed attempts, retries, streaming attempts) (#228)
|
|
568
|
+
if (ev.type === 'assistant/attempt') {
|
|
569
|
+
let attemptUsage = null;
|
|
570
|
+
if (ev.data?.usage) {
|
|
571
|
+
const u = ev.data.usage;
|
|
572
|
+
attemptUsage = {
|
|
549
573
|
inputTokens: Number(u.inputTokens || u.promptTokens || u.uncachedInputTokens) || 0,
|
|
550
574
|
outputTokens: Number(u.outputTokens || u.completionTokens) || 0,
|
|
551
575
|
cacheReadTokens: Number(u.cacheReadTokens || u.cachedTokens) || 0,
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
// DSH 0.2.0-rc.2 AssistantStreamRecord: stream contains usage chunks
|
|
579
|
+
if (Array.isArray(ev.data?.stream)) {
|
|
580
|
+
for (const item of ev.data.stream) {
|
|
581
|
+
const u = item?.chunk?.usage || item?.usage;
|
|
582
|
+
if (u) {
|
|
583
|
+
if (!attemptUsage) {
|
|
584
|
+
attemptUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
|
|
585
|
+
}
|
|
586
|
+
attemptUsage.inputTokens += Number(u.inputTokens || u.promptTokens || u.uncachedInputTokens) || 0;
|
|
587
|
+
attemptUsage.outputTokens += Number(u.outputTokens || u.completionTokens) || 0;
|
|
588
|
+
attemptUsage.cacheReadTokens += Number(u.cacheReadTokens || u.cachedTokens) || 0;
|
|
589
|
+
}
|
|
563
590
|
}
|
|
564
591
|
}
|
|
592
|
+
if (attemptUsage) {
|
|
593
|
+
failedAttempts.push(attemptUsage);
|
|
594
|
+
hasEventUsage = true;
|
|
595
|
+
}
|
|
565
596
|
}
|
|
566
597
|
}
|
|
567
598
|
|
|
@@ -146,6 +146,9 @@ export async function executeOnce(scheduler, task, signal, options = {}) {
|
|
|
146
146
|
result.error = err.message || String(err);
|
|
147
147
|
const timedOut = result.error.toLowerCase().includes('timed out') || result.error.toLowerCase().includes('timeout');
|
|
148
148
|
result.status = timedOut ? 'timeout' : 'error';
|
|
149
|
+
if (err.usage) result.usage = err.usage;
|
|
150
|
+
if (typeof err.costUsd === 'number') result.costUsd = err.costUsd;
|
|
151
|
+
if (err.sessionId) result.sessionId = err.sessionId;
|
|
149
152
|
}
|
|
150
153
|
return result;
|
|
151
154
|
}
|
package/lib/store.js
CHANGED
|
@@ -39,6 +39,7 @@ export class TaskStore {
|
|
|
39
39
|
this._saveTimer = null;
|
|
40
40
|
this._savePromise = Promise.resolve();
|
|
41
41
|
this._saveVersion = 0;
|
|
42
|
+
this._diskVersion = 0;
|
|
42
43
|
this._lastWrittenVersion = 0;
|
|
43
44
|
this.init();
|
|
44
45
|
}
|
|
@@ -178,7 +179,7 @@ export class TaskStore {
|
|
|
178
179
|
};
|
|
179
180
|
|
|
180
181
|
const writeOp = async () => {
|
|
181
|
-
if (this.
|
|
182
|
+
if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
|
|
182
183
|
return;
|
|
183
184
|
}
|
|
184
185
|
const dir = path.dirname(this.filePath);
|
|
@@ -187,18 +188,30 @@ export class TaskStore {
|
|
|
187
188
|
|
|
188
189
|
const rand = Math.random().toString(36).slice(2, 8);
|
|
189
190
|
const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
190
|
-
|
|
191
|
+
this._currentAsyncTmp = tmp;
|
|
192
|
+
|
|
193
|
+
// Use compact serialization without whitespace indentation to eliminate event loop lag on large stores (#244)
|
|
194
|
+
const payload = JSON.stringify(data);
|
|
191
195
|
await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
|
|
192
196
|
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
193
197
|
|
|
194
|
-
if (this.
|
|
198
|
+
if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
|
|
195
199
|
await fs.promises.unlink(tmp).catch(() => {});
|
|
200
|
+
if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
|
|
196
201
|
return;
|
|
197
202
|
}
|
|
198
203
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
try {
|
|
205
|
+
await fs.promises.rename(tmp, this.filePath);
|
|
206
|
+
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
207
|
+
this._currentAsyncTmp = null;
|
|
208
|
+
this._diskVersion = version;
|
|
209
|
+
this._lastWrittenVersion = version;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
this._currentAsyncTmp = null;
|
|
212
|
+
// If tmp was unlinked by newer sync save, rename will fail with ENOENT - safely ignore
|
|
213
|
+
if (err.code !== 'ENOENT') throw err;
|
|
214
|
+
}
|
|
202
215
|
|
|
203
216
|
const now = Date.now();
|
|
204
217
|
if (now - this._lastBackupTime > 60000) {
|
|
@@ -223,6 +236,14 @@ export class TaskStore {
|
|
|
223
236
|
this._saveTimer = null;
|
|
224
237
|
}
|
|
225
238
|
const version = ++this._saveVersion;
|
|
239
|
+
this._lastSyncVersion = version;
|
|
240
|
+
|
|
241
|
+
// Immediately cancel and unlink any in-flight async tmp file so async rename cannot touch disk (#244)
|
|
242
|
+
if (this._currentAsyncTmp) {
|
|
243
|
+
const inFlightTmp = this._currentAsyncTmp;
|
|
244
|
+
this._currentAsyncTmp = null;
|
|
245
|
+
bestEffort('unlink-async-tmp', () => fs.unlinkSync(inFlightTmp));
|
|
246
|
+
}
|
|
226
247
|
try {
|
|
227
248
|
const dir = path.dirname(this.filePath);
|
|
228
249
|
if (!fs.existsSync(dir)) {
|
|
@@ -243,7 +264,8 @@ export class TaskStore {
|
|
|
243
264
|
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
244
265
|
fs.renameSync(tmp, this.filePath);
|
|
245
266
|
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
246
|
-
this.
|
|
267
|
+
this._diskVersion = Math.max(this._diskVersion || 0, version);
|
|
268
|
+
this._lastWrittenVersion = this._diskVersion;
|
|
247
269
|
|
|
248
270
|
// Throttled backup (.bak): avoid expensive copyFileSync on every micro-save
|
|
249
271
|
const now = Date.now();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.43",
|
|
4
4
|
"description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|