clementine-agent 1.18.25 → 1.18.26

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.
@@ -27,6 +27,7 @@ import { workflowsRouter } from './routes/workflows.js';
27
27
  import { digestRouter } from './routes/digest.js';
28
28
  import { loadClementineJson, updateClementineJson } from '../config/clementine-json.js';
29
29
  import { annotateUnleashedStatus } from '../gateway/unleashed-status.js';
30
+ import { buildOperationsSnapshot } from '../dashboard/build-operations.js';
30
31
  const __filename = fileURLToPath(import.meta.url);
31
32
  const __dirname = path.dirname(__filename);
32
33
  const BASE_DIR = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
@@ -1307,6 +1308,178 @@ function getCronJobs() {
1307
1308
  });
1308
1309
  return { jobs: enriched };
1309
1310
  }
1311
+ function getUnleashedTasksForDashboard() {
1312
+ const unleashedDir = path.join(BASE_DIR, 'unleashed');
1313
+ if (!existsSync(unleashedDir))
1314
+ return [];
1315
+ const tasks = [];
1316
+ try {
1317
+ for (const dir of readdirSync(unleashedDir)) {
1318
+ const dirPath = path.join(unleashedDir, dir);
1319
+ if (!statSync(dirPath).isDirectory())
1320
+ continue;
1321
+ const statusFile = path.join(dirPath, 'status.json');
1322
+ if (!existsSync(statusFile))
1323
+ continue;
1324
+ try {
1325
+ const status = JSON.parse(readFileSync(statusFile, 'utf-8'));
1326
+ tasks.push(annotateUnleashedStatus(status, dir));
1327
+ }
1328
+ catch { /* skip corrupt status */ }
1329
+ }
1330
+ }
1331
+ catch {
1332
+ return [];
1333
+ }
1334
+ return tasks.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
1335
+ }
1336
+ function classifyBuildUsageSession(sessionKey, source, agentSlug) {
1337
+ let kind = source || 'chat';
1338
+ let label = sessionKey || '(unknown)';
1339
+ let taskKey = sessionKey || '(unknown)';
1340
+ let controllable = false;
1341
+ let targetTab = 'sessions';
1342
+ if (sessionKey.startsWith('cron:')) {
1343
+ const name = sessionKey.slice('cron:'.length);
1344
+ kind = name.startsWith('goal:') ? 'goal task' : 'scheduled task';
1345
+ label = name;
1346
+ taskKey = name;
1347
+ controllable = true;
1348
+ targetTab = 'crons';
1349
+ }
1350
+ else if (sessionKey.startsWith('unleashed:')) {
1351
+ const name = sessionKey.slice('unleashed:'.length);
1352
+ kind = name.startsWith('bg:') ? 'background task' : 'long-running task';
1353
+ label = name.startsWith('bg:') ? `Deep task ${name.slice(3)}` : name;
1354
+ taskKey = name;
1355
+ controllable = true;
1356
+ targetTab = 'crons';
1357
+ }
1358
+ else if (sessionKey.startsWith('workflow:')) {
1359
+ const rest = sessionKey.slice('workflow:'.length);
1360
+ const splitAt = rest.lastIndexOf(':');
1361
+ const workflowName = splitAt > 0 ? rest.slice(0, splitAt) : rest;
1362
+ const stepName = splitAt > 0 ? rest.slice(splitAt + 1) : '';
1363
+ kind = 'workflow step';
1364
+ label = stepName ? `${workflowName} · ${stepName}` : workflowName;
1365
+ taskKey = workflowName;
1366
+ controllable = true;
1367
+ targetTab = 'workflows';
1368
+ }
1369
+ else if (sessionKey.startsWith('plan:')) {
1370
+ const step = sessionKey.slice('plan:'.length);
1371
+ kind = source === 'workflow_step' ? 'workflow step' : 'plan step';
1372
+ label = step;
1373
+ taskKey = step;
1374
+ targetTab = 'workflows';
1375
+ }
1376
+ else if (sessionKey.startsWith('discord:') || sessionKey.startsWith('chat:') || source === 'chat') {
1377
+ kind = 'chat session';
1378
+ targetTab = 'sessions';
1379
+ }
1380
+ return { kind, label, taskKey, controllable, targetTab, agentSlug: agentSlug || null };
1381
+ }
1382
+ async function getBuildUsageForOperations(hoursInput = 168, limitInput = 50) {
1383
+ const hoursRaw = parseInt(String(hoursInput ?? '168'), 10);
1384
+ const hours = Math.max(1, Math.min(Number.isFinite(hoursRaw) ? hoursRaw : 168, 24 * 90));
1385
+ const limitRaw = parseInt(String(limitInput ?? '50'), 10);
1386
+ const limit = Math.max(5, Math.min(Number.isFinite(limitRaw) ? limitRaw : 50, 50));
1387
+ const sinceIso = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
1388
+ const empty = {
1389
+ ok: true,
1390
+ hours,
1391
+ sinceIso,
1392
+ totalTokens: 0,
1393
+ totalInput: 0,
1394
+ totalOutput: 0,
1395
+ totalCostCents: 0,
1396
+ tasks: [],
1397
+ taskTotals: { totalTokens: 0, totalInput: 0, totalOutput: 0, costCents: 0, queries: 0 },
1398
+ };
1399
+ if (!existsSync(MEMORY_DB_PATH))
1400
+ return empty;
1401
+ const Database = (await import('better-sqlite3')).default;
1402
+ const db = new Database(MEMORY_DB_PATH, { readonly: true });
1403
+ try {
1404
+ const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='usage_log'").get();
1405
+ if (!tableExists)
1406
+ return empty;
1407
+ const columns = new Set(db.prepare('PRAGMA table_info(usage_log)').all().map(c => c.name));
1408
+ const costExpr = columns.has('cost_cents') ? 'COALESCE(SUM(cost_cents), 0)' : '0';
1409
+ const agentExpr = columns.has('agent_slug') ? 'COALESCE(agent_slug, \'\')' : '\'\'';
1410
+ const totals = db.prepare(`SELECT COALESCE(SUM(input_tokens), 0) as ti,
1411
+ COALESCE(SUM(output_tokens), 0) as to_,
1412
+ ${costExpr} as cost
1413
+ FROM usage_log
1414
+ WHERE datetime(created_at) >= datetime(?)`).get(sinceIso);
1415
+ const sessionRows = db.prepare(`SELECT session_key as sessionKey,
1416
+ source,
1417
+ ${agentExpr} as agentSlug,
1418
+ COUNT(*) as queries,
1419
+ COALESCE(SUM(input_tokens), 0) as totalInput,
1420
+ COALESCE(SUM(output_tokens), 0) as totalOutput,
1421
+ ${costExpr} as costCents,
1422
+ MAX(created_at) as lastAt
1423
+ FROM usage_log
1424
+ WHERE datetime(created_at) >= datetime(?)
1425
+ GROUP BY session_key, source, ${agentExpr}
1426
+ ORDER BY COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) DESC`).all(sinceIso);
1427
+ const taskMap = new Map();
1428
+ for (const row of sessionRows) {
1429
+ const identity = classifyBuildUsageSession(row.sessionKey, row.source, row.agentSlug);
1430
+ if (identity.kind === 'chat session')
1431
+ continue;
1432
+ const mapKey = `${identity.kind}:${identity.taskKey}:${identity.agentSlug || ''}`;
1433
+ const existing = taskMap.get(mapKey);
1434
+ if (existing) {
1435
+ existing.totalInput = (existing.totalInput || 0) + row.totalInput;
1436
+ existing.totalOutput = (existing.totalOutput || 0) + row.totalOutput;
1437
+ existing.totalTokens = (existing.totalTokens || 0) + row.totalInput + row.totalOutput;
1438
+ existing.costCents = (existing.costCents || 0) + (row.costCents || 0);
1439
+ existing.queries = (existing.queries || 0) + row.queries;
1440
+ if ((row.lastAt || '') > (existing.lastAt || ''))
1441
+ existing.lastAt = row.lastAt;
1442
+ }
1443
+ else {
1444
+ taskMap.set(mapKey, {
1445
+ ...identity,
1446
+ totalInput: row.totalInput,
1447
+ totalOutput: row.totalOutput,
1448
+ totalTokens: row.totalInput + row.totalOutput,
1449
+ costCents: row.costCents || 0,
1450
+ queries: row.queries,
1451
+ lastAt: row.lastAt,
1452
+ });
1453
+ }
1454
+ }
1455
+ const allTasks = Array.from(taskMap.values()).sort((a, b) => (b.totalTokens || 0) - (a.totalTokens || 0));
1456
+ const taskTotals = allTasks.reduce((acc, t) => {
1457
+ acc.totalTokens += t.totalTokens || 0;
1458
+ acc.totalInput += t.totalInput || 0;
1459
+ acc.totalOutput += t.totalOutput || 0;
1460
+ acc.costCents += t.costCents || 0;
1461
+ acc.queries += t.queries || 0;
1462
+ return acc;
1463
+ }, { totalTokens: 0, totalInput: 0, totalOutput: 0, costCents: 0, queries: 0 });
1464
+ return {
1465
+ ok: true,
1466
+ hours,
1467
+ sinceIso,
1468
+ totalInput: totals.ti,
1469
+ totalOutput: totals.to_,
1470
+ totalCostCents: totals.cost,
1471
+ totalTokens: totals.ti + totals.to_,
1472
+ tasks: allTasks.slice(0, limit),
1473
+ taskTotals,
1474
+ };
1475
+ }
1476
+ catch (err) {
1477
+ return { ...empty, ok: false, error: String(err) };
1478
+ }
1479
+ finally {
1480
+ db.close();
1481
+ }
1482
+ }
1310
1483
  function getTimers() {
1311
1484
  const timersFile = path.join(BASE_DIR, '.timers.json');
1312
1485
  if (!existsSync(timersFile))
@@ -5788,28 +5961,8 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5788
5961
  });
5789
5962
  // ── Unleashed status/cancel routes ─────────────────────────────────
5790
5963
  app.get('/api/unleashed', (_req, res) => {
5791
- const unleashedDir = path.join(BASE_DIR, 'unleashed');
5792
- if (!existsSync(unleashedDir)) {
5793
- res.json({ tasks: [] });
5794
- return;
5795
- }
5796
5964
  try {
5797
- const tasks = [];
5798
- for (const dir of readdirSync(unleashedDir)) {
5799
- const dirPath = path.join(unleashedDir, dir);
5800
- if (!statSync(dirPath).isDirectory())
5801
- continue;
5802
- const statusFile = path.join(dirPath, 'status.json');
5803
- if (existsSync(statusFile)) {
5804
- try {
5805
- const status = JSON.parse(readFileSync(statusFile, 'utf-8'));
5806
- tasks.push(annotateUnleashedStatus(status, dir));
5807
- }
5808
- catch { /* skip corrupt */ }
5809
- }
5810
- }
5811
- tasks.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
5812
- res.json({ tasks });
5965
+ res.json({ tasks: getUnleashedTasksForDashboard() });
5813
5966
  }
5814
5967
  catch (err) {
5815
5968
  res.status(500).json({ error: String(err) });
@@ -7861,6 +8014,30 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7861
8014
  db.close();
7862
8015
  }
7863
8016
  });
8017
+ app.get('/api/build/operations', async (req, res) => {
8018
+ try {
8019
+ const [{ listAllForBuilder }, { computeBrokenJobs }, { listBackgroundTasks }] = await Promise.all([
8020
+ import('../dashboard/builder/serializer.js'),
8021
+ import('../gateway/failure-monitor.js'),
8022
+ import('../agent/background-tasks.js'),
8023
+ ]);
8024
+ const cronPayload = getCronJobs();
8025
+ const usage = await getBuildUsageForOperations(req.query.hours ?? 168, req.query.limit ?? 50);
8026
+ const snapshot = buildOperationsSnapshot({
8027
+ cronJobs: cronPayload.jobs || [],
8028
+ workflowSummaries: listAllForBuilder(),
8029
+ brokenJobs: computeBrokenJobs(),
8030
+ unleashedTasks: getUnleashedTasksForDashboard(),
8031
+ backgroundTasks: await listBackgroundTasks(),
8032
+ usageTasks: usage.tasks,
8033
+ usageSummary: usage,
8034
+ });
8035
+ res.json({ ok: true, hours: usage.hours, sinceIso: usage.sinceIso, ...snapshot });
8036
+ }
8037
+ catch (err) {
8038
+ res.status(500).json({ ok: false, error: String(err) });
8039
+ }
8040
+ });
7864
8041
  // ── Session Detail API ───────────────────────────────────────────
7865
8042
  app.get('/api/sessions/:key/messages', async (req, res) => {
7866
8043
  const sessionKey = req.params.key;
@@ -14395,7 +14572,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14395
14572
  <!-- ═══ Builder Page — Conversational Artifact Creation ═══ -->
14396
14573
  <div class="page" id="page-build">
14397
14574
  <div class="tab-bar" id="build-tabs" style="margin:0;padding:0 18px;background:var(--bg-secondary);border-bottom:1px solid var(--border)">
14398
- <button class="active" data-build-tab="crons" data-icon="clock" onclick="switchBuildTab('crons')"><span class="icon-slot"></span> Automation <span class="tab-badge" id="build-tab-cron-count" style="display:none">0</span></button>
14575
+ <button class="active" data-build-tab="crons" data-icon="clock" onclick="switchBuildTab('crons')"><span class="icon-slot"></span> Operations <span class="tab-badge" id="build-tab-cron-count" style="display:none">0</span></button>
14399
14576
  <button data-build-tab="workflows" data-icon="workflow" onclick="switchBuildTab('workflows')"><span class="icon-slot"></span> Workflow Builder</button>
14400
14577
  <button data-build-tab="skills" data-icon="shield" onclick="switchBuildTab('skills')"><span class="icon-slot"></span> Skills <span class="tab-badge" id="build-tab-skill-count" style="display:none">0</span></button>
14401
14578
  <button data-build-tab="templates" data-icon="fileText" onclick="switchBuildTab('templates')"><span class="icon-slot"></span> Templates</button>
@@ -14518,7 +14695,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14518
14695
  <div id="build-tab-crons" data-build-tabpane="crons" style="display:block;flex:1;min-height:0;overflow-y:auto;padding:18px;background:var(--bg-primary)">
14519
14696
  <div style="display:flex;align-items:flex-start;gap:14px;margin-bottom:16px;flex-wrap:wrap">
14520
14697
  <div style="flex:1;min-width:260px">
14521
- <h2 style="font-size:18px;font-weight:600;margin:0 0 4px;color:var(--text-primary)">Automation</h2>
14698
+ <h2 style="font-size:18px;font-weight:600;margin:0 0 4px;color:var(--text-primary)">Build Operations</h2>
14522
14699
  <p style="font-size:13px;color:var(--text-muted);margin:0;line-height:1.45">Scheduled tasks, scheduled workflows, and live runtime work. Cards here are the turn-on, turn-off, run-now surface.</p>
14523
14700
  </div>
14524
14701
  <div style="display:flex;gap:8px;flex-wrap:wrap">
@@ -18161,7 +18338,7 @@ function openCommandK() {
18161
18338
  { kw: 'home today plan', page: 'home', tab: 'today', label: 'Home · Today' },
18162
18339
  { kw: 'home activity', page: 'home', tab: 'activity', label: 'Home · Activity' },
18163
18340
  { kw: 'build workflows workflow builder', page: 'build', tab: 'workflows', label: 'Build · Workflow Builder' },
18164
- { kw: 'build crons scheduled tasks automation', page: 'build', tab: 'crons', label: 'Build · Automation' },
18341
+ { kw: 'build crons scheduled tasks operations automation', page: 'build', tab: 'crons', label: 'Build · Operations' },
18165
18342
  { kw: 'build skills', page: 'build', tab: 'skills', label: 'Build · Skills' },
18166
18343
  { kw: 'build templates', page: 'build', tab: 'templates', label: 'Build · Templates' },
18167
18344
  { kw: 'team roster', page: 'team', tab: 'roster', label: 'Team · Roster' },
@@ -19231,6 +19408,14 @@ async function cancelBackgroundTask(id) {
19231
19408
  } catch(e) { toast('Failed to cancel background task: ' + e, 'error'); }
19232
19409
  }
19233
19410
 
19411
+ async function deleteBackgroundTask(id) {
19412
+ if (!confirm('Remove background task "' + id + '" from the dashboard? This does not delete any scheduled task definition.')) return;
19413
+ try {
19414
+ await apiDelete('/api/background-tasks/' + encodeURIComponent(id));
19415
+ setTimeout(refreshCron, 500);
19416
+ } catch(e) { toast('Failed to clean up background task: ' + e, 'error'); }
19417
+ }
19418
+
19234
19419
  // ── Theme ─────────────────────────────────
19235
19420
  function toggleTheme() {
19236
19421
  var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
@@ -19817,12 +20002,12 @@ async function refreshBuildUsage() {
19817
20002
  + '<div style="font-size:12px;color:var(--text-muted);margin-top:6px;line-height:1.4">Canvas tests are mock-safe. Use Run Real or schedule the workflow when it should execute.</div>'
19818
20003
  + '</div>'
19819
20004
  + '<div style="border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:12px">'
19820
- + '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px"><strong style="font-size:13px">Automation</strong><span class="badge badge-green">recurring</span></div>'
20005
+ + '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px"><strong style="font-size:13px">Scheduled Work</strong><span class="badge badge-green">recurring</span></div>'
19821
20006
  + '<div style="font-size:12px;color:var(--text-muted);margin-top:6px;line-height:1.4">Enabled cards can spend tokens automatically. Toggle, run, or delete them here.</div>'
19822
20007
  + '</div>'
19823
20008
  + '<div style="border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:12px">'
19824
20009
  + '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.04em">Token Usage · 7d · ' + esc(scopeLabel) + '</div>'
19825
- + '<div style="display:flex;align-items:baseline;gap:10px;margin-top:4px"><strong style="font-size:22px">' + esc(formatTokens(visibleTokenTotal)) + '</strong><span style="font-size:12px;color:var(--text-muted)">' + esc(formatTokens(automationTokens)) + ' automation</span></div>'
20010
+ + '<div style="display:flex;align-items:baseline;gap:10px;margin-top:4px"><strong style="font-size:22px">' + esc(formatTokens(visibleTokenTotal)) + '</strong><span style="font-size:12px;color:var(--text-muted)">' + esc(formatTokens(automationTokens)) + ' scheduled work</span></div>'
19826
20011
  + (topSession ? '<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Top: ' + esc(topSession.label) + '</div>' : '<div style="font-size:11px;color:var(--text-muted);margin-top:4px">No usage logged for this scope.</div>')
19827
20012
  + agentBreakdownHtml
19828
20013
  + '</div>'
@@ -19846,7 +20031,7 @@ async function refreshBuildUsage() {
19846
20031
  html += '</div>';
19847
20032
 
19848
20033
  html += '<div style="border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);overflow:hidden">'
19849
- + '<div style="padding:10px 12px;border-bottom:1px solid var(--border);font-size:12px;font-weight:600">Automation Spend</div>';
20034
+ + '<div style="padding:10px 12px;border-bottom:1px solid var(--border);font-size:12px;font-weight:600">Scheduled Work Spend</div>';
19850
20035
  if (tasks.length === 0) {
19851
20036
  html += '<div style="padding:12px;color:var(--text-muted);font-size:12px">No scheduled or long-running task usage in the last 7 days.</div>';
19852
20037
  } else {
@@ -19868,27 +20053,171 @@ async function refreshBuildUsage() {
19868
20053
  }
19869
20054
  }
19870
20055
 
20056
+ function buildOpsOwnerMatches(owner) {
20057
+ if (getBuildOwnerFilter() === BUILD_OWNER_ALL) return true;
20058
+ return buildOwnerMatches(owner || '');
20059
+ }
20060
+
20061
+ function operationUsageBadge(usage) {
20062
+ if (!usage || !usage.totalTokens) return '';
20063
+ return '<span class="badge badge-blue" title="' + esc(formatTokens(usage.totalInput || 0)) + ' input, ' + esc(formatTokens(usage.totalOutput || 0)) + ' output">' + esc(formatTokens(usage.totalTokens || 0)) + ' tok 7d</span>';
20064
+ }
20065
+
20066
+ function operationScheduleHtml(schedule) {
20067
+ var desc = describeCron(schedule || '');
20068
+ return desc ? esc(desc) + ' <code>' + esc(schedule) + '</code>' : '<code style="color:var(--accent)">' + esc(schedule || '') + '</code>';
20069
+ }
20070
+
20071
+ function operationSectionHeader(title, subtitle, badgeClass, badgeText, marginTop) {
20072
+ return '<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin:' + (marginTop || '0') + ' 0 12px;flex-wrap:wrap">'
20073
+ + '<div><h3 style="font-size:15px;font-weight:600;color:var(--text-primary);margin:0 0 4px">' + esc(title) + '</h3>'
20074
+ + '<div style="font-size:12px;color:var(--text-muted);line-height:1.4">' + esc(subtitle) + '</div></div>'
20075
+ + (badgeText ? '<span class="badge ' + badgeClass + '">' + esc(badgeText) + '</span>' : '')
20076
+ + '</div>';
20077
+ }
20078
+
20079
+ function renderOperationsSummary(ops) {
20080
+ var s = ops.summary || {};
20081
+ return '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:16px">'
20082
+ + '<div style="border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:10px 12px"><div style="font-size:11px;color:var(--text-muted)">Needs Attention</div><div style="font-size:20px;font-weight:700;color:' + ((s.needsAttention || 0) > 0 ? 'var(--red)' : 'var(--green)') + '">' + esc(s.needsAttention || 0) + '</div></div>'
20083
+ + '<div style="border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:10px 12px"><div style="font-size:11px;color:var(--text-muted)">Scheduled Tasks</div><div style="font-size:20px;font-weight:700">' + esc(s.enabledScheduledTasks || 0) + '/' + esc(s.scheduledTasks || 0) + '</div></div>'
20084
+ + '<div style="border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:10px 12px"><div style="font-size:11px;color:var(--text-muted)">Scheduled Workflows</div><div style="font-size:20px;font-weight:700">' + esc(s.enabledScheduledWorkflows || 0) + '/' + esc(s.scheduledWorkflows || 0) + '</div></div>'
20085
+ + '<div style="border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:10px 12px"><div style="font-size:11px;color:var(--text-muted)">Running Now</div><div style="font-size:20px;font-weight:700;color:' + ((s.runningNow || 0) > 0 ? 'var(--blue)' : 'var(--text-primary)') + '">' + esc(s.runningNow || 0) + '</div></div>'
20086
+ + '<div style="border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:10px 12px"><div style="font-size:11px;color:var(--text-muted)">Scheduled Tokens</div><div style="font-size:20px;font-weight:700">' + esc(formatTokens(s.automationTokens || 0)) + '</div></div>'
20087
+ + '</div>';
20088
+ }
20089
+
20090
+ function renderAttentionCard(item) {
20091
+ var broken = item.brokenJob || null;
20092
+ var runtime = item.runtime || {};
20093
+ var jobName = broken ? broken.jobName : '';
20094
+ var runtimeName = runtime.runtimeName || runtime.name || runtime.jobName || runtime.id || '';
20095
+ var cardStyle = 'border-left:3px solid ' + (item.severity === 'critical' ? 'var(--red)' : 'var(--yellow)');
20096
+ var title = item.title || jobName || runtimeName || 'Needs attention';
20097
+ var detail = item.detail ? '<div class="task-card-prompt">' + esc(String(item.detail).slice(0, 240)) + '</div>' : '';
20098
+ var diagnosisHtml = '';
20099
+ if (broken && broken.diagnosis) {
20100
+ var d = broken.diagnosis;
20101
+ var proposed = d.proposedFix || {};
20102
+ diagnosisHtml = '<div style="margin-top:8px;padding:8px;border-radius:6px;background:var(--bg-tertiary);font-size:12px;line-height:1.4">'
20103
+ + '<strong>Diagnosis:</strong> ' + esc(d.rootCause || item.reason || '')
20104
+ + (proposed.details ? '<br><strong>Fix:</strong> ' + esc(proposed.details) : '')
20105
+ + '</div>';
20106
+ }
20107
+ var actions = '';
20108
+ if (jobName) {
20109
+ actions += '<button class="btn-sm" data-trace-job="' + esc(jobName) + '">Trace</button>';
20110
+ if (item.type === 'broken_scheduled_task') {
20111
+ actions += '<button class="btn-sm btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(jobName) + '\\x27)">Run Now</button>'
20112
+ + '<button class="btn-sm" onclick="openEditCronModal(\\x27' + jsStr(jobName) + '\\x27)">Edit</button>'
20113
+ + '<button class="btn-sm" onclick="toggleCronJob(\\x27' + jsStr(jobName) + '\\x27)">Disable</button>';
20114
+ }
20115
+ if (item.actions && item.actions.applyFix) actions += '<button class="btn-sm btn-primary" onclick="applyBrokenJobFix(\\x27' + jsStr(jobName) + '\\x27)">Apply Fix</button>';
20116
+ if (item.actions && item.actions.dismissDiagnosis) actions += '<button class="btn-sm" onclick="dismissBrokenJobDiagnosis(\\x27' + jsStr(jobName) + '\\x27)">Dismiss</button>';
20117
+ }
20118
+ if (item.source === 'runtime') {
20119
+ if (item.actions && item.actions.cancel && runtimeName) actions += '<button class="btn-sm btn-danger" onclick="cancelUnleashed(\\x27' + jsStr(runtimeName) + '\\x27)">Cancel</button>';
20120
+ if (item.actions && item.actions.cleanup && item.type === 'failed_runtime' && runtime.id && !runtime.jobName) actions += '<button class="btn-sm" onclick="deleteBackgroundTask(\\x27' + jsStr(runtime.id) + '\\x27)">Clean up</button>';
20121
+ else if (item.actions && item.actions.cleanup && runtimeName) actions += '<button class="btn-sm" onclick="deleteUnleashedRuntime(\\x27' + jsStr(runtimeName) + '\\x27)">Clean up</button>';
20122
+ }
20123
+ return '<div class="task-card disabled" style="' + cardStyle + '">'
20124
+ + '<div class="task-card-header"><strong>' + esc(title) + '</strong><span class="badge ' + (item.severity === 'critical' ? 'badge-red' : 'badge-yellow') + '">' + esc(item.status || 'review') + '</span></div>'
20125
+ + '<div class="task-card-schedule">' + esc(item.ownerLabel || 'Clementine') + (item.lastAt ? ' · last issue ' + esc(timeAgo(item.lastAt)) : '') + '</div>'
20126
+ + detail
20127
+ + diagnosisHtml
20128
+ + '<div class="task-card-badges"><span class="badge badge-yellow">needs attention</span><span class="badge badge-gray">' + esc(item.source === 'runtime' ? 'runtime' : 'scheduled task') + '</span>' + operationUsageBadge(item.usage) + '</div>'
20129
+ + (actions ? '<div class="task-card-actions">' + actions + '</div>' : '')
20130
+ + '</div>';
20131
+ }
20132
+
20133
+ function renderScheduledTaskCard(task) {
20134
+ var enabled = task.enabled !== false;
20135
+ var cardCls = 'task-card' + (enabled ? '' : ' disabled') + (task.health === 'running' ? ' running' : '');
20136
+ var style = task.health === 'broken' ? 'border-left:3px solid var(--red)' : task.health === 'failed' ? 'border-left:3px solid var(--yellow)' : '';
20137
+ var lastRunHtml = '<span style="color:var(--text-muted)">Never run</span>';
20138
+ if (task.lastRun) {
20139
+ var lr = task.lastRun;
20140
+ var ok = lr.status === 'ok';
20141
+ var statusIcon = ok ? '<span style="color:var(--green)">&#10003;</span>' : '<span style="color:var(--red)">&#10007;</span>';
20142
+ lastRunHtml = statusIcon + ' ' + esc(lr.status || 'unknown') + ' · ' + esc(timeAgo(lr.finishedAt || lr.startedAt || ''));
20143
+ }
20144
+ if (task.broken) {
20145
+ lastRunHtml = '<span style="color:var(--red)">Needs attention</span> · ' + esc(task.broken.errorCount48h || 0) + '/' + esc(task.broken.totalRuns48h || 0) + ' failures';
20146
+ }
20147
+ var badges = '';
20148
+ if (task.owner) badges += '<span class="badge badge-orange">' + esc(task.owner) + '</span>';
20149
+ if (task.mode === 'unleashed') badges += '<span class="badge badge-purple">long-running</span>';
20150
+ if (task.after) badges += '<span class="badge badge-yellow" title="Triggered after ' + esc(task.after) + '">after ' + esc(task.after) + '</span>';
20151
+ if (task.maxRetries != null) badges += '<span class="badge badge-gray">' + esc(task.maxRetries) + ' retries</span>';
20152
+ badges += operationUsageBadge(task.usage);
20153
+ badges += '<span class="badge ' + (enabled ? 'badge-green' : 'badge-gray') + '">' + (enabled ? 'Enabled' : 'Disabled') + '</span>';
20154
+ badges += '<span class="badge ' + (task.health === 'broken' || task.health === 'failed' ? 'badge-yellow' : 'badge-gray') + '">' + esc(task.healthLabel || task.health) + '</span>';
20155
+ var safeName = jsStr(task.name);
20156
+ return '<div class="' + cardCls + '" style="' + style + '">'
20157
+ + '<div class="task-card-header"><strong>' + esc(task.displayName || task.name) + '</strong>'
20158
+ + '<label class="toggle-switch"><input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="toggleCronJob(\\x27' + safeName + '\\x27)"><span class="toggle-slider"></span></label></div>'
20159
+ + '<div class="task-card-schedule">' + operationScheduleHtml(task.schedule) + '</div>'
20160
+ + '<div class="task-card-prompt">' + esc(task.prompt || '') + '</div>'
20161
+ + '<div class="task-card-status">' + lastRunHtml + '</div>'
20162
+ + '<div class="task-card-badges">' + badges + '</div>'
20163
+ + '<div class="task-card-actions">'
20164
+ + '<button class="btn-sm btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(task.name) + '\\x27)">Run Now</button>'
20165
+ + '<button class="btn-sm" data-trace-job="' + esc(task.name) + '">Trace</button>'
20166
+ + '<button class="btn-sm" onclick="openEditCronModal(\\x27' + safeName + '\\x27)">Edit</button>'
20167
+ + '<button class="btn-sm btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)">Del</button>'
20168
+ + '</div></div>';
20169
+ }
20170
+
20171
+ function renderScheduledWorkflowCard(wf) {
20172
+ var enabled = wf.enabled !== false;
20173
+ var wfId = jsStr(wf.id);
20174
+ var wfName = jsStr(wf.name);
20175
+ var badges = '';
20176
+ if (wf.owner) badges += '<span class="badge badge-orange">' + esc(wf.owner) + '</span>';
20177
+ badges += '<span class="badge badge-purple">chained workflow</span>';
20178
+ badges += '<span class="badge badge-gray">' + esc(wf.stepCount || 0) + ' steps</span>';
20179
+ badges += operationUsageBadge(wf.usage);
20180
+ badges += '<span class="badge ' + (enabled ? 'badge-green' : 'badge-gray') + '">' + (enabled ? 'Enabled' : 'Disabled') + '</span>';
20181
+ return '<div class="task-card' + (enabled ? '' : ' disabled') + '">'
20182
+ + '<div class="task-card-header"><strong>' + esc(wf.displayName || wf.name) + '</strong>'
20183
+ + '<label class="toggle-switch"><input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="toggleScheduledWorkflow(\\x27' + wfId + '\\x27)"><span class="toggle-slider"></span></label></div>'
20184
+ + '<div class="task-card-schedule">' + operationScheduleHtml(wf.schedule) + '</div>'
20185
+ + '<div class="task-card-prompt">' + esc(wf.description || ((wf.stepCount || 0) + ' step chained workflow')) + '</div>'
20186
+ + '<div class="task-card-status">Run Now executes the real workflow engine. Canvas tests are mock-safe and stub prompt steps.</div>'
20187
+ + '<div class="task-card-badges">' + badges + '</div>'
20188
+ + '<div class="task-card-actions">'
20189
+ + '<button class="btn-sm btn-success" onclick="runScheduledWorkflow(\\x27' + wfId + '\\x27)">Run Now</button>'
20190
+ + '<button class="btn-sm" onclick="openScheduledWorkflow(\\x27' + wfId + '\\x27)">Open</button>'
20191
+ + '<button class="btn-sm btn-danger" onclick="confirmDeleteScheduledWorkflow(\\x27' + wfId + '\\x27,\\x27' + wfName + '\\x27)">Del</button>'
20192
+ + '</div></div>';
20193
+ }
20194
+
20195
+ function renderRunningCard(item) {
20196
+ var runtime = item.runtime || {};
20197
+ var runtimeName = runtime.runtimeName || runtime.name || runtime.jobName || runtime.id || '';
20198
+ var elapsed = '';
20199
+ if (item.startedAt) elapsed = durationLabel(Date.now() - new Date(item.startedAt).getTime());
20200
+ var cap = item.maxMinutes ? ' · max ' + item.maxMinutes + 'm' : item.maxHours ? ' · max ' + item.maxHours + 'h' : '';
20201
+ return '<div class="task-card running">'
20202
+ + '<div class="task-card-header"><strong>' + esc(item.title || runtimeName || 'running task') + '</strong><span class="badge badge-blue">' + esc(item.status || 'running') + '</span></div>'
20203
+ + '<div class="task-card-schedule">' + esc(item.ownerLabel || 'Clementine') + (elapsed ? ' · running ' + esc(elapsed) : '') + cap + '</div>'
20204
+ + '<div class="task-card-prompt">' + esc(item.promptPreview || '') + '</div>'
20205
+ + '<div class="task-card-badges"><span class="badge badge-purple">' + esc(item.type) + '</span>' + operationUsageBadge(item.usage) + '</div>'
20206
+ + '<div class="task-card-actions">'
20207
+ + (item.type === 'background' ? '<button class="btn-sm btn-danger" onclick="cancelBackgroundTask(\\x27' + jsStr(runtime.id || runtimeName) + '\\x27)">Cancel</button>' : '<button class="btn-sm btn-danger" onclick="cancelUnleashed(\\x27' + jsStr(runtimeName) + '\\x27)">Cancel</button>')
20208
+ + '</div></div>';
20209
+ }
20210
+
19871
20211
  async function refreshCron() {
19872
20212
  try {
19873
- const [cronRes, workflowRes, unleashedRes, backgroundRes, usageRes] = await Promise.all([
19874
- apiFetch('/api/cron'),
19875
- apiFetch('/api/builder/workflows').catch(function() { return null; }),
19876
- apiFetch('/api/unleashed').catch(function() { return null; }),
19877
- apiFetch('/api/background-tasks').catch(function() { return null; }),
19878
- apiFetch('/api/build/usage?hours=168&limit=50').catch(function() { return null; }),
19879
- ]);
19880
- const d = await cronRes.json();
19881
- const workflowPayload = workflowRes && workflowRes.ok ? await workflowRes.json() : { workflows: [] };
19882
- const unleashedPayload = unleashedRes && unleashedRes.ok ? await unleashedRes.json() : { tasks: [] };
19883
- const backgroundPayload = backgroundRes && backgroundRes.ok ? await backgroundRes.json() : [];
19884
- const usagePayload = usageRes && usageRes.ok ? await usageRes.json() : { tasks: [] };
19885
- mergeBuildUsageTasks(usagePayload.tasks || []);
19886
- cronJobsData = d.jobs || [];
19887
- scheduledWorkflowData = (workflowPayload.workflows || []).filter(function(w) {
19888
- return w.origin === 'workflow' && w.schedule;
19889
- });
19890
-
19891
- var totalScheduled = cronJobsData.length + scheduledWorkflowData.length;
20213
+ var r = await apiFetch('/api/build/operations?hours=168&limit=50');
20214
+ var ops = await r.json();
20215
+ if (!r.ok || !ops || ops.ok === false) throw new Error((ops && ops.error) || 'Build operations unavailable');
20216
+ mergeBuildUsageTasks(ops.usageTasks || []);
20217
+ cronJobsData = (ops.scheduledTasks || []).map(function(t) { return t.definition || {}; });
20218
+ scheduledWorkflowData = (ops.scheduledWorkflows || []).map(function(w) { return w.definition || w; });
20219
+
20220
+ var totalScheduled = (ops.summary && (ops.summary.scheduledTasks + ops.summary.scheduledWorkflows)) || (cronJobsData.length + scheduledWorkflowData.length);
19892
20221
  var navCount = document.getElementById('nav-cron-count');
19893
20222
  if (navCount) navCount.textContent = totalScheduled;
19894
20223
  var tabCount = document.getElementById('build-tab-cron-count');
@@ -19901,269 +20230,46 @@ async function refreshCron() {
19901
20230
  if (!panel) return;
19902
20231
 
19903
20232
  var activeBuildTab = document.querySelector('#build-tabs button.active')?.getAttribute('data-build-tab') || '';
19904
- var ownerFilter = currentPage === 'build' && activeBuildTab === 'crons' ? getBuildOwnerFilter() : BUILD_OWNER_ALL;
19905
- var visibleJobs = currentPage === 'build' && activeBuildTab === 'crons'
19906
- ? cronJobsData.filter(function(j) { return buildOwnerMatches(j.agent || ''); })
19907
- : cronJobsData;
19908
- var visibleWorkflows = currentPage === 'build' && activeBuildTab === 'crons'
19909
- ? scheduledWorkflowData.filter(function(w) { return buildOwnerMatches(w.agentSlug || ''); })
19910
- : scheduledWorkflowData;
19911
-
19912
- var noScheduledDefinitions = visibleJobs.length === 0 && visibleWorkflows.length === 0;
19913
-
19914
- var groups = {};
19915
- for (const job of visibleJobs) {
19916
- var g = job.agent || '_main';
19917
- if (!groups[g]) groups[g] = [];
19918
- groups[g].push({ kind: 'cron', item: job });
19919
- }
19920
- for (const wf of visibleWorkflows) {
19921
- var wg = wf.agentSlug || '_main';
19922
- if (!groups[wg]) groups[wg] = [];
19923
- groups[wg].push({ kind: 'workflow', item: wf });
19924
- }
19925
- var groupOrder = Object.keys(groups).sort(function(a, b) {
19926
- if (a === '_main') return -1;
19927
- if (b === '_main') return 1;
19928
- return a.localeCompare(b);
19929
- });
19930
-
19931
- let html = '<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap">'
19932
- + '<div>'
19933
- + '<h3 style="font-size:15px;font-weight:600;color:var(--text-primary);margin:0 0 4px">Scheduled definitions</h3>'
19934
- + '<div style="font-size:12px;color:var(--text-muted)">Recurring jobs from CRON.md plus workflow files with a schedule. Runtime deep work is separated below.</div>'
19935
- + '</div>'
19936
- + '<div style="display:flex;gap:8px;flex-wrap:wrap">'
19937
- + '<span class="badge badge-blue">' + visibleJobs.length + ' cron</span>'
19938
- + '<span class="badge badge-purple">' + visibleWorkflows.length + ' workflows</span>'
19939
- + '</div>'
19940
- + '</div>';
19941
-
19942
- if (noScheduledDefinitions) {
19943
- var emptyLabel = ownerFilter === BUILD_OWNER_ALL ? 'No scheduled definitions across any agent' : (ownerFilter ? 'No scheduled definitions for ' + ownerFilter : 'No global scheduled definitions');
19944
- html += '<div class="task-grid"><div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Task</div></div>'
20233
+ var ownerScoped = currentPage === 'build' && activeBuildTab === 'crons';
20234
+ var visibleAttention = ownerScoped ? (ops.needsAttention || []).filter(function(i) { return buildOpsOwnerMatches(i.owner || ''); }) : (ops.needsAttention || []);
20235
+ var visibleTasks = ownerScoped ? (ops.scheduledTasks || []).filter(function(t) { return buildOpsOwnerMatches(t.owner || ''); }) : (ops.scheduledTasks || []);
20236
+ var visibleWorkflows = ownerScoped ? (ops.scheduledWorkflows || []).filter(function(w) { return buildOpsOwnerMatches(w.owner || ''); }) : (ops.scheduledWorkflows || []);
20237
+ var visibleRunning = ownerScoped ? (ops.runningNow || []).filter(function(i) { return buildOpsOwnerMatches(i.owner || ''); }) : (ops.runningNow || []);
20238
+ var ownerFilter = getBuildOwnerFilter();
20239
+
20240
+ var html = renderOperationsSummary(ops);
20241
+ if (visibleAttention.length > 0) {
20242
+ html += operationSectionHeader('Needs Attention', 'Broken scheduled tasks and failed runtime work that can waste tokens or silently stop.', visibleAttention.length > 0 ? 'badge-yellow' : 'badge-gray', visibleAttention.length + ' review', '0')
20243
+ + '<div class="task-grid">' + visibleAttention.slice(0, 12).map(renderAttentionCard).join('') + '</div>';
20244
+ if (visibleAttention.length > 12) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 12 of ' + visibleAttention.length + ' items. Use the Owner filter to narrow this list.</div>';
20245
+ }
20246
+
20247
+ html += operationSectionHeader('Scheduled Tasks', 'Single recurring jobs from CRON.md. These are the default scheduled automation surface.', 'badge-blue', visibleTasks.length + ' task' + (visibleTasks.length === 1 ? '' : 's'), visibleAttention.length > 0 ? '28px' : '0')
20248
+ + '<div class="task-grid">';
20249
+ if (visibleTasks.length === 0) {
20250
+ var emptyLabel = ownerFilter === BUILD_OWNER_ALL ? 'No scheduled tasks across any agent.' : (ownerFilter ? 'No scheduled tasks for ' + ownerFilter + '.' : 'No global scheduled tasks.');
20251
+ html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Scheduled Task</div>'
19945
20252
  + '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">' + esc(emptyLabel) + '</div>';
20253
+ } else {
20254
+ html += visibleTasks.map(renderScheduledTaskCard).join('');
20255
+ html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Scheduled Task</div>';
19946
20256
  }
20257
+ html += '</div>';
19947
20258
 
19948
- for (var gi = 0; gi < groupOrder.length; gi++) {
19949
- var groupKey = groupOrder[gi];
19950
- var groupItems = groups[groupKey];
19951
- var groupLabel = groupKey === '_main' ? 'Clementine' : groupKey.replace(/-/g, ' ').replace(/\\b\\w/g, function(c) { return c.toUpperCase(); });
19952
-
19953
- if (groupOrder.length > 1) {
19954
- var enabledCount = groupItems.filter(function(entry) { return entry.item.enabled !== false; }).length;
19955
- html += '<div style="display:flex;align-items:center;gap:10px;margin:' + (gi > 0 ? '28px' : '0') + ' 0 12px">'
19956
- + '<h3 style="font-size:15px;font-weight:600;color:var(--text-primary);margin:0">' + esc(groupLabel) + '</h3>'
19957
- + '<span style="font-size:12px;color:var(--text-muted)">' + enabledCount + ' of ' + groupItems.length + ' active</span>'
19958
- + '</div>';
19959
- }
19960
-
19961
- html += '<div class="task-grid">';
19962
- for (const entry of groupItems) {
19963
- if (entry.kind === 'workflow') {
19964
- var wf = entry.item;
19965
- var wfEnabled = wf.enabled !== false;
19966
- var wfCardCls = 'task-card' + (wfEnabled ? '' : ' disabled');
19967
- var wfDesc = describeCron(wf.schedule || '');
19968
- var wfSchedHtml = wfDesc
19969
- ? esc(wfDesc) + ' <code>' + esc(wf.schedule) + '</code>'
19970
- : '<code style="color:var(--accent)">' + esc(wf.schedule || '') + '</code>';
19971
- var wfBadges = '';
19972
- if (wf.agentSlug) wfBadges += '<span class="badge badge-orange">' + esc(wf.agentSlug) + '</span>';
19973
- wfBadges += '<span class="badge badge-purple">workflow</span>';
19974
- wfBadges += '<span class="badge badge-gray">' + (wf.stepCount || 0) + ' steps</span>';
19975
- wfBadges += buildUsageBadge(wf.name, wf.agentSlug || '');
19976
- wfBadges += '<span class="badge ' + (wfEnabled ? 'badge-green' : 'badge-gray') + '">' + (wfEnabled ? 'Enabled' : 'Disabled') + '</span>';
19977
- var wfId = jsStr(wf.id);
19978
- var wfName = jsStr(wf.name);
19979
- html += '<div class="' + wfCardCls + '">'
19980
- + '<div class="task-card-header">'
19981
- + '<strong>' + esc(wf.name) + '</strong>'
19982
- + '<label class="toggle-switch"><input type="checkbox"' + (wfEnabled ? ' checked' : '') + ' onchange="toggleScheduledWorkflow(\\x27' + wfId + '\\x27)"><span class="toggle-slider"></span></label>'
19983
- + '</div>'
19984
- + '<div class="task-card-schedule">' + wfSchedHtml + '</div>'
19985
- + '<div class="task-card-prompt">' + esc(wf.description || ((wf.stepCount || 0) + ' step scheduled workflow')) + '</div>'
19986
- + '<div class="task-card-status"><span style="color:var(--text-muted)">Workflow definition</span></div>'
19987
- + '<div class="task-card-badges">' + wfBadges + '</div>'
19988
- + '<div class="task-card-actions">'
19989
- + '<button class="btn-sm btn-success" onclick="runScheduledWorkflow(\\x27' + wfId + '\\x27)">Run Now</button>'
19990
- + '<button class="btn-sm" onclick="openScheduledWorkflow(\\x27' + wfId + '\\x27)">Open</button>'
19991
- + '<button class="btn-sm btn-danger" onclick="confirmDeleteScheduledWorkflow(\\x27' + wfId + '\\x27,\\x27' + wfName + '\\x27)">Del</button>'
19992
- + '</div></div>';
19993
- continue;
19994
- }
19995
-
19996
- var job = entry.item;
19997
- var enabled = job.enabled !== false;
19998
- var cardCls = 'task-card' + (enabled ? '' : ' disabled');
19999
- if (job.recentRuns && job.recentRuns.length > 0 && job.recentRuns[0].startedAt && !job.recentRuns[0].finishedAt) {
20000
- cardCls += ' running';
20001
- }
20002
- var desc = describeCron(job.schedule || '');
20003
- var schedHtml = desc
20004
- ? esc(desc) + ' <code>' + esc(job.schedule) + '</code>'
20005
- : '<code style="color:var(--accent)">' + esc(job.schedule) + '</code>';
20006
-
20007
- var lastRunHtml = '<span style="color:var(--text-muted)">Never run</span>';
20008
- if (job.recentRuns && job.recentRuns.length > 0) {
20009
- var lr = job.recentRuns[0];
20010
- var statusIcon = lr.status === 'ok' ? '<span style="color:var(--green)">&#10003;</span>' : '<span style="color:var(--red)">&#10007;</span>';
20011
- lastRunHtml = statusIcon + ' ' + esc(lr.status) + ' · ' + timeAgo(lr.finishedAt || lr.startedAt);
20012
- }
20013
-
20014
- var badgesHtml = '';
20015
- var projectName = job.work_dir ? job.work_dir.split('/').pop() : '';
20016
- if (projectName) badgesHtml += '<span class="badge badge-blue">' + esc(projectName) + '</span>';
20017
- if (job.agent) badgesHtml += '<span class="badge badge-orange">' + esc(job.agent) + '</span>';
20018
- if (job.mode === 'unleashed') badgesHtml += '<span class="badge badge-purple">unleashed</span>';
20019
- if (job.after) badgesHtml += '<span class="badge badge-yellow" title="Triggered after ' + esc(job.after) + '">\\u2192 ' + esc(job.after) + '</span>';
20020
- if (job.max_retries != null) badgesHtml += '<span class="badge badge-gray">' + job.max_retries + ' retries</span>';
20021
- badgesHtml += buildUsageBadge(job.name, job.agent || '');
20022
- badgesHtml += '<span class="badge ' + (enabled ? 'badge-green' : 'badge-gray') + '">' + (enabled ? 'Enabled' : 'Disabled') + '</span>';
20023
-
20024
- var safeName = jsStr(job.name);
20025
- // Display name without agent prefix for cleaner cards
20026
- var displayName = job.agent ? job.name.replace(job.agent + ':', '') : job.name;
20027
-
20028
- html += '<div class="' + cardCls + '">'
20029
- + '<div class="task-card-header">'
20030
- + '<strong>' + esc(displayName) + '</strong>'
20031
- + '<label class="toggle-switch"><input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="toggleCronJob(\\x27' + safeName + '\\x27)"><span class="toggle-slider"></span></label>'
20032
- + '</div>'
20033
- + '<div class="task-card-schedule">' + schedHtml + '</div>'
20034
- + '<div class="task-card-prompt">' + esc(job.prompt || '') + '</div>'
20035
- + '<div class="task-card-status">' + lastRunHtml + '</div>'
20036
- + '<div class="task-card-badges">' + badgesHtml + '</div>'
20037
- + '<div class="task-card-actions">'
20038
- + '<button class="btn-sm btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(job.name) + '\\x27)">Run Now</button>'
20039
- + '<button class="btn-sm" data-trace-job="' + esc(job.name) + '">Trace</button>'
20040
- + '<button class="btn-sm" onclick="openEditCronModal(\\x27' + safeName + '\\x27)">Edit</button>'
20041
- + '<button class="btn-sm btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)">Del</button>'
20042
- + '</div></div>';
20043
- }
20044
- if (groupKey === '_main' || ownerFilter === groupKey) {
20045
- html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Task</div>';
20046
- }
20047
- html += '</div>';
20048
- }
20049
-
20050
- var activeBackground = (Array.isArray(backgroundPayload) ? backgroundPayload : []).filter(function(t) {
20051
- if (t.status !== 'pending' && t.status !== 'running') return false;
20052
- var fromAgent = t.fromAgent && t.fromAgent !== 'clementine' ? t.fromAgent : '';
20053
- return ownerFilter === BUILD_OWNER_ALL ? true : buildOwnerMatches(fromAgent || '');
20054
- });
20055
- function runtimeOwner(t) {
20056
- if (t.agentSlug) return String(t.agentSlug);
20057
- var jobName = String(t.jobName || '');
20058
- var idx = jobName.indexOf(':');
20059
- return idx > 0 ? jobName.slice(0, idx) : '';
20060
- }
20061
- function runtimeOwnerMatches(t) {
20062
- return ownerFilter === BUILD_OWNER_ALL ? true : buildOwnerMatches(runtimeOwner(t));
20063
- }
20064
- var activeUnleashed = (unleashedPayload.tasks || []).filter(function(t) {
20065
- var jobName = String(t.jobName || '');
20066
- if (jobName.indexOf('bg:') === 0) return false;
20067
- if (t.live !== true && t.runtimeState !== 'active') return false;
20068
- return runtimeOwnerMatches(t);
20069
- });
20070
- var attentionUnleashed = (unleashedPayload.tasks || []).filter(function(t) {
20071
- var jobName = String(t.jobName || '');
20072
- if (jobName.indexOf('bg:') === 0) return false;
20073
- if (!runtimeOwnerMatches(t)) return false;
20074
- var status = String(t.status || '');
20075
- return t.stale === true || t.runtimeState === 'stale' || status === 'error' || status === 'failed' || status === 'timeout';
20076
- });
20077
-
20078
- var activeTotal = activeBackground.length + activeUnleashed.length;
20079
- if (activeTotal > 0) {
20080
- html += '<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin:28px 0 12px;flex-wrap:wrap">'
20081
- + '<div>'
20082
- + '<h3 style="font-size:15px;font-weight:600;color:var(--text-primary);margin:0 0 4px">Running now</h3>'
20083
- + '<div style="font-size:12px;color:var(--text-muted)">Active deep/background work only. Completed history is intentionally hidden here to keep token-cost risk readable.</div>'
20084
- + '</div>'
20085
- + '<span class="badge badge-blue">' + activeTotal + ' active</span>'
20086
- + '</div><div class="task-grid">';
20087
-
20088
- var shown = 0;
20089
- for (var bi = 0; bi < activeBackground.length && shown < 8; bi++, shown++) {
20090
- var bg = activeBackground[bi];
20091
- var bgRunningMs = bg.runningForMs || (bg.startedAt ? Date.now() - new Date(bg.startedAt).getTime() : 0);
20092
- var bgStatusBadge = '<span class="badge ' + (bg.status === 'running' ? 'badge-blue' : 'badge-yellow') + '">' + esc(bg.status) + '</span>';
20093
- html += '<div class="task-card running">'
20094
- + '<div class="task-card-header"><strong>Deep task ' + esc(bg.id) + '</strong>' + bgStatusBadge + '</div>'
20095
- + '<div class="task-card-schedule">' + esc(bg.fromAgent || 'clementine') + ' · max ' + esc(bg.maxMinutes || '') + 'm' + (bg.status === 'running' ? ' · running ' + esc(durationLabel(bgRunningMs)) : '') + '</div>'
20096
- + '<div class="task-card-prompt">' + esc(bg.prompt || '') + '</div>'
20097
- + '<div class="task-card-status">Created ' + esc(timeAgo(bg.createdAt)) + '</div>'
20098
- + '<div class="task-card-badges"><span class="badge badge-purple">background</span>' + buildUsageBadge('bg:' + bg.id, bg.fromAgent && bg.fromAgent !== 'clementine' ? bg.fromAgent : '') + '</div>'
20099
- + '<div class="task-card-actions">'
20100
- + '<button class="btn-sm btn-danger" onclick="cancelBackgroundTask(\\x27' + jsStr(bg.id) + '\\x27)">Cancel</button>'
20101
- + '</div></div>';
20102
- }
20103
-
20104
- for (var ui = 0; ui < activeUnleashed.length && shown < 8; ui++, shown++) {
20105
- var ut = activeUnleashed[ui];
20106
- var utRuntimeName = ut.runtimeName || ut.name || ut.jobName || '';
20107
- var uDuration = '';
20108
- if (ut.startedAt) {
20109
- var uEnd = ut.finishedAt ? new Date(ut.finishedAt).getTime() : Date.now();
20110
- uDuration = durationLabel(uEnd - new Date(ut.startedAt).getTime());
20111
- }
20112
- html += '<div class="task-card running">'
20113
- + '<div class="task-card-header"><strong>' + esc(ut.jobName || 'unleashed task') + '</strong><span class="badge badge-blue">' + esc(ut.status || 'running') + '</span></div>'
20114
- + '<div class="task-card-schedule">Phase ' + esc(ut.phase || 0) + (uDuration ? ' · running ' + esc(uDuration) : '') + (ut.maxHours ? ' / ' + esc(ut.maxHours) + 'h cap' : '') + '</div>'
20115
- + '<div class="task-card-prompt">' + esc((ut.lastPhaseOutputPreview || '').slice(0, 180)) + '</div>'
20116
- + '<div class="task-card-status">Runtime task spawned by a scheduled definition</div>'
20117
- + '<div class="task-card-badges"><span class="badge badge-purple">unleashed</span>' + buildUsageBadge(ut.jobName || '', runtimeOwner(ut)) + '</div>'
20118
- + '<div class="task-card-actions">'
20119
- + '<button class="btn-sm btn-danger" onclick="cancelUnleashed(\\x27' + jsStr(utRuntimeName) + '\\x27)">Cancel</button>'
20120
- + '</div></div>';
20121
- }
20122
- if (activeTotal > shown) {
20123
- html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing ' + shown + ' of ' + activeTotal + ' active tasks. Use the Owner filter to narrow this list.</div>';
20124
- }
20125
- html += '</div>';
20259
+ html += operationSectionHeader('Scheduled Workflows', 'A scheduled workflow is one scheduled trigger that runs chained steps. It is separate from CRON.md scheduled tasks.', 'badge-purple', visibleWorkflows.length + ' workflow' + (visibleWorkflows.length === 1 ? '' : 's'), '28px');
20260
+ if (visibleWorkflows.length > 0) {
20261
+ html += '<div class="task-grid">' + visibleWorkflows.map(renderScheduledWorkflowCard).join('') + '</div>';
20262
+ } else {
20263
+ html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">No scheduled workflows in this scope. Build and schedule chained workflows from the Workflow Builder when a multi-step run is needed.</div>';
20126
20264
  }
20127
20265
 
20128
- if (attentionUnleashed.length > 0) {
20129
- html += '<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin:28px 0 12px;flex-wrap:wrap">'
20130
- + '<div>'
20131
- + '<h3 style="font-size:15px;font-weight:600;color:var(--text-primary);margin:0 0 4px">Needs attention</h3>'
20132
- + '<div style="font-size:12px;color:var(--text-muted)">Expired or failed runtime records. These are not counted as actively running token spend.</div>'
20133
- + '</div>'
20134
- + '<span class="badge badge-yellow">' + attentionUnleashed.length + ' review</span>'
20135
- + '</div><div class="task-grid">';
20136
-
20137
- for (var ai = 0; ai < attentionUnleashed.length && ai < 8; ai++) {
20138
- var at = attentionUnleashed[ai];
20139
- var atRuntimeName = at.runtimeName || at.name || at.jobName || '';
20140
- var atStatus = at.effectiveStatus || at.status || 'unknown';
20141
- var atDuration = '';
20142
- if (at.startedAt) {
20143
- var atEnd = at.finishedAt ? new Date(at.finishedAt).getTime() : Date.now();
20144
- atDuration = durationLabel(atEnd - new Date(at.startedAt).getTime());
20145
- }
20146
- var canCancel = at.stale === true || at.runtimeState === 'stale' || at.status === 'running' || at.status === 'pending';
20147
- html += '<div class="task-card disabled">'
20148
- + '<div class="task-card-header"><strong>' + esc(at.jobName || atRuntimeName || 'runtime task') + '</strong><span class="badge badge-yellow">' + esc(atStatus) + '</span></div>'
20149
- + '<div class="task-card-schedule">' + (atDuration ? esc(atDuration) + ' elapsed · ' : '') + 'updated ' + esc(timeAgo(at.updatedAt || at.startedAt || '')) + '</div>'
20150
- + '<div class="task-card-prompt">' + esc((at.lastPhaseOutputPreview || at.error || '').slice(0, 180)) + '</div>'
20151
- + '<div class="task-card-status">Review or clean up this runtime record. It is not live work.</div>'
20152
- + '<div class="task-card-badges"><span class="badge badge-purple">unleashed</span>' + buildUsageBadge(at.jobName || '', runtimeOwner(at)) + '</div>'
20153
- + '<div class="task-card-actions">'
20154
- + (canCancel ? '<button class="btn-sm btn-danger" onclick="cancelUnleashed(\\x27' + jsStr(atRuntimeName) + '\\x27)">Cancel</button>' : '')
20155
- + '<button class="btn-sm" onclick="deleteUnleashedRuntime(\\x27' + jsStr(atRuntimeName) + '\\x27)">Clean up</button>'
20156
- + '</div></div>';
20157
- }
20158
- if (attentionUnleashed.length > 8) {
20159
- html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 8 of ' + attentionUnleashed.length + ' records. Use the Owner filter to narrow this list.</div>';
20160
- }
20161
- html += '</div>';
20266
+ if (visibleRunning.length > 0) {
20267
+ html += operationSectionHeader('Running Now', 'Live background, long-running, and unleashed work. These are executions, not scheduled definitions.', 'badge-blue', visibleRunning.length + ' active', '28px')
20268
+ + '<div class="task-grid">' + visibleRunning.slice(0, 10).map(renderRunningCard).join('') + '</div>';
20269
+ if (visibleRunning.length > 10) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 10 of ' + visibleRunning.length + ' active runs. Use the Owner filter to narrow this list.</div>';
20162
20270
  }
20163
20271
 
20164
20272
  panel.innerHTML = html;
20165
-
20166
- // Attach trace button handlers via delegation
20167
20273
  panel.onclick = function(ev) {
20168
20274
  var target = ev.target;
20169
20275
  while (target && target.id !== 'panel-cron') {
@@ -20174,7 +20280,10 @@ async function refreshCron() {
20174
20280
  target = target.parentElement;
20175
20281
  }
20176
20282
  };
20177
- } catch(e) { }
20283
+ } catch(e) {
20284
+ var panel = document.getElementById('panel-cron');
20285
+ if (panel) panel.innerHTML = '<div class="empty-state" style="padding:24px;color:var(--red)">Failed to load Build operations: ' + esc(String(e)) + '</div>';
20286
+ }
20178
20287
  }
20179
20288
 
20180
20289
  var traceData = [];
@@ -25874,6 +25983,23 @@ async function refreshHomeDigest() {
25874
25983
  tokenTile.classList.toggle('muted', token7d === 0);
25875
25984
  }
25876
25985
  }
25986
+ try {
25987
+ var opsRes = await apiFetch('/api/build/operations?hours=168&limit=10');
25988
+ var ops = await opsRes.json();
25989
+ if (ops && ops.ok !== false && ops.summary) {
25990
+ var attentionCount = ops.summary.needsAttention || 0;
25991
+ var runningCount = ops.summary.runningNow || 0;
25992
+ var activeEl = document.getElementById('kpi-active-runs');
25993
+ var activeTile = activeEl ? activeEl.closest('.kpi-tile') : null;
25994
+ var activeLabel = activeTile ? activeTile.querySelector('.kpi-label') : null;
25995
+ if (activeEl) activeEl.textContent = String(attentionCount > 0 ? attentionCount : runningCount);
25996
+ if (activeLabel) activeLabel.textContent = attentionCount > 0 ? 'Needs attention' : 'Active runs';
25997
+ if (activeTile) {
25998
+ activeTile.classList.toggle('alert', attentionCount > 0 || runningCount > 0);
25999
+ activeTile.classList.toggle('muted', attentionCount === 0 && runningCount === 0);
26000
+ }
26001
+ }
26002
+ } catch(e) { /* Build operations signal is optional on home. */ }
25877
26003
  setKpi('kpi-approvals', k.pendingApprovals || 0, (k.pendingApprovals || 0) > 0);
25878
26004
  setKpi('kpi-overdue', k.overdueTasks || 0, (k.overdueTasks || 0) > 0);
25879
26005
 
@@ -27745,6 +27871,7 @@ async function applyBrokenJobFix(jobName) {
27745
27871
  if (res && res.ok) {
27746
27872
  toast('Applied ' + (res.appliedOps || []).length + ' op(s) to ' + jobName, 'success');
27747
27873
  refreshBrokenJobs();
27874
+ if (typeof refreshCron === 'function') refreshCron();
27748
27875
  } else {
27749
27876
  toast('Apply failed: ' + ((res && (res.message || res.error)) || 'unknown'), 'error');
27750
27877
  }
@@ -27760,6 +27887,7 @@ async function dismissBrokenJobDiagnosis(jobName) {
27760
27887
  if (res && res.ok) {
27761
27888
  toast('Diagnosis dismissed', 'info');
27762
27889
  refreshBrokenJobs();
27890
+ if (typeof refreshCron === 'function') refreshCron();
27763
27891
  } else {
27764
27892
  toast('Failed to dismiss: ' + ((res && res.error) || 'unknown'), 'error');
27765
27893
  }