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:
george
2026-01-12 16:38:11 -08:00
committed by Steve Yegge
parent d6dc43938d
commit 1e3bf292f9
6 changed files with 1248 additions and 0 deletions

View 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
}

View File

@@ -0,0 +1,50 @@
package plugin
import (
"testing"
)
func TestPluginRunRecord(t *testing.T) {
record := PluginRunRecord{
PluginName: "test-plugin",
RigName: "gastown",
Result: ResultSuccess,
Body: "Test run completed successfully",
}
if record.PluginName != "test-plugin" {
t.Errorf("expected plugin name 'test-plugin', got %q", record.PluginName)
}
if record.RigName != "gastown" {
t.Errorf("expected rig name 'gastown', got %q", record.RigName)
}
if record.Result != ResultSuccess {
t.Errorf("expected result 'success', got %q", record.Result)
}
}
func TestRunResultConstants(t *testing.T) {
if ResultSuccess != "success" {
t.Errorf("expected ResultSuccess to be 'success', got %q", ResultSuccess)
}
if ResultFailure != "failure" {
t.Errorf("expected ResultFailure to be 'failure', got %q", ResultFailure)
}
if ResultSkipped != "skipped" {
t.Errorf("expected ResultSkipped to be 'skipped', got %q", ResultSkipped)
}
}
func TestNewRecorder(t *testing.T) {
recorder := NewRecorder("/tmp/test-town")
if recorder == nil {
t.Fatal("NewRecorder returned nil")
}
if recorder.townRoot != "/tmp/test-town" {
t.Errorf("expected townRoot '/tmp/test-town', got %q", recorder.townRoot)
}
}
// Integration tests for RecordRun, GetLastRun, GetRunsSince require
// a working beads installation and are skipped in unit tests.
// These functions shell out to `bd` commands.

228
internal/plugin/scanner.go Normal file
View File

@@ -0,0 +1,228 @@
package plugin
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
)
// Scanner discovers plugins in town and rig directories.
type Scanner struct {
townRoot string
rigNames []string
}
// NewScanner creates a new plugin scanner.
func NewScanner(townRoot string, rigNames []string) *Scanner {
return &Scanner{
townRoot: townRoot,
rigNames: rigNames,
}
}
// DiscoverAll scans all plugin locations and returns discovered plugins.
// Town-level plugins are scanned first, then rig-level plugins.
// Plugins are deduplicated by name (rig-level overrides town-level).
func (s *Scanner) DiscoverAll() ([]*Plugin, error) {
pluginMap := make(map[string]*Plugin)
// Scan town-level plugins first
townPlugins, err := s.scanTownPlugins()
if err != nil {
return nil, fmt.Errorf("scanning town plugins: %w", err)
}
for _, p := range townPlugins {
pluginMap[p.Name] = p
}
// Scan rig-level plugins (override town-level by name)
for _, rigName := range s.rigNames {
rigPlugins, err := s.scanRigPlugins(rigName)
if err != nil {
// Log warning but continue with other rigs
fmt.Fprintf(os.Stderr, "Warning: scanning plugins for rig %q: %v\n", rigName, err)
continue
}
for _, p := range rigPlugins {
pluginMap[p.Name] = p
}
}
// Convert map to slice
plugins := make([]*Plugin, 0, len(pluginMap))
for _, p := range pluginMap {
plugins = append(plugins, p)
}
return plugins, nil
}
// scanTownPlugins scans the town-level plugins directory.
func (s *Scanner) scanTownPlugins() ([]*Plugin, error) {
pluginsDir := filepath.Join(s.townRoot, "plugins")
return s.scanDirectory(pluginsDir, LocationTown, "")
}
// scanRigPlugins scans a rig's plugins directory.
func (s *Scanner) scanRigPlugins(rigName string) ([]*Plugin, error) {
pluginsDir := filepath.Join(s.townRoot, rigName, "plugins")
return s.scanDirectory(pluginsDir, LocationRig, rigName)
}
// scanDirectory scans a plugins directory for plugin definitions.
func (s *Scanner) scanDirectory(dir string, location Location, rigName string) ([]*Plugin, error) {
// Check if directory exists
info, err := os.Stat(dir)
if os.IsNotExist(err) {
return nil, nil // No plugins directory is fine
}
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, nil
}
// List plugin directories
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var plugins []*Plugin
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if strings.HasPrefix(entry.Name(), ".") {
continue
}
pluginDir := filepath.Join(dir, entry.Name())
plugin, err := s.loadPlugin(pluginDir, location, rigName)
if err != nil {
// Log warning but continue with other plugins
fmt.Fprintf(os.Stderr, "Warning: loading plugin %q: %v\n", entry.Name(), err)
continue
}
if plugin != nil {
plugins = append(plugins, plugin)
}
}
return plugins, nil
}
// loadPlugin loads a plugin from its directory.
func (s *Scanner) loadPlugin(pluginDir string, location Location, rigName string) (*Plugin, error) {
// Look for plugin.md
pluginFile := filepath.Join(pluginDir, "plugin.md")
if _, err := os.Stat(pluginFile); os.IsNotExist(err) {
return nil, nil // No plugin.md, skip
}
// Read and parse plugin.md
content, err := os.ReadFile(pluginFile) //nolint:gosec // G304: path is from trusted plugin directory
if err != nil {
return nil, fmt.Errorf("reading plugin.md: %w", err)
}
return parsePluginMD(content, pluginDir, location, rigName)
}
// parsePluginMD parses a plugin.md file with TOML frontmatter.
// Format:
//
// +++
// name = "plugin-name"
// ...
// +++
// # Instructions
// ...
func parsePluginMD(content []byte, pluginDir string, location Location, rigName string) (*Plugin, error) {
str := string(content)
// Find TOML frontmatter delimiters
const delimiter = "+++"
start := strings.Index(str, delimiter)
if start == -1 {
return nil, fmt.Errorf("missing TOML frontmatter (no opening +++)")
}
// Find closing delimiter
end := strings.Index(str[start+len(delimiter):], delimiter)
if end == -1 {
return nil, fmt.Errorf("missing TOML frontmatter (no closing +++)")
}
end += start + len(delimiter)
// Extract frontmatter and body
frontmatter := str[start+len(delimiter) : end]
body := strings.TrimSpace(str[end+len(delimiter):])
// Parse TOML frontmatter
var fm PluginFrontmatter
if _, err := toml.Decode(frontmatter, &fm); err != nil {
return nil, fmt.Errorf("parsing TOML frontmatter: %w", err)
}
// Validate required fields
if fm.Name == "" {
return nil, fmt.Errorf("missing required field: name")
}
plugin := &Plugin{
Name: fm.Name,
Description: fm.Description,
Version: fm.Version,
Location: location,
Path: pluginDir,
RigName: rigName,
Gate: fm.Gate,
Tracking: fm.Tracking,
Execution: fm.Execution,
Instructions: body,
}
return plugin, nil
}
// GetPlugin returns a specific plugin by name.
// Searches rig-level plugins first (more specific), then town-level.
func (s *Scanner) GetPlugin(name string) (*Plugin, error) {
// Search rig-level plugins first
for _, rigName := range s.rigNames {
pluginDir := filepath.Join(s.townRoot, rigName, "plugins", name)
plugin, err := s.loadPlugin(pluginDir, LocationRig, rigName)
if err != nil {
continue
}
if plugin != nil {
return plugin, nil
}
}
// Search town-level plugins
pluginDir := filepath.Join(s.townRoot, "plugins", name)
plugin, err := s.loadPlugin(pluginDir, LocationTown, "")
if err != nil {
return nil, err
}
if plugin == nil {
return nil, fmt.Errorf("plugin not found: %s", name)
}
return plugin, nil
}
// ListPluginDirs returns the directories where plugins are stored.
func (s *Scanner) ListPluginDirs() []string {
dirs := []string{filepath.Join(s.townRoot, "plugins")}
for _, rigName := range s.rigNames {
dirs = append(dirs, filepath.Join(s.townRoot, rigName, "plugins"))
}
return dirs
}

View File

@@ -0,0 +1,278 @@
package plugin
import (
"os"
"path/filepath"
"testing"
)
func TestParsePluginMD(t *testing.T) {
content := []byte(`+++
name = "test-plugin"
description = "A test plugin"
version = 1
[gate]
type = "cooldown"
duration = "1h"
[tracking]
labels = ["test:label"]
digest = true
[execution]
timeout = "5m"
notify_on_failure = true
+++
# Test Plugin
These are the instructions.
`)
plugin, err := parsePluginMD(content, "/test/path", LocationTown, "")
if err != nil {
t.Fatalf("parsePluginMD failed: %v", err)
}
if plugin.Name != "test-plugin" {
t.Errorf("expected name 'test-plugin', got %q", plugin.Name)
}
if plugin.Description != "A test plugin" {
t.Errorf("expected description 'A test plugin', got %q", plugin.Description)
}
if plugin.Version != 1 {
t.Errorf("expected version 1, got %d", plugin.Version)
}
if plugin.Location != LocationTown {
t.Errorf("expected location 'town', got %q", plugin.Location)
}
if plugin.Gate == nil {
t.Fatal("expected gate to be non-nil")
}
if plugin.Gate.Type != GateCooldown {
t.Errorf("expected gate type 'cooldown', got %q", plugin.Gate.Type)
}
if plugin.Gate.Duration != "1h" {
t.Errorf("expected gate duration '1h', got %q", plugin.Gate.Duration)
}
if plugin.Tracking == nil {
t.Fatal("expected tracking to be non-nil")
}
if len(plugin.Tracking.Labels) != 1 || plugin.Tracking.Labels[0] != "test:label" {
t.Errorf("expected labels ['test:label'], got %v", plugin.Tracking.Labels)
}
if !plugin.Tracking.Digest {
t.Error("expected digest to be true")
}
if plugin.Execution == nil {
t.Fatal("expected execution to be non-nil")
}
if plugin.Execution.Timeout != "5m" {
t.Errorf("expected timeout '5m', got %q", plugin.Execution.Timeout)
}
if !plugin.Execution.NotifyOnFailure {
t.Error("expected notify_on_failure to be true")
}
if plugin.Instructions == "" {
t.Error("expected instructions to be non-empty")
}
}
func TestParsePluginMD_MissingName(t *testing.T) {
content := []byte(`+++
description = "No name"
+++
# No Name Plugin
`)
_, err := parsePluginMD(content, "/test/path", LocationTown, "")
if err == nil {
t.Error("expected error for missing name")
}
}
func TestParsePluginMD_MissingFrontmatter(t *testing.T) {
content := []byte(`# No Frontmatter
Just instructions.
`)
_, err := parsePluginMD(content, "/test/path", LocationTown, "")
if err == nil {
t.Error("expected error for missing frontmatter")
}
}
func TestParsePluginMD_ManualGate(t *testing.T) {
// Plugin with no gate section should have nil Gate
content := []byte(`+++
name = "manual-plugin"
description = "A manual plugin"
version = 1
+++
# Manual Plugin
`)
plugin, err := parsePluginMD(content, "/test/path", LocationTown, "")
if err != nil {
t.Fatalf("parsePluginMD failed: %v", err)
}
if plugin.Gate != nil {
t.Error("expected gate to be nil for manual plugin")
}
// Summary should report gate type as manual
summary := plugin.Summary()
if summary.GateType != GateManual {
t.Errorf("expected gate type 'manual', got %q", summary.GateType)
}
}
func TestScanner_DiscoverAll(t *testing.T) {
// Create temp directory structure
tmpDir, err := os.MkdirTemp("", "plugin-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create town plugins directory
townPluginsDir := filepath.Join(tmpDir, "plugins")
if err := os.MkdirAll(townPluginsDir, 0755); err != nil {
t.Fatalf("failed to create town plugins dir: %v", err)
}
// Create a town plugin
townPlugin := filepath.Join(townPluginsDir, "town-plugin")
if err := os.MkdirAll(townPlugin, 0755); err != nil {
t.Fatalf("failed to create town plugin dir: %v", err)
}
townPluginContent := []byte(`+++
name = "town-plugin"
description = "Town level plugin"
version = 1
+++
# Town Plugin
`)
if err := os.WriteFile(filepath.Join(townPlugin, "plugin.md"), townPluginContent, 0644); err != nil {
t.Fatalf("failed to write town plugin: %v", err)
}
// Create rig plugins directory
rigPluginsDir := filepath.Join(tmpDir, "testrig", "plugins")
if err := os.MkdirAll(rigPluginsDir, 0755); err != nil {
t.Fatalf("failed to create rig plugins dir: %v", err)
}
// Create a rig plugin
rigPlugin := filepath.Join(rigPluginsDir, "rig-plugin")
if err := os.MkdirAll(rigPlugin, 0755); err != nil {
t.Fatalf("failed to create rig plugin dir: %v", err)
}
rigPluginContent := []byte(`+++
name = "rig-plugin"
description = "Rig level plugin"
version = 1
+++
# Rig Plugin
`)
if err := os.WriteFile(filepath.Join(rigPlugin, "plugin.md"), rigPluginContent, 0644); err != nil {
t.Fatalf("failed to write rig plugin: %v", err)
}
// Create scanner
scanner := NewScanner(tmpDir, []string{"testrig"})
// Discover all plugins
plugins, err := scanner.DiscoverAll()
if err != nil {
t.Fatalf("DiscoverAll failed: %v", err)
}
if len(plugins) != 2 {
t.Errorf("expected 2 plugins, got %d", len(plugins))
}
// Check that we have both plugins
names := make(map[string]bool)
for _, p := range plugins {
names[p.Name] = true
}
if !names["town-plugin"] {
t.Error("expected to find 'town-plugin'")
}
if !names["rig-plugin"] {
t.Error("expected to find 'rig-plugin'")
}
}
func TestScanner_RigOverridesTown(t *testing.T) {
// Create temp directory structure
tmpDir, err := os.MkdirTemp("", "plugin-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create town plugins directory with a plugin
townPluginsDir := filepath.Join(tmpDir, "plugins", "shared-plugin")
if err := os.MkdirAll(townPluginsDir, 0755); err != nil {
t.Fatalf("failed to create town plugins dir: %v", err)
}
townPluginContent := []byte(`+++
name = "shared-plugin"
description = "Town version"
version = 1
+++
# Town Version
`)
if err := os.WriteFile(filepath.Join(townPluginsDir, "plugin.md"), townPluginContent, 0644); err != nil {
t.Fatalf("failed to write town plugin: %v", err)
}
// Create rig plugins directory with same-named plugin
rigPluginsDir := filepath.Join(tmpDir, "testrig", "plugins", "shared-plugin")
if err := os.MkdirAll(rigPluginsDir, 0755); err != nil {
t.Fatalf("failed to create rig plugins dir: %v", err)
}
rigPluginContent := []byte(`+++
name = "shared-plugin"
description = "Rig version"
version = 1
+++
# Rig Version
`)
if err := os.WriteFile(filepath.Join(rigPluginsDir, "plugin.md"), rigPluginContent, 0644); err != nil {
t.Fatalf("failed to write rig plugin: %v", err)
}
// Create scanner
scanner := NewScanner(tmpDir, []string{"testrig"})
// Discover all plugins
plugins, err := scanner.DiscoverAll()
if err != nil {
t.Fatalf("DiscoverAll failed: %v", err)
}
// Should only have one plugin (rig overrides town)
if len(plugins) != 1 {
t.Errorf("expected 1 plugin (rig override), got %d", len(plugins))
}
if plugins[0].Description != "Rig version" {
t.Errorf("expected rig version description, got %q", plugins[0].Description)
}
if plugins[0].Location != LocationRig {
t.Errorf("expected location 'rig', got %q", plugins[0].Location)
}
}

165
internal/plugin/types.go Normal file
View File

@@ -0,0 +1,165 @@
// Package plugin provides plugin discovery and management for Gas Town.
//
// Plugins are periodic automation tasks that run during Deacon patrol cycles.
// Each plugin is defined by a plugin.md file with TOML frontmatter.
//
// Plugin locations:
// - Town-level: ~/gt/plugins/ (universal, apply everywhere)
// - Rig-level: <rig>/plugins/ (project-specific)
package plugin
import (
"time"
)
// Plugin represents a discovered plugin definition.
type Plugin struct {
// Name is the unique plugin identifier (from frontmatter).
Name string `json:"name"`
// Description is a human-readable description.
Description string `json:"description"`
// Version is the schema version (for future evolution).
Version int `json:"version"`
// Location indicates where the plugin was discovered.
Location Location `json:"location"`
// Path is the absolute path to the plugin directory.
Path string `json:"path"`
// RigName is set for rig-level plugins (empty for town-level).
RigName string `json:"rig_name,omitempty"`
// Gate defines when the plugin should run.
Gate *Gate `json:"gate,omitempty"`
// Tracking defines labels and digest settings.
Tracking *Tracking `json:"tracking,omitempty"`
// Execution defines timeout and notification settings.
Execution *Execution `json:"execution,omitempty"`
// Instructions is the markdown body (after frontmatter).
Instructions string `json:"instructions,omitempty"`
}
// Location indicates where a plugin was discovered.
type Location string
const (
// LocationTown indicates a town-level plugin (~/gt/plugins/).
LocationTown Location = "town"
// LocationRig indicates a rig-level plugin (<rig>/plugins/).
LocationRig Location = "rig"
)
// Gate defines when a plugin should run.
type Gate struct {
// Type is the gate type: cooldown, cron, condition, event, or manual.
Type GateType `json:"type" toml:"type"`
// Duration is for cooldown gates (e.g., "1h", "24h").
Duration string `json:"duration,omitempty" toml:"duration,omitempty"`
// Schedule is for cron gates (e.g., "0 9 * * *").
Schedule string `json:"schedule,omitempty" toml:"schedule,omitempty"`
// Check is for condition gates (command that returns exit 0 to run).
Check string `json:"check,omitempty" toml:"check,omitempty"`
// On is for event gates (e.g., "startup").
On string `json:"on,omitempty" toml:"on,omitempty"`
}
// GateType is the type of gate that controls plugin execution.
type GateType string
const (
// GateCooldown runs if enough time has passed since last run.
GateCooldown GateType = "cooldown"
// GateCron runs on a cron schedule.
GateCron GateType = "cron"
// GateCondition runs if a check command returns exit 0.
GateCondition GateType = "condition"
// GateEvent runs on specific events (startup, etc).
GateEvent GateType = "event"
// GateManual never auto-runs, must be triggered explicitly.
GateManual GateType = "manual"
)
// Tracking defines how plugin runs are tracked.
type Tracking struct {
// Labels are applied to execution wisps.
Labels []string `json:"labels,omitempty" toml:"labels,omitempty"`
// Digest indicates whether to include in daily digest.
Digest bool `json:"digest" toml:"digest"`
}
// Execution defines plugin execution settings.
type Execution struct {
// Timeout is the maximum execution time (e.g., "5m").
Timeout string `json:"timeout,omitempty" toml:"timeout,omitempty"`
// NotifyOnFailure escalates on failure.
NotifyOnFailure bool `json:"notify_on_failure" toml:"notify_on_failure"`
// Severity is the escalation severity on failure.
Severity string `json:"severity,omitempty" toml:"severity,omitempty"`
}
// PluginFrontmatter represents the TOML frontmatter in plugin.md files.
type PluginFrontmatter struct {
Name string `toml:"name"`
Description string `toml:"description"`
Version int `toml:"version"`
Gate *Gate `toml:"gate,omitempty"`
Tracking *Tracking `toml:"tracking,omitempty"`
Execution *Execution `toml:"execution,omitempty"`
}
// PluginSummary provides a concise overview of a plugin.
type PluginSummary struct {
Name string `json:"name"`
Description string `json:"description"`
Location Location `json:"location"`
RigName string `json:"rig_name,omitempty"`
GateType GateType `json:"gate_type,omitempty"`
Path string `json:"path"`
}
// Summary returns a PluginSummary for this plugin.
func (p *Plugin) Summary() PluginSummary {
var gateType GateType
if p.Gate != nil {
gateType = p.Gate.Type
} else {
gateType = GateManual
}
return PluginSummary{
Name: p.Name,
Description: p.Description,
Location: p.Location,
RigName: p.RigName,
GateType: gateType,
Path: p.Path,
}
}
// PluginRun represents a single execution of a plugin.
type PluginRun struct {
PluginName string `json:"plugin_name"`
RigName string `json:"rig_name,omitempty"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time,omitempty"`
Result string `json:"result"` // "success" or "failure"
Message string `json:"message,omitempty"`
}