@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/dag.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Directed Acyclic Graph (DAG) Engine for Chained Tasks (#328, #334)
2
+ * Directed Acyclic Graph (DAG) Engine for Chained Tasks (#328, #334, #352)
3
3
  * Provides cycle detection, topological sorting, dependency resolution,
4
- * and graph visualization modeling for multi-step task workflows.
4
+ * and graph visualization modeling for multi-step task workflows and decision nodes.
5
5
  */
6
6
 
7
7
  /**
@@ -61,9 +61,40 @@ export function parseChainTargets(target) {
61
61
  return [];
62
62
  }
63
63
 
64
+ /**
65
+ * Extracts all outgoing targets from a task (onSuccess, branchRules, elseTask).
66
+ *
67
+ * @param {Object} task
68
+ * @returns {string[]}
69
+ */
70
+ export function getTaskOutgoingTargets(task) {
71
+ if (!task || typeof task !== 'object') return [];
72
+ const targets = new Set();
73
+
74
+ for (const t of parseChainTargets(task.onSuccess)) {
75
+ targets.add(t);
76
+ }
77
+
78
+ if (Array.isArray(task.branchRules)) {
79
+ for (const r of task.branchRules) {
80
+ if (r && r.targetTask) {
81
+ const targetId = String(r.targetTask).trim();
82
+ if (targetId) targets.add(targetId);
83
+ }
84
+ }
85
+ }
86
+
87
+ if (task.elseTask) {
88
+ const elseId = String(task.elseTask).trim();
89
+ if (elseId) targets.add(elseId);
90
+ }
91
+
92
+ return Array.from(targets);
93
+ }
94
+
64
95
  /**
65
96
  * Validates that adding or updating a task does not introduce cyclic dependencies.
66
- * Analyzes both `dependsOn` relationships and `onSuccess` chain links.
97
+ * Analyzes `dependsOn` relationships, `onSuccess` chain links, and `branchRules` decision nodes (#352).
67
98
  *
68
99
  * @param {Array<Object>|Map<string, Object>|Object} allTasks All current tasks
69
100
  * @param {Object} [pendingTask] Task being created or updated
@@ -98,16 +129,16 @@ export function validateDAG(allTasks, pendingTask = null) {
98
129
  if (deps.includes(id)) {
99
130
  return { ok: false, error: `Task "${task.title || id}" cannot depend on itself`, cycle: [id, id] };
100
131
  }
101
- const chainTargets = parseChainTargets(task.onSuccess);
102
- if (chainTargets.includes(id)) {
103
- return { ok: false, error: `Task "${task.title || id}" cannot trigger itself as next step`, cycle: [id, id] };
132
+ const outgoingTargets = getTaskOutgoingTargets(task);
133
+ if (outgoingTargets.includes(id)) {
134
+ return { ok: false, error: `Task "${task.title || id}" cannot trigger itself as next step or branch target`, cycle: [id, id] };
104
135
  }
105
136
  }
106
137
 
107
138
  // Construct adjacency list: Edge from U to V means U must complete before V runs.
108
139
  // Sources of edges:
109
140
  // 1) dependsOn: If V depends on U, then U -> V
110
- // 2) onSuccess: If U has onSuccess V, then U -> V
141
+ // 2) outgoing targets (onSuccess, branchRules, elseTask): If U triggers V, then U -> V
111
142
  const adj = new Map();
112
143
  for (const id of taskMap.keys()) {
113
144
  adj.set(id, new Set());
@@ -121,8 +152,8 @@ export function validateDAG(allTasks, pendingTask = null) {
121
152
  adj.get(depId).add(id);
122
153
  }
123
154
 
124
- // 2) onSuccess (id -> target)
125
- const targets = parseChainTargets(task.onSuccess);
155
+ // 2) outgoing targets (id -> target)
156
+ const targets = getTaskOutgoingTargets(task);
126
157
  for (const targetId of targets) {
127
158
  if (!adj.has(id)) adj.set(id, new Set());
128
159
  adj.get(id).add(targetId);
@@ -206,7 +237,7 @@ export function topologicalSort(tasks) {
206
237
  inDegree.set(id, (inDegree.get(id) || 0) + 1);
207
238
  }
208
239
  }
209
- const targets = parseChainTargets(task.onSuccess);
240
+ const targets = getTaskOutgoingTargets(task);
210
241
  for (const targetId of targets) {
211
242
  if (taskMap.has(targetId)) {
212
243
  adj.get(id).add(targetId);
@@ -235,7 +266,7 @@ export function topologicalSort(tasks) {
235
266
  }
236
267
 
237
268
  /**
238
- * Builds a visual graph model (nodes and edges) for UI rendering (#334).
269
+ * Builds a visual graph model (nodes and edges) for UI rendering (#334, #352).
239
270
  *
240
271
  * @param {Array<Object>} tasks All relevant tasks
241
272
  * @param {Array<Object>} [snapshots] Checkpoints for the chain run
@@ -265,7 +296,7 @@ export function buildTaskGraphModel(tasks = [], snapshots = [], focusTaskId = nu
265
296
  let retryCount = 0;
266
297
 
267
298
  if (snap) {
268
- nodeStatus = snap.status === 'completed' || snap.status === 'success' ? 'success' : 'failed';
299
+ nodeStatus = snap.status === 'completed' || snap.status === 'success' ? 'success' : (snap.status === 'skipped' ? 'skipped' : 'failed');
269
300
  durationMs = snap.durationMs || 0;
270
301
  retryCount = snap.retryCount || 0;
271
302
  if (snap.output) {
@@ -278,6 +309,8 @@ export function buildTaskGraphModel(tasks = [], snapshots = [], focusTaskId = nu
278
309
  nodeStatus = 'running';
279
310
  }
280
311
 
312
+ const hasBranches = Array.isArray(task.branchRules) && task.branchRules.length > 0;
313
+
281
314
  nodes.push({
282
315
  id,
283
316
  title: task.title || id,
@@ -288,8 +321,11 @@ export function buildTaskGraphModel(tasks = [], snapshots = [], focusTaskId = nu
288
321
  outputSnippet,
289
322
  isFocus: Boolean(focusTaskId && id === focusTaskId),
290
323
  isRetryable: nodeStatus === 'failed',
324
+ isDecisionNode: hasBranches,
291
325
  dependsOn: sanitizeDependsOn(task.dependsOn),
292
326
  joinPolicy: sanitizeJoinPolicy(task.joinPolicy),
327
+ branchRulesCount: hasBranches ? task.branchRules.length : 0,
328
+ elseTask: task.elseTask || null,
293
329
  });
294
330
 
295
331
  // Edges from dependsOn
@@ -303,17 +339,50 @@ export function buildTaskGraphModel(tasks = [], snapshots = [], focusTaskId = nu
303
339
  });
304
340
  }
305
341
 
306
- // Edges from onSuccess
307
- const targets = parseChainTargets(task.onSuccess);
308
- for (const targetId of targets) {
342
+ // Edges from branchRules (#352 Decision Nodes)
343
+ if (hasBranches) {
344
+ for (const rule of task.branchRules) {
345
+ if (rule && rule.targetTask) {
346
+ const targetId = String(rule.targetTask).trim();
347
+ edges.push({
348
+ from: id,
349
+ to: targetId,
350
+ type: 'branch',
351
+ label: rule.label || `[if: ${rule.field} ${rule.operator} ${rule.value ?? ''}]`.trim(),
352
+ condition: {
353
+ field: rule.field,
354
+ operator: rule.operator,
355
+ value: rule.value,
356
+ },
357
+ });
358
+ }
359
+ }
360
+ }
361
+
362
+ // Edge from elseTask
363
+ if (task.elseTask) {
309
364
  edges.push({
310
365
  from: id,
311
- to: targetId,
312
- type: 'onSuccess',
313
- label: 'onSuccess',
366
+ to: String(task.elseTask).trim(),
367
+ type: 'branch_else',
368
+ label: '[else]',
369
+ isElse: true,
314
370
  });
315
371
  }
316
372
 
373
+ // Edges from onSuccess (only if no branchRules to avoid redundant edge clutter)
374
+ if (!hasBranches) {
375
+ const targets = parseChainTargets(task.onSuccess);
376
+ for (const targetId of targets) {
377
+ edges.push({
378
+ from: id,
379
+ to: targetId,
380
+ type: 'onSuccess',
381
+ label: 'onSuccess',
382
+ });
383
+ }
384
+ }
385
+
317
386
  // Edges from onFailure
318
387
  const failTargets = parseChainTargets(task.onFailure);
319
388
  for (const failId of failTargets) {
@@ -328,3 +397,4 @@ export function buildTaskGraphModel(tasks = [], snapshots = [], focusTaskId = nu
328
397
 
329
398
  return { nodes, edges, hasFailure };
330
399
  }
400
+
package/lib/doctor.js ADDED
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Self-Healing Doctor Engine (#353)
3
+ *
4
+ * Provides autonomous failure classification, root-cause diagnosis,
5
+ * code/prompt fix proposals with unified diff previews, and safe 1-click & auto-apply healing.
6
+ */
7
+
8
+ import { parseJsonAnswer } from './llm-ask.js';
9
+
10
+ /**
11
+ * Standard failure categories for structured diagnosis.
12
+ */
13
+ export const FAILURE_CATEGORIES = {
14
+ SYNTAX_ERROR: 'syntax_error',
15
+ DEPENDENCY_MISSING: 'dependency_missing',
16
+ RATE_LIMIT: 'rate_limit',
17
+ AUTH_FAILED: 'auth_failed',
18
+ SCHEMA_CHANGE: 'schema_change',
19
+ TIMEOUT: 'timeout',
20
+ NETWORK_ERROR: 'network_error',
21
+ RUNTIME_ERROR: 'runtime_error',
22
+ VALIDATION_FAILED: 'validation_failed',
23
+ };
24
+
25
+ /**
26
+ * Classifies an error and execution traces into a standard failure category using deterministic pattern matching.
27
+ *
28
+ * @param {Object} task
29
+ * @param {Object} runInfo
30
+ * @returns {{ category: string, matchedRule: string }}
31
+ */
32
+ export function classifyFailure(task, runInfo = {}) {
33
+ const text = [
34
+ String(runInfo.error || ''),
35
+ String(runInfo.stderr || ''),
36
+ String(runInfo.output || ''),
37
+ String(runInfo.status || ''),
38
+ ].join(' ').toLowerCase();
39
+
40
+ const exitCode = runInfo.exitCode ?? null;
41
+
42
+ // 1. Timeout
43
+ if (runInfo.status === 'timeout' || text.includes('timed out') || text.includes('etimedout') || text.includes('sigterm') && exitCode === 143) {
44
+ return { category: FAILURE_CATEGORIES.TIMEOUT, matchedRule: 'timeout_detector' };
45
+ }
46
+
47
+ // 2. Auth / Permissions
48
+ if (text.includes('401 unauthorized') || text.includes('403 forbidden') || text.includes('permission denied') || text.includes('invalid api key') || text.includes('token expired')) {
49
+ return { category: FAILURE_CATEGORIES.AUTH_FAILED, matchedRule: 'auth_forbidden_detector' };
50
+ }
51
+
52
+ // 3. Rate Limit / Quota
53
+ if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests') || text.includes('quota exceeded') || text.includes('tpm') || text.includes('rpm')) {
54
+ return { category: FAILURE_CATEGORIES.RATE_LIMIT, matchedRule: 'rate_limit_detector' };
55
+ }
56
+
57
+ // 4. Missing Dependencies / Commands
58
+ if (text.includes('command not found') || text.includes('no such file or directory') || text.includes('modulenotfounderror') || text.includes('cannot find module') || text.includes('not recognized as an internal or external command')) {
59
+ return { category: FAILURE_CATEGORIES.DEPENDENCY_MISSING, matchedRule: 'missing_dependency_detector' };
60
+ }
61
+
62
+ // 5. Syntax Error
63
+ if (text.includes('syntaxerror') || text.includes('indentationerror') || text.includes('unexpected token') || text.includes('syntax error near') || text.includes('parse error')) {
64
+ return { category: FAILURE_CATEGORIES.SYNTAX_ERROR, matchedRule: 'syntax_error_detector' };
65
+ }
66
+
67
+ // 6. Schema / JSON / Parsing mismatch
68
+ if (text.includes('json.parse') || text.includes('unexpected end of json') || text.includes('cannot read property') || text.includes('cannot read properties of undefined') || text.includes('keyerror')) {
69
+ return { category: FAILURE_CATEGORIES.SCHEMA_CHANGE, matchedRule: 'schema_mismatch_detector' };
70
+ }
71
+
72
+ // 7. Network / Connection
73
+ if (text.includes('econnrefused') || text.includes('enotfound') || text.includes('network is unreachable') || text.includes('connection reset')) {
74
+ return { category: FAILURE_CATEGORIES.NETWORK_ERROR, matchedRule: 'network_failure_detector' };
75
+ }
76
+
77
+ // 8. Validation Failed
78
+ if (runInfo.status === 'validation_failed' || text.includes('validation_failed')) {
79
+ return { category: FAILURE_CATEGORIES.VALIDATION_FAILED, matchedRule: 'validation_rule_detector' };
80
+ }
81
+
82
+ return { category: FAILURE_CATEGORIES.RUNTIME_ERROR, matchedRule: 'fallback_runtime_detector' };
83
+ }
84
+
85
+ /**
86
+ * Generates a clean text unified diff between original and proposed code.
87
+ *
88
+ * @param {string} originalText
89
+ * @param {string} newText
90
+ * @param {string} [filename]
91
+ * @returns {string}
92
+ */
93
+ export function generateUnifiedDiff(originalText = '', newText = '', filename = 'task_script') {
94
+ const origLines = String(originalText).split('\n');
95
+ const newLines = String(newText).split('\n');
96
+
97
+ if (originalText === newText) {
98
+ return `--- a/${filename}\n+++ b/${filename}\n (no changes)`;
99
+ }
100
+
101
+ const diffLines = [
102
+ `--- a/${filename}`,
103
+ `+++ b/${filename}`,
104
+ `@@ -1,${origLines.length} +1,${newLines.length} @@`,
105
+ ];
106
+
107
+ // Simple line-by-line diff representation
108
+ let i = 0;
109
+ let j = 0;
110
+ while (i < origLines.length || j < newLines.length) {
111
+ if (i < origLines.length && j < newLines.length) {
112
+ if (origLines[i] === newLines[j]) {
113
+ diffLines.push(` ${origLines[i]}`);
114
+ i++;
115
+ j++;
116
+ } else {
117
+ diffLines.push(`-${origLines[i]}`);
118
+ diffLines.push(`+${newLines[j]}`);
119
+ i++;
120
+ j++;
121
+ }
122
+ } else if (i < origLines.length) {
123
+ diffLines.push(`-${origLines[i]}`);
124
+ i++;
125
+ } else if (j < newLines.length) {
126
+ diffLines.push(`+${newLines[j]}`);
127
+ j++;
128
+ }
129
+ }
130
+
131
+ return diffLines.join('\n');
132
+ }
133
+
134
+ /**
135
+ * Builds the diagnostic prompt for the model.
136
+ *
137
+ * @param {Object} task
138
+ * @param {Object} runInfo
139
+ * @param {string} category
140
+ * @returns {string}
141
+ */
142
+ export function buildDoctorPrompt(task, runInfo, category) {
143
+ const currentCode = String(task.prompt || '').slice(0, 4000);
144
+ const errorText = [
145
+ String(runInfo.error || ''),
146
+ String(runInfo.stderr || ''),
147
+ String(runInfo.output || '').slice(0, 2000),
148
+ ].filter(Boolean).join('\n---\n');
149
+
150
+ return [
151
+ `Task: ${task.title || 'Task'} (type: ${task.type || 'prompt'})`,
152
+ `Schedule: ${task.scheduleText || task.schedule || ''}`,
153
+ `Failure Category: ${category}`,
154
+ `Exit Code: ${runInfo.exitCode ?? 'N/A'}`,
155
+ '',
156
+ 'Execution Error & Traces:',
157
+ '```',
158
+ errorText || '(no error output)',
159
+ '```',
160
+ '',
161
+ 'Current Code / Prompt:',
162
+ '```',
163
+ currentCode,
164
+ '```',
165
+ '',
166
+ 'Instructions:',
167
+ '1. Explain what caused the failure in 1-2 concise sentences.',
168
+ '2. Provide the complete fixed code/prompt in `suggestedFix`. Make sure it preserves existing logic and fixes the specific failure.',
169
+ '3. Provide confidence level: "high", "medium", or "low".',
170
+ ].join('\n');
171
+ }
172
+
173
+ const DOCTOR_SYSTEM_PROMPT = [
174
+ 'You are the autonomous Self-Healing Doctor for background scheduled tasks.',
175
+ 'Analyze the failure and output ONLY a JSON object with this exact schema:',
176
+ '{',
177
+ ' "cause": "short 1-2 sentence description of root cause",',
178
+ ' "category": "syntax_error"|"dependency_missing"|"rate_limit"|"auth_failed"|"schema_change"|"timeout"|"network_error"|"runtime_error"|"validation_failed",',
179
+ ' "suggestedFix": "the exact new code or prompt that completely replaces the current code and resolves the issue",',
180
+ ' "confidence": "high"|"medium"|"low",',
181
+ ' "explanation": "brief explanation of what was fixed"',
182
+ '}',
183
+ 'Do not include markdown fences outside the JSON. Return only the JSON.',
184
+ ].join(' ');
185
+
186
+ /**
187
+ * Diagnoses a failed run and produces a doctor report.
188
+ * Uses deterministic analysis if no model is available, or model-assisted analysis when ask is supplied.
189
+ *
190
+ * @param {Object} params
191
+ * @param {Object} params.task Current task
192
+ * @param {Object} params.runInfo Run details
193
+ * @param {Function} [params.ask] Model querying function (optional)
194
+ * @param {string} [params.preferredModel] Preferred LLM model
195
+ * @param {number} [params.timeoutMs] Query timeout in ms
196
+ * @returns {Promise<Object>} Doctor diagnosis report
197
+ */
198
+ export async function diagnoseFailure({ task, runInfo, ask, preferredModel, timeoutMs = 25000 }) {
199
+ if (!task) return { ok: false, error: 'No task provided' };
200
+
201
+ const currentCode = String(task.prompt || '');
202
+ const { category } = classifyFailure(task, runInfo);
203
+
204
+ // If a model querying function is available, ask the model for diagnosis and fix
205
+ if (typeof ask === 'function') {
206
+ try {
207
+ const prompt = buildDoctorPrompt(task, runInfo, category);
208
+ const res = await ask({
209
+ task,
210
+ prompt,
211
+ system: DOCTOR_SYSTEM_PROMPT,
212
+ preferredModel,
213
+ maxTokens: 1200,
214
+ timeoutMs,
215
+ });
216
+
217
+ if (res && res.ok && res.text) {
218
+ const parsed = parseJsonAnswer(res.text);
219
+ if (parsed && (parsed.suggestedFix || parsed.cause)) {
220
+ const suggestedFix = String(parsed.suggestedFix || currentCode);
221
+ const diff = generateUnifiedDiff(currentCode, suggestedFix, task.type === 'script' ? 'script.sh' : 'prompt.txt');
222
+ const confidence = ['high', 'medium', 'low'].includes(String(parsed.confidence).toLowerCase())
223
+ ? String(parsed.confidence).toLowerCase()
224
+ : 'medium';
225
+
226
+ return {
227
+ ok: true,
228
+ category: parsed.category || category,
229
+ cause: String(parsed.cause || 'Task encountered an unhandled execution failure').slice(0, 1000),
230
+ explanation: String(parsed.explanation || '').slice(0, 1000),
231
+ suggestedFix,
232
+ diff,
233
+ confidence,
234
+ timestamp: Date.now(),
235
+ applied: false,
236
+ autoHealed: false,
237
+ };
238
+ }
239
+ }
240
+ } catch {
241
+ // Fall through to deterministic rules
242
+ }
243
+ }
244
+
245
+ // Deterministic fallback heuristics when LLM is unavailable or unparseable
246
+ let cause = `Task failed with ${category}`;
247
+ let suggestedFix = currentCode;
248
+ let confidence = 'low';
249
+
250
+ if (category === FAILURE_CATEGORIES.TIMEOUT) {
251
+ cause = 'Execution exceeded timeout limit. Suggested increasing timeout or adding fast-fail flags.';
252
+ confidence = 'medium';
253
+ } else if (category === FAILURE_CATEGORIES.RATE_LIMIT) {
254
+ cause = 'Rate limit (429) hit. Suggested adding backoff delay between requests.';
255
+ confidence = 'medium';
256
+ } else if (category === FAILURE_CATEGORIES.SYNTAX_ERROR) {
257
+ cause = 'Syntax error detected in script or prompt syntax.';
258
+ confidence = 'low';
259
+ } else if (category === FAILURE_CATEGORIES.AUTH_FAILED) {
260
+ cause = 'Authentication failed (401/403). Required credentials or tokens are invalid/expired.';
261
+ confidence = 'medium';
262
+ }
263
+
264
+ const diff = generateUnifiedDiff(currentCode, suggestedFix);
265
+
266
+ return {
267
+ ok: true,
268
+ category,
269
+ cause,
270
+ explanation: 'Generated via deterministic heuristics',
271
+ suggestedFix,
272
+ diff,
273
+ confidence,
274
+ timestamp: Date.now(),
275
+ applied: false,
276
+ autoHealed: false,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Checks whether a task should be automatically healed by the Doctor.
282
+ * Enforces safe boundaries:
283
+ * 1. `task.autoHeal === true` or `task.selfHealing?.autoApply === true`
284
+ * 2. High confidence requirement
285
+ * 3. Daily quota: at most 1 auto-patch per 24 hours to prevent loops
286
+ *
287
+ * @param {Object} task
288
+ * @param {Object} doctorReport
289
+ * @returns {boolean}
290
+ */
291
+ export function shouldAutoHeal(task, doctorReport) {
292
+ if (!task || !doctorReport) return false;
293
+
294
+ const isAutoEnabled = Boolean(task.autoHeal || task.selfHealing?.autoApply);
295
+ if (!isAutoEnabled) return false;
296
+
297
+ if (doctorReport.confidence !== 'high') return false;
298
+
299
+ const lastPatchedAt = task.selfHealing?.lastPatchedAt || 0;
300
+ const oneDayMs = 24 * 60 * 60 * 1000;
301
+ if (Date.now() - lastPatchedAt < oneDayMs) {
302
+ return false; // Daily patch limit reached
303
+ }
304
+
305
+ // Don't auto-patch if fix is identical
306
+ if (doctorReport.suggestedFix === task.prompt) {
307
+ return false;
308
+ }
309
+
310
+ return true;
311
+ }
312
+
313
+ /**
314
+ * Applies a doctor fix to the task in store, creating backups and updating revision history.
315
+ *
316
+ * @param {Object} task
317
+ * @param {Object} doctorReport
318
+ * @param {Object} options
319
+ * @param {Object} options.store Store instance
320
+ * @param {string} [options.user] Applying user / system identity
321
+ * @param {boolean} [options.isAutoHeal] Whether applied automatically
322
+ * @returns {Promise<{ ok: boolean, error?: string, revision?: Object }>}
323
+ */
324
+ export async function applyDoctorFix(task, doctorReport, { store, user = 'operator', isAutoHeal = false } = {}) {
325
+ if (!task || !doctorReport || !doctorReport.suggestedFix) {
326
+ return { ok: false, error: 'Invalid task or doctor report' };
327
+ }
328
+
329
+ const previousCode = String(task.prompt || '');
330
+ const newCode = String(doctorReport.suggestedFix);
331
+
332
+ const revision = {
333
+ timestamp: Date.now(),
334
+ author: isAutoHeal ? 'doctor:auto' : `user:${user}`,
335
+ category: doctorReport.category || 'unknown',
336
+ cause: doctorReport.cause || '',
337
+ previousCode,
338
+ newCode,
339
+ };
340
+
341
+ const revisions = Array.isArray(task.revisions) ? [...task.revisions] : [];
342
+ revisions.unshift(revision);
343
+ if (revisions.length > 10) revisions.length = 10; // Keep up to 10 historical revisions
344
+
345
+ const updates = {
346
+ prompt: newCode,
347
+ previousPrompt: previousCode,
348
+ revisions,
349
+ updatedAt: Date.now(),
350
+ };
351
+
352
+ if (isAutoHeal) {
353
+ updates.selfHealing = {
354
+ ...(task.selfHealing || {}),
355
+ lastPatchedAt: Date.now(),
356
+ autoPatchesCount: ((task.selfHealing && task.selfHealing.autoPatchesCount) || 0) + 1,
357
+ };
358
+ }
359
+
360
+ // Update in store
361
+ if (store && typeof store.updateTask === 'function') {
362
+ await store.updateTask(task.id, updates);
363
+ } else if (store && store.tasks && store.tasks[task.id]) {
364
+ Object.assign(store.tasks[task.id], updates);
365
+ }
366
+
367
+ Object.assign(task, updates);
368
+ doctorReport.applied = true;
369
+ if (isAutoHeal) {
370
+ doctorReport.autoHealed = true;
371
+ }
372
+
373
+ return { ok: true, revision };
374
+ }
375
+
376
+ /**
377
+ * Reverts a task to its previous prompt/code from the last revision.
378
+ *
379
+ * @param {Object} task
380
+ * @param {Object} options
381
+ * @param {Object} options.store Store instance
382
+ * @param {string} [options.user] Reverting user identity
383
+ * @returns {Promise<{ ok: boolean, error?: string, revertedCode?: string }>}
384
+ */
385
+ export async function revertDoctorFix(task, { store, user = 'operator' } = {}) {
386
+ if (!task) return { ok: false, error: 'No task provided' };
387
+
388
+ let revertCode = null;
389
+ const revisions = Array.isArray(task.revisions) ? [...task.revisions] : [];
390
+
391
+ if (revisions.length > 0) {
392
+ const lastRev = revisions[0];
393
+ revertCode = lastRev.previousCode;
394
+ } else if (task.previousPrompt) {
395
+ revertCode = task.previousPrompt;
396
+ }
397
+
398
+ if (revertCode === null || revertCode === undefined) {
399
+ return { ok: false, error: 'No previous revision or backup found to revert to' };
400
+ }
401
+
402
+ const currentCode = String(task.prompt || '');
403
+ const revertRevision = {
404
+ timestamp: Date.now(),
405
+ author: `revert:${user}`,
406
+ category: 'revert',
407
+ cause: 'Reverted to previous version',
408
+ previousCode: currentCode,
409
+ newCode: revertCode,
410
+ };
411
+
412
+ revisions.unshift(revertRevision);
413
+ if (revisions.length > 10) revisions.length = 10;
414
+
415
+ const updates = {
416
+ prompt: revertCode,
417
+ previousPrompt: currentCode,
418
+ revisions,
419
+ updatedAt: Date.now(),
420
+ };
421
+
422
+ if (store && typeof store.updateTask === 'function') {
423
+ await store.updateTask(task.id, updates);
424
+ } else if (store && store.tasks && store.tasks[task.id]) {
425
+ Object.assign(store.tasks[task.id], updates);
426
+ }
427
+
428
+ Object.assign(task, updates);
429
+ return { ok: true, revertedCode: revertCode };
430
+ }
431
+