roboticus-api 0.11.4

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
App._metricsTab = 'overview';
App.renderMetrics = function() {
      var metricsTab = App._metricsTab || 'overview';
      var tabBar = '<div class="tabs" style="margin-bottom:1rem">'
        + '<button class="' + (metricsTab === 'overview' ? 'active' : '') + '" data-metrics-tab="overview">Overview</button>'
        + '<button class="' + (metricsTab === 'selection' ? 'active' : '') + '" data-metrics-tab="selection">Model Selection Log</button>'
        + '<button class="' + (metricsTab === 'requests' ? 'active' : '') + '" data-metrics-tab="requests">Request Log</button>'
        + '<button class="' + (metricsTab === 'traces' ? 'active' : '') + '" data-metrics-tab="traces">Pipeline Traces</button>'
        + '<button class="' + (metricsTab === 'flight' ? 'active' : '') + '" data-metrics-tab="flight">Flight Recorder</button>'
        + '<button class="' + (metricsTab === 'delegation' ? 'active' : '') + '" data-metrics-tab="delegation">Delegation Outcomes</button>'
        + '</div>';
      if (metricsTab === 'traces') { return App.renderMetricsTraces(tabBar); }
      if (metricsTab === 'flight') { return App.renderMetricsFlight(tabBar); }
      if (metricsTab === 'delegation') { return App.renderMetricsDelegation(tabBar); }
      return Promise.all([
        api('/api/stats/costs'),
        api('/api/stats/transactions?hours=24'),
        api('/api/stats/capacity').catch(function() { return { providers: {} }; }),
        api('/api/models/selections?limit=50').catch(function() { return { events: [] }; })
      ]).then(function(arr) {
        stackedId = 0;
        var costs = arr[0].costs || []; var txs = arr[1].transactions || []; var capacity = arr[2].providers || {};
        var modelSelections = arr[3].events || [];
        var providerCosts = {}; var providerTokens = {};
        var activeProviders = PROVIDERS.slice();
        var now = new Date(); var xLabels = [];
        for (var i = 0; i < 24; i++) { var h = (now.getHours() - 23 + i + 24) % 24; xLabels.push(String(h).padStart(2, '0') + ':00'); }
        // Discover all providers from actual data first
        costs.forEach(function(c) { var p = (c.provider || '').toLowerCase(); if (p && activeProviders.indexOf(p) === -1) activeProviders.push(p); });
        activeProviders.forEach(function(p) { providerCosts[p] = []; providerTokens[p] = []; for (var i = 0; i < 24; i++) { providerCosts[p].push(0); providerTokens[p].push(0); } });
        costs.forEach(function(c) {
          var p = (c.provider || '').toLowerCase();
          if (!p || !providerCosts[p]) p = activeProviders[0] || 'unknown';
          var costVal = Number(c.cost) || 0;
          var tokVal = (Number(c.tokens_in) || 0) + (Number(c.tokens_out) || 0);
          var bucket = 0;
          if (c.created_at) {
            var d = new Date(c.created_at);
            if (!isNaN(d.getTime())) {
              var hoursAgo = (now.getTime() - d.getTime()) / 3600000;
              bucket = Math.max(0, Math.min(23, 23 - Math.floor(hoursAgo)));
            }
          }
          providerCosts[p][bucket] += costVal;
          providerTokens[p][bucket] += tokVal;
        });

        var FALLBACK_COLORS = ['#9baad6','#fb923c','#34d399','#f472b6','#facc15','#38bdf8','#c084fc','#a3e635'];
        function pColor(p) { return PROVIDER_COLORS[p] || FALLBACK_COLORS[activeProviders.indexOf(p) % FALLBACK_COLORS.length] || '#9baad6'; }
        var providerTotals = {}; var providerTotalTokens = {};
        activeProviders.forEach(function(p) {
          providerTotals[p] = providerCosts[p].reduce(function(a, b) { return a + b; }, 0);
          providerTotalTokens[p] = providerTokens[p].reduce(function(a, b) { return a + b; }, 0);
        });
        // Filter to providers with actual activity for cleaner charts
        var chartProviders = activeProviders.filter(function(p) { return providerTotals[p] > 0 || providerTotalTokens[p] > 0; });
        if (chartProviders.length === 0) chartProviders = activeProviders.slice(0, 3);
        var chartColors = {}; chartProviders.forEach(function(p) { chartColors[p] = pColor(p); });
        var totalCost = activeProviders.reduce(function(s, p) { return s + providerTotals[p]; }, 0);
        var totalTokens = activeProviders.reduce(function(s, p) { return s + providerTotalTokens[p]; }, 0);

        var costChart = '<div class="card" style="margin-bottom:1rem;padding-bottom:0.75rem"><div class="card-title">Cost by Provider (24h)</div><div style="display:flex;align-items:baseline;gap:0.75rem;margin-bottom:0.25rem"><div class="card-value">$' + totalCost.toFixed(2) + '</div><span style="font-size:0.75rem;color:var(--muted)">' + costs.length + ' requests</span></div>' + renderStackedArea(providerCosts, chartProviders, chartColors, { height: 180, yAxis: true, xLabels: xLabels, yFormat: function(v) { return '$' + v.toFixed(3); } }) + '<div class="metrics-legend">';
        chartProviders.forEach(function(p) { var pct = totalCost > 0 ? (providerTotals[p] / totalCost * 100).toFixed(0) : '0'; costChart += '<div class="metrics-legend-item"><div class="metrics-legend-dot" style="background:' + pColor(p) + '"></div>' + p + '<span class="metrics-legend-val">$' + providerTotals[p].toFixed(3) + ' (' + pct + '%)</span></div>'; });
        costChart += '</div></div>';

        var tokenChart = '<div class="card" style="margin-bottom:1rem;padding-bottom:0.75rem"><div class="card-title">Token Volume by Provider (24h)</div><div style="display:flex;align-items:baseline;gap:0.75rem;margin-bottom:0.25rem"><div class="card-value">' + Math.round(totalTokens).toLocaleString() + '</div><span style="font-size:0.75rem;color:var(--muted)">tokens total</span></div>' + renderStackedArea(providerTokens, chartProviders, chartColors, { height: 140, yAxis: true, xLabels: xLabels, yFormat: function(v) { return v >= 1000 ? (v / 1000).toFixed(1) + 'k' : Math.round(v).toString(); } }) + '<div class="metrics-legend">';
        chartProviders.forEach(function(p) { tokenChart += '<div class="metrics-legend-item"><div class="metrics-legend-dot" style="background:' + pColor(p) + '"></div>' + p + '<span class="metrics-legend-val">' + Math.round(providerTotalTokens[p]).toLocaleString() + '</span></div>'; });
        tokenChart += '</div></div>';

        var avgCost = costs.length ? (totalCost / costs.length).toFixed(4) : '0';
        var statGrid = '<div class="metrics-summary-grid"><div class="metrics-stat"><div class="metrics-stat-label">Avg / request</div><div class="metrics-stat-value">$' + avgCost + '</div></div>';
        chartProviders.forEach(function(p) { statGrid += '<div class="metrics-stat"><div class="metrics-stat-label">' + p + ' share</div><div class="metrics-stat-value" style="color:' + pColor(p) + '">' + (totalCost > 0 ? (providerTotals[p] / totalCost * 100).toFixed(0) : '0') + '%</div></div>'; });
        statGrid += '</div>';

        var txRows = txs.map(function(t) { return '<tr><td class="card-mono">' + esc(t.tx_type || '') + '</td><td>' + esc((t.amount || '') + ' ' + (t.currency || '')) + '</td><td>' + esc(t.counterparty || '') + '</td><td>' + esc(t.created_at || '') + '</td></tr>'; }).join('');
        var costRows = costs.slice(0, 20).map(function(c) { var pColor = PROVIDER_COLORS[(c.provider || '').toLowerCase()] || '#9baad6'; return '<tr><td class="card-mono">' + esc(truncate(c.id, 10)) + '</td><td>' + esc(c.model || '') + '</td><td><span style="color:' + pColor + '">' + esc(c.provider || '') + '</span></td><td>' + (c.tokens_in || 0) + '</td><td>' + (c.tokens_out || 0) + '</td><td>$' + Number(c.cost || 0).toFixed(6) + '</td><td>' + esc(c.created_at || '') + '</td></tr>'; }).join('');
        var capNames = Object.keys(capacity).sort();
        var capRows = capNames.map(function(name) {
          var p = capacity[name] || {};
          var headroom = Number(p.headroom || 0);
          var pressure = p.sustained_hot ? '<span class="badge error">hot</span>' : (p.near_capacity ? '<span class="badge warning">near cap</span>' : '<span class="badge success">healthy</span>');
          var tpm = p.tpm_limit ? (Math.round((p.token_utilization || 0) * 100) + '% (' + (p.tokens_used || 0).toLocaleString() + '/' + (p.tpm_limit || 0).toLocaleString() + ')') : 'n/a';
          var rpm = p.rpm_limit ? (Math.round((p.request_utilization || 0) * 100) + '% (' + (p.requests_used || 0).toLocaleString() + '/' + (p.rpm_limit || 0).toLocaleString() + ')') : 'n/a';
          return '<tr><td class="card-mono">' + esc(name) + '</td><td>' + Math.round(headroom * 100) + '%</td><td>' + pressure + '</td><td>' + esc(tpm) + '</td><td>' + esc(rpm) + '</td></tr>';
        }).join('');
        var capacityTable = '<p style="margin:1.25rem 0 0.5rem;color:var(--muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em">Capacity & Headroom</p>'
          + '<div class="table-wrap" style="margin-bottom:1.25rem"><table><thead><tr><th>Provider</th><th>Headroom</th><th>State</th><th>TPM Utilization</th><th>RPM Utilization</th></tr></thead><tbody>'
          + (capRows || '<tr><td colspan="5" style="color:var(--muted)">No capacity limits configured.</td></tr>')
          + '</tbody></table></div>';

        var selectionRows = modelSelections.map(function(ms) {
          var strategy = ms.strategy || 'unknown';
          var cands = (ms.candidates || []).length;
          return '<tr>'
            + '<td class="card-mono">' + esc(truncate(ms.turn_id || '', 10)) + '</td>'
            + '<td class="card-mono">' + esc(ms.selected_model || '') + '</td>'
            + '<td>' + esc(strategy) + '</td>'
            + '<td>' + cands + '</td>'
            + '<td>' + esc(ms.complexity || '') + '</td>'
            + '<td>' + esc(ms.created_at || '') + '</td>'
            + '</tr>';
        }).join('');
        var selectionTable = '<p style="margin:1.25rem 0 0.5rem;color:var(--muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em">Live Model Selection Log</p>'
          + '<div class="table-wrap" style="margin-bottom:1.25rem"><table><thead><tr><th>Turn</th><th>Selected Model</th><th>Strategy</th><th>Candidates</th><th>Complexity</th><th>Time</th></tr></thead><tbody>'
          + (selectionRows || '<tr><td colspan="6" style="color:var(--muted)">No model selection traces yet.</td></tr>')
          + '</tbody></table></div>';

        var tabContent = '';
        if (metricsTab === 'selection') {
          tabContent = selectionTable;
        } else if (metricsTab === 'requests') {
          tabContent = '<p style="margin:1.25rem 0 0.5rem;color:var(--muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em">Transactions</p><div class="table-wrap" style="margin-bottom:1.25rem"><table><thead><tr><th>Type</th><th>Amount</th><th>Counterparty</th><th>Time</th></tr></thead><tbody>' + txRows + '</tbody></table></div>'
            + '<p style="margin-bottom:0.5rem;color:var(--muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em">Request log</p><div class="table-wrap"><table><thead><tr><th>ID</th><th>Model</th><th>Provider</th><th>In</th><th>Out</th><th>Cost</th><th>Time</th></tr></thead><tbody>' + costRows + '</tbody></table></div>';
        } else {
          tabContent = costChart + tokenChart + statGrid + capacityTable;
        }
        return tabBar + tabContent;
      });
};
App.renderMetricsTraces = function(tabBar) {
      var self = this;
      var selectedSession = App._tracesSessionId || '';
      return api('/api/sessions?limit=50').catch(function() { return { sessions: [] }; }).then(function(data) {
        var sessions = data.sessions || [];
        var sessionOpts = sessions.map(function(s) {
          var sid = s.id || s.session_id || '';
          var label = s.nickname || s.channel || truncate(sid, 12);
          return '<option value="' + esc(sid) + '"' + (selectedSession === sid ? ' selected' : '') + '>' + esc(label) + '</option>';
        }).join('');
        var sessionSelector = '<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.75rem">'
          + '<label style="font-size:0.8125rem;color:var(--muted)">Session:</label>'
          + '<select id="traces-session-select" style="font-size:0.8125rem;padding:0.3rem 0.6rem;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--text)">'
          + '<option value="">-- select session --</option>'
          + sessionOpts
          + '</select>'
          + '</div>';
        if (!selectedSession) {
          return tabBar + sessionSelector + '<p style="color:var(--muted);font-size:0.875rem">Select a session to view pipeline traces.</p>';
        }
        return api('/api/observability/traces?session_id=' + encodeURIComponent(selectedSession) + '&limit=20').catch(function() { return { traces: [] }; }).then(function(td) {
          var traces = td.traces || [];
          var rows = traces.map(function(t) {
            return '<tr class="traces-turn-row" data-turn-id="' + esc(t.turn_id || '') + '" style="cursor:pointer">'
              + '<td class="card-mono">' + esc(truncate(t.turn_id || '', 12)) + '</td>'
              + '<td>' + esc(t.channel || '') + '</td>'
              + '<td>' + (t.total_ms || 0) + 'ms</td>'
              + '<td>' + (t.stage_count || 0) + '</td>'
              + '<td>' + esc(t.created_at || '') + '</td>'
              + '<td><a href="#" class="trace-flow-link" data-flow-turn-id="' + esc(t.turn_id || '') + '" style="font-size:0.75rem;color:var(--accent);text-decoration:none" title="View decision flow">Flow</a></td>'
              + '</tr>'
              + '<tr class="traces-waterfall-row" id="wf-' + esc(t.turn_id || '') + '" style="display:none"><td colspan="6" style="padding:0.5rem 1rem 0.75rem;background:var(--surface-alt,var(--surface))"><div class="wf-content" style="color:var(--muted);font-size:0.8125rem">Loading\u2026</div></td></tr>';
          }).join('');
          var table = '<div class="table-wrap" style="margin-bottom:1.25rem"><table><thead><tr><th>Turn ID</th><th>Channel</th><th>Duration</th><th>Stages</th><th>Time</th><th></th></tr></thead><tbody>'
            + (rows || '<tr><td colspan="6" style="color:var(--muted)">No traces found for this session.</td></tr>')
            + '</tbody></table></div>';
          return tabBar + sessionSelector + table;
        });
      });
};
App.renderMetricsFlight = function(tabBar) {
      var turnId = App._flightTurnId || '';
      // Build session selector + recent turns for browsing
      return api('/api/sessions?limit=50').catch(function() { return { sessions: [] }; }).then(function(sessData) {
        var sessions = (sessData.sessions || []).slice(0, 30);
        var selectedFlightSession = App._flightSessionId || '';
        var sessionOpts = '<option value="">Select session\u2026</option>'
          + sessions.map(function(s) {
            var label = s.nickname || truncate(s.id, 16);
            return '<option value="' + esc(s.id) + '"' + (s.id === selectedFlightSession ? ' selected' : '') + '>' + esc(label) + '</option>';
          }).join('');
        var sessionSelector = '<div style="margin-bottom:0.75rem;display:flex;align-items:center;gap:0.75rem">'
          + '<label style="font-size:0.8125rem;color:var(--muted)">Session:</label>'
          + '<select id="flight-session-select" style="font-size:0.8125rem;padding:0.3rem 0.6rem;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--text)">'
          + sessionOpts + '</select></div>';

        // If a session is selected, show its recent turns as clickable list
        var turnListPromise = selectedFlightSession
          ? api('/api/observability/traces?session_id=' + encodeURIComponent(selectedFlightSession) + '&limit=20').catch(function() { return { traces: [] }; })
          : Promise.resolve({ traces: [] });

        return turnListPromise.then(function(td) {
          var traces = td.traces || [];
          var turnPicker = traces.length > 0
            ? '<div style="margin-bottom:0.75rem"><div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.25rem">Recent turns (click to load):</div>'
              + traces.map(function(t) {
                var active = t.turn_id === turnId ? 'font-weight:600;color:var(--accent)' : 'color:var(--text)';
                return '<a href="#" class="flight-turn-pick" data-turn-id="' + esc(t.turn_id) + '" style="display:inline-block;font-size:0.6875rem;padding:0.15rem 0.5rem;margin:0.125rem;border:1px solid var(--border);border-radius:3px;text-decoration:none;' + active + '">'
                  + esc(truncate(t.turn_id, 8)) + ' (' + (t.total_ms || '?') + 'ms)</a>';
              }).join('') + '</div>'
            : (selectedFlightSession ? '<p style="color:var(--muted);font-size:0.75rem;margin-bottom:0.75rem">No traces for this session.</p>' : '');

          var inputRow = '<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.75rem">'
            + '<label style="font-size:0.8125rem;color:var(--muted)">Turn ID:</label>'
            + '<input id="flight-turn-input" type="text" value="' + esc(turnId) + '" placeholder="Enter turn ID or select above\u2026" style="font-size:0.8125rem;padding:0.3rem 0.6rem;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--text);width:22rem;font-family:var(--mono)">'
            + '<button class="btn" id="flight-load-btn" style="font-size:0.75rem;padding:0.3rem 0.7rem">Load</button>'
            + '</div>';
          if (!turnId) {
            return tabBar + sessionSelector + turnPicker + inputRow + '<p style="color:var(--muted);font-size:0.875rem">Select a session and turn above, or enter a turn ID manually.</p>';
          }
      return api('/api/traces/' + encodeURIComponent(turnId) + '/react').catch(function() { return { steps: [] }; }).then(function(data) {
        var steps = data.steps || [];
        function stepColor(type) {
          if (type === 'ToolCall') return '#3b82f6';
          if (type === 'Retrieval') return '#22c55e';
          if (type === 'Guard') return '#f97316';
          if (type === 'Normalization') return '#ef4444';
          return '#9baad6';
        }
        function stepDetail(s) {
          var parts = [];
          if (s.tool_name) parts.push('tool: ' + esc(s.tool_name));
          if (s.duration_ms != null) parts.push(s.duration_ms + 'ms');
          if (s.success != null) parts.push(s.success ? 'ok' : 'fail');
          if (s.candidates != null) parts.push('candidates: ' + s.candidates);
          if (s.similarity != null) parts.push('sim: ' + Number(s.similarity).toFixed(3));
          if (s.name) parts.push(esc(s.name));
          if (s.fired != null) parts.push(s.fired ? 'fired' : 'pass');
          if (s.action) parts.push(esc(s.action));
          if (s.pattern) parts.push('pattern: ' + esc(truncate(s.pattern, 30)));
          if (s.retry != null) parts.push('retry: ' + s.retry);
          return parts.join(' \u00b7 ');
        }
        var timeline = steps.map(function(s, i) {
          var type = s.step_type || s.type || 'Unknown';
          var color = stepColor(type);
          return '<div style="display:flex;align-items:flex-start;gap:0.75rem;margin-bottom:0.6rem">'
            + '<div style="min-width:3.5rem;text-align:right;font-size:0.6875rem;color:var(--muted);padding-top:0.2rem">' + (i + 1) + '</div>'
            + '<div style="width:2px;background:var(--border);align-self:stretch;min-height:1.5rem"></div>'
            + '<div style="flex:1;background:var(--surface);border:1px solid var(--border);border-left:3px solid ' + color + ';border-radius:4px;padding:0.35rem 0.65rem">'
            + '<div style="display:flex;align-items:center;gap:0.5rem">'
            + '<span style="font-size:0.75rem;font-weight:600;color:' + color + '">' + esc(type) + '</span>'
            + '<span style="font-size:0.75rem;color:var(--muted)">' + stepDetail(s) + '</span>'
            + '</div>'
            + '</div>'
            + '</div>';
        }).join('');
        var content = steps.length
          ? '<div style="padding:0.25rem 0">' + timeline + '</div>'
          : '<p style="color:var(--muted);font-size:0.875rem">No ReAct steps recorded for this turn.</p>';
        return tabBar + sessionSelector + turnPicker + inputRow + content;
      });
      }); // turnListPromise
      }); // sessData
};
App.renderTraceFlow = function(turnId) {
      return api('/api/traces/' + encodeURIComponent(turnId) + '/flow').then(function(data) {
        var nodes = data.nodes || [];
        var totalMs = data.total_ms || 0;
        var header = '<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.75rem">'
          + '<button class="btn" id="flow-back-btn" style="font-size:0.75rem;padding:0.3rem 0.7rem">&larr; Back</button>'
          + '<span style="font-size:0.8125rem;color:var(--muted)">Decision Flow for <span class="card-mono">' + esc(truncate(turnId, 16)) + '</span></span>'
          + '<span style="font-size:0.75rem;color:var(--muted);margin-left:auto;font-family:var(--font-mono)">' + totalMs + 'ms total</span>'
          + '</div>';
        if (!nodes.length) {
          return header + '<p style="color:var(--muted);font-size:0.875rem">No flow data for this turn.</p>';
        }

        // ── SVG Flow Graph ──────────────────────────────────────────
        var nodeW = 180, nodeH = 44, gapY = 28, padX = 40, padY = 30;
        var svgW = nodeW + padX * 2;
        var svgH = nodes.length * (nodeH + gapY) - gapY + padY * 2;

        var statusColor = function(s) {
          if (s === 'pass' || s === 'executed' || s === 'Ok') return 'var(--success, #22c55e)';
          if (s === 'skip' || s === 'Skipped') return 'var(--muted, #64748b)';
          if (s === 'retry' || s === 'guard_fired') return 'var(--warning, #f59e0b)';
          if (s === 'block' || s === 'fallback' || s === 'Error') return 'var(--error, #ef4444)';
          return 'var(--accent, #c180ff)';
        };

        var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + svgW + '" height="' + svgH
          + '" viewBox="0 0 ' + svgW + ' ' + svgH + '" style="display:block;margin:0 auto">';

        // Arrow marker definition
        svg += '<defs><marker id="flow-arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto">'
          + '<polygon points="0 0, 10 3.5, 0 7" fill="var(--border-ghost, #334155)"/></marker></defs>';

        // Render edges (arrows between consecutive nodes)
        for (var i = 0; i < nodes.length - 1; i++) {
          var y1 = padY + i * (nodeH + gapY) + nodeH;
          var y2 = padY + (i + 1) * (nodeH + gapY);
          var cx = padX + nodeW / 2;
          svg += '<line x1="' + cx + '" y1="' + y1 + '" x2="' + cx + '" y2="' + y2
            + '" stroke="var(--border-ghost, #334155)" stroke-width="2" marker-end="url(#flow-arrow)"/>';
        }

        // Render nodes
        nodes.forEach(function(n, idx) {
          var x = padX, y = padY + idx * (nodeH + gapY);
          var status = n.status || 'executed';
          var borderColor = statusColor(status);
          var label = n.label || n.id || 'stage';
          var dur = (n.duration_ms || 0) + 'ms';

          svg += '<g class="flow-svg-node" data-flow-idx="' + idx + '" style="cursor:pointer">'
            + '<rect x="' + x + '" y="' + y + '" width="' + nodeW + '" height="' + nodeH
            + '" rx="8" fill="var(--surface-2, #1e293b)" stroke="' + borderColor + '" stroke-width="2"/>'
            + '<text x="' + (x + 12) + '" y="' + (y + 18) + '" fill="var(--text, #e2e8f0)" font-size="12" font-weight="600" font-family="var(--font, sans-serif)">'
            + esc(label.length > 20 ? label.substring(0, 18) + '..' : label) + '</text>'
            + '<text x="' + (x + 12) + '" y="' + (y + 34) + '" fill="var(--muted, #94a3b8)" font-size="10" font-family="var(--font-mono, monospace)">'
            + esc(dur) + ' · ' + esc(status) + '</text>'
            + '<circle cx="' + (x + nodeW - 14) + '" cy="' + (y + nodeH / 2)
            + '" r="5" fill="' + borderColor + '"/>'
            + '</g>';
        });

        svg += '</svg>';

        // ── Floating detail popover (click to show) ─────────────────
        var popover = '<div id="flow-popover" style="display:none;position:absolute;z-index:100;background:var(--surface-3,#1a1a2e);border:1px solid var(--accent);border-radius:var(--radius,8px);padding:0.75rem 1rem;max-width:420px;box-shadow:0 8px 32px rgba(0,0,0,0.4);font-size:0.8125rem;color:var(--text);pointer-events:auto">'
          + '<div id="flow-popover-content"></div></div>';

        // Store node data for click handler
        var flowContainer = '<div id="flow-svg-container" style="position:relative;overflow-x:auto">'
          + svg + popover + '</div>'
          + '<script type="application/json" id="flow-node-data">' + JSON.stringify(nodes.map(function(n) {
            return { id: n.id, label: n.label, status: n.status, duration_ms: n.duration_ms, detail: n.detail };
          })) + '</' + 'script>';

        return header + flowContainer;
      }).catch(function(err) {
        return '<p style="color:var(--error)">Failed to load flow: ' + esc(err.message || String(err)) + '</p>';
      });
};
App.renderMetricsDelegation = function(tabBar) {
      return Promise.all([
        api('/api/observability/delegation/outcomes?limit=20').catch(function() { return { outcomes: [] }; }),
        api('/api/observability/delegation/stats?hours=24').catch(function() { return {}; })
      ]).then(function(arr) {
        var outcomes = arr[0].outcomes || [];
        var stats = arr[1] || {};
        function qualColor(q) {
          var v = Number(q) || 0;
          return v > 0.7 ? '#22c55e' : v >= 0.4 ? '#facc15' : '#ef4444';
        }
        var totalDels = stats.total_delegations || outcomes.length;
        var successRate = stats.success_rate != null ? (Number(stats.success_rate) * 100).toFixed(0) + '%' : 'n/a';
        var avgQuality = stats.avg_quality != null ? Number(stats.avg_quality).toFixed(3) : 'n/a';
        var summaryRow = '<div class="metrics-summary-grid" style="margin-bottom:1rem">'
          + '<div class="metrics-stat"><div class="metrics-stat-label">Total delegations</div><div class="metrics-stat-value">' + totalDels + '</div></div>'
          + '<div class="metrics-stat"><div class="metrics-stat-label">Success rate (24h)</div><div class="metrics-stat-value">' + esc(successRate) + '</div></div>'
          + '<div class="metrics-stat"><div class="metrics-stat-label">Avg quality (24h)</div><div class="metrics-stat-value">' + esc(avgQuality) + '</div></div>'
          + '</div>';
        var rows = outcomes.map(function(o) {
          var agents = '';
          try { var ag = JSON.parse(o.assigned_agents_json || '[]'); agents = ag.join(', '); } catch(e) { agents = o.assigned_agents_json || ''; }
          var q = Number(o.quality_score) || 0;
          return '<tr>'
            + '<td>' + esc(truncate(o.task_description || '', 60)) + '</td>'
            + '<td>' + (o.subtask_count || 0) + '</td>'
            + '<td class="card-mono" style="font-size:0.75rem">' + esc(truncate(agents, 40)) + '</td>'
            + '<td>' + (o.duration_ms || 0) + 'ms</td>'
            + '<td><span style="color:' + qualColor(q) + ';font-weight:600">' + q.toFixed(3) + '</span></td>'
            + '<td>' + (o.retry_count || 0) + '</td>'
            + '<td>' + esc(o.created_at || '') + '</td>'
            + '</tr>';
        }).join('');
        var table = '<div class="table-wrap"><table><thead><tr><th>Task</th><th>Subtasks</th><th>Agents</th><th>Duration</th><th>Quality</th><th>Retries</th><th>Time</th></tr></thead><tbody>'
          + (rows || '<tr><td colspan="7" style="color:var(--muted)">No delegation outcomes recorded yet.</td></tr>')
          + '</tbody></table></div>';
        return tabBar + summaryRow + table;
      });
};