531 lines
15 KiB
Go
531 lines
15 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/hjbdev/patterm/internal/mcp"
|
|
"github.com/hjbdev/patterm/internal/persist"
|
|
"github.com/hjbdev/patterm/internal/preset"
|
|
"github.com/hjbdev/patterm/internal/projectkey"
|
|
"github.com/hjbdev/patterm/internal/protocol"
|
|
"github.com/hjbdev/patterm/internal/scratchpad"
|
|
"github.com/hjbdev/patterm/internal/trust"
|
|
)
|
|
|
|
type Project struct {
|
|
Key string
|
|
Dir string
|
|
Name string
|
|
|
|
Session *Session
|
|
Pads *scratchpad.Store
|
|
Trust *trust.Store
|
|
Persist *persist.Store
|
|
Launcher *Launcher
|
|
Host *toolHost
|
|
savedProcess []persist.Entry
|
|
|
|
displayMu sync.Mutex
|
|
displayOwners map[string]paneDisplayOwner
|
|
|
|
lastActive time.Time
|
|
}
|
|
|
|
type paneDisplayOwner struct {
|
|
ClientID string
|
|
Size protocol.Size
|
|
}
|
|
|
|
type projectSummary struct {
|
|
Key string
|
|
Dir string
|
|
Name string
|
|
TabCount int
|
|
IsCurrent bool
|
|
}
|
|
|
|
// ProjectRegistry is the daemon-owned project map. Phase 1 still runs in one
|
|
// local process, but every project already has isolated stores, session,
|
|
// launcher, and tool host so future clients can attach to different projects.
|
|
type ProjectRegistry struct {
|
|
mu sync.Mutex
|
|
projects map[string]*Project
|
|
|
|
defaultProjectKey string
|
|
presets preset.Set
|
|
settings settings
|
|
mcpSrv *mcp.Server
|
|
cols, rows uint16
|
|
}
|
|
|
|
func newProjectRegistry(presets preset.Set, settings settings, mcpSrv *mcp.Server, cols, rows uint16) *ProjectRegistry {
|
|
return &ProjectRegistry{
|
|
projects: make(map[string]*Project),
|
|
presets: presets,
|
|
settings: settings,
|
|
mcpSrv: mcpSrv,
|
|
cols: cols,
|
|
rows: rows,
|
|
}
|
|
}
|
|
|
|
func (r *ProjectRegistry) Open(ctx context.Context, dir string) (*Project, error) {
|
|
key, err := projectkey.Key(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
abs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r.mu.Lock()
|
|
if p := r.projects[key]; p != nil {
|
|
p.lastActive = time.Now()
|
|
r.mu.Unlock()
|
|
return p, nil
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
pads, err := scratchpad.Open(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("app: scratchpad init: %w", err)
|
|
}
|
|
trustStore, err := trust.Open(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("app: trust init: %w", err)
|
|
}
|
|
persistStore, err := persist.Open(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("app: persist init: %w", err)
|
|
}
|
|
sess := NewSession(abs, key)
|
|
savedProcesses := persistStore.List()
|
|
for _, e := range savedProcesses {
|
|
_ = persistStore.Remove(e.ID)
|
|
}
|
|
sess.SetPersistStore(persistStore)
|
|
socket := ""
|
|
if r.mcpSrv != nil {
|
|
socket = r.mcpSrv.Socket()
|
|
}
|
|
launcher := NewLauncher(sess, socket, r.cols, r.rows)
|
|
host := newToolHost(sess, pads, launcher, r.presets, trustStore, r.cols, r.rows)
|
|
go sess.runClassifier(ctx)
|
|
|
|
p := &Project{
|
|
Key: key,
|
|
Dir: abs,
|
|
Name: filepath.Base(abs),
|
|
Session: sess,
|
|
Pads: pads,
|
|
Trust: trustStore,
|
|
Persist: persistStore,
|
|
Launcher: launcher,
|
|
Host: host,
|
|
savedProcess: savedProcesses,
|
|
displayOwners: make(map[string]paneDisplayOwner),
|
|
lastActive: time.Now(),
|
|
}
|
|
|
|
r.mu.Lock()
|
|
if existing := r.projects[key]; existing != nil {
|
|
r.mu.Unlock()
|
|
sess.Shutdown()
|
|
return existing, nil
|
|
}
|
|
r.projects[key] = p
|
|
if r.defaultProjectKey == "" {
|
|
r.defaultProjectKey = key
|
|
}
|
|
r.mu.Unlock()
|
|
return p, nil
|
|
}
|
|
|
|
func (r *ProjectRegistry) Project(key string) *Project {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.projects[key]
|
|
}
|
|
|
|
func (r *ProjectRegistry) Count() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.projects)
|
|
}
|
|
|
|
func (r *ProjectRegistry) DefaultProject() *Project {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.projects[r.defaultProjectKey]
|
|
}
|
|
|
|
func (p *Project) ClaimPaneDisplay(clientID, paneID string, size protocol.Size) (protocol.Size, bool) {
|
|
if p == nil || paneID == "" {
|
|
return size, true
|
|
}
|
|
if size.Cols == 0 || size.Rows == 0 {
|
|
size = protocol.Size{Cols: 80, Rows: 24}
|
|
}
|
|
p.displayMu.Lock()
|
|
if p.displayOwners == nil {
|
|
p.displayOwners = make(map[string]paneDisplayOwner)
|
|
}
|
|
owner, ok := p.displayOwners[paneID]
|
|
if !ok || owner.ClientID == "" || owner.ClientID == clientID {
|
|
p.displayOwners[paneID] = paneDisplayOwner{ClientID: clientID, Size: size}
|
|
p.displayMu.Unlock()
|
|
p.Session.ResizeChild(paneID, size.Cols, size.Rows)
|
|
return size, true
|
|
}
|
|
p.displayMu.Unlock()
|
|
return owner.Size, false
|
|
}
|
|
|
|
func (p *Project) ResizeClientDisplays(clientID string, size protocol.Size) {
|
|
if p == nil || size.Cols == 0 || size.Rows == 0 {
|
|
return
|
|
}
|
|
p.displayMu.Lock()
|
|
var panes []string
|
|
for paneID, owner := range p.displayOwners {
|
|
if owner.ClientID != clientID {
|
|
continue
|
|
}
|
|
owner.Size = size
|
|
p.displayOwners[paneID] = owner
|
|
panes = append(panes, paneID)
|
|
}
|
|
p.displayMu.Unlock()
|
|
for _, paneID := range panes {
|
|
p.Session.ResizeChild(paneID, size.Cols, size.Rows)
|
|
}
|
|
p.Launcher.SetSize(size.Cols, size.Rows)
|
|
p.Host.SetSize(size.Cols, size.Rows)
|
|
}
|
|
|
|
func (p *Project) ReleaseClientDisplays(clientID string) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
p.displayMu.Lock()
|
|
for paneID, owner := range p.displayOwners {
|
|
if owner.ClientID == clientID {
|
|
delete(p.displayOwners, paneID)
|
|
}
|
|
}
|
|
p.displayMu.Unlock()
|
|
}
|
|
|
|
func (p *Project) PaneDisplay(paneID string) (protocol.Size, string, bool) {
|
|
if p == nil || paneID == "" {
|
|
return protocol.Size{}, "", false
|
|
}
|
|
p.displayMu.Lock()
|
|
defer p.displayMu.Unlock()
|
|
owner, ok := p.displayOwners[paneID]
|
|
return owner.Size, owner.ClientID, ok
|
|
}
|
|
|
|
func (r *ProjectRegistry) Shutdown() {
|
|
r.mu.Lock()
|
|
projects := make([]*Project, 0, len(r.projects))
|
|
for _, p := range r.projects {
|
|
projects = append(projects, p)
|
|
}
|
|
r.mu.Unlock()
|
|
for _, p := range projects {
|
|
p.Session.Shutdown()
|
|
}
|
|
}
|
|
|
|
func (r *ProjectRegistry) ResizeAll(cols, rows uint16) {
|
|
r.mu.Lock()
|
|
r.cols, r.rows = cols, rows
|
|
projects := make([]*Project, 0, len(r.projects))
|
|
for _, p := range r.projects {
|
|
projects = append(projects, p)
|
|
}
|
|
r.mu.Unlock()
|
|
for _, p := range projects {
|
|
p.Session.ResizeAll(cols, rows)
|
|
p.Launcher.SetSize(cols, rows)
|
|
p.Host.SetSize(cols, rows)
|
|
}
|
|
}
|
|
|
|
func (r *ProjectRegistry) Summaries(currentKey string) []projectSummary {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]projectSummary, 0, len(r.projects))
|
|
for _, p := range r.projects {
|
|
out = append(out, projectSummary{
|
|
Key: p.Key,
|
|
Dir: p.Dir,
|
|
Name: p.Name,
|
|
TabCount: len(runningTopLevels(p.Session.Children())),
|
|
IsCurrent: p.Key == currentKey,
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].IsCurrent != out[j].IsCurrent {
|
|
return out[i].IsCurrent
|
|
}
|
|
return out[i].Name < out[j].Name
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (r *ProjectRegistry) findProjectByChild(id string) (*Project, *Child) {
|
|
if id == "" {
|
|
return nil, nil
|
|
}
|
|
r.mu.Lock()
|
|
projects := make([]*Project, 0, len(r.projects))
|
|
for _, p := range r.projects {
|
|
projects = append(projects, p)
|
|
}
|
|
r.mu.Unlock()
|
|
for _, p := range projects {
|
|
if c := p.Session.FindChild(id); c != nil {
|
|
return p, c
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *ProjectRegistry) projectForCaller(callerID string) *Project {
|
|
if p, _ := r.findProjectByChild(callerID); p != nil {
|
|
return p
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.projects[r.defaultProjectKey]
|
|
}
|
|
|
|
func (r *ProjectRegistry) hostForCaller(callerID string) *toolHost {
|
|
if p := r.projectForCaller(callerID); p != nil {
|
|
return p.Host
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *ProjectRegistry) hostForProcess(processID string) *toolHost {
|
|
if p, _ := r.findProjectByChild(processID); p != nil {
|
|
return p.Host
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *ProjectRegistry) ResolveCallerIdentity(identity string) string {
|
|
r.mu.Lock()
|
|
projects := make([]*Project, 0, len(r.projects))
|
|
for _, p := range r.projects {
|
|
projects = append(projects, p)
|
|
}
|
|
r.mu.Unlock()
|
|
for _, p := range projects {
|
|
if c := p.Session.FindChildByIdentity(identity); c != nil {
|
|
return c.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (r *ProjectRegistry) CallerRole(processID string) mcp.CallerRole {
|
|
if h := r.hostForCaller(processID); h != nil {
|
|
return h.CallerRole(processID)
|
|
}
|
|
return mcp.RoleOrchestrator
|
|
}
|
|
|
|
func (r *ProjectRegistry) SpawnAgent(callerID string, args mcp.SpawnAgentArgs) (mcp.ProcessInfo, error) {
|
|
return r.hostForCaller(callerID).SpawnAgent(callerID, args)
|
|
}
|
|
|
|
func (r *ProjectRegistry) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp.ProcessInfo, error) {
|
|
return r.hostForCaller(callerID).SpawnProcess(callerID, args)
|
|
}
|
|
|
|
func (r *ProjectRegistry) StartProcess(callerID, processID string) (mcp.ProcessInfo, error) {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.StartProcess(callerID, processID)
|
|
}
|
|
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) RestartProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.RestartProcess(callerID, processID, sig)
|
|
}
|
|
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) StopProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.StopProcess(callerID, processID, sig)
|
|
}
|
|
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) CloseProcess(callerID, processID string) error {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.CloseProcess(callerID, processID)
|
|
}
|
|
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) RenameProcess(callerID, processID, name string) error {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.RenameProcess(callerID, processID, name)
|
|
}
|
|
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) SelectProcess(callerID, processID string) error {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.SelectProcess(callerID, processID)
|
|
}
|
|
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) ListProcesses(callerID, kindFilter string) []mcp.ProcessInfo {
|
|
if h := r.hostForCaller(callerID); h != nil {
|
|
return h.ListProcesses(callerID, kindFilter)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *ProjectRegistry) GetProcessStatus(callerID, processID string) (mcp.ProcessStatus, error) {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.GetProcessStatus(callerID, processID)
|
|
}
|
|
return mcp.ProcessStatus{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) {
|
|
return r.hostForCaller(callerID).GetProjectStatus(callerID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.GetProcessOutput(callerID, processID, mode, sinceOffset)
|
|
}
|
|
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.GetProcessRawOutput(callerID, processID, sinceOffset)
|
|
}
|
|
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.SearchOutput(callerID, processID, pattern, kind, limit)
|
|
}
|
|
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (bool, string, error) {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.WaitForPattern(callerID, processID, pattern, timeoutSeconds, scope)
|
|
}
|
|
return false, "", mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.GetProcessPorts(callerID, processID)
|
|
}
|
|
return nil, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendInputResult, error) {
|
|
if h := r.hostForProcess(args.ProcessID); h != nil {
|
|
return h.SendInput(callerID, args)
|
|
}
|
|
return mcp.SendInputResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) SendMessage(callerID, targetID, message string) error {
|
|
if h := r.hostForProcess(targetID); h != nil {
|
|
return h.SendMessage(callerID, targetID, message)
|
|
}
|
|
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", targetID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) RequestHumanAttention(callerID, processID, reason string) error {
|
|
if h := r.hostForProcess(processID); h != nil {
|
|
return h.RequestHumanAttention(callerID, processID, reason)
|
|
}
|
|
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) TimerWait(callerID string, seconds float64, label string) (string, error) {
|
|
return r.hostForCaller(callerID).TimerWait(callerID, seconds, label)
|
|
}
|
|
|
|
func (r *ProjectRegistry) TimerSet(callerID string, args mcp.TimerSetArgs) (mcp.TimerHandle, error) {
|
|
return r.hostForCaller(callerID).TimerSet(callerID, args)
|
|
}
|
|
|
|
func (r *ProjectRegistry) TimerFireWhenIdleAny(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
|
|
return r.hostForCaller(callerID).TimerFireWhenIdleAny(callerID, args)
|
|
}
|
|
|
|
func (r *ProjectRegistry) TimerFireWhenIdleAll(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
|
|
return r.hostForCaller(callerID).TimerFireWhenIdleAll(callerID, args)
|
|
}
|
|
|
|
func (r *ProjectRegistry) TimerCancel(callerID, id string) error {
|
|
return r.hostForCaller(callerID).TimerCancel(callerID, id)
|
|
}
|
|
|
|
func (r *ProjectRegistry) TimerPause(callerID, id string) error {
|
|
return r.hostForCaller(callerID).TimerPause(callerID, id)
|
|
}
|
|
|
|
func (r *ProjectRegistry) TimerResume(callerID, id string) error {
|
|
return r.hostForCaller(callerID).TimerResume(callerID, id)
|
|
}
|
|
|
|
func (r *ProjectRegistry) TimerList(callerID string) ([]mcp.TimerInfo, error) {
|
|
return r.hostForCaller(callerID).TimerList(callerID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) ScratchpadList(callerID string) ([]scratchpad.Entry, error) {
|
|
return r.hostForCaller(callerID).ScratchpadList(callerID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) ScratchpadRead(callerID, name string) (string, string, error) {
|
|
return r.hostForCaller(callerID).ScratchpadRead(callerID, name)
|
|
}
|
|
|
|
func (r *ProjectRegistry) ScratchpadWrite(callerID, name, content, expectedRevision string) (string, error) {
|
|
return r.hostForCaller(callerID).ScratchpadWrite(callerID, name, content, expectedRevision)
|
|
}
|
|
|
|
func (r *ProjectRegistry) ScratchpadAppend(callerID, name, content string) error {
|
|
return r.hostForCaller(callerID).ScratchpadAppend(callerID, name, content)
|
|
}
|
|
|
|
func (r *ProjectRegistry) ScratchpadDelete(callerID, name string) error {
|
|
return r.hostForCaller(callerID).ScratchpadDelete(callerID, name)
|
|
}
|
|
|
|
func (r *ProjectRegistry) WhoAmI(callerID string) mcp.WhoAmI {
|
|
return r.hostForCaller(callerID).WhoAmI(callerID)
|
|
}
|
|
|
|
func (r *ProjectRegistry) Help(callerID, topic string) mcp.HelpResponse {
|
|
return r.hostForCaller(callerID).Help(callerID, topic)
|
|
}
|