mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-20 02:17:24 +00:00
Add POST /v1/agents/{agentID}/vault/rescan to backfill vault_documents
from filesystem. Walks agent workspace, registers missing/changed files,
publishes enrichment events for async summarize + embed + link classify.
Backend:
- SafeWalkWorkspace: symlink-safe walker with exclusion patterns,
resource limits (5K files, 500MB, 50MB/file), context deadline
- RescanWorkspace: hash-based dedup, path-based scope detection
(teams/{id}/ → team scope), idempotent via UpsertDocument ON CONFLICT
- Per-agent mutex (409 Conflict for concurrent rescans)
- Tenant-scoped workspace resolution (config.TenantWorkspace)
- Enrichment worker: semaphore-based parallel summarize (max 3 concurrent)
- Media files without summary skip LLM summarize, embed title+path only
- DRY: export InferTitle/InferDocType from vault pkg, remove from interceptor
- Auto-register text uploads in vault via onTextUploaded callback
Frontend:
- Rescan button in vault page header (FolderSync icon)
- Toast with result counts, 409 warning, error handling
- i18n strings for en/vi/zh
Security: skip all symlinks, boundary checks, per-file size limit,
tenant isolation via server-side workspace resolution.
226 lines
5.4 KiB
Go
226 lines
5.4 KiB
Go
package vault
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// WalkEntry represents an eligible file found during workspace walk.
|
|
type WalkEntry struct {
|
|
RelPath string // workspace-relative, forward-slash separated
|
|
AbsPath string // absolute filesystem path
|
|
Size int64 // file size in bytes
|
|
ModTime time.Time // last modification time
|
|
}
|
|
|
|
// WalkStats holds metrics from a workspace walk.
|
|
type WalkStats struct {
|
|
TotalWalked int
|
|
Eligible int
|
|
SkippedSymlinks int
|
|
SkippedExcluded int
|
|
SkippedTooLarge int
|
|
Truncated bool
|
|
}
|
|
|
|
// WalkOptions configures resource limits for workspace walking.
|
|
type WalkOptions struct {
|
|
MaxFiles int // max eligible files to return (0 = unlimited)
|
|
MaxTotalBytes int64 // max cumulative file size in bytes (0 = unlimited)
|
|
MaxFileBytes int64 // skip individual files larger than this (0 = unlimited)
|
|
}
|
|
|
|
// DefaultWalkOptions returns safe defaults for production use.
|
|
func DefaultWalkOptions() WalkOptions {
|
|
return WalkOptions{
|
|
MaxFiles: 5000,
|
|
MaxTotalBytes: 500 * 1024 * 1024, // 500MB
|
|
MaxFileBytes: 50 * 1024 * 1024, // 50MB per file
|
|
}
|
|
}
|
|
|
|
// SafeWalkWorkspace walks root directory collecting eligible files.
|
|
// Symlinks are skipped unconditionally. Excluded paths are filtered.
|
|
// Resource limits (file count, total size, context deadline) are enforced.
|
|
func SafeWalkWorkspace(ctx context.Context, root string, opts WalkOptions) ([]WalkEntry, WalkStats, error) {
|
|
root = filepath.Clean(root)
|
|
var (
|
|
entries []WalkEntry
|
|
stats WalkStats
|
|
totalBytes int64
|
|
walkCount int
|
|
)
|
|
|
|
// Early context check before starting walk.
|
|
if ctx.Err() != nil {
|
|
return nil, stats, ctx.Err()
|
|
}
|
|
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil // skip unreadable entries
|
|
}
|
|
|
|
// Check context periodically.
|
|
walkCount++
|
|
if walkCount%100 == 0 {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Skip ALL symlinks unconditionally (both files and dirs).
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
stats.SkippedSymlinks++
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Compute workspace-relative path (forward-slash).
|
|
relPath, relErr := filepath.Rel(root, path)
|
|
if relErr != nil || relPath == "." {
|
|
return nil
|
|
}
|
|
relPath = filepath.ToSlash(relPath)
|
|
|
|
// Boundary check: path must stay inside root.
|
|
if strings.HasPrefix(relPath, "..") {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Directory-level exclusions: skip entire subtree.
|
|
if d.IsDir() {
|
|
if isExcludedDir(relPath) {
|
|
stats.SkippedExcluded++
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
stats.TotalWalked++
|
|
|
|
// File-level exclusions.
|
|
if isExcludedPath(relPath) {
|
|
stats.SkippedExcluded++
|
|
return nil
|
|
}
|
|
|
|
// File info for size check.
|
|
info, infoErr := d.Info()
|
|
if infoErr != nil {
|
|
return nil // skip unreadable
|
|
}
|
|
|
|
// Per-file size limit.
|
|
if opts.MaxFileBytes > 0 && info.Size() > opts.MaxFileBytes {
|
|
stats.SkippedTooLarge++
|
|
return nil
|
|
}
|
|
|
|
// Total size limit.
|
|
if opts.MaxTotalBytes > 0 && totalBytes+info.Size() > opts.MaxTotalBytes {
|
|
stats.Truncated = true
|
|
return filepath.SkipAll
|
|
}
|
|
|
|
// File count limit.
|
|
if opts.MaxFiles > 0 && len(entries) >= opts.MaxFiles {
|
|
stats.Truncated = true
|
|
return filepath.SkipAll
|
|
}
|
|
|
|
totalBytes += info.Size()
|
|
stats.Eligible++
|
|
entries = append(entries, WalkEntry{
|
|
RelPath: relPath,
|
|
AbsPath: path,
|
|
Size: info.Size(),
|
|
ModTime: info.ModTime(),
|
|
})
|
|
return nil
|
|
})
|
|
|
|
// Context cancellation is expected, not an error for partial results.
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return entries, stats, err
|
|
}
|
|
return entries, stats, err
|
|
}
|
|
|
|
// isExcludedDir returns true if an entire directory subtree should be skipped.
|
|
func isExcludedDir(relPath string) bool {
|
|
first, _, _ := strings.Cut(relPath, "/")
|
|
|
|
// Skip memory/ subtree entirely.
|
|
if first == "memory" {
|
|
return true
|
|
}
|
|
|
|
// Skip hidden dirs (. prefix) EXCEPT .uploads (user content).
|
|
if strings.HasPrefix(first, ".") && first != ".uploads" {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// contextFiles are root-level bootstrap files managed by ContextFileInterceptor.
|
|
var contextFiles = map[string]bool{
|
|
"SOUL.md": true,
|
|
"IDENTITY.md": true,
|
|
"USER.md": true,
|
|
"BOOTSTRAP.md": true,
|
|
"AGENTS.md": true,
|
|
"TOOLS.md": true,
|
|
"CAPABILITIES.md": true,
|
|
"MEMORY.md": true,
|
|
"AGENTS_CORE.md": true,
|
|
"AGENTS_TASK.md": true,
|
|
}
|
|
|
|
// isExcludedPath returns true if a file should be excluded from vault registration.
|
|
// Defense-in-depth: also checks parent directory exclusions for callers that bypass dir walk.
|
|
func isExcludedPath(relPath string) bool {
|
|
// Check first path segment for excluded directories.
|
|
first, _, _ := strings.Cut(relPath, "/")
|
|
|
|
// memory/ prefix.
|
|
if first == "memory" {
|
|
return true
|
|
}
|
|
|
|
// Hidden dirs (. prefix) except .uploads.
|
|
if strings.HasPrefix(first, ".") && first != ".uploads" {
|
|
return true
|
|
}
|
|
|
|
base := filepath.Base(relPath)
|
|
|
|
// SQLite database files.
|
|
if strings.HasSuffix(base, ".db") || strings.HasSuffix(base, ".db-wal") || strings.HasSuffix(base, ".db-shm") {
|
|
return true
|
|
}
|
|
|
|
// Root-level context files only (not nested).
|
|
if !strings.Contains(relPath, "/") && contextFiles[base] {
|
|
return true
|
|
}
|
|
|
|
// Hidden files at root level.
|
|
if strings.HasPrefix(base, ".") && !strings.Contains(relPath, "/") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|