Some checks failed
CI / Check for .beads changes (pull_request) Successful in 9s
CI / Check embedded formulas (pull_request) Successful in 32s
CI / Test (pull_request) Failing after 1m47s
CI / Lint (pull_request) Failing after 22s
CI / Integration Tests (pull_request) Successful in 1m35s
CI / Coverage Report (pull_request) Has been skipped
Windows CI / Windows Build and Unit Tests (pull_request) Has been cancelled
`gt plugin run` was recording ResultSuccess even though it only prints instructions without executing them. This poisoned the cooldown gate, blocking actual executions for 24h. Changes: - Record manual runs as ResultSkipped instead of ResultSuccess - Add CountSuccessfulRunsSince() to only count successful runs for gate - Gate check now uses CountSuccessfulRunsSince() so skipped/failed runs don't block future executions Fixes: hq-2dis4c Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
232 lines
6.1 KiB
Go
232 lines
6.1 KiB
Go
package plugin
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
)
|
|
|
|
// 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
|
|
// Set BEADS_DIR explicitly to prevent inherited env vars from causing
|
|
// prefix mismatches when redirects are in play.
|
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beads.ResolveBeadsDir(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",
|
|
"--json",
|
|
"--all", // Include closed beads too
|
|
"-l", "type:plugin-run",
|
|
"-l", fmt.Sprintf("plugin:%s", pluginName),
|
|
}
|
|
if limit > 0 {
|
|
args = append(args, fmt.Sprintf("--limit=%d", limit))
|
|
}
|
|
if since != "" {
|
|
// Convert duration like "1h" to created-after format
|
|
// bd supports relative dates with - prefix (e.g., -1h, -24h)
|
|
sinceArg := since
|
|
if !strings.HasPrefix(since, "-") {
|
|
sinceArg = "-" + since
|
|
}
|
|
args = append(args, "--created-after="+sinceArg)
|
|
}
|
|
|
|
cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool
|
|
cmd.Dir = r.townRoot
|
|
// Set BEADS_DIR explicitly to prevent inherited env vars from causing
|
|
// prefix mismatches when redirects are in play.
|
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beads.ResolveBeadsDir(r.townRoot))
|
|
|
|
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
|
|
}
|
|
|
|
// CountSuccessfulRunsSince returns the count of successful runs for a plugin since the given duration.
|
|
// Only successful runs count for cooldown gate evaluation - skipped/failed runs don't reset the cooldown.
|
|
func (r *Recorder) CountSuccessfulRunsSince(pluginName string, since string) (int, error) {
|
|
runs, err := r.GetRunsSince(pluginName, since)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
count := 0
|
|
for _, run := range runs {
|
|
if run.Result == ResultSuccess {
|
|
count++
|
|
}
|
|
}
|
|
return count, nil
|
|
}
|