6b80fd0c0f
Add a 'scope' TEXT column (default 'homelab') to the tools table so tools can be namespaced by scope (e.g. "homelab:restart", "datacenter:reboot"). Backward-compat: bare name defaults to "homelab" scope. Changes: - Migration #1: ALTER TABLE tools ADD COLUMN scope - store.Tool: add Scope field, update all SQL and scanTool() - ipc.Tool DTO and request types: add Scope field - CoreAPI interface: pass scope in ProposeTool/EnableTool - storeAPI adapters: forward scope - cmd/mavend/voice: pass scope (empty → homelab) - cmd/mavweb/tools: show scope column in UI tables, hidden fields - All tests updated for scope field - Migration test made dynamic (startVer = len(migrations))
289 lines
10 KiB
Go
289 lines
10 KiB
Go
package ipc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// DTOs — wire-level data. Decoupled from internal/store so the protocol is
|
|
// self-describing and a module never needs to import store internals (the
|
|
// boundary is the point). The store adapter maps store.* ⇔ these 1:1.
|
|
|
|
// Fact — one observation. Ts is valid-time (true-as-of), as in store.
|
|
type Fact struct {
|
|
ID int64 `json:"id"`
|
|
Ts time.Time `json:"ts"`
|
|
Kind string `json:"kind"` // "self" | "env" | "config"
|
|
Key string `json:"key"`
|
|
Value string `json:"value"` // raw json if structured
|
|
Source string `json:"source"` // tap:*|infer:*|poll:*|ambient|promote|feedback
|
|
Confidence float64 `json:"confidence"`
|
|
VoidsID *int64 `json:"voids_id,omitempty"`
|
|
}
|
|
|
|
// Bucket — presence hysteresis state: "present" | "away".
|
|
type Bucket string
|
|
|
|
const (
|
|
Present Bucket = "present"
|
|
Away Bucket = "away"
|
|
)
|
|
|
|
// Nudge — one proactive send + its outcome, for the monitoring read path.
|
|
type Nudge struct {
|
|
ID int64 `json:"id"`
|
|
Ts time.Time `json:"ts"`
|
|
Rule string `json:"rule"`
|
|
Channel string `json:"channel"`
|
|
Message string `json:"message"`
|
|
Outcome string `json:"outcome"` // pending|acted|snoozed|ignored
|
|
OutcomeTs *int64 `json:"outcome_ts,omitempty"`
|
|
}
|
|
|
|
// Note — a recall/preference item; ranked by embedding cosine on query.
|
|
// Score is set by QueryNotes (0 on the write path).
|
|
type Note struct {
|
|
ID int64 `json:"id"`
|
|
Ts time.Time `json:"ts"`
|
|
Text string `json:"text"`
|
|
Source string `json:"source"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
// Reminder — user-stated future intent; fires once.
|
|
type Reminder struct {
|
|
ID int64 `json:"id"`
|
|
CreatedTs time.Time `json:"created_ts"`
|
|
FireTs time.Time `json:"fire_ts"`
|
|
Payload string `json:"payload"`
|
|
Status string `json:"status"` // pending|fired|cancelled
|
|
}
|
|
|
|
// Presence — the read the phraser / delivery modules need to decide channel
|
|
// routing and tone. presence = reachability, NOT wakefulness (spec). Loop
|
|
// reads probes + computes score itself; modules get the resolved snapshot.
|
|
type Presence struct {
|
|
Bucket Bucket `json:"bucket"`
|
|
Score float64 `json:"score"`
|
|
Updated time.Time `json:"updated"`
|
|
}
|
|
|
|
// WriteFactReq — the only state mutation a capture/tool module performs.
|
|
// Confidence is 1.0 for taps, (0,1) for inferences; the store enforces range.
|
|
// Source is provenance — the server-side source-scope seam (auth layer) will
|
|
// refuse a module writing under a source it doesn't own ("compromised poller
|
|
// can't forge a trigger"). Today the floor permits any local caller.
|
|
type WriteFactReq struct {
|
|
Ts time.Time `json:"ts"`
|
|
Kind string `json:"kind"`
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Source string `json:"source"`
|
|
Confidence float64 `json:"confidence"`
|
|
VoidsID *int64 `json:"voids_id,omitempty"`
|
|
}
|
|
|
|
// idReq — methods keyed by a single id.
|
|
type idReq struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
|
|
// markReminderReq — pending→fired|cancelled.
|
|
type markReminderReq struct {
|
|
ID int64 `json:"id"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// resolveNudgeReq — pending→acted|snoozed|ignored, once.
|
|
type resolveNudgeReq struct {
|
|
ID int64 `json:"id"`
|
|
Outcome string `json:"outcome"`
|
|
Ts time.Time `json:"ts"`
|
|
}
|
|
|
|
// keyReq / keySourceReq / sinceReq / outcomesReq — read param shapes.
|
|
type keyReq struct {
|
|
Key string `json:"key"`
|
|
}
|
|
type keySourceReq struct {
|
|
Key string `json:"key"`
|
|
Source string `json:"source"`
|
|
}
|
|
type sinceReq struct {
|
|
Key string `json:"key"`
|
|
Now time.Time `json:"now"`
|
|
}
|
|
type outcomesReq struct {
|
|
Rule string `json:"rule"`
|
|
N int `json:"n"`
|
|
}
|
|
type nReq struct {
|
|
N int `json:"n"`
|
|
}
|
|
type revertReq struct {
|
|
Key string `json:"key"`
|
|
}
|
|
type writeNoteReq struct {
|
|
Ts time.Time `json:"ts"`
|
|
Text string `json:"text"`
|
|
Embedding []float32 `json:"embedding"`
|
|
Source string `json:"source"`
|
|
}
|
|
type queryNotesReq struct {
|
|
Embedding []float32 `json:"embedding"`
|
|
K int `json:"k"`
|
|
}
|
|
type createReminderReq struct {
|
|
Fire time.Time `json:"fire"`
|
|
Payload string `json:"payload"`
|
|
}
|
|
type recordNudgeReq struct {
|
|
Rule string `json:"rule"`
|
|
Channel string `json:"channel"`
|
|
Message string `json:"message"`
|
|
Ts time.Time `json:"ts"`
|
|
}
|
|
|
|
// idResp / sinceResp — small scalar return wrappers.
|
|
type idResp struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
type sinceResp struct {
|
|
Dur time.Duration `json:"dur"`
|
|
}
|
|
|
|
// Tool — an act allowlist entry as core exposes it. Status 'proposed' is an
|
|
// inert scaffold; 'enabled' is runnable. The executor only runs 'enabled'.
|
|
type Tool struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Cmd []string `json:"cmd"`
|
|
Destructive bool `json:"destructive"`
|
|
Status string `json:"status"`
|
|
Utterance string `json:"utterance"`
|
|
Created time.Time `json:"created"`
|
|
Updated time.Time `json:"updated"`
|
|
}
|
|
|
|
type proposeToolReq struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Utterance string `json:"utterance"`
|
|
Ts time.Time `json:"ts"`
|
|
}
|
|
type proposeToolResp struct {
|
|
Proposed bool `json:"proposed"`
|
|
}
|
|
type enableToolReq struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Cmd []string `json:"cmd"`
|
|
Destructive bool `json:"destructive"`
|
|
Ts time.Time `json:"ts"`
|
|
}
|
|
type disableToolReq struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type lookupToolReq struct {
|
|
Name string `json:"name"`
|
|
}
|
|
type listToolsReq struct {
|
|
Status string `json:"status"`
|
|
}
|
|
type listToolsResp struct {
|
|
Tools []Tool `json:"tools"`
|
|
}
|
|
|
|
// 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 string) (int64, error)
|
|
MarkReminder(ctx context.Context, id int64, status string) 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)
|
|
RecentNudges(ctx context.Context, n int) ([]Nudge, 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)
|
|
|
|
// 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
|
|
LookupTool(ctx context.Context, name string) (Tool, error)
|
|
ListTools(ctx context.Context, status string) ([]Tool, error)
|
|
RevertFact(ctx context.Context, key string) (int64, error)
|
|
}
|
|
|
|
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
|
|
// wire round-tripping via errors.Is).
|
|
var ErrToolNotFound = errors.New("ipc: tool not found")
|
|
|
|
// callerKey — context key for the authenticated caller. Server sets it from
|
|
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
|
|
// a missing Caller as "trusted same-process", the equivalent of the socket's
|
|
// 0600 floor).
|
|
type callerKey struct{}
|
|
|
|
// Caller — the peer identity as core sees it. Uid/Pid come from SO_PEERCRED
|
|
// on Linux; the future auth layer maps Uid + module enrollment → authority.
|
|
// Today only Uid is populated and used for a same-user check.
|
|
type Caller struct {
|
|
Uid int32
|
|
Pid int32
|
|
}
|
|
|
|
// WithCaller returns ctx annotated with c. Server-side use only.
|
|
func WithCaller(ctx context.Context, c Caller) context.Context {
|
|
return context.WithValue(ctx, callerKey{}, c)
|
|
}
|
|
|
|
// CallerFrom retrieves the Caller, or ok=false if absent (in-process path).
|
|
func CallerFrom(ctx context.Context) (Caller, bool) {
|
|
c, ok := ctx.Value(callerKey{}).(Caller)
|
|
return c, ok
|
|
}
|
|
|
|
// Sentinel errors. Mirror store's 1:1 so module code reads the same whether
|
|
// in-process or over the wire. The store adapter translates store.* → these.
|
|
var (
|
|
ErrNoFact = errors.New("ipc: no fact for key")
|
|
ErrConfidence = errors.New("ipc: confidence must be in (0.0, 1.0]")
|
|
ErrVoidsMissing = errors.New("ipc: voids_id does not reference an existing fact")
|
|
ErrNudgeNotFound = errors.New("ipc: nudge not found")
|
|
ErrNudgeOutcome = errors.New("ipc: nudge already resolved")
|
|
ErrReminderNotFound = errors.New("ipc: reminder not found")
|
|
ErrReminderState = errors.New("ipc: reminder not in a mutable state")
|
|
ErrUnknownMethod = errors.New("ipc: unknown method")
|
|
ErrBadParams = errors.New("ipc: bad params")
|
|
// ErrForbidden — the caller's authority doesn't cover this call. The
|
|
// auth layer's only wire-exported verdict: surface caps the layer, or a
|
|
// write was out-of-scope, or step-up was required but not asserted. The
|
|
// text ErrForbidden carries is derived during dispatch (from auth.ErrForbidden
|
|
// via fmt.Errorf %w wrapping); the wire carries codeForbidden.
|
|
ErrForbidden = errors.New("ipc: forbidden")
|
|
) |