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
588 lines
13 KiB
Go
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
|
|
}
|