@goodandready/dsh-cron 0.3.11 → 0.3.13
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 +14 -1
- package/README.ru.md +14 -1
- package/README.zh.md +23 -0
- package/lib/api-helpers.js +9 -9
- package/lib/api-task-webhook.js +4 -4
- package/lib/api.js +133 -19
- package/lib/branching.js +341 -0
- package/lib/client.js +870 -758
- package/lib/dag.js +88 -18
- package/lib/doctor.js +431 -0
- package/lib/http-utils.js +116 -35
- package/lib/index.js +2 -2
- package/lib/routes.js +19 -19
- package/lib/scheduler-execution.js +59 -6
- package/lib/task-create.js +9 -0
- package/lib/task-patch.js +18 -1
- package/lib/task-transfer.js +7 -0
- package/package.json +1 -1
package/lib/http-utils.js
CHANGED
|
@@ -77,65 +77,142 @@ export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
*
|
|
81
|
-
|
|
80
|
+
* Safe helper to get the DSH connection service from context or request (#357).
|
|
81
|
+
*/
|
|
82
|
+
export function getConnection(ctx) {
|
|
83
|
+
if (!ctx) return null;
|
|
84
|
+
return Reflect.get(ctx, 'connection') || ctx.connection || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getDshConnection(req, options = {}) {
|
|
88
|
+
if (options.connection) return options.connection;
|
|
89
|
+
if (options.ctx) {
|
|
90
|
+
const conn = getConnection(options.ctx);
|
|
91
|
+
if (conn) return conn;
|
|
92
|
+
}
|
|
93
|
+
if (req) {
|
|
94
|
+
if (req.connectionService) return req.connectionService;
|
|
95
|
+
if (req.ctx) {
|
|
96
|
+
const conn = getConnection(req.ctx);
|
|
97
|
+
if (conn) return conn;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Determine if an incoming request should be rejected, and with which HTTP status (401 or 403).
|
|
105
|
+
* Returns undefined if the request is admitted, or 401 / 403 if rejected (#357).
|
|
82
106
|
*
|
|
83
107
|
* Rules:
|
|
84
|
-
* 1.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
108
|
+
* 1. If DSH connection service is available, consult its admission first.
|
|
109
|
+
* - Connection service rejection 403 (untrusted Host / Origin / CSRF) is strictly rejected.
|
|
110
|
+
* - Connection service admission (undefined rejection) admits the request (browser session / Peer).
|
|
111
|
+
* 2. Token authentication: valid Bearer token matching options.apiToken, DSH_AUTH_TOKEN or DSH_TOKEN.
|
|
112
|
+
* 3. Local loopback caller: 127.0.0.1 or ::1 when connection service is not bound (standalone/CLI/test).
|
|
113
|
+
* 4. FAIL-CLOSED: if connection service is bound, request must have DSH admission or valid API token.
|
|
114
|
+
* If connection service is not bound, request must have valid API token or verified loopback IP.
|
|
115
|
+
* Requests with missing socket / missing remoteAddress and no token are rejected (401).
|
|
116
|
+
* 5. Origin and Sec-Fetch-Site: cannot be null, Origin host must match Host, Sec-Fetch-Site must be same-origin or none.
|
|
90
117
|
*/
|
|
91
|
-
export function
|
|
92
|
-
if (!req) return
|
|
118
|
+
export function getRequestRejection(req, options = {}) {
|
|
119
|
+
if (!req) return 403;
|
|
120
|
+
|
|
121
|
+
// 1. Validate Origin header if present: cannot be null, must match Host (CSRF guard)
|
|
93
122
|
const headers = req.headers || {};
|
|
123
|
+
const origin = headers.origin;
|
|
124
|
+
const host = headers.host || '';
|
|
125
|
+
if (origin !== undefined) {
|
|
126
|
+
if (origin === 'null' || !origin) return 403;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = new URL(origin);
|
|
129
|
+
if (!parsed.host || !host || parsed.host !== host) return 403;
|
|
130
|
+
} catch {
|
|
131
|
+
return 403;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
94
134
|
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
135
|
+
// 2. Validate Sec-Fetch-Site header if present
|
|
136
|
+
const site = headers['sec-fetch-site'];
|
|
137
|
+
if (site !== undefined) {
|
|
138
|
+
if (site !== 'same-origin' && site !== 'none') {
|
|
139
|
+
return 403;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
98
142
|
|
|
143
|
+
// 3. Check DSH connection service if available (#357)
|
|
144
|
+
const conn = getDshConnection(req, options);
|
|
145
|
+
let connRejection;
|
|
146
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
147
|
+
connRejection = conn.requestRejection(req);
|
|
148
|
+
if (connRejection === 403) {
|
|
149
|
+
return 403;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 4. Check token authentication
|
|
99
154
|
const token = extractBearerToken(req);
|
|
100
155
|
const expectedToken = options.apiToken || process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
|
|
101
156
|
const isTokenAuthenticated = Boolean(expectedToken && token && token === expectedToken);
|
|
102
157
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
158
|
+
// If DSH connection service admitted the request (undefined rejection)
|
|
159
|
+
const isConnectionAdmitted = Boolean(conn && connRejection === undefined);
|
|
160
|
+
|
|
161
|
+
// Check loopback address
|
|
162
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
163
|
+
const hasRemote = Boolean(remote && typeof remote === 'string');
|
|
164
|
+
const isLoopback = Boolean(hasRemote && isLoopbackAddress(remote));
|
|
165
|
+
|
|
166
|
+
// FAIL-CLOSED AUTHENTICATION CHECK (#357):
|
|
167
|
+
if (conn) {
|
|
168
|
+
if (!isConnectionAdmitted && !isTokenAuthenticated) {
|
|
169
|
+
return connRejection === 403 ? 403 : 401;
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
if (!isTokenAuthenticated && !isLoopback) {
|
|
173
|
+
if (token || !hasRemote) {
|
|
174
|
+
return 401;
|
|
175
|
+
}
|
|
176
|
+
return 403;
|
|
107
177
|
}
|
|
108
178
|
}
|
|
109
179
|
|
|
110
|
-
|
|
180
|
+
return undefined; // Admitted
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Validate that an incoming request originates from a trusted local caller,
|
|
185
|
+
* an admitted DSH browser session, or an authenticated client (Issue #86, #357).
|
|
186
|
+
*/
|
|
187
|
+
export function isTrustedRequest(req, options = {}) {
|
|
188
|
+
return getRequestRejection(req, options) === undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Backward-compatible helper for checking cross-origin browser headers (Origin / Sec-Fetch-Site).
|
|
193
|
+
*/
|
|
194
|
+
export function isCrossOrigin(req, options) {
|
|
195
|
+
if (!req) return true;
|
|
196
|
+
const headers = req.headers || {};
|
|
111
197
|
const origin = headers.origin;
|
|
112
198
|
const host = headers.host || '';
|
|
113
199
|
if (origin !== undefined) {
|
|
114
|
-
if (origin === 'null' || !origin) return
|
|
200
|
+
if (origin === 'null' || !origin) return true;
|
|
115
201
|
try {
|
|
116
202
|
const parsed = new URL(origin);
|
|
117
|
-
if (!parsed.host || !host || parsed.host !== host) return
|
|
203
|
+
if (!parsed.host || !host || parsed.host !== host) return true;
|
|
118
204
|
} catch {
|
|
119
|
-
return
|
|
205
|
+
return true;
|
|
120
206
|
}
|
|
121
207
|
}
|
|
122
208
|
|
|
123
|
-
// 3. Validate Sec-Fetch-Site header if present
|
|
124
209
|
const site = headers['sec-fetch-site'];
|
|
125
210
|
if (site !== undefined) {
|
|
126
211
|
if (site !== 'same-origin' && site !== 'none') {
|
|
127
|
-
return
|
|
212
|
+
return true;
|
|
128
213
|
}
|
|
129
214
|
}
|
|
130
|
-
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Backward-compatible wrapper for checking cross-origin / untrusted requests.
|
|
136
|
-
*/
|
|
137
|
-
export function isCrossOrigin(req, options) {
|
|
138
|
-
return !isTrustedRequest(req, options);
|
|
215
|
+
return false;
|
|
139
216
|
}
|
|
140
217
|
|
|
141
218
|
/**
|
|
@@ -158,9 +235,13 @@ export function sendJson(res, statusCode, data) {
|
|
|
158
235
|
res.end(JSON.stringify(data));
|
|
159
236
|
}
|
|
160
237
|
|
|
161
|
-
export function rejectCrossOrigin(req, res, options) {
|
|
162
|
-
|
|
163
|
-
|
|
238
|
+
export function rejectCrossOrigin(req, res, options = {}) {
|
|
239
|
+
const rejection = getRequestRejection(req, options);
|
|
240
|
+
if (rejection !== undefined) {
|
|
241
|
+
const errorMsg = rejection === 401
|
|
242
|
+
? 'Unauthorized: authentication required'
|
|
243
|
+
: 'Forbidden: untrusted or cross-origin request';
|
|
244
|
+
sendJson(res, rejection, { ok: false, error: errorMsg });
|
|
164
245
|
return true;
|
|
165
246
|
}
|
|
166
247
|
return false;
|
package/lib/index.js
CHANGED
|
@@ -307,7 +307,7 @@ export function apply(ctx, rawConfig) {
|
|
|
307
307
|
try {
|
|
308
308
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
309
309
|
const apiToken = settings.apiToken;
|
|
310
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
310
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
311
311
|
|
|
312
312
|
if (req.method === 'GET') {
|
|
313
313
|
const unwrapped = {
|
|
@@ -324,7 +324,7 @@ export function apply(ctx, rawConfig) {
|
|
|
324
324
|
return;
|
|
325
325
|
}
|
|
326
326
|
if (req.method === 'POST') {
|
|
327
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
327
|
+
if (rejectCrossOrigin(req, res, { ctx })) return;
|
|
328
328
|
const { body, error } = await readBody(req, res);
|
|
329
329
|
if (error) return;
|
|
330
330
|
const payload = sanitizeSettingsPayload(body);
|
package/lib/routes.js
CHANGED
|
@@ -20,20 +20,20 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
20
20
|
ctx.effect(() => ctx.webServer.register({
|
|
21
21
|
kind: 'prefix',
|
|
22
22
|
path: '/dsh-cron/tasks',
|
|
23
|
-
handler: createCronApiHandler(store, scheduler, { recommendations })
|
|
23
|
+
handler: createCronApiHandler(store, scheduler, { recommendations, ctx })
|
|
24
24
|
}), 'dsh-cron: /tasks[...]');
|
|
25
25
|
|
|
26
26
|
// Alias route /dsh-cron/action/:id/:action for backward compatibility (#66)
|
|
27
27
|
ctx.effect(() => ctx.webServer.register({
|
|
28
28
|
kind: 'prefix',
|
|
29
29
|
path: '/dsh-cron/action',
|
|
30
|
-
handler: createCronApiHandler(store, scheduler,
|
|
30
|
+
handler: createCronApiHandler(store, scheduler, { ctx })
|
|
31
31
|
}), 'dsh-cron: /action/:id/:action');
|
|
32
32
|
|
|
33
33
|
ctx.effect(() => ctx.webServer.register({
|
|
34
34
|
kind: 'prefix',
|
|
35
35
|
path: '/dsh-cron/telegram',
|
|
36
|
-
handler: createCronApiHandler(store, scheduler,
|
|
36
|
+
handler: createCronApiHandler(store, scheduler, { ctx })
|
|
37
37
|
}), 'dsh-cron: /telegram[...]');
|
|
38
38
|
|
|
39
39
|
// Incoming HTTP Webhook triggers (#348)
|
|
@@ -46,13 +46,13 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
46
46
|
ctx.effect(() => ctx.webServer.register({
|
|
47
47
|
kind: 'prefix',
|
|
48
48
|
path: '/dsh-cron/heartbeat-ping',
|
|
49
|
-
handler: createCronApiHandler(store, scheduler,
|
|
49
|
+
handler: createCronApiHandler(store, scheduler, { ctx })
|
|
50
50
|
}), 'dsh-cron: /heartbeat-ping[...]');
|
|
51
51
|
|
|
52
52
|
ctx.effect(() => ctx.webServer.register({
|
|
53
53
|
kind: 'prefix',
|
|
54
54
|
path: '/dsh-cron/schedule',
|
|
55
|
-
handler: createCronApiHandler(store, scheduler,
|
|
55
|
+
handler: createCronApiHandler(store, scheduler, { ctx })
|
|
56
56
|
}), 'dsh-cron: /schedule[...]');
|
|
57
57
|
|
|
58
58
|
|
|
@@ -60,14 +60,14 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
60
60
|
ctx.effect(() => ctx.webServer.register({
|
|
61
61
|
kind: 'prefix',
|
|
62
62
|
path: '/dsh-cron/chains',
|
|
63
|
-
handler: (req, res) => handleChainRoutes(store, scheduler, req, res)
|
|
63
|
+
handler: (req, res) => handleChainRoutes(store, scheduler, req, res, ctx)
|
|
64
64
|
}), 'dsh-cron: /chains[...]');
|
|
65
65
|
|
|
66
66
|
// Run artifacts listing, download and safe preview (#321)
|
|
67
67
|
ctx.effect(() => ctx.webServer.register({
|
|
68
68
|
kind: 'prefix',
|
|
69
69
|
path: '/dsh-cron/runs',
|
|
70
|
-
handler: (req, res) => handleRunArtifactRoutes(store, req, res)
|
|
70
|
+
handler: (req, res) => handleRunArtifactRoutes(store, req, res, ctx)
|
|
71
71
|
}), 'dsh-cron: /runs[...]');
|
|
72
72
|
|
|
73
73
|
// GET /dsh-cron/metrics (#53) — Prometheus text exposition
|
|
@@ -98,7 +98,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
98
98
|
handler: async (req, res) => {
|
|
99
99
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
100
100
|
const apiToken = settings.apiToken;
|
|
101
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
101
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
102
102
|
await getModelsHandler(ctx, req, res, sendJson);
|
|
103
103
|
}
|
|
104
104
|
}), 'dsh-cron: /models');
|
|
@@ -108,7 +108,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
108
108
|
kind: 'exact',
|
|
109
109
|
path: '/dsh-cron/chat/start',
|
|
110
110
|
handler: async (req, res) => {
|
|
111
|
-
if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
|
|
111
|
+
if (req.method === 'POST' && rejectCrossOrigin(req, res, { ctx })) return;
|
|
112
112
|
await chatStartHandler(ctx, req, res, parseJsonBody, sendJson, randomUUID);
|
|
113
113
|
}
|
|
114
114
|
}), 'dsh-cron: /chat/start');
|
|
@@ -121,7 +121,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
121
121
|
try {
|
|
122
122
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
123
123
|
const apiToken = settings.apiToken;
|
|
124
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
124
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
125
125
|
|
|
126
126
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
127
127
|
const path = url.pathname;
|
|
@@ -169,7 +169,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
if (req.method === 'POST') {
|
|
172
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
172
|
+
if (rejectCrossOrigin(req, res, { ctx })) return;
|
|
173
173
|
const { body } = await readBody(req, res);
|
|
174
174
|
const days = parseInt(body?.days || url.searchParams.get('days') || '7', 10);
|
|
175
175
|
const tasks = store.list ? store.list() : Object.values(store.tasks || {});
|
|
@@ -227,7 +227,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
227
227
|
}
|
|
228
228
|
const settings = store.getSettings ? store.getSettings() : {};
|
|
229
229
|
const apiToken = settings.apiToken;
|
|
230
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
230
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
231
231
|
const categories = recipesByCategory();
|
|
232
232
|
const recipes = listVerifiedRecipes(settings);
|
|
233
233
|
sendJson(res, 200, { ok: true, categories, recipes });
|
|
@@ -249,7 +249,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
249
249
|
}
|
|
250
250
|
const settings = store.getSettings ? store.getSettings() : {};
|
|
251
251
|
const apiToken = settings.apiToken;
|
|
252
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
252
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
253
253
|
|
|
254
254
|
const tasks = store.list({ status: 'active' });
|
|
255
255
|
const lastRuns = tasks.map((t) => t.lastRunAt || 0);
|
|
@@ -276,7 +276,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
276
276
|
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
277
277
|
return;
|
|
278
278
|
}
|
|
279
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
279
|
+
if (rejectCrossOrigin(req, res, { ctx })) return;
|
|
280
280
|
const { body, error } = await readBody(req, res);
|
|
281
281
|
if (error) return;
|
|
282
282
|
const settings = store.getSettings();
|
|
@@ -308,7 +308,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
308
308
|
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
309
309
|
return;
|
|
310
310
|
}
|
|
311
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
311
|
+
if (rejectCrossOrigin(req, res, { ctx })) return;
|
|
312
312
|
const { body, error } = await readBody(req, res);
|
|
313
313
|
if (error) return;
|
|
314
314
|
const settings = store.getSettings();
|
|
@@ -339,10 +339,10 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
339
339
|
/**
|
|
340
340
|
* Handler for chain step retries (#319)
|
|
341
341
|
*/
|
|
342
|
-
export async function handleChainRoutes(store, scheduler, req, res) {
|
|
342
|
+
export async function handleChainRoutes(store, scheduler, req, res, ctx) {
|
|
343
343
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
344
344
|
const apiToken = settings.apiToken;
|
|
345
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
345
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
346
346
|
const url = new URL(req.url, 'http://localhost');
|
|
347
347
|
|
|
348
348
|
// GET /dsh-cron/chains/:chainRunId (#334)
|
|
@@ -386,7 +386,7 @@ export async function handleChainRoutes(store, scheduler, req, res) {
|
|
|
386
386
|
/**
|
|
387
387
|
* Handler for run artifacts (#321)
|
|
388
388
|
*/
|
|
389
|
-
export async function handleRunArtifactRoutes(store, req, res) {
|
|
389
|
+
export async function handleRunArtifactRoutes(store, req, res, ctx) {
|
|
390
390
|
const url = new URL(req.url, 'http://localhost');
|
|
391
391
|
const m = url.pathname.match(/^\/dsh-cron\/runs\/([^\/]+)\/artifacts(?:\/([^\/]+)(?:\/(preview))?)?$/);
|
|
392
392
|
if (!m) {
|
|
@@ -399,7 +399,7 @@ export async function handleRunArtifactRoutes(store, req, res) {
|
|
|
399
399
|
}
|
|
400
400
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
401
401
|
const apiToken = settings.apiToken;
|
|
402
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
402
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
403
403
|
|
|
404
404
|
const [, runId, artifactId, action] = m;
|
|
405
405
|
if (!artifactId) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { resolveBranchTargets } from './branching.js';
|
|
2
|
+
import { shouldAutoHeal, applyDoctorFix } from './doctor.js';
|
|
1
3
|
import { recordPromptRunOutcome } from './prompt-evolver.js';
|
|
2
4
|
import { evaluateAdaptiveRun, calculateNextAdaptiveState } from './adaptive.js';
|
|
3
5
|
import { approvalManager } from './approval.js';
|
|
@@ -441,14 +443,42 @@ export async function applySilentRuleToRun(task, runInfo, { askModel, readSettin
|
|
|
441
443
|
/**
|
|
442
444
|
* Run the model-assisted steps for a finished run.
|
|
443
445
|
*/
|
|
444
|
-
export async function applyModelAssists(task, runInfo, options) {
|
|
446
|
+
export async function applyModelAssists(task, runInfo, options = {}) {
|
|
445
447
|
const inspection = await inspectFailureRun(task, runInfo, options);
|
|
446
448
|
if (inspection.inspected) {
|
|
447
449
|
runInfo.diagnosis = inspection.diagnosis;
|
|
448
450
|
runInfo.suggestion = inspection.suggestion;
|
|
449
451
|
runInfo.confidence = inspection.confidence;
|
|
452
|
+
runInfo.doctorReport = inspection.doctorReport || {
|
|
453
|
+
category: inspection.category || 'runtime_error',
|
|
454
|
+
cause: inspection.diagnosis,
|
|
455
|
+
suggestedFix: inspection.suggestion,
|
|
456
|
+
confidence: inspection.confidence,
|
|
457
|
+
diff: inspection.diff || '',
|
|
458
|
+
timestamp: Date.now(),
|
|
459
|
+
applied: false,
|
|
460
|
+
autoHealed: false,
|
|
461
|
+
};
|
|
450
462
|
logger.info(`[dsh-cron] task "${task.title}" failure diagnosed (${inspection.confidence}): ${inspection.diagnosis}`);
|
|
451
|
-
|
|
463
|
+
|
|
464
|
+
// Safe Auto-Heal Check (#353)
|
|
465
|
+
if (shouldAutoHeal(task, runInfo.doctorReport)) {
|
|
466
|
+
try {
|
|
467
|
+
const healResult = await applyDoctorFix(task, runInfo.doctorReport, {
|
|
468
|
+
store: options.store,
|
|
469
|
+
user: 'scheduler:auto-heal',
|
|
470
|
+
isAutoHeal: true,
|
|
471
|
+
});
|
|
472
|
+
if (healResult.ok) {
|
|
473
|
+
logger.info(`[dsh-cron] task "${task.title}" auto-healed successfully with doctor fix!`);
|
|
474
|
+
runInfo.autoHealed = true;
|
|
475
|
+
if (runInfo.doctorReport) runInfo.doctorReport.autoHealed = true;
|
|
476
|
+
}
|
|
477
|
+
} catch (autoHealErr) {
|
|
478
|
+
logger.warn(`[dsh-cron] failed to auto-heal task "${task.title}":`, autoHealErr.message);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
} else if (inspection.error && (task.inspectOnFailure || task.autoDiagnose || task.autoHeal)) {
|
|
452
482
|
logger.warn(`[dsh-cron] failure inspection for "${task.title}" not applied: ${inspection.error}`);
|
|
453
483
|
}
|
|
454
484
|
|
|
@@ -769,8 +799,14 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
769
799
|
runInfo.artifacts = scheduler.store.getArtifactsForRun(runInfo.runId);
|
|
770
800
|
}
|
|
771
801
|
|
|
772
|
-
// Chain Checkpoints (#319, #328)
|
|
773
|
-
const
|
|
802
|
+
// Chain Checkpoints & Decision Nodes (#319, #328, #352)
|
|
803
|
+
const branchResult = resolveBranchTargets(task, outcome, runInfo);
|
|
804
|
+
if (branchResult && branchResult.decision) {
|
|
805
|
+
runInfo.branchDecision = branchResult.decision;
|
|
806
|
+
}
|
|
807
|
+
const chainedTargets = branchResult
|
|
808
|
+
? branchResult.targets
|
|
809
|
+
: parseChainTargets(outcome.status === 'success' ? task.onSuccess : task.onFailure);
|
|
774
810
|
let hasDependents = false;
|
|
775
811
|
if (scheduler.store?.tasks) {
|
|
776
812
|
for (const t of Object.values(scheduler.store.tasks)) {
|
|
@@ -888,10 +924,27 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
888
924
|
}
|
|
889
925
|
}
|
|
890
926
|
|
|
891
|
-
// 2. Task Chaining (#137, #328 Fan-Out / Fan-In)
|
|
892
|
-
const targets =
|
|
927
|
+
// 2. Task Chaining & Decision Nodes (#137, #328, #352 Fan-Out / Fan-In)
|
|
928
|
+
const targets = branchResult
|
|
929
|
+
? branchResult.targets
|
|
930
|
+
: parseChainTargets(isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null));
|
|
893
931
|
const currentDepth = options.chainDepth || 0;
|
|
894
932
|
|
|
933
|
+
if (branchResult && branchResult.decision) {
|
|
934
|
+
logger.info(`[dsh-cron] decision node in "${task.title}": routed to ${targets.join(', ') || 'none'} (${branchResult.decision.isElse ? 'fallback else' : (branchResult.decision.matched ? `rule #${branchResult.decision.matchedIndex + 1}` : 'unmatched')})`);
|
|
935
|
+
if (chainRunId && scheduler.store.chainCheckpointManager && Array.isArray(task.branchRules)) {
|
|
936
|
+
for (const rule of task.branchRules) {
|
|
937
|
+
if (rule && rule.targetTask && !targets.includes(rule.targetTask)) {
|
|
938
|
+
scheduler.store.chainCheckpointManager.recordStepSnapshot(chainRunId, rule.targetTask, {
|
|
939
|
+
status: 'skipped',
|
|
940
|
+
skippedReason: 'condition not met',
|
|
941
|
+
durationMs: 0,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
895
948
|
if (currentDepth < 4) {
|
|
896
949
|
// 2.1 Trigger explicit targets (Fan-Out)
|
|
897
950
|
for (const targetId of targets) {
|
package/lib/task-create.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeBranchRules } from './branching.js';
|
|
1
2
|
import { sanitizePromptHistory } from './prompt-evolver.js';
|
|
2
3
|
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
3
4
|
import { sanitizeValidators } from './validators.js';
|
|
@@ -55,6 +56,14 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
55
56
|
webhookDebounceMs: args.webhookDebounceMs !== undefined ? Number(args.webhookDebounceMs) : (args.webhookTrigger?.debounceMs !== undefined ? Number(args.webhookTrigger.debounceMs) : undefined),
|
|
56
57
|
onSuccess: args.onSuccess ? String(args.onSuccess).trim() : '',
|
|
57
58
|
onFailure: args.onFailure ? String(args.onFailure).trim() : '',
|
|
59
|
+
branchRules: args.branchRules !== undefined ? sanitizeBranchRules(args.branchRules) : undefined,
|
|
60
|
+
elseTask: args.elseTask ? String(args.elseTask).trim() : undefined,
|
|
61
|
+
autoHeal: args.autoHeal !== undefined ? Boolean(args.autoHeal) : undefined,
|
|
62
|
+
selfHealing: args.selfHealing && typeof args.selfHealing === 'object' ? {
|
|
63
|
+
autoApply: Boolean(args.selfHealing.autoApply),
|
|
64
|
+
lastPatchedAt: Number(args.selfHealing.lastPatchedAt) || 0,
|
|
65
|
+
autoPatchesCount: Number(args.selfHealing.autoPatchesCount) || 0,
|
|
66
|
+
} : undefined,
|
|
58
67
|
dependsOn: args.dependsOn !== undefined ? sanitizeDependsOn(args.dependsOn) : undefined,
|
|
59
68
|
joinPolicy: args.joinPolicy !== undefined ? sanitizeJoinPolicy(args.joinPolicy) : undefined,
|
|
60
69
|
adaptive: args.adaptive !== undefined ? sanitizeAdaptiveConfig(args.adaptive) : undefined,
|
package/lib/task-patch.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeBranchRules } from './branching.js';
|
|
1
2
|
import { trackPromptEdit, sanitizePromptHistory } from './prompt-evolver.js';
|
|
2
3
|
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
3
4
|
import { sanitizeValidators } from './validators.js';
|
|
@@ -121,6 +122,22 @@ export function buildTaskPatch(current, body) {
|
|
|
121
122
|
if (patch.validators !== undefined) {
|
|
122
123
|
patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
|
|
123
124
|
}
|
|
125
|
+
if (patch.branchRules !== undefined) {
|
|
126
|
+
patch.branchRules = sanitizeBranchRules(patch.branchRules);
|
|
127
|
+
}
|
|
128
|
+
if (patch.elseTask !== undefined) {
|
|
129
|
+
patch.elseTask = patch.elseTask ? String(patch.elseTask).trim() : null;
|
|
130
|
+
}
|
|
131
|
+
if (patch.autoHeal !== undefined) {
|
|
132
|
+
patch.autoHeal = Boolean(patch.autoHeal);
|
|
133
|
+
}
|
|
134
|
+
if (patch.selfHealing !== undefined && patch.selfHealing && typeof patch.selfHealing === 'object') {
|
|
135
|
+
patch.selfHealing = {
|
|
136
|
+
autoApply: Boolean(patch.selfHealing.autoApply),
|
|
137
|
+
lastPatchedAt: Number(patch.selfHealing.lastPatchedAt) || 0,
|
|
138
|
+
autoPatchesCount: Number(patch.selfHealing.autoPatchesCount) || 0,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
124
141
|
if (patch.prompt !== undefined && current && typeof current.prompt === 'string') {
|
|
125
142
|
const edit = trackPromptEdit(current, patch.prompt, body && body.promptNote ? body.promptNote : 'Manual edit');
|
|
126
143
|
patch.prompt = edit.prompt;
|
|
@@ -181,7 +198,7 @@ export function applyTaskPatch({ store, scheduler, id, body, allowCodeSwitch = f
|
|
|
181
198
|
}
|
|
182
199
|
const built = buildTaskPatch(current, body);
|
|
183
200
|
if (!built.ok) return built;
|
|
184
|
-
if (built.patch.dependsOn !== undefined || built.patch.onSuccess !== undefined) {
|
|
201
|
+
if (built.patch.dependsOn !== undefined || built.patch.onSuccess !== undefined || built.patch.branchRules !== undefined || built.patch.elseTask !== undefined) {
|
|
185
202
|
const dagCheck = validateDAG(store?.tasks || {}, { ...current, ...built.patch, id });
|
|
186
203
|
if (!dagCheck.ok) {
|
|
187
204
|
return { ok: false, error: dagCheck.error };
|
package/lib/task-transfer.js
CHANGED
|
@@ -89,6 +89,13 @@ export const PATCHABLE_TASK_FIELDS = [
|
|
|
89
89
|
'adaptiveState',
|
|
90
90
|
'promptVersion',
|
|
91
91
|
'promptHistory',
|
|
92
|
+
'branchRules',
|
|
93
|
+
'elseTask',
|
|
94
|
+
'autoHeal',
|
|
95
|
+
'selfHealing',
|
|
96
|
+
'revisions',
|
|
97
|
+
'previousPrompt',
|
|
98
|
+
'previousScript',
|
|
92
99
|
];
|
|
93
100
|
|
|
94
101
|
/** Fields a duplicate inherits; anything else is runtime state, not config. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
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",
|