summaryrefslogtreecommitdiff
path: root/internal/handlers/agent.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-28 22:18:40 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-28 22:18:40 -1000
commit058ff7d699f088edb851336928dd3eea2934cc07 (patch)
tree54f2925e1ca071b8840ce07372a1dc4d7ebedf16 /internal/handlers/agent.go
parent994b92f6c6ce204675b9e20ff1e9b4a3bfa39bea (diff)
Refactor agent handlers for simplicity and clarity
- Reuse BuildTimeline() from timeline_logic.go instead of duplicating fetch logic (~60 lines removed) - Add section headers for code organization - Extract isSessionExpired() and renderAgentTemplate() helpers - Move AgentRequestPayload from websocket.go to agent.go - Use config.Now() and config.Today() for consistent timezone handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers/agent.go')
-rw-r--r--internal/handlers/agent.go560
1 files changed, 560 insertions, 0 deletions
diff --git a/internal/handlers/agent.go b/internal/handlers/agent.go
new file mode 100644
index 0000000..6f47524
--- /dev/null
+++ b/internal/handlers/agent.go
@@ -0,0 +1,560 @@
+package handlers
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "time"
+
+ "task-dashboard/internal/config"
+ "task-dashboard/internal/models"
+)
+
+// -----------------------------------------------------------------------------
+// Constants
+// -----------------------------------------------------------------------------
+
+const (
+ AgentRequestExpiry = 5 * time.Minute
+ AgentSessionTTL = 1 * time.Hour
+ TokenBytes = 32
+)
+
+// Context key for agent session in request context
+type contextKey string
+
+const agentSessionContextKey contextKey = "agent_session"
+
+// -----------------------------------------------------------------------------
+// Types
+// -----------------------------------------------------------------------------
+
+// AgentRequestPayload is sent via WebSocket when an agent requests access
+type AgentRequestPayload struct {
+ RequestToken string `json:"request_token"`
+ AgentName string `json:"agent_name"`
+ AgentID string `json:"agent_id"`
+ TrustLevel models.AgentTrustLevel `json:"trust_level"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+// agentContextItem is the JSON-serializable timeline item for agent context API
+type agentContextItem struct {
+ ID string `json:"id"`
+ Source string `json:"source"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Description string `json:"description,omitempty"`
+ Due *time.Time `json:"due,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ Completable bool `json:"completable"`
+ URL string `json:"url,omitempty"`
+}
+
+// -----------------------------------------------------------------------------
+// Helpers
+// -----------------------------------------------------------------------------
+
+// generateToken creates a cryptographically random token
+func generateToken() (string, error) {
+ b := make([]byte, TokenBytes)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.URLEncoding.EncodeToString(b), nil
+}
+
+// isSessionExpired checks if a pending session has expired
+func isSessionExpired(session *models.AgentSession) bool {
+ return time.Now().After(session.ExpiresAt) && session.Status == "pending"
+}
+
+// timelineItemToAgentItem converts a TimelineItem to the agent API format
+func timelineItemToAgentItem(item models.TimelineItem) agentContextItem {
+ t := item.Time
+ return agentContextItem{
+ ID: item.ID,
+ Source: item.Source,
+ Type: string(item.Type),
+ Title: item.Title,
+ Description: item.Description,
+ Due: &t,
+ Completable: item.Type == models.TimelineItemTypeTask || item.Type == models.TimelineItemTypeCard || item.Type == models.TimelineItemTypeGTask,
+ URL: item.URL,
+ }
+}
+
+// renderAgentTemplate renders an agent template with common error handling
+func (h *Handler) renderAgentTemplate(w http.ResponseWriter, templateName string, data interface{}) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if h.templates == nil {
+ h.renderAgentError(w, "Templates not loaded", http.StatusInternalServerError)
+ return
+ }
+ if err := h.templates.ExecuteTemplate(w, templateName, data); err != nil {
+ h.renderAgentError(w, "Template error", http.StatusInternalServerError)
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Auth Handlers
+// -----------------------------------------------------------------------------
+
+// HandleAgentAuthRequest handles POST /agent/auth/request
+func (h *Handler) HandleAgentAuthRequest(w http.ResponseWriter, r *http.Request) {
+ var req models.AgentAuthRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Name == "" || req.AgentID == "" {
+ http.Error(w, "name and agent_id are required", http.StatusBadRequest)
+ return
+ }
+
+ // Invalidate any previous sessions for this agent
+ if err := h.store.InvalidatePreviousAgentSessions(req.AgentID); err != nil {
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ // Generate request token
+ requestToken, err := generateToken()
+ if err != nil {
+ http.Error(w, "Failed to generate token", http.StatusInternalServerError)
+ return
+ }
+
+ // Create pending session
+ session := &models.AgentSession{
+ RequestToken: requestToken,
+ AgentName: req.Name,
+ AgentID: req.AgentID,
+ ExpiresAt: time.Now().Add(AgentRequestExpiry),
+ }
+ if err := h.store.CreateAgentSession(session); err != nil {
+ http.Error(w, "Failed to create session", http.StatusInternalServerError)
+ return
+ }
+
+ // Check trust level for WebSocket notification
+ trustLevel, err := h.store.CheckAgentTrust(req.Name, req.AgentID)
+ if err != nil {
+ trustLevel = models.AgentTrustNew
+ }
+
+ // Broadcast to connected browsers via WebSocket
+ h.BroadcastAgentRequest(session, trustLevel)
+
+ // Return response
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(models.AgentAuthResponse{
+ RequestToken: requestToken,
+ Status: "pending",
+ })
+}
+
+// HandleAgentAuthPoll handles GET /agent/auth/poll
+func (h *Handler) HandleAgentAuthPoll(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if token == "" {
+ http.Error(w, "token parameter required", http.StatusBadRequest)
+ return
+ }
+
+ session, err := h.store.GetAgentSessionByRequestToken(token)
+ if err != nil {
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ if session == nil {
+ http.Error(w, "Session not found", http.StatusNotFound)
+ return
+ }
+
+ if isSessionExpired(session) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(models.AgentPollResponse{Status: "expired"})
+ return
+ }
+
+ resp := models.AgentPollResponse{Status: session.Status}
+
+ if session.Status == "approved" && session.SessionToken != "" {
+ resp.SessionToken = session.SessionToken
+ resp.ExpiresAt = session.SessionExpiresAt
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+}
+
+// HandleAgentAuthApprove handles POST /agent/auth/approve (browser auth required)
+func (h *Handler) HandleAgentAuthApprove(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ RequestToken string `json:"request_token"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.RequestToken == "" {
+ http.Error(w, "request_token required", http.StatusBadRequest)
+ return
+ }
+
+ // Verify session exists and is pending
+ session, err := h.store.GetAgentSessionByRequestToken(req.RequestToken)
+ if err != nil {
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ if session == nil {
+ http.Error(w, "Session not found", http.StatusNotFound)
+ return
+ }
+ if session.Status != "pending" {
+ http.Error(w, "Session already processed", http.StatusConflict)
+ return
+ }
+ if time.Now().After(session.ExpiresAt) {
+ http.Error(w, "Session expired", http.StatusGone)
+ return
+ }
+
+ // Generate session token
+ sessionToken, err := generateToken()
+ if err != nil {
+ http.Error(w, "Failed to generate session token", http.StatusInternalServerError)
+ return
+ }
+
+ sessionExpiresAt := time.Now().Add(AgentSessionTTL)
+
+ // Approve the session
+ if err := h.store.ApproveAgentSession(req.RequestToken, sessionToken, sessionExpiresAt); err != nil {
+ http.Error(w, "Failed to approve session", http.StatusInternalServerError)
+ return
+ }
+
+ // Register/update agent in the trusted agents table
+ if err := h.store.CreateOrUpdateAgent(session.AgentName, session.AgentID); err != nil {
+ // Log but don't fail - the session was approved
+ // This just affects future trust level checks
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "approved"})
+}
+
+// HandleAgentAuthDeny handles POST /agent/auth/deny (browser auth required)
+func (h *Handler) HandleAgentAuthDeny(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ RequestToken string `json:"request_token"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.RequestToken == "" {
+ http.Error(w, "request_token required", http.StatusBadRequest)
+ return
+ }
+
+ if err := h.store.DenyAgentSession(req.RequestToken); err != nil {
+ http.Error(w, "Failed to deny session", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "denied"})
+}
+
+// -----------------------------------------------------------------------------
+// Context Handlers
+// -----------------------------------------------------------------------------
+
+// HandleAgentContext handles GET /agent/context (agent auth required)
+func (h *Handler) HandleAgentContext(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value(agentSessionContextKey).(*models.AgentSession)
+ _ = h.store.UpdateAgentLastSeen(session.AgentID)
+
+ now := config.Now()
+ startDate := config.Today()
+ endDate := startDate.Add(7 * 24 * time.Hour)
+
+ timeline := h.buildAgentContext(r.Context(), startDate, endDate)
+
+ resp := map[string]interface{}{
+ "generated_at": now.Format(time.RFC3339),
+ "range": map[string]string{
+ "start": startDate.Format("2006-01-02"),
+ "end": endDate.Format("2006-01-02"),
+ },
+ "timeline": timeline,
+ "summary": h.buildContextSummary(timeline, startDate),
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+}
+
+// buildAgentContext builds the context timeline by reusing BuildTimeline
+func (h *Handler) buildAgentContext(ctx context.Context, start, end time.Time) []agentContextItem {
+ // Reuse the main BuildTimeline function (excludes live API calls for Google services)
+ timelineItems, err := BuildTimeline(ctx, h.store, nil, nil, start, end)
+ if err != nil {
+ return nil
+ }
+
+ // Convert to agent API format, filtering completed items
+ var items []agentContextItem
+ for _, item := range timelineItems {
+ if item.IsCompleted {
+ continue
+ }
+ items = append(items, timelineItemToAgentItem(item))
+ }
+ return items
+}
+
+// buildContextSummary builds summary statistics for the agent context
+func (h *Handler) buildContextSummary(items []agentContextItem, today time.Time) map[string]interface{} {
+ bySource := make(map[string]int)
+ var overdue, todayCount int
+ endOfToday := today.Add(24 * time.Hour)
+
+ for _, item := range items {
+ bySource[item.Source]++
+ if item.Due != nil {
+ if item.Due.Before(today) {
+ overdue++
+ } else if item.Due.Before(endOfToday) {
+ todayCount++
+ }
+ }
+ }
+
+ return map[string]interface{}{
+ "total_items": len(items),
+ "by_source": bySource,
+ "overdue": overdue,
+ "today": todayCount,
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Middleware
+// -----------------------------------------------------------------------------
+
+// AgentAuthMiddleware verifies agent session token
+func (h *Handler) AgentAuthMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ authHeader := r.Header.Get("Authorization")
+ if authHeader == "" || len(authHeader) < 8 || authHeader[:7] != "Bearer " {
+ http.Error(w, "Authorization header required", http.StatusUnauthorized)
+ return
+ }
+
+ token := authHeader[7:]
+
+ session, err := h.store.GetAgentSessionBySessionToken(token)
+ if err != nil || session == nil {
+ http.Error(w, "Invalid session token", http.StatusUnauthorized)
+ return
+ }
+
+ // Check session expiry
+ if session.SessionExpiresAt != nil && time.Now().After(*session.SessionExpiresAt) {
+ http.Error(w, "Session expired", http.StatusUnauthorized)
+ return
+ }
+
+ // Add session to context
+ ctx := context.WithValue(r.Context(), agentSessionContextKey, session)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// -----------------------------------------------------------------------------
+// Web Handlers (HTML pages for browser-only agents)
+// -----------------------------------------------------------------------------
+
+// HandleAgentWebRequest handles GET /agent/web/request for browser-only agents
+func (h *Handler) HandleAgentWebRequest(w http.ResponseWriter, r *http.Request) {
+ name := r.URL.Query().Get("name")
+ agentID := r.URL.Query().Get("agent_id")
+
+ if name == "" || agentID == "" {
+ h.renderAgentError(w, "name and agent_id query parameters are required", http.StatusBadRequest)
+ return
+ }
+
+ // Check for existing pending session
+ existingSession, err := h.store.GetPendingAgentSessionByAgentID(agentID)
+ if err != nil {
+ h.renderAgentError(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ if existingSession != nil {
+ // Return existing pending session
+ h.renderAgentRequest(w, existingSession)
+ return
+ }
+
+ // Invalidate any previous sessions for this agent
+ if err := h.store.InvalidatePreviousAgentSessions(agentID); err != nil {
+ h.renderAgentError(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ // Generate request token
+ requestToken, err := generateToken()
+ if err != nil {
+ h.renderAgentError(w, "Failed to generate token", http.StatusInternalServerError)
+ return
+ }
+
+ // Create pending session
+ session := &models.AgentSession{
+ RequestToken: requestToken,
+ AgentName: name,
+ AgentID: agentID,
+ ExpiresAt: time.Now().Add(AgentRequestExpiry),
+ }
+ if err := h.store.CreateAgentSession(session); err != nil {
+ h.renderAgentError(w, "Failed to create session", http.StatusInternalServerError)
+ return
+ }
+
+ // Check trust level for WebSocket notification
+ trustLevel, err := h.store.CheckAgentTrust(name, agentID)
+ if err != nil {
+ trustLevel = models.AgentTrustNew
+ }
+
+ // Broadcast to connected browsers via WebSocket
+ h.BroadcastAgentRequest(session, trustLevel)
+
+ h.renderAgentRequest(w, session)
+}
+
+// HandleAgentWebStatus handles GET /agent/web/status for browser-only agents
+// Returns HTML page with approval status and session token if approved
+func (h *Handler) HandleAgentWebStatus(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if token == "" {
+ h.renderAgentError(w, "token query parameter required", http.StatusBadRequest)
+ return
+ }
+
+ session, err := h.store.GetAgentSessionByRequestToken(token)
+ if err != nil {
+ h.renderAgentError(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ if session == nil {
+ h.renderAgentError(w, "Session not found", http.StatusNotFound)
+ return
+ }
+
+ h.renderAgentStatus(w, session)
+}
+
+// HandleAgentWebContext handles GET /agent/web/context for browser-only agents
+func (h *Handler) HandleAgentWebContext(w http.ResponseWriter, r *http.Request) {
+ sessionToken := r.URL.Query().Get("session")
+ if sessionToken == "" {
+ h.renderAgentError(w, "session query parameter required", http.StatusBadRequest)
+ return
+ }
+
+ session, err := h.store.GetAgentSessionBySessionToken(sessionToken)
+ if err != nil || session == nil {
+ h.renderAgentError(w, "Invalid session token", http.StatusUnauthorized)
+ return
+ }
+
+ if session.SessionExpiresAt != nil && time.Now().After(*session.SessionExpiresAt) {
+ h.renderAgentError(w, "Session expired", http.StatusUnauthorized)
+ return
+ }
+
+ _ = h.store.UpdateAgentLastSeen(session.AgentID)
+
+ now := config.Now()
+ startDate := config.Today()
+ endDate := startDate.Add(7 * 24 * time.Hour)
+
+ timeline := h.buildAgentContext(r.Context(), startDate, endDate)
+ h.renderAgentContext(w, session, timeline, startDate, endDate, now)
+}
+
+// renderAgentError renders an error page for agent web endpoints
+func (h *Handler) renderAgentError(w http.ResponseWriter, message string, status int) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if h.templates != nil {
+ _ = h.templates.ExecuteTemplate(w, "agent-error.html", map[string]interface{}{
+ "Error": message,
+ "Status": status,
+ })
+ } else {
+ // Fallback if template not loaded
+ w.Write([]byte(`<!DOCTYPE html><html><head><title>Error</title></head><body><h1>Error</h1><p>` + message + `</p></body></html>`))
+ }
+}
+
+// renderAgentRequest renders the request page with token info
+func (h *Handler) renderAgentRequest(w http.ResponseWriter, session *models.AgentSession) {
+ h.renderAgentTemplate(w, "agent-request.html", map[string]interface{}{
+ "RequestToken": session.RequestToken,
+ "AgentName": session.AgentName,
+ "AgentID": session.AgentID,
+ "Status": "pending",
+ "PollURL": "/agent/web/status?token=" + session.RequestToken,
+ "ExpiresAt": session.ExpiresAt.Format(time.RFC3339),
+ })
+}
+
+// renderAgentStatus renders the status page
+func (h *Handler) renderAgentStatus(w http.ResponseWriter, session *models.AgentSession) {
+ status := session.Status
+ if isSessionExpired(session) {
+ status = "expired"
+ }
+
+ data := map[string]interface{}{
+ "RequestToken": session.RequestToken,
+ "AgentName": session.AgentName,
+ "Status": status,
+ }
+
+ if status == "approved" && session.SessionToken != "" {
+ data["SessionToken"] = session.SessionToken
+ data["ContextURL"] = "/agent/web/context?session=" + session.SessionToken
+ if session.SessionExpiresAt != nil {
+ data["SessionExpiresAt"] = session.SessionExpiresAt.Format(time.RFC3339)
+ }
+ }
+
+ h.renderAgentTemplate(w, "agent-status.html", data)
+}
+
+// renderAgentContext renders the context page with timeline data
+func (h *Handler) renderAgentContext(w http.ResponseWriter, session *models.AgentSession, timeline []agentContextItem, startDate, endDate, now time.Time) {
+ h.renderAgentTemplate(w, "agent-context.html", map[string]interface{}{
+ "AgentName": session.AgentName,
+ "GeneratedAt": now.Format(time.RFC3339),
+ "RangeStart": startDate.Format("2006-01-02"),
+ "RangeEnd": endDate.Format("2006-01-02"),
+ "Timeline": timeline,
+ "Summary": h.buildContextSummary(timeline, startDate),
+ })
+}