mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-13 11:07:28 +00:00
28d29ba046
9 fixes for vault enrichment pipeline: 1. Queue key = tenant-only (was per-agent, caused multiple batches blocking EventBus workers and progress bar flashing) 2. Classify chunks 5 candidates per LLM call (prevents response truncation that caused parse_still_failed errors) 3. Classify prompt improved: explicit "EXACTLY one entry per candidate", 5-entry example, ctx capped at 30 words 4. max_tokens kept at 1024 (sufficient for 5 candidates) 5. Progress AddDone removes !running guard (safe before Start) 6. Rescan defers event publishing via PendingEvents — Start() called before workers receive events, eliminating race 7. Upload handler same deferred publish pattern 8. Frontend enrichment timer cancels stale "complete" timeout when new enrichment starts (prevents bar disappearing) 9. Sidebar tree reloads after rescan completes Classify now searches across entire tenant (empty agentID) to build cross-agent links for future vault sharing. Access control enforced at query time — agents only see their own docs.
255 lines
7.3 KiB
Go
255 lines
7.3 KiB
Go
package http
|
|
|
|
import (
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/vault"
|
|
)
|
|
|
|
// allowedUploadExts is the whitelist of text-based file extensions accepted by vault upload.
|
|
var allowedUploadExts = map[string]bool{
|
|
".md": true, ".txt": true, ".json": true, ".yaml": true, ".yml": true,
|
|
".csv": true, ".toml": true, ".xml": true, ".html": true, ".htm": true,
|
|
".go": true, ".py": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true,
|
|
".rs": true, ".java": true, ".rb": true, ".sh": true, ".sql": true,
|
|
".swift": true, ".kt": true, ".c": true, ".cpp": true, ".h": true,
|
|
}
|
|
|
|
// handleUpload accepts multipart file uploads and registers them in the vault.
|
|
// Files are written to the tenant workspace in the appropriate subfolder based on agent/team selection,
|
|
// then UpsertDocument + EventVaultDocUpserted triggers the existing enrichment pipeline.
|
|
func (h *VaultHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|
tenantID := store.TenantIDFromContext(r.Context())
|
|
tenantIDStr := tenantID.String()
|
|
|
|
// 32 MB in-memory; remainder spills to disk.
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
|
|
agentIDStr := r.FormValue("agent_id")
|
|
teamIDStr := r.FormValue("team_id")
|
|
|
|
// Boundary UUID validation. validateTeamMembership below short-circuits
|
|
// on owner role + lite edition (nil teamAccess), which would leave a
|
|
// downstream parseUUIDOrNil(*doc.TeamID) call as a silent-nil trap.
|
|
// Validate at the HTTP boundary so bad form input is rejected before any
|
|
// store call or event publish.
|
|
// See docs/agent-identity-conventions.md.
|
|
if agentIDStr != "" {
|
|
if _, err := uuid.Parse(agentIDStr); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id: must be a UUID"})
|
|
return
|
|
}
|
|
}
|
|
if teamIDStr != "" {
|
|
if _, err := uuid.Parse(teamIDStr); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid team_id: must be a UUID"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate team membership if provided.
|
|
if teamIDStr != "" {
|
|
if !h.validateTeamMembership(r.Context(), w, teamIDStr) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Resolve agent UUID → agent_key for folder placement.
|
|
var agentKey string
|
|
if agentIDStr != "" {
|
|
if h.agents != nil {
|
|
agents, err := h.agents.List(r.Context(), "")
|
|
if err == nil {
|
|
for _, a := range agents {
|
|
if a.ID.String() == agentIDStr {
|
|
agentKey = a.AgentKey
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if agentKey == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent not found"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Determine target subfolder and scope.
|
|
var subDir, scope string
|
|
switch {
|
|
case agentIDStr != "":
|
|
subDir = filepath.Join("agents", agentKey)
|
|
scope = "personal"
|
|
case teamIDStr != "":
|
|
subDir = filepath.Join("teams", teamIDStr)
|
|
scope = "team"
|
|
default:
|
|
scope = "shared"
|
|
}
|
|
|
|
wsPath := h.resolveTenantWorkspace(r.Context())
|
|
if wsPath == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace not available"})
|
|
return
|
|
}
|
|
targetDir := filepath.Join(wsPath, subDir)
|
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
|
slog.Warn("vault.upload: mkdir failed", "dir", targetDir, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create target directory"})
|
|
return
|
|
}
|
|
|
|
files := r.MultipartForm.File["files"]
|
|
if len(files) == 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no files provided"})
|
|
return
|
|
}
|
|
if len(files) > 50 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "too many files (max 50)"})
|
|
return
|
|
}
|
|
|
|
type uploadResult struct {
|
|
Doc *store.VaultDocument `json:"document"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
var results []uploadResult
|
|
var created int
|
|
var pendingEvents []eventbus.DomainEvent
|
|
|
|
for _, fh := range files {
|
|
// Sanitize filename — basename only, no path traversal.
|
|
name := filepath.Base(fh.Filename)
|
|
if name == "." || name == ".." || name == "" {
|
|
results = append(results, uploadResult{Error: "invalid filename: " + fh.Filename})
|
|
continue
|
|
}
|
|
|
|
// Validate extension.
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
if !allowedUploadExts[ext] {
|
|
results = append(results, uploadResult{Error: "unsupported file type: " + name})
|
|
continue
|
|
}
|
|
|
|
// Enforce per-file size limit (50 MB).
|
|
if fh.Size > 50<<20 {
|
|
results = append(results, uploadResult{Error: "file too large: " + name})
|
|
continue
|
|
}
|
|
|
|
// Open uploaded file.
|
|
src, err := fh.Open()
|
|
if err != nil {
|
|
results = append(results, uploadResult{Error: "failed to read: " + name})
|
|
continue
|
|
}
|
|
|
|
// Write to workspace.
|
|
dstPath := filepath.Join(targetDir, name)
|
|
dst, err := os.Create(dstPath)
|
|
if err != nil {
|
|
src.Close()
|
|
results = append(results, uploadResult{Error: "failed to write: " + name})
|
|
continue
|
|
}
|
|
_, copyErr := io.Copy(dst, src)
|
|
dst.Close()
|
|
src.Close()
|
|
if copyErr != nil {
|
|
results = append(results, uploadResult{Error: "failed to write: " + name})
|
|
continue
|
|
}
|
|
|
|
// Hash the written file.
|
|
hash, err := vault.ContentHashFile(dstPath)
|
|
if err != nil {
|
|
results = append(results, uploadResult{Error: "failed to hash: " + name})
|
|
continue
|
|
}
|
|
|
|
// Build workspace-relative path for DB storage.
|
|
relPath := name
|
|
if subDir != "" {
|
|
relPath = subDir + "/" + name
|
|
}
|
|
|
|
doc := &store.VaultDocument{
|
|
TenantID: tenantIDStr,
|
|
Path: relPath,
|
|
Title: vault.InferTitle(relPath),
|
|
DocType: vault.InferDocType(relPath),
|
|
ContentHash: hash,
|
|
Scope: scope,
|
|
}
|
|
if agentIDStr != "" {
|
|
doc.AgentID = &agentIDStr
|
|
}
|
|
if teamIDStr != "" {
|
|
doc.TeamID = &teamIDStr
|
|
}
|
|
|
|
if err := h.store.UpsertDocument(r.Context(), doc); err != nil {
|
|
slog.Warn("vault.upload: upsert failed", "path", relPath, "error", err)
|
|
results = append(results, uploadResult{Error: "failed to register: " + name})
|
|
continue
|
|
}
|
|
|
|
// Collect enrichment events — published after Start() to avoid race.
|
|
if h.eventBus != nil {
|
|
agentForEvent := ""
|
|
if agentIDStr != "" {
|
|
agentForEvent = agentIDStr
|
|
}
|
|
pendingEvents = append(pendingEvents, eventbus.DomainEvent{
|
|
ID: uuid.Must(uuid.NewV7()).String(),
|
|
Type: eventbus.EventVaultDocUpserted,
|
|
SourceID: doc.ID + ":" + hash,
|
|
TenantID: tenantIDStr,
|
|
AgentID: agentForEvent,
|
|
Timestamp: time.Now(),
|
|
Payload: eventbus.VaultDocUpsertedPayload{
|
|
DocID: doc.ID,
|
|
TenantID: tenantIDStr,
|
|
AgentID: agentForEvent,
|
|
Path: relPath,
|
|
ContentHash: hash,
|
|
Workspace: wsPath,
|
|
},
|
|
})
|
|
}
|
|
|
|
results = append(results, uploadResult{Doc: doc})
|
|
created++
|
|
}
|
|
|
|
// Start progress BEFORE publishing events to avoid race with workers.
|
|
if h.enrichProgress != nil && created > 0 {
|
|
h.enrichProgress.Start(created, tenantID)
|
|
}
|
|
for _, event := range pendingEvents {
|
|
h.eventBus.Publish(event)
|
|
}
|
|
|
|
slog.Info("vault.upload", "tenant", tenantIDStr, "uploaded", created, "errors", len(files)-created)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"documents": results,
|
|
"count": created,
|
|
})
|
|
}
|