// mavend/tick_api.go — the daemonAPI read surface over the tick loop. // // Split out of tick.go, move-only (Vikunja #422). What mavweb asks the daemon // for, and the loop-to-ipc conversions those answers need. package main import ( "context" "errors" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/store" ) // daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the // daemon's in-memory tick trace cache. type daemonAPI struct { ipc.CoreAPI getTrace func() *loop.TickTrace getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus getDayPlan func(ctx context.Context) ipc.DayPlan chatFn func(ctx context.Context, conversation, text string) string getMCPServers func() []ipc.MCPServerStatus getEvents func(n int) []ipc.IntakeEvent getDecisions func(n int) []ipc.TurnDecision // 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 // method has nothing to write with, not merely permission to refuse. seedStore *store.Store } // RecentEvents — the unified intake journal (Vikunja #283). Empty, not an // error, when no bus was wired: "nothing has arrived" and "the journal is off" // look the same to a reader on purpose, because neither is a fault and the // page renders both as an empty table. func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) { if d.getEvents == nil { return nil, nil } 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). func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.ChatReply, error) { if d.chatFn == nil { return ipc.ChatReply{}, errors.New("mavend: chat not available") } ctx, sink := withQuerySourceSink(ctx) // The trace id rides back the same way (V-630), so /chat can offer a // correction on the turn it is already showing. 0 when nothing persisted. ctx, traces := withTraceIDSink(ctx) reply := d.chatFn(ctx, conversation, text) return ipc.ChatReply{Reply: reply, Source: sink.Name(), TraceID: traces.ID()}, nil } // CorrectTurn is NOT overridden here, and that is deliberate (V-630). Every other // diagnostic on this type exists because the daemon holds something the store // cannot answer from a table. A correction is a table, so the embedded store // adapter is already the right answer and a second implementation here would be // a second place for it to drift. // MCPServers — the configured MCP servers and their health (Vikunja #251). // Empty, not an error, when the mcp block is absent: "not configured" is the // default state and the web surface renders it as such. func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) { if d.getMCPServers == nil { return nil, nil } return d.getMCPServers(), nil } func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) { trace := d.getTrace() if trace == nil { return ipc.TickTrace{}, nil } return toIPCTickTrace(*trace), nil } // TurnDecisions — the arbitration records of the last few turns (V-564). Nil // getter means voice was never wired, and that is an empty list rather than an // error: a box with no voice path has had no turns to arbitrate, which is not a // fault and renders as an empty table. func (d *daemonAPI) TurnDecisions(ctx context.Context, n int) ([]ipc.TurnDecision, error) { if d.getDecisions == nil { return nil, nil } return d.getDecisions(n), nil } func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) { if d.getMorningStatus == nil { return nil, errors.New("mavend: morning status not available") } return d.getMorningStatus(ctx), nil } func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) { if d.getDayPlan == nil { return ipc.DayPlan{}, errors.New("mavend: day plan not available") } return d.getDayPlan(ctx), nil } func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace { rules := make([]ipc.RuleTrace, len(t.RuleTraces)) for i, r := range t.RuleTraces { rules[i] = toIPCRuleTrace(r) } return ipc.TickTrace{ Now: t.Now, Winner: t.Winner, Rules: rules, } } func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace { return ipc.RuleTrace{ RuleName: r.RuleName, Severity: int(r.Severity), PredicateResult: r.PredicateResult, GateResult: r.GateResult, GateBlockedBy: r.GateBlockedBy, GateDetail: toIPCGateDetail(r.GateDetail), WasSelected: r.WasSelected, LostTo: r.LostTo, } } func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail { return ipc.GateDetail{ SnoozeUntil: d.SnoozeUntil, CooldownUntil: d.CooldownUntil, QuietHours: d.QuietHours, CalendarBusy: d.CalendarBusy, Presence: d.Presence, InertKeysMissing: d.InertKeysMissing, } }