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>
118 lines
4.2 KiB
Go
118 lines
4.2 KiB
Go
// Package service is the ONE capability layer behind both the HTTP API and the
|
|
// MCP server (AGENT.md §1.7). HTTP handlers and MCP tool handlers are thin
|
|
// adapters that call these methods; Git/forge logic never lives in a handler.
|
|
// Everything here goes through the internal/git (and later internal/forge)
|
|
// boundaries and obeys the safety rules (§1.4).
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
|
|
"gitmanager/internal/activity"
|
|
"gitmanager/internal/git"
|
|
"gitmanager/internal/repos"
|
|
)
|
|
|
|
// Service holds the shared dependencies the capabilities need.
|
|
type Service struct {
|
|
git *git.CLI
|
|
index *repos.Index
|
|
feed *activity.Feed
|
|
}
|
|
|
|
// New builds a Service over the git boundary, the scanner's repo index, and the
|
|
// activity feed.
|
|
func New(g *git.CLI, index *repos.Index, feed *activity.Feed) *Service {
|
|
return &Service{git: g, index: index, feed: feed}
|
|
}
|
|
|
|
// ListRepos returns a snapshot of every discovered repository.
|
|
func (s *Service) ListRepos() []repos.State {
|
|
return s.index.List()
|
|
}
|
|
|
|
// GetRepo returns the cached state for one repo, or false if it is not indexed.
|
|
// The path is cleaned so separator style does not defeat the exact-match lookup.
|
|
func (s *Service) GetRepo(path string) (repos.State, bool) {
|
|
return s.index.Get(filepath.Clean(path))
|
|
}
|
|
|
|
// RepoDetail returns the enriched detail (branches, commits, remotes) for one
|
|
// repo. The second return is false when the path is not an indexed repository —
|
|
// we never run git against an arbitrary caller-supplied path (§1.3).
|
|
func (s *Service) RepoDetail(ctx context.Context, path string) (repos.Detail, bool) {
|
|
base, ok := s.index.Get(filepath.Clean(path))
|
|
if !ok {
|
|
return repos.Detail{}, false
|
|
}
|
|
return repos.BuildDetail(ctx, s.git, base), true
|
|
}
|
|
|
|
// --- Activity & active project (§8.2) --------------------------------------
|
|
|
|
// ActiveProject returns the current active project path ("" if none).
|
|
func (s *Service) ActiveProject() string {
|
|
return s.feed.ActiveProject()
|
|
}
|
|
|
|
// SetActiveProject makes path the active project (or clears it when empty). It
|
|
// rejects a path that is not an indexed repository — the active project must be
|
|
// a real repo (§1.3). Returns the recorded event and whether it changed.
|
|
func (s *Service) SetActiveProject(actor activity.Actor, path string) (activity.Event, bool, error) {
|
|
if path != "" {
|
|
path = filepath.Clean(path)
|
|
if _, ok := s.index.Get(path); !ok {
|
|
return activity.Event{}, false, fmt.Errorf("unknown repository %q", path)
|
|
}
|
|
}
|
|
ev, changed := s.feed.SetActiveProject(actor, path)
|
|
return ev, changed, nil
|
|
}
|
|
|
|
// RecordActivity appends an arbitrary event to the feed.
|
|
func (s *Service) RecordActivity(actor activity.Actor, kind, repo, detail string) activity.Event {
|
|
return s.feed.Record(actor, kind, repo, detail)
|
|
}
|
|
|
|
// Activity returns up to limit recent events, oldest first.
|
|
func (s *Service) Activity(limit int) []activity.Event {
|
|
return s.feed.Events(limit)
|
|
}
|
|
|
|
// SubscribeActivity returns a channel of future events plus an unsubscribe func
|
|
// the caller must invoke when done.
|
|
func (s *Service) SubscribeActivity() (<-chan activity.Event, func()) {
|
|
return s.feed.Subscribe()
|
|
}
|
|
|
|
// --- Graceful project handoff (§8.3) ---------------------------------------
|
|
|
|
// RequestSwitch records a user's request for Claude to switch to target. The
|
|
// target must be an indexed repository.
|
|
func (s *Service) RequestSwitch(actor activity.Actor, target, note string) (activity.PendingSwitch, error) {
|
|
target = filepath.Clean(target)
|
|
if _, ok := s.index.Get(target); !ok {
|
|
return activity.PendingSwitch{}, fmt.Errorf("unknown repository %q", target)
|
|
}
|
|
return s.feed.RequestSwitch(actor, target, note), nil
|
|
}
|
|
|
|
// PendingSwitch returns the outstanding switch request, if any.
|
|
func (s *Service) PendingSwitch() (activity.PendingSwitch, bool) {
|
|
return s.feed.PendingSwitch()
|
|
}
|
|
|
|
// AckSwitch completes the pending handoff on Claude's behalf: sets the active
|
|
// project to the requested target and records Claude's summary. Returns false if
|
|
// nothing was pending.
|
|
func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
|
|
return s.feed.AckSwitch(activity.ActorClaude, summary)
|
|
}
|
|
|
|
// CancelSwitch clears a pending switch request.
|
|
func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bool) {
|
|
return s.feed.CancelSwitch(actor)
|
|
}
|