* 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>
91 lines
2.0 KiB
Go
91 lines
2.0 KiB
Go
package core
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// workspaceState holds the runtime state for a single workspace.
|
|
type workspaceState struct {
|
|
mu sync.Mutex
|
|
workspace string
|
|
sessions *SessionManager
|
|
agent Agent
|
|
lastActivity time.Time
|
|
}
|
|
|
|
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) 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,
|
|
}
|
|
}
|
|
|
|
func (p *workspacePool) Get(workspace string) *workspaceState {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
return p.states[workspace]
|
|
}
|
|
|
|
func (p *workspacePool) GetOrCreate(workspace string) *workspaceState {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if s, ok := p.states[workspace]; ok {
|
|
return s
|
|
}
|
|
s := newWorkspaceState(workspace)
|
|
p.states[workspace] = s
|
|
return s
|
|
}
|
|
|
|
// ReapIdle removes and returns workspace paths that have been idle longer than idleTimeout.
|
|
func (p *workspacePool) ReapIdle() []string {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
cutoff := time.Now().Add(-p.idleTimeout)
|
|
var reaped []string
|
|
for path, state := range p.states {
|
|
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
|
|
}
|