mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
- Extend migration 000056 with legacy-docs backfill (telegram/<chat>, ws/<chat>, delegate/<chat>, and agent/bot/<chat> path shapes) so scope=shared/personal docs created before team-scope refactor also get stamped with chat_id and stop leaking across chats - rescan: inferOwnerFromPath now returns chat_id extracted from team path third segment; stamp onto VaultDocument so admin /vault/rescan preserves chat isolation - HTTP vault search: accept optional chat_id body param, set TeamIsolated=true when TeamID + ChatID both present (admin UI can view vault from a specific chat perspective) Applied to staging DB via MCP: 91 legacy rows backfilled.
250 lines
9.0 KiB
Go
250 lines
9.0 KiB
Go
package vault
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// RescanParams holds input for tenant-wide workspace rescan.
|
|
// AgentMap and TeamSet are pre-loaded by the caller to avoid per-file DB lookups.
|
|
type RescanParams struct {
|
|
TenantID string
|
|
Workspace string // absolute path to tenant's workspace root
|
|
AgentMap map[string]string // agent_key → agent UUID
|
|
TeamSet map[string]bool // team UUID → exists (for validation)
|
|
}
|
|
|
|
// RescanResult holds the outcome of a workspace rescan.
|
|
type RescanResult struct {
|
|
Scanned int `json:"scanned"`
|
|
New int `json:"new"`
|
|
Updated int `json:"updated"`
|
|
Unchanged int `json:"unchanged"`
|
|
Reenqueued int `json:"reenqueued"` // docs re-enqueued for failed enrichment retry
|
|
Skipped int `json:"skipped"`
|
|
Errors int `json:"errors"`
|
|
Truncated bool `json:"truncated"`
|
|
|
|
// PendingEvents holds enrichment events collected during scan.
|
|
// Caller must publish these AFTER calling progress.Start(total)
|
|
// to avoid race between event workers and progress tracking.
|
|
PendingEvents []eventbus.DomainEvent `json:"-"`
|
|
}
|
|
|
|
// RescanWorkspace walks the tenant workspace and registers missing or changed
|
|
// files in vault_documents. Ownership (agent/team/scope) is inferred from path.
|
|
// Publishes EventVaultDocUpserted for each new or updated file so the
|
|
// enrichment worker can process them asynchronously.
|
|
func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultStore, bus eventbus.DomainEventBus) (*RescanResult, error) {
|
|
entries, walkStats, err := SafeWalkWorkspace(ctx, params.Workspace, DefaultWalkOptions())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &RescanResult{
|
|
Scanned: walkStats.Eligible,
|
|
Skipped: walkStats.SkippedExcluded + walkStats.SkippedSymlinks + walkStats.SkippedTooLarge,
|
|
Truncated: walkStats.Truncated,
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
agentID, teamID, chatID, scope, strippedPath := inferOwnerFromPath(entry.RelPath, params.AgentMap, params.TeamSet)
|
|
if scope == "" {
|
|
// Unknown agent key or invalid team UUID — skip.
|
|
result.Skipped++
|
|
continue
|
|
}
|
|
|
|
hash, hashErr := ContentHashFile(entry.AbsPath)
|
|
if hashErr != nil {
|
|
result.Errors++
|
|
continue
|
|
}
|
|
|
|
// Resolve the agent ID string for store lookup (empty string = no agent filter).
|
|
agentIDStr := ""
|
|
if agentID != nil {
|
|
agentIDStr = *agentID
|
|
}
|
|
|
|
// Use full workspace-relative path for DB storage and enrichment.
|
|
// This preserves the real filesystem path (e.g. "teams/{uuid}/system/file.md")
|
|
// so enrichment worker can locate files via filepath.Join(workspace, path).
|
|
relPath := entry.RelPath
|
|
|
|
// Check if document already exists with same hash.
|
|
// Fallback: also check old stripped path (pre-fix records stored without prefix).
|
|
existing, _ := vs.GetDocument(ctx, params.TenantID, agentIDStr, relPath)
|
|
if existing == nil && strippedPath != relPath {
|
|
existing, _ = vs.GetDocument(ctx, params.TenantID, agentIDStr, strippedPath)
|
|
}
|
|
if existing != nil && existing.ContentHash == hash {
|
|
result.Unchanged++
|
|
continue
|
|
}
|
|
|
|
doc := &store.VaultDocument{
|
|
TenantID: params.TenantID,
|
|
AgentID: agentID,
|
|
TeamID: teamID,
|
|
ChatID: chatID,
|
|
Scope: scope,
|
|
Path: relPath,
|
|
Title: InferTitle(relPath),
|
|
DocType: InferDocType(relPath),
|
|
ContentHash: hash,
|
|
}
|
|
|
|
if err := vs.UpsertDocument(ctx, doc); err != nil {
|
|
slog.Warn("vault.rescan: upsert", "path", relPath, "err", err)
|
|
result.Errors++
|
|
continue
|
|
}
|
|
|
|
if existing != nil {
|
|
result.Updated++
|
|
} else {
|
|
result.New++
|
|
}
|
|
|
|
// Collect enrichment events — published AFTER the loop so the caller
|
|
// can call progress.Start(total) before workers receive events.
|
|
// Skip auto-generated media files (goclaw_gen_*) — they create excessive
|
|
// noise links and shouldn't be counted in progress tracking.
|
|
if bus != nil && !shouldSkipEnrichment(filepath.Base(relPath)) {
|
|
result.PendingEvents = append(result.PendingEvents, eventbus.DomainEvent{
|
|
ID: uuid.Must(uuid.NewV7()).String(),
|
|
Type: eventbus.EventVaultDocUpserted,
|
|
SourceID: doc.ID + ":" + hash,
|
|
TenantID: params.TenantID,
|
|
AgentID: agentIDStr,
|
|
Timestamp: time.Now(),
|
|
Payload: eventbus.VaultDocUpsertedPayload{
|
|
DocID: doc.ID,
|
|
TenantID: params.TenantID,
|
|
AgentID: agentIDStr,
|
|
Path: relPath,
|
|
ContentHash: hash,
|
|
Workspace: params.Workspace,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
slog.Info("vault.rescan",
|
|
"tenant", params.TenantID,
|
|
"scanned", result.Scanned, "new", result.New,
|
|
"updated", result.Updated, "unchanged", result.Unchanged,
|
|
"skipped", result.Skipped, "errors", result.Errors,
|
|
"truncated", result.Truncated)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// inferOwnerFromPath parses a tenant-relative path to determine ownership.
|
|
// Returns: agentID (*string), teamID (*string), chatID (*string), scope, strippedPath.
|
|
//
|
|
// Path patterns (checked in order):
|
|
//
|
|
// teams/{team_uuid}/{chat}/... → teamID=uuid, chatID=chat, scope="team"
|
|
// teams/{team_uuid}/file.md → teamID=uuid, chatID=nil (team-wide), scope="team"
|
|
// agents/{agent_key}/... → agentID=lookup(key), scope="personal" (legacy prefix)
|
|
// {agent_key}/... → agentID=lookup(key), scope="personal" (workspace layout)
|
|
//
|
|
// Chat segments starting with "." (e.g. ".goclaw") are config dirs, not real chats — chatID stays nil.
|
|
// The full relPath is preserved in strippedPath for DB storage so enrichment workers
|
|
// can locate files via filepath.Join(workspace, path).
|
|
// Returns scope="" to signal the file should be skipped (unknown agent or invalid team).
|
|
func inferOwnerFromPath(relPath string, agentMap map[string]string, teamSet map[string]bool) (agentID *string, teamID *string, chatID *string, scope string, strippedPath string) {
|
|
// Team paths: teams/{uuid}/[chat/]...
|
|
if strings.HasPrefix(relPath, "teams/") {
|
|
rest := relPath[len("teams/"):]
|
|
id, remainder, hasSlash := strings.Cut(rest, "/")
|
|
if !hasSlash || id == "" || strings.Contains(remainder, "..") {
|
|
return nil, nil, nil, "", relPath
|
|
}
|
|
if _, parseErr := uuid.Parse(id); parseErr != nil {
|
|
return nil, nil, nil, "", relPath
|
|
}
|
|
if !teamSet[id] {
|
|
return nil, nil, nil, "", relPath
|
|
}
|
|
// Extract chat segment (second path component after team uuid) if present
|
|
// and not a config/hidden dir. Paths without a chat segment stay team-wide.
|
|
if chatSeg, _, hasChat := strings.Cut(remainder, "/"); hasChat && chatSeg != "" && !strings.HasPrefix(chatSeg, ".") {
|
|
cid := chatSeg
|
|
return nil, &id, &cid, "team", relPath
|
|
}
|
|
return nil, &id, nil, "team", relPath
|
|
}
|
|
|
|
// Agent paths: agents/{key}/... (legacy prefix) or {key}/... (actual workspace layout)
|
|
if strings.HasPrefix(relPath, "agents/") {
|
|
rest := relPath[len("agents/"):]
|
|
key, _, hasSlash := strings.Cut(rest, "/")
|
|
if hasSlash && key != "" && !strings.Contains(relPath, "..") {
|
|
if agentUUID, ok := agentMap[key]; ok {
|
|
return &agentUUID, nil, nil, "personal", relPath
|
|
}
|
|
}
|
|
return nil, nil, nil, "", relPath
|
|
}
|
|
|
|
// Root-level agent_key match: {agent_key}/...
|
|
// Workspace resolver uses agent_key as folder name directly.
|
|
firstSeg, _, hasSlash := strings.Cut(relPath, "/")
|
|
if hasSlash && firstSeg != "" {
|
|
if agentUUID, ok := agentMap[firstSeg]; ok {
|
|
return &agentUUID, nil, nil, "personal", relPath
|
|
}
|
|
}
|
|
|
|
// Everything else is shared (root-level files, unknown folders)
|
|
return nil, nil, nil, "shared", relPath
|
|
}
|
|
|
|
// InferDocType guesses doc_type from path conventions.
|
|
// Exported so both rescan and vault interceptor share the same logic.
|
|
//
|
|
// Path-prefix rules (memory/, skills/, episodic/, SOUL/IDENTITY/AGENTS)
|
|
// take precedence over extension classification — e.g. `memory/foo.md`
|
|
// classifies as `memory` even though `.md` is a whitelisted note extension.
|
|
// Phase 01: extension fallback uses the shared `extensionDocType` whitelist
|
|
// so PDFs/office files resolve to `document` instead of the prior `note`.
|
|
func InferDocType(relPath string) string {
|
|
lower := strings.ToLower(relPath)
|
|
ext := strings.ToLower(filepath.Ext(relPath))
|
|
|
|
// Path-based overrides first (memory/ beats .md → note).
|
|
switch {
|
|
case strings.HasPrefix(lower, "memory/"):
|
|
return "memory"
|
|
case strings.Contains(lower, "soul.md") || strings.Contains(lower, "identity.md") || strings.Contains(lower, "agents.md"):
|
|
return "context"
|
|
case strings.HasPrefix(lower, "skills/") || strings.HasSuffix(lower, "skill.md"):
|
|
return "skill"
|
|
case strings.HasPrefix(lower, "episodic/"):
|
|
return "episodic"
|
|
}
|
|
|
|
// Extension whitelist fallback — single source of truth shared with safe_walk.
|
|
if _, dt := isIncludedExtension(ext); dt != "" {
|
|
return dt
|
|
}
|
|
return "note"
|
|
}
|
|
|
|
// InferTitle extracts a human-readable title from a file path.
|
|
// Exported so both rescan and vault interceptor share the same logic.
|
|
func InferTitle(relPath string) string {
|
|
base := filepath.Base(relPath)
|
|
return strings.TrimSuffix(base, filepath.Ext(base))
|
|
}
|