Files
cc-connect/core/workspace_state.go
T
2026-06-02 23:14:41 +08:00

169 lines
3.6 KiB
Go

package core
import (
"log/slog"
"path/filepath"
"sync"
"time"
)
// normalizeWorkspacePath cleans and resolves a workspace path to prevent
// mismatches caused by trailing slashes, symlinks, or relative segments.
// If the path cannot be resolved (e.g. doesn't exist yet), falls back to
// filepath.Clean only.
func normalizeWorkspacePath(path string) string {
cleaned := filepath.Clean(path)
resolved, err := filepath.EvalSymlinks(cleaned)
if err != nil {
return cleaned
}
if resolved != path {
slog.Debug("workspace path normalized", "original", path, "normalized", resolved)
}
return resolved
}
// workspaceState holds the runtime state for a single workspace.
type workspaceState struct {
mu sync.Mutex
workspace string
sessions *SessionManager
agent Agent
lastActivity time.Time
activeTurns int
}
func newWorkspaceState(workspace string) *workspaceState {
return &workspaceState{
workspace: workspace,
lastActivity: time.Now(),
}
}
func (ws *workspaceState) Touch() {
ws.mu.Lock()
ws.lastActivity = time.Now()
ws.mu.Unlock()
}
func (ws *workspaceState) BeginTurn() {
ws.mu.Lock()
ws.activeTurns++
ws.lastActivity = time.Now()
ws.mu.Unlock()
}
func (ws *workspaceState) EndTurn() {
ws.mu.Lock()
if ws.activeTurns > 0 {
ws.activeTurns--
}
ws.lastActivity = time.Now()
ws.mu.Unlock()
}
func (ws *workspaceState) HasActiveTurn() bool {
ws.mu.Lock()
defer ws.mu.Unlock()
return ws.activeTurns > 0
}
func (ws *workspaceState) LastActivity() time.Time {
ws.mu.Lock()
defer ws.mu.Unlock()
return ws.lastActivity
}
// workspacePool manages a set of workspace states with idle reaping.
type workspacePool struct {
mu sync.RWMutex
states map[string]*workspaceState // workspace path -> state
idleTimeout time.Duration
}
func newWorkspacePool(idleTimeout time.Duration) *workspacePool {
return &workspacePool{
states: make(map[string]*workspaceState),
idleTimeout: idleTimeout,
}
}
// Get returns the state for a workspace.
func (p *workspacePool) Get(workspace string) *workspaceState {
p.mu.RLock()
state := p.states[workspace]
p.mu.RUnlock()
if state != nil {
return state
}
normalized := normalizeWorkspacePath(workspace)
if normalized == workspace {
return nil
}
p.mu.RLock()
defer p.mu.RUnlock()
return p.states[normalized]
}
// GetOrCreate returns or creates state for a workspace.
func (p *workspacePool) GetOrCreate(workspace string) *workspaceState {
p.mu.Lock()
if s, ok := p.states[workspace]; ok {
p.mu.Unlock()
return s
}
p.mu.Unlock()
normalized := normalizeWorkspacePath(workspace)
p.mu.Lock()
defer p.mu.Unlock()
if s, ok := p.states[workspace]; ok {
return s
}
if normalized != workspace {
if s, ok := p.states[normalized]; ok {
return s
}
workspace = normalized
}
s := newWorkspaceState(workspace)
p.states[workspace] = s
return s
}
// ReapIdle removes and returns workspace paths that have been idle longer than idleTimeout.
// A zero idleTimeout disables reaping entirely.
func (p *workspacePool) ReapIdle() []string {
p.mu.Lock()
defer p.mu.Unlock()
if p.idleTimeout <= 0 {
return nil
}
cutoff := time.Now().Add(-p.idleTimeout)
var reaped []string
for path, state := range p.states {
if state.HasActiveTurn() {
continue
}
if state.LastActivity().Before(cutoff) {
reaped = append(reaped, path)
delete(p.states, path)
}
}
return reaped
}
func (p *workspacePool) All() map[string]*workspaceState {
p.mu.RLock()
defer p.mu.RUnlock()
result := make(map[string]*workspaceState, len(p.states))
for k, v := range p.states {
result[k] = v
}
return result
}