diff --git a/cloudflare-gastown/container/src/process-manager.ts b/cloudflare-gastown/container/src/process-manager.ts index 1b21f70ff..756bc3f41 100644 --- a/cloudflare-gastown/container/src/process-manager.ts +++ b/cloudflare-gastown/container/src/process-manager.ts @@ -33,6 +33,9 @@ const eventAbortControllers = new Map(); const eventSinks = new Set<(agentId: string, event: string, data: unknown) => void>(); // Per-agent idle timers — fires exit when no nudges arrive const idleTimers = new Map>(); +// LOC diff stats poller interval +let locStatsTimer: ReturnType | undefined; +const LOC_STATS_INTERVAL_MS = 30_000; // 30s let nextPort = 4096; const startTime = Date.now(); @@ -135,6 +138,143 @@ function broadcastEvent(agentId: string, event: string, data: unknown): void { } } +/** + * Report token/cost usage to TownDO for metrics aggregation. + * Extracts input_tokens, output_tokens, and cost from message.completed + * or assistant.completed event properties. + * Fire-and-forget — errors are silently ignored. + */ +function reportTokenUsage(agent: ManagedAgent, properties: Record): void { + // The SDK emits usage data in various shapes depending on the provider. + // Common shapes: { usage: { inputTokens, outputTokens } }, + // { input_tokens, output_tokens }, { usage: { input_tokens, output_tokens } } + const usage = + properties.usage && typeof properties.usage === 'object' + ? (properties.usage as Record) + : properties; + + const inputTokens = Number(usage.inputTokens ?? usage.input_tokens ?? 0) || 0; + const outputTokens = Number(usage.outputTokens ?? usage.output_tokens ?? 0) || 0; + + // Cost might be in microdollars or dollars depending on provider + let costMicrodollars = 0; + if (usage.cost_microdollars != null) { + costMicrodollars = Number(usage.cost_microdollars) || 0; + } else if (usage.cost != null) { + // Assume cost is in dollars, convert to microdollars + costMicrodollars = Math.round((Number(usage.cost) || 0) * 1_000_000); + } + + if (inputTokens === 0 && outputTokens === 0 && costMicrodollars === 0) return; + + const apiUrl = agent.gastownApiUrl; + const authToken = + process.env.GASTOWN_CONTAINER_TOKEN ?? agent.gastownContainerToken ?? agent.gastownSessionToken; + if (!apiUrl || !authToken) return; + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }; + if (process.env.GASTOWN_CONTAINER_TOKEN || agent.gastownContainerToken) { + headers['X-Gastown-Agent-Id'] = agent.agentId; + if (agent.rigId) headers['X-Gastown-Rig-Id'] = agent.rigId; + } + + fetch(`${apiUrl}/api/towns/${agent.townId ?? '_'}/usage`, { + method: 'POST', + headers, + body: JSON.stringify({ + input_tokens: inputTokens, + output_tokens: outputTokens, + cost_microdollars: costMicrodollars, + }), + }).catch(() => { + // Best-effort — don't block the event loop + }); +} + +/** + * Poll git diff stats for all active agents and report aggregate LOC to TownDO. + * Uses the SDK's worktree.diffSummary() to compare against the default branch. + * Runs every 30s. + */ +async function pollLocStats(): Promise { + let totalAdditions = 0; + let totalDeletions = 0; + let townId: string | null = null; + let apiUrl: string | null = null; + let authToken: string | null = null; + + for (const agent of agents.values()) { + if (agent.status !== 'running' && agent.status !== 'starting') continue; + if (!agent.defaultBranch) continue; + + // Capture credentials from first viable agent + if (!townId) { + townId = agent.townId; + apiUrl = agent.gastownApiUrl; + authToken = + process.env.GASTOWN_CONTAINER_TOKEN ?? + agent.gastownContainerToken ?? + agent.gastownSessionToken; + } + + const instance = sdkInstances.get(agent.workdir); + if (!instance) continue; + + try { + const base = `origin/${agent.defaultBranch}`; + const result = await instance.client.worktree.diffSummary( + { directory: agent.workdir, base }, + { throwOnError: true } + ); + const diffs = result.data; + if (Array.isArray(diffs)) { + for (const diff of diffs) { + const d = diff as { additions?: number; deletions?: number }; + totalAdditions += Number(d.additions ?? 0); + totalDeletions += Number(d.deletions ?? 0); + } + } + } catch { + // Agent may not have a git worktree yet or diff may fail — skip + } + } + + // Report aggregate LOC to TownDO + if (townId && apiUrl && authToken && (totalAdditions > 0 || totalDeletions > 0)) { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }; + fetch(`${apiUrl}/api/towns/${townId}/loc`, { + method: 'POST', + headers, + body: JSON.stringify({ additions: totalAdditions, deletions: totalDeletions }), + }).catch(() => {}); + } +} + +function ensureLocStatsPoller(): void { + if (locStatsTimer) return; + locStatsTimer = setInterval(() => { + void pollLocStats(); + }, LOC_STATS_INTERVAL_MS); + // Run immediately on first start + void pollLocStats(); +} + +function stopLocStatsPollerIfIdle(): void { + const hasActive = [...agents.values()].some( + a => a.status === 'running' || a.status === 'starting' + ); + if (!hasActive && locStatsTimer) { + clearInterval(locStatsTimer); + locStatsTimer = undefined; + } +} + /** * Get or create an SDK server instance for a workdir. * @@ -461,6 +601,14 @@ async function subscribeToEvents( // Broadcast to WebSocket sinks broadcastEvent(agent.agentId, event.type ?? 'unknown', event.properties ?? {}); + // Report token usage to TownDO for throughput gauges + if ( + (event.type === 'message.completed' || event.type === 'assistant.completed') && + event.properties + ) { + reportTokenUsage(agent, event.properties as Record); + } + if (event.type === 'session.idle') { if (request.role === 'mayor') { // Mayor agents are persistent — session.idle means "turn done", not exit. @@ -543,6 +691,7 @@ export async function startAgent( gastownSessionToken: request.envVars?.GASTOWN_SESSION_TOKEN ?? null, completionCallbackUrl: request.envVars?.GASTOWN_COMPLETION_CALLBACK_URL ?? null, model: request.model ?? null, + defaultBranch: request.defaultBranch ?? null, }; agents.set(request.agentId, agent); @@ -600,6 +749,9 @@ export async function startAgent( } agent.messageCount = 1; + // Start LOC diff stats poller when first agent becomes active + ensureLocStatsPoller(); + log.info('agent.start', { agentId: request.agentId, role: request.role, @@ -660,6 +812,7 @@ export async function stopAgent(agentId: string): Promise { agent.exitReason = 'stopped'; log.info('agent.exit', { agentId, reason: 'stopped', exitReason: 'stopped' }); broadcastEvent(agentId, 'agent.exited', { reason: 'stopped' }); + stopLocStatsPollerIfIdle(); } /** diff --git a/cloudflare-gastown/container/src/types.ts b/cloudflare-gastown/container/src/types.ts index 2d7b5eacb..00a48ba21 100644 --- a/cloudflare-gastown/container/src/types.ts +++ b/cloudflare-gastown/container/src/types.ts @@ -119,6 +119,8 @@ export type ManagedAgent = { completionCallbackUrl: string | null; /** Model ID used for this agent's sessions (e.g. "anthropic/claude-sonnet-4.6") */ model: string | null; + /** Default branch for git diff stats (e.g. "main") */ + defaultBranch: string | null; }; export type AgentStatusResponse = { diff --git a/cloudflare-gastown/src/db/tables/metrics-snapshots.table.ts b/cloudflare-gastown/src/db/tables/metrics-snapshots.table.ts new file mode 100644 index 000000000..15f5f647b --- /dev/null +++ b/cloudflare-gastown/src/db/tables/metrics-snapshots.table.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; +import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; + +/** + * Periodic metrics snapshots collected by the alarm handler. + * One row per alarm tick (~5s when active, ~60s when idle). + * Used for timeseries charts on the observability tab. + */ +export const MetricsSnapshotRecord = z.object({ + snapshot_id: z.string(), + /** ISO timestamp of when the snapshot was taken */ + captured_at: z.string(), + /** Number of agents in 'working' status */ + agents_working: z.coerce.number(), + /** Number of agents in 'idle' status */ + agents_idle: z.coerce.number(), + /** Total registered agents */ + agents_total: z.coerce.number(), + /** Beads currently open */ + beads_open: z.coerce.number(), + /** Beads currently in_progress */ + beads_in_progress: z.coerce.number(), + /** Beads currently in_review */ + beads_in_review: z.coerce.number(), + /** Bead events recorded since last snapshot */ + events_since_last: z.coerce.number(), + /** Beads created since last snapshot */ + beads_created_since_last: z.coerce.number(), + /** Beads closed since last snapshot */ + beads_closed_since_last: z.coerce.number(), + /** Accumulated input tokens since last snapshot */ + input_tokens_since_last: z.coerce.number(), + /** Accumulated output tokens since last snapshot */ + output_tokens_since_last: z.coerce.number(), + /** Accumulated cost in microdollars since last snapshot */ + cost_microdollars_since_last: z.coerce.number(), + /** Total LOC additions across all agents (absolute snapshot, not delta) */ + loc_additions: z.coerce.number(), + /** Total LOC deletions across all agents (absolute snapshot, not delta) */ + loc_deletions: z.coerce.number(), +}); + +export type MetricsSnapshotRecord = z.output; + +export const metrics_snapshots = getTableFromZodSchema('metrics_snapshots', MetricsSnapshotRecord); + +export function createTableMetricsSnapshots(): string { + return getCreateTableQueryFromTable(metrics_snapshots, { + snapshot_id: 'text primary key', + captured_at: 'text not null', + agents_working: 'integer not null default 0', + agents_idle: 'integer not null default 0', + agents_total: 'integer not null default 0', + beads_open: 'integer not null default 0', + beads_in_progress: 'integer not null default 0', + beads_in_review: 'integer not null default 0', + events_since_last: 'integer not null default 0', + beads_created_since_last: 'integer not null default 0', + beads_closed_since_last: 'integer not null default 0', + input_tokens_since_last: 'integer not null default 0', + output_tokens_since_last: 'integer not null default 0', + cost_microdollars_since_last: 'integer not null default 0', + loc_additions: 'integer not null default 0', + loc_deletions: 'integer not null default 0', + }); +} + +export function getIndexesMetricsSnapshots(): string[] { + return [ + `CREATE INDEX IF NOT EXISTS idx_metrics_snapshots_captured ON ${metrics_snapshots}(${metrics_snapshots.columns.captured_at})`, + ]; +} diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 211639da7..9fa395c81 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -26,6 +26,7 @@ import * as config from './town/config'; import * as rigs from './town/rigs'; import * as dispatch from './town/container-dispatch'; import * as patrol from './town/patrol'; +import * as metrics from './town/metrics'; import { GitHubPRStatusSchema, GitLabMRStatusSchema } from '../util/platform-pr.util'; // Table imports for beads-centric operations @@ -226,6 +227,8 @@ export class TownDO extends DurableObject { private sql: SqlStorage; private initPromise: Promise | null = null; private _ownerUserId: string | undefined; + private usageAccumulator = metrics.createUsageAccumulator(); + private lastSnapshotAt: string | null = null; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); @@ -443,6 +446,9 @@ export class TownDO extends DurableObject { query(this.sql, idx, []); } + // Metrics timeseries + metrics.initMetricsTables(this.sql); + // Ensure the alarm loop is running. After a deploy/restart, the // Cloudflare runtime normally delivers missed alarms, but if the alarm // was never set or was deleted by destroy(), the loop is dead. Re-arm @@ -2970,6 +2976,14 @@ export class TownDO extends DurableObject { const interval = active ? ACTIVE_ALARM_INTERVAL_MS : IDLE_ALARM_INTERVAL_MS; await this.ctx.storage.setAlarm(Date.now() + interval); + // Collect metrics snapshot for timeseries charts + try { + metrics.collectSnapshot(this.sql, this.lastSnapshotAt, this.usageAccumulator); + this.lastSnapshotAt = new Date().toISOString(); + } catch (err) { + console.warn(`${TOWN_LOG} alarm: metrics collection failed`, err); + } + // Broadcast status snapshot to connected WebSocket clients try { const snapshot = await this.getAlarmStatus(); @@ -4228,6 +4242,14 @@ export class TownDO extends DurableObject { stalledAgents: number; orphanedHooks: number; }; + throughput: { + tokensPerSec: number; + costPerSec: number; + activeAgents: number; + totalAgents: number; + locAdditions: number; + locDeletions: number; + }; recentEvents: Array<{ time: string; type: string; @@ -4363,6 +4385,28 @@ export class TownDO extends DurableObject { message: formatEventMessage(row), })); + // Throughput gauges (5-minute rolling average) + let throughput: { + tokensPerSec: number; + costPerSec: number; + activeAgents: number; + totalAgents: number; + locAdditions: number; + locDeletions: number; + }; + try { + throughput = metrics.computeThroughput(this.sql); + } catch { + throughput = { + tokensPerSec: 0, + costPerSec: 0, + activeAgents: agentCounts.working, + totalAgents: agentCounts.total, + locAdditions: 0, + locDeletions: 0, + }; + } + return { alarm: { nextFireAt: currentAlarm ? new Date(Number(currentAlarm)).toISOString() : null, @@ -4377,10 +4421,48 @@ export class TownDO extends DurableObject { stalledAgents, orphanedHooks, }, + throughput, recentEvents, }; } + // ══════════════════════════════════════════════════════════════════ + // Metrics & Throughput + // ══════════════════════════════════════════════════════════════════ + + /** + * Report token/cost usage from the container. + * Called by container agents after LLM requests complete. + */ + async reportUsage( + inputTokens: number, + outputTokens: number, + costMicrodollars: number + ): Promise { + await this.ensureInitialized(); + metrics.addUsage(this.usageAccumulator, inputTokens, outputTokens, costMicrodollars); + } + + /** + * Report LOC snapshot from the container. + * Called periodically by the container's diff stats poller. + * Values are absolute totals (additions/deletions vs base branch). + */ + async reportLoc(additions: number, deletions: number): Promise { + await this.ensureInitialized(); + metrics.setLoc(this.usageAccumulator, additions, deletions); + } + + /** + * Query timeseries metrics for chart display. + */ + async getMetricsTimeseries(window: string): Promise { + await this.ensureInitialized(); + const parsed = metrics.TimeseriesWindow.safeParse(window); + if (!parsed.success) return []; + return metrics.queryTimeseries(this.sql, parsed.data); + } + async destroy(): Promise { console.log(`${TOWN_LOG} destroy: clearing all storage and alarms`); diff --git a/cloudflare-gastown/src/dos/town/metrics.ts b/cloudflare-gastown/src/dos/town/metrics.ts new file mode 100644 index 000000000..420ab716b --- /dev/null +++ b/cloudflare-gastown/src/dos/town/metrics.ts @@ -0,0 +1,402 @@ +/** + * Metrics collection and timeseries queries for the Town DO. + * Snapshots are recorded every alarm tick and stored in metrics_snapshots. + * Usage data (tokens, cost) is reported by the container via reportUsage(). + */ + +import { z } from 'zod'; +import { beads } from '../../db/tables/beads.table'; +import { agent_metadata } from '../../db/tables/agent-metadata.table'; +import { bead_events } from '../../db/tables/bead-events.table'; +import { + metrics_snapshots, + MetricsSnapshotRecord, + createTableMetricsSnapshots, + getIndexesMetricsSnapshots, +} from '../../db/tables/metrics-snapshots.table'; +import { query } from '../../util/query.util'; + +// ── Table initialization ──────────────────────────────────────────── + +export function initMetricsTables(sql: SqlStorage): void { + query(sql, createTableMetricsSnapshots(), []); + for (const idx of getIndexesMetricsSnapshots()) { + query(sql, idx, []); + } +} + +// ── Usage accumulator ─────────────────────────────────────────────── + +/** + * In-memory accumulator for token/cost usage reported by the container + * between alarm ticks. Drained into the snapshot on each tick. + */ +export type UsageAccumulator = { + inputTokens: number; + outputTokens: number; + costMicrodollars: number; + /** Latest LOC additions snapshot from container (absolute, not delta) */ + locAdditions: number; + /** Latest LOC deletions snapshot from container (absolute, not delta) */ + locDeletions: number; +}; + +export function createUsageAccumulator(): UsageAccumulator { + return { inputTokens: 0, outputTokens: 0, costMicrodollars: 0, locAdditions: 0, locDeletions: 0 }; +} + +export function addUsage(acc: UsageAccumulator, input: number, output: number, cost: number): void { + acc.inputTokens += input; + acc.outputTokens += output; + acc.costMicrodollars += cost; +} + +/** Update the LOC snapshot (replaces, not accumulates — these are absolute totals) */ +export function setLoc(acc: UsageAccumulator, additions: number, deletions: number): void { + acc.locAdditions = additions; + acc.locDeletions = deletions; +} + +export function drainUsage(acc: UsageAccumulator): UsageAccumulator { + const drained = { ...acc }; + acc.inputTokens = 0; + acc.outputTokens = 0; + acc.costMicrodollars = 0; + // LOC values are NOT drained — they persist as the latest snapshot + return drained; +} + +// ── Snapshot collection ───────────────────────────────────────────── + +/** + * Collect a metrics snapshot from current DO state. + * Called at the end of each alarm tick. + */ +export function collectSnapshot( + sql: SqlStorage, + lastSnapshotAt: string | null, + usage: UsageAccumulator +): void { + const capturedAt = new Date().toISOString(); + const snapshotId = crypto.randomUUID(); + + // Agent counts by status + const agentRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${agent_metadata.status} AS status, COUNT(*) AS cnt + FROM ${agent_metadata} + GROUP BY ${agent_metadata.status} + `, + [] + ), + ]; + let agentsWorking = 0; + let agentsIdle = 0; + let agentsTotal = 0; + for (const row of agentRows) { + const s = `${row.status as string}`; + const c = Number(row.cnt); + if (s === 'working') agentsWorking = c; + else if (s === 'idle') agentsIdle = c; + agentsTotal += c; + } + + // Bead status counts (exclude agent and message types) + const beadRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${beads.status} AS status, COUNT(*) AS cnt + FROM ${beads} + WHERE ${beads.type} NOT IN ('agent', 'message') + GROUP BY ${beads.status} + `, + [] + ), + ]; + let beadsOpen = 0; + let beadsInProgress = 0; + let beadsInReview = 0; + for (const row of beadRows) { + const s = `${row.status as string}`; + const c = Number(row.cnt); + if (s === 'open') beadsOpen = c; + else if (s === 'in_progress') beadsInProgress = c; + else if (s === 'in_review') beadsInReview = c; + } + + // Delta counts since last snapshot + const sinceFilter = lastSnapshotAt ?? '1970-01-01T00:00:00.000Z'; + + const eventsSinceRow = [ + ...query( + sql, + /* sql */ ` + SELECT COUNT(*) AS cnt + FROM ${bead_events} + WHERE ${bead_events.created_at} > ? + `, + [sinceFilter] + ), + ]; + const eventsSinceLast = Number(eventsSinceRow[0]?.cnt ?? 0); + + const createdSinceRow = [ + ...query( + sql, + /* sql */ ` + SELECT COUNT(*) AS cnt + FROM ${bead_events} + WHERE ${bead_events.event_type} = 'created' + AND ${bead_events.created_at} > ? + `, + [sinceFilter] + ), + ]; + const beadsCreatedSinceLast = Number(createdSinceRow[0]?.cnt ?? 0); + + const closedSinceRow = [ + ...query( + sql, + /* sql */ ` + SELECT COUNT(*) AS cnt + FROM ${bead_events} + WHERE ${bead_events.event_type} = 'closed' + AND ${bead_events.created_at} > ? + `, + [sinceFilter] + ), + ]; + const beadsClosedSinceLast = Number(closedSinceRow[0]?.cnt ?? 0); + + // Drain accumulated usage + const drained = drainUsage(usage); + + query( + sql, + /* sql */ ` + INSERT INTO ${metrics_snapshots} ( + ${metrics_snapshots.columns.snapshot_id}, + ${metrics_snapshots.columns.captured_at}, + ${metrics_snapshots.columns.agents_working}, + ${metrics_snapshots.columns.agents_idle}, + ${metrics_snapshots.columns.agents_total}, + ${metrics_snapshots.columns.beads_open}, + ${metrics_snapshots.columns.beads_in_progress}, + ${metrics_snapshots.columns.beads_in_review}, + ${metrics_snapshots.columns.events_since_last}, + ${metrics_snapshots.columns.beads_created_since_last}, + ${metrics_snapshots.columns.beads_closed_since_last}, + ${metrics_snapshots.columns.input_tokens_since_last}, + ${metrics_snapshots.columns.output_tokens_since_last}, + ${metrics_snapshots.columns.cost_microdollars_since_last}, + ${metrics_snapshots.columns.loc_additions}, + ${metrics_snapshots.columns.loc_deletions} + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + snapshotId, + capturedAt, + agentsWorking, + agentsIdle, + agentsTotal, + beadsOpen, + beadsInProgress, + beadsInReview, + eventsSinceLast, + beadsCreatedSinceLast, + beadsClosedSinceLast, + drained.inputTokens, + drained.outputTokens, + drained.costMicrodollars, + usage.locAdditions, + usage.locDeletions, + ] + ); + + // Prune old snapshots — keep 7 days of data + query( + sql, + /* sql */ ` + DELETE FROM ${metrics_snapshots} + WHERE ${metrics_snapshots.captured_at} < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days') + `, + [] + ); +} + +// ── Timeseries queries ────────────────────────────────────────────── + +export const TimeseriesWindow = z.enum(['1h', '6h', '24h', '7d']); +export type TimeseriesWindow = z.infer; + +function windowToSqlInterval(window: TimeseriesWindow): string { + switch (window) { + case '1h': + return '-1 hour'; + case '6h': + return '-6 hours'; + case '24h': + return '-1 day'; + case '7d': + return '-7 days'; + } +} + +function windowToBucketSeconds(window: TimeseriesWindow): number { + switch (window) { + case '1h': + return 5; // raw 5s snapshots + case '6h': + return 60; // 1-minute buckets + case '24h': + return 300; // 5-minute buckets + case '7d': + return 3600; // 1-hour buckets + } +} + +export const TimeseriesPointRecord = z.object({ + bucket: z.string(), + agents_working: z.coerce.number(), + agents_idle: z.coerce.number(), + agents_total: z.coerce.number(), + beads_open: z.coerce.number(), + beads_in_progress: z.coerce.number(), + beads_in_review: z.coerce.number(), + events_count: z.coerce.number(), + beads_created: z.coerce.number(), + beads_closed: z.coerce.number(), + input_tokens: z.coerce.number(), + output_tokens: z.coerce.number(), + cost_microdollars: z.coerce.number(), + loc_additions: z.coerce.number(), + loc_deletions: z.coerce.number(), +}); + +export type TimeseriesPointRecord = z.output; + +/** + * Query timeseries data for a given window, bucketed for chart display. + * Gauge-type metrics (agent counts, bead counts) use AVG within each bucket. + * Counter-type metrics (events, created, closed, tokens, cost) use SUM. + */ +export function queryTimeseries( + sql: SqlStorage, + window: TimeseriesWindow +): TimeseriesPointRecord[] { + const interval = windowToSqlInterval(window); + const bucketSec = windowToBucketSeconds(window); + + const rows = [ + ...query( + sql, + /* sql */ ` + SELECT + strftime('%Y-%m-%dT%H:%M:%SZ', + (CAST(strftime('%s', ${metrics_snapshots.captured_at}) AS INTEGER) / ? * ?), + 'unixepoch' + ) AS bucket, + CAST(AVG(${metrics_snapshots.agents_working}) AS INTEGER) AS agents_working, + CAST(AVG(${metrics_snapshots.agents_idle}) AS INTEGER) AS agents_idle, + CAST(AVG(${metrics_snapshots.agents_total}) AS INTEGER) AS agents_total, + CAST(AVG(${metrics_snapshots.beads_open}) AS INTEGER) AS beads_open, + CAST(AVG(${metrics_snapshots.beads_in_progress}) AS INTEGER) AS beads_in_progress, + CAST(AVG(${metrics_snapshots.beads_in_review}) AS INTEGER) AS beads_in_review, + SUM(${metrics_snapshots.events_since_last}) AS events_count, + SUM(${metrics_snapshots.beads_created_since_last}) AS beads_created, + SUM(${metrics_snapshots.beads_closed_since_last}) AS beads_closed, + SUM(${metrics_snapshots.input_tokens_since_last}) AS input_tokens, + SUM(${metrics_snapshots.output_tokens_since_last}) AS output_tokens, + SUM(${metrics_snapshots.cost_microdollars_since_last}) AS cost_microdollars, + CAST(AVG(${metrics_snapshots.loc_additions}) AS INTEGER) AS loc_additions, + CAST(AVG(${metrics_snapshots.loc_deletions}) AS INTEGER) AS loc_deletions + FROM ${metrics_snapshots} + WHERE ${metrics_snapshots.captured_at} >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) + GROUP BY bucket + ORDER BY bucket ASC + `, + [bucketSec, bucketSec, interval] + ), + ]; + + return TimeseriesPointRecord.array().parse(rows); +} + +// ── Throughput computation ────────────────────────────────────────── + +export type ThroughputGauges = { + /** Tokens per second (rolling 5-minute average) */ + tokensPerSec: number; + /** Cost in microdollars per second (rolling 5-minute average) */ + costPerSec: number; + /** Total active (working) agents */ + activeAgents: number; + /** Total agents */ + totalAgents: number; + /** Current total LOC additions across all agents */ + locAdditions: number; + /** Current total LOC deletions across all agents */ + locDeletions: number; +}; + +/** + * Compute current throughput gauges from recent snapshots. + * Uses a 5-minute rolling window for rate metrics. + */ +export function computeThroughput(sql: SqlStorage): ThroughputGauges { + const rows = [ + ...query( + sql, + /* sql */ ` + SELECT + SUM(${metrics_snapshots.input_tokens_since_last} + ${metrics_snapshots.output_tokens_since_last}) AS total_tokens, + SUM(${metrics_snapshots.cost_microdollars_since_last}) AS total_cost, + MIN(${metrics_snapshots.captured_at}) AS earliest, + MAX(${metrics_snapshots.captured_at}) AS latest, + COUNT(*) AS num_snapshots + FROM ${metrics_snapshots} + WHERE ${metrics_snapshots.captured_at} >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes') + `, + [] + ), + ]; + + const row = rows[0]; + const totalTokens = Number(row?.total_tokens ?? 0); + const totalCost = Number(row?.total_cost ?? 0); + const earliest = row?.earliest ? String(row.earliest) : null; + const latest = row?.latest ? String(row.latest) : null; + + let windowSec = 300; // default to 5 minutes + if (earliest && latest) { + const diff = (new Date(latest).getTime() - new Date(earliest).getTime()) / 1000; + if (diff > 0) windowSec = diff; + } + + // Current agent counts (latest snapshot is most accurate) + const agentRow = [ + ...query( + sql, + /* sql */ ` + SELECT ${metrics_snapshots.agents_working}, ${metrics_snapshots.agents_total}, + ${metrics_snapshots.loc_additions}, ${metrics_snapshots.loc_deletions} + FROM ${metrics_snapshots} + ORDER BY ${metrics_snapshots.captured_at} DESC + LIMIT 1 + `, + [] + ), + ]; + + return { + tokensPerSec: windowSec > 0 ? Math.round((totalTokens / windowSec) * 10) / 10 : 0, + costPerSec: windowSec > 0 ? Math.round((totalCost / windowSec) * 10) / 10 : 0, + activeAgents: Number(agentRow[0]?.agents_working ?? 0), + totalAgents: Number(agentRow[0]?.agents_total ?? 0), + locAdditions: Number(agentRow[0]?.loc_additions ?? 0), + locDeletions: Number(agentRow[0]?.loc_deletions ?? 0), + }; +} diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 4e36f52fc..23d88c2dc 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -134,6 +134,11 @@ import { handleListEscalations, handleAcknowledgeEscalation, } from './handlers/town-escalations.handler'; +import { + handleReportUsage, + handleReportLoc, + handleGetMetricsTimeseries, +} from './handlers/town-metrics.handler'; export { GastownUserDO } from './dos/GastownUser.do'; export { GastownOrgDO } from './dos/GastownOrg.do'; @@ -553,6 +558,20 @@ app.patch('/api/towns/:townId/config', c => instrumented(c, 'PATCH /api/towns/:townId/config', () => handleUpdateTownConfig(c, c.req.param())) ); +// ── Town Metrics ──────────────────────────────────────────────────────── + +app.post('/api/towns/:townId/usage', c => + instrumented(c, 'POST /api/towns/:townId/usage', () => handleReportUsage(c, c.req.param())) +); +app.post('/api/towns/:townId/loc', c => + instrumented(c, 'POST /api/towns/:townId/loc', () => handleReportLoc(c, c.req.param())) +); +app.get('/api/towns/:townId/metrics/timeseries', c => + instrumented(c, 'GET /api/towns/:townId/metrics/timeseries', () => + handleGetMetricsTimeseries(c, c.req.param()) + ) +); + // ── Town Events ───────────────────────────────────────────────────────── app.use('/api/users/:userId/towns/:townId/events', async (c: Context, next) => diff --git a/cloudflare-gastown/src/handlers/town-metrics.handler.ts b/cloudflare-gastown/src/handlers/town-metrics.handler.ts new file mode 100644 index 000000000..2b7ef23a0 --- /dev/null +++ b/cloudflare-gastown/src/handlers/town-metrics.handler.ts @@ -0,0 +1,56 @@ +import type { Context } from 'hono'; +import { z } from 'zod'; +import { getTownDOStub } from '../dos/Town.do'; +import { resSuccess, resError } from '../util/res.util'; +import { parseJsonBody } from '../util/parse-json-body.util'; +import type { GastownEnv } from '../gastown.worker'; +import { TimeseriesWindow } from '../dos/town/metrics'; + +const ReportUsageBody = z.object({ + input_tokens: z.number().int().nonnegative(), + output_tokens: z.number().int().nonnegative(), + cost_microdollars: z.number().int().nonnegative(), +}); + +export async function handleReportUsage(c: Context, params: { townId: string }) { + const parsed = ReportUsageBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json(resError('Invalid request body'), 400); + } + const town = getTownDOStub(c.env, params.townId); + await town.reportUsage( + parsed.data.input_tokens, + parsed.data.output_tokens, + parsed.data.cost_microdollars + ); + return c.json(resSuccess({ ok: true })); +} + +const ReportLocBody = z.object({ + additions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), +}); + +export async function handleReportLoc(c: Context, params: { townId: string }) { + const parsed = ReportLocBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json(resError('Invalid request body'), 400); + } + const town = getTownDOStub(c.env, params.townId); + await town.reportLoc(parsed.data.additions, parsed.data.deletions); + return c.json(resSuccess({ ok: true })); +} + +export async function handleGetMetricsTimeseries( + c: Context, + params: { townId: string } +) { + const window = c.req.query('window') ?? '1h'; + const parsed = TimeseriesWindow.safeParse(window); + if (!parsed.success) { + return c.json(resError('Invalid window parameter. Must be 1h, 6h, 24h, or 7d'), 400); + } + const town = getTownDOStub(c.env, params.townId); + const data = await town.getMetricsTimeseries(parsed.data); + return c.json(resSuccess(data)); +} diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 45ee0260d..d45278546 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -32,6 +32,7 @@ import { RpcRigDetailOutput, RpcConvoyDetailOutput, RpcAlarmStatusOutput, + RpcMetricsTimeseriesOutput, RpcOrgTownOutput, } from './schemas'; import type { TRPCContext } from './init'; @@ -633,6 +634,16 @@ export const gastownRouter = router({ return townStub.getAlarmStatus(); }), + getMetricsTimeseries: gastownProcedure + .input(z.object({ townId: z.string().uuid(), window: z.enum(['1h', '6h', '24h', '7d']) })) + .output(RpcMetricsTimeseriesOutput) + .query(async ({ ctx, input }) => { + await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + const townStub = getTownDOStub(ctx.env, input.townId); + await townStub.setTownId(input.townId); + return townStub.getMetricsTimeseries(input.window); + }), + ensureMayor: gastownProcedure .input(z.object({ townId: z.string().uuid() })) .output(RpcMayorSendResultOutput) diff --git a/cloudflare-gastown/src/trpc/schemas.ts b/cloudflare-gastown/src/trpc/schemas.ts index 65067ec78..c2c8b6818 100644 --- a/cloudflare-gastown/src/trpc/schemas.ts +++ b/cloudflare-gastown/src/trpc/schemas.ts @@ -215,6 +215,14 @@ const AlarmStatusOutput = z.object({ stalledAgents: z.number(), orphanedHooks: z.number(), }), + throughput: z.object({ + tokensPerSec: z.number(), + costPerSec: z.number(), + activeAgents: z.number(), + totalAgents: z.number(), + locAdditions: z.number(), + locDeletions: z.number(), + }), recentEvents: z.array( z.object({ time: z.string(), @@ -224,6 +232,26 @@ const AlarmStatusOutput = z.object({ ), }); export const RpcAlarmStatusOutput = rpcSafe(AlarmStatusOutput); + +// Metrics timeseries +const MetricsTimeseriesPointOutput = z.object({ + bucket: z.string(), + agents_working: z.number(), + agents_idle: z.number(), + agents_total: z.number(), + beads_open: z.number(), + beads_in_progress: z.number(), + beads_in_review: z.number(), + events_count: z.number(), + beads_created: z.number(), + beads_closed: z.number(), + input_tokens: z.number(), + output_tokens: z.number(), + cost_microdollars: z.number(), + loc_additions: z.number(), + loc_deletions: z.number(), +}); +export const RpcMetricsTimeseriesOutput = rpcSafe(z.array(MetricsTimeseriesPointOutput)); export const RpcRigDetailOutput = rpcSafe(RigDetailOutput); // OrgTown (from GastownOrgDO) diff --git a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx index a9b80175b..7ceff8931 100644 --- a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx @@ -33,6 +33,7 @@ import { toast } from 'sonner'; import { formatDistanceToNow } from 'date-fns'; import { AreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { motion, AnimatePresence } from 'motion/react'; +import { ThroughputGauges } from '@/components/gastown/ThroughputGauges'; import type { GastownOutputs } from '@/lib/gastown/trpc'; type Agent = GastownOutputs['gastown']['listAgents'][number]; @@ -129,6 +130,33 @@ export function TownOverviewPageClient({ }) ); + // Alarm status for throughput gauges (5s polling mirrors alarm tick rate) + const alarmStatusQuery = useQuery({ + ...trpc.gastown.getAlarmStatus.queryOptions({ townId }), + refetchInterval: 5_000, + }); + const throughput = alarmStatusQuery.data?.throughput ?? null; + + // Gas tank balance (30s polling — balance changes slowly) + const balanceQuery = useQuery({ + queryKey: ['gastown', 'balance', organizationId], + queryFn: async () => { + const params = organizationId ? `?organizationId=${organizationId}` : ''; + const res = await fetch(`/api/gastown/balance${params}`); + if (!res.ok) return null; + const data: unknown = await res.json(); + if (data && typeof data === 'object' && 'balance' in data) { + return (data as { balance: number }).balance; + } + return null; + }, + refetchInterval: 30_000, + }); + const gasTank = + balanceQuery.data !== undefined + ? { balanceDollars: balanceQuery.data, costPerSec: throughput?.costPerSec ?? 0 } + : undefined; + const rigs = rigsQuery.data ?? []; const events = townEventsQuery.data ?? []; const convoys = convoysQuery.data ?? []; @@ -241,6 +269,9 @@ export function TownOverviewPageClient({
{/* Left column: activity feed */}
+ {/* Throughput gauges */} + + {/* Stats strip */}
; +type TimeWindow = '1h' | '6h' | '24h' | '7d'; + +const WINDOW_OPTIONS: Array<{ value: TimeWindow; label: string }> = [ + { value: '1h', label: '1h' }, + { value: '6h', label: '6h' }, + { value: '24h', label: '24h' }, + { value: '7d', label: '7d' }, +]; + +function formatBucketTime(bucket: string, window: TimeWindow): string { + const d = new Date(bucket); + switch (window) { + case '1h': + return format(d, 'HH:mm:ss'); + case '6h': + return format(d, 'HH:mm'); + case '24h': + return format(d, 'HH:mm'); + case '7d': + return format(d, 'MMM d HH:mm'); + } +} + +function WindowSelector({ + value, + onChange, +}: { + value: TimeWindow; + onChange: (w: TimeWindow) => void; +}) { + return ( +
+ {WINDOW_OPTIONS.map(opt => ( + + ))} +
+ ); +} export function ObservabilityPageClient({ townId }: { townId: string }) { const trpc = useGastownTRPC(); + const [window, setWindow] = useState('1h'); const eventsQuery = useQuery({ ...trpc.gastown.getTownEvents.queryOptions({ townId, limit: 500 }), refetchInterval: 5_000, }); + const metricsQuery = useQuery({ + ...trpc.gastown.getMetricsTimeseries.queryOptions({ townId, window }), + refetchInterval: window === '1h' ? 5_000 : 15_000, + }); + const events = eventsQuery.data ?? []; + const metricsData = metricsQuery.data ?? []; + + // Format timeseries data for charts + const chartData = useMemo( + () => + metricsData.map(p => ({ + ...p, + time: formatBucketTime(p.bucket, window), + total_tokens: p.input_tokens + p.output_tokens, + cost_dollars: p.cost_microdollars / 1_000_000, + total_loc: p.loc_additions + p.loc_deletions, + })), + [metricsData, window] + ); // Event type distribution const typeCounts = useMemo(() => { @@ -93,11 +170,222 @@ export function ObservabilityPageClient({ townId }: { townId: string }) {

Observability

{events.length} events
+
- {/* Event rate over time */} + {/* Token throughput timeseries */} + } + tooltipStyles={tooltipStyles} + > + + + + + + + + + + + + + + + + {/* Cost over time */} + } + tooltipStyles={tooltipStyles} + > + + + + + + + + + + `$${Number(v).toFixed(4)}`} + /> + [`$${Number(value).toFixed(6)}`, 'Cost']} + /> + + + + + {/* Agent utilization */} + } + tooltipStyles={tooltipStyles} + > + + + + + + + + + + + + + + + + + + + + + {/* Bead velocity */} + } + tooltipStyles={tooltipStyles} + > + + + + + + + + + + + {/* Lines of code changed */} + } + tooltipStyles={tooltipStyles} + > + + + + + + + + + + + + + + + + {/* Event rate over time (legacy, from bead_events) */}
@@ -239,6 +527,36 @@ export function ObservabilityPageClient({ townId }: { townId: string }) { ); } +// ── Shared chart card wrapper ─────────────────────────────────────── + +function TimeseriesCard({ + label, + icon, + tooltipStyles, + children, +}: { + label: string; + icon: React.ReactNode; + tooltipStyles: { contentStyle: React.CSSProperties }; + children: React.ReactNode; +}) { + return ( +
+
+ {icon} + + {label} + +
+
+ + {children} + +
+
+ ); +} + const EVENT_ICON_MAP: Record = { created: Hexagon, hooked: Bot, diff --git a/src/app/api/gastown/balance/route.ts b/src/app/api/gastown/balance/route.ts new file mode 100644 index 000000000..4a28eb310 --- /dev/null +++ b/src/app/api/gastown/balance/route.ts @@ -0,0 +1,33 @@ +import 'server-only'; +import { NextResponse } from 'next/server'; +import { getUserFromAuth } from '@/lib/user.server'; +import { getBalanceForUser } from '@/lib/user.balance'; +import { getBalanceForOrganizationUser } from '@/lib/organizations/organization-usage'; +import { isGastownEnabled } from '@/lib/gastown/feature-flags'; + +/** + * GET /api/gastown/balance?organizationId= + * + * Returns the user's dollar balance for the gas tank gauge. + * When organizationId is provided, returns the org-scoped balance. + * Authenticated via NextAuth session cookie (same-origin). + */ +export async function GET(request: Request) { + const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + if (authFailedResponse) return authFailedResponse; + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const hasAccess = await isGastownEnabled(user.id); + if (!hasAccess) { + return NextResponse.json({ error: 'Gastown access denied' }, { status: 403 }); + } + + const url = new URL(request.url); + const organizationId = url.searchParams.get('organizationId'); + + const { balance } = organizationId + ? await getBalanceForOrganizationUser(organizationId, user.id) + : await getBalanceForUser(user, { quiet: true }); + + return NextResponse.json({ balance }); +} diff --git a/src/components/gastown/TerminalBar.tsx b/src/components/gastown/TerminalBar.tsx index dd525af4b..b70d71863 100644 --- a/src/components/gastown/TerminalBar.tsx +++ b/src/components/gastown/TerminalBar.tsx @@ -241,6 +241,14 @@ type AlarmStatus = { stalledAgents: number; orphanedHooks: number; }; + throughput: { + tokensPerSec: number; + costPerSec: number; + activeAgents: number; + totalAgents: number; + locAdditions: number; + locDeletions: number; + }; recentEvents: Array<{ time: string; type: string; message: string }>; }; diff --git a/src/components/gastown/ThroughputGauges.tsx b/src/components/gastown/ThroughputGauges.tsx new file mode 100644 index 000000000..56440df26 --- /dev/null +++ b/src/components/gastown/ThroughputGauges.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useMemo } from 'react'; +import { motion } from 'motion/react'; +import { Zap, DollarSign, Bot, Fuel, FileCode } from 'lucide-react'; + +// ── Types ──────────────────────────────────────────────────────────── + +type ThroughputData = { + tokensPerSec: number; + costPerSec: number; + activeAgents: number; + totalAgents: number; + locAdditions: number; + locDeletions: number; +}; + +type GasTankData = { + balanceDollars: number | null; + costPerSec: number; +}; + +type ThroughputGaugesProps = { + throughput: ThroughputData | null; + gasTank?: GasTankData; +}; + +// ── Radial Gauge ───────────────────────────────────────────────────── + +function RadialGauge({ + value, + max, + label, + unit, + icon, + color, + formatValue, +}: { + value: number; + max: number; + label: string; + unit: string; + icon: React.ReactNode; + color: string; + formatValue?: (v: number) => string; +}) { + const ratio = max > 0 ? Math.min(value / max, 1) : 0; + // Semi-circle arc: 180 degrees + const r = 32; + const cx = 40; + const cy = 40; + const circumference = Math.PI * r; // half circle + const dashOffset = circumference * (1 - ratio); + + const display = formatValue ? formatValue(value) : value.toFixed(1); + + return ( +
+
+ {icon} + {label} +
+
+ + {/* Background arc */} + + {/* Value arc */} + + +
+ + {display} + +
+
+ {unit} +
+ ); +} + +// ── Gas Tank Gauge ────────────────────────────────────────────────── + +function GasTankGauge({ balanceDollars, costPerSec }: GasTankData) { + const fill = balanceDollars ?? 0; + // Normalize: $100 = full. Adjust as needed. + const maxBalance = 100; + const ratio = Math.min(Math.max(fill / maxBalance, 0), 1); + + // Color transitions: green > yellow > red + const gaugeColor = + ratio > 0.5 ? 'text-emerald-400' : ratio > 0.2 ? 'text-amber-400' : 'text-red-400'; + + const bgColor = + ratio > 0.5 ? 'bg-emerald-400/20' : ratio > 0.2 ? 'bg-amber-400/20' : 'bg-red-400/20'; + + const fillColor = ratio > 0.5 ? 'bg-emerald-400' : ratio > 0.2 ? 'bg-amber-400' : 'bg-red-400'; + + // Time remaining estimate using rolling cost rate + const timeRemaining = useMemo(() => { + if (costPerSec <= 0 || balanceDollars === null || balanceDollars <= 0) return null; + const costDollarsPerSec = costPerSec / 1_000_000; // microdollars to dollars + const secondsRemaining = balanceDollars / costDollarsPerSec; + if (secondsRemaining > 7 * 24 * 3600) return '>7d'; + if (secondsRemaining > 24 * 3600) return `~${Math.round(secondsRemaining / 3600)}h`; + if (secondsRemaining > 3600) return `~${(secondsRemaining / 3600).toFixed(1)}h`; + if (secondsRemaining > 60) return `~${Math.round(secondsRemaining / 60)}m`; + return '<1m'; + }, [costPerSec, balanceDollars]); + + return ( +
+
+ + Gas Tank +
+ {/* Vertical fuel gauge */} +
+
+
+
+ + {balanceDollars !== null ? `$${balanceDollars.toFixed(2)}` : '---'} + + {timeRemaining &&
{timeRemaining} at pace
} +
+
+ ); +} + +// ── Main Component ────────────────────────────────────────────────── + +export function ThroughputGauges({ throughput, gasTank }: ThroughputGaugesProps) { + const t = throughput ?? { + tokensPerSec: 0, + costPerSec: 0, + activeAgents: 0, + totalAgents: 0, + locAdditions: 0, + locDeletions: 0, + }; + const totalLoc = t.locAdditions + t.locDeletions; + + return ( +
+ } + color="text-sky-400" + /> +
+ } + color="text-amber-400" + formatValue={v => (v < 0.001 ? v.toFixed(4) : v.toFixed(3))} + /> +
+ } + color="text-violet-400" + formatValue={v => `${v}`} + /> +
+ } + color="text-emerald-400" + formatValue={v => `${v}`} + /> + {gasTank && ( + <> +
+ + + )} +
+ ); +} diff --git a/src/lib/gastown/types/router.d.ts b/src/lib/gastown/types/router.d.ts index 3f616b1f9..565a1322c 100644 --- a/src/lib/gastown/types/router.d.ts +++ b/src/lib/gastown/types/router.d.ts @@ -365,6 +365,14 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< stalledAgents: number; orphanedHooks: number; }; + throughput: { + tokensPerSec: number; + costPerSec: number; + activeAgents: number; + totalAgents: number; + locAdditions: number; + locDeletions: number; + }; recentEvents: { time: string; type: string; @@ -373,6 +381,30 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< }; meta: object; }>; + getMetricsTimeseries: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + window: '1h' | '6h' | '24h' | '7d'; + }; + output: { + bucket: string; + agents_working: number; + agents_idle: number; + agents_total: number; + beads_open: number; + beads_in_progress: number; + beads_in_review: number; + events_count: number; + beads_created: number; + beads_closed: number; + input_tokens: number; + output_tokens: number; + cost_microdollars: number; + loc_additions: number; + loc_deletions: number; + }[]; + meta: object; + }>; ensureMayor: import('@trpc/server').TRPCMutationProcedure<{ input: { townId: string; @@ -872,6 +904,14 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< stalledAgents: number; orphanedHooks: number; }; + throughput: { + tokensPerSec: number; + costPerSec: number; + activeAgents: number; + totalAgents: number; + locAdditions: number; + locDeletions: number; + }; recentEvents: { time: string; type: string; @@ -1390,6 +1430,14 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute stalledAgents: number; orphanedHooks: number; }; + throughput: { + tokensPerSec: number; + costPerSec: number; + activeAgents: number; + totalAgents: number; + locAdditions: number; + locDeletions: number; + }; recentEvents: { time: string; type: string; @@ -1398,6 +1446,30 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute }; meta: object; }>; + getMetricsTimeseries: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + window: '1h' | '6h' | '24h' | '7d'; + }; + output: { + bucket: string; + agents_working: number; + agents_idle: number; + agents_total: number; + beads_open: number; + beads_in_progress: number; + beads_in_review: number; + events_count: number; + beads_created: number; + beads_closed: number; + input_tokens: number; + output_tokens: number; + cost_microdollars: number; + loc_additions: number; + loc_deletions: number; + }[]; + meta: object; + }>; ensureMayor: import('@trpc/server').TRPCMutationProcedure<{ input: { townId: string; @@ -1897,6 +1969,14 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute stalledAgents: number; orphanedHooks: number; }; + throughput: { + tokensPerSec: number; + costPerSec: number; + activeAgents: number; + totalAgents: number; + locAdditions: number; + locDeletions: number; + }; recentEvents: { time: string; type: string; diff --git a/src/lib/gastown/types/schemas.d.ts b/src/lib/gastown/types/schemas.d.ts index 98f3440b2..47caa6e0d 100644 --- a/src/lib/gastown/types/schemas.d.ts +++ b/src/lib/gastown/types/schemas.d.ts @@ -781,6 +781,17 @@ export declare const RpcAlarmStatusOutput: z.ZodPipe< }, z.core.$strip >; + throughput: z.ZodObject< + { + tokensPerSec: z.ZodNumber; + costPerSec: z.ZodNumber; + activeAgents: z.ZodNumber; + totalAgents: z.ZodNumber; + locAdditions: z.ZodNumber; + locDeletions: z.ZodNumber; + }, + z.core.$strip + >; recentEvents: z.ZodArray< z.ZodObject< { @@ -795,6 +806,31 @@ export declare const RpcAlarmStatusOutput: z.ZodPipe< z.core.$strip > >; +export declare const RpcMetricsTimeseriesOutput: z.ZodPipe< + z.ZodAny, + z.ZodArray< + z.ZodObject< + { + bucket: z.ZodString; + agents_working: z.ZodNumber; + agents_idle: z.ZodNumber; + agents_total: z.ZodNumber; + beads_open: z.ZodNumber; + beads_in_progress: z.ZodNumber; + beads_in_review: z.ZodNumber; + events_count: z.ZodNumber; + beads_created: z.ZodNumber; + beads_closed: z.ZodNumber; + input_tokens: z.ZodNumber; + output_tokens: z.ZodNumber; + cost_microdollars: z.ZodNumber; + loc_additions: z.ZodNumber; + loc_deletions: z.ZodNumber; + }, + z.core.$strip + > + > +>; export declare const RpcRigDetailOutput: z.ZodPipe< z.ZodAny, z.ZodObject<