feat(plugin): add plugin discovery, management, and run tracking
- internal/plugin/types.go: Plugin type definitions with TOML frontmatter schema - internal/plugin/scanner.go: Discover plugins from town and rig directories - internal/plugin/recording.go: Record plugin runs as ephemeral beads - internal/cmd/plugin.go: `gt plugin list` and `gt plugin show` commands Plugin locations: ~/gt/plugins/ (town-level), <rig>/plugins/ (rig-level). Rig-level plugins override town-level by name. Closes: gt-h8k4z, gt-rsejc, gt-n08ix.3 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
201
internal/plugin/recording.go
Normal file
201
internal/plugin/recording.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunResult represents the outcome of a plugin execution.
|
||||
type RunResult string
|
||||
|
||||
const (
|
||||
ResultSuccess RunResult = "success"
|
||||
ResultFailure RunResult = "failure"
|
||||
ResultSkipped RunResult = "skipped"
|
||||
)
|
||||
|
||||
// PluginRunRecord represents data for creating a plugin run bead.
|
||||
type PluginRunRecord struct {
|
||||
PluginName string
|
||||
RigName string
|
||||
Result RunResult
|
||||
Body string
|
||||
}
|
||||
|
||||
// PluginRunBead represents a recorded plugin run from the ledger.
|
||||
type PluginRunBead struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Labels []string `json:"labels"`
|
||||
Result RunResult `json:"-"` // Parsed from labels
|
||||
}
|
||||
|
||||
// Recorder handles plugin run recording and querying.
|
||||
type Recorder struct {
|
||||
townRoot string
|
||||
}
|
||||
|
||||
// NewRecorder creates a new plugin run recorder.
|
||||
func NewRecorder(townRoot string) *Recorder {
|
||||
return &Recorder{townRoot: townRoot}
|
||||
}
|
||||
|
||||
// RecordRun creates an ephemeral bead for a plugin run.
|
||||
// This is pure data writing - the caller decides what result to record.
|
||||
func (r *Recorder) RecordRun(record PluginRunRecord) (string, error) {
|
||||
title := fmt.Sprintf("Plugin run: %s", record.PluginName)
|
||||
|
||||
// Build labels
|
||||
labels := []string{
|
||||
"type:plugin-run",
|
||||
fmt.Sprintf("plugin:%s", record.PluginName),
|
||||
fmt.Sprintf("result:%s", record.Result),
|
||||
}
|
||||
if record.RigName != "" {
|
||||
labels = append(labels, fmt.Sprintf("rig:%s", record.RigName))
|
||||
}
|
||||
|
||||
// Build bd create command
|
||||
args := []string{
|
||||
"create",
|
||||
"--ephemeral",
|
||||
"--json",
|
||||
"--title=" + title,
|
||||
}
|
||||
for _, label := range labels {
|
||||
args = append(args, "-l", label)
|
||||
}
|
||||
if record.Body != "" {
|
||||
args = append(args, "--description="+record.Body)
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool
|
||||
cmd.Dir = r.townRoot
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("creating plugin run bead: %s: %w", stderr.String(), err)
|
||||
}
|
||||
|
||||
// Parse created bead ID from JSON output
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
return "", fmt.Errorf("parsing bd create output: %w", err)
|
||||
}
|
||||
|
||||
return result.ID, nil
|
||||
}
|
||||
|
||||
// GetLastRun returns the most recent run for a plugin.
|
||||
// Returns nil if no runs found.
|
||||
func (r *Recorder) GetLastRun(pluginName string) (*PluginRunBead, error) {
|
||||
runs, err := r.queryRuns(pluginName, 1, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(runs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return runs[0], nil
|
||||
}
|
||||
|
||||
// GetRunsSince returns all runs for a plugin since the given duration.
|
||||
// Duration format: "1h", "24h", "7d", etc.
|
||||
func (r *Recorder) GetRunsSince(pluginName string, since string) ([]*PluginRunBead, error) {
|
||||
return r.queryRuns(pluginName, 0, since)
|
||||
}
|
||||
|
||||
// queryRuns queries plugin run beads from the ledger.
|
||||
func (r *Recorder) queryRuns(pluginName string, limit int, since string) ([]*PluginRunBead, error) {
|
||||
args := []string{
|
||||
"list",
|
||||
"--ephemeral",
|
||||
"--json",
|
||||
"-l", "type:plugin-run",
|
||||
"-l", fmt.Sprintf("plugin:%s", pluginName),
|
||||
}
|
||||
if limit > 0 {
|
||||
args = append(args, fmt.Sprintf("--limit=%d", limit))
|
||||
}
|
||||
if since != "" {
|
||||
args = append(args, "--since="+since)
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool
|
||||
cmd.Dir = r.townRoot
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Empty result is OK (no runs found)
|
||||
if stderr.Len() == 0 || stdout.String() == "[]\n" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("querying plugin runs: %s: %w", stderr.String(), err)
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var beads []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &beads); err != nil {
|
||||
// Empty array is valid
|
||||
if stdout.String() == "[]\n" || stdout.Len() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("parsing bd list output: %w", err)
|
||||
}
|
||||
|
||||
// Convert to PluginRunBead with parsed result
|
||||
runs := make([]*PluginRunBead, 0, len(beads))
|
||||
for _, b := range beads {
|
||||
run := &PluginRunBead{
|
||||
ID: b.ID,
|
||||
Title: b.Title,
|
||||
Labels: b.Labels,
|
||||
}
|
||||
|
||||
// Parse created_at
|
||||
if t, err := time.Parse(time.RFC3339, b.CreatedAt); err == nil {
|
||||
run.CreatedAt = t
|
||||
}
|
||||
|
||||
// Extract result from labels
|
||||
for _, label := range b.Labels {
|
||||
if len(label) > 7 && label[:7] == "result:" {
|
||||
run.Result = RunResult(label[7:])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
runs = append(runs, run)
|
||||
}
|
||||
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
// CountRunsSince returns the count of runs for a plugin since the given duration.
|
||||
// This is useful for cooldown gate evaluation.
|
||||
func (r *Recorder) CountRunsSince(pluginName string, since string) (int, error) {
|
||||
runs, err := r.GetRunsSince(pluginName, since)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(runs), nil
|
||||
}
|
||||
Reference in New Issue
Block a user