feat(health): add project health store, Mission Control bar, and session metrics
This commit is contained in:
parent
072316d63f
commit
42094eac2a
11 changed files with 773 additions and 16 deletions
|
|
@ -448,6 +448,25 @@ fn project_agent_state_load(
|
|||
state.session_db.load_project_agent_state(&project_id)
|
||||
}
|
||||
|
||||
// --- Session metrics commands ---
|
||||
|
||||
#[tauri::command]
|
||||
fn session_metric_save(
|
||||
state: State<'_, AppState>,
|
||||
metric: session::SessionMetric,
|
||||
) -> Result<(), String> {
|
||||
state.session_db.save_session_metric(&metric)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_metrics_load(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
limit: i64,
|
||||
) -> Result<Vec<session::SessionMetric>, String> {
|
||||
state.session_db.load_session_metrics(&project_id, limit)
|
||||
}
|
||||
|
||||
// --- File browser commands (Files tab) ---
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
|
|
@ -761,6 +780,8 @@ pub fn run() {
|
|||
agent_messages_load,
|
||||
project_agent_state_save,
|
||||
project_agent_state_load,
|
||||
session_metric_save,
|
||||
session_metrics_load,
|
||||
cli_get_group,
|
||||
pick_directory,
|
||||
open_url,
|
||||
|
|
|
|||
|
|
@ -154,7 +154,24 @@ impl SessionDb {
|
|||
output_tokens INTEGER DEFAULT 0,
|
||||
last_prompt TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);"
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER NOT NULL,
|
||||
peak_tokens INTEGER DEFAULT 0,
|
||||
turn_count INTEGER DEFAULT 0,
|
||||
tool_call_count INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0,
|
||||
model TEXT,
|
||||
status TEXT NOT NULL,
|
||||
error_message TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_metrics_project
|
||||
ON session_metrics(project_id);"
|
||||
).map_err(|e| format!("Migration (v3 tables) failed: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
|
|
@ -507,6 +524,81 @@ pub struct ProjectAgentState {
|
|||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionMetric {
|
||||
#[serde(default)]
|
||||
pub id: i64,
|
||||
pub project_id: String,
|
||||
pub session_id: String,
|
||||
pub start_time: i64,
|
||||
pub end_time: i64,
|
||||
pub peak_tokens: i64,
|
||||
pub turn_count: i64,
|
||||
pub tool_call_count: i64,
|
||||
pub cost_usd: f64,
|
||||
pub model: Option<String>,
|
||||
pub status: String,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionDb {
|
||||
pub fn save_session_metric(&self, metric: &SessionMetric) -> Result<(), String> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO session_metrics (project_id, session_id, start_time, end_time, peak_tokens, turn_count, tool_call_count, cost_usd, model, status, error_message) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
metric.project_id,
|
||||
metric.session_id,
|
||||
metric.start_time,
|
||||
metric.end_time,
|
||||
metric.peak_tokens,
|
||||
metric.turn_count,
|
||||
metric.tool_call_count,
|
||||
metric.cost_usd,
|
||||
metric.model,
|
||||
metric.status,
|
||||
metric.error_message,
|
||||
],
|
||||
).map_err(|e| format!("Save session metric failed: {e}"))?;
|
||||
|
||||
// Enforce retention: keep last 100 per project
|
||||
conn.execute(
|
||||
"DELETE FROM session_metrics WHERE project_id = ?1 AND id NOT IN (SELECT id FROM session_metrics WHERE project_id = ?1 ORDER BY end_time DESC LIMIT 100)",
|
||||
params![metric.project_id],
|
||||
).map_err(|e| format!("Prune session metrics failed: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_session_metrics(&self, project_id: &str, limit: i64) -> Result<Vec<SessionMetric>, String> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, session_id, start_time, end_time, peak_tokens, turn_count, tool_call_count, cost_usd, model, status, error_message FROM session_metrics WHERE project_id = ?1 ORDER BY end_time DESC LIMIT ?2"
|
||||
).map_err(|e| format!("Query prepare failed: {e}"))?;
|
||||
|
||||
let metrics = stmt.query_map(params![project_id, limit], |row| {
|
||||
Ok(SessionMetric {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
session_id: row.get(2)?,
|
||||
start_time: row.get(3)?,
|
||||
end_time: row.get(4)?,
|
||||
peak_tokens: row.get(5)?,
|
||||
turn_count: row.get(6)?,
|
||||
tool_call_count: row.get(7)?,
|
||||
cost_usd: row.get(8)?,
|
||||
model: row.get(9)?,
|
||||
status: row.get(10)?,
|
||||
error_message: row.get(11)?,
|
||||
})
|
||||
}).map_err(|e| format!("Query failed: {e}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||
|
||||
Ok(metrics)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { getSetting } from './lib/adapters/settings-bridge';
|
||||
import { isDetachedMode, getDetachedConfig } from './lib/utils/detach';
|
||||
import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher';
|
||||
import { startHealthTick, stopHealthTick, clearHealthTracking } from './lib/stores/health.svelte';
|
||||
import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte';
|
||||
|
||||
// Workspace components
|
||||
|
|
@ -65,6 +66,7 @@
|
|||
if (v) document.documentElement.style.setProperty('--project-max-aspect', v);
|
||||
});
|
||||
startAgentDispatcher();
|
||||
startHealthTick();
|
||||
|
||||
if (!detached) {
|
||||
loadWorkspace().then(() => { loaded = true; });
|
||||
|
|
@ -120,6 +122,7 @@
|
|||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
stopAgentDispatcher();
|
||||
stopHealthTick();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,31 @@ export async function loadProjectAgentState(projectId: string): Promise<ProjectA
|
|||
return invoke('project_agent_state_load', { projectId });
|
||||
}
|
||||
|
||||
// --- Session metrics ---
|
||||
|
||||
export interface SessionMetric {
|
||||
id: number;
|
||||
project_id: string;
|
||||
session_id: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
peak_tokens: number;
|
||||
turn_count: number;
|
||||
tool_call_count: number;
|
||||
cost_usd: number;
|
||||
model: string | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export async function saveSessionMetric(metric: Omit<SessionMetric, 'id'>): Promise<void> {
|
||||
return invoke('session_metric_save', { metric: { id: 0, ...metric } });
|
||||
}
|
||||
|
||||
export async function loadSessionMetrics(projectId: string, limit = 20): Promise<SessionMetric[]> {
|
||||
return invoke('session_metrics_load', { projectId, limit });
|
||||
}
|
||||
|
||||
// --- CLI arguments ---
|
||||
|
||||
export async function getCliGroup(): Promise<string | null> {
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ import { notify } from './stores/notifications.svelte';
|
|||
import {
|
||||
saveProjectAgentState,
|
||||
saveAgentMessages,
|
||||
saveSessionMetric,
|
||||
type AgentMessageRecord,
|
||||
} from './adapters/groups-bridge';
|
||||
import { tel } from './adapters/telemetry-bridge';
|
||||
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
|
||||
|
||||
let unlistenMsg: (() => void) | null = null;
|
||||
let unlistenExit: (() => void) | null = null;
|
||||
|
|
@ -30,6 +32,9 @@ let unlistenExit: (() => void) | null = null;
|
|||
// Map sessionId -> projectId for persistence routing
|
||||
const sessionProjectMap = new Map<string, string>();
|
||||
|
||||
// Map sessionId -> start timestamp for metrics
|
||||
const sessionStartTimes = new Map<string, number>();
|
||||
|
||||
// In-flight persistence counter — prevents teardown from racing with async saves
|
||||
let pendingPersistCount = 0;
|
||||
|
||||
|
|
@ -70,6 +75,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
switch (msg.type) {
|
||||
case 'agent_started':
|
||||
updateAgentStatus(sessionId, 'running');
|
||||
sessionStartTimes.set(sessionId, Date.now());
|
||||
tel.info('agent_started', { sessionId });
|
||||
break;
|
||||
|
||||
|
|
@ -172,6 +178,9 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
if (SUBAGENT_TOOL_NAMES.has(tc.name)) {
|
||||
spawnSubagentPane(sessionId, tc);
|
||||
}
|
||||
// Health: record tool start
|
||||
const projId = sessionProjectMap.get(sessionId);
|
||||
if (projId) recordActivity(projId, tc.name);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +209,12 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
updateAgentStatus(sessionId, 'done');
|
||||
notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`);
|
||||
}
|
||||
// Health: record token snapshot + tool done
|
||||
const costProjId = sessionProjectMap.get(sessionId);
|
||||
if (costProjId) {
|
||||
recordTokenSnapshot(costProjId, cost.inputTokens + cost.outputTokens, cost.totalCostUsd);
|
||||
recordToolDone(costProjId);
|
||||
}
|
||||
// Persist session state for project-scoped sessions
|
||||
persistSessionForProject(sessionId);
|
||||
break;
|
||||
|
|
@ -207,7 +222,14 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
}
|
||||
}
|
||||
|
||||
// Health: record general activity for non-tool messages (text, thinking)
|
||||
if (mainMessages.length > 0) {
|
||||
const actProjId = sessionProjectMap.get(sessionId);
|
||||
if (actProjId) {
|
||||
const hasToolResult = mainMessages.some(m => m.type === 'tool_result');
|
||||
if (hasToolResult) recordToolDone(actProjId);
|
||||
else recordActivity(actProjId);
|
||||
}
|
||||
appendAgentMessages(sessionId, mainMessages);
|
||||
}
|
||||
|
||||
|
|
@ -322,6 +344,23 @@ async function persistSessionForProject(sessionId: string): Promise<void> {
|
|||
if (records.length > 0) {
|
||||
await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records);
|
||||
}
|
||||
|
||||
// Persist session metric for historical tracking
|
||||
const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length;
|
||||
const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000);
|
||||
await saveSessionMetric({
|
||||
project_id: projectId,
|
||||
session_id: sessionId,
|
||||
start_time: Math.floor(startTime / 1000),
|
||||
end_time: nowSecs,
|
||||
peak_tokens: session.inputTokens + session.outputTokens,
|
||||
turn_count: session.numTurns,
|
||||
tool_call_count: toolCallCount,
|
||||
cost_usd: session.costUsd,
|
||||
model: session.model ?? null,
|
||||
status: session.status,
|
||||
error_message: session.error ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to persist agent session:', e);
|
||||
} finally {
|
||||
|
|
@ -341,4 +380,5 @@ export function stopAgentDispatcher(): void {
|
|||
// Clear routing maps to prevent unbounded memory growth
|
||||
toolUseToChildPane.clear();
|
||||
sessionProjectMap.clear();
|
||||
sessionStartTimes.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,43 @@
|
|||
<script lang="ts">
|
||||
import { getAgentSessions } from '../../stores/agents.svelte';
|
||||
import { getActiveGroup, getEnabledProjects, getActiveGroupId } from '../../stores/workspace.svelte';
|
||||
import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte';
|
||||
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
|
||||
|
||||
let agentSessions = $derived(getAgentSessions());
|
||||
let activeGroup = $derived(getActiveGroup());
|
||||
let enabledProjects = $derived(getEnabledProjects());
|
||||
|
||||
let activeAgents = $derived(agentSessions.filter(s => s.status === 'running' || s.status === 'starting').length);
|
||||
let totalCost = $derived(agentSessions.reduce((sum, s) => sum + s.costUsd, 0));
|
||||
let totalTokens = $derived(agentSessions.reduce((sum, s) => sum + s.inputTokens + s.outputTokens, 0));
|
||||
let projectCount = $derived(enabledProjects.length);
|
||||
let agentCount = $derived(agentSessions.length);
|
||||
|
||||
// Health-derived signals
|
||||
let health = $derived(getHealthAggregates());
|
||||
let attentionQueue = $derived(getAttentionQueue(5));
|
||||
|
||||
let showAttention = $state(false);
|
||||
|
||||
function projectName(projectId: string): string {
|
||||
return enabledProjects.find(p => p.id === projectId)?.name ?? projectId.slice(0, 8);
|
||||
}
|
||||
|
||||
function focusProject(projectId: string) {
|
||||
setActiveProject(projectId);
|
||||
showAttention = false;
|
||||
}
|
||||
|
||||
function formatRate(rate: number): string {
|
||||
if (rate < 0.01) return '$0/hr';
|
||||
if (rate < 1) return `$${rate.toFixed(2)}/hr`;
|
||||
return `$${rate.toFixed(1)}/hr`;
|
||||
}
|
||||
|
||||
function attentionColor(item: ProjectHealth): string {
|
||||
if (item.attentionScore >= 90) return 'var(--ctp-red)';
|
||||
if (item.attentionScore >= 70) return 'var(--ctp-peach)';
|
||||
if (item.attentionScore >= 40) return 'var(--ctp-yellow)';
|
||||
return 'var(--ctp-overlay1)';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="status-bar">
|
||||
|
|
@ -21,18 +48,49 @@
|
|||
{/if}
|
||||
<span class="item" title="Enabled projects">{projectCount} projects</span>
|
||||
<span class="sep"></span>
|
||||
<span class="item" title="Agent sessions">{agentCount} agents</span>
|
||||
{#if activeAgents > 0}
|
||||
<span class="sep"></span>
|
||||
<span class="item active">
|
||||
|
||||
<!-- Agent states from health store -->
|
||||
{#if health.running > 0}
|
||||
<span class="item state-running" title="Running agents">
|
||||
<span class="pulse"></span>
|
||||
{activeAgents} running
|
||||
{health.running} running
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
{/if}
|
||||
{#if health.idle > 0}
|
||||
<span class="item state-idle" title="Idle agents">{health.idle} idle</span>
|
||||
<span class="sep"></span>
|
||||
{/if}
|
||||
{#if health.stalled > 0}
|
||||
<span class="item state-stalled" title="Stalled agents (>15 min inactive)">
|
||||
{health.stalled} stalled
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
{/if}
|
||||
|
||||
<!-- Attention queue toggle -->
|
||||
{#if attentionQueue.length > 0}
|
||||
<button
|
||||
class="item attention-btn"
|
||||
class:attention-open={showAttention}
|
||||
onclick={() => showAttention = !showAttention}
|
||||
title="Needs attention — click to expand"
|
||||
>
|
||||
<span class="attention-dot"></span>
|
||||
{attentionQueue.length} need attention
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
{#if health.totalBurnRatePerHour > 0}
|
||||
<span class="item burn-rate" title="Total burn rate across active sessions">
|
||||
{formatRate(health.totalBurnRatePerHour)}
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
{/if}
|
||||
{#if totalTokens > 0}
|
||||
<span class="item tokens">{totalTokens.toLocaleString()} tokens</span>
|
||||
<span class="item tokens">{totalTokens.toLocaleString()} tok</span>
|
||||
<span class="sep"></span>
|
||||
{/if}
|
||||
{#if totalCost > 0}
|
||||
|
|
@ -43,6 +101,24 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attention queue dropdown -->
|
||||
{#if showAttention && attentionQueue.length > 0}
|
||||
<div class="attention-panel">
|
||||
{#each attentionQueue as item (item.projectId)}
|
||||
<button
|
||||
class="attention-card"
|
||||
onclick={() => focusProject(item.projectId)}
|
||||
>
|
||||
<span class="card-name">{projectName(item.projectId)}</span>
|
||||
<span class="card-reason" style="color: {attentionColor(item)}">{item.attentionReason}</span>
|
||||
{#if item.contextPressure !== null && item.contextPressure > 0.5}
|
||||
<span class="card-ctx" title="Context usage">ctx {Math.round(item.contextPressure * 100)}%</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.status-bar {
|
||||
display: flex;
|
||||
|
|
@ -57,6 +133,7 @@
|
|||
font-family: 'JetBrains Mono', monospace;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.left, .right {
|
||||
|
|
@ -82,15 +159,25 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--ctp-blue);
|
||||
/* Agent state indicators */
|
||||
.state-running {
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.state-idle {
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.state-stalled {
|
||||
color: var(--ctp-peach);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-blue);
|
||||
background: var(--ctp-green);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +186,101 @@
|
|||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Attention button */
|
||||
.attention-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ctp-peach);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attention-btn:hover {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.attention-btn.attention-open {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.attention-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-peach);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.attention-btn.attention-open .attention-dot,
|
||||
.attention-btn:hover .attention-dot {
|
||||
background: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* Burn rate */
|
||||
.burn-rate {
|
||||
color: var(--ctp-mauve);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tokens { color: var(--ctp-overlay1); }
|
||||
.cost { color: var(--ctp-yellow); }
|
||||
.version { color: var(--ctp-overlay0); }
|
||||
|
||||
/* Attention panel dropdown */
|
||||
.attention-panel {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--ctp-surface0);
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
z-index: 100;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.attention-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attention-card:hover {
|
||||
background: var(--ctp-surface0);
|
||||
border-color: var(--ctp-surface2);
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.card-reason {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.card-ctx {
|
||||
font-size: 0.5625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
background: color-mix(in srgb, var(--ctp-yellow) 10%, transparent);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
type AgentMessageRecord,
|
||||
} from '../../adapters/groups-bridge';
|
||||
import { registerSessionProject } from '../../agent-dispatcher';
|
||||
import { trackProject, updateProjectSession } from '../../stores/health.svelte';
|
||||
import {
|
||||
createAgentSession,
|
||||
appendAgentMessages,
|
||||
|
|
@ -35,6 +36,7 @@
|
|||
hasRestoredHistory = false;
|
||||
lastState = null;
|
||||
registerSessionProject(sessionId, project.id);
|
||||
trackProject(project.id, sessionId);
|
||||
onsessionid?.(sessionId);
|
||||
}
|
||||
|
||||
|
|
@ -67,8 +69,9 @@
|
|||
sessionId = crypto.randomUUID();
|
||||
} finally {
|
||||
loading = false;
|
||||
// Register session -> project mapping for persistence
|
||||
// Register session -> project mapping for persistence + health tracking
|
||||
registerSessionProject(sessionId, project.id);
|
||||
trackProject(project.id, sessionId);
|
||||
onsessionid?.(sessionId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import SshTab from './SshTab.svelte';
|
||||
import MemoriesTab from './MemoriesTab.svelte';
|
||||
import { getTerminalTabs } from '../../stores/workspace.svelte';
|
||||
import { getProjectHealth } from '../../stores/health.svelte';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
let everActivated = $state<Record<string, boolean>>({});
|
||||
|
||||
let termTabs = $derived(getTerminalTabs(project.id));
|
||||
let projectHealth = $derived(getProjectHealth(project.id));
|
||||
let termTabCount = $derived(termTabs.length);
|
||||
|
||||
/** Activate a tab — for lazy tabs, mark as ever-activated */
|
||||
|
|
@ -56,6 +58,7 @@
|
|||
{project}
|
||||
{slotIndex}
|
||||
{active}
|
||||
health={projectHealth}
|
||||
onclick={onactivate}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { ProjectConfig } from '../../types/groups';
|
||||
import { PROJECT_ACCENTS } from '../../types/groups';
|
||||
import type { ProjectHealth } from '../../stores/health.svelte';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
slotIndex: number;
|
||||
active: boolean;
|
||||
health: ProjectHealth | null;
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { project, slotIndex, active, onclick }: Props = $props();
|
||||
let { project, slotIndex, active, health, onclick }: Props = $props();
|
||||
|
||||
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
|
||||
|
||||
|
|
@ -25,6 +27,44 @@
|
|||
}
|
||||
return cwd;
|
||||
});
|
||||
|
||||
let statusDotClass = $derived(() => {
|
||||
if (!health) return 'dot-inactive';
|
||||
switch (health.activityState) {
|
||||
case 'running': return 'dot-running';
|
||||
case 'idle': return 'dot-idle';
|
||||
case 'stalled': return 'dot-stalled';
|
||||
default: return 'dot-inactive';
|
||||
}
|
||||
});
|
||||
|
||||
let statusTooltip = $derived(() => {
|
||||
if (!health) return 'No active session';
|
||||
switch (health.activityState) {
|
||||
case 'running': return health.activeTool ? `Running: ${health.activeTool}` : 'Running';
|
||||
case 'idle': {
|
||||
const secs = Math.floor(health.idleDurationMs / 1000);
|
||||
return secs < 60 ? `Idle (${secs}s)` : `Idle (${Math.floor(secs / 60)}m ${secs % 60}s)`;
|
||||
}
|
||||
case 'stalled': {
|
||||
const mins = Math.floor(health.idleDurationMs / 60_000);
|
||||
return `Stalled — ${mins} min since last activity`;
|
||||
}
|
||||
default: return 'Inactive';
|
||||
}
|
||||
});
|
||||
|
||||
let contextPct = $derived(health?.contextPressure !== null && health?.contextPressure !== undefined
|
||||
? Math.round(health.contextPressure * 100)
|
||||
: null);
|
||||
|
||||
let ctxColor = $derived(() => {
|
||||
if (contextPct === null) return '';
|
||||
if (contextPct > 90) return 'var(--ctp-red)';
|
||||
if (contextPct > 75) return 'var(--ctp-peach)';
|
||||
if (contextPct > 50) return 'var(--ctp-yellow)';
|
||||
return 'var(--ctp-overlay0)';
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
|
|
@ -34,11 +74,22 @@
|
|||
{onclick}
|
||||
>
|
||||
<div class="header-main">
|
||||
<span class="status-dot {statusDotClass()}" title={statusTooltip()}></span>
|
||||
<span class="project-icon">{project.icon || '📁'}</span>
|
||||
<span class="project-name">{project.name}</span>
|
||||
<span class="project-id">({project.identifier})</span>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
{#if contextPct !== null && contextPct > 0}
|
||||
<span class="info-ctx" style="color: {ctxColor()}" title="Context window usage">ctx {contextPct}%</span>
|
||||
<span class="info-sep">·</span>
|
||||
{/if}
|
||||
{#if health && health.burnRatePerHour > 0.01}
|
||||
<span class="info-rate" title="Burn rate">
|
||||
${health.burnRatePerHour < 1 ? health.burnRatePerHour.toFixed(2) : health.burnRatePerHour.toFixed(1)}/hr
|
||||
</span>
|
||||
<span class="info-sep">·</span>
|
||||
{/if}
|
||||
<span class="info-cwd" title={project.cwd}>{displayCwd()}</span>
|
||||
{#if project.profile}
|
||||
<span class="info-sep">·</span>
|
||||
|
|
@ -83,6 +134,37 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-inactive {
|
||||
background: var(--ctp-surface2);
|
||||
}
|
||||
|
||||
.dot-running {
|
||||
background: var(--ctp-green);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot-idle {
|
||||
background: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.dot-stalled {
|
||||
background: var(--ctp-peach);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.project-icon {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
|
|
@ -109,6 +191,20 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-ctx {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-rate {
|
||||
font-size: 0.6rem;
|
||||
color: var(--ctp-mauve);
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-cwd {
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay0);
|
||||
|
|
|
|||
291
v2/src/lib/stores/health.svelte.ts
Normal file
291
v2/src/lib/stores/health.svelte.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
// Project health tracking — Svelte 5 runes
|
||||
// Tracks per-project activity state, burn rate, context pressure, and attention scoring
|
||||
|
||||
import { getAgentSession, type AgentSession } from './agents.svelte';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled';
|
||||
|
||||
export interface ProjectHealth {
|
||||
projectId: string;
|
||||
sessionId: string | null;
|
||||
/** Current activity state */
|
||||
activityState: ActivityState;
|
||||
/** Name of currently running tool (if any) */
|
||||
activeTool: string | null;
|
||||
/** Duration in ms since last activity (0 if running a tool) */
|
||||
idleDurationMs: number;
|
||||
/** Burn rate in USD per hour (0 if no data) */
|
||||
burnRatePerHour: number;
|
||||
/** Context pressure as fraction 0..1 (null if unknown) */
|
||||
contextPressure: number | null;
|
||||
/** Attention urgency score (higher = more urgent, 0 = no attention needed) */
|
||||
attentionScore: number;
|
||||
/** Human-readable attention reason */
|
||||
attentionReason: string | null;
|
||||
}
|
||||
|
||||
export type AttentionItem = ProjectHealth & { projectName: string; projectIcon: string };
|
||||
|
||||
// --- Configuration ---
|
||||
|
||||
const STALL_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const TICK_INTERVAL_MS = 5_000; // Update derived state every 5s
|
||||
const BURN_RATE_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for burn rate calc
|
||||
|
||||
// Context limits by model (tokens)
|
||||
const MODEL_CONTEXT_LIMITS: Record<string, number> = {
|
||||
'claude-sonnet-4-20250514': 200_000,
|
||||
'claude-opus-4-20250514': 200_000,
|
||||
'claude-haiku-4-20250506': 200_000,
|
||||
'claude-3-5-sonnet-20241022': 200_000,
|
||||
'claude-3-5-haiku-20241022': 200_000,
|
||||
'claude-sonnet-4-6': 200_000,
|
||||
'claude-opus-4-6': 200_000,
|
||||
};
|
||||
const DEFAULT_CONTEXT_LIMIT = 200_000;
|
||||
|
||||
// Attention score weights (higher = more urgent)
|
||||
const SCORE_STALLED = 100;
|
||||
const SCORE_CONTEXT_CRITICAL = 80; // >90% context
|
||||
const SCORE_CONTEXT_HIGH = 40; // >75% context
|
||||
const SCORE_ERROR = 90;
|
||||
const SCORE_IDLE_LONG = 20; // >2x stall threshold but not stalled (shouldn't happen, safety)
|
||||
|
||||
// --- State ---
|
||||
|
||||
interface ProjectTracker {
|
||||
projectId: string;
|
||||
sessionId: string | null;
|
||||
lastActivityTs: number; // epoch ms
|
||||
lastToolName: string | null;
|
||||
toolInFlight: boolean;
|
||||
/** Token snapshots for burn rate calculation: [timestamp, totalTokens] */
|
||||
tokenSnapshots: Array<[number, number]>;
|
||||
/** Cost snapshots for $/hr: [timestamp, costUsd] */
|
||||
costSnapshots: Array<[number, number]>;
|
||||
}
|
||||
|
||||
let trackers = $state<Map<string, ProjectTracker>>(new Map());
|
||||
let tickTs = $state<number>(Date.now());
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/** Register a project for health tracking */
|
||||
export function trackProject(projectId: string, sessionId: string | null): void {
|
||||
const existing = trackers.get(projectId);
|
||||
if (existing) {
|
||||
existing.sessionId = sessionId;
|
||||
return;
|
||||
}
|
||||
trackers.set(projectId, {
|
||||
projectId,
|
||||
sessionId,
|
||||
lastActivityTs: Date.now(),
|
||||
lastToolName: null,
|
||||
toolInFlight: false,
|
||||
tokenSnapshots: [],
|
||||
costSnapshots: [],
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a project from health tracking */
|
||||
export function untrackProject(projectId: string): void {
|
||||
trackers.delete(projectId);
|
||||
}
|
||||
|
||||
/** Update session ID for a tracked project */
|
||||
export function updateProjectSession(projectId: string, sessionId: string): void {
|
||||
const t = trackers.get(projectId);
|
||||
if (t) {
|
||||
t.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Record activity — call on every agent message */
|
||||
export function recordActivity(projectId: string, toolName?: string): void {
|
||||
const t = trackers.get(projectId);
|
||||
if (!t) return;
|
||||
t.lastActivityTs = Date.now();
|
||||
if (toolName !== undefined) {
|
||||
t.lastToolName = toolName;
|
||||
t.toolInFlight = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Record tool completion */
|
||||
export function recordToolDone(projectId: string): void {
|
||||
const t = trackers.get(projectId);
|
||||
if (!t) return;
|
||||
t.lastActivityTs = Date.now();
|
||||
t.toolInFlight = false;
|
||||
}
|
||||
|
||||
/** Record a token/cost snapshot for burn rate calculation */
|
||||
export function recordTokenSnapshot(projectId: string, totalTokens: number, costUsd: number): void {
|
||||
const t = trackers.get(projectId);
|
||||
if (!t) return;
|
||||
const now = Date.now();
|
||||
t.tokenSnapshots.push([now, totalTokens]);
|
||||
t.costSnapshots.push([now, costUsd]);
|
||||
// Prune old snapshots beyond window
|
||||
const cutoff = now - BURN_RATE_WINDOW_MS * 2;
|
||||
t.tokenSnapshots = t.tokenSnapshots.filter(([ts]) => ts > cutoff);
|
||||
t.costSnapshots = t.costSnapshots.filter(([ts]) => ts > cutoff);
|
||||
}
|
||||
|
||||
/** Start the health tick timer */
|
||||
export function startHealthTick(): void {
|
||||
if (tickInterval) return;
|
||||
tickInterval = setInterval(() => {
|
||||
tickTs = Date.now();
|
||||
}, TICK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop the health tick timer */
|
||||
export function stopHealthTick(): void {
|
||||
if (tickInterval) {
|
||||
clearInterval(tickInterval);
|
||||
tickInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all tracked projects */
|
||||
export function clearHealthTracking(): void {
|
||||
trackers = new Map();
|
||||
}
|
||||
|
||||
// --- Derived health per project ---
|
||||
|
||||
function getContextLimit(model?: string): number {
|
||||
if (!model) return DEFAULT_CONTEXT_LIMIT;
|
||||
return MODEL_CONTEXT_LIMITS[model] ?? DEFAULT_CONTEXT_LIMIT;
|
||||
}
|
||||
|
||||
function computeBurnRate(snapshots: Array<[number, number]>): number {
|
||||
if (snapshots.length < 2) return 0;
|
||||
const windowStart = Date.now() - BURN_RATE_WINDOW_MS;
|
||||
const recent = snapshots.filter(([ts]) => ts >= windowStart);
|
||||
if (recent.length < 2) return 0;
|
||||
const first = recent[0];
|
||||
const last = recent[recent.length - 1];
|
||||
const elapsedHours = (last[0] - first[0]) / 3_600_000;
|
||||
if (elapsedHours < 0.001) return 0; // Less than ~4 seconds
|
||||
const costDelta = last[1] - first[1];
|
||||
return Math.max(0, costDelta / elapsedHours);
|
||||
}
|
||||
|
||||
function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||
const session: AgentSession | undefined = tracker.sessionId
|
||||
? getAgentSession(tracker.sessionId)
|
||||
: undefined;
|
||||
|
||||
// Activity state
|
||||
let activityState: ActivityState;
|
||||
let idleDurationMs = 0;
|
||||
let activeTool: string | null = null;
|
||||
|
||||
if (!session || session.status === 'idle' || session.status === 'done' || session.status === 'error') {
|
||||
activityState = session?.status === 'error' ? 'inactive' : 'inactive';
|
||||
} else if (tracker.toolInFlight) {
|
||||
activityState = 'running';
|
||||
activeTool = tracker.lastToolName;
|
||||
idleDurationMs = 0;
|
||||
} else {
|
||||
idleDurationMs = now - tracker.lastActivityTs;
|
||||
if (idleDurationMs >= STALL_THRESHOLD_MS) {
|
||||
activityState = 'stalled';
|
||||
} else {
|
||||
activityState = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
// Context pressure
|
||||
let contextPressure: number | null = null;
|
||||
if (session && (session.inputTokens + session.outputTokens) > 0) {
|
||||
const limit = getContextLimit(session.model);
|
||||
contextPressure = Math.min(1, (session.inputTokens + session.outputTokens) / limit);
|
||||
}
|
||||
|
||||
// Burn rate
|
||||
const burnRatePerHour = computeBurnRate(tracker.costSnapshots);
|
||||
|
||||
// Attention scoring
|
||||
let attentionScore = 0;
|
||||
let attentionReason: string | null = null;
|
||||
|
||||
if (session?.status === 'error') {
|
||||
attentionScore = SCORE_ERROR;
|
||||
attentionReason = `Error: ${session.error?.slice(0, 60) ?? 'Unknown'}`;
|
||||
} else if (activityState === 'stalled') {
|
||||
attentionScore = SCORE_STALLED;
|
||||
const mins = Math.floor(idleDurationMs / 60_000);
|
||||
attentionReason = `Stalled — ${mins} min since last activity`;
|
||||
} else if (contextPressure !== null && contextPressure > 0.9) {
|
||||
attentionScore = SCORE_CONTEXT_CRITICAL;
|
||||
attentionReason = `Context ${Math.round(contextPressure * 100)}% — near limit`;
|
||||
} else if (contextPressure !== null && contextPressure > 0.75) {
|
||||
attentionScore = SCORE_CONTEXT_HIGH;
|
||||
attentionReason = `Context ${Math.round(contextPressure * 100)}%`;
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: tracker.projectId,
|
||||
sessionId: tracker.sessionId,
|
||||
activityState,
|
||||
activeTool,
|
||||
idleDurationMs,
|
||||
burnRatePerHour,
|
||||
contextPressure,
|
||||
attentionScore,
|
||||
attentionReason,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get health for a single project (reactive via tickTs) */
|
||||
export function getProjectHealth(projectId: string): ProjectHealth | null {
|
||||
// Touch tickTs to make this reactive to the timer
|
||||
const now = tickTs;
|
||||
const t = trackers.get(projectId);
|
||||
if (!t) return null;
|
||||
return computeHealth(t, now);
|
||||
}
|
||||
|
||||
/** Get all project health sorted by attention score descending */
|
||||
export function getAllProjectHealth(): ProjectHealth[] {
|
||||
const now = tickTs;
|
||||
const results: ProjectHealth[] = [];
|
||||
for (const t of trackers.values()) {
|
||||
results.push(computeHealth(t, now));
|
||||
}
|
||||
results.sort((a, b) => b.attentionScore - a.attentionScore);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Get top N items needing attention */
|
||||
export function getAttentionQueue(limit = 5): ProjectHealth[] {
|
||||
return getAllProjectHealth().filter(h => h.attentionScore > 0).slice(0, limit);
|
||||
}
|
||||
|
||||
/** Get aggregate stats across all tracked projects */
|
||||
export function getHealthAggregates(): {
|
||||
running: number;
|
||||
idle: number;
|
||||
stalled: number;
|
||||
totalBurnRatePerHour: number;
|
||||
} {
|
||||
const all = getAllProjectHealth();
|
||||
let running = 0;
|
||||
let idle = 0;
|
||||
let stalled = 0;
|
||||
let totalBurnRatePerHour = 0;
|
||||
for (const h of all) {
|
||||
if (h.activityState === 'running') running++;
|
||||
else if (h.activityState === 'idle') idle++;
|
||||
else if (h.activityState === 'stalled') stalled++;
|
||||
totalBurnRatePerHour += h.burnRatePerHour;
|
||||
}
|
||||
return { running, idle, stalled, totalBurnRatePerHour };
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
||||
import type { GroupsFile, GroupConfig, ProjectConfig } from '../types/groups';
|
||||
import { clearAllAgentSessions } from '../stores/agents.svelte';
|
||||
import { clearHealthTracking } from '../stores/health.svelte';
|
||||
import { waitForPendingPersistence } from '../agent-dispatcher';
|
||||
|
||||
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings';
|
||||
|
|
@ -73,9 +74,10 @@ export async function switchGroup(groupId: string): Promise<void> {
|
|||
// Wait for any in-flight persistence before clearing state
|
||||
await waitForPendingPersistence();
|
||||
|
||||
// Teardown: clear terminal tabs and agent sessions for the old group
|
||||
// Teardown: clear terminal tabs, agent sessions, and health tracking for the old group
|
||||
projectTerminals = {};
|
||||
clearAllAgentSessions();
|
||||
clearHealthTracking();
|
||||
|
||||
activeGroupId = groupId;
|
||||
activeProjectId = null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue