Files
gastown/internal/cmd/patrol.go
max f19a0ab5d6 fix(patrol): add idempotency check for digest command
Checks if a 'Patrol Report YYYY-MM-DD' bead already exists before
attempting to create a new one. This prevents confusing output when
the patrol digest runs multiple times per day.

Fixes: gt-budqv9

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 03:41:18 -08:00

382 lines
11 KiB
Go

// Package cmd provides CLI commands for the gt tool.
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
)
var (
// Patrol digest flags
patrolDigestYesterday bool
patrolDigestDate string
patrolDigestDryRun bool
patrolDigestVerbose bool
)
var patrolCmd = &cobra.Command{
Use: "patrol",
GroupID: GroupDiag,
Short: "Patrol digest management",
Long: `Manage patrol cycle digests.
Patrol cycles (Deacon, Witness, Refinery) create ephemeral per-cycle digests
to avoid JSONL pollution. This command aggregates them into daily summaries.
Examples:
gt patrol digest --yesterday # Aggregate yesterday's patrol digests
gt patrol digest --dry-run # Preview what would be aggregated`,
}
var patrolDigestCmd = &cobra.Command{
Use: "digest",
Short: "Aggregate patrol cycle digests into a daily summary bead",
Long: `Aggregate ephemeral patrol cycle digests into a permanent daily summary.
This command is intended to be run by Deacon patrol (daily) or manually.
It queries patrol digests for a target date, creates a single aggregate
"Patrol Report YYYY-MM-DD" bead, then deletes the source digests.
The resulting digest bead is permanent (exported to JSONL, synced via git)
and provides an audit trail without per-cycle pollution.
Examples:
gt patrol digest --yesterday # Digest yesterday's patrols (for daily patrol)
gt patrol digest --date 2026-01-15
gt patrol digest --yesterday --dry-run`,
RunE: runPatrolDigest,
}
func init() {
patrolCmd.AddCommand(patrolDigestCmd)
rootCmd.AddCommand(patrolCmd)
// Patrol digest flags
patrolDigestCmd.Flags().BoolVar(&patrolDigestYesterday, "yesterday", false, "Digest yesterday's patrol cycles")
patrolDigestCmd.Flags().StringVar(&patrolDigestDate, "date", "", "Digest patrol cycles for specific date (YYYY-MM-DD)")
patrolDigestCmd.Flags().BoolVar(&patrolDigestDryRun, "dry-run", false, "Preview what would be created without creating")
patrolDigestCmd.Flags().BoolVarP(&patrolDigestVerbose, "verbose", "v", false, "Verbose output")
}
// PatrolDigest represents the aggregated daily patrol report.
type PatrolDigest struct {
Date string `json:"date"`
TotalCycles int `json:"total_cycles"`
ByRole map[string]int `json:"by_role"` // deacon, witness, refinery
Cycles []PatrolCycleEntry `json:"cycles"`
}
// PatrolCycleEntry represents a single patrol cycle in the digest.
type PatrolCycleEntry struct {
ID string `json:"id"`
Role string `json:"role"` // deacon, witness, refinery
Title string `json:"title"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
ClosedAt time.Time `json:"closed_at,omitempty"`
}
// runPatrolDigest aggregates patrol cycle digests into a daily digest bead.
func runPatrolDigest(cmd *cobra.Command, args []string) error {
// Determine target date
var targetDate time.Time
if patrolDigestDate != "" {
parsed, err := time.Parse("2006-01-02", patrolDigestDate)
if err != nil {
return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err)
}
targetDate = parsed
} else if patrolDigestYesterday {
targetDate = time.Now().AddDate(0, 0, -1)
} else {
return fmt.Errorf("specify --yesterday or --date YYYY-MM-DD")
}
dateStr := targetDate.Format("2006-01-02")
// Idempotency check: see if digest already exists for this date
existingID, err := findExistingPatrolDigest(dateStr)
if err != nil {
// Non-fatal: continue with creation attempt
if patrolDigestVerbose {
fmt.Fprintf(os.Stderr, "[patrol] warning: failed to check existing digest: %v\n", err)
}
} else if existingID != "" {
fmt.Printf("%s Patrol digest already exists for %s (bead: %s)\n",
style.Dim.Render("○"), dateStr, existingID)
return nil
}
// Query ephemeral patrol digest beads for target date
cycles, err := queryPatrolDigests(targetDate)
if err != nil {
return fmt.Errorf("querying patrol digests: %w", err)
}
if len(cycles) == 0 {
fmt.Printf("%s No patrol digests found for %s\n", style.Dim.Render("○"), dateStr)
return nil
}
// Build digest
digest := PatrolDigest{
Date: dateStr,
Cycles: cycles,
ByRole: make(map[string]int),
}
for _, c := range cycles {
digest.TotalCycles++
digest.ByRole[c.Role]++
}
if patrolDigestDryRun {
fmt.Printf("%s [DRY RUN] Would create Patrol Report %s:\n", style.Bold.Render("📊"), dateStr)
fmt.Printf(" Total cycles: %d\n", digest.TotalCycles)
fmt.Printf(" By Role:\n")
roles := make([]string, 0, len(digest.ByRole))
for role := range digest.ByRole {
roles = append(roles, role)
}
sort.Strings(roles)
for _, role := range roles {
fmt.Printf(" %s: %d cycles\n", role, digest.ByRole[role])
}
return nil
}
// Create permanent digest bead
digestID, err := createPatrolDigestBead(digest)
if err != nil {
return fmt.Errorf("creating digest bead: %w", err)
}
// Delete source digests (they're ephemeral)
deletedCount, deleteErr := deletePatrolDigests(targetDate)
if deleteErr != nil {
fmt.Fprintf(os.Stderr, "warning: failed to delete some source digests: %v\n", deleteErr)
}
fmt.Printf("%s Created Patrol Report %s (bead: %s)\n", style.Success.Render("✓"), dateStr, digestID)
fmt.Printf(" Total: %d cycles\n", digest.TotalCycles)
for role, count := range digest.ByRole {
fmt.Printf(" %s: %d\n", role, count)
}
if deletedCount > 0 {
fmt.Printf(" Deleted %d source digests\n", deletedCount)
}
return nil
}
// queryPatrolDigests queries ephemeral patrol digest beads for a target date.
func queryPatrolDigests(targetDate time.Time) ([]PatrolCycleEntry, error) {
// List closed issues with "digest" label that are ephemeral
// Patrol digests have titles like "Digest: mol-deacon-patrol", "Digest: mol-witness-patrol"
listCmd := exec.Command("bd", "list",
"--status=closed",
"--label=digest",
"--json",
"--limit=0", // Get all
)
listOutput, err := listCmd.Output()
if err != nil {
if patrolDigestVerbose {
fmt.Fprintf(os.Stderr, "[patrol] bd list failed: %v\n", err)
}
return nil, nil
}
var issues []struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ClosedAt time.Time `json:"closed_at"`
Ephemeral bool `json:"ephemeral"`
}
if err := json.Unmarshal(listOutput, &issues); err != nil {
return nil, fmt.Errorf("parsing issue list: %w", err)
}
targetDay := targetDate.Format("2006-01-02")
var patrolDigests []PatrolCycleEntry
for _, issue := range issues {
// Only process ephemeral patrol digests
if !issue.Ephemeral {
continue
}
// Must be a patrol digest (title starts with "Digest: mol-")
if !strings.HasPrefix(issue.Title, "Digest: mol-") {
continue
}
// Check if created on target date
if issue.CreatedAt.Format("2006-01-02") != targetDay {
continue
}
// Extract role from title (e.g., "Digest: mol-deacon-patrol" -> "deacon")
role := extractPatrolRole(issue.Title)
patrolDigests = append(patrolDigests, PatrolCycleEntry{
ID: issue.ID,
Role: role,
Title: issue.Title,
Description: issue.Description,
CreatedAt: issue.CreatedAt,
ClosedAt: issue.ClosedAt,
})
}
return patrolDigests, nil
}
// extractPatrolRole extracts the role from a patrol digest title.
// "Digest: mol-deacon-patrol" -> "deacon"
// "Digest: mol-witness-patrol" -> "witness"
// "Digest: gt-wisp-abc123" -> "unknown"
func extractPatrolRole(title string) string {
// Remove "Digest: " prefix
title = strings.TrimPrefix(title, "Digest: ")
// Extract role from "mol-<role>-patrol" or "gt-wisp-<id>"
if strings.HasPrefix(title, "mol-") && strings.HasSuffix(title, "-patrol") {
// "mol-deacon-patrol" -> "deacon"
role := strings.TrimPrefix(title, "mol-")
role = strings.TrimSuffix(role, "-patrol")
return role
}
// For wisp digests, try to extract from description or return generic
return "patrol"
}
// createPatrolDigestBead creates a permanent bead for the daily patrol digest.
func createPatrolDigestBead(digest PatrolDigest) (string, error) {
// Build description with aggregate data
var desc strings.Builder
desc.WriteString(fmt.Sprintf("Daily patrol aggregate for %s.\n\n", digest.Date))
desc.WriteString(fmt.Sprintf("**Total Cycles:** %d\n\n", digest.TotalCycles))
if len(digest.ByRole) > 0 {
desc.WriteString("## By Role\n")
roles := make([]string, 0, len(digest.ByRole))
for role := range digest.ByRole {
roles = append(roles, role)
}
sort.Strings(roles)
for _, role := range roles {
desc.WriteString(fmt.Sprintf("- %s: %d cycles\n", role, digest.ByRole[role]))
}
desc.WriteString("\n")
}
// Build payload JSON with cycle details
payloadJSON, err := json.Marshal(digest)
if err != nil {
return "", fmt.Errorf("marshaling digest payload: %w", err)
}
// Create the digest bead (NOT ephemeral - this is permanent)
title := fmt.Sprintf("Patrol Report %s", digest.Date)
bdArgs := []string{
"create",
"--type=event",
"--title=" + title,
"--event-category=patrol.digest",
"--event-payload=" + string(payloadJSON),
"--description=" + desc.String(),
"--silent",
}
bdCmd := exec.Command("bd", bdArgs...)
output, err := bdCmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("creating digest bead: %w\nOutput: %s", err, string(output))
}
digestID := strings.TrimSpace(string(output))
// Auto-close the digest (it's an audit record, not work)
closeCmd := exec.Command("bd", "close", digestID, "--reason=daily patrol digest")
_ = closeCmd.Run() // Best effort
return digestID, nil
}
// findExistingPatrolDigest checks if a patrol digest already exists for the given date.
// Returns the bead ID if found, empty string if not found.
func findExistingPatrolDigest(dateStr string) (string, error) {
expectedTitle := fmt.Sprintf("Patrol Report %s", dateStr)
// Query event beads with patrol.digest category
listCmd := exec.Command("bd", "list",
"--type=event",
"--json",
"--limit=50", // Recent events only
)
listOutput, err := listCmd.Output()
if err != nil {
return "", err
}
var events []struct {
ID string `json:"id"`
Title string `json:"title"`
}
if err := json.Unmarshal(listOutput, &events); err != nil {
return "", err
}
for _, evt := range events {
if evt.Title == expectedTitle {
return evt.ID, nil
}
}
return "", nil
}
// deletePatrolDigests deletes ephemeral patrol digest beads for a target date.
func deletePatrolDigests(targetDate time.Time) (int, error) {
// Query patrol digests for the target date
cycles, err := queryPatrolDigests(targetDate)
if err != nil {
return 0, err
}
if len(cycles) == 0 {
return 0, nil
}
// Collect IDs to delete
var idsToDelete []string
for _, cycle := range cycles {
idsToDelete = append(idsToDelete, cycle.ID)
}
// Delete in batch
deleteArgs := append([]string{"delete", "--force"}, idsToDelete...)
deleteCmd := exec.Command("bd", deleteArgs...)
if err := deleteCmd.Run(); err != nil {
return 0, fmt.Errorf("deleting patrol digests: %w", err)
}
return len(idsToDelete), nil
}