clementine-agent 1.18.74 → 1.18.76
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/agent/advisor-rules/engine.js +7 -3
- package/dist/agent/execution-advisor.js +1 -1
- package/dist/channels/discord-agent-bot.js +1 -1
- package/dist/cli/cron.js +1 -1
- package/dist/cli/dashboard.js +253 -45
- package/dist/gateway/job-health.js +10 -2
- package/dist/types.d.ts +11 -2
- package/package.json +1 -1
|
@@ -70,14 +70,18 @@ export function evaluateWhen(c, ctx) {
|
|
|
70
70
|
const lastRun = ctx.recentRuns[0];
|
|
71
71
|
if (!lastRun)
|
|
72
72
|
return false;
|
|
73
|
-
|
|
73
|
+
// finishedAt is optional (in-progress runs are 'running' with no
|
|
74
|
+
// finishedAt). Fall back to startedAt so the rule still has a clock.
|
|
75
|
+
const lastRunTime = new Date(lastRun.finishedAt ?? lastRun.startedAt).getTime();
|
|
74
76
|
return ctx.nowMs - lastRunTime > c.ms;
|
|
75
77
|
}
|
|
76
78
|
case 'lastRunWithinMs': {
|
|
77
79
|
const lastRun = ctx.recentRuns[0];
|
|
78
80
|
if (!lastRun)
|
|
79
81
|
return false;
|
|
80
|
-
|
|
82
|
+
// finishedAt is optional (in-progress runs are 'running' with no
|
|
83
|
+
// finishedAt). Fall back to startedAt so the rule still has a clock.
|
|
84
|
+
const lastRunTime = new Date(lastRun.finishedAt ?? lastRun.startedAt).getTime();
|
|
81
85
|
return ctx.nowMs - lastRunTime <= c.ms;
|
|
82
86
|
}
|
|
83
87
|
case 'noRecentRuns':
|
|
@@ -185,7 +189,7 @@ const TEMPLATE_VARS = {
|
|
|
185
189
|
const lastRun = ctx.recentRuns[0];
|
|
186
190
|
if (!lastRun)
|
|
187
191
|
return 0;
|
|
188
|
-
const lastRunTime = new Date(lastRun.finishedAt).getTime();
|
|
192
|
+
const lastRunTime = new Date(lastRun.finishedAt ?? lastRun.startedAt).getTime();
|
|
189
193
|
const elapsed = ctx.nowMs - lastRunTime;
|
|
190
194
|
const cooldown = 60 * 60 * 1000;
|
|
191
195
|
return Math.max(0, Math.ceil((cooldown - elapsed) / 60_000));
|
|
@@ -75,7 +75,7 @@ function computeLegacyAdvice(jobName, job) {
|
|
|
75
75
|
advice.skipReason = `${consecutiveErrors} consecutive errors — circuit breaker engaged`;
|
|
76
76
|
return advice;
|
|
77
77
|
}
|
|
78
|
-
const lastRunTime = new Date(lastRun.finishedAt).getTime();
|
|
78
|
+
const lastRunTime = new Date(lastRun.finishedAt ?? lastRun.startedAt).getTime();
|
|
79
79
|
const elapsed = Date.now() - lastRunTime;
|
|
80
80
|
if (elapsed < CIRCUIT_BREAKER_COOLDOWN_MS) {
|
|
81
81
|
advice.shouldSkip = true;
|
|
@@ -366,7 +366,7 @@ export class AgentBotClient {
|
|
|
366
366
|
if (recent.length > 0) {
|
|
367
367
|
const r = recent[0];
|
|
368
368
|
const icon = r.status === 'ok' ? '\u2705' : r.status === 'error' ? '\u274C' : '\u23ED';
|
|
369
|
-
const ago = Math.round((Date.now() - new Date(r.finishedAt).getTime()) / 60000);
|
|
369
|
+
const ago = Math.round((Date.now() - new Date(r.finishedAt ?? r.startedAt).getTime()) / 60000);
|
|
370
370
|
lastRunLines.push(`${icon} ${display} \u2014 ${formatAgentDuration(ago)} ago`);
|
|
371
371
|
}
|
|
372
372
|
else {
|
package/dist/cli/cron.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function cmdCronList() {
|
|
|
48
48
|
const status = job.enabled ? 'enabled' : 'disabled';
|
|
49
49
|
const recent = runLog.readRecent(job.name, 1);
|
|
50
50
|
const lastRun = recent.length > 0
|
|
51
|
-
? `last run: ${recent[0].finishedAt.slice(0, 16).replace('T', ' ')} (${recent[0].status})`
|
|
51
|
+
? `last run: ${(recent[0].finishedAt ?? recent[0].startedAt).slice(0, 16).replace('T', ' ')} (${recent[0].status})`
|
|
52
52
|
: 'never run';
|
|
53
53
|
const errors = runLog.consecutiveErrors(job.name);
|
|
54
54
|
const errorTag = errors > 0 ? ` [${errors} consecutive error(s)]` : '';
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -1350,20 +1350,13 @@ function getCronJobs() {
|
|
|
1350
1350
|
}
|
|
1351
1351
|
catch { /* ignore */ }
|
|
1352
1352
|
}
|
|
1353
|
-
// Attach recent run history
|
|
1354
|
-
|
|
1353
|
+
// Attach recent run history. Single source of truth via CronRunLog.readRecent
|
|
1354
|
+
// — same path the new /api/cron/runs cross-job endpoint uses, so per-card
|
|
1355
|
+
// last-run and the Recent History zone never disagree.
|
|
1356
|
+
const log = new CronRunLog();
|
|
1355
1357
|
const enriched = jobs.map((job) => {
|
|
1356
1358
|
const name = String(job.name ?? '');
|
|
1357
|
-
const
|
|
1358
|
-
const logPath = path.join(runsDir, `${safe}.jsonl`);
|
|
1359
|
-
let recentRuns = [];
|
|
1360
|
-
if (existsSync(logPath)) {
|
|
1361
|
-
try {
|
|
1362
|
-
const lines = readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean);
|
|
1363
|
-
recentRuns = lines.slice(-10).map((l) => JSON.parse(l)).reverse();
|
|
1364
|
-
}
|
|
1365
|
-
catch { /* ignore */ }
|
|
1366
|
-
}
|
|
1359
|
+
const recentRuns = log.readRecent(name, 10);
|
|
1367
1360
|
return { ...job, recentRuns };
|
|
1368
1361
|
});
|
|
1369
1362
|
return { jobs: enriched };
|
|
@@ -4267,6 +4260,10 @@ export async function cmdDashboard(opts) {
|
|
|
4267
4260
|
const agentSlug = body.agent ? (String(body.agent).trim() || undefined) : undefined;
|
|
4268
4261
|
// Parse the agent's draft if present; on any parse error fall back to the
|
|
4269
4262
|
// single-step stub so the user lands in the editor with something usable.
|
|
4263
|
+
// ── KEY FIX (1.18.75): forward EVERY step-kind body the agent produces.
|
|
4264
|
+
// Previously this parser dropped mcp/cli/channel/transform/conditional/loop
|
|
4265
|
+
// configs, leaving the user with prompt-only stubs and forcing them to
|
|
4266
|
+
// rebuild every Slack-send / shell-cmd / branch by hand.
|
|
4270
4267
|
let steps = null;
|
|
4271
4268
|
if (body.draftYaml && typeof body.draftYaml === 'string') {
|
|
4272
4269
|
try {
|
|
@@ -4277,16 +4274,39 @@ export async function cmdDashboard(opts) {
|
|
|
4277
4274
|
const dependsOn = Array.isArray(r.dependsOn)
|
|
4278
4275
|
? r.dependsOn.map(String)
|
|
4279
4276
|
: (typeof r.dependsOn === 'string' ? r.dependsOn.split(',').map(s => s.trim()).filter(Boolean) : []);
|
|
4280
|
-
|
|
4277
|
+
const kind = typeof r.kind === 'string' ? r.kind : 'prompt';
|
|
4278
|
+
const step = {
|
|
4281
4279
|
id: String(id),
|
|
4282
|
-
prompt: String(r.prompt ?? ''),
|
|
4283
4280
|
dependsOn,
|
|
4284
4281
|
tier: typeof r.tier === 'number' ? r.tier : 1,
|
|
4285
4282
|
maxTurns: typeof r.maxTurns === 'number' ? r.maxTurns : 15,
|
|
4286
|
-
...(typeof r.model === 'string' ? { model: r.model } : {}),
|
|
4287
|
-
...(typeof r.workDir === 'string' ? { workDir: r.workDir } : {}),
|
|
4288
|
-
...(typeof r.kind === 'string' && r.kind !== 'prompt' ? { kind: r.kind } : {}),
|
|
4289
4283
|
};
|
|
4284
|
+
if (kind !== 'prompt')
|
|
4285
|
+
step.kind = kind;
|
|
4286
|
+
if (typeof r.model === 'string')
|
|
4287
|
+
step.model = r.model;
|
|
4288
|
+
if (typeof r.workDir === 'string')
|
|
4289
|
+
step.workDir = r.workDir;
|
|
4290
|
+
// Prompt body — present on prompt steps and optionally on others.
|
|
4291
|
+
if (r.prompt != null)
|
|
4292
|
+
step.prompt = String(r.prompt);
|
|
4293
|
+
else if (kind === 'prompt')
|
|
4294
|
+
step.prompt = '';
|
|
4295
|
+
// Non-prompt step bodies. We forward them as-is when shaped like
|
|
4296
|
+
// an object; the validator on save will catch missing required
|
|
4297
|
+
// sub-fields and surface a structured error to the chat UI.
|
|
4298
|
+
const passThrough = (key) => {
|
|
4299
|
+
const v = r[key];
|
|
4300
|
+
if (v && typeof v === 'object' && !Array.isArray(v))
|
|
4301
|
+
step[key] = v;
|
|
4302
|
+
};
|
|
4303
|
+
passThrough('mcp');
|
|
4304
|
+
passThrough('cli');
|
|
4305
|
+
passThrough('channel');
|
|
4306
|
+
passThrough('transform');
|
|
4307
|
+
passThrough('conditional');
|
|
4308
|
+
passThrough('loop');
|
|
4309
|
+
return step;
|
|
4290
4310
|
}).filter(s => s.id);
|
|
4291
4311
|
if (steps.length === 0)
|
|
4292
4312
|
steps = null;
|
|
@@ -4570,6 +4590,25 @@ export async function cmdDashboard(opts) {
|
|
|
4570
4590
|
app.post('/api/cron/run/:job', (req, res) => {
|
|
4571
4591
|
const jobName = req.params.job;
|
|
4572
4592
|
try {
|
|
4593
|
+
// Concurrency lock (1.18.76+): the CronScheduler persists a sidecar
|
|
4594
|
+
// ~/.clementine/cron-running.json with one entry per in-flight job.
|
|
4595
|
+
// If a manual Run Now collides with an already-running job (whether
|
|
4596
|
+
// from another Run Now click or a scheduled tick), reject 409 so the
|
|
4597
|
+
// user gets immediate feedback instead of two parallel runs racing
|
|
4598
|
+
// each other into the same outputs.
|
|
4599
|
+
try {
|
|
4600
|
+
const runningFile = path.join(BASE_DIR, 'cron-running.json');
|
|
4601
|
+
if (existsSync(runningFile)) {
|
|
4602
|
+
const entries = JSON.parse(readFileSync(runningFile, 'utf-8'));
|
|
4603
|
+
const collision = Array.isArray(entries) && entries.find(e => String(e.jobName ?? '').toLowerCase() === jobName.toLowerCase());
|
|
4604
|
+
if (collision) {
|
|
4605
|
+
const since = collision.startedAt ? ` (running since ${collision.startedAt})` : '';
|
|
4606
|
+
res.status(409).json({ ok: false, error: `Job "${jobName}" is already running${since}` });
|
|
4607
|
+
return;
|
|
4608
|
+
}
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
catch { /* if the sidecar is unreadable, fall through and let the runner handle dedup */ }
|
|
4573
4612
|
const child = spawn('node', [DIST_ENTRY, 'cron', 'run', jobName], {
|
|
4574
4613
|
detached: true,
|
|
4575
4614
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -6557,8 +6596,18 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
6557
6596
|
}
|
|
6558
6597
|
jobs[idx].enabled = !jobs[idx].enabled;
|
|
6559
6598
|
writeCronFileAt(cronFile, parsed, jobs);
|
|
6560
|
-
const
|
|
6561
|
-
|
|
6599
|
+
const enabled = jobs[idx].enabled;
|
|
6600
|
+
const state = enabled ? 'enabled' : 'disabled';
|
|
6601
|
+
// Broadcast so other open dashboard tabs flip the toggle without polling.
|
|
6602
|
+
try {
|
|
6603
|
+
broadcastEvent({ type: 'cron_toggled', data: { job: bareJobName, enabled } });
|
|
6604
|
+
}
|
|
6605
|
+
catch { /* non-fatal */ }
|
|
6606
|
+
// The CronScheduler's watchFile polls CRON.md every 2s and reloads
|
|
6607
|
+
// automatically (cron-scheduler.ts:756 watchCronFile), so the toggle
|
|
6608
|
+
// takes effect within 2 seconds without an explicit reload here. We
|
|
6609
|
+
// surface a hint to the user about that latency in the response.
|
|
6610
|
+
res.json({ ok: true, message: `${jobName} is now ${state}`, enabled });
|
|
6562
6611
|
}
|
|
6563
6612
|
catch (err) {
|
|
6564
6613
|
res.status(500).json({ error: String(err) });
|
|
@@ -6576,7 +6625,52 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
6576
6625
|
}
|
|
6577
6626
|
jobs.splice(idx, 1);
|
|
6578
6627
|
writeCronFileAt(cronFile, parsed, jobs);
|
|
6579
|
-
|
|
6628
|
+
// Cascade cleanup unless explicitly opted out (?purge=false). Storing the
|
|
6629
|
+
// job name's safe filename form once — same sanitization the run-log and
|
|
6630
|
+
// trace writers use, so we hit the right files.
|
|
6631
|
+
const purge = String(req.query.purge ?? 'true') !== 'false';
|
|
6632
|
+
const purged = [];
|
|
6633
|
+
if (purge) {
|
|
6634
|
+
const safe = bareJobName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
6635
|
+
const runLog = path.join(BASE_DIR, 'cron', 'runs', `${safe}.jsonl`);
|
|
6636
|
+
try {
|
|
6637
|
+
if (existsSync(runLog)) {
|
|
6638
|
+
unlinkSync(runLog);
|
|
6639
|
+
purged.push('runs.jsonl');
|
|
6640
|
+
}
|
|
6641
|
+
}
|
|
6642
|
+
catch { /* non-fatal */ }
|
|
6643
|
+
try {
|
|
6644
|
+
const traceDir = path.join(BASE_DIR, 'cron', 'traces');
|
|
6645
|
+
if (existsSync(traceDir)) {
|
|
6646
|
+
const traceFiles = readdirSync(traceDir).filter(f => f.startsWith(`${safe}_`) && f.endsWith('.json'));
|
|
6647
|
+
for (const f of traceFiles) {
|
|
6648
|
+
try {
|
|
6649
|
+
unlinkSync(path.join(traceDir, f));
|
|
6650
|
+
}
|
|
6651
|
+
catch { /* skip */ }
|
|
6652
|
+
}
|
|
6653
|
+
if (traceFiles.length > 0)
|
|
6654
|
+
purged.push(`${traceFiles.length} trace${traceFiles.length === 1 ? '' : 's'}`);
|
|
6655
|
+
}
|
|
6656
|
+
}
|
|
6657
|
+
catch { /* non-fatal */ }
|
|
6658
|
+
try {
|
|
6659
|
+
const uploadsDir = path.join(BASE_DIR, 'uploads', `cron-${safe}`);
|
|
6660
|
+
if (existsSync(uploadsDir)) {
|
|
6661
|
+
rmSync(uploadsDir, { recursive: true, force: true });
|
|
6662
|
+
purged.push('attachments');
|
|
6663
|
+
}
|
|
6664
|
+
}
|
|
6665
|
+
catch { /* non-fatal */ }
|
|
6666
|
+
}
|
|
6667
|
+
// Notify other dashboard tabs so they drop the card without polling.
|
|
6668
|
+
try {
|
|
6669
|
+
broadcastEvent({ type: 'cron_deleted', data: { job: bareJobName, purged } });
|
|
6670
|
+
}
|
|
6671
|
+
catch { /* non-fatal */ }
|
|
6672
|
+
const purgeNote = purged.length > 0 ? ` (purged: ${purged.join(', ')})` : '';
|
|
6673
|
+
res.json({ ok: true, message: `Deleted cron job: ${jobName}${purgeNote}`, purged });
|
|
6580
6674
|
}
|
|
6581
6675
|
catch (err) {
|
|
6582
6676
|
res.status(500).json({ error: String(err) });
|
|
@@ -16422,7 +16516,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16422
16516
|
apiFetch('/api/routines/' + encodeURIComponent(id) + '/toggle', { method: 'POST' })
|
|
16423
16517
|
.then(function(r){ return r.json(); })
|
|
16424
16518
|
.then(function(){ R.refreshList(); })
|
|
16425
|
-
.catch(function(err){
|
|
16519
|
+
.catch(function(err){ toast('Toggle failed: ' + err, 'error'); });
|
|
16426
16520
|
},
|
|
16427
16521
|
run: function(id, approvedSideEffects) {
|
|
16428
16522
|
apiFetch('/api/routines/' + encodeURIComponent(id) + '/run', {
|
|
@@ -16438,19 +16532,19 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16438
16532
|
}
|
|
16439
16533
|
return r.json().then(function(j){
|
|
16440
16534
|
if (j.ok) R.flash('Triggered.');
|
|
16441
|
-
else
|
|
16535
|
+
else toast('Run failed: ' + (j.error || 'unknown'), 'error');
|
|
16442
16536
|
});
|
|
16443
|
-
}).catch(function(err){
|
|
16537
|
+
}).catch(function(err){ toast('Run failed: ' + err, 'error'); });
|
|
16444
16538
|
},
|
|
16445
16539
|
// ── editor ──────────────────────────────────────────────────
|
|
16446
16540
|
openEditor: function(id) {
|
|
16447
16541
|
apiFetch('/api/routines/' + encodeURIComponent(id))
|
|
16448
16542
|
.then(function(r){ return r.json(); })
|
|
16449
16543
|
.then(function(data){
|
|
16450
|
-
if (!data || !data.routine) {
|
|
16544
|
+
if (!data || !data.routine) { toast('Failed to load workflow', 'error'); return; }
|
|
16451
16545
|
R.state.editing = { id: data.id, routine: data.routine, dirty: false, validation: data.validation };
|
|
16452
16546
|
R.showEditor();
|
|
16453
|
-
}).catch(function(err){
|
|
16547
|
+
}).catch(function(err){ toast('Open failed: ' + err, 'error'); });
|
|
16454
16548
|
},
|
|
16455
16549
|
showEditor: function() {
|
|
16456
16550
|
document.getElementById('routines-list-pane').style.display = 'none';
|
|
@@ -16702,7 +16796,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16702
16796
|
},
|
|
16703
16797
|
removeStep: function(idx) {
|
|
16704
16798
|
if (!R.state.editing) return;
|
|
16705
|
-
if (R.state.editing.routine.steps.length <= 1) {
|
|
16799
|
+
if (R.state.editing.routine.steps.length <= 1) { toast('A workflow must have at least one step.', 'error'); return; }
|
|
16706
16800
|
if (!confirm('Remove this step?')) return;
|
|
16707
16801
|
var removed = R.state.editing.routine.steps.splice(idx, 1)[0];
|
|
16708
16802
|
// Strip lingering dependsOn references.
|
|
@@ -16803,7 +16897,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16803
16897
|
(d.steps || []).forEach(function(s){ lines.push('• ' + s.description + (s.warnings.length ? '\\n ⚠ ' + s.warnings.join('; ') : '')); });
|
|
16804
16898
|
if (d.notes && d.notes.length) lines.push('\\n' + d.notes.join('\\n'));
|
|
16805
16899
|
alert(lines.join('\\n'));
|
|
16806
|
-
}).catch(function(err){
|
|
16900
|
+
}).catch(function(err){ toast('Dry-run failed: ' + err, 'error'); });
|
|
16807
16901
|
},
|
|
16808
16902
|
testCurrent: function() {
|
|
16809
16903
|
if (!R.state.editing) return;
|
|
@@ -16822,9 +16916,9 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16822
16916
|
apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id), { method: 'DELETE' })
|
|
16823
16917
|
.then(function(r){ return r.json(); })
|
|
16824
16918
|
.then(function(j){
|
|
16825
|
-
if (j.ok) { R.state.editing = null; R.closeEditor(); R.refreshList(); }
|
|
16826
|
-
else
|
|
16827
|
-
}).catch(function(err){
|
|
16919
|
+
if (j.ok) { R.state.editing = null; R.closeEditor(); R.refreshList(); toast('Deleted.', 'success'); }
|
|
16920
|
+
else toast('Delete failed: ' + (j.error || 'unknown'), 'error');
|
|
16921
|
+
}).catch(function(err){ toast('Delete error: ' + err, 'error'); });
|
|
16828
16922
|
},
|
|
16829
16923
|
setStatus: function(msg) {
|
|
16830
16924
|
var el = document.getElementById('re-status');
|
|
@@ -16879,7 +16973,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16879
16973
|
},
|
|
16880
16974
|
submitCreate: function() {
|
|
16881
16975
|
var name = document.getElementById('routines-create-name').value.trim();
|
|
16882
|
-
if (!name) {
|
|
16976
|
+
if (!name) { toast('Name is required', 'error'); document.getElementById('routines-create-name').focus(); return; }
|
|
16883
16977
|
var body = {
|
|
16884
16978
|
name: name,
|
|
16885
16979
|
description: document.getElementById('routines-create-description').value.trim(),
|
|
@@ -16892,11 +16986,12 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16892
16986
|
body: JSON.stringify(body)
|
|
16893
16987
|
}).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
|
|
16894
16988
|
.then(function(res){
|
|
16895
|
-
if (!res.ok) {
|
|
16989
|
+
if (!res.ok) { toast('Create failed: ' + (res.body.error || 'unknown'), 'error'); return; }
|
|
16896
16990
|
R.closeCreate();
|
|
16897
16991
|
R.refreshList();
|
|
16898
16992
|
R.openEditor(res.body.id);
|
|
16899
|
-
|
|
16993
|
+
toast('Workflow created.', 'success');
|
|
16994
|
+
}).catch(function(err){ toast('Create error: ' + err, 'error'); });
|
|
16900
16995
|
},
|
|
16901
16996
|
// ── chat-first builder ──────────────────────────────────────
|
|
16902
16997
|
// Multi-turn conversation that asks clarifying questions and
|
|
@@ -17153,14 +17248,15 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
17153
17248
|
.then(function(res){
|
|
17154
17249
|
if (btn) { btn.textContent = 'Save workflow'; btn.disabled = false; }
|
|
17155
17250
|
if (!res.ok) {
|
|
17156
|
-
|
|
17251
|
+
toast('Save failed: ' + (res.body && res.body.error || 'unknown'), 'error');
|
|
17157
17252
|
return;
|
|
17158
17253
|
}
|
|
17254
|
+
toast('Workflow saved.', 'success');
|
|
17159
17255
|
R.closeChat();
|
|
17160
17256
|
if (res.body && res.body.id) R.openEditor(res.body.id);
|
|
17161
17257
|
}).catch(function(err){
|
|
17162
17258
|
if (btn) { btn.textContent = 'Save workflow'; btn.disabled = false; }
|
|
17163
|
-
|
|
17259
|
+
toast('Save failed: ' + err, 'error');
|
|
17164
17260
|
});
|
|
17165
17261
|
},
|
|
17166
17262
|
// ── helpers ─────────────────────────────────────────────────
|
|
@@ -19950,7 +20046,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
19950
20046
|
<div class="cap-section">
|
|
19951
20047
|
<label class="cap-section-label">Tags</label>
|
|
19952
20048
|
<div class="cap-picker-chips" id="cron-tags-chips"></div>
|
|
19953
|
-
<input type="text" class="cap-tag-input" id="cron-tags-input" placeholder="Type a tag and press Enter (e.g. morning, ops)" onkeydown="handleTagInputKeydown(event)">
|
|
20049
|
+
<input type="text" class="cap-tag-input" id="cron-tags-input" placeholder="Type a tag and press Enter, Tab, or comma (e.g. morning, ops)" onkeydown="handleTagInputKeydown(event)" onblur="handleTagInputBlur()">
|
|
19954
20050
|
</div>
|
|
19955
20051
|
</div>
|
|
19956
20052
|
|
|
@@ -23367,7 +23463,7 @@ async function openTraceViewer(jobName) {
|
|
|
23367
23463
|
traceData = d.traces || [];
|
|
23368
23464
|
|
|
23369
23465
|
if (traceData.length === 0) {
|
|
23370
|
-
document.getElementById('trace-content').innerHTML = '<div style="padding:
|
|
23466
|
+
document.getElementById('trace-content').innerHTML = '<div style="padding:24px;color:var(--text-muted);line-height:1.6"><div style="font-weight:500;color:var(--text-secondary);margin-bottom:8px">No traces recorded yet</div><div style="font-size:12px">Traces are captured per run and show every tool call the agent made.<br/>If a recent run errored before any tool was invoked, see the <strong>Recent history</strong> zone on the Tasks page for the error message.</div></div>';
|
|
23371
23467
|
document.getElementById('trace-run-selector').innerHTML = '';
|
|
23372
23468
|
return;
|
|
23373
23469
|
}
|
|
@@ -23419,7 +23515,7 @@ function renderTrace(idx) {
|
|
|
23419
23515
|
+ '</div>';
|
|
23420
23516
|
}
|
|
23421
23517
|
|
|
23422
|
-
document.getElementById('trace-content').innerHTML = html || '<div style="padding:
|
|
23518
|
+
document.getElementById('trace-content').innerHTML = html || '<div style="padding:24px;color:var(--text-muted);line-height:1.6"><div style="font-weight:500;color:var(--text-secondary);margin-bottom:8px">This run produced no tool calls</div><div style="font-size:12px">The run started but didn\\x27t reach any tool — it likely errored before the agent acted, or was a no-op response.<br/>Check <strong>Recent history</strong> on the Tasks page for the error message or output preview from this run.</div></div>';
|
|
23423
23519
|
}
|
|
23424
23520
|
|
|
23425
23521
|
// ── Inline Trace (Execution tab) ──
|
|
@@ -24472,8 +24568,24 @@ function toggleAllowedToolsPanel() {
|
|
|
24472
24568
|
}
|
|
24473
24569
|
|
|
24474
24570
|
function handleTagInputKeydown(event) {
|
|
24475
|
-
|
|
24571
|
+
// Accept Enter, Tab, and comma as commit keys. Tab still moves focus
|
|
24572
|
+
// afterward when the field is empty (no preventDefault). On-blur
|
|
24573
|
+
// commit is handled by handleTagInputBlur below.
|
|
24574
|
+
if (event.key !== 'Enter' && event.key !== ',' && event.key !== 'Tab') return;
|
|
24575
|
+
var inp = document.getElementById('cron-tags-input');
|
|
24576
|
+
if (!inp) return;
|
|
24577
|
+
var val = inp.value.trim().replace(/^#+/, '');
|
|
24578
|
+
if (!val) return; // Tab on empty input → let it move focus naturally
|
|
24476
24579
|
event.preventDefault();
|
|
24580
|
+
if (_cronTags.indexOf(val) === -1) {
|
|
24581
|
+
_cronTags.push(val);
|
|
24582
|
+
renderTagsPickerChips();
|
|
24583
|
+
}
|
|
24584
|
+
inp.value = '';
|
|
24585
|
+
}
|
|
24586
|
+
|
|
24587
|
+
function handleTagInputBlur() {
|
|
24588
|
+
// Catch tags the user typed but forgot to commit before clicking elsewhere.
|
|
24477
24589
|
var inp = document.getElementById('cron-tags-input');
|
|
24478
24590
|
if (!inp) return;
|
|
24479
24591
|
var val = inp.value.trim().replace(/^#+/, '');
|
|
@@ -24669,6 +24781,9 @@ function openCreateCronModal(agentSlug) {
|
|
|
24669
24781
|
switchCronTab('configure');
|
|
24670
24782
|
onPredictableChange();
|
|
24671
24783
|
document.getElementById('cron-modal').classList.add('show');
|
|
24784
|
+
// Snapshot AFTER all defaults are populated so an immediate close with no
|
|
24785
|
+
// edits doesn't trigger the "discard changes?" prompt.
|
|
24786
|
+
setTimeout(captureCronModalSnapshot, 0);
|
|
24672
24787
|
}
|
|
24673
24788
|
|
|
24674
24789
|
function openEditCronModal(jobName) {
|
|
@@ -24730,6 +24845,7 @@ function openEditCronModal(jobName) {
|
|
|
24730
24845
|
if (previewBtn) previewBtn.removeAttribute('disabled');
|
|
24731
24846
|
switchCronTab('configure');
|
|
24732
24847
|
document.getElementById('cron-modal').classList.add('show');
|
|
24848
|
+
setTimeout(captureCronModalSnapshot, 0);
|
|
24733
24849
|
}
|
|
24734
24850
|
|
|
24735
24851
|
/**
|
|
@@ -24864,10 +24980,68 @@ function renderCronPreview(d) {
|
|
|
24864
24980
|
return html;
|
|
24865
24981
|
}
|
|
24866
24982
|
|
|
24867
|
-
|
|
24983
|
+
// Snapshot of the form values at the moment the modal opened. closeCronModal
|
|
24984
|
+
// compares against this and prompts the user before discarding edits.
|
|
24985
|
+
var _cronModalSnapshot = null;
|
|
24986
|
+
|
|
24987
|
+
function captureCronModalSnapshot() {
|
|
24988
|
+
// String concatenation is enough for a dirty check; we don't need the
|
|
24989
|
+
// structured object back. Order must match restoreCheck below.
|
|
24990
|
+
function v(id) { var el = document.getElementById(id); return el ? (el.value || '') : ''; }
|
|
24991
|
+
_cronModalSnapshot = [
|
|
24992
|
+
v('cron-name'),
|
|
24993
|
+
v('cron-schedule'),
|
|
24994
|
+
v('cron-prompt'),
|
|
24995
|
+
v('cron-context'),
|
|
24996
|
+
v('cron-tier'),
|
|
24997
|
+
v('cron-mode'),
|
|
24998
|
+
v('cron-maxhours'),
|
|
24999
|
+
v('cron-max-retries'),
|
|
25000
|
+
v('cron-after'),
|
|
25001
|
+
v('cron-workdir'),
|
|
25002
|
+
v('cron-allowed-tools'),
|
|
25003
|
+
v('cron-category'),
|
|
25004
|
+
(document.getElementById('cron-predictable') || {}).checked ? '1' : '0',
|
|
25005
|
+
JSON.stringify(_cronSelectedSkills || []),
|
|
25006
|
+
JSON.stringify(_cronSelectedMcp || []),
|
|
25007
|
+
JSON.stringify(_cronTags || []),
|
|
25008
|
+
String((_pendingAttachments || []).length),
|
|
25009
|
+
].join('\\u0001');
|
|
25010
|
+
}
|
|
25011
|
+
|
|
25012
|
+
function isCronModalDirty() {
|
|
25013
|
+
if (_cronModalSnapshot === null) return false;
|
|
25014
|
+
function v(id) { var el = document.getElementById(id); return el ? (el.value || '') : ''; }
|
|
25015
|
+
var current = [
|
|
25016
|
+
v('cron-name'),
|
|
25017
|
+
v('cron-schedule'),
|
|
25018
|
+
v('cron-prompt'),
|
|
25019
|
+
v('cron-context'),
|
|
25020
|
+
v('cron-tier'),
|
|
25021
|
+
v('cron-mode'),
|
|
25022
|
+
v('cron-maxhours'),
|
|
25023
|
+
v('cron-max-retries'),
|
|
25024
|
+
v('cron-after'),
|
|
25025
|
+
v('cron-workdir'),
|
|
25026
|
+
v('cron-allowed-tools'),
|
|
25027
|
+
v('cron-category'),
|
|
25028
|
+
(document.getElementById('cron-predictable') || {}).checked ? '1' : '0',
|
|
25029
|
+
JSON.stringify(_cronSelectedSkills || []),
|
|
25030
|
+
JSON.stringify(_cronSelectedMcp || []),
|
|
25031
|
+
JSON.stringify(_cronTags || []),
|
|
25032
|
+
String((_pendingAttachments || []).length),
|
|
25033
|
+
].join('\\u0001');
|
|
25034
|
+
return current !== _cronModalSnapshot;
|
|
25035
|
+
}
|
|
25036
|
+
|
|
25037
|
+
function closeCronModal(force) {
|
|
25038
|
+
if (force !== true && isCronModalDirty()) {
|
|
25039
|
+
if (!confirm('You have unsaved changes. Discard them?')) return;
|
|
25040
|
+
}
|
|
24868
25041
|
document.getElementById('cron-modal').classList.remove('show');
|
|
24869
25042
|
editingCronJob = null;
|
|
24870
25043
|
_cronPreviewLoadedFor = null;
|
|
25044
|
+
_cronModalSnapshot = null;
|
|
24871
25045
|
var attachList = document.getElementById('cron-attachments-list');
|
|
24872
25046
|
if (attachList) attachList.innerHTML = '';
|
|
24873
25047
|
var bannerHost = document.getElementById('cron-legacy-banner-host');
|
|
@@ -25001,10 +25175,29 @@ async function saveCronJob() {
|
|
|
25001
25175
|
const allowedTools = parseAllowedToolsRaw();
|
|
25002
25176
|
const predictable = !!document.getElementById('cron-predictable')?.checked;
|
|
25003
25177
|
|
|
25004
|
-
|
|
25005
|
-
|
|
25178
|
+
// Field-specific validation. Toasting one message per problem is more
|
|
25179
|
+
// actionable than the old "Please fill in all fields" — and we bail before
|
|
25180
|
+
// hitting the API so the user sees the issue without a round-trip.
|
|
25181
|
+
if (!name) { toast('Task name is required', 'error'); document.getElementById('cron-name').focus(); return; }
|
|
25182
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(name)) {
|
|
25183
|
+
toast('Task name must start with a lowercase letter and contain only a–z, 0–9, and hyphens (max 64 chars)', 'error');
|
|
25184
|
+
document.getElementById('cron-name').focus();
|
|
25185
|
+
return;
|
|
25186
|
+
}
|
|
25187
|
+
if (!editingCronJob && Array.isArray(cronJobsData)) {
|
|
25188
|
+
var dup = cronJobsData.find(function(j) { return String(j.name || '').toLowerCase() === name.toLowerCase(); });
|
|
25189
|
+
if (dup) { toast('A task named "' + name + '" already exists', 'error'); document.getElementById('cron-name').focus(); return; }
|
|
25190
|
+
}
|
|
25191
|
+
if (!schedule) { toast('Schedule is required', 'error'); return; }
|
|
25192
|
+
// Light client-side cron sanity check — server uses real cron-parser.
|
|
25193
|
+
// Catches obvious garbage like "foo bar" without making a round-trip.
|
|
25194
|
+
// Note: the hyphen sits at the END of the character class to avoid being
|
|
25195
|
+
// interpreted as a range. \\\\s in source → \\s in served JS → \s in regex.
|
|
25196
|
+
if (!/^([0-9*/, -]+|@(yearly|annually|monthly|weekly|daily|hourly|reboot))$/i.test(schedule) && schedule.split(/\\s+/).length < 5) {
|
|
25197
|
+
toast('Schedule does not look like a valid cron expression', 'error');
|
|
25006
25198
|
return;
|
|
25007
25199
|
}
|
|
25200
|
+
if (!prompt) { toast('Prompt is required — tell the agent what to do', 'error'); document.getElementById('cron-prompt').focus(); return; }
|
|
25008
25201
|
|
|
25009
25202
|
const body = {
|
|
25010
25203
|
name, schedule, tier, prompt, enabled: true,
|
|
@@ -25026,12 +25219,18 @@ async function saveCronJob() {
|
|
|
25026
25219
|
};
|
|
25027
25220
|
|
|
25028
25221
|
var wasEditing = !!editingCronJob;
|
|
25222
|
+
// Don't celebrate before the round-trip lands. apiJson toasts on its own;
|
|
25223
|
+
// we just check the {ok} flag and bail so the modal doesn't close (and
|
|
25224
|
+
// erase the user's input) on a 400/409/500.
|
|
25225
|
+
var resp;
|
|
25029
25226
|
if (editingCronJob) {
|
|
25030
|
-
await apiJson('PUT', '/api/cron/' + encodeURIComponent(editingCronJob), body);
|
|
25227
|
+
resp = await apiJson('PUT', '/api/cron/' + encodeURIComponent(editingCronJob), body);
|
|
25228
|
+
if (!resp || resp.ok !== true) return;
|
|
25031
25229
|
if (_pendingAttachments.length > 0) await uploadPendingAttachments(editingCronJob);
|
|
25032
25230
|
} else {
|
|
25033
25231
|
var createBody = _cronAgentContext ? Object.assign({}, body, { agent: _cronAgentContext }) : body;
|
|
25034
|
-
await apiJson('POST', '/api/cron', createBody);
|
|
25232
|
+
resp = await apiJson('POST', '/api/cron', createBody);
|
|
25233
|
+
if (!resp || resp.ok !== true) return;
|
|
25035
25234
|
var attachJobName = _cronAgentContext ? (_cronAgentContext + ':' + name) : name;
|
|
25036
25235
|
if (_pendingAttachments.length > 0) await uploadPendingAttachments(attachJobName);
|
|
25037
25236
|
}
|
|
@@ -25041,11 +25240,14 @@ async function saveCronJob() {
|
|
|
25041
25240
|
// confirm what they just saved actually runs the way they intended.
|
|
25042
25241
|
// This is the close-the-loop UX move that makes Predictable Mode visible.
|
|
25043
25242
|
markCronPreviewDirty();
|
|
25243
|
+
// Re-snapshot now that the form matches what's on disk — prevents the
|
|
25244
|
+
// dirty-guard from firing if the user closes the modal without further edits.
|
|
25245
|
+
captureCronModalSnapshot();
|
|
25044
25246
|
if (wasEditing) {
|
|
25045
25247
|
toast('Saved. Showing what will run…', 'success');
|
|
25046
25248
|
switchCronTab('preview');
|
|
25047
25249
|
} else {
|
|
25048
|
-
closeCronModal();
|
|
25250
|
+
closeCronModal(true); // force; we just saved, no dirty prompt
|
|
25049
25251
|
}
|
|
25050
25252
|
}
|
|
25051
25253
|
|
|
@@ -34500,6 +34702,12 @@ try {
|
|
|
34500
34702
|
if (currentPage === 'build') refreshCron();
|
|
34501
34703
|
refreshTeamNav();
|
|
34502
34704
|
}
|
|
34705
|
+
// A delete on one tab should drop the card from every open dashboard
|
|
34706
|
+
// without waiting for the next poll. cron_toggled is similar but lighter.
|
|
34707
|
+
if (evt.type === 'cron_deleted' || evt.type === 'cron_toggled') {
|
|
34708
|
+
if (currentPage === 'build') refreshCron();
|
|
34709
|
+
refreshTeamNav();
|
|
34710
|
+
}
|
|
34503
34711
|
if (evt.type === 'agent_created' || evt.type === 'agent_updated' || evt.type === 'agent_deleted' || evt.type === 'agent_status') {
|
|
34504
34712
|
refreshTeamNav();
|
|
34505
34713
|
refreshActivity();
|
|
@@ -85,12 +85,20 @@ export function classifyRunHealth(entry) {
|
|
|
85
85
|
requiresApproval: true,
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
-
|
|
88
|
+
// 'timeout' and 'lost' are explicit terminal failure states (1.18.76+).
|
|
89
|
+
// 'timeout' = the job exceeded max_hours. 'lost' = the daemon-boot sweep
|
|
90
|
+
// closed an orphaned 'running' entry that never got a finishedAt because
|
|
91
|
+
// the daemon crashed mid-run.
|
|
92
|
+
if (entry.status === 'error' || entry.status === 'retried' || entry.status === 'timeout' || entry.status === 'lost') {
|
|
89
93
|
return {
|
|
90
94
|
...base,
|
|
91
95
|
status: 'failed',
|
|
92
96
|
evidence: compactEvidence(entry.error, entry.outputPreview, terminalReason ? `terminalReason=${terminalReason}` : undefined),
|
|
93
|
-
recommendedAction:
|
|
97
|
+
recommendedAction: entry.status === 'lost'
|
|
98
|
+
? 'The daemon crashed before this run completed. Check the daemon logs around the startedAt timestamp; if this repeats, lower max_hours or investigate the underlying job.'
|
|
99
|
+
: entry.status === 'timeout'
|
|
100
|
+
? 'The job exceeded its time budget. Lower max_hours, narrow the prompt, or split into smaller steps.'
|
|
101
|
+
: 'Inspect the latest error and current job definition before applying a fix.',
|
|
94
102
|
requiresApproval: true,
|
|
95
103
|
};
|
|
96
104
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -392,8 +392,17 @@ export type TerminalReason = 'blocking_limit' | 'rapid_refill_breaker' | 'prompt
|
|
|
392
392
|
export interface CronRunEntry {
|
|
393
393
|
jobName: string;
|
|
394
394
|
startedAt: string;
|
|
395
|
-
|
|
396
|
-
|
|
395
|
+
/** Optional: in-progress runs are appended with status='running' before the
|
|
396
|
+
* finishedAt is known. The runner replaces or supersedes the entry on
|
|
397
|
+
* completion. The stale-running sweep emits a closing 'lost' entry for
|
|
398
|
+
* any 'running' row whose startedAt has aged past the deadline. */
|
|
399
|
+
finishedAt?: string;
|
|
400
|
+
/** 'ok' | 'error' | 'retried' | 'skipped' are terminal. 'running' is in-flight.
|
|
401
|
+
* 'timeout' fires when max_hours is exceeded. 'lost' is appended by the
|
|
402
|
+
* daemon-boot stale sweep when a 'running' entry has no companion close
|
|
403
|
+
* (daemon likely crashed mid-run). */
|
|
404
|
+
status: 'ok' | 'error' | 'retried' | 'skipped' | 'running' | 'timeout' | 'lost';
|
|
405
|
+
/** 0 for in-progress 'running' rows; populated when terminal. */
|
|
397
406
|
durationMs: number;
|
|
398
407
|
error?: string;
|
|
399
408
|
errorType?: 'transient' | 'permanent';
|