diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 7968479..57d8402 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -341,6 +341,7 @@ func run(args []string) error { getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) }, getEvents: intakeEventsFn(evBus), seedStore: seedStoreIfAllowed(st), + nexus: nexusOf(voiceW), } if voiceW != nil && voiceW.handler != nil { api := coreAPI.(*daemonAPI) diff --git a/cmd/mavend/tick_api.go b/cmd/mavend/tick_api.go index c221912..24ef255 100644 --- a/cmd/mavend/tick_api.go +++ b/cmd/mavend/tick_api.go @@ -23,6 +23,11 @@ type daemonAPI struct { chatFn func(ctx context.Context, conversation, text string) string getMCPServers func() []ipc.MCPServerStatus getEvents func(n int) []ipc.IntakeEvent + // nexus — the identity client, nil when no nexus block is configured. It + // is what makes ResolveEntity answerable at all; without it the store + // adapter's refusal stands, and a surface that wanted an entity id says so + // instead of storing a name. + nexus *nexusClient // seedStore — non-nil ONLY when mavend was started with -allow-seed. It is // the whole off-switch for the backdated write path (Vikunja #518), and it // is a store rather than a bool so that leaving the flag off means the @@ -41,6 +46,48 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, return d.getEvents(n), nil } +// nexusOf — the identity client the voice wiring built, or nil. Same shape as +// embedderOf: a wiring that is absent and a wiring with no nexus block are one +// answer here. +func nexusOf(w *voiceWiring) *nexusClient { + if w == nil || w.handler == nil || w.handler.ecosystem == nil { + return nil + } + return w.handler.ecosystem.nexus +} + +// ResolveEntity asks Nexus for the canonical id behind a name (Vikunja #511). +// +// Three outcomes, kept apart on purpose. No nexus block is ErrNotImplemented, +// so a surface can say "identity is not configured here" rather than invent an +// id. A miss is ipc.ErrNoEntity. A match against several entities comes back +// Ambiguous with the names, because picking one is how a task ends up blocked +// on the wrong person and nobody can see it happened. +func (d *daemonAPI) ResolveEntity(ctx context.Context, query string, types []string) (ipc.EntityRef, error) { + if d.nexus == nil { + return ipc.EntityRef{}, ipc.ErrNotImplemented + } + res, err := d.nexus.Resolve(ctx, query, types) + if err != nil { + return ipc.EntityRef{}, err + } + if len(res.Candidates) > 1 { + names := make([]string, 0, len(res.Candidates)) + for _, c := range res.Candidates { + names = append(names, c.DisplayName) + } + return ipc.EntityRef{Ambiguous: true, Candidates: names}, nil + } + if res.Entity == nil || res.Entity.ID == "" { + return ipc.EntityRef{}, ipc.ErrNoEntity + } + return ipc.EntityRef{ + ID: res.Entity.ID, + Type: res.Entity.Type, + DisplayName: res.Entity.DisplayName, + }, nil +} + // Chat runs one text turn and reports which query source claimed it. The sink // rides the context so handleText keeps the one string signature the mic, // telegram and the web all call it through (V-539). diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index e36ed3d..88b4414 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1041,6 +1041,14 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri return "", errors.New("invalid id") } + if action == "promote" { + msg, err := promoteCandidate(ctx, core, r, id) + if err != nil { + return "", err + } + return msg, nil + } + if action == "edit" { // The three fields capture set, and only those (Vikunja #509). Status // is not editable here: that ladder is one-way and has its own buttons. @@ -1095,6 +1103,82 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri // fmtTaskDateValue renders a due date the way requires, or // "" for no date. Separate from fmtTaskDate, which renders it for reading. +// promoteCandidate turns a candidate into open work with the three things the +// board needs (Vikunja #511): a definition of done, an optional blocker, and an +// optional date. +// +// The definition of done is required, and the refusal is the store's — this +// only reaches it in a readable order. The blocker is a NAME here and an entity +// id in the row: identity lives in Nexus, so the name is resolved first and a +// name Nexus cannot resolve stops the promotion instead of being stored. +// +// A date set here writes a reminder, which is the one unprompted delivery the +// persona allows: he asked to be told, on a day he named. +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") + } + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + due, err := formDue(r, now()) + if err != nil { + return "", err + } + + blockedOn := "" + if name := strings.TrimSpace(r.FormValue("blocked_on")); name != "" { + 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") + case errors.Is(err, ipc.ErrNoEntity): + return "", fmt.Errorf("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, ", ")) + } + blockedOn = ref.ID + } + + if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil { + return "", err + } + if due != nil { + wgt, err := formWeight(r) + if err != nil { + return "", err + } + if err := core.EditTask(ctx, id, text, due, wgt); err != nil { + return "", err + } + } + if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil { + if errors.Is(err, ipc.ErrTaskNoDoneWhen) { + return "", errors.New("write a definition of done before confirming this candidate") + } + return "", err + } + if due == nil { + return "confirmed", nil + } + // A date-only field has no hour. Nine in the morning, because the reminder + // is about a day's work and being told at midnight is being told the night + // before. + fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location()) + 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 "confirmed, and maven will remind you that morning", nil +} + // formWeight reads the importance select. Out-of-range clamps rather than // rejects — a bad select is not worth a 400 — but trailing garbage is refused, // because strconv is not Sscanf and "3junk" is not a 3. diff --git a/cmd/mavweb/tasks.html b/cmd/mavweb/tasks.html index 583a486..427feb5 100644 --- a/cmd/mavweb/tasks.html +++ b/cmd/mavweb/tasks.html @@ -37,16 +37,27 @@

found, not confirmed {{len .Candidates}}

maven derived these from something she read. nothing counts as your work until you confirm it.
+
confirming asks for a definition of done: what has to be true for this to be finished. a task without one can never leave the board. a date here also books a reminder that morning.
- + {{range .Candidates}} -
taskwhere fromduecaptured
taskwhere fromcapturedconfirm
{{.Text}} {{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}{{.Due}} {{.Created}}
- + + + + + + +
diff --git a/cmd/mavweb/tasks_test.go b/cmd/mavweb/tasks_test.go index 5f945ba..cbd639e 100644 --- a/cmd/mavweb/tasks_test.go +++ b/cmd/mavweb/tasks_test.go @@ -77,10 +77,14 @@ func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) { t.Errorf("body missing %q", want) } } - // The candidate must offer confirm, and the open task must not. - if !strings.Contains(body, "value=confirm") { + // The candidate must offer the intake form, and it asks for a definition of + // done before it will confirm anything (V-511). + if !strings.Contains(body, "value=promote") { t.Error("candidate row has no confirm action") } + if !strings.Contains(body, "name=done_when") { + t.Error("the confirm form does not ask for a definition of done") + } } func TestHandleTasksAddCaptures(t *testing.T) { diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 4cfa642..7025895 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -549,6 +549,31 @@ type setTaskStatusReq struct { By string `json:"by,omitempty"` } +// EntityRef — one canonical entity from Nexus. Maven never mints these: an id +// exists because Nexus resolved a name to it. +// +// Ambiguous is the answer when the name matched more than one entity. It is a +// separate state from "not found" because the surface handles them +// differently: an unknown name may be a typo, and an ambiguous one has to be +// asked about, never picked (ECOSYSTEM-SPEC, and the same rule the mutating +// Hexis path follows). +type EntityRef struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Ambiguous bool `json:"ambiguous,omitempty"` + Candidates []string `json:"candidates,omitempty"` +} + +type resolveEntityReq struct { + Query string `json:"query"` + Types []string `json:"types,omitempty"` +} + +type resolveEntityResp struct { + Ref EntityRef `json:"ref"` +} + // 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. @@ -886,6 +911,11 @@ var ErrTaskNoDoneWhen = errors.New("ipc: task has no definition of done") // 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") +// ErrNoEntity — Nexus resolved the name to nothing. Distinct from an outage, +// which surfaces as the transport error: "there is no such person" and "Nexus +// is down" must not read the same to a caller deciding whether to store an id. +var ErrNoEntity = errors.New("ipc: no such entity") + // ErrTaskResolved — a resolved task is not editable. var ErrTaskResolved = errors.New("ipc: task is resolved") diff --git a/internal/ipc/client.go b/internal/ipc/client.go index f917d01..063ca68 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -515,6 +515,14 @@ 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) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) { + var r resolveEntityResp + if err := c.call(ctx, MethodResolveEntity, resolveEntityReq{Query: query, Types: types}, &r); err != nil { + return EntityRef{}, err + } + return r.Ref, 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) } diff --git a/internal/ipc/coreapi.go b/internal/ipc/coreapi.go index bec310a..89464f3 100644 --- a/internal/ipc/coreapi.go +++ b/internal/ipc/coreapi.go @@ -142,6 +142,13 @@ 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 + // ResolveEntity asks Nexus for the canonical id behind a name. It is how a + // surface turns "Kate" into an entity id before storing one, because + // identity lives in Nexus and a local name is a second answer to a + // question Nexus already owns. ErrNotImplemented when no Nexus is + // configured, ErrNoEntity when the name matched nothing, and an Ambiguous + // ref when it matched several — the caller asks, it does not pick. + ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, 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 diff --git a/internal/ipc/server.go b/internal/ipc/server.go index f68edb0..e11cbb0 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -540,6 +540,13 @@ 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) }), + MethodResolveEntity: withParams(func(ctx context.Context, api CoreAPI, p resolveEntityReq) (resolveEntityResp, error) { + ref, err := api.ResolveEntity(ctx, p.Query, p.Types) + if err != nil { + return resolveEntityResp{}, err + } + return resolveEntityResp{Ref: ref}, nil + }), MethodEditTask: withParamsVoid(func(ctx context.Context, api CoreAPI, p editTaskReq) error { return api.EditTask(ctx, p.ID, p.Text, p.Due, p.Weight) }), diff --git a/internal/ipc/storeapi.go b/internal/ipc/storeapi.go index 3fb2a30..67e891b 100644 --- a/internal/ipc/storeapi.go +++ b/internal/ipc/storeapi.go @@ -356,6 +356,13 @@ func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, t return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by)) } +// ResolveEntity is not the store's to answer: identity lives in Nexus and this +// adapter has no client. The daemon overrides it (cmd/mavend/tick_api.go), and +// a deployment with no nexus block keeps this refusal. +func (a *storeAPI) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) { + return EntityRef{}, ErrNotImplemented +} + 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)) } diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 63ce042..25d8a6a 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -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) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) { + return EntityRef{}, ErrNotImplemented +} func (UnimplementedCoreAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error { return ErrNotImplemented } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index d19888b..137828d 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -57,6 +57,7 @@ const ( MethodSetTaskStatus Method = "set_task_status" MethodSetTaskFields Method = "set_task_fields" MethodEditTask Method = "edit_task" + MethodResolveEntity Method = "resolve_entity" MethodIngestMail Method = "ingest_mail" MethodSwapModel Method = "swap_model" MethodModelStatus Method = "model_status"