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>
200 lines
4.9 KiB
Go
200 lines
4.9 KiB
Go
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"})
|
|
}
|