package core import ( "encoding/json" "log/slog" "os" "strings" "sync" "time" ) const sharedWorkspaceBindingsKey = "shared" // FlexTime wraps time.Time with lenient JSON unmarshaling. type FlexTime struct{ time.Time } func (ft *FlexTime) UnmarshalJSON(b []byte) error { var s string if err := json.Unmarshal(b, &s); err != nil { ft.Time = time.Time{} return nil } if s == "" { ft.Time = time.Time{} return nil } for _, layout := range []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999999", "2006-01-02T15:04:05", "2006-01-02 15:04:05", } { if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { ft.Time = t return nil } } slog.Warn("workspace bindings: unparseable bound_at, treating as zero", "value", s) ft.Time = time.Time{} return nil } // WorkspaceBinding maps a channel to a workspace directory. type WorkspaceBinding struct { ChannelName string `json:"channel_name"` Workspace string `json:"workspace"` BoundAt FlexTime `json:"bound_at"` } // WorkspaceBindingManager persists channel->workspace mappings. // Top-level key is "project:", second-level key is a workspace channel key. type WorkspaceBindingManager struct { mu sync.RWMutex bindings map[string]map[string]*WorkspaceBinding storePath string lastLoadedModTime time.Time lastLoadedSize int64 } func NewWorkspaceBindingManager(storePath string) *WorkspaceBindingManager { m := &WorkspaceBindingManager{ bindings: make(map[string]map[string]*WorkspaceBinding), storePath: storePath, } if storePath != "" { m.load() } return m } func legacyWorkspaceChannelKey(channelKey string) string { if i := strings.IndexByte(channelKey, ':'); i >= 0 { return channelKey[i+1:] } return channelKey } func workspaceChannelKeyCandidates(channelKey string) []string { if channelKey == "" { return nil } legacyKey := legacyWorkspaceChannelKey(channelKey) if legacyKey == channelKey { return []string{channelKey} } return []string{channelKey, legacyKey} } func (m *WorkspaceBindingManager) lookupLocked(projectKey, channelKey string) *WorkspaceBinding { proj := m.bindings[projectKey] if proj == nil { return nil } for _, candidate := range workspaceChannelKeyCandidates(channelKey) { if b := proj[candidate]; b != nil { return b } } return nil } func (m *WorkspaceBindingManager) Bind(projectKey, channelKey, channelName, workspace string) { m.mu.Lock() defer m.mu.Unlock() m.refreshLocked() if m.bindings[projectKey] == nil { m.bindings[projectKey] = make(map[string]*WorkspaceBinding) } m.bindings[projectKey][channelKey] = &WorkspaceBinding{ ChannelName: channelName, Workspace: workspace, BoundAt: FlexTime{time.Now()}, } m.saveLocked() } func (m *WorkspaceBindingManager) Unbind(projectKey, channelKey string) { m.mu.Lock() defer m.mu.Unlock() m.refreshLocked() if proj := m.bindings[projectKey]; proj != nil { for _, candidate := range workspaceChannelKeyCandidates(channelKey) { delete(proj, candidate) } if len(proj) == 0 { delete(m.bindings, projectKey) } } m.saveLocked() } func (m *WorkspaceBindingManager) Lookup(projectKey, channelKey string) *WorkspaceBinding { m.mu.Lock() defer m.mu.Unlock() m.refreshLocked() return m.lookupLocked(projectKey, channelKey) } // LookupEffective returns the effective binding for a channel, checking the // current project first and then the shared routing layer. func (m *WorkspaceBindingManager) LookupEffective(projectKey, channelKey string) (*WorkspaceBinding, string) { m.mu.Lock() defer m.mu.Unlock() m.refreshLocked() if b := m.lookupLocked(projectKey, channelKey); b != nil { return b, projectKey } if b := m.lookupLocked(sharedWorkspaceBindingsKey, channelKey); b != nil { return b, sharedWorkspaceBindingsKey } return nil, "" } func (m *WorkspaceBindingManager) ListByProject(projectKey string) map[string]*WorkspaceBinding { m.mu.Lock() defer m.mu.Unlock() m.refreshLocked() result := make(map[string]*WorkspaceBinding) if proj := m.bindings[projectKey]; proj != nil { for k, v := range proj { result[k] = v } } return result } func (m *WorkspaceBindingManager) saveLocked() { if m.storePath == "" { return } data, err := json.MarshalIndent(m.bindings, "", " ") if err != nil { slog.Error("workspace bindings: marshal error", "err", err) return } if err := AtomicWriteFile(m.storePath, data, 0o644); err != nil { slog.Error("workspace bindings: save error", "err", err) return } if info, err := os.Stat(m.storePath); err == nil { m.lastLoadedModTime = info.ModTime() m.lastLoadedSize = info.Size() } } func (m *WorkspaceBindingManager) load() { m.mu.Lock() defer m.mu.Unlock() m.refreshLocked() } func (m *WorkspaceBindingManager) refreshLocked() { if m.storePath == "" { return } info, err := os.Stat(m.storePath) if err != nil { if os.IsNotExist(err) { m.bindings = make(map[string]map[string]*WorkspaceBinding) m.lastLoadedModTime = time.Time{} m.lastLoadedSize = 0 return } slog.Error("workspace bindings: stat error", "err", err) return } if !m.lastLoadedModTime.IsZero() && info.ModTime().Equal(m.lastLoadedModTime) && info.Size() == m.lastLoadedSize { return } data, err := os.ReadFile(m.storePath) if err != nil { if !os.IsNotExist(err) { slog.Error("workspace bindings: load error", "err", err) } return } loaded := make(map[string]map[string]*WorkspaceBinding) if len(data) > 0 { if err := json.Unmarshal(data, &loaded); err != nil { slog.Error("workspace bindings: unmarshal error", "err", err) return } } m.bindings = loaded m.lastLoadedModTime = info.ModTime() m.lastLoadedSize = info.Size() }