feat(runtimes): add Runtimes tab with usage tracking and connection test
Add a new "Runtimes" sidebar tab to manage local agent runtimes with three main capabilities: runtime status overview, token usage tracking (reading Claude Code and Codex CLI local JSONL logs via daemon), and an interactive connection test that sends a ping through the daemon to verify end-to-end agent CLI connectivity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6fd0e2b319
commit
903fbee55d
24 changed files with 1773 additions and 9 deletions
|
|
@ -132,7 +132,15 @@ func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
slog.Debug("daemon heartbeat", "runtime_id", req.RuntimeID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
|
||||
resp := map[string]any{"status": "ok"}
|
||||
|
||||
// Check for pending ping requests for this runtime.
|
||||
if pending := h.PingStore.PopPending(req.RuntimeID); pending != nil {
|
||||
resp["pending_ping"] = map[string]string{"id": pending.ID}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ClaimTaskByRuntime atomically claims the next queued task for a runtime.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ type Handler struct {
|
|||
Bus *events.Bus
|
||||
TaskService *service.TaskService
|
||||
EmailService *service.EmailService
|
||||
PingStore *PingStore
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService) *Handler {
|
||||
|
|
@ -50,6 +51,7 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
|
|||
Bus: bus,
|
||||
TaskService: service.NewTaskService(queries, hub, bus),
|
||||
EmailService: emailService,
|
||||
PingStore: NewPingStore(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ package handler
|
|||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
|
|
@ -47,6 +51,114 @@ func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime Usage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RuntimeUsageEntry struct {
|
||||
Date string `json:"date"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
||||
}
|
||||
|
||||
type RuntimeUsageResponse struct {
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Date string `json:"date"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
||||
}
|
||||
|
||||
// ReportRuntimeUsage receives usage data from the daemon (unauthenticated daemon route).
|
||||
func (h *Handler) ReportRuntimeUsage(w http.ResponseWriter, r *http.Request) {
|
||||
runtimeID := chi.URLParam(r, "runtimeId")
|
||||
if runtimeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "runtimeId is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Entries []RuntimeUsageEntry `json:"entries"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range req.Entries {
|
||||
date, err := time.Parse("2006-01-02", entry.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
h.Queries.UpsertRuntimeUsage(r.Context(), db.UpsertRuntimeUsageParams{
|
||||
RuntimeID: parseUUID(runtimeID),
|
||||
Date: pgtype.Date{Time: date, Valid: true},
|
||||
Provider: entry.Provider,
|
||||
Model: entry.Model,
|
||||
InputTokens: entry.InputTokens,
|
||||
OutputTokens: entry.OutputTokens,
|
||||
CacheReadTokens: entry.CacheReadTokens,
|
||||
CacheWriteTokens: entry.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// GetRuntimeUsage returns usage data for a runtime (protected route).
|
||||
func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
|
||||
runtimeID := chi.URLParam(r, "runtimeId")
|
||||
|
||||
rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "runtime not found")
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(rt.WorkspaceID), "runtime not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
limit := int32(90)
|
||||
if l := r.URL.Query().Get("days"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 365 {
|
||||
limit = int32(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := h.Queries.ListRuntimeUsage(r.Context(), db.ListRuntimeUsageParams{
|
||||
RuntimeID: parseUUID(runtimeID),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list usage")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]RuntimeUsageResponse, len(rows))
|
||||
for i, row := range rows {
|
||||
resp[i] = RuntimeUsageResponse{
|
||||
RuntimeID: runtimeID,
|
||||
Date: row.Date.Time.Format("2006-01-02"),
|
||||
Provider: row.Provider,
|
||||
Model: row.Model,
|
||||
InputTokens: row.InputTokens,
|
||||
OutputTokens: row.OutputTokens,
|
||||
CacheReadTokens: row.CacheReadTokens,
|
||||
CacheWriteTokens: row.CacheWriteTokens,
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
|
|
|
|||
200
server/internal/handler/runtime_ping.go
Normal file
200
server/internal/handler/runtime_ping.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory ping store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PingStatus represents the lifecycle of a runtime ping test.
|
||||
type PingStatus string
|
||||
|
||||
const (
|
||||
PingPending PingStatus = "pending"
|
||||
PingRunning PingStatus = "running"
|
||||
PingCompleted PingStatus = "completed"
|
||||
PingFailed PingStatus = "failed"
|
||||
PingTimeout PingStatus = "timeout"
|
||||
)
|
||||
|
||||
// PingRequest represents a pending or completed ping test.
|
||||
type PingRequest struct {
|
||||
ID string `json:"id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Status PingStatus `json:"status"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PingStore is a thread-safe in-memory store for ping requests.
|
||||
// Pings expire after 2 minutes.
|
||||
type PingStore struct {
|
||||
mu sync.Mutex
|
||||
pings map[string]*PingRequest // keyed by ping ID
|
||||
}
|
||||
|
||||
func NewPingStore() *PingStore {
|
||||
return &PingStore{
|
||||
pings: make(map[string]*PingRequest),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PingStore) Create(runtimeID string) *PingRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Clean up old pings for this runtime
|
||||
for id, p := range s.pings {
|
||||
if time.Since(p.CreatedAt) > 2*time.Minute {
|
||||
delete(s.pings, id)
|
||||
}
|
||||
}
|
||||
|
||||
ping := &PingRequest{
|
||||
ID: randomID(),
|
||||
RuntimeID: runtimeID,
|
||||
Status: PingPending,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
s.pings[ping.ID] = ping
|
||||
return ping
|
||||
}
|
||||
|
||||
func (s *PingStore) Get(id string) *PingRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
p, ok := s.pings[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// Check for timeout
|
||||
if p.Status == PingPending && time.Since(p.CreatedAt) > 60*time.Second {
|
||||
p.Status = PingTimeout
|
||||
p.Error = "daemon did not respond within 60 seconds"
|
||||
p.UpdatedAt = time.Now()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// PopPending returns and removes the oldest pending ping for a runtime.
|
||||
func (s *PingStore) PopPending(runtimeID string) *PingRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var oldest *PingRequest
|
||||
for _, p := range s.pings {
|
||||
if p.RuntimeID == runtimeID && p.Status == PingPending {
|
||||
if oldest == nil || p.CreatedAt.Before(oldest.CreatedAt) {
|
||||
oldest = p
|
||||
}
|
||||
}
|
||||
}
|
||||
if oldest != nil {
|
||||
oldest.Status = PingRunning
|
||||
oldest.UpdatedAt = time.Now()
|
||||
}
|
||||
return oldest
|
||||
}
|
||||
|
||||
func (s *PingStore) Complete(id string, output string, durationMs int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if p, ok := s.pings[id]; ok {
|
||||
p.Status = PingCompleted
|
||||
p.Output = output
|
||||
p.DurationMs = durationMs
|
||||
p.UpdatedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PingStore) Fail(id string, errMsg string, durationMs int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if p, ok := s.pings[id]; ok {
|
||||
p.Status = PingFailed
|
||||
p.Error = errMsg
|
||||
p.DurationMs = durationMs
|
||||
p.UpdatedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// InitiatePing creates a new ping request (protected route, called by frontend).
|
||||
func (h *Handler) InitiatePing(w http.ResponseWriter, r *http.Request) {
|
||||
runtimeID := chi.URLParam(r, "runtimeId")
|
||||
|
||||
rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "runtime not found")
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(rt.WorkspaceID), "runtime not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ping := h.PingStore.Create(runtimeID)
|
||||
writeJSON(w, http.StatusOK, ping)
|
||||
}
|
||||
|
||||
// GetPing returns the status of a ping request (protected route, called by frontend).
|
||||
func (h *Handler) GetPing(w http.ResponseWriter, r *http.Request) {
|
||||
pingID := chi.URLParam(r, "pingId")
|
||||
|
||||
ping := h.PingStore.Get(pingID)
|
||||
if ping == nil {
|
||||
writeError(w, http.StatusNotFound, "ping not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, ping)
|
||||
}
|
||||
|
||||
// ReportPingResult receives the ping result from the daemon.
|
||||
func (h *Handler) ReportPingResult(w http.ResponseWriter, r *http.Request) {
|
||||
pingID := chi.URLParam(r, "pingId")
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"` // "completed" or "failed"
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "completed" {
|
||||
h.PingStore.Complete(pingID, req.Output, req.DurationMs)
|
||||
} else {
|
||||
h.PingStore.Fail(pingID, req.Error, req.DurationMs)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue