@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 +1 -1
- package/lib/channels.js +15 -2
- package/lib/config-jobs.js +2 -1
- package/lib/cron-tool.js +3 -2
- package/lib/http-utils.js +6 -4
- package/lib/index.js +11 -14
- package/lib/routes.js +1 -1
- package/lib/scheduler-execution.js +15 -14
- package/lib/scheduler.js +10 -8
- package/lib/settings.js +3 -1
- package/lib/store.js +151 -149
- package/lib/task-create.js +2 -1
- package/lib/task-patch.js +11 -7
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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)) {
|
package/lib/config-jobs.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
104
|
-
if (
|
|
105
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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:
|
|
177
|
-
history:
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
return
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
292
|
-
|
|
319
|
+
}
|
|
320
|
+
yield ']';
|
|
293
321
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
for (let
|
|
306
|
-
if (isCanceled())
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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);
|
package/lib/task-create.js
CHANGED
|
@@ -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
|
|
31
|
-
const
|
|
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.
|
|
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",
|