summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-23 06:50:43 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-23 06:50:43 +0000
commitb0688c8819da1b7fcb4a97b6ec1fa58050e4841e (patch)
treec62b4ea3e5cdb56225c39ad930dd3e5584053827 /internal
parentef7a45361996b7a49226a0b088e2599f2801d017 (diff)
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
Diffstat (limited to 'internal')
-rw-r--r--internal/handlers/agent.go271
-rw-r--r--internal/handlers/agent_test.go152
-rw-r--r--internal/handlers/settings.go3
-rw-r--r--internal/store/sqlite.go29
4 files changed, 451 insertions, 4 deletions
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"
)
// -----------------------------------------------------------------------------
@@ -385,6 +388,274 @@ func (h *Handler) buildCompletedLog(limit int) []agentCompletedItem {
}
// -----------------------------------------------------------------------------
+// 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,
}
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index f4651bb..3879395 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -1222,8 +1222,8 @@ func (s *Store) SaveCompletedTask(source, sourceID, title string, dueDate *time.
}
_, err := s.db.Exec(`
INSERT OR REPLACE INTO completed_tasks (source, source_id, title, due_date, completed_at)
- VALUES (?, ?, ?, ?, datetime('now', 'localtime'))
- `, source, sourceID, title, dueDateStr)
+ VALUES (?, ?, ?, ?, ?)
+ `, source, sourceID, title, dueDateStr, time.Now())
return err
}
@@ -1244,9 +1244,9 @@ func (s *Store) GetCompletedTasks(limit int) ([]models.CompletedTask, error) {
for rows.Next() {
var task models.CompletedTask
var dueDate sql.NullString
- var completedAt time.Time
+ var completedAtStr string
- if err := rows.Scan(&task.ID, &task.Source, &task.SourceID, &task.Title, &dueDate, &completedAt); err != nil {
+ if err := rows.Scan(&task.ID, &task.Source, &task.SourceID, &task.Title, &dueDate, &completedAtStr); err != nil {
return nil, err
}
@@ -1255,6 +1255,27 @@ func (s *Store) GetCompletedTasks(limit int) ([]models.CompletedTask, error) {
task.DueDate = &t
}
}
+
+ // Try different formats for completedAt
+ formats := []string{
+ "2006-01-02 15:04:05.999999999-07:00",
+ "2006-01-02 15:04:05",
+ time.RFC3339,
+ }
+ var completedAt time.Time
+ var parseErr error
+ for _, f := range formats {
+ if t, err := time.Parse(f, completedAtStr); err == nil {
+ completedAt = t
+ parseErr = nil
+ break
+ } else {
+ parseErr = err
+ }
+ }
+ if parseErr != nil {
+ log.Printf("Warning: failed to parse completed_at %q: %v", completedAtStr, parseErr)
+ }
task.CompletedAt = completedAt
tasks = append(tasks, task)