a live task can be edited, a resolved one cannot (V-509)

SetTaskStatus was the only mutation on a task row, so a typo in a dictated
task was permanent and a deadline could not move. EditTask rewrites the three
fields capture set — text, due date and weight — and nothing else. Status
stays the one-way ladder SetTaskStatus owns.

Two things the task asked to settle.

A text edit re-normalises the dedupe key and can collide with another live
row. That is ErrTaskDuplicate, a refusal rather than a merge: two live rows
carry two provenances, two capture times and possibly two external
identities, and merging picks a winner for all three with nobody asked. The
surface names the row that holds the text.

A resolved task is refused outright (ErrTaskResolved). Its text is the record
of what was finished, and rewriting it rewrites history.

due nil clears the date, because clearing has to be sayable — an absent date
and "remove the date" cannot be one argument.
This commit is contained in:
2026-08-05 20:39:11 +04:00
parent 92949e886f
commit a6b17ada8b
10 changed files with 174 additions and 0 deletions
+17
View File
@@ -549,6 +549,16 @@ type setTaskStatusReq struct {
By string `json:"by,omitempty"`
}
// editTaskReq — the rewrite of the three fields capture set (Vikunja #509).
// Due nil clears the date, so "no date given" and "remove the date" cannot be
// the same request.
type editTaskReq struct {
ID int64 `json:"id"`
Text string `json:"text"`
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
}
// setTaskFieldsReq — the write for the two board columns. Both are sent every
// time and both may be empty: clearing a blocker is as ordinary as setting one,
// so an omitted field cannot mean "leave it alone" without a second way to say
@@ -872,6 +882,13 @@ var ErrToolNotFound = errors.New("ipc: tool not found")
// form can say which refusal it hit rather than "не найдено".
var ErrTaskNoDoneWhen = errors.New("ipc: task has no definition of done")
// ErrTaskDuplicate — an edit would collide with another live task's normalised
// text (Vikunja #509). The surface says which row holds it rather than merging.
var ErrTaskDuplicate = errors.New("ipc: another live task already has this text")
// ErrTaskResolved — a resolved task is not editable.
var ErrTaskResolved = errors.New("ipc: task is resolved")
// callerKey — context key for the authenticated caller. Server sets it from
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
// a missing Caller as "trusted same-process", the equivalent of the socket's
+4
View File
@@ -515,6 +515,10 @@ func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil)
}
func (c *Client) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
return c.call(ctx, MethodEditTask, editTaskReq{ID: id, Text: text, Due: due, Weight: weight}, nil)
}
func (c *Client) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
return c.call(ctx, MethodSetTaskFields, setTaskFieldsReq{ID: id, DoneWhen: doneWhen, BlockedOn: blockedOn}, nil)
}
+5
View File
@@ -142,6 +142,11 @@ type TaskAPI interface {
// SetTaskStatus moves a task forward once: candidate→open|dropped,
// open→done|dropped. Any other move is refused.
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
// EditTask rewrites the three fields capture set: text, due date and
// weight. Status is not among them — that ladder is one-way and belongs to
// SetTaskStatus. A resolved task is refused, and a text edit that would
// duplicate another live task is refused rather than merged.
EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error
// SetTaskFields writes the definition of done and the blocker. Not a
// status move, so it is not one-way: he may sharpen a criterion, and a
// blocker clears when the person answers. blockedOn is a canonical Nexus
+2
View File
@@ -32,6 +32,8 @@ var mapErrPairs = []struct {
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
{"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate},
{"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved},
}
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
+3
View File
@@ -540,6 +540,9 @@ var methodTable = map[Method]handlerFunc{
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By)
}),
MethodEditTask: withParamsVoid(func(ctx context.Context, api CoreAPI, p editTaskReq) error {
return api.EditTask(ctx, p.ID, p.Text, p.Due, p.Weight)
}),
MethodSetTaskFields: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskFieldsReq) error {
return api.SetTaskFields(ctx, p.ID, p.DoneWhen, p.BlockedOn)
}),
+8
View File
@@ -356,6 +356,10 @@ func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, t
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
}
func (a *storeAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
return mapErr(a.s.EditTask(ctx, id, text, due, weight))
}
func (a *storeAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
return mapErr(a.s.SetTaskFields(ctx, id, doneWhen, blockedOn))
}
@@ -468,6 +472,10 @@ func mapErr(err error) error {
return ErrToolNotFound
case errors.Is(err, store.ErrTaskNoDoneWhen):
return ErrTaskNoDoneWhen
case errors.Is(err, store.ErrTaskDuplicate):
return ErrTaskDuplicate
case errors.Is(err, store.ErrTaskResolved):
return ErrTaskResolved
}
return err
}
+3
View File
@@ -114,6 +114,9 @@ func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Tas
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
return ErrNotImplemented
}
+1
View File
@@ -56,6 +56,7 @@ const (
MethodListTasks Method = "list_tasks"
MethodSetTaskStatus Method = "set_task_status"
MethodSetTaskFields Method = "set_task_fields"
MethodEditTask Method = "edit_task"
MethodIngestMail Method = "ingest_mail"
MethodSwapModel Method = "swap_model"
MethodModelStatus Method = "model_status"