@goodandready/dsh-cron 0.3.12 → 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 +12 -0
- package/README.ru.md +12 -0
- package/README.zh.md +12 -0
- package/lib/api.js +117 -5
- 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/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/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
|
|
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
|
|
102
|
-
if (
|
|
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
|
|
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)
|
|
125
|
-
const targets =
|
|
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 =
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
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:
|
|
312
|
-
type: '
|
|
313
|
-
label: '
|
|
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
|
+
|