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))
161 lines
5.3 KiB
Go
161 lines
5.3 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|