ed9bdd5e09
The plan answers "какие планы на сегодня?" by putting one day in order: calendar events (with #126's ambient provenance carried through and hedged), pending reminders, and one line per morning routine that still has items outstanding. "что дальше?" trims what has already passed. It lives in internal/morning, not in a parallel system, because it is the same question the checklist asks at a different scale — the routine knows what is missing from a window, the plan knows what the whole day holds, and both read the same facts and the same idea of "today". BuildPlan is pure; tickLoop.dayPlan is the impure half that reads the store. It is not a nag. Nothing here fires, schedules or announces: the plan is built only when asked, over IPC (day_plan) or on the existing /morning page. Unprompted delivery stays with the morning nudge and the dispatcher's policy. The query source sits before "calendar" in querySources because both match "…на сегодня" and the plan's matcher is the more specific one; IsDayPlanQuery matches whole words so "планёрка" (a meeting) is not read as a request for the plan, and refuses any utterance naming another day, since the plan is built for the clock's own day only. Verified: make build and make test both exit 0; new tests cover plan ordering, the checklist-only-what-is-left rule, other-day rejection, the RU rendering against the persona checks, rest-of-day trimming, the source ordering, and the matcher's refusals.
890 lines
29 KiB
Go
890 lines
29 KiB
Go
package ipc
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of
|
|
// these inside the core process; the socket Server calls it through the
|
|
// CoreAPI interface, so over-the-wire and in-process callers behave
|
|
// identically. The translation here is the only place store sentinels cross
|
|
// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module
|
|
// view of errors stable regardless of transport.
|
|
type storeAPI struct {
|
|
s *store.Store
|
|
}
|
|
|
|
// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher-
|
|
// unlocked handle held ONLY in core's address space; this adapter never
|
|
// returns it to a caller — core mediates.
|
|
func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} }
|
|
|
|
func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
|
|
var voids sql.NullInt64
|
|
if req.VoidsID != nil {
|
|
voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true}
|
|
}
|
|
id, err := a.s.WriteFactAboutSubject(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Subject, req.Value, req.Source, req.Confidence, voids)
|
|
return id, mapErr(err)
|
|
}
|
|
|
|
func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
|
|
f, err := a.s.LatestFact(ctx, key)
|
|
if err != nil {
|
|
return Fact{}, mapErr(err)
|
|
}
|
|
return toFact(f), nil
|
|
}
|
|
|
|
func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
|
|
f, err := a.s.LatestFactBySource(ctx, key, source)
|
|
if err != nil {
|
|
return Fact{}, mapErr(err)
|
|
}
|
|
return toFact(f), nil
|
|
}
|
|
|
|
func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
|
d, err := a.s.Since(ctx, key, now)
|
|
return d, mapErr(err)
|
|
}
|
|
|
|
func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
|
|
b, score, upd, err := a.s.LoadPresenceState(ctx)
|
|
if err != nil {
|
|
return Presence{}, fmt.Errorf("ipc: load presence: %w", err)
|
|
}
|
|
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
|
|
}
|
|
|
|
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
|
id, err := a.s.CreateReminder(ctx, fire, payload, cron)
|
|
return id, mapErr(err)
|
|
}
|
|
|
|
func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
|
return mapErr(a.s.MarkReminder(ctx, id, status))
|
|
}
|
|
|
|
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
|
rs, err := a.s.ListReminders(ctx, n)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]Reminder, len(rs))
|
|
for i, r := range rs {
|
|
out[i] = toReminder(r)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
|
|
return mapErr(a.s.RescheduleReminder(ctx, id, now))
|
|
}
|
|
|
|
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
|
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
|
|
return id, mapErr(err)
|
|
}
|
|
|
|
func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
|
return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts))
|
|
}
|
|
|
|
func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
|
out, err := a.s.RecentOutcomes(ctx, rule, n)
|
|
return out, mapErr(err)
|
|
}
|
|
|
|
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
|
fs, err := a.s.RecentFacts(ctx, n)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]Fact, len(fs))
|
|
for i, f := range fs {
|
|
out[i] = toFact(f)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
|
fs, err := a.s.CalendarEvents(ctx, from, to)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]Fact, len(fs))
|
|
for i, f := range fs {
|
|
out[i] = toFact(f)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
|
ns, err := a.s.RecentNudges(ctx, n)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]Nudge, len(ns))
|
|
for i, ng := range ns {
|
|
out[i] = toNudge(ng)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
|
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
|
return id, mapErr(err)
|
|
}
|
|
|
|
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
|
ns, err := a.s.QueryNotes(ctx, embedding, k)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]Note, len(ns))
|
|
for i, n := range ns {
|
|
out[i] = toNote(n)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
|
ns, err := a.s.RecentNotes(ctx, n)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]Note, len(ns))
|
|
for i, note := range ns {
|
|
out[i] = toNote(note)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
|
ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
|
|
return ok, mapErr(err)
|
|
}
|
|
|
|
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
|
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
|
|
}
|
|
|
|
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
|
|
return mapErr(a.s.DisableTool(ctx, name))
|
|
}
|
|
|
|
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
|
|
t, err := a.s.LookupTool(ctx, name)
|
|
if err != nil {
|
|
return Tool{}, mapErr(err)
|
|
}
|
|
return toTool(t), nil
|
|
}
|
|
|
|
func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
|
_, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now())
|
|
return newID, mapErr(err)
|
|
}
|
|
|
|
func (a *storeAPI) Chat(ctx context.Context, text string) (string, error) {
|
|
return "", errors.New("store: chat not available via direct store API")
|
|
}
|
|
|
|
func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
|
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
|
}
|
|
|
|
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
|
return nil, errors.New("store: morning status not available via direct store API")
|
|
}
|
|
|
|
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
|
return DayPlan{}, errors.New("store: day plan not available via direct store API")
|
|
}
|
|
|
|
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
|
ts, err := a.s.ListTools(ctx, status)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]Tool, len(ts))
|
|
for i, t := range ts {
|
|
out[i] = toTool(t)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
|
|
return mapErr(a.s.DeleteTool(ctx, name))
|
|
}
|
|
|
|
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
|
rs, err := a.s.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]ProposedRoutine, len(rs))
|
|
for i, r := range rs {
|
|
out[i] = ProposedRoutine{
|
|
ID: r.ID,
|
|
Action: r.Action,
|
|
Object: r.Object,
|
|
IntervalDays: r.IntervalDays,
|
|
Status: r.Status,
|
|
CreatedTs: r.CreatedTs.UnixMilli(),
|
|
}
|
|
if r.ReminderID != nil {
|
|
out[i].ReminderID = r.ReminderID
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
|
return mapErr(a.s.DismissProposedRoutine(ctx, id))
|
|
}
|
|
|
|
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
|
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
|
|
}
|
|
|
|
func toTool(t store.Tool) Tool {
|
|
return Tool{
|
|
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
|
Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
|
|
}
|
|
}
|
|
|
|
func toReminder(r store.Reminder) Reminder {
|
|
return Reminder{
|
|
ID: r.ID,
|
|
CreatedTs: r.CreatedTs,
|
|
FireTs: r.FireTs,
|
|
NextFireTs: r.NextFireTs,
|
|
Payload: r.Payload,
|
|
Status: r.Status,
|
|
Cron: r.Cron,
|
|
}
|
|
}
|
|
|
|
func toNote(n store.Note) Note {
|
|
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
|
|
}
|
|
|
|
func toNudge(n store.Nudge) Nudge {
|
|
out := Nudge{
|
|
ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel,
|
|
Message: n.Message, Outcome: n.Outcome,
|
|
}
|
|
if n.OutcomeTs.Valid {
|
|
v := n.OutcomeTs.Int64
|
|
out.OutcomeTs = &v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func toFact(f store.Fact) Fact {
|
|
out := Fact{
|
|
ID: f.ID,
|
|
Ts: f.Ts,
|
|
Kind: string(f.Kind),
|
|
Key: f.Key,
|
|
Value: f.Value,
|
|
Source: f.Source,
|
|
Confidence: f.Confidence,
|
|
}
|
|
if f.VoidsID.Valid {
|
|
v := f.VoidsID.Int64
|
|
out.VoidsID = &v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is
|
|
// wrapped but not mapped (server-side dispatch surfaces it as codeInternal,
|
|
// keeping internal text off the wire except to the daemon log).
|
|
func mapErr(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
switch {
|
|
case errors.Is(err, store.ErrNoFact):
|
|
return ErrNoFact
|
|
case errors.Is(err, store.ErrConfidence):
|
|
return ErrConfidence
|
|
case errors.Is(err, store.ErrVoidsMissing):
|
|
return ErrVoidsMissing
|
|
case errors.Is(err, store.ErrNudgeNotFound):
|
|
return ErrNudgeNotFound
|
|
case errors.Is(err, store.ErrNudgeOutcome):
|
|
return ErrNudgeOutcome
|
|
case errors.Is(err, store.ErrReminderNotFound):
|
|
return ErrReminderNotFound
|
|
case errors.Is(err, store.ErrReminderState):
|
|
return ErrReminderState
|
|
case errors.Is(err, store.ErrToolNotFound):
|
|
return ErrToolNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Server — the core side of the boundary. Listens on a unix domain socket,
|
|
// accepts module connections, frames requests to a CoreAPI and responses back.
|
|
// One Server per daemon process; concurrent connections are handled in their
|
|
// own goroutine but share the single CoreAPI (and therefore the single store
|
|
// writer — store is single-connection, SetMaxOpenConns(1), so serialization is
|
|
// already guaranteed at the db; the Server adds no locking of its own).
|
|
type Server struct {
|
|
api atomic.Value // stores CoreAPI
|
|
path string
|
|
|
|
ln net.Listener
|
|
wg sync.WaitGroup
|
|
done chan struct{}
|
|
accept sync.Mutex // guards wg.Add vs Close's wg.Wait sequence
|
|
|
|
// Check — optional authorization hook. dispatch runs it BEFORE method
|
|
// dispatch, with the raw params, so the auth layer can make verdicts
|
|
// that depend on the call's shape (e.g. WriteFact's source). A non-nil
|
|
// error aborts the call; the wire code is codeForbidden when the error
|
|
// satisfies errors.Is(ErrForbidden), else codeInternal.
|
|
//
|
|
// Nil ⇒ today's auth floor: any same-uid caller (the 0600 socket perms)
|
|
// is authorized, identical to pre-auth behavior. The daemon sets this to
|
|
// auth.Gate.Check once the auth layer is constructed; there is no module
|
|
// change to gain or lose the seam.
|
|
Check CheckFunc
|
|
|
|
// StepUp — optional handler for MethodAssertStepUp. When a real Session
|
|
// (PasskeySession) is wired, the daemon sets this to session.Assert so a
|
|
// module (mavweb) can assert a user-verification gesture over IPC. Nil ⇒
|
|
// MethodAssertStepUp returns ErrUnknownMethod (same as pre-stepup floor).
|
|
StepUp StepUpFunc
|
|
|
|
// WrapKeyFn — wraps the in-memory store encryption key with a passkey
|
|
// credential public key (HKDF-AESGCM) and writes the wrapped blob to disk.
|
|
// Set by the daemon; nil ⇒ MethodStoreEncryptionKey returns ErrUnknownMethod.
|
|
WrapKeyFn WrapKeyFunc
|
|
|
|
// UnlockFn — unwraps the store encryption key from the wrapped blob using
|
|
// the passkey credential public key, opens the encrypted store, and wires
|
|
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
|
// in locked mode; nil ⇒ MethodUnlock returns ErrUnknownMethod.
|
|
UnlockFn UnlockFunc
|
|
|
|
// now is injected so tests can drive time; the loop already works in
|
|
// absolute ts supplied by callers, so this isn't load-bearing for live ops.
|
|
}
|
|
|
|
// WrapKeyFunc — wraps the store encryption key with the given credential
|
|
// public key and persists the wrapped blob.
|
|
type WrapKeyFunc func(ctx context.Context, publicKey []byte) error
|
|
|
|
// UnlockFunc — unwraps the store encryption key using the given credential
|
|
// public key and completes daemon initialization.
|
|
type UnlockFunc func(ctx context.Context, publicKey []byte) error
|
|
|
|
// CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check
|
|
// satisfies this); dispatch calls it once per request after param-unmarshal
|
|
// independence (it gets the raw params, may unmarshal what it needs — ipc
|
|
// already unmarshals for the typed call separately). Keeping Check on raw
|
|
// params means ipc doesn't need to know each method's authority shape, and
|
|
// auth doesn't need to leak implementation into ipc.
|
|
type CheckFunc func(ctx context.Context, m Method, params json.RawMessage) error
|
|
|
|
// StepUpFunc — records a user-verification gesture. Set by the daemon when
|
|
// a real Session is wired (PasskeySession); nil means not available.
|
|
// MethodAssertStepUp dispatch calls this instead of going through CoreAPI.
|
|
type StepUpFunc func(ctx context.Context) error
|
|
|
|
// Listen creates a Server bound to path. path's parent dir must exist and be
|
|
// 0700 (we chmod it if we own it); the socket file itself is created 0600 so
|
|
// only the same unix user can connect — the current "auth floor", same radius
|
|
// as wg at the network boundary. Removing a stale socket at path first lets
|
|
// the daemon restart cleanly.
|
|
func Listen(path string, api CoreAPI) (*Server, error) {
|
|
_ = os.Remove(path) // stale socket from a crashed daemon; ignore missing
|
|
if err := os.MkdirAll(parentDir(path), 0o700); err != nil {
|
|
return nil, fmt.Errorf("ipc: mkdir socket dir: %w", err)
|
|
}
|
|
// umask could widen the perms on socket creation; tighten then chmod to
|
|
// be explicit. 0600 ⇒ read+write by owner only.
|
|
oldMask := unix.Umask(0o077)
|
|
ln, err := net.Listen("unix", path)
|
|
unix.Umask(oldMask)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ipc: listen %s: %w", path, err)
|
|
}
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
_ = ln.Close()
|
|
_ = os.Remove(path)
|
|
return nil, fmt.Errorf("ipc: chmod socket: %w", err)
|
|
}
|
|
s := &Server{
|
|
path: path,
|
|
ln: ln,
|
|
done: make(chan struct{}),
|
|
}
|
|
s.api.Store(api)
|
|
return s, nil
|
|
}
|
|
|
|
// Serve accepts connections until the listener closes. Each connection is
|
|
// served in its own goroutine; a panicking handler or a malformed frame tears
|
|
// down only that conn, not the server (a misbehaving module can't kill core).
|
|
func (s *Server) Serve() error {
|
|
for {
|
|
c, err := s.ln.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-s.done:
|
|
return nil // graceful Close
|
|
default:
|
|
return fmt.Errorf("ipc: accept: %w", err)
|
|
}
|
|
}
|
|
// wg.Add under accept mutex so Close's wg.Wait (also under accept) sees
|
|
// a consistent counter — a connection accepted just before Close closes
|
|
// the listener must be tracked before Wait starts.
|
|
s.accept.Lock()
|
|
s.wg.Add(1)
|
|
s.accept.Unlock()
|
|
go func(c net.Conn) {
|
|
defer s.wg.Done()
|
|
defer c.Close()
|
|
s.serveConn(c)
|
|
}(c)
|
|
}
|
|
}
|
|
|
|
func (s *Server) serveConn(c net.Conn) {
|
|
caller, callerOK := peerCaller(c)
|
|
ctx := context.Background()
|
|
if callerOK {
|
|
ctx = WithCaller(ctx, caller)
|
|
}
|
|
for {
|
|
var req Request
|
|
if err := readFrame(c, &req); err != nil {
|
|
return // EOF or malformed ⇒ end this conn; nothing to recover
|
|
}
|
|
// redispatch expects the framework's recover so one bad call can't
|
|
// take the goroutine (and therefore the conn) with it.
|
|
result, err := s.safeDispatch(ctx, req)
|
|
resp := Response{}
|
|
if err != nil {
|
|
resp.Error = rpcErr(err)
|
|
} else {
|
|
resp.Result = result
|
|
}
|
|
if err := writeFrame(c, resp); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) safeDispatch(ctx context.Context, req Request) (result json.RawMessage, err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = fmt.Errorf("ipc: panic dispatching %s: %v", req.Method, r)
|
|
}
|
|
}()
|
|
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.
|
|
MethodDayPlan: withoutParams(func(ctx context.Context, api CoreAPI) (DayPlan, error) {
|
|
return api.DayPlan(ctx)
|
|
}),
|
|
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
|
|
// the module except as a generic message via rpcErr).
|
|
//
|
|
// Authorization runs ONCE at the top: if Server.Check is set, we call it with
|
|
// the raw params before any method-specific unmarshal; auth unmarshals fields
|
|
// it cares about (WriteFact's source, etc.) itself. A nil Check is the floor
|
|
// and is invisible at the wire — pre-auth Server behavior is unchanged.
|
|
func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, error) {
|
|
api := s.api.Load().(CoreAPI)
|
|
if s.Check != nil {
|
|
if err := s.Check(ctx, req.Method, req.Params); err != nil {
|
|
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 MethodAssertStepUp:
|
|
if s.StepUp != nil {
|
|
return marshalResult(nil), s.StepUp(ctx)
|
|
}
|
|
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
|
|
|
case MethodStoreEncryptionKey:
|
|
if s.WrapKeyFn != nil {
|
|
var p storeEncryptionKeyReq
|
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(nil), s.WrapKeyFn(ctx, p.PublicKey)
|
|
}
|
|
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
|
|
|
case MethodUnlock:
|
|
if s.UnlockFn != nil {
|
|
var p unlockReq
|
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(nil), s.UnlockFn(ctx, p.PublicKey)
|
|
}
|
|
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
|
}
|
|
|
|
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 {
|
|
if len(raw) == 0 {
|
|
raw = []byte("null")
|
|
}
|
|
if err := json.Unmarshal(raw, v); err != nil {
|
|
return fmt.Errorf("%w: %v", ErrBadParams, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func marshalResult(v any) json.RawMessage {
|
|
if v == nil {
|
|
return json.RawMessage("null")
|
|
}
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|
|
|
|
// Close stops accepting and waits for in-flight connections to drain. The
|
|
// socket file is removed so a restart can rebind cleanly. Idempotent.
|
|
func (s *Server) Close() error {
|
|
select {
|
|
case <-s.done:
|
|
return nil
|
|
default:
|
|
close(s.done)
|
|
}
|
|
err := s.ln.Close()
|
|
// Under accept lock: after the listener closes, no new Accept can complete,
|
|
// so no new wg.Add will be called. The Wait is safe to observe the wg
|
|
// counter because any in-flight Accept that already got a conn either
|
|
// already called wg.Add (before releasing the lock) or will see the closed
|
|
// listener error and not call wg.Add at all.
|
|
s.accept.Lock()
|
|
s.wg.Wait()
|
|
s.accept.Unlock()
|
|
_ = os.Remove(s.path)
|
|
return err
|
|
}
|
|
|
|
// Path returns the filesystem path of the listening socket.
|
|
func (s *Server) Path() string { return s.path }
|
|
|
|
// SetAPI atomically replaces the CoreAPI the server dispatches to. Used by
|
|
// the daemon's unlock path: in locked mode a dummy API returns errors for all
|
|
// store methods; after unlock, the real store API is swapped in. Safe to call
|
|
// while the server is serving (dispatch loads api once per request via atomic).
|
|
func (s *Server) SetAPI(api CoreAPI) { s.api.Store(api) }
|
|
|
|
func parentDir(p string) string {
|
|
if i := lastIndexByte(p, '/'); i >= 0 {
|
|
if i == 0 {
|
|
return "/"
|
|
}
|
|
return p[:i]
|
|
}
|
|
return "."
|
|
}
|
|
|
|
func lastIndexByte(s string, b byte) int {
|
|
for i := len(s) - 1; i >= 0; i-- {
|
|
if s[i] == b {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// peerCaller — read SO_PEERCRED off a unix conn to identify the connecting
|
|
// process. Returns ok=false on a non-unix conn or a platform without
|
|
// SO_PEERCRED; the caller then proceeds without a Caller (the socket perms
|
|
// already proved same-user). Linux only today; on other platforms this floors
|
|
// to "unknown caller" rather than failing — the wire still works.
|
|
func peerCaller(c net.Conn) (Caller, bool) {
|
|
uc, ok := c.(*net.UnixConn)
|
|
if !ok {
|
|
return Caller{}, false
|
|
}
|
|
raw, err := uc.SyscallConn()
|
|
if err != nil {
|
|
return Caller{}, false
|
|
}
|
|
var cred *unix.Ucred
|
|
ctrlErr := raw.Control(func(fd uintptr) {
|
|
cred, err = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
|
})
|
|
if ctrlErr != nil || err != nil || cred == nil {
|
|
return Caller{}, false
|
|
}
|
|
return Caller{Uid: int32(cred.Uid), Pid: int32(cred.Pid)}, true
|
|
}
|