dc4c5b7841
A `smarthome` block points Maven at a Home Assistant instance. She reads its entity states to answer "что включено дома?", and every controllable device becomes a PROPOSED row in the existing act allowlist — cmd ["smarthome",<entity_id>,<service>], scope smarthome:<domain> — so nothing new had to be invented for the mutating half. ProposeTool/EnableTool/DisableTool, tool.Matcher and the confirm turn are untouched; one branch in Executor.Exec routes such a row to the client instead of exec, and "smarthome" is never run as a binary. This is the same trick overnight/mcp-tools used for #251, on purpose. Discovery only ever PROPOSES, and every control row is destructive=true: there is no read-only way to turn the heating off, so flipping something in his flat always costs a confirm turn and always had to be enabled by hand on /tools, behind step-up. The entity and the service come from the row he enabled, never from the utterance — Exec drops the spoken tail for a house row. A router that misheard can pick the wrong lamp; it cannot compose a target of its own. The service is checked against the domain's table on the way out too, so a hand-edited cmd column cannot reach an arbitrary Home Assistant service. set_brightness and set_temperature are deliberately absent: a spoken number the router got wrong is a wrong act on real hardware, and on/off is the whole of what a voice turn can defend. The read side is a query source ("home", before calendar and the recall passes) so "что нового дома?" is not answered from an old note. Its matcher needs a house marker plus an ask plus a device word and bails out on weather wording, because "какая температура на улице?" belongs to the weather source. Off unless configured: the block is dark without "enabled": true, and applyDefaults normalises a disabled block to nil so "off" stays in one place. deploy/mavend.json carries it disabled, with the token as ${HA_TOKEN}. NOT shipped, and not faked: MQTT / Zigbee2MQTT (plan steps 2 and 5) and the sensor-to-fact and presence-probe pipelines. There is no broker and no Home Assistant anywhere on this network — 8123 and 1883 are closed on every host in 192.168.1.0/24 — the module tree is vendored so a paho dependency cannot be added offline, and Home Assistant already fronts Zigbee2MQTT where it exists. Writing a sensor pipeline with no sensor to test it against would be a guess. Vikunja #256
222 lines
7.9 KiB
Go
222 lines
7.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Tool — one act in the allowlist. Scope namespaces tools (e.g. "homelab").
|
|
// Cmd is the fixed argv prefix run with the utterance's args appended (no
|
|
// shell). Status 'proposed' is a scaffold that drives nothing; 'enabled' is
|
|
// the human-flipped, runnable form.
|
|
type Tool struct {
|
|
Name string
|
|
Scope string
|
|
Cmd []string
|
|
Destructive bool
|
|
Status string // proposed | enabled
|
|
Utterance string // provenance: the utterance that scaffolded a proposal
|
|
CreatedTs time.Time
|
|
UpdatedTs time.Time
|
|
}
|
|
|
|
var (
|
|
// ErrToolNotFound — no tool row with this name.
|
|
ErrToolNotFound = errors.New("store: tool not found")
|
|
// ErrToolCmd — an enable supplied an empty argv (an enabled tool must run something).
|
|
ErrToolCmd = errors.New("store: enabled tool needs a non-empty cmd")
|
|
)
|
|
|
|
// ProposeTool inserts a 'proposed' scaffold for name (provenance = utterance)
|
|
// if no row for name exists yet. Returns true when a new proposal was written,
|
|
// false when a row (proposed or enabled) already existed. maven calls this when
|
|
// she classifies an act whose verb isn't on the enabled allowlist — she drafts
|
|
// the registration; a human enables it. Never overwrites an enabled tool.
|
|
// scope defaults to "homelab" when empty.
|
|
func (s *Store) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
|
if scope == "" {
|
|
scope = "homelab"
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
|
|
VALUES (?, ?, '[]', 0, 'proposed', ?, ?, ?)
|
|
ON CONFLICT(name) DO NOTHING`,
|
|
name, scope, utterance, ts.UnixMilli(), ts.UnixMilli())
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose tool: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose tool: rows affected: %w", err)
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// ProposeMCPTool is ProposeTool for a tool discovered on an MCP server
|
|
// (Vikunja #251): the proposal already knows what it would run, so cmd and
|
|
// destructive are written with it and Kami only has to press enable.
|
|
//
|
|
// It is still a PROPOSAL. Discovery cannot grant a capability — that is the
|
|
// whole reason a server can be configured without its tools becoming live.
|
|
// Like ProposeTool it never touches an existing row, so re-discovery on every
|
|
// restart is idempotent and cannot silently re-arm a tool that was disabled or
|
|
// change the cmd of one already enabled.
|
|
func (s *Store) ProposeMCPTool(ctx context.Context, name, scope string, cmd []string, destructive bool, utterance string, ts time.Time) (bool, error) {
|
|
if len(cmd) == 0 {
|
|
return false, ErrToolCmd
|
|
}
|
|
if scope == "" {
|
|
scope = "homelab"
|
|
}
|
|
raw, err := json.Marshal(cmd)
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose mcp tool: %w", err)
|
|
}
|
|
d := 0
|
|
if destructive {
|
|
d = 1
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
|
|
VALUES (?, ?, ?, ?, 'proposed', ?, ?, ?)
|
|
ON CONFLICT(name) DO NOTHING`,
|
|
name, scope, string(raw), d, utterance, ts.UnixMilli(), ts.UnixMilli())
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose mcp tool: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose mcp tool: rows affected: %w", err)
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// ProposeSmartHomeTool is ProposeTool for a controllable device discovered on
|
|
// the Home Assistant instance (Vikunja #256). Like ProposeMCPTool the proposal
|
|
// already knows what it would run, so cmd is written with it and Kami only has
|
|
// to press enable.
|
|
//
|
|
// It is still a PROPOSAL, and destructive is not a parameter: there is no
|
|
// read-only way to turn a lamp off, so every house row carries the confirm
|
|
// turn. Re-discovery on every refresh is idempotent — an existing row is never
|
|
// touched, so a device he disabled stays disabled.
|
|
func (s *Store) ProposeSmartHomeTool(ctx context.Context, name, scope string, cmd []string, utterance string, ts time.Time) (bool, error) {
|
|
return s.ProposeMCPTool(ctx, name, scope, cmd, true, utterance, ts)
|
|
}
|
|
|
|
// EnableTool fills cmd + destructive and flips status to 'enabled'. This is the
|
|
// human "enable" act (the authed surface calls it); it upserts so enabling a
|
|
// name that was never proposed still works. An empty cmd is refused — an
|
|
// enabled tool that runs nothing is a footgun, not a tool.
|
|
// scope defaults to "homelab" when empty.
|
|
func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
|
if len(cmd) == 0 {
|
|
return ErrToolCmd
|
|
}
|
|
if scope == "" {
|
|
scope = "homelab"
|
|
}
|
|
raw, err := json.Marshal(cmd)
|
|
if err != nil {
|
|
return fmt.Errorf("enable tool: %w", err)
|
|
}
|
|
d := 0
|
|
if destructive {
|
|
d = 1
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
|
|
VALUES (?, ?, ?, ?, 'enabled', '', ?, ?)
|
|
ON CONFLICT(name) DO UPDATE SET scope=excluded.scope, cmd=excluded.cmd, destructive=excluded.destructive,
|
|
status='enabled', updated_ts=excluded.updated_ts`,
|
|
name, scope, string(raw), d, ts.UnixMilli(), ts.UnixMilli())
|
|
if err != nil {
|
|
return fmt.Errorf("enable tool: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteTool permanently removes a tool row. Used for "dismiss" on proposed
|
|
// tools — there's no dismissed status, the proposal is simply gone and maven
|
|
// can re-propose it later if the same gap is encountered. Idempotent: deleting
|
|
// a tool that doesn't exist is a no-op.
|
|
func (s *Store) DeleteTool(ctx context.Context, name string) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM tools WHERE name = ?`, name)
|
|
return err
|
|
}
|
|
|
|
// DisableTool sets a tool's status from 'enabled' back to 'proposed'. This is
|
|
// the "disable" act on the authed surface — the tool stays in the store (its
|
|
// provenance preserved) but won't run until re-enabled. Idempotent: disabling
|
|
// a tool that is already proposed or doesn't exist is a no-op.
|
|
func (s *Store) DisableTool(ctx context.Context, name string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE tools SET status = 'proposed', updated_ts = ? WHERE name = ? AND status = 'enabled'`,
|
|
time.Now().UnixMilli(), name)
|
|
if err != nil {
|
|
return fmt.Errorf("disable tool: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LookupTool returns the tool by name. ErrToolNotFound when absent.
|
|
func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts
|
|
FROM tools WHERE name = ?`, name)
|
|
t, err := scanTool(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Tool{}, ErrToolNotFound
|
|
}
|
|
return t, err
|
|
}
|
|
|
|
// ListTools returns tools filtered by status ("" ⇒ all), name-sorted.
|
|
func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
|
q := `SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools`
|
|
var args []any
|
|
if status != "" {
|
|
q += ` WHERE status = ?`
|
|
args = append(args, status)
|
|
}
|
|
q += ` ORDER BY name ASC`
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list tools: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []Tool
|
|
for rows.Next() {
|
|
t, err := scanTool(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// scanner is the shared shape of *sql.Row and *sql.Rows.
|
|
type scanner interface{ Scan(...any) error }
|
|
|
|
func scanTool(sc scanner) (Tool, error) {
|
|
var t Tool
|
|
var cmdJSON string
|
|
var d int
|
|
var created, updated int64
|
|
if err := sc.Scan(&t.Name, &t.Scope, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil {
|
|
return Tool{}, err
|
|
}
|
|
if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil {
|
|
return Tool{}, fmt.Errorf("scan tool %q cmd: %w", t.Name, err)
|
|
}
|
|
t.Destructive = d != 0
|
|
t.CreatedTs = time.UnixMilli(created).UTC()
|
|
t.UpdatedTs = time.UnixMilli(updated).UTC()
|
|
return t, nil
|
|
}
|