Files
chenhg5-cc-connect/core/workspace_binding.go
Cigarrr 51e13e65e9 fix(core): lenient bound_at parsing in workspace bindings
The BoundAt field in WorkspaceBinding uses time.Time which requires
strict RFC3339 format. When workspace_bindings.json is hand-edited
(e.g. by an AI agent), a missing timezone suffix causes json.Unmarshal
to fail for the entire file, breaking ALL workspace bindings.

Introduce FlexTime, a time.Time wrapper with lenient UnmarshalJSON
that tries multiple time formats and falls back to zero value on
failure instead of returning an error. This ensures a non-critical
metadata field cannot take down the workspace routing.
2026-04-15 17:01:43 +08:00

230 lines
5.6 KiB
Go

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:<name>", 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()
}