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
+1 -1
View File
@@ -31,7 +31,7 @@ Notes:
| # | Task | Commit | Status | | # | Task | Commit | Status |
|---|------|--------|--------| |---|------|--------|--------|
| 5 | **mavcaldav tests**`cmd/mavcaldav/` (314 lines, 0 coverage). CalDAV polling, iCal parsing, value-change filtering | 6daa96b | done | | 5 | **mavcaldav tests**`cmd/mavcaldav/` (314 lines, 0 coverage). CalDAV polling, iCal parsing, value-change filtering | 6daa96b | done |
| 6 | **mavttsd tests**`cmd/mavttsd/` (Piper handler). TTS worker protocol round-trip | — | pending | | 6 | **mavttsd tests**`cmd/mavttsd/` (Piper handler). TTS worker protocol round-trip | a80b919 | done |
| 7 | **voicesink tests**`internal/delivery/voicesink/`. Voice channel dispatch, ErrNoSession mapping | ffef44f | done | | 7 | **voicesink tests**`internal/delivery/voicesink/`. Voice channel dispatch, ErrNoSession mapping | ffef44f | done |
| 8 | **mavweb tests** — extend to cover `main.go` routes (server setup, template parsing, route registration, startup flags) | 185f4f5 | done | | 8 | **mavweb tests** — extend to cover `main.go` routes (server setup, template parsing, route registration, startup flags) | 185f4f5 | done |
+2 -2
View File
@@ -719,7 +719,7 @@ func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) s
if name == "" { if name == "" {
return "не разобрала команду — попробуй иначе." return "не разобрала команду — попробуй иначе."
} }
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, h.now()) newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now())
if err != nil { if err != nil {
log.Printf("voice: propose tool %q: %v", name, err) log.Printf("voice: propose tool %q: %v", name, err)
return "команды «" + name + "» нет в списке разрешённых." return "команды «" + name + "» нет в списке разрешённых."
@@ -800,7 +800,7 @@ func seedTools(api ipc.CoreAPI, tools []config.ToolConfig) {
log.Printf("voice: skipping malformed tool config %+v", tc) log.Printf("voice: skipping malformed tool config %+v", tc)
continue continue
} }
if err := api.EnableTool(ctx, tc.Name, tc.Cmd, tc.Destructive, now); err != nil { if err := api.EnableTool(ctx, tc.Name, tc.Cmd, tc.Destructive, tc.Scope, now); err != nil {
log.Printf("voice: seed tool %q: %v", tc.Name, err) log.Printf("voice: seed tool %q: %v", tc.Name, err)
continue continue
} }
+3 -3
View File
@@ -56,7 +56,7 @@ type fakeCore struct {
historyErr error historyErr error
} }
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, _ time.Time) error { func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
f.gotEnableName, f.gotEnableCmd, f.gotEnableDest = name, cmd, destructive f.gotEnableName, f.gotEnableCmd, f.gotEnableDest = name, cmd, destructive
return f.enableErr return f.enableErr
} }
@@ -134,8 +134,8 @@ func (f *fakeCore) RevertFact(_ context.Context, _ string) (int64, error) {
func TestHandleTools_GET_RendersAndEscapes(t *testing.T) { func TestHandleTools_GET_RendersAndEscapes(t *testing.T) {
core := &fakeCore{ core := &fakeCore{
proposed: []ipc.Tool{{Name: "<b>x", Utterance: "restart the <i>thing"}}, proposed: []ipc.Tool{{Name: "<b>x", Scope: "homelab", Utterance: "restart the <i>thing"}},
enabled: []ipc.Tool{{Name: "svc", Cmd: []string{"systemctl", "restart"}, Destructive: true}}, enabled: []ipc.Tool{{Name: "svc", Scope: "", Cmd: []string{"systemctl", "restart"}, Destructive: true}},
} }
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core) handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core)
+8 -5
View File
@@ -344,11 +344,12 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
{{if .Msg}}<div class=msg>{{.Msg}}</div>{{end}} {{if .Msg}}<div class=msg>{{.Msg}}</div>{{end}}
<h2>proposed <small>({{len .Proposed}})</small></h2> <h2>proposed <small>({{len .Proposed}})</small></h2>
{{if .Proposed}}<p>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p> {{if .Proposed}}<p>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
<table><tr><th>name</th><th>from utterance</th><th>enable as</th></tr> <table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr> {{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td>{{.Utterance}}</td> <td><code>{{.Name}}</code></td><td>{{.Scope}}</td><td>{{.Utterance}}</td>
<td><form method=post action=/tools> <td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}"> <input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=enable> <input type=hidden name=action value=enable>
<input type=text name=cmd placeholder="systemctl restart" required> <input type=text name=cmd placeholder="systemctl restart" required>
<label><input type=checkbox name=destructive> destructive</label> <label><input type=checkbox name=destructive> destructive</label>
@@ -356,11 +357,12 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
</tr>{{end}}</table> </tr>{{end}}</table>
{{else}}<p>none pending.</p>{{end}} {{else}}<p>none pending.</p>{{end}}
<h2>enabled <small>({{len .Enabled}})</small></h2> <h2>enabled <small>({{len .Enabled}})</small></h2>
{{if .Enabled}}<table><tr><th>name</th><th>command</th><th></th><th></th></tr> {{if .Enabled}}<table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td> {{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td>{{.Scope}}</td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=d>destructive</span>{{end}}</td> <td>{{if .Destructive}}<span class=d>destructive</span>{{end}}</td>
<td><form method=post action=/tools style=display:inline> <td><form method=post action=/tools style=display:inline>
<input type=hidden name=name value="{{.Name}}"> <input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable> <input type=hidden name=action value=disable>
<button>disable</button></form></td></tr>{{end}}</table> <button>disable</button></form></td></tr>{{end}}</table>
{{else}}<p>none enabled.</p>{{end}} {{else}}<p>none enabled.</p>{{end}}
@@ -435,13 +437,14 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
switch action { switch action {
case "enable": case "enable":
scope := r.FormValue("scope")
cmd := strings.Fields(r.FormValue("cmd")) cmd := strings.Fields(r.FormValue("cmd"))
destructive := r.FormValue("destructive") != "" destructive := r.FormValue("destructive") != ""
if name == "" || len(cmd) == 0 { if name == "" || len(cmd) == 0 {
http.Error(w, "name and cmd required", http.StatusBadRequest) http.Error(w, "name and cmd required", http.StatusBadRequest)
return return
} }
if err := core.EnableTool(ctx, name, cmd, destructive, time.Now()); err != nil { if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil {
log.Printf("tools: enable %q: %v", name, err) log.Printf("tools: enable %q: %v", name, err)
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway) http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
return return
+2 -2
View File
@@ -363,10 +363,10 @@ func (r *recordingAPI) QueryNotes(_ context.Context, _ []float32, _ int) ([]ipc.
func (r *recordingAPI) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) { func (r *recordingAPI) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) {
return nil, nil return nil, nil
} }
func (r *recordingAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool, error) { func (r *recordingAPI) ProposeTool(_ context.Context, _, _, _ string, _ time.Time) (bool, error) {
return false, nil return false, nil
} }
func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ time.Time) error { func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ string, _ time.Time) error {
return nil return nil
} }
func (r *recordingAPI) DisableTool(_ context.Context, _ string) error { func (r *recordingAPI) DisableTool(_ context.Context, _ string) error {
+1
View File
@@ -180,6 +180,7 @@ type VoiceConfig struct {
// must not fire from the voice path (they need a confirm on an authed surface). // must not fire from the voice path (they need a confirm on an authed surface).
type ToolConfig struct { type ToolConfig struct {
Name string `json:"name"` Name string `json:"name"`
Scope string `json:"scope,omitempty"`
Cmd []string `json:"cmd"` Cmd []string `json:"cmd"`
Destructive bool `json:"destructive,omitempty"` Destructive bool `json:"destructive,omitempty"`
} }
+6 -2
View File
@@ -157,6 +157,7 @@ type sinceResp struct {
// inert scaffold; 'enabled' is runnable. The executor only runs 'enabled'. // inert scaffold; 'enabled' is runnable. The executor only runs 'enabled'.
type Tool struct { type Tool struct {
Name string `json:"name"` Name string `json:"name"`
Scope string `json:"scope"`
Cmd []string `json:"cmd"` Cmd []string `json:"cmd"`
Destructive bool `json:"destructive"` Destructive bool `json:"destructive"`
Status string `json:"status"` Status string `json:"status"`
@@ -167,6 +168,7 @@ type Tool struct {
type proposeToolReq struct { type proposeToolReq struct {
Name string `json:"name"` Name string `json:"name"`
Scope string `json:"scope"`
Utterance string `json:"utterance"` Utterance string `json:"utterance"`
Ts time.Time `json:"ts"` Ts time.Time `json:"ts"`
} }
@@ -175,6 +177,7 @@ type proposeToolResp struct {
} }
type enableToolReq struct { type enableToolReq struct {
Name string `json:"name"` Name string `json:"name"`
Scope string `json:"scope"`
Cmd []string `json:"cmd"` Cmd []string `json:"cmd"`
Destructive bool `json:"destructive"` Destructive bool `json:"destructive"`
Ts time.Time `json:"ts"` Ts time.Time `json:"ts"`
@@ -227,8 +230,9 @@ type CoreAPI interface {
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only); // Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
// ProposeTool is maven-callable (no step-up — she has no passkey). // ProposeTool is maven-callable (no step-up — she has no passkey).
// LookupTool/ListTools read them. // LookupTool/ListTools read them.
ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) // scope defaults to "homelab" when empty.
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error 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 DisableTool(ctx context.Context, name string) error
LookupTool(ctx context.Context, name string) (Tool, error) LookupTool(ctx context.Context, name string) (Tool, error)
ListTools(ctx context.Context, status string) ([]Tool, error) ListTools(ctx context.Context, status string) ([]Tool, error)
+4 -4
View File
@@ -309,16 +309,16 @@ func (c *Client) RecentNotes(ctx context.Context, n int) ([]Note, error) {
return out, nil return out, nil
} }
func (c *Client) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) { func (c *Client) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
var r proposeToolResp var r proposeToolResp
if err := c.call(ctx, MethodProposeTool, proposeToolReq{Name: name, Utterance: utterance, Ts: ts}, &r); err != nil { if err := c.call(ctx, MethodProposeTool, proposeToolReq{Name: name, Scope: scope, Utterance: utterance, Ts: ts}, &r); err != nil {
return false, err return false, err
} }
return r.Proposed, nil return r.Proposed, nil
} }
func (c *Client) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error { func (c *Client) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
return c.call(ctx, MethodEnableTool, enableToolReq{Name: name, Cmd: cmd, Destructive: destructive, Ts: ts}, nil) return c.call(ctx, MethodEnableTool, enableToolReq{Name: name, Scope: scope, Cmd: cmd, Destructive: destructive, Ts: ts}, nil)
} }
func (c *Client) DisableTool(ctx context.Context, name string) error { func (c *Client) DisableTool(ctx context.Context, name string) error {
+8 -8
View File
@@ -144,13 +144,13 @@ func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
return out, nil return out, nil
} }
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) { func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
ok, err := a.s.ProposeTool(ctx, name, utterance, ts) ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
return ok, mapErr(err) return ok, mapErr(err)
} }
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error { 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, ts)) return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
} }
func (a *storeAPI) DisableTool(ctx context.Context, name string) error { func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
@@ -184,8 +184,8 @@ func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error)
func toTool(t store.Tool) Tool { func toTool(t store.Tool) Tool {
return Tool{ return Tool{
Name: t.Name, Cmd: t.Cmd, Destructive: t.Destructive, Status: t.Status, Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs, Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
} }
} }
@@ -567,7 +567,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
ok, err := s.api.ProposeTool(ctx, p.Name, p.Utterance, p.Ts) ok, err := s.api.ProposeTool(ctx, p.Name, p.Utterance, p.Scope, p.Ts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -578,7 +578,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
return marshalResult(nil), s.api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Ts) return marshalResult(nil), s.api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Scope, p.Ts)
case MethodDisableTool: case MethodDisableTool:
var p disableToolReq var p disableToolReq
+3 -1
View File
@@ -16,7 +16,9 @@ import (
// var migrations = []string{ // var migrations = []string{
// `ALTER TABLE ...;`, // #1 // `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 // 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 // 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() ctx := context.Background()
s := newTestStore(t) s := newTestStore(t)
// Empty baseline slice leaves the DB at version 0. // The 1 migration in the built-in slice (tools scope) was applied on Open.
if v := userVersion(t, s); v != 0 { startVer := len(migrations)
t.Fatalf("fresh DB user_version = %d, want 0", v) 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)`) migrations = append(migrations, `CREATE TABLE migrate_probe (id INTEGER PRIMARY KEY)`)
t.Cleanup(func() { migrations = migrations[:len(migrations)-1] }) t.Cleanup(func() { migrations = migrations[:len(migrations)-1] })
if err := migrate(ctx, s.db); err != nil { if err := migrate(ctx, s.db); err != nil {
t.Fatalf("migrate: %v", err) t.Fatalf("migrate: %v", err)
} }
if v := userVersion(t, s); v != 1 { want := startVer + 1
t.Fatalf("after migrate user_version = %d, want 1", v) 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 { if _, err := s.db.ExecContext(ctx, "INSERT INTO migrate_probe DEFAULT VALUES"); err != nil {
t.Fatalf("probe table not created: %v", err) 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 { if err := migrate(ctx, s.db); err != nil {
t.Fatalf("migrate second run not idempotent: %v", err) t.Fatalf("migrate second run not idempotent: %v", err)
} }
if v := userVersion(t, s); v != 1 { if v := userVersion(t, s); v != want {
t.Fatalf("after re-migrate user_version = %d, want 1", v) t.Fatalf("after re-migrate user_version = %d, want %d", v, want)
} }
} }
+25 -15
View File
@@ -9,11 +9,13 @@ import (
"time" "time"
) )
// Tool — one act in the allowlist. Cmd is the fixed argv prefix run with the // Tool — one act in the allowlist. Scope namespaces tools (e.g. "homelab").
// utterance's args appended (no shell). Status 'proposed' is a scaffold that // Cmd is the fixed argv prefix run with the utterance's args appended (no
// drives nothing; 'enabled' is the human-flipped, runnable form. // shell). Status 'proposed' is a scaffold that drives nothing; 'enabled' is
// the human-flipped, runnable form.
type Tool struct { type Tool struct {
Name string Name string
Scope string
Cmd []string Cmd []string
Destructive bool Destructive bool
Status string // proposed | enabled Status string // proposed | enabled
@@ -34,12 +36,16 @@ var (
// false when a row (proposed or enabled) already existed. maven calls this when // 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 // 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. // 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, ` res, err := s.db.ExecContext(ctx, `
INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts) INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, '[]', 0, 'proposed', ?, ?, ?) VALUES (?, ?, '[]', 0, 'proposed', ?, ?, ?)
ON CONFLICT(name) DO NOTHING`, ON CONFLICT(name) DO NOTHING`,
name, utterance, ts.UnixMilli(), ts.UnixMilli()) name, scope, utterance, ts.UnixMilli(), ts.UnixMilli())
if err != nil { if err != nil {
return false, fmt.Errorf("propose tool: %w", err) 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 // 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 // name that was never proposed still works. An empty cmd is refused — an
// enabled tool that runs nothing is a footgun, not a tool. // 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 { if len(cmd) == 0 {
return ErrToolCmd return ErrToolCmd
} }
if scope == "" {
scope = "homelab"
}
raw, err := json.Marshal(cmd) raw, err := json.Marshal(cmd)
if err != nil { if err != nil {
return fmt.Errorf("enable tool: %w", err) 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 d = 1
} }
_, err = s.db.ExecContext(ctx, ` _, err = s.db.ExecContext(ctx, `
INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts) INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, ?, ?, 'enabled', '', ?, ?) VALUES (?, ?, ?, ?, 'enabled', '', ?, ?)
ON CONFLICT(name) DO UPDATE SET cmd=excluded.cmd, destructive=excluded.destructive, ON CONFLICT(name) DO UPDATE SET scope=excluded.scope, cmd=excluded.cmd, destructive=excluded.destructive,
status='enabled', updated_ts=excluded.updated_ts`, 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 { if err != nil {
return fmt.Errorf("enable tool: %w", err) 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. // LookupTool returns the tool by name. ErrToolNotFound when absent.
func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) { func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
row := s.db.QueryRowContext(ctx, ` 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) FROM tools WHERE name = ?`, name)
t, err := scanTool(row) t, err := scanTool(row)
if errors.Is(err, sql.ErrNoRows) { 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. // ListTools returns tools filtered by status ("" ⇒ all), name-sorted.
func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) { 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 var args []any
if status != "" { if status != "" {
q += ` WHERE status = ?` q += ` WHERE status = ?`
@@ -137,7 +147,7 @@ func scanTool(sc scanner) (Tool, error) {
var cmdJSON string var cmdJSON string
var d int var d int
var created, updated int64 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 return Tool{}, err
} }
if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil { 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() ctx := context.Background()
now := time.Now() 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) 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) 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) 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 { if err := s.DisableTool(ctx, "restart_svc"); err != nil {
t.Fatalf("disable: %v", err) t.Fatalf("disable: %v", err)
+1 -1
View File
@@ -39,7 +39,7 @@ import (
type API interface { type API interface {
LookupTool(ctx context.Context, name string) (ipc.Tool, error) LookupTool(ctx context.Context, name string) (ipc.Tool, error)
ListTools(ctx context.Context, status string) ([]ipc.Tool, error) ListTools(ctx context.Context, status string) ([]ipc.Tool, error)
ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
} }
var ( var (
+4 -4
View File
@@ -29,7 +29,7 @@ func (f fakeAPI) ListTools(_ context.Context, status string) ([]ipc.Tool, error)
} }
return out, nil return out, nil
} }
func (f fakeAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool, error) { func (f fakeAPI) ProposeTool(_ context.Context, _, _, _ string, _ time.Time) (bool, error) {
return true, nil return true, nil
} }
@@ -37,9 +37,9 @@ func (f fakeAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool,
// destructive needs confirm, and args land as argv (no shell) after the prefix. // destructive needs confirm, and args land as argv (no shell) after the prefix.
func TestExec(t *testing.T) { func TestExec(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{ api := fakeAPI{tools: map[string]ipc.Tool{
"restart": {Name: "restart", Cmd: []string{"systemctl", "restart"}, Status: "enabled"}, "restart": {Name: "restart", Scope: "homelab", Cmd: []string{"systemctl", "restart"}, Status: "enabled"},
"drop": {Name: "drop", Cmd: []string{"dropdb"}, Destructive: true, Status: "enabled"}, "drop": {Name: "drop", Scope: "homelab", Cmd: []string{"dropdb"}, Destructive: true, Status: "enabled"},
"draft": {Name: "draft", Cmd: []string{"x"}, Status: "proposed"}, "draft": {Name: "draft", Scope: "homelab", Cmd: []string{"x"}, Status: "proposed"},
}} }}
var gotArgv []string var gotArgv []string