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.
170 lines
5.8 KiB
Go
170 lines
5.8 KiB
Go
// 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
|
|
// 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)
|
|
reply := d.chatFn(ctx, conversation, text)
|
|
return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|