// 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 // 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 } // 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, } }