mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 04:10:26 +00:00
4b780bbffa
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
81 lines
2.4 KiB
Go
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()
|
|
}
|