Files
GitManager/internal/activity/activity.go
T
TBNilles 65daedc902 Slice 3: activity feed and active project (SSE + MCP tools)
Add internal/activity (thread-safe active project + bounded event feed with subscriber fan-out). Service exposes active-project/activity methods and takes the feed. New MCP tools get_active_project/set_active_project/get_activity and HTTP endpoints GET/POST /api/active-project, /api/activity, and /events (SSE). New <activity-feed> component updates live via EventSource; <repo-list> sets the active project on selection. Verified end-to-end in the running app: user actions push live events and set the active project (actor=user), all readable by Claude over MCP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:16:42 -04:00

155 lines
4.2 KiB
Go

// Package activity holds the app's coordination state: the single active project
// (the repo/task currently in focus) and a bounded feed of what happened — user
// AND Claude actions. Both are in-memory (mirrored to the logs, no datastore —
// AGENT.md §1.3) and queryable so Claude can sync on any turn boundary; new
// events also fan out to subscribers for the browser SSE stream (§8.2). This is
// the foundation the graceful project handoff (§8.3) builds on.
package activity
import (
"log/slog"
"sync"
"time"
)
// Actor is who caused an event.
type Actor string
const (
ActorUser Actor = "user"
ActorClaude Actor = "claude"
ActorSystem Actor = "system"
)
// Event is one entry in the activity feed.
type Event struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
Actor Actor `json:"actor"`
Kind string `json:"kind"` // e.g. "active-project-changed"
Repo string `json:"repo,omitempty"` // repo path, when relevant
Detail string `json:"detail,omitempty"` // human-readable extra context
}
// Feed is the concurrency-safe active-project + activity store with fan-out.
type Feed struct {
mu sync.RWMutex
active string // active project path ("" = none)
events []Event
maxEvents int
nextID int64
subs map[chan Event]struct{}
log *slog.Logger
}
// New builds a Feed keeping at most maxEvents recent events.
func New(log *slog.Logger, maxEvents int) *Feed {
if maxEvents <= 0 {
maxEvents = 200
}
return &Feed{
maxEvents: maxEvents,
nextID: 1,
subs: make(map[chan Event]struct{}),
log: log,
}
}
// ActiveProject returns the current active project path ("" if none).
func (f *Feed) ActiveProject() string {
f.mu.RLock()
defer f.mu.RUnlock()
return f.active
}
// SetActiveProject sets the active project and records an event. It is a no-op
// (changed=false, zero Event) when path already matches, so repeated sets don't
// spam the feed.
func (f *Feed) SetActiveProject(actor Actor, path string) (Event, bool) {
f.mu.Lock()
if f.active == path {
f.mu.Unlock()
return Event{}, false
}
f.active = path
ev, subs := f.appendLocked(actor, "active-project-changed", path, "")
f.mu.Unlock()
publish(subs, ev)
return ev, true
}
// Record adds an arbitrary event to the feed.
func (f *Feed) Record(actor Actor, kind, repo, detail string) Event {
f.mu.Lock()
ev, subs := f.appendLocked(actor, kind, repo, detail)
f.mu.Unlock()
publish(subs, ev)
return ev
}
// Events returns up to limit of the most recent events, oldest first. limit<=0
// returns all retained events.
func (f *Feed) Events(limit int) []Event {
f.mu.RLock()
defer f.mu.RUnlock()
if limit <= 0 || limit > len(f.events) {
limit = len(f.events)
}
out := make([]Event, limit)
copy(out, f.events[len(f.events)-limit:])
return out
}
// Subscribe returns a channel of future events and an unsubscribe func the
// caller MUST invoke when done (e.g. via defer) to avoid leaking the channel.
func (f *Feed) Subscribe() (<-chan Event, func()) {
ch := make(chan Event, 16)
f.mu.Lock()
f.subs[ch] = struct{}{}
f.mu.Unlock()
var once sync.Once
unsub := func() {
once.Do(func() {
f.mu.Lock()
delete(f.subs, ch)
f.mu.Unlock()
close(ch)
})
}
return ch, unsub
}
// appendLocked assigns id/time, appends (trimming to maxEvents), logs, and
// returns the event plus a snapshot of subscriber channels to publish to after
// the lock is released. Caller must hold f.mu.
func (f *Feed) appendLocked(actor Actor, kind, repo, detail string) (Event, []chan Event) {
ev := Event{ID: f.nextID, Time: time.Now(), Actor: actor, Kind: kind, Repo: repo, Detail: detail}
f.nextID++
f.events = append(f.events, ev)
if len(f.events) > f.maxEvents {
f.events = f.events[len(f.events)-f.maxEvents:]
}
if f.log != nil {
f.log.Info("activity", "actor", actor, "kind", kind, "repo", repo, "detail", detail)
}
subs := make([]chan Event, 0, len(f.subs))
for ch := range f.subs {
subs = append(subs, ch)
}
return ev, subs
}
// publish does a non-blocking send to each subscriber; a full channel (slow
// consumer) drops the event rather than stalling the producer.
func publish(subs []chan Event, ev Event) {
for _, ch := range subs {
select {
case ch <- ev:
default:
}
}
}