Files
goclaw/internal/tools/workspace_interceptor.go
T
Viet TranandGitHub 84650c5c14 feat(teams): attachments refactor, semantic search, improved prompting (#310)
* feat(teams): refactor attachments, remove team_message, add task comments UI

Major team system refactoring:

- Drop team_workspace_files, team_workspace_file_versions, team_workspace_comments,
  team_messages tables; replace team_task_attachments with path-based schema
- Add denormalized comment_count/attachment_count on team_tasks for dashboard perf
- Auto-track file writes as task attachments via WorkspaceInterceptor
- Remove team_message tool entirely (tool, store, i18n, builtin_tools, MCP bridge)
- Members communicate via task comments; approve/reject use comments for audit trail
- Add commented/new_task notification types to TeamNotifyConfig
- Enrich task completion announce with member comments
- User-created tasks stay pending (backlog) — no auto-assign to leader
- Configurable member request tasks (member_requests.enabled in team settings)
- Structured task description template in TEAM.md for v2 leads
- HTTP attachment download endpoint with IDOR + path traversal protection
- Web UI: count badges on task list, comments section with input, download button
- Team settings UI: completed/commented/new_task toggles, member requests section

* feat(teams): priority dispatch, compact prompting, realtime comments

- Priority dispatch: DispatchUnblockedTasks dispatches only 1 task per
  owner per round (highest priority first). Fixes cancel bug where
  CancelSession killed innocent queued tasks.
- Prompt rework: Replace verbose Task Decomposition (25 lines) with
  compact Task Planning (8 lines). Add explicit UUID warning and
  sequencing guidance for weak models (Qwen, MiniMax).
- Recent comments in dispatch: buildRecentCommentsSummary appends 3
  most recent comments to re-dispatched tasks (reject, retry, stale).
- Enrich comment event payload with TaskNumber, Subject, CommentText
  (truncated 500 runes, UTF-8 safe).
- UI: Board subscribes to TEAM_TASK_COMMENTED for realtime comment_count
  badge updates. Task detail dialog auto-refreshes comments on event.
- Tool description hint: guide models to write self-contained task
  descriptions with clear objectives and context.

* perf(teams): add ListRecentTaskComments with SQL LIMIT

Dispatch only needs 3 most recent comments — avoid fetching all.
New ListRecentTaskComments(ctx, taskID, limit) uses ORDER BY DESC
LIMIT N then reverses to chronological order.

* feat(teams): add subject embedding for semantic task search + improve prompting

- Add vector(1536) embedding column to team_tasks with HNSW index
- Implement hybrid search: FTS (0.3) + cosine similarity (0.7) with graceful fallback
- Auto-generate embeddings on task create/update, backfill existing tasks on startup
- Wire embedding provider into PGTeamStore via gateway_setup
- Change FTS from OR to AND with prefix matching for precise keyword search
- Reduce search page size from 30 to 5 to save tokens
- Rename migration 000023 → 000024, bump RequiredSchemaVersion to 24
- Update TEAM.md hints: prefer search over list, batch task creation with blocked_by
- Add anti-pattern examples to prevent sequential task creation
2026-03-20 17:51:40 +07:00

201 lines
5.8 KiB
Go

package tools
import (
"context"
"fmt"
"log/slog"
"mime"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// WorkspaceInterceptor validates writes and broadcasts events for team workspace files.
// When no team context is active (ToolTeamIDFromCtx returns ""), all methods are no-ops.
type WorkspaceInterceptor struct {
teamMgr *TeamToolManager
}
func NewWorkspaceInterceptor(mgr *TeamToolManager) *WorkspaceInterceptor {
return &WorkspaceInterceptor{teamMgr: mgr}
}
// HandleWrite validates a file write in team workspace context.
// Returns (true, nil) if the write should be treated as a delete (empty content).
// Returns (false, nil) to proceed with normal write.
// Returns (_, error) to block the write.
func (w *WorkspaceInterceptor) HandleWrite(ctx context.Context, path string, content string) (isDelete bool, err error) {
if w == nil {
return false, nil
}
teamIDStr := ToolTeamIDFromCtx(ctx)
if teamIDStr == "" {
return false, nil // Not in team context
}
// Only apply team validation when path is inside the team workspace.
teamWs := ToolTeamWorkspaceFromCtx(ctx)
if teamWs == "" || !strings.HasPrefix(filepath.Clean(path), filepath.Clean(teamWs)) {
return false, nil // Write is to agent's own workspace, not team workspace
}
// Resolve team and role for RBAC. Fail-open: if resolution fails (DB issue,
// corrupt cache), allow the write but log a warning for observability.
team, agentID, err := w.teamMgr.resolveTeam(ctx)
if err != nil {
slog.Warn("workspace: team resolution failed, skipping validation", "team", teamIDStr, "error", err)
return false, nil
}
role, err := w.teamMgr.resolveTeamRole(ctx, team, agentID)
if err != nil {
slog.Warn("workspace: role resolution failed, skipping validation", "team", teamIDStr, "error", err)
return false, nil
}
// Empty content = delete.
if content == "" {
if role == store.TeamRoleReviewer {
return false, fmt.Errorf("reviewers cannot delete workspace files")
}
return true, nil
}
// RBAC: reviewer cannot write.
if role == store.TeamRoleReviewer {
return false, fmt.Errorf("reviewers cannot write to the workspace")
}
// Blocked extensions.
ext := strings.ToLower(filepath.Ext(path))
if blockedExtensions[ext] {
return false, fmt.Errorf("executable file type %q is not allowed", ext)
}
// File size limit (10MB).
if len(content) > maxFileSizeBytes {
return false, fmt.Errorf("file exceeds max size (10MB)")
}
// Quota: count files in team workspace scope.
wsDir := teamWs
if wsDir != "" {
entries, err := os.ReadDir(wsDir)
if err != nil {
slog.Warn("workspace: quota check ReadDir failed", "dir", wsDir, "error", err)
}
fileCount := 0
for _, e := range entries {
if !e.IsDir() {
fileCount++
}
}
// Only check when creating new file (not updating existing).
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
if fileCount >= maxFilesPerScope {
return false, fmt.Errorf("workspace file limit reached (%d/%d)", fileCount, maxFilesPerScope)
}
}
}
return false, nil
}
// AfterWrite broadcasts a workspace file change event and auto-tracks
// file writes as task attachments when a team task ID is in context.
func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, action string) {
if w == nil {
return
}
teamIDStr := ToolTeamIDFromCtx(ctx)
if teamIDStr == "" {
return
}
// Only process writes inside team workspace.
teamWs := ToolTeamWorkspaceFromCtx(ctx)
if teamWs == "" || !strings.HasPrefix(filepath.Clean(path), filepath.Clean(teamWs)) {
return
}
fileName := filepath.Base(path)
chatID := ToolChatIDFromCtx(ctx)
if chatID == "" {
chatID = store.UserIDFromContext(ctx)
}
// Broadcast workspace file change event for real-time UI updates.
w.teamMgr.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{
"team_id": teamIDStr,
"channel": "",
"chat_id": chatID,
"file_name": fileName,
"action": action,
})
slog.Debug("workspace: file changed", "team", teamIDStr, "file", fileName, "action", action)
// Auto-track file as task attachment when team task context is present.
taskIDStr := TeamTaskIDFromCtx(ctx)
if taskIDStr == "" {
return
}
teamID, err := uuid.Parse(teamIDStr)
if err != nil {
return
}
taskID, err := uuid.Parse(taskIDStr)
if err != nil {
return
}
// Compute relative path from workspace root for storage.
relPath, err := filepath.Rel(filepath.Clean(teamWs), filepath.Clean(path))
if err != nil {
relPath = fileName
}
switch action {
case "write":
// Get file size and detect MIME type.
var fileSize int64
if info, err := os.Stat(path); err == nil {
fileSize = info.Size()
}
mimeType := mimeFromExt(filepath.Ext(path))
// Resolve agent ID for created_by_agent_id.
_, agentID, _ := w.teamMgr.resolveTeam(ctx)
att := &store.TeamTaskAttachmentData{
TaskID: taskID,
TeamID: teamID,
ChatID: chatID,
Path: relPath,
FileSize: fileSize,
MimeType: mimeType,
CreatedByAgentID: &agentID,
}
if err := w.teamMgr.teamStore.AttachFileToTask(ctx, att); err != nil {
slog.Warn("workspace: auto-attach failed", "task_id", taskIDStr, "path", relPath, "error", err)
} else {
slog.Debug("workspace: auto-attached file to task", "task_id", taskIDStr, "path", relPath)
}
case "delete":
if err := w.teamMgr.teamStore.DetachFileFromTask(ctx, taskID, relPath); err != nil {
slog.Warn("workspace: auto-detach failed", "task_id", taskIDStr, "path", relPath, "error", err)
}
}
}
// mimeFromExt returns a MIME type for a file extension using the stdlib registry.
func mimeFromExt(ext string) string {
if t := mime.TypeByExtension(ext); t != "" {
return t
}
return "application/octet-stream"
}