Files
goclaw/internal/tools/workspace_resolver.go
viettranx 4b780bbffa refactor(workspace): extract layered resolver pipeline for workspace path computation
Replace inline workspace path computation in loop_context.go with composable
WorkspaceLayer pipeline (tenant → team → project → user/chat). Each layer is
a pure function that appends a path segment or is a no-op.

- New workspace_resolver.go: ResolveWorkspace, TenantLayer, TeamLayer,
  ProjectLayer (future), UserChatLayer, SanitizePathSegment
- 16 unit tests covering all layer combinations
- Migrate loop_context.go, loop_history.go, team_tasks_mutations.go
- Move sanitizePathSegment from agent to tools package (exported)
- Zero behavior change — identical paths for all scenarios
2026-03-25 16:03:31 +07:00

81 lines
2.4 KiB
Go

package tools
import (
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// WorkspaceLayer transforms a base path into a scoped path.
// Returns base unchanged if the layer is not applicable (no-op).
type WorkspaceLayer func(base string) string
// ResolveWorkspace applies layers sequentially to produce the final workspace path.
// Each layer either appends a path segment or returns base unchanged (no-op).
func ResolveWorkspace(base string, layers ...WorkspaceLayer) string {
for _, layer := range layers {
base = layer(base)
}
return base
}
// TenantLayer scopes to tenant subdirectory.
// Master tenant is a no-op (backward compat — returns base unchanged).
func TenantLayer(tenantID uuid.UUID, slug string) WorkspaceLayer {
return func(base string) string {
return config.TenantWorkspace(base, tenantID, slug)
}
}
// TeamLayer scopes to team subdirectory: {base}/teams/{teamID}.
// Nil teamID is a no-op.
func TeamLayer(teamID uuid.UUID) WorkspaceLayer {
return func(base string) string {
if teamID == uuid.Nil {
return base
}
return filepath.Join(base, "teams", teamID.String())
}
}
// ProjectLayer scopes to project subdirectory: {base}/projects/{projectID}.
// Nil projectID is a no-op. Reserved for future use.
func ProjectLayer(projectID *uuid.UUID) WorkspaceLayer {
return func(base string) string {
if projectID == nil || *projectID == uuid.Nil {
return base
}
return filepath.Join(base, "projects", projectID.String())
}
}
// UserChatLayer scopes to per-user or per-chat subdirectory: {base}/{segment}.
// Empty segment or shared=true is a no-op.
// The segment should already be sanitized via SanitizePathSegment if it contains user input.
func UserChatLayer(segment string, shared bool) WorkspaceLayer {
return func(base string) string {
if shared || segment == "" {
return base
}
return filepath.Join(base, segment)
}
}
// SanitizePathSegment makes a string safe for use as a directory name.
// Replaces colons, spaces, and other unsafe chars with underscores.
// Used to convert userIDs and chatIDs into safe filesystem path segments.
func SanitizePathSegment(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}