Make delivery and integration failures explicit

Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
This commit is contained in:
2026-08-13 02:50:59 +04:00
parent da9114b623
commit 35c6ff5a71
67 changed files with 3174 additions and 477 deletions
+52 -21
View File
@@ -5,7 +5,6 @@ import (
_ "embed"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
@@ -88,7 +87,7 @@ func rowOf(t ipc.Task) taskRow {
// from something she read into work he owns. That review step is why derived
// tasks are captured as candidates in the first place.
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "tasks") {
if !requireCore(w, r, core, "tasks") {
return
}
ctx := r.Context()
@@ -97,15 +96,14 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
var err error
msg, err = applyTaskPost(ctx, core, r)
if err != nil {
log.Printf("tasks: %v", err)
errMsg = err.Error()
errMsg = inlineProblem(r, problemCoreChangeFailed, taskPublicMessage(err), err)
}
}
all, err := core.ListTasks(ctx, "")
if err != nil {
log.Printf("tasks: list: %v", err)
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
"tasks unavailable", fmt.Errorf("list tasks: %w", err))
return
}
// Live rows are ordered by the same ranker the spoken list uses, so the page
@@ -162,6 +160,39 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
}{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)})
}
// taskInputError is a form/domain refusal safe to show back to the owner. IPC,
// storage and transport errors never use this type and therefore receive the
// generic task failure text plus a request reference.
type taskInputError struct{ message string }
func (e *taskInputError) Error() string { return e.message }
func taskInput(message string) error { return &taskInputError{message: message} }
func taskInputf(format string, args ...any) error {
return &taskInputError{message: fmt.Sprintf(format, args...)}
}
type taskPartialError struct {
public string
err error
}
func (e *taskPartialError) Error() string { return e.public + ": " + e.err.Error() }
func (e *taskPartialError) Unwrap() error { return e.err }
func taskPublicMessage(err error) string {
var input *taskInputError
if errors.As(err, &input) {
return input.message
}
var partial *taskPartialError
if errors.As(err, &partial) {
return partial.public
}
return "task update failed"
}
// applyTaskPost performs one write and returns the message to show. A bad
// request returns an error, which the page renders inline rather than as a
// bare 400 — this is a form surface, not an API.
@@ -170,7 +201,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
if action == "add" {
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
return "", errors.New("empty task text")
return "", taskInput("empty task text")
}
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
wgt, err := formWeight(r)
@@ -198,7 +229,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil {
return "", errors.New("invalid id")
return "", taskInput("invalid id")
}
if action == "promote" {
@@ -214,7 +245,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
// is not editable here: that ladder is one-way and has its own buttons.
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
return "", errors.New("empty task text")
return "", taskInput("empty task text")
}
wgt, err := formWeight(r)
if err != nil {
@@ -230,9 +261,9 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
case errors.Is(err, ipc.ErrTaskDuplicate):
// Naming the collision instead of merging: two live rows carry two
// provenances, and picking one is not the page's call.
return "", errors.New("another open task already says this — drop one of the two")
return "", taskInput("another open task already says this — drop one of the two")
case errors.Is(err, ipc.ErrTaskResolved):
return "", errors.New("a resolved task keeps the text it was finished under")
return "", taskInput("a resolved task keeps the text it was finished under")
default:
return "", err
}
@@ -247,7 +278,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
case "drop":
status, msg = "dropped", "dropped task"
default:
return "", fmt.Errorf("unknown action %q", action)
return "", taskInputf("unknown action %q", action)
}
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
return "", statusWriteErr(err)
@@ -257,7 +288,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
// errNoDoneWhen — the refusal has to name what is missing, or the button looks
// broken. The field it asks for arrives with the intake form (Vikunja #511).
var errNoDoneWhen = errors.New("write a definition of done before confirming this candidate")
var errNoDoneWhen = taskInput("write a definition of done before confirming this candidate")
// statusWriteErr translates a SetTaskStatus failure into what the page says.
func statusWriteErr(err error) error {
@@ -281,11 +312,11 @@ func statusWriteErr(err error) error {
func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) {
doneWhen := strings.TrimSpace(r.FormValue("done_when"))
if doneWhen == "" {
return "", errors.New("write a definition of done — what has to be true for this to be finished")
return "", taskInput("write a definition of done — what has to be true for this to be finished")
}
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
return "", errors.New("empty task text")
return "", taskInput("empty task text")
}
due, err := formDue(r, now())
if err != nil {
@@ -326,7 +357,7 @@ func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id
if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil {
// The task IS promoted; only the reminder failed. Saying "confirmed"
// and nothing else would leave him expecting a nudge that will not come.
return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err)
return "", &taskPartialError{public: "task confirmed, but the reminder did not save", err: err}
}
return "confirmed, and maven will remind you that morning", nil
}
@@ -343,15 +374,15 @@ func resolveBlocker(ctx context.Context, core ipc.CoreAPI, field string) (string
ref, err := core.ResolveEntity(ctx, name, []string{"person"})
switch {
case errors.Is(err, ipc.ErrNotImplemented):
return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty")
return "", taskInput("no identity service here, so blocked-on cannot be stored — leave it empty")
case errors.Is(err, ipc.ErrNoEntity):
return "", fmt.Errorf("nexus does not know %q", name)
return "", taskInputf("nexus does not know %q", name)
case err != nil:
return "", fmt.Errorf("resolving %q: %w", name, err)
case ref.Ambiguous:
// Asking, not picking: a task blocked on the wrong person is a
// mistake nobody can see afterwards.
return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", "))
return "", taskInputf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", "))
}
return ref.ID, nil
}
@@ -366,7 +397,7 @@ func formWeight(r *http.Request) (int, error) {
}
wgt, err := strconv.Atoi(v)
if err != nil || wgt < 0 {
return 0, fmt.Errorf("bad weight %q", v)
return 0, taskInputf("bad weight %q", v)
}
if wgt > tasks.MaxWeight {
wgt = tasks.MaxWeight
@@ -383,7 +414,7 @@ func formDue(r *http.Request, now time.Time) (*time.Time, error) {
}
due, err := time.ParseInLocation("2006-01-02", d, now.Location())
if err != nil {
return nil, fmt.Errorf("bad due date %q", d)
return nil, taskInputf("bad due date %q", d)
}
return &due, nil
}