@goodandready/dsh-cron 0.2.46 → 0.2.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.js CHANGED
@@ -547,7 +547,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
547
547
  }
548
548
 
549
549
  if (req.method === 'POST') {
550
- const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, apiToken });
550
+ const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
551
551
  if (handled) return;
552
552
  }
553
553
  if (req.method === 'GET' && !action) {
package/lib/channels.js CHANGED
@@ -153,14 +153,27 @@ export function buildGiteaIssuePayload({ task, runInfo, settings = {} }) {
153
153
  const failed = isFailed(runInfo.status);
154
154
  const defaultNames = failed ? ['cron', 'bug', 'alert'] : ['cron', 'auto'];
155
155
 
156
- // Resolve labels to integer IDs per Gitea Swagger (array<int64>) (#224)
157
- const mapping = settings.giteaLabelIds || {};
156
+ // Resolve labels to integer IDs per Gitea Swagger (array<int64>) (#224, #297)
157
+ let mapping = settings.giteaLabelIds || {};
158
+ if (typeof mapping === 'string' && mapping.trim().startsWith('{')) {
159
+ try {
160
+ mapping = JSON.parse(mapping);
161
+ } catch (_) {
162
+ mapping = {};
163
+ }
164
+ }
158
165
  let candidateIds = [];
159
166
 
160
167
  if (Array.isArray(task && task.giteaLabelIds)) {
161
168
  candidateIds = task.giteaLabelIds;
162
169
  } else if (Array.isArray(settings.giteaLabels)) {
163
170
  candidateIds = settings.giteaLabels;
171
+ } else if (typeof settings.giteaLabels === 'string' && settings.giteaLabels.trim()) {
172
+ candidateIds = settings.giteaLabels.split(',').map((s) => s.trim()).filter(Boolean).map((name) => {
173
+ const num = Number(name);
174
+ if (Number.isInteger(num) && num > 0) return num;
175
+ return typeof mapping === 'object' && mapping !== null ? mapping[name] : undefined;
176
+ });
164
177
  } else if (Array.isArray(settings.giteaLabelIds)) {
165
178
  candidateIds = settings.giteaLabelIds;
166
179
  } else if (Array.isArray(task && task.labels)) {
@@ -17,6 +17,7 @@ import { parseScheduleExpression } from './scheduler.js';
17
17
  import { unknownChannelIds } from './channels.js';
18
18
  import { PATCHABLE_TASK_FIELDS, validateTaskType } from './task-transfer.js';
19
19
  import { pickPatchableFields } from './http-utils.js';
20
+ import { logger } from './logger.js';
20
21
 
21
22
  /** Marker that makes a task owned by the config (#50). */
22
23
  export const MANAGED_BY_CONFIG = 'config';
@@ -171,7 +172,7 @@ export function planConfigSync(entries, existingTasks) {
171
172
  * Apply the plan: create, update and remove tasks, then arm or pause them.
172
173
  * Never throws for a single bad entry — that entry is reported and skipped.
173
174
  */
174
- export function applyConfigSync({ store, scheduler, entries, log = console }) {
175
+ export function applyConfigSync({ store, scheduler, entries, log = logger }) {
175
176
  const plan = planConfigSync(entries, store.list({ status: 'all' }));
176
177
  for (const warning of plan.warnings) log.warn('[dsh-cron] config jobs: ' + warning);
177
178
  for (const entry of plan.skipped) log.error('[dsh-cron] config jobs: skipped: ' + entry.error);
package/lib/cron-tool.js CHANGED
@@ -3,6 +3,7 @@ import { applyTaskPatch, describeTaskPatch } from './task-patch.js';
3
3
  import { isConfigOwned, configOwnedMessage } from './config-jobs.js';
4
4
  import { TASK_TYPES } from './runtimes.js';
5
5
  import { CHANNEL_IDS } from './channels.js';
6
+ import { redactTaskSecrets } from './http-utils.js';
6
7
 
7
8
  export const LEGACY_TOOL_NAMES = [
8
9
  'cron_create_task',
@@ -394,7 +395,7 @@ export async function executeCronTool(store, scheduler, args = {}) {
394
395
  `channels: ${Array.isArray(task.channels) && task.channels.length ? task.channels.join(', ') : '(legacy flags)'}`,
395
396
  `prompt: ${String(task.prompt || '').slice(0, 500)}`,
396
397
  ].filter(Boolean).join('\n');
397
- return { success: true, message: summary, task };
398
+ return { success: true, message: summary, task: redactTaskSecrets(task) };
398
399
  }
399
400
 
400
401
  case 'update': {
@@ -411,7 +412,7 @@ export async function executeCronTool(store, scheduler, args = {}) {
411
412
  return {
412
413
  success: true,
413
414
  message: `Task "${result.task.title}" updated (${result.task.scheduleText || result.task.schedule}). Changed: ${describeTaskPatch(result.task, result.patch)}`,
414
- task: result.task,
415
+ task: redactTaskSecrets(result.task),
415
416
  };
416
417
  }
417
418
 
package/lib/http-utils.js CHANGED
@@ -94,15 +94,17 @@ export function isTrustedRequest(req, options = {}) {
94
94
 
95
95
  const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
96
96
  const hasRemote = Boolean(remote && typeof remote === 'string');
97
- const isLoopback = !hasRemote || isLoopbackAddress(remote);
97
+ const isLoopback = Boolean(hasRemote && isLoopbackAddress(remote));
98
98
 
99
99
  const token = extractBearerToken(req);
100
100
  const expectedToken = options.apiToken || process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
101
101
  const isTokenAuthenticated = Boolean(expectedToken && token && token === expectedToken);
102
102
 
103
- // 1. Non-loopback remote addresses require explicit token authentication (#86)
104
- if (hasRemote && !isLoopback && !isTokenAuthenticated) {
105
- return false;
103
+ // 1. Non-loopback socket callers (or callers with missing remoteAddress) require explicit token authentication (#86, #295)
104
+ if (req.socket || req.connection || hasRemote) {
105
+ if (!isLoopback && !isTokenAuthenticated) {
106
+ return false;
107
+ }
106
108
  }
107
109
 
108
110
  // 2. Validate Origin header if present: cannot be null, must match Host
package/lib/index.js CHANGED
@@ -117,22 +117,20 @@ export {
117
117
  LEGACY_ACTION_MAP,
118
118
  };
119
119
  const pkgUrl = new URL('../package.json', import.meta.url);
120
- // Helper to access configuration safely in native Cordis Context without throwing missing-inject error (#235)
121
- function getContextConfig(ctx, rawConfig) {
120
+ // Helper to access configuration safely in native Cordis Context without throwing missing-inject error (#235, #288)
121
+ export function getContextConfig(ctx, rawConfig) {
122
122
  try {
123
123
  if (ctx && ctx.fiber && ctx.fiber.config) return ctx.fiber.config;
124
124
  if (ctx && ctx.scope && ctx.scope.config) return ctx.scope.config;
125
125
  if (ctx && ctx.runtime && ctx.runtime.config) return ctx.runtime.config;
126
- if (ctx && typeof ctx === 'object' && 'config' in ctx) {
127
- try {
128
- const c = ctx.config;
129
- if (c) return c;
130
- } catch (err) {
131
- // Ignore missing inject error on native Cordis Context (#235)
132
- }
126
+ // In plain/mock contexts without fiber/scope, safely attempt reading config
127
+ try {
128
+ if (ctx && ctx.config) return ctx.config;
129
+ } catch (_) {
130
+ // Ignored if ctx is a Cordis proxy throwing without inject (#235, #288)
133
131
  }
134
132
  } catch (err) {
135
- // Ignore context access error and fall back to rawConfig (#235)
133
+ // Ignore context access error and fall back to rawConfig (#235, #288)
136
134
  }
137
135
  return rawConfig || {};
138
136
  }
@@ -199,7 +197,7 @@ export function apply(ctx, rawConfig) {
199
197
  // A broken entry is reported and skipped, never fatal.
200
198
  try {
201
199
  const configJobs = Array.isArray(config && config.jobs) ? config.jobs : [];
202
- const summary = applyConfigSync({ store, scheduler, entries: configJobs });
200
+ const summary = applyConfigSync({ store, scheduler, entries: configJobs, log: logger });
203
201
  logger.info('[dsh-cron] config jobs synced: ' + JSON.stringify(summary));
204
202
  } catch (err) {
205
203
  logger.error('[dsh-cron] config job sync failed:', err.message);
@@ -253,7 +251,7 @@ export function apply(ctx, rawConfig) {
253
251
  }
254
252
  const current = store.getSettings();
255
253
  const patch = {};
256
- for (const k of ['maxConcurrent', 'defaultTimezone', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled']) {
254
+ for (const k of SETTINGS_SYNC_KEYS) {
257
255
  if (unwrapped[k] !== undefined && unwrapped[k] !== current[k]) {
258
256
  patch[k] = unwrapped[k];
259
257
  }
@@ -295,7 +293,6 @@ export function apply(ctx, rawConfig) {
295
293
  registerRoutes(ctx, {
296
294
  store,
297
295
  scheduler,
298
- getCronSettingsScope: () => settingsService,
299
296
  recommendations,
300
297
  });
301
298
 
@@ -311,7 +308,7 @@ export function apply(ctx, rawConfig) {
311
308
  ...activeVolatileConfig,
312
309
  };
313
310
  const clientSettings = store.getClientSettings();
314
- for (const k of ['maxConcurrent', 'defaultTimezone', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled']) {
311
+ for (const k of SETTINGS_SYNC_KEYS) {
315
312
  if (unwrapped[k] !== undefined) {
316
313
  clientSettings[k] = unwrapped[k];
317
314
  }
package/lib/routes.js CHANGED
@@ -10,7 +10,7 @@ import { sendTelegramMessage, createTelegramTaskKeyboard } from './telegram.js';
10
10
  import { createKanbanCard } from './integrations.js';
11
11
  import { sanitizeSettingsPayload, applySettingsToScope } from './settings.js';
12
12
 
13
- export function registerRoutes(ctx, { store, scheduler, getCronSettingsScope, recommendations }) {
13
+ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
14
14
  // handler per route family; registered WITHOUT the trailing slash (#96)
15
15
  ctx.effect(() => ctx.webServer.register({
16
16
  kind: 'prefix',
@@ -40,15 +40,17 @@ export async function executePreflight(task) {
40
40
  }
41
41
 
42
42
  if (type === 'http') {
43
+ let timeout;
43
44
  try {
44
45
  const controller = new AbortController();
45
- const timeout = setTimeout(() => controller.abort(), 5000);
46
+ timeout = setTimeout(() => controller.abort(), 5000);
46
47
  const res = await fetch(target, { signal: controller.signal });
47
- clearTimeout(timeout);
48
48
  if (res.ok) return { ok: true };
49
49
  return { ok: false, reason: `HTTP check returned status ${res.status}` };
50
50
  } catch (err) {
51
51
  return { ok: false, reason: `HTTP check failed: ${err.message}` };
52
+ } finally {
53
+ if (timeout) clearTimeout(timeout);
52
54
  }
53
55
  }
54
56
 
@@ -595,7 +597,7 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
595
597
  const guard = await checkAndApplyBurnGuard(scheduler, taskId, outcome);
596
598
  if (guard && guard.exceeded) return;
597
599
 
598
- while (!scheduler.isStopped && scheduler.queue.length > 0 && (scheduler.maxConcurrent === 0 || scheduler.running.size < scheduler.maxConcurrent)) {
600
+ if (!scheduler.isStopped && scheduler.queue.length > 0 && (scheduler.maxConcurrent === 0 || scheduler.running.size < scheduler.maxConcurrent)) {
599
601
  let admittedIndex = -1;
600
602
  for (let i = 0; i < scheduler.queue.length; i++) {
601
603
  const candidate = scheduler.queue[i];
@@ -619,17 +621,16 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
619
621
  break;
620
622
  }
621
623
 
622
- if (admittedIndex === -1) break;
623
-
624
- const nextItem = scheduler.queue.splice(admittedIndex, 1)[0];
625
- setImmediate(() => {
626
- if (!scheduler.isStopped) {
627
- scheduler.runTask(nextItem.taskId, nextItem.options || {}).catch((runErr) => {
628
- bestEffort('drain-queued-task', () => {}, scheduler.logger);
629
- });
630
- }
631
- });
632
- break;
624
+ if (admittedIndex !== -1) {
625
+ const nextItem = scheduler.queue.splice(admittedIndex, 1)[0];
626
+ setImmediate(() => {
627
+ if (!scheduler.isStopped) {
628
+ scheduler.runTask(nextItem.taskId, nextItem.options || {}).catch((runErr) => {
629
+ bestEffort('drain-queued-task', () => {}, scheduler.logger);
630
+ });
631
+ }
632
+ });
633
+ }
633
634
  }
634
635
 
635
636
  // 1. Structured LLM Actions (#137)
package/lib/scheduler.js CHANGED
@@ -140,15 +140,17 @@ function parseIntervalExpression(str) {
140
140
  if (num >= 60) {
141
141
  if (num % 60 === 0) {
142
142
  const hours = num / 60;
143
- if (hours >= 24 && hours % 24 === 0) {
144
- const days = hours / 24;
145
- if (days > 31) {
146
- throw new Error(`Invalid interval: minute interval "${num}m" (${days} days) exceeds 31 days`);
143
+ if (hours >= 24) {
144
+ if (hours % 24 === 0) {
145
+ const days = hours / 24;
146
+ if (days > 31) {
147
+ throw new Error(`Invalid interval: minute interval "${num}m" (${days} days) exceeds 31 days`);
148
+ }
149
+ pattern = `0 0 */${days} * *`;
150
+ humanText = `Every ${days} days`;
151
+ } else {
152
+ throw new Error(`Invalid interval: minute interval "${num}m" (${hours} hours) cannot be expressed as a cron step`);
147
153
  }
148
- pattern = `0 0 */${days} * *`;
149
- humanText = `Every ${days} days`;
150
- } else if (hours > 23) {
151
- throw new Error(`Invalid interval: minute interval "${num}m" (${hours} hours) cannot be expressed as a cron step`);
152
154
  } else {
153
155
  pattern = `0 */${hours} * * *`;
154
156
  humanText = `Every ${hours} hours`;
package/lib/settings.js CHANGED
@@ -44,6 +44,8 @@ export const Config = z.object({
44
44
  giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues').volatile(),
45
45
  giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues').volatile(),
46
46
  giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token').volatile(),
47
+ giteaLabels: z.any().default([]).description('Gitea label names or IDs for alert issues').volatile(),
48
+ giteaLabelIds: z.any().default({}).description('Mapping of label name to integer ID for Gitea issues').volatile(),
47
49
  prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)').volatile(),
48
50
  // --- Declarative jobs (#50, ADR-0001) ---
49
51
  jobs: z.array(z.any()).default([]).description('Static jobs owned by the config; each entry needs id, title, schedule and (for agent types) prompt'),
@@ -79,7 +81,7 @@ export const SETTINGS_SYNC_KEYS = [
79
81
  'pushplusTokenRef',
80
82
  'pushplusUrl',
81
83
  'ttsBaseUrl',
82
- 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'prReviewerEnabled',
84
+ 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'giteaLabels', 'giteaLabelIds', 'prReviewerEnabled',
83
85
  'apiToken',
84
86
  ];
85
87
 
package/lib/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { logger } from './logger.js';
2
2
  import { bestEffort } from './best-effort.js';
3
3
  import fs from 'node:fs';
4
+ import { pipeline } from 'node:stream/promises';
4
5
  import path from 'node:path';
5
6
  import { getDshDefaultTelegramCredentials } from './telegram.js';
6
7
 
@@ -168,15 +169,36 @@ export class TaskStore {
168
169
  * Asynchronously serialize and write tasks store to disk using fs.promises queue (#244).
169
170
  * Safe mode 0600 on files and 0700 on dir (#222).
170
171
  */
171
- async saveAsync() {
172
- const version = ++this._saveVersion;
173
- const data = {
172
+ /**
173
+ * Create an isolated deep snapshot of tasks, history, and settings (#301).
174
+ * Prevents concurrent in-memory mutations (e.g. recordRun during streamed save)
175
+ * from corrupting serialized JSON state or causing duplicate / skipped run records.
176
+ */
177
+ _createSnapshot() {
178
+ const tasksSnapshot = Array.from(this.tasks.values()).map((t) => ({
179
+ ...t,
180
+ costLedger: Array.isArray(t.costLedger) ? t.costLedger.slice() : t.costLedger,
181
+ }));
182
+ const historySnapshot = {};
183
+ for (const [id, runs] of this.history.entries()) {
184
+ historySnapshot[id] = Array.isArray(runs) ? runs.slice() : runs;
185
+ }
186
+ return {
174
187
  version: 1,
175
188
  updatedAt: new Date().toISOString(),
176
- tasks: Array.from(this.tasks.values()),
177
- history: Object.fromEntries(this.history.entries()),
178
- settings: this.settings || {},
189
+ tasks: tasksSnapshot,
190
+ history: historySnapshot,
191
+ settings: this.settings ? { ...this.settings } : {},
179
192
  };
193
+ }
194
+
195
+ /**
196
+ * Asynchronously serialize and write tasks store to disk using fs.promises queue (#244, #301, #303).
197
+ * Safe mode 0600 on files and 0700 on dir (#222).
198
+ */
199
+ async saveAsync() {
200
+ const version = ++this._saveVersion;
201
+ const data = this._createSnapshot();
180
202
 
181
203
  const writeOp = async () => {
182
204
  if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
@@ -244,116 +266,98 @@ export class TaskStore {
244
266
  * Stream data incrementally to disk with fine-grained setImmediate yields (#244).
245
267
  * Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
246
268
  */
247
- _writeStoreStream(filePath, data, version) {
248
- return new Promise((resolve, reject) => {
249
- let aborted = false;
250
- const ws = fs.createWriteStream(filePath, { encoding: 'utf-8', mode: 0o600 });
251
-
252
- const cleanupListeners = () => {
253
- ws.removeAllListeners('error');
254
- ws.removeAllListeners('drain');
255
- };
256
-
257
- ws.on('error', (err) => {
258
- aborted = true;
259
- cleanupListeners();
260
- reject(err);
261
- });
269
+ /**
270
+ * Stream data incrementally to disk with fine-grained setImmediate yields (#244, #303).
271
+ * Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
272
+ * Uses canonical Node stream/promises pipeline for error-handling and backpressure hygiene (#303).
273
+ */
274
+ async _writeStoreStream(filePath, data, version) {
275
+ const isCanceled = () => {
276
+ return (
277
+ (this._diskVersion || 0) >= version ||
278
+ (this._lastSyncVersion || 0) >= version ||
279
+ this._saveVersion > version
280
+ );
281
+ };
282
+
283
+ if (isCanceled()) {
284
+ return;
285
+ }
286
+
287
+ const ac = new AbortController();
288
+ let ws;
289
+ try {
290
+ ws = fs.createWriteStream(filePath, { encoding: 'utf-8', mode: 0o600 });
291
+ } catch (createErr) {
292
+ if (isCanceled()) return;
293
+ throw createErr;
294
+ }
295
+
296
+ async function* generateChunks() {
297
+ await new Promise((r) => setImmediate(r));
298
+ if (isCanceled()) {
299
+ ac.abort();
300
+ return;
301
+ }
302
+
303
+ yield '{"version":' + JSON.stringify(data.version || 1);
304
+ yield ',"updatedAt":' + JSON.stringify(data.updatedAt || new Date().toISOString());
305
+ yield ',"settings":' + JSON.stringify(data.settings || {});
262
306
 
263
- const isCanceled = () => {
264
- if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
265
- aborted = true;
266
- cleanupListeners();
267
- ws.destroy();
268
- return true;
307
+ yield ',"tasks":[';
308
+ const tasks = data.tasks || [];
309
+ for (let i = 0; i < tasks.length; i++) {
310
+ if (isCanceled()) {
311
+ ac.abort();
312
+ return;
269
313
  }
270
- return false;
271
- };
272
-
273
- const writeChunk = (chunk) => {
274
- if (aborted) return Promise.reject(new Error('Store write aborted'));
275
- if (!ws.write(chunk)) {
276
- return new Promise((res, rej) => {
277
- const onDrain = () => {
278
- ws.removeListener('drain', onDrain);
279
- ws.removeListener('error', onError);
280
- res();
281
- };
282
- const onError = (e) => {
283
- ws.removeListener('drain', onDrain);
284
- ws.removeListener('error', onError);
285
- rej(e);
286
- };
287
- ws.once('drain', onDrain);
288
- ws.once('error', onError);
289
- });
314
+ if (i > 0) yield ',';
315
+ yield JSON.stringify(tasks[i]);
316
+ if ((i + 1) % 10 === 0) {
317
+ await new Promise((r) => setImmediate(r));
290
318
  }
291
- return Promise.resolve();
292
- };
319
+ }
320
+ yield ']';
293
321
 
294
- const run = async () => {
295
- try {
296
- await new Promise((r) => setImmediate(r));
297
- if (isCanceled()) return resolve();
298
-
299
- await writeChunk('{"version":' + JSON.stringify(data.version || 1));
300
- await writeChunk(',"updatedAt":' + JSON.stringify(data.updatedAt || new Date().toISOString()));
301
- await writeChunk(',"settings":' + JSON.stringify(data.settings || {}));
302
-
303
- await writeChunk(',"tasks":[');
304
- const tasks = data.tasks || [];
305
- for (let i = 0; i < tasks.length; i++) {
306
- if (isCanceled()) return resolve();
307
- if (i > 0) await writeChunk(',');
308
- await writeChunk(JSON.stringify(tasks[i]));
309
- if ((i + 1) % 10 === 0) {
310
- await new Promise((r) => setImmediate(r));
322
+ yield ',"history":{';
323
+ const historyEntries = Object.entries(data.history || {});
324
+ for (let i = 0; i < historyEntries.length; i++) {
325
+ if (isCanceled()) {
326
+ ac.abort();
327
+ return;
328
+ }
329
+ if (i > 0) yield ',';
330
+ const [taskId, runs] = historyEntries[i];
331
+ yield JSON.stringify(taskId) + ':[';
332
+ if (Array.isArray(runs)) {
333
+ for (let j = 0; j < runs.length; j++) {
334
+ if (isCanceled()) {
335
+ ac.abort();
336
+ return;
311
337
  }
312
- }
313
- await writeChunk(']');
314
-
315
- await writeChunk(',"history":{');
316
- const historyEntries = Object.entries(data.history || {});
317
- for (let i = 0; i < historyEntries.length; i++) {
318
- if (isCanceled()) return resolve();
319
- if (i > 0) await writeChunk(',');
320
- const [taskId, runs] = historyEntries[i];
321
- await writeChunk(JSON.stringify(taskId) + ':[');
322
- if (Array.isArray(runs)) {
323
- for (let j = 0; j < runs.length; j++) {
324
- if (isCanceled()) return resolve();
325
- if (j > 0) await writeChunk(',');
326
- await writeChunk(JSON.stringify(runs[j]));
327
- if ((j + 1) % 15 === 0) {
328
- await new Promise((r) => setImmediate(r));
329
- }
330
- }
331
- } else {
332
- await writeChunk(JSON.stringify(runs));
338
+ if (j > 0) yield ',';
339
+ yield JSON.stringify(runs[j]);
340
+ if ((j + 1) % 15 === 0) {
341
+ await new Promise((r) => setImmediate(r));
333
342
  }
334
- await writeChunk(']');
335
- await new Promise((r) => setImmediate(r));
336
- }
337
- await writeChunk('}}');
338
-
339
- if (isCanceled()) return resolve();
340
-
341
- ws.end(() => {
342
- cleanupListeners();
343
- resolve();
344
- });
345
- } catch (err) {
346
- if (!aborted) {
347
- aborted = true;
348
- cleanupListeners();
349
- ws.destroy();
350
- reject(err);
351
343
  }
344
+ } else {
345
+ yield JSON.stringify(runs);
352
346
  }
353
- };
347
+ yield ']';
348
+ await new Promise((r) => setImmediate(r));
349
+ }
350
+ yield '}}';
351
+ }
354
352
 
355
- run();
356
- });
353
+ try {
354
+ await pipeline(generateChunks(), ws, { signal: ac.signal });
355
+ } catch (err) {
356
+ if (ac.signal.aborted || isCanceled()) {
357
+ return;
358
+ }
359
+ throw err;
360
+ }
357
361
  }
358
362
 
359
363
  save() {
@@ -376,13 +380,7 @@ export class TaskStore {
376
380
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
377
381
  }
378
382
  bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
379
- const data = {
380
- version: 1,
381
- updatedAt: new Date().toISOString(),
382
- tasks: Array.from(this.tasks.values()),
383
- history: Object.fromEntries(this.history.entries()),
384
- settings: this.settings || {},
385
- };
383
+ const data = this._createSnapshot();
386
384
  // Unique tmp file per save call with process PID and random token to eliminate file collision
387
385
  const rand = Math.random().toString(36).slice(2, 8);
388
386
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
@@ -670,42 +668,46 @@ export class TaskStore {
670
668
  task.updatedAt = Date.now();
671
669
 
672
670
  // Rolling 24h cost ledger preserved across archive rotation and restarts (#221)
673
- if (!Array.isArray(task.costLedger)) {
674
- task.costLedger = [];
675
- }
676
- task.costLedger.push({
677
- at: task.lastRunAt,
678
- costUsd,
679
- tokens: runTokens,
680
- });
671
+ const existingLedger = Array.isArray(task.costLedger) ? task.costLedger : [];
681
672
  const cutoff24h = Date.now() - 86400000;
682
- task.costLedger = task.costLedger.filter(e => e && typeof e.at === 'number' && e.at >= cutoff24h);
683
-
684
- const runs = this.history.get(id) || [];
685
- runs.unshift({
686
- id: 'run_' + Math.random().toString(36).slice(2, 9),
687
- at: task.lastRunAt,
688
- status: task.lastStatus,
689
- durationMs: task.lastDurationMs,
690
- output: (runInfo.output || '').slice(0, 4000),
691
- error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
692
- usage,
693
- costUsd,
694
- sessionId: runInfo.sessionId || null,
695
- // #45: which model produced the run, and whether it only finished thanks
696
- // to the configured fallback.
697
- model: runInfo.model || '',
698
- fallback: Boolean(runInfo.fallback),
699
- // #44: this run was suppressed by its silent rule, with the reason.
700
- silentSkip: Boolean(runInfo.silentSkip),
701
- silentReason: runInfo.silentSkip ? String(runInfo.silentReason || '').slice(0, 500) : '',
702
- // #43: the failure diagnosis and the prompt change it proposes.
703
- diagnosis: runInfo.diagnosis ? String(runInfo.diagnosis).slice(0, 1000) : '',
704
- suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
705
- selfHealing: runInfo.selfHealing || null,
706
- autoDiagnosis: runInfo.autoDiagnosis ? String(runInfo.autoDiagnosis).slice(0, 1000) : '',
707
- confidence: runInfo.confidence || '',
708
- });
673
+ task.costLedger = [
674
+ ...existingLedger.filter(e => e && typeof e.at === 'number' && e.at >= cutoff24h),
675
+ {
676
+ at: task.lastRunAt,
677
+ costUsd,
678
+ tokens: runTokens,
679
+ },
680
+ ];
681
+
682
+ // Immutable copy-on-write history update (#301)
683
+ const prevRuns = this.history.get(id) || [];
684
+ const runs = [
685
+ {
686
+ id: 'run_' + Math.random().toString(36).slice(2, 9),
687
+ at: task.lastRunAt,
688
+ status: task.lastStatus,
689
+ durationMs: task.lastDurationMs,
690
+ output: (runInfo.output || '').slice(0, 4000),
691
+ error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
692
+ usage,
693
+ costUsd,
694
+ sessionId: runInfo.sessionId || null,
695
+ // #45: which model produced the run, and whether it only finished thanks
696
+ // to the configured fallback.
697
+ model: runInfo.model || '',
698
+ fallback: Boolean(runInfo.fallback),
699
+ // #44: this run was suppressed by its silent rule, with the reason.
700
+ silentSkip: Boolean(runInfo.silentSkip),
701
+ silentReason: runInfo.silentSkip ? String(runInfo.silentReason || '').slice(0, 500) : '',
702
+ // #43: the failure diagnosis and the prompt change it proposes.
703
+ diagnosis: runInfo.diagnosis ? String(runInfo.diagnosis).slice(0, 1000) : '',
704
+ suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
705
+ selfHealing: runInfo.selfHealing || null,
706
+ autoDiagnosis: runInfo.autoDiagnosis ? String(runInfo.autoDiagnosis).slice(0, 1000) : '',
707
+ confidence: runInfo.confidence || '',
708
+ },
709
+ ...prevRuns,
710
+ ];
709
711
  // Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
710
712
  if (runs.length > 100) {
711
713
  const overflow = runs.splice(100);
@@ -2,6 +2,7 @@ import { normalizeTaskType } from './runtimes.js';
2
2
  import { validateTaskType } from './task-transfer.js';
3
3
  import { parseScheduleExpression } from './scheduler.js';
4
4
  import { CHANNEL_IDS } from './channels.js';
5
+ import { redactTaskSecrets } from './http-utils.js';
5
6
 
6
7
  export function executeCreateTask(store, scheduler, args) {
7
8
  const taskType = normalizeTaskType(args.type);
@@ -76,7 +77,7 @@ export function executeCreateTask(store, scheduler, args) {
76
77
  return {
77
78
  success: true,
78
79
  message: `Task "${task.title}" scheduled successfully (${task.scheduleText})`,
79
- task
80
+ task: redactTaskSecrets(task),
80
81
  };
81
82
  }
82
83
 
package/lib/task-patch.js CHANGED
@@ -22,23 +22,27 @@ export function isCodeTypeSwitch(current, nextType) {
22
22
  return CODE_EXECUTING_TYPES.includes(target) && !CODE_EXECUTING_TYPES.includes(current.type);
23
23
  }
24
24
 
25
- /** True when a patch introduces code-executing type or shell hooks (#212). */
25
+ /** True when a patch introduces code-executing type or shell hooks (#212, #296). */
26
26
  export function isCodeExecutionIntroduced(current, body) {
27
27
  if (!current || !body) return false;
28
28
  if (body.type !== undefined && isCodeTypeSwitch(current, body.type)) return true;
29
29
 
30
- const hadPreflight = Boolean(current.preflightCommand && String(current.preflightCommand).trim()) || current.preflightType === 'command' || current.preflightType === 'shell';
31
- const hasPreflight = Boolean(body.preflightCommand && String(body.preflightCommand).trim()) || body.preflightType === 'command' || body.preflightType === 'shell';
30
+ const isShellPreflight = (t) => t === 'command' || t === 'shell';
31
+ const currentPreflightType = current.preflightType || 'none';
32
+ const nextPreflightType = body.preflightType !== undefined ? body.preflightType : currentPreflightType;
33
+
34
+ const currentPreflightTarget = current.preflightTarget || current.preflightCommand || '';
35
+ const bodyPreflightTarget = body.preflightTarget !== undefined ? body.preflightTarget : (body.preflightCommand !== undefined ? body.preflightCommand : '');
36
+ const nextPreflightTarget = (body.preflightTarget !== undefined || body.preflightCommand !== undefined) ? bodyPreflightTarget : currentPreflightTarget;
37
+
38
+ const hadPreflight = isShellPreflight(currentPreflightType) && Boolean(String(currentPreflightTarget).trim());
39
+ const hasPreflight = isShellPreflight(nextPreflightType) && Boolean(String(nextPreflightTarget).trim());
32
40
  if (hasPreflight && !hadPreflight) return true;
33
41
 
34
42
  const hadSelfHealing = Boolean(current.selfHealingCommand && String(current.selfHealingCommand).trim());
35
43
  const hasSelfHealing = Boolean(body.selfHealingCommand && String(body.selfHealingCommand).trim());
36
44
  if (hasSelfHealing && !hadSelfHealing) return true;
37
45
 
38
- const hadCommand = Boolean(current.command && String(current.command).trim());
39
- const hasCommand = Boolean(body.command && String(body.command).trim());
40
- if (hasCommand && !hadCommand && !CODE_EXECUTING_TYPES.includes(current.type)) return true;
41
-
42
46
  return false;
43
47
  }
44
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.46",
3
+ "version": "0.2.48",
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",