feat(mcp): delete tasks, create task lists and lists, agent badge; export/import settings category
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s
New MCP tools delete_task (tasks:write), create_task_list (tasks:write) and create_list (lists:write); list_task_lists now returns the board columns so agents know the valid kanbanStatus values. Task deletion is a shared helper (deleteTask) for REST and MCP. Tasks and list items get agent_mark (migration 12): "created" when an agent adds them via MCP, "edited" when an agent changes them; a change by the user clears it. The new AgentBadge shows "KI" / "KI geändert" in task rows, Kanban cards, the task detail, the inbox and lists. Settings: export and import move out of "Webhooks, KI-Agenten & Export" into their own category "Export & Import". Docs, help, README and changelog updated; v0.29.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8faed7a160
commit
ac068248d7
28 files changed
+441
-65
No files matched your search
@@ -226,7 +226,7 @@ var errListIDRequired = errors.New("Feld 'listId' ist für Listeneinträge erfor
|
||||
|
||||
// addListItem appends an item to a list the user may write to; the error is storage.ErrListReadOnly for
|
||||
// viewers and "not found" for lists the user cannot see.
|
||||
func (s *Server) addListItem(userID, listID, content, category, quantity string) (*domain.ListItem, error) {
|
||||
func (s *Server) addListItem(userID, listID, content, category, quantity string, byAgent bool) (*domain.ListItem, error) {
|
||||
if strings.TrimSpace(listID) == "" {
|
||||
return nil, errListIDRequired
|
||||
}
|
||||
@@ -237,13 +237,16 @@ func (s *Server) addListItem(userID, listID, content, category, quantity string)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if byAgent {
|
||||
item.AgentMark = s.store.MarkListItemAgentCreated(item.ID)
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to.
|
||||
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) {
|
||||
item, err := s.addListItem(userID, listID, content, category, quantity)
|
||||
item, err := s.addListItem(userID, listID, content, category, quantity, false)
|
||||
if err != nil {
|
||||
status := http.StatusNotFound
|
||||
switch {
|
||||
|
||||
@@ -167,8 +167,8 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// setListItemDone checks an item off (or back on) and tells the list's viewers. Shared by
|
||||
// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item; access is checked first.
|
||||
func (s *Server) setListItemDone(userID, listID, itemID string, done bool) (*domain.ListItem, error) {
|
||||
// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item (byAgent); access is checked first.
|
||||
func (s *Server) setListItemDone(userID, listID, itemID string, done, byAgent bool) (*domain.ListItem, error) {
|
||||
if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -181,6 +181,7 @@ func (s *Server) setListItemDone(userID, listID, itemID string, done bool) (*dom
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AgentMark = s.store.SetListItemAgentMark(itemID, byAgent)
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
||||
return item, nil
|
||||
}
|
||||
@@ -201,7 +202,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted)
|
||||
item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -231,6 +232,7 @@ func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.AgentMark = s.store.SetListItemAgentMark(itemID, false)
|
||||
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
|
||||
@@ -245,6 +245,8 @@ type taskPatch struct {
|
||||
Tags *[]string `json:"tags"`
|
||||
Assignees *[]string `json:"assignees"`
|
||||
Recurrence *string `json:"recurrence"`
|
||||
// byAgent: the change comes from an AI agent (MCP) and marks the task; otherwise the mark goes
|
||||
byAgent bool
|
||||
}
|
||||
|
||||
// updateTask applies a patch, keeps a linked note block in step, tells everyone who sees the task
|
||||
@@ -261,6 +263,7 @@ func (s *Server) updateTask(userID, thoughtID string, req taskPatch) (*domain.Th
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated.AgentMark = s.store.SetThoughtAgentMark(thoughtID, updated.AgentMark, req.byAgent)
|
||||
if req.Assignees != nil {
|
||||
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
|
||||
if err != nil {
|
||||
@@ -305,23 +308,30 @@ func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
// deleteTask removes a task, tells everyone who saw it and notifies its watchers. Shared by
|
||||
// DELETE /thoughts/{id} and the MCP tool delete_task.
|
||||
func (s *Server) deleteTask(userID, thoughtID string) error {
|
||||
th, err := s.store.GetThoughtByID(userID, thoughtID)
|
||||
if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
|
||||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||||
return
|
||||
return storage.ErrTaskListReadOnly
|
||||
}
|
||||
audience := s.taskAudience(th)
|
||||
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
||||
writeStoreError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
if th != nil {
|
||||
s.notifyTaskChange(userID, th, nil, true)
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
if err := s.deleteTask(userID, chi.URLParam(r, "id")); err != nil {
|
||||
writeStoreError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
|
||||
+112
-11
@@ -128,7 +128,8 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
"serverInfo": map[string]string{"name": "MindSift", "version": mcpServerVersion},
|
||||
"instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " +
|
||||
"to see open tasks, complete_task to check one off, list_lists/get_list for shopping and other " +
|
||||
"lists and check_list_item to tick items. IDs come from the list tools; never guess them.",
|
||||
"lists and check_list_item to tick items. IDs come from the list tools; never guess them. " +
|
||||
"Entries you create or change are marked for the user until they touch them.",
|
||||
}, nil)
|
||||
case "ping":
|
||||
writeRPC(w, req.ID, map[string]interface{}{}, nil)
|
||||
@@ -275,11 +276,31 @@ func toTaskView(t *domain.Thought) taskView {
|
||||
}
|
||||
|
||||
type taskListView struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Permission string `json:"permission"`
|
||||
OpenTasks int `json:"openTasks"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Permission string `json:"permission"`
|
||||
OpenTasks int `json:"openTasks"`
|
||||
Columns []columnView `json:"columns,omitempty"`
|
||||
}
|
||||
|
||||
// columnView: a board column, so an agent knows the valid kanbanStatus values of a task list
|
||||
type columnView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsDone bool `json:"isDone,omitempty"`
|
||||
}
|
||||
|
||||
func toTaskListView(l domain.TaskList) taskListView {
|
||||
cols := make([]columnView, 0, len(l.Columns))
|
||||
for _, c := range l.Columns {
|
||||
cols = append(cols, columnView{ID: c.ID, Name: c.Name, IsDone: c.IsDone})
|
||||
}
|
||||
permission := l.Permission
|
||||
if permission == "" && l.IsOwner {
|
||||
permission = "owner"
|
||||
}
|
||||
return taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: permission, OpenTasks: l.TasksCount, Columns: cols}
|
||||
}
|
||||
|
||||
type listView struct {
|
||||
@@ -309,7 +330,7 @@ func toListView(l domain.SharedList) listView {
|
||||
var mcpTools = []mcpTool{
|
||||
{
|
||||
Name: "list_task_lists",
|
||||
Description: "All task lists the user can see, with permission (owner/editor/viewer) and the number of open tasks. Tasks without a list live in the personal inbox list (taskListId empty).",
|
||||
Description: "All task lists the user can see, with permission (owner/editor/viewer), the number of open tasks and the board columns (valid kanbanStatus values). Tasks without a list live in the personal inbox list (taskListId empty).",
|
||||
Scope: storage.ScopeTasksRead,
|
||||
Schema: schema(nil, map[string]interface{}{}),
|
||||
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
|
||||
@@ -319,11 +340,39 @@ var mcpTools = []mcpTool{
|
||||
}
|
||||
out := make([]taskListView, 0, len(lists))
|
||||
for _, l := range lists {
|
||||
out = append(out, taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: l.Permission, OpenTasks: l.TasksCount})
|
||||
out = append(out, toTaskListView(l))
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create_task_list",
|
||||
Description: "Create a personal task list (board with the default columns). Returns it with its id and columns.",
|
||||
Scope: storage.ScopeTasksWrite,
|
||||
Schema: schema([]string{"title"}, map[string]interface{}{
|
||||
"title": prop("string", "Name of the task list"),
|
||||
"icon": prop("string", "One emoji, optional (default 📋)"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(a.Title) == "" {
|
||||
return nil, errors.New("'title' darf nicht leer sein")
|
||||
}
|
||||
tl, err := s.store.CreateTaskList(userID, "", "", a.Title, a.Icon, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.broadcastTaskListChange(tl.ID, nil, "TASK_LIST_CREATED")
|
||||
tl.IsOwner = true
|
||||
return toTaskListView(*tl), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_tasks",
|
||||
Description: "Tasks the user can see. Defaults to open tasks across all lists; filter by taskListId, status (open|done|all) or dueBefore (YYYY-MM-DD, inclusive). Sorted: overdue and due first.",
|
||||
@@ -458,6 +507,7 @@ var mcpTools = []mcpTool{
|
||||
return nil, err
|
||||
}
|
||||
a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil
|
||||
a.taskPatch.byAgent = true
|
||||
t, err := s.updateTask(userID, a.ID, a.taskPatch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -482,13 +532,34 @@ var mcpTools = []mcpTool{
|
||||
return nil, err
|
||||
}
|
||||
done := a.Completed == nil || *a.Completed
|
||||
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done})
|
||||
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done, byAgent: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toTaskView(t), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete_task",
|
||||
Description: "Delete a task for good, e.g. one created by mistake. Prefer complete_task for work that is done.",
|
||||
Scope: storage.ScopeTasksWrite,
|
||||
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.store.GetThoughtByID(userID, a.ID); err != nil {
|
||||
return nil, errors.New("Aufgabe nicht gefunden")
|
||||
}
|
||||
if err := s.deleteTask(userID, a.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"id": a.ID, "deleted": true}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_lists",
|
||||
Description: "Shopping lists, checklists and other shared lists the user can see, with open/total item counts.",
|
||||
@@ -506,6 +577,35 @@ var mcpTools = []mcpTool{
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create_list",
|
||||
Description: "Create a list for the user: shopping (items grouped by category), checklist or vacation (packing list).",
|
||||
Scope: storage.ScopeListsWrite,
|
||||
Schema: schema([]string{"title"}, map[string]interface{}{
|
||||
"title": prop("string", "Name of the list"),
|
||||
"icon": prop("string", "One emoji, optional"),
|
||||
"listType": map[string]interface{}{"type": "string", "enum": []string{"shopping", "checklist", "vacation"}, "description": "Default shopping"},
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(a.Title) == "" {
|
||||
return nil, errors.New("'title' darf nicht leer sein")
|
||||
}
|
||||
l, err := s.store.CreateList(userID, a.Title, a.Icon, a.ListType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(l.ID, "LIST_CREATED", l)
|
||||
return toListView(*l), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_list",
|
||||
Description: "One list with all its items (id, content, category, quantity, isCompleted).",
|
||||
@@ -545,7 +645,7 @@ var mcpTools = []mcpTool{
|
||||
if strings.TrimSpace(a.Content) == "" {
|
||||
return nil, errors.New("'content' darf nicht leer sein")
|
||||
}
|
||||
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity)
|
||||
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity, true)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -566,7 +666,7 @@ var mcpTools = []mcpTool{
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed)
|
||||
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed, true)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -598,6 +698,7 @@ var mcpTools = []mcpTool{
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AgentMark = s.store.SetListItemAgentMark(a.ItemID, true)
|
||||
s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item)
|
||||
return item, nil
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -88,7 +89,10 @@ func TestMCPToolsFollowScopes(t *testing.T) {
|
||||
|
||||
// Read-only task key: lists tasks, cannot complete them
|
||||
readKey, _ := store.CreateAPIKey(claims.UserID, "Reader", []string{"tasks:read"})
|
||||
names := strings.Join(toolNames(func() map[string]interface{} { _, r := rpc(t, router, readKey.RawKey, `{"jsonrpc":"2.0","id":5,"method":"tools/list"}`); return r }()), ",")
|
||||
names := strings.Join(toolNames(func() map[string]interface{} {
|
||||
_, r := rpc(t, router, readKey.RawKey, `{"jsonrpc":"2.0","id":5,"method":"tools/list"}`)
|
||||
return r
|
||||
}()), ",")
|
||||
if !strings.Contains(names, "list_tasks") || strings.Contains(names, "complete_task") || strings.Contains(names, "list_lists") {
|
||||
t.Fatalf("tasks:read key sees the wrong tools: %s", names)
|
||||
}
|
||||
@@ -192,3 +196,82 @@ func TestMCPTasksAndLists(t *testing.T) {
|
||||
t.Fatalf("anonymous MCP call should be 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Agents can create task lists and lists and delete tasks; their entries carry a mark until the user
|
||||
// changes them.
|
||||
func TestMCPCreateDeleteAndAgentMark(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
key, _ := store.CreateAPIKey(claims.UserID, "Agent", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"})
|
||||
call := func(id int, name, args string) map[string]interface{} {
|
||||
t.Helper()
|
||||
_, res := rpc(t, router, key.RawKey, fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":%q,"arguments":%s}}`, id, name, args))
|
||||
result, isErr := callResult(t, res)
|
||||
if isErr {
|
||||
t.Fatalf("%s failed: %v", name, result)
|
||||
}
|
||||
return result["structuredContent"].(map[string]interface{})
|
||||
}
|
||||
|
||||
tl := call(1, "create_task_list", `{"title":"Agentenliste"}`)
|
||||
tlID, _ := tl["id"].(string)
|
||||
if tlID == "" || tl["permission"] != "owner" || len(tl["columns"].([]interface{})) == 0 {
|
||||
t.Fatalf("create_task_list: %v", tl)
|
||||
}
|
||||
task := call(2, "create_task", `{"content":"Von Claude","taskListId":"`+tlID+`"}`)
|
||||
taskID := task["id"].(string)
|
||||
th, _ := store.GetThoughtByID(claims.UserID, taskID)
|
||||
if th.AgentMark != "created" || th.TaskListID != tlID {
|
||||
t.Fatalf("agent task should be marked created: %+v", th)
|
||||
}
|
||||
// An agent's change keeps "created"; the user's own change removes the mark
|
||||
call(3, "update_task", `{"id":"`+taskID+`","priority":"high"}`)
|
||||
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "created" {
|
||||
t.Fatalf("mark after agent edit: %q", th.AgentMark)
|
||||
}
|
||||
notes := "selbst ergänzt"
|
||||
if _, err := srv.updateTask(claims.UserID, taskID, taskPatch{Notes: ¬es}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "" {
|
||||
t.Fatalf("user edit should clear the mark, got %q", th.AgentMark)
|
||||
}
|
||||
call(4, "complete_task", `{"id":"`+taskID+`"}`)
|
||||
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "edited" {
|
||||
t.Fatalf("agent edit of a user task should mark edited, got %q", th.AgentMark)
|
||||
}
|
||||
|
||||
call(5, "delete_task", `{"id":"`+taskID+`"}`)
|
||||
if th, err := store.GetThoughtByID(claims.UserID, taskID); err == nil && th != nil {
|
||||
t.Fatalf("task should be gone")
|
||||
}
|
||||
|
||||
l := call(6, "create_list", `{"title":"Packliste","listType":"vacation"}`)
|
||||
listID := l["id"].(string)
|
||||
if l["listType"] != "vacation" || l["role"] != "owner" {
|
||||
t.Fatalf("create_list: %v", l)
|
||||
}
|
||||
item := call(7, "add_list_item", `{"listId":"`+listID+`","content":"Zahnbürste"}`)
|
||||
if item["agentMark"] != "created" {
|
||||
t.Fatalf("agent item should be marked created: %v", item)
|
||||
}
|
||||
items, _ := store.GetListItems(listID)
|
||||
if len(items) != 1 || items[0].AgentMark != "created" {
|
||||
t.Fatalf("stored item mark: %+v", items)
|
||||
}
|
||||
if _, err := srv.setListItemDone(claims.UserID, listID, items[0].ID, true, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items, _ = store.GetListItems(listID); items[0].AgentMark != "" {
|
||||
t.Fatalf("ticking by the user should clear the mark: %q", items[0].AgentMark)
|
||||
}
|
||||
|
||||
// Other people's tasks cannot be deleted
|
||||
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
|
||||
foreign, _ := store.CreateThoughtWithDetails(other.ID, "Fremd", "normal", "manual", "", "", "todo", "")
|
||||
_, res := rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"delete_task","arguments":{"id":"`+foreign.ID+`"}}}`)
|
||||
if _, isErr := callResult(t, res); !isErr {
|
||||
t.Fatalf("key must not delete foreign tasks")
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,7 @@ type Thought struct {
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
Priority string `json:"priority"` // low | normal | high | urgent
|
||||
Source string `json:"source"` // manual | voice | webhook
|
||||
Source string `json:"source"` // manual | voice | webhook | agent
|
||||
Status ThoughtStatus `json:"status"`
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
|
||||
@@ -268,6 +268,7 @@ type Thought struct {
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TriagedAt string `json:"triagedAt,omitempty"`
|
||||
AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since
|
||||
}
|
||||
|
||||
type ThoughtReorderItem struct {
|
||||
@@ -570,6 +571,7 @@ type ListItem struct {
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CompletedAt string `json:"completedAt,omitempty"`
|
||||
AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since
|
||||
}
|
||||
|
||||
type SharedList struct {
|
||||
|
||||
@@ -483,7 +483,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string {
|
||||
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
|
||||
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
|
||||
return scanThought(s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
FROM thoughts WHERE id = ?`, thoughtID))
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ func (s *Store) DeleteList(userID, listID string) error {
|
||||
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, li.agent_mark
|
||||
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
||||
WHERE li.list_id = ?
|
||||
ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`,
|
||||
@@ -318,7 +318,7 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||||
for rows.Next() {
|
||||
var it domain.ListItem
|
||||
var doneInt int
|
||||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil {
|
||||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &it.AgentMark); err == nil {
|
||||
it.IsCompleted = doneInt != 0
|
||||
items = append(items, it)
|
||||
}
|
||||
@@ -461,11 +461,11 @@ func (s *Store) MoveListItem(itemID, targetListID string) (*domain.ListItem, err
|
||||
var dInt int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, li.agent_mark
|
||||
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
||||
WHERE li.id = ?`,
|
||||
itemID,
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &it.AgentMark)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ var migrations = []migration{
|
||||
{9, "Web-Push", (*Store).migratePush},
|
||||
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
|
||||
{11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification},
|
||||
{12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -72,7 +72,7 @@ func (s *Store) SetTaskRecurrence(userID, taskID, rule string) error {
|
||||
|
||||
func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) {
|
||||
t, err := scanThought(s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
FROM thoughts WHERE id = ?`, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -76,11 +76,14 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
|
||||
Notes: notes,
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
if source == "agent" {
|
||||
t.AgentMark = AgentMarkCreated
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`,
|
||||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
|
||||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks, agent_mark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]', ?)`,
|
||||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt, t.AgentMark,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -99,7 +102,7 @@ func scanThought(rows interface {
|
||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||||
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
|
||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence, &t.AgentMark,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -136,7 +139,7 @@ func (s *Store) accessibleTaskListIDsQuery() string {
|
||||
|
||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||
thoughtID, userID, userID, userID, userID,
|
||||
)
|
||||
@@ -157,14 +160,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
|
||||
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
FROM thoughts WHERE user_id = ? AND status = ?
|
||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, statusFilter, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
FROM thoughts WHERE user_id = ?
|
||||
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, limit,
|
||||
@@ -190,7 +193,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
var err error
|
||||
if taskListID != "" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND task_list_id = ?
|
||||
@@ -200,7 +203,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
@@ -509,6 +512,59 @@ func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateAgentMark: entries an AI agent created or changed via MCP and the user has not touched since
|
||||
func (s *Store) migrateAgentMark() error {
|
||||
if err := s.addColumnIfMissing("thoughts", "agent_mark", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.addColumnIfMissing("list_items", "agent_mark", "TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
|
||||
// Values of agent_mark on thoughts and list items
|
||||
const (
|
||||
AgentMarkCreated = "created"
|
||||
AgentMarkEdited = "edited"
|
||||
)
|
||||
|
||||
// nextAgentMark: an agent's change marks the entry (an entry it created stays "created"), a change by
|
||||
// the user removes the mark.
|
||||
func nextAgentMark(current string, byAgent bool) string {
|
||||
if !byAgent {
|
||||
return ""
|
||||
}
|
||||
if current == AgentMarkCreated {
|
||||
return AgentMarkCreated
|
||||
}
|
||||
return AgentMarkEdited
|
||||
}
|
||||
|
||||
// SetThoughtAgentMark records who changed a task last and returns the new mark.
|
||||
func (s *Store) SetThoughtAgentMark(thoughtID, current string, byAgent bool) string {
|
||||
mark := nextAgentMark(current, byAgent)
|
||||
if mark != current {
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET agent_mark = ? WHERE id = ?`, mark, thoughtID)
|
||||
}
|
||||
return mark
|
||||
}
|
||||
|
||||
// SetListItemAgentMark is SetThoughtAgentMark for list items; the current mark is read here because
|
||||
// the item queries after a change do not carry it.
|
||||
func (s *Store) SetListItemAgentMark(itemID string, byAgent bool) string {
|
||||
var current string
|
||||
_ = s.db.QueryRow(`SELECT agent_mark FROM list_items WHERE id = ?`, itemID).Scan(¤t)
|
||||
mark := nextAgentMark(current, byAgent)
|
||||
if mark != current {
|
||||
_, _ = s.db.Exec(`UPDATE list_items SET agent_mark = ? WHERE id = ?`, mark, itemID)
|
||||
}
|
||||
return mark
|
||||
}
|
||||
|
||||
// MarkListItemAgentCreated marks an item an agent just added.
|
||||
func (s *Store) MarkListItemAgentCreated(itemID string) string {
|
||||
_, _ = s.db.Exec(`UPDATE list_items SET agent_mark = ? WHERE id = ?`, AgentMarkCreated, itemID)
|
||||
return AgentMarkCreated
|
||||
}
|
||||
|
||||
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user