From b0688c8819da1b7fcb4a97b6ec1fa58050e4841e Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 23 Mar 2026 06:50:43 +0000 Subject: feat: complete Agent Context API Phase 2 & 3 (Write/Create/Management) - Implement write operations (complete, uncomplete, update due date, update task) - Implement create operations (create task, add shopping item) - Add Trusted Agents management UI in Settings with revocation support - Fix SQLite timestamp scanning bug for completed tasks - Add comprehensive unit tests for all new agent endpoints - Update worklog and feature documentation --- internal/handlers/agent.go | 271 ++++++++++++++++++++++++++++++++++++++++ internal/handlers/agent_test.go | 152 ++++++++++++++++++++++ internal/handlers/settings.go | 3 + 3 files changed, 426 insertions(+) (limited to 'internal/handlers') diff --git a/internal/handlers/agent.go b/internal/handlers/agent.go index aa3f000..6d6079f 100644 --- a/internal/handlers/agent.go +++ b/internal/handlers/agent.go @@ -9,8 +9,11 @@ import ( "net/http" "time" + "github.com/go-chi/chi/v5" + "task-dashboard/internal/config" "task-dashboard/internal/models" + "task-dashboard/internal/store" ) // ----------------------------------------------------------------------------- @@ -384,6 +387,274 @@ func (h *Handler) buildCompletedLog(limit int) []agentCompletedItem { return items } +// ----------------------------------------------------------------------------- +// Write Handlers +// ----------------------------------------------------------------------------- + +// HandleAgentTaskComplete handles POST /agent/tasks/{id}/complete +func (h *Handler) HandleAgentTaskComplete(w http.ResponseWriter, r *http.Request) { + h.handleAgentTaskToggle(w, r, true) +} + +// HandleAgentTaskUncomplete handles POST /agent/tasks/{id}/uncomplete +func (h *Handler) HandleAgentTaskUncomplete(w http.ResponseWriter, r *http.Request) { + h.handleAgentTaskToggle(w, r, false) +} + +// handleAgentTaskToggle handles both complete and uncomplete operations for agents +func (h *Handler) handleAgentTaskToggle(w http.ResponseWriter, r *http.Request, complete bool) { + id := chi.URLParam(r, "id") + source := r.URL.Query().Get("source") + + if id == "" || source == "" { + http.Error(w, "id and source are required", http.StatusBadRequest) + return + } + + var err error + ctx := r.Context() + switch source { + case "todoist": + if complete { + err = h.todoistClient.CompleteTask(ctx, id) + } else { + err = h.todoistClient.ReopenTask(ctx, id) + } + case "trello": + err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"closed": complete}) + case "gtasks": + listID := r.URL.Query().Get("listId") + if listID == "" { + listID = "@default" + } + if h.googleTasksClient != nil { + if complete { + err = h.googleTasksClient.CompleteTask(ctx, listID, id) + } else { + err = h.googleTasksClient.UncompleteTask(ctx, listID, id) + } + } else { + http.Error(w, "Google Tasks not configured", http.StatusServiceUnavailable) + return + } + default: + http.Error(w, "Unknown source: "+source, http.StatusBadRequest) + return + } + + if err != nil { + http.Error(w, "Failed to toggle task: "+err.Error(), http.StatusInternalServerError) + return + } + + if complete { + title, dueDate := h.getAtomDetails(id, source) + _ = h.store.SaveCompletedTask(source, id, title, dueDate) + switch source { + case "todoist": + _ = h.store.DeleteTask(id) + case "trello": + _ = h.store.DeleteCard(id) + } + } else { + switch source { + case "todoist": + _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) + case "trello": + _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} + +// HandleAgentTaskUpdateDue handles PATCH /agent/tasks/{id}/due +func (h *Handler) HandleAgentTaskUpdateDue(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + source := r.URL.Query().Get("source") + + var req struct { + Due *time.Time `json:"due"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + var err error + ctx := r.Context() + switch source { + case "todoist": + err = h.todoistClient.UpdateTask(ctx, id, map[string]interface{}{"due_datetime": req.Due}) + case "trello": + err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"due": req.Due}) + default: + http.Error(w, "Source does not support due date updates via this endpoint", http.StatusBadRequest) + return + } + + if err != nil { + http.Error(w, "Failed to update due date: "+err.Error(), http.StatusInternalServerError) + return + } + + // Invalidate cache + switch source { + case "todoist": + _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) + case "trello": + _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} + +// HandleAgentTaskUpdate handles PATCH /agent/tasks/{id} +func (h *Handler) HandleAgentTaskUpdate(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + source := r.URL.Query().Get("source") + + var updates map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + var err error + ctx := r.Context() + switch source { + case "todoist": + // Map generic updates to Todoist specific ones if needed + if title, ok := updates["title"].(string); ok { + updates["content"] = title + delete(updates, "title") + } + err = h.todoistClient.UpdateTask(ctx, id, updates) + case "trello": + if title, ok := updates["title"].(string); ok { + updates["name"] = title + delete(updates, "title") + } + if desc, ok := updates["description"].(string); ok { + updates["desc"] = desc + delete(updates, "description") + } + err = h.trelloClient.UpdateCard(ctx, id, updates) + default: + http.Error(w, "Source does not support updates via this endpoint", http.StatusBadRequest) + return + } + + if err != nil { + http.Error(w, "Failed to update task: "+err.Error(), http.StatusInternalServerError) + return + } + + // Invalidate cache + switch source { + case "todoist": + _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) + case "trello": + _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} + +// HandleAgentTaskCreate handles POST /agent/tasks +func (h *Handler) HandleAgentTaskCreate(w http.ResponseWriter, r *http.Request) { + var req struct { + Title string `json:"title"` + Source string `json:"source"` + DueDate *time.Time `json:"due_date"` + ProjectID string `json:"project_id"` + ListID string `json:"list_id"` + Priority int `json:"priority"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Title == "" || req.Source == "" { + http.Error(w, "title and source are required", http.StatusBadRequest) + return + } + + var err error + ctx := r.Context() + switch req.Source { + case "todoist": + _, err = h.todoistClient.CreateTask(ctx, req.Title, req.ProjectID, req.DueDate, req.Priority) + _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) + case "trello": + if req.ListID == "" { + http.Error(w, "list_id is required for Trello", http.StatusBadRequest) + return + } + _, err = h.trelloClient.CreateCard(ctx, req.ListID, req.Title, "", req.DueDate) + _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) + default: + http.Error(w, "Unsupported source for task creation", http.StatusBadRequest) + return + } + + if err != nil { + http.Error(w, "Failed to create task: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} + +// HandleAgentShoppingAdd handles POST /agent/shopping +func (h *Handler) HandleAgentShoppingAdd(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Store string `json:"store"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Name == "" || req.Store == "" { + http.Error(w, "name and store are required", http.StatusBadRequest) + return + } + + if err := h.store.SaveUserShoppingItem(req.Name, req.Store); err != nil { + http.Error(w, "Failed to save shopping item: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} + +// HandleDeleteAgent handles DELETE /settings/agents/{id} +func (h *Handler) HandleDeleteAgent(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "id") + if agentID == "" { + http.Error(w, "agent ID is required", http.StatusBadRequest) + return + } + + if err := h.store.RevokeAgent(agentID); err != nil { + http.Error(w, "Failed to revoke agent: "+err.Error(), http.StatusInternalServerError) + return + } + + // Also invalidate their sessions + _ = h.store.InvalidatePreviousAgentSessions(agentID) + + w.WriteHeader(http.StatusOK) +} + // ----------------------------------------------------------------------------- // Middleware // ----------------------------------------------------------------------------- diff --git a/internal/handlers/agent_test.go b/internal/handlers/agent_test.go index 5775962..eab1609 100644 --- a/internal/handlers/agent_test.go +++ b/internal/handlers/agent_test.go @@ -2,12 +2,15 @@ package handlers import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" + "github.com/go-chi/chi/v5" + "task-dashboard/internal/config" "task-dashboard/internal/models" ) @@ -738,6 +741,155 @@ func TestHandleAgentWebContext(t *testing.T) { } } +func TestHandleAgentTaskWriteOperations(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // Mock clients + mockTodoist := &mockTodoistClient{} + mockTrello := &mockTrelloClient{} + h := &Handler{ + store: db, + todoistClient: mockTodoist, + trelloClient: mockTrello, + config: &config.Config{}, + } + + // Create an approved session + sessionToken := "write-session-token" + session := &models.AgentSession{ + RequestToken: "write-request-token", + AgentName: "WriteAgent", + AgentID: "write-agent-uuid", + ExpiresAt: time.Now().Add(5 * time.Minute), + } + db.CreateAgentSession(session) + db.ApproveAgentSession("write-request-token", sessionToken, time.Now().Add(1*time.Hour)) + + // Pre-populate store with a task to get details during completion + db.SaveTasks([]models.Task{{ID: "task123", Content: "Test Task"}}) + + t.Run("complete task", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/agent/tasks/task123/complete?source=todoist", nil) + w := httptest.NewRecorder() + + // Manually inject session into context as middleware would + ctx := context.WithValue(req.Context(), agentSessionContextKey, session) + req = req.WithContext(ctx) + + // Set chi URL param + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "task123") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + h.HandleAgentTaskComplete(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify task was logged as completed + completed, err := db.GetCompletedTasks(1) + if err != nil { + t.Fatalf("Failed to get completed tasks: %v", err) + } + if len(completed) == 0 { + t.Fatal("Expected task to be logged as completed, but got 0 results") + } + if completed[0].SourceID != "task123" { + t.Errorf("Expected source ID task123, got %s", completed[0].SourceID) + } + }) + + t.Run("update due date", func(t *testing.T) { + due := time.Now().Add(24 * time.Hour) + body, _ := json.Marshal(map[string]*time.Time{"due": &due}) + req := httptest.NewRequest(http.MethodPatch, "/agent/tasks/task123/due?source=todoist", bytes.NewReader(body)) + w := httptest.NewRecorder() + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "task123") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + h.HandleAgentTaskUpdateDue(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + }) + + t.Run("update task details", func(t *testing.T) { + body, _ := json.Marshal(map[string]string{"title": "Updated Title", "description": "New Desc"}) + req := httptest.NewRequest(http.MethodPatch, "/agent/tasks/task123?source=todoist", bytes.NewReader(body)) + w := httptest.NewRecorder() + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "task123") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + h.HandleAgentTaskUpdate(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + }) +} + +func TestHandleAgentCreateOperations(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + mockTodoist := &mockTodoistClient{} + h := &Handler{ + store: db, + todoistClient: mockTodoist, + config: &config.Config{}, + } + + t.Run("create task", func(t *testing.T) { + body, _ := json.Marshal(map[string]string{ + "title": "New Agent Task", + "source": "todoist", + }) + req := httptest.NewRequest(http.MethodPost, "/agent/tasks", bytes.NewReader(body)) + w := httptest.NewRecorder() + + h.HandleAgentTaskCreate(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + }) + + t.Run("add shopping item", func(t *testing.T) { + body, _ := json.Marshal(map[string]string{ + "name": "Milk", + "store": "Costco", + }) + req := httptest.NewRequest(http.MethodPost, "/agent/shopping", bytes.NewReader(body)) + w := httptest.NewRecorder() + + h.HandleAgentShoppingAdd(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify item was saved + items, _ := db.GetUserShoppingItems() + found := false + for _, item := range items { + if item.Name == "Milk" && item.Store == "Costco" { + found = true + break + } + } + if !found { + t.Error("Expected shopping item to be saved") + } + }) +} + // contains is a helper function to check if a string contains a substring func contains(s, substr string) bool { return bytes.Contains([]byte(s), []byte(substr)) diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index a780170..32e0215 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -16,6 +16,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { configs, _ := h.store.GetSourceConfigs() toggles, _ := h.store.GetFeatureToggles() syncLog, _ := h.store.GetRecentSyncLog(20) + agents, _ := h.store.GetAllAgents() // Group configs by source bySource := make(map[string][]models.SourceConfig) @@ -28,6 +29,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { Sources []string Toggles []models.FeatureToggle SyncLog []store.SyncLogEntry + Agents []models.Agent CSRFToken string WebAuthnEnabled bool }{ @@ -35,6 +37,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { Sources: []string{"trello", "todoist", "gcal", "gtasks"}, Toggles: toggles, SyncLog: syncLog, + Agents: agents, CSRFToken: auth.GetCSRFTokenFromContext(r.Context()), WebAuthnEnabled: h.WebAuthnEnabled, } -- cgit v1.2.3