tasks carry a definition of done and a blocker (V-510)
Migration #22 adds done_when and blocked_on to tasks, both NOT NULL DEFAULT ''. "He has not written one" and "there is nothing to write" are the same state here, so no caller has to tell NULL from empty. blocked_on is a canonical Nexus entity id, never a name. It names a person and identity lives in Nexus, so free text here would be a second answer to a question Nexus already owns. The caller resolves before it writes. Both columns round-trip through ipc.TaskAPI: on ipc.Task, settable at intake through CaptureTaskReq, and writable afterwards through the new SetTaskFields, which is deliberately not one-way — he may sharpen a criterion, and a blocker clears when the person answers. SetTaskStatus now refuses candidate → open when done_when is empty (ErrTaskNoDoneWhen, mapped across the wire), the same refusal ParseTaskCapture makes for a capture marker with nothing after it: confirming work whose finish line nobody wrote is how a board fills with rows that can never leave it. Dropping such a candidate stays legal, and the /tasks confirm button now says what is missing instead of surfacing a not-found. One caller skips the gate. CaptureTask promoting a candidate he stated out loud would otherwise be denied intake rather than asked for a criterion, and a direct open capture never carried one either. The gate belongs to the deliberate promotion on /tasks, where V-511 puts a form.
This commit is contained in:
@@ -142,6 +142,11 @@ type Task struct {
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Resolved *time.Time `json:"resolved,omitempty"`
|
||||
ResolvedBy string `json:"resolved_by,omitempty"`
|
||||
// DoneWhen — the acceptance criterion. Empty until he writes one, and a
|
||||
// candidate with no criterion cannot be promoted to open (Vikunja #510).
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
// BlockedOn — a canonical Nexus entity id, never a name.
|
||||
BlockedOn string `json:"blocked_on,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
|
||||
@@ -170,6 +175,11 @@ type CaptureTaskReq struct {
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Ts time.Time `json:"ts"`
|
||||
// DoneWhen and BlockedOn are optional at intake. A derived source leaves
|
||||
// both empty: mail says what to do, not what finishing means, and guessing
|
||||
// a criterion would put Maven's reading in the field he is meant to write.
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
BlockedOn string `json:"blocked_on,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureTaskResp — Created is false when the same live task already existed,
|
||||
@@ -539,6 +549,16 @@ type setTaskStatusReq struct {
|
||||
By string `json:"by,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
|
||||
// "make it empty".
|
||||
type setTaskFieldsReq struct {
|
||||
ID int64 `json:"id"`
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
BlockedOn string `json:"blocked_on,omitempty"`
|
||||
}
|
||||
|
||||
// idReq — methods keyed by a single id.
|
||||
type idReq struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -847,6 +867,11 @@ type unlockReq struct {
|
||||
// wire round-tripping via errors.Is).
|
||||
var ErrToolNotFound = errors.New("ipc: tool not found")
|
||||
|
||||
// ErrTaskNoDoneWhen — a candidate cannot be promoted to open with no
|
||||
// definition of done (Vikunja #510). Carried across the wire so the /tasks
|
||||
// form can say which refusal it hit rather than "не найдено".
|
||||
var ErrTaskNoDoneWhen = errors.New("ipc: task has no definition of done")
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return c.call(ctx, MethodSetTaskFields, setTaskFieldsReq{ID: id, DoneWhen: doneWhen, BlockedOn: blockedOn}, nil)
|
||||
}
|
||||
|
||||
// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
|
||||
// means core has no email block configured — the caller should stop asking, not
|
||||
// retry.
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// entity id or empty, never a name the caller had lying around.
|
||||
SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error
|
||||
}
|
||||
|
||||
// SystemAPI — what the daemon knows about itself, plus the one method that
|
||||
|
||||
@@ -31,6 +31,7 @@ var mapErrPairs = []struct {
|
||||
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
||||
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
||||
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
||||
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
|
||||
}
|
||||
|
||||
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
MethodSetTaskFields: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskFieldsReq) error {
|
||||
return api.SetTaskFields(ctx, p.ID, p.DoneWhen, p.BlockedOn)
|
||||
}),
|
||||
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
||||
out, err := api.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -317,6 +317,8 @@ func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (Capture
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
Weight: req.Weight,
|
||||
DoneWhen: req.DoneWhen,
|
||||
BlockedOn: req.BlockedOn,
|
||||
})
|
||||
if err != nil {
|
||||
return CaptureTaskResp{}, mapErr(err)
|
||||
@@ -343,6 +345,8 @@ func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
Weight: t.Weight,
|
||||
Resolved: t.ResolvedTs,
|
||||
ResolvedBy: t.ResolvedBy,
|
||||
DoneWhen: t.DoneWhen,
|
||||
BlockedOn: t.BlockedOn,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@@ -352,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) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return mapErr(a.s.SetTaskFields(ctx, id, doneWhen, blockedOn))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
rs, err := a.s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -458,6 +466,8 @@ func mapErr(err error) error {
|
||||
return ErrReminderState
|
||||
case errors.Is(err, store.ErrToolNotFound):
|
||||
return ErrToolNotFound
|
||||
case errors.Is(err, store.ErrTaskNoDoneWhen):
|
||||
return ErrTaskNoDoneWhen
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ const (
|
||||
MethodCaptureTask Method = "capture_task"
|
||||
MethodListTasks Method = "list_tasks"
|
||||
MethodSetTaskStatus Method = "set_task_status"
|
||||
MethodSetTaskFields Method = "set_task_fields"
|
||||
MethodIngestMail Method = "ingest_mail"
|
||||
MethodSwapModel Method = "swap_model"
|
||||
MethodModelStatus Method = "model_status"
|
||||
|
||||
Reference in New Issue
Block a user