mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 00:17:27 +00:00
fix(teams): auto-copy media files to team workspace on task creation, scope task_number per chat
- Add RunMediaPaths context key to track media files from current run
- Collect persisted media paths in agent loop after enrichment
- Auto-copy media files to {workspace}/attachments/ when leader creates task
- Append attached files hint in dispatch content so members know what to read
- Scope task_number per (team_id, chat_id) instead of global per team
- Fix NULL chat_id comparison with COALESCE
- Use hard link first, copy fallback to save disk space
- Validate filenames and use restrictive file permissions (0640)
This commit is contained in:
1 parent
4fc072697f
commit
c7d0bc19f8
6 files changed
+222
-4
No files matched your search
+24
-2
@@ -243,7 +243,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
|
||||
// buildMessages resolves context files once and also detects BOOTSTRAP.md presence
|
||||
// (hadBootstrap) — no extra DB roundtrip needed for bootstrap detection.
|
||||
messages, hadBootstrap := l.buildMessages(ctx, history, summary, req.Message, req.ExtraSystemPrompt, req.SessionKey, req.Channel, req.ChannelType, req.PeerKind, req.UserID, req.HistoryLimit, req.SkillFilter)
|
||||
messages, hadBootstrap := l.buildMessages(ctx, history, summary, req.Message, req.ExtraSystemPrompt, req.SessionKey, req.Channel, req.ChannelType, req.PeerKind, req.UserID, req.HistoryLimit, req.SkillFilter, req.LightContext)
|
||||
|
||||
// 1b. Determine image routing strategy.
|
||||
// If read_image tool has a dedicated vision provider, images are NOT attached inline
|
||||
@@ -355,7 +355,29 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
// knows images were received and stored (consistent with audio/video enrichment).
|
||||
l.enrichImageIDs(messages, mediaRefs)
|
||||
|
||||
// 2f. Cross-session task reminder: notify team leads about pending and in-progress tasks.
|
||||
// 2f. Collect all media file paths for team workspace auto-collect.
|
||||
// When the leader calls team_tasks(create), these paths are copied to the
|
||||
// team workspace so members can access attached files.
|
||||
if len(mediaRefs) > 0 && l.mediaStore != nil {
|
||||
var mediaPaths []string
|
||||
for _, ref := range mediaRefs {
|
||||
if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
|
||||
mediaPaths = append(mediaPaths, p)
|
||||
}
|
||||
}
|
||||
if len(mediaPaths) > 0 {
|
||||
ctx = tools.WithRunMediaPaths(ctx, mediaPaths)
|
||||
// Extract original filenames from <media:document name="X" path="Y"> tags
|
||||
// in the last user message (enriched in step 2b above).
|
||||
if lastMsg := messages[len(messages)-1]; lastMsg.Role == "user" {
|
||||
if nameMap := tools.ExtractMediaNameMap(lastMsg.Content); len(nameMap) > 0 {
|
||||
ctx = tools.WithRunMediaNames(ctx, nameMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2g. Cross-session task reminder: notify team leads about pending and in-progress tasks.
|
||||
// Stale recovery (expired lock → pending) is handled by the background TaskTicker.
|
||||
if l.teamStore != nil && l.agentUUID != uuid.Nil {
|
||||
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil && team.LeadAgentID == l.agentUUID {
|
||||
|
||||
@@ -89,10 +89,11 @@ func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData)
|
||||
return fmt.Errorf("lock team: %w", err)
|
||||
}
|
||||
|
||||
// Scope task_number per (team_id, chat_id) so each conversation starts from 1.
|
||||
var taskNumber int
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(MAX(task_number), 0) + 1 FROM team_tasks WHERE team_id = $1`,
|
||||
task.TeamID,
|
||||
`SELECT COALESCE(MAX(task_number), 0) + 1 FROM team_tasks WHERE team_id = $1 AND COALESCE(chat_id, '') = $2`,
|
||||
task.TeamID, task.ChatID,
|
||||
).Scan(&taskNumber)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute task_number: %w", err)
|
||||
|
||||
@@ -368,6 +368,35 @@ func PendingTeamDispatchFromCtx(ctx context.Context) *PendingTeamDispatch {
|
||||
return v
|
||||
}
|
||||
|
||||
// --- Run media file paths (for team workspace auto-collect) ---
|
||||
|
||||
const ctxRunMediaPaths toolContextKey = "tool_run_media_paths"
|
||||
|
||||
// WithRunMediaPaths stores the absolute file paths of media files received
|
||||
// in the current run. Used by team_tasks to auto-copy files to team workspace.
|
||||
func WithRunMediaPaths(ctx context.Context, paths []string) context.Context {
|
||||
return context.WithValue(ctx, ctxRunMediaPaths, paths)
|
||||
}
|
||||
|
||||
// RunMediaPathsFromCtx returns media file paths from the current run.
|
||||
func RunMediaPathsFromCtx(ctx context.Context) []string {
|
||||
v, _ := ctx.Value(ctxRunMediaPaths).([]string)
|
||||
return v
|
||||
}
|
||||
|
||||
const ctxRunMediaNames toolContextKey = "tool_run_media_names"
|
||||
|
||||
// WithRunMediaNames stores the mapping from media file path to original filename.
|
||||
func WithRunMediaNames(ctx context.Context, names map[string]string) context.Context {
|
||||
return context.WithValue(ctx, ctxRunMediaNames, names)
|
||||
}
|
||||
|
||||
// RunMediaNamesFromCtx returns the media path → original filename mapping.
|
||||
func RunMediaNamesFromCtx(ctx context.Context) map[string]string {
|
||||
v, _ := ctx.Value(ctxRunMediaNames).(map[string]string)
|
||||
return v
|
||||
}
|
||||
|
||||
// --- Per-agent sandbox config override ---
|
||||
|
||||
const ctxSandboxCfg toolContextKey = "tool_sandbox_config"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -119,6 +120,32 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
|
||||
if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, wsChat); err == nil {
|
||||
taskMeta["team_workspace"] = teamWsDir
|
||||
}
|
||||
// Auto-collect media files from current run to team workspace.
|
||||
// When leader received files from user and creates a task, copy those
|
||||
// files to the team workspace so members can access them via read_file.
|
||||
// Also rewrite any media paths in the description to point to the workspace copy,
|
||||
// since members can't access the original .media/ paths outside their workspace.
|
||||
if mediaPaths := RunMediaPathsFromCtx(ctx); len(mediaPaths) > 0 {
|
||||
if wsDir, _ := taskMeta["team_workspace"].(string); wsDir != "" {
|
||||
nameMap := RunMediaNamesFromCtx(ctx)
|
||||
if copiedPaths := copyMediaToWorkspace(mediaPaths, wsDir, nameMap); len(copiedPaths) > 0 {
|
||||
// Store as []any so type assertion works both before and after JSON round-trip.
|
||||
files := make([]any, len(copiedPaths))
|
||||
for i, p := range copiedPaths {
|
||||
files[i] = p
|
||||
}
|
||||
taskMeta["attached_files"] = files
|
||||
|
||||
// Rewrite media paths in description so members see workspace paths.
|
||||
for i, src := range mediaPaths {
|
||||
if i < len(copiedPaths) {
|
||||
description = strings.ReplaceAll(description, src, copiedPaths[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
|
||||
if len(blockedBy) > 0 {
|
||||
ids := make([]string, len(blockedBy))
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -69,6 +70,15 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T
|
||||
if ws := taskTeamWorkspace(task); ws != "" {
|
||||
content += fmt.Sprintf("\n\n[Team workspace: %s — use read_file/write_file/list_files to access shared files. All files you write are visible to the team lead and other members.]", ws)
|
||||
}
|
||||
// List attached files so member knows what's available to read.
|
||||
if files, ok := task.Metadata["attached_files"].([]any); ok && len(files) > 0 {
|
||||
content += "\n\n[Attached files in team workspace — use read_file to access:]"
|
||||
for _, f := range files {
|
||||
if path, ok := f.(string); ok {
|
||||
content += "\n- attachments/" + filepath.Base(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use task's stored channel/chat as primary source for routing.
|
||||
// Falls back to ctx values for initial dispatch (task just created, fields match ctx).
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mediaDocNameRe extracts name and path attributes from <media:document> tags.
|
||||
var mediaDocNameRe = regexp.MustCompile(`<media:document\b[^>]*\bname="([^"]+)"[^>]*\bpath="([^"]+)"`)
|
||||
|
||||
// ExtractMediaNameMap parses message content for <media:document name="X" path="Y"> tags
|
||||
// and returns a map from absolute file path to original filename.
|
||||
func ExtractMediaNameMap(content string) map[string]string {
|
||||
nameMap := make(map[string]string)
|
||||
for _, m := range mediaDocNameRe.FindAllStringSubmatch(content, -1) {
|
||||
if len(m) == 3 {
|
||||
nameMap[m[2]] = m[1] // path → name
|
||||
}
|
||||
}
|
||||
// Also match path before name: <media:document path="Y" name="X">
|
||||
altRe := regexp.MustCompile(`<media:document\b[^>]*\bpath="([^"]+)"[^>]*\bname="([^"]+)"`)
|
||||
for _, m := range altRe.FindAllStringSubmatch(content, -1) {
|
||||
if len(m) == 3 {
|
||||
if _, exists := nameMap[m[1]]; !exists {
|
||||
nameMap[m[1]] = m[2] // path → name
|
||||
}
|
||||
}
|
||||
}
|
||||
return nameMap
|
||||
}
|
||||
|
||||
// copyMediaToWorkspace copies media files to the team workspace attachments dir.
|
||||
// Uses original filenames from nameMap when available; falls back to UUID filename.
|
||||
// Appends -1, -2 suffix on collision. Skips files that already exist with same size.
|
||||
// Returns absolute paths of copied files.
|
||||
func copyMediaToWorkspace(mediaPaths []string, wsDir string, nameMap map[string]string) []string {
|
||||
if len(mediaPaths) == 0 || wsDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
attachDir := filepath.Join(wsDir, "attachments")
|
||||
if err := os.MkdirAll(attachDir, 0750); err != nil {
|
||||
slog.Warn("workspace_media: failed to create attachments dir", "dir", attachDir, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var copied []string
|
||||
for _, src := range mediaPaths {
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
slog.Debug("workspace_media: source file not found", "path", src, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Use original filename if available, otherwise fall back to UUID name.
|
||||
baseName := filepath.Base(src)
|
||||
if origName, ok := nameMap[src]; ok && origName != "" {
|
||||
baseName = origName
|
||||
}
|
||||
if baseName == "." || baseName == ".." || baseName == "" {
|
||||
slog.Debug("workspace_media: skipping invalid filename", "path", src)
|
||||
continue
|
||||
}
|
||||
|
||||
dst := filepath.Join(attachDir, baseName)
|
||||
|
||||
// Check if already copied (same name + same size = skip).
|
||||
if dstInfo, err := os.Stat(dst); err == nil {
|
||||
if dstInfo.Size() == srcInfo.Size() {
|
||||
copied = append(copied, dst)
|
||||
continue
|
||||
}
|
||||
// Name collision with different file — add suffix.
|
||||
dst = deduplicatePath(attachDir, baseName)
|
||||
}
|
||||
|
||||
// Hard link first (same filesystem, no disk usage), fallback to copy.
|
||||
if err := os.Link(src, dst); err != nil {
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
slog.Warn("workspace_media: failed to copy file", "src", src, "dst", dst, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("workspace_media: copied to team workspace",
|
||||
"original", baseName, "src", filepath.Base(src), "dst", dst)
|
||||
copied = append(copied, dst)
|
||||
}
|
||||
|
||||
return copied
|
||||
}
|
||||
|
||||
// deduplicatePath appends -1, -2, etc. to the filename until it's unique.
|
||||
func deduplicatePath(dir, name string) string {
|
||||
ext := filepath.Ext(name)
|
||||
base := strings.TrimSuffix(name, ext)
|
||||
for i := 1; i < 100; i++ {
|
||||
candidate := filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext))
|
||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return filepath.Join(dir, name) // fallback
|
||||
}
|
||||
|
||||
// copyFile copies src to dst using io.Copy.
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
Reference in new issue
Block a user