cb350efb19
blocked_on stores a canonical entity id, so the form that fills it needs a way to turn "Kate" into one. ipc.ResolveEntity is that seam: the store adapter refuses it, because identity is not the store's to answer, and the daemon overrides it with the Nexus client the voice path already holds. Three outcomes are kept apart, because a caller deciding whether to store an id has to tell them apart. No nexus block is ErrNotImplemented. A miss is ErrNoEntity. Several matches come back Ambiguous with the names, and the caller asks — picking one is how a task ends up blocked on the wrong person with nobody able to see it happened. An outage stays the transport error. "There is no such person" and "Nexus is down" must not read the same.
489 lines
14 KiB
Go
489 lines
14 KiB
Go
// ipc/storeapi.go — the sqlite-backed CoreAPI.
|
|
//
|
|
// Split out of server.go, move-only (Vikunja #423). server.go was two
|
|
// unrelated things: this adapter, and the dispatcher that calls it over the
|
|
// socket. Nothing here knows there is a wire.
|
|
package ipc
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// 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) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
|
|
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), 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) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
|
|
trs, err := a.s.RecentEcosystemTraces(ctx, n)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]EcosystemTrace, len(trs))
|
|
for i, tr := range trs {
|
|
out[i] = EcosystemTrace{
|
|
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
|
|
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
|
|
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
|
|
}
|
|
}
|
|
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) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
|
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]DeliveryAttempt, len(as))
|
|
for i, at := range as {
|
|
out[i] = DeliveryAttempt{
|
|
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
|
|
Channel: at.Channel, Status: at.Status, Created: at.Created,
|
|
}
|
|
if at.HasComplete {
|
|
t := at.Completed
|
|
out[i].Completed = &t
|
|
}
|
|
}
|
|
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) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
|
|
ns, err := a.s.RecentNotesFromSource(ctx, prefix, 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) 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, conversation, text string) (ChatReply, error) {
|
|
return ChatReply{}, 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")
|
|
}
|
|
|
|
// SeedEvent — same shape as MorningStatus: writing the fact is a store call,
|
|
// but extraction and detect-and-propose live in mavend, and a seed that wrote
|
|
// the fact without running them would be the one thing this seam must not be,
|
|
// a way to prove a detector that never ran (Vikunja #518).
|
|
func (a *storeAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
|
return SeedEventResp{}, errors.New("store: seed event 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")
|
|
}
|
|
|
|
// RecentEvents — same shape as TickTrace: the intake journal is a bounded ring
|
|
// in the daemon's memory, not a table, so a bare store cannot serve it.
|
|
func (a *storeAPI) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
|
|
return nil, errors.New("store: intake events not available via direct store API")
|
|
}
|
|
|
|
func (a *storeAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
|
|
return nil, nil // no manager behind a bare store: nothing configured
|
|
}
|
|
|
|
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) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
|
res, err := a.s.CaptureTask(ctx, store.Task{
|
|
CreatedTs: req.Ts,
|
|
Text: req.Text,
|
|
Source: req.Source,
|
|
Evidence: req.Evidence,
|
|
ExternalID: req.ExternalID,
|
|
Status: req.Status,
|
|
Due: req.Due,
|
|
Weight: req.Weight,
|
|
DoneWhen: req.DoneWhen,
|
|
BlockedOn: req.BlockedOn,
|
|
})
|
|
if err != nil {
|
|
return CaptureTaskResp{}, mapErr(err)
|
|
}
|
|
return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
|
|
}
|
|
|
|
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
|
ts, err := a.s.ListTasks(ctx, status)
|
|
if err != nil {
|
|
return nil, mapErr(err)
|
|
}
|
|
out := make([]Task, len(ts))
|
|
for i, t := range ts {
|
|
out[i] = Task{
|
|
ID: t.ID,
|
|
CreatedTs: t.CreatedTs,
|
|
Text: t.Text,
|
|
Source: t.Source,
|
|
Evidence: t.Evidence,
|
|
ExternalID: t.ExternalID,
|
|
Status: t.Status,
|
|
Due: t.Due,
|
|
Weight: t.Weight,
|
|
Resolved: t.ResolvedTs,
|
|
ResolvedBy: t.ResolvedBy,
|
|
DoneWhen: t.DoneWhen,
|
|
BlockedOn: t.BlockedOn,
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
|
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))
|
|
}
|
|
|
|
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 {
|
|
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: string(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
|
|
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
|
|
}
|