diff --git a/internal/ipc/server.go b/internal/ipc/server.go index b316c9b..91e4bc1 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -501,6 +501,239 @@ func (s *Server) safeDispatch(ctx context.Context, req Request) (result json.Raw return s.dispatch(ctx, req) } +// handlerFunc — one table entry's shape: unmarshal req.Params (if it wants +// any), call the matching CoreAPI method against the api passed in, marshal +// the result. api is a parameter, not a closed-over field, precisely so a +// table built once at package init never pins a stale CoreAPI — see the note +// on methodTable below about SetAPI. +type handlerFunc func(ctx context.Context, api CoreAPI, raw json.RawMessage) (json.RawMessage, error) + +// withParams adapts a (typed params, typed result) CoreAPI call into a +// handlerFunc: unmarshal into P, call fn, marshal R. On error the result is +// dropped (marshalResult's output is never read when err != nil — see +// serveConn) so every entry can uniformly return early on error without +// re-deriving what the pre-table per-arm code used to return in that case. +func withParams[P any, R any](fn func(ctx context.Context, api CoreAPI, p P) (R, error)) handlerFunc { + return func(ctx context.Context, api CoreAPI, raw json.RawMessage) (json.RawMessage, error) { + var p P + if err := unmarshalParams(raw, &p); err != nil { + return nil, err + } + r, err := fn(ctx, api, p) + if err != nil { + return nil, err + } + return marshalResult(r), nil + } +} + +// withParamsVoid is withParams for the error-only methods (mark/resolve/ +// enable/disable/...): params in, no result out, wire reply is always null. +func withParamsVoid[P any](fn func(ctx context.Context, api CoreAPI, p P) error) handlerFunc { + return func(ctx context.Context, api CoreAPI, raw json.RawMessage) (json.RawMessage, error) { + var p P + if err := unmarshalParams(raw, &p); err != nil { + return nil, err + } + return marshalResult(nil), fn(ctx, api, p) + } +} + +// withoutParams is withParams for the handful of methods that take no +// params at all (Presence, TickTrace, MorningStatus, ListProposedRoutines). +// It does NOT call unmarshalParams — matching the pre-table arms, which +// never touched req.Params for these four methods. +func withoutParams[R any](fn func(ctx context.Context, api CoreAPI) (R, error)) handlerFunc { + return func(ctx context.Context, api CoreAPI, _ json.RawMessage) (json.RawMessage, error) { + r, err := fn(ctx, api) + if err != nil { + return nil, err + } + return marshalResult(r), nil + } +} + +// methodTable — one entry per CoreAPI-backed method. Built once at package +// init, not per-Server and not per-dispatch: entries close over nothing but +// the CoreAPI method being called, and dispatch passes in the *current* +// api (loaded fresh via s.api.Load() every call, same as before the table +// existed) as an argument — so SetAPI's runtime swap (the unlock transition) +// is still honored on the very next request with no extra plumbing here. +// +// MethodAssertStepUp, MethodStoreEncryptionKey and MethodUnlock are NOT in +// this table: they bypass CoreAPI entirely (s.StepUp / s.WrapKeyFn / +// s.UnlockFn), so dispatch special-cases them before consulting the table. +var methodTable = map[Method]handlerFunc{ + MethodWriteFact: withParams(func(ctx context.Context, api CoreAPI, p WriteFactReq) (idResp, error) { + id, err := api.WriteFact(ctx, p) + return idResp{ID: id}, err + }), + MethodLatestFact: withParams(func(ctx context.Context, api CoreAPI, p keyReq) (Fact, error) { + return api.LatestFact(ctx, p.Key) + }), + MethodLatestFactBySource: withParams(func(ctx context.Context, api CoreAPI, p keySourceReq) (Fact, error) { + return api.LatestFactBySource(ctx, p.Key, p.Source) + }), + MethodSince: withParams(func(ctx context.Context, api CoreAPI, p sinceReq) (sinceResp, error) { + d, err := api.Since(ctx, p.Key, p.Now) + return sinceResp{Dur: d}, err + }), + MethodPresence: withoutParams(func(ctx context.Context, api CoreAPI) (Presence, error) { + return api.Presence(ctx) + }), + MethodCreateReminder: withParams(func(ctx context.Context, api CoreAPI, p createReminderReq) (idResp, error) { + id, err := api.CreateReminder(ctx, p.Fire, p.Payload, p.Cron) + return idResp{ID: id}, err + }), + MethodMarkReminder: withParamsVoid(func(ctx context.Context, api CoreAPI, p markReminderReq) error { + return api.MarkReminder(ctx, p.ID, p.Status) + }), + MethodListReminders: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Reminder, error) { + out, err := api.ListReminders(ctx, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []Reminder{} + } + return out, nil + }), + MethodRecordNudge: withParams(func(ctx context.Context, api CoreAPI, p recordNudgeReq) (idResp, error) { + id, err := api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts) + return idResp{ID: id}, err + }), + MethodResolveNudge: withParamsVoid(func(ctx context.Context, api CoreAPI, p resolveNudgeReq) error { + return api.ResolveNudge(ctx, p.ID, p.Outcome, p.Ts) + }), + MethodRecentOutcomes: withParams(func(ctx context.Context, api CoreAPI, p outcomesReq) ([]string, error) { + out, err := api.RecentOutcomes(ctx, p.Rule, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []string{} // stable non-null on the wire + } + return out, nil + }), + MethodRecentFacts: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Fact, error) { + out, err := api.RecentFacts(ctx, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []Fact{} + } + return out, nil + }), + MethodCalendarEvents: withParams(func(ctx context.Context, api CoreAPI, p calendarEventsReq) ([]Fact, error) { + out, err := api.CalendarEvents(ctx, p.From, p.To) + if err != nil { + return nil, err + } + if out == nil { + out = []Fact{} + } + return out, nil + }), + MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) { + out, err := api.RecentNudges(ctx, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []Nudge{} + } + return out, nil + }), + MethodWriteNote: withParams(func(ctx context.Context, api CoreAPI, p writeNoteReq) (idResp, error) { + id, err := api.WriteNote(ctx, p.Ts, p.Text, p.Embedding, p.Source) + return idResp{ID: id}, err + }), + MethodQueryNotes: withParams(func(ctx context.Context, api CoreAPI, p queryNotesReq) ([]Note, error) { + out, err := api.QueryNotes(ctx, p.Embedding, p.K) + if err != nil { + return nil, err + } + if out == nil { + out = []Note{} + } + return out, nil + }), + MethodRecentNotes: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Note, error) { + out, err := api.RecentNotes(ctx, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []Note{} + } + return out, nil + }), + MethodProposeTool: withParams(func(ctx context.Context, api CoreAPI, p proposeToolReq) (proposeToolResp, error) { + ok, err := api.ProposeTool(ctx, p.Name, p.Utterance, p.Scope, p.Ts) + return proposeToolResp{Proposed: ok}, err + }), + MethodEnableTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p enableToolReq) error { + return api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Scope, p.Ts) + }), + MethodDisableTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error { + return api.DisableTool(ctx, p.Name) + }), + MethodLookupTool: withParams(func(ctx context.Context, api CoreAPI, p lookupToolReq) (Tool, error) { + return api.LookupTool(ctx, p.Name) + }), + MethodListTools: withParams(func(ctx context.Context, api CoreAPI, p listToolsReq) (listToolsResp, error) { + out, err := api.ListTools(ctx, p.Status) + if err != nil { + return listToolsResp{}, err + } + if out == nil { + out = []Tool{} + } + return listToolsResp{Tools: out}, nil + }), + // MethodDeleteTool shares disableToolReq — both take just a tool name. + MethodDeleteTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error { + return api.DeleteTool(ctx, p.Name) + }), + MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) { + out, err := api.ListProposedRoutines(ctx) + if err != nil { + return listProposedRoutinesResp{}, err + } + if out == nil { + out = []ProposedRoutine{} + } + return listProposedRoutinesResp{Routines: out}, nil + }), + MethodDismissProposedRoutine: withParamsVoid(func(ctx context.Context, api CoreAPI, p dismissProposedRoutineReq) error { + return api.DismissProposedRoutine(ctx, p.ID) + }), + MethodAcceptProposedRoutine: withParamsVoid(func(ctx context.Context, api CoreAPI, p acceptProposedRoutineReq) error { + return api.AcceptProposedRoutine(ctx, p.ID) + }), + MethodRevertFact: withParams(func(ctx context.Context, api CoreAPI, p revertReq) (map[string]int64, error) { + newID, err := api.RevertFact(ctx, p.Key) + if err != nil { + return nil, err + } + return map[string]int64{"new_id": newID}, nil + }), + MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) { + reply, err := api.Chat(ctx, p.Text) + return chatResp{Reply: reply}, err + }), + MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) { + return api.TickTrace(ctx) + }), + // MorningStatus intentionally has no nil→[]T{} normalization here — the + // pre-table arm marshaled api.MorningStatus's result as-is (a nil slice + // serializes as JSON null), and this preserves that exact wire shape. + MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) { + return api.MorningStatus(ctx) + }), +} + // dispatch unmarshals params for req.Method and calls the matching CoreAPI // method. Unknown method ⇒ ErrUnknownMethod; a malformed params payload ⇒ // ErrBadParams with the underlying text (local, server-side, not shipped to @@ -517,312 +750,11 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er return nil, err } } + + // These three bypass CoreAPI entirely — they drive Server fields set + // directly by the daemon (StepUp / WrapKeyFn / UnlockFn), not store + // state, so they can never be table entries keyed on a CoreAPI method. switch req.Method { - case MethodWriteFact: - var p WriteFactReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - id, err := api.WriteFact(ctx, p) - return marshalResult(idResp{ID: id}), err - - case MethodLatestFact: - var p keyReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - f, err := api.LatestFact(ctx, p.Key) - if err != nil { - return nil, err - } - return marshalResult(f), nil - - case MethodLatestFactBySource: - var p keySourceReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - f, err := api.LatestFactBySource(ctx, p.Key, p.Source) - if err != nil { - return nil, err - } - return marshalResult(f), nil - - case MethodSince: - var p sinceReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - d, err := api.Since(ctx, p.Key, p.Now) - if err != nil { - return nil, err - } - return marshalResult(sinceResp{Dur: d}), nil - - case MethodPresence: - pres, err := api.Presence(ctx) - if err != nil { - return nil, err - } - return marshalResult(pres), nil - - case MethodCreateReminder: - var p createReminderReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - id, err := api.CreateReminder(ctx, p.Fire, p.Payload, p.Cron) - return marshalResult(idResp{ID: id}), err - - case MethodMarkReminder: - var p markReminderReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - err := api.MarkReminder(ctx, p.ID, p.Status) - return marshalResult(nil), err - - case MethodListReminders: - var p nReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - out, err := api.ListReminders(ctx, p.N) - if err != nil { - return nil, err - } - if out == nil { - out = []Reminder{} - } - return marshalResult(out), nil - - case MethodRecordNudge: - var p recordNudgeReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - id, err := api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts) - return marshalResult(idResp{ID: id}), err - - case MethodResolveNudge: - var p resolveNudgeReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - err := api.ResolveNudge(ctx, p.ID, p.Outcome, p.Ts) - return marshalResult(nil), err - - case MethodRecentOutcomes: - var p outcomesReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - out, err := api.RecentOutcomes(ctx, p.Rule, p.N) - if err != nil { - return nil, err - } - if out == nil { - out = []string{} // stable non-null on the wire - } - return marshalResult(out), nil - - case MethodRecentFacts: - var p nReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - out, err := api.RecentFacts(ctx, p.N) - if err != nil { - return nil, err - } - if out == nil { - out = []Fact{} - } - return marshalResult(out), nil - - case MethodCalendarEvents: - var p calendarEventsReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - out, err := api.CalendarEvents(ctx, p.From, p.To) - if err != nil { - return nil, err - } - if out == nil { - out = []Fact{} - } - return marshalResult(out), nil - - case MethodRecentNudges: - var p nReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - out, err := api.RecentNudges(ctx, p.N) - if err != nil { - return nil, err - } - if out == nil { - out = []Nudge{} - } - return marshalResult(out), nil - - case MethodWriteNote: - var p writeNoteReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - id, err := api.WriteNote(ctx, p.Ts, p.Text, p.Embedding, p.Source) - return marshalResult(idResp{ID: id}), err - - case MethodQueryNotes: - var p queryNotesReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - out, err := api.QueryNotes(ctx, p.Embedding, p.K) - if err != nil { - return nil, err - } - if out == nil { - out = []Note{} - } - return marshalResult(out), nil - - case MethodRecentNotes: - var p nReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - out, err := api.RecentNotes(ctx, p.N) - if err != nil { - return nil, err - } - if out == nil { - out = []Note{} - } - return marshalResult(out), nil - - case MethodProposeTool: - var p proposeToolReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - ok, err := api.ProposeTool(ctx, p.Name, p.Utterance, p.Scope, p.Ts) - if err != nil { - return nil, err - } - return marshalResult(proposeToolResp{Proposed: ok}), nil - - case MethodEnableTool: - var p enableToolReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - return marshalResult(nil), api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Scope, p.Ts) - - case MethodDisableTool: - var p disableToolReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - return marshalResult(nil), api.DisableTool(ctx, p.Name) - - case MethodLookupTool: - var p lookupToolReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - t, err := api.LookupTool(ctx, p.Name) - if err != nil { - return nil, err - } - return marshalResult(t), nil - - case MethodListTools: - var p listToolsReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - out, err := api.ListTools(ctx, p.Status) - if err != nil { - return nil, err - } - if out == nil { - out = []Tool{} - } - return marshalResult(listToolsResp{Tools: out}), nil - - case MethodDeleteTool: - var p disableToolReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - return marshalResult(nil), api.DeleteTool(ctx, p.Name) - - case MethodListProposedRoutines: - out, err := api.ListProposedRoutines(ctx) - if err != nil { - return nil, err - } - if out == nil { - out = []ProposedRoutine{} - } - return marshalResult(listProposedRoutinesResp{Routines: out}), nil - - case MethodDismissProposedRoutine: - var p dismissProposedRoutineReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - return marshalResult(nil), api.DismissProposedRoutine(ctx, p.ID) - - case MethodAcceptProposedRoutine: - var p acceptProposedRoutineReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID) - - case MethodRevertFact: - var p struct { - Key string `json:"key"` - } - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - newID, err := api.RevertFact(ctx, p.Key) - if err != nil { - return nil, err - } - return marshalResult(map[string]int64{"new_id": newID}), nil - - case MethodChat: - var p chatReq - if err := unmarshalParams(req.Params, &p); err != nil { - return nil, err - } - reply, err := api.Chat(ctx, p.Text) - if err != nil { - return nil, err - } - return marshalResult(chatResp{Reply: reply}), nil - - case MethodTickTrace: - t, err := api.TickTrace(ctx) - if err != nil { - return nil, err - } - return marshalResult(t), nil - - case MethodMorningStatus: - s, err := api.MorningStatus(ctx) - if err != nil { - return nil, err - } - return marshalResult(s), nil - case MethodAssertStepUp: if s.StepUp != nil { return marshalResult(nil), s.StepUp(ctx) @@ -848,10 +780,13 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er return marshalResult(nil), s.UnlockFn(ctx, p.PublicKey) } return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) + } - default: + h, ok := methodTable[req.Method] + if !ok { return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) } + return h(ctx, api, req.Params) } func unmarshalParams(raw json.RawMessage, v any) error {