134 lines
4.3 KiB
Go
134 lines
4.3 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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.
|
|
type Tool struct {
|
|
Name 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.
|
|
func (s *Store) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
|
|
res, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts)
|
|
VALUES (?, '[]', 0, 'proposed', ?, ?, ?)
|
|
ON CONFLICT(name) DO NOTHING`,
|
|
name, utterance, ts.UnixMilli(), ts.UnixMilli())
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose tool: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
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.
|
|
func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error {
|
|
if len(cmd) == 0 {
|
|
return ErrToolCmd
|
|
}
|
|
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, cmd, destructive, status, utterance, created_ts, updated_ts)
|
|
VALUES (?, ?, ?, 'enabled', '', ?, ?)
|
|
ON CONFLICT(name) DO UPDATE SET cmd=excluded.cmd, destructive=excluded.destructive,
|
|
status='enabled', updated_ts=excluded.updated_ts`,
|
|
name, string(raw), d, ts.UnixMilli(), ts.UnixMilli())
|
|
if err != nil {
|
|
return fmt.Errorf("enable 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, 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, 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, &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
|
|
}
|