Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6abb19090 | |||
| 1f7fd476ec | |||
| 08512ad58b | |||
| ff71d981ef |
@@ -678,135 +678,6 @@ type acceptProposedRoutineReq struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
|
||||||
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
|
||||||
// for modules that live in-process for now (router, delivery) and by tests,
|
|
||||||
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
|
||||||
// - the client proxy (client.go) — same interface, over the wire.
|
|
||||||
//
|
|
||||||
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
|
||||||
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
|
||||||
// That swappability is the seam the auth layer will insert into without
|
|
||||||
// touching the module code.
|
|
||||||
type CoreAPI interface {
|
|
||||||
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
|
||||||
LatestFact(ctx context.Context, key string) (Fact, error)
|
|
||||||
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
|
||||||
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
|
||||||
Presence(ctx context.Context) (Presence, error)
|
|
||||||
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
|
||||||
MarkReminder(ctx context.Context, id int64, status string) error
|
|
||||||
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
|
||||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
|
||||||
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
|
||||||
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
|
||||||
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
|
||||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
|
||||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
|
||||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
|
||||||
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
|
||||||
// every status (Vikunja #390).
|
|
||||||
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
|
||||||
|
|
||||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
|
||||||
// own table so machine-rate traces never crowd out human-rate facts.
|
|
||||||
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
|
||||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
|
||||||
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
|
||||||
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
|
||||||
// RecentNotesFromSource — the newest n notes whose source starts with
|
|
||||||
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
|
||||||
// from recall, so this is the only way to reach them, and it keeps the feed
|
|
||||||
// answer from being crowded out of a fixed window by his own notes.
|
|
||||||
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
|
|
||||||
|
|
||||||
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
|
||||||
// returns whether a new proposal was written. EnableTool fills cmd +
|
|
||||||
// destructive and flips status to 'enabled'. DisableTool reverts an
|
|
||||||
// enabled tool back to proposed (it stays in the store, won't run).
|
|
||||||
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
|
||||||
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
|
||||||
// LookupTool/ListTools read them.
|
|
||||||
// scope defaults to "homelab" when empty.
|
|
||||||
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
|
||||||
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
|
||||||
DisableTool(ctx context.Context, name string) error
|
|
||||||
DeleteTool(ctx context.Context, name string) error
|
|
||||||
LookupTool(ctx context.Context, name string) (Tool, error)
|
|
||||||
ListTools(ctx context.Context, status string) ([]Tool, error)
|
|
||||||
RevertFact(ctx context.Context, key string) (int64, error)
|
|
||||||
|
|
||||||
// ListProposedRoutines returns proposed routines, newest first.
|
|
||||||
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
|
||||||
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
|
||||||
DismissProposedRoutine(ctx context.Context, id int64) error
|
|
||||||
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
|
|
||||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
|
||||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
|
||||||
|
|
||||||
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
|
||||||
// intake seam for the voice path, the web form and the future email
|
|
||||||
// extractor. Idempotent per live normalised text; the response says
|
|
||||||
// whether a row was actually created.
|
|
||||||
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
|
||||||
// ListTasks returns tasks in one status, newest first. "" is every row,
|
|
||||||
// "live" is candidate + open (outstanding work).
|
|
||||||
ListTasks(ctx context.Context, status string) ([]Task, error)
|
|
||||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
|
||||||
// open→done|dropped. Any other move is refused.
|
|
||||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
|
||||||
|
|
||||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
|
||||||
// this after every tick; the store adapter returns an error (trace is not
|
|
||||||
// persisted — it's a daemon-level cache).
|
|
||||||
TickTrace(ctx context.Context) (TickTrace, error)
|
|
||||||
|
|
||||||
// MorningStatus returns each configured morning routine's current
|
|
||||||
// checklist state (see internal/morning): active today/now, which items
|
|
||||||
// are done, which are still missing. The store adapter returns an error
|
|
||||||
// (morning routines are daemon-config, not persisted) — same shape as
|
|
||||||
// TickTrace.
|
|
||||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
|
||||||
|
|
||||||
// MCPServers reports the configured MCP servers and their health
|
|
||||||
// (Vikunja #251). Read-only introspection for /tools — there is no
|
|
||||||
// "call this tool" method on purpose: an MCP tool runs through the same
|
|
||||||
// allowlist, confirm turn and act path as any other tool, and a second
|
|
||||||
// mutation path would be a second thing to get wrong. Empty when the
|
|
||||||
// mcp config block is absent, which is the default.
|
|
||||||
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
|
||||||
|
|
||||||
// DayPlan returns today's ordered plan — calendar events, pending
|
|
||||||
// reminders and any morning checklist still outstanding (see
|
|
||||||
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
|
||||||
// Read-only: asking for the plan never dispatches or schedules anything.
|
|
||||||
// The store adapter returns an error (the plan needs the daemon's routine
|
|
||||||
// config) — same shape as TickTrace and MorningStatus.
|
|
||||||
DayPlan(ctx context.Context) (DayPlan, error)
|
|
||||||
|
|
||||||
// Chat routes a text utterance through the reactive handler's core path
|
|
||||||
// (router → dialogue → action → replier) and returns the reply text.
|
|
||||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
|
||||||
//
|
|
||||||
// conversation names the thread. A parked clarifying question is held per
|
|
||||||
// conversation, so an unanswered question on one reach cannot eat the next
|
|
||||||
// utterance from another (Vikunja #466). Empty means the unattributed text
|
|
||||||
// tap and is still one conversation of its own, separate from the mic.
|
|
||||||
Chat(ctx context.Context, conversation, text string) (string, error)
|
|
||||||
|
|
||||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
|
||||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
|
||||||
// it came from: a relayed notification, a mail candidate, a feed item, a
|
|
||||||
// changed page, a spend, a presence probe.
|
|
||||||
//
|
|
||||||
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
|
||||||
// the store adapter returns an error, because the journal is a bounded
|
|
||||||
// in-memory ring and not a table. Its contents are a window over intake,
|
|
||||||
// never the durable record — that is still the fact, note or task the
|
|
||||||
// intake path wrote.
|
|
||||||
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
|
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
|
||||||
// event.Event field for field; the ipc package does not import internal/event
|
// event.Event field for field; the ipc package does not import internal/event
|
||||||
// so the wire shape stays independent of the in-process type.
|
// so the wire shape stays independent of the in-process type.
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package ipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CoreAPI and the eight domain interfaces it composes (Vikunja #408).
|
||||||
|
//
|
||||||
|
// It used to be one flat block of forty methods, and the cost of that shape
|
||||||
|
// was paid three times over: every method added an arm to the dispatcher, a
|
||||||
|
// stub to the daemon's locked API, and a stub to every test double. Two of
|
||||||
|
// those three are already gone — the dispatcher is a table (methodTable in
|
||||||
|
// server.go), and UnimplementedCoreAPI took the padding out of the doubles and
|
||||||
|
// out of lockedAPI, which no longer exists.
|
||||||
|
//
|
||||||
|
// What was left is the interface itself, and this file is that half. CoreAPI
|
||||||
|
// is unchanged as a type: the same forty methods, in the same order, so the
|
||||||
|
// wire contract, the client proxy and the store adapter are all untouched.
|
||||||
|
// What it gains is a named seam per domain, so a caller that only reads facts
|
||||||
|
// can say FactAPI and a reader can see which cluster a method belongs to
|
||||||
|
// without counting lines.
|
||||||
|
//
|
||||||
|
// Add a method to the domain it belongs to, not to CoreAPI.
|
||||||
|
|
||||||
|
// FactAPI — the fact store: write, read the current value, read history, and
|
||||||
|
// undo. Presence sits here because it is a hysteresis view over presence
|
||||||
|
// facts, not a store of its own.
|
||||||
|
type FactAPI interface {
|
||||||
|
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
||||||
|
LatestFact(ctx context.Context, key string) (Fact, error)
|
||||||
|
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
||||||
|
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
||||||
|
Presence(ctx context.Context) (Presence, error)
|
||||||
|
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
||||||
|
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||||
|
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||||
|
RevertFact(ctx context.Context, key string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReminderAPI — scheduled sends the owner asked for.
|
||||||
|
type ReminderAPI interface {
|
||||||
|
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
||||||
|
MarkReminder(ctx context.Context, id int64, status string) error
|
||||||
|
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NudgeAPI — proactive sends Maven proposed, their outcomes, and the outbox
|
||||||
|
// they went out through.
|
||||||
|
type NudgeAPI interface {
|
||||||
|
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||||
|
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
||||||
|
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
||||||
|
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||||
|
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
||||||
|
// every status (Vikunja #390).
|
||||||
|
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteAPI — free text he captured, plus the embedded recall over it.
|
||||||
|
type NoteAPI interface {
|
||||||
|
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||||
|
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
||||||
|
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
||||||
|
// RecentNotesFromSource — the newest n notes whose source starts with
|
||||||
|
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
||||||
|
// from recall, so this is the only way to reach them, and it keeps the feed
|
||||||
|
// answer from being crowded out of a fixed window by his own notes.
|
||||||
|
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolAPI — the capability allowlist and its read side.
|
||||||
|
//
|
||||||
|
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
||||||
|
// returns whether a new proposal was written. EnableTool fills cmd +
|
||||||
|
// destructive and flips status to 'enabled'. DisableTool reverts an
|
||||||
|
// enabled tool back to proposed (it stays in the store, won't run).
|
||||||
|
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
||||||
|
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
||||||
|
// LookupTool/ListTools read them.
|
||||||
|
// scope defaults to "homelab" when empty.
|
||||||
|
type ToolAPI interface {
|
||||||
|
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
||||||
|
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
||||||
|
DisableTool(ctx context.Context, name string) error
|
||||||
|
DeleteTool(ctx context.Context, name string) error
|
||||||
|
LookupTool(ctx context.Context, name string) (Tool, error)
|
||||||
|
ListTools(ctx context.Context, status string) ([]Tool, error)
|
||||||
|
|
||||||
|
// MCPServers reports the configured MCP servers and their health
|
||||||
|
// (Vikunja #251). Read-only introspection for /tools — there is no
|
||||||
|
// "call this tool" method on purpose: an MCP tool runs through the same
|
||||||
|
// allowlist, confirm turn and act path as any other tool, and a second
|
||||||
|
// mutation path would be a second thing to get wrong. Empty when the
|
||||||
|
// mcp config block is absent, which is the default.
|
||||||
|
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoutineAPI — the shapes of his day: routines Maven noticed and proposed, the
|
||||||
|
// morning checklist, and today's plan.
|
||||||
|
//
|
||||||
|
// MorningStatus and DayPlan are daemon-computed rather than stored, so the
|
||||||
|
// store adapter returns an error for both — the same shape as TickTrace.
|
||||||
|
type RoutineAPI interface {
|
||||||
|
// ListProposedRoutines returns proposed routines, newest first.
|
||||||
|
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
||||||
|
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
||||||
|
DismissProposedRoutine(ctx context.Context, id int64) error
|
||||||
|
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
|
||||||
|
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||||
|
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||||
|
|
||||||
|
// MorningStatus returns each configured morning routine's current
|
||||||
|
// checklist state (see internal/morning): active today/now, which items
|
||||||
|
// are done, which are still missing.
|
||||||
|
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||||
|
|
||||||
|
// DayPlan returns today's ordered plan — calendar events, pending
|
||||||
|
// reminders and any morning checklist still outstanding (see
|
||||||
|
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||||
|
// Read-only: asking for the plan never dispatches or schedules anything.
|
||||||
|
DayPlan(ctx context.Context) (DayPlan, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskAPI — outstanding work, whatever surface it arrived from.
|
||||||
|
type TaskAPI interface {
|
||||||
|
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||||
|
// intake seam for the voice path, the web form and the future email
|
||||||
|
// extractor. Idempotent per live normalised text; the response says
|
||||||
|
// whether a row was actually created.
|
||||||
|
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||||
|
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||||
|
// "live" is candidate + open (outstanding work).
|
||||||
|
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||||
|
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||||
|
// open→done|dropped. Any other move is refused.
|
||||||
|
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemAPI — what the daemon knows about itself, plus the one method that
|
||||||
|
// runs a whole turn.
|
||||||
|
type SystemAPI interface {
|
||||||
|
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||||
|
// this after every tick; the store adapter returns an error (trace is not
|
||||||
|
// persisted — it's a daemon-level cache).
|
||||||
|
TickTrace(ctx context.Context) (TickTrace, error)
|
||||||
|
|
||||||
|
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||||
|
// own table so machine-rate traces never crowd out human-rate facts.
|
||||||
|
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
||||||
|
|
||||||
|
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||||
|
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||||
|
// it came from: a relayed notification, a mail candidate, a feed item, a
|
||||||
|
// changed page, a spend, a presence probe.
|
||||||
|
//
|
||||||
|
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
||||||
|
// the store adapter returns an error, because the journal is a bounded
|
||||||
|
// in-memory ring and not a table. Its contents are a window over intake,
|
||||||
|
// never the durable record — that is still the fact, note or task the
|
||||||
|
// intake path wrote.
|
||||||
|
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
||||||
|
|
||||||
|
// Chat routes a text utterance through the reactive handler's core path
|
||||||
|
// (router → dialogue → action → replier) and returns the reply text.
|
||||||
|
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||||
|
//
|
||||||
|
// conversation names the thread. A parked clarifying question is held per
|
||||||
|
// conversation, so an unanswered question on one reach cannot eat the next
|
||||||
|
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||||
|
// tap and is still one conversation of its own, separate from the mic.
|
||||||
|
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||||
|
// - the in-process store adapter (storeapi.go) — used by the daemon
|
||||||
|
// for modules that live in-process for now (router, delivery) and by tests,
|
||||||
|
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
||||||
|
// - the client proxy (client.go) — same interface, over the wire.
|
||||||
|
//
|
||||||
|
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
||||||
|
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
||||||
|
// That swappability is the seam the auth layer inserts into without touching
|
||||||
|
// the module code.
|
||||||
|
type CoreAPI interface {
|
||||||
|
FactAPI
|
||||||
|
ReminderAPI
|
||||||
|
NudgeAPI
|
||||||
|
NoteAPI
|
||||||
|
ToolAPI
|
||||||
|
RoutineAPI
|
||||||
|
TaskAPI
|
||||||
|
SystemAPI
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@ const maxFrame = 4 << 20
|
|||||||
// (we read the length but not the body), so the caller must close it.
|
// (we read the length but not the body), so the caller must close it.
|
||||||
var ErrFrameTooLarge = errors.New("ipc: frame too large")
|
var ErrFrameTooLarge = errors.New("ipc: frame too large")
|
||||||
|
|
||||||
|
// The framing is hand-rolled and it stays that way (Vikunja #410): ninety
|
||||||
|
// lines, readable on the wire with socat, and every standard replacement
|
||||||
|
// brings schema machinery this boundary does not want. The condition is that
|
||||||
|
// it is defended by tests rather than inherited untested — truncated header,
|
||||||
|
// truncated body, partial reads, frame boundaries and a non-JSON body all
|
||||||
|
// live in frame_test.go.
|
||||||
|
//
|
||||||
// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length
|
// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length
|
||||||
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
|
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
|
||||||
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
|
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package ipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The framing is hand-rolled, and it is the protocol all nine daemons depend
|
||||||
|
// on, so a bug in it is a bug everywhere (Vikunja #410). The review asked
|
||||||
|
// whether to replace it with something standard. It stays: length-prefixed
|
||||||
|
// JSON over a unix socket is ninety lines, it is readable with socat, and the
|
||||||
|
// alternatives (net/rpc, gRPC, a codec library) all buy schema machinery this
|
||||||
|
// boundary does not want. What was wrong was inheriting it untested. These
|
||||||
|
// are the paths a real socket produces that the round-trip test never does.
|
||||||
|
|
||||||
|
// A short header is not EOF. EOF means the peer closed cleanly between
|
||||||
|
// frames, which the server treats as a normal disconnect; a header that stops
|
||||||
|
// halfway is a truncated frame and must be reported as an error, or a peer
|
||||||
|
// that dies mid-write looks like one that hung up politely.
|
||||||
|
func TestReadFrameTruncatedHeader(t *testing.T) {
|
||||||
|
var v any
|
||||||
|
err := readFrame(bytes.NewReader([]byte{0, 0, 4}), &v)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a three-byte header must fail")
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("err = %v, want a truncation error, not EOF", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A header promising more body than follows. Same reasoning: the frame never
|
||||||
|
// arrived, so it must not decode into a zero value the caller then trusts.
|
||||||
|
func TestReadFrameTruncatedBody(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var hdr [4]byte
|
||||||
|
binary.BigEndian.PutUint32(hdr[:], 32)
|
||||||
|
buf.Write(hdr[:])
|
||||||
|
buf.WriteString(`{"m":"pi`)
|
||||||
|
|
||||||
|
var got map[string]string
|
||||||
|
if err := readFrame(&buf, &got); err == nil {
|
||||||
|
t.Fatal("a body shorter than its prefix must fail")
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("decoded %v from a truncated frame", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing at all is EOF, and only this is.
|
||||||
|
func TestReadFrameEmptyIsEOF(t *testing.T) {
|
||||||
|
var v any
|
||||||
|
if err := readFrame(bytes.NewReader(nil), &v); !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("err = %v, want io.EOF", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// byteAtATime returns one byte per Read, which is what a socket is allowed to
|
||||||
|
// do and what a bytes.Reader never does. readFrame uses io.ReadFull for both
|
||||||
|
// the header and the body; this is the test that would fail if either turned
|
||||||
|
// into a bare Read.
|
||||||
|
type byteAtATime struct {
|
||||||
|
b []byte
|
||||||
|
i int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *byteAtATime) Read(p []byte) (int, error) {
|
||||||
|
if r.i >= len(r.b) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
if len(p) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
p[0] = r.b[r.i]
|
||||||
|
r.i++
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFrameReassemblesPartialReads(t *testing.T) {
|
||||||
|
type payload struct {
|
||||||
|
Msg string `json:"m"`
|
||||||
|
N int `json:"n"`
|
||||||
|
}
|
||||||
|
want := payload{Msg: "привет", N: 7}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := writeFrame(&buf, want); err != nil {
|
||||||
|
t.Fatalf("writeFrame: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got payload
|
||||||
|
if err := readFrame(&byteAtATime{b: buf.Bytes()}, &got); err != nil {
|
||||||
|
t.Fatalf("readFrame: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two frames written back to back must come back as two frames. A reader that
|
||||||
|
// consumed more than one frame's body would desynchronize the connection, and
|
||||||
|
// the symptom would be a reply attributed to the wrong request.
|
||||||
|
func TestReadFrameStopsAtTheFrameBoundary(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for _, m := range []string{"first", "second"} {
|
||||||
|
if err := writeFrame(&buf, map[string]string{"m": m}); err != nil {
|
||||||
|
t.Fatalf("writeFrame: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := &byteAtATime{b: buf.Bytes()}
|
||||||
|
for _, want := range []string{"first", "second"} {
|
||||||
|
var got map[string]string
|
||||||
|
if err := readFrame(r, &got); err != nil {
|
||||||
|
t.Fatalf("readFrame(%s): %v", want, err)
|
||||||
|
}
|
||||||
|
if got["m"] != want {
|
||||||
|
t.Fatalf("got %q, want %q", got["m"], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var extra map[string]string
|
||||||
|
if err := readFrame(r, &extra); !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("after two frames: err = %v, want io.EOF", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A body that is not JSON is an error, not a zero value. The peer is either
|
||||||
|
// broken or not speaking this protocol; either way the caller must not read
|
||||||
|
// on as though it decoded.
|
||||||
|
func TestReadFrameRejectsNonJSONBody(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var hdr [4]byte
|
||||||
|
body := []byte("not json at all")
|
||||||
|
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
||||||
|
buf.Write(hdr[:])
|
||||||
|
buf.Write(body)
|
||||||
|
|
||||||
|
var got map[string]string
|
||||||
|
if err := readFrame(&buf, &got); err == nil {
|
||||||
|
t.Fatal("a non-JSON body must fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package ipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mapErr turns a store sentinel into its wire twin so a module can errors.Is
|
||||||
|
// without importing internal/store. The design is right and the failure mode is
|
||||||
|
// quiet: add a sentinel to store, forget the switch, and the client gets an
|
||||||
|
// untyped error that no caller can branch on. These two tests are the alarm.
|
||||||
|
|
||||||
|
// mapErrPairs — every store sentinel that has a wire twin, and the twin.
|
||||||
|
var mapErrPairs = []struct {
|
||||||
|
name string // the store identifier, for the coverage test below
|
||||||
|
from error
|
||||||
|
wants error
|
||||||
|
}{
|
||||||
|
{"ErrNoFact", store.ErrNoFact, ErrNoFact},
|
||||||
|
{"ErrConfidence", store.ErrConfidence, ErrConfidence},
|
||||||
|
{"ErrVoidsMissing", store.ErrVoidsMissing, ErrVoidsMissing},
|
||||||
|
{"ErrNudgeNotFound", store.ErrNudgeNotFound, ErrNudgeNotFound},
|
||||||
|
{"ErrNudgeOutcome", store.ErrNudgeOutcome, ErrNudgeOutcome},
|
||||||
|
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
||||||
|
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
||||||
|
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
||||||
|
// each with the reason it stays store-side. A new sentinel is in neither list
|
||||||
|
// and fails TestMapErrCoversEveryStoreSentinel, which is the point: whether a
|
||||||
|
// module can branch on an error is a decision, not a default.
|
||||||
|
var unmappedStoreErrors = map[string]string{
|
||||||
|
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
|
||||||
|
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
|
||||||
|
|
||||||
|
"ErrToolCmd": "write-side validation of an allowlist mutation; the caller is the owner at a step-up, not a module branching on the verdict",
|
||||||
|
|
||||||
|
"ErrProposedRoutineNotFound": "no module branches on a routine id that vanished; accept and dismiss are owner clicks",
|
||||||
|
"ErrProposedRoutineExists": "the propose path already reports 'nothing new' through its bool return",
|
||||||
|
"ErrRoutineStatus": "an unknown status is a caller bug, not a state a module recovers from",
|
||||||
|
|
||||||
|
"ErrTaskNotFound": "the task surfaces re-list rather than branch",
|
||||||
|
"ErrTaskEmpty": "input validation — the surface refuses empty text before it gets here",
|
||||||
|
"ErrTaskStatus": "an illegal status move is a caller bug; the surface offers only legal ones",
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mapping itself, through a wrap, because every real caller wraps.
|
||||||
|
func TestMapErrMapsEveryPair(t *testing.T) {
|
||||||
|
for _, p := range mapErrPairs {
|
||||||
|
got := mapErr(fmt.Errorf("storeapi: %w", p.from))
|
||||||
|
if got != p.wants {
|
||||||
|
t.Errorf("mapErr(store.%s) = %v, want %v", p.name, got, p.wants)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything mapErr does not recognise passes through untouched. A module that
|
||||||
|
// cannot branch on an error must still see the original text.
|
||||||
|
func TestMapErrPassesUnknownThrough(t *testing.T) {
|
||||||
|
if mapErr(nil) != nil {
|
||||||
|
t.Error("mapErr(nil) must stay nil")
|
||||||
|
}
|
||||||
|
own := fmt.Errorf("socket closed")
|
||||||
|
if got := mapErr(own); got != own {
|
||||||
|
t.Errorf("mapErr(%v) = %v, want the same error back", own, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parity half: every exported sentinel in internal/store is either mapped
|
||||||
|
// or listed with a reason. Read off the source, so a sentinel added in a file
|
||||||
|
// this package never touches still trips it.
|
||||||
|
func TestMapErrCoversEveryStoreSentinel(t *testing.T) {
|
||||||
|
mapped := map[string]bool{}
|
||||||
|
for _, p := range mapErrPairs {
|
||||||
|
mapped[p.name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range storeSentinelNames(t) {
|
||||||
|
if mapped[name] || unmappedStoreErrors[name] != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Errorf("store.%s is a new sentinel with no verdict: add it to mapErr and mapErrPairs, "+
|
||||||
|
"or to unmappedStoreErrors with the reason a module cannot branch on it", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeSentinelNames reads internal/store for exported package-level error
|
||||||
|
// values: `var ErrX = errors.New(...)`, inside a block or on its own.
|
||||||
|
func storeSentinelNames(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
pkgs, err := parser.ParseDir(fset, "../store", func(fi fs.FileInfo) bool {
|
||||||
|
return !strings.HasSuffix(fi.Name(), "_test.go")
|
||||||
|
}, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse internal/store: %v", err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
for _, f := range pkg.Files {
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
gd, ok := d.(*ast.GenDecl)
|
||||||
|
if !ok || gd.Tok != token.VAR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, spec := range gd.Specs {
|
||||||
|
vs, ok := spec.(*ast.ValueSpec)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, n := range vs.Names {
|
||||||
|
if !strings.HasPrefix(n.Name, "Err") || !n.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i < len(vs.Values) && isErrorsNew(vs.Values[i]) {
|
||||||
|
out = append(out, n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) < len(mapErrPairs) {
|
||||||
|
t.Fatalf("found %d sentinels in internal/store, fewer than the %d already mapped — the scan is broken, not the store", len(out), len(mapErrPairs))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isErrorsNew(e ast.Expr) bool {
|
||||||
|
call, ok := e.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||||
|
if !ok || sel.Sel.Name != "New" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
id, ok := sel.X.(*ast.Ident)
|
||||||
|
return ok && id.Name == "errors"
|
||||||
|
}
|
||||||
@@ -2,9 +2,7 @@ package ipc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -13,449 +11,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/netaddr"
|
"github.com/kami/maven/internal/netaddr"
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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) (string, error) {
|
|
||||||
return "", 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
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: 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
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server — the core side of the boundary. Listens on a unix domain socket,
|
// Server — the core side of the boundary. Listens on a unix domain socket,
|
||||||
// accepts module connections, frames requests to a CoreAPI and responses back.
|
// accepts module connections, frames requests to a CoreAPI and responses back.
|
||||||
// One Server per daemon process; concurrent connections are handled in their
|
// One Server per daemon process; concurrent connections are handled in their
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
// 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) (string, error) {
|
||||||
|
return "", 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -8,14 +8,37 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The three states a proposal can be in. A proposal starts 'proposed' and
|
// RoutineStatus — the state a proposal is in. A defined type, not a bare
|
||||||
// moves once, either way, and never moves again.
|
// string, because the legal set used to live in a comment: nothing caught a
|
||||||
|
// typo at compile time, nothing enumerated the set for a test, and a bad value
|
||||||
|
// surfaced as a /routines row that neither accepts nor dismisses (Vikunja #46,
|
||||||
|
// #410).
|
||||||
|
type RoutineStatus string
|
||||||
|
|
||||||
|
// The three states a proposal can be in. A proposal starts proposed and moves
|
||||||
|
// once, either way, and never moves again.
|
||||||
const (
|
const (
|
||||||
RoutineProposed = "proposed"
|
RoutineProposed RoutineStatus = "proposed"
|
||||||
RoutineAccepted = "accepted"
|
RoutineAccepted RoutineStatus = "accepted"
|
||||||
RoutineDismissed = "dismissed"
|
RoutineDismissed RoutineStatus = "dismissed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RoutineStatuses — the legal set, and the single source of truth a test can
|
||||||
|
// range over. Adding a state means adding it here.
|
||||||
|
var RoutineStatuses = []RoutineStatus{RoutineProposed, RoutineAccepted, RoutineDismissed}
|
||||||
|
|
||||||
|
// Valid reports whether s is one of RoutineStatuses.
|
||||||
|
func (s RoutineStatus) Valid() bool {
|
||||||
|
for _, v := range RoutineStatuses {
|
||||||
|
if s == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s RoutineStatus) String() string { return string(s) }
|
||||||
|
|
||||||
// ProposedRoutine — a detected pattern the system wants to nudge about on a
|
// ProposedRoutine — a detected pattern the system wants to nudge about on a
|
||||||
// repeating interval. Status 'proposed' means awaiting human confirmation;
|
// repeating interval. Status 'proposed' means awaiting human confirmation;
|
||||||
// 'accepted' means the human confirmed and the tick loop now owns the schedule;
|
// 'accepted' means the human confirmed and the tick loop now owns the schedule;
|
||||||
@@ -30,7 +53,7 @@ type ProposedRoutine struct {
|
|||||||
Action string
|
Action string
|
||||||
Object string
|
Object string
|
||||||
IntervalDays float64
|
IntervalDays float64
|
||||||
Status string // proposed | accepted | dismissed
|
Status RoutineStatus
|
||||||
CreatedTs time.Time
|
CreatedTs time.Time
|
||||||
ReminderID *int64
|
ReminderID *int64
|
||||||
AcceptedTs *time.Time
|
AcceptedTs *time.Time
|
||||||
@@ -40,6 +63,7 @@ type ProposedRoutine struct {
|
|||||||
var (
|
var (
|
||||||
ErrProposedRoutineNotFound = errors.New("store: proposed routine not found")
|
ErrProposedRoutineNotFound = errors.New("store: proposed routine not found")
|
||||||
ErrProposedRoutineExists = errors.New("store: proposed routine already exists for this action+object")
|
ErrProposedRoutineExists = errors.New("store: proposed routine already exists for this action+object")
|
||||||
|
ErrRoutineStatus = errors.New("store: unknown routine status")
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateProposedRoutine inserts a new proposed routine. Returns
|
// CreateProposedRoutine inserts a new proposed routine. Returns
|
||||||
@@ -51,6 +75,16 @@ var (
|
|||||||
// keep finding the pattern, and every re-propose is refused here. Maven is not
|
// keep finding the pattern, and every re-propose is refused here. Maven is not
|
||||||
// a nag.
|
// a nag.
|
||||||
//
|
//
|
||||||
|
// The object stays a local string. It is not resolved against Nexus and it
|
||||||
|
// carries no canonical entity ref (asked on the PR 4 review, decided here,
|
||||||
|
// Vikunja #410). Nexus owns identity for things the ecosystem acts on, and
|
||||||
|
// nothing acts on a routine object: it is the word he used, replayed back to
|
||||||
|
// him in a nudge, and compared only against itself for the UNIQUE key. Two
|
||||||
|
// spellings of the same watering can are two routines, and that is the right
|
||||||
|
// answer when the point is to say the sentence he would say. Canonical refs
|
||||||
|
// arrive here only if a routine ever drives a Hexis call, which is Vikunja
|
||||||
|
// #272, not this.
|
||||||
|
//
|
||||||
// Vikunja #43: this is called both from the voice fact-write path (for the
|
// Vikunja #43: this is called both from the voice fact-write path (for the
|
||||||
// immediate spoken confirmation) and from the digestion tick's proactive
|
// immediate spoken confirmation) and from the digestion tick's proactive
|
||||||
// scan (cmd/mavend/tick.go's detectPatterns, via patterns.go's
|
// scan (cmd/mavend/tick.go's detectPatterns, via patterns.go's
|
||||||
@@ -102,8 +136,13 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListProposedRoutinesByStatus returns routines in one status, newest first.
|
// ListProposedRoutinesByStatus returns routines in one status, newest first.
|
||||||
// An empty status returns every row.
|
// An empty status returns every row; an unknown one is refused rather than
|
||||||
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
|
// silently answering with nothing, since a typo and a genuinely empty state
|
||||||
|
// read the same otherwise.
|
||||||
|
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status RoutineStatus) ([]ProposedRoutine, error) {
|
||||||
|
if status != "" && !status.Valid() {
|
||||||
|
return nil, fmt.Errorf("%w: %q", ErrRoutineStatus, status)
|
||||||
|
}
|
||||||
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
|
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
|
||||||
FROM proposed_routines`
|
FROM proposed_routines`
|
||||||
var args []any
|
var args []any
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ func TestListProposedRoutinesByStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
status string
|
status RoutineStatus
|
||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
{RoutineProposed, 0},
|
{RoutineProposed, 0},
|
||||||
@@ -266,3 +266,59 @@ func TestLookupMissingProposedRoutine(t *testing.T) {
|
|||||||
t.Fatal("want nil for missing routine")
|
t.Fatal("want nil for missing routine")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every legal status must survive the database. The status column is written
|
||||||
|
// by three different UPDATE statements with the value spelled inline, so a
|
||||||
|
// constant that drifts from its SQL is exactly the failure this catches: the
|
||||||
|
// row would come back in a state no Go code compares equal to.
|
||||||
|
func TestRoutineStatusRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
write := map[RoutineStatus]func(id int64) error{
|
||||||
|
RoutineProposed: func(int64) error { return nil },
|
||||||
|
RoutineAccepted: func(id int64) error { return s.AcceptProposedRoutine(ctx, id, now) },
|
||||||
|
RoutineDismissed: func(id int64) error { return s.DismissProposedRoutine(ctx, id) },
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range RoutineStatuses {
|
||||||
|
if !want.Valid() {
|
||||||
|
t.Fatalf("%q is in RoutineStatuses but not Valid()", want)
|
||||||
|
}
|
||||||
|
object := "object-" + want.String()
|
||||||
|
id, err := s.CreateProposedRoutine(ctx, "полить", object, 3, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProposedRoutine(%s): %v", want, err)
|
||||||
|
}
|
||||||
|
if err := write[want](id); err != nil {
|
||||||
|
t.Fatalf("move to %s: %v", want, err)
|
||||||
|
}
|
||||||
|
got, err := s.LookupProposedRoutine(ctx, "полить", object)
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("LookupProposedRoutine(%s): %v", want, err)
|
||||||
|
}
|
||||||
|
if got.Status != want {
|
||||||
|
t.Errorf("status = %q, want %q", got.Status, want)
|
||||||
|
}
|
||||||
|
list, err := s.ListProposedRoutinesByStatus(ctx, want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProposedRoutinesByStatus(%s): %v", want, err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Errorf("status %s: listed %d rows, want 1", want, len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A typo used to read as "nothing is in that state", which is the same answer
|
||||||
|
// a correct query gives on an empty table.
|
||||||
|
func TestListByStatusRefusesAnUnknownStatus(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
if _, err := s.ListProposedRoutinesByStatus(context.Background(), "accpeted"); !errors.Is(err, ErrRoutineStatus) {
|
||||||
|
t.Fatalf("err = %v, want ErrRoutineStatus", err)
|
||||||
|
}
|
||||||
|
if RoutineStatus("accpeted").Valid() {
|
||||||
|
t.Error("a typo must not be Valid()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user