Files
cc-connect/core/workspace_binding.go
T
Leigh StillardandClaude Opus 4.6 647398f765 feat: multi-workspace mode — single bot, multiple workspaces per channel (#79)
* feat: add multi-workspace mode and base_dir config fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add WorkspaceBindingManager for channel-to-workspace persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add workspacePool for managing per-workspace agent state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add ChannelNameResolver interface and Slack implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add multi-workspace fields, SetMultiWorkspace, and idle reaper to Engine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add resolveWorkspace method for channel-to-directory mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire multi-workspace mode setup in main.go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add workspace init flow for cloning repos and binding channels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add /workspace commands (init, unbind, list, status)

Add handleWorkspaceCommand with subcommands:
- (no args): show current workspace binding for the channel
- init <git-url>: clone repo and bind workspace to channel
- unbind: remove workspace binding from channel
- list: show all workspace bindings for the project

Only available when multi-workspace mode is enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire multi-workspace routing into handleMessage with per-workspace agents

Add workspace resolution to handleMessage that resolves channels to workspace
directories, creates per-workspace agents with correct work_dir, and routes
messages through workspace-specific session managers. The normal (single-workspace)
code path is unchanged — all new logic is gated behind e.multiWorkspace.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add integration tests for multi-workspace resolution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve multi-workspace bugs found during live testing

Three bugs fixed:

1. App mention events not handled - Slack sends `app_mention` events
   (not `message.channels`) when the bot is @mentioned. Added
   AppMentionEvent handler in the Slack platform.

2. Bot mention prefix not stripped - app_mention text includes
   `<@UBOTID> ` prefix, breaking command parsing and workspace
   resolution. Now stripped before passing to engine.

3. Slash command syntax incompatible with Slack - `/workspace` commands
   were being intercepted by Slack as slash commands. Changed all
   user-facing references and prefix detection to use `workspace`
   without the leading slash.

Also added debug logging to handleEvent for diagnosing event delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: instruct agent to use Slack mrkdwn formatting

Add Slack formatting guidelines to the agent system prompt so responses
use Slack's mrkdwn syntax instead of standard Markdown (e.g. *bold*
instead of **bold**, no ## headings).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:38:58 +08:00

110 lines
2.6 KiB
Go

package core
import (
"encoding/json"
"log/slog"
"os"
"sync"
"time"
)
// WorkspaceBinding maps a channel to a workspace directory.
type WorkspaceBinding struct {
ChannelName string `json:"channel_name"`
Workspace string `json:"workspace"`
BoundAt time.Time `json:"bound_at"`
}
// WorkspaceBindingManager persists channel->workspace mappings.
// Top-level key is "project:<name>", second-level key is channel ID.
type WorkspaceBindingManager struct {
mu sync.RWMutex
bindings map[string]map[string]*WorkspaceBinding
storePath string
}
func NewWorkspaceBindingManager(storePath string) *WorkspaceBindingManager {
m := &WorkspaceBindingManager{
bindings: make(map[string]map[string]*WorkspaceBinding),
storePath: storePath,
}
if storePath != "" {
m.load()
}
return m
}
func (m *WorkspaceBindingManager) Bind(projectKey, channelID, channelName, workspace string) {
m.mu.Lock()
defer m.mu.Unlock()
if m.bindings[projectKey] == nil {
m.bindings[projectKey] = make(map[string]*WorkspaceBinding)
}
m.bindings[projectKey][channelID] = &WorkspaceBinding{
ChannelName: channelName,
Workspace: workspace,
BoundAt: time.Now(),
}
m.saveLocked()
}
func (m *WorkspaceBindingManager) Unbind(projectKey, channelID string) {
m.mu.Lock()
defer m.mu.Unlock()
if proj := m.bindings[projectKey]; proj != nil {
delete(proj, channelID)
if len(proj) == 0 {
delete(m.bindings, projectKey)
}
}
m.saveLocked()
}
func (m *WorkspaceBindingManager) Lookup(projectKey, channelID string) *WorkspaceBinding {
m.mu.RLock()
defer m.mu.RUnlock()
if proj := m.bindings[projectKey]; proj != nil {
return proj[channelID]
}
return nil
}
func (m *WorkspaceBindingManager) ListByProject(projectKey string) map[string]*WorkspaceBinding {
m.mu.RLock()
defer m.mu.RUnlock()
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)
}
}
func (m *WorkspaceBindingManager) load() {
data, err := os.ReadFile(m.storePath)
if err != nil {
if !os.IsNotExist(err) {
slog.Error("workspace bindings: load error", "err", err)
}
return
}
if err := json.Unmarshal(data, &m.bindings); err != nil {
slog.Error("workspace bindings: unmarshal error", "err", err)
}
}