tools: add scope column for capability model

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))
This commit is contained in:
kami
2026-07-05 11:40:15 +04:00
parent a80b919780
commit 6b80fd0c0f
15 changed files with 98 additions and 59 deletions
+3 -1
View File
@@ -16,7 +16,9 @@ import (
// var migrations = []string{
// `ALTER TABLE ...;`, // #1
// }
var migrations = []string{}
var migrations = []string{
`ALTER TABLE tools ADD COLUMN scope TEXT NOT NULL DEFAULT 'homelab';`,
}
// migrate applies every migration with a number greater than the DB's current
// user_version, each in its own transaction that also bumps user_version. Fails
+10 -8
View File
@@ -18,20 +18,22 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
// Empty baseline slice leaves the DB at version 0.
if v := userVersion(t, s); v != 0 {
t.Fatalf("fresh DB user_version = %d, want 0", v)
// The 1 migration in the built-in slice (tools scope) was applied on Open.
startVer := len(migrations)
if v := userVersion(t, s); v != startVer {
t.Fatalf("fresh DB user_version = %d, want %d", v, startVer)
}
// Append a fake migration and run it: creates a throwaway table, bumps to 1.
// Append a fake migration and run it: creates a throwaway table, bumps by 1.
migrations = append(migrations, `CREATE TABLE migrate_probe (id INTEGER PRIMARY KEY)`)
t.Cleanup(func() { migrations = migrations[:len(migrations)-1] })
if err := migrate(ctx, s.db); err != nil {
t.Fatalf("migrate: %v", err)
}
if v := userVersion(t, s); v != 1 {
t.Fatalf("after migrate user_version = %d, want 1", v)
want := startVer + 1
if v := userVersion(t, s); v != want {
t.Fatalf("after migrate user_version = %d, want %d", v, want)
}
if _, err := s.db.ExecContext(ctx, "INSERT INTO migrate_probe DEFAULT VALUES"); err != nil {
t.Fatalf("probe table not created: %v", err)
@@ -41,7 +43,7 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
if err := migrate(ctx, s.db); err != nil {
t.Fatalf("migrate second run not idempotent: %v", err)
}
if v := userVersion(t, s); v != 1 {
t.Fatalf("after re-migrate user_version = %d, want 1", v)
if v := userVersion(t, s); v != want {
t.Fatalf("after re-migrate user_version = %d, want %d", v, want)
}
}
+25 -15
View File
@@ -9,11 +9,13 @@ import (
"time"
)
// Tool — one act in the allowlist. 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.
// 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
@@ -34,12 +36,16 @@ var (
// 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.
func (s *Store) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
// 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, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, '[]', 0, 'proposed', ?, ?, ?)
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, ?, '[]', 0, 'proposed', ?, ?, ?)
ON CONFLICT(name) DO NOTHING`,
name, utterance, ts.UnixMilli(), ts.UnixMilli())
name, scope, utterance, ts.UnixMilli(), ts.UnixMilli())
if err != nil {
return false, fmt.Errorf("propose tool: %w", err)
}
@@ -54,10 +60,14 @@ func (s *Store) ProposeTool(ctx context.Context, name, utterance string, ts time
// 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.
func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error {
// 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)
@@ -67,11 +77,11 @@ func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destr
d = 1
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, ?, ?, 'enabled', '', ?, ?)
ON CONFLICT(name) DO UPDATE SET cmd=excluded.cmd, destructive=excluded.destructive,
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, string(raw), d, ts.UnixMilli(), ts.UnixMilli())
name, scope, string(raw), d, ts.UnixMilli(), ts.UnixMilli())
if err != nil {
return fmt.Errorf("enable tool: %w", err)
}
@@ -95,7 +105,7 @@ func (s *Store) DisableTool(ctx context.Context, name string) error {
// 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, cmd, destructive, status, utterance, created_ts, updated_ts
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) {
@@ -106,7 +116,7 @@ func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
// ListTools returns tools filtered by status ("" ⇒ all), name-sorted.
func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) {
q := `SELECT name, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools`
q := `SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools`
var args []any
if status != "" {
q += ` WHERE status = ?`
@@ -137,7 +147,7 @@ func scanTool(sc scanner) (Tool, error) {
var cmdJSON string
var d int
var created, updated int64
if err := sc.Scan(&t.Name, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil {
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 {
+20 -3
View File
@@ -14,15 +14,32 @@ func TestToolLifecycle(t *testing.T) {
ctx := context.Background()
now := time.Now()
if _, err := s.ProposeTool(ctx, "restart_svc", "restart the service", now); err != nil {
// Propose with empty scope → defaults to "homelab".
if _, err := s.ProposeTool(ctx, "restart_svc", "restart the service", "", now); err != nil {
t.Fatalf("propose: %v", err)
}
if err := s.EnableTool(ctx, "restart_svc", []string{"systemctl", "restart", "x"}, true, now); err != nil {
if err := s.EnableTool(ctx, "restart_svc", []string{"systemctl", "restart", "x"}, true, "", now); err != nil {
t.Fatalf("enable: %v", err)
}
if tl, _ := s.LookupTool(ctx, "restart_svc"); tl.Status != "enabled" {
tl, _ := s.LookupTool(ctx, "restart_svc")
if tl.Status != "enabled" {
t.Fatalf("after enable: status=%q want enabled", tl.Status)
}
if tl.Scope != "homelab" {
t.Fatalf("after enable: scope=%q want homelab", tl.Scope)
}
// Propose with explicit scope.
if _, err := s.ProposeTool(ctx, "reboot", "reboot the server", "datacenter", now); err != nil {
t.Fatalf("propose with scope: %v", err)
}
if err := s.EnableTool(ctx, "reboot", []string{"reboot"}, true, "datacenter", now); err != nil {
t.Fatalf("enable with scope: %v", err)
}
tl2, _ := s.LookupTool(ctx, "reboot")
if tl2.Scope != "datacenter" {
t.Fatalf("explicit scope: %q want datacenter", tl2.Scope)
}
if err := s.DisableTool(ctx, "restart_svc"); err != nil {
t.Fatalf("disable: %v", err)