@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/lib/http-utils.js CHANGED
@@ -77,65 +77,142 @@ export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
77
77
  }
78
78
 
79
79
  /**
80
- * Validate that an incoming request originates from a trusted local caller or
81
- * an authenticated client (Issue #86).
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. Non-loopback remote callers are rejected unless authenticated with a valid token.
85
- * 2. Host header must be present and non-empty.
86
- * 3. Origin header (if present) cannot be null and its host must match Host.
87
- * 4. Sec-Fetch-Site (if present) must be same-origin or none (cross-site/same-site rejected).
88
- * 5. Mutating requests (POST, PUT, PATCH, DELETE) must provide browser proof (same-origin / matching Origin)
89
- * or an explicit capability header (confirm header, token, or update header).
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 isTrustedRequest(req, options = {}) {
92
- if (!req) return false;
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
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
96
- const hasRemote = Boolean(remote && typeof remote === 'string');
97
- const isLoopback = Boolean(hasRemote && isLoopbackAddress(remote));
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
- // 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;
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
- // 2. Validate Origin header if present: cannot be null, must match Host
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 false;
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 false;
203
+ if (!parsed.host || !host || parsed.host !== host) return true;
118
204
  } catch {
119
- return false;
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 false;
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
- if (!isTrustedRequest(req, options)) {
163
- sendJson(res, 403, { ok: false, error: 'Forbidden: untrusted or cross-origin request' });
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, null)
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, null)
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, null)
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, null)
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
- } else if (inspection.error && task.inspectOnFailure) {
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 chainedTargets = parseChainTargets(outcome.status === 'success' ? task.onSuccess : task.onFailure);
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 = parseChainTargets(isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null));
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) {
@@ -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 };
@@ -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.11",
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",