* 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>
110 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|