diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-28 22:18:40 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-28 22:18:40 -1000 |
| commit | 058ff7d699f088edb851336928dd3eea2934cc07 (patch) | |
| tree | 54f2925e1ca071b8840ce07372a1dc4d7ebedf16 /internal/handlers/agent.go | |
| parent | 994b92f6c6ce204675b9e20ff1e9b4a3bfa39bea (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.go | 560 |
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), + }) +} |
