2b77e15b36
Add pending-switch coordination to internal/activity (RequestSwitch/PendingSwitch/AckSwitch/CancelSwitch); AckSwitch atomically sets the active project and records switch-completed with Claude's summary. New MCP tools get_pending_switch and ack_switch (request is user-only via HTTP). HTTP GET/POST/DELETE /api/switch. New <handoff-bar> component (ask/waiting/cancel/completed) over SSE; activity-feed reflects switch-completed. Test covers request->ack->clear. Verified live: request/waiting/cancel over SSE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
225 lines
6.4 KiB
Go
225 lines
6.4 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
|
|
}
|
|
|
|
// PendingSwitch is a user's request for Claude to switch to another project.
|
|
// It is the request half of the graceful handoff (§8.3) — Claude fulfils it at a
|
|
// safe stopping point via AckSwitch.
|
|
type PendingSwitch struct {
|
|
Target string `json:"target"` // requested repo path
|
|
Note string `json:"note,omitempty"`
|
|
RequestedBy Actor `json:"requestedBy"` // normally the user
|
|
RequestedAt time.Time `json:"requestedAt"`
|
|
}
|
|
|
|
// Feed is the concurrency-safe active-project + activity store with fan-out.
|
|
type Feed struct {
|
|
mu sync.RWMutex
|
|
active string // active project path ("" = none)
|
|
pending *PendingSwitch // an outstanding switch request, if any
|
|
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
|
|
}
|
|
|
|
// RequestSwitch records a user's request for Claude to switch to target. The
|
|
// latest request wins (it overwrites any outstanding one).
|
|
func (f *Feed) RequestSwitch(actor Actor, target, note string) PendingSwitch {
|
|
f.mu.Lock()
|
|
p := PendingSwitch{Target: target, Note: note, RequestedBy: actor, RequestedAt: time.Now()}
|
|
f.pending = &p
|
|
ev, subs := f.appendLocked(actor, "switch-requested", target, note)
|
|
f.mu.Unlock()
|
|
|
|
publish(subs, ev)
|
|
return p
|
|
}
|
|
|
|
// PendingSwitch returns the outstanding switch request, if any.
|
|
func (f *Feed) PendingSwitch() (PendingSwitch, bool) {
|
|
f.mu.RLock()
|
|
defer f.mu.RUnlock()
|
|
if f.pending == nil {
|
|
return PendingSwitch{}, false
|
|
}
|
|
return *f.pending, true
|
|
}
|
|
|
|
// AckSwitch completes a pending switch: it makes the requested target the active
|
|
// project, clears the request, and records a "switch-completed" event carrying
|
|
// Claude's summary of where it left the previous project. Returns false if there
|
|
// was nothing pending.
|
|
func (f *Feed) AckSwitch(actor Actor, summary string) (PendingSwitch, bool) {
|
|
f.mu.Lock()
|
|
if f.pending == nil {
|
|
f.mu.Unlock()
|
|
return PendingSwitch{}, false
|
|
}
|
|
p := *f.pending
|
|
f.pending = nil
|
|
f.active = p.Target
|
|
ev, subs := f.appendLocked(actor, "switch-completed", p.Target, summary)
|
|
f.mu.Unlock()
|
|
|
|
publish(subs, ev)
|
|
return p, true
|
|
}
|
|
|
|
// CancelSwitch clears a pending switch (e.g. the user changed their mind).
|
|
func (f *Feed) CancelSwitch(actor Actor) (PendingSwitch, bool) {
|
|
f.mu.Lock()
|
|
if f.pending == nil {
|
|
f.mu.Unlock()
|
|
return PendingSwitch{}, false
|
|
}
|
|
p := *f.pending
|
|
f.pending = nil
|
|
ev, subs := f.appendLocked(actor, "switch-cancelled", p.Target, "")
|
|
f.mu.Unlock()
|
|
|
|
publish(subs, ev)
|
|
return p, 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:
|
|
}
|
|
}
|
|
}
|