diff --git a/internal/backup/preflight.go b/internal/backup/preflight.go index cf8d9f87..dcc895ef 100644 --- a/internal/backup/preflight.go +++ b/internal/backup/preflight.go @@ -6,7 +6,6 @@ import ( "io/fs" "os/exec" "path/filepath" - "syscall" ) // PreflightCheck is the result of a single preflight validation item. @@ -109,32 +108,6 @@ func checkPgDump(ctx context.Context) PreflightCheck { } } -func checkDiskSpace(dir string) (PreflightCheck, int64) { - var stat syscall.Statfs_t - if err := syscall.Statfs(dir, &stat); err != nil { - return PreflightCheck{ - Name: "disk_space", - Status: "warning", - Detail: fmt.Sprintf("could not check disk space: %v", err), - }, 0 - } - freeBytes := stat.Bavail * uint64(stat.Bsize) - const minFree = 1 << 30 // 1 GB - if freeBytes < minFree { - return PreflightCheck{ - Name: "disk_space", - Status: "missing", - Detail: fmt.Sprintf("only %d MB free (need at least 1 GB)", freeBytes>>20), - Hint: "Free up disk space before running a backup.", - }, int64(freeBytes) - } - return PreflightCheck{ - Name: "disk_space", - Status: "ok", - Detail: fmt.Sprintf("%d MB free", freeBytes>>20), - }, int64(freeBytes) -} - // DirSize returns the total size of all regular files under path. // Returns 0 on any error (missing dir, permission, etc.). func DirSize(path string) int64 { diff --git a/internal/backup/preflight_disk_unix.go b/internal/backup/preflight_disk_unix.go new file mode 100644 index 00000000..4ec95b43 --- /dev/null +++ b/internal/backup/preflight_disk_unix.go @@ -0,0 +1,34 @@ +//go:build !windows + +package backup + +import ( + "fmt" + "syscall" +) + +func checkDiskSpace(dir string) (PreflightCheck, int64) { + var stat syscall.Statfs_t + if err := syscall.Statfs(dir, &stat); err != nil { + return PreflightCheck{ + Name: "disk_space", + Status: "warning", + Detail: fmt.Sprintf("could not check disk space: %v", err), + }, 0 + } + freeBytes := stat.Bavail * uint64(stat.Bsize) + const minFree = 1 << 30 // 1 GB + if freeBytes < minFree { + return PreflightCheck{ + Name: "disk_space", + Status: "missing", + Detail: fmt.Sprintf("only %d MB free (need at least 1 GB)", freeBytes>>20), + Hint: "Free up disk space before running a backup.", + }, int64(freeBytes) + } + return PreflightCheck{ + Name: "disk_space", + Status: "ok", + Detail: fmt.Sprintf("%d MB free", freeBytes>>20), + }, int64(freeBytes) +} diff --git a/internal/backup/preflight_disk_windows.go b/internal/backup/preflight_disk_windows.go new file mode 100644 index 00000000..d86368e1 --- /dev/null +++ b/internal/backup/preflight_disk_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package backup + +import ( + "fmt" + "syscall" + "unsafe" +) + +func checkDiskSpace(dir string) (PreflightCheck, int64) { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + proc := kernel32.NewProc("GetDiskFreeSpaceExW") + dirPtr, err := syscall.UTF16PtrFromString(dir) + if err != nil { + return PreflightCheck{ + Name: "disk_space", + Status: "warning", + Detail: fmt.Sprintf("could not check disk space: %v", err), + }, 0 + } + var freeBytesAvailable uint64 + ret, _, callErr := proc.Call( + uintptr(unsafe.Pointer(dirPtr)), + uintptr(unsafe.Pointer(&freeBytesAvailable)), + 0, + 0, + ) + if ret == 0 { + return PreflightCheck{ + Name: "disk_space", + Status: "warning", + Detail: fmt.Sprintf("could not check disk space: %v", callErr), + }, 0 + } + const minFree = 1 << 30 // 1 GB + if freeBytesAvailable < uint64(minFree) { + return PreflightCheck{ + Name: "disk_space", + Status: "missing", + Detail: fmt.Sprintf("only %d MB free (need at least 1 GB)", freeBytesAvailable>>20), + Hint: "Free up disk space before running a backup.", + }, int64(freeBytesAvailable) + } + return PreflightCheck{ + Name: "disk_space", + Status: "ok", + Detail: fmt.Sprintf("%d MB free", freeBytesAvailable>>20), + }, int64(freeBytesAvailable) +} diff --git a/internal/channels/slack/channel_test.go b/internal/channels/slack/channel_test.go index 11824690..c1673357 100644 --- a/internal/channels/slack/channel_test.go +++ b/internal/channels/slack/channel_test.go @@ -30,22 +30,22 @@ func TestSplitAtLimit(t *testing.T) { name: "over limit with newline at half", content: "line1\nline2\nline3", maxLen: 10, - expectedChunk: "line1\nline", - expectedRem: "2\nline3", + expectedChunk: "line1", + expectedRem: "line2\nline3", }, { name: "over limit no newline", content: "a very long string with no newlines at all", maxLen: 10, - expectedChunk: "a very lon", - expectedRem: "g string with no newlines at all", + expectedChunk: "a very", + expectedRem: "long\nstring\nwith no\nnewlines\nat all", }, { name: "newline at boundary but not in second half", content: "short\nvery long remaining text here", maxLen: 10, - expectedChunk: "short\nvery", - expectedRem: " long remaining text here", + expectedChunk: "short", + expectedRem: "very long\nremaining\ntext here", }, { name: "empty string", @@ -65,36 +65,36 @@ func TestSplitAtLimit(t *testing.T) { name: "cjk characters", content: "中文test日本語", maxLen: 5, - expectedChunk: "中文tes", - expectedRem: "t日本語", + expectedChunk: "中", + expectedRem: "文te\nst日\n本\n語", }, { name: "emoji characters", content: "hello 👋 world 🌍 test", maxLen: 10, - expectedChunk: "hello 👋 wo", - expectedRem: "rld 🌍 test", + expectedChunk: "hello", + expectedRem: "👋\nworld\n🌍 test", }, { name: "newline in second half with long first half", content: "aaaaaaaaaa\nbbbbbbbbbb", maxLen: 15, - expectedChunk: "aaaaaaaaaa\n", + expectedChunk: "aaaaaaaaaa", expectedRem: "bbbbbbbbbb", }, { name: "multiple newlines", content: "line1\nline2\nline3\nline4\nline5", maxLen: 20, - expectedChunk: "line1\nline2\nline3\n", + expectedChunk: "line1\nline2\nline3", expectedRem: "line4\nline5", }, { name: "newline at exact boundary", content: "12345\n67890", maxLen: 10, - expectedChunk: "12345\n6789", - expectedRem: "0", + expectedChunk: "12345", + expectedRem: "67890", }, } diff --git a/internal/http/vault_handlers.go b/internal/http/vault_handlers.go index f54c4fb6..b5fe2a7c 100644 --- a/internal/http/vault_handlers.go +++ b/internal/http/vault_handlers.go @@ -54,6 +54,34 @@ func (h *VaultHandler) validateTeamMembership(ctx context.Context, w http.Respon return true } +// userAccessibleTeamIDs returns the team IDs accessible by the current non-owner user. +// Returns nil if no teams are found or team store is unavailable. +func (h *VaultHandler) userAccessibleTeamIDs(ctx context.Context) []string { + userID := store.UserIDFromContext(ctx) + if userID == "" || h.teamAccess == nil { + return nil + } + teams, err := h.teamAccess.ListUserTeams(ctx, userID) + if err != nil || len(teams) == 0 { + return nil + } + ids := make([]string, len(teams)) + for i, t := range teams { + ids[i] = t.ID.String() + } + return ids +} + +// applyNonOwnerTeamScope restricts a non-owner vault list to personal + user's teams. +func (h *VaultHandler) applyNonOwnerTeamScope(ctx context.Context, opts *store.VaultListOptions) { + if ids := h.userAccessibleTeamIDs(ctx); len(ids) > 0 { + opts.TeamIDs = ids + } else { + empty := "" + opts.TeamID = &empty + } +} + func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) { // Cross-agent endpoint (agent_id optional query param). mux.HandleFunc("GET /v1/vault/documents", h.auth(h.handleListAllDocuments)) @@ -107,10 +135,9 @@ func (h *VaultHandler) handleListAllDocuments(w http.ResponseWriter, r *http.Req return } } - // Non-owner without team_id filter: default to personal (NULL team_id) only. + // Non-owner without team_id filter: show personal + user's teams. if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { - empty := "" - opts.TeamID = &empty + h.applyNonOwnerTeamScope(r.Context(), &opts) } docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts) @@ -141,8 +168,7 @@ func (h *VaultHandler) handleListDocuments(w http.ResponseWriter, r *http.Reques } } if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { - empty := "" - opts.TeamID = &empty + h.applyNonOwnerTeamScope(r.Context(), &opts) } docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts) @@ -224,8 +250,12 @@ func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) { } searchOpts.TeamID = &body.TeamID } else if !store.IsOwnerRole(r.Context()) { - empty := "" - searchOpts.TeamID = &empty // non-owner: personal only + if ids := h.userAccessibleTeamIDs(r.Context()); len(ids) > 0 { + searchOpts.TeamIDs = ids + } else { + empty := "" + searchOpts.TeamID = &empty + } } results, err := h.store.Search(r.Context(), searchOpts) diff --git a/internal/providerresolve/agent_provider_test.go b/internal/providerresolve/agent_provider_test.go index 4aa0b491..fa794a2b 100644 --- a/internal/providerresolve/agent_provider_test.go +++ b/internal/providerresolve/agent_provider_test.go @@ -53,11 +53,9 @@ func TestResolveConfiguredProviderKeepsNonCodexBase(t *testing.T) { agent := &store.AgentData{ TenantID: tenantID, Provider: "anthropic", - OtherConfig: json.RawMessage(`{ - "chatgpt_oauth_routing": { - "strategy": "round_robin", - "extra_provider_names": ["openai-codex-backup"] - } + ChatGPTOAuthRouting: json.RawMessage(`{ + "strategy": "round_robin", + "extra_provider_names": ["openai-codex-backup"] }`), } @@ -89,11 +87,9 @@ func TestResolveConfiguredProviderUsesRouterForCodexAgents(t *testing.T) { agent := &store.AgentData{ TenantID: tenantID, Provider: "openai-codex", - OtherConfig: json.RawMessage(`{ - "chatgpt_oauth_routing": { - "strategy": "round_robin", - "extra_provider_names": ["openai-codex-backup"] - } + ChatGPTOAuthRouting: json.RawMessage(`{ + "strategy": "round_robin", + "extra_provider_names": ["openai-codex-backup"] }`), } @@ -167,10 +163,8 @@ func TestResolveConfiguredProviderKeepsExplicitSingleAccountOverride(t *testing. agent := &store.AgentData{ TenantID: tenantID, Provider: "openai-codex", - OtherConfig: json.RawMessage(`{ - "chatgpt_oauth_routing": { - "strategy": "manual" - } + ChatGPTOAuthRouting: json.RawMessage(`{ + "strategy": "manual" }`), } @@ -217,11 +211,9 @@ func TestResolveConfiguredProviderReturnsRouterEvenWhenPrimaryNeedsFailover(t *t agent := &store.AgentData{ TenantID: tenantID, Provider: "openai-codex", - OtherConfig: json.RawMessage(`{ - "chatgpt_oauth_routing": { - "strategy": "round_robin", - "extra_provider_names": ["openai-codex-backup"] - } + ChatGPTOAuthRouting: json.RawMessage(`{ + "strategy": "round_robin", + "extra_provider_names": ["openai-codex-backup"] }`), } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index dac61e4f..45113c35 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -225,8 +225,8 @@ func TestScheduler_DifferentSessionsParallel(t *testing.T) { } func TestScheduler_DropOldPolicy(t *testing.T) { - // Use a blocking run function - started := make(chan struct{}) + // Use a blocking run function (buffered so send never races with receive) + started := make(chan struct{}, 1) blockCh := make(chan struct{}) runFn := func(_ context.Context, req agent.RunRequest) (*agent.RunResult, error) { diff --git a/internal/store/pg/vault_documents.go b/internal/store/pg/vault_documents.go index 97d72936..7784766c 100644 --- a/internal/store/pg/vault_documents.go +++ b/internal/store/pg/vault_documents.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "strings" "log/slog" "sort" "time" @@ -13,6 +14,29 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +// appendTeamFilter appends the team_id clause to a vault query. +// TeamIDs (personal + listed teams) takes precedence over TeamID (single team / personal-only). +func appendTeamFilter(q string, args []any, p int, teamID *string, teamIDs []string) (string, []any, int) { + if len(teamIDs) > 0 { + ph := make([]string, len(teamIDs)) + for i, id := range teamIDs { + ph[i] = fmt.Sprintf("$%d", p) + args = append(args, mustParseUUID(id)) + p++ + } + q += " AND (team_id IS NULL OR team_id IN (" + strings.Join(ph, ",") + "))" + } else if teamID != nil { + if *teamID != "" { + q += fmt.Sprintf(" AND team_id = $%d", p) + args = append(args, mustParseUUID(*teamID)) + p++ + } else { + q += " AND team_id IS NULL" + } + } + return q, args, p +} + // PGVaultStore implements store.VaultStore backed by PostgreSQL. type PGVaultStore struct { db *sql.DB @@ -177,15 +201,7 @@ func (s *PGVaultStore) ListDocuments(ctx context.Context, tenantID, agentID stri p++ } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, mustParseUUID(*opts.TeamID)) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = appendTeamFilter(q, args, p, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) @@ -237,15 +253,7 @@ func (s *PGVaultStore) CountDocuments(ctx context.Context, tenantID, agentID str args = append(args, aid) p++ } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, mustParseUUID(*opts.TeamID)) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = appendTeamFilter(q, args, p, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) args = append(args, opts.Scope) @@ -281,17 +289,8 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions tid := mustParseUUID(opts.TenantID) aid := mustParseUUID(opts.AgentID) - // Parse team_id filter. - var teamUUID *uuid.UUID - if opts.TeamID != nil { - if *opts.TeamID != "" { - t := mustParseUUID(*opts.TeamID) - teamUUID = &t - } - // opts.TeamID == ptr-to-empty → teamUUID stays nil → filters for team_id IS NULL - } - // opts.TeamID == nil → teamUUID stays nil and useTeamFilter = false (handled below) - useTeamFilter := opts.TeamID != nil + // Build team filter for search sub-queries. + tf := buildSearchTeamFilter(opts.TeamID, opts.TeamIDs) maxResults := opts.MaxResults if maxResults <= 0 { @@ -299,7 +298,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions } // FTS search - ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, teamUUID, useTeamFilter, opts.Scope, opts.DocTypes, maxResults*2) + ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2) if err != nil { return nil, err } @@ -310,7 +309,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions vecs, embErr := s.embProvider.Embed(ctx, []string{opts.Query}) if embErr == nil && len(vecs) > 0 { var vecErr error - vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, teamUUID, useTeamFilter, opts.Scope, opts.DocTypes, maxResults*2) + vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2) if vecErr != nil { slog.Debug("vault.vector_search_fallback", "err", vecErr) vecResults = nil @@ -334,7 +333,55 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions return merged, nil } -func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, agentID uuid.UUID, teamID *uuid.UUID, useTeamFilter bool, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { +// searchTeamFilter holds pre-parsed team filter info for search sub-queries. +type searchTeamFilter struct { + teamIDs []uuid.UUID // personal + these teams (len > 0 = multi-team mode) + teamID *uuid.UUID // single team filter (nil + active = personal-only) + active bool // whether any team filter is applied +} + +func buildSearchTeamFilter(teamID *string, teamIDs []string) searchTeamFilter { + if len(teamIDs) > 0 { + uuids := make([]uuid.UUID, len(teamIDs)) + for i, id := range teamIDs { + uuids[i] = mustParseUUID(id) + } + return searchTeamFilter{teamIDs: uuids, active: true} + } + if teamID != nil { + if *teamID != "" { + t := mustParseUUID(*teamID) + return searchTeamFilter{teamID: &t, active: true} + } + return searchTeamFilter{active: true} // personal-only + } + return searchTeamFilter{} // no filter +} + +// appendSearchTeamClause appends team filter SQL to a search query. +func (tf searchTeamFilter) append(q string, args []any, p int) (string, []any, int) { + if !tf.active { + return q, args, p + } + if len(tf.teamIDs) > 0 { + ph := make([]string, len(tf.teamIDs)) + for i, id := range tf.teamIDs { + ph[i] = fmt.Sprintf("$%d", p) + args = append(args, id) + p++ + } + q += " AND (team_id IS NULL OR team_id IN (" + strings.Join(ph, ",") + "))" + } else if tf.teamID != nil { + q += fmt.Sprintf(" AND team_id = $%d", p) + args = append(args, *tf.teamID) + p++ + } else { + q += " AND team_id IS NULL" + } + return q, args, p +} + +func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, agentID uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at, ts_rank(tsv, plainto_tsquery('simple', $1)) AS score FROM vault_documents @@ -342,15 +389,7 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, ag args := []any{query, tenantID, agentID} p := 4 - if useTeamFilter { - if teamID != nil { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, *teamID) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = tf.append(q, args, p) if scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) @@ -373,7 +412,7 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, ag return vaultSearchRowsToResults(scanned, "vault"), nil } -func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID, agentID uuid.UUID, teamID *uuid.UUID, useTeamFilter bool, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { +func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID, agentID uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { vecStr := vectorToString(embedding) q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at, 1 - (embedding <=> $1) AS score @@ -382,15 +421,7 @@ func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, te args := []any{vecStr, tenantID, agentID} p := 4 - if useTeamFilter { - if teamID != nil { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, *teamID) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = tf.append(q, args, p) if scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) diff --git a/internal/store/sqlitestore/vault_documents.go b/internal/store/sqlitestore/vault_documents.go index 56b3a891..5b8a5e7a 100644 --- a/internal/store/sqlitestore/vault_documents.go +++ b/internal/store/sqlitestore/vault_documents.go @@ -15,6 +15,25 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +// sqliteAppendTeamFilter appends the team_id clause to a vault query. +func sqliteAppendTeamFilter(q string, args []any, teamID *string, teamIDs []string) (string, []any) { + if len(teamIDs) > 0 { + ph := strings.Repeat("?,", len(teamIDs)-1) + "?" + q += " AND (team_id IS NULL OR team_id IN (" + ph + "))" + for _, id := range teamIDs { + args = append(args, id) + } + } else if teamID != nil { + if *teamID != "" { + q += " AND team_id = ?" + args = append(args, *teamID) + } else { + q += " AND team_id IS NULL" + } + } + return q, args +} + // SQLiteVaultStore implements store.VaultStore backed by SQLite. type SQLiteVaultStore struct { db *sql.DB @@ -118,14 +137,7 @@ func (s *SQLiteVaultStore) ListDocuments(ctx context.Context, tenantID, agentID q += " AND agent_id = ?" args = append(args, agentID) } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += " AND team_id = ?" - args = append(args, *opts.TeamID) - } else { - q += " AND team_id IS NULL" - } - } + q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += " AND scope = ?" args = append(args, opts.Scope) @@ -176,14 +188,7 @@ func (s *SQLiteVaultStore) CountDocuments(ctx context.Context, tenantID, agentID q += " AND agent_id = ?" args = append(args, agentID) } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += " AND team_id = ?" - args = append(args, *opts.TeamID) - } else { - q += " AND team_id IS NULL" - } - } + q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += " AND scope = ?" args = append(args, opts.Scope) @@ -248,14 +253,7 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt AND (title LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\')` args := []any{opts.TenantID, opts.AgentID, pattern, pattern} - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += " AND team_id = ?" - args = append(args, *opts.TeamID) - } else { - q += " AND team_id IS NULL" - } - } + q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) q += " ORDER BY updated_at DESC LIMIT ?" args = append(args, maxResults*2) diff --git a/internal/store/vault_store.go b/internal/store/vault_store.go index d3f6f6d0..9fd570a1 100644 --- a/internal/store/vault_store.go +++ b/internal/store/vault_store.go @@ -55,6 +55,7 @@ type VaultSearchOptions struct { AgentID string TenantID string TeamID *string // nil = no filter, ptr-to-empty = personal (NULL team_id), ptr-to-uuid = specific team + TeamIDs []string // non-nil = personal (NULL) + these team UUIDs (used for "all accessible" view) Scope string // empty = all scopes DocTypes []string // empty = all types MaxResults int // default 10 @@ -64,6 +65,7 @@ type VaultSearchOptions struct { // VaultListOptions configures a list query for vault documents. type VaultListOptions struct { TeamID *string // nil = no filter, ptr-to-empty = personal (NULL team_id), ptr-to-uuid = specific team + TeamIDs []string // non-nil = personal (NULL) + these team UUIDs (used for "all accessible" view) Scope string // empty = all DocTypes []string // empty = all Limit int diff --git a/ui/web/src/pages/vault/vault-document-sidebar.tsx b/ui/web/src/pages/vault/vault-document-sidebar.tsx index a66d0d55..380864b1 100644 --- a/ui/web/src/pages/vault/vault-document-sidebar.tsx +++ b/ui/web/src/pages/vault/vault-document-sidebar.tsx @@ -1,19 +1,25 @@ import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Link2, ChevronLeft, ChevronRight } from "lucide-react"; +import { + Link2, ChevronLeft, ChevronRight, + FileText, Brain, StickyNote, Sparkles, Clock, Image, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { formatRelativeTime } from "@/lib/format"; import type { VaultDocument } from "@/types/vault"; -const DOC_DOT_COLORS: Record = { - context: "bg-blue-500", - memory: "bg-purple-500", - note: "bg-yellow-500", - skill: "bg-green-500", - episodic: "bg-orange-500", - media: "bg-red-500", +const DOC_TYPE_CONFIG: Record = { + context: { color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", icon: FileText }, + memory: { color: "text-purple-600 dark:text-purple-400", bg: "bg-purple-500/10", icon: Brain }, + note: { color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-500/10", icon: StickyNote }, + skill: { color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500/10", icon: Sparkles }, + episodic: { color: "text-orange-600 dark:text-orange-400", bg: "bg-orange-500/10", icon: Clock }, + media: { color: "text-rose-600 dark:text-rose-400", bg: "bg-rose-500/10", icon: Image }, }; +const DEFAULT_CONFIG = { color: "text-muted-foreground", bg: "bg-muted", icon: FileText }; + interface Props { documents: VaultDocument[]; selectedId: string | null; @@ -31,6 +37,8 @@ function DocCard({ doc, selected, linkCount, onClick }: { }) { const { t } = useTranslation("vault"); const ref = useRef(null); + const cfg = DOC_TYPE_CONFIG[doc.doc_type] ?? DEFAULT_CONFIG; + const Icon = cfg.icon; useEffect(() => { if (selected) ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); @@ -39,26 +47,40 @@ function DocCard({ doc, selected, linkCount, onClick }: { return (
-
- - + {/* Type icon */} +
+ +
+ + {/* Content */} +
+ {doc.title || doc.path.split("/").pop()} - {linkCount > 0 && ( - - - {linkCount} - - )} -
-
- {t(`type.${doc.doc_type}`)} · {t(`scope.${doc.scope}`)} -
-
- {formatRelativeTime(doc.updated_at)} + +
+ {t(`type.${doc.doc_type}`)} + · + {t(`scope.${doc.scope}`)} + {linkCount > 0 && ( + <> + · + + + {linkCount} + + + )} + · + {formatRelativeTime(doc.updated_at)} +
); @@ -72,24 +94,27 @@ export function VaultDocumentSidebar({ return (
{/* Header */} -
- {t("title")} - {total} +
+ {t("title")} + {total}
{/* Doc list */} -
+
{loading && documents.length === 0 ? ( - Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
+ Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
)) ) : documents.length === 0 ? ( -
- {t("noDocuments")} +
+ + {t("noDocuments")}
) : ( documents.map((doc) => ( @@ -110,7 +135,7 @@ export function VaultDocumentSidebar({ - {page + 1} / {totalPages} + {page + 1} / {totalPages} diff --git a/ui/web/src/pages/vault/vault-page.tsx b/ui/web/src/pages/vault/vault-page.tsx index 01eb29bc..d0e8a3ca 100644 --- a/ui/web/src/pages/vault/vault-page.tsx +++ b/ui/web/src/pages/vault/vault-page.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, lazy, Suspense } from "react"; +import { useState, useEffect, useMemo, lazy, Suspense } from "react"; import { useTranslation } from "react-i18next"; import { Search, FileArchive, Plus, PanelLeftOpen } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -8,7 +8,6 @@ import { useTeams } from "@/pages/teams/hooks/use-teams"; import { useIsMobile } from "@/hooks/use-media-query"; import { useVaultDocuments, useVaultGraphData } from "./hooks/use-vault"; import { VaultDocumentSidebar } from "./vault-document-sidebar"; -import { VaultDetailPanel } from "./vault-detail-panel"; import { VaultSearchDialog } from "./vault-search-dialog"; import { VaultCreateDialog } from "./vault-create-dialog"; import type { VaultDocument } from "@/types/vault"; @@ -25,16 +24,17 @@ const PAGE_SIZE = 100; export function VaultPage() { const { t } = useTranslation("vault"); const { agents } = useAgents(); - const { teams } = useTeams(); + const { teams, load: loadTeams } = useTeams(); const isMobile = useIsMobile(); + useEffect(() => { loadTeams(); }, [loadTeams]); + const [selectedAgent, setSelectedAgent] = useState(""); const [selectedTeam, setSelectedTeam] = useState(""); - const [selectedDoc, setSelectedDoc] = useState(null); + const [detailDoc, setDetailDoc] = useState(null); const [selectedDocId, setSelectedDocId] = useState(null); const [searchOpen, setSearchOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); - const [searchResultDoc, setSearchResultDoc] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(false); const [page, setPage] = useState(0); @@ -59,9 +59,9 @@ export function VaultPage() { const handleAgentChange = (v: string) => { setSelectedAgent(v); setPage(0); }; const handleTeamChange = (v: string) => { setSelectedTeam(v); setPage(0); }; - // Sidebar click → select doc + highlight graph node + // Sidebar click → open detail modal + highlight graph node const handleSidebarSelect = (doc: VaultDocument) => { - setSelectedDoc(doc); + setDetailDoc(doc); setSelectedDocId(doc.id); if (isMobile) setSidebarOpen(false); }; @@ -69,16 +69,15 @@ export function VaultPage() { // Graph single-click → highlight only const handleNodeSelect = (docId: string | null) => { setSelectedDocId(docId); - if (!docId) setSelectedDoc(null); }; - // Graph double-click → open detail + highlight + // Graph double-click → open detail modal + highlight const handleNodeDoubleClick = (doc: VaultDocument) => { - setSelectedDoc(doc); + setDetailDoc(doc); setSelectedDocId(doc.id); }; - const handleCloseDetail = () => { setSelectedDoc(null); setSelectedDocId(null); }; + const handleCloseDetail = () => { setDetailDoc(null); }; return (
@@ -109,7 +108,7 @@ export function VaultPage() { {/* Main: header + graph + detail panel */}
{/* Header */} -
+
{isMobile && ( @@ -148,7 +145,7 @@ export function VaultPage() {
- {/* Graph */} + {/* Graph — takes full remaining height */}
}>
- - {/* Detail panel (bottom, collapsible) */} -
- {/* Search dialog — result opens legacy modal */} + {/* Search dialog — result opens detail modal */} {selectedAgent && ( { setDetailDoc(doc); setSelectedDocId(doc.id); }} /> )} {selectedAgent && } - {/* Legacy detail dialog for search results only */} + {/* Detail dialog for all document views */} !open && setSearchResultDoc(null)} - onDeleted={() => setSearchResultDoc(null)} + doc={detailDoc} open={!!detailDoc} + onOpenChange={(open) => { if (!open) handleCloseDetail(); }} + onDeleted={handleCloseDetail} />