From 8c3aa33f134c91bf182967f2a35cccc563309751 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Wed, 7 Oct 2026 02:03:40 +0200 Subject: [PATCH] =?UTF-8?q?fix(udgaver):=20porten=20d=C3=A6kkede=2029=20ru?= =?UTF-8?q?ter=20og=20skulle=20d=C3=A6kke=2063=20=E2=80=94=20og=20fladen?= =?UTF-8?q?=20ringede=20stadig=20p=C3=A5=20d=C3=B8rene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prøven, der skal holde udgaverne adskilt, stod grøn gennem to merges, mens 34 opgaveruter blev betjent af noter-udgaven. Den fejlede ikke: den sammenligner routeren med en nedskreven liste, og listen var skrevet ud fra de ruter, nogen havde tænkt på. Den er en DRIFT-kontrol, og den kan ikke være en fuldstændighedskontrol. Det står nu i prøven selv. Lukket: CalDAV-træet (8 ruter, alle læser eller skriver en VTODO), .well-known/caldav (en påstand om at der er en CalDAV-server), ICS-feedet og de to ruter til dets token, de fire opgave-eksporter, Todoist- og CSV-importen, skabelonerne, sektionerne, de seks AI-opgaveruter og indbakke-krogen. Plus fem af ni MCP-værktøjer — endepunktet er der stadig, værktøjslisten er kortere. Tre blev afgjort ved at læse frem for ved navnet: en skabelon laver opgaver (CreateProjectFromTemplate), `section_id` står på `tasks` og findes ikke på `notes`, og add_comment hænger på en opgave. Men den dyre halvdel var fladen. En vandring gennem hver side, der kan nås i noter-udgaven, med konsollen og svarene aflæst, fandt SYV brud — fire af dem fra den første port: sidebjælken GET /filters 404 ved hver navigation, ædt af .catch() sidebjælken GET /labels 404 ved hver navigation, ædt af .catch() notifikationer GET /ai/plan 404, stille notesbogens side GET /tasks kastede → status = 'failed' notesbogens side GET /sections kastede → samme fejlskærm indstillinger GET /templates toast med fejlbesked på skærmen integrationer GET /feed 404, stille Den fjerde er den, der har kostet noget: /projekt/{id} er notesbogens side, og kaldene samles i et Promise.all, så én 404 tog hele siden med. Den har vist en fejlskærm frem for noter siden porten blev sat. Ingen prøve åbnede en notesbog i den udgave, og .catch(() => {}) sørgede for, at resten ikke sagde noget. "Fladen følger udgaven" var sandt om menuen og falsk om hentningerne. Vagten for opgaver står i butikken frem for ved kaldestederne: der var tre, og den fjerde blev glemt — hvilket er præcis sådan, den slap igennem. Og rettelsen lavede selv en fejl, som vandringen fangede: da opgavekaldet holdt op med at fejle, kom en uendelig effekt-løkke frem, som 404'eren havde holdt låg på. En ubetinget tilskrivning til app.tasks får projektsidens effekt til at kalde tilbage. 404'eren var låget på en løkke, ikke bare støj. Også: -timeout 20m i begge CI-workflows. internal/httpapi tog 664s her og 498s i CI på #95, mod Go's standardgrænse på 600 — og når den krydses, er beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, som læser som en deadlock i koden frem for som en suite, der blev for stor. watchForTrouble udskilt til web/e2e/trouble.js, så udgave-suiten kan bruge den samme. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/ci.yml | 8 +- .github/workflows/ci.yml | 8 +- README.md | 3 +- docs/editions.md | 41 ++++- internal/httpapi/edition_test.go | 145 ++++++++++++++++++ internal/httpapi/mcp_handlers.go | 135 ++++++++-------- internal/httpapi/server.go | 139 ++++++++++++----- web/e2e/edition.spec.js | 67 ++++++++ web/e2e/smoke.spec.js | 35 +---- web/e2e/trouble.js | 38 +++++ web/src/lib/components/Sidebar.svelte | 5 + web/src/lib/stores.svelte.js | 15 ++ .../routes/indstillinger/data/+page.svelte | 7 + .../indstillinger/integrationer/+page.svelte | 6 +- .../indstillinger/notifikationer/+page.svelte | 2 + web/src/routes/projekt/[id]/+page.svelte | 7 +- 16 files changed, 514 insertions(+), 147 deletions(-) create mode 100644 web/e2e/trouble.js diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 50425ec..ce14c4e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -97,7 +97,13 @@ jobs: # -race is the whole point of running tests in CI for a server with a # WebSocket fan-out: a data race is exactly the bug that passes locally. - name: Test - run: go test -race -count=1 ./... + # -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s: + # 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er + # beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og + # det læser som en deadlock i koden frem for som en suite, der blev for + # stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder + # "noget hænger" i stedet for "maskinen var langsom i dag". + run: go test -race -count=1 -timeout 20m ./... - name: Build run: CGO_ENABLED=0 go build ./... diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17644aa..395050e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,13 @@ jobs: # -race is the whole point of running tests in CI for a server with a # WebSocket fan-out: a data race is exactly the bug that passes locally. - name: Test - run: go test -race -count=1 ./... + # -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s: + # 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er + # beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og + # det læser som en deadlock i koden frem for som en suite, der blev for + # stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder + # "noget hænger" i stedet for "maskinen var langsom i dag". + run: go test -race -count=1 -timeout 20m ./... - name: Build run: CGO_ENABLED=0 go build ./... diff --git a/README.md b/README.md index 49ac577..6750b7d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ wood. Verdande is what is becoming; urd is what has become and was kept. Not a fork, because the two share everything that is hard — accounts, sharing, passkeys, the realtime socket, search, backups, migrations — and notes are about a twentieth of the Go here. A test walks both routers and fails in either direction, -so "the task routes are not there" is measured rather than promised. See +so "the task routes are not there" is measured rather than promised — 63 routes and +five of nine MCP tools. See [Two editions](docs/editions.md). ## Status diff --git a/docs/editions.md b/docs/editions.md index 57297b6..ff0451f 100644 --- a/docs/editions.md +++ b/docs/editions.md @@ -6,7 +6,7 @@ One program, built once, with two faces. |---|---|---| | What it is | Tasks, projects and notes | Notes | | `VERDANDE_EDITION` | `full` (the default) | `notes` | -| Routes served | 200 | 171 | +| Routes served | 200 | 137 | | Mark | ᚹ | ᚢ | Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not @@ -24,7 +24,7 @@ needed applying twice, by hand, forever. So the split is a configuration value read in one place, and the thing that keeps it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes` -walks both routers, compares them against a written-down list of the 29 withheld +walks both routers, compares them against a written-down list of the 63 withheld routes, and fails in **both** directions — a route that appears in the notes edition and a route that stops being withheld are both a failure. @@ -39,16 +39,45 @@ with no error anywhere and no way to tell from the outside. ## What urd does not have -The 29 withheld routes are tasks, sub-tasks, labels, saved filters, reminders, the -upcoming and delegated views, and the task half of search and export. Everything -else is the same program: +The 63 withheld routes: + +| | what | +|---|---| +| 29 | tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views | +| 8 | the CalDAV server — every route in it reads or writes a VTODO | +| 1 | `/.well-known/caldav`, which is a claim that a CalDAV server is here | +| 1 | the ICS calendar feed, plus 2 for the token it is reached with | +| 4 | the task exports: `tasks.ics`, `projects.zip`, and a project's `.csv` and `.ics` | +| 2 | the Todoist and CSV imports, which create tasks | +| 4 | project templates — a template is a project plus sections plus tasks | +| 5 | sections, which group tasks: `section_id` is a column on `tasks` and not on `notes` | +| 6 | the AI task features: split, tidy the inbox, and the day's plan | +| 1 | the inbox hook, which pushes a line of text in as a task | + +Plus five of the nine MCP tools — `search_tasks`, `create_task`, `update_task`, +`complete_task` and `add_comment`. The endpoint is still there; the tool list is +shorter. A tool a model can call is a surface, and one that answers "there are no +tasks" is worse than one that does not exist: the model tries it, gets an empty +answer, and tells the person there is nothing to do. + +Everything else is the same program: - accounts, invitations, passkeys and two-factor - projects — which in urd are what a note belongs to - per-note sharing with other people, independent of projects - search, over notes - export, of notes -- the mail, calendar and MCP surfaces, minus their task routes +- the mail, calendar and MCP surfaces, minus their task routes and task tools +- AI settings, `ask`, and the note actions + +!!! warning "Still being decided" + Four connectors are reachable in urd today and have not been settled either + way, because each has a plausible notes reading and withholding them would be + deciding what this product is rather than closing a hole: the mail-in address, + IMAP mailboxes, Gmail, and Google Calendar. They currently file what they + receive as **tasks**, so in urd they connect and then have nowhere to put + anything. The same applies to one handler rather than a route: + `POST /api/v1/ai/notes/{noteID}/actions/apply` creates tasks from a note. ## The name diff --git a/internal/httpapi/edition_test.go b/internal/httpapi/edition_test.go index 39bcc78..f84679c 100644 --- a/internal/httpapi/edition_test.go +++ b/internal/httpapi/edition_test.go @@ -2,6 +2,7 @@ package httpapi import ( "context" + "encoding/json" "io" "log/slog" "net/http" @@ -36,28 +37,69 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) { // Every route the full edition has and the notes edition does not. Written as // the whole list rather than a prefix rule: a prefix is a description of what // somebody meant, and this has to be a description of what is there. + // + // And note what this list could NOT catch, because it is the shape of mistake + // that outlived the first version of this test. The list went green at 29 + // routes for two merges while CalDAV, the ICS feed, the inbox hook, four task + // exports, both task imports, the project templates, the sections and six AI + // task endpoints were all still served by the notes edition — because the test + // compares the router against THIS LIST, and the list was written from the + // routes somebody had thought of. It is a drift check, and it cannot be an + // is-it-complete check. + // + // So when a route is added that one edition should not have, the question to + // ask is not "does the walk still pass" but "can this endpoint do anything at + // all without the half it is being withheld from". Three of the ones above were + // settled by reading rather than by the name: a template turned out to create + // tasks (CreateProjectFromTemplate), `section_id` turned out to exist on + // `tasks` and not on `notes`, and add_comment turned out to hang off a task. withheld := map[string]bool{} for _, r := range []string{ "DELETE /api/v1/filters/{filterID}", "DELETE /api/v1/labels/{labelID}", "DELETE /api/v1/reminders/{reminderID}", + "DELETE /api/v1/sections/{sectionID}", "DELETE /api/v1/tasks/{taskID}", + "DELETE /api/v1/templates/{templateID}", + "DELETE /caldav/{userID}/{projectID}/{taskFile}", + "GET /.well-known/caldav", + "GET /api/v1/ai/plan", "GET /api/v1/delegated", + "GET /api/v1/export/projects.zip", + "GET /api/v1/export/projects/{projectID}.csv", + "GET /api/v1/export/projects/{projectID}.ics", + "GET /api/v1/export/tasks.ics", + "GET /api/v1/feed", "GET /api/v1/filters", "GET /api/v1/filters/preview", "GET /api/v1/filters/{filterID}/tasks", "GET /api/v1/labels", + "GET /api/v1/projects/{projectID}/sections", "GET /api/v1/tasks", "GET /api/v1/tasks/quick-add/preview", "GET /api/v1/tasks/{taskID}", "GET /api/v1/tasks/{taskID}/comments", "GET /api/v1/tasks/{taskID}/reminders", + "GET /api/v1/templates", "GET /api/v1/upcoming", + "GET /caldav/{userID}/{projectID}/{taskFile}", + "GET /ics/{token}", + "OPTIONS /caldav/*", "PATCH /api/v1/filters/{filterID}", "PATCH /api/v1/labels/{labelID}", + "PATCH /api/v1/sections/{sectionID}", "PATCH /api/v1/tasks/{taskID}", + "POST /api/v1/ai/inbox/tidy", + "POST /api/v1/ai/inbox/tidy/apply", + "POST /api/v1/ai/plan/now", + "POST /api/v1/ai/tasks/{taskID}/split", + "POST /api/v1/feed/rotate", "POST /api/v1/filters", + "POST /api/v1/import/csv", + "POST /api/v1/import/todoist", "POST /api/v1/labels", + "POST /api/v1/projects/{projectID}/sections", + "POST /api/v1/projects/{projectID}/sections/reorder", "POST /api/v1/tasks", "POST /api/v1/tasks/quick-add", "POST /api/v1/tasks/{taskID}/attachments", @@ -67,6 +109,15 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) { "POST /api/v1/tasks/{taskID}/reminders", "POST /api/v1/tasks/{taskID}/reopen", "POST /api/v1/tasks/{taskID}/snooze", + "POST /api/v1/templates", + "POST /api/v1/templates/{templateID}/use", + "POST /inbound/hook/{token}", + "PROPFIND /caldav", + "PROPFIND /caldav/{userID}", + "PROPFIND /caldav/{userID}/{projectID}", + "PUT /api/v1/ai/plan", + "PUT /caldav/{userID}/{projectID}/{taskFile}", + "REPORT /caldav/{userID}/{projectID}", } { withheld[r] = true } @@ -115,10 +166,25 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) { for _, must := range []string{ "GET /api/v1/notes", "POST /api/v1/notes", + "PATCH /api/v1/notes/{noteID}", + "DELETE /api/v1/notes/{noteID}", + // Deling er hele grunden til at det er et selvstændigt produkt frem for en + // enkeltbrugers notesblok, så den halvdel skal navngives her. + "POST /api/v1/notes/{noteID}/shares", + "GET /api/v1/notes/{noteID}/shares", + "POST /api/v1/notes/{noteID}/attachments", + "POST /api/v1/notes/import", "GET /api/v1/export/notes.zip", + "GET /api/v1/export/account", + "POST /api/v1/ai/notes/{noteID}/actions", "GET /api/v1/projects", // a notebook, in this edition "GET /api/v1/search", "GET /api/v1/auth/me", + // 34 ruter blev lukket i den anden omgang af denne port. Den her linje er + // den, der afgør, at det var en port: MCP-endepunktet er stadig der, og det + // er værktøjslisten bagved, der er kortere. + "POST /api/v1/mcp", + "POST /mcp", } { if !notes[must] { t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must) @@ -228,3 +294,82 @@ func TestNotesEditionSearchFindsNoTasks(t *testing.T) { } } } + +// TestNotesEditionOffersNoTaskToolsOverMCP. +// +// The same hole as the routes, through a different door — and it was still open +// after the first gate. An MCP server advertises its tools, so a model connected +// to a notes instance would be told it can create_task, call it, get something +// back, and tell the person their task was filed. +// +// Asked through tools/list rather than by reading the server's own slice: that is +// what a client actually sees, and the slice is unexported for the same reason. +func TestNotesEditionOffersNoTaskToolsOverMCP(t *testing.T) { + taskTools := []string{"search_tasks", "create_task", "update_task", "complete_task", "add_comment"} + noteTools := []string{"search_notes", "create_note", "update_note"} + + for _, edition := range []string{config.EditionFull, config.EditionNotes} { + t.Run(edition, func(t *testing.T) { + names := mcpToolNames(t, edition) + + // Both editions: the shared tool, and the notes half. + for _, want := range append([]string{"list_projects"}, noteTools...) { + if !names[want] { + t.Errorf("%s edition does not offer %s", edition, want) + } + } + for _, task := range taskTools { + switch edition { + case config.EditionFull: + if !names[task] { + t.Errorf("the full edition lost %s — that is a deletion, not a gate", task) + } + default: + if names[task] { + t.Errorf("the notes edition offers %s over MCP", task) + } + } + } + t.Logf("%s: %d tools", edition, len(names)) + }) + } +} + +// mcpToolNames asks one edition's MCP server for its tool list. +func mcpToolNames(t *testing.T, edition string) map[string]bool { + t.Helper() + + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + cfg := &config.Config{BaseURL: "http://localhost", DataDir: t.TempDir(), SessionTTL: time.Hour, Edition: edition} + srv := New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + + resp := srv.mcp.Handle(context.Background(), "nobody", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)) + if resp == nil { + t.Fatal("tools/list got no response") + } + raw, err := json.Marshal(resp) + if err != nil { + t.Fatal(err) + } + var parsed struct { + Result struct { + Tools []struct{ Name string } `json:"tools"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("tools/list: %v — %s", err, raw) + } + if len(parsed.Result.Tools) == 0 { + t.Fatalf("tools/list returned no tools at all: %s", raw) + } + names := map[string]bool{} + for _, tool := range parsed.Result.Tools { + names[tool.Name] = true + } + return names +} diff --git a/internal/httpapi/mcp_handlers.go b/internal/httpapi/mcp_handlers.go index 2039ce1..6792ae2 100644 --- a/internal/httpapi/mcp_handlers.go +++ b/internal/httpapi/mcp_handlers.go @@ -23,7 +23,9 @@ import ( // `search_tasks` and `list_projects` is answering "which of these gets me closer", // and a description that only names the endpoint gives it nothing to decide with. func (s *Server) buildMCP() *mcp.Server { - m := mcp.NewServer("verdande", "1") + // Udgavens eget navn: det er sådan, instansen står i klientens + // forbindelsesliste. + m := mcp.NewServer(s.cfg.ProductSlug(), "1") m.Register(mcp.Tool{ Name: "list_projects", @@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server { }), }, s.mcpListProjects) - m.Register(mcp.Tool{ - Name: "search_tasks", - Description: "Find tasks by text, project, label, priority, due date or assignee. " + - "This is the tool for any question about what the user has to do — " + - "\"what is due today\", \"what is overdue\", \"anything about the tax return\". " + - "The query accepts the same filter language as the app: \"today & p1\", " + - "\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".", - InputSchema: mcp.Schema(map[string]any{ - "query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."), - "text": mcp.Str("Free text to search task titles and descriptions for."), - "limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."), - "include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."), - }), - }, s.mcpSearchTasks) + // De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne: + // et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der + // svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen + // prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at + // lave. add_comment er med her: en kommentar hænger på en opgave. + if !s.cfg.NotesOnly() { + m.Register(mcp.Tool{ + Name: "search_tasks", + Description: "Find tasks by text, project, label, priority, due date or assignee. " + + "This is the tool for any question about what the user has to do — " + + "\"what is due today\", \"what is overdue\", \"anything about the tax return\". " + + "The query accepts the same filter language as the app: \"today & p1\", " + + "\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".", + InputSchema: mcp.Schema(map[string]any{ + "query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."), + "text": mcp.Str("Free text to search task titles and descriptions for."), + "limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."), + "include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."), + }), + }, s.mcpSearchTasks) - m.Register(mcp.Tool{ - Name: "create_task", - Description: "Create a task. Prefer passing the whole thing as natural language in " + - "`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " + - "parser handles dates, times, priorities, projects, labels and recurrence in Danish " + - "and English. Use the explicit fields only when the user was explicit.", - InputSchema: mcp.Schema(map[string]any{ - "text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."), - "content": mcp.Str("The task title, if not using `text`."), - "project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."), - "parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."), - "due_date": mcp.Str("A date as YYYY-MM-DD."), - "priority": mcp.Int("1 (highest) to 4 (none)."), - "description": mcp.Str("Longer notes on the task."), - "labels": mcp.StrArray("Label names."), - }), - }, s.mcpCreateTask) + m.Register(mcp.Tool{ + Name: "create_task", + Description: "Create a task. Prefer passing the whole thing as natural language in " + + "`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " + + "parser handles dates, times, priorities, projects, labels and recurrence in Danish " + + "and English. Use the explicit fields only when the user was explicit.", + InputSchema: mcp.Schema(map[string]any{ + "text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."), + "content": mcp.Str("The task title, if not using `text`."), + "project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."), + "parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."), + "due_date": mcp.Str("A date as YYYY-MM-DD."), + "priority": mcp.Int("1 (highest) to 4 (none)."), + "description": mcp.Str("Longer notes on the task."), + "labels": mcp.StrArray("Label names."), + }), + }, s.mcpCreateTask) - m.Register(mcp.Tool{ - Name: "update_task", - Description: "Change an existing task: its title, description, priority, due date or " + - "project. Only the fields given are changed. To move a due date, pass due_date; " + - "to clear it, pass an empty string.", - InputSchema: mcp.Schema(map[string]any{ - "task_id": mcp.Str("The task to change."), - "content": mcp.Str("A new title."), - "description": mcp.Str("New notes."), - "priority": mcp.Int("1 (highest) to 4 (none)."), - "due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."), - "project_id": mcp.Str("Move the task to another project."), - "labels": mcp.StrArray("Replace the task's labels."), - }, "task_id"), - }, s.mcpUpdateTask) + m.Register(mcp.Tool{ + Name: "update_task", + Description: "Change an existing task: its title, description, priority, due date or " + + "project. Only the fields given are changed. To move a due date, pass due_date; " + + "to clear it, pass an empty string.", + InputSchema: mcp.Schema(map[string]any{ + "task_id": mcp.Str("The task to change."), + "content": mcp.Str("A new title."), + "description": mcp.Str("New notes."), + "priority": mcp.Int("1 (highest) to 4 (none)."), + "due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."), + "project_id": mcp.Str("Move the task to another project."), + "labels": mcp.StrArray("Replace the task's labels."), + }, "task_id"), + }, s.mcpUpdateTask) - m.Register(mcp.Tool{ - Name: "complete_task", - Description: "Mark a task done. A repeating task advances to its next occurrence " + - "instead of closing, and the result says so.", - InputSchema: mcp.Schema(map[string]any{ - "task_id": mcp.Str("The task to complete."), - }, "task_id"), - }, s.mcpCompleteTask) + m.Register(mcp.Tool{ + Name: "complete_task", + Description: "Mark a task done. A repeating task advances to its next occurrence " + + "instead of closing, and the result says so.", + InputSchema: mcp.Schema(map[string]any{ + "task_id": mcp.Str("The task to complete."), + }, "task_id"), + }, s.mcpCompleteTask) - m.Register(mcp.Tool{ - Name: "add_comment", - Description: "Add a comment to a task. Everyone else with access to the project is notified.", - InputSchema: mcp.Schema(map[string]any{ - "task_id": mcp.Str("The task to comment on."), - "body": mcp.Str("What to say."), - }, "task_id", "body"), - }, s.mcpAddComment) + m.Register(mcp.Tool{ + Name: "add_comment", + Description: "Add a comment to a task. Everyone else with access to the project is notified.", + InputSchema: mcp.Schema(map[string]any{ + "task_id": mcp.Str("The task to comment on."), + "body": mcp.Str("What to say."), + }, "task_id", "body"), + }, s.mcpAddComment) + } s.registerNoteTools(m) return m diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7821668..5fa464f 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -159,13 +159,31 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server // Docker both need this to answer before anyone has logged in. r.Get("/healthz", s.handleHealth) + // The same question as inside /api/v1, asked again here because four of the + // task-only surfaces live outside it: the calendar feed, CalDAV, the inbox + // hook, and the well-known path CalDAV clients discover through. They are out + // here because their callers cannot hold a session, not because they are any + // less about tasks — and the first gate missed all four for exactly that + // reason. It read as complete because the /tasks tree was covered. + full := !s.cfg.NotesOnly() + // The calendar feed is outside /api/v1 and outside the session: a calendar // client cannot log in, so the token in the path is the whole credential. - r.Get("/ics/{token}", s.handleICSFeed) + // + // A feed of VTODOs, so there is nothing for it to answer in the notes edition. + if full { + r.Get("/ics/{token}", s.handleICSFeed) - // CalDAV, outside /api/v1 because clients do well-known-path discovery - // against the root and authenticate with Basic rather than a session. - r.Get("/.well-known/caldav", s.wellKnownCalDAV) + // CalDAV, outside /api/v1 because clients do well-known-path discovery + // against the root and authenticate with Basic rather than a session. + // + // Withheld rather than left to answer an empty collection: the well-known + // path is a claim that a CalDAV server is here, and a client that believes + // it goes on to PROPFIND, authenticate, and sync nothing — reporting a + // broken calendar server on an instance that has none and needs none. Same + // reasoning as the /.well-known/ 404 in serveWeb. + r.Get("/.well-known/caldav", s.wellKnownCalDAV) + } // Inbound mail. Delivered by the mail server rather than by a browser, and // authenticated by the token in the recipient address. @@ -190,8 +208,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server // // Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke // for den indgående post, og omvendt. - r.Method(http.MethodPost, "/inbound/hook/{token}", - s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook))) + // + // Opgaver, efter sin egen beskrivelse ovenfor — den skubber en linje tekst ind + // i indbakken — så den findes ikke i noter-udgaven. + if full { + r.Method(http.MethodPost, "/inbound/hook/{token}", + s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook))) + } // MCP with the token in the query string, for clients that cannot send a // header. Claude's custom-connector dialog takes a URL and nothing else, so @@ -218,7 +241,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server // Gmail connection with calendar tokens. r.Get("/oauth/calendar/callback", s.handleCalendarCallback) }) - r.Route("/caldav", s.caldavRoutes) + // Hele CalDAV-træet: otte ruter, der alle læser eller skriver en VTODO. + if full { + r.Route("/caldav", s.caldavRoutes) + } r.Route("/api/v1", func(r chi.Router) { r.Use(s.requireCSRF) @@ -346,7 +372,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server }) } - r.Get("/feed", s.handleGetFeed) + // Tokenet til ICS-feedet ovenfor. Et token til et feed, der ikke + // findes, er en indstillingsside, der tilbyder en adresse, intet + // svarer på. + if full { + r.Get("/feed", s.handleGetFeed) + } r.Route("/notifications", func(r chi.Router) { r.Get("/", s.handleListNotifications) @@ -372,7 +403,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Patch("/", s.handleUpdateComment) r.Delete("/", s.handleDeleteComment) }) - r.Post("/feed/rotate", s.handleRotateFeed) + if full { + r.Post("/feed/rotate", s.handleRotateFeed) + } r.Get("/mail-address", s.handleGetMailAddress) r.Post("/mail-address/rotate", s.handleRotateMailAddress) @@ -393,20 +426,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Get("/settings", s.handleGetAISettings) r.Put("/settings", s.handleSetAISettings) r.Post("/summary", s.handleAISummary) - r.Post("/tasks/{taskID}/split", s.handleAISplit) - // Forslag, ikke skrivninger. De to første svarer med linjer i - // quick-add-syntaks, som fladen viser frem; de to sidste er dét, - // der sker, når nogen siger ja til én af dem. - r.Post("/inbox/tidy", s.handleAITidyInbox) - r.Post("/inbox/tidy/apply", s.handleAIApplyToTask) + if full { + r.Post("/tasks/{taskID}/split", s.handleAISplit) + // Forslag, ikke skrivninger. De to første svarer med linjer i + // quick-add-syntaks, som fladen viser frem; de to sidste er dét, + // der sker, når nogen siger ja til én af dem. + r.Post("/inbox/tidy", s.handleAITidyInbox) + r.Post("/inbox/tidy/apply", s.handleAIApplyToTask) + } r.Post("/notes/{noteID}/actions", s.handleAINoteActions) r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote) // Dagens plan: indstillingen, og en knap til at se den nu. En // indstilling, man skal vente til i morgen for at se virkningen af, // er en indstilling, ingen tør slå til. - r.Get("/plan", s.handleGetPlanSettings) - r.Put("/plan", s.handleSetPlanSettings) - r.Post("/plan/now", s.handlePlanNow) + if full { + r.Get("/plan", s.handleGetPlanSettings) + r.Put("/plan", s.handleSetPlanSettings) + r.Post("/plan/now", s.handlePlanNow) + } // Spørg om sine egne noter og opgaver i almindeligt sprog. Basen // finder kandidaterne, modellen læser dem — og svaret bærer, hvad // det er bygget på. @@ -491,24 +528,38 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar) }) - r.Route("/import", func(r chi.Router) { - r.Post("/todoist", s.handleImportTodoist) - r.Post("/csv", s.handleImportCSV) - }) + // Begge retninger laver eller læser opgaver: Todoist-importen laver + // dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem + // ud. De blev alle betjent i noter-udgaven indtil nu — de svarede tomt + // frem for at fejle, hvilket er den stille version af samme fejl. + if full { + r.Route("/import", func(r chi.Router) { + r.Post("/todoist", s.handleImportTodoist) + r.Post("/csv", s.handleImportCSV) + }) + } r.Get("/export/account", s.handleExportAccount) r.Get("/export/notes.zip", s.handleExportNotes) - r.Get("/export/projects.zip", s.handleExportAllCSV) - r.Get("/export/tasks.ics", s.handleExportAllICS) - r.Get("/export/projects/{projectID}.csv", s.handleExportProject) - r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS) + if full { + r.Get("/export/projects.zip", s.handleExportAllCSV) + r.Get("/export/tasks.ics", s.handleExportAllICS) + r.Get("/export/projects/{projectID}.csv", s.handleExportProject) + r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS) + } - r.Route("/templates", func(r chi.Router) { - r.Get("/", s.handleListTemplates) - r.Post("/", s.handleSaveTemplate) - r.Post("/{templateID}/use", s.handleUseTemplate) - r.Delete("/{templateID}", s.handleDeleteTemplate) - }) + // En skabelon er et projekt plus sektioner plus opgaver — læst i + // CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som + // kun siger "projekt". Uden opgaver er den et tomt projekt med + // overskrifter i. + if full { + r.Route("/templates", func(r chi.Router) { + r.Get("/", s.handleListTemplates) + r.Post("/", s.handleSaveTemplate) + r.Post("/{templateID}/use", s.handleUseTemplate) + r.Delete("/{templateID}", s.handleDeleteTemplate) + }) + } if full { r.Delete("/reminders/{reminderID}", s.handleDeleteReminder) @@ -539,7 +590,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Use(s.requireProject(store.RoleViewer)) r.Get("/", s.handleGetProject) - r.Get("/sections", s.handleListSections) + // Sektioner grupperer opgaver inde i et projekt, og det er + // afgjort af skemaet frem for af navnet: `section_id` står på + // `tasks` og findes ikke på `notes`. En sektion i en notesbog + // kan ikke indeholde noget. + if full { + r.Get("/sections", s.handleListSections) + } r.Get("/members", s.handleListMembers) // Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der // fjerner nogen, og den, der selv går. Handleren skiller dem ad. @@ -548,8 +605,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Group(func(r chi.Router) { r.Use(s.requireProject(store.RoleEditor)) - r.Post("/sections", s.handleCreateSection) - r.Post("/sections/reorder", s.handleReorderSections) + if full { + r.Post("/sections", s.handleCreateSection) + r.Post("/sections/reorder", s.handleReorderSections) + } }) r.Group(func(r chi.Router) { @@ -624,10 +683,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Get("/trash/projects", s.handleListTrashedProjects) r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject) - r.Route("/sections/{sectionID}", func(r chi.Router) { - r.Patch("/", s.handleUpdateSection) - r.Delete("/", s.handleDeleteSection) - }) + if full { + r.Route("/sections/{sectionID}", func(r chi.Router) { + r.Patch("/", s.handleUpdateSection) + r.Delete("/", s.handleDeleteSection) + }) + } if full { r.Route("/tasks", func(r chi.Router) { diff --git a/web/e2e/edition.spec.js b/web/e2e/edition.spec.js index 7defacd..a679768 100644 --- a/web/e2e/edition.spec.js +++ b/web/e2e/edition.spec.js @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { watchForTrouble } from './trouble.js'; /** * Noter-udgaven, kørt som sit eget program. @@ -179,3 +180,69 @@ test('navnet står også i teksterne, ikke kun i mærket', async ({ page }) => { await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible(); await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0); }); + +/** + * Hver side, der KAN nås i noter-udgaven, må ikke kalde noget, der er lukket. + * + * Det er den halvdel, en port let glemmer, og den fejler stille: lukkes en rute, + * fladen stadig henter, svarer serveren 404, komponenten fanger det, og siden + * tegner uden den sektion — pænt, tomt, og uden at nogen får at vide, at noget + * manglede. Der er ingen fejlskærm at se. + * + * Målt på svarene frem for på det tegnede: en manglende sektion ser ud som en + * sektion, der ikke var noget i. + * + * Skrevet før ruterne blev lukket, og den fangede SYV — fire af dem fra den første + * port, som havde været i drift i to merges: sidebjælken hentede /filters og + * /labels ved hver navigation, notifikationssiden hentede /ai/plan, og + * notesbogens egen side hentede /tasks. Den sidste var ikke støj: kaldene samles i + * et Promise.all, så 404'eren kastede og siden viste `status = 'failed'` frem for + * notesbogens noter. + * + * Og den fangede én mere, som rettelsen selv lavede: da opgavekaldet holdt op med + * at fejle, kom en uendelig effekt-løkke frem, som 404'eren havde holdt låg på + * (effect_update_depth_exceeded). Derfor læser den konsollen og ikke kun svarene. + * + * Hvad den IKKE dækker, sagt her frem for underforstået: elleve sider plus ét + * projekt. En gruppe (/gruppe/{id}) og en åben note er ikke med, fordi begge + * kræver, at prøven først laver en — og skuffer og dialoger, der åbnes ved et + * klik, er slet ikke besøgt. En grøn her betyder "ingen af de tolv adresser", + * ikke "ingen flade". + */ +test('ingen side kalder en rute, udgaven ikke har', async ({ page }) => { + const trouble = watchForTrouble(page); + + // Projektet skal med som en rigtig adresse: /projekt/{id} er en notesbog her, + // og den er den eneste side med en id i stien, der kan nås. + await page.goto('/noter'); + const projekter = await (await page.request.get('/api/v1/projects')).json(); + const id = (projekter.projects ?? projekter)[0]?.id; + expect(id, 'der skal findes mindst ét projekt at åbne').toBeTruthy(); + + const sider = [ + '/noter', + '/genveje', + '/indstillinger', + '/indstillinger/ai', + '/indstillinger/brugere', + '/indstillinger/data', + '/indstillinger/fejl', + '/indstillinger/historik', + '/indstillinger/integrationer', + '/indstillinger/notifikationer', + '/indstillinger/tokens', + `/projekt/${id}` + ]; + + for (const side of sider) { + await page.goto(side); + // Ventet på at fladen er tegnet, så de kald siden laver ved opstart er + // nået at komme afsted. Uden den her går løkken videre, før svarene er + // kommet, og prøven måler ingenting. + await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); + await page.waitForLoadState('networkidle'); + } + + // Tallet med på det grønne: elleve sider og et projekt, frem for "ingen fejl". + expect(trouble, `besøgte ${sider.length} sider`).toEqual([]); +}); diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index bfa3849..899ae20 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { USER } from './user.js'; +import { watchForTrouble } from './trouble.js'; /** * The four flows a broken build must not survive: add a task, tick it off, reach @@ -11,14 +12,6 @@ import { USER } from './user.js'; * precisely the bug this suite exists to catch. */ -/** - * Collects anything that went wrong quietly: a thrown exception, a console error, - * or an API call that came back a failure. - * - * Failed responses are recorded with their URL and status. "A console error - * happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned - * 500" is. - */ /** * Opens the project header's overflow menu and clicks one of its items. * @@ -93,32 +86,6 @@ async function nyNote(side) { return felt; } -function watchForTrouble(page) { - const trouble = []; - page.on('console', (message) => { - // The browser logs its own line for every failed fetch. It carries no URL, - // so it is noise next to the response listener below. - if (message.type() === 'error' && !message.text().includes('Failed to load resource')) { - trouble.push(message.text()); - } - }); - page.on('pageerror', (error) => trouble.push(String(error))); - page.on('response', (response) => { - const url = response.url(); - if (!url.includes('/api/') || response.ok()) return; - - // A 401 from /auth/me is the app asking "am I signed in?" and being told no, - // which is the correct answer on the sign-in page and after signing out. - // Every other non-ok response is worth failing over — that is what this - // watcher is for. - const path = new URL(url).pathname; - if (path.endsWith('/auth/me') && response.status() === 401) return; - - trouble.push(`${response.request().method()} ${path} → ${response.status()}`); - }); - return trouble; -} - /** * Kommendes liste kan se længere frem end en uge. * diff --git a/web/e2e/trouble.js b/web/e2e/trouble.js new file mode 100644 index 0000000..b6943f4 --- /dev/null +++ b/web/e2e/trouble.js @@ -0,0 +1,38 @@ +/** + * Collects anything that went wrong quietly: a thrown exception, a console error, + * or an API call that came back a failure. + * + * Failed responses are recorded with their URL and status. "A console error + * happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned + * 500" is. + * + * Udskilt fra smoke.spec.js, så udgave-suiten kan bruge den samme — og den + * fortjente flytningen med det samme: lukker man en rute, fladen stadig kalder, + * står der en 404 på en side, der ser helt rigtig ud, og det her er det eneste, + * der ser den. + */ +export function watchForTrouble(page) { + const trouble = []; + page.on('console', (message) => { + // The browser logs its own line for every failed fetch. It carries no URL, + // so it is noise next to the response listener below. + if (message.type() === 'error' && !message.text().includes('Failed to load resource')) { + trouble.push(message.text()); + } + }); + page.on('pageerror', (error) => trouble.push(String(error))); + page.on('response', (response) => { + const url = response.url(); + if (!url.includes('/api/') || response.ok()) return; + + // A 401 from /auth/me is the app asking "am I signed in?" and being told no, + // which is the correct answer on the sign-in page and after signing out. + // Every other non-ok response is worth failing over — that is what this + // watcher is for. + const path = new URL(url).pathname; + if (path.endsWith('/auth/me') && response.status() === 401) return; + + trouble.push(`${response.request().method()} ${path} → ${response.status()}`); + }); + return trouble; +} diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index af58039..fc15bdd 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -73,6 +73,11 @@ // through the connector. $effect(() => { app.labelsChanged; + // Ikke i noter-udgaven: begge ruter findes slet ikke der, så de to kald var + // to 404'ere ved hver navigation — opslugt af .catch() og derfor usynlige. + // Fundet af vandringen i edition.spec.js, efter at porten havde været i + // drift i to merges. + if (app.notesOnly) return; api.listFilters().then((r) => (filters = r.filters)).catch(() => {}); api.listLabels().then((r) => (labels = r.labels)).catch(() => {}); }); diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 284e413..d21532e 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -343,6 +343,21 @@ class AppState { * `completed: 'exclude'`. */ async loadTasks(params) { + // Noter-udgaven har ingen opgaveruter, så den her ville kaste en 404 — og + // kalderne samler den i et Promise.all, hvor én fejl tager hele siden med. + // Vagten står HER frem for ved de tre kaldesteder, fordi butikken er det + // ene sted, der kender udgaven: en vagt pr. kalder er tre steder at glemme + // den fjerde, og det var præcis sådan den her slap igennem. + if (this.notesOnly) { + // Skrevet kun hvis der ER noget at rydde. En ubetinget tilskrivning + // looper: projektsiden har en effekt, der læser app.tasks og kalder + // herind, så et skriv ved hvert kald er en uendelig runde — + // effect_update_depth_exceeded, ikke en 404. Før vagten kastede kaldet + // på 404'eren og nåede aldrig at skrive, så løkken var skjult bag en + // fejl. + if (this.tasks.length) this.tasks = []; + return this.tasks; + } const { tasks } = await api.listTasks({ completed: completedView.shown ? 'include' : undefined, ...params diff --git a/web/src/routes/indstillinger/data/+page.svelte b/web/src/routes/indstillinger/data/+page.svelte index b7fac1c..eeaee68 100644 --- a/web/src/routes/indstillinger/data/+page.svelte +++ b/web/src/routes/indstillinger/data/+page.svelte @@ -141,6 +141,13 @@ let startDate = $state(''); $effect(() => { + // Skabeloner er projekt plus sektioner plus opgaver, så ruten findes ikke i + // noter-udgaven — og den her fangede fejlen i en toast, altså en synlig + // fejlbesked på indstillingssiden frem for en tom liste. + if (app.notesOnly) { + loadingTemplates = false; + return; + } api .listTemplates() .then((r) => (templates = r.templates)) diff --git a/web/src/routes/indstillinger/integrationer/+page.svelte b/web/src/routes/indstillinger/integrationer/+page.svelte index f7b3e20..13a5de0 100644 --- a/web/src/routes/indstillinger/integrationer/+page.svelte +++ b/web/src/routes/indstillinger/integrationer/+page.svelte @@ -296,7 +296,11 @@ let mailConfigured = $state(null); $effect(() => { - api.feed().then((r) => (feedURL = r.url)).catch(() => {}); + // Feedet er en kalender af opgaver, så adressen findes ikke i + // noter-udgaven. + if (!app.notesOnly) { + api.feed().then((r) => (feedURL = r.url)).catch(() => {}); + } api .getMailAddress() .then((r) => { diff --git a/web/src/routes/indstillinger/notifikationer/+page.svelte b/web/src/routes/indstillinger/notifikationer/+page.svelte index 8f8b205..df4de97 100644 --- a/web/src/routes/indstillinger/notifikationer/+page.svelte +++ b/web/src/routes/indstillinger/notifikationer/+page.svelte @@ -92,6 +92,8 @@ let planBusy = $state(false); $effect(() => { + // Dagens plan er dagens opgaver. + if (app.notesOnly) return; api .planSettings() .then((p) => (plan = p)) diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 86128f4..dfff58f 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -88,9 +88,14 @@ if (!projectId) return; status = 'loading'; try { + // Sektioner findes ikke i noter-udgaven, hvor et projekt er en notesbog + // — og det er ikke bare støj: Promise.all kaster på den første, der + // fejler, så en 404 herfra satte status = 'failed', og siden viste en + // fejlskærm frem for notesbogens noter. `loadTasks` vogter sig selv i + // butikken, fordi den har tre kaldesteder her. const [p, s] = await Promise.all([ api.getProject(projectId), - api.listSections(projectId), + app.notesOnly ? Promise.resolve({ sections: [] }) : api.listSections(projectId), app.loadTasks({ project_id: projectId }) ]); project = p;