Files
gastown/internal/tui/feed/events.go
max 1b69576573 fix: Address golangci-lint errors (errcheck, gosec) (#76)
Apply PR #76 from dannomayernotabot:

- Add golangci exclusions for internal package false positives
- Tighten file permissions (0644 -> 0600) for sensitive files
- Add ReadHeaderTimeout to HTTP server (slowloris prevention)
- Explicit error ignoring with _ = for intentional cases
- Add //nolint comments with justifications
- Spelling: cancelled -> canceled (US locale)

Co-Authored-By: dannomayernotabot <noreply@github.com>

🤖 Generated with Claude Code
2026-01-03 16:11:55 -08:00

588 lines
13 KiB
Go

package feed
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/steveyegge/gastown/internal/beads"
)
// EventSource represents a source of events
type EventSource interface {
Events() <-chan Event
Close() error
}
// BdActivitySource reads events from bd activity --follow
type BdActivitySource struct {
cmd *exec.Cmd
events chan Event
cancel context.CancelFunc
workDir string
}
// NewBdActivitySource creates a new source that tails bd activity
func NewBdActivitySource(workDir string) (*BdActivitySource, error) {
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, "bd", "activity", "--follow")
cmd.Dir = workDir
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, err
}
if err := cmd.Start(); err != nil {
cancel()
return nil, err
}
source := &BdActivitySource{
cmd: cmd,
events: make(chan Event, 100),
cancel: cancel,
workDir: workDir,
}
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if event := parseBdActivityLine(line); event != nil {
select {
case source.events <- *event:
default:
// Drop event if channel full
}
}
}
close(source.events)
}()
return source, nil
}
// Events returns the event channel
func (s *BdActivitySource) Events() <-chan Event {
return s.events
}
// Close stops the source
func (s *BdActivitySource) Close() error {
s.cancel()
return s.cmd.Wait()
}
// bd activity line pattern: [HH:MM:SS] SYMBOL BEAD_ID action · description
var bdActivityPattern = regexp.MustCompile(`^\[(\d{2}:\d{2}:\d{2})\]\s+([+→✓✗⊘📌])\s+(\S+)?\s*(\w+)?\s*·?\s*(.*)$`)
// parseBdActivityLine parses a line from bd activity output
func parseBdActivityLine(line string) *Event {
matches := bdActivityPattern.FindStringSubmatch(line)
if matches == nil {
// Try simpler pattern
return parseSimpleLine(line)
}
timeStr := matches[1]
symbol := matches[2]
beadID := matches[3]
action := matches[4]
message := matches[5]
// Parse time (assume today)
now := time.Now()
t, err := time.Parse("15:04:05", timeStr)
if err != nil {
t = now
} else {
t = time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), t.Second(), 0, now.Location())
}
// Map symbol to event type
eventType := "update"
switch symbol {
case "+":
eventType = "create"
case "→":
eventType = "update"
case "✓":
eventType = "complete"
case "✗":
eventType = "fail"
case "⊘":
eventType = "delete"
case "📌":
eventType = "pin"
}
// Try to extract actor and rig from bead ID
actor, rig, role := parseBeadContext(beadID)
return &Event{
Time: t,
Type: eventType,
Actor: actor,
Target: beadID,
Message: strings.TrimSpace(action + " " + message),
Rig: rig,
Role: role,
Raw: line,
}
}
// parseSimpleLine handles lines that don't match the full pattern
func parseSimpleLine(line string) *Event {
if strings.TrimSpace(line) == "" {
return nil
}
// Try to extract timestamp
var t time.Time
if len(line) > 10 && line[0] == '[' {
if idx := strings.Index(line, "]"); idx > 0 {
timeStr := line[1:idx]
now := time.Now()
if parsed, err := time.Parse("15:04:05", timeStr); err == nil {
t = time.Date(now.Year(), now.Month(), now.Day(),
parsed.Hour(), parsed.Minute(), parsed.Second(), 0, now.Location())
}
}
}
if t.IsZero() {
t = time.Now()
}
return &Event{
Time: t,
Type: "update",
Message: line,
Raw: line,
}
}
// parseBeadContext extracts actor/rig/role from a bead ID
// Uses canonical naming: prefix-rig-role-name
// Examples: gt-gastown-crew-joe, gt-gastown-witness, gt-mayor
func parseBeadContext(beadID string) (actor, rig, role string) {
if beadID == "" {
return
}
// Use the canonical parser
parsedRig, parsedRole, name, ok := beads.ParseAgentBeadID(beadID)
if !ok {
return
}
rig = parsedRig
role = parsedRole
// Build actor identifier
switch parsedRole {
case "mayor", "deacon":
actor = parsedRole
case "witness", "refinery":
actor = parsedRole
case "crew":
if name != "" {
actor = parsedRig + "/crew/" + name
} else {
actor = parsedRole
}
case "polecat":
if name != "" {
actor = parsedRig + "/" + name
} else {
actor = parsedRole
}
}
return
}
// GtEventsSource reads events from ~/gt/.events.jsonl (gt activity log)
type GtEventsSource struct {
file *os.File
events chan Event
cancel context.CancelFunc
}
// GtEvent is the structure of events in .events.jsonl
type GtEvent struct {
Timestamp string `json:"ts"`
Source string `json:"source"`
Type string `json:"type"`
Actor string `json:"actor"`
Payload map[string]interface{} `json:"payload"`
Visibility string `json:"visibility"`
}
// NewGtEventsSource creates a source that tails ~/gt/.events.jsonl
func NewGtEventsSource(townRoot string) (*GtEventsSource, error) {
eventsPath := filepath.Join(townRoot, ".events.jsonl")
file, err := os.Open(eventsPath)
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
source := &GtEventsSource{
file: file,
events: make(chan Event, 100),
cancel: cancel,
}
go source.tail(ctx)
return source, nil
}
// tail follows the file and sends events
func (s *GtEventsSource) tail(ctx context.Context) {
defer close(s.events)
// Seek to end for live tailing
_, _ = s.file.Seek(0, 2)
scanner := bufio.NewScanner(s.file)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for scanner.Scan() {
line := scanner.Text()
if event := parseGtEventLine(line); event != nil {
select {
case s.events <- *event:
default:
}
}
}
}
}
}
// Events returns the event channel
func (s *GtEventsSource) Events() <-chan Event {
return s.events
}
// Close stops the source
func (s *GtEventsSource) Close() error {
s.cancel()
return s.file.Close()
}
// parseGtEventLine parses a line from .events.jsonl
func parseGtEventLine(line string) *Event {
if strings.TrimSpace(line) == "" {
return nil
}
var ge GtEvent
if err := json.Unmarshal([]byte(line), &ge); err != nil {
return nil
}
// Only show feed-visible events
if ge.Visibility != "feed" && ge.Visibility != "both" {
return nil
}
t, err := time.Parse(time.RFC3339, ge.Timestamp)
if err != nil {
t = time.Now()
}
// Extract rig from payload or actor
rig := ""
if ge.Payload != nil {
if r, ok := ge.Payload["rig"].(string); ok {
rig = r
}
}
if rig == "" && ge.Actor != "" {
// Extract rig from actor like "gastown/witness"
parts := strings.Split(ge.Actor, "/")
if len(parts) > 0 && parts[0] != "mayor" && parts[0] != "deacon" {
rig = parts[0]
}
}
// Extract role from actor
role := ""
if ge.Actor != "" {
parts := strings.Split(ge.Actor, "/")
if len(parts) >= 2 {
role = parts[len(parts)-1]
// Check for known roles
switch parts[len(parts)-1] {
case "witness", "refinery":
role = parts[len(parts)-1]
default:
// Could be polecat name - check second-to-last part
if len(parts) >= 2 {
switch parts[len(parts)-2] {
case "polecats":
role = "polecat"
case "crew":
role = "crew"
}
}
}
} else if len(parts) == 1 {
role = parts[0]
}
}
// Build message from event type and payload
message := buildEventMessage(ge.Type, ge.Payload)
return &Event{
Time: t,
Type: ge.Type,
Actor: ge.Actor,
Target: getPayloadString(ge.Payload, "bead"),
Message: message,
Rig: rig,
Role: role,
Raw: line,
}
}
// buildEventMessage creates a human-readable message from event type and payload
func buildEventMessage(eventType string, payload map[string]interface{}) string {
switch eventType {
case "patrol_started":
count := getPayloadInt(payload, "polecat_count")
if msg := getPayloadString(payload, "message"); msg != "" {
return msg
}
if count > 0 {
return fmt.Sprintf("patrol started (%d polecats)", count)
}
return "patrol started"
case "patrol_complete":
count := getPayloadInt(payload, "polecat_count")
if msg := getPayloadString(payload, "message"); msg != "" {
return msg
}
if count > 0 {
return fmt.Sprintf("patrol complete (%d polecats)", count)
}
return "patrol complete"
case "polecat_checked":
polecat := getPayloadString(payload, "polecat")
status := getPayloadString(payload, "status")
if polecat != "" {
if status != "" {
return fmt.Sprintf("checked %s (%s)", polecat, status)
}
return fmt.Sprintf("checked %s", polecat)
}
return "polecat checked"
case "polecat_nudged":
polecat := getPayloadString(payload, "polecat")
reason := getPayloadString(payload, "reason")
if polecat != "" {
if reason != "" {
return fmt.Sprintf("nudged %s: %s", polecat, reason)
}
return fmt.Sprintf("nudged %s", polecat)
}
return "polecat nudged"
case "escalation_sent":
target := getPayloadString(payload, "target")
to := getPayloadString(payload, "to")
reason := getPayloadString(payload, "reason")
if target != "" && to != "" {
if reason != "" {
return fmt.Sprintf("escalated %s to %s: %s", target, to, reason)
}
return fmt.Sprintf("escalated %s to %s", target, to)
}
return "escalation sent"
case "sling":
bead := getPayloadString(payload, "bead")
target := getPayloadString(payload, "target")
if bead != "" && target != "" {
return fmt.Sprintf("slung %s to %s", bead, target)
}
return "work slung"
case "hook":
bead := getPayloadString(payload, "bead")
if bead != "" {
return fmt.Sprintf("hooked %s", bead)
}
return "bead hooked"
case "handoff":
subject := getPayloadString(payload, "subject")
if subject != "" {
return fmt.Sprintf("handoff: %s", subject)
}
return "session handoff"
case "done":
bead := getPayloadString(payload, "bead")
if bead != "" {
return fmt.Sprintf("done: %s", bead)
}
return "work done"
case "mail":
subject := getPayloadString(payload, "subject")
to := getPayloadString(payload, "to")
if subject != "" {
if to != "" {
return fmt.Sprintf("→ %s: %s", to, subject)
}
return subject
}
return "mail sent"
case "merged":
worker := getPayloadString(payload, "worker")
if worker != "" {
return fmt.Sprintf("merged work from %s", worker)
}
return "merged"
case "merge_failed":
reason := getPayloadString(payload, "reason")
if reason != "" {
return fmt.Sprintf("merge failed: %s", reason)
}
return "merge failed"
default:
if msg := getPayloadString(payload, "message"); msg != "" {
return msg
}
return eventType
}
}
// getPayloadString extracts a string from payload
func getPayloadString(payload map[string]interface{}, key string) string {
if payload == nil {
return ""
}
if v, ok := payload[key].(string); ok {
return v
}
return ""
}
// getPayloadInt extracts an int from payload
func getPayloadInt(payload map[string]interface{}, key string) int {
if payload == nil {
return 0
}
if v, ok := payload[key].(float64); ok {
return int(v)
}
return 0
}
// CombinedSource merges events from multiple sources
type CombinedSource struct {
sources []EventSource
events chan Event
cancel context.CancelFunc
}
// NewCombinedSource creates a source that merges multiple event sources
func NewCombinedSource(sources ...EventSource) *CombinedSource {
ctx, cancel := context.WithCancel(context.Background())
combined := &CombinedSource{
sources: sources,
events: make(chan Event, 100),
cancel: cancel,
}
// Fan-in from all sources
for _, src := range sources {
go func(s EventSource) {
for {
select {
case <-ctx.Done():
return
case event, ok := <-s.Events():
if !ok {
return
}
select {
case combined.events <- event:
default:
// Drop if full
}
}
}
}(src)
}
return combined
}
// Events returns the combined event channel
func (c *CombinedSource) Events() <-chan Event {
return c.events
}
// Close stops all sources
func (c *CombinedSource) Close() error {
c.cancel()
var lastErr error
for _, src := range c.sources {
if err := src.Close(); err != nil {
lastErr = err
}
}
return lastErr
}
// FindBeadsDir finds the beads directory for the given working directory
func FindBeadsDir(workDir string) (string, error) {
// Walk up looking for .beads
dir := workDir
for {
beadsPath := filepath.Join(dir, ".beads")
if info, err := os.Stat(beadsPath); err == nil && info.IsDir() {
return beadsPath, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", os.ErrNotExist
}