clementine-agent 1.0.18 → 1.0.20
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/dist/cli/dashboard.js +115 -1
- package/dist/gateway/cron-scheduler.js +8 -1
- package/dist/gateway/failure-diagnostics.d.ts +25 -0
- package/dist/gateway/failure-diagnostics.js +76 -1
- package/dist/gateway/failure-monitor.d.ts +11 -0
- package/dist/gateway/fix-applier.d.ts +34 -0
- package/dist/gateway/fix-applier.js +308 -0
- package/package.json +1 -1
package/dist/cli/dashboard.js
CHANGED
|
@@ -2085,6 +2085,54 @@ export async function cmdDashboard(opts) {
|
|
|
2085
2085
|
res.status(500).json({ error: String(err) });
|
|
2086
2086
|
}
|
|
2087
2087
|
});
|
|
2088
|
+
/**
|
|
2089
|
+
* Apply the cached diagnosis's autoApply operations to the right CRON.md.
|
|
2090
|
+
* Strict safety: requires a fresh diagnosis, requires autoApply present,
|
|
2091
|
+
* requires riskLevel == 'low'. Otherwise returns 409 with a reason.
|
|
2092
|
+
*/
|
|
2093
|
+
app.post('/api/cron/broken-jobs/:jobName/apply-fix', async (req, res) => {
|
|
2094
|
+
const jobName = req.params.jobName;
|
|
2095
|
+
try {
|
|
2096
|
+
const { getDiagnosisIfFresh, clearDiagnosis } = await import('../gateway/failure-diagnostics.js');
|
|
2097
|
+
const { applyFix } = await import('../gateway/fix-applier.js');
|
|
2098
|
+
const d = getDiagnosisIfFresh(jobName);
|
|
2099
|
+
if (!d) {
|
|
2100
|
+
res.status(404).json({ error: 'No fresh diagnosis for this job. Wait for the next sweep.' });
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
if (!d.proposedFix.autoApply) {
|
|
2104
|
+
res.status(409).json({ error: 'Diagnosis has no auto-applicable operations — review manually.' });
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
if (d.riskLevel !== 'low') {
|
|
2108
|
+
res.status(409).json({ error: `riskLevel is '${d.riskLevel}' — only 'low' is auto-apply-able.` });
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
const dryRun = req.body?.dryRun === true;
|
|
2112
|
+
const result = applyFix(jobName, d.proposedFix.autoApply, { dryRun });
|
|
2113
|
+
if (result.ok && !dryRun) {
|
|
2114
|
+
// Clear the cached diagnosis so the next sweep re-evaluates with the
|
|
2115
|
+
// new config. The existing CRON.md watcher will reload cron jobs
|
|
2116
|
+
// within a couple of seconds.
|
|
2117
|
+
clearDiagnosis(jobName);
|
|
2118
|
+
}
|
|
2119
|
+
res.status(result.ok ? 200 : 400).json(result);
|
|
2120
|
+
}
|
|
2121
|
+
catch (err) {
|
|
2122
|
+
res.status(500).json({ error: String(err) });
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
/** Dismiss a diagnosis without applying — clears the cached result. */
|
|
2126
|
+
app.post('/api/cron/broken-jobs/:jobName/dismiss-diagnosis', async (req, res) => {
|
|
2127
|
+
try {
|
|
2128
|
+
const { clearDiagnosis } = await import('../gateway/failure-diagnostics.js');
|
|
2129
|
+
clearDiagnosis(req.params.jobName);
|
|
2130
|
+
res.json({ ok: true });
|
|
2131
|
+
}
|
|
2132
|
+
catch (err) {
|
|
2133
|
+
res.status(500).json({ error: String(err) });
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2088
2136
|
// ── Cron trace viewer ──────────────────────────────────────────
|
|
2089
2137
|
app.get('/api/cron/traces/:job', (req, res) => {
|
|
2090
2138
|
try {
|
|
@@ -16162,6 +16210,49 @@ async function expandSkill(name) {
|
|
|
16162
16210
|
} catch(e) { toast('Failed to load skill', 'error'); }
|
|
16163
16211
|
}
|
|
16164
16212
|
|
|
16213
|
+
async function applyBrokenJobFix(jobName) {
|
|
16214
|
+
try {
|
|
16215
|
+
// First: dry-run to get the actual diff to show in the confirm dialog
|
|
16216
|
+
var dryRes = await apiJson('POST', '/api/cron/broken-jobs/' + encodeURIComponent(jobName) + '/apply-fix', { dryRun: true });
|
|
16217
|
+
if (!dryRes || !dryRes.ok) {
|
|
16218
|
+
toast('Cannot apply: ' + ((dryRes && (dryRes.message || dryRes.error)) || 'unknown error'), 'error');
|
|
16219
|
+
return;
|
|
16220
|
+
}
|
|
16221
|
+
var diffPreview = (dryRes.diff || '(no diff)').slice(0, 1200);
|
|
16222
|
+
var msg = 'Apply this fix to ' + jobName + '?\n\n'
|
|
16223
|
+
+ 'File: ' + (dryRes.file || 'unknown') + '\n'
|
|
16224
|
+
+ 'Operations: ' + (dryRes.appliedOps || []).length + '\n\n'
|
|
16225
|
+
+ diffPreview
|
|
16226
|
+
+ '\n\nA .bak will be written. The daemon auto-reloads; the next run will be fix-verified.';
|
|
16227
|
+
if (!confirm(msg)) return;
|
|
16228
|
+
|
|
16229
|
+
var res = await apiJson('POST', '/api/cron/broken-jobs/' + encodeURIComponent(jobName) + '/apply-fix', {});
|
|
16230
|
+
if (res && res.ok) {
|
|
16231
|
+
toast('Applied ' + (res.appliedOps || []).length + ' op(s) to ' + jobName, 'success');
|
|
16232
|
+
refreshBrokenJobs();
|
|
16233
|
+
} else {
|
|
16234
|
+
toast('Apply failed: ' + ((res && (res.message || res.error)) || 'unknown'), 'error');
|
|
16235
|
+
}
|
|
16236
|
+
} catch (e) {
|
|
16237
|
+
toast('Apply failed: ' + String(e), 'error');
|
|
16238
|
+
}
|
|
16239
|
+
}
|
|
16240
|
+
|
|
16241
|
+
async function dismissBrokenJobDiagnosis(jobName) {
|
|
16242
|
+
if (!confirm('Clear the cached diagnosis for ' + jobName + '? It will be re-diagnosed on the next sweep if still failing.')) return;
|
|
16243
|
+
try {
|
|
16244
|
+
var res = await apiJson('POST', '/api/cron/broken-jobs/' + encodeURIComponent(jobName) + '/dismiss-diagnosis', {});
|
|
16245
|
+
if (res && res.ok) {
|
|
16246
|
+
toast('Diagnosis dismissed', 'info');
|
|
16247
|
+
refreshBrokenJobs();
|
|
16248
|
+
} else {
|
|
16249
|
+
toast('Failed to dismiss: ' + ((res && res.error) || 'unknown'), 'error');
|
|
16250
|
+
}
|
|
16251
|
+
} catch (e) {
|
|
16252
|
+
toast('Failed to dismiss: ' + String(e), 'error');
|
|
16253
|
+
}
|
|
16254
|
+
}
|
|
16255
|
+
|
|
16165
16256
|
async function refreshBrokenJobs() {
|
|
16166
16257
|
try {
|
|
16167
16258
|
var r = await apiFetch('/api/cron/broken-jobs');
|
|
@@ -16202,7 +16293,8 @@ async function refreshBrokenJobs() {
|
|
|
16202
16293
|
? '<span class="badge badge-blue" style="font-size:10px">' + esc(j.agentSlug) + '</span>'
|
|
16203
16294
|
: '';
|
|
16204
16295
|
|
|
16205
|
-
// Diagnosis block — root cause + proposed fix + diff preview
|
|
16296
|
+
// Diagnosis block — root cause + proposed fix + diff preview +
|
|
16297
|
+
// Apply/Dismiss buttons when autoApply is present and risk is low.
|
|
16206
16298
|
var diagnosisHtml = '';
|
|
16207
16299
|
if (j.diagnosis) {
|
|
16208
16300
|
var riskColor = j.diagnosis.riskLevel === 'high' ? '#ef4444'
|
|
@@ -16215,6 +16307,27 @@ async function refreshBrokenJobs() {
|
|
|
16215
16307
|
diffHtml = '<pre style="font-size:11px;background:#0f172a;color:#e2e8f0;padding:8px;border-radius:4px;margin:6px 0 0;white-space:pre-wrap;word-break:break-word;max-height:200px;overflow-y:auto">'
|
|
16216
16308
|
+ esc(j.diagnosis.proposedFix.diff) + '</pre>';
|
|
16217
16309
|
}
|
|
16310
|
+
|
|
16311
|
+
var canAutoApply = !!j.diagnosis.proposedFix.autoApply
|
|
16312
|
+
&& j.diagnosis.riskLevel === 'low';
|
|
16313
|
+
var actionsHtml = '';
|
|
16314
|
+
if (canAutoApply) {
|
|
16315
|
+
var opCount = (j.diagnosis.proposedFix.autoApply.operations || []).length;
|
|
16316
|
+
actionsHtml = '<div style="margin-top:10px;display:flex;gap:8px;align-items:center">'
|
|
16317
|
+
+ '<button onclick="applyBrokenJobFix(\\x27' + esc(j.jobName) + '\\x27)" '
|
|
16318
|
+
+ 'style="background:var(--accent);border:1px solid var(--accent);color:white;padding:4px 12px;border-radius:4px;font-size:11px;cursor:pointer">'
|
|
16319
|
+
+ 'Apply fix (' + opCount + ' op' + (opCount === 1 ? '' : 's') + ')</button>'
|
|
16320
|
+
+ '<button onclick="dismissBrokenJobDiagnosis(\\x27' + esc(j.jobName) + '\\x27)" '
|
|
16321
|
+
+ 'style="background:none;border:1px solid var(--border);color:var(--text-secondary);padding:4px 12px;border-radius:4px;font-size:11px;cursor:pointer">'
|
|
16322
|
+
+ 'Dismiss</button>'
|
|
16323
|
+
+ '<span style="font-size:10px;color:var(--text-muted);margin-left:auto">auto-verified after next run</span>'
|
|
16324
|
+
+ '</div>';
|
|
16325
|
+
} else if (j.diagnosis.proposedFix.autoApply && j.diagnosis.riskLevel !== 'low') {
|
|
16326
|
+
actionsHtml = '<div style="margin-top:10px;font-size:11px;color:var(--text-muted);font-style:italic">'
|
|
16327
|
+
+ 'Not auto-applicable (risk: ' + esc(j.diagnosis.riskLevel) + ') — review manually'
|
|
16328
|
+
+ '</div>';
|
|
16329
|
+
}
|
|
16330
|
+
|
|
16218
16331
|
diagnosisHtml = '<div style="margin-top:10px;padding:10px;border-left:3px solid ' + riskColor
|
|
16219
16332
|
+ ';background:var(--bg-tertiary);border-radius:4px">'
|
|
16220
16333
|
+ '<div style="font-size:12px;margin-bottom:4px"><strong>Root cause' + confLabel + ':</strong> '
|
|
@@ -16222,6 +16335,7 @@ async function refreshBrokenJobs() {
|
|
|
16222
16335
|
+ '<div style="font-size:12px"><strong>Proposed fix:</strong> '
|
|
16223
16336
|
+ esc(j.diagnosis.proposedFix.details) + '</div>'
|
|
16224
16337
|
+ diffHtml
|
|
16338
|
+
+ actionsHtml
|
|
16225
16339
|
+ '<div style="font-size:10px;color:var(--text-muted);margin-top:6px">'
|
|
16226
16340
|
+ esc(j.diagnosis.proposedFix.type) + ' \\u00b7 ' + esc(j.diagnosis.riskLevel) + ' risk \\u00b7 diagnosed ' + timeAgo(j.diagnosis.generatedAt)
|
|
16227
16341
|
+ '</div>'
|
|
@@ -98,11 +98,18 @@ export function parseCronJobs() {
|
|
|
98
98
|
const alwaysDeliver = job.always_deliver === true ? true : undefined;
|
|
99
99
|
const context = job.context != null ? String(job.context) : undefined;
|
|
100
100
|
const preCheck = job.pre_check != null ? String(job.pre_check) : undefined;
|
|
101
|
+
// Optional: scope a global job to a specific agent's profile (loads
|
|
102
|
+
// the agent's allowedTools whitelist, system prompt, etc.). Accept
|
|
103
|
+
// both camelCase and snake_case to be forgiving of user-written YAML.
|
|
104
|
+
const agentSlugRaw = job.agentSlug ?? job.agent_slug;
|
|
105
|
+
const agentSlug = typeof agentSlugRaw === 'string' && /^[a-z0-9-]+$/i.test(agentSlugRaw)
|
|
106
|
+
? agentSlugRaw
|
|
107
|
+
: undefined;
|
|
101
108
|
if (!name || !schedule || !prompt) {
|
|
102
109
|
logger.warn({ job }, 'Skipping malformed cron job');
|
|
103
110
|
continue;
|
|
104
111
|
}
|
|
105
|
-
jobs.push({ name, schedule, prompt, enabled, tier, maxTurns, model, workDir, mode, maxHours, maxRetries, after, successCriteria, alwaysDeliver, context, preCheck });
|
|
112
|
+
jobs.push({ name, schedule, prompt, enabled, tier, maxTurns, model, workDir, mode, maxHours, maxRetries, after, successCriteria, alwaysDeliver, context, preCheck, agentSlug });
|
|
106
113
|
}
|
|
107
114
|
return jobs;
|
|
108
115
|
}
|
|
@@ -12,6 +12,21 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { Gateway } from './router.js';
|
|
14
14
|
import type { BrokenJob } from './failure-monitor.js';
|
|
15
|
+
/**
|
|
16
|
+
* Fields safe for one-click auto-apply. Limited to simple scalar YAML
|
|
17
|
+
* fields on cron jobs — nothing multi-line (prompt, pre_check, context,
|
|
18
|
+
* success_criteria), nothing structural (schedule edits would re-schedule
|
|
19
|
+
* a running job, handled manually).
|
|
20
|
+
*/
|
|
21
|
+
export declare const EDITABLE_FIELDS: Set<string>;
|
|
22
|
+
export type FixOperation = {
|
|
23
|
+
op: 'set';
|
|
24
|
+
field: string;
|
|
25
|
+
value: string | number | boolean;
|
|
26
|
+
} | {
|
|
27
|
+
op: 'remove';
|
|
28
|
+
field: string;
|
|
29
|
+
};
|
|
15
30
|
export interface Diagnosis {
|
|
16
31
|
rootCause: string;
|
|
17
32
|
confidence: 'high' | 'medium' | 'low';
|
|
@@ -19,6 +34,16 @@ export interface Diagnosis {
|
|
|
19
34
|
type: 'config_change' | 'prompt_change' | 'agent_scope' | 'disable' | 'credential_refresh' | 'escalate_to_owner';
|
|
20
35
|
details: string;
|
|
21
36
|
diff?: string;
|
|
37
|
+
/**
|
|
38
|
+
* When present, the fix can be applied with one click via the
|
|
39
|
+
* /api/cron/broken-jobs/:jobName/apply-fix endpoint. Operations are
|
|
40
|
+
* silently filtered against EDITABLE_FIELDS — a proposal that mixes
|
|
41
|
+
* safe and unsafe edits gets the unsafe ones dropped.
|
|
42
|
+
*/
|
|
43
|
+
autoApply?: {
|
|
44
|
+
agentSlug?: string;
|
|
45
|
+
operations: FixOperation[];
|
|
46
|
+
};
|
|
22
47
|
};
|
|
23
48
|
riskLevel: 'low' | 'medium' | 'high';
|
|
24
49
|
generatedAt: string;
|
|
@@ -18,6 +18,26 @@ const logger = pino({ name: 'clementine.failure-diagnostics' });
|
|
|
18
18
|
const CACHE_FILE = path.join(BASE_DIR, 'cron', 'failure-diagnostics.json');
|
|
19
19
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
20
20
|
const RUNS_DIR = path.join(BASE_DIR, 'cron', 'runs');
|
|
21
|
+
/**
|
|
22
|
+
* Fields safe for one-click auto-apply. Limited to simple scalar YAML
|
|
23
|
+
* fields on cron jobs — nothing multi-line (prompt, pre_check, context,
|
|
24
|
+
* success_criteria), nothing structural (schedule edits would re-schedule
|
|
25
|
+
* a running job, handled manually).
|
|
26
|
+
*/
|
|
27
|
+
export const EDITABLE_FIELDS = new Set([
|
|
28
|
+
'tier',
|
|
29
|
+
'mode',
|
|
30
|
+
'max_hours',
|
|
31
|
+
'max_turns',
|
|
32
|
+
'max_retries',
|
|
33
|
+
'enabled',
|
|
34
|
+
'agentSlug',
|
|
35
|
+
'work_dir',
|
|
36
|
+
'model',
|
|
37
|
+
'always_deliver',
|
|
38
|
+
'after',
|
|
39
|
+
'timeout_ms',
|
|
40
|
+
]);
|
|
21
41
|
function loadCache() {
|
|
22
42
|
try {
|
|
23
43
|
if (!existsSync(CACHE_FILE))
|
|
@@ -149,6 +169,21 @@ function buildPrompt(broken, jobDef, agentProfile, recentRuns) {
|
|
|
149
169
|
'- **Output preview contains BLOCKED / "no local bash" / "permission denied"** → agent picked the wrong tool. Propose either scoping the job to an agent whose allowedTools excludes the bad MCP, or adding explicit tool-choice guidance in the prompt.',
|
|
150
170
|
'- **No clear pattern** → escalate_to_owner with what you would need to know.',
|
|
151
171
|
'',
|
|
172
|
+
'## Auto-apply contract',
|
|
173
|
+
'',
|
|
174
|
+
'When (and ONLY when) the fix is a simple edit to one of these scalar fields — tier, mode, max_hours, max_turns, max_retries, enabled, agentSlug, work_dir, model, always_deliver, after, timeout_ms — also populate `proposedFix.autoApply`. The owner can one-click approve it from the dashboard.',
|
|
175
|
+
'',
|
|
176
|
+
'For multi-line fields (prompt, pre_check, context, success_criteria), or for credential refreshes, or any change you are not very confident about: OMIT autoApply entirely. The owner will handle those manually.',
|
|
177
|
+
'',
|
|
178
|
+
`If the job is agent-scoped (job name includes ":"), set autoApply.agentSlug to the part BEFORE the colon. Otherwise omit it (global CRON.md).`,
|
|
179
|
+
'',
|
|
180
|
+
'Operations use the shape { "op": "set", "field": "<name>", "value": <scalar> } or { "op": "remove", "field": "<name>" }. Values are strings, numbers, or booleans.',
|
|
181
|
+
'',
|
|
182
|
+
'Examples:',
|
|
183
|
+
'- Remove unleashed mode + its companion: operations: [{"op":"remove","field":"mode"}, {"op":"remove","field":"max_hours"}, {"op":"set","field":"max_turns","value":25}]',
|
|
184
|
+
'- Scope a broken global job to Ross\'s profile: operations: [{"op":"set","field":"agentSlug","value":"ross-the-sdr"}]',
|
|
185
|
+
'- Bump maxTurns on an under-resourced job: operations: [{"op":"set","field":"max_turns","value":10}]',
|
|
186
|
+
'',
|
|
152
187
|
'## Output schema (JSON only, no markdown fences):',
|
|
153
188
|
'{',
|
|
154
189
|
' "rootCause": "1-2 sentences explaining WHY the job is failing, referencing specific fields or error patterns from the CURRENT config",',
|
|
@@ -156,7 +191,8 @@ function buildPrompt(broken, jobDef, agentProfile, recentRuns) {
|
|
|
156
191
|
' "proposedFix": {',
|
|
157
192
|
' "type": "config_change|prompt_change|agent_scope|disable|credential_refresh|escalate_to_owner",',
|
|
158
193
|
' "details": "prose description of the fix, citing the exact field(s) to change",',
|
|
159
|
-
' "diff": "optional: exact before/after diff
|
|
194
|
+
' "diff": "optional: exact before/after diff",',
|
|
195
|
+
' "autoApply": "optional: { agentSlug?, operations: [...] } — ONLY for simple scalar-field edits on the allowlist"',
|
|
160
196
|
' },',
|
|
161
197
|
' "riskLevel": "low|medium|high"',
|
|
162
198
|
'}',
|
|
@@ -172,6 +208,7 @@ function parseResponse(raw) {
|
|
|
172
208
|
const parsed = JSON.parse(match[0]);
|
|
173
209
|
if (!parsed.rootCause || !parsed.proposedFix)
|
|
174
210
|
return null;
|
|
211
|
+
const autoApply = sanitizeAutoApply(parsed.proposedFix.autoApply);
|
|
175
212
|
return {
|
|
176
213
|
rootCause: String(parsed.rootCause).slice(0, 500),
|
|
177
214
|
confidence: (parsed.confidence ?? 'medium'),
|
|
@@ -179,6 +216,7 @@ function parseResponse(raw) {
|
|
|
179
216
|
type: (parsed.proposedFix.type ?? 'escalate_to_owner'),
|
|
180
217
|
details: String(parsed.proposedFix.details ?? '').slice(0, 800),
|
|
181
218
|
diff: parsed.proposedFix.diff ? String(parsed.proposedFix.diff).slice(0, 1000) : undefined,
|
|
219
|
+
...(autoApply ? { autoApply } : {}),
|
|
182
220
|
},
|
|
183
221
|
riskLevel: (parsed.riskLevel ?? 'medium'),
|
|
184
222
|
generatedAt: new Date().toISOString(),
|
|
@@ -189,6 +227,43 @@ function parseResponse(raw) {
|
|
|
189
227
|
return null;
|
|
190
228
|
}
|
|
191
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* Strictly validate and filter autoApply. Drops ops on non-allowlisted fields
|
|
232
|
+
* silently (rather than rejecting the whole diagnosis). Returns null if
|
|
233
|
+
* nothing valid remains.
|
|
234
|
+
*/
|
|
235
|
+
function sanitizeAutoApply(raw) {
|
|
236
|
+
if (!raw || typeof raw !== 'object')
|
|
237
|
+
return null;
|
|
238
|
+
const obj = raw;
|
|
239
|
+
if (!Array.isArray(obj.operations))
|
|
240
|
+
return null;
|
|
241
|
+
const operations = [];
|
|
242
|
+
for (const op of obj.operations) {
|
|
243
|
+
if (!op || typeof op !== 'object')
|
|
244
|
+
continue;
|
|
245
|
+
const raw = op;
|
|
246
|
+
if (typeof raw.field !== 'string')
|
|
247
|
+
continue;
|
|
248
|
+
if (!EDITABLE_FIELDS.has(raw.field))
|
|
249
|
+
continue;
|
|
250
|
+
if (raw.op === 'remove') {
|
|
251
|
+
operations.push({ op: 'remove', field: raw.field });
|
|
252
|
+
}
|
|
253
|
+
else if (raw.op === 'set') {
|
|
254
|
+
const v = raw.value;
|
|
255
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
|
|
256
|
+
operations.push({ op: 'set', field: raw.field, value: v });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (operations.length === 0)
|
|
261
|
+
return null;
|
|
262
|
+
const agentSlug = typeof obj.agentSlug === 'string' && /^[a-z0-9-]+$/i.test(obj.agentSlug)
|
|
263
|
+
? obj.agentSlug
|
|
264
|
+
: undefined;
|
|
265
|
+
return agentSlug ? { agentSlug, operations } : { operations };
|
|
266
|
+
}
|
|
192
267
|
/**
|
|
193
268
|
* Diagnose one broken job. Returns a cached diagnosis if one exists and is
|
|
194
269
|
* fresher than 24h; otherwise invokes the LLM. Always best-effort — returns
|
|
@@ -32,6 +32,17 @@ export interface BrokenJob {
|
|
|
32
32
|
type: string;
|
|
33
33
|
details: string;
|
|
34
34
|
diff?: string;
|
|
35
|
+
autoApply?: {
|
|
36
|
+
agentSlug?: string;
|
|
37
|
+
operations: Array<{
|
|
38
|
+
op: 'set';
|
|
39
|
+
field: string;
|
|
40
|
+
value: string | number | boolean;
|
|
41
|
+
} | {
|
|
42
|
+
op: 'remove';
|
|
43
|
+
field: string;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
35
46
|
};
|
|
36
47
|
riskLevel: 'low' | 'medium' | 'high';
|
|
37
48
|
generatedAt: string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Deterministic cron job fix applier.
|
|
3
|
+
*
|
|
4
|
+
* Applies the `autoApply` operations from a Diagnosis to a CRON.md file
|
|
5
|
+
* (global or agent-scoped). Strictly scoped to:
|
|
6
|
+
* - Allowlisted scalar fields only (enforced by the diagnostics module
|
|
7
|
+
* before they arrive here, and re-checked here for safety).
|
|
8
|
+
* - A single job's YAML block, identified by `- name: <jobName>`.
|
|
9
|
+
* - Line-level edits — never touches multi-line fields like `prompt`.
|
|
10
|
+
*
|
|
11
|
+
* Every apply writes a .bak next to the CRON.md and appends to an audit
|
|
12
|
+
* log before touching the file.
|
|
13
|
+
*/
|
|
14
|
+
import { type FixOperation } from './failure-diagnostics.js';
|
|
15
|
+
export interface ApplyResult {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
message: string;
|
|
18
|
+
file?: string;
|
|
19
|
+
appliedOps?: FixOperation[];
|
|
20
|
+
skippedOps?: FixOperation[];
|
|
21
|
+
diff?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Apply a proposed fix to the right CRON.md file. Idempotent with respect
|
|
25
|
+
* to already-applied ops (remove on a missing field is a no-op, set on a
|
|
26
|
+
* matching value is a no-op).
|
|
27
|
+
*/
|
|
28
|
+
export declare function applyFix(jobName: string, autoApply: {
|
|
29
|
+
agentSlug?: string;
|
|
30
|
+
operations: FixOperation[];
|
|
31
|
+
}, opts?: {
|
|
32
|
+
dryRun?: boolean;
|
|
33
|
+
}): ApplyResult;
|
|
34
|
+
//# sourceMappingURL=fix-applier.d.ts.map
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Deterministic cron job fix applier.
|
|
3
|
+
*
|
|
4
|
+
* Applies the `autoApply` operations from a Diagnosis to a CRON.md file
|
|
5
|
+
* (global or agent-scoped). Strictly scoped to:
|
|
6
|
+
* - Allowlisted scalar fields only (enforced by the diagnostics module
|
|
7
|
+
* before they arrive here, and re-checked here for safety).
|
|
8
|
+
* - A single job's YAML block, identified by `- name: <jobName>`.
|
|
9
|
+
* - Line-level edits — never touches multi-line fields like `prompt`.
|
|
10
|
+
*
|
|
11
|
+
* Every apply writes a .bak next to the CRON.md and appends to an audit
|
|
12
|
+
* log before touching the file.
|
|
13
|
+
*/
|
|
14
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import pino from 'pino';
|
|
17
|
+
import { AGENTS_DIR, BASE_DIR, CRON_FILE } from '../config.js';
|
|
18
|
+
import { EDITABLE_FIELDS } from './failure-diagnostics.js';
|
|
19
|
+
const logger = pino({ name: 'clementine.fix-applier' });
|
|
20
|
+
const AUDIT_FILE = path.join(BASE_DIR, 'cron', 'fix-applier.log');
|
|
21
|
+
/**
|
|
22
|
+
* Resolve which CRON.md to edit for this job. Agent-scoped jobs live in
|
|
23
|
+
* vault/00-System/agents/<slug>/CRON.md; everything else is the global
|
|
24
|
+
* vault/00-System/CRON.md. If autoApply.agentSlug is provided, trust it;
|
|
25
|
+
* otherwise infer from the job name.
|
|
26
|
+
*/
|
|
27
|
+
function resolveCronFile(jobName, autoApply) {
|
|
28
|
+
if (autoApply.agentSlug) {
|
|
29
|
+
const f = path.join(AGENTS_DIR, autoApply.agentSlug, 'CRON.md');
|
|
30
|
+
if (existsSync(f))
|
|
31
|
+
return f;
|
|
32
|
+
logger.warn({ agentSlug: autoApply.agentSlug, expected: f }, 'agent-scoped CRON.md not found');
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
// Infer from jobName prefix (e.g., "ross-the-sdr:reply-detection")
|
|
36
|
+
if (jobName.includes(':')) {
|
|
37
|
+
const slug = jobName.split(':')[0];
|
|
38
|
+
const f = path.join(AGENTS_DIR, slug, 'CRON.md');
|
|
39
|
+
if (existsSync(f))
|
|
40
|
+
return f;
|
|
41
|
+
}
|
|
42
|
+
return existsSync(CRON_FILE) ? CRON_FILE : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The bare job name without the agent prefix. Agent-scoped cron jobs are
|
|
46
|
+
* written in their own file without the prefix — it's added programmatically
|
|
47
|
+
* when the scheduler merges them into the global job list.
|
|
48
|
+
*/
|
|
49
|
+
function bareJobName(jobName) {
|
|
50
|
+
const idx = jobName.indexOf(':');
|
|
51
|
+
return idx === -1 ? jobName : jobName.slice(idx + 1);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Find the line-range of a job's YAML block in a CRON.md file.
|
|
55
|
+
* Blocks start with ` - name: <bareName>` and run until the next ` - name:`
|
|
56
|
+
* at the same indent, or end of the jobs array.
|
|
57
|
+
*/
|
|
58
|
+
function findJobBlock(lines, bareName) {
|
|
59
|
+
// Match: two-space indent, hyphen, space, "name:", name (allow trailing spaces)
|
|
60
|
+
const nameEsc = bareName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
61
|
+
const startRe = new RegExp(`^ - name:\\s+${nameEsc}\\s*$`);
|
|
62
|
+
const anyStartRe = /^ - name:\s+/;
|
|
63
|
+
let start = -1;
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
if (startRe.test(lines[i])) {
|
|
66
|
+
start = i;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (start === -1)
|
|
71
|
+
return null;
|
|
72
|
+
let end = lines.length;
|
|
73
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
74
|
+
if (anyStartRe.test(lines[i])) {
|
|
75
|
+
end = i;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { start, end };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Search a job block for a top-level scalar field (4-space indent, single
|
|
83
|
+
* line `key: value`). Returns the line index, or -1 if not present.
|
|
84
|
+
* Skips lines inside multi-line blocks (|>|, >) by tracking when we enter
|
|
85
|
+
* and exit them.
|
|
86
|
+
*/
|
|
87
|
+
function findFieldLine(lines, blockStart, blockEnd, field) {
|
|
88
|
+
const fieldEsc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
89
|
+
const fieldRe = new RegExp(`^ ${fieldEsc}:\\s*(.*)$`);
|
|
90
|
+
// Multi-line marker pattern: ` key: |` or ` key: >-` or ` key: >`
|
|
91
|
+
const multiLineStartRe = /^ \w[\w-]*:\s*[|>][-+]?\s*$/;
|
|
92
|
+
let inMultiLine = false;
|
|
93
|
+
for (let i = blockStart + 1; i < blockEnd; i++) {
|
|
94
|
+
const line = lines[i];
|
|
95
|
+
if (inMultiLine) {
|
|
96
|
+
// Multi-line content is indented MORE than 4 spaces. When we hit a line
|
|
97
|
+
// indented exactly 4 (another field) or less, we've exited.
|
|
98
|
+
if (/^ \S/.test(line) && !/^ /.test(line)) {
|
|
99
|
+
inMultiLine = false;
|
|
100
|
+
// Fall through to check this line
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (multiLineStartRe.test(line)) {
|
|
107
|
+
inMultiLine = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (fieldRe.test(line))
|
|
111
|
+
return i;
|
|
112
|
+
}
|
|
113
|
+
return -1;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Serialize a scalar value to YAML. Strings with colons, leading dashes, or
|
|
117
|
+
* YAML-sensitive characters get quoted. Everything else emits bare.
|
|
118
|
+
*/
|
|
119
|
+
function yamlScalar(value) {
|
|
120
|
+
if (typeof value === 'boolean')
|
|
121
|
+
return value ? 'true' : 'false';
|
|
122
|
+
if (typeof value === 'number')
|
|
123
|
+
return String(value);
|
|
124
|
+
const s = String(value);
|
|
125
|
+
if (/^[\w\-./]+$/.test(s) && !/^(true|false|yes|no|null|~|\d)/i.test(s)) {
|
|
126
|
+
return s;
|
|
127
|
+
}
|
|
128
|
+
// Quote with double quotes, escape any embedded "
|
|
129
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Apply operations to a single job block in-place (returns a new array of
|
|
133
|
+
* lines). Silently drops operations targeting fields not in EDITABLE_FIELDS
|
|
134
|
+
* (defense in depth — the diagnostics parser filters these too).
|
|
135
|
+
*/
|
|
136
|
+
function applyOperations(lines, block, operations) {
|
|
137
|
+
// Work on a mutable copy. We track the evolving block.end as we insert/delete.
|
|
138
|
+
let working = lines.slice();
|
|
139
|
+
let blockEnd = block.end;
|
|
140
|
+
const applied = [];
|
|
141
|
+
const skipped = [];
|
|
142
|
+
for (const op of operations) {
|
|
143
|
+
if (!EDITABLE_FIELDS.has(op.field)) {
|
|
144
|
+
skipped.push(op);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const existing = findFieldLine(working, block.start, blockEnd, op.field);
|
|
148
|
+
if (op.op === 'remove') {
|
|
149
|
+
if (existing === -1) {
|
|
150
|
+
skipped.push(op); // nothing to remove
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
working.splice(existing, 1);
|
|
154
|
+
blockEnd -= 1;
|
|
155
|
+
applied.push(op);
|
|
156
|
+
}
|
|
157
|
+
else if (op.op === 'set') {
|
|
158
|
+
const newLine = ` ${op.field}: ${yamlScalar(op.value)}`;
|
|
159
|
+
if (existing !== -1) {
|
|
160
|
+
working[existing] = newLine;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// Insert right after the name line so field order stays predictable.
|
|
164
|
+
working.splice(block.start + 1, 0, newLine);
|
|
165
|
+
blockEnd += 1;
|
|
166
|
+
}
|
|
167
|
+
applied.push(op);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { newLines: working, applied, skipped };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Build a compact diff of only the scalar-field lines that changed.
|
|
174
|
+
* Ignores multi-line content like embedded prompts — walks each block,
|
|
175
|
+
* extracts single-line ` key: value` fields, and compares those.
|
|
176
|
+
* Keeps output readable for confirm dialogs and audit logs.
|
|
177
|
+
*/
|
|
178
|
+
function makeDiff(before, after, blockStart, newBlockEnd) {
|
|
179
|
+
const beforeEnd = findBlockEnd(before, blockStart);
|
|
180
|
+
const beforeFields = extractScalarFields(before.slice(blockStart, beforeEnd));
|
|
181
|
+
const afterFields = extractScalarFields(after.slice(blockStart, newBlockEnd));
|
|
182
|
+
const allKeys = new Set([...beforeFields.keys(), ...afterFields.keys()]);
|
|
183
|
+
const lines = [];
|
|
184
|
+
lines.push(`@@ ${after[blockStart].trim()} @@`);
|
|
185
|
+
for (const key of allKeys) {
|
|
186
|
+
const b = beforeFields.get(key);
|
|
187
|
+
const a = afterFields.get(key);
|
|
188
|
+
if (b === a)
|
|
189
|
+
continue;
|
|
190
|
+
if (b !== undefined)
|
|
191
|
+
lines.push(`- ${b}`);
|
|
192
|
+
if (a !== undefined)
|
|
193
|
+
lines.push(`+ ${a}`);
|
|
194
|
+
}
|
|
195
|
+
return lines.join('\n');
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Extract single-line scalar ` key: value` fields from a job block.
|
|
199
|
+
* Skips the `- name:` line and multi-line `key: |` / `key: >` content.
|
|
200
|
+
*/
|
|
201
|
+
function extractScalarFields(blockLines) {
|
|
202
|
+
const out = new Map();
|
|
203
|
+
const scalarRe = /^ ([\w-]+):\s*(.*)$/;
|
|
204
|
+
const multiStartRe = /^ [\w-]+:\s*[|>][-+]?\s*$/;
|
|
205
|
+
let inMulti = false;
|
|
206
|
+
for (const line of blockLines) {
|
|
207
|
+
if (inMulti) {
|
|
208
|
+
// Exit when we hit another 4-space field
|
|
209
|
+
if (/^ \S/.test(line) && !/^ /.test(line)) {
|
|
210
|
+
inMulti = false;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (multiStartRe.test(line)) {
|
|
217
|
+
inMulti = true;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const m = line.match(scalarRe);
|
|
221
|
+
if (m)
|
|
222
|
+
out.set(m[1], line);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
function findBlockEnd(lines, start) {
|
|
227
|
+
const anyStartRe = /^ - name:\s+/;
|
|
228
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
229
|
+
if (anyStartRe.test(lines[i]))
|
|
230
|
+
return i;
|
|
231
|
+
}
|
|
232
|
+
return lines.length;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Apply a proposed fix to the right CRON.md file. Idempotent with respect
|
|
236
|
+
* to already-applied ops (remove on a missing field is a no-op, set on a
|
|
237
|
+
* matching value is a no-op).
|
|
238
|
+
*/
|
|
239
|
+
export function applyFix(jobName, autoApply, opts = {}) {
|
|
240
|
+
const cronFile = resolveCronFile(jobName, autoApply);
|
|
241
|
+
if (!cronFile) {
|
|
242
|
+
return { ok: false, message: `No CRON.md found for ${jobName}` };
|
|
243
|
+
}
|
|
244
|
+
const bare = bareJobName(jobName);
|
|
245
|
+
const original = readFileSync(cronFile, 'utf-8');
|
|
246
|
+
const lines = original.split('\n');
|
|
247
|
+
const block = findJobBlock(lines, bare);
|
|
248
|
+
if (!block) {
|
|
249
|
+
return { ok: false, message: `Job '${bare}' not found in ${cronFile}`, file: cronFile };
|
|
250
|
+
}
|
|
251
|
+
const { newLines, applied, skipped } = applyOperations(lines, block, autoApply.operations);
|
|
252
|
+
if (applied.length === 0) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
message: 'Nothing to apply (all ops were no-ops or on disallowed fields)',
|
|
256
|
+
file: cronFile,
|
|
257
|
+
appliedOps: applied,
|
|
258
|
+
skippedOps: skipped,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const newBlockEnd = findBlockEnd(newLines, block.start);
|
|
262
|
+
const diff = makeDiff(lines, newLines, block.start, newBlockEnd);
|
|
263
|
+
if (opts.dryRun) {
|
|
264
|
+
return {
|
|
265
|
+
ok: true,
|
|
266
|
+
message: `Dry run: ${applied.length} op(s) would apply`,
|
|
267
|
+
file: cronFile,
|
|
268
|
+
appliedOps: applied,
|
|
269
|
+
skippedOps: skipped,
|
|
270
|
+
diff,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// Backup before write
|
|
274
|
+
try {
|
|
275
|
+
copyFileSync(cronFile, cronFile + '.bak');
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
logger.warn({ err, file: cronFile }, 'Failed to write .bak before applying fix');
|
|
279
|
+
}
|
|
280
|
+
const newContent = newLines.join('\n');
|
|
281
|
+
writeFileSync(cronFile, newContent);
|
|
282
|
+
appendAudit({
|
|
283
|
+
jobName,
|
|
284
|
+
file: cronFile,
|
|
285
|
+
applied,
|
|
286
|
+
skipped,
|
|
287
|
+
diff,
|
|
288
|
+
});
|
|
289
|
+
logger.info({ jobName, file: cronFile, applied: applied.length }, 'Applied cron job fix');
|
|
290
|
+
return {
|
|
291
|
+
ok: true,
|
|
292
|
+
message: `Applied ${applied.length} op(s) to ${path.basename(cronFile)}`,
|
|
293
|
+
file: cronFile,
|
|
294
|
+
appliedOps: applied,
|
|
295
|
+
skippedOps: skipped,
|
|
296
|
+
diff,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function appendAudit(entry) {
|
|
300
|
+
try {
|
|
301
|
+
mkdirSync(path.dirname(AUDIT_FILE), { recursive: true });
|
|
302
|
+
appendFileSync(AUDIT_FILE, JSON.stringify({ ...entry, timestamp: new Date().toISOString() }) + '\n');
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
logger.warn({ err }, 'Failed to append fix-applier audit');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
//# sourceMappingURL=fix-applier.js.map
|