a5b245dbf5
Same shape as TickTrace and RecentEvents: a bounded daemon ring, so the store adapter refuses rather than pretending a table exists. No voice wiring means an empty list and not an error, because a box with no voice path has had no turns to arbitrate.
182 lines
6.3 KiB
Go
182 lines
6.3 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
|
|
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)
|
|
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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|