Add live agent views and improve Codex monitoring
This commit is contained in:
@@ -192,6 +192,19 @@ AGENTMON_VM_NAME=zap # or orb, sun
|
|||||||
|
|
||||||
Deployment is automated via Ansible — see the [swarm ansible playbook](https://gitea-http.taildb3494.ts.net/will/swarm) `playbooks/customize.yml`.
|
Deployment is automated via Ansible — see the [swarm ansible playbook](https://gitea-http.taildb3494.ts.net/will/swarm) `playbooks/customize.yml`.
|
||||||
|
|
||||||
|
## Codex Hook
|
||||||
|
|
||||||
|
The `hooks/codex/` directory contains a TypeScript handler for Codex CLI telemetry. Current Codex support is session/run oriented:
|
||||||
|
|
||||||
|
- `sessionStart` and `sessionEnd` map to `session.start`, `run.start`, `run.end`, and `session.end`
|
||||||
|
- `notify` maps turn-complete notifications into `run.end`
|
||||||
|
- prompt-submit hooks map user prompts into the next `run.start`
|
||||||
|
- usage payloads emit both `run.end.payload.usage` and a `metric.snapshot` event
|
||||||
|
|
||||||
|
Sample Codex hook configuration lives in [hooks/codex/hooks.json](/home/will/lab/agentmon/hooks/codex/hooks.json). On the local Codex CLI version we checked (`0.116.0`), `notify` is confirmed. Online reports suggest prompt-submit hooks may appear as `userpromptsubmit` or `userPromptSubmit`, so the sample config includes those aliases.
|
||||||
|
|
||||||
|
The current Codex integration does not assume tool or subagent span hooks exist. If a newer Codex CLI exposes official tool/span hooks, they can be added separately without changing the run/session flow above.
|
||||||
|
|
||||||
## Go SDK
|
## Go SDK
|
||||||
|
|
||||||
Emit events from Go applications:
|
Emit events from Go applications:
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ func main() {
|
|||||||
Limit: limit,
|
Limit: limit,
|
||||||
EventType: r.URL.Query().Get("event_type"),
|
EventType: r.URL.Query().Get("event_type"),
|
||||||
Framework: r.URL.Query().Get("framework"),
|
Framework: r.URL.Query().Get("framework"),
|
||||||
|
ClientID: r.URL.Query().Get("client_id"),
|
||||||
}
|
}
|
||||||
events, err := db.ListRecentEvents(r.Context(), f)
|
events, err := db.ListRecentEvents(r.Context(), f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -195,6 +196,23 @@ func main() {
|
|||||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"session": session, "runs": runs})
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"session": session, "runs": runs})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Get("/v1/agents/live", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientID := r.URL.Query().Get("client_id")
|
||||||
|
framework := r.URL.Query().Get("framework")
|
||||||
|
if clientID == "" || framework == "" {
|
||||||
|
httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "missing_agent_selector"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
events, err := db.ListAgentLiveEvents(r.Context(), framework, clientID, limit)
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"events": events})
|
||||||
|
})
|
||||||
|
|
||||||
r.Get("/v1/runs/{runID}", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/v1/runs/{runID}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
runID := chi.URLParam(r, "runID")
|
runID := chi.URLParam(r, "runID")
|
||||||
run, spans, err := db.GetRunWithSpans(r.Context(), runID)
|
run, spans, err := db.GetRunWithSpans(r.Context(), runID)
|
||||||
|
|||||||
+658
-85
@@ -475,6 +475,7 @@
|
|||||||
const data = await api('/v1/sessions/' + sessionID);
|
const data = await api('/v1/sessions/' + sessionID);
|
||||||
const s = data.session;
|
const s = data.session;
|
||||||
const runs = data.runs || [];
|
const runs = data.runs || [];
|
||||||
|
const active = !s.ended_at;
|
||||||
const duration = s.ended_at
|
const duration = s.ended_at
|
||||||
? formatDuration(new Date(s.ended_at) - new Date(s.started_at))
|
? formatDuration(new Date(s.ended_at) - new Date(s.started_at))
|
||||||
: 'ongoing';
|
: 'ongoing';
|
||||||
@@ -483,6 +484,10 @@
|
|||||||
<a href="/sessions" class="back-link">← Back to Sessions</a>
|
<a href="/sessions" class="back-link">← Back to Sessions</a>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(sessionID.substring(0, 16))}...</span></h2>
|
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(sessionID.substring(0, 16))}...</span></h2>
|
||||||
|
<div class="session-status-line">
|
||||||
|
<span class="fw-dot ${escapeHTML((s.framework || 'unknown').replace(/[^a-z0-9-]/g, '-'))} ${active ? 'active' : 'ended'}"></span>
|
||||||
|
<span class="session-status-text">${active ? 'Active' : 'Ended'}</span>
|
||||||
|
</div>
|
||||||
<div class="meta-tiles">
|
<div class="meta-tiles">
|
||||||
<div class="meta-tile">
|
<div class="meta-tile">
|
||||||
<div class="meta-tile-label">Started</div>
|
<div class="meta-tile-label">Started</div>
|
||||||
@@ -516,32 +521,14 @@
|
|||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="session-runs-body">
|
||||||
${runs.map(r => {
|
${renderSessionRunsRows(runs)}
|
||||||
const runDuration = r.ended_at
|
|
||||||
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
|
||||||
: '-';
|
|
||||||
const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-';
|
|
||||||
return `
|
|
||||||
<tr class="clickable" data-run="${escapeHTML(r.run_id)}">
|
|
||||||
<td class="id-cell">${escapeHTML(r.run_id.substring(0, 12))}...</td>
|
|
||||||
<td>${statusIcon(r.status)}</td>
|
|
||||||
<td><span class="model-badge">${modelLabel}</span></td>
|
|
||||||
<td>${r.tool_count || 0}</td>
|
|
||||||
<td>${r.span_count}</td>
|
|
||||||
<td>${escapeHTML(runDuration)}</td>
|
|
||||||
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('') || '<tr><td colspan="7" class="empty-state">No runs</td></tr>'}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.querySelectorAll('tr.clickable').forEach(row => {
|
bindSessionRunRows();
|
||||||
row.addEventListener('click', () => navigate('/runs/' + row.dataset.run));
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector('.back-link').addEventListener('click', e => {
|
document.querySelector('.back-link').addEventListener('click', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -564,30 +551,11 @@
|
|||||||
const data = await api('/v1/sessions/' + sessionID);
|
const data = await api('/v1/sessions/' + sessionID);
|
||||||
const runs = data.runs || [];
|
const runs = data.runs || [];
|
||||||
|
|
||||||
const tbody = document.querySelector('#app table tbody');
|
const tbody = document.getElementById('session-runs-body');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
tbody.innerHTML = runs.map(r => {
|
tbody.innerHTML = renderSessionRunsRows(runs);
|
||||||
const runDuration = r.ended_at
|
bindSessionRunRows();
|
||||||
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
|
||||||
: '-';
|
|
||||||
const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-';
|
|
||||||
return `
|
|
||||||
<tr class="clickable" data-run="${escapeHTML(r.run_id)}">
|
|
||||||
<td class="id-cell">${escapeHTML(r.run_id.substring(0, 12))}...</td>
|
|
||||||
<td>${statusIcon(r.status)}</td>
|
|
||||||
<td><span class="model-badge">${modelLabel}</span></td>
|
|
||||||
<td>${r.tool_count || 0}</td>
|
|
||||||
<td>${r.span_count}</td>
|
|
||||||
<td>${escapeHTML(runDuration)}</td>
|
|
||||||
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('') || '<tr><td colspan="7" class="empty-state">No runs</td></tr>';
|
|
||||||
|
|
||||||
tbody.querySelectorAll('tr.clickable').forEach(row => {
|
|
||||||
row.addEventListener('click', () => navigate('/runs/' + row.dataset.run));
|
|
||||||
});
|
|
||||||
|
|
||||||
const countSpan = document.querySelector('.section-title .count');
|
const countSpan = document.querySelector('.section-title .count');
|
||||||
if (countSpan) countSpan.textContent = runs.length;
|
if (countSpan) countSpan.textContent = runs.length;
|
||||||
@@ -667,6 +635,96 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSessionRunsRows(runs) {
|
||||||
|
if (!runs || runs.length === 0) {
|
||||||
|
return '<tr><td colspan="7" class="empty-state">No runs</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs.map((r, i) => {
|
||||||
|
const runDuration = r.ended_at
|
||||||
|
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||||
|
: '-';
|
||||||
|
const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-';
|
||||||
|
const spans = r.spans || [];
|
||||||
|
const spansHTML = spans.length > 0 ? `
|
||||||
|
<div class="session-run-spans">
|
||||||
|
${spans.map(sp => {
|
||||||
|
const body = getSessionSpanSummary(sp);
|
||||||
|
return `
|
||||||
|
<div class="session-span-pill ${escapeHTML(sp.kind || 'unknown')}">
|
||||||
|
<span class="session-span-name">${escapeHTML(sp.name || sp.kind || 'span')}</span>
|
||||||
|
<span class="session-span-meta">${escapeHTML(body)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
` : '<div class="empty-state" style="padding:0.5rem 0">No spans yet</div>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="clickable expandable-run" data-run="${escapeHTML(r.run_id)}" data-index="${i}">
|
||||||
|
<td class="id-cell"><span class="expand-icon"></span>${escapeHTML(r.run_id.substring(0, 12))}...</td>
|
||||||
|
<td>${statusIcon(r.status)}</td>
|
||||||
|
<td><span class="model-badge">${modelLabel}</span></td>
|
||||||
|
<td>${r.tool_count || 0}</td>
|
||||||
|
<td>${r.span_count}</td>
|
||||||
|
<td>${escapeHTML(runDuration)}</td>
|
||||||
|
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="span-detail-row" data-index="${i}" style="display:none">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="session-run-detail">
|
||||||
|
<div class="section-title" style="margin-bottom:0.5rem">Spans <span class="count">${spans.length}</span></div>
|
||||||
|
${spansHTML}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionSpanSummary(sp) {
|
||||||
|
const payload = sp.payload || {};
|
||||||
|
const innerPayload = payload.payload || {};
|
||||||
|
if (sp.kind === 'tool') {
|
||||||
|
const result = innerPayload.result_preview || '';
|
||||||
|
const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
|
||||||
|
return result ? `${duration} · ${String(result).slice(0, 80)}` : duration;
|
||||||
|
}
|
||||||
|
if (sp.kind === 'agent') {
|
||||||
|
const usage = innerPayload.usage || {};
|
||||||
|
const totalTokens = usage.total_tokens !== undefined ? `${usage.total_tokens} tok` : '';
|
||||||
|
const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
|
||||||
|
return totalTokens ? `${duration} · ${totalTokens}` : duration;
|
||||||
|
}
|
||||||
|
return sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSessionRunRows() {
|
||||||
|
document.querySelectorAll('tr.expandable-run').forEach(row => {
|
||||||
|
row.addEventListener('click', event => {
|
||||||
|
if (event.metaKey || event.ctrlKey) {
|
||||||
|
navigate('/runs/' + row.dataset.run);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = row.dataset.index;
|
||||||
|
const detailRow = document.querySelector(`tr.span-detail-row[data-index="${idx}"]`);
|
||||||
|
const icon = row.querySelector('.expand-icon');
|
||||||
|
if (!detailRow) return;
|
||||||
|
|
||||||
|
if (detailRow.style.display === 'none') {
|
||||||
|
detailRow.style.display = 'table-row';
|
||||||
|
if (icon) icon.style.transform = 'rotate(45deg)';
|
||||||
|
} else {
|
||||||
|
detailRow.style.display = 'none';
|
||||||
|
if (icon) icon.style.transform = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.addEventListener('dblclick', () => navigate('/runs/' + row.dataset.run));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function renderInfrastructure() {
|
async function renderInfrastructure() {
|
||||||
app.innerHTML = '<div class="page-header"><h2>Infrastructure</h2></div><p class="empty-state">Loading...</p>';
|
app.innerHTML = '<div class="page-header"><h2>Infrastructure</h2></div><p class="empty-state">Loading...</p>';
|
||||||
|
|
||||||
@@ -1020,13 +1078,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createAgentsState() {
|
function createAgentsState() {
|
||||||
function agentBucket() {
|
|
||||||
return { sessions: {}, operations: {}, events: [], eventIDs: new Set() };
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
agents: { zap: agentBucket(), orb: agentBucket(), sun: agentBucket() },
|
agents: {},
|
||||||
stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} },
|
stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} },
|
||||||
dbStats: { messages: 0, tools: 0, errors: 0 },
|
dbStats: { messages: 0, tools: 0, errors: 0 },
|
||||||
|
viewMode: 'overview',
|
||||||
|
selectedAgentKey: '',
|
||||||
timerInterval: null,
|
timerInterval: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1044,9 +1101,134 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAgentKey(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentIdentity(evt) {
|
||||||
|
const source = getEnvelopeSource(evt);
|
||||||
|
const correlation = getEnvelopeCorrelation(evt);
|
||||||
|
const framework = source.framework || evt.source_framework || 'unknown';
|
||||||
|
const host = source.host || '';
|
||||||
|
const clientID = source.client_id || '';
|
||||||
|
const sessionID = correlation.session_id || '';
|
||||||
|
const name = clientID || host || framework || sessionID || 'unknown';
|
||||||
|
const key = normalizeAgentKey(clientID || host || sessionID || framework || 'unknown');
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
framework,
|
||||||
|
host,
|
||||||
|
clientID,
|
||||||
|
sessionID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAgentBucket(evt) {
|
||||||
|
const identity = getAgentIdentity(evt);
|
||||||
|
if (!identity.key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agentsState.agents[identity.key]) {
|
||||||
|
agentsState.agents[identity.key] = {
|
||||||
|
key: identity.key,
|
||||||
|
name: identity.name,
|
||||||
|
framework: identity.framework,
|
||||||
|
host: identity.host,
|
||||||
|
clientID: identity.clientID,
|
||||||
|
sessions: {},
|
||||||
|
operations: {},
|
||||||
|
events: [],
|
||||||
|
eventIDs: new Set(),
|
||||||
|
lastSeenAt: 0,
|
||||||
|
liveLoaded: false,
|
||||||
|
liveLoading: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = agentsState.agents[identity.key];
|
||||||
|
agent.name = identity.name || agent.name || identity.key;
|
||||||
|
agent.framework = identity.framework || agent.framework;
|
||||||
|
agent.host = identity.host || agent.host;
|
||||||
|
agent.clientID = identity.clientID || agent.clientID;
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedAgentKeys() {
|
||||||
|
return Object.keys(agentsState.agents).sort((a, b) => {
|
||||||
|
const left = agentsState.agents[a];
|
||||||
|
const right = agentsState.agents[b];
|
||||||
|
const leftOnline = isAgentOnline(left);
|
||||||
|
const rightOnline = isAgentOnline(right);
|
||||||
|
|
||||||
|
if (leftOnline !== rightOnline) {
|
||||||
|
return leftOnline ? -1 : 1;
|
||||||
|
}
|
||||||
|
return (left.name || left.key).localeCompare(right.name || right.key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSelectedAgentKey() {
|
||||||
|
const keys = getSortedAgentKeys();
|
||||||
|
if (keys.length === 0) {
|
||||||
|
agentsState.selectedAgentKey = '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (!agentsState.selectedAgentKey || !agentsState.agents[agentsState.selectedAgentKey]) {
|
||||||
|
agentsState.selectedAgentKey = keys[0];
|
||||||
|
}
|
||||||
|
return agentsState.selectedAgentKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAgentsViewMode(mode) {
|
||||||
|
agentsState.viewMode = mode === 'live' ? 'live' : 'overview';
|
||||||
|
renderAgentsContent();
|
||||||
|
if (agentsState.viewMode === 'live') {
|
||||||
|
void loadSelectedAgentLiveData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAgent(key, nextMode) {
|
||||||
|
if (!key || !agentsState.agents[key]) return;
|
||||||
|
agentsState.selectedAgentKey = key;
|
||||||
|
if (nextMode) {
|
||||||
|
agentsState.viewMode = nextMode;
|
||||||
|
}
|
||||||
|
renderAgentsContent();
|
||||||
|
if (agentsState.viewMode === 'live') {
|
||||||
|
void loadSelectedAgentLiveData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenClawVM(agent) {
|
||||||
|
const key = normalizeAgentKey(agent && agent.name);
|
||||||
|
return ['zap', 'orb', 'sun'].includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAgentOnline(agent) {
|
||||||
|
if (!agent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpenClawVM(agent)) {
|
||||||
|
const vmStatus = getVMStatus().find(v => v.name === normalizeAgentKey(agent.name));
|
||||||
|
if (vmStatus) {
|
||||||
|
return vmStatus.active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSessions = Object.keys(agent.sessions).length > 0;
|
||||||
|
const hasOps = Object.keys(agent.operations).length > 0;
|
||||||
|
const seenRecently = agent.lastSeenAt > 0 && (Date.now() - agent.lastSeenAt) < 300000;
|
||||||
|
return hasSessions || hasOps || seenRecently;
|
||||||
|
}
|
||||||
|
|
||||||
function getAgentBucket(evt) {
|
function getAgentBucket(evt) {
|
||||||
const name = getVMName(evt).toLowerCase();
|
return ensureAgentBucket(evt);
|
||||||
return agentsState.agents[name] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function processAgentEvent(evt) {
|
function processAgentEvent(evt) {
|
||||||
@@ -1056,6 +1238,8 @@
|
|||||||
const eventType = getEnvelopeType(evt);
|
const eventType = getEnvelopeType(evt);
|
||||||
const correlation = getEnvelopeCorrelation(evt);
|
const correlation = getEnvelopeCorrelation(evt);
|
||||||
const attrs = getEnvelopeAttributes(evt);
|
const attrs = getEnvelopeAttributes(evt);
|
||||||
|
const ts = new Date(getEnvelopeTS(evt)).getTime();
|
||||||
|
agent.lastSeenAt = Number.isFinite(ts) ? ts : Date.now();
|
||||||
|
|
||||||
if (eventType === 'session.start' && correlation.session_id) {
|
if (eventType === 'session.start' && correlation.session_id) {
|
||||||
agent.sessions[correlation.session_id] = { ts: getEnvelopeTS(evt) };
|
agent.sessions[correlation.session_id] = { ts: getEnvelopeTS(evt) };
|
||||||
@@ -1069,6 +1253,7 @@
|
|||||||
type: 'span',
|
type: 'span',
|
||||||
name: attrs.name || attrs.span_kind || 'unknown',
|
name: attrs.name || attrs.span_kind || 'unknown',
|
||||||
kind: attrs.span_kind || '',
|
kind: attrs.span_kind || '',
|
||||||
|
subType: attrs.type || '',
|
||||||
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
|
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1102,8 +1287,21 @@
|
|||||||
function getAgentDisplayOps(agent) {
|
function getAgentDisplayOps(agent) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const ops = Object.values(agent.operations).filter(op => (now - op.startedAt) < 300000);
|
const ops = Object.values(agent.operations).filter(op => (now - op.startedAt) < 300000);
|
||||||
const hasTools = ops.some(op => op.kind === 'tool');
|
const hasSpecificSpans = ops.some(op => op.kind && op.kind !== 'run');
|
||||||
return hasTools ? ops.filter(op => op.kind === 'tool') : ops;
|
return hasSpecificSpans ? ops.filter(op => op.kind && op.kind !== 'run') : ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAgentTimelineEvent(evt) {
|
||||||
|
const eventType = getEnvelopeType(evt);
|
||||||
|
return [
|
||||||
|
'session.start',
|
||||||
|
'session.end',
|
||||||
|
'run.start',
|
||||||
|
'run.end',
|
||||||
|
'span.start',
|
||||||
|
'span.end',
|
||||||
|
'error',
|
||||||
|
].includes(eventType);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAgents() {
|
async function renderAgents() {
|
||||||
@@ -1113,36 +1311,38 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
<h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="agents-summary-row" id="agents-summary"></div>
|
<div class="agents-toolbar">
|
||||||
<div class="agent-lanes" id="agents-lanes">
|
<div class="view-toggle" id="agents-view-toggle">
|
||||||
<div class="agent-lane"><div class="agent-lane-header"><div class="agent-lane-name">ZAP</div></div><div class="agent-lane-events"><p class="empty-state">Loading...</p></div></div>
|
<button class="view-toggle-btn active" data-mode="overview" type="button">Overview</button>
|
||||||
<div class="agent-lane"><div class="agent-lane-header"><div class="agent-lane-name">ORB</div></div><div class="agent-lane-events"><p class="empty-state">Loading...</p></div></div>
|
<button class="view-toggle-btn" data-mode="live" type="button">Live</button>
|
||||||
<div class="agent-lane"><div class="agent-lane-header"><div class="agent-lane-name">SUN</div></div><div class="agent-lane-events"><p class="empty-state">Loading...</p></div></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="agents-summary-row" id="agents-summary"></div>
|
||||||
|
<div id="agents-content"><p class="empty-state">Loading...</p></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
bindAgentViewToggle();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [snapshots, events, summaryData] = await Promise.all([
|
const [snapshots, events, summaryData] = await Promise.all([
|
||||||
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
||||||
api('/v1/events?framework=openclaw&limit=200'),
|
api('/v1/events?limit=300'),
|
||||||
api('/v1/stats/summary').catch(() => null),
|
api('/v1/stats/summary').catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!isCurrentPath('/agents')) return;
|
if (!isCurrentPath('/agents')) return;
|
||||||
|
|
||||||
if (summaryData) {
|
if (summaryData) {
|
||||||
const fw = (summaryData.by_framework || {}).openclaw || {};
|
agentsState.dbStats.messages = summaryData.runs_today || 0;
|
||||||
agentsState.dbStats.messages = fw.runs || 0;
|
agentsState.dbStats.tools = summaryData.tool_calls_today || 0;
|
||||||
agentsState.dbStats.tools = fw.tools || 0;
|
agentsState.dbStats.errors = summaryData.errors_today || 0;
|
||||||
agentsState.dbStats.errors = fw.errors || 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeOpenClawEvents(snapshots.events || []);
|
mergeOpenClawEvents(snapshots.events || []);
|
||||||
addAgentEvents((events.events || []).slice().reverse());
|
addAgentEvents((events.events || []).filter(isAgentTimelineEvent).slice().reverse());
|
||||||
renderAgentLanes();
|
renderAgentsContent();
|
||||||
renderAgentSummary();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('agents-lanes').innerHTML =
|
document.getElementById('agents-content').innerHTML =
|
||||||
`<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
|
`<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1150,29 +1350,96 @@
|
|||||||
agentsUnsubscribe = subscribeWS(handleAgentsWS);
|
agentsUnsubscribe = subscribeWS(handleAgentsWS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindAgentViewToggle() {
|
||||||
|
const root = document.getElementById('agents-view-toggle');
|
||||||
|
if (!root) return;
|
||||||
|
root.querySelectorAll('[data-mode]').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
setAgentsViewMode(button.dataset.mode || 'overview');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAgentViewToggle() {
|
||||||
|
const root = document.getElementById('agents-view-toggle');
|
||||||
|
if (!root) return;
|
||||||
|
root.querySelectorAll('[data-mode]').forEach(button => {
|
||||||
|
button.classList.toggle('active', button.dataset.mode === agentsState.viewMode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgentsContent() {
|
||||||
|
renderAgentSummary();
|
||||||
|
updateAgentViewToggle();
|
||||||
|
if (agentsState.viewMode === 'live') {
|
||||||
|
renderAgentsLive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderAgentLanes();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSelectedAgentLiveData() {
|
||||||
|
const selectedKey = ensureSelectedAgentKey();
|
||||||
|
if (!selectedKey) return;
|
||||||
|
|
||||||
|
const agent = agentsState.agents[selectedKey];
|
||||||
|
if (!agent || agent.liveLoaded || agent.liveLoading || !agent.clientID || !agent.framework) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.liveLoading = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('client_id', agent.clientID);
|
||||||
|
params.set('framework', agent.framework);
|
||||||
|
params.set('limit', '250');
|
||||||
|
const data = await api('/v1/agents/live?' + params.toString());
|
||||||
|
addAgentEvents((data.events || []).slice().reverse());
|
||||||
|
agent.liveLoaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load live agent context:', err);
|
||||||
|
} finally {
|
||||||
|
agent.liveLoading = false;
|
||||||
|
if (isCurrentPath('/agents') && agentsState.viewMode === 'live' && agentsState.selectedAgentKey === selectedKey) {
|
||||||
|
renderAgentsContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderAgentLanes() {
|
function renderAgentLanes() {
|
||||||
|
const contentEl = document.getElementById('agents-content');
|
||||||
|
if (!contentEl) return;
|
||||||
|
contentEl.innerHTML = '<div class="agent-lanes" id="agents-lanes"></div>';
|
||||||
|
|
||||||
const lanesEl = document.getElementById('agents-lanes');
|
const lanesEl = document.getElementById('agents-lanes');
|
||||||
if (!lanesEl) return;
|
if (!lanesEl) return;
|
||||||
|
|
||||||
const vmNames = ['zap', 'orb', 'sun'];
|
const agentKeys = getSortedAgentKeys();
|
||||||
|
|
||||||
lanesEl.innerHTML = vmNames.map(name => {
|
if (agentKeys.length === 0) {
|
||||||
const agent = agentsState.agents[name];
|
lanesEl.innerHTML = '<p class="empty-state">No recent agent activity</p>';
|
||||||
const vmStatus = getVMStatus().find(v => v.name === name);
|
return;
|
||||||
const isOnline = vmStatus && vmStatus.active;
|
}
|
||||||
|
|
||||||
|
lanesEl.innerHTML = agentKeys.map(key => {
|
||||||
|
const agent = agentsState.agents[key];
|
||||||
|
const isOnline = isAgentOnline(agent);
|
||||||
const sessionCount = Object.keys(agent.sessions).length;
|
const sessionCount = Object.keys(agent.sessions).length;
|
||||||
const ops = getAgentDisplayOps(agent);
|
const ops = getAgentDisplayOps(agent);
|
||||||
|
const subagentCount = ops.filter(op => op.kind === 'agent' || op.subType === 'subagent').length;
|
||||||
|
|
||||||
const statusClass = sessionCount > 0 ? ' has-sessions' : '';
|
const statusClass = sessionCount > 0 ? ' has-sessions' : '';
|
||||||
const statusText = !isOnline ? 'offline'
|
const statusText = !isOnline ? 'offline'
|
||||||
|
: subagentCount > 0 ? subagentCount + ' subagent' + (subagentCount > 1 ? 's' : '')
|
||||||
: sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '')
|
: sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '')
|
||||||
: 'idle';
|
: 'idle';
|
||||||
|
|
||||||
const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => {
|
const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => {
|
||||||
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
|
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
|
||||||
const stale = elapsed > 300;
|
const stale = elapsed > 300;
|
||||||
|
const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent' : '';
|
||||||
return `
|
return `
|
||||||
<div class="active-op${stale ? ' stale' : ''}">
|
<div class="active-op${stale ? ' stale' : ''}${kindClass}">
|
||||||
<span class="active-op-dot"></span>
|
<span class="active-op-dot"></span>
|
||||||
<span class="active-op-name">${escapeHTML(op.name)}</span>
|
<span class="active-op-name">${escapeHTML(op.name)}</span>
|
||||||
<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
|
<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
|
||||||
@@ -1183,7 +1450,6 @@
|
|||||||
const recent = agent.events.slice(-40).reverse();
|
const recent = agent.events.slice(-40).reverse();
|
||||||
const eventsHTML = recent.length > 0 ? recent.map(evt => {
|
const eventsHTML = recent.length > 0 ? recent.map(evt => {
|
||||||
const eventType = getEnvelopeType(evt);
|
const eventType = getEnvelopeType(evt);
|
||||||
const vmClass = getVMClassName(name);
|
|
||||||
const details = getEventDetails(evt);
|
const details = getEventDetails(evt);
|
||||||
const detailHTML = details ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
|
const detailHTML = details ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
|
||||||
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
||||||
@@ -1202,11 +1468,14 @@
|
|||||||
}).join('') : '<p class="empty-state">No recent activity</p>';
|
}).join('') : '<p class="empty-state">No recent activity</p>';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="agent-lane">
|
<div class="agent-lane" data-agent-key="${escapeHTML(key)}">
|
||||||
<div class="agent-lane-header">
|
<div class="agent-lane-header">
|
||||||
|
<div>
|
||||||
<div class="agent-lane-name">
|
<div class="agent-lane-name">
|
||||||
<span class="agent-lane-dot ${isOnline ? 'online' : 'offline'}"></span>
|
<span class="agent-lane-dot ${isOnline ? 'online' : 'offline'}"></span>
|
||||||
${escapeHTML(name.toUpperCase())}
|
${escapeHTML(agent.name || key)}
|
||||||
|
</div>
|
||||||
|
<div class="agent-lane-meta">${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="agent-lane-status${statusClass}">${statusText}</span>
|
<span class="agent-lane-status${statusClass}">${statusText}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1215,8 +1484,14 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
lanesEl.querySelectorAll('.agent-lane[data-agent-key]').forEach(lane => {
|
||||||
|
lane.addEventListener('click', () => {
|
||||||
|
selectAgent(lane.dataset.agentKey || '', 'live');
|
||||||
|
});
|
||||||
|
});
|
||||||
lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => {
|
lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', event => {
|
||||||
|
event.stopPropagation();
|
||||||
button.parentElement.classList.toggle('expanded');
|
button.parentElement.classList.toggle('expanded');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1232,17 +1507,316 @@
|
|||||||
const el = document.getElementById('agents-summary');
|
const el = document.getElementById('agents-summary');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const s = agentsState.dbStats;
|
const s = agentsState.dbStats;
|
||||||
|
const liveAgents = getSortedAgentKeys().filter(key => isAgentOnline(agentsState.agents[key])).length;
|
||||||
|
const liveSubagents = getSortedAgentKeys().reduce((count, key) => {
|
||||||
|
const agent = agentsState.agents[key];
|
||||||
|
return count + Object.values(agent.operations).filter(op => op.kind === 'agent' || op.subType === 'subagent').length;
|
||||||
|
}, 0);
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
|
<div class="agents-summary-stat">Live Agents <span class="value">${liveAgents}</span></div>
|
||||||
|
<div class="agents-summary-stat">Active Subagents <span class="value">${liveSubagents}</span></div>
|
||||||
<div class="agents-summary-stat">Runs Today <span class="value">${s.messages}</span></div>
|
<div class="agents-summary-stat">Runs Today <span class="value">${s.messages}</span></div>
|
||||||
<div class="agents-summary-stat">Tool Calls <span class="value">${s.tools}</span></div>
|
<div class="agents-summary-stat">Tool Calls <span class="value">${s.tools}</span></div>
|
||||||
<div class="agents-summary-stat">Errors <span class="value">${s.errors}</span></div>
|
<div class="agents-summary-stat">Errors <span class="value">${s.errors}</span></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAgentLabel(agent) {
|
||||||
|
if (!agent) return 'Unknown';
|
||||||
|
return agent.name || agent.host || agent.framework || agent.key || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentLiveSummary(agent) {
|
||||||
|
const recent = agent.events.slice().reverse();
|
||||||
|
const activeOps = getAgentDisplayOps(agent);
|
||||||
|
const sessionIDs = Object.keys(agent.sessions);
|
||||||
|
const live = {
|
||||||
|
sessionIDs,
|
||||||
|
activeOps,
|
||||||
|
activeSubagents: activeOps.filter(op => op.kind === 'agent' || op.subType === 'subagent'),
|
||||||
|
activeTools: activeOps.filter(op => op.kind === 'tool'),
|
||||||
|
latestPrompt: '',
|
||||||
|
latestRunStatus: '',
|
||||||
|
latestModel: '',
|
||||||
|
latestError: '',
|
||||||
|
latestUsage: null,
|
||||||
|
latestContextWindow: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const evt of recent) {
|
||||||
|
const eventType = getEnvelopeType(evt);
|
||||||
|
const payload = getEnvelopePayload(evt);
|
||||||
|
if (!live.latestPrompt && eventType === 'run.start') {
|
||||||
|
live.latestPrompt = payload.prompt_preview || payload.message_preview || payload.message || '';
|
||||||
|
}
|
||||||
|
if (!live.latestRunStatus && eventType === 'run.end') {
|
||||||
|
live.latestRunStatus = payload.status || '';
|
||||||
|
live.latestModel = payload.model || '';
|
||||||
|
live.latestUsage = payload.usage || null;
|
||||||
|
live.latestContextWindow = payload.context_window || null;
|
||||||
|
}
|
||||||
|
if (!live.latestUsage && eventType === 'metric.snapshot' && payload.metrics) {
|
||||||
|
live.latestUsage = payload.metrics.usage || null;
|
||||||
|
live.latestModel = live.latestModel || payload.metrics.model || '';
|
||||||
|
}
|
||||||
|
if (!live.latestError && eventType === 'error') {
|
||||||
|
const errPayload = payload.error || {};
|
||||||
|
live.latestError = errPayload.message || payload.message || '';
|
||||||
|
}
|
||||||
|
if (live.latestPrompt && live.latestRunStatus && live.latestError) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(value) {
|
||||||
|
if (value === undefined || value === null || value === '') return '-';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(value) {
|
||||||
|
if (value === undefined || value === null || value === '') return '-';
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return String(value);
|
||||||
|
return '$' + num.toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLiveEventContext(evt) {
|
||||||
|
const eventType = getEnvelopeType(evt);
|
||||||
|
const payload = getEnvelopePayload(evt);
|
||||||
|
const attrs = getEnvelopeAttributes(evt);
|
||||||
|
const correlation = getEnvelopeCorrelation(evt);
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if ((eventType === 'span.start' || eventType === 'span.end') && attrs.span_kind === 'tool') {
|
||||||
|
if (payload.input) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">input</span><span class="v">${escapeHTML(typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input))}</span></div>`);
|
||||||
|
}
|
||||||
|
if (payload.result_preview) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">result</span><span class="v">${escapeHTML(String(payload.result_preview))}</span></div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((eventType === 'span.start' || eventType === 'span.end') && (attrs.span_kind === 'agent' || attrs.type === 'subagent')) {
|
||||||
|
if (payload.prompt_preview) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">prompt</span><span class="v">${escapeHTML(String(payload.prompt_preview))}</span></div>`);
|
||||||
|
}
|
||||||
|
if (payload.usage && payload.usage.total_tokens !== undefined) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.usage.total_tokens))}</span></div>`);
|
||||||
|
}
|
||||||
|
if (payload.usage && payload.usage.total_cost !== undefined) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">cost</span><span class="v">${escapeHTML(formatCost(payload.usage.total_cost))}</span></div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eventType === 'run.start') {
|
||||||
|
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
|
||||||
|
if (preview) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">prompt</span><span class="v">${escapeHTML(String(preview))}</span></div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eventType === 'run.end') {
|
||||||
|
if (payload.model) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">model</span><span class="v">${escapeHTML(String(payload.model))}</span></div>`);
|
||||||
|
}
|
||||||
|
if (payload.usage && payload.usage.total_tokens !== undefined) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.usage.total_tokens))}</span></div>`);
|
||||||
|
}
|
||||||
|
if (payload.duration_ms !== undefined) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">duration</span><span class="v">${escapeHTML(formatDuration(payload.duration_ms))}</span></div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eventType === 'metric.snapshot' && payload.metrics) {
|
||||||
|
if (payload.metrics.model) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">model</span><span class="v">${escapeHTML(String(payload.metrics.model))}</span></div>`);
|
||||||
|
}
|
||||||
|
if (payload.metrics.usage && payload.metrics.usage.total_tokens !== undefined) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.metrics.usage.total_tokens))}</span></div>`);
|
||||||
|
}
|
||||||
|
if (payload.metrics.usage && payload.metrics.usage.total_cost !== undefined) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">cost</span><span class="v">${escapeHTML(formatCost(payload.metrics.usage.total_cost))}</span></div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eventType === 'error') {
|
||||||
|
const errPayload = payload.error || {};
|
||||||
|
if (errPayload.type) {
|
||||||
|
parts.push(`<div class="live-detail-row"><span class="k">type</span><span class="v">${escapeHTML(String(errPayload.type))}</span></div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [];
|
||||||
|
if (correlation.session_id) ids.push(`session ${correlation.session_id}`);
|
||||||
|
if (correlation.run_id) ids.push(`run ${correlation.run_id}`);
|
||||||
|
if (correlation.span_id) ids.push(`span ${correlation.span_id}`);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
parts.push(`<div class="live-detail-row ids"><span class="k">ids</span><span class="v">${escapeHTML(ids.join(' · '))}</span></div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRunGroupLabel(runID, events) {
|
||||||
|
const runStart = events.find(evt => getEnvelopeType(evt) === 'run.start');
|
||||||
|
if (!runStart) {
|
||||||
|
return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity';
|
||||||
|
}
|
||||||
|
const payload = getEnvelopePayload(runStart);
|
||||||
|
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
|
||||||
|
if (preview) {
|
||||||
|
return preview.length > 72 ? preview.slice(0, 72) + '...' : preview;
|
||||||
|
}
|
||||||
|
return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity';
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupAgentEventsByRun(events) {
|
||||||
|
const groups = [];
|
||||||
|
const byRun = new Map();
|
||||||
|
|
||||||
|
for (const evt of events) {
|
||||||
|
const correlation = getEnvelopeCorrelation(evt);
|
||||||
|
const runID = correlation.run_id || '';
|
||||||
|
const key = runID || `session:${correlation.session_id || 'unknown'}`;
|
||||||
|
if (!byRun.has(key)) {
|
||||||
|
const group = {
|
||||||
|
key,
|
||||||
|
runID,
|
||||||
|
sessionID: correlation.session_id || '',
|
||||||
|
events: [],
|
||||||
|
subagents: new Set(),
|
||||||
|
tools: new Set(),
|
||||||
|
};
|
||||||
|
byRun.set(key, group);
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = byRun.get(key);
|
||||||
|
group.events.push(evt);
|
||||||
|
const attrs = getEnvelopeAttributes(evt);
|
||||||
|
if (attrs.span_kind === 'agent' || attrs.type === 'subagent') {
|
||||||
|
group.subagents.add(attrs.name || 'unknown');
|
||||||
|
}
|
||||||
|
if (attrs.span_kind === 'tool' && attrs.name) {
|
||||||
|
group.tools.add(attrs.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgentsLive() {
|
||||||
|
const contentEl = document.getElementById('agents-content');
|
||||||
|
if (!contentEl) return;
|
||||||
|
|
||||||
|
const agentKeys = getSortedAgentKeys();
|
||||||
|
const selectedKey = ensureSelectedAgentKey();
|
||||||
|
if (!selectedKey || agentKeys.length === 0) {
|
||||||
|
contentEl.innerHTML = '<p class="empty-state">No recent agent activity</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = agentsState.agents[selectedKey];
|
||||||
|
const summary = getAgentLiveSummary(selected);
|
||||||
|
const recent = selected.events.slice(-80).reverse();
|
||||||
|
const runGroups = groupAgentEventsByRun(recent);
|
||||||
|
|
||||||
|
contentEl.innerHTML = `
|
||||||
|
<div class="agents-live-layout">
|
||||||
|
<aside class="agents-live-sidebar">
|
||||||
|
<div class="section-title">Agents</div>
|
||||||
|
<div class="agent-picker" id="agent-picker">
|
||||||
|
${agentKeys.map(key => {
|
||||||
|
const agent = agentsState.agents[key];
|
||||||
|
const active = key === selectedKey ? ' active' : '';
|
||||||
|
const online = isAgentOnline(agent) ? 'online' : 'offline';
|
||||||
|
const sessions = Object.keys(agent.sessions).length;
|
||||||
|
const ops = getAgentDisplayOps(agent).length;
|
||||||
|
return `
|
||||||
|
<button class="agent-picker-item${active}" data-agent-key="${escapeHTML(key)}" type="button">
|
||||||
|
<span class="agent-picker-dot ${online}"></span>
|
||||||
|
<span class="agent-picker-main">
|
||||||
|
<span class="agent-picker-name">${escapeHTML(getAgentLabel(agent))}</span>
|
||||||
|
<span class="agent-picker-meta">${escapeHTML(agent.framework || 'unknown')} · ${sessions} sessions · ${ops} ops</span>
|
||||||
|
</span>
|
||||||
|
</button>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section class="agents-live-main">
|
||||||
|
<div class="agents-live-header">
|
||||||
|
<div>
|
||||||
|
<div class="agent-lane-name"><span class="agent-lane-dot ${isAgentOnline(selected) ? 'online' : 'offline'}"></span>${escapeHTML(getAgentLabel(selected))}</div>
|
||||||
|
<div class="agent-lane-meta">${escapeHTML(selected.framework || 'unknown')}${selected.host && selected.host !== selected.name ? ' · ' + escapeHTML(selected.host) : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="agents-live-badges">
|
||||||
|
<span class="agents-live-badge">${summary.sessionIDs.length} sessions</span>
|
||||||
|
<span class="agents-live-badge">${summary.activeSubagents.length} subagents</span>
|
||||||
|
<span class="agents-live-badge">${summary.activeTools.length} tools</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="agents-live-cards">
|
||||||
|
<div class="agents-live-card">
|
||||||
|
<div class="agents-live-card-title">Current State</div>
|
||||||
|
<div class="live-kv"><span>Active subagents</span><strong>${escapeHTML(summary.activeSubagents.map(op => op.name).join(', ') || '-')}</strong></div>
|
||||||
|
<div class="live-kv"><span>Active tools</span><strong>${escapeHTML(summary.activeTools.map(op => op.name).join(', ') || '-')}</strong></div>
|
||||||
|
<div class="live-kv"><span>Latest prompt</span><strong>${escapeHTML(summary.latestPrompt || '-')}</strong></div>
|
||||||
|
<div class="live-kv"><span>Run status</span><strong>${escapeHTML(summary.latestRunStatus || 'in_progress')}</strong></div>
|
||||||
|
<div class="live-kv"><span>Model</span><strong>${escapeHTML(summary.latestModel || '-')}</strong></div>
|
||||||
|
<div class="live-kv"><span>Last error</span><strong>${escapeHTML(summary.latestError || '-')}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="agents-live-card">
|
||||||
|
<div class="agents-live-card-title">Usage</div>
|
||||||
|
<div class="live-kv"><span>Total tokens</span><strong>${escapeHTML(formatCount(summary.latestUsage && summary.latestUsage.total_tokens))}</strong></div>
|
||||||
|
<div class="live-kv"><span>Input tokens</span><strong>${escapeHTML(formatCount(summary.latestUsage && summary.latestUsage.input_tokens))}</strong></div>
|
||||||
|
<div class="live-kv"><span>Output tokens</span><strong>${escapeHTML(formatCount(summary.latestUsage && summary.latestUsage.output_tokens))}</strong></div>
|
||||||
|
<div class="live-kv"><span>Total cost</span><strong>${escapeHTML(formatCost(summary.latestUsage && summary.latestUsage.total_cost))}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="agents-live-card">
|
||||||
|
<div class="agents-live-card-title">Session Context</div>
|
||||||
|
<div class="live-kv"><span>Session IDs</span><strong>${escapeHTML(summary.sessionIDs.join(', ') || '-')}</strong></div>
|
||||||
|
<div class="live-kv"><span>Window input</span><strong>${escapeHTML(formatCount(summary.latestContextWindow && summary.latestContextWindow.input_tokens))}</strong></div>
|
||||||
|
<div class="live-kv"><span>Window output</span><strong>${escapeHTML(formatCount(summary.latestContextWindow && summary.latestContextWindow.output_tokens))}</strong></div>
|
||||||
|
<div class="live-kv"><span>Remaining</span><strong>${escapeHTML(formatCount(summary.latestContextWindow && (summary.latestContextWindow.tokens_remaining ?? summary.latestContextWindow.used_tokens)))}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="agents-live-timeline">
|
||||||
|
${runGroups.length > 0 ? runGroups.map(group => `
|
||||||
|
<section class="live-run-group">
|
||||||
|
<div class="live-run-group-header">
|
||||||
|
<div class="live-run-group-title">${escapeHTML(getRunGroupLabel(group.runID, group.events))}</div>
|
||||||
|
<div class="live-run-group-meta">
|
||||||
|
<span>${escapeHTML(group.runID ? `run ${group.runID.slice(0, 12)}...` : 'session-only')}</span>
|
||||||
|
<span>${escapeHTML(group.subagents.size > 0 ? `${group.subagents.size} subagents` : '0 subagents')}</span>
|
||||||
|
<span>${escapeHTML(group.tools.size > 0 ? `${group.tools.size} tools` : '0 tools')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="live-run-events">
|
||||||
|
${group.events.map(evt => `
|
||||||
|
<div class="timeline-event live-event">
|
||||||
|
<div class="timeline-event-header">
|
||||||
|
${getEventIcon(getEnvelopeType(evt))}
|
||||||
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(getEnvelopeType(evt)))}</span>
|
||||||
|
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
||||||
|
</div>
|
||||||
|
${getEventBody(evt)}
|
||||||
|
<div class="live-detail-grid">${buildLiveEventContext(evt)}</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`).join('') : '<p class="empty-state">No recent activity</p>'}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
contentEl.querySelectorAll('[data-agent-key]').forEach(button => {
|
||||||
|
button.addEventListener('click', () => selectAgent(button.dataset.agentKey || '', 'live'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderAgentVMStrip() {
|
function renderAgentVMStrip() {
|
||||||
// VM online/offline state is shown in each lane header via getVMStatus().
|
// VM online/offline state is shown in each lane header via getVMStatus().
|
||||||
// Re-render lanes to pick up the updated openclawState.
|
// Re-render lanes to pick up the updated openclawState.
|
||||||
renderAgentLanes();
|
renderAgentsContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAgentsWS(msg) {
|
function handleAgentsWS(msg) {
|
||||||
@@ -1251,12 +1825,10 @@
|
|||||||
const eventType = getEnvelopeType(msg.data);
|
const eventType = getEnvelopeType(msg.data);
|
||||||
if (eventType === 'openclaw.snapshot') {
|
if (eventType === 'openclaw.snapshot') {
|
||||||
mergeOpenClawEvents([msg.data]);
|
mergeOpenClawEvents([msg.data]);
|
||||||
renderAgentLanes();
|
renderAgentsContent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isAgentTimelineEvent(msg.data)) return;
|
||||||
const framework = getEnvelopeSource(msg.data).framework || msg.data.source_framework;
|
|
||||||
if (framework !== 'openclaw') return;
|
|
||||||
|
|
||||||
if (eventType === 'run.start') agentsState.dbStats.messages++;
|
if (eventType === 'run.start') agentsState.dbStats.messages++;
|
||||||
else if (eventType === 'span.end') {
|
else if (eventType === 'span.end') {
|
||||||
@@ -1265,8 +1837,7 @@
|
|||||||
} else if (eventType === 'error') agentsState.dbStats.errors++;
|
} else if (eventType === 'error') agentsState.dbStats.errors++;
|
||||||
|
|
||||||
addAgentEvents([msg.data]);
|
addAgentEvents([msg.data]);
|
||||||
renderAgentLanes();
|
renderAgentsContent();
|
||||||
renderAgentSummary();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAgentTimers() {
|
function updateAgentTimers() {
|
||||||
@@ -1360,7 +1931,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getVMName(evt) {
|
function getVMName(evt) {
|
||||||
return getEnvelopeSource(evt).client_id || evt.client_id || 'unknown';
|
return getAgentIdentity(evt).name || 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVMClassName(vmName) {
|
function getVMClassName(vmName) {
|
||||||
@@ -1379,11 +1950,13 @@
|
|||||||
const duration = payload.duration_ms !== undefined && payload.duration_ms !== null
|
const duration = payload.duration_ms !== undefined && payload.duration_ms !== null
|
||||||
? ` <span class="timeline-duration">${escapeHTML(formatDuration(payload.duration_ms))}</span>`
|
? ` <span class="timeline-duration">${escapeHTML(formatDuration(payload.duration_ms))}</span>`
|
||||||
: '';
|
: '';
|
||||||
return `<div class="timeline-event-body tool-name">${escapeHTML(name)}${duration}</div>`;
|
const detailClass = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? ' subagent-name' : ' tool-name';
|
||||||
|
const prefix = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? 'subagent ' : '';
|
||||||
|
return `<div class="timeline-event-body${detailClass}">${escapeHTML(prefix + name)}${duration}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === 'run.start') {
|
if (eventType === 'run.start') {
|
||||||
const preview = payload.message_preview || payload.message || '';
|
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
+356
-1
@@ -475,6 +475,51 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-run-detail {
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-run-spans {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-span-pill {
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.18rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-span-pill.tool {
|
||||||
|
border-color: rgba(34, 211, 238, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-span-pill.agent {
|
||||||
|
border-color: rgba(167, 139, 250, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-span-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-span-meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Empty state ───────────────────────────────────────────── */
|
/* ── Empty state ───────────────────────────────────────────── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -855,6 +900,12 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-event-body.subagent-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--purple);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-event-body.message-preview {
|
.timeline-event-body.message-preview {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -1340,6 +1391,20 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
50% { opacity: 0.5; }
|
50% { opacity: 0.5; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-status-line {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status-text {
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Meta tiles ───────────────────────────────────────────── */
|
/* ── Meta tiles ───────────────────────────────────────────── */
|
||||||
.meta-tiles {
|
.meta-tiles {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1482,6 +1547,37 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agents-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
.agents-summary-stat {
|
.agents-summary-stat {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -1502,7 +1598,7 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
|
|
||||||
.agent-lanes {
|
.agent-lanes {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1519,6 +1615,7 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-lane-header {
|
.agent-lane-header {
|
||||||
@@ -1541,6 +1638,13 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-lane-meta {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.agent-lane-dot {
|
.agent-lane-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
@@ -1596,6 +1700,11 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
border-color: rgba(8, 145, 178, 0.2);
|
border-color: rgba(8, 145, 178, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-op.subagent {
|
||||||
|
background: rgba(167, 139, 250, 0.12);
|
||||||
|
border-color: rgba(167, 139, 250, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.active-op.stale {
|
.active-op.stale {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
@@ -1622,6 +1731,10 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-op.subagent .active-op-name {
|
||||||
|
color: var(--purple);
|
||||||
|
}
|
||||||
|
|
||||||
.active-op-time {
|
.active-op-time {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -1678,6 +1791,239 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agents-live-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px minmax(0, 1fr);
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-sidebar,
|
||||||
|
.agents-live-main,
|
||||||
|
.agents-live-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-sidebar {
|
||||||
|
padding: 1rem;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-picker-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-picker-item.active {
|
||||||
|
border-color: rgba(34, 211, 238, 0.28);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-picker-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-picker-dot.online {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-picker-dot.offline {
|
||||||
|
background: var(--text-dim);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-picker-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-picker-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-picker-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-main {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-badge {
|
||||||
|
padding: 0.38rem 0.7rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-card-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-kv {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.18rem;
|
||||||
|
padding: 0.45rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-kv:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-kv span {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-kv strong {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-bright);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-run-group {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-run-group-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-run-group-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-run-group-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-run-events {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-event {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-detail-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-detail-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-detail-row .k {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-detail-row .v {
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-detail-row.ids .v {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mobile layout ────────────────────────────────────────── */
|
/* ── Mobile layout ────────────────────────────────────────── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
main {
|
main {
|
||||||
@@ -1692,6 +2038,15 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
padding: 0.375rem 0.5rem;
|
padding: 0.375rem 0.5rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agents-live-layout,
|
||||||
|
.agents-live-cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-live-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|||||||
+99
-39
@@ -52,27 +52,52 @@ function safeJSONStringify(value) {
|
|||||||
}
|
}
|
||||||
function getSessionKey(input) {
|
function getSessionKey(input) {
|
||||||
return pickString(
|
return pickString(
|
||||||
|
input.id,
|
||||||
|
input.session,
|
||||||
input.sessionId,
|
input.sessionId,
|
||||||
input.session_id,
|
input.session_id,
|
||||||
|
input.threadId,
|
||||||
|
input.thread_id,
|
||||||
|
input.chatId,
|
||||||
|
input.chat_id,
|
||||||
input.conversationId,
|
input.conversationId,
|
||||||
input.conversation_id
|
input.conversation_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function getUsage(input) {
|
function getUsage(input) {
|
||||||
const usage = isRecord(input.usage) ? input.usage : isRecord(input.llm) ? input.llm : isRecord(input.tokens) ? input.tokens : void 0;
|
const usage = isRecord(input.usage) ? input.usage : isRecord(input.llm) ? input.llm : isRecord(input.tokens) ? input.tokens : isRecord(input.llm_usage) ? input.llm_usage : void 0;
|
||||||
if (!usage)
|
if (!usage)
|
||||||
return void 0;
|
return void 0;
|
||||||
const result = {};
|
const result = {};
|
||||||
|
if (usage.input_tokens !== void 0)
|
||||||
|
result.input_tokens = usage.input_tokens;
|
||||||
|
if (usage.prompt_tokens !== void 0)
|
||||||
|
result.input_tokens = usage.prompt_tokens;
|
||||||
if (usage.input !== void 0)
|
if (usage.input !== void 0)
|
||||||
result.input_tokens = usage.input;
|
result.input_tokens = usage.input;
|
||||||
|
if (usage.output_tokens !== void 0)
|
||||||
|
result.output_tokens = usage.output_tokens;
|
||||||
|
if (usage.completion_tokens !== void 0)
|
||||||
|
result.output_tokens = usage.completion_tokens;
|
||||||
if (usage.output !== void 0)
|
if (usage.output !== void 0)
|
||||||
result.output_tokens = usage.output;
|
result.output_tokens = usage.output;
|
||||||
|
if (usage.total_tokens !== void 0)
|
||||||
|
result.total_tokens = usage.total_tokens;
|
||||||
if (usage.total !== void 0)
|
if (usage.total !== void 0)
|
||||||
result.total_tokens = usage.total;
|
result.total_tokens = usage.total;
|
||||||
if (usage.cost !== void 0)
|
if (usage.cost !== void 0)
|
||||||
result.total_cost = usage.cost;
|
result.total_cost = usage.cost;
|
||||||
|
if (usage.total_cost !== void 0)
|
||||||
|
result.total_cost = usage.total_cost;
|
||||||
return Object.keys(result).length > 0 ? result : void 0;
|
return Object.keys(result).length > 0 ? result : void 0;
|
||||||
}
|
}
|
||||||
|
function getModel(input) {
|
||||||
|
return pickString(
|
||||||
|
input.model,
|
||||||
|
isRecord(input.llm) ? input.llm.model : void 0,
|
||||||
|
isRecord(input.usage) ? input.usage.model : void 0
|
||||||
|
);
|
||||||
|
}
|
||||||
function buildEnvelope(type, sessionKey, opts = {}) {
|
function buildEnvelope(type, sessionKey, opts = {}) {
|
||||||
const correlation = {};
|
const correlation = {};
|
||||||
if (sessionKey) {
|
if (sessionKey) {
|
||||||
@@ -126,6 +151,52 @@ function enqueue(event) {
|
|||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function enqueueMetricSnapshot(sessionKey, runId, usage, input) {
|
||||||
|
if (!usage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const metrics = { usage };
|
||||||
|
const model = getModel(input);
|
||||||
|
if (model) {
|
||||||
|
metrics.model = model;
|
||||||
|
}
|
||||||
|
enqueue(buildEnvelope("metric.snapshot", sessionKey, {
|
||||||
|
runId,
|
||||||
|
payload: { metrics }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function startRun(sessionKey, input) {
|
||||||
|
const runId = randomUUID();
|
||||||
|
if (sessionKey) {
|
||||||
|
activeRuns.set(sessionKey, runId);
|
||||||
|
}
|
||||||
|
enqueue(buildEnvelope("run.start", sessionKey, {
|
||||||
|
runId,
|
||||||
|
attributes: {
|
||||||
|
trigger: pickString(input.trigger_type, input.trigger, input.event, input.event_type)
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
prompt_preview: truncate(pickString(input.prompt, input.message, input.text), 200)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return runId;
|
||||||
|
}
|
||||||
|
function endRun(sessionKey, runId, input, duration) {
|
||||||
|
if (!runId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const usage = getUsage(input);
|
||||||
|
enqueue(buildEnvelope("run.end", sessionKey, {
|
||||||
|
runId,
|
||||||
|
payload: {
|
||||||
|
status: "success",
|
||||||
|
duration_ms: duration,
|
||||||
|
model: getModel(input),
|
||||||
|
...usage && { usage }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
enqueueMetricSnapshot(sessionKey, runId, usage, input);
|
||||||
|
}
|
||||||
async function postBatch(batch) {
|
async function postBatch(batch) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
@@ -167,18 +238,8 @@ async function flush() {
|
|||||||
}
|
}
|
||||||
async function handleSessionStart(input) {
|
async function handleSessionStart(input) {
|
||||||
const sessionKey = getSessionKey(input) || randomUUID();
|
const sessionKey = getSessionKey(input) || randomUUID();
|
||||||
const runId = randomUUID();
|
|
||||||
activeRuns.set(sessionKey, runId);
|
|
||||||
enqueue(buildEnvelope("session.start", sessionKey));
|
enqueue(buildEnvelope("session.start", sessionKey));
|
||||||
enqueue(buildEnvelope("run.start", sessionKey, {
|
startRun(sessionKey, input);
|
||||||
runId,
|
|
||||||
attributes: {
|
|
||||||
trigger: pickString(input.trigger_type, input.trigger)
|
|
||||||
},
|
|
||||||
payload: {
|
|
||||||
prompt_preview: truncate(input.prompt, 200)
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
await flush();
|
await flush();
|
||||||
}
|
}
|
||||||
async function handleSessionEnd(input) {
|
async function handleSessionEnd(input) {
|
||||||
@@ -186,46 +247,41 @@ async function handleSessionEnd(input) {
|
|||||||
const runId = sessionKey ? activeRuns.get(sessionKey) : void 0;
|
const runId = sessionKey ? activeRuns.get(sessionKey) : void 0;
|
||||||
const usage = getUsage(input);
|
const usage = getUsage(input);
|
||||||
const duration = pickNumber(input.duration_ms, input.elapsed_ms, input.duration);
|
const duration = pickNumber(input.duration_ms, input.elapsed_ms, input.duration);
|
||||||
if (runId) {
|
endRun(sessionKey, runId, input, duration);
|
||||||
enqueue(buildEnvelope("run.end", sessionKey, {
|
enqueue(buildEnvelope("session.end", sessionKey, {
|
||||||
runId,
|
|
||||||
payload: {
|
payload: {
|
||||||
status: "success",
|
model: getModel(input),
|
||||||
duration_ms: duration,
|
|
||||||
...usage && { usage }
|
...usage && { usage }
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
enqueueMetricSnapshot(sessionKey, runId, usage, input);
|
||||||
enqueue(buildEnvelope("session.end", sessionKey, {
|
|
||||||
payload: usage ? { usage } : void 0
|
|
||||||
}));
|
|
||||||
activeRuns.delete(sessionKey || "");
|
activeRuns.delete(sessionKey || "");
|
||||||
await flush();
|
await flush();
|
||||||
}
|
}
|
||||||
|
async function handlePromptSubmit(input) {
|
||||||
|
const sessionKey = getSessionKey(input);
|
||||||
|
const runId = sessionKey ? activeRuns.get(sessionKey) : void 0;
|
||||||
|
const duration = pickNumber(input.elapsed_ms, input.duration_ms, input.duration);
|
||||||
|
const prompt = pickString(input.prompt, input.text, input.message);
|
||||||
|
if (runId && prompt) {
|
||||||
|
endRun(sessionKey, runId, input, duration);
|
||||||
|
}
|
||||||
|
startRun(sessionKey, input);
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
async function handleNotification(input) {
|
async function handleNotification(input) {
|
||||||
const sessionKey = getSessionKey(input);
|
const sessionKey = getSessionKey(input);
|
||||||
const notificationType = pickString(input.type, input.notification_type);
|
const notificationType = pickString(input.type, input.notification_type, input.event, input.event_type);
|
||||||
const usage = getUsage(input);
|
const usage = getUsage(input);
|
||||||
const duration = pickNumber(input.duration_ms, input.elapsed_ms);
|
const duration = pickNumber(input.duration_ms, input.elapsed_ms);
|
||||||
if (notificationType === "agent-turn-complete" || notificationType === "Done") {
|
if (notificationType === "agent-turn-complete" || notificationType === "Done" || notificationType === "turn.complete") {
|
||||||
const runId = sessionKey ? activeRuns.get(sessionKey) : void 0;
|
const runId = sessionKey ? activeRuns.get(sessionKey) : void 0;
|
||||||
if (runId) {
|
endRun(sessionKey, runId, input, duration);
|
||||||
enqueue(buildEnvelope("run.end", sessionKey, {
|
if (pickString(input.prompt, input.message, input.text)) {
|
||||||
runId,
|
startRun(sessionKey, input);
|
||||||
payload: {
|
|
||||||
status: "success",
|
|
||||||
duration_ms: duration,
|
|
||||||
...usage && { usage }
|
|
||||||
}
|
}
|
||||||
}));
|
} else if (usage) {
|
||||||
}
|
enqueueMetricSnapshot(sessionKey, sessionKey ? activeRuns.get(sessionKey) : void 0, usage, input);
|
||||||
const newRunId = randomUUID();
|
|
||||||
if (sessionKey) {
|
|
||||||
activeRuns.set(sessionKey, newRunId);
|
|
||||||
}
|
|
||||||
enqueue(buildEnvelope("run.start", sessionKey, {
|
|
||||||
runId: newRunId
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
await flush();
|
await flush();
|
||||||
}
|
}
|
||||||
@@ -249,6 +305,10 @@ const handler = async () => {
|
|||||||
case "stop":
|
case "stop":
|
||||||
await handleSessionEnd(input);
|
await handleSessionEnd(input);
|
||||||
break;
|
break;
|
||||||
|
case "prompt":
|
||||||
|
case "prompt-submit":
|
||||||
|
await handlePromptSubmit(input);
|
||||||
|
break;
|
||||||
case "notification":
|
case "notification":
|
||||||
await handleNotification(input);
|
await handleNotification(input);
|
||||||
break;
|
break;
|
||||||
|
|||||||
+107
-42
@@ -65,8 +65,14 @@ function safeJSONStringify(value: unknown): string {
|
|||||||
|
|
||||||
function getSessionKey(input: Dict): string | undefined {
|
function getSessionKey(input: Dict): string | undefined {
|
||||||
return pickString(
|
return pickString(
|
||||||
|
input.id,
|
||||||
|
input.session,
|
||||||
input.sessionId,
|
input.sessionId,
|
||||||
input.session_id,
|
input.session_id,
|
||||||
|
input.threadId,
|
||||||
|
input.thread_id,
|
||||||
|
input.chatId,
|
||||||
|
input.chat_id,
|
||||||
input.conversationId,
|
input.conversationId,
|
||||||
input.conversation_id,
|
input.conversation_id,
|
||||||
);
|
);
|
||||||
@@ -75,18 +81,33 @@ function getSessionKey(input: Dict): string | undefined {
|
|||||||
function getUsage(input: Dict): Dict | undefined {
|
function getUsage(input: Dict): Dict | undefined {
|
||||||
const usage = isRecord(input.usage) ? input.usage :
|
const usage = isRecord(input.usage) ? input.usage :
|
||||||
isRecord(input.llm) ? input.llm :
|
isRecord(input.llm) ? input.llm :
|
||||||
isRecord(input.tokens) ? input.tokens : undefined;
|
isRecord(input.tokens) ? input.tokens :
|
||||||
|
isRecord(input.llm_usage) ? input.llm_usage : undefined;
|
||||||
if (!usage) return undefined;
|
if (!usage) return undefined;
|
||||||
|
|
||||||
const result: Dict = {};
|
const result: Dict = {};
|
||||||
|
if (usage.input_tokens !== undefined) result.input_tokens = usage.input_tokens;
|
||||||
|
if (usage.prompt_tokens !== undefined) result.input_tokens = usage.prompt_tokens;
|
||||||
if (usage.input !== undefined) result.input_tokens = usage.input;
|
if (usage.input !== undefined) result.input_tokens = usage.input;
|
||||||
|
if (usage.output_tokens !== undefined) result.output_tokens = usage.output_tokens;
|
||||||
|
if (usage.completion_tokens !== undefined) result.output_tokens = usage.completion_tokens;
|
||||||
if (usage.output !== undefined) result.output_tokens = usage.output;
|
if (usage.output !== undefined) result.output_tokens = usage.output;
|
||||||
|
if (usage.total_tokens !== undefined) result.total_tokens = usage.total_tokens;
|
||||||
if (usage.total !== undefined) result.total_tokens = usage.total;
|
if (usage.total !== undefined) result.total_tokens = usage.total;
|
||||||
if (usage.cost !== undefined) result.total_cost = usage.cost;
|
if (usage.cost !== undefined) result.total_cost = usage.cost;
|
||||||
|
if (usage.total_cost !== undefined) result.total_cost = usage.total_cost;
|
||||||
|
|
||||||
return Object.keys(result).length > 0 ? result : undefined;
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getModel(input: Dict): string | undefined {
|
||||||
|
return pickString(
|
||||||
|
input.model,
|
||||||
|
isRecord(input.llm) ? input.llm.model : undefined,
|
||||||
|
isRecord(input.usage) ? input.usage.model : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildEnvelope(
|
function buildEnvelope(
|
||||||
type: string,
|
type: string,
|
||||||
sessionKey?: string,
|
sessionKey?: string,
|
||||||
@@ -156,6 +177,60 @@ function enqueue(event: Dict) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enqueueMetricSnapshot(sessionKey: string | undefined, runId: string | undefined, usage: Dict | undefined, input: Dict) {
|
||||||
|
if (!usage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics: Dict = { usage };
|
||||||
|
const model = getModel(input);
|
||||||
|
if (model) {
|
||||||
|
metrics.model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(buildEnvelope('metric.snapshot', sessionKey, {
|
||||||
|
runId,
|
||||||
|
payload: { metrics },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRun(sessionKey: string | undefined, input: Dict): string {
|
||||||
|
const runId = randomUUID();
|
||||||
|
if (sessionKey) {
|
||||||
|
activeRuns.set(sessionKey, runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(buildEnvelope('run.start', sessionKey, {
|
||||||
|
runId,
|
||||||
|
attributes: {
|
||||||
|
trigger: pickString(input.trigger_type, input.trigger, input.event, input.event_type),
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
prompt_preview: truncate(pickString(input.prompt, input.message, input.text), 200),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return runId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endRun(sessionKey: string | undefined, runId: string | undefined, input: Dict, duration?: number) {
|
||||||
|
if (!runId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = getUsage(input);
|
||||||
|
enqueue(buildEnvelope('run.end', sessionKey, {
|
||||||
|
runId,
|
||||||
|
payload: {
|
||||||
|
status: 'success',
|
||||||
|
duration_ms: duration,
|
||||||
|
model: getModel(input),
|
||||||
|
...(usage && { usage }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
enqueueMetricSnapshot(sessionKey, runId, usage, input);
|
||||||
|
}
|
||||||
|
|
||||||
async function postBatch(batch: Dict[]) {
|
async function postBatch(batch: Dict[]) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
@@ -202,20 +277,9 @@ async function flush() {
|
|||||||
|
|
||||||
async function handleSessionStart(input: Dict) {
|
async function handleSessionStart(input: Dict) {
|
||||||
const sessionKey = getSessionKey(input) || randomUUID();
|
const sessionKey = getSessionKey(input) || randomUUID();
|
||||||
const runId = randomUUID();
|
|
||||||
activeRuns.set(sessionKey, runId);
|
|
||||||
|
|
||||||
enqueue(buildEnvelope('session.start', sessionKey));
|
enqueue(buildEnvelope('session.start', sessionKey));
|
||||||
|
startRun(sessionKey, input);
|
||||||
enqueue(buildEnvelope('run.start', sessionKey, {
|
|
||||||
runId,
|
|
||||||
attributes: {
|
|
||||||
trigger: pickString(input.trigger_type, input.trigger),
|
|
||||||
},
|
|
||||||
payload: {
|
|
||||||
prompt_preview: truncate(input.prompt, 200),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
await flush();
|
await flush();
|
||||||
}
|
}
|
||||||
@@ -226,53 +290,50 @@ async function handleSessionEnd(input: Dict) {
|
|||||||
const usage = getUsage(input);
|
const usage = getUsage(input);
|
||||||
const duration = pickNumber(input.duration_ms, input.elapsed_ms, input.duration);
|
const duration = pickNumber(input.duration_ms, input.elapsed_ms, input.duration);
|
||||||
|
|
||||||
if (runId) {
|
endRun(sessionKey, runId, input, duration);
|
||||||
enqueue(buildEnvelope('run.end', sessionKey, {
|
|
||||||
runId,
|
enqueue(buildEnvelope('session.end', sessionKey, {
|
||||||
payload: {
|
payload: {
|
||||||
status: 'success',
|
model: getModel(input),
|
||||||
duration_ms: duration,
|
|
||||||
...(usage && { usage }),
|
...(usage && { usage }),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
enqueue(buildEnvelope('session.end', sessionKey, {
|
enqueueMetricSnapshot(sessionKey, runId, usage, input);
|
||||||
payload: usage ? { usage } : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
activeRuns.delete(sessionKey || '');
|
activeRuns.delete(sessionKey || '');
|
||||||
await flush();
|
await flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePromptSubmit(input: Dict) {
|
||||||
|
const sessionKey = getSessionKey(input);
|
||||||
|
const runId = sessionKey ? activeRuns.get(sessionKey) : undefined;
|
||||||
|
const duration = pickNumber(input.elapsed_ms, input.duration_ms, input.duration);
|
||||||
|
const prompt = pickString(input.prompt, input.text, input.message);
|
||||||
|
|
||||||
|
if (runId && prompt) {
|
||||||
|
endRun(sessionKey, runId, input, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
startRun(sessionKey, input);
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleNotification(input: Dict) {
|
async function handleNotification(input: Dict) {
|
||||||
const sessionKey = getSessionKey(input);
|
const sessionKey = getSessionKey(input);
|
||||||
const notificationType = pickString(input.type, input.notification_type);
|
const notificationType = pickString(input.type, input.notification_type, input.event, input.event_type);
|
||||||
const usage = getUsage(input);
|
const usage = getUsage(input);
|
||||||
const duration = pickNumber(input.duration_ms, input.elapsed_ms);
|
const duration = pickNumber(input.duration_ms, input.elapsed_ms);
|
||||||
|
|
||||||
if (notificationType === 'agent-turn-complete' || notificationType === 'Done') {
|
if (notificationType === 'agent-turn-complete' || notificationType === 'Done' || notificationType === 'turn.complete') {
|
||||||
const runId = sessionKey ? activeRuns.get(sessionKey) : undefined;
|
const runId = sessionKey ? activeRuns.get(sessionKey) : undefined;
|
||||||
|
endRun(sessionKey, runId, input, duration);
|
||||||
|
|
||||||
if (runId) {
|
if (pickString(input.prompt, input.message, input.text)) {
|
||||||
enqueue(buildEnvelope('run.end', sessionKey, {
|
startRun(sessionKey, input);
|
||||||
runId,
|
|
||||||
payload: {
|
|
||||||
status: 'success',
|
|
||||||
duration_ms: duration,
|
|
||||||
...(usage && { usage }),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
} else if (usage) {
|
||||||
const newRunId = randomUUID();
|
enqueueMetricSnapshot(sessionKey, sessionKey ? activeRuns.get(sessionKey) : undefined, usage, input);
|
||||||
if (sessionKey) {
|
|
||||||
activeRuns.set(sessionKey, newRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
enqueue(buildEnvelope('run.start', sessionKey, {
|
|
||||||
runId: newRunId,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await flush();
|
await flush();
|
||||||
@@ -300,6 +361,10 @@ const handler = async () => {
|
|||||||
case 'stop':
|
case 'stop':
|
||||||
await handleSessionEnd(input);
|
await handleSessionEnd(input);
|
||||||
break;
|
break;
|
||||||
|
case 'prompt':
|
||||||
|
case 'prompt-submit':
|
||||||
|
await handlePromptSubmit(input);
|
||||||
|
break;
|
||||||
case 'notification':
|
case 'notification':
|
||||||
await handleNotification(input);
|
await handleNotification(input);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -17,6 +17,24 @@
|
|||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "~/.local/bin/agentmon-codex-handler notification"
|
"command": "~/.local/bin/agentmon-codex-handler notification"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"userpromptsubmit": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.local/bin/agentmon-codex-handler prompt"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"userPromptSubmit": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.local/bin/agentmon-codex-handler prompt"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"userPromptSubmitted": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.local/bin/agentmon-codex-handler prompt"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"main": "handler.js",
|
"main": "handler.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agentmon-handler": "./handler.js"
|
"agentmon-codex-handler": "./handler.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npx esbuild handler.ts --platform=node --format=esm --outfile=handler.js"
|
"build": "npx esbuild handler.ts --platform=node --format=esm --outfile=handler.js"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type EventsFilter struct {
|
|||||||
Limit int
|
Limit int
|
||||||
EventType string
|
EventType string
|
||||||
Framework string
|
Framework string
|
||||||
|
ClientID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) ListRecentEvents(ctx context.Context, f EventsFilter) ([]EventRow, error) {
|
func (d *DB) ListRecentEvents(ctx context.Context, f EventsFilter) ([]EventRow, error) {
|
||||||
@@ -42,6 +43,11 @@ func (d *DB) ListRecentEvents(ctx context.Context, f EventsFilter) ([]EventRow,
|
|||||||
args = append(args, f.Framework)
|
args = append(args, f.Framework)
|
||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
|
if f.ClientID != "" {
|
||||||
|
query += fmt.Sprintf(" AND client_id = $%d", argN)
|
||||||
|
args = append(args, f.ClientID)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
query += fmt.Sprintf(" ORDER BY ts DESC LIMIT $%d", argN)
|
query += fmt.Sprintf(" ORDER BY ts DESC LIMIT $%d", argN)
|
||||||
args = append(args, f.Limit)
|
args = append(args, f.Limit)
|
||||||
@@ -62,3 +68,36 @@ func (d *DB) ListRecentEvents(ctx context.Context, f EventsFilter) ([]EventRow,
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) ListAgentLiveEvents(ctx context.Context, framework, clientID string, limit int) ([]EventRow, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
if limit > 1000 {
|
||||||
|
limit = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := d.sql.QueryContext(ctx, `
|
||||||
|
SELECT event_id, ts, type, payload
|
||||||
|
FROM events
|
||||||
|
WHERE source_framework = $1
|
||||||
|
AND client_id = $2
|
||||||
|
AND type IN ('session.start', 'session.end', 'run.start', 'run.end', 'span.start', 'span.end', 'error')
|
||||||
|
ORDER BY ts DESC
|
||||||
|
LIMIT $3
|
||||||
|
`, framework, clientID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []EventRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r EventRow
|
||||||
|
if err := rows.Scan(&r.EventID, &r.TS, &r.Type, &r.Payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type RunRow struct {
|
|||||||
SpanCount int `json:"span_count"`
|
SpanCount int `json:"span_count"`
|
||||||
ToolCount int `json:"tool_count"`
|
ToolCount int `json:"tool_count"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
|
Spans []SpanRow `json:"spans,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionDetail struct {
|
type SessionDetail struct {
|
||||||
@@ -84,10 +85,20 @@ func (d *DB) GetSessionWithRuns(ctx context.Context, sessionID string) (*Session
|
|||||||
runs = append(runs, r)
|
runs = append(runs, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &session, runs, rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
runs, err = d.attachSpansToRuns(ctx, sessionID, runs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &session, runs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpanRow struct {
|
type SpanRow struct {
|
||||||
|
RunID string `json:"run_id,omitempty"`
|
||||||
SpanID string `json:"span_id"`
|
SpanID string `json:"span_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
@@ -129,9 +140,18 @@ func (d *DB) GetRunWithSpans(ctx context.Context, runID string) (*RunDetail, []S
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get spans
|
spans, err := d.listSpansForRun(ctx, runID)
|
||||||
spansQuery := `
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &run, spans, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) listSpansForRun(ctx context.Context, runID string) ([]SpanRow, error) {
|
||||||
|
rows, err := d.sql.QueryContext(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
|
run_id,
|
||||||
span_id,
|
span_id,
|
||||||
COALESCE(payload->'attributes'->>'name', payload->'event'->>'type', type) as name,
|
COALESCE(payload->'attributes'->>'name', payload->'event'->>'type', type) as name,
|
||||||
COALESCE(payload->'attributes'->>'span_kind', 'unknown') as kind,
|
COALESCE(payload->'attributes'->>'span_kind', 'unknown') as kind,
|
||||||
@@ -145,21 +165,84 @@ func (d *DB) GetRunWithSpans(ctx context.Context, runID string) (*RunDetail, []S
|
|||||||
FROM events
|
FROM events
|
||||||
WHERE run_id = $1 AND span_id IS NOT NULL
|
WHERE run_id = $1 AND span_id IS NOT NULL
|
||||||
ORDER BY ts ASC
|
ORDER BY ts ASC
|
||||||
`
|
`, runID)
|
||||||
rows, err := d.sql.QueryContext(ctx, spansQuery, runID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var spans []SpanRow
|
spansByID := make(map[string]*SpanRow)
|
||||||
|
var spanOrder []string
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s SpanRow
|
var s SpanRow
|
||||||
if err := rows.Scan(&s.SpanID, &s.Name, &s.Kind, &s.StartedAt, &s.Duration, &s.Status, &s.Payload); err != nil {
|
if err := rows.Scan(&s.RunID, &s.SpanID, &s.Name, &s.Kind, &s.StartedAt, &s.Duration, &s.Status, &s.Payload); err != nil {
|
||||||
return nil, nil, err
|
return nil, err
|
||||||
}
|
|
||||||
spans = append(spans, s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &run, spans, rows.Err()
|
existing := spansByID[s.SpanID]
|
||||||
|
if existing == nil {
|
||||||
|
copy := s
|
||||||
|
spansByID[s.SpanID] = ©
|
||||||
|
spanOrder = append(spanOrder, s.SpanID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing.Name == "" && s.Name != "" {
|
||||||
|
existing.Name = s.Name
|
||||||
|
}
|
||||||
|
if existing.Kind == "" || existing.Kind == "unknown" {
|
||||||
|
existing.Kind = s.Kind
|
||||||
|
}
|
||||||
|
if s.Duration != nil {
|
||||||
|
existing.Duration = s.Duration
|
||||||
|
}
|
||||||
|
if s.Status == "error" {
|
||||||
|
existing.Status = "error"
|
||||||
|
}
|
||||||
|
existing.Payload = s.Payload
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
spans := make([]SpanRow, 0, len(spanOrder))
|
||||||
|
for _, spanID := range spanOrder {
|
||||||
|
spans = append(spans, *spansByID[spanID])
|
||||||
|
}
|
||||||
|
return spans, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) attachSpansToRuns(ctx context.Context, sessionID string, runs []RunRow) ([]RunRow, error) {
|
||||||
|
rows, err := d.sql.QueryContext(ctx, `
|
||||||
|
SELECT DISTINCT run_id
|
||||||
|
FROM events
|
||||||
|
WHERE session_id = $1 AND run_id IS NOT NULL
|
||||||
|
ORDER BY run_id
|
||||||
|
`, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
spansByRun := make(map[string][]SpanRow)
|
||||||
|
for rows.Next() {
|
||||||
|
var runID string
|
||||||
|
if err := rows.Scan(&runID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
spans, err := d.listSpansForRun(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
spansByRun[runID] = spans
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range runs {
|
||||||
|
runs[i].Spans = spansByRun[runs[i].RunID]
|
||||||
|
}
|
||||||
|
return runs, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user